@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.
- package/dist/ChoroplethMap/ChoroplethMap.d.ts +14 -1
- package/dist/ChoroplethMap/cityLayout.d.ts +3 -2
- package/dist/ChoroplethMap/mixedGeo.d.ts +74 -0
- package/dist/ChoroplethMap/mixedGeo.test.d.ts +1 -0
- package/dist/index.css +1 -1
- package/dist/index.js +1234 -1119
- package/docs/ChoroplethMap.md +130 -3
- package/docs/index.json +1 -1
- package/package.json +2 -2
- package/src/ChoroplethMap/ChoroplethMap.md +130 -3
- package/src/ChoroplethMap/ChoroplethMap.vue +183 -81
- package/src/ChoroplethMap/cityLayout.ts +3 -2
- package/src/ChoroplethMap/mixedGeo.ts +201 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
1386
|
-
const fc = feature(topo, topo.objects.counties);
|
|
1430
|
+
const fc = countiesFeatures.value;
|
|
1387
1431
|
if (!scopeFips) return fc;
|
|
1388
|
-
return
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
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
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1618
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
1763
|
-
|
|
1764
|
-
|
|
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
|
|
18
|
-
*
|
|
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
|
+
}
|