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:
parent
565b1a6e2b
commit
54644c7ce8
2 changed files with 140 additions and 39 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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='© <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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue