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.
This commit is contained in:
SJCoetz 2026-05-27 14:53:54 +02:00
parent 565b1a6e2b
commit 54644c7ce8
2 changed files with 140 additions and 39 deletions

View file

@ -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;
}

View file

@ -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: `<div style="display:grid;place-items:center;width:30px;height:30px;border-radius:999px;background:${color};color:white;border:3px solid white;box-shadow:0 8px 18px rgba(15,23,42,.28);font-size:11px;font-weight:800;">${label}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15],
html: `<div style="display:grid;place-items:center;width:22px;height:22px;border-radius:999px;background:${color};color:white;border:2px solid white;box-shadow:0 6px 14px rgba(15,23,42,.24);font-size:9px;font-weight:800;">${label}</div>`,
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: `<div style="display:grid;place-items:center;width:${size}px;height:${size}px;border-radius:999px;background:${color};color:white;border:3px solid white;box-shadow:0 12px 28px rgba(15,23,42,.32);font-size:14px;font-weight:900;">${count}</div>`,
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 (
<Marker position={[supplier.lat, supplier.lng]} icon={markerIcon(cluster.color, "")}>
<Popup>
<SupplierPopup supplier={supplier} />
</Popup>
</Marker>
);
}
return (
<Marker
position={cluster.center}
icon={clusterIcon(cluster.color, count)}
eventHandlers={{
click: () => map.flyTo(cluster.center, Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 }),
}}
>
<Popup>
<div className="max-h-64 space-y-2 overflow-auto text-sm">
<p className="font-semibold">{count} suppliers in this area</p>
{cluster.suppliers.slice(0, 12).map((supplier) => (
<div key={supplier.id} className="border-t pt-2 first:border-t-0 first:pt-0">
<p className="font-medium">{supplier.suppliername || "Unnamed supplier"}</p>
{supplier.supplieraddress && <p className="text-xs text-muted-foreground">{supplier.supplieraddress}</p>}
</div>
))}
{count > 12 && <p className="text-xs text-muted-foreground">Zoom in to see {count - 12} more.</p>}
</div>
</Popup>
</Marker>
);
}
function SupplierPopup({ supplier }: { supplier: SupplierPoint }) {
return (
<div className="space-y-1 text-sm">
<p className="font-semibold">{supplier.suppliername || "Unnamed supplier"}</p>
{supplier.supplieraddress && <p>{supplier.supplieraddress}</p>}
{supplier.suppliernumber && <p>Phone: {supplier.suppliernumber}</p>}
{supplier.email && <p>Email: {supplier.email}</p>}
{supplier.supply_tags && <p>Tags: {supplier.supply_tags}</p>}
</div>
);
}
export function SupplierMap() {
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
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() {
<Card className="overflow-hidden border-primary/10">
<div className="h-[68vh] min-h-[520px]">
<MapContainer center={mapCenter} zoom={mapped.length ? 10 : 6} scrollWheelZoom className="h-full w-full">
<ZoomWatcher onZoomChange={setZoom} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{clusters.map((cluster) => (
<Circle
key={cluster.id}
center={cluster.center}
radius={CLUSTER_DISTANCE_KM * 1000}
pathOptions={{ color: cluster.color, fillColor: cluster.color, fillOpacity: 0.08, weight: 2 }}
/>
))}
{mapped.map((supplier) => {
const cluster = clusterBySupplierId.get(supplier.id);
const label = cluster ? String(cluster.suppliers.findIndex((item) => item.id === supplier.id) + 1) : "";
return (
<Marker
key={supplier.id}
position={[supplier.lat, supplier.lng]}
icon={markerIcon(cluster?.color || CLUSTER_COLORS[0], label)}
>
<Tooltip direction="top" offset={[0, -16]} permanent>
{supplier.suppliername || "Unnamed supplier"}
</Tooltip>
<Popup>
<div className="space-y-1 text-sm">
<p className="font-semibold">{supplier.suppliername || "Unnamed supplier"}</p>
{supplier.supplieraddress && <p>{supplier.supplieraddress}</p>}
{supplier.suppliernumber && <p>Phone: {supplier.suppliernumber}</p>}
{supplier.email && <p>Email: {supplier.email}</p>}
{supplier.supply_tags && <p>Tags: {supplier.supply_tags}</p>}
</div>
</Popup>
</Marker>
);
})}
{!showIndividualSuppliers &&
clusters.map((cluster) => (
<ClusterMarker key={cluster.id} cluster={cluster} />
))}
{showIndividualSuppliers &&
clusters.map((cluster) => (
<Circle
key={cluster.id}
center={cluster.center}
radius={CLUSTER_DISTANCE_KM * 1000}
pathOptions={{ color: cluster.color, fillColor: cluster.color, fillOpacity: 0.05, weight: 1 }}
/>
))}
{showIndividualSuppliers &&
mapped.map((supplier) => {
const cluster = clusterBySupplierId.get(supplier.id);
const label = cluster ? String(cluster.suppliers.findIndex((item) => item.id === supplier.id) + 1) : "";
return (
<Marker
key={supplier.id}
position={[supplier.lat, supplier.lng]}
icon={markerIcon(cluster?.color || CLUSTER_COLORS[0], label)}
>
{showLabels && (
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
{supplier.suppliername || "Unnamed supplier"}
</Tooltip>
)}
<Popup>
<SupplierPopup supplier={supplier} />
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
</Card>