@cfasim-ui/docs 0.7.1 → 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
@@ -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);
@@ -592,10 +596,19 @@ function setupZoom() {
592
596
  if (isTouchDevice()) return false;
593
597
  break;
594
598
  case "mousedown":
595
- if (!expanded && !isActivated.value) return false;
599
+ if (!expanded && !hasZoomed.value) return false;
596
600
  break;
597
601
  case "touchstart":
598
- 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
+ }
599
612
  break;
600
613
  }
601
614
  }
@@ -825,8 +838,8 @@ function resetZoom() {
825
838
  svg.interrupt();
826
839
  hideTooltip();
827
840
  // 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
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
830
843
  // activated (sticky), and inside fullscreen the ✕ handles leaving.
831
844
  const deactivate =
832
845
  isTouchDevice() && !fullscreen.isFullscreen.value && !isScrollMode.value;
@@ -837,7 +850,6 @@ function resetZoom() {
837
850
  if (deactivate) {
838
851
  transition.on("end", () => {
839
852
  hasZoomed.value = false;
840
- hasInteracted.value = false;
841
853
  });
842
854
  }
843
855
  }
@@ -871,24 +883,24 @@ const isPannable = computed(
871
883
  () =>
872
884
  props.zoom &&
873
885
  !isTouchDevice() &&
874
- (isScrollMode.value || isActivated.value || fullscreen.isFullscreen.value),
886
+ (isScrollMode.value || hasZoomed.value || fullscreen.isFullscreen.value),
875
887
  );
876
888
 
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
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
879
891
  // 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.
892
+ // the first in-place zoom (without). Never while fullscreen or in scroll
893
+ // mode — those are already interactive.
882
894
  const showZoomHint = computed(
883
895
  () =>
884
896
  props.zoom &&
885
897
  props.zoomHint &&
886
898
  !isScrollMode.value &&
887
899
  !fullscreen.isFullscreen.value &&
888
- ((isTouchDevice() && props.touchExpand) || !isActivated.value),
900
+ ((isTouchDevice() && props.touchExpand) || !hasZoomed.value),
889
901
  );
890
902
  const zoomHintText = computed(() =>
891
- isTouchDevice() ? "Tap to zoom" : "Double click to zoom",
903
+ isTouchDevice() ? "Double tap to zoom" : "Double click to zoom",
892
904
  );
893
905
 
894
906
  // Inline style for the map svg. `touch-action: none` hands pan/pinch to
@@ -902,9 +914,15 @@ const svgStyle = computed(() => {
902
914
  const style: Record<string, string> = {};
903
915
  if (fullscreen.isFullscreen.value || touchGesturesInline.value) {
904
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";
905
923
  }
906
924
  if (
907
- isActivated.value ||
925
+ hasZoomed.value ||
908
926
  fullscreen.isFullscreen.value ||
909
927
  (props.zoom && isScrollMode.value)
910
928
  ) {
@@ -1666,9 +1684,6 @@ function eventToFeatureId(target: EventTarget | null): string | null {
1666
1684
  // wanting fine-grained multi-select handle merging via `@update:focus`.
1667
1685
  // Shared by the mouse-click and touch-tap paths.
1668
1686
  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
1687
  emit("stateClick", { id: data.id, name: data.name, value: data.value });
1673
1688
  const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
1674
1689
  emit("update:focus", wasFocused ? null : data.id);
@@ -1742,38 +1757,80 @@ function onTouchEnd(event: TouchEvent) {
1742
1757
  if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
1743
1758
  if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
1744
1759
  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.
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.
1751
1767
  if (
1752
1768
  props.zoom &&
1753
1769
  !isScrollMode.value &&
1754
1770
  isTouchDevice() &&
1755
1771
  !fullscreen.isFullscreen.value
1756
1772
  ) {
1757
- if (props.touchExpand) {
1758
- event.preventDefault();
1759
- 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
+ }
1760
1792
  return;
1761
1793
  }
1762
- if (!isActivated.value) {
1763
- event.preventDefault();
1764
- 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();
1765
1798
  return;
1766
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;
1767
1813
  }
1768
- if (!start.featId) return;
1769
1814
  const data = tooltipDataById.get(start.featId);
1770
1815
  if (!data) return;
1771
1816
  // Suppress the synthetic click/hover the browser would replay from this
1772
1817
  // tap, so selection fires exactly once and never via iOS's hover-first tap.
1773
1818
  event.preventDefault();
1819
+ touchHover(data, t.clientX, t.clientY);
1774
1820
  emitSelection(data);
1775
1821
  }
1776
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
+
1777
1834
  function onTouchCancel() {
1778
1835
  tapStart = null;
1779
1836
  }
@@ -2192,6 +2249,8 @@ watch(
2192
2249
 
2193
2250
  position: relative;
2194
2251
  width: 100%;
2252
+ /* Size container so the zoom hint can reposition on narrow maps. */
2253
+ container-type: inline-size;
2195
2254
  }
2196
2255
 
2197
2256
  .choropleth-wrapper svg {
@@ -2226,11 +2285,13 @@ watch(
2226
2285
 
2227
2286
  /* Overlays the top of the map: absolutely positioned with no `top`, the
2228
2287
  box keeps its static position — the top edge of the svg that follows it
2229
- 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. */
2230
2290
  .choropleth-zoom-hint {
2231
2291
  position: absolute;
2232
2292
  left: 0;
2233
2293
  right: 0;
2294
+ z-index: 1;
2234
2295
  padding-top: 6px;
2235
2296
  text-align: center;
2236
2297
  font-size: 12px;
@@ -2238,6 +2299,21 @@ watch(
2238
2299
  color: var(--color-text-secondary, #777);
2239
2300
  opacity: 0.6;
2240
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
+ }
2241
2317
  }
2242
2318
 
2243
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.1",
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.1",
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": {