// --------------------------------------------------------------------------- // 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.
)}
)}
)}
); }