@cfasim-ui/docs 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,18 @@ import type { Topology, GeometryCollection } from "topojson-specification";
25
25
  import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
26
26
  import ChartMenu from "../ChartMenu/ChartMenu.vue";
27
27
  import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
28
- import { saveSvg, savePng } from "../ChartMenu/download.js";
28
+ import { saveSvg, savePng, saveCanvasPng } from "../ChartMenu/download.js";
29
+ import {
30
+ buildScene,
31
+ buildPicking,
32
+ drawScene,
33
+ drawHoverHighlight,
34
+ pickIndexAt,
35
+ type CanvasScene,
36
+ type CanvasDrawState,
37
+ type CanvasOverlayItem,
38
+ type CanvasHighlightItem,
39
+ } from "./canvasLayer.js";
29
40
  import {
30
41
  useChartFullscreen,
31
42
  isTouchDevice,
@@ -131,6 +142,16 @@ const props = withDefaults(
131
142
  state?: string;
132
143
  width?: number;
133
144
  height?: number;
145
+ /**
146
+ * Tighten the national (multi-state) fit by cropping the Alaska/Hawaii
147
+ * overhang so the contiguous US fills more of the frame. `false`/`0`
148
+ * (default) fits every region into view; `true`/`1` fits the lower-48 to
149
+ * the frame and lets Alaska's western tail (and Hawaii) clip into the
150
+ * lower-left corner. A number in between (e.g. `0.5`) crops partway. No
151
+ * effect in single-state mode or on a national HSA map (HSA ids aren't
152
+ * FIPS, so AK/HI can't be split out).
153
+ */
154
+ tightFit?: boolean | number;
134
155
  colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
135
156
  /**
136
157
  * Map title. `\n` in the string creates additional lines, each
@@ -255,6 +276,16 @@ const props = withDefaults(
255
276
  * built-in reset button is unaffected.
256
277
  */
257
278
  focusZoom?: boolean;
279
+ /**
280
+ * Rendering backend, fixed at mount. `"svg"` (default) keeps one DOM
281
+ * path per feature — full assistive-tech fallback (per-feature
282
+ * `<title>`) and SVG export. `"canvas"` paints every feature into a
283
+ * single canvas: much faster for dense maps (counties, HSAs) and on
284
+ * mobile WebKit, with identical interactions. In canvas mode the menu
285
+ * offers PNG export only, and there is no per-feature fallback for
286
+ * assistive tech — configure an interactive tooltip.
287
+ */
288
+ renderer?: "svg" | "canvas";
258
289
  /**
259
290
  * Where to teleport the map while expanded (the Expand menu item). A
260
291
  * CSS selector or element; defaults to `body`. Moving it to the
@@ -273,12 +304,14 @@ const props = withDefaults(
273
304
  menu: true,
274
305
  legend: true,
275
306
  zoom: false,
307
+ renderer: "svg",
276
308
  zoomMode: "activate",
277
309
  touchExpand: true,
278
310
  zoomHint: true,
279
311
  tooltipClamp: "chart",
280
312
  focusZoomLevel: 4,
281
313
  focusZoom: true,
314
+ tightFit: false,
282
315
  },
283
316
  );
284
317
 
@@ -398,6 +431,32 @@ const overlayPathEls = new Map<
398
431
  string,
399
432
  { el: SVGPathElement; strokeWidth?: number }
400
433
  >();
434
+
435
+ // ─── Canvas renderer state (renderer="canvas") ───────────────────────────
436
+ // The svg stays as the transparent interaction/zoom surface; the canvas
437
+ // underneath paints the scene. Plain module state, mutated imperatively —
438
+ // same rationale as the path maps above.
439
+ const isCanvas = computed(() => props.renderer === "canvas");
440
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
441
+ let scene: CanvasScene | null = null;
442
+ let pickingCanvas: HTMLCanvasElement | null = null;
443
+ let pickingCtx: CanvasRenderingContext2D | null = null;
444
+ let canvasHoveredId: string | null = null;
445
+ const canvasFocused = new Map<string, CanvasHighlightItem>();
446
+ let canvasOverlays: CanvasOverlayItem[] = [];
447
+ let redrawFrame = 0;
448
+ // xMidYMid-meet letterbox offsets (CSS px) inside the svg/canvas box,
449
+ // tracked by the resize observer alongside viewScale.
450
+ let meetOffsetX = 0;
451
+ let meetOffsetY = 0;
452
+
453
+ interface CanvasViewState {
454
+ dpr: number;
455
+ meetScale: number;
456
+ offsetX: number;
457
+ offsetY: number;
458
+ zoom: { k: number; x: number; y: number };
459
+ }
401
460
  let isZooming = false;
402
461
  let tooltipObserver: ResizeObserver | null = null;
403
462
  const lastTooltipSize = { width: 0, height: 0 };
@@ -483,6 +542,14 @@ function setupInteraction() {
483
542
  // degrades zoom/pan); taps provide the one-shot hover + tooltip instead
484
543
  // (see touchHover).
485
544
  if (isTouchDevice()) return;
545
+ if (isCanvas.value) {
546
+ // No per-feature elements to delegate to — clicks resolve through the
547
+ // picking canvas and hover through per-mousemove picking on the svg.
548
+ svg.addEventListener("click", onDelegatedEvent);
549
+ svg.addEventListener("mousemove", onCanvasMouseMove);
550
+ svg.addEventListener("mouseleave", onCanvasMouseLeave);
551
+ return;
552
+ }
486
553
  g.addEventListener("click", onDelegatedEvent);
487
554
  g.addEventListener("mouseover", onDelegatedEvent);
488
555
  g.addEventListener("mousemove", onDelegatedMouseMove);
@@ -496,6 +563,9 @@ function teardownInteraction() {
496
563
  svg.removeEventListener("touchstart", onTouchStart);
497
564
  svg.removeEventListener("touchend", onTouchEnd);
498
565
  svg.removeEventListener("touchcancel", onTouchCancel);
566
+ svg.removeEventListener("click", onDelegatedEvent);
567
+ svg.removeEventListener("mousemove", onCanvasMouseMove);
568
+ svg.removeEventListener("mouseleave", onCanvasMouseLeave);
499
569
  }
500
570
  if (!g) return;
501
571
  g.removeEventListener("click", onDelegatedEvent);
@@ -529,13 +599,43 @@ onMounted(() => {
529
599
  attachTooltipObserver();
530
600
  if (svgRef.value && typeof ResizeObserver !== "undefined") {
531
601
  svgResizeObserver = new ResizeObserver((entries) => {
532
- const w = entries[0]?.contentRect.width;
533
- if (!w) return;
534
- viewScale.value = w / CANONICAL_WIDTH;
535
- applyStrokeScale();
602
+ const rect = entries[0]?.contentRect;
603
+ if (!rect?.width) return;
604
+ // Replicate preserveAspectRatio="xMidYMid meet": a uniform scale to
605
+ // fit, centered — the letterbox offsets matter in fullscreen, where
606
+ // the svg box no longer matches the viewBox aspect ratio.
607
+ const s = rect.height
608
+ ? Math.min(rect.width / width.value, rect.height / height.value)
609
+ : rect.width / width.value;
610
+ viewScale.value = s;
611
+ meetOffsetX = (rect.width - s * width.value) / 2;
612
+ meetOffsetY = rect.height ? (rect.height - s * height.value) / 2 : 0;
613
+ if (isCanvas.value) {
614
+ const canvas = canvasRef.value;
615
+ const svgEl = svgRef.value;
616
+ if (canvas && svgEl) {
617
+ // Pin the canvas over the svg box (the wrapper also contains the
618
+ // in-flow header above the map). SVG elements have no offsetTop,
619
+ // so derive the offset from the two bounding rects.
620
+ const wrapRect = containerRef.value?.getBoundingClientRect();
621
+ const svgRect = svgEl.getBoundingClientRect();
622
+ canvas.style.top = `${svgRect.top - (wrapRect?.top ?? 0)}px`;
623
+ canvas.style.left = `${svgRect.left - (wrapRect?.left ?? 0)}px`;
624
+ canvas.style.width = `${rect.width}px`;
625
+ canvas.style.height = `${rect.height}px`;
626
+ const dpr =
627
+ (typeof window !== "undefined" && window.devicePixelRatio) || 1;
628
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
629
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
630
+ }
631
+ requestRedraw();
632
+ } else {
633
+ applyStrokeScale();
634
+ }
536
635
  });
537
636
  svgResizeObserver.observe(svgRef.value);
538
637
  }
638
+ if (isCanvas.value) armDprListener();
539
639
  window.addEventListener("scroll", dismissOnViewportChange, {
540
640
  passive: true,
541
641
  capture: true,
@@ -546,7 +646,9 @@ onMounted(() => {
546
646
  onUnmounted(() => {
547
647
  tooltipObserver?.disconnect();
548
648
  svgResizeObserver?.disconnect();
649
+ dprQuery?.removeEventListener("change", onDprChange);
549
650
  if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
651
+ if (redrawFrame) cancelAnimationFrame(redrawFrame);
550
652
  window.clearTimeout(pendingSelectTimer);
551
653
  teardownZoom();
552
654
  teardownInteraction();
@@ -576,7 +678,10 @@ function setupZoom() {
576
678
  // click press felt broken. The tooltip only needs to go once the
577
679
  // map actually moves under the cursor.
578
680
  clearHover();
579
- if (mapGroupRef.value) {
681
+ if (isCanvas.value) {
682
+ // Frames blit + progressively sharpen via the render pipeline.
683
+ requestRedraw();
684
+ } else if (mapGroupRef.value) {
580
685
  mapGroupRef.value.setAttribute("transform", event.transform);
581
686
  }
582
687
  const t = event.transform;
@@ -587,6 +692,8 @@ function setupZoom() {
587
692
  })
588
693
  .on("end", () => {
589
694
  isZooming = false;
695
+ // Sharpen after gestures: an idle-view frame starts a base refresh.
696
+ if (isCanvas.value) requestRedraw();
590
697
  });
591
698
 
592
699
  // Dynamic filter deciding which pointer gestures d3-zoom may handle —
@@ -700,33 +807,55 @@ function applyFocus() {
700
807
  const baseResolved = resolved.filter((r) => r.geoType === props.geoType);
701
808
  const overlayResolved = resolved.filter((r) => r.geoType !== props.geoType);
702
809
 
703
- // Diff base-geoType highlights, keyed by path element so we can
704
- // restyle on style change without churning unrelated paths.
705
- const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
706
- for (const r of baseResolved) {
707
- const p = pathsByFeatureId.get(String(r.feature.id));
708
- if (p) nextBaseStyles.set(p, r.item);
709
- }
710
- for (const [p] of focusedPathStyles) {
711
- if (nextBaseStyles.has(p) || p === hoveredEl) continue;
712
- restoreDefaultStroke(p);
713
- }
714
- for (const [p, item] of nextBaseStyles) {
715
- const prev = focusedPathStyles.get(p);
716
- const unchanged =
717
- prev != null &&
718
- prev.style === item.style &&
719
- prev.stroke === item.stroke &&
720
- prev.strokeWidth === item.strokeWidth;
721
- if (unchanged && p !== hoveredEl) continue; // already styled
722
- if (p !== hoveredEl) applyHighlightStroke(p, item);
723
- }
724
- focusedPathStyles.clear();
725
- for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
810
+ if (isCanvas.value) {
811
+ // Canvas backend: highlights and overlays are just draw state.
812
+ canvasFocused.clear();
813
+ for (const r of baseResolved) {
814
+ canvasFocused.set(String(r.feature.id), r.item);
815
+ }
816
+ const generator = pathGenerator.value;
817
+ canvasOverlays = overlayResolved.flatMap((r) => {
818
+ const d = generator(r.feature);
819
+ if (!d) return [];
820
+ return [
821
+ {
822
+ path: new Path2D(d),
823
+ stroke: r.item.stroke ?? "#fff",
824
+ strokeWidth: r.item.strokeWidth,
825
+ style: r.item.style,
826
+ },
827
+ ];
828
+ });
829
+ requestRedraw();
830
+ } else {
831
+ // Diff base-geoType highlights, keyed by path element so we can
832
+ // restyle on style change without churning unrelated paths.
833
+ const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
834
+ for (const r of baseResolved) {
835
+ const p = pathsByFeatureId.get(String(r.feature.id));
836
+ if (p) nextBaseStyles.set(p, r.item);
837
+ }
838
+ for (const [p] of focusedPathStyles) {
839
+ if (nextBaseStyles.has(p) || p === hoveredEl) continue;
840
+ restoreDefaultStroke(p);
841
+ }
842
+ for (const [p, item] of nextBaseStyles) {
843
+ const prev = focusedPathStyles.get(p);
844
+ const unchanged =
845
+ prev != null &&
846
+ prev.style === item.style &&
847
+ prev.stroke === item.stroke &&
848
+ prev.strokeWidth === item.strokeWidth;
849
+ if (unchanged && p !== hoveredEl) continue; // already styled
850
+ if (p !== hoveredEl) applyHighlightStroke(p, item);
851
+ }
852
+ focusedPathStyles.clear();
853
+ for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
726
854
 
727
- // Cross-geoType outlines render as non-interactive paths on top of
728
- // the base layer.
729
- syncOverlayPaths(overlayResolved);
855
+ // Cross-geoType outlines render as non-interactive paths on top of
856
+ // the base layer.
857
+ syncOverlayPaths(overlayResolved);
858
+ }
730
859
 
731
860
  // Clearing focus doesn't touch the zoom transform — the user keeps
732
861
  // whatever pan/zoom they had. Only the reset button snaps back to
@@ -773,10 +902,15 @@ function applyFocus() {
773
902
  const showFocusTooltip = () => {
774
903
  if (!hasInteractiveTooltip.value || !tooltipTarget) return;
775
904
  const firstId = String(tooltipTarget.id);
905
+ // Read positions *after* the transform commits so the tooltip lands
906
+ // at the focused feature's on-screen position.
907
+ if (isCanvas.value) {
908
+ const anchor = featureClientAnchor(firstId);
909
+ if (anchor) showTooltip(firstId, anchor.x, anchor.y);
910
+ return;
911
+ }
776
912
  const pathEl = pathsByFeatureId.get(firstId);
777
913
  if (!pathEl) return;
778
- // Read the rect *after* the transform commits so the tooltip lands at
779
- // the focused feature's on-screen position.
780
914
  const rect = pathEl.getBoundingClientRect();
781
915
  showTooltip(
782
916
  firstId,
@@ -963,13 +1097,16 @@ function zoomBy(factor: number) {
963
1097
  // Zoom level a tap zooms to (expanding or in place).
964
1098
  const TAP_ZOOM_SCALE = 2;
965
1099
 
966
- // Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
967
- // point. Falls back to the map center when the CTM is unavailable (jsdom).
968
- function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
969
- const svgEl = svgRef.value!;
970
- const k = TAP_ZOOM_SCALE;
971
- let mx = width.value / 2;
972
- let my = height.value / 2;
1100
+ // client canonical viewBox coordinates via the svg's CTM (which covers
1101
+ // viewBox scaling, letterboxing, and pinch-zoom quirks). Falls back to a
1102
+ // rect-proportional mapping when the CTM is unavailable (non-rendering
1103
+ // test DOMs).
1104
+ function clientToViewBox(
1105
+ clientX: number,
1106
+ clientY: number,
1107
+ ): [number, number] | null {
1108
+ const svgEl = svgRef.value;
1109
+ if (!svgEl) return null;
973
1110
  let ctm: DOMMatrix | null = null;
974
1111
  try {
975
1112
  ctm = svgEl.getScreenCTM();
@@ -977,13 +1114,27 @@ function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
977
1114
  ctm = null;
978
1115
  }
979
1116
  if (ctm) {
980
- // client → viewBox coords (the svg CTM covers viewBox scaling and
981
- // letterboxing), then unwind the current zoom transform.
982
1117
  const inv = ctm.inverse();
983
- const px = inv.a * clientX + inv.c * clientY + inv.e;
984
- const py = inv.b * clientX + inv.d * clientY + inv.f;
985
- [mx, my] = zoomTransform(svgEl).invert([px, py]);
1118
+ return [
1119
+ inv.a * clientX + inv.c * clientY + inv.e,
1120
+ inv.b * clientX + inv.d * clientY + inv.f,
1121
+ ];
986
1122
  }
1123
+ const rect = svgEl.getBoundingClientRect();
1124
+ const sx = rect.width ? width.value / rect.width : 1;
1125
+ const sy = rect.height ? height.value / rect.height : 1;
1126
+ return [(clientX - rect.left) * sx, (clientY - rect.top) * sy];
1127
+ }
1128
+
1129
+ // Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
1130
+ // point. Falls back to the map center when the point can't be resolved.
1131
+ function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
1132
+ const svgEl = svgRef.value!;
1133
+ const k = TAP_ZOOM_SCALE;
1134
+ let mx = width.value / 2;
1135
+ let my = height.value / 2;
1136
+ const p = clientToViewBox(clientX, clientY);
1137
+ if (p) [mx, my] = zoomTransform(svgEl).invert(p);
987
1138
  return zoomIdentity
988
1139
  .translate(width.value / 2 - k * mx, height.value / 2 - k * my)
989
1140
  .scale(k);
@@ -1217,6 +1368,31 @@ const stateBordersPath = computed(() => {
1217
1368
  // flush against the SVG edge. Only applied in single-state mode.
1218
1369
  const STATE_FIT_INSET = 12;
1219
1370
 
1371
+ // Resolved `tightFit` amount in [0,1]: false/0 → 0 (full fit), true → 1.
1372
+ const tightFitAmount = computed(() => {
1373
+ const v = props.tightFit;
1374
+ if (v === true) return 1;
1375
+ if (!v) return 0;
1376
+ return Math.max(0, Math.min(1, Number(v)));
1377
+ });
1378
+
1379
+ // The national fit target with Alaska (FIPS 02) and Hawaii (15) removed, so
1380
+ // the projection can be re-fit to just the contiguous US. Null when the crop
1381
+ // is off, in single-state mode, on an HSA map (ids aren't FIPS), or when
1382
+ // nothing was removed — the projection then uses the plain full fit.
1383
+ const conusFeaturesGeo = computed(() => {
1384
+ if (tightFitAmount.value <= 0 || stateFips.value) return null;
1385
+ if (props.geoType === "hsas") return null;
1386
+ const fc = featuresGeo.value;
1387
+ const pad = props.geoType === "counties" ? 5 : 2;
1388
+ const kept = fc.features.filter((f) => {
1389
+ const st = String(f.id).padStart(pad, "0").slice(0, 2);
1390
+ return st !== "02" && st !== "15";
1391
+ });
1392
+ if (kept.length === fc.features.length) return null;
1393
+ return { type: "FeatureCollection" as const, features: kept };
1394
+ });
1395
+
1220
1396
  const projection = computed(() => {
1221
1397
  const outline = stateOutlineFeature.value;
1222
1398
  if (stateFips.value && outline) {
@@ -1236,13 +1412,27 @@ const projection = computed(() => {
1236
1412
  if (Number.isFinite(c[0]) && Number.isFinite(c[1])) return albers;
1237
1413
  return geoMercator().fitExtent(extent, outline);
1238
1414
  }
1239
- return geoAlbersUsa().fitExtent(
1240
- [
1241
- [0, 0],
1242
- [width.value, height.value],
1243
- ],
1244
- featuresGeo.value,
1245
- );
1415
+ const extent: [[number, number], [number, number]] = [
1416
+ [0, 0],
1417
+ [width.value, height.value],
1418
+ ];
1419
+ const full = geoAlbersUsa().fitExtent(extent, featuresGeo.value);
1420
+ const conus = conusFeaturesGeo.value;
1421
+ if (!conus) return full;
1422
+ // Tighten the fit by interpolating the projection's scale + translate from
1423
+ // the full fit toward the contiguous-US fit. At amount 1 CONUS fills the
1424
+ // frame and Alaska's western tail (and Hawaii) clip into the lower-left
1425
+ // corner, cropped by the SVG/canvas viewport. Everything downstream (paths,
1426
+ // picking, tooltip anchors, focus zoom) derives from this projection, so
1427
+ // both renderers stay consistent.
1428
+ const tight = geoAlbersUsa().fitExtent(extent, conus);
1429
+ const t = tightFitAmount.value;
1430
+ const lerp = (a: number, b: number) => a + (b - a) * t;
1431
+ const [fx, fy] = full.translate();
1432
+ const [tx, ty] = tight.translate();
1433
+ return geoAlbersUsa()
1434
+ .scale(lerp(full.scale(), tight.scale()))
1435
+ .translate([lerp(fx, tx), lerp(fy, ty)]);
1246
1436
  });
1247
1437
 
1248
1438
  const pathGenerator = computed(() => geoPath(projection.value));
@@ -1483,7 +1673,8 @@ function titleText(name: string, value: number | string | undefined): string {
1483
1673
  // using lastTooltipSize (possibly stale by one frame) → visibility:visible
1484
1674
  // 2. tooltipObserver fires when the slot DOM has actually committed → we
1485
1675
  // refresh lastTooltipSize and re-apply the position if still visible.
1486
- // 3. mousemove → rAF-throttled direct DOM write of transform; no reactivity.
1676
+ // 3. mousemove → rAF-throttled; re-runs the same flip/clamp placement as
1677
+ // the initial hover (direct DOM write of transform, no reactivity).
1487
1678
  // 4. mouseout (leaving the map) → visibility:hidden.
1488
1679
  //
1489
1680
  // There is no `await` and no token: out-of-order completion is impossible
@@ -1583,11 +1774,14 @@ function moveTooltip(clientX: number, clientY: number) {
1583
1774
  if (pendingMoveFrame) return;
1584
1775
  pendingMoveFrame = requestAnimationFrame(() => {
1585
1776
  pendingMoveFrame = 0;
1586
- const el = tooltipChildRef.value?.getEl();
1587
- if (!el || !tooltipVisible) return;
1777
+ if (!tooltipVisible) return;
1588
1778
  lastPointer = { x: pendingMoveX, y: pendingMoveY };
1589
- // Mid-hover: don't re-run flip/clamp on every pixel; just translate.
1590
- el.style.transform = `translate3d(${pendingMoveX + 16}px, ${pendingMoveY}px, 0) translateY(-50%)`;
1779
+ // Re-run the same flip/clamp as the initial hover. This is already
1780
+ // rAF-coalesced (at most once per frame), so the flip/clamp cost is
1781
+ // negligible. Hardcoding the right side here instead made the tooltip
1782
+ // ignore the boundary while moving and fight showTooltip's per-feature
1783
+ // flip on dense maps (the tooltip appeared to switch sides).
1784
+ applyTooltipPosition(pendingMoveX, pendingMoveY);
1591
1785
  });
1592
1786
  }
1593
1787
 
@@ -1647,7 +1841,224 @@ function highlightWidthFor(item?: FocusItem): number {
1647
1841
  return item?.strokeWidth ?? effectiveStrokeWidth.value + 1;
1648
1842
  }
1649
1843
 
1844
+ // ─── Canvas renderer: redraw, picking, tooltip anchors ───────────────────
1845
+
1846
+ function canvasHighlightColor(): string {
1847
+ // light-dark() isn't paintable on a canvas context; resolve it through
1848
+ // the shared probe. (Cached by input — a mid-session theme flip keeps
1849
+ // the first resolution until remount, same tradeoff as resolveColorToRgb
1850
+ // elsewhere.)
1851
+ const rgb = resolveColorToRgb(HIGHLIGHT_STROKE);
1852
+ return rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : "#000";
1853
+ }
1854
+
1855
+ function currentCanvasView(): CanvasViewState {
1856
+ const t = zoomTransform(svgRef.value!);
1857
+ return {
1858
+ dpr: (typeof window !== "undefined" && window.devicePixelRatio) || 1,
1859
+ meetScale: viewScale.value,
1860
+ offsetX: meetOffsetX,
1861
+ offsetY: meetOffsetY,
1862
+ zoom: { k: t.k, x: t.x, y: t.y },
1863
+ };
1864
+ }
1865
+
1866
+ function canvasDrawState(): CanvasDrawState {
1867
+ return {
1868
+ strokeColor: props.strokeColor,
1869
+ strokeWidth: effectiveStrokeWidth.value,
1870
+ highlightStroke: canvasHighlightColor(),
1871
+ hoveredId: canvasHoveredId,
1872
+ focused: canvasFocused,
1873
+ overlays: canvasOverlays,
1874
+ };
1875
+ }
1876
+
1877
+ // Direct rendering with an adaptive buffered fallback. With the canvas on
1878
+ // its own compositor layer and feature outlines stroked as one path, a
1879
+ // full county-scale pass rasters in single-digit milliseconds, so every
1880
+ // frame renders the whole scene crisply. If a device proves slower (two
1881
+ // consecutive full draws over SLOW_FRAME_MS), gesture frames fall back to
1882
+ // blitting the last crisp render, refreshed at least every
1883
+ // GESTURE_REFRESH_MS, and always sharpened at rest.
1884
+ const SLOW_FRAME_MS = 24;
1885
+ const GESTURE_REFRESH_MS = 350;
1886
+ let slowDrawStreak = 0;
1887
+ let fastDrawStreak = 0;
1888
+ let rendererIsSlow = false;
1889
+ // Canvas backing stores are sized in device pixels, and ResizeObserver
1890
+ // doesn't fire when the window moves to a display with a different
1891
+ // devicePixelRatio. Watch a resolution media query instead — it matches
1892
+ // only the current DPR, so each change re-arms a fresh query (the
1893
+ // standard trick), resizes the backing store, and redraws.
1894
+ let dprQuery: MediaQueryList | null = null;
1895
+
1896
+ function armDprListener() {
1897
+ if (
1898
+ typeof window === "undefined" ||
1899
+ typeof window.matchMedia !== "function"
1900
+ ) {
1901
+ return;
1902
+ }
1903
+ dprQuery?.removeEventListener("change", onDprChange);
1904
+ dprQuery = window.matchMedia(
1905
+ `(resolution: ${window.devicePixelRatio || 1}dppx)`,
1906
+ );
1907
+ dprQuery.addEventListener("change", onDprChange);
1908
+ }
1909
+
1910
+ function onDprChange() {
1911
+ armDprListener();
1912
+ const canvas = canvasRef.value;
1913
+ const svgEl = svgRef.value;
1914
+ if (!isCanvas.value || !canvas || !svgEl) return;
1915
+ const rect = svgEl.getBoundingClientRect();
1916
+ if (rect.width) {
1917
+ const dpr = window.devicePixelRatio || 1;
1918
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
1919
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
1920
+ }
1921
+ requestRedraw();
1922
+ }
1923
+
1924
+ let snapshotCanvas: HTMLCanvasElement | null = null;
1925
+ let snapshotView: CanvasViewState | null = null;
1926
+ let lastFullDrawAt = 0;
1927
+
1928
+ function blitSnapshot(
1929
+ ctx: CanvasRenderingContext2D,
1930
+ view: CanvasViewState,
1931
+ img: HTMLCanvasElement,
1932
+ imgView: CanvasViewState,
1933
+ ) {
1934
+ const off = (v: CanvasViewState, axis: "x" | "y") =>
1935
+ v.dpr *
1936
+ ((axis === "x" ? v.offsetX : v.offsetY) +
1937
+ v.meetScale * (axis === "x" ? v.zoom.x : v.zoom.y));
1938
+ const r =
1939
+ (view.dpr * view.meetScale * view.zoom.k) /
1940
+ (imgView.dpr * imgView.meetScale * imgView.zoom.k);
1941
+ ctx.setTransform(
1942
+ r,
1943
+ 0,
1944
+ 0,
1945
+ r,
1946
+ off(view, "x") - r * off(imgView, "x"),
1947
+ off(view, "y") - r * off(imgView, "y"),
1948
+ );
1949
+ ctx.drawImage(img, 0, 0);
1950
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1951
+ }
1952
+
1953
+ function drawNow() {
1954
+ const display = canvasRef.value;
1955
+ if (!display) return;
1956
+ const ctx = display.getContext("2d");
1957
+ if (!ctx) return;
1958
+ const now = () =>
1959
+ typeof performance !== "undefined" ? performance.now() : 0;
1960
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1961
+ ctx.clearRect(0, 0, display.width, display.height);
1962
+ if (!scene) return;
1963
+ const view = currentCanvasView();
1964
+
1965
+ // Slow-device fallback: blit between throttled crisp refreshes while a
1966
+ // gesture is live.
1967
+ if (
1968
+ rendererIsSlow &&
1969
+ isZooming &&
1970
+ snapshotCanvas &&
1971
+ snapshotView &&
1972
+ typeof ctx.drawImage === "function" &&
1973
+ now() - lastFullDrawAt < GESTURE_REFRESH_MS
1974
+ ) {
1975
+ blitSnapshot(ctx, view, snapshotCanvas, snapshotView);
1976
+ if (canvasHoveredId) {
1977
+ drawHoverHighlight(ctx, scene, view, canvasDrawState(), canvasHoveredId);
1978
+ }
1979
+ return;
1980
+ }
1981
+
1982
+ const t0 = now();
1983
+ drawScene(ctx, scene, view, canvasDrawState());
1984
+ const dt = now() - t0;
1985
+ lastFullDrawAt = t0 + dt;
1986
+ // Symmetric hysteresis: two consecutive slow full draws engage the
1987
+ // fallback, two consecutive fast ones release it — so a cold-start
1988
+ // hiccup (JIT warmup, first-paint contention) can't lock a fast device
1989
+ // into blurry gesture blits forever.
1990
+ if (dt > SLOW_FRAME_MS) {
1991
+ fastDrawStreak = 0;
1992
+ if (++slowDrawStreak >= 2) rendererIsSlow = true;
1993
+ } else {
1994
+ slowDrawStreak = 0;
1995
+ if (++fastDrawStreak >= 2) rendererIsSlow = false;
1996
+ }
1997
+ if (!rendererIsSlow || typeof document === "undefined") return;
1998
+ // Keep a snapshot current for the fallback's gesture blits.
1999
+ if (!snapshotCanvas) snapshotCanvas = document.createElement("canvas");
2000
+ if (snapshotCanvas.width !== display.width) {
2001
+ snapshotCanvas.width = display.width;
2002
+ }
2003
+ if (snapshotCanvas.height !== display.height) {
2004
+ snapshotCanvas.height = display.height;
2005
+ }
2006
+ const sctx = snapshotCanvas.getContext("2d");
2007
+ if (sctx && typeof sctx.drawImage === "function") {
2008
+ sctx.setTransform(1, 0, 0, 1, 0, 0);
2009
+ sctx.clearRect(0, 0, snapshotCanvas.width, snapshotCanvas.height);
2010
+ sctx.drawImage(display, 0, 0);
2011
+ snapshotView = view;
2012
+ } else {
2013
+ snapshotView = null;
2014
+ }
2015
+ }
2016
+
2017
+ // rAF-coalesced: gesture zooms can emit several events per frame.
2018
+ function requestRedraw() {
2019
+ if (!isCanvas.value || redrawFrame) return;
2020
+ redrawFrame = requestAnimationFrame(() => {
2021
+ redrawFrame = 0;
2022
+ drawNow();
2023
+ });
2024
+ }
2025
+
2026
+ // Feature under a client point via the picking canvas — O(1) per query
2027
+ // regardless of feature count. The zoom transform is unwound here; the
2028
+ // picking bitmap itself is only rebuilt on geometry changes.
2029
+ function pickFeatureAt(clientX: number, clientY: number): string | null {
2030
+ const svgEl = svgRef.value;
2031
+ if (!svgEl || !scene || !pickingCtx) return null;
2032
+ const p = clientToViewBox(clientX, clientY);
2033
+ if (!p) return null;
2034
+ const [mx, my] = zoomTransform(svgEl).invert(p);
2035
+ const idx = pickIndexAt(pickingCtx, scene, mx, my);
2036
+ return idx == null ? null : scene.items[idx].id;
2037
+ }
2038
+
2039
+ // Client-coordinate center of a feature — the canvas-mode stand-in for a
2040
+ // path element's getBoundingClientRect (tooltip anchoring).
2041
+ function featureClientAnchor(featId: string): { x: number; y: number } | null {
2042
+ const svgEl = svgRef.value;
2043
+ const f = featuresById.value.get(featId);
2044
+ if (!svgEl || !f) return null;
2045
+ const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds(f);
2046
+ const [vx, vy] = zoomTransform(svgEl).apply([(x0 + x1) / 2, (y0 + y1) / 2]);
2047
+ let ctm: DOMMatrix | null = null;
2048
+ try {
2049
+ ctm = svgEl.getScreenCTM();
2050
+ } catch {
2051
+ ctm = null;
2052
+ }
2053
+ if (!ctm) return null;
2054
+ return {
2055
+ x: ctm.a * vx + ctm.c * vy + ctm.e,
2056
+ y: ctm.b * vx + ctm.d * vy + ctm.f,
2057
+ };
2058
+ }
2059
+
1650
2060
  function applyStrokeScale() {
2061
+ if (isCanvas.value) return; // stroke widths are computed at draw time
1651
2062
  const d = strokeDivisor();
1652
2063
  const eff = effectiveStrokeWidth.value;
1653
2064
  baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
@@ -1704,7 +2115,31 @@ function setHover(pathEl: SVGPathElement) {
1704
2115
  applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl));
1705
2116
  }
1706
2117
 
2118
+ // Renderer-agnostic hover entry point: canvas mode tracks an id and
2119
+ // repaints; svg mode restyles the path element.
2120
+ function setHoverId(featId: string) {
2121
+ if (isCanvas.value) {
2122
+ if (canvasHoveredId === featId) return;
2123
+ canvasHoveredId = featId;
2124
+ requestRedraw();
2125
+ return;
2126
+ }
2127
+ const p = pathsByFeatureId.get(featId);
2128
+ if (p) setHover(p);
2129
+ }
2130
+
1707
2131
  function clearHover() {
2132
+ if (isCanvas.value) {
2133
+ if (canvasHoveredId != null) {
2134
+ canvasHoveredId = null;
2135
+ // The hover highlight lives on the display layer, so dropping it
2136
+ // is just another cheap composite frame.
2137
+ requestRedraw();
2138
+ emit("stateHover", null);
2139
+ }
2140
+ hideTooltip();
2141
+ return;
2142
+ }
1708
2143
  if (hoveredEl) {
1709
2144
  const focusItem = focusedPathStyles.get(hoveredEl);
1710
2145
  if (focusItem == null) {
@@ -1746,7 +2181,7 @@ function onDelegatedEvent(event: Event) {
1746
2181
  // already swallows genuine post-drag clicks via clickDistance.
1747
2182
  if (event.type === "mouseover" && isZooming) return;
1748
2183
  const me = event as MouseEvent;
1749
- const featId = eventToFeatureId(me.target);
2184
+ const featId = featureIdFromEvent(me.target, me.clientX, me.clientY);
1750
2185
  if (!featId) return;
1751
2186
  const data = tooltipDataById.get(featId);
1752
2187
  if (!data) return;
@@ -1767,7 +2202,7 @@ function onDelegatedEvent(event: Event) {
1767
2202
  emitSelection(data);
1768
2203
  }
1769
2204
  } else if (event.type === "mouseover") {
1770
- setHover(pathsByFeatureId.get(featId)!);
2205
+ setHoverId(featId);
1771
2206
  if (hasInteractiveTooltip.value)
1772
2207
  showTooltip(featId, me.clientX, me.clientY);
1773
2208
  emit("stateHover", payload);
@@ -1785,6 +2220,44 @@ function onDelegatedMouseOut(event: MouseEvent) {
1785
2220
  clearHover();
1786
2221
  }
1787
2222
 
2223
+ // Canvas mode has no per-feature elements to delegate mouseover to —
2224
+ // hover is resolved by picking on every mousemove instead (a 1px read
2225
+ // from a CPU canvas; cheap even at mousemove rates).
2226
+ function onCanvasMouseMove(event: MouseEvent) {
2227
+ if (isZooming) return;
2228
+ const featId = pickFeatureAt(event.clientX, event.clientY);
2229
+ if (featId === canvasHoveredId) {
2230
+ if (featId) moveTooltip(event.clientX, event.clientY);
2231
+ return;
2232
+ }
2233
+ if (!featId) {
2234
+ clearHover();
2235
+ return;
2236
+ }
2237
+ const data = tooltipDataById.get(featId);
2238
+ if (!data) return;
2239
+ setHoverId(featId);
2240
+ emit("stateHover", { id: data.id, name: data.name, value: data.value });
2241
+ if (hasInteractiveTooltip.value) {
2242
+ showTooltip(featId, event.clientX, event.clientY);
2243
+ }
2244
+ }
2245
+
2246
+ function onCanvasMouseLeave() {
2247
+ clearHover();
2248
+ }
2249
+
2250
+ // Renderer-agnostic feature resolution for pointer events.
2251
+ function featureIdFromEvent(
2252
+ target: EventTarget | null,
2253
+ clientX: number,
2254
+ clientY: number,
2255
+ ): string | null {
2256
+ return isCanvas.value
2257
+ ? pickFeatureAt(clientX, clientY)
2258
+ : eventToFeatureId(target);
2259
+ }
2260
+
1788
2261
  function onTouchStart(event: TouchEvent) {
1789
2262
  // Single-finger only — a second touch is a pinch-zoom, not a selection.
1790
2263
  if (event.touches.length !== 1) {
@@ -1796,7 +2269,7 @@ function onTouchStart(event: TouchEvent) {
1796
2269
  x: t.clientX,
1797
2270
  y: t.clientY,
1798
2271
  time: event.timeStamp,
1799
- featId: eventToFeatureId(event.target),
2272
+ featId: featureIdFromEvent(event.target, t.clientX, t.clientY),
1800
2273
  };
1801
2274
  }
1802
2275
 
@@ -1825,8 +2298,10 @@ function onTouchEnd(event: TouchEvent) {
1825
2298
  !fullscreen.isFullscreen.value
1826
2299
  ) {
1827
2300
  // Suppress the synthetic click/hover the browser would replay from
1828
- // this tap (and iOS's hover-first tap).
1829
- event.preventDefault();
2301
+ // this tap (and iOS's hover-first tap). Guard on cancelable: a
2302
+ // touchend fired while the page is mid-scroll is non-cancelable, and
2303
+ // preventDefault there is a no-op that only logs a console intervention.
2304
+ if (event.cancelable) event.preventDefault();
1830
2305
  const prev = lastTap;
1831
2306
  lastTap = { x: t.clientX, y: t.clientY, time: event.timeStamp };
1832
2307
  const isDoubleTap =
@@ -1868,8 +2343,10 @@ function onTouchEnd(event: TouchEvent) {
1868
2343
  const data = tooltipDataById.get(start.featId);
1869
2344
  if (!data) return;
1870
2345
  // Suppress the synthetic click/hover the browser would replay from this
1871
- // tap, so selection fires exactly once and never via iOS's hover-first tap.
1872
- event.preventDefault();
2346
+ // tap, so selection fires exactly once and never via iOS's hover-first
2347
+ // tap. Guard on cancelable — a touchend fired mid page-scroll is
2348
+ // non-cancelable, and preventDefault there only logs an intervention.
2349
+ if (event.cancelable) event.preventDefault();
1873
2350
  touchHover(data, t.clientX, t.clientY);
1874
2351
  emitSelection(data);
1875
2352
  }
@@ -1879,16 +2356,22 @@ function onTouchEnd(event: TouchEvent) {
1879
2356
  // *tracking* stays off on touch — see setupInteraction — this is the
1880
2357
  // one-shot equivalent.)
1881
2358
  function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
1882
- const pathEl = pathsByFeatureId.get(data.id);
1883
- if (pathEl) setHover(pathEl);
2359
+ setHoverId(data.id);
1884
2360
  emit("stateHover", { id: data.id, name: data.name, value: data.value });
1885
2361
  if (!hasInteractiveTooltip.value) return;
2362
+ // Anchor to the feature, not the finger: element rects and fixed
2363
+ // positioning share the layout-viewport coordinate space, so this
2364
+ // stays put when the page itself is pinch-zoomed — Safari reports
2365
+ // touch client coords in *visual*-viewport space, which would skew a
2366
+ // finger-anchored tooltip. Also keeps it out from under the finger.
2367
+ if (isCanvas.value) {
2368
+ const anchor = featureClientAnchor(data.id);
2369
+ if (anchor) showTooltip(data.id, anchor.x, anchor.y);
2370
+ else showTooltip(data.id, clientX, clientY);
2371
+ return;
2372
+ }
2373
+ const pathEl = pathsByFeatureId.get(data.id);
1886
2374
  if (pathEl) {
1887
- // Anchor to the feature, not the finger: element rects and fixed
1888
- // positioning share the layout-viewport coordinate space, so this
1889
- // stays put when the page itself is pinch-zoomed — Safari reports
1890
- // touch client coords in *visual*-viewport space, which would skew a
1891
- // finger-anchored tooltip. Also keeps it out from under the finger.
1892
2375
  const rect = pathEl.getBoundingClientRect();
1893
2376
  showTooltip(
1894
2377
  data.id,
@@ -1934,6 +2417,9 @@ function rebuildPaths() {
1934
2417
  // applyFocus can re-resolve against the new path tree.
1935
2418
  focusedPathStyles.clear();
1936
2419
  overlayPathEls.clear();
2420
+ canvasHoveredId = null;
2421
+ canvasFocused.clear();
2422
+ canvasOverlays = [];
1937
2423
 
1938
2424
  const path = pathGenerator.value;
1939
2425
  const features = featuresGeo.value.features;
@@ -1941,7 +2427,43 @@ function rebuildPaths() {
1941
2427
  // means the projection was fitted to an empty collection and yields NaN
1942
2428
  // coordinates — skip drawing entirely, including the state-borders mesh,
1943
2429
  // until real features arrive and re-trigger a rebuild.
1944
- if (features.length === 0) return;
2430
+ if (features.length === 0) {
2431
+ scene = null;
2432
+ pickingCtx = null;
2433
+ snapshotView = null;
2434
+ requestRedraw();
2435
+ return;
2436
+ }
2437
+
2438
+ if (isCanvas.value) {
2439
+ // Canvas backend: no DOM per feature — build the scene + picking
2440
+ // bitmap, and keep the tooltip payload cache exactly as svg mode does.
2441
+ for (const feat of features) {
2442
+ const id = String(feat.id);
2443
+ tooltipDataById.set(id, {
2444
+ id,
2445
+ name: featureName(feat),
2446
+ value: valueFor(id),
2447
+ feature: feat as ChoroplethFeature,
2448
+ });
2449
+ }
2450
+ const borders = stateBordersPath.value;
2451
+ scene = buildScene(
2452
+ features as Array<{ id?: string | number | null }>,
2453
+ path as (feature: never) => string | null,
2454
+ colorFor,
2455
+ borders ? path(borders) : null,
2456
+ );
2457
+ if (!pickingCanvas && typeof document !== "undefined") {
2458
+ pickingCanvas = document.createElement("canvas");
2459
+ }
2460
+ pickingCtx = pickingCanvas
2461
+ ? buildPicking(scene, width.value, height.value, pickingCanvas)
2462
+ : null;
2463
+ requestRedraw();
2464
+ return;
2465
+ }
2466
+
1945
2467
  const stroke = props.strokeColor;
1946
2468
  const wantsTitleFallback = !hasInteractiveTooltip.value;
1947
2469
 
@@ -1989,6 +2511,14 @@ function rebuildPaths() {
1989
2511
  }
1990
2512
 
1991
2513
  function updateFills() {
2514
+ if (isCanvas.value) {
2515
+ if (scene) {
2516
+ for (const item of scene.items) item.fill = colorFor(item.id);
2517
+ }
2518
+ for (const [id, entry] of tooltipDataById) entry.value = valueFor(id);
2519
+ requestRedraw();
2520
+ return;
2521
+ }
1992
2522
  const refreshTitle = !hasInteractiveTooltip.value;
1993
2523
  for (const [id, p] of pathsByFeatureId) {
1994
2524
  const value = valueFor(id);
@@ -2007,6 +2537,11 @@ function updateFills() {
2007
2537
  }
2008
2538
 
2009
2539
  function updateStrokes() {
2540
+ if (isCanvas.value) {
2541
+ // Stroke params are read at render time — refresh both buffers.
2542
+ requestRedraw();
2543
+ return;
2544
+ }
2010
2545
  for (const p of pathsByFeatureId.values()) {
2011
2546
  // Highlighted paths (hover / focus) keep their #555 + thicker stroke.
2012
2547
  if (p === hoveredEl || focusedPathStyles.has(p)) continue;
@@ -2102,6 +2637,19 @@ const fullscreen = useChartFullscreen({
2102
2637
 
2103
2638
  const menuItems = computed<ChartMenuItem[]>(() => {
2104
2639
  const fname = menuFilename();
2640
+ if (isCanvas.value) {
2641
+ // No SVG DOM to serialize in canvas mode; PNG exports straight off
2642
+ // the rendering canvas (already devicePixelRatio-sized).
2643
+ return [
2644
+ fullscreen.menuItem.value,
2645
+ {
2646
+ label: "Save as PNG",
2647
+ action: () => {
2648
+ if (canvasRef.value) saveCanvasPng(canvasRef.value, fname);
2649
+ },
2650
+ },
2651
+ ];
2652
+ }
2105
2653
  return [
2106
2654
  fullscreen.menuItem.value,
2107
2655
  {
@@ -2263,6 +2811,17 @@ watch(
2263
2811
  <div v-if="showZoomHint" class="choropleth-zoom-hint">
2264
2812
  {{ zoomHintText }}
2265
2813
  </div>
2814
+ <!-- Canvas backend paints here; the (empty) svg after it stays the
2815
+ interaction/zoom surface. pointer-events: none lets every event fall
2816
+ through to the svg even though the positioned canvas paints above
2817
+ the transparent svg. Position/size are pinned to the svg box by the
2818
+ resize observer. -->
2819
+ <canvas
2820
+ v-if="isCanvas"
2821
+ ref="canvasRef"
2822
+ class="choropleth-canvas"
2823
+ aria-hidden="true"
2824
+ />
2266
2825
  <svg
2267
2826
  ref="svgRef"
2268
2827
  :viewBox="`0 0 ${width} ${height}`"
@@ -2358,6 +2917,18 @@ watch(
2358
2917
  cursor: pointer;
2359
2918
  }
2360
2919
 
2920
+ .choropleth-canvas {
2921
+ position: absolute;
2922
+ top: 0;
2923
+ left: 0;
2924
+ pointer-events: none;
2925
+ /* Own compositor layer: without it WebKit repaints the surrounding
2926
+ page layer on every canvas frame (the same >100ms/frame pathology
2927
+ the svg renderer hit). Canvas mode implies interactivity, so the
2928
+ layer is always warranted. */
2929
+ will-change: transform;
2930
+ }
2931
+
2361
2932
  /* Overlays the top of the map: absolutely positioned with no `top`, the
2362
2933
  box keeps its static position — the top edge of the svg that follows it
2363
2934
  in the markup — without taking up flow space. The text carries a