284 lines
No EOL
10 KiB
TypeScript
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";
|
|
} |