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