@cfasim-ui/docs 0.7.3 → 0.7.4

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.
@@ -279,10 +279,10 @@ hide it.
279
279
  full extent.
280
280
  - **Touch:** a **double tap** expands the map to fill the window, zoomed
281
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
+ expanded view, one finger pans, a pinch zooms, and a tap selects — its
283
+ tooltip slides up as a bottom sheet; the +/−/reset controls sit top-left
284
+ and a close (✕) button top-right returns to the inline map at full
285
+ extent. A single finger over the inline map still scrolls the page.
286
286
 
287
287
  Filling the window is always an activated state: whether entered via the
288
288
  menu's **Fullscreen** item or a double tap, pan/zoom works immediately and
@@ -319,11 +319,16 @@ pans, and touch gestures work inline with no tap-to-expand step. The
319
319
 
320
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.
321
321
 
322
+ This demo also wires up an interactive tooltip (`tooltip-trigger="hover"`):
323
+ hover a county on desktop, or tap one on touch, and it works the same
324
+ before and after zooming in.
325
+
322
326
  <ComponentDemo>
323
327
  <ChoroplethMap
324
328
  :topology="countiesTopo"
325
329
  geo-type="counties"
326
330
  zoom
331
+ tooltip-trigger="hover"
327
332
  :data="[
328
333
  { id: '06037', value: 100 },
329
334
  { id: '06073', value: 80 },
@@ -348,6 +353,7 @@ Set `geoType="counties"` to render county-level data using 5-digit FIPS codes. S
348
353
  :topology="countiesTopo"
349
354
  geo-type="counties"
350
355
  zoom
356
+ tooltip-trigger="hover"
351
357
  :data="[
352
358
  { id: '06037', value: 100 },
353
359
  { id: '36061', value: 90 },
@@ -368,11 +374,15 @@ Set `geoType="counties"` to render county-level data using 5-digit FIPS codes. S
368
374
 
369
375
  Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolved from county boundaries using a built-in FIPS-to-HSA mapping. Use 6-digit HSA codes as IDs. State borders are overlaid for context.
370
376
 
377
+ This demo pairs `tooltip-trigger="hover"` with a custom `#tooltip` slot —
378
+ hover an HSA on desktop, or tap one on touch, before or after zooming in.
379
+
371
380
  <ComponentDemo>
372
381
  <ChoroplethMap
373
382
  :topology="countiesTopo"
374
383
  geo-type="hsas"
375
384
  zoom
385
+ tooltip-trigger="hover"
376
386
  :data="[
377
387
  { id: '010259', value: 100 },
378
388
  { id: '060766', value: 90 },
@@ -388,7 +398,14 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
388
398
  title="Cases by HSA"
389
399
  :legend-title="'Cases'"
390
400
  :height="400"
391
- />
401
+ >
402
+ <template #tooltip="{ id, name, value }">
403
+ <div style="font-weight: 600">{{ name }}</div>
404
+ <div style="opacity: 0.7; font-size: 0.85em">HSA {{ id }}</div>
405
+ <div v-if="value != null">Cases: {{ value }}</div>
406
+ <div v-else style="opacity: 0.6">No data</div>
407
+ </template>
408
+ </ChoroplethMap>
392
409
 
393
410
  <template #code>
394
411
 
@@ -397,6 +414,7 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
397
414
  :topology="countiesTopo"
398
415
  geo-type="hsas"
399
416
  zoom
417
+ tooltip-trigger="hover"
400
418
  :data="[
401
419
  { id: '010259', value: 100 },
402
420
  { id: '060766', value: 90 },
@@ -407,7 +425,14 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
407
425
  title="Cases by HSA"
408
426
  :legend-title="'Cases'"
409
427
  :height="400"
410
- />
428
+ >
429
+ <template #tooltip="{ id, name, value }">
430
+ <div style="font-weight: 600">{{ name }}</div>
431
+ <div style="opacity: 0.7; font-size: 0.85em">HSA {{ id }}</div>
432
+ <div v-if="value != null">Cases: {{ value }}</div>
433
+ <div v-else style="opacity: 0.6">No data</div>
434
+ </template>
435
+ </ChoroplethMap>
411
436
  ```
412
437
 
413
438
  </template>
@@ -499,8 +524,9 @@ Selection works the same on touch: a single-finger **tap** on a feature
499
524
  emits `stateClick` and toggles focus, inline or inside the expanded view.
500
525
  (Inline with `zoom` on, the selection fires after the brief double-tap
501
526
  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.
527
+ emits `stateHover`, and shows the feature's tooltip (anchored to the
528
+ feature inline; sliding up as a bottom sheet in the expanded view); only
529
+ continuous hover _tracking_ is off on touch, for performance.
504
530
 
505
531
  Click-to-focus interactions usually pair best with `:touch-expand="false"`,
506
532
  as in the demos below — a double tap zooms in place while taps keep
@@ -474,6 +474,8 @@ function setupInteraction() {
474
474
  // `touchend` is non-passive so a confirmed tap can preventDefault and
475
475
  // suppress the compatibility click/hover the browser would otherwise
476
476
  // synthesize (the double-fire and iOS first-tap-hover sources).
477
+ // Must run before setupZoom(): d3-zoom stopImmediatePropagation()s
478
+ // touch events once its filter passes, which would starve these.
477
479
  svg.addEventListener("touchstart", onTouchStart, { passive: true });
478
480
  svg.addEventListener("touchend", onTouchEnd);
479
481
  svg.addEventListener("touchcancel", onTouchCancel, { passive: true });
@@ -515,8 +517,13 @@ function dismissOnViewportChange() {
515
517
  let svgResizeObserver: ResizeObserver | null = null;
516
518
 
517
519
  onMounted(() => {
518
- setupZoom();
520
+ // Order is load-bearing: once its filter passes (zoom activated /
521
+ // expanded), d3-zoom calls stopImmediatePropagation() on touchstart and
522
+ // touchend. Same-element listeners run in registration order, so our
523
+ // tap listeners must be registered BEFORE d3-zoom's or taps (selection,
524
+ // tooltips) go dead the moment the map owns touch gestures.
519
525
  setupInteraction();
526
+ setupZoom();
520
527
  rebuildPaths();
521
528
  applyFocus();
522
529
  attachTooltipObserver();
@@ -933,8 +940,12 @@ const svgStyle = computed(() => {
933
940
  if (
934
941
  hasZoomed.value ||
935
942
  fullscreen.isFullscreen.value ||
936
- (props.zoom && isScrollMode.value)
943
+ (props.zoom && (isScrollMode.value || isTouchDevice()))
937
944
  ) {
945
+ // Touch maps get the layer up front, not just once zoomed: a tap
946
+ // restyles a feature path, and without the layer WebKit repaints the
947
+ // surrounding page layer (~200ms per mutation on the HSA map — taps
948
+ // felt sluggish inline while fullscreen, already layered, was fast).
938
949
  style["will-change"] = "transform";
939
950
  }
940
951
  return Object.keys(style).length ? style : undefined;
@@ -1520,23 +1531,53 @@ function applyTooltipPosition(clientX: number, clientY: number) {
1520
1531
  chartRect,
1521
1532
  );
1522
1533
  el.style.transform = `translate3d(${left}px, ${top}px, 0) translateY(-50%)`;
1534
+ // Safari reports client coordinates (events AND element rects) in
1535
+ // visual-viewport space, while position: fixed resolves against the
1536
+ // layout viewport — under page pinch-zoom the tooltip lands offset from
1537
+ // its target (typically "too high" by the pan offset). Rather than
1538
+ // sniffing engines, self-calibrate: re-measure where the tooltip
1539
+ // actually landed — the rect comes back in the same client space as the
1540
+ // target coords — and shift by the residual. Chrome measures ~0.
1541
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
1542
+ if (!vv || (vv.scale <= 1.01 && vv.offsetTop < 1 && vv.offsetLeft < 1)) {
1543
+ return;
1544
+ }
1545
+ const actual = el.getBoundingClientRect();
1546
+ const dx = actual.left - left;
1547
+ const dy = actual.top + actual.height / 2 - top;
1548
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
1549
+ el.style.transform = `translate3d(${left - dx}px, ${top - dy}px, 0) translateY(-50%)`;
1550
+ }
1523
1551
  }
1524
1552
 
1553
+ // The expanded touch view swaps the pointer-anchored tooltip for a bottom
1554
+ // sheet: floating fixed positioning is unreliable there (Safari reports
1555
+ // touch/rect coordinates in visual-viewport space once the page is
1556
+ // pinch-zoomed), while a sheet pinned to the wrapper is always right.
1557
+ const tooltipMode = computed<"float" | "sheet">(() =>
1558
+ fullscreen.isFullscreen.value && isTouchDevice() ? "sheet" : "float",
1559
+ );
1560
+
1525
1561
  function showTooltip(featId: string, clientX: number, clientY: number) {
1526
1562
  const data = tooltipDataById.get(featId);
1527
1563
  if (!data) return;
1528
1564
  const child = tooltipChildRef.value;
1529
- const el = child?.getEl();
1530
- if (!child || !el) return;
1565
+ if (!child) return;
1531
1566
  child.setData(data);
1532
- lastPointer = { x: clientX, y: clientY };
1533
1567
  tooltipVisible = true;
1568
+ if (tooltipMode.value === "sheet") {
1569
+ child.setOpen(true);
1570
+ return;
1571
+ }
1572
+ const el = child.getEl();
1573
+ if (!el) return;
1574
+ lastPointer = { x: clientX, y: clientY };
1534
1575
  applyTooltipPosition(clientX, clientY);
1535
1576
  el.style.visibility = "visible";
1536
1577
  }
1537
1578
 
1538
1579
  function moveTooltip(clientX: number, clientY: number) {
1539
- if (!tooltipVisible) return;
1580
+ if (!tooltipVisible || tooltipMode.value === "sheet") return;
1540
1581
  pendingMoveX = clientX;
1541
1582
  pendingMoveY = clientY;
1542
1583
  if (pendingMoveFrame) return;
@@ -1554,6 +1595,7 @@ function hideTooltip() {
1554
1595
  if (!tooltipVisible) return;
1555
1596
  tooltipVisible = false;
1556
1597
  lastPointer = null;
1598
+ tooltipChildRef.value?.setOpen(false);
1557
1599
  const el = tooltipChildRef.value?.getEl();
1558
1600
  if (el) el.style.visibility = "hidden";
1559
1601
  }
@@ -1833,14 +1875,29 @@ function onTouchEnd(event: TouchEvent) {
1833
1875
  }
1834
1876
 
1835
1877
  // 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.)
1878
+ // highlight, announce it, and show the tooltip. (Continuous hover
1879
+ // *tracking* stays off on touch — see setupInteraction — this is the
1880
+ // one-shot equivalent.)
1839
1881
  function touchHover(data: TooltipPayload, clientX: number, clientY: number) {
1840
1882
  const pathEl = pathsByFeatureId.get(data.id);
1841
1883
  if (pathEl) setHover(pathEl);
1842
1884
  emit("stateHover", { id: data.id, name: data.name, value: data.value });
1843
- if (hasInteractiveTooltip.value) showTooltip(data.id, clientX, clientY);
1885
+ if (!hasInteractiveTooltip.value) return;
1886
+ if (pathEl) {
1887
+ // Anchor to the feature, not the finger: element rects and fixed
1888
+ // positioning share the layout-viewport coordinate space, so this
1889
+ // stays put when the page itself is pinch-zoomed — Safari reports
1890
+ // touch client coords in *visual*-viewport space, which would skew a
1891
+ // finger-anchored tooltip. Also keeps it out from under the finger.
1892
+ const rect = pathEl.getBoundingClientRect();
1893
+ showTooltip(
1894
+ data.id,
1895
+ rect.left + rect.width / 2,
1896
+ rect.top + rect.height / 2,
1897
+ );
1898
+ } else {
1899
+ showTooltip(data.id, clientX, clientY);
1900
+ }
1844
1901
  }
1845
1902
 
1846
1903
  function onTouchCancel() {
@@ -2111,10 +2168,12 @@ watch(
2111
2168
 
2112
2169
  // Exiting the expanded touch view (✕, Escape) returns to the static inline
2113
2170
  // map at full extent — the inline map has no pan gestures, so a preserved
2114
- // transform would strand the user off-center.
2171
+ // transform would strand the user off-center. Any toggle also drops the
2172
+ // tooltip: the presentation mode (float vs sheet) may change with it.
2115
2173
  watch(
2116
2174
  () => fullscreen.isFullscreen.value,
2117
2175
  (expanded) => {
2176
+ hideTooltip();
2118
2177
  if (expanded || !isTouchDevice()) return;
2119
2178
  if (!svgRef.value || !zoomBehavior) return;
2120
2179
  const svg = select(svgRef.value);
@@ -2234,7 +2293,11 @@ watch(
2234
2293
  @zoom-out="zoomBy(1 / ZOOM_STEP)"
2235
2294
  @reset="resetZoom"
2236
2295
  />
2237
- <ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
2296
+ <ChoroplethTooltip
2297
+ v-if="hasInteractiveTooltip"
2298
+ ref="tooltipChildRef"
2299
+ :mode="tooltipMode"
2300
+ >
2238
2301
  <template #default="raw">
2239
2302
  <slot name="tooltip" v-bind="narrowSlotProps(raw)">
2240
2303
  <span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
@@ -8,20 +8,36 @@ export interface ChoroplethTooltipData {
8
8
  feature: unknown;
9
9
  }
10
10
 
11
- defineSlots<{
12
- default?(props: ChoroplethTooltipData): unknown;
13
- }>();
11
+ withDefaults(
12
+ defineProps<{
13
+ /**
14
+ * `"float"` (default) renders the classic pointer-anchored tooltip.
15
+ * `"sheet"` renders a bottom sheet that slides up inside the map
16
+ * wrapper instead — used in the expanded touch view, where it stays
17
+ * correctly positioned regardless of the page's pinch-zoom state
18
+ * (the wrapper is pinned to the visual viewport; floating fixed
19
+ * positioning is not).
20
+ */
21
+ mode?: "float" | "sheet";
22
+ }>(),
23
+ { mode: "float" },
24
+ );
14
25
 
15
26
  // Local reactive state. Held inside the child so the parent's render scope
16
27
  // never subscribes to it — hover updates re-render only this small tree,
17
28
  // not the parent's 3,000+ paths.
18
29
  const data = ref<ChoroplethTooltipData | null>(null);
30
+ const open = ref(false);
19
31
  const rootRef = useTemplateRef<HTMLDivElement>("root");
20
32
 
21
33
  defineExpose({
22
34
  setData(next: ChoroplethTooltipData | null) {
23
35
  data.value = next;
24
36
  },
37
+ /** Sheet-mode visibility (float mode is driven imperatively via getEl). */
38
+ setOpen(next: boolean) {
39
+ open.value = next;
40
+ },
25
41
  getEl(): HTMLDivElement | null {
26
42
  return rootRef.value;
27
43
  },
@@ -29,6 +45,10 @@ defineExpose({
29
45
  </script>
30
46
 
31
47
  <template>
48
+ <!-- Float: always mounted (the parent's ResizeObserver holds onto it);
49
+ its slot only renders in float mode. z-index sits one above the
50
+ fullscreen overlay so it stays visible while the map fills the window
51
+ (both live in body). -->
32
52
  <Teleport to="body">
33
53
  <div
34
54
  ref="root"
@@ -37,13 +57,47 @@ defineExpose({
37
57
  position: fixed;
38
58
  left: 0;
39
59
  top: 0;
60
+ z-index: calc(var(--cfasim-z-fullscreen, 1000) + 1);
40
61
  visibility: hidden;
41
62
  will-change: transform;
42
63
  pointer-events: none;
43
64
  transform: translateY(-50%);
44
65
  "
45
66
  >
46
- <slot v-if="data" v-bind="data" />
67
+ <slot v-if="data && mode === 'float'" v-bind="data" />
47
68
  </div>
48
69
  </Teleport>
70
+ <!-- Sheet: rendered in place inside the map wrapper, so it anchors to
71
+ the (visual-viewport-pinned) expanded box. -->
72
+ <div
73
+ v-if="mode === 'sheet'"
74
+ class="chart-tooltip-sheet"
75
+ :class="{ 'is-open': open }"
76
+ >
77
+ <slot v-if="data" v-bind="data" />
78
+ </div>
49
79
  </template>
80
+
81
+ <style scoped>
82
+ .chart-tooltip-sheet {
83
+ position: absolute;
84
+ left: 0;
85
+ right: 0;
86
+ bottom: 0;
87
+ z-index: 2;
88
+ background: var(--color-bg-0, #fff);
89
+ color: var(--color-text, inherit);
90
+ border-top: 1px solid var(--color-border, #e5e7eb);
91
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.12);
92
+ padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
93
+ font-size: 14px;
94
+ line-height: 1.4;
95
+ pointer-events: none;
96
+ transform: translateY(100%);
97
+ transition: transform 0.25s ease;
98
+ }
99
+
100
+ .chart-tooltip-sheet.is-open {
101
+ transform: translateY(0);
102
+ }
103
+ </style>
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.3",
2
+ "version": "0.7.4",
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.3",
3
+ "version": "0.7.4",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {