| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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" }, |
| ]; |
| |
| |
| const OVERFLOW = { fill: "#D7DBE3", line: "#8A909C" }; |
| const DEFAULT_PIN = { fill: "#9DBFF2", line: "#4F6079" }; |
|
|
| interface MapPoint { |
| pid: number; |
| title: string; |
| p: Pt; |
| |
| |
| |
| lat: number; |
| lon: number; |
| colorKey: string | null; |
| size: number | null; |
| |
| |
| |
| |
| 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<number>; |
| onSelectPids: (pids: number[], mode: "replace" | "add") => void; |
| onOpen: (pid: number) => void; |
| }) { |
| const [hoverPid, setHoverPid] = useState<number | null>(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<number | null>(null); |
| const [view, setView] = useState<View | null>(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 <svg> 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<SVGSVGElement | null>(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<string, number>(); |
| 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<string, { fill: string; line: string }>(); |
| 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<HTMLDivElement>) => { |
| 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<SVGSVGElement>) => { |
| 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<SVGSVGElement>) => { |
| 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<SVGSVGElement>) => { |
| 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 ( |
| <div className="cg-mode-empty"> |
| No records with a location to map yet. |
| {noCoords > 0 && |
| ` ${noCoords.toLocaleString()} matching record${noCoords === 1 ? "" : "s"} have no location.`} |
| </div> |
| ); |
| } |
| |
| 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 ( |
| <div className="cg-mapview"> |
| <div className="cg-map-bar"> |
| {/* 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]]). */} |
| <span className="cg-cal-note cg-map-count"> |
| <strong>{points.length.toLocaleString()}</strong> of{" "} |
| {rows.length.toLocaleString()} mapped · pinned at each customer's address |
| </span> |
| {noCoords > 0 && ( |
| <span |
| className="cg-cal-nodate" |
| title={`${noCoords.toLocaleString()} matching record${noCoords === 1 ? "" : "s"} have no geocoded location and are not on the map. They remain in the Grid and List views.`} |
| > |
| {noCoords.toLocaleString()} matching record{noCoords === 1 ? " has" : "s have"} no location |
| </span> |
| )} |
| {/* 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 && ( |
| <span className="cg-map-go"> |
| <a |
| href={googleMapsUrl(solo.lat, solo.lon)} |
| target="_blank" |
| rel="noopener noreferrer" |
| title={`Open ${solo.title} in Google Maps at ${solo.lat.toFixed(5)}, ${solo.lon.toFixed(5)} (new tab)`} |
| > |
| {solo.title || "Selected pin"} in Google Maps |
| </a> |
| <a |
| href={googleDirectionsUrl(solo.lat, solo.lon)} |
| target="_blank" |
| rel="noopener noreferrer" |
| title={`Directions to ${solo.title} (new tab)`} |
| > |
| Directions |
| </a> |
| </span> |
| )} |
| {/* 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 && ( |
| <span className="cg-map-route"> |
| {!routeOn ? ( |
| <button |
| type="button" |
| className="cg-map-route-go" |
| onClick={() => setRouteOn(true)} |
| disabled={routable.length < 2} |
| title={ |
| routable.length < 2 |
| ? "At least two selected records need a location to plan a route" |
| : "Order these stops into a route" |
| } |
| > |
| Plan route ({routable.length.toLocaleString()} stops) |
| </button> |
| ) : ( |
| plan && ( |
| <> |
| <span className="cg-map-route-sum"> |
| <strong>{plan.ordered.length.toLocaleString()}</strong> 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. */} |
| <span className="cg-map-route-note"> straight-line, not driving distance</span> |
| </span> |
| <label className="cg-map-route-opt"> |
| Start |
| {/* ⚠ `value` is always set — a <select> without one renders its |
| FIRST option regardless of state. It mirrors the ACTUAL |
| origin, so the westernmost default shows itself too. */} |
| <select |
| className="cg-map-route-start" |
| value={String(plan.ordered[0].pid)} |
| onChange={(e) => setRouteStartPid(Number(e.target.value))} |
| > |
| {routable.map((p) => ( |
| <option key={p.pid} value={p.pid}> |
| {p.title || `#${p.pid}`} |
| </option> |
| ))} |
| </select> |
| </label> |
| <label className="cg-map-route-opt"> |
| <input |
| type="checkbox" |
| checked={roundTrip} |
| onChange={(e) => setRoundTrip(e.target.checked)} |
| /> |
| Return to start |
| </label> |
| {plan.link && ( |
| <a |
| href={plan.link.url} |
| target="_blank" |
| rel="noopener noreferrer" |
| title="Open this route in Google Maps for driving directions (new tab)" |
| > |
| 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})`} |
| </a> |
| )} |
| <button |
| type="button" |
| className="cg-map-route-go" |
| onClick={() => { |
| setRouteOn(false); |
| setRouteStartPid(null); |
| }} |
| > |
| Clear |
| </button> |
| </> |
| ) |
| )} |
| {unroutable > 0 && ( |
| <span className="cg-cal-nodate"> |
| {unroutable.toLocaleString()} selected record{unroutable === 1 ? " has" : "s have"} no |
| location and cannot be routed |
| </span> |
| )} |
| </span> |
| )} |
| {/* 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. */} |
| <span className="cg-map-hint"> |
| Scroll to zoom · drag to pan · {lassoTool ? "shift-drag to lasso" : "shift-drag to select"}{" "} |
| · shift-alt-drag to extend the selection |
| </span> |
| </div> |
| <div |
| className="cg-map-frame" |
| tabIndex={0} |
| role="group" |
| aria-label="Map canvas. Arrow keys pan, plus and minus zoom, zero fits to data." |
| onKeyDown={onFrameKeyDown} |
| > |
| <svg |
| ref={setSvgEl} |
| viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} |
| preserveAspectRatio="xMidYMid meet" |
| className={"cg-map-svg" + (drag ? " is-selecting" : "")} |
| role="img" |
| aria-label={`Map of ${points.length.toLocaleString()} customers`} |
| onPointerDown={onPointerDown} |
| onPointerMove={onPointerMove} |
| onPointerUp={onPointerUp} |
| onPointerCancel={onPointerUp} |
| > |
| <rect className="cg-map-water" x={0} y={0} width={VIEW_W} height={VIEW_H} /> |
| <g transform={tf}> |
| {/* 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 && ( |
| <g className="cg-map-grat" strokeWidth={hair(0.6)} opacity={gratOpacity}> |
| {Array.from({ length: 17 }, (_, i) => { |
| const y = project(0, -80 + i * 10).y; |
| return <line key={`p${i}`} x1={0} y1={y} x2={WORLD} y2={y} />; |
| })} |
| {Array.from({ length: 19 }, (_, i) => { |
| const x = project(-180 + i * 20, 0).x; |
| return <line key={`m${i}`} x1={x} y1={0} x2={x} y2={WORLD} />; |
| })} |
| </g> |
| )} |
| {/* 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. */} |
| <path className="cg-map-land" d={LAND_PATH} strokeWidth={hair(0.9)} /> |
| {LAKE_PATHS.map((d, i) => ( |
| <path key={i} className="cg-map-lake" d={d} strokeWidth={hair(0.8)} /> |
| ))} |
| {/* 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 && ( |
| <polyline |
| className="cg-map-route-line" |
| strokeWidth={hair(1.8)} |
| // ⚠ The dash MUST be pre-divided by the zoom too — a CSS |
| // dasharray is in user units and would scale with the group, |
| // turning the route into a few disconnected strokes. |
| strokeDasharray={dashPattern(5, 4, view.k)} |
| points={ |
| plan.ordered.map((p) => `${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 ( |
| <circle |
| key={p.pid} |
| className={ |
| "cg-map-pin" + |
| (hoverPid === p.pid ? " is-hover" : "") + |
| (on ? " is-selected" : "") |
| } |
| cx={p.p.x} |
| cy={p.p.y} |
| r={hoverPid === p.pid ? r * 1.28 : r} |
| fill={c.fill} |
| stroke={on ? "#202433" : c.line} |
| strokeWidth={hair(on ? 2 : 1)} |
| role="button" |
| tabIndex={0} |
| aria-label={`Open ${p.title}`} |
| onMouseEnter={() => 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); |
| }} |
| > |
| <title>{p.title}</title> |
| </circle> |
| ); |
| })} |
| </g> |
| {/* 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 ( |
| <g |
| className="cg-map-stopno" |
| key={p.pid} |
| transform={`translate(${s.x.toFixed(2)} ${(s.y - 13).toFixed(2)})`} |
| > |
| <circle r={7.5} /> |
| <text textAnchor="middle" dy="3.4">{i + 1}</text> |
| </g> |
| ); |
| })} |
| {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 ( |
| <g className="cg-map-card" aria-hidden="true"> |
| <rect x={b.x} y={b.y} width={w} height={h} rx={5} /> |
| <text className="cg-map-card-t" x={b.x + 10} y={b.y + 15}>{title}</text> |
| {lines.map((l, i) => ( |
| <text className="cg-map-card-l" key={i} x={b.x + 10} y={b.y + 15 + 14 * (i + 1)}> |
| {l} |
| </text> |
| ))} |
| </g> |
| ); |
| })()} |
| {rect && ( |
| <rect |
| className="cg-map-marquee" |
| x={rect.x0} |
| y={rect.y0} |
| width={rect.x1 - rect.x0} |
| height={rect.y1 - rect.y0} |
| /> |
| )} |
| {/* The loop paints as a POLYGON, so the shape on screen is the shape |
| the hit test uses — an open <polyline> 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 && ( |
| <polygon |
| className="cg-map-marquee" |
| points={lassoPts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} |
| /> |
| )} |
| </svg> |
| |
| {/* --- 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 <svg> 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. --- */} |
| <div className="cg-map-ctl" role="group" aria-label="Map camera"> |
| <button |
| type="button" |
| className="cg-map-ctl-b" |
| // A greyed button with no reason reads as broken, not as honest — |
| // and filtering to a single metro can push the FITTED zoom past the |
| // cap, so this can be disabled the instant the view opens. Say why, |
| // and point at the thing that does go further. |
| title={ |
| view.k >= kMax * (1 - 1e-9) |
| ? "Zoom in — at the limit. The basemap's detail ends here; use Open in Google Maps for street level." |
| : "Zoom in (+)" |
| } |
| aria-label="Zoom in" |
| disabled={view.k >= kMax * (1 - 1e-9)} |
| onClick={() => zoomBy(1.6)} |
| > |
| <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.6v8.8M3.6 8h8.8" /></svg> |
| </button> |
| <button |
| type="button" |
| className="cg-map-ctl-b" |
| title="Zoom out (−)" |
| aria-label="Zoom out" |
| disabled={view.k <= kMin * (1 + 1e-9)} |
| onClick={() => zoomBy(1 / 1.6)} |
| > |
| <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.6 8h8.8" /></svg> |
| </button> |
| <button |
| type="button" |
| className="cg-map-ctl-b" |
| title="Fit to data (0)" |
| aria-label="Fit to data" |
| onClick={doFit} |
| > |
| <svg viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M2.6 5.8V2.6h3.2M10.2 2.6h3.2v3.2M13.4 10.2v3.2h-3.2M5.8 13.4H2.6v-3.2" /> |
| <path d="M6.6 8h2.8M8 6.6v2.8" /> |
| </svg> |
| </button> |
| {/* 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. */} |
| <button |
| type="button" |
| className="cg-map-ctl-b" |
| title={ |
| lassoTool |
| ? "Selection shape: lasso - shift-drag traces a freehand loop. Click for a rectangle." |
| : "Selection shape: rectangle - shift-drag draws a box. Click for a freehand lasso." |
| } |
| aria-label="Selection shape" |
| aria-pressed={lassoTool} |
| onClick={() => setLassoTool((v) => !v)} |
| > |
| {lassoTool ? ( |
| <svg viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M12.1 6.2c1.1 1.6.3 3.5-1.9 4.4-2.4 1-5.5.5-6.7-1.1-1-1.4-.1-3.1 2-3.9 2.2-.8 4.9-.5 6.1.6" /> |
| <path d="M4.9 10.3 4.2 13.4" /> |
| </svg> |
| ) : ( |
| <svg viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M3.2 4.4h9.6v7.2H3.2z" /> |
| </svg> |
| )} |
| </button> |
| {/* 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. */} |
| <a |
| className="cg-map-ctl-b" |
| href={areaUrl} |
| target="_blank" |
| rel="noopener noreferrer" |
| title="Open this area in Google Maps (new tab)" |
| aria-label="Open this area in Google Maps" |
| > |
| <svg viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M7 3.8H4.3a.9.9 0 0 0-.9.9v6.9a.9.9 0 0 0 .9.9h6.9a.9.9 0 0 0 .9-.9V9.2" /> |
| <path d="M8.6 3.4h4v4M12.6 3.4 7.4 8.6" /> |
| </svg> |
| </a> |
| </div> |
| |
| {/* --- legends (I3/I5). Floated over the map, never in the flow. --- */} |
| {(colorBuckets || sizeLegend) && ( |
| <div className="cg-map-legends"> |
| {colorField && colorBuckets && ( |
| <div className="cg-map-legend"> |
| <div className="cg-map-legend-t">{colorField.label}</div> |
| {colorBuckets.order.slice(0, SERIES.length + 1).map((k) => { |
| const c = k === "" ? OVERFLOW : colorBuckets.swatch.get(k) ?? OVERFLOW; |
| return ( |
| <div key={k || "(blank)"} className="cg-map-legend-row"> |
| <span |
| className="cg-map-swatch" |
| style={{ background: c.fill, borderColor: c.line }} |
| /> |
| <span className="cg-map-legend-k">{k === "" ? "(blank)" : k}</span> |
| <span className="cg-map-legend-n"> |
| {(colorBuckets.counts.get(k) ?? 0).toLocaleString()} |
| </span> |
| </div> |
| ); |
| })} |
| {colorBuckets.overflow > 0 && ( |
| <div className="cg-map-legend-note"> |
| {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. |
| </div> |
| )} |
| </div> |
| )} |
| {sizeField && sizeRange && ( |
| <div className="cg-map-legend"> |
| <div className="cg-map-legend-t">{sizeField.label}</div> |
| {sizeLegend ? ( |
| <div className="cg-map-sizerow"> |
| {sizeLegend.map((v, i) => { |
| const r = bubbleRadius(v, sizeRange.min, sizeRange.max, R_MIN, R_MAX, R_NULL); |
| return ( |
| <span key={i} className="cg-map-sizeitem"> |
| <svg width={R_MAX * 2 + 2} height={R_MAX * 2 + 2} aria-hidden> |
| <circle |
| cx={R_MAX + 1} |
| cy={R_MAX + 1} |
| r={r} |
| fill={DEFAULT_PIN.fill} |
| stroke={DEFAULT_PIN.line} |
| /> |
| </svg> |
| <span className="cg-map-legend-k">{formatDisplay(sizeField, v)}</span> |
| </span> |
| ); |
| })} |
| </div> |
| ) : ( |
| <div className="cg-map-legend-note"> |
| Every mapped record has the same {sizeField.label.toLowerCase()}, so the |
| bubbles cannot differ in size. |
| </div> |
| )} |
| {sizeRange.missing > 0 && ( |
| <div className="cg-map-legend-note"> |
| {sizeRange.missing.toLocaleString()} mapped record |
| {sizeRange.missing === 1 ? " has" : "s have"} no value — drawn at the |
| smallest dot, never removed from the map. |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
| |