- Add standardized item display helpers for consistent labeling across components - Rework PocketBase data fetching into scoped assigned/pool/problem item queries - Add proximity-based clustering and AreaGroup component for geographic list grouping - Sync map page tab state with main collections list using localStorage - Update all existing components to use new data and display patterns - Add custom map marker, tooltip, and cluster styling
690 lines
No EOL
27 KiB
TypeScript
690 lines
No EOL
27 KiB
TypeScript
'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 = `
|
|
<div class="waypoint-dot-shadow"></div>
|
|
<div class="waypoint-dot" style="background:${dotColor};"></div>
|
|
${nameForSingles
|
|
? `<div class="waypoint-dot-name">${escapeHtml(nameForSingles)}</div>`
|
|
: ''}
|
|
${isMulti
|
|
? `<div class="waypoint-dot-badge">${count > 99 ? '99+' : count}</div>`
|
|
: ''}
|
|
`;
|
|
|
|
// 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: `<div class="waypoint-dot-wrap" style="width:${hit}px;height:${hit}px;">${dotHtml}</div>`,
|
|
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, '"')
|
|
.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: `<div style="position:relative;">
|
|
<div style="width:18px;height:18px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
|
|
<div style="width:36px;height:36px;background:rgba(234,88,12,0.15);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
|
</div>
|
|
<style>@keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:0}}</style>`,
|
|
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<string, ResolvedPoint[]>();
|
|
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 (
|
|
<button
|
|
type="button"
|
|
onClick={() => map.setView(center, map.getZoom(), { animate: true })}
|
|
aria-label="Recenter on me"
|
|
className="flex items-center justify-center w-10 h-10 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl active:bg-[#ebeae5] transition-all touch-manipulation press-feedback-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f54e00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f2f1ed]"
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<Crosshair size={18} className="text-[#26251e]" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// MapFloatingControls — MUST live inside <MapContainer>. 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 (
|
|
<div
|
|
className="leaflet-fab-host"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 12,
|
|
right: 12,
|
|
zIndex: 400,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
pointerEvents: 'auto',
|
|
}}
|
|
>
|
|
{userLocation && (
|
|
<RecenterButton center={userLocation} />
|
|
)}
|
|
{trailCount > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleTrail}
|
|
aria-pressed={showTrail}
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-colors press-feedback-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f54e00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f2f1ed] ${
|
|
showTrail
|
|
? 'bg-[#26251e] text-[#f2f1ed]'
|
|
: 'bg-[#e6e5e0]/95 backdrop-blur-sm text-[#26251e]'
|
|
}`}
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<Route size={14} />
|
|
Trail
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="min-w-[220px] max-w-[280px]">
|
|
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
|
{supplier.supplieraddress && (
|
|
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#ebeae5] text-[10px] font-bold text-[#26251e]">
|
|
<Package size={10} aria-hidden="true" />
|
|
{cluster.points.length} stop{cluster.points.length !== 1 ? 's' : ''}
|
|
</span>
|
|
{distance && (
|
|
<span className="text-xs text-[#f54e00] font-semibold">
|
|
{distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)} km`} away
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1.5 mb-3 max-h-[180px] overflow-y-auto">
|
|
{cluster.points.map((p) => (
|
|
<div key={p.item.id} className="text-xs p-2 bg-[#ebeae5] rounded-lg">
|
|
<div className="flex items-center gap-1.5">
|
|
{p.item.problem_flag && <AlertTriangle size={12} className="text-[#b3224a] flex-shrink-0" />}
|
|
<span className="font-medium truncate">{getItemDisplayDescription(p.item)}</span>
|
|
{p.item.sku && (
|
|
<span className="text-[10px] text-[rgba(38,37,30,0.5)] font-mono">#{p.item.sku}</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[rgba(38,37,30,0.55)] text-[11px]">
|
|
Qty: {getItemDisplayQty(p.item)}
|
|
{p.item.order_qty && p.item.quantity && p.item.order_qty !== p.item.quantity && (
|
|
<span className="ml-1 text-[rgba(38,37,30,0.4)]">
|
|
(cust: {p.item.quantity})
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => onNavigate(cluster.center[0], cluster.center[1], supplier.suppliername)}
|
|
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-[#f54e00] text-white rounded-lg text-sm font-semibold active:bg-[#d44200] transition-colors"
|
|
>
|
|
<Navigation size={14} /> Navigate
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Marker
|
|
key={`w-${supplierName}-${count}`}
|
|
position={cluster.center}
|
|
icon={buildWaypointIcon(count, supplierName, areaColor)}
|
|
eventHandlers={{ click: handleClick }}
|
|
title={`${supplierName} — ${count} stop${count === 1 ? '' : 's'}`}
|
|
keyboard
|
|
alt={`Waypoint: ${supplierName} (${count} ${count === 1 ? 'stop' : 'stops'})`}
|
|
>
|
|
<Popup>
|
|
<ClusterPopupBody cluster={cluster} userLocation={userLocation} onNavigate={onNavigate} />
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
}
|
|
|
|
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<ReturnType<typeof setInterval> | 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<string, number>();
|
|
clusters.forEach((cluster, idx) => {
|
|
cluster.items.forEach(item => {
|
|
map.set(item.id, idx % AREA_COLORS.length);
|
|
});
|
|
});
|
|
return map;
|
|
}, [items]);
|
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
|
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 (
|
|
<div className="absolute inset-0">
|
|
<MapContainer
|
|
center={center}
|
|
zoom={12}
|
|
className="absolute inset-0 z-0"
|
|
zoomControl={false}
|
|
style={{ height: '100%', width: '100%' }}
|
|
>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
<MapBounds points={points} />
|
|
<ClusterSync />
|
|
<MapFloatingControls
|
|
userLocation={userLocation}
|
|
showTrail={showTrail}
|
|
trailCount={trail.length}
|
|
onToggleTrail={() => setShowTrail((v) => !v)}
|
|
/>
|
|
|
|
{userLocation && (
|
|
<Marker position={userLocation} icon={currentLocationIcon}>
|
|
<Popup>
|
|
<div className="text-sm font-medium">Your Location</div>
|
|
</Popup>
|
|
</Marker>
|
|
)}
|
|
|
|
{/* Trail polyline */}
|
|
{showTrail && trail.length > 1 && (
|
|
<>
|
|
<Polyline
|
|
positions={trail}
|
|
pathOptions={{
|
|
color: '#1f8a65',
|
|
weight: 4,
|
|
opacity: 0.7,
|
|
dashArray: '10, 8',
|
|
}}
|
|
/>
|
|
<Marker
|
|
position={trail[0]}
|
|
icon={L.divIcon({
|
|
className: '',
|
|
html: '<div style="width:14px;height:14px;background:#1f8a65;border:3px solid #f2f1ed;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);"></div>',
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7],
|
|
})}
|
|
>
|
|
<Popup><div className="text-xs font-medium">Trip Start</div></Popup>
|
|
</Marker>
|
|
</>
|
|
)}
|
|
|
|
{clusters.map((cluster, i) => {
|
|
// Find the majority area color among the points in this visual cluster
|
|
const colorCounts = new Map<number, number>();
|
|
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 (
|
|
<ClusterMarker
|
|
key={`cm-${i}`}
|
|
cluster={cluster}
|
|
userLocation={userLocation}
|
|
onNavigate={onNavigate}
|
|
areaColor={majorityColor}
|
|
/>
|
|
);
|
|
})}
|
|
</MapContainer>
|
|
|
|
{/* Legend — pure UI, no leaflet hooks, safe to render outside MapContainer. */}
|
|
<div
|
|
className="absolute bottom-24 right-3 z-[400] bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl p-3"
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<p className="text-[10px] font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider mb-2">Legend</p>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-[#f54e00] border-2 border-white shadow-sm" />
|
|
<span className="text-[11px] text-[#26251e]">You</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Miniature of the actual dot + a tiny corner badge so the legend
|
|
matches exactly what the map renders — not the old pill. */}
|
|
<div className="relative w-5 h-5 flex items-center justify-center">
|
|
<div className="w-3.5 h-3.5 rounded-full bg-[#f54e00] border border-white shadow-sm" />
|
|
<div className="absolute -top-0.5 -right-0.5 min-w-[12px] h-[12px] px-[3px] flex items-center justify-center rounded-full bg-[#26251e] text-[7px] font-bold text-[#f2f1ed] border border-white">
|
|
N
|
|
</div>
|
|
</div>
|
|
<span className="text-[11px] text-[#26251e]">Waypoint (N stops)</span>
|
|
</div>
|
|
{trail.length > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-5 h-0" style={{ borderTop: '2px dashed #1f8a65' }} />
|
|
<span className="text-[11px] text-[#26251e]">Route</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className="absolute inset-x-0 bottom-24 mx-auto max-w-xs px-4 py-3 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl text-center z-[400] focus-within:ring-2 focus-within:ring-[#f54e00]"
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<p className="text-sm font-semibold text-[#26251e]">No stops on the map yet</p>
|
|
<p className="text-[11px] text-[rgba(38,37,30,0.55)] mt-1">
|
|
Assigned pickups need a supplier address with coordinates. Use the list to add one.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |