'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 26px 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.
// • Count moves to a corner badge that appears only when ≥ 2 stops
// share the pin — the dot itself never lies about "this is a stop".
//
// Where the name lives:
// • On the dot itself — only when there's exactly 1 stop here
// (the badge slot is available, and the name fits without crowding).
// Multi-point dots stay pure geometry; the popup names them.
// • On hover — a tooltip shows the supplier name + count.
// • On click — the popup lists every item at this pin.
// ────────────────────────────────────────────────────────────────────
const DOT_PX = 26; // fixed across every waypoint — silent unity
const buildWaypointIcon = (count: number, supplierName: string, areaColor?: string): L.DivIcon => {
const isMulti = count >= 2;
const nameForSingles = (!isMulti && supplierName) ? supplierName : '';
const dotColor = areaColor || '#f54e00';
const dotHtml = `
${nameForSingles
? `
${escapeHtml(nameForSingles)}
`
: ''}
${isMulti
? `
${count > 99 ? '99+' : count}
`
: ''}
`;
// iconSize is the hit-box around the visual. We give it a slightly
// larger square than the dot so the hit area is generous, the badge
// sits inside it, and stacks of identical dots near each other don't
// fight for clicks.
const hit = DOT_PX + 8;
return L.divIcon({
className: 'waypoint-div-icon',
html: `
${dotHtml}
`,
iconSize: [hit, hit],
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.
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 (
);
}
// 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.
const pointsRef = useRef(points);
useEffect(() => { pointsRef.current = points; }, [points]);
useMapEvents({
zoomend: () => {
if (!mapReadyRef.current) return;
const next = clusterPoints(pointsRef.current, map);
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
},
moveend: () => {
if (!mapReadyRef.current) return;
const next = clusterPoints(pointsRef.current, map);
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
},
});
return null;
}
return (
>
)}
{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.