doxa-maps/library/components/legends/listSelection.js
Doxa Maps 802d7b5b8a P assembly: the widened census, stated neutrally
The case-insensitive census with every plugin agent's name found 30 more lines: library (ProfileLoader,
useMapProvider, ApproxPinNote, listSelection, colocation), the gallery's embed-contract card id, the gospel
plugins' source notes and build scripts, a regions script note and one gate description. Comments and notes
only, except the Gospel x Internet CSV export's provenance line, which no longer names who screened the links.
2026-10-01 05:42:24 +00:00

86 lines
4 KiB
JavaScript

/**
* listSelection.js — what a vertical-legend row selects, and the zones a tree forgot.
*
* Extracted from ListSelectorLegend.vue so both rules can be tested without mounting a component.
* Small rules, large consequences: a dashboard filters thousands of rows by the first, and the
* second decides whether 100+ groups are reachable at all.
*/
/** Every leaf under a node, or the node itself when it is a leaf. */
export function leavesOf(node) {
if (!node) return []
const kids = node.children || node.nodes || []
if (!kids.length) return [node]
const out = []
for (const k of kids) out.push(...leavesOf(k))
return out
}
/** Every zone id present anywhere in a tree. */
export function zoneIdsIn(nodes) {
const out = new Set()
for (const n of nodes || []) for (const l of leavesOf(n)) if (l && l.id) out.add(String(l.id))
return out
}
export const UNASSIGNED_ID = '__unassigned__'
/**
* ★ A ZONE THE TREE DOES NOT CONTAIN MUST STILL BE REACHABLE. Four joinable zones are absent from
* zone-regions.json today — guizhou, xinjiang (100+ groups on its own), bihar, madhya-pradesh — so a
* legend that renders only the region tree DROPS them, silently, and a reader has no way to select
* groups that plainly exist. The fix is not to remember: it is to hand this function the full zone
* set and let it render the difference. A drop then becomes structurally impossible rather than a
* thing someone has to keep checking.
*
* Returns the tree unchanged when nothing is missing, so the bucket never appears without cause.
*
* @param {Array} nodes the region-grouped tree
* @param {Array<string>} allZoneIds every zone the data can join, tree or no tree
* @param {string} label what to call the bucket (the caller localises it)
*/
export function withUnassigned(nodes, allZoneIds, label = 'Unassigned') {
const present = zoneIdsIn(nodes)
const missing = (allZoneIds || []).map(String).filter((id) => !present.has(id))
if (!missing.length) return nodes || []
return [
...(nodes || []),
{
id: UNASSIGNED_ID,
label,
depth: 0,
// Each missing zone is a selectable leaf, not a lump: a reader wants xinjiang, not "the four
// zones nobody filed".
children: missing.map((id) => ({ id, label: id, zoneKey: id })),
},
]
}
/**
* The value the legend emits. gospel's contract: a single SERIALISABLE id, so it survives ?zone=,
* profile-config and a deep link, and stays usable across their four tab shapes.
*
* A leaf emits its zone id; a REGION row emits the region's own id (a region selects
* the union of its zones). The consumer expands a region id to its zones using the zone data it
* already holds — which is also where the diacritic-folding join lives, so there is no second copy
* of it here to drift.
*
* ★ REGION IDS AND ZONE IDS SHARE ONE NAMESPACE, SO NAMESPACE YOUR REGION ROWS (e.g. `region:europe`,
* leaves left bare). One bare string on one channel carries no kind, and the two id sets overlap in
* the shipped data: measured against zone-regions.json there are 33 zone ids and 7 region ids, and
* the intersection is exactly {europe}, whose region is itself. So `europe` alone cannot tell a
* consumer whether the Europe REGION was clicked (expand to every European zone) or the Europe ZONE
* leaf (filter to that one).
*
* It is not live TODAY only because region `europe` currently contains exactly one zone, so both
* readings produce the same set — the bug is masked by a data coincidence, not absent. It goes live
* the day a second European zone is filed, and then it fails the quiet way: a smaller result, no
* error, no empty state. check:list-selection FAILS ON PURPOSE at that moment.
*
* This component cannot fix it: the CONSUMER builds the tree, so only the consumer knows which rows
* are regions. Found while wiring a plugin that namespaces its region rows; written
* here because the next consumer to build a tree from the same zone data inherits the same collision.
*/
export function selectedId(node) {
return node && node.id != null ? String(node.id) : null
}