@cfasim-ui/docs 0.6.4 → 0.7.1
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/ChoroplethMap/ChoroplethMap.md +105 -29
- package/charts/ChoroplethMap/ChoroplethMap.vue +528 -161
- package/charts/DataTable/DataTable.vue +1 -7
- package/charts/_shared/ChartZoomControls.vue +138 -0
- package/charts/_shared/index.ts +4 -15
- package/charts/_shared/seriesCsv.ts +2 -2
- package/charts/_shared/touch.ts +8 -0
- package/charts/_shared/useChartFoundation.ts +0 -1
- package/components/MultiSelect/MultiSelect.md +1 -1
- package/components/MultiSelect/MultiSelect.vue +24 -83
- package/components/NumberInput/NumberInput.md +5 -5
- package/components/NumberInput/NumberInput.vue +12 -59
- package/components/SelectBox/SelectBox.md +1 -0
- package/components/SelectBox/SelectBox.vue +34 -125
- package/components/TextInput/TextInput.md +2 -1
- package/components/TextInput/TextInput.vue +11 -51
- package/components/ToggleGroup/ToggleGroup.md +2 -2
- package/components/ToggleGroup/ToggleGroup.vue +21 -26
- package/components/_internal/FieldLabel.vue +27 -0
- package/components/_internal/field.ts +27 -0
- package/components/_internal/input.css +54 -0
- package/components/_internal/listbox.css +53 -0
- package/components/env.d.ts +4 -0
- package/index.json +8 -8
- package/package.json +1 -1
- package/pyodide/messages.ts +23 -0
- package/pyodide/pyodide.worker.ts +2 -21
- package/pyodide/pyodideWorkerApi.ts +7 -44
- package/shared/index.ts +1 -0
- package/shared/workerError.ts +17 -0
- package/wasm/messages.ts +6 -0
- package/wasm/wasm.worker.ts +1 -7
- package/wasm/wasmWorkerApi.ts +8 -26
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import {
|
|
3
3
|
computed,
|
|
4
|
+
nextTick,
|
|
4
5
|
ref,
|
|
5
6
|
watch,
|
|
6
7
|
onMounted,
|
|
@@ -9,7 +10,12 @@ import {
|
|
|
9
10
|
useSlots,
|
|
10
11
|
} from "vue";
|
|
11
12
|
import { geoPath, geoAlbersUsa, geoMercator } from "d3-geo";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
zoom as d3Zoom,
|
|
15
|
+
zoomIdentity,
|
|
16
|
+
zoomTransform,
|
|
17
|
+
type ZoomTransform,
|
|
18
|
+
} from "d3-zoom";
|
|
13
19
|
import { select } from "d3-selection";
|
|
14
20
|
// Side-effect import: enables `selection.transition()` on d3 selections so
|
|
15
21
|
// `applyFocus` can animate the zoom transform.
|
|
@@ -22,6 +28,9 @@ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
|
22
28
|
import { saveSvg, savePng } from "../ChartMenu/download.js";
|
|
23
29
|
import {
|
|
24
30
|
useChartFullscreen,
|
|
31
|
+
isTouchDevice,
|
|
32
|
+
ChartZoomControls,
|
|
33
|
+
resolveColorToRgb,
|
|
25
34
|
TITLE_FONT_SIZE,
|
|
26
35
|
TITLE_LINE_HEIGHT,
|
|
27
36
|
TITLE_FONT_WEIGHT,
|
|
@@ -81,10 +90,14 @@ export interface FocusItem {
|
|
|
81
90
|
* `"dashed"` uses long dashes; `"dotted"` uses small round dots —
|
|
82
91
|
* useful when stacking multiple outlines of different geoTypes. */
|
|
83
92
|
style?: FocusStyle;
|
|
84
|
-
/** Stroke color for the outline.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
93
|
+
/** Stroke color for the outline. In-place highlights (items in the
|
|
94
|
+
* base geoType) default to pure black/white following the theme
|
|
95
|
+
* (`light-dark(#000, #fff)`); cross-geoType overlay paths default to
|
|
96
|
+
* `"#fff"`. */
|
|
87
97
|
stroke?: string;
|
|
98
|
+
/** Outline width in CSS px. Defaults to the map's stroke width + 1
|
|
99
|
+
* for in-place highlights and + 1.5 for cross-geoType overlays. */
|
|
100
|
+
strokeWidth?: number;
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
export type FocusValue = string | FocusItem | Array<string | FocusItem> | null;
|
|
@@ -151,18 +164,45 @@ const props = withDefaults(
|
|
|
151
164
|
legend?: boolean;
|
|
152
165
|
/** Title displayed next to the legend */
|
|
153
166
|
legendTitle?: string;
|
|
154
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Enable the activate-to-zoom interaction. Default `false` — the map
|
|
169
|
+
* is fully static (tooltips and click-select still work, and
|
|
170
|
+
* programmatic `focus` zoom still applies). When enabled, the map
|
|
171
|
+
* stays static until the user double-clicks (desktop) or taps (touch):
|
|
172
|
+
* desktop double-click zooms in place alongside always-visible
|
|
173
|
+
* +/−/reset controls, with drag-pan once zoomed; on touch a tap
|
|
174
|
+
* expands the map to fill the window, where one finger pans, pinch
|
|
175
|
+
* zooms, and a tap selects. The scroll wheel never zooms inline, so
|
|
176
|
+
* the map can't hijack page scrolling.
|
|
177
|
+
*/
|
|
155
178
|
zoom?: boolean;
|
|
156
|
-
/** Enable click-and-drag panning. Default: false */
|
|
157
|
-
pan?: boolean;
|
|
158
179
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
180
|
+
* Interaction style when `zoom` is enabled. `"activate"` (default)
|
|
181
|
+
* keeps the map static until a double-click / tap / feature click, so
|
|
182
|
+
* the page scrolls freely past it. `"scroll"` makes the map immediately
|
|
183
|
+
* interactive — the wheel zooms, dragging pans, and touch gestures work
|
|
184
|
+
* inline with no tap-to-expand step. Use it for full-page experiences
|
|
185
|
+
* where the map is the main surface and there's no page scroll to
|
|
186
|
+
* hijack.
|
|
187
|
+
*/
|
|
188
|
+
zoomMode?: "activate" | "scroll";
|
|
189
|
+
/**
|
|
190
|
+
* On touch devices, expand the map to fill the window when tapped.
|
|
191
|
+
* Default `true`. Set `false` to zoom in place instead: the first tap
|
|
192
|
+
* zooms the inline map in on the tapped point (like a desktop
|
|
193
|
+
* double-click), after which one-finger pan / pinch work and taps
|
|
194
|
+
* select features; the +/−/reset controls render inline. Note the
|
|
195
|
+
* activated map captures touch scrolling over it. No effect on desktop
|
|
196
|
+
* or when `zoom` is off; `zoom-mode="scroll"` already implies inline
|
|
197
|
+
* interaction.
|
|
198
|
+
*/
|
|
199
|
+
touchExpand?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* Show the grey "Double click to zoom" / "Tap to zoom" affordance over
|
|
202
|
+
* the top of the map while the zoom gesture hasn't been used yet.
|
|
203
|
+
* Default `true`; set `false` to hide it. No effect when `zoom` is off.
|
|
164
204
|
*/
|
|
165
|
-
|
|
205
|
+
zoomHint?: boolean;
|
|
166
206
|
/** Tooltip activation mode */
|
|
167
207
|
tooltipTrigger?: "hover" | "click";
|
|
168
208
|
/**
|
|
@@ -199,7 +239,7 @@ const props = withDefaults(
|
|
|
199
239
|
* current pan/zoom transform is preserved; only the highlight is
|
|
200
240
|
* removed. Works with `v-model:focus`: clicking an unfocused feature
|
|
201
241
|
* (in the base geoType) emits its id; clicking the focused feature
|
|
202
|
-
* emits `null`. The built-in
|
|
242
|
+
* emits `null`. The built-in reset button clears focus *and* resets
|
|
203
243
|
* the zoom. If a tooltip is configured, focusing a feature in the
|
|
204
244
|
* base geoType shows its tooltip.
|
|
205
245
|
*/
|
|
@@ -211,7 +251,7 @@ const props = withDefaults(
|
|
|
211
251
|
* Default `true`. Set `false` to highlight (and draw cross-geoType
|
|
212
252
|
* overlays) without changing the current pan/zoom — useful for a
|
|
213
253
|
* click-to-select interaction where the map should stay put. The
|
|
214
|
-
* built-in
|
|
254
|
+
* built-in reset button is unaffected.
|
|
215
255
|
*/
|
|
216
256
|
focusZoom?: boolean;
|
|
217
257
|
/**
|
|
@@ -232,8 +272,9 @@ const props = withDefaults(
|
|
|
232
272
|
menu: true,
|
|
233
273
|
legend: true,
|
|
234
274
|
zoom: false,
|
|
235
|
-
|
|
236
|
-
|
|
275
|
+
zoomMode: "activate",
|
|
276
|
+
touchExpand: true,
|
|
277
|
+
zoomHint: true,
|
|
237
278
|
tooltipClamp: "chart",
|
|
238
279
|
focusZoomLevel: 4,
|
|
239
280
|
focusZoom: true,
|
|
@@ -344,27 +385,57 @@ let hoveredEl: SVGPathElement | null = null;
|
|
|
344
385
|
// Paths currently styled as focused. Tracked separately from hover so the
|
|
345
386
|
// two states compose: hovering a focused path keeps the highlight on
|
|
346
387
|
// un-hover, and clearing focus while still hovering keeps the hover style.
|
|
347
|
-
// Maps each focused base-geoType path to the
|
|
348
|
-
// repeat focus with
|
|
388
|
+
// Maps each focused base-geoType path to the FocusItem that styled it so
|
|
389
|
+
// a repeat focus with different styling can re-apply without diffing the
|
|
349
390
|
// attribute set manually.
|
|
350
|
-
const focusedPathStyles = new Map<SVGPathElement,
|
|
391
|
+
const focusedPathStyles = new Map<SVGPathElement, FocusItem>();
|
|
351
392
|
// Cross-geoType focus items render as standalone outline paths layered on
|
|
352
393
|
// top of the base map. Keyed by `${geoType}:${id}` so we can diff add /
|
|
353
|
-
// remove / restyle on each applyFocus.
|
|
354
|
-
|
|
394
|
+
// remove / restyle on each applyFocus. `strokeWidth` (visual CSS px) is
|
|
395
|
+
// kept alongside so applyStrokeScale can re-compensate custom widths.
|
|
396
|
+
const overlayPathEls = new Map<
|
|
397
|
+
string,
|
|
398
|
+
{ el: SVGPathElement; strokeWidth?: number }
|
|
399
|
+
>();
|
|
355
400
|
let isZooming = false;
|
|
356
|
-
// TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
|
|
357
|
-
// changes + compositing layers degrade zoom/pan). Disabled on touch devices.
|
|
358
|
-
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
|
|
359
401
|
let tooltipObserver: ResizeObserver | null = null;
|
|
360
402
|
const lastTooltipSize = { width: 0, height: 0 };
|
|
361
403
|
let lastPointer: { x: number; y: number } | null = null;
|
|
362
404
|
let tooltipVisible = false;
|
|
363
405
|
let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
|
|
364
406
|
null;
|
|
365
|
-
// True
|
|
366
|
-
// Drives the visibility of the reset button.
|
|
407
|
+
// True while the transform is away from identity.
|
|
367
408
|
const isZoomed = ref(false);
|
|
409
|
+
// Current zoom scale — drives the +/− buttons' disabled states and the
|
|
410
|
+
// stroke-width compensation in applyStrokeScale.
|
|
411
|
+
const scaleK = ref(1);
|
|
412
|
+
// How much the browser scales the canonical viewBox to fit the container
|
|
413
|
+
// (rendered CSS width / CANONICAL_WIDTH). Tracked by a ResizeObserver so
|
|
414
|
+
// stroke widths can stay visually constant without `vector-effect:
|
|
415
|
+
// non-scaling-stroke` (see applyStrokeScale).
|
|
416
|
+
const viewScale = ref(1);
|
|
417
|
+
// Latched true the first time the transform leaves identity (double-click,
|
|
418
|
+
// tap zoom, +/− press, or a focus zoom).
|
|
419
|
+
const hasZoomed = ref(false);
|
|
420
|
+
// Latched true when the user selects a feature while zooming is enabled —
|
|
421
|
+
// engaging with the map's clickable elements counts as opting in.
|
|
422
|
+
const hasInteracted = ref(false);
|
|
423
|
+
// The pan/zoom interaction is "activated" once the user has zoomed or
|
|
424
|
+
// engaged with the map. Activation is sticky (reset keeps it, except the
|
|
425
|
+
// inline touch flow, where reset restores the pre-tap static mode by
|
|
426
|
+
// clearing both latches).
|
|
427
|
+
const isActivated = computed(() => hasZoomed.value || hasInteracted.value);
|
|
428
|
+
// `zoom-mode="scroll"`: no activation step at all — the map owns wheel,
|
|
429
|
+
// drag, and touch gestures from the start (for full-page experiences).
|
|
430
|
+
const isScrollMode = computed(() => props.zoomMode === "scroll");
|
|
431
|
+
// Inline touch gestures (one-finger pan, pinch) go to the map instead of
|
|
432
|
+
// the page: always in scroll mode, and — with tap-to-expand opted out —
|
|
433
|
+
// once the first tap has activated the interaction.
|
|
434
|
+
const touchGesturesInline = computed(
|
|
435
|
+
() =>
|
|
436
|
+
props.zoom &&
|
|
437
|
+
(isScrollMode.value || (!props.touchExpand && isActivated.value)),
|
|
438
|
+
);
|
|
368
439
|
// rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
|
|
369
440
|
// events into one transform write per animation frame.
|
|
370
441
|
let pendingMoveX = 0;
|
|
@@ -386,18 +457,26 @@ let tapStart: {
|
|
|
386
457
|
featId: string | null;
|
|
387
458
|
} | null = null;
|
|
388
459
|
|
|
460
|
+
// Desktop click-vs-double-click: with the zoom interaction on, a click can
|
|
461
|
+
// be the first half of a double-click zoom, so selection defers by a
|
|
462
|
+
// double-click-sized window and the second click cancels it.
|
|
463
|
+
const CLICK_SELECT_DELAY_MS = 250;
|
|
464
|
+
let pendingSelectTimer = 0;
|
|
465
|
+
|
|
389
466
|
function setupInteraction() {
|
|
467
|
+
const svg = svgRef.value;
|
|
390
468
|
const g = mapGroupRef.value;
|
|
391
|
-
if (!g) return;
|
|
392
|
-
// Tap
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
469
|
+
if (!svg || !g) return;
|
|
470
|
+
// Tap handling is wired on every device, on the svg itself so taps on the
|
|
471
|
+
// map background (not just feature paths) can expand the touch map.
|
|
472
|
+
// `touchend` is non-passive so a confirmed tap can preventDefault and
|
|
473
|
+
// suppress the compatibility click/hover the browser would otherwise
|
|
474
|
+
// synthesize (the double-fire and iOS first-tap-hover sources).
|
|
475
|
+
svg.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
476
|
+
svg.addEventListener("touchend", onTouchEnd);
|
|
477
|
+
svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
|
|
399
478
|
// Hover + tooltip stay off on touch (stroke-width churn degrades zoom/pan).
|
|
400
|
-
if (isTouchDevice) return;
|
|
479
|
+
if (isTouchDevice()) return;
|
|
401
480
|
g.addEventListener("click", onDelegatedEvent);
|
|
402
481
|
g.addEventListener("mouseover", onDelegatedEvent);
|
|
403
482
|
g.addEventListener("mousemove", onDelegatedMouseMove);
|
|
@@ -405,11 +484,14 @@ function setupInteraction() {
|
|
|
405
484
|
}
|
|
406
485
|
|
|
407
486
|
function teardownInteraction() {
|
|
487
|
+
const svg = svgRef.value;
|
|
408
488
|
const g = mapGroupRef.value;
|
|
489
|
+
if (svg) {
|
|
490
|
+
svg.removeEventListener("touchstart", onTouchStart);
|
|
491
|
+
svg.removeEventListener("touchend", onTouchEnd);
|
|
492
|
+
svg.removeEventListener("touchcancel", onTouchCancel);
|
|
493
|
+
}
|
|
409
494
|
if (!g) return;
|
|
410
|
-
g.removeEventListener("touchstart", onTouchStart);
|
|
411
|
-
g.removeEventListener("touchend", onTouchEnd);
|
|
412
|
-
g.removeEventListener("touchcancel", onTouchCancel);
|
|
413
495
|
g.removeEventListener("click", onDelegatedEvent);
|
|
414
496
|
g.removeEventListener("mouseover", onDelegatedEvent);
|
|
415
497
|
g.removeEventListener("mousemove", onDelegatedMouseMove);
|
|
@@ -423,12 +505,26 @@ function dismissOnViewportChange() {
|
|
|
423
505
|
clearHover();
|
|
424
506
|
}
|
|
425
507
|
|
|
508
|
+
// Tracks the svg's rendered width so applyStrokeScale can compensate
|
|
509
|
+
// stroke widths for the viewBox-to-CSS scale. Fires on container resizes
|
|
510
|
+
// only — cheap, and the map itself never relayouts on zoom/pan.
|
|
511
|
+
let svgResizeObserver: ResizeObserver | null = null;
|
|
512
|
+
|
|
426
513
|
onMounted(() => {
|
|
427
514
|
setupZoom();
|
|
428
515
|
setupInteraction();
|
|
429
516
|
rebuildPaths();
|
|
430
517
|
applyFocus();
|
|
431
518
|
attachTooltipObserver();
|
|
519
|
+
if (svgRef.value && typeof ResizeObserver !== "undefined") {
|
|
520
|
+
svgResizeObserver = new ResizeObserver((entries) => {
|
|
521
|
+
const w = entries[0]?.contentRect.width;
|
|
522
|
+
if (!w) return;
|
|
523
|
+
viewScale.value = w / CANONICAL_WIDTH;
|
|
524
|
+
applyStrokeScale();
|
|
525
|
+
});
|
|
526
|
+
svgResizeObserver.observe(svgRef.value);
|
|
527
|
+
}
|
|
432
528
|
window.addEventListener("scroll", dismissOnViewportChange, {
|
|
433
529
|
passive: true,
|
|
434
530
|
capture: true,
|
|
@@ -438,7 +534,9 @@ onMounted(() => {
|
|
|
438
534
|
|
|
439
535
|
onUnmounted(() => {
|
|
440
536
|
tooltipObserver?.disconnect();
|
|
537
|
+
svgResizeObserver?.disconnect();
|
|
441
538
|
if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
|
|
539
|
+
window.clearTimeout(pendingSelectTimer);
|
|
442
540
|
teardownZoom();
|
|
443
541
|
teardownInteraction();
|
|
444
542
|
window.removeEventListener("scroll", dismissOnViewportChange, {
|
|
@@ -451,12 +549,8 @@ function setupZoom() {
|
|
|
451
549
|
if (!svgRef.value || !mapGroupRef.value) return;
|
|
452
550
|
|
|
453
551
|
const svg = select(svgRef.value);
|
|
454
|
-
// Always span focusZoomLevel and at least the standard 12× ceiling so the
|
|
455
|
-
// user can wheel further in/out of a focused view. Programmatic
|
|
456
|
-
// `.transform()` calls are clamped to this range too.
|
|
457
|
-
const maxScale = Math.max(12, props.focusZoomLevel);
|
|
458
552
|
zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
|
|
459
|
-
.scaleExtent([1, maxScale])
|
|
553
|
+
.scaleExtent([1, maxScale.value])
|
|
460
554
|
.on("start", () => {
|
|
461
555
|
isZooming = true;
|
|
462
556
|
clearHover();
|
|
@@ -466,41 +560,47 @@ function setupZoom() {
|
|
|
466
560
|
mapGroupRef.value.setAttribute("transform", event.transform);
|
|
467
561
|
}
|
|
468
562
|
const t = event.transform;
|
|
563
|
+
scaleK.value = t.k;
|
|
564
|
+
applyStrokeScale();
|
|
469
565
|
isZoomed.value = t.k !== 1 || t.x !== 0 || t.y !== 0;
|
|
566
|
+
if (isZoomed.value) hasZoomed.value = true;
|
|
470
567
|
})
|
|
471
568
|
.on("end", () => {
|
|
472
569
|
isZooming = false;
|
|
473
570
|
});
|
|
474
571
|
|
|
475
|
-
// Dynamic filter
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
572
|
+
// Dynamic filter deciding which pointer gestures d3-zoom may handle —
|
|
573
|
+
// re-evaluated per event, so mode/activation changes never require
|
|
574
|
+
// tearing down the zoom behavior. Programmatic `.transform()` calls
|
|
575
|
+
// (focus zoom, the +/− controls, reset) bypass it entirely. Scroll mode
|
|
576
|
+
// skips every gate; otherwise, per gesture:
|
|
577
|
+
// - wheel: only while the map fills the window (body scroll is locked
|
|
578
|
+
// there, so there's no page scroll to hijack);
|
|
579
|
+
// - dblclick: desktop's activation + zoom-in gesture (touch uses taps);
|
|
580
|
+
// - mousedown (drag-pan): once activated or while filling the window;
|
|
581
|
+
// - touchstart (pan/pinch): while filling the window, or inline once
|
|
582
|
+
// `touchGesturesInline` says the map owns touch gestures.
|
|
482
583
|
zoomBehavior.filter((event) => {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
584
|
+
if (!props.zoom) return false;
|
|
585
|
+
if (!isScrollMode.value) {
|
|
586
|
+
const expanded = fullscreen.isFullscreen.value;
|
|
587
|
+
switch (event.type) {
|
|
588
|
+
case "wheel":
|
|
589
|
+
if (!expanded) return false;
|
|
590
|
+
break;
|
|
591
|
+
case "dblclick":
|
|
592
|
+
if (isTouchDevice()) return false;
|
|
593
|
+
break;
|
|
594
|
+
case "mousedown":
|
|
595
|
+
if (!expanded && !isActivated.value) return false;
|
|
596
|
+
break;
|
|
597
|
+
case "touchstart":
|
|
598
|
+
if (!expanded && !touchGesturesInline.value) return false;
|
|
599
|
+
break;
|
|
499
600
|
}
|
|
500
|
-
} else if (!allowZoom && !allowPan) {
|
|
501
|
-
return false;
|
|
502
601
|
}
|
|
503
|
-
// Mirror d3-zoom's default rejections (ctrl-click, non-primary
|
|
602
|
+
// Mirror d3-zoom's default rejections (ctrl-click, non-primary
|
|
603
|
+
// buttons); ctrl+wheel is trackpad pinch, so it stays allowed.
|
|
504
604
|
return (!event.ctrlKey || event.type === "wheel") && !event.button;
|
|
505
605
|
});
|
|
506
606
|
|
|
@@ -556,7 +656,7 @@ function focusedBaseIds(items: FocusItem[]): Set<string> {
|
|
|
556
656
|
|
|
557
657
|
// Duration (ms) of focus zoom-in and Reset-button zoom-out transitions.
|
|
558
658
|
// Initial mount applies instantly; clearing focus is a no-op on the
|
|
559
|
-
// transform (the
|
|
659
|
+
// transform (the reset button is the only path back to identity).
|
|
560
660
|
const FOCUS_ANIM_MS = 450;
|
|
561
661
|
// Tracks whether applyFocus has been called once — initial mount apply
|
|
562
662
|
// is instant, subsequent focus-in calls animate.
|
|
@@ -573,29 +673,34 @@ function applyFocus() {
|
|
|
573
673
|
|
|
574
674
|
// Diff base-geoType highlights, keyed by path element so we can
|
|
575
675
|
// restyle on style change without churning unrelated paths.
|
|
576
|
-
const nextBaseStyles = new Map<SVGPathElement,
|
|
676
|
+
const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
|
|
577
677
|
for (const r of baseResolved) {
|
|
578
678
|
const p = pathsByFeatureId.get(String(r.feature.id));
|
|
579
|
-
if (p) nextBaseStyles.set(p, r.item
|
|
679
|
+
if (p) nextBaseStyles.set(p, r.item);
|
|
580
680
|
}
|
|
581
681
|
for (const [p] of focusedPathStyles) {
|
|
582
682
|
if (nextBaseStyles.has(p) || p === hoveredEl) continue;
|
|
583
683
|
restoreDefaultStroke(p);
|
|
584
684
|
}
|
|
585
|
-
for (const [p,
|
|
685
|
+
for (const [p, item] of nextBaseStyles) {
|
|
586
686
|
const prev = focusedPathStyles.get(p);
|
|
587
|
-
|
|
588
|
-
|
|
687
|
+
const unchanged =
|
|
688
|
+
prev != null &&
|
|
689
|
+
prev.style === item.style &&
|
|
690
|
+
prev.stroke === item.stroke &&
|
|
691
|
+
prev.strokeWidth === item.strokeWidth;
|
|
692
|
+
if (unchanged && p !== hoveredEl) continue; // already styled
|
|
693
|
+
if (p !== hoveredEl) applyHighlightStroke(p, item);
|
|
589
694
|
}
|
|
590
695
|
focusedPathStyles.clear();
|
|
591
|
-
for (const [p,
|
|
696
|
+
for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
|
|
592
697
|
|
|
593
698
|
// Cross-geoType outlines render as non-interactive paths on top of
|
|
594
699
|
// the base layer.
|
|
595
700
|
syncOverlayPaths(overlayResolved);
|
|
596
701
|
|
|
597
702
|
// Clearing focus doesn't touch the zoom transform — the user keeps
|
|
598
|
-
// whatever pan/zoom they had. Only the
|
|
703
|
+
// whatever pan/zoom they had. Only the reset button snaps back to
|
|
599
704
|
// identity. Drop the highlight + tooltip and we're done.
|
|
600
705
|
if (resolved.length === 0) {
|
|
601
706
|
focusApplied = true;
|
|
@@ -674,38 +779,40 @@ function syncOverlayPaths(items: ResolvedFocus[]) {
|
|
|
674
779
|
if (!g) return;
|
|
675
780
|
|
|
676
781
|
const nextKeys = new Set(items.map((i) => i.key));
|
|
677
|
-
for (const [key,
|
|
782
|
+
for (const [key, entry] of overlayPathEls) {
|
|
678
783
|
if (!nextKeys.has(key)) {
|
|
679
|
-
el.remove();
|
|
784
|
+
entry.el.remove();
|
|
680
785
|
overlayPathEls.delete(key);
|
|
681
786
|
}
|
|
682
787
|
}
|
|
683
788
|
|
|
684
789
|
const generator = pathGenerator.value;
|
|
685
|
-
// Overlay strokes need extra weight: they paint on top of the base
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
790
|
+
// Overlay strokes need extra weight: they paint on top of the base map
|
|
791
|
+
// and the (optional) state-borders mesh, so a stroke that matches the
|
|
792
|
+
// in-place focused base path's weight would visually merge with the
|
|
793
|
+
// layers underneath. The default width lives on the overlay *group*;
|
|
794
|
+
// per-item `strokeWidth` overrides are written per path — both
|
|
795
|
+
// compensated by applyStrokeScale.
|
|
690
796
|
for (const { item, feature: f, key } of items) {
|
|
691
|
-
let
|
|
692
|
-
if (!
|
|
693
|
-
el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
|
|
797
|
+
let entry = overlayPathEls.get(key);
|
|
798
|
+
if (!entry) {
|
|
799
|
+
const el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
|
|
694
800
|
el.setAttribute("d", generator(f) ?? "");
|
|
695
801
|
el.setAttribute("fill", "none");
|
|
696
802
|
el.setAttribute("pointer-events", "none");
|
|
697
|
-
el.setAttribute("vector-effect", "non-scaling-stroke");
|
|
698
803
|
el.setAttribute("stroke-linejoin", "round");
|
|
699
804
|
el.setAttribute("class", "focus-overlay");
|
|
700
805
|
g.appendChild(el);
|
|
701
|
-
|
|
806
|
+
entry = { el };
|
|
807
|
+
overlayPathEls.set(key, entry);
|
|
702
808
|
}
|
|
809
|
+
entry.strokeWidth = item.strokeWidth;
|
|
703
810
|
// White contrasts cleanly against the (typically dark) data-colored
|
|
704
811
|
// fill; callers can override per-item via `FocusItem.stroke`.
|
|
705
|
-
el.setAttribute("stroke", item.stroke ?? "#fff");
|
|
706
|
-
el
|
|
707
|
-
applyDasharray(el, item.style);
|
|
812
|
+
entry.el.setAttribute("stroke", item.stroke ?? "#fff");
|
|
813
|
+
applyDasharray(entry.el, item.style);
|
|
708
814
|
}
|
|
815
|
+
applyStrokeScale();
|
|
709
816
|
}
|
|
710
817
|
|
|
711
818
|
function resetZoom() {
|
|
@@ -717,20 +824,165 @@ function resetZoom() {
|
|
|
717
824
|
const svg = select(svgRef.value);
|
|
718
825
|
svg.interrupt();
|
|
719
826
|
hideTooltip();
|
|
720
|
-
|
|
827
|
+
// In the inline touch flow (no tap-to-expand step), reset restores the
|
|
828
|
+
// pre-tap mode: once the zoom-out lands, deactivate so the page gets
|
|
829
|
+
// touch scrolling back and the tap hint returns. Desktop stays
|
|
830
|
+
// activated (sticky), and inside fullscreen the ✕ handles leaving.
|
|
831
|
+
const deactivate =
|
|
832
|
+
isTouchDevice() && !fullscreen.isFullscreen.value && !isScrollMode.value;
|
|
833
|
+
const transition = svg
|
|
721
834
|
.transition()
|
|
722
835
|
.duration(FOCUS_ANIM_MS)
|
|
723
836
|
.call(zoomBehavior.transform, zoomIdentity);
|
|
837
|
+
if (deactivate) {
|
|
838
|
+
transition.on("end", () => {
|
|
839
|
+
hasZoomed.value = false;
|
|
840
|
+
hasInteracted.value = false;
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Ceiling always spans focusZoomLevel and at least the standard 12× so the
|
|
846
|
+
// user can zoom further in/out of a focused view. Programmatic
|
|
847
|
+
// `.transform()` calls are clamped to this range too.
|
|
848
|
+
const maxScale = computed(() => Math.max(12, props.focusZoomLevel));
|
|
849
|
+
|
|
850
|
+
// Scale factor per +/− press and per desktop double-click (d3's built-in).
|
|
851
|
+
const ZOOM_STEP = 2;
|
|
852
|
+
|
|
853
|
+
// The +/−/reset controls — only ever shown while zooming is enabled. On
|
|
854
|
+
// desktop they're always present (pressing + is itself an activation
|
|
855
|
+
// path). On touch they render inline only when the inline map is (or can
|
|
856
|
+
// become) interactive (scroll mode / in-place tap zoom), plus always in
|
|
857
|
+
// the expanded view. With `zoom: false` the map never shows them; a
|
|
858
|
+
// parent driving `focus` owns its own way back.
|
|
859
|
+
const showZoomControls = computed(
|
|
860
|
+
() =>
|
|
861
|
+
props.zoom &&
|
|
862
|
+
(isTouchDevice()
|
|
863
|
+
? fullscreen.isFullscreen.value ||
|
|
864
|
+
isScrollMode.value ||
|
|
865
|
+
!props.touchExpand
|
|
866
|
+
: true),
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
// Drag-pan cursor affordance — desktop only, once drag-pan is available.
|
|
870
|
+
const isPannable = computed(
|
|
871
|
+
() =>
|
|
872
|
+
props.zoom &&
|
|
873
|
+
!isTouchDevice() &&
|
|
874
|
+
(isScrollMode.value || isActivated.value || fullscreen.isFullscreen.value),
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
// Grey affordance line shown while the activation gesture is still the way
|
|
878
|
+
// in: on desktop until activation (the controls take over from there), on
|
|
879
|
+
// touch whenever the inline map is showing (with tap-to-expand) or until
|
|
880
|
+
// the first in-place tap zoom (without). Never while fullscreen or in
|
|
881
|
+
// scroll mode — those are already interactive.
|
|
882
|
+
const showZoomHint = computed(
|
|
883
|
+
() =>
|
|
884
|
+
props.zoom &&
|
|
885
|
+
props.zoomHint &&
|
|
886
|
+
!isScrollMode.value &&
|
|
887
|
+
!fullscreen.isFullscreen.value &&
|
|
888
|
+
((isTouchDevice() && props.touchExpand) || !isActivated.value),
|
|
889
|
+
);
|
|
890
|
+
const zoomHintText = computed(() =>
|
|
891
|
+
isTouchDevice() ? "Tap to zoom" : "Double click to zoom",
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
// Inline style for the map svg. `touch-action: none` hands pan/pinch to
|
|
895
|
+
// d3-zoom (and blocks scroll chaining) wherever touch gestures belong to
|
|
896
|
+
// the map. `will-change: transform` gives the svg its own compositor
|
|
897
|
+
// layer while the interaction is live — without it WebKit repaints the
|
|
898
|
+
// surrounding page layer on every zoom/pan frame (~10× slower on iOS).
|
|
899
|
+
// Scoped to active maps so a page of static maps doesn't pay a raster
|
|
900
|
+
// layer apiece.
|
|
901
|
+
const svgStyle = computed(() => {
|
|
902
|
+
const style: Record<string, string> = {};
|
|
903
|
+
if (fullscreen.isFullscreen.value || touchGesturesInline.value) {
|
|
904
|
+
style["touch-action"] = "none";
|
|
905
|
+
}
|
|
906
|
+
if (
|
|
907
|
+
isActivated.value ||
|
|
908
|
+
fullscreen.isFullscreen.value ||
|
|
909
|
+
(props.zoom && isScrollMode.value)
|
|
910
|
+
) {
|
|
911
|
+
style["will-change"] = "transform";
|
|
912
|
+
}
|
|
913
|
+
return Object.keys(style).length ? style : undefined;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
function zoomBy(factor: number) {
|
|
917
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
918
|
+
const svg = select(svgRef.value);
|
|
919
|
+
svg.interrupt();
|
|
920
|
+
hideTooltip();
|
|
921
|
+
// scaleBy centers on the viewBox extent's midpoint; scaleExtent clamps.
|
|
922
|
+
svg.transition().duration(250).call(zoomBehavior.scaleBy, factor);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Zoom level a tap zooms to (expanding or in place).
|
|
926
|
+
const TAP_ZOOM_SCALE = 2;
|
|
927
|
+
|
|
928
|
+
// Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
|
|
929
|
+
// point. Falls back to the map center when the CTM is unavailable (jsdom).
|
|
930
|
+
function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
|
|
931
|
+
const svgEl = svgRef.value!;
|
|
932
|
+
const k = TAP_ZOOM_SCALE;
|
|
933
|
+
let mx = width.value / 2;
|
|
934
|
+
let my = height.value / 2;
|
|
935
|
+
let ctm: DOMMatrix | null = null;
|
|
936
|
+
try {
|
|
937
|
+
ctm = svgEl.getScreenCTM();
|
|
938
|
+
} catch {
|
|
939
|
+
ctm = null;
|
|
940
|
+
}
|
|
941
|
+
if (ctm) {
|
|
942
|
+
// client → viewBox coords (the svg CTM covers viewBox scaling and
|
|
943
|
+
// letterboxing), then unwind the current zoom transform.
|
|
944
|
+
const inv = ctm.inverse();
|
|
945
|
+
const px = inv.a * clientX + inv.c * clientY + inv.e;
|
|
946
|
+
const py = inv.b * clientX + inv.d * clientY + inv.f;
|
|
947
|
+
[mx, my] = zoomTransform(svgEl).invert([px, py]);
|
|
948
|
+
}
|
|
949
|
+
return zoomIdentity
|
|
950
|
+
.translate(width.value / 2 - k * mx, height.value / 2 - k * my)
|
|
951
|
+
.scale(k);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function animateZoomTo(target: ZoomTransform) {
|
|
955
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
956
|
+
const svg = select(svgRef.value);
|
|
957
|
+
svg.interrupt();
|
|
958
|
+
svg.transition().duration(FOCUS_ANIM_MS).call(zoomBehavior.transform, target);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Tap on the inline touch map: expand to fill the window, then zoom in
|
|
962
|
+
// centered on the tapped point once the expanded layout has committed.
|
|
963
|
+
function enterTouchZoom(clientX: number, clientY: number) {
|
|
964
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
965
|
+
// Resolve the tapped point to map coords before the layout changes.
|
|
966
|
+
const target = tapZoomTransform(clientX, clientY);
|
|
967
|
+
fullscreen.enter();
|
|
968
|
+
nextTick(() => animateZoomTo(target));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// `touchExpand: false`: the first tap zooms the inline map in place
|
|
972
|
+
// instead of expanding it (the touch analogue of desktop double-click).
|
|
973
|
+
function zoomInPlaceAt(clientX: number, clientY: number) {
|
|
974
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
975
|
+
animateZoomTo(tapZoomTransform(clientX, clientY));
|
|
724
976
|
}
|
|
725
977
|
|
|
726
978
|
// `focusZoomLevel` only affects scaleExtent + the next focus apply. The
|
|
727
|
-
// d3-zoom filter reads `props.zoom`
|
|
728
|
-
// need to tear down zoom on those changes.
|
|
979
|
+
// d3-zoom filter reads `props.zoom` and the activation state dynamically,
|
|
980
|
+
// so we don't need to tear down zoom on those changes.
|
|
729
981
|
watch(
|
|
730
982
|
() => props.focusZoomLevel,
|
|
731
983
|
() => {
|
|
732
984
|
if (zoomBehavior) {
|
|
733
|
-
zoomBehavior.scaleExtent([1,
|
|
985
|
+
zoomBehavior.scaleExtent([1, maxScale.value]);
|
|
734
986
|
}
|
|
735
987
|
applyFocus();
|
|
736
988
|
},
|
|
@@ -1118,18 +1370,10 @@ const maxColor = computed(() =>
|
|
|
1118
1370
|
: "",
|
|
1119
1371
|
);
|
|
1120
1372
|
|
|
1121
|
-
|
|
1122
|
-
const h = hex.replace("#", "");
|
|
1123
|
-
return [
|
|
1124
|
-
parseInt(h.slice(0, 2), 16),
|
|
1125
|
-
parseInt(h.slice(2, 4), 16),
|
|
1126
|
-
parseInt(h.slice(4, 6), 16),
|
|
1127
|
-
];
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1373
|
+
// Unresolvable endpoint colors fall back to the default scale endpoints.
|
|
1130
1374
|
function interpolateColor(t: number): string {
|
|
1131
|
-
const [r1, g1, b1] =
|
|
1132
|
-
const [r2, g2, b2] =
|
|
1375
|
+
const [r1, g1, b1] = resolveColorToRgb(minColor.value) ?? [229, 240, 250];
|
|
1376
|
+
const [r2, g2, b2] = resolveColorToRgb(maxColor.value) ?? [8, 81, 156];
|
|
1133
1377
|
const r = Math.round(r1 + (r2 - r1) * t);
|
|
1134
1378
|
const g = Math.round(g1 + (g2 - g1) * t);
|
|
1135
1379
|
const b = Math.round(b1 + (b2 - b1) * t);
|
|
@@ -1309,21 +1553,70 @@ function applyDasharray(el: SVGPathElement, style?: FocusStyle) {
|
|
|
1309
1553
|
}
|
|
1310
1554
|
}
|
|
1311
1555
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
)
|
|
1556
|
+
// ─── Stroke-width compensation (no `vector-effect`) ──────────────────────
|
|
1557
|
+
//
|
|
1558
|
+
// WebKit renders `vector-effect: non-scaling-stroke` across thousands of
|
|
1559
|
+
// paths dramatically slowly (the docs page dropped to ~3fps on iOS), so
|
|
1560
|
+
// visual stroke widths are compensated by hand instead: a width of `w`
|
|
1561
|
+
// CSS px is written as `w / (zoom scale × viewBox-to-CSS scale)`. Base
|
|
1562
|
+
// feature paths inherit one group-level width; only the borders mesh,
|
|
1563
|
+
// focus overlays (via their group), and highlighted paths carry their own.
|
|
1564
|
+
|
|
1565
|
+
/** Divisor turning a visual CSS-px width into an attribute width. */
|
|
1566
|
+
function strokeDivisor(): number {
|
|
1567
|
+
return scaleK.value * viewScale.value || 1;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Highlight (hover + focus) outline color: pure black on light, pure
|
|
1571
|
+
// white on dark, following the theme's color-scheme (the same
|
|
1572
|
+
// light-dark() mechanism the theme tokens use). Applied via inline style
|
|
1573
|
+
// — SVG presentation attributes don't parse CSS functions.
|
|
1574
|
+
const HIGHLIGHT_STROKE = "light-dark(#000, #fff)";
|
|
1575
|
+
|
|
1576
|
+
/** Visual outline width for an in-place highlight (hover or focus). */
|
|
1577
|
+
function highlightWidthFor(item?: FocusItem): number {
|
|
1578
|
+
return item?.strokeWidth ?? effectiveStrokeWidth.value + 1;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function applyStrokeScale() {
|
|
1582
|
+
const d = strokeDivisor();
|
|
1583
|
+
const eff = effectiveStrokeWidth.value;
|
|
1584
|
+
baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
|
|
1585
|
+
bordersPathEl?.setAttribute("stroke-width", String(1 / d));
|
|
1586
|
+
overlayGroupRef.value?.setAttribute("stroke-width", String((eff + 1.5) / d));
|
|
1587
|
+
for (const { el, strokeWidth } of overlayPathEls.values()) {
|
|
1588
|
+
if (strokeWidth != null) {
|
|
1589
|
+
el.setAttribute("stroke-width", String(strokeWidth / d));
|
|
1590
|
+
} else {
|
|
1591
|
+
el.removeAttribute("stroke-width");
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
for (const [p, item] of focusedPathStyles) {
|
|
1595
|
+
p.setAttribute("stroke-width", String(highlightWidthFor(item) / d));
|
|
1596
|
+
}
|
|
1597
|
+
if (hoveredEl && !focusedPathStyles.has(hoveredEl)) {
|
|
1598
|
+
hoveredEl.setAttribute("stroke-width", String(highlightWidthFor() / d));
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function applyHighlightStroke(pathEl: SVGPathElement, item?: FocusItem) {
|
|
1316
1603
|
// Bring path to top so its thicker border isn't clipped by neighbors.
|
|
1317
1604
|
// Skip for overlay paths (they live above everything and own their
|
|
1318
1605
|
// own z-order via syncOverlayPaths).
|
|
1319
1606
|
pathEl.parentNode?.appendChild(pathEl);
|
|
1320
|
-
pathEl.setAttribute(
|
|
1321
|
-
|
|
1322
|
-
|
|
1607
|
+
pathEl.setAttribute(
|
|
1608
|
+
"stroke-width",
|
|
1609
|
+
String(highlightWidthFor(item) / strokeDivisor()),
|
|
1610
|
+
);
|
|
1611
|
+
pathEl.style.stroke = item?.stroke ?? HIGHLIGHT_STROKE;
|
|
1612
|
+
applyDasharray(pathEl, item?.style);
|
|
1323
1613
|
}
|
|
1324
1614
|
|
|
1325
1615
|
function restoreDefaultStroke(pathEl: SVGPathElement) {
|
|
1326
|
-
|
|
1616
|
+
// Dropping the attribute lets the path inherit the compensated width
|
|
1617
|
+
// from the base group again.
|
|
1618
|
+
pathEl.removeAttribute("stroke-width");
|
|
1619
|
+
pathEl.style.stroke = "";
|
|
1327
1620
|
pathEl.setAttribute("stroke", props.strokeColor);
|
|
1328
1621
|
pathEl.removeAttribute("stroke-dasharray");
|
|
1329
1622
|
pathEl.removeAttribute("stroke-linecap");
|
|
@@ -1337,18 +1630,20 @@ function setHover(pathEl: SVGPathElement) {
|
|
|
1337
1630
|
restoreDefaultStroke(hoveredEl);
|
|
1338
1631
|
}
|
|
1339
1632
|
hoveredEl = pathEl;
|
|
1340
|
-
// Hover
|
|
1341
|
-
|
|
1633
|
+
// Hover styling follows whatever focus styling is in effect (or the
|
|
1634
|
+
// defaults).
|
|
1635
|
+
applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl));
|
|
1342
1636
|
}
|
|
1343
1637
|
|
|
1344
1638
|
function clearHover() {
|
|
1345
1639
|
if (hoveredEl) {
|
|
1346
|
-
const
|
|
1347
|
-
if (
|
|
1640
|
+
const focusItem = focusedPathStyles.get(hoveredEl);
|
|
1641
|
+
if (focusItem == null) {
|
|
1348
1642
|
restoreDefaultStroke(hoveredEl);
|
|
1349
1643
|
} else {
|
|
1350
|
-
// Restore the focused
|
|
1351
|
-
|
|
1644
|
+
// Restore the focused styling (in case hover overwrote a dashed
|
|
1645
|
+
// or custom-styled item).
|
|
1646
|
+
applyHighlightStroke(hoveredEl, focusItem);
|
|
1352
1647
|
}
|
|
1353
1648
|
hoveredEl = null;
|
|
1354
1649
|
emit("stateHover", null);
|
|
@@ -1371,6 +1666,9 @@ function eventToFeatureId(target: EventTarget | null): string | null {
|
|
|
1371
1666
|
// wanting fine-grained multi-select handle merging via `@update:focus`.
|
|
1372
1667
|
// Shared by the mouse-click and touch-tap paths.
|
|
1373
1668
|
function emitSelection(data: TooltipPayload) {
|
|
1669
|
+
// Selecting a feature counts as engaging with the map — it switches the
|
|
1670
|
+
// pan/zoom interaction on, same as a first zoom would.
|
|
1671
|
+
if (props.zoom) hasInteracted.value = true;
|
|
1374
1672
|
emit("stateClick", { id: data.id, name: data.name, value: data.value });
|
|
1375
1673
|
const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
|
|
1376
1674
|
emit("update:focus", wasFocused ? null : data.id);
|
|
@@ -1385,7 +1683,20 @@ function onDelegatedEvent(event: Event) {
|
|
|
1385
1683
|
if (!data) return;
|
|
1386
1684
|
const payload = { id: data.id, name: data.name, value: data.value };
|
|
1387
1685
|
if (event.type === "click") {
|
|
1388
|
-
|
|
1686
|
+
if (props.zoom) {
|
|
1687
|
+
window.clearTimeout(pendingSelectTimer);
|
|
1688
|
+
pendingSelectTimer = 0;
|
|
1689
|
+
// detail > 1 is the second click of a double-click — that gesture is
|
|
1690
|
+
// a zoom (handled by d3), not a select.
|
|
1691
|
+
if (me.detail <= 1) {
|
|
1692
|
+
pendingSelectTimer = window.setTimeout(() => {
|
|
1693
|
+
pendingSelectTimer = 0;
|
|
1694
|
+
emitSelection(data);
|
|
1695
|
+
}, CLICK_SELECT_DELAY_MS);
|
|
1696
|
+
}
|
|
1697
|
+
} else {
|
|
1698
|
+
emitSelection(data);
|
|
1699
|
+
}
|
|
1389
1700
|
} else if (event.type === "mouseover") {
|
|
1390
1701
|
setHover(pathsByFeatureId.get(featId)!);
|
|
1391
1702
|
if (hasInteractiveTooltip.value)
|
|
@@ -1423,14 +1734,38 @@ function onTouchStart(event: TouchEvent) {
|
|
|
1423
1734
|
function onTouchEnd(event: TouchEvent) {
|
|
1424
1735
|
const start = tapStart;
|
|
1425
1736
|
tapStart = null;
|
|
1426
|
-
//
|
|
1427
|
-
if (!start ||
|
|
1737
|
+
// A second finger joined in — that's a pinch, not a tap.
|
|
1738
|
+
if (!start || event.touches.length > 0) return;
|
|
1428
1739
|
const t = event.changedTouches[0];
|
|
1429
1740
|
if (!t) return;
|
|
1430
1741
|
// Moved too far (a pan) or held too long (a long-press) → not a tap.
|
|
1431
1742
|
if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
|
|
1432
1743
|
if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
|
|
1433
1744
|
if (event.timeStamp - start.time > TAP_MAX_MS) return;
|
|
1745
|
+
// Inline touch map with zoom on (activate mode): a tap anywhere
|
|
1746
|
+
// (features and background alike) starts the interaction — expanding to
|
|
1747
|
+
// fill the window by default, or zooming in place with
|
|
1748
|
+
// `touchExpand: false`, after which taps fall through to selection.
|
|
1749
|
+
// Scroll mode skips all of this: the inline map is already the
|
|
1750
|
+
// interactive surface.
|
|
1751
|
+
if (
|
|
1752
|
+
props.zoom &&
|
|
1753
|
+
!isScrollMode.value &&
|
|
1754
|
+
isTouchDevice() &&
|
|
1755
|
+
!fullscreen.isFullscreen.value
|
|
1756
|
+
) {
|
|
1757
|
+
if (props.touchExpand) {
|
|
1758
|
+
event.preventDefault();
|
|
1759
|
+
enterTouchZoom(t.clientX, t.clientY);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (!isActivated.value) {
|
|
1763
|
+
event.preventDefault();
|
|
1764
|
+
zoomInPlaceAt(t.clientX, t.clientY);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (!start.featId) return;
|
|
1434
1769
|
const data = tooltipDataById.get(start.featId);
|
|
1435
1770
|
if (!data) return;
|
|
1436
1771
|
// Suppress the synthetic click/hover the browser would replay from this
|
|
@@ -1476,11 +1811,17 @@ function rebuildPaths() {
|
|
|
1476
1811
|
|
|
1477
1812
|
const path = pathGenerator.value;
|
|
1478
1813
|
const features = featuresGeo.value.features;
|
|
1814
|
+
// No features (e.g. geoType="hsas" before the lazy HSA module resolves)
|
|
1815
|
+
// means the projection was fitted to an empty collection and yields NaN
|
|
1816
|
+
// coordinates — skip drawing entirely, including the state-borders mesh,
|
|
1817
|
+
// until real features arrive and re-trigger a rebuild.
|
|
1818
|
+
if (features.length === 0) return;
|
|
1479
1819
|
const stroke = props.strokeColor;
|
|
1480
|
-
const sw = String(effectiveStrokeWidth.value);
|
|
1481
1820
|
const wantsTitleFallback = !hasInteractiveTooltip.value;
|
|
1482
1821
|
|
|
1483
1822
|
// Single DocumentFragment append → one layout flush for the whole batch.
|
|
1823
|
+
// Feature paths carry no stroke-width of their own — they inherit the
|
|
1824
|
+
// compensated group-level width from applyStrokeScale.
|
|
1484
1825
|
const frag = document.createDocumentFragment();
|
|
1485
1826
|
for (const feat of features) {
|
|
1486
1827
|
const id = String(feat.id);
|
|
@@ -1491,11 +1832,6 @@ function rebuildPaths() {
|
|
|
1491
1832
|
p.setAttribute("data-feat-id", id);
|
|
1492
1833
|
p.setAttribute("fill", colorFor(id));
|
|
1493
1834
|
p.setAttribute("stroke", stroke);
|
|
1494
|
-
p.setAttribute("stroke-width", sw);
|
|
1495
|
-
// Keep stroke width pixel-accurate regardless of how the browser scales
|
|
1496
|
-
// the viewBox to fit the container — otherwise borders appear thicker
|
|
1497
|
-
// as the map is enlarged.
|
|
1498
|
-
p.setAttribute("vector-effect", "non-scaling-stroke");
|
|
1499
1835
|
if (wantsTitleFallback) {
|
|
1500
1836
|
const title = document.createElementNS(SVG_NS, "title");
|
|
1501
1837
|
title.textContent = titleText(name, value);
|
|
@@ -1517,14 +1853,13 @@ function rebuildPaths() {
|
|
|
1517
1853
|
const b = makePath(path(borders));
|
|
1518
1854
|
b.setAttribute("fill", "none");
|
|
1519
1855
|
b.setAttribute("stroke", stroke);
|
|
1520
|
-
b.setAttribute("stroke-width", "1");
|
|
1521
1856
|
b.setAttribute("stroke-linejoin", "round");
|
|
1522
1857
|
b.setAttribute("pointer-events", "none");
|
|
1523
|
-
b.setAttribute("vector-effect", "non-scaling-stroke");
|
|
1524
1858
|
frag.appendChild(b);
|
|
1525
1859
|
bordersPathEl = b;
|
|
1526
1860
|
}
|
|
1527
1861
|
baseG.appendChild(frag);
|
|
1862
|
+
applyStrokeScale();
|
|
1528
1863
|
}
|
|
1529
1864
|
|
|
1530
1865
|
function updateFills() {
|
|
@@ -1552,6 +1887,8 @@ function updateStrokes() {
|
|
|
1552
1887
|
restoreDefaultStroke(p);
|
|
1553
1888
|
}
|
|
1554
1889
|
if (bordersPathEl) bordersPathEl.setAttribute("stroke", props.strokeColor);
|
|
1890
|
+
// Group-level widths track effectiveStrokeWidth.
|
|
1891
|
+
applyStrokeScale();
|
|
1555
1892
|
}
|
|
1556
1893
|
|
|
1557
1894
|
function menuFilename() {
|
|
@@ -1702,6 +2039,20 @@ watch(
|
|
|
1702
2039
|
() => applyFocus(),
|
|
1703
2040
|
{ flush: "post" },
|
|
1704
2041
|
);
|
|
2042
|
+
|
|
2043
|
+
// Exiting the expanded touch view (✕, Escape) returns to the static inline
|
|
2044
|
+
// map at full extent — the inline map has no pan gestures, so a preserved
|
|
2045
|
+
// transform would strand the user off-center.
|
|
2046
|
+
watch(
|
|
2047
|
+
() => fullscreen.isFullscreen.value,
|
|
2048
|
+
(expanded) => {
|
|
2049
|
+
if (expanded || !isTouchDevice()) return;
|
|
2050
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
2051
|
+
const svg = select(svgRef.value);
|
|
2052
|
+
svg.interrupt();
|
|
2053
|
+
zoomBehavior.transform(svg, zoomIdentity);
|
|
2054
|
+
},
|
|
2055
|
+
);
|
|
1705
2056
|
</script>
|
|
1706
2057
|
|
|
1707
2058
|
<template>
|
|
@@ -1714,14 +2065,19 @@ watch(
|
|
|
1714
2065
|
v-bind="$attrs"
|
|
1715
2066
|
:class="[
|
|
1716
2067
|
'choropleth-wrapper',
|
|
1717
|
-
{
|
|
2068
|
+
{
|
|
2069
|
+
pannable: isPannable,
|
|
2070
|
+
'is-fullscreen': fullscreen.isFullscreen.value,
|
|
2071
|
+
},
|
|
1718
2072
|
]"
|
|
1719
2073
|
:style="fullscreen.fullscreenStyle.value"
|
|
1720
2074
|
:role="chartRole || undefined"
|
|
1721
2075
|
:aria-label="chartAriaLabel || undefined"
|
|
1722
2076
|
>
|
|
2077
|
+
<!-- Rendered while expanded even with `menu` off — the ✕ close
|
|
2078
|
+
button it swaps to is the way back from the tap-to-expand view. -->
|
|
1723
2079
|
<ChartMenu
|
|
1724
|
-
v-if="menu"
|
|
2080
|
+
v-if="menu || fullscreen.isFullscreen.value"
|
|
1725
2081
|
:items="menuItems"
|
|
1726
2082
|
:is-fullscreen="fullscreen.isFullscreen.value"
|
|
1727
2083
|
@close="fullscreen.exit"
|
|
@@ -1776,11 +2132,14 @@ watch(
|
|
|
1776
2132
|
</div>
|
|
1777
2133
|
</div>
|
|
1778
2134
|
</div>
|
|
2135
|
+
<div v-if="showZoomHint" class="choropleth-zoom-hint">
|
|
2136
|
+
{{ zoomHintText }}
|
|
2137
|
+
</div>
|
|
1779
2138
|
<svg
|
|
1780
2139
|
ref="svgRef"
|
|
1781
2140
|
:viewBox="`0 0 ${width} ${height}`"
|
|
1782
2141
|
preserveAspectRatio="xMidYMid meet"
|
|
1783
|
-
:style="
|
|
2142
|
+
:style="svgStyle"
|
|
1784
2143
|
>
|
|
1785
2144
|
<!--
|
|
1786
2145
|
Path elements are created imperatively in `rebuildPaths()`; Vue never
|
|
@@ -1796,15 +2155,16 @@ watch(
|
|
|
1796
2155
|
<g ref="overlayGroupRef" />
|
|
1797
2156
|
</g>
|
|
1798
2157
|
</svg>
|
|
1799
|
-
<
|
|
1800
|
-
v-if="
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2158
|
+
<ChartZoomControls
|
|
2159
|
+
v-if="showZoomControls"
|
|
2160
|
+
:can-zoom-in="scaleK < maxScale"
|
|
2161
|
+
:can-zoom-out="scaleK > 1"
|
|
2162
|
+
:can-reset="isZoomed"
|
|
2163
|
+
:is-fullscreen="fullscreen.isFullscreen.value"
|
|
2164
|
+
@zoom-in="zoomBy(ZOOM_STEP)"
|
|
2165
|
+
@zoom-out="zoomBy(1 / ZOOM_STEP)"
|
|
2166
|
+
@reset="resetZoom"
|
|
2167
|
+
/>
|
|
1808
2168
|
<ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
|
|
1809
2169
|
<template #default="raw">
|
|
1810
2170
|
<slot name="tooltip" v-bind="narrowSlotProps(raw)">
|
|
@@ -1864,24 +2224,20 @@ watch(
|
|
|
1864
2224
|
cursor: pointer;
|
|
1865
2225
|
}
|
|
1866
2226
|
|
|
1867
|
-
|
|
2227
|
+
/* Overlays the top of the map: absolutely positioned with no `top`, the
|
|
2228
|
+
box keeps its static position — the top edge of the svg that follows it
|
|
2229
|
+
in the markup — without taking up flow space. */
|
|
2230
|
+
.choropleth-zoom-hint {
|
|
1868
2231
|
position: absolute;
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
padding:
|
|
1872
|
-
|
|
2232
|
+
left: 0;
|
|
2233
|
+
right: 0;
|
|
2234
|
+
padding-top: 6px;
|
|
2235
|
+
text-align: center;
|
|
1873
2236
|
font-size: 12px;
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
cursor: pointer;
|
|
1879
|
-
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
.choropleth-reset:hover {
|
|
1883
|
-
background: var(--color-bg-1, #f8f9fa);
|
|
1884
|
-
color: var(--color-text, #212529);
|
|
2237
|
+
line-height: 1.4;
|
|
2238
|
+
color: var(--color-text-secondary, #777);
|
|
2239
|
+
opacity: 0.6;
|
|
2240
|
+
pointer-events: none;
|
|
1885
2241
|
}
|
|
1886
2242
|
|
|
1887
2243
|
/*
|
|
@@ -1925,6 +2281,17 @@ watch(
|
|
|
1925
2281
|
font-weight: 600;
|
|
1926
2282
|
}
|
|
1927
2283
|
|
|
2284
|
+
/* Continuous legend: the tick labels under the gradient unbalance the
|
|
2285
|
+
row's vertical centering, so anchor items to the top and line the title
|
|
2286
|
+
up with the gradient bar itself (line-height matches its 12px height). */
|
|
2287
|
+
.choropleth-legend:has(.choropleth-legend-continuous) {
|
|
2288
|
+
align-items: flex-start;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
.choropleth-legend:has(.choropleth-legend-continuous) .choropleth-legend-title {
|
|
2292
|
+
line-height: 12px;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
1928
2295
|
.choropleth-legend-item {
|
|
1929
2296
|
display: inline-flex;
|
|
1930
2297
|
align-items: center;
|