@cfasim-ui/docs 0.7.1 → 0.7.3

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
@@ -492,14 +495,17 @@ Set `:focus-zoom="false"` to highlight (and draw cross-geoType overlays)
492
495
  **without** panning or zooming — useful for a click-to-select interaction
493
496
  where the map should stay put while a side panel shows the details.
494
497
 
495
- On touch, selection happens inside the tap-to-expand view: a single-finger
496
- **tap** on a feature there emits `stateClick` and toggles focus. (With
497
- `:zoom="false"` a tap selects directly on the inline map instead.) Hover
498
- 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.
499
504
 
500
505
  Click-to-focus interactions usually pair best with `:touch-expand="false"`,
501
- as in the demos below — the first tap zooms in place and the next tap
502
- 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.
503
509
 
504
510
  Counties are tiny without a zoom — focus is a natural fit for drill-down.
505
511
 
@@ -167,13 +167,13 @@ const props = withDefaults(
167
167
  /**
168
168
  * Enable the activate-to-zoom interaction. Default `false` — the map
169
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.
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.
177
177
  */
178
178
  zoom?: boolean;
179
179
  /**
@@ -187,20 +187,21 @@ const props = withDefaults(
187
187
  */
188
188
  zoomMode?: "activate" | "scroll";
189
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
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
197
197
  * interaction.
198
198
  */
199
199
  touchExpand?: boolean;
200
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.
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.
204
205
  */
205
206
  zoomHint?: boolean;
206
207
  /** Tooltip activation mode */
@@ -414,27 +415,22 @@ const scaleK = ref(1);
414
415
  // stroke widths can stay visually constant without `vector-effect:
415
416
  // non-scaling-stroke` (see applyStrokeScale).
416
417
  const viewScale = ref(1);
417
- // Latched true the first time the transform leaves identity (double-click,
418
- // 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.
419
423
  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
424
  // `zoom-mode="scroll"`: no activation step at all — the map owns wheel,
429
425
  // drag, and touch gestures from the start (for full-page experiences).
430
426
  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.
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.
434
430
  const touchGesturesInline = computed(
435
431
  () =>
436
432
  props.zoom &&
437
- (isScrollMode.value || (!props.touchExpand && isActivated.value)),
433
+ (isScrollMode.value || (!props.touchExpand && hasZoomed.value)),
438
434
  );
439
435
  // rAF-throttled cursor coords for moveTooltip; we coalesce many mousemove
440
436
  // events into one transform write per animation frame.
@@ -457,12 +453,18 @@ let tapStart: {
457
453
  featId: string | null;
458
454
  } | null = null;
459
455
 
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.
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.
463
459
  const CLICK_SELECT_DELAY_MS = 250;
464
460
  let pendingSelectTimer = 0;
465
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
+
466
468
  function setupInteraction() {
467
469
  const svg = svgRef.value;
468
470
  const g = mapGroupRef.value;
@@ -475,7 +477,9 @@ function setupInteraction() {
475
477
  svg.addEventListener("touchstart", onTouchStart, { passive: true });
476
478
  svg.addEventListener("touchend", onTouchEnd);
477
479
  svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
478
- // 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).
479
483
  if (isTouchDevice()) return;
480
484
  g.addEventListener("click", onDelegatedEvent);
481
485
  g.addEventListener("mouseover", onDelegatedEvent);
@@ -551,11 +555,20 @@ function setupZoom() {
551
555
  const svg = select(svgRef.value);
552
556
  zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
553
557
  .scaleExtent([1, maxScale.value])
558
+ // d3-zoom swallows the click after any mousedown→mouseup movement
559
+ // beyond this distance. The default of 0 makes clicks unreliable the
560
+ // moment drag-pan is live (a pixel of hand jitter reads as a drag),
561
+ // so allow a few pixels of slack.
562
+ .clickDistance(6)
554
563
  .on("start", () => {
555
564
  isZooming = true;
556
- clearHover();
557
565
  })
558
566
  .on("zoom", (event) => {
567
+ // Cleared here, not on gesture start: a plain mousedown opens a
568
+ // gesture once drag-pan is live, and hiding the tooltip on every
569
+ // click press felt broken. The tooltip only needs to go once the
570
+ // map actually moves under the cursor.
571
+ clearHover();
559
572
  if (mapGroupRef.value) {
560
573
  mapGroupRef.value.setAttribute("transform", event.transform);
561
574
  }
@@ -592,10 +605,19 @@ function setupZoom() {
592
605
  if (isTouchDevice()) return false;
593
606
  break;
594
607
  case "mousedown":
595
- if (!expanded && !isActivated.value) return false;
608
+ if (!expanded && !hasZoomed.value) return false;
596
609
  break;
597
610
  case "touchstart":
598
- if (!expanded && !touchGesturesInline.value) return false;
611
+ if (!expanded && !touchGesturesInline.value) {
612
+ // In-place touch mode: a pinch (second finger) is itself the
613
+ // zoom entry gesture; single-finger drags stay with the page
614
+ // until the first zoom.
615
+ const pinchEntry =
616
+ !props.touchExpand &&
617
+ props.zoomMode !== "scroll" &&
618
+ (event as TouchEvent).touches.length >= 2;
619
+ if (!pinchEntry) return false;
620
+ }
599
621
  break;
600
622
  }
601
623
  }
@@ -825,8 +847,8 @@ function resetZoom() {
825
847
  svg.interrupt();
826
848
  hideTooltip();
827
849
  // 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
850
+ // pre-zoom mode: once the zoom-out lands, deactivate so the page gets
851
+ // touch scrolling back and the zoom hint returns. Desktop stays
830
852
  // activated (sticky), and inside fullscreen the ✕ handles leaving.
831
853
  const deactivate =
832
854
  isTouchDevice() && !fullscreen.isFullscreen.value && !isScrollMode.value;
@@ -837,7 +859,6 @@ function resetZoom() {
837
859
  if (deactivate) {
838
860
  transition.on("end", () => {
839
861
  hasZoomed.value = false;
840
- hasInteracted.value = false;
841
862
  });
842
863
  }
843
864
  }
@@ -871,24 +892,24 @@ const isPannable = computed(
871
892
  () =>
872
893
  props.zoom &&
873
894
  !isTouchDevice() &&
874
- (isScrollMode.value || isActivated.value || fullscreen.isFullscreen.value),
895
+ (isScrollMode.value || hasZoomed.value || fullscreen.isFullscreen.value),
875
896
  );
876
897
 
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
898
+ // Grey affordance line shown while the zoom gesture is still the way in:
899
+ // on desktop until the first zoom (the controls take over from there), on
879
900
  // 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.
901
+ // the first in-place zoom (without). Never while fullscreen or in scroll
902
+ // mode — those are already interactive.
882
903
  const showZoomHint = computed(
883
904
  () =>
884
905
  props.zoom &&
885
906
  props.zoomHint &&
886
907
  !isScrollMode.value &&
887
908
  !fullscreen.isFullscreen.value &&
888
- ((isTouchDevice() && props.touchExpand) || !isActivated.value),
909
+ ((isTouchDevice() && props.touchExpand) || !hasZoomed.value),
889
910
  );
890
911
  const zoomHintText = computed(() =>
891
- isTouchDevice() ? "Tap to zoom" : "Double click to zoom",
912
+ isTouchDevice() ? "Double tap to zoom" : "Double click to zoom",
892
913
  );
893
914
 
894
915
  // Inline style for the map svg. `touch-action: none` hands pan/pinch to
@@ -902,9 +923,15 @@ const svgStyle = computed(() => {
902
923
  const style: Record<string, string> = {};
903
924
  if (fullscreen.isFullscreen.value || touchGesturesInline.value) {
904
925
  style["touch-action"] = "none";
926
+ } else if (props.zoom && !isScrollMode.value && isTouchDevice()) {
927
+ // Inline, pre-zoom: keep page panning but claim the zoom gestures.
928
+ // `manipulation` suppresses the browser's double-tap zoom (the expand
929
+ // gesture); in-place mode also blocks browser pinch-zoom so a pinch
930
+ // reaches d3-zoom as the entry gesture.
931
+ style["touch-action"] = props.touchExpand ? "manipulation" : "pan-x pan-y";
905
932
  }
906
933
  if (
907
- isActivated.value ||
934
+ hasZoomed.value ||
908
935
  fullscreen.isFullscreen.value ||
909
936
  (props.zoom && isScrollMode.value)
910
937
  ) {
@@ -1666,16 +1693,16 @@ function eventToFeatureId(target: EventTarget | null): string | null {
1666
1693
  // wanting fine-grained multi-select handle merging via `@update:focus`.
1667
1694
  // Shared by the mouse-click and touch-tap paths.
1668
1695
  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;
1672
1696
  emit("stateClick", { id: data.id, name: data.name, value: data.value });
1673
1697
  const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
1674
1698
  emit("update:focus", wasFocused ? null : data.id);
1675
1699
  }
1676
1700
 
1677
1701
  function onDelegatedEvent(event: Event) {
1678
- if (isZooming) return;
1702
+ // Only hover is suppressed mid-gesture (stroke churn while the map
1703
+ // moves); clicks stay live — even during zoom animations — since d3
1704
+ // already swallows genuine post-drag clicks via clickDistance.
1705
+ if (event.type === "mouseover" && isZooming) return;
1679
1706
  const me = event as MouseEvent;
1680
1707
  const featId = eventToFeatureId(me.target);
1681
1708
  if (!featId) return;
@@ -1742,38 +1769,80 @@ function onTouchEnd(event: TouchEvent) {
1742
1769
  if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
1743
1770
  if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
1744
1771
  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.
1772
+ // Inline touch map with zoom on (activate mode): a *double tap*
1773
+ // anywhere (features and background alike) is the zoom gesture
1774
+ // expanding to fill the window by default, or zooming in place with
1775
+ // `touchExpand: false` (a pinch works there too). Single taps select,
1776
+ // deferred by the double-tap window so the second tap of a double tap
1777
+ // can cancel them — mirroring the desktop click debounce. Scroll mode
1778
+ // and the expanded view skip all of this: taps there select directly.
1751
1779
  if (
1752
1780
  props.zoom &&
1753
1781
  !isScrollMode.value &&
1754
1782
  isTouchDevice() &&
1755
1783
  !fullscreen.isFullscreen.value
1756
1784
  ) {
1757
- if (props.touchExpand) {
1758
- event.preventDefault();
1759
- enterTouchZoom(t.clientX, t.clientY);
1785
+ // Suppress the synthetic click/hover the browser would replay from
1786
+ // this tap (and iOS's hover-first tap).
1787
+ event.preventDefault();
1788
+ const prev = lastTap;
1789
+ lastTap = { x: t.clientX, y: t.clientY, time: event.timeStamp };
1790
+ const isDoubleTap =
1791
+ prev != null &&
1792
+ event.timeStamp - prev.time <= CLICK_SELECT_DELAY_MS &&
1793
+ Math.abs(t.clientX - prev.x) <= DOUBLE_TAP_SLOP &&
1794
+ Math.abs(t.clientY - prev.y) <= DOUBLE_TAP_SLOP;
1795
+ if (isDoubleTap) {
1796
+ lastTap = null;
1797
+ window.clearTimeout(pendingSelectTimer);
1798
+ pendingSelectTimer = 0;
1799
+ if (props.touchExpand) {
1800
+ enterTouchZoom(t.clientX, t.clientY);
1801
+ } else {
1802
+ zoomInPlaceAt(t.clientX, t.clientY);
1803
+ }
1760
1804
  return;
1761
1805
  }
1762
- if (!isActivated.value) {
1763
- event.preventDefault();
1764
- zoomInPlaceAt(t.clientX, t.clientY);
1806
+ const data = start.featId ? tooltipDataById.get(start.featId) : undefined;
1807
+ if (!data) {
1808
+ // Background tap: dismiss any tap-hover state.
1809
+ clearHover();
1765
1810
  return;
1766
1811
  }
1812
+ // Highlight + tooltip respond instantly; only the selection emits
1813
+ // wait out the double-tap window.
1814
+ touchHover(data, t.clientX, t.clientY);
1815
+ window.clearTimeout(pendingSelectTimer);
1816
+ pendingSelectTimer = window.setTimeout(() => {
1817
+ pendingSelectTimer = 0;
1818
+ emitSelection(data);
1819
+ }, CLICK_SELECT_DELAY_MS);
1820
+ return;
1821
+ }
1822
+ if (!start.featId) {
1823
+ clearHover();
1824
+ return;
1767
1825
  }
1768
- if (!start.featId) return;
1769
1826
  const data = tooltipDataById.get(start.featId);
1770
1827
  if (!data) return;
1771
1828
  // Suppress the synthetic click/hover the browser would replay from this
1772
1829
  // tap, so selection fires exactly once and never via iOS's hover-first tap.
1773
1830
  event.preventDefault();
1831
+ touchHover(data, t.clientX, t.clientY);
1774
1832
  emitSelection(data);
1775
1833
  }
1776
1834
 
1835
+ // A tap doubles as the touch replacement for hover: apply the hover-style
1836
+ // highlight, announce it, and show the tooltip at the tapped point.
1837
+ // (Continuous hover *tracking* stays off on touch — see setupInteraction —
1838
+ // this is the one-shot equivalent.)
1839
+ function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
1840
+ const pathEl = pathsByFeatureId.get(data.id);
1841
+ if (pathEl) setHover(pathEl);
1842
+ emit("stateHover", { id: data.id, name: data.name, value: data.value });
1843
+ if (hasInteractiveTooltip.value) showTooltip(data.id, clientX, clientY);
1844
+ }
1845
+
1777
1846
  function onTouchCancel() {
1778
1847
  tapStart = null;
1779
1848
  }
@@ -2192,6 +2261,8 @@ watch(
2192
2261
 
2193
2262
  position: relative;
2194
2263
  width: 100%;
2264
+ /* Size container so the zoom hint can reposition on narrow maps. */
2265
+ container-type: inline-size;
2195
2266
  }
2196
2267
 
2197
2268
  .choropleth-wrapper svg {
@@ -2226,11 +2297,13 @@ watch(
2226
2297
 
2227
2298
  /* Overlays the top of the map: absolutely positioned with no `top`, the
2228
2299
  box keeps its static position — the top edge of the svg that follows it
2229
- in the markup — without taking up flow space. */
2300
+ in the markup — without taking up flow space. The text carries a
2301
+ page-color halo so it stays legible over map fills. */
2230
2302
  .choropleth-zoom-hint {
2231
2303
  position: absolute;
2232
2304
  left: 0;
2233
2305
  right: 0;
2306
+ z-index: 1;
2234
2307
  padding-top: 6px;
2235
2308
  text-align: center;
2236
2309
  font-size: 12px;
@@ -2238,6 +2311,21 @@ watch(
2238
2311
  color: var(--color-text-secondary, #777);
2239
2312
  opacity: 0.6;
2240
2313
  pointer-events: none;
2314
+ text-shadow:
2315
+ 1px 0 0 var(--color-bg-0, #fff),
2316
+ -1px 0 0 var(--color-bg-0, #fff),
2317
+ 0 1px 0 var(--color-bg-0, #fff),
2318
+ 0 -1px 0 var(--color-bg-0, #fff),
2319
+ 0 0 3px var(--color-bg-0, #fff);
2320
+ }
2321
+
2322
+ /* On narrow maps the overlay would cover content that matters — show the
2323
+ hint in flow above the map instead (the wrapper is the container). */
2324
+ @container (max-width: 480px) {
2325
+ .choropleth-zoom-hint {
2326
+ position: static;
2327
+ padding: 0;
2328
+ }
2241
2329
  }
2242
2330
 
2243
2331
  /*
@@ -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.1",
2
+ "version": "0.7.3",
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.1",
3
+ "version": "0.7.3",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {