diff --git a/src/app/globals.css b/src/app/globals.css index fb75e9d..88cb57b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -169,3 +169,18 @@ @apply font-sans; } } + +.supplier-map-label { + border: 0; + border-radius: 999px; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.16); + color: var(--foreground); + font-size: 10px; + font-weight: 700; + line-height: 1; + padding: 3px 7px; +} + +.supplier-map-label::before { + display: none; +} diff --git a/src/components/supplier-map.tsx b/src/components/supplier-map.tsx index b84b917..258011b 100644 --- a/src/components/supplier-map.tsx +++ b/src/components/supplier-map.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import L from "leaflet"; -import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle } from "react-leaflet"; +import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle, useMap, useMapEvents } from "react-leaflet"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -26,6 +26,8 @@ type SupplierCluster = { const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473]; const CLUSTER_DISTANCE_KM = 8; +const CLUSTER_ZOOM_BREAKPOINT = 12; +const LABEL_ZOOM_BREAKPOINT = 13; const CLUSTER_COLORS = ["#1B76BB", "#E64B4B", "#F4B842", "#169B62", "#7C3AED", "#EA580C", "#0F766E"]; function parseCoordinates(value?: string): [number, number] | null { @@ -66,18 +68,36 @@ function distanceKm(a: [number, number], b: [number, number]) { function markerIcon(color: string, label: string) { return L.divIcon({ className: "", - html: `
${label}
`, - iconSize: [30, 30], - iconAnchor: [15, 15], + html: `
${label}
`, + iconSize: [22, 22], + iconAnchor: [11, 11], }); } -function buildClusters(points: SupplierPoint[]) { +function clusterIcon(color: string, count: number) { + const size = count >= 25 ? 52 : count >= 10 ? 46 : 38; + + return L.divIcon({ + className: "", + html: `
${count}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + +function clusterDistanceForZoom(zoom: number) { + if (zoom <= 7) return 80; + if (zoom <= 9) return 35; + if (zoom <= 11) return 14; + return CLUSTER_DISTANCE_KM; +} + +function buildClusters(points: SupplierPoint[], distanceKmLimit = CLUSTER_DISTANCE_KM) { const clusters: SupplierCluster[] = []; for (const supplier of points) { const location: [number, number] = [supplier.lat, supplier.lng]; - const nearby = clusters.find((cluster) => distanceKm(cluster.center, location) <= CLUSTER_DISTANCE_KM); + const nearby = clusters.find((cluster) => distanceKm(cluster.center, location) <= distanceKmLimit); if (!nearby) { clusters.push({ @@ -99,10 +119,70 @@ function buildClusters(points: SupplierPoint[]) { return clusters; } +function ZoomWatcher({ onZoomChange }: { onZoomChange: (zoom: number) => void }) { + useMapEvents({ + zoomend: (event) => onZoomChange(event.target.getZoom()), + }); + + return null; +} + +function ClusterMarker({ cluster }: { cluster: SupplierCluster }) { + const map = useMap(); + const count = cluster.suppliers.length; + + if (count === 1) { + const supplier = cluster.suppliers[0]; + return ( + + + + + + ); + } + + return ( + map.flyTo(cluster.center, Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 }), + }} + > + +
+

{count} suppliers in this area

+ {cluster.suppliers.slice(0, 12).map((supplier) => ( +
+

{supplier.suppliername || "Unnamed supplier"}

+ {supplier.supplieraddress &&

{supplier.supplieraddress}

} +
+ ))} + {count > 12 &&

Zoom in to see {count - 12} more.

} +
+
+
+ ); +} + +function SupplierPopup({ supplier }: { supplier: SupplierPoint }) { + return ( +
+

{supplier.suppliername || "Unnamed supplier"}

+ {supplier.supplieraddress &&

{supplier.supplieraddress}

} + {supplier.suppliernumber &&

Phone: {supplier.suppliernumber}

} + {supplier.email &&

Email: {supplier.email}

} + {supplier.supply_tags &&

Tags: {supplier.supply_tags}

} +
+ ); +} + export function SupplierMap() { const [suppliers, setSuppliers] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); + const [zoom, setZoom] = useState(10); const fetchSuppliers = async () => { setLoading(true); @@ -147,7 +227,10 @@ export function SupplierMap() { return { mapped: supplierPoints, missing: missingCoordinates }; }, [search, suppliers]); - const clusters = useMemo(() => buildClusters(mapped), [mapped]); + const clusterDistanceKm = clusterDistanceForZoom(zoom); + const clusters = useMemo(() => buildClusters(mapped, clusterDistanceKm), [clusterDistanceKm, mapped]); + const showIndividualSuppliers = zoom >= CLUSTER_ZOOM_BREAKPOINT; + const showLabels = zoom >= LABEL_ZOOM_BREAKPOINT; const mapCenter = mapped.length ? ([ mapped.reduce((sum, supplier) => sum + supplier.lat, 0) / mapped.length, @@ -210,42 +293,45 @@ export function SupplierMap() {
+ - {clusters.map((cluster) => ( - - ))} - {mapped.map((supplier) => { - const cluster = clusterBySupplierId.get(supplier.id); - const label = cluster ? String(cluster.suppliers.findIndex((item) => item.id === supplier.id) + 1) : ""; - return ( - - - {supplier.suppliername || "Unnamed supplier"} - - -
-

{supplier.suppliername || "Unnamed supplier"}

- {supplier.supplieraddress &&

{supplier.supplieraddress}

} - {supplier.suppliernumber &&

Phone: {supplier.suppliernumber}

} - {supplier.email &&

Email: {supplier.email}

} - {supplier.supply_tags &&

Tags: {supplier.supply_tags}

} -
-
-
- ); - })} + {!showIndividualSuppliers && + clusters.map((cluster) => ( + + ))} + {showIndividualSuppliers && + clusters.map((cluster) => ( + + ))} + {showIndividualSuppliers && + mapped.map((supplier) => { + const cluster = clusterBySupplierId.get(supplier.id); + const label = cluster ? String(cluster.suppliers.findIndex((item) => item.id === supplier.id) + 1) : ""; + return ( + + {showLabels && ( + + {supplier.suppliername || "Unnamed supplier"} + + )} + + + + + ); + })}