/** * 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]; /** * A PocketBase `geoPoint` column as it comes back over the wire. */ export type GeoPoint = { lat: number; lon: number }; /** * Treat PB's `{ lon: 0, lat: 0 }` (Null Island) as "no coordinates". * * Every `geoPoint` column in this database defaults to `{0, 0}` rather * than null, so "has the operator actually set a position?" is a * value check, not a null check. Null Island is in the Gulf of Guinea * — no WFDASH customer, company or supplier will ever legitimately sit * there, so treating it as "unset" is safe. * * Previously duplicated inline in `customers/[id]/page.tsx` and * `companies/[id]/page.tsx`; lives here now that the rep check-in flow * is a third caller. */ export function isRealGeoPoint( point: { lat?: number | null; lon?: number | null } | null | undefined ): point is GeoPoint { if (!point) return false; const { lat, lon } = point; if (typeof lat !== "number" || typeof lon !== "number") return false; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; return lat !== 0 || lon !== 0; } /** * 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; } /** * A [lat, lng] point randomly offset from `center` by a distance in * `[minMetres, maxMetres]`, at a random bearing. * * Used by the batch pin tool: dozens of customers on the same mine * shaft get the same clicked point, which would render as a single * marker on the office map and make it impossible to confirm who's * actually pinned. Scattering them by a few tens of metres keeps every * pin individually visible without moving anyone off the site — at the * 2 km check-in radius, an 80 m jitter is noise. * * Uses an equirectangular approximation (flat-earth over the offset * distance), which is accurate to well under a metre at this scale — * good enough for "visually distinct dots on a map", not for anything * that needs survey-grade precision. */ export function jitterCoordinates( center: LatLng, minMetres: number, maxMetres: number ): LatLng { const [lat, lng] = center; const bearing = Math.random() * 2 * Math.PI; const distanceM = minMetres + Math.random() * (maxMetres - minMetres); const earthRadiusM = 6_371_000; const dLat = ((distanceM * Math.cos(bearing)) / earthRadiusM) * (180 / Math.PI); const dLng = ((distanceM * Math.sin(bearing)) / (earthRadiusM * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI); return [lat + dLat, lng + dLng] 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]; } } /* ============================================================================ * 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"; }