doxa-maps/library/components/legends/listSelection.js
Doxa Maps 891f7535d8
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 53d386262
2026-09-28 14:27:42 +00:00

70 lines
2.9 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 (dx-shepherd: 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.
*/
export function selectedId(node) {
return node && node.id != null ? String(node.id) : null
}