'use client'; import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, useMapEvents } from 'react-leaflet'; import { OrderItem, Supplier } from '@/lib/types'; import { formatCurrency, calculateDistance, parseCoordinates, clusterByProximity } from '@/lib/utils'; import { getItemDisplayDescription, getItemDisplayQty, getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase'; import { Navigation, AlertTriangle, Route, Crosshair, Package } from 'lucide-react'; import L from 'leaflet'; const AREA_COLORS = [ '#f54e00', // orange '#18714f', // emerald '#6c5a99', // violet '#cf2d56', // rose '#8a5a02', // amber '#26251e', // ink '#2563eb', // blue '#0d9488', // teal '#7c3aed', // purple '#dc2626', // red ]; // ──────────────────────────────────────────────────────────────────── // ONE shape for every waypoint. Every stop — singleton or cluster — // uses the same 22px orange dot so the map reads as one product surface, // not "Leaflet default + my clusters glued on top". // // Why a circle, not a pill: // • At 10+ stops the pill stage was crowded — names overlapped and // the silhouette grew with text length, so dense regions became // unreadable. // • A consistent dot keeps the map balanced at every zoom: silhouette // is identical for 1 stop or 99, so the driver reads shape density // (= how clustered) instead of having to re-parse variable icons. // // Where the label lives: // • ALWAYS below the dot (was hover-only — drivers said they couldn't // read stop names without tapping). The label is a cream chip on // dark text, readable over any tile. // • Singletons: just the supplier name ("Acme Hardware"). // • Clusters: supplier name + count ("Acme Hardware + 3 more"), so // the driver knows it's a multi-stop without clicking. // • On click: the popup lists every item at this pin (same as before). // ──────────────────────────────────────────────────────────────────── const DOT_PX = 22; // fixed across every waypoint — silent unity const buildWaypointIcon = (count: number, supplierName: string, areaColor?: string): L.DivIcon => { const isMulti = count >= 2; // Label content. Singletons get the supplier name; clusters get // "Supplier + N more" so the driver reads the count without // tapping. Fallback to a generic label when supplier is unknown. let label = ''; if (supplierName) { label = isMulti ? `${supplierName} + ${count - 1}` : supplierName; } else { label = isMulti ? `${count} stops` : 'Stop'; } const dotColor = areaColor || '#f54e00'; const dotHtml = `
${escapeHtml(label)}
${isMulti ? `
${count > 99 ? '99+' : count}
` : ''} `; // iconSize is the hit-box around the visual. The hit box itself // stays small so pins near each other don't fight for clicks, // but the iconAnchor shifts down to account for the label that // hangs below the dot — otherwise the dot would visually sit // above its true lat/lng. const hit = DOT_PX + 8; const labelOffset = 12; // approx vertical extent of the label chip return L.divIcon({ className: 'waypoint-div-icon', html: `
${dotHtml}
`, iconSize: [hit, hit + labelOffset], // Anchor on the dot center, NOT on the whole icon box. Leaflet // positions the marker so this point lands on the lat/lng. We // also shift up by half the label height so the label doesn't // push the dot up off its true position. iconAnchor: [hit / 2, hit / 2], popupAnchor: [0, -(hit / 2) - 4], }); }; // Escape supplier names for safe HTML interpolation inside divIcon.html. // (Leaflet renders this html via document.write-ish paths that can // break if a supplier name contains `<` or `&` — unlikely but cheap.) function escapeHtml(s: string): string { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Current-location marker (you) — distinct shape so it's never confused // with a stop. Solid white-bordered dot + slow outer pulse. // Color matches the brand `#f54e00` so the you-marker reads as part of // the same orange family as the supplier waypoints (the previous // `#ea580c` was Tailwind's default orange-600 and clashed). const currentLocationIcon = L.divIcon({ className: 'custom-div-icon', html: `
`, iconSize: [18, 18], iconAnchor: [9, 9], }); interface MapViewProps { items: OrderItem[]; onNavigate: (lat: number, lng: number, name: string) => void; } // One stop = one item's supplier coordinates. Multiple items at the same // supplier count as multiple stops at that pin. interface ResolvedPoint { item: OrderItem; supplier: Supplier; coords: { latitude: number; longitude: number }; } // Resolve items to lat/lng; skip rows without a usable supplier location. function resolvePoints(items: OrderItem[]): ResolvedPoint[] { const out: ResolvedPoint[] = []; for (const item of items) { const supplier = item.expand?.supplier; if (!supplier) continue; const coords = parseCoordinates(supplier.coordinates); if (!coords) continue; out.push({ item, supplier, coords }); } return out; } // Cluster radius (in pixels) — points within this pixel distance get merged. const CLUSTER_PX = 60; interface Cluster { points: ResolvedPoint[]; center: [number, number]; // average lat/lng of member points bounds: L.LatLngBounds; } function clusterPoints( points: ResolvedPoint[], map: L.Map ): Cluster[] { if (points.length === 0) return []; const projected: Array<{ p: ResolvedPoint; px: [number, number] }> = []; for (const p of points) { const latLng = L.latLng(p.coords.latitude, p.coords.longitude); const px = map.latLngToContainerPoint(latLng); projected.push({ p, px: [px.x, px.y] }); } // Grid cluster: snap each point to a grid cell of size CLUSTER_PX. // Two points sharing the same cell belong to the same cluster. const cellSize = CLUSTER_PX; const cellMap = new Map(); for (const { p, px } of projected) { const cx = Math.floor(px[0] / cellSize); const cy = Math.floor(px[1] / cellSize); const key = `${cx}:${cy}`; if (!cellMap.has(key)) cellMap.set(key, []); cellMap.get(key)!.push(p); } const clusters: Cluster[] = []; for (const pts of Array.from(cellMap.values())) { const lats = pts.map((p: ResolvedPoint) => p.coords.latitude); const lngs = pts.map((p: ResolvedPoint) => p.coords.longitude); const centerLat = lats.reduce((a: number, b: number) => a + b, 0) / lats.length; const centerLng = lngs.reduce((a: number, b: number) => a + b, 0) / lngs.length; const bounds = L.latLngBounds(pts.map((p: ResolvedPoint) => [p.coords.latitude, p.coords.longitude])); clusters.push({ points: pts, center: [centerLat, centerLng], bounds, }); } return clusters; } function MapBounds({ points }: { points: ResolvedPoint[] }) { const map = useMap(); useEffect(() => { if (points.length === 0) return; const bounds = L.latLngBounds(points.map((p) => [p.coords.latitude, p.coords.longitude])); map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 }); }, [map, points]); return null; } function RecenterButton({ center }: { center: [number, number] }) { const map = useMap(); return ( ); } // MapFloatingControls — MUST live inside . The RecenterButton // and TrailToggle call useMap(), which requires a leaflet context. Earlier // revisions tried a createPortal pattern but it failed because the // dynamically-created host div was removed by React 18 strict-mode double- // effect-invoke before the portal could populate it. // // The fix: render the FAB stack directly (no portal). The MapContainer // takes its children's CSS positioning seriously — we use `position: // absolute` on the wrapper, anchor to the map's container via the CSS // variables leaflet sets, and accept that the FAB stack is part of the // React tree rendered by MapContainer (which is fine — react-leaflet // renders arbitrary children inside the map div). function MapFloatingControls({ userLocation, showTrail, trailCount, onToggleTrail, }: { userLocation: [number, number] | null; showTrail: boolean; trailCount: number; onToggleTrail: () => void; }) { const map = useMap(); return (
{userLocation && ( )} {trailCount > 1 && ( )}
); } // Internal helper: re-cluster whenever the map moves or zooms. function ClusterPopupBody({ cluster, userLocation, onNavigate, }: { cluster: Cluster; userLocation: [number, number] | null; onNavigate: (lat: number, lng: number, name: string) => void; }) { const supplier = cluster.points[0].supplier; const distance = userLocation ? calculateDistance(userLocation[0], userLocation[1], cluster.center[0], cluster.center[1]) : null; return (

{supplier.suppliername}

{supplier.supplieraddress && (

{supplier.supplieraddress}

)}
{distance && ( {distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)} km`} away )}
{cluster.points.map((p) => (
{p.item.problem_flag && } {getItemDisplayDescription(p.item)} {p.item.sku && ( #{p.item.sku} )}
Qty: {getItemDisplayQty(p.item)} {p.item.order_qty && p.item.quantity && p.item.order_qty !== p.item.quantity && ( (cust: {p.item.quantity}) )}
))}
); } // A single cluster marker. Click on a multi-point cluster zooms into // its bounds so the individual pins fan out; a single-point marker // shows the popup normally. All clusters — count 1 or 99 — render the // same `buildWaypointIcon` shape, so the map never mixes marker styles. // // Cluster zoom-in: we fitBounds with maxZoom 17 (street-level), then // panBy(-y) so the cluster settles slightly above the FAB stack // instead of behind it. Without the pan, the driver has to drag the // map before they can read the popup. function ClusterMarker({ cluster, onNavigate, userLocation, areaColor, }: { cluster: Cluster; userLocation: [number, number] | null; onNavigate: (lat: number, lng: number, name: string) => void; areaColor?: string; }) { const map = useMap(); const count = cluster.points.length; // Use the SUPPLIER name as the cluster's primary label. Every point // in this cluster shares a supplier (and in this dataset sometimes // shares coords), so this is the right identity — not an item SKU. const supplierName = cluster.points[0].supplier.suppliername || 'Unknown supplier'; const handleClick = () => { if (count === 1) { // Singleton: just open the popup at the current zoom — moving the // map on every single pin is disorienting when there are several // singletons visible. The popup itself is the action. const m = cluster.points[0]; map.panTo([m.coords.latitude, m.coords.longitude], { animate: true }); } else { // Multi-point: fitBounds and zoom in DEEP. maxZoom 17 is full // street detail; we then nudge the map down so the cluster // appears just below the FAB controls instead of behind them. map.fitBounds(cluster.bounds, { padding: [40, 40], maxZoom: 17, animate: true, }); // After fitBounds, shift the visible center down a touch so the // pill badge isn't occluded by the top-right control stack. // setTimeout because fitBounds updates the view synchronously but // the panBy needs the new container size + zoom to be applied. setTimeout(() => { const size = map.getSize(); if (size.y > 0) { map.panBy([0, -Math.round(size.y * 0.12)], { animate: true }); } }, 60); } }; return ( ); } export default function CollectionMap({ items, onNavigate }: MapViewProps) { const [userLocation, setUserLocation] = useState<[number, number] | null>(null); const [trail, setTrail] = useState<[number, number][]>([]); const [showTrail, setShowTrail] = useState(true); const intervalRef = useRef | null>(null); const points = useMemo(() => resolvePoints(items), [items]); // Proximity-based area coloring — items within 2km share a color const areaColorMap = useMemo(() => { const clusters = clusterByProximity(items, 2); const map = new Map(); clusters.forEach((cluster, idx) => { cluster.items.forEach(item => { map.set(item.id, idx % AREA_COLORS.length); }); }); return map; }, [items]); const [clusters, setClusters] = useState([]); const mapReadyRef = useRef(false); // Get current position useEffect(() => { if ('geolocation' in navigator) { navigator.geolocation.getCurrentPosition( (pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]), () => {}, { enableHighAccuracy: false, timeout: 30000 } ); } }, []); // Fetch trail data periodically const fetchTrail = useCallback(async () => { if (!isAuthenticated()) return; const user = getCurrentUser(); if (!user) return; const data = await getDriverTrail(user.id); const pts = data .filter((loc) => loc.latitude && loc.longitude) .map((loc) => [loc.latitude, loc.longitude] as [number, number]); if (pts.length > 0) { setTrail(pts); } }, []); useEffect(() => { fetchTrail(); intervalRef.current = setInterval(fetchTrail, 60000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [fetchTrail]); const defaultCenter: [number, number] = [-33.9249, 18.4241]; const center = userLocation || defaultCenter; const getDistance = (coords: { latitude: number; longitude: number }) => { if (!userLocation) return null; return calculateDistance(userLocation[0], userLocation[1], coords.latitude, coords.longitude); }; // Layer that wires up clustering reactively when items change. // // Why this used to infinite-loop: the original implementation called // setClusters inside the moveend/zoomend handlers AND inside an // effect that depended on `points`. Each parent render that produced // a new `points` reference (or each moveend fire from Leaflet's // internal layout invalidation) triggered another setState, which // re-rendered, which produced another `points` reference, which // fired the effect again — bounded only by React's depth limit. // // The fix: compute clusters in a layout-effect-free model. We // subscribe to Leaflet's moveend/zoomend (the only times cluster // pixel positions actually change) and re-cluster then. The initial // cluster pass happens after the map container's first layout // settles, gated by mapReadyRef so we never call latLngToContainer // on an unmeasured map. Content-dedup via signature string kills // redundant state churn even when Leaflet fires spurious moveend. function ClusterSync() { const map = useMap(); // Stable signature function — no deps so the ref never changes. const sigOf = useCallback((cs: Cluster[]) => cs.map((c) => `${c.points.length}@${c.center[0].toFixed(4)},${c.center[1].toFixed(4)}`).join('|'), []); // Initial clustering: run once after map layout is ready. We use // a raf to wait for Leaflet's first container-size measurement, // because latLngToContainerPoint() returns invalid coords before // then — which would produce NaN-bearing clusters that flip the // sig every frame. useEffect(() => { let cancelled = false; mapReadyRef.current = false; // eslint-disable-next-line no-console console.log('[ClusterSync] mount effect firing'); const initialFrame = requestAnimationFrame(() => { if (cancelled) return; mapReadyRef.current = true; const next = clusterPoints(points, map); // eslint-disable-next-line no-console console.log('[ClusterSync] initial frame, sig=', sigOf(next)); setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next)); }); return () => { // eslint-disable-next-line no-console console.log('[ClusterSync] mount cleanup'); cancelled = true; cancelAnimationFrame(initialFrame); }; // We intentionally don't include `points` or `sigOf` here — this // is the FIRST snapshot only. Subsequent updates are driven by // the points-effect and the map event handlers below. // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); // Re-cluster when the points list itself changes (new items, tab // switch, PB poll). Content-deduped. useEffect(() => { if (!mapReadyRef.current) return; const next = clusterPoints(points, map); // eslint-disable-next-line no-console console.log('[ClusterSync] points change, next.sig=', sigOf(next)); setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next)); }, [points, map, sigOf]); // Re-cluster on pan/zoom — these are the only events where cluster // pixel positions change. Read points from a ref so the handler // subscription is stable across renders. // // Throttled: Leaflet fires `moveend` many times during a drag // (once per animation frame, ish). Running `clusterPoints` on // each one with 10+ stops is wasted work — the visible result // only changes meaningfully once the user stops dragging. // `coalesce` collapses bursts into one trailing-edge call so // we re-cluster exactly once after the gesture settles. const pointsRef = useRef(points); useEffect(() => { pointsRef.current = points; }, [points]); const recompute = useCallback(() => { if (!mapReadyRef.current) return; const next = clusterPoints(pointsRef.current, map); setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); const coalesceTimer = useRef | null>(null); const scheduleRecompute = useCallback(() => { if (coalesceTimer.current !== null) clearTimeout(coalesceTimer.current); coalesceTimer.current = setTimeout(() => { coalesceTimer.current = null; recompute(); }, 80); }, [recompute]); useMapEvents({ zoomend: recompute, moveend: scheduleRecompute, }); useEffect(() => () => { if (coalesceTimer.current !== null) { clearTimeout(coalesceTimer.current); coalesceTimer.current = null; } }, []); return null; } return (
setShowTrail((v) => !v)} /> {userLocation && (
Your Location
)} {/* Trail polyline */} {showTrail && trail.length > 1 && ( <>
', iconSize: [14, 14], iconAnchor: [7, 7], })} >
Trip Start
)} {clusters.map((cluster, i) => { // Find the majority area color among the points in this visual cluster const colorCounts = new Map(); for (const p of cluster.points) { const ci = areaColorMap.get(p.item.id); if (ci !== undefined) { colorCounts.set(ci, (colorCounts.get(ci) || 0) + 1); } } let majorityColor: string | undefined; if (colorCounts.size > 0) { let maxCount = 0; let maxIdx = 0; Array.from(colorCounts.entries()).forEach(([idx, count]) => { if (count > maxCount) { maxCount = count; maxIdx = idx; } }); majorityColor = AREA_COLORS[maxIdx]; } return ( ); })}
{/* Legend — pure UI, no leaflet hooks, safe to render outside MapContainer. */}

Legend

You
{/* Miniature of the actual dot + a tiny corner badge so the legend matches exactly what the map renders — not the old pill. */}
N
Waypoint (N stops)
{trail.length > 1 && (
Route
)}
{/* Empty-state overlay. Shown when the API returned items but none of them have usable supplier coordinates (the most common cause in this codebase: supplier row exists but coordinates field is blank). The map still renders, so the driver can manually navigate via the list view and search for the supplier. */} {points.length === 0 && (

No stops on the map yet

Assigned pickups need a supplier address with coordinates. Use the list to add one.

)}
); }