@cfasim-ui/docs 0.4.4 → 0.4.5
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.
- package/charts/ChoroplethMap/ChoroplethMap.md +153 -1
- package/charts/ChoroplethMap/ChoroplethMap.vue +369 -106
- package/charts/index.ts +4 -0
- package/index.json +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, ref } from "vue";
|
|
3
3
|
import countiesTopoForPerf from "us-atlas/counties-10m.json";
|
|
4
|
+
import { fipsToHsa } from "@cfasim-ui/charts";
|
|
4
5
|
|
|
5
6
|
// Build one row per county (~3,143) with a deterministic-ish value so the
|
|
6
7
|
// perf example can render every region with a custom tooltip.
|
|
@@ -16,6 +17,19 @@ const denseCountyData = computed(() => {
|
|
|
16
17
|
// handles click-to-toggle and emits null when the focused feature is
|
|
17
18
|
// re-clicked.
|
|
18
19
|
const focused = ref(null);
|
|
20
|
+
|
|
21
|
+
// "Outline a focused feature's parent" demo: focus is a county id;
|
|
22
|
+
// we derive the parent HSA and add it to the focus array as a dashed
|
|
23
|
+
// overlay so clicking a county also outlines its HSA.
|
|
24
|
+
const focusedCounty = ref(null);
|
|
25
|
+
const parentFocus = computed(() => {
|
|
26
|
+
const fips = focusedCounty.value;
|
|
27
|
+
if (!fips) return null;
|
|
28
|
+
const hsa = fipsToHsa[fips];
|
|
29
|
+
return hsa
|
|
30
|
+
? [fips, { id: hsa, geoType: "hsas", style: "dashed", stroke: "#666" }]
|
|
31
|
+
: fips;
|
|
32
|
+
});
|
|
19
33
|
</script>
|
|
20
34
|
|
|
21
35
|
# ChoroplethMap
|
|
@@ -407,6 +421,124 @@ const focused = ref(null);
|
|
|
407
421
|
</template>
|
|
408
422
|
</ComponentDemo>
|
|
409
423
|
|
|
424
|
+
### Color by HSA, interact by county (`dataGeoType`)
|
|
425
|
+
|
|
426
|
+
Set `dataGeoType` when your data is keyed by a coarser geography than
|
|
427
|
+
the one you want to render. Each county fills with its parent HSA's
|
|
428
|
+
value (via the built-in FIPS → HSA mapping); hover, click, and `focus`
|
|
429
|
+
still operate on the county geometry, and you can layer an HSA outline
|
|
430
|
+
on top with a `FocusItem`.
|
|
431
|
+
|
|
432
|
+
<ComponentDemo>
|
|
433
|
+
<ChoroplethMap
|
|
434
|
+
:topology="countiesTopo"
|
|
435
|
+
geo-type="counties"
|
|
436
|
+
data-geo-type="hsas"
|
|
437
|
+
:pan="true"
|
|
438
|
+
:zoom="true"
|
|
439
|
+
:data="[
|
|
440
|
+
{ id: '060737', value: 80 },
|
|
441
|
+
{ id: '060723', value: 60 },
|
|
442
|
+
{ id: '060757', value: 45 },
|
|
443
|
+
{ id: '060807', value: 35 },
|
|
444
|
+
{ id: '060768', value: 25 },
|
|
445
|
+
{ id: '060774', value: 50 },
|
|
446
|
+
]"
|
|
447
|
+
:focus="[
|
|
448
|
+
{ id: '06043' },
|
|
449
|
+
{ id: '060737', geoType: 'hsas', style: 'dashed' },
|
|
450
|
+
]"
|
|
451
|
+
:focus-zoom-level="6"
|
|
452
|
+
title="HSA-keyed data on a county map"
|
|
453
|
+
:legend-title="'Cases'"
|
|
454
|
+
:height="400"
|
|
455
|
+
/>
|
|
456
|
+
|
|
457
|
+
<template #code>
|
|
458
|
+
|
|
459
|
+
```vue
|
|
460
|
+
<ChoroplethMap
|
|
461
|
+
:topology="countiesTopo"
|
|
462
|
+
geo-type="counties"
|
|
463
|
+
data-geo-type="hsas"
|
|
464
|
+
:data="hsaData"
|
|
465
|
+
:focus="[{ id: '06043' }, { id: '060737', geoType: 'hsas', style: 'dashed' }]"
|
|
466
|
+
title="HSA-keyed data on a county map"
|
|
467
|
+
/>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
</template>
|
|
471
|
+
</ComponentDemo>
|
|
472
|
+
|
|
473
|
+
### Outline a focused feature's parent
|
|
474
|
+
|
|
475
|
+
Use `v-model:focus` together with a computed that derives a parent
|
|
476
|
+
feature (e.g. an HSA from a county via `fipsToHsa`). Pass both as a
|
|
477
|
+
`FocusItem` array — the focused county lights up as usual and the
|
|
478
|
+
parent HSA renders on top as a dashed overlay (`stroke: "#666"` here —
|
|
479
|
+
default is white).
|
|
480
|
+
|
|
481
|
+
<ComponentDemo>
|
|
482
|
+
<ChoroplethMap
|
|
483
|
+
:topology="countiesTopo"
|
|
484
|
+
geo-type="counties"
|
|
485
|
+
data-geo-type="hsas"
|
|
486
|
+
:pan="true"
|
|
487
|
+
:zoom="true"
|
|
488
|
+
:focus="parentFocus"
|
|
489
|
+
@update:focus="focusedCounty = typeof $event === 'string' ? $event : null"
|
|
490
|
+
:data="[
|
|
491
|
+
{ id: '060737', value: 80 },
|
|
492
|
+
{ id: '060723', value: 60 },
|
|
493
|
+
{ id: '060757', value: 45 },
|
|
494
|
+
{ id: '060807', value: 35 },
|
|
495
|
+
{ id: '060768', value: 25 },
|
|
496
|
+
{ id: '060774', value: 50 },
|
|
497
|
+
]"
|
|
498
|
+
:focus-zoom-level="6"
|
|
499
|
+
title="Click a county to outline its HSA"
|
|
500
|
+
:legend-title="'Cases'"
|
|
501
|
+
:height="400"
|
|
502
|
+
>
|
|
503
|
+
<template #tooltip="{ name, value }">
|
|
504
|
+
<div style="font-weight: 600">{{ name }}</div>
|
|
505
|
+
<div v-if="value != null">Cases: {{ value }}</div>
|
|
506
|
+
<div v-else style="opacity: 0.6">No data</div>
|
|
507
|
+
</template>
|
|
508
|
+
</ChoroplethMap>
|
|
509
|
+
|
|
510
|
+
<template #code>
|
|
511
|
+
|
|
512
|
+
```vue
|
|
513
|
+
<script setup>
|
|
514
|
+
import { ref, computed } from "vue";
|
|
515
|
+
import { fipsToHsa } from "@cfasim-ui/charts";
|
|
516
|
+
|
|
517
|
+
const focusedCounty = ref(null);
|
|
518
|
+
const focus = computed(() => {
|
|
519
|
+
const fips = focusedCounty.value;
|
|
520
|
+
if (!fips) return null;
|
|
521
|
+
const hsa = fipsToHsa[fips];
|
|
522
|
+
return hsa
|
|
523
|
+
? [fips, { id: hsa, geoType: "hsas", style: "dashed", stroke: "#666" }]
|
|
524
|
+
: fips;
|
|
525
|
+
});
|
|
526
|
+
</script>
|
|
527
|
+
|
|
528
|
+
<ChoroplethMap
|
|
529
|
+
:topology="countiesTopo"
|
|
530
|
+
geo-type="counties"
|
|
531
|
+
data-geo-type="hsas"
|
|
532
|
+
:data="hsaData"
|
|
533
|
+
:focus="focus"
|
|
534
|
+
@update:focus="focusedCounty = typeof $event === 'string' ? $event : null"
|
|
535
|
+
title="Click a county to outline its HSA"
|
|
536
|
+
/>
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
</template>
|
|
540
|
+
</ComponentDemo>
|
|
541
|
+
|
|
410
542
|
### Custom tooltip number format
|
|
411
543
|
|
|
412
544
|
Pass `tooltip-value-format` to format numeric values shown in the tooltip
|
|
@@ -554,6 +686,7 @@ set `tooltip-trigger`.
|
|
|
554
686
|
| `topology` | `Topology` | Yes | — |
|
|
555
687
|
| `data` | `StateData[]` | No | — |
|
|
556
688
|
| `geoType` | `GeoType` | No | `"states"` |
|
|
689
|
+
| `dataGeoType` | `GeoType` | No | — |
|
|
557
690
|
| `width` | `number` | No | — |
|
|
558
691
|
| `height` | `number` | No | — |
|
|
559
692
|
| `colorScale` | `ChoroplethColorScale \| ThresholdStop[] \| CategoricalStop[]` | No | — |
|
|
@@ -573,7 +706,7 @@ set `tooltip-trigger`.
|
|
|
573
706
|
| `value` | `number \| string` | No | — |
|
|
574
707
|
| `tooltipValueFormat` | `(value: number) => string` | No | — |
|
|
575
708
|
| `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
|
|
576
|
-
| `focus` | `
|
|
709
|
+
| `focus` | `FocusValue` | No | — |
|
|
577
710
|
| `focusZoomLevel` | `number` | No | `4` |
|
|
578
711
|
|
|
579
712
|
|
|
@@ -624,3 +757,22 @@ interface CategoricalStop {
|
|
|
624
757
|
color: string;
|
|
625
758
|
}
|
|
626
759
|
```
|
|
760
|
+
|
|
761
|
+
### FocusItem
|
|
762
|
+
|
|
763
|
+
The `focus` prop accepts a bare id, a `FocusItem`, or an array of either. Use objects when you want to pin features from a different `geoType` than the base map, or pick a non-default outline style.
|
|
764
|
+
|
|
765
|
+
```ts
|
|
766
|
+
interface FocusItem {
|
|
767
|
+
/** Feature id (FIPS code, HSA code) or name. */
|
|
768
|
+
id: string;
|
|
769
|
+
/** Defaults to the map's geoType. Cross-geoType items render as
|
|
770
|
+
* non-interactive outlines on top of the base map. */
|
|
771
|
+
geoType?: "states" | "counties" | "hsas";
|
|
772
|
+
/** Outline style. "solid" (default) matches the hover highlight;
|
|
773
|
+
* "dashed" uses long dashes; "dotted" uses small round dots. */
|
|
774
|
+
style?: "solid" | "dashed" | "dotted";
|
|
775
|
+
/** Stroke color for cross-geoType overlay paths. Default: "#fff". */
|
|
776
|
+
stroke?: string;
|
|
777
|
+
}
|
|
778
|
+
```
|
|
@@ -55,6 +55,32 @@ export interface CategoricalStop {
|
|
|
55
55
|
color: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A focused feature. Pass a plain string to focus a feature in the map's
|
|
60
|
+
* current `geoType` with the default solid highlight, or an object to
|
|
61
|
+
* specify a different `geoType` (drawn as an overlay on top of the base
|
|
62
|
+
* map) and/or a `style`.
|
|
63
|
+
*/
|
|
64
|
+
export type FocusStyle = "solid" | "dashed" | "dotted";
|
|
65
|
+
|
|
66
|
+
export interface FocusItem {
|
|
67
|
+
/** Feature id (FIPS code, HSA code) or name. */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Defaults to the map's `geoType`. Cross-geoType items render as
|
|
70
|
+
* non-interactive outlines on top of the base map. */
|
|
71
|
+
geoType?: GeoType;
|
|
72
|
+
/** Outline style. `"solid"` (default) matches the hover highlight;
|
|
73
|
+
* `"dashed"` uses long dashes; `"dotted"` uses small round dots —
|
|
74
|
+
* useful when stacking multiple outlines of different geoTypes. */
|
|
75
|
+
style?: FocusStyle;
|
|
76
|
+
/** Stroke color for the outline. Applies to cross-geoType overlay
|
|
77
|
+
* paths only (base-geoType highlights stay at the default focus
|
|
78
|
+
* color). Default: `"#fff"`. */
|
|
79
|
+
stroke?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type FocusValue = string | FocusItem | Array<string | FocusItem> | null;
|
|
83
|
+
|
|
58
84
|
const props = withDefaults(
|
|
59
85
|
defineProps<{
|
|
60
86
|
/** TopoJSON topology object (e.g. from us-atlas/states-10m.json or us-atlas/counties-10m.json).
|
|
@@ -64,6 +90,15 @@ const props = withDefaults(
|
|
|
64
90
|
data?: StateData[];
|
|
65
91
|
/** Geographic type: "states" (default), "counties", or "hsas" (Health Service Areas) */
|
|
66
92
|
geoType?: GeoType;
|
|
93
|
+
/**
|
|
94
|
+
* GeoType of the entries in `data`, if different from `geoType`. Lets
|
|
95
|
+
* you color a county-level base map by HSA values (each county fills
|
|
96
|
+
* with its parent HSA's value) or by state values, without changing
|
|
97
|
+
* the rendered/interactive geometry. Supported combinations:
|
|
98
|
+
* `counties` ← `hsas`, `counties` ← `states`, `hsas` ← `states`.
|
|
99
|
+
* When unset, data ids must match the base `geoType`.
|
|
100
|
+
*/
|
|
101
|
+
dataGeoType?: GeoType;
|
|
67
102
|
width?: number;
|
|
68
103
|
height?: number;
|
|
69
104
|
colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
|
|
@@ -106,15 +141,20 @@ const props = withDefaults(
|
|
|
106
141
|
*/
|
|
107
142
|
tooltipClamp?: "none" | "chart" | "window";
|
|
108
143
|
/**
|
|
109
|
-
* Feature
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
144
|
+
* Feature(s) to pan/zoom to. Accepts a feature id (FIPS code, HSA
|
|
145
|
+
* code, or feature name), a `FocusItem` object, or an array of
|
|
146
|
+
* either. `FocusItem` lets you pin features from a different
|
|
147
|
+
* `geoType` than the base map (drawn as a non-interactive outline) or
|
|
148
|
+
* pick a `style` ("solid" / "dashed"). All items contribute to the
|
|
149
|
+
* zoom bounds. Pass `null` or an empty array to clear focus — the
|
|
150
|
+
* current pan/zoom transform is preserved; only the highlight is
|
|
151
|
+
* removed. Works with `v-model:focus`: clicking an unfocused feature
|
|
152
|
+
* (in the base geoType) emits its id; clicking the focused feature
|
|
153
|
+
* emits `null`. The built-in Reset button clears focus *and* resets
|
|
154
|
+
* the zoom. If a tooltip is configured, focusing a feature in the
|
|
155
|
+
* base geoType shows its tooltip.
|
|
116
156
|
*/
|
|
117
|
-
focus?:
|
|
157
|
+
focus?: FocusValue;
|
|
118
158
|
/** Scale factor applied when `focus` is set. Default: 4 */
|
|
119
159
|
focusZoomLevel?: number;
|
|
120
160
|
}>(),
|
|
@@ -170,7 +210,14 @@ const narrowSlotProps = (
|
|
|
170
210
|
|
|
171
211
|
const containerRef = ref<HTMLElement | null>(null);
|
|
172
212
|
const svgRef = ref<SVGSVGElement | null>(null);
|
|
213
|
+
// `mapGroupRef` is the zoom target. Inside it we split into two layers:
|
|
214
|
+
// `baseGroupRef` holds feature paths + the state-borders mesh and absorbs
|
|
215
|
+
// click/hover events, while `overlayGroupRef` holds focus overlay paths
|
|
216
|
+
// and always sits above so cross-geoType outlines never get covered by a
|
|
217
|
+
// hover-raised base path.
|
|
173
218
|
const mapGroupRef = ref<SVGGElement | null>(null);
|
|
219
|
+
const baseGroupRef = ref<SVGGElement | null>(null);
|
|
220
|
+
const overlayGroupRef = ref<SVGGElement | null>(null);
|
|
174
221
|
const tooltipChildRef = ref<InstanceType<typeof ChoroplethTooltip> | null>(
|
|
175
222
|
null,
|
|
176
223
|
);
|
|
@@ -190,7 +237,14 @@ let hoveredEl: SVGPathElement | null = null;
|
|
|
190
237
|
// Paths currently styled as focused. Tracked separately from hover so the
|
|
191
238
|
// two states compose: hovering a focused path keeps the highlight on
|
|
192
239
|
// un-hover, and clearing focus while still hovering keeps the hover style.
|
|
193
|
-
|
|
240
|
+
// Maps each focused base-geoType path to the style it was given so a
|
|
241
|
+
// repeat focus with a different style can re-apply without diffing the
|
|
242
|
+
// attribute set manually.
|
|
243
|
+
const focusedPathStyles = new Map<SVGPathElement, FocusStyle>();
|
|
244
|
+
// Cross-geoType focus items render as standalone outline paths layered on
|
|
245
|
+
// top of the base map. Keyed by `${geoType}:${id}` so we can diff add /
|
|
246
|
+
// remove / restyle on each applyFocus.
|
|
247
|
+
const overlayPathEls = new Map<string, SVGPathElement>();
|
|
194
248
|
let isZooming = false;
|
|
195
249
|
// TODO: map hover/tooltip causes performance issues on mobile (SVG stroke-width
|
|
196
250
|
// changes + compositing layers degrade zoom/pan). Disabled on touch devices.
|
|
@@ -315,86 +369,109 @@ function teardownZoom() {
|
|
|
315
369
|
}
|
|
316
370
|
}
|
|
317
371
|
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
372
|
+
// Resolved focus item: ties a user-supplied FocusItem to the actual
|
|
373
|
+
// GeoJSON feature it refers to plus a stable cross-geoType cache key.
|
|
374
|
+
interface ResolvedFocus {
|
|
375
|
+
item: FocusItem;
|
|
376
|
+
geoType: GeoType;
|
|
377
|
+
feature: ChoroplethFeature;
|
|
378
|
+
/** Stable key for overlay-path lifecycle: `${geoType}:${featureId}` */
|
|
379
|
+
key: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function resolveFocusItems(items: FocusItem[]): ResolvedFocus[] {
|
|
383
|
+
const lookups = featuresByGeoType.value;
|
|
384
|
+
const nameLookups = nameToIdByGeoType.value;
|
|
385
|
+
const out: ResolvedFocus[] = [];
|
|
386
|
+
for (const item of items) {
|
|
387
|
+
const geoType = item.geoType ?? props.geoType;
|
|
388
|
+
const lookup = lookups.get(geoType);
|
|
389
|
+
if (!lookup) continue;
|
|
390
|
+
let f = lookup.get(item.id);
|
|
391
|
+
if (!f) {
|
|
392
|
+
// Name fallback in the item's own geoType.
|
|
393
|
+
const id = nameLookups.get(geoType)?.get(item.id);
|
|
394
|
+
if (id) f = lookup.get(id);
|
|
395
|
+
}
|
|
396
|
+
if (!f) continue;
|
|
397
|
+
out.push({ item, geoType, feature: f, key: `${geoType}:${String(f.id)}` });
|
|
328
398
|
}
|
|
329
399
|
return out;
|
|
330
400
|
}
|
|
331
401
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (
|
|
402
|
+
// Click-to-toggle uses only the base-geoType focus ids — clicks on
|
|
403
|
+
// overlay paths are blocked by pointer-events: none.
|
|
404
|
+
function focusedBaseIds(items: FocusItem[]): Set<string> {
|
|
405
|
+
const out = new Set<string>();
|
|
406
|
+
for (const r of resolveFocusItems(items)) {
|
|
407
|
+
if (r.geoType === props.geoType) out.add(String(r.feature.id));
|
|
338
408
|
}
|
|
339
409
|
return out;
|
|
340
410
|
}
|
|
341
411
|
|
|
342
|
-
// Duration of
|
|
343
|
-
//
|
|
412
|
+
// Duration (ms) of focus zoom-in and Reset-button zoom-out transitions.
|
|
413
|
+
// Initial mount applies instantly; clearing focus is a no-op on the
|
|
414
|
+
// transform (the Reset button is the only path back to identity).
|
|
344
415
|
const FOCUS_ANIM_MS = 450;
|
|
345
416
|
// Tracks whether applyFocus has been called once — initial mount apply
|
|
346
|
-
// is instant, subsequent
|
|
417
|
+
// is instant, subsequent focus-in calls animate.
|
|
347
418
|
let focusApplied = false;
|
|
348
419
|
|
|
349
420
|
function applyFocus() {
|
|
350
421
|
if (!svgRef.value || !zoomBehavior) return;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
422
|
+
const resolved = resolveFocusItems(normalizedFocus.value);
|
|
423
|
+
|
|
424
|
+
// Split into items that live in the base geoType (decorate the
|
|
425
|
+
// existing path) and items that need their own overlay path.
|
|
426
|
+
const baseResolved = resolved.filter((r) => r.geoType === props.geoType);
|
|
427
|
+
const overlayResolved = resolved.filter((r) => r.geoType !== props.geoType);
|
|
428
|
+
|
|
429
|
+
// Diff base-geoType highlights, keyed by path element so we can
|
|
430
|
+
// restyle on style change without churning unrelated paths.
|
|
431
|
+
const nextBaseStyles = new Map<SVGPathElement, FocusStyle>();
|
|
432
|
+
for (const r of baseResolved) {
|
|
433
|
+
const p = pathsByFeatureId.get(String(r.feature.id));
|
|
434
|
+
if (p) nextBaseStyles.set(p, r.item.style ?? "solid");
|
|
360
435
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// hovered — hover keeps its own highlight.
|
|
364
|
-
for (const p of focusedPathEls) {
|
|
365
|
-
if (nextFocused.has(p) || p === hoveredEl) continue;
|
|
436
|
+
for (const [p] of focusedPathStyles) {
|
|
437
|
+
if (nextBaseStyles.has(p) || p === hoveredEl) continue;
|
|
366
438
|
restoreDefaultStroke(p);
|
|
367
439
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (
|
|
440
|
+
for (const [p, style] of nextBaseStyles) {
|
|
441
|
+
const prev = focusedPathStyles.get(p);
|
|
442
|
+
if (prev === style && p !== hoveredEl) continue; // already styled
|
|
443
|
+
if (p !== hoveredEl) applyHighlightStroke(p, style);
|
|
444
|
+
}
|
|
445
|
+
focusedPathStyles.clear();
|
|
446
|
+
for (const [p, style] of nextBaseStyles) focusedPathStyles.set(p, style);
|
|
447
|
+
|
|
448
|
+
// Cross-geoType outlines render as non-interactive paths on top of
|
|
449
|
+
// the base layer.
|
|
450
|
+
syncOverlayPaths(overlayResolved);
|
|
451
|
+
|
|
452
|
+
// Clearing focus doesn't touch the zoom transform — the user keeps
|
|
453
|
+
// whatever pan/zoom they had. Only the Reset button snaps back to
|
|
454
|
+
// identity. Drop the highlight + tooltip and we're done.
|
|
455
|
+
if (resolved.length === 0) {
|
|
456
|
+
focusApplied = true;
|
|
457
|
+
clearHover();
|
|
458
|
+
return;
|
|
372
459
|
}
|
|
373
|
-
focusedPathEls.clear();
|
|
374
|
-
for (const p of nextFocused) focusedPathEls.add(p);
|
|
375
460
|
|
|
376
461
|
const svg = select(svgRef.value);
|
|
377
462
|
// Always cancel any in-flight transition first — d3-transition queues
|
|
378
463
|
// same-named transitions rather than replacing them, so rapid focus
|
|
379
|
-
// changes would otherwise chain animations end-to-end.
|
|
380
|
-
// straight-snap path actually take effect mid-animation.
|
|
464
|
+
// changes would otherwise chain animations end-to-end.
|
|
381
465
|
svg.interrupt();
|
|
382
|
-
// First apply (initial mount) is instant
|
|
383
|
-
|
|
384
|
-
const animate = focusApplied && features.length > 0;
|
|
466
|
+
// First apply (initial mount) is instant; subsequent focus-in animates.
|
|
467
|
+
const animate = focusApplied;
|
|
385
468
|
focusApplied = true;
|
|
386
469
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
clearHover();
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Compute pan + scale onto the focused features' bounding box, in
|
|
394
|
-
// viewBox (canonical) coordinates.
|
|
470
|
+
// Combined bounding box over every resolved feature (regardless of
|
|
471
|
+
// geoType) so multi-layer focus zooms to fit them all.
|
|
395
472
|
const [[x0, y0], [x1, y1]] = pathGenerator.value.bounds({
|
|
396
473
|
type: "FeatureCollection",
|
|
397
|
-
features,
|
|
474
|
+
features: resolved.map((r) => r.feature),
|
|
398
475
|
});
|
|
399
476
|
const cx = (x0 + x1) / 2;
|
|
400
477
|
const cy = (y0 + y1) / 2;
|
|
@@ -403,9 +480,13 @@ function applyFocus() {
|
|
|
403
480
|
.translate(width.value / 2 - k * cx, height.value / 2 - k * cy)
|
|
404
481
|
.scale(k);
|
|
405
482
|
|
|
483
|
+
// Tooltip target: prefer the first base-geoType item (overlay paths
|
|
484
|
+
// are non-interactive and don't carry tooltip data). Falls back to
|
|
485
|
+
// skipping the tooltip entirely when only overlays are focused.
|
|
486
|
+
const tooltipTarget = baseResolved[0]?.feature ?? null;
|
|
406
487
|
const showFocusTooltip = () => {
|
|
407
|
-
if (!hasInteractiveTooltip.value) return;
|
|
408
|
-
const firstId = String(
|
|
488
|
+
if (!hasInteractiveTooltip.value || !tooltipTarget) return;
|
|
489
|
+
const firstId = String(tooltipTarget.id);
|
|
409
490
|
const pathEl = pathsByFeatureId.get(firstId);
|
|
410
491
|
if (!pathEl) return;
|
|
411
492
|
// Read the rect *after* the transform commits so the tooltip lands at
|
|
@@ -436,15 +517,58 @@ function applyFocus() {
|
|
|
436
517
|
}
|
|
437
518
|
}
|
|
438
519
|
|
|
520
|
+
function syncOverlayPaths(items: ResolvedFocus[]) {
|
|
521
|
+
const g = overlayGroupRef.value;
|
|
522
|
+
if (!g) return;
|
|
523
|
+
|
|
524
|
+
const nextKeys = new Set(items.map((i) => i.key));
|
|
525
|
+
for (const [key, el] of overlayPathEls) {
|
|
526
|
+
if (!nextKeys.has(key)) {
|
|
527
|
+
el.remove();
|
|
528
|
+
overlayPathEls.delete(key);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const generator = pathGenerator.value;
|
|
533
|
+
// Overlay strokes need extra weight: they paint on top of the base
|
|
534
|
+
// map and the (optional) state-borders mesh, so a stroke that matches
|
|
535
|
+
// the in-place focused base path's weight would visually merge with
|
|
536
|
+
// the layers underneath.
|
|
537
|
+
const sw = effectiveStrokeWidth.value + 1.5;
|
|
538
|
+
for (const { item, feature: f, key } of items) {
|
|
539
|
+
let el = overlayPathEls.get(key);
|
|
540
|
+
if (!el) {
|
|
541
|
+
el = document.createElementNS(SVG_NS, "path") as SVGPathElement;
|
|
542
|
+
el.setAttribute("d", generator(f) ?? "");
|
|
543
|
+
el.setAttribute("fill", "none");
|
|
544
|
+
el.setAttribute("pointer-events", "none");
|
|
545
|
+
el.setAttribute("vector-effect", "non-scaling-stroke");
|
|
546
|
+
el.setAttribute("stroke-linejoin", "round");
|
|
547
|
+
el.setAttribute("class", "focus-overlay");
|
|
548
|
+
g.appendChild(el);
|
|
549
|
+
overlayPathEls.set(key, el);
|
|
550
|
+
}
|
|
551
|
+
// White contrasts cleanly against the (typically dark) data-colored
|
|
552
|
+
// fill; callers can override per-item via `FocusItem.stroke`.
|
|
553
|
+
el.setAttribute("stroke", item.stroke ?? "#fff");
|
|
554
|
+
el.setAttribute("stroke-width", String(sw));
|
|
555
|
+
applyDasharray(el, item.style);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
439
559
|
function resetZoom() {
|
|
440
560
|
if (!svgRef.value || !zoomBehavior) return;
|
|
561
|
+
// Reset both the zoom transform AND any active focus. The watcher's
|
|
562
|
+
// applyFocus call (post-flush) only tears down the highlight strokes
|
|
563
|
+
// now; this transition handles the actual zoom-out animation.
|
|
564
|
+
if (normalizedFocus.value.length > 0) emit("update:focus", null);
|
|
441
565
|
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
566
|
svg.interrupt();
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
567
|
+
hideTooltip();
|
|
568
|
+
svg
|
|
569
|
+
.transition()
|
|
570
|
+
.duration(FOCUS_ANIM_MS)
|
|
571
|
+
.call(zoomBehavior.transform, zoomIdentity);
|
|
448
572
|
}
|
|
449
573
|
|
|
450
574
|
// `focusZoomLevel` only affects scaleExtent + the next focus apply. The
|
|
@@ -545,18 +669,43 @@ const effectiveStrokeWidth = computed(() =>
|
|
|
545
669
|
: props.strokeWidth,
|
|
546
670
|
);
|
|
547
671
|
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
672
|
+
// Per-geoType name → id index, mirroring featuresByGeoType. Drives the
|
|
673
|
+
// "pass a feature name instead of an id" fallback for both `data` (with
|
|
674
|
+
// dataGeoType applied) and `focus` (where each FocusItem can specify its
|
|
675
|
+
// own geoType). O(features) per geoType, computed once per topology change.
|
|
676
|
+
const nameToIdByGeoType = computed(() => {
|
|
677
|
+
const map = new Map<GeoType, Map<string, string>>();
|
|
678
|
+
for (const [gt, lookup] of featuresByGeoType.value) {
|
|
679
|
+
const m = new Map<string, string>();
|
|
680
|
+
for (const [id, f] of lookup) {
|
|
681
|
+
if (f.properties?.name != null) m.set(f.properties.name, id);
|
|
555
682
|
}
|
|
683
|
+
map.set(gt, m);
|
|
556
684
|
}
|
|
557
|
-
return
|
|
685
|
+
return map;
|
|
558
686
|
});
|
|
559
687
|
|
|
688
|
+
// Maps a base feature id to the id it should look up in `dataMap` under
|
|
689
|
+
// the active `dataGeoType`. Supports county→hsa (via fipsToHsa), county→
|
|
690
|
+
// state (FIPS prefix), and hsa→state (HSA-code prefix). Returns the id
|
|
691
|
+
// unchanged when dataGeoType is unset or equal to the base geoType.
|
|
692
|
+
function baseToDataId(baseId: string): string | undefined {
|
|
693
|
+
const dataGt = props.dataGeoType;
|
|
694
|
+
if (!dataGt || dataGt === props.geoType) return baseId;
|
|
695
|
+
if (props.geoType === "counties" && dataGt === "hsas") {
|
|
696
|
+
return fipsToHsa[baseId];
|
|
697
|
+
}
|
|
698
|
+
if (props.geoType === "counties" && dataGt === "states") {
|
|
699
|
+
return baseId.slice(0, 2);
|
|
700
|
+
}
|
|
701
|
+
if (props.geoType === "hsas" && dataGt === "states") {
|
|
702
|
+
return baseId.slice(0, 2);
|
|
703
|
+
}
|
|
704
|
+
// Other combinations (e.g. coloring HSAs by per-county data) require
|
|
705
|
+
// aggregation rules we haven't specified — silently treat as no data.
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
|
|
560
709
|
// id → feature lookup used by `applyFocus`. Cached so focus changes don't
|
|
561
710
|
// trigger a linear scan through 3k+ features per apply.
|
|
562
711
|
const featuresById = computed(() => {
|
|
@@ -567,23 +716,72 @@ const featuresById = computed(() => {
|
|
|
567
716
|
return m;
|
|
568
717
|
});
|
|
569
718
|
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
const normalizedFocus = computed<string[]>(() => {
|
|
719
|
+
// Normalized array form of `props.focus`. Bare strings collapse into
|
|
720
|
+
// `{ id }` so downstream code only has to deal with FocusItem objects.
|
|
721
|
+
const normalizedFocus = computed<FocusItem[]>(() => {
|
|
574
722
|
const f = props.focus;
|
|
575
723
|
if (f == null) return [];
|
|
576
|
-
|
|
577
|
-
return
|
|
724
|
+
const arr = Array.isArray(f) ? f : [f];
|
|
725
|
+
return arr.map((x) => (typeof x === "string" ? { id: x } : x));
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Per-geoType feature lookups. Populated for any geoType the topology
|
|
729
|
+
// supports (states-only topology → just "states"; counties topology →
|
|
730
|
+
// all three, with HSAs derived via the FIPS→HSA mapping). Used by
|
|
731
|
+
// resolveFocusItems so a single focus call can mix geoTypes.
|
|
732
|
+
const featuresByGeoType = computed(() => {
|
|
733
|
+
const map = new Map<GeoType, Map<string, ChoroplethFeature>>();
|
|
734
|
+
// The base geoType is always represented — reuse the existing lookup.
|
|
735
|
+
map.set(props.geoType, featuresById.value);
|
|
736
|
+
|
|
737
|
+
const topo = toRaw(props.topology) as unknown as {
|
|
738
|
+
objects?: { states?: NamedGeometry; counties?: NamedGeometry };
|
|
739
|
+
};
|
|
740
|
+
const objs = topo?.objects;
|
|
741
|
+
if (!objs) return map;
|
|
742
|
+
|
|
743
|
+
type AnyFeature = GeoJSON.Feature<GeoJSON.Geometry | null>;
|
|
744
|
+
const indexFeatures = (feats: AnyFeature[]) => {
|
|
745
|
+
const m = new Map<string, ChoroplethFeature>();
|
|
746
|
+
for (const f of feats) {
|
|
747
|
+
if (f.id != null) m.set(String(f.id), f as ChoroplethFeature);
|
|
748
|
+
}
|
|
749
|
+
return m;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
if (!map.has("states") && objs.states) {
|
|
753
|
+
const fc = feature(topo as unknown as Topology, objs.states);
|
|
754
|
+
map.set(
|
|
755
|
+
"states",
|
|
756
|
+
indexFeatures(
|
|
757
|
+
(fc as GeoJSON.FeatureCollection<GeoJSON.Geometry | null>).features,
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
if (!map.has("counties") && objs.counties) {
|
|
762
|
+
const fc = feature(topo as unknown as Topology, objs.counties);
|
|
763
|
+
map.set(
|
|
764
|
+
"counties",
|
|
765
|
+
indexFeatures(
|
|
766
|
+
(fc as GeoJSON.FeatureCollection<GeoJSON.Geometry | null>).features,
|
|
767
|
+
),
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
if (!map.has("hsas") && objs.counties) {
|
|
771
|
+
map.set("hsas", indexFeatures(hsaFeaturesGeo.value.features));
|
|
772
|
+
}
|
|
773
|
+
return map;
|
|
578
774
|
});
|
|
579
775
|
|
|
580
776
|
const dataMap = computed(() => {
|
|
581
777
|
const map = new Map<string, number | string>();
|
|
582
778
|
if (!props.data) return map;
|
|
583
|
-
|
|
779
|
+
// Name fallback resolves in whichever geoType the data is keyed by.
|
|
780
|
+
const dataGt = props.dataGeoType ?? props.geoType;
|
|
781
|
+
const nameIdx = nameToIdByGeoType.value.get(dataGt);
|
|
584
782
|
for (const d of props.data) {
|
|
585
783
|
map.set(d.id, d.value);
|
|
586
|
-
const fid = nameIdx
|
|
784
|
+
const fid = nameIdx?.get(d.id);
|
|
587
785
|
if (fid) map.set(fid, d.value);
|
|
588
786
|
}
|
|
589
787
|
return map;
|
|
@@ -662,9 +860,15 @@ const categoricalByValue = computed(() => {
|
|
|
662
860
|
return m;
|
|
663
861
|
});
|
|
664
862
|
|
|
863
|
+
/** Looks up the data value for a base feature id, honoring `dataGeoType`. */
|
|
864
|
+
function valueFor(baseId: string): number | string | undefined {
|
|
865
|
+
const dataId = baseToDataId(baseId);
|
|
866
|
+
return dataId == null ? undefined : dataMap.value.get(dataId);
|
|
867
|
+
}
|
|
868
|
+
|
|
665
869
|
/** Single color-resolution path. Returns the noData color for missing rows. */
|
|
666
870
|
function colorFor(id: string): string {
|
|
667
|
-
const value =
|
|
871
|
+
const value = valueFor(id);
|
|
668
872
|
const noData = props.noDataColor!;
|
|
669
873
|
if (value == null) return noData;
|
|
670
874
|
const cat = categoricalByValue.value;
|
|
@@ -789,32 +993,69 @@ function hideTooltip() {
|
|
|
789
993
|
if (el) el.style.visibility = "hidden";
|
|
790
994
|
}
|
|
791
995
|
|
|
792
|
-
|
|
996
|
+
/**
|
|
997
|
+
* Sets `stroke-dasharray` (and `stroke-linecap` for round dots) on a
|
|
998
|
+
* path element to match the requested FocusStyle. Used both for
|
|
999
|
+
* highlighted base paths and for cross-geoType overlay paths.
|
|
1000
|
+
*/
|
|
1001
|
+
function applyDasharray(el: SVGPathElement, style?: FocusStyle) {
|
|
1002
|
+
if (style === "dashed") {
|
|
1003
|
+
// Long dash + short gap so the pattern reads clearly even when the
|
|
1004
|
+
// overlay is painted on top of a similarly-colored parent-border
|
|
1005
|
+
// mesh.
|
|
1006
|
+
el.setAttribute("stroke-dasharray", "8 4");
|
|
1007
|
+
el.removeAttribute("stroke-linecap");
|
|
1008
|
+
} else if (style === "dotted") {
|
|
1009
|
+
// 0-length dashes with round caps render as evenly-spaced dots.
|
|
1010
|
+
el.setAttribute("stroke-dasharray", "0 5");
|
|
1011
|
+
el.setAttribute("stroke-linecap", "round");
|
|
1012
|
+
} else {
|
|
1013
|
+
el.removeAttribute("stroke-dasharray");
|
|
1014
|
+
el.removeAttribute("stroke-linecap");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function applyHighlightStroke(
|
|
1019
|
+
pathEl: SVGPathElement,
|
|
1020
|
+
style: FocusStyle = "solid",
|
|
1021
|
+
) {
|
|
793
1022
|
// Bring path to top so its thicker border isn't clipped by neighbors.
|
|
1023
|
+
// Skip for overlay paths (they live above everything and own their
|
|
1024
|
+
// own z-order via syncOverlayPaths).
|
|
794
1025
|
pathEl.parentNode?.appendChild(pathEl);
|
|
795
1026
|
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value + 1));
|
|
796
1027
|
pathEl.setAttribute("stroke", "#555");
|
|
1028
|
+
applyDasharray(pathEl, style);
|
|
797
1029
|
}
|
|
798
1030
|
|
|
799
1031
|
function restoreDefaultStroke(pathEl: SVGPathElement) {
|
|
800
1032
|
pathEl.setAttribute("stroke-width", String(effectiveStrokeWidth.value));
|
|
801
1033
|
pathEl.setAttribute("stroke", props.strokeColor);
|
|
1034
|
+
pathEl.removeAttribute("stroke-dasharray");
|
|
1035
|
+
pathEl.removeAttribute("stroke-linecap");
|
|
802
1036
|
}
|
|
803
1037
|
|
|
804
1038
|
function setHover(pathEl: SVGPathElement) {
|
|
805
1039
|
if (hoveredEl === pathEl) return;
|
|
806
|
-
if (hoveredEl && !
|
|
1040
|
+
if (hoveredEl && !focusedPathStyles.has(hoveredEl)) {
|
|
807
1041
|
// Restore previous hover unless it's also focused — focus keeps the
|
|
808
1042
|
// highlight on its own.
|
|
809
1043
|
restoreDefaultStroke(hoveredEl);
|
|
810
1044
|
}
|
|
811
1045
|
hoveredEl = pathEl;
|
|
812
|
-
|
|
1046
|
+
// Hover style follows whatever focus style is in effect (or solid).
|
|
1047
|
+
applyHighlightStroke(pathEl, focusedPathStyles.get(pathEl) ?? "solid");
|
|
813
1048
|
}
|
|
814
1049
|
|
|
815
1050
|
function clearHover() {
|
|
816
1051
|
if (hoveredEl) {
|
|
817
|
-
|
|
1052
|
+
const focusStyle = focusedPathStyles.get(hoveredEl);
|
|
1053
|
+
if (focusStyle == null) {
|
|
1054
|
+
restoreDefaultStroke(hoveredEl);
|
|
1055
|
+
} else {
|
|
1056
|
+
// Restore the focused style (in case hover overwrote a dashed item).
|
|
1057
|
+
applyHighlightStroke(hoveredEl, focusStyle);
|
|
1058
|
+
}
|
|
818
1059
|
hoveredEl = null;
|
|
819
1060
|
emit("stateHover", null);
|
|
820
1061
|
}
|
|
@@ -844,7 +1085,7 @@ function onDelegatedEvent(event: Event) {
|
|
|
844
1085
|
// clicking any other feature emits its id. With a `focus` array, any
|
|
845
1086
|
// click on a member clears everything — parents wanting fine-grained
|
|
846
1087
|
// multi-select handle merging themselves via `@update:focus`.
|
|
847
|
-
const wasFocused =
|
|
1088
|
+
const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
|
|
848
1089
|
emit("update:focus", wasFocused ? null : data.id);
|
|
849
1090
|
} else if (event.type === "mouseover") {
|
|
850
1091
|
setHover(pathsByFeatureId.get(featId)!);
|
|
@@ -878,16 +1119,23 @@ function makePath(d: string | null): SVGPathElement {
|
|
|
878
1119
|
}
|
|
879
1120
|
|
|
880
1121
|
function rebuildPaths() {
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
1122
|
+
const baseG = baseGroupRef.value;
|
|
1123
|
+
const overlayG = overlayGroupRef.value;
|
|
1124
|
+
if (!baseG || !overlayG) return;
|
|
1125
|
+
// Only the base group is wiped — the overlay group stays in place so
|
|
1126
|
+
// applyFocus can repopulate it. Overlay path *elements* are dropped
|
|
1127
|
+
// from the tracking map so applyFocus rebuilds them against the new
|
|
1128
|
+
// base tree (their `d` attributes are derived from `pathGenerator`).
|
|
1129
|
+
while (baseG.firstChild) baseG.removeChild(baseG.firstChild);
|
|
1130
|
+
while (overlayG.firstChild) overlayG.removeChild(overlayG.firstChild);
|
|
884
1131
|
pathsByFeatureId.clear();
|
|
885
1132
|
tooltipDataById.clear();
|
|
886
1133
|
bordersPathEl = null;
|
|
887
1134
|
hoveredEl = null;
|
|
888
|
-
// Old focused paths are about to be detached — drop refs so
|
|
889
|
-
// can re-resolve against the new path tree.
|
|
890
|
-
|
|
1135
|
+
// Old focused / overlay paths are about to be detached — drop refs so
|
|
1136
|
+
// applyFocus can re-resolve against the new path tree.
|
|
1137
|
+
focusedPathStyles.clear();
|
|
1138
|
+
overlayPathEls.clear();
|
|
891
1139
|
|
|
892
1140
|
const path = pathGenerator.value;
|
|
893
1141
|
const features = featuresGeo.value.features;
|
|
@@ -900,7 +1148,7 @@ function rebuildPaths() {
|
|
|
900
1148
|
for (const feat of features) {
|
|
901
1149
|
const id = String(feat.id);
|
|
902
1150
|
const name = featureName(feat);
|
|
903
|
-
const value =
|
|
1151
|
+
const value = valueFor(id);
|
|
904
1152
|
const p = makePath(path(feat));
|
|
905
1153
|
p.setAttribute("class", "state-path");
|
|
906
1154
|
p.setAttribute("data-feat-id", id);
|
|
@@ -939,13 +1187,13 @@ function rebuildPaths() {
|
|
|
939
1187
|
frag.appendChild(b);
|
|
940
1188
|
bordersPathEl = b;
|
|
941
1189
|
}
|
|
942
|
-
|
|
1190
|
+
baseG.appendChild(frag);
|
|
943
1191
|
}
|
|
944
1192
|
|
|
945
1193
|
function updateFills() {
|
|
946
1194
|
const refreshTitle = !hasInteractiveTooltip.value;
|
|
947
1195
|
for (const [id, p] of pathsByFeatureId) {
|
|
948
|
-
const value =
|
|
1196
|
+
const value = valueFor(id);
|
|
949
1197
|
const entry = tooltipDataById.get(id);
|
|
950
1198
|
p.setAttribute("fill", colorFor(id));
|
|
951
1199
|
// Refresh cached tooltip payload so a later hover (or the SVG <title>
|
|
@@ -963,7 +1211,7 @@ function updateFills() {
|
|
|
963
1211
|
function updateStrokes() {
|
|
964
1212
|
for (const p of pathsByFeatureId.values()) {
|
|
965
1213
|
// Highlighted paths (hover / focus) keep their #555 + thicker stroke.
|
|
966
|
-
if (p === hoveredEl ||
|
|
1214
|
+
if (p === hoveredEl || focusedPathStyles.has(p)) continue;
|
|
967
1215
|
restoreDefaultStroke(p);
|
|
968
1216
|
}
|
|
969
1217
|
if (bordersPathEl) bordersPathEl.setAttribute("stroke", props.strokeColor);
|
|
@@ -1076,9 +1324,18 @@ watch(
|
|
|
1076
1324
|
() => rebuildPaths(),
|
|
1077
1325
|
);
|
|
1078
1326
|
|
|
1079
|
-
// Data or scale → repaint fills (and refresh fallback <title>s).
|
|
1327
|
+
// Data or scale → repaint fills (and refresh fallback <title>s). Reading
|
|
1328
|
+
// `props.dataGeoType` directly so a change to the parent-mapping mode
|
|
1329
|
+
// re-evaluates every county's color even when `dataMap` itself
|
|
1330
|
+
// (data-id keyed) is unchanged.
|
|
1080
1331
|
watch(
|
|
1081
|
-
() =>
|
|
1332
|
+
() =>
|
|
1333
|
+
[
|
|
1334
|
+
dataMap.value,
|
|
1335
|
+
props.colorScale,
|
|
1336
|
+
props.noDataColor,
|
|
1337
|
+
props.dataGeoType,
|
|
1338
|
+
] as const,
|
|
1082
1339
|
() => updateFills(),
|
|
1083
1340
|
);
|
|
1084
1341
|
|
|
@@ -1152,10 +1409,16 @@ watch(
|
|
|
1152
1409
|
<!--
|
|
1153
1410
|
Path elements are created imperatively in `rebuildPaths()`; Vue never
|
|
1154
1411
|
diffs the per-feature subtree so reactive state changes don't walk
|
|
1155
|
-
thousands of vnodes.
|
|
1156
|
-
|
|
1412
|
+
thousands of vnodes. `mapGroupRef` carries the zoom transform;
|
|
1413
|
+
`baseGroupRef` holds feature paths + the state-borders mesh and is
|
|
1414
|
+
the event-delegation target; `overlayGroupRef` is the always-on-top
|
|
1415
|
+
focus-overlay layer (separate group so hover-raised base paths
|
|
1416
|
+
can't cover an overlay).
|
|
1157
1417
|
-->
|
|
1158
|
-
<g ref="mapGroupRef"
|
|
1418
|
+
<g ref="mapGroupRef">
|
|
1419
|
+
<g ref="baseGroupRef" />
|
|
1420
|
+
<g ref="overlayGroupRef" />
|
|
1421
|
+
</g>
|
|
1159
1422
|
</svg>
|
|
1160
1423
|
<button
|
|
1161
1424
|
v-if="isZoomed"
|
package/charts/index.ts
CHANGED
|
@@ -17,7 +17,11 @@ export {
|
|
|
17
17
|
type ChoroplethColorScale,
|
|
18
18
|
type ThresholdStop,
|
|
19
19
|
type CategoricalStop,
|
|
20
|
+
type FocusItem,
|
|
21
|
+
type FocusValue,
|
|
22
|
+
type FocusStyle,
|
|
20
23
|
} from "./ChoroplethMap/ChoroplethMap.vue";
|
|
24
|
+
export { fipsToHsa, hsaNames } from "./ChoroplethMap/hsaMapping.js";
|
|
21
25
|
export { default as ChartTooltip } from "./ChartTooltip/ChartTooltip.vue";
|
|
22
26
|
export {
|
|
23
27
|
default as DataTable,
|
package/index.json
CHANGED