@cfasim-ui/docs 0.7.4 → 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.
@@ -104,6 +104,17 @@ export function savePng(svg: SVGSVGElement, filename: string) {
104
104
  img.src = url;
105
105
  }
106
106
 
107
+ /**
108
+ * Downloads a rendering canvas's current contents as a PNG — no SVG
109
+ * round-trip. The canvas backing store is already sized at
110
+ * devicePixelRatio, so the export is naturally high-resolution.
111
+ */
112
+ export function saveCanvasPng(canvas: HTMLCanvasElement, filename: string) {
113
+ canvas.toBlob((blob) => {
114
+ if (blob) downloadBlob(blob, `${filename}.png`);
115
+ }, "image/png");
116
+ }
117
+
107
118
  export function downloadCsv(csv: string, filename: string) {
108
119
  downloadBlob(new Blob([csv], { type: "text/csv" }), `${filename}.csv`);
109
120
  }
@@ -607,6 +607,7 @@ on top with a `FocusItem`.
607
607
  geo-type="counties"
608
608
  data-geo-type="hsas"
609
609
  zoom
610
+ renderer="canvas"
610
611
  :touch-expand="false"
611
612
  :data="[
612
613
  { id: '060737', value: 80 },
@@ -634,6 +635,7 @@ on top with a `FocusItem`.
634
635
  geo-type="counties"
635
636
  data-geo-type="hsas"
636
637
  zoom
638
+ renderer="canvas"
637
639
  :touch-expand="false"
638
640
  :data="hsaData"
639
641
  :focus="[{ id: '06043' }, { id: '060737', geoType: 'hsas', style: 'dashed' }]"
@@ -759,9 +761,31 @@ Pass `tooltip-value-format` to format numeric values shown in the tooltip
759
761
  </template>
760
762
  </ComponentDemo>
761
763
 
764
+ ### Canvas rendering (`renderer="canvas"`)
765
+
766
+ For dense maps — every US county, HSAs, or anything that feels sluggish on
767
+ mobile — set `renderer="canvas"`. Instead of one DOM path per feature, the
768
+ whole map paints into a single `<canvas>`: zooming, panning, and hover/tap
769
+ highlights redraw in a few milliseconds regardless of feature count, and
770
+ DOM memory stays flat. Interactions are identical (hover, click/tap
771
+ selection, `focus`, every zoom mode and touch flow); hit-testing runs
772
+ through an offscreen picking bitmap, so it stays pixel-accurate.
773
+
774
+ Differences from the default SVG renderer:
775
+
776
+ - The menu offers **Fullscreen** and **Save as PNG** only (there is no SVG
777
+ DOM to serialize; the PNG exports straight off the rendering canvas).
778
+ - There is no per-feature `<title>` fallback or per-feature DOM for
779
+ assistive tech — configure an interactive tooltip (`tooltip-trigger` or
780
+ the `#tooltip` slot), or stay on SVG where that fallback matters.
781
+ - `renderer` is fixed at mount.
782
+
783
+ The dense county demo below uses it.
784
+
762
785
  ### Dense county map
763
786
 
764
- Renders every US county with a value and a custom tooltip slot.
787
+ Renders every US county with a value and a custom tooltip slot, on the
788
+ canvas backend.
765
789
 
766
790
  <ComponentDemo>
767
791
  <ChoroplethMap
@@ -769,6 +793,7 @@ Renders every US county with a value and a custom tooltip slot.
769
793
  geo-type="counties"
770
794
  :data="denseCountyData"
771
795
  zoom
796
+ renderer="canvas"
772
797
  :color-scale="{ min: '#f0f5ff', max: '#08306b' }"
773
798
  title="All US counties — tooltip perf demo"
774
799
  :height="500"
@@ -793,7 +818,13 @@ const data = countiesTopo.objects.counties.geometries.map((g, i) => ({
793
818
  }));
794
819
  </script>
795
820
 
796
- <ChoroplethMap :topology="countiesTopo" geo-type="counties" :data="data" zoom>
821
+ <ChoroplethMap
822
+ :topology="countiesTopo"
823
+ geo-type="counties"
824
+ :data="data"
825
+ zoom
826
+ renderer="canvas"
827
+ >
797
828
  <template #tooltip="{ id, name, value }">
798
829
  <div style="font-weight: 600">{{ name }}</div>
799
830
  <div style="opacity: 0.7; font-size: 0.85em">FIPS {{ id }}</div>
@@ -913,6 +944,7 @@ tech).
913
944
  | `focus` | `FocusValue` | No | — |
914
945
  | `focusZoomLevel` | `number` | No | `4` |
915
946
  | `focusZoom` | `boolean` | No | `true` |
947
+ | `renderer` | `"svg" \| "canvas"` | No | `"svg"` |
916
948
  | `fullscreenTarget` | `string \| HTMLElement` | No | — |
917
949
 
918
950
 
@@ -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 };
@@ -483,6 +531,14 @@ function setupInteraction() {
483
531
  // degrades zoom/pan); taps provide the one-shot hover + tooltip instead
484
532
  // (see touchHover).
485
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
+ }
486
542
  g.addEventListener("click", onDelegatedEvent);
487
543
  g.addEventListener("mouseover", onDelegatedEvent);
488
544
  g.addEventListener("mousemove", onDelegatedMouseMove);
@@ -496,6 +552,9 @@ function teardownInteraction() {
496
552
  svg.removeEventListener("touchstart", onTouchStart);
497
553
  svg.removeEventListener("touchend", onTouchEnd);
498
554
  svg.removeEventListener("touchcancel", onTouchCancel);
555
+ svg.removeEventListener("click", onDelegatedEvent);
556
+ svg.removeEventListener("mousemove", onCanvasMouseMove);
557
+ svg.removeEventListener("mouseleave", onCanvasMouseLeave);
499
558
  }
500
559
  if (!g) return;
501
560
  g.removeEventListener("click", onDelegatedEvent);
@@ -529,13 +588,43 @@ onMounted(() => {
529
588
  attachTooltipObserver();
530
589
  if (svgRef.value && typeof ResizeObserver !== "undefined") {
531
590
  svgResizeObserver = new ResizeObserver((entries) => {
532
- const w = entries[0]?.contentRect.width;
533
- if (!w) return;
534
- viewScale.value = w / CANONICAL_WIDTH;
535
- 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
+ }
536
624
  });
537
625
  svgResizeObserver.observe(svgRef.value);
538
626
  }
627
+ if (isCanvas.value) armDprListener();
539
628
  window.addEventListener("scroll", dismissOnViewportChange, {
540
629
  passive: true,
541
630
  capture: true,
@@ -546,7 +635,9 @@ onMounted(() => {
546
635
  onUnmounted(() => {
547
636
  tooltipObserver?.disconnect();
548
637
  svgResizeObserver?.disconnect();
638
+ dprQuery?.removeEventListener("change", onDprChange);
549
639
  if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
640
+ if (redrawFrame) cancelAnimationFrame(redrawFrame);
550
641
  window.clearTimeout(pendingSelectTimer);
551
642
  teardownZoom();
552
643
  teardownInteraction();
@@ -576,7 +667,10 @@ function setupZoom() {
576
667
  // click press felt broken. The tooltip only needs to go once the
577
668
  // map actually moves under the cursor.
578
669
  clearHover();
579
- if (mapGroupRef.value) {
670
+ if (isCanvas.value) {
671
+ // Frames blit + progressively sharpen via the render pipeline.
672
+ requestRedraw();
673
+ } else if (mapGroupRef.value) {
580
674
  mapGroupRef.value.setAttribute("transform", event.transform);
581
675
  }
582
676
  const t = event.transform;
@@ -587,6 +681,8 @@ function setupZoom() {
587
681
  })
588
682
  .on("end", () => {
589
683
  isZooming = false;
684
+ // Sharpen after gestures: an idle-view frame starts a base refresh.
685
+ if (isCanvas.value) requestRedraw();
590
686
  });
591
687
 
592
688
  // Dynamic filter deciding which pointer gestures d3-zoom may handle —
@@ -700,33 +796,55 @@ function applyFocus() {
700
796
  const baseResolved = resolved.filter((r) => r.geoType === props.geoType);
701
797
  const overlayResolved = resolved.filter((r) => r.geoType !== props.geoType);
702
798
 
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);
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);
726
843
 
727
- // Cross-geoType outlines render as non-interactive paths on top of
728
- // the base layer.
729
- syncOverlayPaths(overlayResolved);
844
+ // Cross-geoType outlines render as non-interactive paths on top of
845
+ // the base layer.
846
+ syncOverlayPaths(overlayResolved);
847
+ }
730
848
 
731
849
  // Clearing focus doesn't touch the zoom transform — the user keeps
732
850
  // whatever pan/zoom they had. Only the reset button snaps back to
@@ -773,10 +891,15 @@ function applyFocus() {
773
891
  const showFocusTooltip = () => {
774
892
  if (!hasInteractiveTooltip.value || !tooltipTarget) return;
775
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
+ }
776
901
  const pathEl = pathsByFeatureId.get(firstId);
777
902
  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
903
  const rect = pathEl.getBoundingClientRect();
781
904
  showTooltip(
782
905
  firstId,
@@ -963,13 +1086,16 @@ function zoomBy(factor: number) {
963
1086
  // Zoom level a tap zooms to (expanding or in place).
964
1087
  const TAP_ZOOM_SCALE = 2;
965
1088
 
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;
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;
973
1099
  let ctm: DOMMatrix | null = null;
974
1100
  try {
975
1101
  ctm = svgEl.getScreenCTM();
@@ -977,13 +1103,27 @@ function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
977
1103
  ctm = null;
978
1104
  }
979
1105
  if (ctm) {
980
- // client → viewBox coords (the svg CTM covers viewBox scaling and
981
- // letterboxing), then unwind the current zoom transform.
982
1106
  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]);
1107
+ return [
1108
+ inv.a * clientX + inv.c * clientY + inv.e,
1109
+ inv.b * clientX + inv.d * clientY + inv.f,
1110
+ ];
986
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);
987
1127
  return zoomIdentity
988
1128
  .translate(width.value / 2 - k * mx, height.value / 2 - k * my)
989
1129
  .scale(k);
@@ -1647,7 +1787,224 @@ function highlightWidthFor(item?: FocusItem): number {
1647
1787
  return item?.strokeWidth ?? effectiveStrokeWidth.value + 1;
1648
1788
  }
1649
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
+
1650
2006
  function applyStrokeScale() {
2007
+ if (isCanvas.value) return; // stroke widths are computed at draw time
1651
2008
  const d = strokeDivisor();
1652
2009
  const eff = effectiveStrokeWidth.value;
1653
2010
  baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
@@ -1704,7 +2061,31 @@ function setHover(pathEl: SVGPathElement) {
1704
2061
  applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl));
1705
2062
  }
1706
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
+
1707
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
+ }
1708
2089
  if (hoveredEl) {
1709
2090
  const focusItem = focusedPathStyles.get(hoveredEl);
1710
2091
  if (focusItem == null) {
@@ -1746,7 +2127,7 @@ function onDelegatedEvent(event: Event) {
1746
2127
  // already swallows genuine post-drag clicks via clickDistance.
1747
2128
  if (event.type === "mouseover" && isZooming) return;
1748
2129
  const me = event as MouseEvent;
1749
- const featId = eventToFeatureId(me.target);
2130
+ const featId = featureIdFromEvent(me.target, me.clientX, me.clientY);
1750
2131
  if (!featId) return;
1751
2132
  const data = tooltipDataById.get(featId);
1752
2133
  if (!data) return;
@@ -1767,7 +2148,7 @@ function onDelegatedEvent(event: Event) {
1767
2148
  emitSelection(data);
1768
2149
  }
1769
2150
  } else if (event.type === "mouseover") {
1770
- setHover(pathsByFeatureId.get(featId)!);
2151
+ setHoverId(featId);
1771
2152
  if (hasInteractiveTooltip.value)
1772
2153
  showTooltip(featId, me.clientX, me.clientY);
1773
2154
  emit("stateHover", payload);
@@ -1785,6 +2166,44 @@ function onDelegatedMouseOut(event: MouseEvent) {
1785
2166
  clearHover();
1786
2167
  }
1787
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
+
1788
2207
  function onTouchStart(event: TouchEvent) {
1789
2208
  // Single-finger only — a second touch is a pinch-zoom, not a selection.
1790
2209
  if (event.touches.length !== 1) {
@@ -1796,7 +2215,7 @@ function onTouchStart(event: TouchEvent) {
1796
2215
  x: t.clientX,
1797
2216
  y: t.clientY,
1798
2217
  time: event.timeStamp,
1799
- featId: eventToFeatureId(event.target),
2218
+ featId: featureIdFromEvent(event.target, t.clientX, t.clientY),
1800
2219
  };
1801
2220
  }
1802
2221
 
@@ -1879,16 +2298,22 @@ function onTouchEnd(event: TouchEvent) {
1879
2298
  // *tracking* stays off on touch — see setupInteraction — this is the
1880
2299
  // one-shot equivalent.)
1881
2300
  function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
1882
- const pathEl = pathsByFeatureId.get(data.id);
1883
- if (pathEl) setHover(pathEl);
2301
+ setHoverId(data.id);
1884
2302
  emit("stateHover", { id: data.id, name: data.name, value: data.value });
1885
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);
1886
2316
  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
2317
  const rect = pathEl.getBoundingClientRect();
1893
2318
  showTooltip(
1894
2319
  data.id,
@@ -1934,6 +2359,9 @@ function rebuildPaths() {
1934
2359
  // applyFocus can re-resolve against the new path tree.
1935
2360
  focusedPathStyles.clear();
1936
2361
  overlayPathEls.clear();
2362
+ canvasHoveredId = null;
2363
+ canvasFocused.clear();
2364
+ canvasOverlays = [];
1937
2365
 
1938
2366
  const path = pathGenerator.value;
1939
2367
  const features = featuresGeo.value.features;
@@ -1941,7 +2369,43 @@ function rebuildPaths() {
1941
2369
  // means the projection was fitted to an empty collection and yields NaN
1942
2370
  // coordinates — skip drawing entirely, including the state-borders mesh,
1943
2371
  // until real features arrive and re-trigger a rebuild.
1944
- 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
+
1945
2409
  const stroke = props.strokeColor;
1946
2410
  const wantsTitleFallback = !hasInteractiveTooltip.value;
1947
2411
 
@@ -1989,6 +2453,14 @@ function rebuildPaths() {
1989
2453
  }
1990
2454
 
1991
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
+ }
1992
2464
  const refreshTitle = !hasInteractiveTooltip.value;
1993
2465
  for (const [id, p] of pathsByFeatureId) {
1994
2466
  const value = valueFor(id);
@@ -2007,6 +2479,11 @@ function updateFills() {
2007
2479
  }
2008
2480
 
2009
2481
  function updateStrokes() {
2482
+ if (isCanvas.value) {
2483
+ // Stroke params are read at render time — refresh both buffers.
2484
+ requestRedraw();
2485
+ return;
2486
+ }
2010
2487
  for (const p of pathsByFeatureId.values()) {
2011
2488
  // Highlighted paths (hover / focus) keep their #555 + thicker stroke.
2012
2489
  if (p === hoveredEl || focusedPathStyles.has(p)) continue;
@@ -2102,6 +2579,19 @@ const fullscreen = useChartFullscreen({
2102
2579
 
2103
2580
  const menuItems = computed<ChartMenuItem[]>(() => {
2104
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
+ }
2105
2595
  return [
2106
2596
  fullscreen.menuItem.value,
2107
2597
  {
@@ -2263,6 +2753,17 @@ watch(
2263
2753
  <div v-if="showZoomHint" class="choropleth-zoom-hint">
2264
2754
  {{ zoomHintText }}
2265
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
+ />
2266
2767
  <svg
2267
2768
  ref="svgRef"
2268
2769
  :viewBox="`0 0 ${width} ${height}`"
@@ -2358,6 +2859,18 @@ watch(
2358
2859
  cursor: pointer;
2359
2860
  }
2360
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
+
2361
2874
  /* Overlays the top of the map: absolutely positioned with no `top`, the
2362
2875
  box keeps its static position — the top edge of the svg that follows it
2363
2876
  in the markup — without taking up flow space. The text carries a
@@ -0,0 +1,319 @@
1
+ // Canvas rendering backend for ChoroplethMap (`renderer="canvas"`).
2
+ //
3
+ // Pure scene/draw/pick helpers, kept free of Vue and DOM lookups so they
4
+ // can be unit-tested against a recording fake context. The component owns
5
+ // all state; these functions just paint and answer point queries.
6
+
7
+ /** Matches ChoroplethMap's FocusStyle without importing from the SFC. */
8
+ export type CanvasDashStyle = "solid" | "dashed" | "dotted";
9
+
10
+ export interface SceneItem {
11
+ id: string;
12
+ path: Path2D;
13
+ fill: string;
14
+ }
15
+
16
+ export interface CanvasScene {
17
+ items: SceneItem[];
18
+ indexById: Map<string, number>;
19
+ /**
20
+ * Every feature outline concatenated into one path — features share a
21
+ * stroke color, and one native stroke call beats thousands (WebKit's
22
+ * canvas2D especially).
23
+ */
24
+ outlines: Path2D | null;
25
+ /** State-borders mesh (counties / hsas mode). */
26
+ borders: Path2D | null;
27
+ }
28
+
29
+ export interface CanvasView {
30
+ dpr: number;
31
+ /** Uniform viewBox→CSS scale (xMidYMid meet). */
32
+ meetScale: number;
33
+ /** CSS-px letterbox offsets inside the canvas box. */
34
+ offsetX: number;
35
+ offsetY: number;
36
+ zoom: { k: number; x: number; y: number };
37
+ }
38
+
39
+ export interface CanvasOverlayItem {
40
+ path: Path2D;
41
+ stroke: string;
42
+ strokeWidth?: number;
43
+ style?: CanvasDashStyle;
44
+ }
45
+
46
+ export interface CanvasHighlightItem {
47
+ stroke?: string;
48
+ strokeWidth?: number;
49
+ style?: CanvasDashStyle;
50
+ }
51
+
52
+ export interface CanvasBaseState {
53
+ strokeColor: string;
54
+ /** Base feature stroke width in CSS px (effectiveStrokeWidth). */
55
+ strokeWidth: number;
56
+ /** Resolved highlight color (light-dark() can't paint on canvas). */
57
+ highlightStroke: string;
58
+ focused: Map<string, CanvasHighlightItem>;
59
+ overlays: CanvasOverlayItem[];
60
+ }
61
+
62
+ export interface CanvasDrawState extends CanvasBaseState {
63
+ hoveredId: string | null;
64
+ }
65
+
66
+ function applyViewTransform(
67
+ ctx: CanvasRenderingContext2D,
68
+ view: CanvasView,
69
+ ): void {
70
+ const s = view.dpr * view.meetScale * view.zoom.k;
71
+ ctx.setTransform(
72
+ s,
73
+ 0,
74
+ 0,
75
+ s,
76
+ view.dpr * (view.offsetX + view.meetScale * view.zoom.x),
77
+ view.dpr * (view.offsetY + view.meetScale * view.zoom.y),
78
+ );
79
+ }
80
+
81
+ /** Line width (map units) rendering as `css` CSS px on screen. */
82
+ function lwFor(view: CanvasView): (css: number) => number {
83
+ return (css) => css / (view.meetScale * view.zoom.k || 1);
84
+ }
85
+
86
+ function applyLineDash(
87
+ ctx: CanvasRenderingContext2D,
88
+ lw: (css: number) => number,
89
+ style: CanvasDashStyle | undefined,
90
+ ): void {
91
+ if (style === "dashed") {
92
+ ctx.setLineDash([lw(8), lw(4)]);
93
+ ctx.lineCap = "butt";
94
+ } else if (style === "dotted") {
95
+ ctx.setLineDash([0, lw(5)]);
96
+ ctx.lineCap = "round";
97
+ } else {
98
+ ctx.setLineDash([]);
99
+ ctx.lineCap = "butt";
100
+ }
101
+ }
102
+
103
+ export function buildScene(
104
+ features: Array<{ id?: string | number | null }>,
105
+ pathFor: (feature: never) => string | null,
106
+ colorFor: (id: string) => string,
107
+ bordersD?: string | null,
108
+ ): CanvasScene {
109
+ const items: SceneItem[] = [];
110
+ const indexById = new Map<string, number>();
111
+ const outlines = new Path2D();
112
+ let outlineCount = 0;
113
+ for (const feat of features) {
114
+ const d = pathFor(feat as never);
115
+ if (!d) continue;
116
+ const id = String(feat.id);
117
+ const path = new Path2D(d);
118
+ indexById.set(id, items.length);
119
+ items.push({ id, path, fill: colorFor(id) });
120
+ if (typeof outlines.addPath === "function") {
121
+ outlines.addPath(path);
122
+ outlineCount++;
123
+ }
124
+ }
125
+ return {
126
+ items,
127
+ indexById,
128
+ outlines: outlineCount ? outlines : null,
129
+ borders: bordersD ? new Path2D(bordersD) : null,
130
+ };
131
+ }
132
+
133
+ function highlightOne(
134
+ ctx: CanvasRenderingContext2D,
135
+ scene: CanvasScene,
136
+ lw: (css: number) => number,
137
+ state: CanvasBaseState,
138
+ id: string,
139
+ item?: CanvasHighlightItem,
140
+ ): void {
141
+ const idx = scene.indexById.get(id);
142
+ if (idx == null) return;
143
+ const it = scene.items[idx];
144
+ // Re-filling first covers neighbors' strokes — the canvas equivalent of
145
+ // the SVG renderer's appendChild raise.
146
+ ctx.fillStyle = it.fill;
147
+ ctx.fill(it.path);
148
+ ctx.strokeStyle = item?.stroke ?? state.highlightStroke;
149
+ ctx.lineWidth = lw(item?.strokeWidth ?? state.strokeWidth + 1);
150
+ applyLineDash(ctx, lw, item?.style);
151
+ ctx.stroke(it.path);
152
+ }
153
+
154
+ /**
155
+ * Base pass internals composed by drawScene. The transform maps canonical
156
+ * viewBox coordinates to device pixels:
157
+ * `device = dpr · (letterboxOffset + meetScale · zoom(point))`. Stroke
158
+ * widths are compensated so a visual width `w` CSS px stays constant at
159
+ * any zoom, mirroring the SVG renderer's `strokeDivisor`.
160
+ */
161
+ function beginBasePass(ctx: CanvasRenderingContext2D, view: CanvasView): void {
162
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
163
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
164
+ applyViewTransform(ctx, view);
165
+ ctx.lineJoin = "round";
166
+ ctx.setLineDash([]);
167
+ }
168
+
169
+ /** Fills items [from, to); returns the next index. The ctx keeps the
170
+ * transform beginBasePass applied, so slices can span multiple frames. */
171
+ function drawFillSlice(
172
+ ctx: CanvasRenderingContext2D,
173
+ scene: CanvasScene,
174
+ from: number,
175
+ to: number,
176
+ ): number {
177
+ const end = Math.min(to, scene.items.length);
178
+ for (let i = from; i < end; i++) {
179
+ ctx.fillStyle = scene.items[i].fill;
180
+ ctx.fill(scene.items[i].path);
181
+ }
182
+ return end;
183
+ }
184
+
185
+ /** Outlines, borders, focus highlights, and overlays — everything but the
186
+ * transient hover, which the display frame draws live on top of blits. */
187
+ function finishBasePass(
188
+ ctx: CanvasRenderingContext2D,
189
+ scene: CanvasScene,
190
+ view: CanvasView,
191
+ state: CanvasBaseState,
192
+ ): void {
193
+ const lw = lwFor(view);
194
+ ctx.setLineDash([]);
195
+ ctx.lineWidth = lw(state.strokeWidth);
196
+ ctx.strokeStyle = state.strokeColor;
197
+ if (scene.outlines) {
198
+ ctx.stroke(scene.outlines);
199
+ } else {
200
+ for (const item of scene.items) ctx.stroke(item.path);
201
+ }
202
+
203
+ if (scene.borders) {
204
+ ctx.lineWidth = lw(1);
205
+ ctx.stroke(scene.borders);
206
+ }
207
+
208
+ for (const [id, item] of state.focused) {
209
+ highlightOne(ctx, scene, lw, state, id, item);
210
+ }
211
+
212
+ // Overlays above the focus highlights, matching the SVG z-order.
213
+ for (const o of state.overlays) {
214
+ ctx.strokeStyle = o.stroke;
215
+ ctx.lineWidth = lw(o.strokeWidth ?? state.strokeWidth + 1.5);
216
+ applyLineDash(ctx, lw, o.style);
217
+ ctx.stroke(o.path);
218
+ }
219
+ ctx.setLineDash([]);
220
+ }
221
+
222
+ /** Live hover highlight, drawn by the display frame on top of the blitted
223
+ * base (focused features are already highlighted in the base itself). */
224
+ export function drawHoverHighlight(
225
+ ctx: CanvasRenderingContext2D,
226
+ scene: CanvasScene,
227
+ view: CanvasView,
228
+ state: CanvasBaseState,
229
+ hoveredId: string | null,
230
+ ): void {
231
+ if (!hoveredId || state.focused.has(hoveredId)) return;
232
+ applyViewTransform(ctx, view);
233
+ ctx.lineJoin = "round";
234
+ highlightOne(ctx, scene, lwFor(view), state, hoveredId);
235
+ ctx.setLineDash([]);
236
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
237
+ }
238
+
239
+ /** One-shot full repaint (composition of the passes above). */
240
+ export function drawScene(
241
+ ctx: CanvasRenderingContext2D,
242
+ scene: CanvasScene,
243
+ view: CanvasView,
244
+ state: CanvasDrawState,
245
+ ): void {
246
+ beginBasePass(ctx, view);
247
+ drawFillSlice(ctx, scene, 0, scene.items.length);
248
+ finishBasePass(ctx, scene, view, state);
249
+ drawHoverHighlight(ctx, scene, view, state, state.hoveredId);
250
+ }
251
+
252
+ /** Unique fill color for feature index `i` on the picking canvas. */
253
+ export function indexToColor(i: number): string {
254
+ const v = i + 1;
255
+ return `rgb(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255})`;
256
+ }
257
+
258
+ /**
259
+ * Paints every feature in a unique index color onto `canvas` at canonical
260
+ * viewBox resolution. Rebuilt only when the geometry changes; hit-testing
261
+ * then reads one pixel per query regardless of feature count.
262
+ */
263
+ export function buildPicking(
264
+ scene: CanvasScene,
265
+ width: number,
266
+ height: number,
267
+ canvas: HTMLCanvasElement,
268
+ ): CanvasRenderingContext2D | null {
269
+ canvas.width = Math.max(1, Math.round(width));
270
+ canvas.height = Math.max(1, Math.round(height));
271
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
272
+ if (!ctx) return null;
273
+ ctx.imageSmoothingEnabled = false;
274
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
275
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
276
+ scene.items.forEach((item, i) => {
277
+ ctx.fillStyle = indexToColor(i);
278
+ ctx.fill(item.path);
279
+ });
280
+ return ctx;
281
+ }
282
+
283
+ /**
284
+ * Feature index at canonical viewBox coordinates (the caller has already
285
+ * inverted the zoom transform), or null over the background. Fill edges
286
+ * antialias into blended colors, so a candidate whose decoded index is
287
+ * implausible — or that fails a point-in-path check — falls back to a
288
+ * linear `isPointInPath` scan (rare, borders only).
289
+ */
290
+ export function pickIndexAt(
291
+ ctx: CanvasRenderingContext2D,
292
+ scene: CanvasScene,
293
+ x: number,
294
+ y: number,
295
+ ): number | null {
296
+ const px = Math.round(x);
297
+ const py = Math.round(y);
298
+ if (px < 0 || py < 0 || px >= ctx.canvas.width || py >= ctx.canvas.height) {
299
+ return null;
300
+ }
301
+ const d = ctx.getImageData(px, py, 1, 1).data;
302
+ if (d[3] === 0) return null;
303
+ const idx = ((d[0] << 16) | (d[1] << 8) | d[2]) - 1;
304
+ if (idx >= 0 && idx < scene.items.length) {
305
+ if (
306
+ typeof ctx.isPointInPath !== "function" ||
307
+ ctx.isPointInPath(scene.items[idx].path, x, y)
308
+ ) {
309
+ return idx;
310
+ }
311
+ }
312
+ // Antialiased edge pixel: verify the hard way.
313
+ if (typeof ctx.isPointInPath === "function") {
314
+ for (let i = 0; i < scene.items.length; i++) {
315
+ if (ctx.isPointInPath(scene.items[i].path, x, y)) return i;
316
+ }
317
+ }
318
+ return null;
319
+ }
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.4",
2
+ "version": "0.7.5",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {