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