@cfasim-ui/docs 0.7.0 → 0.7.2

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.
@@ -263,36 +263,39 @@ exploration (see the state-map pattern below); a parent driving `focus`
263
263
  provides its own way back (e.g. a "Back" button that clears the focus).
264
264
 
265
265
  Add the `zoom` prop to enable the interaction. The map is then **static
266
- until activated** — the scroll wheel never zooms inline, so the map can't
267
- hijack page scrolling. A grey hint overlaid on the top of the map ("Double
268
- click to zoom" / "Tap to zoom") advertises the gesture until it's been
269
- used; pass `:zoom-hint="false"` to hide it.
266
+ until the first zoom** — clicks and taps only ever select; it's the first
267
+ zoom gesture that switches panning on. The scroll wheel never zooms
268
+ inline, so the map can't hijack page scrolling. A grey hint overlaid on
269
+ the top of the map ("Double click to zoom" / "Double tap to zoom")
270
+ advertises the gesture until it's been used; pass `:zoom-hint="false"` to
271
+ hide it.
270
272
 
271
273
  - **Desktop:** double-click zooms in place (shift+double-click zooms out),
272
274
  or press **+** in the always-present **+ / − / reset** control stack in
273
- the top-left corner. Any of theseor clicking a feature — starts the
274
- pan/zoom mode; from then on, drag pans the map. The reset button (a
275
- counterclockwise arrow) animates back to the full extent (clearing any
276
- `focus`); − and reset are disabled while the map is at its full extent.
277
- - **Touch:** a tap expands the map to fill the window, zoomed in on the
278
- tapped point. Inside, one finger pans, a pinch zooms, and a tap selects a
279
- feature; the +/−/reset controls sit top-left and a close (✕) button
280
- top-right returns to the inline map at full extent. The inline map ignores
281
- touch gestures entirely, so the page always scrolls freely.
275
+ the top-left corner. The first zoomincluding a programmatic `focus`
276
+ zoom — starts the pan/zoom mode; from then on, drag pans the map. The
277
+ reset button (a counterclockwise arrow) animates back to the full extent
278
+ (clearing any `focus`); − and reset are disabled while the map is at its
279
+ full extent.
280
+ - **Touch:** a **double tap** expands the map to fill the window, zoomed
281
+ in on the tapped point; single taps select features inline. Inside the
282
+ expanded view, one finger pans, a pinch zooms, and a tap selects; the
283
+ +/−/reset controls sit top-left and a close (✕) button top-right returns
284
+ to the inline map at full extent. A single finger over the inline map
285
+ still scrolls the page.
282
286
 
283
287
  Filling the window is always an activated state: whether entered via the
284
- menu's **Fullscreen** item or a tap, pan/zoom works immediately and the
285
- controls are present — no double-click needed first. The scroll wheel (and
286
- trackpad pinch) zooms there too, since page scrolling is locked while the
287
- map fills the window.
288
-
289
- Prefer in-place zooming on touch? Set `:touch-expand="false"` — the first
290
- tap zooms the inline map in on the tapped point (the touch analogue of a
291
- desktop double-click) instead of expanding it. From there one finger pans,
292
- a pinch zooms, and taps select; the +/−/reset controls render inline, and
293
- reset restores the original static, page-scrollable state. Fullscreen is
294
- unaffected: that view stays continuously interactive and its reset only
295
- recenters.
288
+ menu's **Fullscreen** item or a double tap, pan/zoom works immediately and
289
+ the controls are present — no double-click needed first. The scroll wheel
290
+ (and trackpad pinch) zooms there too, since page scrolling is locked while
291
+ the map fills the window.
292
+
293
+ Prefer in-place zooming on touch? Set `:touch-expand="false"` — a double
294
+ tap (or a pinch) zooms the inline map on that point instead of expanding
295
+ it. From there one finger pans, a pinch zooms, and taps keep selecting;
296
+ the +/−/reset controls render inline, and reset restores the original
297
+ static, page-scrollable state. Fullscreen is unaffected: that view stays
298
+ continuously interactive and its reset only recenters.
296
299
 
297
300
  #### Full-page mode (`zoom-mode="scroll"`)
298
301
 
@@ -314,7 +317,7 @@ pans, and touch gestures work inline with no tap-to-expand step. The
314
317
 
315
318
  ### County-level map
316
319
 
317
- Set `geoType="counties"` to render county-level data using 5-digit FIPS codes. State borders are drawn on top for context. Double-click (or tap on touch) to explore — useful for dense county data.
320
+ Set `geoType="counties"` to render county-level data using 5-digit FIPS codes. State borders are drawn on top for context. Double-click (or double-tap on touch) to explore — useful for dense county data.
318
321
 
319
322
  <ComponentDemo>
320
323
  <ChoroplethMap
@@ -420,8 +423,8 @@ can stay national; only features inside the selected state are drawn and
420
423
  colored.
421
424
 
422
425
  This demo also opts for the quieter touch flow: `:touch-expand="false"` makes
423
- a tap zoom in place instead of expanding to fill the window, and
424
- `:zoom-hint="false"` drops the grey gesture hint.
426
+ a double tap (or pinch) zoom in place instead of expanding to fill the
427
+ window, and `:zoom-hint="false"` drops the grey gesture hint.
425
428
 
426
429
  <ComponentDemo>
427
430
  <ChoroplethMap
@@ -482,18 +485,27 @@ shows that feature's tooltip. Users can keep exploring around the focused
482
485
  area; the built-in **reset** button clears focus and snaps back to the full
483
486
  extent.
484
487
 
488
+ Each focus target's outline can be styled by passing a `FocusItem` object:
489
+ `style` picks the dash pattern (`"solid" | "dashed" | "dotted"`), `stroke`
490
+ the color, and `strokeWidth` the width in CSS px. In-place highlights
491
+ default to pure black/white following the theme (`light-dark(#000, #fff)`);
492
+ cross-geoType overlays default to white.
493
+
485
494
  Set `:focus-zoom="false"` to highlight (and draw cross-geoType overlays)
486
495
  **without** panning or zooming — useful for a click-to-select interaction
487
496
  where the map should stay put while a side panel shows the details.
488
497
 
489
- On touch, selection happens inside the tap-to-expand view: a single-finger
490
- **tap** on a feature there emits `stateClick` and toggles focus. (With
491
- `:zoom="false"` a tap selects directly on the inline map instead.) Hover
492
- tooltips stay off on touch for performance.
498
+ Selection works the same on touch: a single-finger **tap** on a feature
499
+ emits `stateClick` and toggles focus, inline or inside the expanded view.
500
+ (Inline with `zoom` on, the selection fires after the brief double-tap
501
+ window.) A tap also stands in for hover — it applies the hover highlight,
502
+ emits `stateHover`, and shows the feature's tooltip at the tapped point;
503
+ only continuous hover _tracking_ is off on touch, for performance.
493
504
 
494
505
  Click-to-focus interactions usually pair best with `:touch-expand="false"`,
495
- as in the demos below — the first tap zooms in place and the next tap
496
- selects, so the map stays in your layout instead of taking over the window.
506
+ as in the demos below — a double tap zooms in place while taps keep
507
+ selecting, so the map stays in your layout instead of taking over the
508
+ window.
497
509
 
498
510
  Counties are tiny without a zoom — focus is a natural fit for drill-down.
499
511
 
@@ -90,10 +90,14 @@ export interface FocusItem {
90
90
  * `"dashed"` uses long dashes; `"dotted"` uses small round dots —
91
91
  * useful when stacking multiple outlines of different geoTypes. */
92
92
  style?: FocusStyle;
93
- /** Stroke color for the outline. Applies to cross-geoType overlay
94
- * paths only (base-geoType highlights stay at the default focus
95
- * 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"`. */
96
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;
97
101
  }
98
102
 
99
103
  export type FocusValue = string | FocusItem | Array<string | FocusItem> | null;
@@ -163,13 +167,13 @@ const props = withDefaults(
163
167
  /**
164
168
  * Enable the activate-to-zoom interaction. Default `false` — the map
165
169
  * 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.
170
+ * programmatic `focus` zoom still applies). When enabled, clicks and
171
+ * taps still only select; the first *zoom* is what switches panning
172
+ * on: desktop double-click (or the always-visible +/−/reset controls,
173
+ * or a programmatic focus zoom) zooms in place, and on touch a double
174
+ * tap expands the map to fill the window, where one finger pans,
175
+ * pinch zooms, and a tap selects. The scroll wheel never zooms
176
+ * inline, so the map can't hijack page scrolling.
173
177
  */
174
178
  zoom?: boolean;
175
179
  /**
@@ -183,20 +187,21 @@ const props = withDefaults(
183
187
  */
184
188
  zoomMode?: "activate" | "scroll";
185
189
  /**
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
190
+ * On touch devices, expand the map to fill the window on a double
191
+ * tap. Default `true`. Set `false` to zoom in place instead: a double
192
+ * tap (or pinch) zooms the inline map on that point, after which
193
+ * one-finger pan / pinch work and the +/−/reset controls render
194
+ * inline. Single taps select in both modes. Note the zoomed in-place
195
+ * map captures touch scrolling over it. No effect on desktop or when
196
+ * `zoom` is off; `zoom-mode="scroll"` already implies inline
193
197
  * interaction.
194
198
  */
195
199
  touchExpand?: boolean;
196
200
  /**
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.
201
+ * Show the grey "Double click to zoom" / "Double tap to zoom"
202
+ * affordance over the top of the map while the zoom gesture hasn't
203
+ * been used yet. Default `true`; set `false` to hide it. No effect
204
+ * when `zoom` is off.
200
205
  */
201
206
  zoomHint?: boolean;
202
207
  /** Tooltip activation mode */
@@ -381,14 +386,18 @@ let hoveredEl: SVGPathElement | null = null;
381
386
  // Paths currently styled as focused. Tracked separately from hover so the
382
387
  // two states compose: hovering a focused path keeps the highlight on
383
388
  // un-hover, and clearing focus while still hovering keeps the hover style.
384
- // Maps each focused base-geoType path to the style it was given so a
385
- // repeat focus with a different style can re-apply without diffing the
389
+ // Maps each focused base-geoType path to the FocusItem that styled it so
390
+ // a repeat focus with different styling can re-apply without diffing the
386
391
  // attribute set manually.
387
- const focusedPathStyles = new Map<SVGPathElement, FocusStyle>();
392
+ const focusedPathStyles = new Map<SVGPathElement, FocusItem>();
388
393
  // Cross-geoType focus items render as standalone outline paths layered on
389
394
  // top of the base map. Keyed by `${geoType}:${id}` so we can diff add /
390
- // remove / restyle on each applyFocus.
391
- const overlayPathEls = new Map<string, SVGPathElement>();
395
+ // remove / restyle on each applyFocus. `strokeWidth` (visual CSS px) is
396
+ // kept alongside so applyStrokeScale can re-compensate custom widths.
397
+ const overlayPathEls = new Map<
398
+ string,
399
+ { el: SVGPathElement; strokeWidth?: number }
400
+ >();
392
401
  let isZooming = false;
393
402
  let tooltipObserver: ResizeObserver | null = null;
394
403
  const lastTooltipSize = { width: 0, height: 0 };
@@ -406,27 +415,22 @@ const scaleK = ref(1);
406
415
  // stroke widths can stay visually constant without `vector-effect:
407
416
  // non-scaling-stroke` (see applyStrokeScale).
408
417
  const viewScale = ref(1);
409
- // Latched true the first time the transform leaves identity (double-click,
410
- // tap zoom, +/− press, or a focus zoom).
418
+ // The first *zoom* is what activates the pan/zoom interaction plain
419
+ // clicks/taps only select. Latched true the first time the transform
420
+ // leaves identity (double-click, double-tap/pinch zoom, +/− press, or a
421
+ // programmatic focus zoom) and sticky except in the inline touch flow,
422
+ // where reset restores the pre-zoom static mode by clearing it.
411
423
  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
424
  // `zoom-mode="scroll"`: no activation step at all — the map owns wheel,
421
425
  // drag, and touch gestures from the start (for full-page experiences).
422
426
  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.
427
+ // Inline touch gestures (one-finger pan, pinch-to-continue) go to the map
428
+ // instead of the page: always in scroll mode, and — with tap-to-expand
429
+ // opted out — once the first zoom has activated the interaction.
426
430
  const touchGesturesInline = computed(
427
431
  () =>
428
432
  props.zoom &&
429
- (isScrollMode.value || (!props.touchExpand && isActivated.value)),
433
+ (isScrollMode.value || (!props.touchExpand && hasZoomed.value)),
430
434
  );
431
435
  // rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
432
436
  // events into one transform write per animation frame.
@@ -449,12 +453,18 @@ let tapStart: {
449
453
  featId: string | null;
450
454
  } | null = null;
451
455
 
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.
456
+ // Click/tap-vs-double: with the zoom interaction on, a click or tap can be
457
+ // the first half of a double-click/double-tap zoom, so selection defers by
458
+ // this window and the second click/tap cancels it.
455
459
  const CLICK_SELECT_DELAY_MS = 250;
456
460
  let pendingSelectTimer = 0;
457
461
 
462
+ // Double-tap detection for the inline touch map (the zoom gesture there).
463
+ // Two taps landing this close together within the select-defer window
464
+ // count as one double tap.
465
+ const DOUBLE_TAP_SLOP = 30;
466
+ let lastTap: { x: number; y: number; time: number } | null = null;
467
+
458
468
  function setupInteraction() {
459
469
  const svg = svgRef.value;
460
470
  const g = mapGroupRef.value;
@@ -467,7 +477,9 @@ function setupInteraction() {
467
477
  svg.addEventListener("touchstart", onTouchStart, { passive: true });
468
478
  svg.addEventListener("touchend", onTouchEnd);
469
479
  svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
470
- // Hover + tooltip stay off on touch (stroke-width churn degrades zoom/pan).
480
+ // Continuous hover tracking stays off on touch (stroke-width churn
481
+ // degrades zoom/pan); taps provide the one-shot hover + tooltip instead
482
+ // (see touchHover).
471
483
  if (isTouchDevice()) return;
472
484
  g.addEventListener("click", onDelegatedEvent);
473
485
  g.addEventListener("mouseover", onDelegatedEvent);
@@ -584,10 +596,19 @@ function setupZoom() {
584
596
  if (isTouchDevice()) return false;
585
597
  break;
586
598
  case "mousedown":
587
- if (!expanded && !isActivated.value) return false;
599
+ if (!expanded && !hasZoomed.value) return false;
588
600
  break;
589
601
  case "touchstart":
590
- if (!expanded && !touchGesturesInline.value) return false;
602
+ if (!expanded && !touchGesturesInline.value) {
603
+ // In-place touch mode: a pinch (second finger) is itself the
604
+ // zoom entry gesture; single-finger drags stay with the page
605
+ // until the first zoom.
606
+ const pinchEntry =
607
+ !props.touchExpand &&
608
+ props.zoomMode !== "scroll" &&
609
+ (event as TouchEvent).touches.length >= 2;
610
+ if (!pinchEntry) return false;
611
+ }
591
612
  break;
592
613
  }
593
614
  }
@@ -665,22 +686,27 @@ function applyFocus() {
665
686
 
666
687
  // Diff base-geoType highlights, keyed by path element so we can
667
688
  // restyle on style change without churning unrelated paths.
668
- const nextBaseStyles = new Map<SVGPathElement, FocusStyle>();
689
+ const nextBaseStyles = new Map<SVGPathElement, FocusItem>();
669
690
  for (const r of baseResolved) {
670
691
  const p = pathsByFeatureId.get(String(r.feature.id));
671
- if (p) nextBaseStyles.set(p, r.item.style ?? "solid");
692
+ if (p) nextBaseStyles.set(p, r.item);
672
693
  }
673
694
  for (const [p] of focusedPathStyles) {
674
695
  if (nextBaseStyles.has(p) || p === hoveredEl) continue;
675
696
  restoreDefaultStroke(p);
676
697
  }
677
- for (const [p, style] of nextBaseStyles) {
698
+ for (const [p, item] of nextBaseStyles) {
678
699
  const prev = focusedPathStyles.get(p);
679
- if (prev === style && p !== hoveredEl) continue; // already styled
680
- if (p !== hoveredEl) applyHighlightStroke(p, style);
700
+ const unchanged =
701
+ prev != null &&
702
+ prev.style === item.style &&
703
+ prev.stroke === item.stroke &&
704
+ prev.strokeWidth === item.strokeWidth;
705
+ if (unchanged && p !== hoveredEl) continue; // already styled
706
+ if (p !== hoveredEl) applyHighlightStroke(p, item);
681
707
  }
682
708
  focusedPathStyles.clear();
683
- for (const [p, style] of nextBaseStyles) focusedPathStyles.set(p, style);
709
+ for (const [p, item] of nextBaseStyles) focusedPathStyles.set(p, item);
684
710
 
685
711
  // Cross-geoType outlines render as non-interactive paths on top of
686
712
  // the base layer.
@@ -766,9 +792,9 @@ function syncOverlayPaths(items: ResolvedFocus[]) {
766
792
  if (!g) return;
767
793
 
768
794
  const nextKeys = new Set(items.map((i) => i.key));
769
- for (const [key, el] of overlayPathEls) {
795
+ for (const [key, entry] of overlayPathEls) {
770
796
  if (!nextKeys.has(key)) {
771
- el.remove();
797
+ entry.el.remove();
772
798
  overlayPathEls.delete(key);
773
799
  }
774
800
  }
@@ -777,25 +803,29 @@ function syncOverlayPaths(items: ResolvedFocus[]) {
777
803
  // Overlay strokes need extra weight: they paint on top of the base map
778
804
  // and the (optional) state-borders mesh, so a stroke that matches the
779
805
  // 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.
806
+ // layers underneath. The default width lives on the overlay *group*;
807
+ // per-item `strokeWidth` overrides are written per path — both
808
+ // compensated by applyStrokeScale.
782
809
  for (const { item, feature: f, key } of items) {
783
- let el = overlayPathEls.get(key);
784
- if (!el) {
785
- el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
810
+ let entry = overlayPathEls.get(key);
811
+ if (!entry) {
812
+ const el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
786
813
  el.setAttribute("d", generator(f) ?? "");
787
814
  el.setAttribute("fill", "none");
788
815
  el.setAttribute("pointer-events", "none");
789
816
  el.setAttribute("stroke-linejoin", "round");
790
817
  el.setAttribute("class", "focus-overlay");
791
818
  g.appendChild(el);
792
- overlayPathEls.set(key, el);
819
+ entry = { el };
820
+ overlayPathEls.set(key, entry);
793
821
  }
822
+ entry.strokeWidth = item.strokeWidth;
794
823
  // White contrasts cleanly against the (typically dark) data-colored
795
824
  // fill; callers can override per-item via `FocusItem.stroke`.
796
- el.setAttribute("stroke", item.stroke ?? "#fff");
797
- applyDasharray(el, item.style);
825
+ entry.el.setAttribute("stroke", item.stroke ?? "#fff");
826
+ applyDasharray(entry.el, item.style);
798
827
  }
828
+ applyStrokeScale();
799
829
  }
800
830
 
801
831
  function resetZoom() {
@@ -808,8 +838,8 @@ function resetZoom() {
808
838
  svg.interrupt();
809
839
  hideTooltip();
810
840
  // 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
841
+ // pre-zoom mode: once the zoom-out lands, deactivate so the page gets
842
+ // touch scrolling back and the zoom hint returns. Desktop stays
813
843
  // activated (sticky), and inside fullscreen the ✕ handles leaving.
814
844
  const deactivate =
815
845
  isTouchDevice() && !fullscreen.isFullscreen.value && !isScrollMode.value;
@@ -820,7 +850,6 @@ function resetZoom() {
820
850
  if (deactivate) {
821
851
  transition.on("end", () => {
822
852
  hasZoomed.value = false;
823
- hasInteracted.value = false;
824
853
  });
825
854
  }
826
855
  }
@@ -854,24 +883,24 @@ const isPannable = computed(
854
883
  () =>
855
884
  props.zoom &&
856
885
  !isTouchDevice() &&
857
- (isScrollMode.value || isActivated.value || fullscreen.isFullscreen.value),
886
+ (isScrollMode.value || hasZoomed.value || fullscreen.isFullscreen.value),
858
887
  );
859
888
 
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
889
+ // Grey affordance line shown while the zoom gesture is still the way in:
890
+ // on desktop until the first zoom (the controls take over from there), on
862
891
  // 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.
892
+ // the first in-place zoom (without). Never while fullscreen or in scroll
893
+ // mode — those are already interactive.
865
894
  const showZoomHint = computed(
866
895
  () =>
867
896
  props.zoom &&
868
897
  props.zoomHint &&
869
898
  !isScrollMode.value &&
870
899
  !fullscreen.isFullscreen.value &&
871
- ((isTouchDevice() && props.touchExpand) || !isActivated.value),
900
+ ((isTouchDevice() && props.touchExpand) || !hasZoomed.value),
872
901
  );
873
902
  const zoomHintText = computed(() =>
874
- isTouchDevice() ? "Tap to zoom" : "Double click to zoom",
903
+ isTouchDevice() ? "Double tap to zoom" : "Double click to zoom",
875
904
  );
876
905
 
877
906
  // Inline style for the map svg. `touch-action: none` hands pan/pinch to
@@ -885,9 +914,15 @@ const svgStyle = computed(() => {
885
914
  const style: Record<string, string> = {};
886
915
  if (fullscreen.isFullscreen.value || touchGesturesInline.value) {
887
916
  style["touch-action"] = "none";
917
+ } else if (props.zoom && !isScrollMode.value && isTouchDevice()) {
918
+ // Inline, pre-zoom: keep page panning but claim the zoom gestures.
919
+ // `manipulation` suppresses the browser's double-tap zoom (the expand
920
+ // gesture); in-place mode also blocks browser pinch-zoom so a pinch
921
+ // reaches d3-zoom as the entry gesture.
922
+ style["touch-action"] = props.touchExpand ? "manipulation" : "pan-x pan-y";
888
923
  }
889
924
  if (
890
- isActivated.value ||
925
+ hasZoomed.value ||
891
926
  fullscreen.isFullscreen.value ||
892
927
  (props.zoom && isScrollMode.value)
893
928
  ) {
@@ -1550,37 +1585,56 @@ function strokeDivisor(): number {
1550
1585
  return scaleK.value * viewScale.value || 1;
1551
1586
  }
1552
1587
 
1588
+ // Highlight (hover + focus) outline color: pure black on light, pure
1589
+ // white on dark, following the theme's color-scheme (the same
1590
+ // light-dark() mechanism the theme tokens use). Applied via inline style
1591
+ // — SVG presentation attributes don't parse CSS functions.
1592
+ const HIGHLIGHT_STROKE = "light-dark(#000, #fff)";
1593
+
1594
+ /** Visual outline width for an in-place highlight (hover or focus). */
1595
+ function highlightWidthFor(item?: FocusItem): number {
1596
+ return item?.strokeWidth ?? effectiveStrokeWidth.value + 1;
1597
+ }
1598
+
1553
1599
  function applyStrokeScale() {
1554
1600
  const d = strokeDivisor();
1555
1601
  const eff = effectiveStrokeWidth.value;
1556
1602
  baseGroupRef.value?.setAttribute("stroke-width", String(eff / d));
1557
1603
  bordersPathEl?.setAttribute("stroke-width", String(1 / d));
1558
1604
  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);
1605
+ for (const { el, strokeWidth } of overlayPathEls.values()) {
1606
+ if (strokeWidth != null) {
1607
+ el.setAttribute("stroke-width", String(strokeWidth / d));
1608
+ } else {
1609
+ el.removeAttribute("stroke-width");
1610
+ }
1611
+ }
1612
+ for (const [p, item] of focusedPathStyles) {
1613
+ p.setAttribute("stroke-width", String(highlightWidthFor(item) / d));
1614
+ }
1615
+ if (hoveredEl && !focusedPathStyles.has(hoveredEl)) {
1616
+ hoveredEl.setAttribute("stroke-width", String(highlightWidthFor() / d));
1617
+ }
1562
1618
  }
1563
1619
 
1564
- function applyHighlightStroke(
1565
- pathEl: SVGPathElement,
1566
- style: FocusStyle = "solid",
1567
- ) {
1620
+ function applyHighlightStroke(pathEl: SVGPathElement, item?: FocusItem) {
1568
1621
  // Bring path to top so its thicker border isn't clipped by neighbors.
1569
1622
  // Skip for overlay paths (they live above everything and own their
1570
1623
  // own z-order via syncOverlayPaths).
1571
1624
  pathEl.parentNode?.appendChild(pathEl);
1572
1625
  pathEl.setAttribute(
1573
1626
  "stroke-width",
1574
- String((effectiveStrokeWidth.value + 1) / strokeDivisor()),
1627
+ String(highlightWidthFor(item) / strokeDivisor()),
1575
1628
  );
1576
- pathEl.setAttribute("stroke", "#555");
1577
- applyDasharray(pathEl, style);
1629
+ pathEl.style.stroke = item?.stroke ?? HIGHLIGHT_STROKE;
1630
+ applyDasharray(pathEl, item?.style);
1578
1631
  }
1579
1632
 
1580
1633
  function restoreDefaultStroke(pathEl: SVGPathElement) {
1581
1634
  // Dropping the attribute lets the path inherit the compensated width
1582
1635
  // from the base group again.
1583
1636
  pathEl.removeAttribute("stroke-width");
1637
+ pathEl.style.stroke = "";
1584
1638
  pathEl.setAttribute("stroke", props.strokeColor);
1585
1639
  pathEl.removeAttribute("stroke-dasharray");
1586
1640
  pathEl.removeAttribute("stroke-linecap");
@@ -1594,18 +1648,20 @@ function setHover(pathEl: SVGPathElement) {
1594
1648
  restoreDefaultStroke(hoveredEl);
1595
1649
  }
1596
1650
  hoveredEl = pathEl;
1597
- // Hover style follows whatever focus style is in effect (or solid).
1598
- applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl) ?? "solid");
1651
+ // Hover styling follows whatever focus styling is in effect (or the
1652
+ // defaults).
1653
+ applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl));
1599
1654
  }
1600
1655
 
1601
1656
  function clearHover() {
1602
1657
  if (hoveredEl) {
1603
- const focusStyle = focusedPathStyles.get(hoveredEl);
1604
- if (focusStyle == null) {
1658
+ const focusItem = focusedPathStyles.get(hoveredEl);
1659
+ if (focusItem == null) {
1605
1660
  restoreDefaultStroke(hoveredEl);
1606
1661
  } else {
1607
- // Restore the focused style (in case hover overwrote a dashed item).
1608
- applyHighlightStroke(hoveredEl, focusStyle);
1662
+ // Restore the focused styling (in case hover overwrote a dashed
1663
+ // or custom-styled item).
1664
+ applyHighlightStroke(hoveredEl, focusItem);
1609
1665
  }
1610
1666
  hoveredEl = null;
1611
1667
  emit("stateHover", null);
@@ -1628,9 +1684,6 @@ function eventToFeatureId(target: EventTarget | null): string | null {
1628
1684
  // wanting fine-grained multi-select handle merging via `@update:focus`.
1629
1685
  // Shared by the mouse-click and touch-tap paths.
1630
1686
  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;
1634
1687
  emit("stateClick", { id: data.id, name: data.name, value: data.value });
1635
1688
  const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
1636
1689
  emit("update:focus", wasFocused ? null : data.id);
@@ -1704,38 +1757,80 @@ function onTouchEnd(event: TouchEvent) {
1704
1757
  if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
1705
1758
  if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
1706
1759
  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.
1760
+ // Inline touch map with zoom on (activate mode): a *double tap*
1761
+ // anywhere (features and background alike) is the zoom gesture
1762
+ // expanding to fill the window by default, or zooming in place with
1763
+ // `touchExpand: false` (a pinch works there too). Single taps select,
1764
+ // deferred by the double-tap window so the second tap of a double tap
1765
+ // can cancel them — mirroring the desktop click debounce. Scroll mode
1766
+ // and the expanded view skip all of this: taps there select directly.
1713
1767
  if (
1714
1768
  props.zoom &&
1715
1769
  !isScrollMode.value &&
1716
1770
  isTouchDevice() &&
1717
1771
  !fullscreen.isFullscreen.value
1718
1772
  ) {
1719
- if (props.touchExpand) {
1720
- event.preventDefault();
1721
- enterTouchZoom(t.clientX, t.clientY);
1773
+ // Suppress the synthetic click/hover the browser would replay from
1774
+ // this tap (and iOS's hover-first tap).
1775
+ event.preventDefault();
1776
+ const prev = lastTap;
1777
+ lastTap = { x: t.clientX, y: t.clientY, time: event.timeStamp };
1778
+ const isDoubleTap =
1779
+ prev != null &&
1780
+ event.timeStamp - prev.time <= CLICK_SELECT_DELAY_MS &&
1781
+ Math.abs(t.clientX - prev.x) <= DOUBLE_TAP_SLOP &&
1782
+ Math.abs(t.clientY - prev.y) <= DOUBLE_TAP_SLOP;
1783
+ if (isDoubleTap) {
1784
+ lastTap = null;
1785
+ window.clearTimeout(pendingSelectTimer);
1786
+ pendingSelectTimer = 0;
1787
+ if (props.touchExpand) {
1788
+ enterTouchZoom(t.clientX, t.clientY);
1789
+ } else {
1790
+ zoomInPlaceAt(t.clientX, t.clientY);
1791
+ }
1722
1792
  return;
1723
1793
  }
1724
- if (!isActivated.value) {
1725
- event.preventDefault();
1726
- zoomInPlaceAt(t.clientX, t.clientY);
1794
+ const data = start.featId ? tooltipDataById.get(start.featId) : undefined;
1795
+ if (!data) {
1796
+ // Background tap: dismiss any tap-hover state.
1797
+ clearHover();
1727
1798
  return;
1728
1799
  }
1800
+ // Highlight + tooltip respond instantly; only the selection emits
1801
+ // wait out the double-tap window.
1802
+ touchHover(data, t.clientX, t.clientY);
1803
+ window.clearTimeout(pendingSelectTimer);
1804
+ pendingSelectTimer = window.setTimeout(() => {
1805
+ pendingSelectTimer = 0;
1806
+ emitSelection(data);
1807
+ }, CLICK_SELECT_DELAY_MS);
1808
+ return;
1809
+ }
1810
+ if (!start.featId) {
1811
+ clearHover();
1812
+ return;
1729
1813
  }
1730
- if (!start.featId) return;
1731
1814
  const data = tooltipDataById.get(start.featId);
1732
1815
  if (!data) return;
1733
1816
  // Suppress the synthetic click/hover the browser would replay from this
1734
1817
  // tap, so selection fires exactly once and never via iOS's hover-first tap.
1735
1818
  event.preventDefault();
1819
+ touchHover(data, t.clientX, t.clientY);
1736
1820
  emitSelection(data);
1737
1821
  }
1738
1822
 
1823
+ // A tap doubles as the touch replacement for hover: apply the hover-style
1824
+ // highlight, announce it, and show the tooltip at the tapped point.
1825
+ // (Continuous hover *tracking* stays off on touch — see setupInteraction —
1826
+ // this is the one-shot equivalent.)
1827
+ function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
1828
+ const pathEl = pathsByFeatureId.get(data.id);
1829
+ if (pathEl) setHover(pathEl);
1830
+ emit("stateHover", { id: data.id, name: data.name, value: data.value });
1831
+ if (hasInteractiveTooltip.value) showTooltip(data.id, clientX, clientY);
1832
+ }
1833
+
1739
1834
  function onTouchCancel() {
1740
1835
  tapStart = null;
1741
1836
  }
@@ -2154,6 +2249,8 @@ watch(
2154
2249
 
2155
2250
  position: relative;
2156
2251
  width: 100%;
2252
+ /* Size container so the zoom hint can reposition on narrow maps. */
2253
+ container-type: inline-size;
2157
2254
  }
2158
2255
 
2159
2256
  .choropleth-wrapper svg {
@@ -2188,11 +2285,13 @@ watch(
2188
2285
 
2189
2286
  /* Overlays the top of the map: absolutely positioned with no `top`, the
2190
2287
  box keeps its static position — the top edge of the svg that follows it
2191
- in the markup — without taking up flow space. */
2288
+ in the markup — without taking up flow space. The text carries a
2289
+ page-color halo so it stays legible over map fills. */
2192
2290
  .choropleth-zoom-hint {
2193
2291
  position: absolute;
2194
2292
  left: 0;
2195
2293
  right: 0;
2294
+ z-index: 1;
2196
2295
  padding-top: 6px;
2197
2296
  text-align: center;
2198
2297
  font-size: 12px;
@@ -2200,6 +2299,21 @@ watch(
2200
2299
  color: var(--color-text-secondary, #777);
2201
2300
  opacity: 0.6;
2202
2301
  pointer-events: none;
2302
+ text-shadow:
2303
+ 1px 0 0 var(--color-bg-0, #fff),
2304
+ -1px 0 0 var(--color-bg-0, #fff),
2305
+ 0 1px 0 var(--color-bg-0, #fff),
2306
+ 0 -1px 0 var(--color-bg-0, #fff),
2307
+ 0 0 3px var(--color-bg-0, #fff);
2308
+ }
2309
+
2310
+ /* On narrow maps the overlay would cover content that matters — show the
2311
+ hint in flow above the map instead (the wrapper is the container). */
2312
+ @container (max-width: 480px) {
2313
+ .choropleth-zoom-hint {
2314
+ position: static;
2315
+ padding: 0;
2316
+ }
2203
2317
  }
2204
2318
 
2205
2319
  /*
@@ -1,5 +1,6 @@
1
1
  import { computed, onMounted, onUnmounted, ref } from "vue";
2
2
  import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
3
+ import { isTouchDevice } from "./touch.js";
3
4
 
4
5
  /**
5
6
  * Module-level refcount so multiple expanded charts on the same page
@@ -27,6 +28,50 @@ function unlockBodyScroll() {
27
28
  }
28
29
  }
29
30
 
31
+ /**
32
+ * Pinch-zoom lock (touch devices): a `position: fixed` overlay covers the
33
+ * *layout* viewport, so if the user has pinch-zoomed the page, the
34
+ * expanded chart's corners (controls, ✕) sit outside the visible area.
35
+ * Tightening the viewport meta to `maximum-scale=1` snaps the page zoom
36
+ * back to 1× and holds it while expanded — a pinch should zoom the chart,
37
+ * not the page. The last chart to collapse restores the original content
38
+ * so page zoom (an accessibility feature) comes back. No-op without a
39
+ * viewport meta or on mouse-only devices.
40
+ */
41
+ let viewportLockCount = 0;
42
+ let savedViewportContent: string | null = null;
43
+
44
+ function viewportMeta(): HTMLMetaElement | null {
45
+ return document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
46
+ }
47
+
48
+ function lockViewportZoom() {
49
+ if (typeof document === "undefined" || !isTouchDevice()) return;
50
+ const meta = viewportMeta();
51
+ if (!meta) return;
52
+ if (viewportLockCount === 0) {
53
+ savedViewportContent = meta.getAttribute("content");
54
+ // Append rather than replace so app directives (e.g. viewport-fit for
55
+ // safe-area insets) survive while expanded; a later duplicate key wins.
56
+ const base = savedViewportContent || "width=device-width, initial-scale=1";
57
+ meta.setAttribute("content", `${base}, maximum-scale=1`);
58
+ }
59
+ viewportLockCount++;
60
+ }
61
+
62
+ function unlockViewportZoom() {
63
+ if (typeof document === "undefined") return;
64
+ if (viewportLockCount === 0) return;
65
+ viewportLockCount--;
66
+ if (viewportLockCount === 0) {
67
+ const meta = viewportMeta();
68
+ if (meta && savedViewportContent != null) {
69
+ meta.setAttribute("content", savedViewportContent);
70
+ }
71
+ savedViewportContent = null;
72
+ }
73
+ }
74
+
30
75
  export interface ChartFullscreenOptions {
31
76
  /**
32
77
  * Reactive getter for where to teleport the expanded chart. A CSS
@@ -62,14 +107,55 @@ export function useChartFullscreen(opts: ChartFullscreenOptions = {}) {
62
107
  const isFullscreen = ref(false);
63
108
  let locked = false;
64
109
 
110
+ // Visual-viewport pinning. A fixed overlay covers the *layout* viewport,
111
+ // but on a pinch-zoomed page only part of that is visible — and iOS
112
+ // refuses to programmatically reset page zoom (the viewport-meta clamp
113
+ // above is best-effort; it works on Android and older iOS). While
114
+ // expanded, track the visual viewport and pin the overlay to exactly the
115
+ // visible box instead of `inset: 0`, so the chart and its controls are
116
+ // always fully on screen whatever the page zoom.
117
+ const visualBox = ref<Record<string, string> | null>(null);
118
+
119
+ function updateVisualBox() {
120
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
121
+ if (!vv) return;
122
+ // Always box off the visual viewport while it's observable — at 1×
123
+ // this equals inset: 0, and it additionally tracks mobile URL-bar
124
+ // collapse. `inset: 0` remains only as the no-API fallback.
125
+ visualBox.value = {
126
+ top: `${vv.offsetTop}px`,
127
+ left: `${vv.offsetLeft}px`,
128
+ width: `${vv.width}px`,
129
+ height: `${vv.height}px`,
130
+ };
131
+ }
132
+
133
+ function watchVisualViewport(on: boolean) {
134
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
135
+ if (!vv) return;
136
+ if (on) {
137
+ vv.addEventListener("resize", updateVisualBox);
138
+ vv.addEventListener("scroll", updateVisualBox);
139
+ updateVisualBox();
140
+ } else {
141
+ vv.removeEventListener("resize", updateVisualBox);
142
+ vv.removeEventListener("scroll", updateVisualBox);
143
+ visualBox.value = null;
144
+ }
145
+ }
146
+
65
147
  function setExpanded(value: boolean) {
66
148
  if (value === isFullscreen.value) return;
67
149
  isFullscreen.value = value;
68
150
  if (value && !locked) {
69
151
  lockBodyScroll();
152
+ lockViewportZoom();
153
+ watchVisualViewport(true);
70
154
  locked = true;
71
155
  } else if (!value && locked) {
72
156
  unlockBodyScroll();
157
+ unlockViewportZoom();
158
+ watchVisualViewport(false);
73
159
  locked = false;
74
160
  }
75
161
  }
@@ -104,10 +190,12 @@ export function useChartFullscreen(opts: ChartFullscreenOptions = {}) {
104
190
  onUnmounted(() => {
105
191
  if (typeof document === "undefined") return;
106
192
  document.removeEventListener("keydown", onKey);
107
- // Component torn down while expanded — release the lock so the body
108
- // doesn't stay frozen.
193
+ // Component torn down while expanded — release the locks so the body
194
+ // doesn't stay frozen and page zoom comes back.
109
195
  if (locked) {
110
196
  unlockBodyScroll();
197
+ unlockViewportZoom();
198
+ watchVisualViewport(false);
111
199
  locked = false;
112
200
  }
113
201
  });
@@ -132,11 +220,16 @@ export function useChartFullscreen(opts: ChartFullscreenOptions = {}) {
132
220
  isFullscreen.value
133
221
  ? {
134
222
  position: "fixed",
135
- inset: "0",
223
+ // Pinned to the visual viewport when the page is pinch-zoomed;
224
+ // identical to inset: 0 otherwise.
225
+ ...(visualBox.value ?? { inset: "0" }),
136
226
  "z-index": "var(--cfasim-z-fullscreen, 1000)",
137
227
  background: "var(--color-bg-0, #fff)",
138
228
  color: "var(--color-text, inherit)",
139
- padding: "2em",
229
+ // Edge-to-edge on touch — padding just frames slivers of the
230
+ // page underneath on small screens; desktop keeps modal-style
231
+ // breathing room.
232
+ padding: isTouchDevice() ? "0" : "2em",
140
233
  "box-sizing": "border-box",
141
234
  display: "flex",
142
235
  "flex-direction": "column",
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.0",
2
+ "version": "0.7.2",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {