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"}
+
+ )}
+
+
+
+
+ );
+ })}