@cfasim-ui/docs 0.6.3 → 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.
Files changed (38) hide show
  1. package/charts/BarChart/BarChart.md +7 -8
  2. package/charts/BarChart/BarChart.vue +13 -7
  3. package/charts/ChoroplethMap/ChoroplethMap.md +99 -29
  4. package/charts/ChoroplethMap/ChoroplethMap.vue +456 -127
  5. package/charts/DataTable/DataTable.vue +1 -7
  6. package/charts/LineChart/LineChart.md +7 -8
  7. package/charts/LineChart/LineChart.vue +13 -7
  8. package/charts/_shared/ChartZoomControls.vue +138 -0
  9. package/charts/_shared/chartProps.ts +7 -7
  10. package/charts/_shared/index.ts +4 -15
  11. package/charts/_shared/seriesCsv.ts +2 -2
  12. package/charts/_shared/touch.ts +8 -0
  13. package/charts/_shared/useChartFoundation.ts +0 -1
  14. package/components/MultiSelect/MultiSelect.md +1 -1
  15. package/components/MultiSelect/MultiSelect.vue +24 -83
  16. package/components/NumberInput/NumberInput.md +5 -5
  17. package/components/NumberInput/NumberInput.vue +12 -59
  18. package/components/SelectBox/SelectBox.md +1 -0
  19. package/components/SelectBox/SelectBox.vue +34 -125
  20. package/components/TextInput/TextInput.md +2 -1
  21. package/components/TextInput/TextInput.vue +11 -51
  22. package/components/ToggleGroup/ToggleGroup.md +2 -2
  23. package/components/ToggleGroup/ToggleGroup.vue +21 -26
  24. package/components/_internal/FieldLabel.vue +27 -0
  25. package/components/_internal/field.ts +27 -0
  26. package/components/_internal/input.css +54 -0
  27. package/components/_internal/listbox.css +53 -0
  28. package/components/env.d.ts +4 -0
  29. package/index.json +8 -8
  30. package/package.json +1 -1
  31. package/pyodide/messages.ts +23 -0
  32. package/pyodide/pyodide.worker.ts +2 -21
  33. package/pyodide/pyodideWorkerApi.ts +7 -44
  34. package/shared/index.ts +1 -0
  35. package/shared/workerError.ts +17 -0
  36. package/wasm/messages.ts +6 -0
  37. package/wasm/wasm.worker.ts +1 -7
  38. 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 { 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,
@@ -151,18 +160,45 @@ const props = withDefaults(
151
160
  legend?: boolean;
152
161
  /** Title displayed next to the legend */
153
162
  legendTitle?: string;
154
- /** Enable mouse-wheel zooming. Default: false */
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
- * 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.
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
- twoFingerPan?: boolean;
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 Reset button clears focus *and* resets
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 Reset button is unaffected.
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
- pan: false,
236
- twoFingerPan: false,
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 once the user has zoomed or panned away from the identity transform.
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-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 });
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: 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.
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
- 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;
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 buttons).
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 Reset button is the only path back to identity).
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 Reset button snaps back to
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
- // 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;
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
- svg
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` / `props.pan` dynamically, so we don't
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, Math.max(12, props.focusZoomLevel)]);
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
- 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
-
1356
+ // Unresolvable endpoint colors fall back to the default scale endpoints.
1130
1357
  function interpolateColor(t: number): string {
1131
- const [r1, g1, b1] = parseHex(minColor.value);
1132
- const [r2, g2, b2] = parseHex(maxColor.value);
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("stroke-width", String(effectiveStrokeWidth.value + 1));
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
- pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
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
- emitSelection(data);
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
- // No feature under the initial touch, or a second finger joined in.
1427
- if (!start || !start.featId || event.touches.length > 0) return;
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
- { pannable: pan, 'is-fullscreen': fullscreen.isFullscreen.value },
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="twoFingerPan ? { touchAction: 'pan-y' } : undefined"
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
- <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>
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
- .choropleth-reset {
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
- bottom: 8px;
1870
- left: 8px;
1871
- padding: 4px 10px;
1872
- font: inherit;
2194
+ left: 0;
2195
+ right: 0;
2196
+ padding-top: 6px;
2197
+ text-align: center;
1873
2198
  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);
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;