WFDASH-WEBAPP/src/lib/geo.ts

284 lines
No EOL
10 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];
}
}
/* ============================================================================
* Collection map — pin label + glyph helpers.
*
* Pure functions that drive the new two-pin model on the collection map.
* Driver labels are alphabetical by id (A → Z, then 1 → 9); the unassigned
* glyph collapses ready / unready into ✓ / ✕ based on the bucket contents.
* ========================================================================= */
/**
* Returns the driver label for a given driver id within an lexically-
* sorted driver list. Position 0 → "A", 1 → "B", ..., 25 → "Z", 26 → "1",
* 27 → "2", ..., 34 → "9", 35 → "9" (clamped). Returns "?" if the id
* isn't in the list.
*
* The caller MUST pass a list already sorted by `id` ascending — typically
* the `driversWithItems` array, which `page.tsx` already sorts by name via
* `localeCompare` on the matching `LookupUser` record. Because driver
* `id` and driver `name` are stable across days, the letter for a given
* driver is also stable across days.
*/
export function driverLabelFor(
sortedDrivers: { id: string }[],
driverId: string
): string {
const idx = sortedDrivers.findIndex((d) => d.id === driverId);
if (idx === -1) return "?";
if (idx < 26) return String.fromCharCode("A".charCodeAt(0) + idx);
// After Z, fall back to 1..9 and clamp at 9 — the glyph "0" would
// read as "unknown driver" next to the existing "?" fallback, and
// wrapping back to "1" would alias position 26's driver. Clamping
// makes it visually obvious you have >35 drivers without silently
// mislabeling anyone.
return String(Math.min(idx - 25, 9));
}
/**
* Returns the unassigned glyph for a supplier bucket:
* - "ready" → ✓ : every pool item has ready_for_collection === true.
* - "unready" → ✕ : at least one isn't ready, or the bucket is empty.
*
* Returning "unready" for an empty input keeps the pin from falsely
* advertising "ready, 0 items" — an empty bucket shouldn't render a pin
* at all, but if the caller asks the helper for the glyph defensively,
* "unready" is the safer answer.
*/
export function unassignedGlyphFor(
poolItems: { ready_for_collection?: boolean }[]
): "ready" | "unready" {
if (poolItems.length === 0) return "unready";
const anyUnready = poolItems.some(
(it) => it.ready_for_collection !== true
);
return anyUnready ? "unready" : "ready";
}