/** * Geo helpers — shared by `SupplierMap` and `CollectionMap`. * * Pure functions, no React / no Leaflet, so they tree-shake cleanly and * can be unit-tested in isolation. */ export type LatLng = readonly [number, number]; /** * Parse a coordinate string in any of three common shapes: * 1. JSON object — `{"lat": -26.2, "lng": 28.04}` * 2. Google Maps URL fragment — `@-26.2041,28.0473` * 3. Plain `lat, lng` pair (any separator: `,`, `;`, or whitespace) * * Returns `[lat, lng]` or `null` if the string isn't parseable. * * The regex is permissive — it accepts `"lat"` / `"latitude"` / `"lon"` / * `"lng"` / `"longitude"` so legacy data written in any of those shapes * round-trips without re-writing the row. */ export function parseCoordinates(value?: string | null): LatLng | null { if (!value) return null; const trimmed = value.trim(); if (!trimmed) return null; const jsonMatch = trimmed.match( /"?(?:lat|latitude)"?\s*:\s*(-?\d+(?:\.\d+)?).*"?(?:lng|lon|longitude)"?\s*:\s*(-?\d+(?:\.\d+)?)/i ); if (jsonMatch) return [Number(jsonMatch[1]), Number(jsonMatch[2])] as const; const atMatch = trimmed.match(/@(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)/); if (atMatch) return [Number(atMatch[1]), Number(atMatch[2])] as const; const pairMatch = trimmed.match(/(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)/); if (!pairMatch) return null; const first = Number(pairMatch[1]); const second = Number(pairMatch[2]); if (!Number.isFinite(first) || !Number.isFinite(second)) return null; return [first, second] as const; } /** * Great-circle distance between two [lat, lng] points in kilometres. * * Haversine formula — accurate enough for "is this stop within Xkm of * that supplier?" queries at city/country scale. Not suitable for * sub-metre navigation. */ export function distanceKm(a: LatLng, b: LatLng): number { const toRad = (value: number) => (value * Math.PI) / 180; const earthRadiusKm = 6371; const dLat = toRad(b[0] - a[0]); const dLng = toRad(b[1] - a[1]); const lat1 = toRad(a[0]); const lat2 = toRad(b[0]); const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; return 2 * earthRadiusKm * Math.asin(Math.sqrt(h)); } /** * Cluster radius in km, bucketed by Leaflet zoom level. * * Far-zoomed-out (≤7): 80km — a city-wide cluster. * Mid-zoom (8-9): 35km — neighbouring suburbs. * Mid-high (10-11): 14km — same industrial area. * Close-up (≥12): 8km — almost individual pins. * * Bucketing on zoom breakpoints (not raw zoom) keeps `buildClusters` * from re-running on every wheel tick. If you change this function, * re-check the `useMemo([clusterDistanceKm])` deps in callers. */ export function clusterDistanceForZoom(zoom: number): number { if (zoom <= 7) return 80; if (zoom <= 9) return 35; if (zoom <= 11) return 14; return 8; } /** * Stable colour palette for clusters. Each cluster picks its colour by * index so the same cluster keeps the same colour across re-renders * (no hash needed — insertion order is deterministic for our use case). * * Brand-driven: picks with high contrast on both light OSM and dark * CARTO tiles. The first two (deep blue + crimson) intentionally contrast * the supplier palette so an assigned-driver pin doesn't get lost in a * cluster of nearby supplier pins. */ export const CLUSTER_PALETTE = [ "#1B76BB", // deep blue "#E64B4B", // crimson "#F4B842", // amber "#169B62", // forest "#7C3AED", // violet "#EA580C", // burnt orange "#0F766E", // teal "#FFE600", // brand yellow — PixelForge signature accent ] as const; export type Cluster = { id: string; color: string; center: LatLng; items: T[]; }; /** * Greedy single-pass clustering: each new item joins the nearest * existing cluster within `distanceKmLimit`, otherwise spawns a new * cluster. Cluster centres are the running mean of their members. * * Returns clusters sorted by insertion order — i.e. by the order * items appear in the input. That's stable for our use case (PO * rows are sorted by `assigned_at` ascending). */ export function buildClusters( items: T[], distanceKmLimit: number ): Cluster[] { const clusters: Cluster[] = []; for (const item of items) { const location: LatLng = [item.lat, item.lng]; const nearby = clusters.find( (cluster) => distanceKm(cluster.center, location) <= distanceKmLimit ); if (!nearby) { clusters.push({ id: `cluster-${clusters.length + 1}`, color: CLUSTER_PALETTE[clusters.length % CLUSTER_PALETTE.length], center: location, items: [item], }); continue; } nearby.items.push(item); const n = nearby.items.length; nearby.center = [ (nearby.center[0] * (n - 1) + item.lat) / n, (nearby.center[1] * (n - 1) + item.lng) / n, ] as LatLng; } return clusters; } /** * FNV-1a hash → palette index. Used to assign a stable colour per * supplier/driver id so the same supplier keeps the same swatch * across renders without needing a lookup table. */ export function paletteIndexForId(id: string, paletteSize = CLUSTER_PALETTE.length): number { let hash = 0x811c9dc5; for (let i = 0; i < id.length; i++) { hash ^= id.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash % paletteSize; } /* ============================================================================ * Collection map — role-driven colours (separate from CLUSTER_PALETTE so the * supplier-page clustering and the assignment-page map can use independent * colour semantics without coupling.) * * Role → colour: * driver (index 0) → Rausch crimson #E64B4B * driver (index 1) → deep blue #1B76BB * unassigned → brand amber #F59E0B (diamond) * collected → emerald #22C55E * * Failed collects get a red ring on the green pin so the operator sees the * sub-state without losing the colour-coded bucket. * * Driver index is assigned by sorting the active driver list by id (stable, * deterministic across re-renders). Adding a third driver means adding a * colour here — the page checks via `driverIndex + 1 < DRIVER_PALETTE.length` * and falls back to the brand yellow if you've added more drivers than slots. * ========================================================================= */ export const DRIVER_PALETTE = ["#E64B4B", "#1B76BB"] as const; export const STOP_STATUS_COLOR = { unassigned: "#F59E0B", collected: "#22C55E", failed: "#DC2626", } as const; /** * Resolves the stable driver index (0 or 1) by sorting the supplied drivers * by id and returning the position of the requested driver in that sorted * list. Returns 0 as a safe fallback so a missing driver renders red (the * same colour as driver 0) instead of crashing the page. */ export function driverIndexFor(sortedDrivers: { id: string }[], driverId: string | undefined): number { if (!driverId) return 0; const idx = sortedDrivers.findIndex((d) => d.id === driverId); return idx === -1 ? 0 : Math.min(idx, DRIVER_PALETTE.length - 1); } /** * The single colour decision for a stop pin. Combines the per-role colour * for unassigned/collected with the driver-indexed palette for assigned. * `failed` overlays the red ring on top of green via the failedOutlined * parameter — the marker factory reads both. */ export function colorForStop(args: { role: "unassigned" | "assigned" | "collected" | "failed"; driverIndex: number; }): string { switch (args.role) { case "unassigned": return STOP_STATUS_COLOR.unassigned; case "collected": case "failed": return STOP_STATUS_COLOR.collected; case "assigned": default: return DRIVER_PALETTE[args.driverIndex] ?? DRIVER_PALETTE[0]; } }