@cfasim-ui/charts 0.8.0 → 0.8.1

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.
@@ -56,6 +56,14 @@ import { placeTooltip } from "../tooltip-position.js";
56
56
  import ChoroplethTooltip from "./ChoroplethTooltip.vue";
57
57
  import { layoutCities } from "./cityLayout.js";
58
58
  import type { CityMarker } from "./cityLayout.js";
59
+ import {
60
+ resolveGeoOverrides,
61
+ serializeOverrides,
62
+ parseOverrides,
63
+ mixFeatures,
64
+ stateOfId,
65
+ type LevelLookup,
66
+ } from "./mixedGeo.js";
59
67
 
60
68
  const SVG_NS = "http://www.w3.org/2000/svg";
61
69
 
@@ -65,6 +73,16 @@ export interface StateData {
65
73
  /** FIPS code (e.g. "06" for California, "04015" for a county) or name */
66
74
  id: string;
67
75
  value: number | string;
76
+ /**
77
+ * Geographic level of *this row*, when it differs from the map's `geoType`.
78
+ * The state the row belongs to is then re-tiled at this level: on a county
79
+ * map, `{ id: "06", geoType: "states" }` draws California as one merged
80
+ * shape carrying this value; on a state map, `{ id: "36061", geoType:
81
+ * "counties" }` breaks New York out into its counties. Rows at the base
82
+ * level (the common case) leave `geoType` unset. Ignores `dataGeoType` —
83
+ * an off-level row is looked up by its own id.
84
+ */
85
+ geoType?: GeoType;
68
86
  }
69
87
 
70
88
  export interface ChoroplethColorScale {
@@ -138,7 +156,10 @@ const props = withDefaults(
138
156
  * with its parent HSA's value) or by state values, without changing
139
157
  * the rendered/interactive geometry. Supported combinations:
140
158
  * `counties` ← `hsas`, `counties` ← `states`, `hsas` ← `states`.
141
- * When unset, data ids must match the base `geoType`.
159
+ * When unset, data ids must match the base `geoType`. Re-keys the whole
160
+ * dataset — to mix levels on one map (a county map where California
161
+ * reports one statewide value), set `geoType` on the individual rows
162
+ * instead.
142
163
  */
143
164
  dataGeoType?: GeoType;
144
165
  /**
@@ -1333,6 +1354,9 @@ function loadHsaModule() {
1333
1354
  watch(
1334
1355
  () => {
1335
1356
  if (props.geoType === "hsas" || props.dataGeoType === "hsas") return true;
1357
+ // A mixed-level row can pull HSAs in (`data[].geoType`), and any state
1358
+ // override on an HSA base map needs the table to place its features.
1359
+ if (props.data?.some((d) => d.geoType === "hsas")) return true;
1336
1360
  const focus = props.focus;
1337
1361
  if (!focus) return false;
1338
1362
  const items = Array.isArray(focus) ? focus : [focus];
@@ -1349,7 +1373,10 @@ const hsaFeaturesGeo = computed(() => {
1349
1373
  if (!mod) return { type: "FeatureCollection" as const, features: [] };
1350
1374
  const { fipsToHsa, hsaNames } = mod;
1351
1375
  const topo = toRaw(props.topology) as unknown as CountiesTopo;
1352
- const countyGeometries = topo.objects.counties.geometries;
1376
+ // HSAs are unions of counties: a states-only topology can't build them.
1377
+ const countyGeometries = topo.objects?.counties?.geometries;
1378
+ if (!countyGeometries)
1379
+ return { type: "FeatureCollection" as const, features: [] };
1353
1380
  const scopeFips = stateFips.value;
1354
1381
  const groups = new Map<string, typeof countyGeometries>();
1355
1382
 
@@ -1377,32 +1404,140 @@ const hsaFeaturesGeo = computed(() => {
1377
1404
  return { type: "FeatureCollection" as const, features };
1378
1405
  });
1379
1406
 
1380
- const featuresGeo = computed(() => {
1407
+ // Every county in the topology, unscoped. Split out of `baseFeaturesGeo` so
1408
+ // the mixed-level code can index counties without depending on the base
1409
+ // geoType (and without re-running topojson's `feature()` per consumer).
1410
+ const countiesFeatures = computed<ChoroplethFeature[]>(() => {
1411
+ const topo = toRaw(props.topology) as unknown as {
1412
+ objects?: { counties?: NamedGeometry };
1413
+ };
1414
+ const obj = topo?.objects?.counties;
1415
+ if (!obj) return [];
1416
+ const fc = feature(topo as unknown as Topology, obj);
1417
+ return (
1418
+ fc.type === "FeatureCollection" ? fc.features : [fc]
1419
+ ) as ChoroplethFeature[];
1420
+ });
1421
+
1422
+ // Features at the base `geoType`, scoped by the `state` prop — the map before
1423
+ // any per-state level overrides (`data[].geoType`) are mixed in.
1424
+ const baseFeaturesGeo = computed<ChoroplethFeature[]>(() => {
1381
1425
  // hsaFeaturesGeo already honors `state` (it scopes the source counties).
1382
- if (props.geoType === "hsas") return hsaFeaturesGeo.value;
1426
+ if (props.geoType === "hsas")
1427
+ return hsaFeaturesGeo.value.features as ChoroplethFeature[];
1383
1428
  const scopeFips = stateFips.value;
1384
1429
  if (props.geoType === "counties") {
1385
- const topo = toRaw(props.topology) as unknown as CountiesTopo;
1386
- const fc = feature(topo, topo.objects.counties);
1430
+ const fc = countiesFeatures.value;
1387
1431
  if (!scopeFips) return fc;
1388
- return {
1389
- type: "FeatureCollection" as const,
1390
- features: fc.features.filter(
1391
- (f) => String(f.id).padStart(5, "0").slice(0, 2) === scopeFips,
1392
- ),
1393
- };
1394
- }
1395
- const topo = toRaw(props.topology) as unknown as StatesTopo;
1396
- const fc = feature(topo, topo.objects.states);
1432
+ return fc.filter(
1433
+ (f) => String(f.id).padStart(5, "0").slice(0, 2) === scopeFips,
1434
+ );
1435
+ }
1436
+ const fc = statesFeatures.value as ChoroplethFeature[];
1397
1437
  if (!scopeFips) return fc;
1438
+ return fc.filter((f) => String(f.id).padStart(2, "0") === scopeFips);
1439
+ });
1440
+
1441
+ // ─── Mixed geographic levels (`data[].geoType`) ──────────────────────────
1442
+ //
1443
+ // A data row that declares a level other than the base `geoType` re-tiles its
1444
+ // whole state at that level: a state row merges a county map's counties into
1445
+ // one shape, a county row splits a state map's state into counties. See
1446
+ // `mixedGeo.ts` for the substitution itself.
1447
+
1448
+ // HSA code → 2-digit state FIPS, inverted from the lazy FIPS→HSA table. HSA
1449
+ // codes aren't state-prefixed, so this is the only way to place one.
1450
+ const hsaToState = computed<Map<string, string> | null>(() => {
1451
+ const mod = hsaModule.value;
1452
+ if (!mod) return null;
1453
+ const m = new Map<string, string>();
1454
+ for (const fips in mod.fipsToHsa)
1455
+ m.set(mod.fipsToHsa[fips], fips.slice(0, 2));
1456
+ return m;
1457
+ });
1458
+
1459
+ const levelLookups = computed(() => {
1460
+ const build = (
1461
+ features: ChoroplethFeature[],
1462
+ ): LevelLookup<ChoroplethFeature> => {
1463
+ const byId = new Map<string, ChoroplethFeature>();
1464
+ const byName = new Map<string, string>();
1465
+ for (const f of features) {
1466
+ if (f.id == null) continue;
1467
+ const id = String(f.id);
1468
+ byId.set(id, f);
1469
+ if (f.properties?.name != null) byName.set(f.properties.name, id);
1470
+ }
1471
+ return { byId, byName };
1472
+ };
1473
+ // Getters, so indexing a level only happens if a row actually references it.
1398
1474
  return {
1399
- type: "FeatureCollection" as const,
1400
- features: fc.features.filter(
1401
- (f) => String(f.id).padStart(2, "0") === scopeFips,
1402
- ),
1475
+ states: () => build(statesFeatures.value as ChoroplethFeature[]),
1476
+ counties: () => build(countiesFeatures.value),
1477
+ hsas: () => build(hsaFeaturesGeo.value.features as ChoroplethFeature[]),
1403
1478
  };
1404
1479
  });
1405
1480
 
1481
+ function levelLookup(level: GeoType): LevelLookup<ChoroplethFeature> | null {
1482
+ const lookup = levelLookups.value[level]();
1483
+ return lookup.byId.size ? lookup : null;
1484
+ }
1485
+
1486
+ const overrideResolution = computed(() =>
1487
+ resolveGeoOverrides(props.data, props.geoType, levelLookup, hsaToState.value),
1488
+ );
1489
+
1490
+ // Serialized first so the override map's *identity* only changes when the set
1491
+ // of overrides does. `featuresGeo` hangs off it, and rebuilding every path on
1492
+ // each `data` update (the animation case) would be a real cost.
1493
+ const geoOverrideKey = computed(() =>
1494
+ serializeOverrides(overrideResolution.value.overrides),
1495
+ );
1496
+ const geoOverrides = computed(() => parseOverrides(geoOverrideKey.value));
1497
+
1498
+ const mixedFeatures = computed(() =>
1499
+ mixFeatures(
1500
+ baseFeaturesGeo.value,
1501
+ props.geoType,
1502
+ geoOverrides.value,
1503
+ levelLookup,
1504
+ hsaToState.value,
1505
+ stateFips.value,
1506
+ ),
1507
+ );
1508
+
1509
+ /** Feature id → level, for features substituted by a `data[].geoType` row. */
1510
+ const featureLevelById = computed(() => mixedFeatures.value.levelById);
1511
+
1512
+ /** The level a rendered feature is drawn at. */
1513
+ function levelOf(featureId: string): GeoType {
1514
+ return featureLevelById.value.get(featureId) ?? props.geoType;
1515
+ }
1516
+
1517
+ const featuresGeo = computed(() => ({
1518
+ type: "FeatureCollection" as const,
1519
+ features: mixedFeatures.value.features,
1520
+ }));
1521
+
1522
+ watch(
1523
+ () => overrideResolution.value,
1524
+ ({ unresolved, conflicts }) => {
1525
+ if (unresolved.length) {
1526
+ console.warn(
1527
+ `[ChoroplethMap] data rows with a geoType that resolved to no state: ${unresolved.join(", ")}. ` +
1528
+ `Use a FIPS/HSA code or feature name available in that geoType.`,
1529
+ );
1530
+ }
1531
+ if (conflicts.length) {
1532
+ console.warn(
1533
+ `[ChoroplethMap] data rows claiming an already-mixed state at a different geoType: ${conflicts.join(", ")}. ` +
1534
+ `A state renders at one level; the first row wins.`,
1535
+ );
1536
+ }
1537
+ },
1538
+ { immediate: true },
1539
+ );
1540
+
1406
1541
  const stateBordersPath = computed(() => {
1407
1542
  if (props.geoType !== "counties" && props.geoType !== "hsas") return null;
1408
1543
  // Single-state mode: trace just the selected state's outline instead of
@@ -1455,23 +1590,18 @@ const tightFitAmount = computed(() => {
1455
1590
 
1456
1591
  // Predicate marking a feature id as Alaska (FIPS 02) or Hawaii (15), so the
1457
1592
  // projection can re-fit to just the contiguous US. States/counties key off
1458
- // the FIPS prefix directly; HSA ids are HSA codes, so an HSA counts as AK/HI
1459
- // when any of its member counties is derived from the `fipsToHsa` table
1460
- // (null until that lazy chunk loads, so the crop kicks in once it's ready).
1593
+ // the FIPS prefix directly; HSA ids are HSA codes, so an HSA has to be placed
1594
+ // through the `fipsToHsa` table (null until that lazy chunk loads, so the crop
1595
+ // kicks in once it's ready). Each id is read at the level it renders at, which
1596
+ // mixed-level maps make per-feature.
1461
1597
  const isAkHiFeature = computed<((id: string) => boolean) | null>(() => {
1462
- if (props.geoType === "hsas") {
1463
- const mod = hsaModule.value;
1464
- if (!mod) return null;
1465
- const akHi = new Set<string>();
1466
- for (const fips in mod.fipsToHsa) {
1467
- const st = fips.slice(0, 2);
1468
- if (st === "02" || st === "15") akHi.add(mod.fipsToHsa[fips]);
1469
- }
1470
- return (id) => akHi.has(id);
1471
- }
1472
- const pad = props.geoType === "counties" ? 5 : 2;
1598
+ const hsaStates = hsaToState.value;
1599
+ const usesHsas =
1600
+ props.geoType === "hsas" ||
1601
+ [...featureLevelById.value.values()].includes("hsas");
1602
+ if (usesHsas && !hsaStates) return null;
1473
1603
  return (id) => {
1474
- const st = id.padStart(pad, "0").slice(0, 2);
1604
+ const st = stateOfId(levelOf(id), id, hsaStates);
1475
1605
  return st === "02" || st === "15";
1476
1606
  };
1477
1607
  });
@@ -1611,44 +1741,13 @@ const normalizedFocus = computed<FocusItem[]>(() => {
1611
1741
  // resolveFocusItems so a single focus call can mix geoTypes.
1612
1742
  const featuresByGeoType = computed(() => {
1613
1743
  const map = new Map<GeoType, Map<string, ChoroplethFeature>>();
1614
- // The base geoType is always represented reuse the existing lookup.
1744
+ // The base geoType is always represented by what's actually rendered (which
1745
+ // on a mixed-level map includes the substituted features).
1615
1746
  map.set(props.geoType, featuresById.value);
1616
-
1617
- const topo = toRaw(props.topology) as unknown as {
1618
- objects?: { states?: NamedGeometry; counties?: NamedGeometry };
1619
- };
1620
- const objs = topo?.objects;
1621
- if (!objs) return map;
1622
-
1623
- type AnyFeature = GeoJSON.Feature<GeoJSON.Geometry | null>;
1624
- const indexFeatures = (feats: AnyFeature[]) => {
1625
- const m = new Map<string, ChoroplethFeature>();
1626
- for (const f of feats) {
1627
- if (f.id != null) m.set(String(f.id), f as ChoroplethFeature);
1628
- }
1629
- return m;
1630
- };
1631
-
1632
- if (!map.has("states") && objs.states) {
1633
- const fc = feature(topo as unknown as Topology, objs.states);
1634
- map.set(
1635
- "states",
1636
- indexFeatures(
1637
- (fc as GeoJSON.FeatureCollection<GeoJSON.Geometry | null>).features,
1638
- ),
1639
- );
1640
- }
1641
- if (!map.has("counties") && objs.counties) {
1642
- const fc = feature(topo as unknown as Topology, objs.counties);
1643
- map.set(
1644
- "counties",
1645
- indexFeatures(
1646
- (fc as GeoJSON.FeatureCollection<GeoJSON.Geometry | null>).features,
1647
- ),
1648
- );
1649
- }
1650
- if (!map.has("hsas") && objs.counties) {
1651
- map.set("hsas", indexFeatures(hsaFeaturesGeo.value.features));
1747
+ for (const gt of ["states", "counties", "hsas"] as GeoType[]) {
1748
+ if (map.has(gt)) continue;
1749
+ const lookup = levelLookup(gt);
1750
+ if (lookup) map.set(gt, lookup.byId);
1652
1751
  }
1653
1752
  return map;
1654
1753
  });
@@ -1656,12 +1755,13 @@ const featuresByGeoType = computed(() => {
1656
1755
  const dataMap = computed(() => {
1657
1756
  const map = new Map<string, number | string>();
1658
1757
  if (!props.data) return map;
1659
- // Name fallback resolves in whichever geoType the data is keyed by.
1758
+ // Name fallback resolves in whichever geoType the data is keyed by — the
1759
+ // row's own `geoType` when it declares one (mixed-level maps), else the
1760
+ // map-wide `dataGeoType`.
1660
1761
  const dataGt = props.dataGeoType ?? props.geoType;
1661
- const nameIdx = nameToIdByGeoType.value.get(dataGt);
1662
1762
  for (const d of props.data) {
1663
1763
  map.set(d.id, d.value);
1664
- const fid = nameIdx?.get(d.id);
1764
+ const fid = nameToIdByGeoType.value.get(d.geoType ?? dataGt)?.get(d.id);
1665
1765
  if (fid) map.set(fid, d.value);
1666
1766
  }
1667
1767
  return map;
@@ -1759,9 +1859,15 @@ const categoricalByValue = computed(() => {
1759
1859
  return m;
1760
1860
  });
1761
1861
 
1762
- /** Looks up the data value for a base feature id, honoring `dataGeoType`. */
1763
- function valueFor(baseId: string): number | string | undefined {
1764
- const dataId = baseToDataId(baseId);
1862
+ /**
1863
+ * Looks up the data value for a rendered feature id, honoring `dataGeoType`.
1864
+ * Features substituted by a mixed-level row carry their own level, so they're
1865
+ * keyed by their own id and skip the `dataGeoType` remapping entirely.
1866
+ */
1867
+ function valueFor(featureId: string): number | string | undefined {
1868
+ if (featureLevelById.value.has(featureId))
1869
+ return dataMap.value.get(featureId);
1870
+ const dataId = baseToDataId(featureId);
1765
1871
  return dataId == null ? undefined : dataMap.value.get(dataId);
1766
1872
  }
1767
1873
 
@@ -3536,10 +3642,6 @@ watch(
3536
3642
  font-weight: 500;
3537
3643
  }
3538
3644
 
3539
- .choropleth-cities :deep(.choropleth-city-label-capital) {
3540
- font-weight: 700;
3541
- }
3542
-
3543
3645
  .choropleth-canvas {
3544
3646
  position: absolute;
3545
3647
  top: 0;
@@ -14,8 +14,9 @@ export interface CityMarker {
14
14
  coordinates: [number, number];
15
15
  /**
16
16
  * Mark as a capital: the label gets first pick during placement and is never
17
- * dropped for collisions (rendered slightly emphasized). The marker itself is
18
- * a plain dot, same as any other city.
17
+ * dropped for collisions. It renders like any other label (with a
18
+ * `choropleth-city-label-capital` class to style if you want), and the marker
19
+ * is a plain dot, same as any other city.
19
20
  */
20
21
  capital?: boolean;
21
22
  /**
@@ -0,0 +1,201 @@
1
+ // Mixed-level geography for ChoroplethMap: render most of the map at the base
2
+ // `geoType` while a few states render at a different level — a county map where
3
+ // California is one merged shape (a single state-level estimate), or a state map
4
+ // where New York is broken out into its counties.
5
+ //
6
+ // The unit of substitution is always a whole state: a data row that declares a
7
+ // `geoType` other than the base marks its state, and that state's entire area is
8
+ // re-tiled at the row's level. That keeps the map a partition of the same
9
+ // territory (no gaps, no double-painted areas) whichever direction you mix in.
10
+ //
11
+ // Kept free of Vue/DOM/topojson so it can be unit tested in isolation.
12
+
13
+ import type { GeoType } from "./ChoroplethMap.vue";
14
+
15
+ /** Minimal shape of the GeoJSON features this module shuffles around. */
16
+ export interface MixFeature {
17
+ id?: string | number | null;
18
+ properties?: { name?: string } | null;
19
+ }
20
+
21
+ /** id → feature and name → id indexes for one geographic level. */
22
+ export interface LevelLookup<F extends MixFeature = MixFeature> {
23
+ byId: Map<string, F>;
24
+ byName: Map<string, string>;
25
+ }
26
+
27
+ /**
28
+ * Lazily resolves the lookup for a level, so a map that never mixes never pays
29
+ * to index the levels it doesn't render. Returns null when the topology can't
30
+ * supply that level (e.g. "counties" from a states-only topology, or "hsas"
31
+ * before the lazy HSA chunk loads).
32
+ */
33
+ export type LevelResolver<F extends MixFeature = MixFeature> = (
34
+ level: GeoType,
35
+ ) => LevelLookup<F> | null;
36
+
37
+ /** Digits in a fully-qualified id at each level ("06" / "06075"). */
38
+ const ID_WIDTH: Record<GeoType, number> = { states: 2, counties: 5, hsas: 6 };
39
+
40
+ /**
41
+ * 2-digit state FIPS a feature id belongs to, or null when it can't be
42
+ * determined. States and counties carry it as their first two digits; HSA
43
+ * codes need the FIPS→HSA table (inverted into `hsaToState`), because an HSA
44
+ * code isn't state-prefixed. No HSA spans two states, so the mapping is total.
45
+ */
46
+ export function stateOfId(
47
+ level: GeoType,
48
+ id: string,
49
+ hsaToState?: ReadonlyMap<string, string> | null,
50
+ ): string | null {
51
+ if (level === "hsas") return hsaToState?.get(id) ?? null;
52
+ const padded = id.padStart(ID_WIDTH[level], "0");
53
+ return /^\d{2}/.test(padded) ? padded.slice(0, 2) : null;
54
+ }
55
+
56
+ /** Resolves a user-supplied id (code or feature name) to a canonical feature id. */
57
+ function canonicalId(
58
+ rawId: string,
59
+ level: GeoType,
60
+ lookup: LevelLookup,
61
+ ): string | null {
62
+ if (lookup.byId.has(rawId)) return rawId;
63
+ const padded = rawId.padStart(ID_WIDTH[level], "0");
64
+ if (lookup.byId.has(padded)) return padded;
65
+ return lookup.byName.get(rawId) ?? null;
66
+ }
67
+
68
+ /** A `data` row as far as mixing is concerned. */
69
+ export interface MixRow {
70
+ id: string;
71
+ geoType?: GeoType;
72
+ }
73
+
74
+ export interface OverrideResolution {
75
+ /** state FIPS → the level that state renders at. */
76
+ overrides: Map<string, GeoType>;
77
+ /** Row ids whose state couldn't be resolved (bad id, or level unavailable). */
78
+ unresolved: string[];
79
+ /**
80
+ * Row ids that named an already-claimed state at a different level. The first
81
+ * row wins; these are reported so the caller can warn.
82
+ */
83
+ conflicts: string[];
84
+ }
85
+
86
+ /**
87
+ * Collects the per-state level overrides implied by `rows`. Rows without a
88
+ * `geoType`, or whose `geoType` matches the base map, are plain data and are
89
+ * ignored here.
90
+ */
91
+ export function resolveGeoOverrides(
92
+ rows: readonly MixRow[] | undefined,
93
+ baseGeoType: GeoType,
94
+ getLevel: LevelResolver,
95
+ hsaToState?: ReadonlyMap<string, string> | null,
96
+ ): OverrideResolution {
97
+ const overrides = new Map<string, GeoType>();
98
+ const unresolved: string[] = [];
99
+ const conflicts: string[] = [];
100
+ if (!rows) return { overrides, unresolved, conflicts };
101
+
102
+ for (const row of rows) {
103
+ const level = row.geoType;
104
+ if (!level || level === baseGeoType) continue;
105
+ const lookup = getLevel(level);
106
+ if (!lookup) {
107
+ unresolved.push(row.id);
108
+ continue;
109
+ }
110
+ const id = canonicalId(row.id, level, lookup);
111
+ const fips = id == null ? null : stateOfId(level, id, hsaToState);
112
+ if (!fips) {
113
+ unresolved.push(row.id);
114
+ continue;
115
+ }
116
+ const claimed = overrides.get(fips);
117
+ if (claimed == null) overrides.set(fips, level);
118
+ else if (claimed !== level) conflicts.push(row.id);
119
+ }
120
+ return { overrides, unresolved, conflicts };
121
+ }
122
+
123
+ /**
124
+ * Serializes overrides into a comparable key. The caller derives the override
125
+ * map from this string so its identity only changes when the *set* of overrides
126
+ * does — otherwise every `data` update would rebuild the whole feature tree.
127
+ */
128
+ export function serializeOverrides(overrides: Map<string, GeoType>): string {
129
+ return [...overrides]
130
+ .map(([fips, level]) => `${fips}:${level}`)
131
+ .sort()
132
+ .join(",");
133
+ }
134
+
135
+ /** Inverse of `serializeOverrides`. */
136
+ export function parseOverrides(key: string): Map<string, GeoType> {
137
+ const map = new Map<string, GeoType>();
138
+ if (!key) return map;
139
+ for (const part of key.split(",")) {
140
+ const [fips, level] = part.split(":");
141
+ map.set(fips, level as GeoType);
142
+ }
143
+ return map;
144
+ }
145
+
146
+ export interface MixedFeatures<F extends MixFeature> {
147
+ features: F[];
148
+ /**
149
+ * Feature id → level, for substituted features only. Ids absent from the map
150
+ * render at the base `geoType`.
151
+ */
152
+ levelById: Map<string, GeoType>;
153
+ }
154
+
155
+ /**
156
+ * Replaces every overridden state's base features with that state's features at
157
+ * the override level. Returns `base` untouched when there's nothing to mix, so
158
+ * the common case costs one `size` check.
159
+ *
160
+ * `scopeFips` mirrors the map's single-state mode: substituted features outside
161
+ * the scoped state are dropped, exactly as the base set already is.
162
+ */
163
+ export function mixFeatures<F extends MixFeature>(
164
+ base: readonly F[],
165
+ baseGeoType: GeoType,
166
+ overrides: Map<string, GeoType>,
167
+ getLevel: LevelResolver<F>,
168
+ hsaToState?: ReadonlyMap<string, string> | null,
169
+ scopeFips?: string | null,
170
+ ): MixedFeatures<F> {
171
+ const levelById = new Map<string, GeoType>();
172
+ if (overrides.size === 0) return { features: base as F[], levelById };
173
+
174
+ // Collect the replacements first: a state is only cut out of the base once
175
+ // something is standing by to cover it, so a level that isn't ready yet (the
176
+ // lazy HSA chunk) leaves the base map intact instead of punching a hole.
177
+ const substitutes: F[] = [];
178
+ const replaced = new Set<string>();
179
+ for (const [fips, level] of overrides) {
180
+ if (scopeFips && fips !== scopeFips) continue;
181
+ const lookup = getLevel(level);
182
+ if (!lookup) continue;
183
+ const forState: F[] = [];
184
+ for (const [id, f] of lookup.byId) {
185
+ if (stateOfId(level, id, hsaToState) !== fips) continue;
186
+ forState.push(f);
187
+ levelById.set(id, level);
188
+ }
189
+ if (!forState.length) continue;
190
+ replaced.add(fips);
191
+ substitutes.push(...forState);
192
+ }
193
+ if (!replaced.size) return { features: base as F[], levelById };
194
+
195
+ const features = base.filter((f) => {
196
+ const fips = stateOfId(baseGeoType, String(f.id), hsaToState);
197
+ return !(fips && replaced.has(fips));
198
+ });
199
+ features.push(...substitutes);
200
+ return { features, levelById };
201
+ }