@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.
@@ -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 { zoom as d3Zoom, zoomIdentity } from "d3-zoom";
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. Applies to cross-geoType overlay
85
- * paths only (base-geoType highlights stay at the default focus
86
- * color). Default: `"#fff"`. */
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
- /** Enable mouse-wheel zooming. Default: false */
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
- * Require two fingers to pan/zoom on touch devices. Default `false`.
160
- * When `true`, a single finger scrolls the page (the map sets
161
- * `touch-action: pan-y` and ignores one-finger drags) while two fingers
162
- * pan and pinch-zoom the map. Single-finger tap-to-select still works.
163
- * Has no effect with a mouse.
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
- twoFingerPan?: boolean;
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 Reset button clears focus *and* resets
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 Reset button is unaffected.
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
- pan: false,
236
- twoFingerPan: false,
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 style it was given so a
348
- // repeat focus with a different style can re-apply without diffing the
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, FocusStyle>();
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
- const overlayPathEls = new Map<string, SVGPathElement>();
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 once the user has zoomed or panned away from the identity transform.
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-to-select is wired on every device. `touchend` is non-passive so a
393
- // confirmed tap can preventDefault and suppress the compatibility
394
- // click/hover the browser would otherwise synthesize (the double-fire and
395
- // iOS first-tap-hover sources).
396
- g.addEventListener("touchstart", onTouchStart, { passive: true });
397
- g.addEventListener("touchend", onTouchEnd);
398
- g.addEventListener("touchcancel", onTouchCancel, { passive: true });
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: re-evaluated per event, so toggling `focus`,
476
- // `zoom`, or `pan` doesn't require tearing down the zoom behavior.
477
- // When focus is active we always allow drag + wheel so users can
478
- // explore away from the focused area regardless of `zoom`/`pan` but
479
- // not in highlight-only mode (`focusZoom=false`), where focus is purely
480
- // visual and shouldn't grant interaction the props didn't ask for.
481
- // Programmatic `.transform()` calls bypass this filter entirely.
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
- const focused =
484
- normalizedFocus.value.length > 0 && props.focusZoom !== false;
485
- const allowZoom = !!props.zoom || focused;
486
- const allowPan = !!props.pan || focused;
487
- if (event.type === "wheel" || event.type === "dblclick") {
488
- if (!allowZoom) return false;
489
- } else if (event.type === "mousedown" || event.type === "touchstart") {
490
- if (!allowPan) return false;
491
- // Two-finger-only mode: ignore single-finger touches so the page can
492
- // scroll; a second finger drives the map (pan + pinch-zoom).
493
- if (
494
- props.twoFingerPan &&
495
- event.type === "touchstart" &&
496
- (event as TouchEvent).touches.length < 2
497
- ) {
498
- return false;
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 buttons).
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 Reset button is the only path back to identity).
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, FocusStyle>();
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.style ?? "solid");
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, style] of nextBaseStyles) {
685
+ for (const [p, item] of nextBaseStyles) {
586
686
  const prev = focusedPathStyles.get(p);
587
- if (prev === style && p !== hoveredEl) continue; // already styled
588
- if (p !== hoveredEl) applyHighlightStroke(p, style);
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, style] of nextBaseStyles) focusedPathStyles.set(p, style);
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 Reset button snaps back to
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, el] of overlayPathEls) {
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
- // map and the (optional) state-borders mesh, so a stroke that matches
687
- // the in-place focused base path's weight would visually merge with
688
- // the layers underneath.
689
- const sw = effectiveStrokeWidth.value + 1.5;
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 el = overlayPathEls.get(key);
692
- if (!el) {
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
- overlayPathEls.set(key, el);
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.setAttribute("stroke-width", String(sw));
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
- svg
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` / `props.pan` dynamically, so we don't
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, Math.max(12, props.focusZoomLevel)]);
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
- function parseHex(hex: string): [number, number, number] {
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] = parseHex(minColor.value);
1132
- const [r2, g2, b2] = parseHex(maxColor.value);
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
- function applyHighlightStroke(
1313
- pathEl: SVGPathElement,
1314
- style: FocusStyle = "solid",
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("stroke-width", String(effectiveStrokeWidth.value + 1));
1321
- pathEl.setAttribute("stroke", "#555");
1322
- applyDasharray(pathEl, style);
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
- pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
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 style follows whatever focus style is in effect (or solid).
1341
- applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl) ?? "solid");
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 focusStyle = focusedPathStyles.get(hoveredEl);
1347
- if (focusStyle == null) {
1640
+ const focusItem = focusedPathStyles.get(hoveredEl);
1641
+ if (focusItem == null) {
1348
1642
  restoreDefaultStroke(hoveredEl);
1349
1643
  } else {
1350
- // Restore the focused style (in case hover overwrote a dashed item).
1351
- applyHighlightStroke(hoveredEl, focusStyle);
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
- emitSelection(data);
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
- // No feature under the initial touch, or a second finger joined in.
1427
- if (!start || !start.featId || event.touches.length > 0) return;
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
- { pannable: pan, 'is-fullscreen': fullscreen.isFullscreen.value },
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="twoFingerPan ? { touchAction: 'pan-y' } : undefined"
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
- <button
1800
- v-if="isZoomed"
1801
- type="button"
1802
- class="choropleth-reset"
1803
- aria-label="Reset zoom"
1804
- @click="resetZoom"
1805
- >
1806
- Reset
1807
- </button>
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
- .choropleth-reset {
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
- bottom: 8px;
1870
- left: 8px;
1871
- padding: 4px 10px;
1872
- font: inherit;
2232
+ left: 0;
2233
+ right: 0;
2234
+ padding-top: 6px;
2235
+ text-align: center;
1873
2236
  font-size: 12px;
1874
- color: var(--color-text-secondary, #555);
1875
- background: var(--color-bg-0, #fff);
1876
- border: 1px solid var(--color-border, #e5e7eb);
1877
- border-radius: 4px;
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;