loopable / web /src /customer-grid /MapView.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
45.4 kB
// ---------------------------------------------------------------------------
// 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<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>
);
}