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