@cfasim-ui/docs 0.4.4 → 0.4.6

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.
@@ -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 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.
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?: string | string[] | null;
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
- const focusedPathEls = new Set<SVGPathElement>();
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
- // 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);
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
- 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);
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 the focus zoom transition (ms). Initial mount and explicit
343
- // "clear focus" still snap instantly; only focus-prop changes animate.
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 updates animate.
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 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);
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
- // 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;
436
+ for (const [p] of focusedPathStyles) {
437
+ if (nextBaseStyles.has(p) || p === hoveredEl) continue;
366
438
  restoreDefaultStroke(p);
367
439
  }
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);
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. Also lets a
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. Only zoom-IN animates;
383
- // clearing snaps back. Matches resetZoom's instant feel.
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
- 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.
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(features[0].id);
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
- 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);
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
- // O(features + data) name→id index, so `dataMap` doesn't fall back to a
549
- // linear scan per data point (previously O(features × data)).
550
- const nameToFeatureId = computed(() => {
551
- const m = new Map<string, string>();
552
- for (const f of featuresGeo.value.features) {
553
- if (f.properties?.name != null && f.id != null) {
554
- m.set(f.properties.name, String(f.id));
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 m;
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
- // 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[]>(() => {
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
- if (Array.isArray(f)) return f;
577
- return [f];
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
- const nameIdx = nameToFeatureId.value;
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.get(d.id);
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 = dataMap.value.get(id);
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
- function applyHighlightStroke(pathEl: SVGPathElement) {
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 && !focusedPathEls.has(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
- applyHighlightStroke(pathEl);
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
- if (!focusedPathEls.has(hoveredEl)) restoreDefaultStroke(hoveredEl);
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 = resolveFocusIds(normalizedFocus.value).has(data.id);
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 g = mapGroupRef.value;
882
- if (!g) return;
883
- while (g.firstChild) g.removeChild(g.firstChild);
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 applyFocus
889
- // can re-resolve against the new path tree.
890
- focusedPathEls.clear();
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 = dataMap.value.get(id);
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
- g.appendChild(frag);
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 = dataMap.value.get(id);
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 || focusedPathEls.has(p)) continue;
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
- () => [dataMap.value, props.colorScale, props.noDataColor],
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. This <g> is the mount point + event delegation
1156
- target.
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"