@cfasim-ui/docs 0.7.3 → 0.7.5

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,
@@ -255,6 +266,16 @@ const props = withDefaults(
255
266
  * built-in reset button is unaffected.
256
267
  */
257
268
  focusZoom?: boolean;
269
+ /**
270
+ * Rendering backend, fixed at mount. `"svg"` (default) keeps one DOM
271
+ * path per feature — full assistive-tech fallback (per-feature
272
+ * `<title>`) and SVG export. `"canvas"` paints every feature into a
273
+ * single canvas: much faster for dense maps (counties, HSAs) and on
274
+ * mobile WebKit, with identical interactions. In canvas mode the menu
275
+ * offers PNG export only, and there is no per-feature fallback for
276
+ * assistive tech — configure an interactive tooltip.
277
+ */
278
+ renderer?: "svg" | "canvas";
258
279
  /**
259
280
  * Where to teleport the map while expanded (the Expand menu item). A
260
281
  * CSS selector or element; defaults to `body`. Moving it to the
@@ -273,6 +294,7 @@ const props = withDefaults(
273
294
  menu: true,
274
295
  legend: true,
275
296
  zoom: false,
297
+ renderer: "svg",
276
298
  zoomMode: "activate",
277
299
  touchExpand: true,
278
300
  zoomHint: true,
@@ -398,6 +420,32 @@ const overlayPathEls = new Map<
398
420
  string,
399
421
  { el: SVGPathElement; strokeWidth?: number }
400
422
  >();
423
+
424
+ // ─── Canvas renderer state (renderer="canvas") ───────────────────────────
425
+ // The svg stays as the transparent interaction/zoom surface; the canvas
426
+ // underneath paints the scene. Plain module state, mutated imperatively —
427
+ // same rationale as the path maps above.
428
+ const isCanvas = computed(() => props.renderer === "canvas");
429
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
430
+ let scene: CanvasScene | null = null;
431
+ let pickingCanvas: HTMLCanvasElement | null = null;
432
+ let pickingCtx: CanvasRenderingContext2D | null = null;
433
+ let canvasHoveredId: string | null = null;
434
+ const canvasFocused = new Map<string, CanvasHighlightItem>();
435
+ let canvasOverlays: CanvasOverlayItem[] = [];
436
+ let redrawFrame = 0;
437
+ // xMidYMid-meet letterbox offsets (CSS px) inside the svg/canvas box,
438
+ // tracked by the resize observer alongside viewScale.
439
+ let meetOffsetX = 0;
440
+ let meetOffsetY = 0;
441
+
442
+ interface CanvasViewState {
443
+ dpr: number;
444
+ meetScale: number;
445
+ offsetX: number;
446
+ offsetY: number;
447
+ zoom: { k: number; x: number; y: number };
448
+ }
401
449
  let isZooming = false;
402
450
  let tooltipObserver: ResizeObserver | null = null;
403
451
  const lastTooltipSize = { width: 0, height: 0 };
@@ -474,6 +522,8 @@ function setupInteraction() {
474
522
  // `touchend` is non-passive so a confirmed tap can preventDefault and
475
523
  // suppress the compatibility click/hover the browser would otherwise
476
524
  // synthesize (the double-fire and iOS first-tap-hover sources).
525
+ // Must run before setupZoom(): d3-zoom stopImmediatePropagation()s
526
+ // touch events once its filter passes, which would starve these.
477
527
  svg.addEventListener("touchstart", onTouchStart, { passive: true });
478
528
  svg.addEventListener("touchend", onTouchEnd);
479
529
  svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
@@ -481,6 +531,14 @@ function setupInteraction() {
481
531
  // degrades zoom/pan); taps provide the one-shot hover + tooltip instead
482
532
  // (see touchHover).
483
533
  if (isTouchDevice()) return;
534
+ if (isCanvas.value) {
535
+ // No per-feature elements to delegate to — clicks resolve through the
536
+ // picking canvas and hover through per-mousemove picking on the svg.
537
+ svg.addEventListener("click", onDelegatedEvent);
538
+ svg.addEventListener("mousemove", onCanvasMouseMove);
539
+ svg.addEventListener("mouseleave", onCanvasMouseLeave);
540
+ return;
541
+ }
484
542
  g.addEventListener("click", onDelegatedEvent);
485
543
  g.addEventListener("mouseover", onDelegatedEvent);
486
544
  g.addEventListener("mousemove", onDelegatedMouseMove);
@@ -494,6 +552,9 @@ function teardownInteraction() {
494
552
  svg.removeEventListener("touchstart", onTouchStart);
495
553
  svg.removeEventListener("touchend", onTouchEnd);
496
554
  svg.removeEventListener("touchcancel", onTouchCancel);
555
+ svg.removeEventListener("click", onDelegatedEvent);
556
+ svg.removeEventListener("mousemove", onCanvasMouseMove);
557
+ svg.removeEventListener("mouseleave", onCanvasMouseLeave);
497
558
  }
498
559
  if (!g) return;
499
560
  g.removeEventListener("click", onDelegatedEvent);
@@ -515,20 +576,55 @@ function dismissOnViewportChange() {
515
576
  let svgResizeObserver: ResizeObserver | null = null;
516
577
 
517
578
  onMounted(() => {
518
- setupZoom();
579
+ // Order is load-bearing: once its filter passes (zoom activated /
580
+ // expanded), d3-zoom calls stopImmediatePropagation() on touchstart and
581
+ // touchend. Same-element listeners run in registration order, so our
582
+ // tap listeners must be registered BEFORE d3-zoom's or taps (selection,
583
+ // tooltips) go dead the moment the map owns touch gestures.
519
584
  setupInteraction();
585
+ setupZoom();
520
586
  rebuildPaths();
521
587
  applyFocus();
522
588
  attachTooltipObserver();
523
589
  if (svgRef.value && typeof ResizeObserver !== "undefined") {
524
590
  svgResizeObserver = new ResizeObserver((entries) => {
525
- const w = entries[0]?.contentRect.width;
526
- if (!w) return;
527
- viewScale.value = w / CANONICAL_WIDTH;
528
- applyStrokeScale();
591
+ const rect = entries[0]?.contentRect;
592
+ if (!rect?.width) return;
593
+ // Replicate preserveAspectRatio="xMidYMid meet": a uniform scale to
594
+ // fit, centered — the letterbox offsets matter in fullscreen, where
595
+ // the svg box no longer matches the viewBox aspect ratio.
596
+ const s = rect.height
597
+ ? Math.min(rect.width / width.value, rect.height / height.value)
598
+ : rect.width / width.value;
599
+ viewScale.value = s;
600
+ meetOffsetX = (rect.width - s * width.value) / 2;
601
+ meetOffsetY = rect.height ? (rect.height - s * height.value) / 2 : 0;
602
+ if (isCanvas.value) {
603
+ const canvas = canvasRef.value;
604
+ const svgEl = svgRef.value;
605
+ if (canvas && svgEl) {
606
+ // Pin the canvas over the svg box (the wrapper also contains the
607
+ // in-flow header above the map). SVG elements have no offsetTop,
608
+ // so derive the offset from the two bounding rects.
609
+ const wrapRect = containerRef.value?.getBoundingClientRect();
610
+ const svgRect = svgEl.getBoundingClientRect();
611
+ canvas.style.top = `${svgRect.top - (wrapRect?.top ?? 0)}px`;
612
+ canvas.style.left = `${svgRect.left - (wrapRect?.left ?? 0)}px`;
613
+ canvas.style.width = `${rect.width}px`;
614
+ canvas.style.height = `${rect.height}px`;
615
+ const dpr =
616
+ (typeof window !== "undefined" && window.devicePixelRatio) || 1;
617
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
618
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
619
+ }
620
+ requestRedraw();
621
+ } else {
622
+ applyStrokeScale();
623
+ }
529
624
  });
530
625
  svgResizeObserver.observe(svgRef.value);
531
626
  }
627
+ if (isCanvas.value) armDprListener();
532
628
  window.addEventListener("scroll", dismissOnViewportChange, {
533
629
  passive: true,
534
630
  capture: true,
@@ -539,7 +635,9 @@ onMounted(() => {
539
635
  onUnmounted(() => {
540
636
  tooltipObserver?.disconnect();
541
637
  svgResizeObserver?.disconnect();
638
+ dprQuery?.removeEventListener("change", onDprChange);
542
639
  if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
640
+ if (redrawFrame) cancelAnimationFrame(redrawFrame);
543
641
  window.clearTimeout(pendingSelectTimer);
544
642
  teardownZoom();
545
643
  teardownInteraction();
@@ -569,7 +667,10 @@ function setupZoom() {
569
667
  // click press felt broken. The tooltip only needs to go once the
570
668
  // map actually moves under the cursor.
571
669
  clearHover();
572
- if (mapGroupRef.value) {
670
+ if (isCanvas.value) {
671
+ // Frames blit + progressively sharpen via the render pipeline.
672
+ requestRedraw();
673
+ } else if (mapGroupRef.value) {
573
674
  mapGroupRef.value.setAttribute("transform", event.transform);
574
675
  }
575
676
  const t = event.transform;
@@ -580,6 +681,8 @@ function setupZoom() {
580
681
  })
581
682
  .on("end", () => {
582
683
  isZooming = false;
684
+ // Sharpen after gestures: an idle-view frame starts a base refresh.
685
+ if (isCanvas.value) requestRedraw();
583
686
  });
584
687
 
585
688
  // Dynamic filter deciding which pointer gestures d3-zoom may handle —
@@ -693,33 +796,55 @@ function applyFocus() {
693
796
  const baseResolved = resolved.filter((r) => r.geoType === props.geoType);
694
797
  const overlayResolved = resolved.filter((r) => r.geoType !== props.geoType);
695
798
 
696
- // Diff base-geoType highlights, keyed by path element so we can
697
- // restyle on style change without churning unrelated paths.
698
- const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
699
- for (const r of baseResolved) {
700
- const p = pathsByFeatureId.get(String(r.feature.id));
701
- if (p) nextBaseStyles.set(p, r.item);
702
- }
703
- for (const [p] of focusedPathStyles) {
704
- if (nextBaseStyles.has(p) || p === hoveredEl) continue;
705
- restoreDefaultStroke(p);
706
- }
707
- for (const [p, item] of nextBaseStyles) {
708
- const prev = focusedPathStyles.get(p);
709
- const unchanged =
710
- prev != null &&
711
- prev.style === item.style &&
712
- prev.stroke === item.stroke &&
713
- prev.strokeWidth === item.strokeWidth;
714
- if (unchanged && p !== hoveredEl) continue; // already styled
715
- if (p !== hoveredEl) applyHighlightStroke(p, item);
716
- }
717
- focusedPathStyles.clear();
718
- for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
799
+ if (isCanvas.value) {
800
+ // Canvas backend: highlights and overlays are just draw state.
801
+ canvasFocused.clear();
802
+ for (const r of baseResolved) {
803
+ canvasFocused.set(String(r.feature.id), r.item);
804
+ }
805
+ const generator = pathGenerator.value;
806
+ canvasOverlays = overlayResolved.flatMap((r) => {
807
+ const d = generator(r.feature);
808
+ if (!d) return [];
809
+ return [
810
+ {
811
+ path: new Path2D(d),
812
+ stroke: r.item.stroke ?? "#fff",
813
+ strokeWidth: r.item.strokeWidth,
814
+ style: r.item.style,
815
+ },
816
+ ];
817
+ });
818
+ requestRedraw();
819
+ } else {
820
+ // Diff base-geoType highlights, keyed by path element so we can
821
+ // restyle on style change without churning unrelated paths.
822
+ const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
823
+ for (const r of baseResolved) {
824
+ const p = pathsByFeatureId.get(String(r.feature.id));
825
+ if (p) nextBaseStyles.set(p, r.item);
826
+ }
827
+ for (const [p] of focusedPathStyles) {
828
+ if (nextBaseStyles.has(p) || p === hoveredEl) continue;
829
+ restoreDefaultStroke(p);
830
+ }
831
+ for (const [p, item] of nextBaseStyles) {
832
+ const prev = focusedPathStyles.get(p);
833
+ const unchanged =
834
+ prev != null &&
835
+ prev.style === item.style &&
836
+ prev.stroke === item.stroke &&
837
+ prev.strokeWidth === item.strokeWidth;
838
+ if (unchanged && p !== hoveredEl) continue; // already styled
839
+ if (p !== hoveredEl) applyHighlightStroke(p, item);
840
+ }
841
+ focusedPathStyles.clear();
842
+ for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
719
843
 
720
- // Cross-geoType outlines render as non-interactive paths on top of
721
- // the base layer.
722
- syncOverlayPaths(overlayResolved);
844
+ // Cross-geoType outlines render as non-interactive paths on top of
845
+ // the base layer.
846
+ syncOverlayPaths(overlayResolved);
847
+ }
723
848
 
724
849
  // Clearing focus doesn't touch the zoom transform — the user keeps
725
850
  // whatever pan/zoom they had. Only the reset button snaps back to
@@ -766,10 +891,15 @@ function applyFocus() {
766
891
  const showFocusTooltip = () => {
767
892
  if (!hasInteractiveTooltip.value || !tooltipTarget) return;
768
893
  const firstId = String(tooltipTarget.id);
894
+ // Read positions *after* the transform commits so the tooltip lands
895
+ // at the focused feature's on-screen position.
896
+ if (isCanvas.value) {
897
+ const anchor = featureClientAnchor(firstId);
898
+ if (anchor) showTooltip(firstId, anchor.x, anchor.y);
899
+ return;
900
+ }
769
901
  const pathEl = pathsByFeatureId.get(firstId);
770
902
  if (!pathEl) return;
771
- // Read the rect *after* the transform commits so the tooltip lands at
772
- // the focused feature's on-screen position.
773
903
  const rect = pathEl.getBoundingClientRect();
774
904
  showTooltip(
775
905
  firstId,
@@ -933,8 +1063,12 @@ const svgStyle = computed(() => {
933
1063
  if (
934
1064
  hasZoomed.value ||
935
1065
  fullscreen.isFullscreen.value ||
936
- (props.zoom && isScrollMode.value)
1066
+ (props.zoom && (isScrollMode.value || isTouchDevice()))
937
1067
  ) {
1068
+ // Touch maps get the layer up front, not just once zoomed: a tap
1069
+ // restyles a feature path, and without the layer WebKit repaints the
1070
+ // surrounding page layer (~200ms per mutation on the HSA map — taps
1071
+ // felt sluggish inline while fullscreen, already layered, was fast).
938
1072
  style["will-change"] = "transform";
939
1073
  }
940
1074
  return Object.keys(style).length ? style : undefined;
@@ -952,13 +1086,16 @@ function zoomBy(factor: number) {
952
1086
  // Zoom level a tap zooms to (expanding or in place).
953
1087
  const TAP_ZOOM_SCALE = 2;
954
1088
 
955
- // Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
956
- // point. Falls back to the map center when the CTM is unavailable (jsdom).
957
- function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
958
- const svgEl = svgRef.value!;
959
- const k = TAP_ZOOM_SCALE;
960
- let mx = width.value / 2;
961
- let my = height.value / 2;
1089
+ // client canonical viewBox coordinates via the svg's CTM (which covers
1090
+ // viewBox scaling, letterboxing, and pinch-zoom quirks). Falls back to a
1091
+ // rect-proportional mapping when the CTM is unavailable (non-rendering
1092
+ // test DOMs).
1093
+ function clientToViewBox(
1094
+ clientX: number,
1095
+ clientY: number,
1096
+ ): [number, number] | null {
1097
+ const svgEl = svgRef.value;
1098
+ if (!svgEl) return null;
962
1099
  let ctm: DOMMatrix | null = null;
963
1100
  try {
964
1101
  ctm = svgEl.getScreenCTM();
@@ -966,13 +1103,27 @@ function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
966
1103
  ctm = null;
967
1104
  }
968
1105
  if (ctm) {
969
- // client → viewBox coords (the svg CTM covers viewBox scaling and
970
- // letterboxing), then unwind the current zoom transform.
971
1106
  const inv = ctm.inverse();
972
- const px = inv.a * clientX + inv.c * clientY + inv.e;
973
- const py = inv.b * clientX + inv.d * clientY + inv.f;
974
- [mx, my] = zoomTransform(svgEl).invert([px, py]);
1107
+ return [
1108
+ inv.a * clientX + inv.c * clientY + inv.e,
1109
+ inv.b * clientX + inv.d * clientY + inv.f,
1110
+ ];
975
1111
  }
1112
+ const rect = svgEl.getBoundingClientRect();
1113
+ const sx = rect.width ? width.value / rect.width : 1;
1114
+ const sy = rect.height ? height.value / rect.height : 1;
1115
+ return [(clientX - rect.left) * sx, (clientY - rect.top) * sy];
1116
+ }
1117
+
1118
+ // Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
1119
+ // point. Falls back to the map center when the point can't be resolved.
1120
+ function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
1121
+ const svgEl = svgRef.value!;
1122
+ const k = TAP_ZOOM_SCALE;
1123
+ let mx = width.value / 2;
1124
+ let my = height.value / 2;
1125
+ const p = clientToViewBox(clientX, clientY);
1126
+ if (p) [mx, my] = zoomTransform(svgEl).invert(p);
976
1127
  return zoomIdentity
977
1128
  .translate(width.value / 2 - k * mx, height.value / 2 - k * my)
978
1129
  .scale(k);
@@ -1520,23 +1671,53 @@ function applyTooltipPosition(clientX: number, clientY: number) {
1520
1671
  chartRect,
1521
1672
  );
1522
1673
  el.style.transform = `translate3d(${left}px, ${top}px, 0) translateY(-50%)`;
1674
+ // Safari reports client coordinates (events AND element rects) in
1675
+ // visual-viewport space, while position: fixed resolves against the
1676
+ // layout viewport — under page pinch-zoom the tooltip lands offset from
1677
+ // its target (typically "too high" by the pan offset). Rather than
1678
+ // sniffing engines, self-calibrate: re-measure where the tooltip
1679
+ // actually landed — the rect comes back in the same client space as the
1680
+ // target coords — and shift by the residual. Chrome measures ~0.
1681
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
1682
+ if (!vv || (vv.scale <= 1.01 && vv.offsetTop < 1 && vv.offsetLeft < 1)) {
1683
+ return;
1684
+ }
1685
+ const actual = el.getBoundingClientRect();
1686
+ const dx = actual.left - left;
1687
+ const dy = actual.top + actual.height / 2 - top;
1688
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
1689
+ el.style.transform = `translate3d(${left - dx}px, ${top - dy}px, 0) translateY(-50%)`;
1690
+ }
1523
1691
  }
1524
1692
 
1693
+ // The expanded touch view swaps the pointer-anchored tooltip for a bottom
1694
+ // sheet: floating fixed positioning is unreliable there (Safari reports
1695
+ // touch/rect coordinates in visual-viewport space once the page is
1696
+ // pinch-zoomed), while a sheet pinned to the wrapper is always right.
1697
+ const tooltipMode = computed<"float" | "sheet">(() =>
1698
+ fullscreen.isFullscreen.value && isTouchDevice() ? "sheet" : "float",
1699
+ );
1700
+
1525
1701
  function showTooltip(featId: string, clientX: number, clientY: number) {
1526
1702
  const data = tooltipDataById.get(featId);
1527
1703
  if (!data) return;
1528
1704
  const child = tooltipChildRef.value;
1529
- const el = child?.getEl();
1530
- if (!child || !el) return;
1705
+ if (!child) return;
1531
1706
  child.setData(data);
1532
- lastPointer = { x: clientX, y: clientY };
1533
1707
  tooltipVisible = true;
1708
+ if (tooltipMode.value === "sheet") {
1709
+ child.setOpen(true);
1710
+ return;
1711
+ }
1712
+ const el = child.getEl();
1713
+ if (!el) return;
1714
+ lastPointer = { x: clientX, y: clientY };
1534
1715
  applyTooltipPosition(clientX, clientY);
1535
1716
  el.style.visibility = "visible";
1536
1717
  }
1537
1718
 
1538
1719
  function moveTooltip(clientX: number, clientY: number) {
1539
- if (!tooltipVisible) return;
1720
+ if (!tooltipVisible || tooltipMode.value === "sheet") return;
1540
1721
  pendingMoveX = clientX;
1541
1722
  pendingMoveY = clientY;
1542
1723
  if (pendingMoveFrame) return;
@@ -1554,6 +1735,7 @@ function hideTooltip() {
1554
1735
  if (!tooltipVisible) return;
1555
1736
  tooltipVisible = false;
1556
1737
  lastPointer = null;
1738
+ tooltipChildRef.value?.setOpen(false);
1557
1739
  const el = tooltipChildRef.value?.getEl();
1558
1740
  if (el) el.style.visibility = "hidden";
1559
1741
  }
@@ -1605,7 +1787,224 @@ function highlightWidthFor(item?: FocusItem): number {
1605
1787
  return item?.strokeWidth ?? effectiveStrokeWidth.value + 1;
1606
1788
  }
1607
1789
 
1790
+ // ─── Canvas renderer: redraw, picking, tooltip anchors ───────────────────
1791
+
1792
+ function canvasHighlightColor(): string {
1793
+ // light-dark() isn't paintable on a canvas context; resolve it through
1794
+ // the shared probe. (Cached by input — a mid-session theme flip keeps
1795
+ // the first resolution until remount, same tradeoff as resolveColorToRgb
1796
+ // elsewhere.)
1797
+ const rgb = resolveColorToRgb(HIGHLIGHT_STROKE);
1798
+ return rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : "#000";
1799
+ }
1800
+
1801
+ function currentCanvasView(): CanvasViewState {
1802
+ const t = zoomTransform(svgRef.value!);
1803
+ return {
1804
+ dpr: (typeof window !== "undefined" && window.devicePixelRatio) || 1,
1805
+ meetScale: viewScale.value,
1806
+ offsetX: meetOffsetX,
1807
+ offsetY: meetOffsetY,
1808
+ zoom: { k: t.k, x: t.x, y: t.y },
1809
+ };
1810
+ }
1811
+
1812
+ function canvasDrawState(): CanvasDrawState {
1813
+ return {
1814
+ strokeColor: props.strokeColor,
1815
+ strokeWidth: effectiveStrokeWidth.value,
1816
+ highlightStroke: canvasHighlightColor(),
1817
+ hoveredId: canvasHoveredId,
1818
+ focused: canvasFocused,
1819
+ overlays: canvasOverlays,
1820
+ };
1821
+ }
1822
+
1823
+ // Direct rendering with an adaptive buffered fallback. With the canvas on
1824
+ // its own compositor layer and feature outlines stroked as one path, a
1825
+ // full county-scale pass rasters in single-digit milliseconds, so every
1826
+ // frame renders the whole scene crisply. If a device proves slower (two
1827
+ // consecutive full draws over SLOW_FRAME_MS), gesture frames fall back to
1828
+ // blitting the last crisp render, refreshed at least every
1829
+ // GESTURE_REFRESH_MS, and always sharpened at rest.
1830
+ const SLOW_FRAME_MS = 24;
1831
+ const GESTURE_REFRESH_MS = 350;
1832
+ let slowDrawStreak = 0;
1833
+ let fastDrawStreak = 0;
1834
+ let rendererIsSlow = false;
1835
+ // Canvas backing stores are sized in device pixels, and ResizeObserver
1836
+ // doesn't fire when the window moves to a display with a different
1837
+ // devicePixelRatio. Watch a resolution media query instead — it matches
1838
+ // only the current DPR, so each change re-arms a fresh query (the
1839
+ // standard trick), resizes the backing store, and redraws.
1840
+ let dprQuery: MediaQueryList | null = null;
1841
+
1842
+ function armDprListener() {
1843
+ if (
1844
+ typeof window === "undefined" ||
1845
+ typeof window.matchMedia !== "function"
1846
+ ) {
1847
+ return;
1848
+ }
1849
+ dprQuery?.removeEventListener("change", onDprChange);
1850
+ dprQuery = window.matchMedia(
1851
+ `(resolution: ${window.devicePixelRatio || 1}dppx)`,
1852
+ );
1853
+ dprQuery.addEventListener("change", onDprChange);
1854
+ }
1855
+
1856
+ function onDprChange() {
1857
+ armDprListener();
1858
+ const canvas = canvasRef.value;
1859
+ const svgEl = svgRef.value;
1860
+ if (!isCanvas.value || !canvas || !svgEl) return;
1861
+ const rect = svgEl.getBoundingClientRect();
1862
+ if (rect.width) {
1863
+ const dpr = window.devicePixelRatio || 1;
1864
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
1865
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
1866
+ }
1867
+ requestRedraw();
1868
+ }
1869
+
1870
+ let snapshotCanvas: HTMLCanvasElement | null = null;
1871
+ let snapshotView: CanvasViewState | null = null;
1872
+ let lastFullDrawAt = 0;
1873
+
1874
+ function blitSnapshot(
1875
+ ctx: CanvasRenderingContext2D,
1876
+ view: CanvasViewState,
1877
+ img: HTMLCanvasElement,
1878
+ imgView: CanvasViewState,
1879
+ ) {
1880
+ const off = (v: CanvasViewState, axis: "x" | "y") =>
1881
+ v.dpr *
1882
+ ((axis === "x" ? v.offsetX : v.offsetY) +
1883
+ v.meetScale * (axis === "x" ? v.zoom.x : v.zoom.y));
1884
+ const r =
1885
+ (view.dpr * view.meetScale * view.zoom.k) /
1886
+ (imgView.dpr * imgView.meetScale * imgView.zoom.k);
1887
+ ctx.setTransform(
1888
+ r,
1889
+ 0,
1890
+ 0,
1891
+ r,
1892
+ off(view, "x") - r * off(imgView, "x"),
1893
+ off(view, "y") - r * off(imgView, "y"),
1894
+ );
1895
+ ctx.drawImage(img, 0, 0);
1896
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1897
+ }
1898
+
1899
+ function drawNow() {
1900
+ const display = canvasRef.value;
1901
+ if (!display) return;
1902
+ const ctx = display.getContext("2d");
1903
+ if (!ctx) return;
1904
+ const now = () =>
1905
+ typeof performance !== "undefined" ? performance.now() : 0;
1906
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1907
+ ctx.clearRect(0, 0, display.width, display.height);
1908
+ if (!scene) return;
1909
+ const view = currentCanvasView();
1910
+
1911
+ // Slow-device fallback: blit between throttled crisp refreshes while a
1912
+ // gesture is live.
1913
+ if (
1914
+ rendererIsSlow &&
1915
+ isZooming &&
1916
+ snapshotCanvas &&
1917
+ snapshotView &&
1918
+ typeof ctx.drawImage === "function" &&
1919
+ now() - lastFullDrawAt < GESTURE_REFRESH_MS
1920
+ ) {
1921
+ blitSnapshot(ctx, view, snapshotCanvas, snapshotView);
1922
+ if (canvasHoveredId) {
1923
+ drawHoverHighlight(ctx, scene, view, canvasDrawState(), canvasHoveredId);
1924
+ }
1925
+ return;
1926
+ }
1927
+
1928
+ const t0 = now();
1929
+ drawScene(ctx, scene, view, canvasDrawState());
1930
+ const dt = now() - t0;
1931
+ lastFullDrawAt = t0 + dt;
1932
+ // Symmetric hysteresis: two consecutive slow full draws engage the
1933
+ // fallback, two consecutive fast ones release it — so a cold-start
1934
+ // hiccup (JIT warmup, first-paint contention) can't lock a fast device
1935
+ // into blurry gesture blits forever.
1936
+ if (dt > SLOW_FRAME_MS) {
1937
+ fastDrawStreak = 0;
1938
+ if (++slowDrawStreak >= 2) rendererIsSlow = true;
1939
+ } else {
1940
+ slowDrawStreak = 0;
1941
+ if (++fastDrawStreak >= 2) rendererIsSlow = false;
1942
+ }
1943
+ if (!rendererIsSlow || typeof document === "undefined") return;
1944
+ // Keep a snapshot current for the fallback's gesture blits.
1945
+ if (!snapshotCanvas) snapshotCanvas = document.createElement("canvas");
1946
+ if (snapshotCanvas.width !== display.width) {
1947
+ snapshotCanvas.width = display.width;
1948
+ }
1949
+ if (snapshotCanvas.height !== display.height) {
1950
+ snapshotCanvas.height = display.height;
1951
+ }
1952
+ const sctx = snapshotCanvas.getContext("2d");
1953
+ if (sctx && typeof sctx.drawImage === "function") {
1954
+ sctx.setTransform(1, 0, 0, 1, 0, 0);
1955
+ sctx.clearRect(0, 0, snapshotCanvas.width, snapshotCanvas.height);
1956
+ sctx.drawImage(display, 0, 0);
1957
+ snapshotView = view;
1958
+ } else {
1959
+ snapshotView = null;
1960
+ }
1961
+ }
1962
+
1963
+ // rAF-coalesced: gesture zooms can emit several events per frame.
1964
+ function requestRedraw() {
1965
+ if (!isCanvas.value || redrawFrame) return;
1966
+ redrawFrame = requestAnimationFrame(() => {
1967
+ redrawFrame = 0;
1968
+ drawNow();
1969
+ });
1970
+ }
1971
+
1972
+ // Feature under a client point via the picking canvas — O(1) per query
1973
+ // regardless of feature count. The zoom transform is unwound here; the
1974
+ // picking bitmap itself is only rebuilt on geometry changes.
1975
+ function pickFeatureAt(clientX: number, clientY: number): string | null {
1976
+ const svgEl = svgRef.value;
1977
+ if (!svgEl || !scene || !pickingCtx) return null;
1978
+ const p = clientToViewBox(clientX, clientY);
1979
+ if (!p) return null;
1980
+ const [mx, my] = zoomTransform(svgEl).invert(p);
1981
+ const idx = pickIndexAt(pickingCtx, scene, mx, my);
1982
+ return idx == null ? null : scene.items[idx].id;
1983
+ }
1984
+
1985
+ // Client-coordinate center of a feature — the canvas-mode stand-in for a
1986
+ // path element's getBoundingClientRect (tooltip anchoring).
1987
+ function featureClientAnchor(featId: string): { x: number; y: number } | null {
1988
+ const svgEl = svgRef.value;
1989
+ const f = featuresById.value.get(featId);
1990
+ if (!svgEl || !f) return null;
1991
+ const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds(f);
1992
+ const [vx, vy] = zoomTransform(svgEl).apply([(x0 + x1) / 2, (y0 + y1) / 2]);
1993
+ let ctm: DOMMatrix | null = null;
1994
+ try {
1995
+ ctm = svgEl.getScreenCTM();
1996
+ } catch {
1997
+ ctm = null;
1998
+ }
1999
+ if (!ctm) return null;
2000
+ return {
2001
+ x: ctm.a * vx + ctm.c * vy + ctm.e,
2002
+ y: ctm.b * vx + ctm.d * vy + ctm.f,
2003
+ };
2004
+ }
2005
+
1608
2006
  function applyStrokeScale() {
2007
+ if (isCanvas.value) return; // stroke widths are computed at draw time
1609
2008
  const d = strokeDivisor();
1610
2009
  const eff = effectiveStrokeWidth.value;
1611
2010
  baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
@@ -1662,7 +2061,31 @@ function setHover(pathEl: SVGPathElement) {
1662
2061
  applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl));
1663
2062
  }
1664
2063
 
2064
+ // Renderer-agnostic hover entry point: canvas mode tracks an id and
2065
+ // repaints; svg mode restyles the path element.
2066
+ function setHoverId(featId: string) {
2067
+ if (isCanvas.value) {
2068
+ if (canvasHoveredId === featId) return;
2069
+ canvasHoveredId = featId;
2070
+ requestRedraw();
2071
+ return;
2072
+ }
2073
+ const p = pathsByFeatureId.get(featId);
2074
+ if (p) setHover(p);
2075
+ }
2076
+
1665
2077
  function clearHover() {
2078
+ if (isCanvas.value) {
2079
+ if (canvasHoveredId != null) {
2080
+ canvasHoveredId = null;
2081
+ // The hover highlight lives on the display layer, so dropping it
2082
+ // is just another cheap composite frame.
2083
+ requestRedraw();
2084
+ emit("stateHover", null);
2085
+ }
2086
+ hideTooltip();
2087
+ return;
2088
+ }
1666
2089
  if (hoveredEl) {
1667
2090
  const focusItem = focusedPathStyles.get(hoveredEl);
1668
2091
  if (focusItem == null) {
@@ -1704,7 +2127,7 @@ function onDelegatedEvent(event: Event) {
1704
2127
  // already swallows genuine post-drag clicks via clickDistance.
1705
2128
  if (event.type === "mouseover" && isZooming) return;
1706
2129
  const me = event as MouseEvent;
1707
- const featId = eventToFeatureId(me.target);
2130
+ const featId = featureIdFromEvent(me.target, me.clientX, me.clientY);
1708
2131
  if (!featId) return;
1709
2132
  const data = tooltipDataById.get(featId);
1710
2133
  if (!data) return;
@@ -1725,7 +2148,7 @@ function onDelegatedEvent(event: Event) {
1725
2148
  emitSelection(data);
1726
2149
  }
1727
2150
  } else if (event.type === "mouseover") {
1728
- setHover(pathsByFeatureId.get(featId)!);
2151
+ setHoverId(featId);
1729
2152
  if (hasInteractiveTooltip.value)
1730
2153
  showTooltip(featId, me.clientX, me.clientY);
1731
2154
  emit("stateHover", payload);
@@ -1743,6 +2166,44 @@ function onDelegatedMouseOut(event: MouseEvent) {
1743
2166
  clearHover();
1744
2167
  }
1745
2168
 
2169
+ // Canvas mode has no per-feature elements to delegate mouseover to —
2170
+ // hover is resolved by picking on every mousemove instead (a 1px read
2171
+ // from a CPU canvas; cheap even at mousemove rates).
2172
+ function onCanvasMouseMove(event: MouseEvent) {
2173
+ if (isZooming) return;
2174
+ const featId = pickFeatureAt(event.clientX, event.clientY);
2175
+ if (featId === canvasHoveredId) {
2176
+ if (featId) moveTooltip(event.clientX, event.clientY);
2177
+ return;
2178
+ }
2179
+ if (!featId) {
2180
+ clearHover();
2181
+ return;
2182
+ }
2183
+ const data = tooltipDataById.get(featId);
2184
+ if (!data) return;
2185
+ setHoverId(featId);
2186
+ emit("stateHover", { id: data.id, name: data.name, value: data.value });
2187
+ if (hasInteractiveTooltip.value) {
2188
+ showTooltip(featId, event.clientX, event.clientY);
2189
+ }
2190
+ }
2191
+
2192
+ function onCanvasMouseLeave() {
2193
+ clearHover();
2194
+ }
2195
+
2196
+ // Renderer-agnostic feature resolution for pointer events.
2197
+ function featureIdFromEvent(
2198
+ target: EventTarget | null,
2199
+ clientX: number,
2200
+ clientY: number,
2201
+ ): string | null {
2202
+ return isCanvas.value
2203
+ ? pickFeatureAt(clientX, clientY)
2204
+ : eventToFeatureId(target);
2205
+ }
2206
+
1746
2207
  function onTouchStart(event: TouchEvent) {
1747
2208
  // Single-finger only — a second touch is a pinch-zoom, not a selection.
1748
2209
  if (event.touches.length !== 1) {
@@ -1754,7 +2215,7 @@ function onTouchStart(event: TouchEvent) {
1754
2215
  x: t.clientX,
1755
2216
  y: t.clientY,
1756
2217
  time: event.timeStamp,
1757
- featId: eventToFeatureId(event.target),
2218
+ featId: featureIdFromEvent(event.target, t.clientX, t.clientY),
1758
2219
  };
1759
2220
  }
1760
2221
 
@@ -1833,14 +2294,35 @@ function onTouchEnd(event: TouchEvent) {
1833
2294
  }
1834
2295
 
1835
2296
  // A tap doubles as the touch replacement for hover: apply the hover-style
1836
- // highlight, announce it, and show the tooltip at the tapped point.
1837
- // (Continuous hover *tracking* stays off on touch — see setupInteraction —
1838
- // this is the one-shot equivalent.)
2297
+ // highlight, announce it, and show the tooltip. (Continuous hover
2298
+ // *tracking* stays off on touch — see setupInteraction — this is the
2299
+ // one-shot equivalent.)
1839
2300
  function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
1840
- const pathEl = pathsByFeatureId.get(data.id);
1841
- if (pathEl) setHover(pathEl);
2301
+ setHoverId(data.id);
1842
2302
  emit("stateHover", { id: data.id, name: data.name, value: data.value });
1843
- if (hasInteractiveTooltip.value) showTooltip(data.id, clientX, clientY);
2303
+ if (!hasInteractiveTooltip.value) return;
2304
+ // Anchor to the feature, not the finger: element rects and fixed
2305
+ // positioning share the layout-viewport coordinate space, so this
2306
+ // stays put when the page itself is pinch-zoomed — Safari reports
2307
+ // touch client coords in *visual*-viewport space, which would skew a
2308
+ // finger-anchored tooltip. Also keeps it out from under the finger.
2309
+ if (isCanvas.value) {
2310
+ const anchor = featureClientAnchor(data.id);
2311
+ if (anchor) showTooltip(data.id, anchor.x, anchor.y);
2312
+ else showTooltip(data.id, clientX, clientY);
2313
+ return;
2314
+ }
2315
+ const pathEl = pathsByFeatureId.get(data.id);
2316
+ if (pathEl) {
2317
+ const rect = pathEl.getBoundingClientRect();
2318
+ showTooltip(
2319
+ data.id,
2320
+ rect.left + rect.width / 2,
2321
+ rect.top + rect.height / 2,
2322
+ );
2323
+ } else {
2324
+ showTooltip(data.id, clientX, clientY);
2325
+ }
1844
2326
  }
1845
2327
 
1846
2328
  function onTouchCancel() {
@@ -1877,6 +2359,9 @@ function rebuildPaths() {
1877
2359
  // applyFocus can re-resolve against the new path tree.
1878
2360
  focusedPathStyles.clear();
1879
2361
  overlayPathEls.clear();
2362
+ canvasHoveredId = null;
2363
+ canvasFocused.clear();
2364
+ canvasOverlays = [];
1880
2365
 
1881
2366
  const path = pathGenerator.value;
1882
2367
  const features = featuresGeo.value.features;
@@ -1884,7 +2369,43 @@ function rebuildPaths() {
1884
2369
  // means the projection was fitted to an empty collection and yields NaN
1885
2370
  // coordinates — skip drawing entirely, including the state-borders mesh,
1886
2371
  // until real features arrive and re-trigger a rebuild.
1887
- if (features.length === 0) return;
2372
+ if (features.length === 0) {
2373
+ scene = null;
2374
+ pickingCtx = null;
2375
+ snapshotView = null;
2376
+ requestRedraw();
2377
+ return;
2378
+ }
2379
+
2380
+ if (isCanvas.value) {
2381
+ // Canvas backend: no DOM per feature — build the scene + picking
2382
+ // bitmap, and keep the tooltip payload cache exactly as svg mode does.
2383
+ for (const feat of features) {
2384
+ const id = String(feat.id);
2385
+ tooltipDataById.set(id, {
2386
+ id,
2387
+ name: featureName(feat),
2388
+ value: valueFor(id),
2389
+ feature: feat as ChoroplethFeature,
2390
+ });
2391
+ }
2392
+ const borders = stateBordersPath.value;
2393
+ scene = buildScene(
2394
+ features as Array<{ id?: string | number | null }>,
2395
+ path as (feature: never) => string | null,
2396
+ colorFor,
2397
+ borders ? path(borders) : null,
2398
+ );
2399
+ if (!pickingCanvas && typeof document !== "undefined") {
2400
+ pickingCanvas = document.createElement("canvas");
2401
+ }
2402
+ pickingCtx = pickingCanvas
2403
+ ? buildPicking(scene, width.value, height.value, pickingCanvas)
2404
+ : null;
2405
+ requestRedraw();
2406
+ return;
2407
+ }
2408
+
1888
2409
  const stroke = props.strokeColor;
1889
2410
  const wantsTitleFallback = !hasInteractiveTooltip.value;
1890
2411
 
@@ -1932,6 +2453,14 @@ function rebuildPaths() {
1932
2453
  }
1933
2454
 
1934
2455
  function updateFills() {
2456
+ if (isCanvas.value) {
2457
+ if (scene) {
2458
+ for (const item of scene.items) item.fill = colorFor(item.id);
2459
+ }
2460
+ for (const [id, entry] of tooltipDataById) entry.value = valueFor(id);
2461
+ requestRedraw();
2462
+ return;
2463
+ }
1935
2464
  const refreshTitle = !hasInteractiveTooltip.value;
1936
2465
  for (const [id, p] of pathsByFeatureId) {
1937
2466
  const value = valueFor(id);
@@ -1950,6 +2479,11 @@ function updateFills() {
1950
2479
  }
1951
2480
 
1952
2481
  function updateStrokes() {
2482
+ if (isCanvas.value) {
2483
+ // Stroke params are read at render time — refresh both buffers.
2484
+ requestRedraw();
2485
+ return;
2486
+ }
1953
2487
  for (const p of pathsByFeatureId.values()) {
1954
2488
  // Highlighted paths (hover / focus) keep their #555 + thicker stroke.
1955
2489
  if (p === hoveredEl || focusedPathStyles.has(p)) continue;
@@ -2045,6 +2579,19 @@ const fullscreen = useChartFullscreen({
2045
2579
 
2046
2580
  const menuItems = computed<ChartMenuItem[]>(() => {
2047
2581
  const fname = menuFilename();
2582
+ if (isCanvas.value) {
2583
+ // No SVG DOM to serialize in canvas mode; PNG exports straight off
2584
+ // the rendering canvas (already devicePixelRatio-sized).
2585
+ return [
2586
+ fullscreen.menuItem.value,
2587
+ {
2588
+ label: "Save as PNG",
2589
+ action: () => {
2590
+ if (canvasRef.value) saveCanvasPng(canvasRef.value, fname);
2591
+ },
2592
+ },
2593
+ ];
2594
+ }
2048
2595
  return [
2049
2596
  fullscreen.menuItem.value,
2050
2597
  {
@@ -2111,10 +2658,12 @@ watch(
2111
2658
 
2112
2659
  // Exiting the expanded touch view (✕, Escape) returns to the static inline
2113
2660
  // map at full extent — the inline map has no pan gestures, so a preserved
2114
- // transform would strand the user off-center.
2661
+ // transform would strand the user off-center. Any toggle also drops the
2662
+ // tooltip: the presentation mode (float vs sheet) may change with it.
2115
2663
  watch(
2116
2664
  () => fullscreen.isFullscreen.value,
2117
2665
  (expanded) => {
2666
+ hideTooltip();
2118
2667
  if (expanded || !isTouchDevice()) return;
2119
2668
  if (!svgRef.value || !zoomBehavior) return;
2120
2669
  const svg = select(svgRef.value);
@@ -2204,6 +2753,17 @@ watch(
2204
2753
  <div v-if="showZoomHint" class="choropleth-zoom-hint">
2205
2754
  {{ zoomHintText }}
2206
2755
  </div>
2756
+ <!-- Canvas backend paints here; the (empty) svg after it stays the
2757
+ interaction/zoom surface. pointer-events: none lets every event fall
2758
+ through to the svg even though the positioned canvas paints above
2759
+ the transparent svg. Position/size are pinned to the svg box by the
2760
+ resize observer. -->
2761
+ <canvas
2762
+ v-if="isCanvas"
2763
+ ref="canvasRef"
2764
+ class="choropleth-canvas"
2765
+ aria-hidden="true"
2766
+ />
2207
2767
  <svg
2208
2768
  ref="svgRef"
2209
2769
  :viewBox="`0 0 ${width} ${height}`"
@@ -2234,7 +2794,11 @@ watch(
2234
2794
  @zoom-out="zoomBy(1 / ZOOM_STEP)"
2235
2795
  @reset="resetZoom"
2236
2796
  />
2237
- <ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
2797
+ <ChoroplethTooltip
2798
+ v-if="hasInteractiveTooltip"
2799
+ ref="tooltipChildRef"
2800
+ :mode="tooltipMode"
2801
+ >
2238
2802
  <template #default="raw">
2239
2803
  <slot name="tooltip" v-bind="narrowSlotProps(raw)">
2240
2804
  <span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
@@ -2295,6 +2859,18 @@ watch(
2295
2859
  cursor: pointer;
2296
2860
  }
2297
2861
 
2862
+ .choropleth-canvas {
2863
+ position: absolute;
2864
+ top: 0;
2865
+ left: 0;
2866
+ pointer-events: none;
2867
+ /* Own compositor layer: without it WebKit repaints the surrounding
2868
+ page layer on every canvas frame (the same >100ms/frame pathology
2869
+ the svg renderer hit). Canvas mode implies interactivity, so the
2870
+ layer is always warranted. */
2871
+ will-change: transform;
2872
+ }
2873
+
2298
2874
  /* Overlays the top of the map: absolutely positioned with no `top`, the
2299
2875
  box keeps its static position — the top edge of the svg that follows it
2300
2876
  in the markup — without taking up flow space. The text carries a