From 54644c7ce8f6406d823bb6f25fc1fb2b5f1d6eb0 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 27 May 2026 14:53:54 +0200 Subject: [PATCH] Improve map clustering and labels Enhance supplier map UX by introducing zoom-aware clustering and refined marker visuals. Adds clusterIcon, clusterDistanceForZoom, ZoomWatcher, ClusterMarker and SupplierPopup components; switches to dynamic cluster sizes and distances based on zoom, and flies into clusters on click. Shows individual supplier markers and optional permanent labels above configurable zoom breakpoints, and reduces marker sizes/shadows for a cleaner look. Also adds .supplier-map-label CSS for tooltip-style permanent labels. --- src/app/globals.css | 15 +++ src/components/supplier-map.tsx | 164 ++++++++++++++++++++++++-------- 2 files changed, 140 insertions(+), 39 deletions(-) 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"} + + )} + + + + + ); + })}