diff --git "a/web/src/customer-grid/MapView.tsx" "b/web/src/customer-grid/MapView.tsx" --- "a/web/src/customer-grid/MapView.tsx" +++ "b/web/src/customer-grid/MapView.tsx" @@ -1,1033 +1,1768 @@ -// --------------------------------------------------------------------------- -// customer-grid / MapView.tsx -// "Map" is a VIEW beside Grid/List/Calendar/Kanban, not a module: it projects -// the SAME pipeline output the grid paints, so the toolbar count, filters and -// sorts mean exactly what they mean on the grid; only the projection changes. -// -// Wave 8 grows it up (I2/I3/I5/I6, contracts C2/C3/C7): -// I2 wheel/pinch zoom, drag to pan, and a box-select that feeds the grid's -// OWN selection — so the existing "N selected · Add to cohort" bar serves -// the map with no second cohort picker to keep in sync. -// I3 colour pins by any single-select field (`display.colorField`) + legend. -// I5 size pins by any numeric/measure field (`display.sizeField`) + legend. -// I6 a less templatic picture: water wash, land fill, thin C1 strokes, -// graticule, hover halos. -// -// C7 CALL — NO map library. C7 allows maplibre-gl "with a self-hosted -// vector/raster-free style (NO external tile fetches ... if real tiles need a -// network fetch, that is a blocker: fall back)". A basemap IS a network fetch, -// and self-hosting tiles means shipping them inside a single-file embed. A -// tileless maplibre would render exactly the geometry below for +230 KB gz on a -// 251 KB budget. Declined on the contract's own terms; see the mailbox. -// -// Honesty rules (rule 8b), carried and extended: -// - rows WITHOUT coordinates are never silently hidden — a chip says -// "N matching records have no location"; -// - a pin per row at its geocoded position, no clustering and no jitter (two -// customers at one address overlap rather than being drawn where they are -// not); -// - the colour legend STATES it when a field has more choices than the -// palette has entries, rather than recycling a colour onto a second meaning; -// - a row with no value under the size field gets the smallest dot and the -// legend says so — it is never dropped from the map. -// --------------------------------------------------------------------------- - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; -import type { Field, Row } from "./types"; -import { formatDisplay } from "./cells"; -import { LAND_PATH, LAKE_PATHS } from "./mapGeometry"; -import { - BASEMAP_DETAIL_K, - bubbleRadius, - cardBox, - clientToUser, - dashPattern, - fitView, - fromScreen, - googleDirectionsUrl, - googleMapsUrl, - googleRouteUrl, - googleZoomForK, - graticuleOpacity, - hairline, - haversineKm, - isPlottable, - normRect, - pathBounds, - planRoute, - pointInPolygon, - project, - toScreen, - unproject, - WORLD, - zoomAt, - zoomLimits, -} from "./mapProjection"; -import type { GeoStop, Pt, View } from "./mapProjection"; - -const VIEW_W = 1000; -const VIEW_H = 620; -const R_MIN = 3.2; -const R_MAX = 15; -const R_PLAIN = 4.5; -const R_NULL = 2.6; - -/** - * The pin palette: C1's cycle (blue -> green -> yellow -> red) then its - * 20%-darkened variants, exactly as the contract specifies for series colour. - * `fill` is the pastel; `line` is a darker companion, because a C1 pastel - * measures 1.4-1.9:1 on white and a fill alone would be a smudge (the same - * measurement that drove the I7c amendment). - */ -const SERIES: { fill: string; line: string }[] = [ - { fill: "#9DBFF2", line: "#5F7FB0" }, - { fill: "#A5D8B4", line: "#5E9C74" }, - { fill: "#F5D989", line: "#B39A46" }, - { fill: "#F0A8A0", line: "#B76D65" }, - { fill: "#7E99C2", line: "#4A6790" }, - { fill: "#84AD91", line: "#4C7A5C" }, - { fill: "#C4AE6E", line: "#8C7838" }, - { fill: "#C0867F", line: "#8A5049" }, -]; -/** Anything past the palette, plus the blank bucket. Neutral ON PURPOSE: a - * ninth colour that repeats the first would make two meanings look identical. */ -const OVERFLOW = { fill: "#D7DBE3", line: "#8A909C" }; -const DEFAULT_PIN = { fill: "#9DBFF2", line: "#4F6079" }; - -interface MapPoint { - pid: number; - title: string; - p: Pt; - /** The ORIGINAL coordinate, kept beside the projected one so the Google - * hand-off links to the exact geocode we plotted rather than to a round-trip - * through the projection. */ - lat: number; - lon: number; - colorKey: string | null; - size: number | null; - /** The RAW cell values behind the two encodings. The hover card formats these - * with `formatDisplay`, the grid's own formatter — `colorKey` is trimmed for - * bucketing and `size` is coerced for the bubble scale, so neither is what a - * human should be shown. */ - colorVal: Row[keyof Row]; - sizeVal: Row[keyof Row]; -} - -function coord(v: Row[keyof Row]): number | null { - if (v == null || v === "") return null; - const n = typeof v === "number" ? v : Number(v); - return Number.isFinite(n) ? n : null; -} - -function numOrNull(v: Row[keyof Row]): number | null { - if (v == null || v === "") return null; - const n = typeof v === "number" ? v : Number(v); - return Number.isFinite(n) ? n : null; -} - -export function MapView({ - rows, - field, - colorField, - sizeField, - selectedPids, - onSelectPids, - onOpen, -}: { - /** DISTINCT data rows from the full pipeline, overlay edits layered — the - * calendar/kanban contract, verbatim. */ - rows: Row[]; - /** The locked identity column — pin tooltips and aria labels. */ - field: Field; - /** I3 — the view's `display.colorField`, already resolved to a real field. */ - colorField?: Field; - /** I5 — the view's `display.sizeField`, already resolved to a real field. */ - sizeField?: Field; - /** The grid's selection, shared: pins render selected, and the existing - * selection bar is what offers "Add to cohort" (C3). */ - selectedPids: ReadonlySet; - onSelectPids: (pids: number[], mode: "replace" | "add") => void; - onOpen: (pid: number) => void; -}) { - const [hoverPid, setHoverPid] = useState(null); - // I18-R — the route planner. Off until asked for: a route drawn over a - // selection nobody asked to route is just clutter. - const [routeOn, setRouteOn] = useState(false); - const [roundTrip, setRoundTrip] = useState(false); - const [routeStartPid, setRouteStartPid] = useState(null); - const [view, setView] = useState(null); - /** - * The selection gesture in flight. Two shapes, never a mode for SELECTING - * itself: shift-drag always selects, and `lassoTool` only changes what it - * draws. That distinction is why the "no mode toggle" note over `onPointerDown` - * still holds — the thing people would never find behind a mode is selection, - * and it is still on the bare gesture. - */ - const [drag, setDrag] = useState< - | { kind: "rect"; x0: number; y0: number; x1: number; y1: number } - | { kind: "lasso"; pts: Pt[] } - | null - >(null); - const [lassoTool, setLassoTool] = useState(false); - /** - * ⚠ The svg element lives in STATE, not in a ref, and that is load-bearing. - * - * `view` starts null, so the FIRST render returns the empty state and there is - * no in the tree at all. A `useRef` would still be null when the wheel - * effect below first ran, and by the time the svg actually mounted the - * effect's deps (`localPoint`, `kMin`, `kMax`) were all unchanged — so React - * would never re-run it and the wheel listener would never be attached. Wheel - * zoom would be silently dead on the ordinary path. - * - * Holding the element in state makes attachment a consequence of MOUNTING - * rather than of a dependency happening to change, so the hazard cannot come - * back. No unit test can see this: the gate is pure TS under node, and a - * screenshot of a static page has no React in it. - */ - const [svgEl, setSvgEl] = useState(null); - const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null); - /** A finished drag must not also read as a click on the pin underneath — the - * kanban card's lesson (viewModes.tsx), same fix. */ - const movedRef = useRef(false); - /** Once the user has zoomed or panned, a data change must NOT yank the view - * back. Before that, refitting on new data is the helpful behaviour. */ - const touchedRef = useRef(false); - - const points = useMemo(() => { - const out: MapPoint[] = []; - for (const r of rows) { - const lat = coord(r.lat); - const lon = coord(r.lon); - if (lat == null || lon == null) continue; - if (Math.abs(lat) > 90 || Math.abs(lon) > 180) continue; - out.push({ - pid: r.pid, - title: String(r[field.key] ?? ""), - p: project(lon, lat), - lat, - lon, - colorKey: colorField ? String(r[colorField.key] ?? "").trim() : null, - size: sizeField ? numOrNull(r[sizeField.key]) : null, - colorVal: colorField ? r[colorField.key] : null, - sizeVal: sizeField ? r[sizeField.key] : null, - }); - } - return out; - }, [rows, field.key, colorField, sizeField]); - const noCoords = rows.length - points.length; - - // The fit is the INITIAL view, not the projection (see mapProjection.ts). - const fit = useMemo( - () => fitView(points.map((p) => p.p), VIEW_W, VIEW_H), - [points] - ); - useEffect(() => { - if (!fit) return; - if (!touchedRef.current || view == null) setView(fit); - // `view` is deliberately absent from the deps: this effect exists to seed - // and re-fit, and re-running it on every pan would fight the user for the - // camera. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fit]); - - const { kMin, kMax } = useMemo( - () => (fit ? zoomLimits(fit.k, VIEW_H, BASEMAP_DETAIL_K) : { kMin: 1, kMax: 1 }), - [fit] - ); - - /** Colour buckets, in first-seen order so the legend is stable. */ - const colorBuckets = useMemo(() => { - if (!colorField) return null; - const order: string[] = []; - const counts = new Map(); - for (const p of points) { - const k = p.colorKey ?? ""; - if (!counts.has(k)) { - counts.set(k, 0); - order.push(k); - } - counts.set(k, (counts.get(k) ?? 0) + 1); - } - const named = order.filter((k) => k !== ""); - const swatch = new Map(); - named.forEach((k, i) => swatch.set(k, i < SERIES.length ? SERIES[i] : OVERFLOW)); - return { - order, - counts, - swatch, - overflow: Math.max(0, named.length - SERIES.length), - }; - }, [colorField, points]); - - /** The size field's observed range across MAPPED points (not the whole table: - * the legend must describe the picture actually on screen). */ - const sizeRange = useMemo(() => { - if (!sizeField) return null; - let min = Infinity; - let max = -Infinity; - let missing = 0; - for (const p of points) { - if (p.size == null) { - missing += 1; - continue; - } - min = Math.min(min, p.size); - max = Math.max(max, p.size); - } - if (!Number.isFinite(min)) return { min: 0, max: 0, missing, none: true }; - return { min, max, missing, none: false }; - }, [sizeField, points]); - - /** A coarse pointer means a phone or tablet, where Google's free URL takes 3 - * waypoints rather than 9. A media query, not user-agent sniffing. */ - const coarsePointer = useMemo( - () => - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia("(pointer: coarse)").matches, - [] - ); - - /** Selected pins that can actually be routed, in a STABLE order (by pid) — - * a Set's iteration order must not be what decides a route. */ - const routable = useMemo( - () => - selectedPids.size < 2 - ? [] - : points - .filter((p) => selectedPids.has(p.pid) && isPlottable(p.lat, p.lon)) - .sort((a, b) => a.pid - b.pid), - [points, selectedPids] - ); - /** Selected records with no usable coordinate. Counted and shown, never - * folded silently into the stop total ([[no-unverifiable-aggregates]]). */ - const unroutable = selectedPids.size - routable.length; - - const plan = useMemo(() => { - if (!routeOn || routable.length < 2) return null; - const stops: GeoStop[] = routable.map((p) => ({ lat: p.lat, lon: p.lon })); - // Default origin: the WESTERNMOST stop. Deterministic, stable while the user - // pans, and sayable out loud — unlike "whatever ended up at index 0". The - // picker below overrides it. - let start = 0; - for (let i = 1; i < routable.length; i++) - if (routable[i].lon < routable[start].lon) start = i; - if (routeStartPid != null) { - const i = routable.findIndex((p) => p.pid === routeStartPid); - if (i >= 0) start = i; - } - const { order, km } = planRoute(stops, haversineKm, { start, roundTrip }); - return { - ordered: order.map((i) => routable[i]), - km, - link: googleRouteUrl(order.map((i) => stops[i]), { roundTrip, coarsePointer }), - }; - }, [routeOn, routable, roundTrip, routeStartPid, coarsePointer]); - - const paint = useCallback( - (p: MapPoint) => { - if (!colorBuckets) return DEFAULT_PIN; - const k = p.colorKey ?? ""; - if (k === "") return OVERFLOW; - return colorBuckets.swatch.get(k) ?? OVERFLOW; - }, - [colorBuckets] - ); - - const radius = useCallback( - (p: MapPoint) => { - if (!sizeField || !sizeRange || sizeRange.none) return R_PLAIN; - return bubbleRadius(p.size, sizeRange.min, sizeRange.max, R_MIN, R_MAX, R_NULL); - }, - [sizeField, sizeRange] - ); - - /** - * Client coords -> the svg's own user-space coords. - * - * ⚠ The maths lives in `clientToUser` (mapProjection) rather than here, and - * that placement is the fix's other half. Wave 8 wrote the conversion inline - * in this .tsx — where the gate's negative controls, every one of which - * mutates the compiled `mapProjection.js`, could not reach it. So the box- - * select legs passed on coordinates handed to them ALREADY in user space, and - * a broken conversion shipped green for four waves. See `clientToUser`. - */ - const localPoint = useCallback( - (clientX: number, clientY: number): Pt => { - if (!svgEl) return { x: 0, y: 0 }; - return clientToUser(clientX, clientY, svgEl.getBoundingClientRect(), VIEW_W, VIEW_H); - }, - [svgEl] - ); - - /** - * ⚠ Wheel zoom MUST be a native, non-passive listener. React registers - * `wheel` PASSIVELY at the root, so `e.preventDefault()` inside an `onWheel` - * prop is a silent no-op: the page scrolls out from under the map while you - * zoom, which reads as "the zoom is broken". Nothing in a screenshot shows - * this, and no assertion on the rendered DOM can see it either. - * - * The zoom maths itself is `zoomAt` — the function the gate already proves - * holds the point under the cursor still. Wave 8 re-derived that formula - * inline here, so the tested copy and the shipped copy were two copies. - */ - useEffect(() => { - if (!svgEl) return; - const onWheelNative = (e: WheelEvent) => { - e.preventDefault(); - const { x, y } = localPoint(e.clientX, e.clientY); - touchedRef.current = true; - setView((v) => (v ? zoomAt(v, Math.exp(-e.deltaY * 0.0016), x, y, kMin, kMax) : v)); - }; - svgEl.addEventListener("wheel", onWheelNative, { passive: false }); - return () => svgEl.removeEventListener("wheel", onWheelNative); - }, [svgEl, localPoint, kMin, kMax]); - - /** Zoom about the viewport centre — the button and keyboard gesture, where - * there is no cursor to hold still. */ - const zoomBy = useCallback( - (factor: number) => { - touchedRef.current = true; - setView((v) => (v ? zoomAt(v, factor, VIEW_W / 2, VIEW_H / 2, kMin, kMax) : v)); - }, - [kMin, kMax] - ); - - const doFit = useCallback(() => { - touchedRef.current = false; - setView(fit); - }, [fit]); - - const panBy = useCallback((dx: number, dy: number) => { - touchedRef.current = true; - setView((v) => (v ? { ...v, tx: v.tx + dx, ty: v.ty + dy } : v)); - }, []); - - /** The camera had NO keyboard path at all before wave 9 — scroll wheel only, - * which is unusable without a mouse and unreachable for anyone driving the - * page from the keyboard. */ - const onFrameKeyDown = useCallback( - (e: ReactKeyboardEvent) => { - const step = e.shiftKey ? 160 : 60; - switch (e.key) { - case "ArrowLeft": panBy(step, 0); break; - case "ArrowRight": panBy(-step, 0); break; - case "ArrowUp": panBy(0, step); break; - case "ArrowDown": panBy(0, -step); break; - case "+": case "=": zoomBy(1.6); break; - case "-": case "_": zoomBy(1 / 1.6); break; - case "0": doFit(); break; - // Esc abandons a marquee mid-drag. It falls THROUGH when there is no - // drag, so it keeps closing whatever the host has open. - case "Escape": if (!drag) return; setDrag(null); break; - default: return; - } - e.preventDefault(); - }, - [panBy, zoomBy, doFit, drag] - ); - - const onPointerDown = useCallback( - (e: ReactPointerEvent) => { - if (e.button !== 0 || !view) return; - const { x, y } = localPoint(e.clientX, e.clientY); - movedRef.current = false; - e.currentTarget.setPointerCapture(e.pointerId); - // Shift (or Ctrl/Cmd) turns the drag into a SELECTION rectangle; a plain - // drag pans. Both gestures are on the same button because a map that - // needs a mode toggle to select is a map people never select on. - if (e.shiftKey || e.ctrlKey || e.metaKey) - setDrag(lassoTool ? { kind: "lasso", pts: [{ x, y }] } : { kind: "rect", x0: x, y0: y, x1: x, y1: y }); - else panRef.current = { x, y, tx: view.tx, ty: view.ty }; - }, - [view, localPoint, lassoTool] - ); - - const onPointerMove = useCallback( - (e: ReactPointerEvent) => { - const { x, y } = localPoint(e.clientX, e.clientY); - if (drag) { - movedRef.current = true; - setDrag((d) => { - if (!d) return d; - if (d.kind === "rect") return { ...d, x1: x, y1: y }; - // `pointermove` fires far faster than a loop needs vertices, so a slow - // hand tracing 200 px would otherwise build a thousand-point polygon - // that every pin is then tested against on every frame. Drop a sample - // that has not travelled ~2 px — the viewBox is fixed relative to the - // screen, so this threshold means the same thing at every zoom. - const last = d.pts[d.pts.length - 1]; - if (Math.abs(x - last.x) + Math.abs(y - last.y) < 2) return d; - return { kind: "lasso", pts: [...d.pts, { x, y }] }; - }); - return; - } - const pan = panRef.current; - if (!pan) return; - if (Math.abs(x - pan.x) + Math.abs(y - pan.y) > 2) movedRef.current = true; - touchedRef.current = true; - setView((v) => (v ? { ...v, tx: pan.tx + (x - pan.x), ty: pan.ty + (y - pan.y) } : v)); - }, - [drag, localPoint] - ); - - const onPointerUp = useCallback( - (e: ReactPointerEvent) => { - if (drag && view) { - // A gesture smaller than a few px is a mis-click, not a selection — - // clearing the user's set on a stray shift-click would be its own bug. - // ONE rule for both shapes, measured on the lasso's own bounds; a loop - // also needs three points before it is a polygon at all. - const b = - drag.kind === "rect" - ? normRect(drag.x0, drag.y0, drag.x1, drag.y1) - : pathBounds(drag.pts); - const drawn = b.x1 - b.x0 > 3 && b.y1 - b.y0 > 3 && (drag.kind === "rect" || drag.pts.length >= 3); - if (drawn) { - const hits: number[] = []; - for (const p of points) { - const s = toScreen(p.p, view); - const held = - drag.kind === "rect" - ? s.x >= b.x0 && s.x <= b.x1 && s.y >= b.y0 && s.y <= b.y1 - : pointInPolygon(s, drag.pts); - if (held) hits.push(p.pid); - } - onSelectPids(hits, e.altKey ? "add" : "replace"); - } - setDrag(null); - } - panRef.current = null; - if (e.currentTarget.hasPointerCapture(e.pointerId)) - e.currentTarget.releasePointerCapture(e.pointerId); - }, - [drag, view, points, onSelectPids] - ); - - if (!view || !fit) { - return ( -
- No records with a location to map yet. - {noCoords > 0 && - ` ${noCoords.toLocaleString()} matching record${noCoords === 1 ? "" : "s"} have no location.`} -
- ); - } - - const hovered = hoverPid != null ? points.find((p) => p.pid === hoverPid) : undefined; - const rect = drag?.kind === "rect" ? normRect(drag.x0, drag.y0, drag.x1, drag.y1) : null; - const lassoPts = drag?.kind === "lasso" && drag.pts.length > 1 ? drag.pts : null; - const tf = `translate(${view.tx.toFixed(2)} ${view.ty.toFixed(2)}) scale(${view.k.toFixed(6)})`; - // Strokes live in the transformed group, so they are pre-divided by the zoom. - // ⚠ This is the map's ONE stroke mechanism — see the note over `hairline` in - // mapProjection.ts. No `.cg-map*` rule may add `vector-effect: - // non-scaling-stroke` on top; that double-cancel is the wave-9 blur bug and - // `scalingConflicts()` gates the stylesheet against it. - const hair = (w: number) => hairline(w, view.k); - const gratOpacity = graticuleOpacity(view.k); - // I18 — the Google hand-off. Our vendored basemap is honest to roughly metro - // scale; below that the answer is a LINK, not 270 KB gz of tile renderer plus - // a hosted planet file. Nothing is fetched and no coordinate leaves the page - // unless the user deliberately clicks. - const centre = unproject(fromScreen({ x: VIEW_W / 2, y: VIEW_H / 2 }, view)); - const areaUrl = googleMapsUrl(centre.lat, centre.lon, googleZoomForK(view.k)); - // A single selected pin gets its own exact hand-off. Selection is persistent, - // unlike hover — and the hover card must stay pointer-events:none, so a link - // could never live in it without becoming a click trap. - const solo = selectedPids.size === 1 ? points.find((p) => selectedPids.has(p.pid)) : undefined; - const sizeLegend = sizeRange && !sizeRange.none && sizeRange.max > sizeRange.min - ? [sizeRange.min, (sizeRange.min + sizeRange.max) / 2, sizeRange.max] - : null; - - return ( -
-
- {/* The count is "N of M", never a bare N: a pin can only be drawn for a - row the host geocoded, and a lone "1,402" silently redefines the - toolbar's 1,550 ([[no-unverifiable-aggregates]]). */} - - {points.length.toLocaleString()} of{" "} - {rows.length.toLocaleString()} mapped · pinned at each customer's address - - {noCoords > 0 && ( - - {noCoords.toLocaleString()} matching record{noCoords === 1 ? " has" : "s have"} no location - - )} - {/* One selected pin -> the exact geocode, handed off to Google. By - lat/lon and never by name: a name search can resolve somewhere else, - and then this link and our pin disagree about where a customer is. - rel="noopener noreferrer" strips the Referer, so Google never learns - which tenant or deployment the click came from. */} - {solo && ( - - - {solo.title || "Selected pin"} in Google Maps - - - Directions - - - )} - {/* I18-R — the route planner. Appears only with a multi-pin selection, so - it is mutually exclusive with the single-pin links above and the bar - never carries both. */} - {selectedPids.size >= 2 && ( - - {!routeOn ? ( - - ) : ( - plan && ( - <> - - {plan.ordered.length.toLocaleString()} stops ·{" "} - {Math.round(plan.km).toLocaleString()} km - {/* ⚠ Never call this a driving distance, and never derive a - time from it: it is the sum of straight lines. Saying so - is the difference between a useful estimate and a lie. */} - straight-line, not driving distance - - - - {plan.link && ( - - Open route in Google Maps - {/* The free URL takes 9 waypoints on desktop and 3 on a - phone. When the route is longer, SAY which part rides. */} - {plan.link.used < plan.ordered.length && - ` (first ${plan.link.used} of ${plan.ordered.length})`} - - )} - - - ) - )} - {unroutable > 0 && ( - - {unroutable.toLocaleString()} selected record{unroutable === 1 ? " has" : "s have"} no - location and cannot be routed - - )} - - )} - {/* Every gesture the map has, named. `alt` (add to the selection rather - than replace it, onPointerUp) shipped in wave 8 and was disclosed - NOWHERE, so "select these as well" was a feature only the source - knew about. It needs shift too — alt alone still pans. */} - - Scroll to zoom · drag to pan · {lassoTool ? "shift-drag to lasso" : "shift-drag to select"}{" "} - · shift-alt-drag to extend the selection - -
-
- - - - {/* Graticule every 10 degrees. It earns its place zoomed OUT, where - it is the only thing giving scale; once the real state borders - arrive it would be a second line system fighting the first, so it - fades away before they take over. Skipped entirely at zero - opacity — ~36 invisible lines are still 36 nodes to lay out. */} - {gratOpacity > 0.01 && ( - - {Array.from({ length: 17 }, (_, i) => { - const y = project(0, -80 + i * 10).y; - return ; - })} - {Array.from({ length: 19 }, (_, i) => { - const x = project(-180 + i * 20, 0).x; - return ; - })} - - )} - {/* One path, every US state ring. Filled AND stroked, so interior - state borders come free from the same geometry — no second pass - and no chance of the borders disagreeing with the coastline. - 0.9 rather than wave 8's 1.1: that weight was tuned for a single - lone coastline, and it reads heavy once ~169 rings share it. */} - - {LAKE_PATHS.map((d, i) => ( - - ))} - {/* The planned path, UNDER the pins so it never hides a stop. Inside - the zoomed group, so it pans and scales with the geography; the - stroke is pre-divided by k like every other line here. */} - {plan && plan.ordered.length > 1 && ( - `${p.p.x},${p.p.y}`).join(" ") + - (roundTrip ? ` ${plan.ordered[0].p.x},${plan.ordered[0].p.y}` : "") - } - /> - )} - {points.map((p) => { - const c = paint(p); - const on = selectedPids.has(p.pid); - const r = radius(p) / view.k; - return ( - setHoverPid(p.pid)} - onMouseLeave={() => setHoverPid((h) => (h === p.pid ? null : h))} - onFocus={() => setHoverPid(p.pid)} - onBlur={() => setHoverPid((h) => (h === p.pid ? null : h))} - onClick={() => { - if (movedRef.current) return; // a finished pan/box is not a click - onOpen(p.pid); - }} - onKeyDown={(e) => { - if (e.key !== "Enter" && e.key !== " ") return; - e.preventDefault(); - onOpen(p.pid); - }} - > - {p.title} - - ); - })} - - {/* I18 — the hover card. Wave 8 painted the title alone; a map whose - pins carry a colour and a size encoding should say what they ARE. - ⚠ Every value goes through `formatDisplay`, the SAME formatter the - grid cells use, so a currency, a percentage or a date can never - read one way on the map and another way in the table. - Drawn in SVG screen space rather than as an HTML overlay: the - viewBox letterboxes under preserveAspectRatio, so an HTML card - would need the rendered scale re-derived, and this needs no - conversion at all. pointer-events stay off — a card that can - swallow the next click is a scar this codebase already carries. */} - {/* Stop numbers, in SCREEN space so they stay legible at every zoom. - pointer-events off: a badge sitting over a pin must not steal the - click that opens the record — re-rooting the route is the "Start" - picker's job, where it is visible and reversible. */} - {plan && - plan.ordered.map((p, i) => { - const s = toScreen(p.p, view); - return ( - - - {i + 1} - - ); - })} - {hovered && (() => { - const s = toScreen(hovered.p, view); - const lines: string[] = []; - if (colorField) - lines.push(`${colorField.label}: ${formatDisplay(colorField, hovered.colorVal) || "—"}`); - if (sizeField) - lines.push(`${sizeField.label}: ${formatDisplay(sizeField, hovered.sizeVal) || "—"}`); - const title = hovered.title || "(untitled)"; - // Inter's average advance at 11.5px. An estimate, deliberately - // generous: too wide is a slightly roomy card, too narrow is text - // spilling past its own background. - const w = Math.max(title.length, ...lines.map((l) => l.length)) * 6.2 + 20; - const h = 21 + lines.length * 14; - const b = cardBox(s.x, s.y, w, h, VIEW_W, VIEW_H); - return ( - - ); - })()} - {rect && ( - - )} - {/* The loop paints as a POLYGON, so the shape on screen is the shape - the hit test uses — an open would draw a mouth the - selection does not have. Same class as the rectangle: one marquee - look, and no new stylesheet rule (index.css is not this fence). */} - {lassoPts && ( - `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} - /> - )} - - - {/* --- I18 — the camera controls. Zoom in / zoom out / fit to data, the - affordance every map has, replacing wave 8's link-button-in-a-text-bar. - OUTSIDE the deliberately: a mousedown on a button inside it would - begin a pan. Icons are SVG strokes — no emoji, no glyph font. The zoom - buttons DISABLE at the limits, which is also how the honesty cap on - zoom-in makes itself visible instead of just feeling stuck. --- */} -
- - - - {/* The selection SHAPE. Not a mode for selecting — shift-drag selects - either way — so the note over `onPointerDown` still stands. The - icon shows the shape you will get, which is the only "on" state - available: `.cg-map-ctl-b` has no pressed style and index.css - belongs to another session this wave, so a pressed look would have - been a control that cannot show its own state. */} - - {/* The street-level hand-off, aimed at whatever is on screen right - now. This is what makes the zoom cap honest rather than merely - restrictive: the map stops where its geometry stops, and points at - something that does not. */} - - - -
- - {/* --- legends (I3/I5). Floated over the map, never in the flow. --- */} - {(colorBuckets || sizeLegend) && ( -
- {colorField && colorBuckets && ( -
-
{colorField.label}
- {colorBuckets.order.slice(0, SERIES.length + 1).map((k) => { - const c = k === "" ? OVERFLOW : colorBuckets.swatch.get(k) ?? OVERFLOW; - return ( -
- - {k === "" ? "(blank)" : k} - - {(colorBuckets.counts.get(k) ?? 0).toLocaleString()} - -
- ); - })} - {colorBuckets.overflow > 0 && ( -
- {colorBuckets.overflow.toLocaleString()} further value - {colorBuckets.overflow === 1 ? " is" : "s are"} drawn grey — the palette - holds {SERIES.length} colours and reusing one would make two values look - like the same value. -
- )} -
- )} - {sizeField && sizeRange && ( -
-
{sizeField.label}
- {sizeLegend ? ( -
- {sizeLegend.map((v, i) => { - const r = bubbleRadius(v, sizeRange.min, sizeRange.max, R_MIN, R_MAX, R_NULL); - return ( - - - - - {formatDisplay(sizeField, v)} - - ); - })} -
- ) : ( -
- Every mapped record has the same {sizeField.label.toLowerCase()}, so the - bubbles cannot differ in size. -
- )} - {sizeRange.missing > 0 && ( -
- {sizeRange.missing.toLocaleString()} mapped record - {sizeRange.missing === 1 ? " has" : "s have"} no value — drawn at the - smallest dot, never removed from the map. -
- )} -
- )} -
- )} -
-
- ); -} +// --------------------------------------------------------------------------- +// customer-grid / MapView.tsx +// "Map" is a VIEW beside Grid/List/Calendar/Kanban, not a module: it projects +// the SAME pipeline output the grid paints, so the toolbar count, filters and +// sorts mean exactly what they mean on the grid; only the projection changes. +// +// Wave 8 grows it up (I2/I3/I5/I6, contracts C2/C3/C7): +// I2 wheel/pinch zoom, drag to pan, and a box-select that feeds the grid's +// OWN selection — so the existing "N selected · Add to cohort" bar serves +// the map with no second cohort picker to keep in sync. +// I3 colour pins by any single-select field (`display.colorField`) + legend. +// I5 size pins by any numeric/measure field (`display.sizeField`) + legend. +// I6 a less templatic picture: water wash, land fill, thin C1 strokes, +// graticule, hover halos. +// +// C7 CALL — NO map library, and that call still stands. C7 allowed maplibre-gl +// "with a self-hosted vector/raster-free style"; a tileless maplibre would have +// rendered exactly the geometry below for +230 KB gz on a 251 KB budget. +// +// ⭐⭐ WAVE 37 (R3/R14, W37-T35) — THE MAP NOW FETCHES TILES, AND THE OLD NOTE +// HERE SAID THE OPPOSITE. It read: "Nothing is fetched and no coordinate leaves +// the page unless the user deliberately clicks." That was true for two waves and +// is FALSE now, so it is rewritten rather than left to rot. What is true today: +// +// - a RASTER BASEMAP is fetched, by the browser, straight from the tile +// provider. Panning the map therefore tells that provider roughly where you +// are looking. It does NOT tell them who: no record, no name and no address +// is ever sent, because a tile request carries only a z/x/y grid square that +// millions of other people also request; +// - the provider is a SEAM, not a vendor. `/api/v1/geo/providers` serves a URL +// template plus a credit line, both environment-driven, so pointing this at +// a paid vendor is configuration and not a rewrite. That seam is the whole +// reason R14 could defer the Google key without dropping the feature; +// - the DEFAULT is OpenStreetMap, which needs no key and no billing account. +// Its credit line is a LICENCE TERM and is rendered, not optional; +// - GEOCODING is still not automatic and never will be from here (R3). It has +// its own door, it takes an explicit list of addresses, and it is rate +// limited in code. Nothing on this surface geocodes anything; +// - if the tiles fail to load, the vendored vector basemap below is still +// here and takes over. A map that has lost its network draws the coastline +// it always drew rather than an empty rectangle. +// +// Honesty rules (rule 8b), carried and extended: +// - rows WITHOUT coordinates are never silently hidden — a chip says +// "N matching records have no location"; +// - a pin per row at its geocoded position, no clustering and no jitter (two +// customers at one address overlap rather than being drawn where they are +// not); +// - the colour legend STATES it when a field has more choices than the +// palette has entries, rather than recycling a colour onto a second meaning; +// - a row with no value under the size field gets the smallest dot and the +// legend says so — it is never dropped from the map. +// --------------------------------------------------------------------------- + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; +import type { Field, Row } from "./types"; +import { formatDisplay } from "./cells"; +import { LAND_PATH, LAKE_PATHS } from "./mapGeometry"; +import { + BASEMAP_DETAIL_K, + bubbleRadius, + cardBox, + clientToUser, + dashPattern, + fitView, + formatDuration, + fromScreen, + googleDirectionsUrl, + googleMapsUrl, + googleRouteUrl, + googleZoomForK, + graticuleOpacity, + hairline, + haversineKm, + isPlottable, + normRect, + parseLatLon, + pathBounds, + planRoute, + pointInPolygon, + project, + toScreen, + unproject, + WORLD, + zoomAt, + zoomLimits, +} from "./mapProjection"; +import type { GeoStop, Pt, View } from "./mapProjection"; +import "./map.css"; + +/** + * W37-T35 — THE PROVIDER SEAM, client side. + * + * A URL template and a credit line, nothing else. The renderer never learns which company is + * behind them, which is exactly what makes swapping in a paid provider a config change (R14). + * These constants are the OFFLINE DEFAULT: `/api/v1/geo/providers` overrides them at mount, and + * the map still draws if that call never answers. + */ +interface TileProvider { + url: string; + attribution: string; + attributionUrl: string; + maxZoom: number; +} + +const TILES_DEFAULT: TileProvider = { + url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: "© OpenStreetMap contributors", + attributionUrl: "https://www.openstreetmap.org/copyright", + maxZoom: 19, +}; + +/** How many tiles one frame may paint. A pathological zoom/pan otherwise asks for thousands, and + * a shared free tile service is exactly the wrong place to find that out. */ +const TILE_BUDGET = 240; + +/** + * CSS pixels per SVG user unit, at the width this view is normally painted. + * + * ⛔ MIRRORED FROM `mapProjection.googleZoomForK`, WHICH HARDCODES THE SAME 1.08 AND DOES NOT + * EXPORT IT. Two copies of one number is [[one-question-two-normalizers]], so `verify_map.py` + * asserts the constant still reads 1.08 over there. If that gate goes red, these two have drifted + * and the tile grid will be one zoom level off the hand-off link it is supposed to agree with. + */ +const CSS_PX_PER_UNIT = 1.08; + +/** The inverse of `googleZoomForK`: the camera `k` at which one 256px tile paints at ~256 CSS px. + * Used for the zoom ceiling, so "as far in as the provider actually serves" is a number and not + * a guess. */ +function kForTileZoom(z: number): number { + return (256 * Math.pow(2, z)) / (WORLD * CSS_PX_PER_UNIT); +} + +/** Consecutive tile errors before the vendored vector basemap takes back over. Three rather than + * one: a single 404 at the edge of coverage is normal and is not an outage. */ +const TILE_FAIL_LIMIT = 3; + +const VIEW_W = 1000; +const VIEW_H = 620; +const R_MIN = 3.2; +const R_MAX = 15; +const R_PLAIN = 4.5; +const R_NULL = 2.6; + +/** + * The pin palette: C1's cycle (blue -> green -> yellow -> red) then its + * 20%-darkened variants, exactly as the contract specifies for series colour. + * `fill` is the pastel; `line` is a darker companion, because a C1 pastel + * measures 1.4-1.9:1 on white and a fill alone would be a smudge (the same + * measurement that drove the I7c amendment). + */ +const SERIES: { fill: string; line: string }[] = [ + { fill: "#9DBFF2", line: "#5F7FB0" }, + { fill: "#A5D8B4", line: "#5E9C74" }, + { fill: "#F5D989", line: "#B39A46" }, + { fill: "#F0A8A0", line: "#B76D65" }, + { fill: "#7E99C2", line: "#4A6790" }, + { fill: "#84AD91", line: "#4C7A5C" }, + { fill: "#C4AE6E", line: "#8C7838" }, + { fill: "#C0867F", line: "#8A5049" }, +]; +/** Anything past the palette, plus the blank bucket. Neutral ON PURPOSE: a + * ninth colour that repeats the first would make two meanings look identical. */ +const OVERFLOW = { fill: "#D7DBE3", line: "#8A909C" }; +const DEFAULT_PIN = { fill: "#9DBFF2", line: "#4F6079" }; + +interface MapPoint { + pid: number; + title: string; + p: Pt; + /** The ORIGINAL coordinate, kept beside the projected one so the Google + * hand-off links to the exact geocode we plotted rather than to a round-trip + * through the projection. */ + lat: number; + lon: number; + colorKey: string | null; + size: number | null; + /** The RAW cell values behind the two encodings. The hover card formats these + * with `formatDisplay`, the grid's own formatter — `colorKey` is trimmed for + * bucketing and `size` is coerced for the bubble scale, so neither is what a + * human should be shown. */ + colorVal: Row[keyof Row]; + sizeVal: Row[keyof Row]; +} + +function coord(v: Row[keyof Row]): number | null { + if (v == null || v === "") return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +function numOrNull(v: Row[keyof Row]): number | null { + if (v == null || v === "") return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +export function MapView({ + rows, + field, + coordField, + colorField, + sizeField, + selectedPids, + onSelectPids, + onOpen, + onGeocoded, +}: { + /** DISTINCT data rows from the full pipeline, overlay edits layered — the + * calendar/kanban contract, verbatim. */ + rows: Row[]; + /** The locked identity column — pin tooltips and aria labels. */ + field: Field; + /** + * W37-T34 — the view's `display.coordField`, resolved to a real field by the caller exactly + * like `colorField`/`sizeField`. Its cell holds `"lat,lon"` (contract C7). + * + * ⛔ OPTIONAL, AND THE ABSENT CASE IS NOT "NO MAP" — it is the `lat`/`lon` column pair the + * customers mirror has always carried. That fallback is the ONLY reason this change is not a + * migration: every existing map view stored no `coordField`, and every one of them keeps + * painting exactly what it painted before. + */ + coordField?: Field; + /** I3 — the view's `display.colorField`, already resolved to a real field. */ + colorField?: Field; + /** I5 — the view's `display.sizeField`, already resolved to a real field. */ + sizeField?: Field; + /** The grid's selection, shared: pins render selected, and the existing + * selection bar is what offers "Add to cohort" (C3). */ + selectedPids: ReadonlySet; + onSelectPids: (pids: number[], mode: "replace" | "add") => void; + onOpen: (pid: number) => void; + /** + * W37-T37 (R4 / C7) — where looked-up coordinates go. The map does the ASKING; the host does the + * WRITING, through whichever cell door it already owns. + * + * ⛔ THE MAP DELIBERATELY HAS NO WRITER OF ITS OWN. A second path that can put values into cells + * would sit outside every guard the grid's own edit door carries, and this view is the last place + * that should own one. Absent callback = the control does not render at all, so there is no + * arrangement in which a person can run a lookup whose results have nowhere to land. + */ + onGeocoded?: (writes: { pid: number; value: string }[]) => void; +}) { + const [hoverPid, setHoverPid] = useState(null); + // I18-R — the route planner. Off until asked for: a route drawn over a + // selection nobody asked to route is just clutter. + const [routeOn, setRouteOn] = useState(false); + const [roundTrip, setRoundTrip] = useState(false); + const [routeStartPid, setRouteStartPid] = useState(null); + /** + * W37-T36 — A HAND-PICKED STOP ORDER, pids in visiting sequence, or null for the planner's. + * + * The planner solves for distance; a person routing a day of visits is solving for an + * appointment at eleven. So the arithmetic proposes and the human disposes, and "Re-optimise" + * hands it back. Kept as pids rather than indices because the selection can change underneath it. + */ + const [routeOrder, setRouteOrder] = useState(null); + /** + * W37-T36 — the ROAD answer, which is the half arithmetic cannot produce. + * + * ⛔ FETCHED ONLY WHEN ASKED (R3), and "asked" means a click, never a render and never a + * selection change. When the stops or their order move underneath a fetched route, the geometry + * is marked STALE and stays on screen greyed rather than being silently refetched or silently + * left looking current. A route that quietly describes a different set of stops than the one on + * screen is the failure mode worth spending a state field to prevent. + */ + const [road, setRoad] = useState< + { km: number; minutes: number; line: number[][]; key: string; attribution: string } | null + >(null); + const [roadBusy, setRoadBusy] = useState(false); + const [roadErr, setRoadErr] = useState(null); + /** + * W37-T37 — the address lookup's own progress, and it exists so the WAIT IS VISIBLE. + * + * ⛔ R3/R14: a lookup runs only when a person asks, for the records that person chose, and at + * roughly one address per second. That last part means a real run TAKES MINUTES, so hiding it + * behind a spinner would be the difference between a wait and a hang. `done` of `total` is on + * screen the whole time, and it counts records rather than requests because that is the unit the + * person selected. + */ + const [geo, setGeo] = useState<{ + busy: boolean; done: number; total: number; found: number; error: string | null; + }>({ busy: false, done: 0, total: 0, found: 0, error: null }); + const [view, setView] = useState(null); + /** + * The selection gesture in flight. Two shapes, never a mode for SELECTING + * itself: shift-drag always selects, and `lassoTool` only changes what it + * draws. That distinction is why the "no mode toggle" note over `onPointerDown` + * still holds — the thing people would never find behind a mode is selection, + * and it is still on the bare gesture. + */ + const [drag, setDrag] = useState< + | { kind: "rect"; x0: number; y0: number; x1: number; y1: number } + | { kind: "lasso"; pts: Pt[] } + | null + >(null); + const [lassoTool, setLassoTool] = useState(false); + /** + * ⚠ The svg element lives in STATE, not in a ref, and that is load-bearing. + * + * `view` starts null, so the FIRST render returns the empty state and there is + * no in the tree at all. A `useRef` would still be null when the wheel + * effect below first ran, and by the time the svg actually mounted the + * effect's deps (`localPoint`, `kMin`, `kMax`) were all unchanged — so React + * would never re-run it and the wheel listener would never be attached. Wheel + * zoom would be silently dead on the ordinary path. + * + * Holding the element in state makes attachment a consequence of MOUNTING + * rather than of a dependency happening to change, so the hazard cannot come + * back. No unit test can see this: the gate is pure TS under node, and a + * screenshot of a static page has no React in it. + */ + const [svgEl, setSvgEl] = useState(null); + /** + * W37-T35 — the tile provider, and whether tiles are working. + * + * ⚠ `tiles` starts at the OFFLINE DEFAULT rather than at null, so the basemap paints on the + * first frame instead of flashing the vector map and then replacing it. The fetch below only + * ever REPLACES a working default with a configured one. + */ + const [tiles, setTiles] = useState(TILES_DEFAULT); + /** W37-T37 — the lookup's batch size and its MEASURED seconds-per-address, both served by the + * same provider door as the tiles. Defaults match `routes_geo.py`'s own, so the control works + * before the config call lands and keeps working if it never does. */ + const [geoBatch, setGeoBatch] = useState(25); + const [geoSeconds, setGeoSeconds] = useState(3.3); + const [tileFails, setTileFails] = useState(0); + const tilesOk = tileFails < TILE_FAIL_LIMIT; + + useEffect(() => { + let live = true; + // ⛔ A FAILURE HERE IS SILENT ON PURPOSE, and it is the one place in this file where that is + // right: the answer is a STYLE CHOICE, the default is already correct and already painting, + // and a person cannot act on "the provider config endpoint is unreachable". The failures that + // must be loud are the ones that change what the data SAYS, and this changes none. + fetch("/api/v1/geo/providers", { credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + const t = j && j.tiles; + if (!live || !t || typeof t.url !== "string" || !t.url) return; + setTiles({ + url: t.url, + attribution: String(t.attribution || TILES_DEFAULT.attribution), + attributionUrl: String(t.attributionUrl || TILES_DEFAULT.attributionUrl), + maxZoom: Number(t.maxZoom) > 0 ? Number(t.maxZoom) : TILES_DEFAULT.maxZoom, + }); + const gc = j.geocode; + if (gc) { + if (Number(gc.maxBatch) > 0) setGeoBatch(Number(gc.maxBatch)); + if (Number(gc.secondsPerAddress) > 0) setGeoSeconds(Number(gc.secondsPerAddress)); + } + }) + .catch(() => {}); + return () => { + live = false; + }; + }, []); + const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null); + /** A finished drag must not also read as a click on the pin underneath — the + * kanban card's lesson (viewModes.tsx), same fix. */ + const movedRef = useRef(false); + /** Once the user has zoomed or panned, a data change must NOT yank the view + * back. Before that, refitting on new data is the helpful behaviour. */ + const touchedRef = useRef(false); + + const points = useMemo(() => { + const out: MapPoint[] = []; + for (const r of rows) { + // W37-T34 — the chosen field first, the historic `lat`/`lon` pair when no field is chosen. + const at = coordField + ? parseLatLon(r[coordField.key]) + : (() => { + const lat = coord(r.lat); + const lon = coord(r.lon); + if (lat == null || lon == null) return null; + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + return { lat, lon }; + })(); + if (!at) continue; + const { lat, lon } = at; + out.push({ + pid: r.pid, + title: String(r[field.key] ?? ""), + p: project(lon, lat), + lat, + lon, + colorKey: colorField ? String(r[colorField.key] ?? "").trim() : null, + size: sizeField ? numOrNull(r[sizeField.key]) : null, + colorVal: colorField ? r[colorField.key] : null, + sizeVal: sizeField ? r[sizeField.key] : null, + }); + } + return out; + }, [rows, field.key, coordField, colorField, sizeField]); + const noCoords = rows.length - points.length; + + // The fit is the INITIAL view, not the projection (see mapProjection.ts). + const fit = useMemo( + () => fitView(points.map((p) => p.p), VIEW_W, VIEW_H), + [points] + ); + useEffect(() => { + if (!fit) return; + if (!touchedRef.current || view == null) setView(fit); + // `view` is deliberately absent from the deps: this effect exists to seed + // and re-fit, and re-running it on every pan would fight the user for the + // camera. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fit]); + + /** + * W37-T35 — HOW FAR IN THE CAMERA MAY GO, and the answer now depends on what is under the pins. + * + * `BASEMAP_DETAIL_K` is an HONESTY limit on the VENDORED geometry: past it you are magnifying a + * generalised polygon and learning nothing. Real tiles have their own resolution, several orders + * further in, so keeping that cap while a street map is loaded would be a limit with no subject. + * + * ⚠ `zoomLimits`' second term (`fitK * 60`) also binds, and it binds FIRST: a US-wide fit sits + * near k=1.36, so 60x lands around tile zoom 10 and the user never reaches a street whatever + * `detailK` says. So the tile branch computes its ceiling from the PROVIDER's own max zoom + * rather than taking that helper's answer. `kMin` is still the helper's, unchanged. + */ + const { kMin, kMax } = useMemo(() => { + if (!fit) return { kMin: 1, kMax: 1 }; + const vector = zoomLimits(fit.k, VIEW_H, BASEMAP_DETAIL_K); + if (!tilesOk) return vector; + return { kMin: vector.kMin, kMax: Math.max(fit.k, kForTileZoom(tiles.maxZoom)) }; + }, [fit, tilesOk, tiles.maxZoom]); + + /** + * W37-T35 — WHICH TILES COVER WHAT IS ON SCREEN. + * + * ⭐ THE REASON THIS IS ~20 LINES AND NOT A LIBRARY: `mapProjection.project` is already fixed Web + * Mercator onto a 0..WORLD square, which IS the tile scheme's own coordinate system scaled by + * WORLD. So tile (x, y) at zoom z occupies exactly `[x*S, (x+1)*S] x [y*S, (y+1)*S]` with + * `S = WORLD / 2^z`, and dropping the images inside the SAME transformed group as the pins makes + * them pan and zoom in lockstep with the geography. There is no second camera to keep in sync, + * which is the failure mode a tile library would have been carrying the weight of. + * + * ⚠ Clamped to the world, never wrapped. Panning past the antimeridian shows no tiles rather + * than a second copy of the planet, and the vendored coastline never wrapped either. + */ + const tileLayer = useMemo(() => { + if (!view || !tilesOk || !tiles.url) return null; + const z = Math.max(0, Math.min(tiles.maxZoom, googleZoomForK(view.k))); + const n = Math.pow(2, z); + const S = WORLD / n; + const tl = fromScreen({ x: 0, y: 0 }, view); + const br = fromScreen({ x: VIEW_W, y: VIEW_H }, view); + const x0 = Math.max(0, Math.floor(tl.x / S)); + const x1 = Math.min(n - 1, Math.floor(br.x / S)); + const y0 = Math.max(0, Math.floor(tl.y / S)); + const y1 = Math.min(n - 1, Math.floor(br.y / S)); + if (x1 < x0 || y1 < y0) return null; + // One tile-pixel of overlap. Tile edges land on fractional device pixels at most zooms, and + // without it the seams paint as a faint grid across the whole basemap. + const over = S / 256; + const items: { key: string; x: number; y: number; s: number; href: string }[] = []; + for (let ty = y0; ty <= y1 && items.length < TILE_BUDGET; ty++) { + for (let tx = x0; tx <= x1 && items.length < TILE_BUDGET; tx++) { + items.push({ + key: `${z}/${tx}/${ty}`, + x: tx * S, + y: ty * S, + s: S + over, + href: tiles.url + .replace("{z}", String(z)) + .replace("{x}", String(tx)) + .replace("{y}", String(ty)), + }); + } + } + return { + z, + items, + bed: { x: x0 * S, y: y0 * S, w: (x1 - x0 + 1) * S, h: (y1 - y0 + 1) * S }, + }; + }, [view, tilesOk, tiles.url, tiles.maxZoom]); + + /** Colour buckets, in first-seen order so the legend is stable. */ + const colorBuckets = useMemo(() => { + if (!colorField) return null; + const order: string[] = []; + const counts = new Map(); + for (const p of points) { + const k = p.colorKey ?? ""; + if (!counts.has(k)) { + counts.set(k, 0); + order.push(k); + } + counts.set(k, (counts.get(k) ?? 0) + 1); + } + const named = order.filter((k) => k !== ""); + const swatch = new Map(); + named.forEach((k, i) => swatch.set(k, i < SERIES.length ? SERIES[i] : OVERFLOW)); + return { + order, + counts, + swatch, + overflow: Math.max(0, named.length - SERIES.length), + }; + }, [colorField, points]); + + /** The size field's observed range across MAPPED points (not the whole table: + * the legend must describe the picture actually on screen). */ + const sizeRange = useMemo(() => { + if (!sizeField) return null; + let min = Infinity; + let max = -Infinity; + let missing = 0; + for (const p of points) { + if (p.size == null) { + missing += 1; + continue; + } + min = Math.min(min, p.size); + max = Math.max(max, p.size); + } + if (!Number.isFinite(min)) return { min: 0, max: 0, missing, none: true }; + return { min, max, missing, none: false }; + }, [sizeField, points]); + + /** A coarse pointer means a phone or tablet, where Google's free URL takes 3 + * waypoints rather than 9. A media query, not user-agent sniffing. */ + const coarsePointer = useMemo( + () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches, + [] + ); + + /** Selected pins that can actually be routed, in a STABLE order (by pid) — + * a Set's iteration order must not be what decides a route. */ + const routable = useMemo( + () => + selectedPids.size < 2 + ? [] + : points + .filter((p) => selectedPids.has(p.pid) && isPlottable(p.lat, p.lon)) + .sort((a, b) => a.pid - b.pid), + [points, selectedPids] + ); + /** Selected records with no usable coordinate. Counted and shown, never + * folded silently into the stop total ([[no-unverifiable-aggregates]]). */ + const unroutable = selectedPids.size - routable.length; + + /** Selected records with no usable coordinate, BY NAME. W37-T36: the chip used to give a bare + * count, which tells you that something is missing and not which visit you are about to fail to + * make. Three names inline, the rest on the tooltip, so the bar cannot be swamped by a big + * selection while the full list stays one hover away. */ + const unroutableNames = useMemo(() => { + // ⚠ Read from `rows` and NOT from `points`: a record with no coordinate never becomes a point + // in the first place, so filtering the pin list for unplottable pins finds exactly nothing. + // That mistake would have shipped a chip whose count said 4 and whose names said none. + const ok = new Set(routable.map((p) => p.pid)); + return rows + .filter((r) => selectedPids.has(r.pid) && !ok.has(r.pid)) + .map((r) => String(r[field.key] ?? "").trim() || `#${r.pid}`); + }, [rows, field.key, selectedPids, routable]); + + const plan = useMemo(() => { + if (!routeOn || routable.length < 2) return null; + const stops: GeoStop[] = routable.map((p) => ({ lat: p.lat, lon: p.lon })); + // Default origin: the WESTERNMOST stop. Deterministic, stable while the user + // pans, and sayable out loud — unlike "whatever ended up at index 0". The + // picker below overrides it. + let start = 0; + for (let i = 1; i < routable.length; i++) + if (routable[i].lon < routable[start].lon) start = i; + if (routeStartPid != null) { + const i = routable.findIndex((p) => p.pid === routeStartPid); + if (i >= 0) start = i; + } + const { order, km } = planRoute(stops, haversineKm, { start, roundTrip }); + let ordered = order.map((i) => routable[i]); + // W37-T36 — a hand-picked order wins over the planner's, but only for the stops that are still + // selected. Anything the user has since deselected drops out; anything newly selected is + // appended in the planner's own order rather than being silently left off the route. + if (routeOrder) { + const byPid = new Map(routable.map((p) => [p.pid, p])); + const picked = routeOrder.map((pid) => byPid.get(pid)).filter(Boolean) as MapPoint[]; + const seen = new Set(picked.map((p) => p.pid)); + ordered = [...picked, ...ordered.filter((p) => !seen.has(p.pid))]; + } + return { + ordered, + km, + link: googleRouteUrl( + ordered.map((p) => ({ lat: p.lat, lon: p.lon })), + { roundTrip, coarsePointer } + ), + /** The identity of THIS set of stops in THIS order. The road answer is only about the plan + * whose key it carries; anything else on screen makes it stale rather than wrong. */ + key: ordered.map((p) => p.pid).join(",") + (roundTrip ? "|rt" : ""), + }; + }, [routeOn, routable, roundTrip, routeStartPid, coarsePointer, routeOrder]); + + const roadStale = !!road && !!plan && road.key !== plan.key; + + /** + * W37-T36 — ask the routing service for the ROAD answer. + * + * ⛔ CALLED FROM A CLICK AND FROM NOTHING ELSE. There is deliberately no effect that watches + * `plan` and refetches, because R3 forbids computing a route on a render or a selection change + * and an effect on `plan` is exactly both. The stale marker is what covers the gap: the user + * sees that the drawn route no longer matches the stops and asks again when they want to. + */ + const fetchRoad = useCallback(async () => { + if (!plan || plan.ordered.length < 2) return; + const key = plan.key; + const stops = plan.ordered.map((p) => ({ lat: p.lat, lon: p.lon })); + if (roundTrip) stops.push({ lat: plan.ordered[0].lat, lon: plan.ordered[0].lon }); + setRoadBusy(true); + setRoadErr(null); + try { + const res = await fetch("/api/v1/geo/route", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stops, roundTrip }), + }); + const body = await res.json().catch(() => null); + if (!res.ok) { + // ⭐ The SERVER's sentence, not a generic one. It knows whether the service was down or + // whether no road connects these stops, and those need different actions from the user. + setRoadErr( + (body && body.detail && body.detail.error && body.detail.error.message) || + "The routing service did not answer." + ); + setRoad(null); + return; + } + setRoad({ + km: Number(body.km) || 0, + minutes: Number(body.minutes) || 0, + line: Array.isArray(body.line) ? body.line : [], + key, + attribution: String(body.attribution || ""), + }); + } catch { + setRoadErr("The routing service could not be reached."); + setRoad(null); + } finally { + setRoadBusy(false); + } + }, [plan, roundTrip]); + + /** + * ⛔ THE ONE-SHOT THAT MAKES "PLAN ROUTE" A SINGLE CLICK WITHOUT BECOMING AN AUTO-FETCH. + * + * `plan` is null until `routeOn` flips, so the click handler cannot call `fetchRoad` directly: + * its closure still has the null plan. A ref set by the click and consumed here fires the + * request exactly once, on the render where the plan first exists. + * + * ⚠ This is deliberately NOT `useEffect(..., [plan])` doing the fetch. The effect runs on every + * plan change; only the ones a CLICK armed do anything. That distinction is R3: a selection + * change recomputes the plan and must not spend a request. + */ + const askRoadRef = useRef(false); + useEffect(() => { + if (!askRoadRef.current || !plan) return; + askRoadRef.current = false; + void fetchRoad(); + }, [plan, fetchRoad]); + + /** The drawn road, projected once per answer. Straight-line fallback stays in `plan`, so a map + * that never got a road answer still shows the sequence it worked out for itself. */ + const roadPath = useMemo(() => { + if (!road || road.line.length < 2) return null; + return road.line + .map((c) => { + const p = project(c[0], c[1]); + return `${p.x.toFixed(2)},${p.y.toFixed(2)}`; + }) + .join(" "); + }, [road]); + + /** + * W37-T37 — SELECTED RECORDS THAT COULD GAIN A LOCATION. + * + * ⛔ SELECTED, and that word is the whole guard. There is no expression here that means "every + * record without a pin": R3 forbids geocoding a table, and the way to forbid it is to have no + * code that can express it. Somebody chose these rows on this screen. + */ + const geocodable = useMemo(() => { + const addrKey = coordField?.geocode?.addressField; + if (!addrKey || !coordField || selectedPids.size === 0) return []; + const placed = new Set(points.map((p) => p.pid)); + return rows.filter((r) => selectedPids.has(r.pid) && !placed.has(r.pid)); + }, [rows, coordField, selectedPids, points]); + + /** The estimate, in the words a person plans with. ⚠ Built from the SERVER's measured + * seconds-per-address, never from the published rate limit: the limit is a floor on politeness + * and was measured at a third of the real latency, so quoting it promises a minute and takes + * three. */ + const geoEstimate = useMemo(() => { + const secs = Math.round(geocodable.length * geoSeconds); + if (secs < 90) return `about ${Math.max(5, secs)} seconds`; + const mins = Math.round(secs / 60); + return `about ${mins} minute${mins === 1 ? "" : "s"}`; + }, [geocodable.length, geoSeconds]); + + const runGeocode = useCallback(async () => { + const addrKey = coordField?.geocode?.addressField; + if (!addrKey || !onGeocoded || geocodable.length === 0) return; + setGeo({ busy: true, done: 0, total: geocodable.length, found: 0, error: null }); + let found = 0; + try { + // ⛔ BATCHED BY THE SERVER'S OWN `maxBatch`, and the loop lives HERE rather than there on + // purpose: a single request that sat on the connection for eight minutes would be killed by + // every proxy between us, and would report nothing until it finished. Small calls mean the + // count on screen moves, and an interrupted run keeps the records it already placed. + for (let i = 0; i < geocodable.length; i += geoBatch) { + const slice = geocodable.slice(i, i + geoBatch); + const res = await fetch("/api/v1/geo/geocode", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + records: slice.map((r) => ({ key: r.pid, row: r })), + config: { addressField: addrKey, country: coordField?.geocode?.country }, + }), + }); + const body = await res.json().catch(() => null); + if (!res.ok) { + setGeo((g) => ({ + ...g, busy: false, + error: (body && body.detail && body.detail.error && body.detail.error.message) || + "The address lookup service did not answer.", + })); + return; + } + const writes = (body.results || []) + .filter((r: { found?: boolean }) => r && r.found) + .map((r: { key: number; lat: number; lon: number }) => ({ + pid: Number(r.key), + // The cell shape contract C7 fixes, at the precision `googleMapsUrl` already uses so + // the cell, the pin and the hand-off link agree to the digit. + value: `${r.lat.toFixed(6)},${r.lon.toFixed(6)}`, + })); + found += writes.length; + // ⭐ HANDED OVER PER BATCH, not at the end. A run over 300 records is minutes long, and + // a person who navigates away mid-run should keep every coordinate already paid for. + if (writes.length) onGeocoded(writes); + setGeo((g) => ({ ...g, done: Math.min(g.total, i + slice.length), found })); + } + setGeo((g) => ({ ...g, busy: false })); + } catch { + setGeo((g) => ({ ...g, busy: false, error: "The address lookup could not be reached." })); + } + }, [coordField, onGeocoded, geocodable, geoBatch]); + + const moveStop = useCallback( + (pid: number, delta: number) => { + if (!plan) return; + const ids = plan.ordered.map((p) => p.pid); + const at = ids.indexOf(pid); + const to = at + delta; + if (at < 0 || to < 0 || to >= ids.length) return; + const next = [...ids]; + next.splice(to, 0, next.splice(at, 1)[0]); + setRouteOrder(next); + }, + [plan] + ); + + const paint = useCallback( + (p: MapPoint) => { + if (!colorBuckets) return DEFAULT_PIN; + const k = p.colorKey ?? ""; + if (k === "") return OVERFLOW; + return colorBuckets.swatch.get(k) ?? OVERFLOW; + }, + [colorBuckets] + ); + + const radius = useCallback( + (p: MapPoint) => { + if (!sizeField || !sizeRange || sizeRange.none) return R_PLAIN; + return bubbleRadius(p.size, sizeRange.min, sizeRange.max, R_MIN, R_MAX, R_NULL); + }, + [sizeField, sizeRange] + ); + + /** + * Client coords -> the svg's own user-space coords. + * + * ⚠ The maths lives in `clientToUser` (mapProjection) rather than here, and + * that placement is the fix's other half. Wave 8 wrote the conversion inline + * in this .tsx — where the gate's negative controls, every one of which + * mutates the compiled `mapProjection.js`, could not reach it. So the box- + * select legs passed on coordinates handed to them ALREADY in user space, and + * a broken conversion shipped green for four waves. See `clientToUser`. + */ + const localPoint = useCallback( + (clientX: number, clientY: number): Pt => { + if (!svgEl) return { x: 0, y: 0 }; + return clientToUser(clientX, clientY, svgEl.getBoundingClientRect(), VIEW_W, VIEW_H); + }, + [svgEl] + ); + + /** + * ⚠ Wheel zoom MUST be a native, non-passive listener. React registers + * `wheel` PASSIVELY at the root, so `e.preventDefault()` inside an `onWheel` + * prop is a silent no-op: the page scrolls out from under the map while you + * zoom, which reads as "the zoom is broken". Nothing in a screenshot shows + * this, and no assertion on the rendered DOM can see it either. + * + * The zoom maths itself is `zoomAt` — the function the gate already proves + * holds the point under the cursor still. Wave 8 re-derived that formula + * inline here, so the tested copy and the shipped copy were two copies. + */ + useEffect(() => { + if (!svgEl) return; + const onWheelNative = (e: WheelEvent) => { + e.preventDefault(); + const { x, y } = localPoint(e.clientX, e.clientY); + touchedRef.current = true; + setView((v) => (v ? zoomAt(v, Math.exp(-e.deltaY * 0.0016), x, y, kMin, kMax) : v)); + }; + svgEl.addEventListener("wheel", onWheelNative, { passive: false }); + return () => svgEl.removeEventListener("wheel", onWheelNative); + }, [svgEl, localPoint, kMin, kMax]); + + /** Zoom about the viewport centre — the button and keyboard gesture, where + * there is no cursor to hold still. */ + const zoomBy = useCallback( + (factor: number) => { + touchedRef.current = true; + setView((v) => (v ? zoomAt(v, factor, VIEW_W / 2, VIEW_H / 2, kMin, kMax) : v)); + }, + [kMin, kMax] + ); + + const doFit = useCallback(() => { + touchedRef.current = false; + setView(fit); + }, [fit]); + + const panBy = useCallback((dx: number, dy: number) => { + touchedRef.current = true; + setView((v) => (v ? { ...v, tx: v.tx + dx, ty: v.ty + dy } : v)); + }, []); + + /** The camera had NO keyboard path at all before wave 9 — scroll wheel only, + * which is unusable without a mouse and unreachable for anyone driving the + * page from the keyboard. */ + const onFrameKeyDown = useCallback( + (e: ReactKeyboardEvent) => { + const step = e.shiftKey ? 160 : 60; + switch (e.key) { + case "ArrowLeft": panBy(step, 0); break; + case "ArrowRight": panBy(-step, 0); break; + case "ArrowUp": panBy(0, step); break; + case "ArrowDown": panBy(0, -step); break; + case "+": case "=": zoomBy(1.6); break; + case "-": case "_": zoomBy(1 / 1.6); break; + case "0": doFit(); break; + // Esc abandons a marquee mid-drag. It falls THROUGH when there is no + // drag, so it keeps closing whatever the host has open. + case "Escape": if (!drag) return; setDrag(null); break; + default: return; + } + e.preventDefault(); + }, + [panBy, zoomBy, doFit, drag] + ); + + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button !== 0 || !view) return; + const { x, y } = localPoint(e.clientX, e.clientY); + movedRef.current = false; + e.currentTarget.setPointerCapture(e.pointerId); + // Shift (or Ctrl/Cmd) turns the drag into a SELECTION rectangle; a plain + // drag pans. Both gestures are on the same button because a map that + // needs a mode toggle to select is a map people never select on. + if (e.shiftKey || e.ctrlKey || e.metaKey) + setDrag(lassoTool ? { kind: "lasso", pts: [{ x, y }] } : { kind: "rect", x0: x, y0: y, x1: x, y1: y }); + else panRef.current = { x, y, tx: view.tx, ty: view.ty }; + }, + [view, localPoint, lassoTool] + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent) => { + const { x, y } = localPoint(e.clientX, e.clientY); + if (drag) { + // ⚠ W37-T30: this used to set `movedRef` UNCONDITIONALLY, and that is what swallowed a + // shift-CLICK. Holding shift starts a marquee on pointerdown, so a shift-click is a + // marquee that never travels; one stray pointermove then marked the gesture "moved" and + // the pin's onClick returned early. Measured on the render probe: the click fired, the + // handler ran, and the selection did not change. + // The threshold is the pan branch's own rule, applied to the same question, and a marquee + // that really was drawn clears it long before `drawn` (> 3 px) lets it select anything. + const from = drag.kind === "rect" ? { x: drag.x0, y: drag.y0 } : drag.pts[0]; + if (Math.abs(x - from.x) + Math.abs(y - from.y) > 2) movedRef.current = true; + setDrag((d) => { + if (!d) return d; + if (d.kind === "rect") return { ...d, x1: x, y1: y }; + // `pointermove` fires far faster than a loop needs vertices, so a slow + // hand tracing 200 px would otherwise build a thousand-point polygon + // that every pin is then tested against on every frame. Drop a sample + // that has not travelled ~2 px — the viewBox is fixed relative to the + // screen, so this threshold means the same thing at every zoom. + const last = d.pts[d.pts.length - 1]; + if (Math.abs(x - last.x) + Math.abs(y - last.y) < 2) return d; + return { kind: "lasso", pts: [...d.pts, { x, y }] }; + }); + return; + } + const pan = panRef.current; + if (!pan) return; + if (Math.abs(x - pan.x) + Math.abs(y - pan.y) > 2) movedRef.current = true; + touchedRef.current = true; + setView((v) => (v ? { ...v, tx: pan.tx + (x - pan.x), ty: pan.ty + (y - pan.y) } : v)); + }, + [drag, localPoint] + ); + + /** + * D-135 (W37-T30) — a pin could be OPENED and never SELECTED. + * + * ⭐ THE MAP WAS THE LAST VIEW STILL ON THE OLD INTERACTION MODEL. Owner item 19 moved the whole + * product off click-to-open: in the grid "the single click that used to open a record now + * highlights it" and opening moved to a deliberate affordance (CustomerGrid.tsx, the hover-only + * Expand). The map kept firing `onOpen` on a bare click, so the one view built around choosing a + * SET of records was the one view where choosing was impossible, and the "N selected" bar, + * "Add to cohort" and the route planner all sat behind a gesture the map did not have. + * + * So: a plain click selects, a modifier click TOGGLES that pin in or out of the set, and opening + * escalates to a double click. Toggle rather than plain accumulate is done here rather than + * asked for from the host, because `onSelectPids` offers "replace" and "add" only, and a second + * shift-click that cannot undo the first is a trap on a map where pins overlap. + */ + const pinSelect = useCallback( + (pid: number, additive: boolean) => { + if (!additive) { + onSelectPids([pid], "replace"); + return; + } + if (selectedPids.has(pid)) { + onSelectPids([...selectedPids].filter((x) => x !== pid), "replace"); + return; + } + onSelectPids([pid], "add"); + }, + [onSelectPids, selectedPids] + ); + + const onPointerUp = useCallback( + (e: ReactPointerEvent) => { + if (drag && view) { + // A gesture smaller than a few px is a mis-click, not a selection — + // clearing the user's set on a stray shift-click would be its own bug. + // ONE rule for both shapes, measured on the lasso's own bounds; a loop + // also needs three points before it is a polygon at all. + const b = + drag.kind === "rect" + ? normRect(drag.x0, drag.y0, drag.x1, drag.y1) + : pathBounds(drag.pts); + const drawn = b.x1 - b.x0 > 3 && b.y1 - b.y0 > 3 && (drag.kind === "rect" || drag.pts.length >= 3); + if (drawn) { + const hits: number[] = []; + for (const p of points) { + const s = toScreen(p.p, view); + const held = + drag.kind === "rect" + ? s.x >= b.x0 && s.x <= b.x1 && s.y >= b.y0 && s.y <= b.y1 + : pointInPolygon(s, drag.pts); + if (held) hits.push(p.pid); + } + onSelectPids(hits, e.altKey ? "add" : "replace"); + } + setDrag(null); + } + panRef.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) + e.currentTarget.releasePointerCapture(e.pointerId); + }, + [drag, view, points, onSelectPids] + ); + + /** + * ⭐⭐ W37-T37 — THE LOOKUP CONTROL IS BUILT ONCE AND RENDERED IN TWO PLACES, and the second + * place is the whole reason this is a variable rather than inline JSX. + * + * ⛔ IT SHIPPED UNREACHABLE THE FIRST TIME AND ONLY LOOKING FOUND IT. The control lived in the + * map bar; the bar lives after the `!view || !fit` early return below; and a database whose + * records have addresses and no coordinates has NO POINTS, so it takes that return. The feature + * was therefore invisible in precisely the situation it exists for, and visible only once you no + * longer needed it. Every gate was green: the door scan found the control, `tsc` was clean, and + * the component rendered. [[reachable-is-not-the-same-as-built]] is this exact shape. + */ + const geoControl = coordField?.geocode?.addressField && onGeocoded ? ( + + {geo.busy ? ( + <> + + Looking up addresses: {geo.done.toLocaleString()} of{" "} + {geo.total.toLocaleString()} + + {/* ⚠ The rate limit is a LICENCE TERM here, not a performance excuse, so the surface + says why it is slow instead of letting it read as a hang. */} + + about one address a second, which is what the free map service allows + + + ) : geocodable.length > 0 ? ( + + ) : null} + {!geo.busy && geo.total > 0 && ( + + Located {geo.found.toLocaleString()} of {geo.total.toLocaleString()} + {geo.found < geo.total && ". The rest had no address on file or could not be matched."} + + )} + {geo.error && {geo.error}} + + ) : null; + + if (!view || !fit) { + return ( +
+ No records with a location to map yet. + {noCoords > 0 && + ` ${noCoords.toLocaleString()} matching record${noCoords === 1 ? "" : "s"} have no location.`} + {/* The way OUT of the empty state, offered from inside it. Selecting records and looking + up their addresses is the one action that turns this screen into a map. */} + {geoControl &&
{geoControl}
} + {geoControl && geocodable.length === 0 && selectedPids.size === 0 && ( +
+ Select the records you want placed, then look up their addresses. +
+ )} +
+ ); + } + + const hovered = hoverPid != null ? points.find((p) => p.pid === hoverPid) : undefined; + const rect = drag?.kind === "rect" ? normRect(drag.x0, drag.y0, drag.x1, drag.y1) : null; + const lassoPts = drag?.kind === "lasso" && drag.pts.length > 1 ? drag.pts : null; + const tf = `translate(${view.tx.toFixed(2)} ${view.ty.toFixed(2)}) scale(${view.k.toFixed(6)})`; + // Strokes live in the transformed group, so they are pre-divided by the zoom. + // ⚠ This is the map's ONE stroke mechanism — see the note over `hairline` in + // mapProjection.ts. No `.cg-map*` rule may add `vector-effect: + // non-scaling-stroke` on top; that double-cancel is the wave-9 blur bug and + // `scalingConflicts()` gates the stylesheet against it. + const hair = (w: number) => hairline(w, view.k); + const gratOpacity = graticuleOpacity(view.k); + // I18 — the Google hand-off, and W37-T35 changed what it is FOR. + // It used to be the answer to "I need to see the actual street", because the + // vendored basemap stopped at roughly metro scale. Real tiles go to street + // level themselves, so this link is no longer an apology for a missing + // capability: it is a hand-off to a tool that does routing, transit and + // street imagery, which this view does not. + // ⚠ The old note here read "Nothing is fetched and no coordinate leaves the + // page unless the user deliberately clicks". The second half still holds and + // is the part that matters: a tile request carries a z/x/y grid square and + // never a record. The first half does not, and is corrected in the header. + const centre = unproject(fromScreen({ x: VIEW_W / 2, y: VIEW_H / 2 }, view)); + const areaUrl = googleMapsUrl(centre.lat, centre.lon, googleZoomForK(view.k)); + // A single selected pin gets its own exact hand-off. Selection is persistent, + // unlike hover — and the hover card must stay pointer-events:none, so a link + // could never live in it without becoming a click trap. + const solo = selectedPids.size === 1 ? points.find((p) => selectedPids.has(p.pid)) : undefined; + const sizeLegend = sizeRange && !sizeRange.none && sizeRange.max > sizeRange.min + ? [sizeRange.min, (sizeRange.min + sizeRange.max) / 2, sizeRange.max] + : null; + + return ( +
+
+ {/* The count is "N of M", never a bare N: a pin can only be drawn for a + row the host geocoded, and a lone "1,402" silently redefines the + toolbar's 1,550 ([[no-unverifiable-aggregates]]). */} + + {points.length.toLocaleString()} of{" "} + {rows.length.toLocaleString()} mapped · one pin per record that has a location + + {noCoords > 0 && ( + + {noCoords.toLocaleString()} matching record{noCoords === 1 ? " has" : "s have"} no location + + )} + {/* One selected pin -> the exact geocode, handed off to Google. By + lat/lon and never by name: a name search can resolve somewhere else, + and then this link and our pin disagree about where a customer is. + rel="noopener noreferrer" strips the Referer, so Google never learns + which tenant or deployment the click came from. */} + {solo && ( + + + {solo.title || "Selected pin"} in Google Maps + + + Directions + + + )} + {/* I18-R — the route planner. Appears only with a multi-pin selection, so + it is mutually exclusive with the single-pin links above and the bar + never carries both. */} + {selectedPids.size >= 2 && ( + + {!routeOn ? ( + + ) : ( + plan && ( + <> + + {plan.ordered.length.toLocaleString()} stops ·{" "} + {/* ⭐ W37-T36 — THE ROAD ANSWER REPLACES THE STRAIGHT LINE WHEN WE HAVE ONE, + and the old caveat goes with it. The note underneath used to read + "straight-line, not driving distance", which was the honest thing to say + about a sum of great circles and is simply wrong over a road route with a + duration. Which claim is on screen depends on which answer we hold, so + both the number and its caveat move together. */} + {road && !roadStale ? ( + <> + {road.km.toLocaleString()} km · {formatDuration(road.minutes)} + by road + + ) : ( + <> + {Math.round(plan.km).toLocaleString()} km + + {roadBusy + ? " working out the driving route" + : roadStale + ? " straight line. The stops moved, so the driving route below is out of date." + : " straight line, not driving distance"} + + + )} + + {/* The re-ask. Present exactly when the drawn route no longer describes the + stops on screen, which is the only moment a person needs it. */} + {(roadStale || (!road && !roadBusy)) && ( + + )} + {roadErr && {roadErr}} + + + {plan.link && ( + + Open route in Google Maps + {/* The free URL takes 9 waypoints on desktop and 3 on a + phone. When the route is longer, SAY which part rides. */} + {plan.link.used < plan.ordered.length && + ` (first ${plan.link.used} of ${plan.ordered.length})`} + + )} + {/* ⭐ W37-T36 — THE ORDER IS CHANGEABLE, and it has to be more than the Start + picker. The planner optimises for distance; a person routing a day of visits + is working around an appointment at eleven, and no solver knows that. So the + numbered stops become a list with move controls, and "Re-optimise" gives the + arithmetic its turn back. */} + + {plan.ordered.map((p, i) => ( + + {i + 1} + {p.title || `#${p.pid}`} + + + + ))} + + {routeOrder && ( + + )} + + + ) + )} + {/* ⛔ W37-T36 — NAMED, NOT COUNTED. "4 selected records have no location" tells you + something is missing and not WHICH visit you are about to fail to make, and the + whole point of the chip is that the exclusion is not silent. Three inline so a big + selection cannot swamp the bar, the rest on the tooltip. */} + {unroutable > 0 && ( + + No location, so not routed: {unroutableNames.slice(0, 3).join(", ")} + {unroutableNames.length > 3 && + ` and ${(unroutableNames.length - 3).toLocaleString()} more`} + + )} + + )} + {/* ⭐⭐ W37-T37 (R4 / R3 / R14) — LOOK UP THE ADDRESSES OF THE RECORDS YOU CHOSE. + It lives HERE, on the map, because this is the surface where the absence is felt: you + are looking at a picture with a chip saying N records have no location, and the answer + is one control away instead of in a field editor two menus deep. + ⛔ THREE THINGS MAKE IT R3-SAFE AND NONE OF THEM IS A COMMENT. It renders only when a + geocode column EXISTS, only when records are SELECTED, and it walks `geocodable`, which + is built from the selection. There is no expression anywhere in this file that means + "every record without a pin", so a whole-table run is not a thing a user can reach or a + future edit can accidentally enable. */} + {geoControl} + {/* Every gesture the map has, named. `alt` (add to the selection rather + than replace it, onPointerUp) shipped in wave 8 and was disclosed + NOWHERE, so "select these as well" was a feature only the source + knew about. It needs shift too — alt alone still pans. */} + + Scroll to zoom · drag to pan · {lassoTool ? "shift-drag to lasso" : "shift-drag to select"}{" "} + · shift-alt-drag to extend the selection · click a pin to select it · shift-click to add + or remove one · double-click to open the record + +
+
+ + + + {/* --- W37-T35: the raster basemap, UNDER everything, inside the same transform. + ⛔ AND THE VENDORED GEOMETRY BELOW IS HIDDEN WHILE IT PAINTS. Drawing a + generalised coastline on top of a real one does not add detail, it adds a SECOND + coastline a few pixels off the first, plus a graticule fighting the tiles' own + grid. Two basemaps disagreeing about where Florida is looks like a rendering bug + to everyone except the person who knows there are two of them. --- */} + {tileLayer && ( + + )} + {/* Graticule every 10 degrees. It earns its place zoomed OUT, where + it is the only thing giving scale; once the real state borders + arrive it would be a second line system fighting the first, so it + fades away before they take over. Skipped entirely at zero + opacity — ~36 invisible lines are still 36 nodes to lay out. */} + {!tileLayer && gratOpacity > 0.01 && ( + + {Array.from({ length: 17 }, (_, i) => { + const y = project(0, -80 + i * 10).y; + return ; + })} + {Array.from({ length: 19 }, (_, i) => { + const x = project(-180 + i * 20, 0).x; + return ; + })} + + )} + {/* One path, every US state ring. Filled AND stroked, so interior + state borders come free from the same geometry — no second pass + and no chance of the borders disagreeing with the coastline. + 0.9 rather than wave 8's 1.1: that weight was tuned for a single + lone coastline, and it reads heavy once ~169 rings share it. */} + {!tileLayer && ( + <> + + {LAKE_PATHS.map((d, i) => ( + + ))} + + )} + {/* The planned path, UNDER the pins so it never hides a stop. Inside + the zoomed group, so it pans and scales with the geography; the + stroke is pre-divided by k like every other line here. */} + {/* ⭐ W37-T36 — THE ROAD, drawn from the service's own geometry. Two claims about the + same stops need two looks: this is solid and follows streets, the dashed line below + is the straight-line plan and stays provisional. A white halo underneath, because a + navy line over a raster basemap full of navy roads is invisible exactly where + somebody is trying to read it. + ⚠ Greyed rather than removed when stale: it still describes a real route, just not + the one currently listed, and hiding it would lose the picture the moment somebody + nudges one stop. */} + {roadPath && ( + + + + + )} + {plan && plan.ordered.length > 1 && ( + `${p.p.x},${p.p.y}`).join(" ") + + (roundTrip ? ` ${plan.ordered[0].p.x},${plan.ordered[0].p.y}` : "") + } + /> + )} + {points.map((p) => { + const c = paint(p); + const on = selectedPids.has(p.pid); + const r = radius(p) / view.k; + return ( + setHoverPid(p.pid)} + onMouseLeave={() => setHoverPid((h) => (h === p.pid ? null : h))} + onFocus={() => setHoverPid(p.pid)} + onBlur={() => setHoverPid((h) => (h === p.pid ? null : h))} + onClick={(e) => { + if (movedRef.current) return; // a finished pan/box is not a click + pinSelect(p.pid, e.shiftKey || e.ctrlKey || e.metaKey); + }} + // Opening escalates to a double click now that a single one selects. The two + // clicks React reports before this one are both `pinSelect` on the SAME pid, so + // the selection lands where the user pointed either way. + onDoubleClick={(e) => { + if (movedRef.current) return; + e.preventDefault(); + onOpen(p.pid); + }} + onKeyDown={(e) => { + // ⚠ The keyboard keeps a DIRECT open on Enter rather than mirroring the double + // click: focus already says which pin, and there is no keyboard gesture that + // means "twice, quickly". Space is the selection half. + if (e.key === "Enter") { + e.preventDefault(); + onOpen(p.pid); + return; + } + if (e.key !== " ") return; + e.preventDefault(); + pinSelect(p.pid, e.shiftKey || e.ctrlKey || e.metaKey); + }} + > + {p.title} + + ); + })} + + {/* I18 — the hover card. Wave 8 painted the title alone; a map whose + pins carry a colour and a size encoding should say what they ARE. + ⚠ Every value goes through `formatDisplay`, the SAME formatter the + grid cells use, so a currency, a percentage or a date can never + read one way on the map and another way in the table. + Drawn in SVG screen space rather than as an HTML overlay: the + viewBox letterboxes under preserveAspectRatio, so an HTML card + would need the rendered scale re-derived, and this needs no + conversion at all. pointer-events stay off — a card that can + swallow the next click is a scar this codebase already carries. */} + {/* Stop numbers, in SCREEN space so they stay legible at every zoom. + pointer-events off: a badge sitting over a pin must not steal the + click that opens the record — re-rooting the route is the "Start" + picker's job, where it is visible and reversible. */} + {plan && + plan.ordered.map((p, i) => { + const s = toScreen(p.p, view); + return ( + + + {i + 1} + + ); + })} + {hovered && (() => { + const s = toScreen(hovered.p, view); + const lines: string[] = []; + if (colorField) + lines.push(`${colorField.label}: ${formatDisplay(colorField, hovered.colorVal) || "(blank)"}`); + if (sizeField) + lines.push(`${sizeField.label}: ${formatDisplay(sizeField, hovered.sizeVal) || "(blank)"}`); + const title = hovered.title || "(untitled)"; + // Inter's average advance at 11.5px. An estimate, deliberately + // generous: too wide is a slightly roomy card, too narrow is text + // spilling past its own background. + const w = Math.max(title.length, ...lines.map((l) => l.length)) * 6.2 + 20; + const h = 21 + lines.length * 14; + const b = cardBox(s.x, s.y, w, h, VIEW_W, VIEW_H); + return ( + + ); + })()} + {rect && ( + + )} + {/* The loop paints as a POLYGON, so the shape on screen is the shape + the hit test uses — an open would draw a mouth the + selection does not have. Same class as the rectangle: one marquee + look, and no new stylesheet rule (index.css is not this fence). */} + {lassoPts && ( + `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} + /> + )} + + + {/* --- I18 — the camera controls. Zoom in / zoom out / fit to data, the + affordance every map has, replacing wave 8's link-button-in-a-text-bar. + OUTSIDE the deliberately: a mousedown on a button inside it would + begin a pan. Icons are SVG strokes — no emoji, no glyph font. The zoom + buttons DISABLE at the limits, which is also how the honesty cap on + zoom-in makes itself visible instead of just feeling stuck. --- */} +
+ + + + {/* The selection SHAPE. Not a mode for selecting — shift-drag selects + either way — so the note over `onPointerDown` still stands. The + icon shows the shape you will get, which is the only "on" state + available: `.cg-map-ctl-b` has no pressed style and index.css + belongs to another session this wave, so a pressed look would have + been a control that cannot show its own state. */} + + {/* The street-level hand-off, aimed at whatever is on screen right + now. This is what makes the zoom cap honest rather than merely + restrictive: the map stops where its geometry stops, and points at + something that does not. */} + + + +
+ + {/* --- W37-T35 — THE CREDIT LINE. ⛔ A LICENCE TERM, NOT DECORATION. + OpenStreetMap's terms require visible attribution wherever its tiles appear, and every + provider that could replace it says the same. Both the text and the link come from the + server's provider config, so a swap carries its own credit and cannot leave the + previous vendor's name on screen. It is rendered only while tiles are actually + painting: crediting a provider whose tiles failed to load would be a false statement + about what you are looking at. --- */} + {tileLayer && tiles.attribution && ( + + )} + {/* --- legends (I3/I5). Floated over the map, never in the flow. --- */} + {(colorBuckets || sizeLegend) && ( +
+ {colorField && colorBuckets && ( +
+
{colorField.label}
+ {colorBuckets.order.slice(0, SERIES.length + 1).map((k) => { + const c = k === "" ? OVERFLOW : colorBuckets.swatch.get(k) ?? OVERFLOW; + return ( +
+ + {k === "" ? "(blank)" : k} + + {(colorBuckets.counts.get(k) ?? 0).toLocaleString()} + +
+ ); + })} + {colorBuckets.overflow > 0 && ( +
+ {colorBuckets.overflow.toLocaleString()} further value + {colorBuckets.overflow === 1 ? " is" : "s are"} drawn grey. The palette + holds {SERIES.length} colours, and reusing one would make two values look + like the same value. +
+ )} +
+ )} + {sizeField && sizeRange && ( +
+
{sizeField.label}
+ {sizeLegend ? ( +
+ {sizeLegend.map((v, i) => { + const r = bubbleRadius(v, sizeRange.min, sizeRange.max, R_MIN, R_MAX, R_NULL); + return ( + + + + + {formatDisplay(sizeField, v)} + + ); + })} +
+ ) : ( +
+ Every mapped record has the same {sizeField.label.toLowerCase()}, so the + bubbles cannot differ in size. +
+ )} + {sizeRange.missing > 0 && ( +
+ {sizeRange.missing.toLocaleString()} mapped record + {sizeRange.missing === 1 ? " has" : "s have"} no value, so they are drawn + at the smallest dot and never removed from the map. +
+ )} +
+ )} +
+ )} +
+
+ ); +}