@cfasim-ui/docs 0.6.4 → 0.7.0
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 +99 -29
- package/charts/ChoroplethMap/ChoroplethMap.vue +456 -127
- 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,
|
|
@@ -151,18 +160,45 @@ const props = withDefaults(
|
|
|
151
160
|
legend?: boolean;
|
|
152
161
|
/** Title displayed next to the legend */
|
|
153
162
|
legendTitle?: string;
|
|
154
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Enable the activate-to-zoom interaction. Default `false` — the map
|
|
165
|
+
* is fully static (tooltips and click-select still work, and
|
|
166
|
+
* programmatic `focus` zoom still applies). When enabled, the map
|
|
167
|
+
* stays static until the user double-clicks (desktop) or taps (touch):
|
|
168
|
+
* desktop double-click zooms in place alongside always-visible
|
|
169
|
+
* +/−/reset controls, with drag-pan once zoomed; on touch a tap
|
|
170
|
+
* expands the map to fill the window, where one finger pans, pinch
|
|
171
|
+
* zooms, and a tap selects. The scroll wheel never zooms inline, so
|
|
172
|
+
* the map can't hijack page scrolling.
|
|
173
|
+
*/
|
|
155
174
|
zoom?: boolean;
|
|
156
|
-
/** Enable click-and-drag panning. Default: false */
|
|
157
|
-
pan?: boolean;
|
|
158
175
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
176
|
+
* Interaction style when `zoom` is enabled. `"activate"` (default)
|
|
177
|
+
* keeps the map static until a double-click / tap / feature click, so
|
|
178
|
+
* the page scrolls freely past it. `"scroll"` makes the map immediately
|
|
179
|
+
* interactive — the wheel zooms, dragging pans, and touch gestures work
|
|
180
|
+
* inline with no tap-to-expand step. Use it for full-page experiences
|
|
181
|
+
* where the map is the main surface and there's no page scroll to
|
|
182
|
+
* hijack.
|
|
183
|
+
*/
|
|
184
|
+
zoomMode?: "activate" | "scroll";
|
|
185
|
+
/**
|
|
186
|
+
* On touch devices, expand the map to fill the window when tapped.
|
|
187
|
+
* Default `true`. Set `false` to zoom in place instead: the first tap
|
|
188
|
+
* zooms the inline map in on the tapped point (like a desktop
|
|
189
|
+
* double-click), after which one-finger pan / pinch work and taps
|
|
190
|
+
* select features; the +/−/reset controls render inline. Note the
|
|
191
|
+
* activated map captures touch scrolling over it. No effect on desktop
|
|
192
|
+
* or when `zoom` is off; `zoom-mode="scroll"` already implies inline
|
|
193
|
+
* interaction.
|
|
194
|
+
*/
|
|
195
|
+
touchExpand?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Show the grey "Double click to zoom" / "Tap to zoom" affordance over
|
|
198
|
+
* the top of the map while the zoom gesture hasn't been used yet.
|
|
199
|
+
* Default `true`; set `false` to hide it. No effect when `zoom` is off.
|
|
164
200
|
*/
|
|
165
|
-
|
|
201
|
+
zoomHint?: boolean;
|
|
166
202
|
/** Tooltip activation mode */
|
|
167
203
|
tooltipTrigger?: "hover" | "click";
|
|
168
204
|
/**
|
|
@@ -199,7 +235,7 @@ const props = withDefaults(
|
|
|
199
235
|
* current pan/zoom transform is preserved; only the highlight is
|
|
200
236
|
* removed. Works with `v-model:focus`: clicking an unfocused feature
|
|
201
237
|
* (in the base geoType) emits its id; clicking the focused feature
|
|
202
|
-
* emits `null`. The built-in
|
|
238
|
+
* emits `null`. The built-in reset button clears focus *and* resets
|
|
203
239
|
* the zoom. If a tooltip is configured, focusing a feature in the
|
|
204
240
|
* base geoType shows its tooltip.
|
|
205
241
|
*/
|
|
@@ -211,7 +247,7 @@ const props = withDefaults(
|
|
|
211
247
|
* Default `true`. Set `false` to highlight (and draw cross-geoType
|
|
212
248
|
* overlays) without changing the current pan/zoom — useful for a
|
|
213
249
|
* click-to-select interaction where the map should stay put. The
|
|
214
|
-
* built-in
|
|
250
|
+
* built-in reset button is unaffected.
|
|
215
251
|
*/
|
|
216
252
|
focusZoom?: boolean;
|
|
217
253
|
/**
|
|
@@ -232,8 +268,9 @@ const props = withDefaults(
|
|
|
232
268
|
menu: true,
|
|
233
269
|
legend: true,
|
|
234
270
|
zoom: false,
|
|
235
|
-
|
|
236
|
-
|
|
271
|
+
zoomMode: "activate",
|
|
272
|
+
touchExpand: true,
|
|
273
|
+
zoomHint: true,
|
|
237
274
|
tooltipClamp: "chart",
|
|
238
275
|
focusZoomLevel: 4,
|
|
239
276
|
focusZoom: true,
|
|
@@ -353,18 +390,44 @@ const focusedPathStyles = new Map<SVGPathElement, FocusStyle>();
|
|
|
353
390
|
// remove / restyle on each applyFocus.
|
|
354
391
|
const overlayPathEls = new Map<string, SVGPathElement>();
|
|
355
392
|
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
393
|
let tooltipObserver: ResizeObserver | null = null;
|
|
360
394
|
const lastTooltipSize = { width: 0, height: 0 };
|
|
361
395
|
let lastPointer: { x: number; y: number } | null = null;
|
|
362
396
|
let tooltipVisible = false;
|
|
363
397
|
let zoomBehavior: ReturnType<typeof d3Zoom<SVGSVGElement, unknown>> | null =
|
|
364
398
|
null;
|
|
365
|
-
// True
|
|
366
|
-
// Drives the visibility of the reset button.
|
|
399
|
+
// True while the transform is away from identity.
|
|
367
400
|
const isZoomed = ref(false);
|
|
401
|
+
// Current zoom scale — drives the +/− buttons' disabled states and the
|
|
402
|
+
// stroke-width compensation in applyStrokeScale.
|
|
403
|
+
const scaleK = ref(1);
|
|
404
|
+
// How much the browser scales the canonical viewBox to fit the container
|
|
405
|
+
// (rendered CSS width / CANONICAL_WIDTH). Tracked by a ResizeObserver so
|
|
406
|
+
// stroke widths can stay visually constant without `vector-effect:
|
|
407
|
+
// non-scaling-stroke` (see applyStrokeScale).
|
|
408
|
+
const viewScale = ref(1);
|
|
409
|
+
// Latched true the first time the transform leaves identity (double-click,
|
|
410
|
+
// tap zoom, +/− press, or a focus zoom).
|
|
411
|
+
const hasZoomed = ref(false);
|
|
412
|
+
// Latched true when the user selects a feature while zooming is enabled —
|
|
413
|
+
// engaging with the map's clickable elements counts as opting in.
|
|
414
|
+
const hasInteracted = ref(false);
|
|
415
|
+
// The pan/zoom interaction is "activated" once the user has zoomed or
|
|
416
|
+
// engaged with the map. Activation is sticky (reset keeps it, except the
|
|
417
|
+
// inline touch flow, where reset restores the pre-tap static mode by
|
|
418
|
+
// clearing both latches).
|
|
419
|
+
const isActivated = computed(() => hasZoomed.value || hasInteracted.value);
|
|
420
|
+
// `zoom-mode="scroll"`: no activation step at all — the map owns wheel,
|
|
421
|
+
// drag, and touch gestures from the start (for full-page experiences).
|
|
422
|
+
const isScrollMode = computed(() => props.zoomMode === "scroll");
|
|
423
|
+
// Inline touch gestures (one-finger pan, pinch) go to the map instead of
|
|
424
|
+
// the page: always in scroll mode, and — with tap-to-expand opted out —
|
|
425
|
+
// once the first tap has activated the interaction.
|
|
426
|
+
const touchGesturesInline = computed(
|
|
427
|
+
() =>
|
|
428
|
+
props.zoom &&
|
|
429
|
+
(isScrollMode.value || (!props.touchExpand && isActivated.value)),
|
|
430
|
+
);
|
|
368
431
|
// rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
|
|
369
432
|
// events into one transform write per animation frame.
|
|
370
433
|
let pendingMoveX = 0;
|
|
@@ -386,18 +449,26 @@ let tapStart: {
|
|
|
386
449
|
featId: string | null;
|
|
387
450
|
} | null = null;
|
|
388
451
|
|
|
452
|
+
// Desktop click-vs-double-click: with the zoom interaction on, a click can
|
|
453
|
+
// be the first half of a double-click zoom, so selection defers by a
|
|
454
|
+
// double-click-sized window and the second click cancels it.
|
|
455
|
+
const CLICK_SELECT_DELAY_MS = 250;
|
|
456
|
+
let pendingSelectTimer = 0;
|
|
457
|
+
|
|
389
458
|
function setupInteraction() {
|
|
459
|
+
const svg = svgRef.value;
|
|
390
460
|
const g = mapGroupRef.value;
|
|
391
|
-
if (!g) return;
|
|
392
|
-
// Tap
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
461
|
+
if (!svg || !g) return;
|
|
462
|
+
// Tap handling is wired on every device, on the svg itself so taps on the
|
|
463
|
+
// map background (not just feature paths) can expand the touch map.
|
|
464
|
+
// `touchend` is non-passive so a confirmed tap can preventDefault and
|
|
465
|
+
// suppress the compatibility click/hover the browser would otherwise
|
|
466
|
+
// synthesize (the double-fire and iOS first-tap-hover sources).
|
|
467
|
+
svg.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
468
|
+
svg.addEventListener("touchend", onTouchEnd);
|
|
469
|
+
svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
|
|
399
470
|
// Hover + tooltip stay off on touch (stroke-width churn degrades zoom/pan).
|
|
400
|
-
if (isTouchDevice) return;
|
|
471
|
+
if (isTouchDevice()) return;
|
|
401
472
|
g.addEventListener("click", onDelegatedEvent);
|
|
402
473
|
g.addEventListener("mouseover", onDelegatedEvent);
|
|
403
474
|
g.addEventListener("mousemove", onDelegatedMouseMove);
|
|
@@ -405,11 +476,14 @@ function setupInteraction() {
|
|
|
405
476
|
}
|
|
406
477
|
|
|
407
478
|
function teardownInteraction() {
|
|
479
|
+
const svg = svgRef.value;
|
|
408
480
|
const g = mapGroupRef.value;
|
|
481
|
+
if (svg) {
|
|
482
|
+
svg.removeEventListener("touchstart", onTouchStart);
|
|
483
|
+
svg.removeEventListener("touchend", onTouchEnd);
|
|
484
|
+
svg.removeEventListener("touchcancel", onTouchCancel);
|
|
485
|
+
}
|
|
409
486
|
if (!g) return;
|
|
410
|
-
g.removeEventListener("touchstart", onTouchStart);
|
|
411
|
-
g.removeEventListener("touchend", onTouchEnd);
|
|
412
|
-
g.removeEventListener("touchcancel", onTouchCancel);
|
|
413
487
|
g.removeEventListener("click", onDelegatedEvent);
|
|
414
488
|
g.removeEventListener("mouseover", onDelegatedEvent);
|
|
415
489
|
g.removeEventListener("mousemove", onDelegatedMouseMove);
|
|
@@ -423,12 +497,26 @@ function dismissOnViewportChange() {
|
|
|
423
497
|
clearHover();
|
|
424
498
|
}
|
|
425
499
|
|
|
500
|
+
// Tracks the svg's rendered width so applyStrokeScale can compensate
|
|
501
|
+
// stroke widths for the viewBox-to-CSS scale. Fires on container resizes
|
|
502
|
+
// only — cheap, and the map itself never relayouts on zoom/pan.
|
|
503
|
+
let svgResizeObserver: ResizeObserver | null = null;
|
|
504
|
+
|
|
426
505
|
onMounted(() => {
|
|
427
506
|
setupZoom();
|
|
428
507
|
setupInteraction();
|
|
429
508
|
rebuildPaths();
|
|
430
509
|
applyFocus();
|
|
431
510
|
attachTooltipObserver();
|
|
511
|
+
if (svgRef.value && typeof ResizeObserver !== "undefined") {
|
|
512
|
+
svgResizeObserver = new ResizeObserver((entries) => {
|
|
513
|
+
const w = entries[0]?.contentRect.width;
|
|
514
|
+
if (!w) return;
|
|
515
|
+
viewScale.value = w / CANONICAL_WIDTH;
|
|
516
|
+
applyStrokeScale();
|
|
517
|
+
});
|
|
518
|
+
svgResizeObserver.observe(svgRef.value);
|
|
519
|
+
}
|
|
432
520
|
window.addEventListener("scroll", dismissOnViewportChange, {
|
|
433
521
|
passive: true,
|
|
434
522
|
capture: true,
|
|
@@ -438,7 +526,9 @@ onMounted(() => {
|
|
|
438
526
|
|
|
439
527
|
onUnmounted(() => {
|
|
440
528
|
tooltipObserver?.disconnect();
|
|
529
|
+
svgResizeObserver?.disconnect();
|
|
441
530
|
if (pendingMoveFrame) cancelAnimationFrame(pendingMoveFrame);
|
|
531
|
+
window.clearTimeout(pendingSelectTimer);
|
|
442
532
|
teardownZoom();
|
|
443
533
|
teardownInteraction();
|
|
444
534
|
window.removeEventListener("scroll", dismissOnViewportChange, {
|
|
@@ -451,12 +541,8 @@ function setupZoom() {
|
|
|
451
541
|
if (!svgRef.value || !mapGroupRef.value) return;
|
|
452
542
|
|
|
453
543
|
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
544
|
zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
|
|
459
|
-
.scaleExtent([1, maxScale])
|
|
545
|
+
.scaleExtent([1, maxScale.value])
|
|
460
546
|
.on("start", () => {
|
|
461
547
|
isZooming = true;
|
|
462
548
|
clearHover();
|
|
@@ -466,41 +552,47 @@ function setupZoom() {
|
|
|
466
552
|
mapGroupRef.value.setAttribute("transform", event.transform);
|
|
467
553
|
}
|
|
468
554
|
const t = event.transform;
|
|
555
|
+
scaleK.value = t.k;
|
|
556
|
+
applyStrokeScale();
|
|
469
557
|
isZoomed.value = t.k !== 1 || t.x !== 0 || t.y !== 0;
|
|
558
|
+
if (isZoomed.value) hasZoomed.value = true;
|
|
470
559
|
})
|
|
471
560
|
.on("end", () => {
|
|
472
561
|
isZooming = false;
|
|
473
562
|
});
|
|
474
563
|
|
|
475
|
-
// Dynamic filter
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
564
|
+
// Dynamic filter deciding which pointer gestures d3-zoom may handle —
|
|
565
|
+
// re-evaluated per event, so mode/activation changes never require
|
|
566
|
+
// tearing down the zoom behavior. Programmatic `.transform()` calls
|
|
567
|
+
// (focus zoom, the +/− controls, reset) bypass it entirely. Scroll mode
|
|
568
|
+
// skips every gate; otherwise, per gesture:
|
|
569
|
+
// - wheel: only while the map fills the window (body scroll is locked
|
|
570
|
+
// there, so there's no page scroll to hijack);
|
|
571
|
+
// - dblclick: desktop's activation + zoom-in gesture (touch uses taps);
|
|
572
|
+
// - mousedown (drag-pan): once activated or while filling the window;
|
|
573
|
+
// - touchstart (pan/pinch): while filling the window, or inline once
|
|
574
|
+
// `touchGesturesInline` says the map owns touch gestures.
|
|
482
575
|
zoomBehavior.filter((event) => {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
576
|
+
if (!props.zoom) return false;
|
|
577
|
+
if (!isScrollMode.value) {
|
|
578
|
+
const expanded = fullscreen.isFullscreen.value;
|
|
579
|
+
switch (event.type) {
|
|
580
|
+
case "wheel":
|
|
581
|
+
if (!expanded) return false;
|
|
582
|
+
break;
|
|
583
|
+
case "dblclick":
|
|
584
|
+
if (isTouchDevice()) return false;
|
|
585
|
+
break;
|
|
586
|
+
case "mousedown":
|
|
587
|
+
if (!expanded && !isActivated.value) return false;
|
|
588
|
+
break;
|
|
589
|
+
case "touchstart":
|
|
590
|
+
if (!expanded && !touchGesturesInline.value) return false;
|
|
591
|
+
break;
|
|
499
592
|
}
|
|
500
|
-
} else if (!allowZoom && !allowPan) {
|
|
501
|
-
return false;
|
|
502
593
|
}
|
|
503
|
-
// Mirror d3-zoom's default rejections (ctrl-click, non-primary
|
|
594
|
+
// Mirror d3-zoom's default rejections (ctrl-click, non-primary
|
|
595
|
+
// buttons); ctrl+wheel is trackpad pinch, so it stays allowed.
|
|
504
596
|
return (!event.ctrlKey || event.type === "wheel") && !event.button;
|
|
505
597
|
});
|
|
506
598
|
|
|
@@ -556,7 +648,7 @@ function focusedBaseIds(items: FocusItem[]): Set<string> {
|
|
|
556
648
|
|
|
557
649
|
// Duration (ms) of focus zoom-in and Reset-button zoom-out transitions.
|
|
558
650
|
// Initial mount applies instantly; clearing focus is a no-op on the
|
|
559
|
-
// transform (the
|
|
651
|
+
// transform (the reset button is the only path back to identity).
|
|
560
652
|
const FOCUS_ANIM_MS = 450;
|
|
561
653
|
// Tracks whether applyFocus has been called once — initial mount apply
|
|
562
654
|
// is instant, subsequent focus-in calls animate.
|
|
@@ -595,7 +687,7 @@ function applyFocus() {
|
|
|
595
687
|
syncOverlayPaths(overlayResolved);
|
|
596
688
|
|
|
597
689
|
// Clearing focus doesn't touch the zoom transform — the user keeps
|
|
598
|
-
// whatever pan/zoom they had. Only the
|
|
690
|
+
// whatever pan/zoom they had. Only the reset button snaps back to
|
|
599
691
|
// identity. Drop the highlight + tooltip and we're done.
|
|
600
692
|
if (resolved.length === 0) {
|
|
601
693
|
focusApplied = true;
|
|
@@ -682,11 +774,11 @@ function syncOverlayPaths(items: ResolvedFocus[]) {
|
|
|
682
774
|
}
|
|
683
775
|
|
|
684
776
|
const generator = pathGenerator.value;
|
|
685
|
-
// Overlay strokes need extra weight: they paint on top of the base
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
777
|
+
// Overlay strokes need extra weight: they paint on top of the base map
|
|
778
|
+
// and the (optional) state-borders mesh, so a stroke that matches the
|
|
779
|
+
// in-place focused base path's weight would visually merge with the
|
|
780
|
+
// layers underneath. The width itself lives on the overlay *group*
|
|
781
|
+
// (compensated by applyStrokeScale); the paths only carry color/dash.
|
|
690
782
|
for (const { item, feature: f, key } of items) {
|
|
691
783
|
let el = overlayPathEls.get(key);
|
|
692
784
|
if (!el) {
|
|
@@ -694,7 +786,6 @@ function syncOverlayPaths(items: ResolvedFocus[]) {
|
|
|
694
786
|
el.setAttribute("d", generator(f) ?? "");
|
|
695
787
|
el.setAttribute("fill", "none");
|
|
696
788
|
el.setAttribute("pointer-events", "none");
|
|
697
|
-
el.setAttribute("vector-effect", "non-scaling-stroke");
|
|
698
789
|
el.setAttribute("stroke-linejoin", "round");
|
|
699
790
|
el.setAttribute("class", "focus-overlay");
|
|
700
791
|
g.appendChild(el);
|
|
@@ -703,7 +794,6 @@ function syncOverlayPaths(items: ResolvedFocus[]) {
|
|
|
703
794
|
// White contrasts cleanly against the (typically dark) data-colored
|
|
704
795
|
// fill; callers can override per-item via `FocusItem.stroke`.
|
|
705
796
|
el.setAttribute("stroke", item.stroke ?? "#fff");
|
|
706
|
-
el.setAttribute("stroke-width", String(sw));
|
|
707
797
|
applyDasharray(el, item.style);
|
|
708
798
|
}
|
|
709
799
|
}
|
|
@@ -717,20 +807,165 @@ function resetZoom() {
|
|
|
717
807
|
const svg = select(svgRef.value);
|
|
718
808
|
svg.interrupt();
|
|
719
809
|
hideTooltip();
|
|
720
|
-
|
|
810
|
+
// In the inline touch flow (no tap-to-expand step), reset restores the
|
|
811
|
+
// pre-tap mode: once the zoom-out lands, deactivate so the page gets
|
|
812
|
+
// touch scrolling back and the tap hint returns. Desktop stays
|
|
813
|
+
// activated (sticky), and inside fullscreen the ✕ handles leaving.
|
|
814
|
+
const deactivate =
|
|
815
|
+
isTouchDevice() && !fullscreen.isFullscreen.value && !isScrollMode.value;
|
|
816
|
+
const transition = svg
|
|
721
817
|
.transition()
|
|
722
818
|
.duration(FOCUS_ANIM_MS)
|
|
723
819
|
.call(zoomBehavior.transform, zoomIdentity);
|
|
820
|
+
if (deactivate) {
|
|
821
|
+
transition.on("end", () => {
|
|
822
|
+
hasZoomed.value = false;
|
|
823
|
+
hasInteracted.value = false;
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Ceiling always spans focusZoomLevel and at least the standard 12× so the
|
|
829
|
+
// user can zoom further in/out of a focused view. Programmatic
|
|
830
|
+
// `.transform()` calls are clamped to this range too.
|
|
831
|
+
const maxScale = computed(() => Math.max(12, props.focusZoomLevel));
|
|
832
|
+
|
|
833
|
+
// Scale factor per +/− press and per desktop double-click (d3's built-in).
|
|
834
|
+
const ZOOM_STEP = 2;
|
|
835
|
+
|
|
836
|
+
// The +/−/reset controls — only ever shown while zooming is enabled. On
|
|
837
|
+
// desktop they're always present (pressing + is itself an activation
|
|
838
|
+
// path). On touch they render inline only when the inline map is (or can
|
|
839
|
+
// become) interactive (scroll mode / in-place tap zoom), plus always in
|
|
840
|
+
// the expanded view. With `zoom: false` the map never shows them; a
|
|
841
|
+
// parent driving `focus` owns its own way back.
|
|
842
|
+
const showZoomControls = computed(
|
|
843
|
+
() =>
|
|
844
|
+
props.zoom &&
|
|
845
|
+
(isTouchDevice()
|
|
846
|
+
? fullscreen.isFullscreen.value ||
|
|
847
|
+
isScrollMode.value ||
|
|
848
|
+
!props.touchExpand
|
|
849
|
+
: true),
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
// Drag-pan cursor affordance — desktop only, once drag-pan is available.
|
|
853
|
+
const isPannable = computed(
|
|
854
|
+
() =>
|
|
855
|
+
props.zoom &&
|
|
856
|
+
!isTouchDevice() &&
|
|
857
|
+
(isScrollMode.value || isActivated.value || fullscreen.isFullscreen.value),
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
// Grey affordance line shown while the activation gesture is still the way
|
|
861
|
+
// in: on desktop until activation (the controls take over from there), on
|
|
862
|
+
// touch whenever the inline map is showing (with tap-to-expand) or until
|
|
863
|
+
// the first in-place tap zoom (without). Never while fullscreen or in
|
|
864
|
+
// scroll mode — those are already interactive.
|
|
865
|
+
const showZoomHint = computed(
|
|
866
|
+
() =>
|
|
867
|
+
props.zoom &&
|
|
868
|
+
props.zoomHint &&
|
|
869
|
+
!isScrollMode.value &&
|
|
870
|
+
!fullscreen.isFullscreen.value &&
|
|
871
|
+
((isTouchDevice() && props.touchExpand) || !isActivated.value),
|
|
872
|
+
);
|
|
873
|
+
const zoomHintText = computed(() =>
|
|
874
|
+
isTouchDevice() ? "Tap to zoom" : "Double click to zoom",
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
// Inline style for the map svg. `touch-action: none` hands pan/pinch to
|
|
878
|
+
// d3-zoom (and blocks scroll chaining) wherever touch gestures belong to
|
|
879
|
+
// the map. `will-change: transform` gives the svg its own compositor
|
|
880
|
+
// layer while the interaction is live — without it WebKit repaints the
|
|
881
|
+
// surrounding page layer on every zoom/pan frame (~10× slower on iOS).
|
|
882
|
+
// Scoped to active maps so a page of static maps doesn't pay a raster
|
|
883
|
+
// layer apiece.
|
|
884
|
+
const svgStyle = computed(() => {
|
|
885
|
+
const style: Record<string, string> = {};
|
|
886
|
+
if (fullscreen.isFullscreen.value || touchGesturesInline.value) {
|
|
887
|
+
style["touch-action"] = "none";
|
|
888
|
+
}
|
|
889
|
+
if (
|
|
890
|
+
isActivated.value ||
|
|
891
|
+
fullscreen.isFullscreen.value ||
|
|
892
|
+
(props.zoom && isScrollMode.value)
|
|
893
|
+
) {
|
|
894
|
+
style["will-change"] = "transform";
|
|
895
|
+
}
|
|
896
|
+
return Object.keys(style).length ? style : undefined;
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
function zoomBy(factor: number) {
|
|
900
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
901
|
+
const svg = select(svgRef.value);
|
|
902
|
+
svg.interrupt();
|
|
903
|
+
hideTooltip();
|
|
904
|
+
// scaleBy centers on the viewBox extent's midpoint; scaleExtent clamps.
|
|
905
|
+
svg.transition().duration(250).call(zoomBehavior.scaleBy, factor);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Zoom level a tap zooms to (expanding or in place).
|
|
909
|
+
const TAP_ZOOM_SCALE = 2;
|
|
910
|
+
|
|
911
|
+
// Target transform for a tap: TAP_ZOOM_SCALE× centered on the tapped
|
|
912
|
+
// point. Falls back to the map center when the CTM is unavailable (jsdom).
|
|
913
|
+
function tapZoomTransform(clientX: number, clientY: number): ZoomTransform {
|
|
914
|
+
const svgEl = svgRef.value!;
|
|
915
|
+
const k = TAP_ZOOM_SCALE;
|
|
916
|
+
let mx = width.value / 2;
|
|
917
|
+
let my = height.value / 2;
|
|
918
|
+
let ctm: DOMMatrix | null = null;
|
|
919
|
+
try {
|
|
920
|
+
ctm = svgEl.getScreenCTM();
|
|
921
|
+
} catch {
|
|
922
|
+
ctm = null;
|
|
923
|
+
}
|
|
924
|
+
if (ctm) {
|
|
925
|
+
// client → viewBox coords (the svg CTM covers viewBox scaling and
|
|
926
|
+
// letterboxing), then unwind the current zoom transform.
|
|
927
|
+
const inv = ctm.inverse();
|
|
928
|
+
const px = inv.a * clientX + inv.c * clientY + inv.e;
|
|
929
|
+
const py = inv.b * clientX + inv.d * clientY + inv.f;
|
|
930
|
+
[mx, my] = zoomTransform(svgEl).invert([px, py]);
|
|
931
|
+
}
|
|
932
|
+
return zoomIdentity
|
|
933
|
+
.translate(width.value / 2 - k * mx, height.value / 2 - k * my)
|
|
934
|
+
.scale(k);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function animateZoomTo(target: ZoomTransform) {
|
|
938
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
939
|
+
const svg = select(svgRef.value);
|
|
940
|
+
svg.interrupt();
|
|
941
|
+
svg.transition().duration(FOCUS_ANIM_MS).call(zoomBehavior.transform, target);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Tap on the inline touch map: expand to fill the window, then zoom in
|
|
945
|
+
// centered on the tapped point once the expanded layout has committed.
|
|
946
|
+
function enterTouchZoom(clientX: number, clientY: number) {
|
|
947
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
948
|
+
// Resolve the tapped point to map coords before the layout changes.
|
|
949
|
+
const target = tapZoomTransform(clientX, clientY);
|
|
950
|
+
fullscreen.enter();
|
|
951
|
+
nextTick(() => animateZoomTo(target));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// `touchExpand: false`: the first tap zooms the inline map in place
|
|
955
|
+
// instead of expanding it (the touch analogue of desktop double-click).
|
|
956
|
+
function zoomInPlaceAt(clientX: number, clientY: number) {
|
|
957
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
958
|
+
animateZoomTo(tapZoomTransform(clientX, clientY));
|
|
724
959
|
}
|
|
725
960
|
|
|
726
961
|
// `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.
|
|
962
|
+
// d3-zoom filter reads `props.zoom` and the activation state dynamically,
|
|
963
|
+
// so we don't need to tear down zoom on those changes.
|
|
729
964
|
watch(
|
|
730
965
|
() => props.focusZoomLevel,
|
|
731
966
|
() => {
|
|
732
967
|
if (zoomBehavior) {
|
|
733
|
-
zoomBehavior.scaleExtent([1,
|
|
968
|
+
zoomBehavior.scaleExtent([1, maxScale.value]);
|
|
734
969
|
}
|
|
735
970
|
applyFocus();
|
|
736
971
|
},
|
|
@@ -1118,18 +1353,10 @@ const maxColor = computed(() =>
|
|
|
1118
1353
|
: "",
|
|
1119
1354
|
);
|
|
1120
1355
|
|
|
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
|
-
|
|
1356
|
+
// Unresolvable endpoint colors fall back to the default scale endpoints.
|
|
1130
1357
|
function interpolateColor(t: number): string {
|
|
1131
|
-
const [r1, g1, b1] =
|
|
1132
|
-
const [r2, g2, b2] =
|
|
1358
|
+
const [r1, g1, b1] = resolveColorToRgb(minColor.value) ?? [229, 240, 250];
|
|
1359
|
+
const [r2, g2, b2] = resolveColorToRgb(maxColor.value) ?? [8, 81, 156];
|
|
1133
1360
|
const r = Math.round(r1 + (r2 - r1) * t);
|
|
1134
1361
|
const g = Math.round(g1 + (g2 - g1) * t);
|
|
1135
1362
|
const b = Math.round(b1 + (b2 - b1) * t);
|
|
@@ -1309,6 +1536,31 @@ function applyDasharray(el: SVGPathElement, style?: FocusStyle) {
|
|
|
1309
1536
|
}
|
|
1310
1537
|
}
|
|
1311
1538
|
|
|
1539
|
+
// ─── Stroke-width compensation (no `vector-effect`) ──────────────────────
|
|
1540
|
+
//
|
|
1541
|
+
// WebKit renders `vector-effect: non-scaling-stroke` across thousands of
|
|
1542
|
+
// paths dramatically slowly (the docs page dropped to ~3fps on iOS), so
|
|
1543
|
+
// visual stroke widths are compensated by hand instead: a width of `w`
|
|
1544
|
+
// CSS px is written as `w / (zoom scale × viewBox-to-CSS scale)`. Base
|
|
1545
|
+
// feature paths inherit one group-level width; only the borders mesh,
|
|
1546
|
+
// focus overlays (via their group), and highlighted paths carry their own.
|
|
1547
|
+
|
|
1548
|
+
/** Divisor turning a visual CSS-px width into an attribute width. */
|
|
1549
|
+
function strokeDivisor(): number {
|
|
1550
|
+
return scaleK.value * viewScale.value || 1;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function applyStrokeScale() {
|
|
1554
|
+
const d = strokeDivisor();
|
|
1555
|
+
const eff = effectiveStrokeWidth.value;
|
|
1556
|
+
baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
|
|
1557
|
+
bordersPathEl?.setAttribute("stroke-width", String(1 / d));
|
|
1558
|
+
overlayGroupRef.value?.setAttribute("stroke-width", String((eff + 1.5) / d));
|
|
1559
|
+
const hw = String((eff + 1) / d);
|
|
1560
|
+
for (const [p] of focusedPathStyles) p.setAttribute("stroke-width", hw);
|
|
1561
|
+
if (hoveredEl) hoveredEl.setAttribute("stroke-width", hw);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1312
1564
|
function applyHighlightStroke(
|
|
1313
1565
|
pathEl: SVGPathElement,
|
|
1314
1566
|
style: FocusStyle = "solid",
|
|
@@ -1317,13 +1569,18 @@ function applyHighlightStroke(
|
|
|
1317
1569
|
// Skip for overlay paths (they live above everything and own their
|
|
1318
1570
|
// own z-order via syncOverlayPaths).
|
|
1319
1571
|
pathEl.parentNode?.appendChild(pathEl);
|
|
1320
|
-
pathEl.setAttribute(
|
|
1572
|
+
pathEl.setAttribute(
|
|
1573
|
+
"stroke-width",
|
|
1574
|
+
String((effectiveStrokeWidth.value + 1) / strokeDivisor()),
|
|
1575
|
+
);
|
|
1321
1576
|
pathEl.setAttribute("stroke", "#555");
|
|
1322
1577
|
applyDasharray(pathEl, style);
|
|
1323
1578
|
}
|
|
1324
1579
|
|
|
1325
1580
|
function restoreDefaultStroke(pathEl: SVGPathElement) {
|
|
1326
|
-
|
|
1581
|
+
// Dropping the attribute lets the path inherit the compensated width
|
|
1582
|
+
// from the base group again.
|
|
1583
|
+
pathEl.removeAttribute("stroke-width");
|
|
1327
1584
|
pathEl.setAttribute("stroke", props.strokeColor);
|
|
1328
1585
|
pathEl.removeAttribute("stroke-dasharray");
|
|
1329
1586
|
pathEl.removeAttribute("stroke-linecap");
|
|
@@ -1371,6 +1628,9 @@ function eventToFeatureId(target: EventTarget | null): string | null {
|
|
|
1371
1628
|
// wanting fine-grained multi-select handle merging via `@update:focus`.
|
|
1372
1629
|
// Shared by the mouse-click and touch-tap paths.
|
|
1373
1630
|
function emitSelection(data: TooltipPayload) {
|
|
1631
|
+
// Selecting a feature counts as engaging with the map — it switches the
|
|
1632
|
+
// pan/zoom interaction on, same as a first zoom would.
|
|
1633
|
+
if (props.zoom) hasInteracted.value = true;
|
|
1374
1634
|
emit("stateClick", { id: data.id, name: data.name, value: data.value });
|
|
1375
1635
|
const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
|
|
1376
1636
|
emit("update:focus", wasFocused ? null : data.id);
|
|
@@ -1385,7 +1645,20 @@ function onDelegatedEvent(event: Event) {
|
|
|
1385
1645
|
if (!data) return;
|
|
1386
1646
|
const payload = { id: data.id, name: data.name, value: data.value };
|
|
1387
1647
|
if (event.type === "click") {
|
|
1388
|
-
|
|
1648
|
+
if (props.zoom) {
|
|
1649
|
+
window.clearTimeout(pendingSelectTimer);
|
|
1650
|
+
pendingSelectTimer = 0;
|
|
1651
|
+
// detail > 1 is the second click of a double-click — that gesture is
|
|
1652
|
+
// a zoom (handled by d3), not a select.
|
|
1653
|
+
if (me.detail <= 1) {
|
|
1654
|
+
pendingSelectTimer = window.setTimeout(() => {
|
|
1655
|
+
pendingSelectTimer = 0;
|
|
1656
|
+
emitSelection(data);
|
|
1657
|
+
}, CLICK_SELECT_DELAY_MS);
|
|
1658
|
+
}
|
|
1659
|
+
} else {
|
|
1660
|
+
emitSelection(data);
|
|
1661
|
+
}
|
|
1389
1662
|
} else if (event.type === "mouseover") {
|
|
1390
1663
|
setHover(pathsByFeatureId.get(featId)!);
|
|
1391
1664
|
if (hasInteractiveTooltip.value)
|
|
@@ -1423,14 +1696,38 @@ function onTouchStart(event: TouchEvent) {
|
|
|
1423
1696
|
function onTouchEnd(event: TouchEvent) {
|
|
1424
1697
|
const start = tapStart;
|
|
1425
1698
|
tapStart = null;
|
|
1426
|
-
//
|
|
1427
|
-
if (!start ||
|
|
1699
|
+
// A second finger joined in — that's a pinch, not a tap.
|
|
1700
|
+
if (!start || event.touches.length > 0) return;
|
|
1428
1701
|
const t = event.changedTouches[0];
|
|
1429
1702
|
if (!t) return;
|
|
1430
1703
|
// Moved too far (a pan) or held too long (a long-press) → not a tap.
|
|
1431
1704
|
if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
|
|
1432
1705
|
if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
|
|
1433
1706
|
if (event.timeStamp - start.time > TAP_MAX_MS) return;
|
|
1707
|
+
// Inline touch map with zoom on (activate mode): a tap anywhere
|
|
1708
|
+
// (features and background alike) starts the interaction — expanding to
|
|
1709
|
+
// fill the window by default, or zooming in place with
|
|
1710
|
+
// `touchExpand: false`, after which taps fall through to selection.
|
|
1711
|
+
// Scroll mode skips all of this: the inline map is already the
|
|
1712
|
+
// interactive surface.
|
|
1713
|
+
if (
|
|
1714
|
+
props.zoom &&
|
|
1715
|
+
!isScrollMode.value &&
|
|
1716
|
+
isTouchDevice() &&
|
|
1717
|
+
!fullscreen.isFullscreen.value
|
|
1718
|
+
) {
|
|
1719
|
+
if (props.touchExpand) {
|
|
1720
|
+
event.preventDefault();
|
|
1721
|
+
enterTouchZoom(t.clientX, t.clientY);
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
if (!isActivated.value) {
|
|
1725
|
+
event.preventDefault();
|
|
1726
|
+
zoomInPlaceAt(t.clientX, t.clientY);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
if (!start.featId) return;
|
|
1434
1731
|
const data = tooltipDataById.get(start.featId);
|
|
1435
1732
|
if (!data) return;
|
|
1436
1733
|
// Suppress the synthetic click/hover the browser would replay from this
|
|
@@ -1476,11 +1773,17 @@ function rebuildPaths() {
|
|
|
1476
1773
|
|
|
1477
1774
|
const path = pathGenerator.value;
|
|
1478
1775
|
const features = featuresGeo.value.features;
|
|
1776
|
+
// No features (e.g. geoType="hsas" before the lazy HSA module resolves)
|
|
1777
|
+
// means the projection was fitted to an empty collection and yields NaN
|
|
1778
|
+
// coordinates — skip drawing entirely, including the state-borders mesh,
|
|
1779
|
+
// until real features arrive and re-trigger a rebuild.
|
|
1780
|
+
if (features.length === 0) return;
|
|
1479
1781
|
const stroke = props.strokeColor;
|
|
1480
|
-
const sw = String(effectiveStrokeWidth.value);
|
|
1481
1782
|
const wantsTitleFallback = !hasInteractiveTooltip.value;
|
|
1482
1783
|
|
|
1483
1784
|
// Single DocumentFragment append → one layout flush for the whole batch.
|
|
1785
|
+
// Feature paths carry no stroke-width of their own — they inherit the
|
|
1786
|
+
// compensated group-level width from applyStrokeScale.
|
|
1484
1787
|
const frag = document.createDocumentFragment();
|
|
1485
1788
|
for (const feat of features) {
|
|
1486
1789
|
const id = String(feat.id);
|
|
@@ -1491,11 +1794,6 @@ function rebuildPaths() {
|
|
|
1491
1794
|
p.setAttribute("data-feat-id", id);
|
|
1492
1795
|
p.setAttribute("fill", colorFor(id));
|
|
1493
1796
|
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
1797
|
if (wantsTitleFallback) {
|
|
1500
1798
|
const title = document.createElementNS(SVG_NS, "title");
|
|
1501
1799
|
title.textContent = titleText(name, value);
|
|
@@ -1517,14 +1815,13 @@ function rebuildPaths() {
|
|
|
1517
1815
|
const b = makePath(path(borders));
|
|
1518
1816
|
b.setAttribute("fill", "none");
|
|
1519
1817
|
b.setAttribute("stroke", stroke);
|
|
1520
|
-
b.setAttribute("stroke-width", "1");
|
|
1521
1818
|
b.setAttribute("stroke-linejoin", "round");
|
|
1522
1819
|
b.setAttribute("pointer-events", "none");
|
|
1523
|
-
b.setAttribute("vector-effect", "non-scaling-stroke");
|
|
1524
1820
|
frag.appendChild(b);
|
|
1525
1821
|
bordersPathEl = b;
|
|
1526
1822
|
}
|
|
1527
1823
|
baseG.appendChild(frag);
|
|
1824
|
+
applyStrokeScale();
|
|
1528
1825
|
}
|
|
1529
1826
|
|
|
1530
1827
|
function updateFills() {
|
|
@@ -1552,6 +1849,8 @@ function updateStrokes() {
|
|
|
1552
1849
|
restoreDefaultStroke(p);
|
|
1553
1850
|
}
|
|
1554
1851
|
if (bordersPathEl) bordersPathEl.setAttribute("stroke", props.strokeColor);
|
|
1852
|
+
// Group-level widths track effectiveStrokeWidth.
|
|
1853
|
+
applyStrokeScale();
|
|
1555
1854
|
}
|
|
1556
1855
|
|
|
1557
1856
|
function menuFilename() {
|
|
@@ -1702,6 +2001,20 @@ watch(
|
|
|
1702
2001
|
() => applyFocus(),
|
|
1703
2002
|
{ flush: "post" },
|
|
1704
2003
|
);
|
|
2004
|
+
|
|
2005
|
+
// Exiting the expanded touch view (✕, Escape) returns to the static inline
|
|
2006
|
+
// map at full extent — the inline map has no pan gestures, so a preserved
|
|
2007
|
+
// transform would strand the user off-center.
|
|
2008
|
+
watch(
|
|
2009
|
+
() => fullscreen.isFullscreen.value,
|
|
2010
|
+
(expanded) => {
|
|
2011
|
+
if (expanded || !isTouchDevice()) return;
|
|
2012
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
2013
|
+
const svg = select(svgRef.value);
|
|
2014
|
+
svg.interrupt();
|
|
2015
|
+
zoomBehavior.transform(svg, zoomIdentity);
|
|
2016
|
+
},
|
|
2017
|
+
);
|
|
1705
2018
|
</script>
|
|
1706
2019
|
|
|
1707
2020
|
<template>
|
|
@@ -1714,14 +2027,19 @@ watch(
|
|
|
1714
2027
|
v-bind="$attrs"
|
|
1715
2028
|
:class="[
|
|
1716
2029
|
'choropleth-wrapper',
|
|
1717
|
-
{
|
|
2030
|
+
{
|
|
2031
|
+
pannable: isPannable,
|
|
2032
|
+
'is-fullscreen': fullscreen.isFullscreen.value,
|
|
2033
|
+
},
|
|
1718
2034
|
]"
|
|
1719
2035
|
:style="fullscreen.fullscreenStyle.value"
|
|
1720
2036
|
:role="chartRole || undefined"
|
|
1721
2037
|
:aria-label="chartAriaLabel || undefined"
|
|
1722
2038
|
>
|
|
2039
|
+
<!-- Rendered while expanded even with `menu` off — the ✕ close
|
|
2040
|
+
button it swaps to is the way back from the tap-to-expand view. -->
|
|
1723
2041
|
<ChartMenu
|
|
1724
|
-
v-if="menu"
|
|
2042
|
+
v-if="menu || fullscreen.isFullscreen.value"
|
|
1725
2043
|
:items="menuItems"
|
|
1726
2044
|
:is-fullscreen="fullscreen.isFullscreen.value"
|
|
1727
2045
|
@close="fullscreen.exit"
|
|
@@ -1776,11 +2094,14 @@ watch(
|
|
|
1776
2094
|
</div>
|
|
1777
2095
|
</div>
|
|
1778
2096
|
</div>
|
|
2097
|
+
<div v-if="showZoomHint" class="choropleth-zoom-hint">
|
|
2098
|
+
{{ zoomHintText }}
|
|
2099
|
+
</div>
|
|
1779
2100
|
<svg
|
|
1780
2101
|
ref="svgRef"
|
|
1781
2102
|
:viewBox="`0 0 ${width} ${height}`"
|
|
1782
2103
|
preserveAspectRatio="xMidYMid meet"
|
|
1783
|
-
:style="
|
|
2104
|
+
:style="svgStyle"
|
|
1784
2105
|
>
|
|
1785
2106
|
<!--
|
|
1786
2107
|
Path elements are created imperatively in `rebuildPaths()`; Vue never
|
|
@@ -1796,15 +2117,16 @@ watch(
|
|
|
1796
2117
|
<g ref="overlayGroupRef" />
|
|
1797
2118
|
</g>
|
|
1798
2119
|
</svg>
|
|
1799
|
-
<
|
|
1800
|
-
v-if="
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2120
|
+
<ChartZoomControls
|
|
2121
|
+
v-if="showZoomControls"
|
|
2122
|
+
:can-zoom-in="scaleK < maxScale"
|
|
2123
|
+
:can-zoom-out="scaleK > 1"
|
|
2124
|
+
:can-reset="isZoomed"
|
|
2125
|
+
:is-fullscreen="fullscreen.isFullscreen.value"
|
|
2126
|
+
@zoom-in="zoomBy(ZOOM_STEP)"
|
|
2127
|
+
@zoom-out="zoomBy(1 / ZOOM_STEP)"
|
|
2128
|
+
@reset="resetZoom"
|
|
2129
|
+
/>
|
|
1808
2130
|
<ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
|
|
1809
2131
|
<template #default="raw">
|
|
1810
2132
|
<slot name="tooltip" v-bind="narrowSlotProps(raw)">
|
|
@@ -1864,24 +2186,20 @@ watch(
|
|
|
1864
2186
|
cursor: pointer;
|
|
1865
2187
|
}
|
|
1866
2188
|
|
|
1867
|
-
|
|
2189
|
+
/* Overlays the top of the map: absolutely positioned with no `top`, the
|
|
2190
|
+
box keeps its static position — the top edge of the svg that follows it
|
|
2191
|
+
in the markup — without taking up flow space. */
|
|
2192
|
+
.choropleth-zoom-hint {
|
|
1868
2193
|
position: absolute;
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
padding:
|
|
1872
|
-
|
|
2194
|
+
left: 0;
|
|
2195
|
+
right: 0;
|
|
2196
|
+
padding-top: 6px;
|
|
2197
|
+
text-align: center;
|
|
1873
2198
|
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);
|
|
2199
|
+
line-height: 1.4;
|
|
2200
|
+
color: var(--color-text-secondary, #777);
|
|
2201
|
+
opacity: 0.6;
|
|
2202
|
+
pointer-events: none;
|
|
1885
2203
|
}
|
|
1886
2204
|
|
|
1887
2205
|
/*
|
|
@@ -1925,6 +2243,17 @@ watch(
|
|
|
1925
2243
|
font-weight: 600;
|
|
1926
2244
|
}
|
|
1927
2245
|
|
|
2246
|
+
/* Continuous legend: the tick labels under the gradient unbalance the
|
|
2247
|
+
row's vertical centering, so anchor items to the top and line the title
|
|
2248
|
+
up with the gradient bar itself (line-height matches its 12px height). */
|
|
2249
|
+
.choropleth-legend:has(.choropleth-legend-continuous) {
|
|
2250
|
+
align-items: flex-start;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
.choropleth-legend:has(.choropleth-legend-continuous) .choropleth-legend-title {
|
|
2254
|
+
line-height: 12px;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
1928
2257
|
.choropleth-legend-item {
|
|
1929
2258
|
display: inline-flex;
|
|
1930
2259
|
align-items: center;
|