@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
|
|
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
|
|
533
|
-
if (!
|
|
534
|
-
|
|
535
|
-
|
|
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 (
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
//
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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