229 lines
7.7 KiB
TypeScript
229 lines
7.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<T> = {
|
||
|
|
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<T extends { lat: number; lng: number }>(
|
||
|
|
items: T[],
|
||
|
|
distanceKmLimit: number
|
||
|
|
): Cluster<T>[] {
|
||
|
|
const clusters: Cluster<T>[] = [];
|
||
|
|
|
||
|
|
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];
|
||
|
|
}
|
||
|
|
}
|