@cfasim-ui/docs 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed } from "vue";
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
3
|
import countiesTopoForPerf from "us-atlas/counties-10m.json";
|
|
4
4
|
|
|
5
5
|
// Build one row per county (~3,143) with a deterministic-ish value so the
|
|
@@ -11,6 +11,11 @@ const denseCountyData = computed(() => {
|
|
|
11
11
|
value: (i * 37) % 100,
|
|
12
12
|
}));
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
// Focus demo state — bound directly to v-model:focus. The component
|
|
16
|
+
// handles click-to-toggle and emits null when the focused feature is
|
|
17
|
+
// re-clicked.
|
|
18
|
+
const focused = ref(null);
|
|
14
19
|
</script>
|
|
15
20
|
|
|
16
21
|
# ChoroplethMap
|
|
@@ -337,6 +342,71 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
|
|
|
337
342
|
</template>
|
|
338
343
|
</ComponentDemo>
|
|
339
344
|
|
|
345
|
+
### Click to focus (`v-model:focus`)
|
|
346
|
+
|
|
347
|
+
Bind the `focus` prop to pan and zoom to a specific feature. Pass a feature
|
|
348
|
+
id (FIPS code, HSA code, or name) — or an array of ids to focus on a region.
|
|
349
|
+
With `v-model:focus`, clicking an unfocused feature focuses it and clicking
|
|
350
|
+
the focused feature toggles back off. If a tooltip is configured, focusing
|
|
351
|
+
shows that feature's tooltip. Users can pan/zoom freely around the focused
|
|
352
|
+
area; the built-in **Reset** button clears focus and snaps back.
|
|
353
|
+
|
|
354
|
+
Counties are tiny without a zoom — focus is a natural fit for drill-down.
|
|
355
|
+
|
|
356
|
+
<ComponentDemo>
|
|
357
|
+
<ChoroplethMap
|
|
358
|
+
:topology="countiesTopo"
|
|
359
|
+
geo-type="counties"
|
|
360
|
+
v-model:focus="focused"
|
|
361
|
+
:focus-zoom-level="8"
|
|
362
|
+
:data="[
|
|
363
|
+
{ id: '06037', value: 100 },
|
|
364
|
+
{ id: '06073', value: 80 },
|
|
365
|
+
{ id: '36061', value: 90 },
|
|
366
|
+
{ id: '17031', value: 85 },
|
|
367
|
+
{ id: '48201', value: 65 },
|
|
368
|
+
{ id: '04013', value: 60 },
|
|
369
|
+
{ id: '12086', value: 55 },
|
|
370
|
+
{ id: '53033', value: 50 },
|
|
371
|
+
]"
|
|
372
|
+
title="Click a county to focus"
|
|
373
|
+
:legend-title="'Cases'"
|
|
374
|
+
:height="400"
|
|
375
|
+
>
|
|
376
|
+
<template #tooltip="{ name, value }">
|
|
377
|
+
<div style="font-weight: 600">{{ name }}</div>
|
|
378
|
+
<div v-if="value != null">Cases: {{ value }}</div>
|
|
379
|
+
<div v-else style="opacity: 0.6">No data</div>
|
|
380
|
+
</template>
|
|
381
|
+
</ChoroplethMap>
|
|
382
|
+
|
|
383
|
+
<template #code>
|
|
384
|
+
|
|
385
|
+
```vue
|
|
386
|
+
<script setup>
|
|
387
|
+
import { ref } from "vue";
|
|
388
|
+
const focused = ref(null);
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<ChoroplethMap
|
|
392
|
+
:topology="countiesTopo"
|
|
393
|
+
geo-type="counties"
|
|
394
|
+
v-model:focus="focused"
|
|
395
|
+
:focus-zoom-level="8"
|
|
396
|
+
:data="data"
|
|
397
|
+
title="Click a county to focus"
|
|
398
|
+
>
|
|
399
|
+
<template #tooltip="{ name, value }">
|
|
400
|
+
<div style="font-weight: 600">{{ name }}</div>
|
|
401
|
+
<div v-if="value != null">Cases: {{ value }}</div>
|
|
402
|
+
<div v-else style="opacity: 0.6">No data</div>
|
|
403
|
+
</template>
|
|
404
|
+
</ChoroplethMap>
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
</template>
|
|
408
|
+
</ComponentDemo>
|
|
409
|
+
|
|
340
410
|
### Custom tooltip number format
|
|
341
411
|
|
|
342
412
|
Pass `tooltip-value-format` to format numeric values shown in the tooltip
|
|
@@ -377,13 +447,9 @@ Pass `tooltip-value-format` to format numeric values shown in the tooltip
|
|
|
377
447
|
</template>
|
|
378
448
|
</ComponentDemo>
|
|
379
449
|
|
|
380
|
-
### Dense county map
|
|
450
|
+
### Dense county map
|
|
381
451
|
|
|
382
|
-
Renders every US county with a value and a custom tooltip slot.
|
|
383
|
-
manual perf harness — open DevTools Performance, record a hover/sweep across
|
|
384
|
-
many counties, and inspect tooltip update cost. The tooltip element is
|
|
385
|
-
mounted once and patched in place; `mousemove` writes the position
|
|
386
|
-
straight to the DOM.
|
|
452
|
+
Renders every US county with a value and a custom tooltip slot.
|
|
387
453
|
|
|
388
454
|
<ComponentDemo>
|
|
389
455
|
<ChoroplethMap
|
|
@@ -507,6 +573,8 @@ set `tooltip-trigger`.
|
|
|
507
573
|
| `value` | `number \| string` | No | — |
|
|
508
574
|
| `tooltipValueFormat` | `(value: number) => string` | No | — |
|
|
509
575
|
| `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
|
|
576
|
+
| `focus` | `string \| string[] \| null` | No | — |
|
|
577
|
+
| `focusZoomLevel` | `number` | No | `4` |
|
|
510
578
|
|
|
511
579
|
|
|
512
580
|
### StateData
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
import { geoPath, geoAlbersUsa } from "d3-geo";
|
|
12
12
|
import { zoom as d3Zoom, zoomIdentity } from "d3-zoom";
|
|
13
13
|
import { select } from "d3-selection";
|
|
14
|
+
// Side-effect import: enables `selection.transition()` on d3 selections so
|
|
15
|
+
// `applyFocus` can animate the zoom transform.
|
|
16
|
+
import "d3-transition";
|
|
14
17
|
import { feature, mesh, merge } from "topojson-client";
|
|
15
18
|
import type { Topology, GeometryCollection } from "topojson-specification";
|
|
16
19
|
import { fipsToHsa, hsaNames } from "./hsaMapping.js";
|
|
@@ -102,6 +105,18 @@ const props = withDefaults(
|
|
|
102
105
|
* container's bounding box. `"window"` uses the viewport.
|
|
103
106
|
*/
|
|
104
107
|
tooltipClamp?: "none" | "chart" | "window";
|
|
108
|
+
/**
|
|
109
|
+
* Feature id(s) (FIPS code, HSA code, or feature name) to pan/zoom to.
|
|
110
|
+
* Pass `null` or an empty array to clear. Works with `v-model:focus`:
|
|
111
|
+
* clicking an unfocused feature emits its id; clicking a focused
|
|
112
|
+
* feature emits `null` (toggle off). Users can pan/zoom away from the
|
|
113
|
+
* focused area even when `zoom` and `pan` are disabled, and the
|
|
114
|
+
* built-in Reset button also clears focus. If a tooltip is configured,
|
|
115
|
+
* focusing a feature shows its tooltip.
|
|
116
|
+
*/
|
|
117
|
+
focus?: string | string[] | null;
|
|
118
|
+
/** Scale factor applied when `focus` is set. Default: 4 */
|
|
119
|
+
focusZoomLevel?: number;
|
|
105
120
|
}>(),
|
|
106
121
|
{
|
|
107
122
|
geoType: "states",
|
|
@@ -113,6 +128,7 @@ const props = withDefaults(
|
|
|
113
128
|
zoom: false,
|
|
114
129
|
pan: false,
|
|
115
130
|
tooltipClamp: "chart",
|
|
131
|
+
focusZoomLevel: 4,
|
|
116
132
|
},
|
|
117
133
|
);
|
|
118
134
|
|
|
@@ -125,6 +141,7 @@ const emit = defineEmits<{
|
|
|
125
141
|
e: "stateHover",
|
|
126
142
|
state: { id: string; name: string; value?: number | string } | null,
|
|
127
143
|
): void;
|
|
144
|
+
(e: "update:focus", focus: string | null): void;
|
|
128
145
|
}>();
|
|
129
146
|
|
|
130
147
|
type ChoroplethFeature = GeoJSON.Feature<
|
|
@@ -170,6 +187,10 @@ const pathsByFeatureId = new Map<string, SVGPathElement>();
|
|
|
170
187
|
const tooltipDataById = new Map<string, TooltipPayload>();
|
|
171
188
|
let bordersPathEl: SVGPathElement | null = null;
|
|
172
189
|
let hoveredEl: SVGPathElement | null = null;
|
|
190
|
+
// Paths currently styled as focused. Tracked separately from hover so the
|
|
191
|
+
// two states compose: hovering a focused path keeps the highlight on
|
|
192
|
+
// un-hover, and clearing focus while still hovering keeps the hover style.
|
|
193
|
+
const focusedPathEls = new Set<SVGPathElement>();
|
|
173
194
|
let isZooming = false;
|
|
174
195
|
// TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
|
|
175
196
|
// changes + compositing layers degrade zoom/pan). Disabled on touch devices.
|
|
@@ -219,6 +240,7 @@ onMounted(() => {
|
|
|
219
240
|
setupZoom();
|
|
220
241
|
setupInteraction();
|
|
221
242
|
rebuildPaths();
|
|
243
|
+
applyFocus();
|
|
222
244
|
attachTooltipObserver();
|
|
223
245
|
window.addEventListener("scroll", dismissOnViewportChange, {
|
|
224
246
|
passive: true,
|
|
@@ -240,11 +262,14 @@ onUnmounted(() => {
|
|
|
240
262
|
|
|
241
263
|
function setupZoom() {
|
|
242
264
|
if (!svgRef.value || !mapGroupRef.value) return;
|
|
243
|
-
if (!props.zoom && !props.pan) return;
|
|
244
265
|
|
|
245
266
|
const svg = select(svgRef.value);
|
|
267
|
+
// Always span focusZoomLevel and at least the standard 12× ceiling so the
|
|
268
|
+
// user can wheel further in/out of a focused view. Programmatic
|
|
269
|
+
// `.transform()` calls are clamped to this range too.
|
|
270
|
+
const maxScale = Math.max(12, props.focusZoomLevel);
|
|
246
271
|
zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
|
|
247
|
-
.scaleExtent(
|
|
272
|
+
.scaleExtent([1, maxScale])
|
|
248
273
|
.on("start", () => {
|
|
249
274
|
isZooming = true;
|
|
250
275
|
clearHover();
|
|
@@ -260,11 +285,25 @@ function setupZoom() {
|
|
|
260
285
|
isZooming = false;
|
|
261
286
|
});
|
|
262
287
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
288
|
+
// Dynamic filter: re-evaluated per event, so toggling `focus`,
|
|
289
|
+
// `zoom`, or `pan` doesn't require tearing down the zoom behavior.
|
|
290
|
+
// When focus is active we always allow drag + wheel so users can
|
|
291
|
+
// explore away from the focused area regardless of `zoom`/`pan`.
|
|
292
|
+
// Programmatic `.transform()` calls bypass this filter entirely.
|
|
293
|
+
zoomBehavior.filter((event) => {
|
|
294
|
+
const focused = normalizedFocus.value.length > 0;
|
|
295
|
+
const allowZoom = !!props.zoom || focused;
|
|
296
|
+
const allowPan = !!props.pan || focused;
|
|
297
|
+
if (event.type === "wheel" || event.type === "dblclick") {
|
|
298
|
+
if (!allowZoom) return false;
|
|
299
|
+
} else if (event.type === "mousedown" || event.type === "touchstart") {
|
|
300
|
+
if (!allowPan) return false;
|
|
301
|
+
} else if (!allowZoom && !allowPan) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
// Mirror d3-zoom's default rejections (ctrl-click, non-primary buttons).
|
|
305
|
+
return (!event.ctrlKey || event.type === "wheel") && !event.button;
|
|
306
|
+
});
|
|
268
307
|
|
|
269
308
|
svg.call(zoomBehavior);
|
|
270
309
|
}
|
|
@@ -276,20 +315,148 @@ function teardownZoom() {
|
|
|
276
315
|
}
|
|
277
316
|
}
|
|
278
317
|
|
|
318
|
+
// Resolve user-facing focus identifiers (FIPS, HSA codes, or feature names)
|
|
319
|
+
// to canonical feature ids. Used both for highlighting/zoom and for the
|
|
320
|
+
// click-to-toggle "is this feature currently focused?" check.
|
|
321
|
+
function resolveFocusIds(rawIds: string[]): Set<string> {
|
|
322
|
+
const byId = featuresById.value;
|
|
323
|
+
const nameIdx = nameToFeatureId.value;
|
|
324
|
+
const out = new Set<string>();
|
|
325
|
+
for (const raw of rawIds) {
|
|
326
|
+
const id = byId.has(raw) ? raw : nameIdx.get(raw);
|
|
327
|
+
if (id != null) out.add(id);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function resolveFocusFeatures(rawIds: string[]): ChoroplethFeature[] {
|
|
333
|
+
const byId = featuresById.value;
|
|
334
|
+
const out: ChoroplethFeature[] = [];
|
|
335
|
+
for (const id of resolveFocusIds(rawIds)) {
|
|
336
|
+
const f = byId.get(id);
|
|
337
|
+
if (f) out.push(f);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Duration of the focus zoom transition (ms). Initial mount and explicit
|
|
343
|
+
// "clear focus" still snap instantly; only focus-prop changes animate.
|
|
344
|
+
const FOCUS_ANIM_MS = 450;
|
|
345
|
+
// Tracks whether applyFocus has been called once — initial mount apply
|
|
346
|
+
// is instant, subsequent updates animate.
|
|
347
|
+
let focusApplied = false;
|
|
348
|
+
|
|
349
|
+
function applyFocus() {
|
|
350
|
+
if (!svgRef.value || !zoomBehavior) return;
|
|
351
|
+
const ids = normalizedFocus.value;
|
|
352
|
+
const features = ids.length > 0 ? resolveFocusFeatures(ids) : [];
|
|
353
|
+
|
|
354
|
+
// Compute the new highlight set first so we can diff against the
|
|
355
|
+
// previous one without re-resolving twice.
|
|
356
|
+
const nextFocused = new Set<SVGPathElement>();
|
|
357
|
+
for (const f of features) {
|
|
358
|
+
const p = pathsByFeatureId.get(String(f.id));
|
|
359
|
+
if (p) nextFocused.add(p);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Restore strokes on paths that are no longer focused. Skip those still
|
|
363
|
+
// hovered — hover keeps its own highlight.
|
|
364
|
+
for (const p of focusedPathEls) {
|
|
365
|
+
if (nextFocused.has(p) || p === hoveredEl) continue;
|
|
366
|
+
restoreDefaultStroke(p);
|
|
367
|
+
}
|
|
368
|
+
// Apply highlight to newly-focused paths (skip those already hovered:
|
|
369
|
+
// hover style is visually identical, no DOM churn needed).
|
|
370
|
+
for (const p of nextFocused) {
|
|
371
|
+
if (!focusedPathEls.has(p) && p !== hoveredEl) applyHighlightStroke(p);
|
|
372
|
+
}
|
|
373
|
+
focusedPathEls.clear();
|
|
374
|
+
for (const p of nextFocused) focusedPathEls.add(p);
|
|
375
|
+
|
|
376
|
+
const svg = select(svgRef.value);
|
|
377
|
+
// Always cancel any in-flight transition first — d3-transition queues
|
|
378
|
+
// same-named transitions rather than replacing them, so rapid focus
|
|
379
|
+
// changes would otherwise chain animations end-to-end. Also lets a
|
|
380
|
+
// straight-snap path actually take effect mid-animation.
|
|
381
|
+
svg.interrupt();
|
|
382
|
+
// First apply (initial mount) is instant. Only zoom-IN animates;
|
|
383
|
+
// clearing snaps back. Matches resetZoom's instant feel.
|
|
384
|
+
const animate = focusApplied && features.length > 0;
|
|
385
|
+
focusApplied = true;
|
|
386
|
+
|
|
387
|
+
if (features.length === 0) {
|
|
388
|
+
zoomBehavior.transform(svg, zoomIdentity);
|
|
389
|
+
clearHover();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Compute pan + scale onto the focused features' bounding box, in
|
|
394
|
+
// viewBox (canonical) coordinates.
|
|
395
|
+
const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds({
|
|
396
|
+
type: "FeatureCollection",
|
|
397
|
+
features,
|
|
398
|
+
});
|
|
399
|
+
const cx = (x0 + x1) / 2;
|
|
400
|
+
const cy = (y0 + y1) / 2;
|
|
401
|
+
const k = props.focusZoomLevel;
|
|
402
|
+
const target = zoomIdentity
|
|
403
|
+
.translate(width.value / 2 - k * cx, height.value / 2 - k * cy)
|
|
404
|
+
.scale(k);
|
|
405
|
+
|
|
406
|
+
const showFocusTooltip = () => {
|
|
407
|
+
if (!hasInteractiveTooltip.value) return;
|
|
408
|
+
const firstId = String(features[0].id);
|
|
409
|
+
const pathEl = pathsByFeatureId.get(firstId);
|
|
410
|
+
if (!pathEl) return;
|
|
411
|
+
// Read the rect *after* the transform commits so the tooltip lands at
|
|
412
|
+
// the focused feature's on-screen position.
|
|
413
|
+
const rect = pathEl.getBoundingClientRect();
|
|
414
|
+
showTooltip(
|
|
415
|
+
firstId,
|
|
416
|
+
rect.left + rect.width / 2,
|
|
417
|
+
rect.top + rect.height / 2,
|
|
418
|
+
);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
if (animate) {
|
|
422
|
+
// d3-zoom + d3-transition: `transition.call(zoomBehavior.transform,
|
|
423
|
+
// target)` interpolates the transform smoothly, firing the zoom
|
|
424
|
+
// callback per frame so pan + scale animate together. Hide any prior
|
|
425
|
+
// tooltip up front so it doesn't track the moving viewport; re-show
|
|
426
|
+
// once the new target is reached.
|
|
427
|
+
hideTooltip();
|
|
428
|
+
svg
|
|
429
|
+
.transition()
|
|
430
|
+
.duration(FOCUS_ANIM_MS)
|
|
431
|
+
.call(zoomBehavior.transform, target)
|
|
432
|
+
.on("end", showFocusTooltip);
|
|
433
|
+
} else {
|
|
434
|
+
zoomBehavior.transform(svg, target);
|
|
435
|
+
showFocusTooltip();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
279
439
|
function resetZoom() {
|
|
280
440
|
if (!svgRef.value || !zoomBehavior) return;
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
441
|
+
const svg = select(svgRef.value);
|
|
442
|
+
// Cancel any in-flight focus animation before snapping so the transition
|
|
443
|
+
// can't keep writing transforms after we set identity.
|
|
444
|
+
svg.interrupt();
|
|
445
|
+
zoomBehavior.transform(svg, zoomIdentity);
|
|
446
|
+
// Keep v-model:focus in sync when the user resets a focused view.
|
|
447
|
+
if (normalizedFocus.value.length > 0) emit("update:focus", null);
|
|
284
448
|
}
|
|
285
449
|
|
|
450
|
+
// `focusZoomLevel` only affects scaleExtent + the next focus apply. The
|
|
451
|
+
// d3-zoom filter reads `props.zoom` / `props.pan` dynamically, so we don't
|
|
452
|
+
// need to tear down zoom on those changes.
|
|
286
453
|
watch(
|
|
287
|
-
() =>
|
|
454
|
+
() => props.focusZoomLevel,
|
|
288
455
|
() => {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
456
|
+
if (zoomBehavior) {
|
|
457
|
+
zoomBehavior.scaleExtent([1, Math.max(12, props.focusZoomLevel)]);
|
|
458
|
+
}
|
|
459
|
+
applyFocus();
|
|
293
460
|
},
|
|
294
461
|
);
|
|
295
462
|
|
|
@@ -390,6 +557,26 @@ const nameToFeatureId = computed(() => {
|
|
|
390
557
|
return m;
|
|
391
558
|
});
|
|
392
559
|
|
|
560
|
+
// id → feature lookup used by `applyFocus`. Cached so focus changes don't
|
|
561
|
+
// trigger a linear scan through 3k+ features per apply.
|
|
562
|
+
const featuresById = computed(() => {
|
|
563
|
+
const m = new Map<string, ChoroplethFeature>();
|
|
564
|
+
for (const f of featuresGeo.value.features) {
|
|
565
|
+
if (f.id != null) m.set(String(f.id), f as ChoroplethFeature);
|
|
566
|
+
}
|
|
567
|
+
return m;
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Stable, deduped array form of `props.focus`. Drives the focus watcher;
|
|
571
|
+
// scalar `string` and `string[]` collapse to the same shape so the watcher
|
|
572
|
+
// only fires on a real change.
|
|
573
|
+
const normalizedFocus = computed<string[]>(() => {
|
|
574
|
+
const f = props.focus;
|
|
575
|
+
if (f == null) return [];
|
|
576
|
+
if (Array.isArray(f)) return f;
|
|
577
|
+
return [f];
|
|
578
|
+
});
|
|
579
|
+
|
|
393
580
|
const dataMap = computed(() => {
|
|
394
581
|
const map = new Map<string, number | string>();
|
|
395
582
|
if (!props.data) return map;
|
|
@@ -602,23 +789,32 @@ function hideTooltip() {
|
|
|
602
789
|
if (el) el.style.visibility = "hidden";
|
|
603
790
|
}
|
|
604
791
|
|
|
792
|
+
function applyHighlightStroke(pathEl: SVGPathElement) {
|
|
793
|
+
// Bring path to top so its thicker border isn't clipped by neighbors.
|
|
794
|
+
pathEl.parentNode?.appendChild(pathEl);
|
|
795
|
+
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
|
|
796
|
+
pathEl.setAttribute("stroke", "#555");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function restoreDefaultStroke(pathEl: SVGPathElement) {
|
|
800
|
+
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
801
|
+
pathEl.setAttribute("stroke", props.strokeColor);
|
|
802
|
+
}
|
|
803
|
+
|
|
605
804
|
function setHover(pathEl: SVGPathElement) {
|
|
606
805
|
if (hoveredEl === pathEl) return;
|
|
607
|
-
if (hoveredEl) {
|
|
608
|
-
|
|
609
|
-
|
|
806
|
+
if (hoveredEl && !focusedPathEls.has(hoveredEl)) {
|
|
807
|
+
// Restore previous hover unless it's also focused — focus keeps the
|
|
808
|
+
// highlight on its own.
|
|
809
|
+
restoreDefaultStroke(hoveredEl);
|
|
610
810
|
}
|
|
611
811
|
hoveredEl = pathEl;
|
|
612
|
-
|
|
613
|
-
pathEl.parentNode?.appendChild(pathEl);
|
|
614
|
-
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
|
|
615
|
-
pathEl.setAttribute("stroke", "#555");
|
|
812
|
+
applyHighlightStroke(pathEl);
|
|
616
813
|
}
|
|
617
814
|
|
|
618
815
|
function clearHover() {
|
|
619
816
|
if (hoveredEl) {
|
|
620
|
-
|
|
621
|
-
hoveredEl.setAttribute("stroke", props.strokeColor);
|
|
817
|
+
if (!focusedPathEls.has(hoveredEl)) restoreDefaultStroke(hoveredEl);
|
|
622
818
|
hoveredEl = null;
|
|
623
819
|
emit("stateHover", null);
|
|
624
820
|
}
|
|
@@ -643,6 +839,13 @@ function onDelegatedEvent(event: Event) {
|
|
|
643
839
|
const payload = { id: data.id, name: data.name, value: data.value };
|
|
644
840
|
if (event.type === "click") {
|
|
645
841
|
emit("stateClick", payload);
|
|
842
|
+
// Click-to-focus toggle, baked in so `v-model:focus="ref"` Just Works:
|
|
843
|
+
// clicking the currently focused feature clears focus (emits null);
|
|
844
|
+
// clicking any other feature emits its id. With a `focus` array, any
|
|
845
|
+
// click on a member clears everything — parents wanting fine-grained
|
|
846
|
+
// multi-select handle merging themselves via `@update:focus`.
|
|
847
|
+
const wasFocused = resolveFocusIds(normalizedFocus.value).has(data.id);
|
|
848
|
+
emit("update:focus", wasFocused ? null : data.id);
|
|
646
849
|
} else if (event.type === "mouseover") {
|
|
647
850
|
setHover(pathsByFeatureId.get(featId)!);
|
|
648
851
|
if (hasInteractiveTooltip.value)
|
|
@@ -682,6 +885,9 @@ function rebuildPaths() {
|
|
|
682
885
|
tooltipDataById.clear();
|
|
683
886
|
bordersPathEl = null;
|
|
684
887
|
hoveredEl = null;
|
|
888
|
+
// Old focused paths are about to be detached — drop refs so applyFocus
|
|
889
|
+
// can re-resolve against the new path tree.
|
|
890
|
+
focusedPathEls.clear();
|
|
685
891
|
|
|
686
892
|
const path = pathGenerator.value;
|
|
687
893
|
const features = featuresGeo.value.features;
|
|
@@ -755,14 +961,12 @@ function updateFills() {
|
|
|
755
961
|
}
|
|
756
962
|
|
|
757
963
|
function updateStrokes() {
|
|
758
|
-
const stroke = props.strokeColor;
|
|
759
|
-
const sw = String(effectiveStrokeWidth.value);
|
|
760
964
|
for (const p of pathsByFeatureId.values()) {
|
|
761
|
-
|
|
762
|
-
p.
|
|
763
|
-
p
|
|
965
|
+
// Highlighted paths (hover / focus) keep their #555 + thicker stroke.
|
|
966
|
+
if (p === hoveredEl || focusedPathEls.has(p)) continue;
|
|
967
|
+
restoreDefaultStroke(p);
|
|
764
968
|
}
|
|
765
|
-
if (bordersPathEl) bordersPathEl.setAttribute("stroke",
|
|
969
|
+
if (bordersPathEl) bordersPathEl.setAttribute("stroke", props.strokeColor);
|
|
766
970
|
}
|
|
767
971
|
|
|
768
972
|
function menuFilename() {
|
|
@@ -883,6 +1087,17 @@ watch(
|
|
|
883
1087
|
() => [props.strokeColor, effectiveStrokeWidth.value],
|
|
884
1088
|
() => updateStrokes(),
|
|
885
1089
|
);
|
|
1090
|
+
|
|
1091
|
+
// Focus or projection changed → re-apply the focus transform imperatively.
|
|
1092
|
+
// `flush: "post"` so any pending path rebuild from the watcher above has
|
|
1093
|
+
// already run; we still use the GeoJSON pathGenerator directly so the SVG
|
|
1094
|
+
// path tree isn't actually required, but keeping the order avoids stacking
|
|
1095
|
+
// two zoom transforms in the same tick.
|
|
1096
|
+
watch(
|
|
1097
|
+
() => [normalizedFocus.value, pathGenerator.value],
|
|
1098
|
+
() => applyFocus(),
|
|
1099
|
+
{ flush: "post" },
|
|
1100
|
+
);
|
|
886
1101
|
</script>
|
|
887
1102
|
|
|
888
1103
|
<template>
|
|
@@ -943,7 +1158,7 @@ watch(
|
|
|
943
1158
|
<g ref="mapGroupRef" />
|
|
944
1159
|
</svg>
|
|
945
1160
|
<button
|
|
946
|
-
v-if="
|
|
1161
|
+
v-if="isZoomed"
|
|
947
1162
|
type="button"
|
|
948
1163
|
class="choropleth-reset"
|
|
949
1164
|
aria-label="Reset zoom"
|
package/index.json
CHANGED