374 lines
17 KiB
JavaScript
Vendored
374 lines
17 KiB
JavaScript
Vendored
// legend-tree.js — slice 04t §1(a)
|
|
//
|
|
// Pure shaper: buildLegendNodes(clusters) -> TreeNode[]
|
|
//
|
|
// Turns clusters.json (13 regional `lists` + 2 `deaf_lists` + 12 `asia_lists`)
|
|
// into the NESTED `nodes` tree consumed by the forge-shared
|
|
// src/components/SemanticTreeLegend.vue (S04t-D4: nested). It does NOT rebuild a
|
|
// legend — it only shapes data.
|
|
//
|
|
// Tree node shape (per SemanticTreeLegend.vue:13-30):
|
|
// { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
|
|
//
|
|
// Output shape (26 LEAF rows total):
|
|
// - 13 Region leaves (top level)
|
|
// - 1 Deaf leaf (top level; the two deaf_lists collapse into ONE
|
|
// A017 row, count 123 — R1.1 design correction)
|
|
// - 1 Asia parent ▸ India (10 state-cluster leaves) / China (2 leaves)
|
|
//
|
|
// Parents (Asia / India / China) carry rolled-up count. A stable zoneKey === leaf
|
|
// id is assigned so the paint layer's ['match', ['get','zoneKey'], …] and the
|
|
// legend's `select` emit share one key space.
|
|
|
|
// Deterministic per-leaf color: even hue spread across the 26 leaves, comma-HSL
|
|
// (Mapbox paint expressions reject the modern slash hsl() syntax). Mirrors the
|
|
// slice-04b color rule (profiles/upg-100-list.vue:77).
|
|
export function leafColor(i, n) {
|
|
return `hsl(${Math.round((i * 360) / (n || 1))}, 72%, 56%)`;
|
|
}
|
|
|
|
import { evaluate } from './selector-evaluator.js';
|
|
|
|
// Count for one leaf: regional/asia carry `count`, deaf carries the A017 total.
|
|
function leafCount(row) {
|
|
if (typeof row.count === 'number') return row.count;
|
|
if (typeof row.expected_count === 'number') return row.expected_count;
|
|
return undefined;
|
|
}
|
|
|
|
// ── slice 04u §1(b): population per leaf ─────────────────────────────────────
|
|
// Sum parseInt(member.population) over a zone's MEMBER records — the same member
|
|
// set used for `count`. population is a flat string on the live record (full
|
|
// recache parses it to int; parseInt here is belt-and-suspenders for either form).
|
|
function popOf(record) {
|
|
const n = parseInt(record && record.population, 10);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
// Resolve a zone's member records, then sum their population.
|
|
// - regional + deaf rows use the selector-evaluator (country_code / wagf_* /
|
|
// union / affinity_bloc) — same path zone-geometry uses for `count`.
|
|
// - ASIA rows carry a `pg_slugs` selector the evaluator intentionally REJECTS
|
|
// (it resolves geometry by pre-tagged listId, not by evaluating slugs). For
|
|
// population we sum records whose slug ∈ selector.values directly — no
|
|
// evaluator change, no shared-src edit.
|
|
function leafPopulation(row, records) {
|
|
if (!Array.isArray(records) || records.length === 0) return undefined;
|
|
const sel = row && row.selector;
|
|
if (sel == null) return undefined;
|
|
|
|
// Asia state-clusters: explicit pg_slugs member list.
|
|
if (sel.type === 'pg_slugs') {
|
|
const want = new Set(Array.isArray(sel.values) ? sel.values : []);
|
|
let sum = 0;
|
|
for (const r of records) if (r && want.has(r.slug)) sum += popOf(r);
|
|
return sum;
|
|
}
|
|
|
|
// Everything else the evaluator handles → Set<slug> → sum member population.
|
|
const slugs = evaluate(sel, records);
|
|
let sum = 0;
|
|
for (const r of records) if (r && slugs.has(r.slug)) sum += popOf(r);
|
|
return sum;
|
|
}
|
|
|
|
/**
|
|
* buildLegendNodes(clusters, opts?) -> TreeNode[]
|
|
* @param {object} clusters parsed clusters.json
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.deafCount=123] A017 PG count for the unified deaf leaf
|
|
* @param {Array} [opts.records] normalized live records — when supplied, every
|
|
* node also gets `pop` (Σ member population; parents roll up).
|
|
* Omit to keep the prior count-only behaviour (pop undefined).
|
|
* @returns {Array} nested TreeNode[] (3 top-level groups, 26 leaves total)
|
|
*/
|
|
export function buildLegendNodes(clusters, opts = {}) {
|
|
const deafCount = opts.deafCount ?? 123;
|
|
const records = Array.isArray(opts.records) ? opts.records : null;
|
|
// ★ OPTIONAL GENERATED TITLE (the agent, 2026-09-27). `opts.title(zone)` returns a localized title
|
|
// composed from already-translated parts, or NULL for a zone whose title cannot be generated — see
|
|
// data/zone-title.js. Passing nothing keeps every hand-written English label exactly as it was, which
|
|
// is why this is an option rather than a rewrite: the legend, the poster and the packet adopt it
|
|
// independently, and a caller that has no records yet (records:null) still builds a correct tree.
|
|
// ⚠ NULL MEANS "KEEP YOURS", NOT "NO TITLE". 18 of 28 zones decline (their labels are sub-national or
|
|
// taxonomic, and Intl has no subdivisions), so the fallback is the normal path, not the error path.
|
|
const titleOf = typeof opts.title === 'function'
|
|
? (zone, fallback) => opts.title(zone) || fallback
|
|
: (_zone, fallback) => fallback;
|
|
const lists = clusters.lists || [];
|
|
const asia = clusters.asia_lists || [];
|
|
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
|
|
// Himalaya, SA-West, Central-Asia, Mongolia, Japan, Scatter). They carry a
|
|
// `country_codes` selector → resolved by zone-geometry's REGIONAL/ADM0 path
|
|
// (same as the 13 regional rows), NOT the asia ADM1 path. Default empty so
|
|
// pre-09b-6 clusters (no uncovered_lists) keep the prior 26-leaf shape.
|
|
const uncovered = clusters.uncovered_lists || [];
|
|
|
|
// Deaf row's selector is implicit (affinity bloc A017), so population can be
|
|
// summed the same way as the regional rows.
|
|
const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' };
|
|
|
|
// ── ordered leaf list (drives color index space) ───────────────────────────
|
|
// order: 13 regional, 1 deaf, asia (10 India + 2 China), then the Frontier-Asia
|
|
// uncovered rows — matches the legend reading order and the paint match arms.
|
|
// ★ COUNTS ARE DERIVED FROM clusters.json, never asserted here (2026-09-21). This comment
|
|
// used to claim "33 leaves" and "7 Frontier-Asia uncovered"; the shipped clusters.json
|
|
// carries 13 + 12 + 2 + 1 deaf = 28, with exactly TWO uncovered rows
|
|
// (uncovered-scatter, uncovered-nas-a). The stale 26-vs-reality gap also shipped as a
|
|
// "27/26 zones" badge in upg-100-list.vue. A hardcoded count next to data that grows is a
|
|
// drift waiting to happen — read the arrays instead.
|
|
const indiaRows = asia.filter((a) => a.id.startsWith('asia-india'));
|
|
const chinaRows = asia.filter((a) => a.id.startsWith('asia-china'));
|
|
const orderedLeafIds = [
|
|
...lists.map((l) => l.id),
|
|
'deaf',
|
|
...indiaRows.map((r) => r.id),
|
|
...chinaRows.map((r) => r.id),
|
|
...uncovered.map((u) => u.id),
|
|
];
|
|
const N = orderedLeafIds.length; // 33 (26 + 7 uncovered)
|
|
const colorOf = (id) => leafColor(orderedLeafIds.indexOf(id), N);
|
|
|
|
// `pop` is attached only when records were supplied; undefined otherwise so the
|
|
// prior count-only callers are unaffected. `withPop` adds the key conditionally.
|
|
const withPop = (node, row, selector) => {
|
|
if (!records) return node;
|
|
const sel = selector ?? (row && row.selector);
|
|
node.pop = leafPopulation({ selector: sel }, records);
|
|
return node;
|
|
};
|
|
// Sum child pops (parent roll-up, S04u-D3). Returns undefined when no records.
|
|
const sumPop = (children) =>
|
|
records ? children.reduce((s, c) => s + (c.pop || 0), 0) : undefined;
|
|
|
|
// ── 13 Region leaves ───────────────────────────────────────────────────────
|
|
const regionLeaves = lists.map((l) =>
|
|
withPop(
|
|
{
|
|
id: l.id,
|
|
zoneKey: l.id,
|
|
label: titleOf(l, l.label),
|
|
// Carry the province enumeration the region-name label replaced.
|
|
...(l.provinceLabel ? { provinceLabel: l.provinceLabel } : {}),
|
|
color: colorOf(l.id),
|
|
count: leafCount(l),
|
|
},
|
|
l,
|
|
),
|
|
);
|
|
|
|
// ── 1 Deaf leaf (unified A017; R1.1) ───────────────────────────────────────
|
|
// D2: deaf pop = Σ population of the 123 A017 members. This WILL double-count
|
|
// vs the geographic rows (those members also fall inside a regional zone) —
|
|
// the design-approved deaf double-count (slice 08). Per-zone equality still
|
|
// holds; only a naïve global sum would over-count, which the proof avoids.
|
|
const deafLeaf = withPop(
|
|
{
|
|
id: 'deaf',
|
|
zoneKey: 'deaf',
|
|
label: 'Deaf (affinity bloc A017)',
|
|
color: colorOf('deaf'),
|
|
count: deafCount,
|
|
info: 'Unified deaf row — affinity bloc rop1=A017 (collapses the two deaf_lists; zero-delta vs old deaf_predicate). Population double-counts vs geographic rows (design-approved, slice 08).',
|
|
},
|
|
null,
|
|
DEAF_SELECTOR,
|
|
);
|
|
|
|
// ── Asia parent ▸ India / China ────────────────────────────────────────────
|
|
const mkAsiaLeaf = (r) =>
|
|
withPop(
|
|
{
|
|
id: r.id,
|
|
zoneKey: r.id,
|
|
label: titleOf(r, r.label),
|
|
// The province enumeration the region-name label replaced. Carried through
|
|
// like adm0/provinces above — a node rebuilt field-by-field silently drops
|
|
// anything not named, which is exactly how adm0 and population were lost.
|
|
...(r.provinceLabel ? { provinceLabel: r.provinceLabel } : {}),
|
|
color: colorOf(r.id),
|
|
count: leafCount(r),
|
|
// MIXED GEOMETRY: carry the optional `adm0` ISO3 list through to the node.
|
|
// zone-geometry.js ALREADY supports it — an Asia zone may union whole ADM0
|
|
// countries alongside its ADM1 provinces, and its comment names the Nepal
|
|
// case explicitly — but the node was rebuilt field-by-field here and
|
|
// silently dropped adm0, so the hook could never fire.
|
|
...(Array.isArray(r.adm0) && r.adm0.length ? { adm0: r.adm0 } : {}),
|
|
// Same passthrough reason as adm0: the province enumeration is data the
|
|
// poster header prints, and rebuilding the node field-by-field drops it.
|
|
...(Array.isArray(r.provinces) && r.provinces.length ? { provinces: r.provinces } : {}),
|
|
},
|
|
r,
|
|
);
|
|
const indiaChildren = indiaRows.map(mkAsiaLeaf);
|
|
const chinaChildren = chinaRows.map(mkAsiaLeaf);
|
|
const sumCount = (rows) => rows.reduce((s, r) => s + (leafCount(r) || 0), 0);
|
|
|
|
const indiaNode = {
|
|
id: 'asia-india',
|
|
label: 'India',
|
|
count: sumCount(indiaRows),
|
|
children: indiaChildren,
|
|
};
|
|
const chinaNode = {
|
|
id: 'asia-china',
|
|
label: 'China',
|
|
count: sumCount(chinaRows),
|
|
children: chinaChildren,
|
|
};
|
|
if (records) {
|
|
indiaNode.pop = sumPop(indiaChildren);
|
|
chinaNode.pop = sumPop(chinaChildren);
|
|
}
|
|
|
|
const asiaNode = {
|
|
id: 'asia',
|
|
label: 'Asia (admin-1)',
|
|
count: sumCount(asia),
|
|
children: [indiaNode, chinaNode],
|
|
};
|
|
if (records) asiaNode.pop = sumPop([indiaNode, chinaNode]);
|
|
|
|
// ── Frontier Asia parent ▸ 7 uncovered zones (09b-6 §1a, W-D1) ─────────────
|
|
// Each uncovered row carries a `country_codes` selector → ADM0 polygons
|
|
// (Nepal→NPL, SA-West→AFG+PAK, …). Same leaf shape as regional rows so the
|
|
// tapestry's single ['match',['get','zoneKey'],…] paints them with the rest.
|
|
const mkUncoveredLeaf = (u) =>
|
|
withPop(
|
|
{
|
|
id: u.id,
|
|
zoneKey: u.id,
|
|
label: titleOf(u, u.label),
|
|
// Carry the province enumeration the region-name label replaced.
|
|
...(u.provinceLabel ? { provinceLabel: u.provinceLabel } : {}),
|
|
color: colorOf(u.id),
|
|
count: leafCount(u),
|
|
},
|
|
u,
|
|
);
|
|
const uncoveredChildren = uncovered.map(mkUncoveredLeaf);
|
|
const frontierNode =
|
|
uncoveredChildren.length > 0
|
|
? (() => {
|
|
const node = {
|
|
id: 'frontier-asia',
|
|
label: 'Frontier Asia (uncovered)',
|
|
count: sumCount(uncovered),
|
|
children: uncoveredChildren,
|
|
};
|
|
if (records) node.pop = sumPop(uncoveredChildren);
|
|
return node;
|
|
})()
|
|
: null;
|
|
|
|
// ── REGIONAL GROUPING — the SAME sections the poster page uses ─────────────
|
|
// The legend used to emit 14 LOOSE LEAVES at top level (West Africa, Sudan,
|
|
// Brazil, Laos, …) beside two odd parents, "Asia (admin-1)" and "Frontier Asia
|
|
// (uncovered)". Brief: "the legend itself is not really well grouped — it says
|
|
// Asia and it mentions other groups… find the sections on the poster page and
|
|
// use those as the legend sections."
|
|
//
|
|
// So the same 28 zones now group into the poster page's nine regions. The
|
|
// mapping is duplicated from poster-wall.vue rather than imported because this
|
|
// file is a PURE SHAPER with no imports by design — but the two lists must stay
|
|
// in step, so any edit here belongs in poster-wall.vue's REGION_OF too.
|
|
const REGION_OF = {
|
|
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
|
|
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
|
|
'maghreb-egypt': 'Middle East', 'middle-east-core': 'Middle East',
|
|
'uncovered-nas-a': 'Central Asia',
|
|
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
|
|
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
|
|
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
|
|
'europe': 'Europe', 'deaf': 'Deaf', 'deaf-old-world': 'Deaf',
|
|
};
|
|
const regionFor = (id) =>
|
|
REGION_OF[id]
|
|
|| (String(id).startsWith('asia-india-') ? 'India'
|
|
: String(id).startsWith('asia-china-') ? 'China + Mongolia'
|
|
: 'Other');
|
|
|
|
// Every leaf the tree can paint, flattened out of its source block. India and
|
|
// China leaves come from asiaNode's children; the rest are already flat.
|
|
const allLeaves = [
|
|
...regionLeaves,
|
|
deafLeaf,
|
|
...indiaChildren,
|
|
...chinaChildren,
|
|
...uncoveredChildren,
|
|
].filter(Boolean);
|
|
|
|
const byRegion = new Map();
|
|
for (const leaf of allLeaves) {
|
|
const r = regionFor(leaf.id || leaf.zoneKey);
|
|
if (!byRegion.has(r)) byRegion.set(r, []);
|
|
byRegion.get(r).push(leaf);
|
|
}
|
|
|
|
// Sections ordered by TOTAL people groups (largest first), leaves within a
|
|
// section by their own count — the densest prayer need leads at both levels,
|
|
// matching the poster page exactly.
|
|
const tops = [...byRegion.entries()]
|
|
.map(([label, children]) => {
|
|
children.sort((a, b) => (b.count || 0) - (a.count || 0));
|
|
const node = {
|
|
id: 'region-' + label.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
label,
|
|
count: children.reduce((n, c) => n + (c.count || 0), 0),
|
|
children,
|
|
};
|
|
if (records) node.pop = sumPop(children);
|
|
return node;
|
|
})
|
|
.sort((a, b) => (b.count || 0) - (a.count || 0));
|
|
|
|
// ── L15 consumer-shape assertion (09b-6 §1b) ───────────────────────────────
|
|
// Every RENDERED LEAF — regardless of source block (lists / asia_lists /
|
|
// uncovered_lists) — must carry the same required fields so zone-geometry and
|
|
// the paint match expression read a uniform shape. Fail loud (throw) rather
|
|
// than silently paint a malformed zone.
|
|
assertUniformLeafShape(leafZones(tops), { withPop: !!records });
|
|
|
|
return tops;
|
|
}
|
|
|
|
// L15 (09b-6 §1b): assert every leaf zone has the uniform consumer shape.
|
|
// Required on ALL leaves: id, zoneKey, label, color (string), count (number).
|
|
// When records were supplied, `pop` must also be a number on every leaf.
|
|
// Parents (asia/india/china/frontier-asia) are intentionally NOT leaves and are
|
|
// excluded by leafZones() — they carry roll-ups, not a zoneKey/selector.
|
|
export function assertUniformLeafShape(leaves, { withPop = false } = {}) {
|
|
const bad = [];
|
|
for (const z of leaves || []) {
|
|
const errs = [];
|
|
if (typeof z.id !== 'string' || !z.id) errs.push('id');
|
|
if (typeof z.zoneKey !== 'string' || !z.zoneKey) errs.push('zoneKey');
|
|
if (typeof z.label !== 'string' || !z.label) errs.push('label');
|
|
if (typeof z.color !== 'string' || !z.color) errs.push('color');
|
|
if (typeof z.count !== 'number') errs.push('count');
|
|
if (withPop && typeof z.pop !== 'number') errs.push('pop');
|
|
if (errs.length) bad.push(`${z.id || '(no-id)'}: missing/!type [${errs.join(', ')}]`);
|
|
}
|
|
if (bad.length) {
|
|
throw new Error(
|
|
`legend-tree L15: ${bad.length} leaf node(s) violate the uniform consumer shape:\n ` +
|
|
bad.join('\n '),
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Flatten a nodes tree to its LEAF nodes (rows that own a zoneKey/paint zone).
|
|
export function leafZones(nodes) {
|
|
const out = [];
|
|
const walk = (arr) => {
|
|
for (const n of arr) {
|
|
if (n.children && n.children.length) walk(n.children);
|
|
else out.push(n);
|
|
}
|
|
};
|
|
walk(nodes || []);
|
|
return out;
|
|
}
|
|
|
|
export default buildLegendNodes;
|