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