@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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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.
|
|
274
|
-
pan/zoom mode; from then on, drag pans the map. The
|
|
275
|
-
counterclockwise arrow) animates back to the full extent
|
|
276
|
-
`focus`); − and reset are disabled while the map is at its
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
the top-left corner. The first zoom — including 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
|
|
285
|
-
controls are present — no double-click needed first. The scroll wheel
|
|
286
|
-
trackpad pinch) zooms there too, since page scrolling is locked while
|
|
287
|
-
map fills the window.
|
|
288
|
-
|
|
289
|
-
Prefer in-place zooming on touch? Set `:touch-expand="false"` —
|
|
290
|
-
tap zooms the inline map
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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 —
|
|
502
|
-
|
|
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,
|
|
171
|
-
*
|
|
172
|
-
* desktop double-click
|
|
173
|
-
*
|
|
174
|
-
* expands the map to fill the window, where one finger pans,
|
|
175
|
-
* zooms, and a tap selects. The scroll wheel never zooms
|
|
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
|
|
191
|
-
* Default `true`. Set `false` to zoom in place instead:
|
|
192
|
-
* zooms the inline map
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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" / "
|
|
202
|
-
* the top of the map while the zoom gesture hasn't
|
|
203
|
-
* Default `true`; set `false` to hide it. No effect
|
|
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
|
-
//
|
|
418
|
-
//
|
|
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
|
|
432
|
-
// the page: always in scroll mode, and — with tap-to-expand
|
|
433
|
-
// once the first
|
|
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 &&
|
|
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
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
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
|
-
//
|
|
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 && !
|
|
599
|
+
if (!expanded && !hasZoomed.value) return false;
|
|
596
600
|
break;
|
|
597
601
|
case "touchstart":
|
|
598
|
-
if (!expanded && !touchGesturesInline.value)
|
|
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-
|
|
829
|
-
// touch scrolling back and the
|
|
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 ||
|
|
886
|
+
(isScrollMode.value || hasZoomed.value || fullscreen.isFullscreen.value),
|
|
875
887
|
);
|
|
876
888
|
|
|
877
|
-
// Grey affordance line shown while the
|
|
878
|
-
//
|
|
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
|
|
881
|
-
//
|
|
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) || !
|
|
900
|
+
((isTouchDevice() && props.touchExpand) || !hasZoomed.value),
|
|
889
901
|
);
|
|
890
902
|
const zoomHintText = computed(() =>
|
|
891
|
-
isTouchDevice() ? "
|
|
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
|
-
|
|
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
|
|
1746
|
-
// (features and background alike)
|
|
1747
|
-
// fill the window by default, or zooming in place with
|
|
1748
|
-
// `touchExpand: false
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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