UPG100 uses the library's zone copy; its own copies are gone

UPG100's seven zone files (zone-sheet-print, zone-sheet, legend-tree,
selector-evaluator, zone-geometry, zones-for-list, clusters.json) now live
only in library/packet/upg100Zones/, so check:upg100-zone-copy runs in
its one-path mode: one definition, one owner.

- Every reference is re-pointed by path: the plugin's views and data
  scripts, the library scripts and the gates. The clusters.json writers
  (apply-grouping, emit-list-bounds, the emit scripts) write the library
  copy.
- generate.mjs and packet.mjs take the zone files from the data folder
  when it carries its own (a fork's DATA_DIR), else from the library
  copy; the rest of the data folder is read as before.
- check:selector-fields checks a bundle that uses the library copy
  against it, and names the bundles it checked.
- check:upg100-zone-copy's self-test simulates a copy tree for its copy
  cases, so it holds before and after the switch.

Posters (28 zones x letter, 3x3, 3x4, packet) and packets (full and
lightweight) are byte-identical before and after.
This commit is contained in:
Doxa Maps 2026-10-03 06:29:53 +00:00
parent 789d90eb2f
commit c91c284ce4
46 changed files with 115 additions and 2684 deletions

View file

@ -32,7 +32,7 @@ try {
committed = JSON.parse(readFileSync(join(REPO, 'data/people-groups/christian-share.json'), 'utf8'))
const snap = JSON.parse(readFileSync(join(REPO, 'data/people-groups/doxa-groups-snapshot.json'), 'utf8'))
snapshot = Array.isArray(snap) ? snap : (snap.posts || snap.groups || snap.rows || [])
clusters = JSON.parse(readFileSync(join(REPO, 'plugin-designs/my-upg-100-list/data/clusters.json'), 'utf8'))
clusters = JSON.parse(readFileSync(join(REPO, 'library/packet/upg100Zones/clusters.json'), 'utf8'))
strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
} catch (e) {
console.error(`EVANGELICAL-SHARE: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)

View file

@ -52,14 +52,14 @@ const TABLES = [
{
file: 'plugin-designs/my-upg-100-list/poster-wall.vue',
produces: 'REGION_OF',
mapsFile: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
mapsFile: 'library/packet/upg100Zones/legend-tree.js',
maps: 'REGION_KEY',
via: 'regionLabel',
area: 'poster',
},
{
// the legend's region rows: the same REGION_KEY, through buildLegendNodes' opts.regionTitle
file: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
file: 'library/packet/upg100Zones/legend-tree.js',
produces: 'REGION_OF',
maps: 'REGION_KEY',
via: 'regionTitleOf',

View file

@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const PLUGIN = join(REPO, 'plugin-designs/my-upg-100-list')
const ZONES = join(REPO, 'library/packet/upg100Zones') // UPG100's zone files: one copy, in the library (C12b)
const DOC = join(PLUGIN, 'documentation/PATTERN-A-LIST-OF-ZONES.md')
const fails = []
@ -45,8 +46,8 @@ if (!existsSync(DOC)) {
const doc = readFileSync(DOC, 'utf8')
// ── 1. leaf zones. The doc's headline count, and the one with a history of being wrong. ──────────
const clusters = JSON.parse(readFileSync(join(PLUGIN, 'data/clusters.json'), 'utf8'))
const tree = await import(join(PLUGIN, 'data/legend-tree.js'))
const clusters = JSON.parse(readFileSync(join(ZONES, 'clusters.json'), 'utf8'))
const tree = await import(join(ZONES, 'legend-tree.js'))
// ⚠ buildLegendNodes THROWS on a malformed zone (assertUniformLeafShape guards the consumer shape),
// and an uncaught throw here would exit 1 — the same code as a real FAIL, so "the doc is wrong" and

View file

@ -30,9 +30,9 @@ const REPO = resolve(HERE, '..')
let createPacketRenderer, zonesWithSelectors, DEAF_SELECTOR, strings, clusters, funnelRaw, christian
try {
;({ createPacketRenderer } = await import(join(REPO, 'library/packet/packet-render.js')))
;({ zonesWithSelectors, DEAF_SELECTOR } = await import(join(REPO, 'plugin-designs/my-upg-100-list/data/legend-tree.js')))
;({ zonesWithSelectors, DEAF_SELECTOR } = await import(join(REPO, 'library/packet/upg100Zones/legend-tree.js')))
strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
clusters = JSON.parse(readFileSync(join(REPO, 'plugin-designs/my-upg-100-list/data/clusters.json'), 'utf8'))
clusters = JSON.parse(readFileSync(join(REPO, 'library/packet/upg100Zones/clusters.json'), 'utf8'))
funnelRaw = JSON.parse(readFileSync(join(REPO, 'data/people-groups/vision-funnel.json'), 'utf8'))
christian = JSON.parse(readFileSync(join(REPO, 'data/people-groups/christian-share.json'), 'utf8'))
} catch (e) {

View file

@ -80,12 +80,12 @@ if (!files.length) {
// 1. COVERAGE: every zone the catalogue defines has a packet. The catalogue is the same
// clusters.json the plugin ships; where this repo does not carry it (this gate lives in two
// repos), coverage is skipped rather than guessed.
const CLUSTERS = resolve(REPO, 'plugin-designs/my-upg-100-list/data/clusters.json')
const CLUSTERS = resolve(REPO, 'library/packet/upg100Zones/clusters.json')
if (existsSync(CLUSTERS)) {
const z = JSON.parse(readFileSync(CLUSTERS, 'utf8'))
// ★ THE LEGEND'S 28, NOT THE THREE BUCKETS' 27: spreading lists/asia_lists/uncovered_lists drops
// `deaf`, the one zone the legend shows as a single row, so this never required a Deaf packet.
const { buildLegendNodes, leafZones } = await import(pathToFileURL(resolve(REPO, 'plugin-designs/my-upg-100-list/data/legend-tree.js')).href)
const { buildLegendNodes, leafZones } = await import(pathToFileURL(resolve(REPO, 'library/packet/upg100Zones/legend-tree.js')).href)
const zones = leafZones(buildLegendNodes(z, { records: null })).map((l) => l.id)
const have = new Set(all.map((f) => f.replace(/\.html$/, '')))
// ★ COVERAGE IS ASSERTED WHERE ZONES WERE GENERATED. gates:pre's check:packet-offline writes the four
@ -233,8 +233,9 @@ let totalZones = null
let zoneIds = null
try {
const DATA = resolve(REPO, 'plugin-designs/my-upg-100-list/data')
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
const ZONES = resolve(REPO, 'library/packet/upg100Zones') // UPG100's zone files: one copy, in the library (C12b)
const clusters = JSON.parse(readFileSync(join(ZONES, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(ZONES, 'legend-tree.js')).href)
zoneIds = leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id).sort()
totalZones = zoneIds.length
} catch (e) {

View file

@ -107,7 +107,7 @@ const assetsBase = pathToFileURL(ASSETS).href
const results = []
{ // a UPG-100 zone, through the helper every UPG-100 surface prints with
const { makeZoneSheet } = await import(join(REPO, 'plugin-designs/my-upg-100-list/data/zone-sheet-print.js'))
const { makeZoneSheet } = await import(join(REPO, 'library/packet/upg100Zones/zone-sheet-print.js'))
const zoneSheet = makeZoneSheet({ allRecords: async () => records, assetsBase: () => assetsBase, profileConfig: null, date: DATE })
const id = 'asia-india-gujarat'
// The UPG-100 packet's page 1 is the FULL letter poster (zone-sheet-print answers 'packet' with it), so the

View file

@ -22,7 +22,7 @@ import { readFileSync } from 'node:fs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
const { evaluate } = await import(resolve(ROOT, 'plugin-designs/my-upg-100-list/data/selector-evaluator.js'))
const { evaluate } = await import(resolve(ROOT, 'library/packet/upg100Zones/selector-evaluator.js'))
const VUE = read('plugin-designs/my-upg-100-list/upg-100-list.vue')
const TERR = read('plugin-designs/my-upg-100-list/data/territory-counts.js')
@ -77,7 +77,7 @@ ok(/!lists\.length\s*&&\s*!codes\.length/.test(VUE),
ok(/selectedAdm1Names\s*\(/.test(VUE), 'adm1 selections are no longer distinguished — they will be silently dropped or silently widened')
ok(/no people-group data for/.test(VUE),
'the adm1 refusal no longer names the territory — "nothing selected" is a lie when the reader can see their selection on the map')
const adm1Cases = (read('plugin-designs/my-upg-100-list/data/selector-evaluator.js').match(/case 'adm1/g) || []).length
const adm1Cases = (read('library/packet/upg100Zones/selector-evaluator.js').match(/case 'adm1/g) || []).length
ok(adm1Cases === 0,
'the evaluator now HAS an adm1 case — if provinces can resolve, stop refusing them and wire them in instead')

View file

@ -122,13 +122,20 @@ if (process.argv.includes('--self-test')) {
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DESIGNS = join(ROOT, 'plugin-designs')
let bad = 0, unchecked = 0
const checked = []
for (const bundle of existsSync(DESIGNS) ? readdirSync(DESIGNS) : []) {
const dir = join(DESIGNS, bundle)
if (!statSync(dir).isDirectory()) continue
const evalPath = join(dir, 'data/selector-evaluator.js')
const clustersPath = join(dir, 'data/clusters.json')
if (!existsSync(evalPath) || !existsSync(clustersPath)) continue
// A bundle whose .vue use the library's UPG100 zone copy is checked against that copy (C12b: UPG100 deleted its own),
// so the switch cannot silently drop UPG100 from this check.
const ZONES = join(ROOT, 'library/packet/upg100Zones')
const usesZones = readdirSync(dir).some((f) => f.endsWith('.vue') && readFileSync(join(dir, f), 'utf8').includes('@map/packet/upg100Zones/'))
const pick = (own, lib) => (existsSync(join(dir, own)) ? join(dir, own) : usesZones ? join(ZONES, lib) : null)
const evalPath = pick('data/selector-evaluator.js', 'selector-evaluator.js')
const clustersPath = pick('data/clusters.json', 'clusters.json')
if (!evalPath || !clustersPath || !existsSync(evalPath) || !existsSync(clustersPath)) continue
checked.push(bundle)
const byType = fieldsByType(readFileSync(evalPath, 'utf8'))
const c = JSON.parse(readFileSync(clustersPath, 'utf8'))
@ -160,5 +167,5 @@ if (bad) {
console.log(`SELECTOR-FIELDS: FAIL — ${bad} fetch/selector mismatch(es). A zone whose selector field is absent resolves to NOBODY, or worse, to HALF.`)
process.exit(1)
}
console.log(`SELECTOR-FIELDS: PASS — every selector's fields are requested by every fetch that evaluates them${unchecked ? `, ${unchecked} dynamic list(s) unchecked` : ''}.`)
console.log(`SELECTOR-FIELDS: PASS — every selector's fields are requested by every fetch that evaluates them${unchecked ? `, ${unchecked} dynamic list(s) unchecked` : ''} (bundles checked: ${checked.join(', ') || 'none'}).`)
process.exit(0)

View file

@ -34,7 +34,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import evaluate from '../plugin-designs/my-upg-100-list/data/selector-evaluator.js'
import evaluate from '../library/packet/upg100Zones/selector-evaluator.js'
import { buildZoneTree, zonesForSelection, isRegionId, regionOf, humanize, REGION_PREFIX }
from '../plugin-designs/gospel-internet/src/zoneTree.js'
import { zonesByGroup, zonesBySelector, toSelectorRecord } from '../plugin-designs/gospel-internet/src/upgZones.js'
@ -68,7 +68,7 @@ if (naiveHits >= prov.length) {
}
// ── 2. the selector join, flat records vs the object shape ──────────────────
const clusters = read(D + 'clusters.json')
const clusters = read('library/packet/upg100Zones/clusters.json')
const zoneLists = [...(clusters.lists || []), ...(clusters.deaf_lists || []), ...(clusters.asia_lists || [])]
const nn = read('data/tables/near-neighbors.json')
const ix = Object.fromEntries(nn.cols.map((c, i) => [c, i]))

View file

@ -31,11 +31,12 @@ import { pathToFileURL, fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DATA = join(REPO, 'plugin-designs', 'my-upg-100-list', 'data')
const ZONES = join(REPO, 'library', 'packet', 'upg100Zones') // UPG100's zone files: one copy, in the library (C12b)
const POSTERS = resolve(process.argv[2]
|| join(REPO, 'library', 'scripts', 'posters', 'out', 'posters'))
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
const clusters = JSON.parse(readFileSync(join(ZONES, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(ZONES, 'legend-tree.js')).href)
const leaves = leafZones(buildLegendNodes(clusters, { records: null }))
if (!existsSync(POSTERS)) {

View file

@ -31,9 +31,10 @@ import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const PLUGIN = join(REPO, 'plugin-designs/my-upg-100-list')
const ZONES = join(REPO, 'library/packet/upg100Zones') // UPG100's zone files: one copy, in the library (C12b)
const LOCALES = ['en', 'es', 'ar']
const clusters = JSON.parse(readFileSync(join(PLUGIN, 'data/clusters.json'), 'utf8'))
const clusters = JSON.parse(readFileSync(join(ZONES, 'clusters.json'), 'utf8'))
const zones = Object.values(clusters).filter(Array.isArray).flat()
const { zoneTitle, zoneCountryCodes } = await import(join(PLUGIN, 'data/zone-title.js'))
@ -125,7 +126,7 @@ notes.push(` ${''.padEnd(22)} (= ${multi.length - 1} leaf zones; the 2 de
// own check. Two-sided: with the strings it takes its translated name; with none it declines and keeps
// its label, rather than inventing one.
{
const { DEAF_SELECTOR } = await import(join(PLUGIN, 'data/legend-tree.js'))
const { DEAF_SELECTOR } = await import(join(ZONES, 'legend-tree.js'))
const deafZone = { id: 'deaf', label: 'Deaf (affinity bloc A017)', selector: DEAF_SELECTOR }
const got = {}
for (const lc of LOCALES) {

View file

@ -156,6 +156,7 @@ if (SELF_TEST) {
// ── the run ───────────────────────────────────────────────────────────────────────────────────────────
const DATA = join(REPO, 'plugin-designs', 'my-upg-100-list', 'data')
const ZONES = join(REPO, 'library', 'packet', 'upg100Zones') // UPG100's zone files: one copy, in the library (C12b)
const declPath = join(DATA, LIST === 'upg-100' ? 'zoning-declarations.json' : `zoning-declarations.${LIST}.json`)
if (!existsSync(declPath)) {
console.log(`${TAG}: NOT MEASURED — no declarations at ${declPath}.`)
@ -187,9 +188,9 @@ const rawRecords = JSON.parse(readFileSync(recordsPath, 'utf8'))
// The fixture wraps its rows so it can carry its own provenance; the live cache is a bare array.
const records = Array.isArray(rawRecords) ? rawRecords : (rawRecords.records || [])
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
const { evaluate } = await import(pathToFileURL(join(DATA, 'selector-evaluator.js')).href)
const clusters = JSON.parse(readFileSync(join(ZONES, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(ZONES, 'legend-tree.js')).href)
const { evaluate } = await import(pathToFileURL(join(ZONES, 'selector-evaluator.js')).href)
// Selectors by id, exactly as generate.mjs:2801-2808 and packet.mjs's allZones do it — leaves do not
// carry one, and `deaf`'s is implicit.

View file

@ -1,4 +1,4 @@
// The toy dataDir carries its own evaluator per the contract. Re-exported rather than copied:
// the CONTRACT is "the folder resolves selector-evaluator.js"; the toy proves resolution, and a
// copy here would be a second evaluator that drifts (the exact defect class the standard avoids).
export { evaluate } from '../../../plugin-designs/my-upg-100-list/data/selector-evaluator.js'
export { evaluate } from '../../../library/packet/upg100Zones/selector-evaluator.js'

View file

@ -31,7 +31,7 @@ import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CACHE = join(ROOT, 'library/scripts/posters/out/_records.json')
const CLUSTERS = join(ROOT, 'plugin-designs/my-upg-100-list/data/clusters.json')
const CLUSTERS = join(ROOT, 'library/packet/upg100Zones/clusters.json')
const OUT_DIR = join(ROOT, 'library/scripts/posters/fixtures')
const OUT = join(OUT_DIR, 'records-tibet.json')
// The smallest zone by slug count, so the fixture stays small. Its membership lives in

View file

@ -74,16 +74,21 @@ const SERIES_DEF = SERIES_DEF_ALL[SERIES_ID];
// readJSON is declared below, after the dynamic imports; this one line cannot wait for it.
function readJSON_early(p) { return JSON.parse(readFileSync(p, 'utf8')); }
// ★ UPG100'S ZONE FILES LIVE ONCE, IN THE LIBRARY (C12b, 2026-10-03): zone-sheet.js, zone-geometry.js and clusters.json
// are taken from the data folder when it carries its own (a fork's UPG100_DATA_PATH), else from library/packet/upg100Zones/.
// The rest of the data folder (normalize, territory-counts, the boundaries) is read from DATA as before.
const ZONES = resolve(__dirname, '..', '..', 'packet', 'upg100Zones');
const zoneFile = (f) => (existsSync(join(DATA, f)) ? join(DATA, f) : join(ZONES, f));
// ── the map's own logic, reused read-only ────────────────────────────────────
const { upg100Zones, assignPins, seriesFacts } = await import(join(DATA, 'zone-sheet.js'));
const { resolveZoneGeometry } = await import(join(DATA, 'zone-geometry.js'));
const { upg100Zones, assignPins, seriesFacts } = await import(zoneFile('zone-sheet.js'));
const { resolveZoneGeometry } = await import(zoneFile('zone-geometry.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
// The adm0 geojson carries ONLY { iso3 } — no country names at all — so the label
// text has to come from the map's own ISO3 dictionary rather than the geometry.
const { ISO3_NAME } = await import(join(DATA, 'territory-counts.js'));
const readJSON = (p) => JSON.parse(readFileSync(p, 'utf8'));
const clusters = readJSON(join(DATA, 'clusters.json'));
const clusters = readJSON(zoneFile('clusters.json'));
const adm0 = readJSON(join(DATA, 'adm0', 'world-adm0.geojson'));
const adm1Ind = readJSON(join(DATA, 'gb-IND-ADM1.geojson'));
const adm1Chn = readJSON(join(DATA, 'gb-CHN-ADM1.geojson'));

View file

@ -64,6 +64,11 @@ const SERIES = SERIES_ALL[SERIES_ID];
// selector-evaluator.js generates packets with NO series.json edit. Series flags (funnel,
// strings, countryMode) still come from SERIES_ID, defaulting to upg100's.
const DATA = process.env.DATA_DIR ? resolve(process.env.DATA_DIR) : join(REPO, SERIES.dataDir);
// ★ UPG100'S ZONE FILES LIVE ONCE, IN THE LIBRARY (C12b, 2026-10-03): clusters.json, selector-evaluator.js and legend-tree.js
// come from the data folder when it carries its own (a fork's DATA_DIR, the toy fixture), else from library/packet/upg100Zones/.
// normalize.js, zone-title.js, territory-counts.js and records.json are read from DATA as before.
const ZONES = join(REPO, 'library', 'packet', 'upg100Zones');
const zoneFile = (f) => (existsSync(join(DATA, f)) ? join(DATA, f) : join(ZONES, f));
if (process.env.DATA_DIR && !existsSync(join(DATA, 'clusters.json'))) {
console.error(`[packet] DATA_DIR set but ${join(DATA, 'clusters.json')} does not exist.`);
process.exit(1);
@ -208,7 +213,7 @@ const R = createPacketRenderer({ strings: STRINGS, lang: LANG, fontData, describ
const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H } = R;
// ── DATA ────────────────────────────────────────────────────────────────────
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
const clusters = JSON.parse(readFileSync(zoneFile('clusters.json'), 'utf8'));
// ── THE %-EVANGELICAL PAGE — this packet's OWN groups ───────────────────────
// The page says each box is a share of the people in this list, so each packet sums its own groups
@ -269,7 +274,7 @@ function buildFunnel(membership) {
return { ...raw, listCount, listTotal };
} catch (_) { return null; }
}
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { evaluate } = await import(zoneFile('selector-evaluator.js'));
// normalize.js is OPTIONAL in the fork contract: a fork's records.json is its own shape, already
// flat. Absent -> identity, said OUT LOUD once — a silent fallback would let a TYPO'd normalize.js
// quietly skip normalization on the real series, which is the fail-open trap.
@ -286,7 +291,7 @@ const { normalizeRecords, extractRecords } = await (async () => {
// its enumeration and must not be forced to ship a legend module it never uses.
let _legend = null;
async function legendModules() {
if (!_legend) _legend = await import(join(DATA, 'legend-tree.js'));
if (!_legend) _legend = await import(zoneFile('legend-tree.js'));
return _legend;
}

View file

@ -73,9 +73,9 @@ if (!existsSync(SRC)) {
// unreachable. Enumerating them made this script warn that two zones were
// "MISSING" when nothing was missing at all.
const clusters = JSON.parse(readFileSync(
join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'clusters.json'), 'utf8'));
join(REPO, 'library', 'packet', 'upg100Zones', 'clusters.json'), 'utf8'));
const { buildLegendNodes, leafZones } = await import(
pathToFileURL(join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'legend-tree.js')).href);
pathToFileURL(join(REPO, 'library', 'packet', 'upg100Zones', 'legend-tree.js')).href);
const zoneIds = new Set(leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id));
mkdirSync(DEST, { recursive: true });

View file

@ -51,7 +51,7 @@ const PRE_V2_CLUSTERS_MD5 = 'ae59ecb3e75ed37f32e03b2a686d533d'; // historical (p
// files toolB can touch — snapshot ALL of them so we can prove byte-identity +
// guarantee a clean revert.
const TRACKED = [
'clusters.json', 'india-clusters.json', 'china-clusters.json',
'../../../library/packet/upg100Zones/clusters.json', 'india-clusters.json', 'china-clusters.json',
'uncovered-clusters.json', 'zone-colors.json',
'gb-IND-ADM1.geojson', 'gb-CHN-ADM1.geojson',
];
@ -78,7 +78,7 @@ function restore() {
try {
// ── PART A — NO-OP ROUND-TRIP ───────────────────────────────────────────────
console.log('── PART A: no-op round-trip (the build GUARDRAIL) ──');
ok(md5('clusters.json') === FROZEN_CLUSTERS_MD5,
ok(md5('../../../library/packet/upg100Zones/clusters.json') === FROZEN_CLUSTERS_MD5,
`(A0) baseline clusters.json md5 == ${FROZEN_CLUSTERS_MD5} (frozen)`);
// (re)generate groupings.json from the CURRENT clusters, then no-op apply.
@ -96,7 +96,7 @@ try {
if (!same) { allIdentical = false; console.log(` CHANGED: ${f}`); }
}
ok(allIdentical, '(A2) every tracked file BYTE-IDENTICAL after no-op apply');
ok(md5('clusters.json') === FROZEN_CLUSTERS_MD5,
ok(md5('../../../library/packet/upg100Zones/clusters.json') === FROZEN_CLUSTERS_MD5,
`(A3) clusters.json md5 STILL == ${FROZEN_CLUSTERS_MD5} after no-op`);
// ── PART B — ONE-STATE MOVE ─────────────────────────────────────────────────
@ -153,7 +153,7 @@ try {
`(B7) payload color ${TEST_HEX} written to zone-colors.json`);
// clusters.json actually changed (proves the canonical writer wrote the move).
ok(md5('clusters.json') !== FROZEN_CLUSTERS_MD5,
ok(md5('../../../library/packet/upg100Zones/clusters.json') !== FROZEN_CLUSTERS_MD5,
'(B8) clusters.json md5 CHANGED by the move (canonical writer emitted it)');
// ── REVERT ──────────────────────────────────────────────────────────────────
@ -165,7 +165,7 @@ try {
if (groupingsExisted) writeFileSync(P(GROUPINGS), groupingsSnap);
const resR = runApply({ quiet: true });
ok(resR.ok && !resR.blocked, '(B9) revert whole-config re-apply ran clean');
ok(md5('clusters.json') === FROZEN_CLUSTERS_MD5,
ok(md5('../../../library/packet/upg100Zones/clusters.json') === FROZEN_CLUSTERS_MD5,
`(B10) clusters.json md5 BACK to ${FROZEN_CLUSTERS_MD5} after revert`);
let revertIdentical = true;

View file

@ -67,7 +67,7 @@ const readJSON = (f) => JSON.parse(readFileSync(P(f), 'utf8'));
// runtime — a build-time import path would not reach the bundle. The name stays relative so P()
// still resolves it against this directory; only the prefix changed.
const GROUPINGS = '../../../data/tables/groupings.json';
const CLUSTERS = 'clusters.json';
const CLUSTERS = '../../../library/packet/upg100Zones/clusters.json'; // the one copy, in the library (C12b)
const INDIA = 'india-clusters.json';
const CHINA = 'china-clusters.json';
const UNCOVERED = 'uncovered-clusters.json';

View file

@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLUSTERS = join(__dirname, 'clusters.json');
const CLUSTERS = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const ORIGINAL_CHINA = join(__dirname, 'china-original-pre-09b-4.json');
// (C) swap-back: revert the China asia_lists to their verbatim pre-09b-4 originals

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url)); // .../data
const ROOT = join(HERE, '..', '..', '..'); // map-bundler root
const SHARED_DIR = join(ROOT, 'src', 'config', 'color-strategies');
const CLUSTERS = join(HERE, 'clusters.json');
const CLUSTERS = join(HERE, '../../../library/packet/upg100Zones/clusters.json');
const STORE = join(HERE, 'zone-colors.json');
const SETTER = join(HERE, 'set-zone-color.mjs');
const APP_DIR = join(ROOT, 'app');

View file

@ -33,7 +33,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const MEMBERSHIP = JSON.parse(
readFileSync(join(__dirname, 'admin-membership.json'), 'utf8'),
);
const CLUSTERS_PATH = join(__dirname, 'clusters.json');
const CLUSTERS_PATH = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const OUT_PATH = join(__dirname, 'china-clusters.json');
const DRY = process.argv.includes('--dry');

View file

@ -25,7 +25,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const MEMBERSHIP = JSON.parse(
readFileSync(join(__dirname, 'admin-membership.json'), 'utf8'),
);
const CLUSTERS_PATH = join(__dirname, 'clusters.json');
const CLUSTERS_PATH = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const OUT_PATH = join(__dirname, 'india-clusters.json');
const DRY = process.argv.includes('--dry');

View file

@ -24,11 +24,11 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import evaluate from './selector-evaluator.js'
import evaluate from '../../../library/packet/upg100Zones/selector-evaluator.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const CACHE = '/tmp/upg-live-records-full.json'
const CLUSTERS = join(HERE, 'clusters.json')
const CLUSTERS = join(HERE, '../../../library/packet/upg100Zones/clusters.json')
const API = 'https://pray.doxa.life/api/people-groups/list' +
'?fields=slug,name,country_code,wagf_region,wagf_block,primary_language,rop1,population,latitude,longitude' +

View file

@ -31,7 +31,7 @@ import { dirname, join } from 'node:path';
import { partitionUncovered } from './partition-uncovered.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLUSTERS_PATH = join(__dirname, 'clusters.json');
const CLUSTERS_PATH = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const OUT_PATH = join(__dirname, 'uncovered-clusters.json');
const DRY = process.argv.includes('--dry');

View file

@ -20,13 +20,13 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { evaluate } from './selector-evaluator.js';
import { evaluate } from '../../../library/packet/upg100Zones/selector-evaluator.js';
import { loadRecords } from './normalize.js';
import { acquireRop1Payload } from './recache-rop1.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLUSTERS_PATH = join(__dirname, 'clusters.json');
const CLUSTERS_PATH = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const CACHE_PATH = '/tmp/upg-live-records.json';
const API_URL =
'https://pray.doxa.life/api/people-groups/list' +

View file

@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLUSTERS = join(__dirname, 'clusters.json');
const CLUSTERS = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const CHINA_CLUSTERS = join(__dirname, 'china-clusters.json');
let fail = 0;

View file

@ -1,401 +0,0 @@
// 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)
*/
// ★ THE UNIFIED DEAF LEAF'S SELECTOR, DEFINED ONCE. The legend collapses the two deaf_lists into one
// leaf (affinity bloc A017) whose selector exists nowhere in clusters.json, so every consumer that
// RESOLVES zones had to know it — and each typed its own copy of the literal (the map in
// upg-100-list.vue, generate.mjs, packet.mjs). The browser packet path had no copy at all, which is why
// the Deaf packet could not be printed from the UI: the zone lookup found nothing and returned silently.
export const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' };
// The catalogue key (poster.<key>) that names each REGION_OF grouping in the reader's language. The
// English values stay the grouping identity (ids are derived from them), so the heading is translated
// at render: here for the legend via opts.regionTitle, and by poster-wall.vue for the wall's sections.
// One table for both surfaces, so a region added here cannot be translated on one and English on the other.
export const REGION_KEY = {
'Africa': 'region_africa',
'Middle East': 'region_middle_east',
'Central Asia': 'region_central_asia',
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
'China + Mongolia': 'region_china_mongolia',
'India': 'region_india',
'Other': 'region_other',
'Americas': 'region_americas',
'Europe': 'region_europe',
'Deaf': 'region_deaf',
};
export function buildLegendNodes(clusters, opts = {}) {
const deafCount = opts.deafCount ?? 123;
const records = Array.isArray(opts.records) ? opts.records : null;
// ★ OPTIONAL GENERATED TITLE (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;
// `opts.regionTitle(english)` names a region section in the reader's language (see REGION_KEY); the
// node id stays derived from the English grouping key, so selection and ids never depend on a locale.
const regionTitleOf = typeof opts.regionTitle === 'function'
? (english) => opts.regionTitle(english) || english
: (english) => english;
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. Module-level and exported — see DEAF_SELECTOR below.
// ── 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',
// Named like every other leaf: opts.title reads the bloc's name from the strings it was given
// (zone-title.js, zone_affinity_A017); the English label is only the no-title fallback.
label: titleOf({ id: 'deaf', selector: DEAF_SELECTOR }, '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);
// (The India / China / "Asia (admin-1)" and "Frontier Asia (uncovered)" PARENT nodes that used to be
// built here are gone: since the regional grouping below, the tree is assembled from the LEAVES, so those
// parents were constructed and never returned — dead code whose English labels read as reader text.)
// ── 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);
// ── 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)". That grouping read poorly beside the poster page, so the legend
// now uses the poster page's own 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 are indiaChildren / chinaChildren; 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 = {
// ★ FOLD DIACRITICS BEFORE STRIPPING, or the mark is DELETED and the id silently becomes a
// different id: Gujarāt -> 'region-gujar-t', not 'region-gujarat'. Today's labels are the
// fixed ASCII set from regionFor(), so nothing is broken — but the whole point is that
// a fork brings its OWN people-group set and names its own regions, and the first accented
// label would split one region into two with no error and no empty state. Same class as a measured
// join of admin-to-list.json: a naive fold matched 514 of 1009 rows and lost THREE
// whole zones, while every surviving zone still looked populated. Fix the class, not the six
// spellings we happen to know.
id: 'region-' + label.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
label: regionTitleOf(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({ id: z.id || '(no-id)', errs });
}
if (bad.length) {
// A developer assertion: the text is composed here, inside the throw, so it is plainly not reader copy.
throw new Error(
`legend-tree L15: ${bad.length} leaf node(s) violate the uniform consumer shape:\n ` +
bad.map((b) => `${b.id}: missing/!type [${b.errs.join(', ')}]`).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;
}
/**
* Every printable zone — the legend's leaves — each carrying the selector that resolves its groups.
*
* ★ THE ENUMERATION IS THE LEGEND'S, NOT clusters.json's BUCKETS. Spreading lists/asia_lists/
* uncovered_lists gives 27 zones and silently drops `deaf`, which the legend shows as one row; a
* packet lookup built that way cannot print Deaf, and a count built that way prints "one of 27" on
* page 1 and at the triangle's tip while 28 packets exist. Leaves do not carry selectors, so they are
* attached here by id, the unified deaf leaf taking DEAF_SELECTOR.
* Returns COPIES; the legend's own node objects are left untouched.
*
* @param {object} clusters the zone catalogue (clusters.json shape)
* @param {object} [opts] passed through to buildLegendNodes
* @returns {Array<{id:string, zoneKey:string, label:string, selector:object}>}
*/
export function zonesWithSelectors(clusters, opts = {}) {
const byId = {};
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
for (const z of (clusters && clusters[k]) || []) byId[z.id] = z.selector;
}
byId.deaf = DEAF_SELECTOR;
return leafZones(buildLegendNodes(clusters, opts))
.map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] }));
}
export default buildLegendNodes;

View file

@ -39,8 +39,8 @@ import { dirname, join } from 'node:path';
import { loadRecords } from './normalize.js';
import { acquireRop1Payload } from './recache-rop1.mjs';
import { buildLegendNodes, leafZones } from './legend-tree.js';
import { resolveAllZones } from './zone-geometry.js';
import { buildLegendNodes, leafZones } from '../../../library/packet/upg100Zones/legend-tree.js';
import { resolveAllZones } from '../../../library/packet/upg100Zones/zone-geometry.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const readJSON = (p) => JSON.parse(readFileSync(p, 'utf8'));
@ -51,7 +51,7 @@ const OCEANIA_18 = ['ASM','COK','FJI','FSM','GUM','IDN','KIR','MHL','MNP','NCL',
function loadDeps() {
const adm0Path = join(__dirname, 'adm0', 'world-adm0.geojson');
return {
clusters: readJSON(join(__dirname, 'clusters.json')),
clusters: readJSON(join(__dirname, '../../../library/packet/upg100Zones/clusters.json')),
adm0: existsSync(adm0Path) ? readJSON(adm0Path) : { type: 'FeatureCollection', features: [] },
adm1Ind: readJSON(join(__dirname, 'gb-IND-ADM1.geojson')),
adm1Chn: readJSON(join(__dirname, 'gb-CHN-ADM1.geojson')),

View file

@ -109,7 +109,7 @@ const PALETTE = {
// of the other (clusters treated as adjacent if their members border). Computed
// live below so it always matches the frozen partition.
function asiaClusterAdjacency() {
const clusters = JSON.parse(readFileSync(join(HERE, 'clusters.json'), 'utf8'));
const clusters = JSON.parse(readFileSync(join(HERE, '../../../library/packet/upg100Zones/clusters.json'), 'utf8'));
const membership = JSON.parse(readFileSync(join(HERE, 'admin-membership.json'), 'utf8'));
const adj = JSON.parse(readFileSync(join(HERE, 'adjacency.json'), 'utf8'));
const adminsFor = (slugs) => {

View file

@ -75,7 +75,7 @@ function recomputeUncovered() {
const cachePath = existsSync(ROP1_CACHE) ? ROP1_CACHE : existsSync(PLAIN_CACHE) ? PLAIN_CACHE : null;
if (!cachePath) return null;
const posts = JSON.parse(readFileSync(cachePath, 'utf8')).posts || [];
const clusters = JSON.parse(readFileSync(join(__dirname, 'clusters.json'), 'utf8'));
const clusters = JSON.parse(readFileSync(join(__dirname, '../../../library/packet/upg100Zones/clusters.json'), 'utf8'));
// covered = regional lists + deaf (A017) + every asia_lists pg_slug.
const covered = new Set();

View file

@ -16,10 +16,10 @@
// Data source = the FULL cache (rop1 + population + lat/lng). The build emit +
// sibling-isolation checks live in the bash wrapper (proof.sh).
import { evaluate } from './selector-evaluator.js';
import { evaluate } from '../../../library/packet/upg100Zones/selector-evaluator.js';
import { loadRecords } from './normalize.js';
import { acquireFullPayload } from './recache-full.mjs';
import { buildLegendNodes, leafZones } from './legend-tree.js';
import { buildLegendNodes, leafZones } from '../../../library/packet/upg100Zones/legend-tree.js';
const EXPECT_PINS = 2106; // live UUPG count (records with finite lat/lng)
const REGRESS = process.env.REGRESS === '1'; // can-fail switch: disable the fix
@ -72,7 +72,7 @@ async function main() {
// Build the 26-leaf nested legend + attach each leaf's selector (same as the .vue).
const clusters = JSON.parse(
(await import('node:fs')).readFileSync(new URL('./clusters.json', import.meta.url), 'utf8'),
(await import('node:fs')).readFileSync(new URL('../../../library/packet/upg100Zones/clusters.json', import.meta.url), 'utf8'),
);
const nodes = buildLegendNodes(clusters, { deafCount: 123 });
const leafZoneList = leafZones(nodes);

View file

@ -26,9 +26,9 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { loadRecords } from './normalize.js';
import { evaluate } from './selector-evaluator.js';
import { evaluate } from '../../../library/packet/upg100Zones/selector-evaluator.js';
import { acquireFullPayload } from './recache-full.mjs';
import { buildLegendNodes } from './legend-tree.js';
import { buildLegendNodes } from '../../../library/packet/upg100Zones/legend-tree.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const readJSON = (p) => JSON.parse(readFileSync(p, 'utf8'));
@ -73,7 +73,7 @@ async function main() {
}
console.log(`[pop-proof] records from ${source}`);
const records = loadRecords(payload);
const clusters = readJSON(join(__dirname, 'clusters.json'));
const clusters = readJSON(join(__dirname, '../../../library/packet/upg100Zones/clusters.json'));
// Build the tree WITH records → every node should carry pop.
const nodes = buildLegendNodes(clusters, { deafCount: 123, records });

View file

@ -1,165 +0,0 @@
// selector-evaluator.js — slice 01 (my-upg-100-list)
//
// Pure function: evaluate(selector, records) -> Set<slug>
//
// `records` are assumed ALREADY NORMALIZED by normalize.js, i.e. the nested
// { value, label } fields (country_code, wagf_region, wagf_block, primary_language)
// have been flattened to flat `.value` strings. The evaluator therefore compares
// flat record fields against flat selector values — no .value access here.
//
// Handles the 6 selector types documented in data/clusters.source.md + the qa file:
// 1. { type: "country_code", value: "SDN" }
// -> records whose country_code === value
// 2. { type: "country_codes", values: [...] } (note plural `values`)
// -> records whose country_code ∈ values
// 3. { type: "wagf_block", value: "west_africa" }
// -> records whose wagf_block === value
// 4. { type: "wagf_region", value: "europe" }
// -> records whose wagf_region === value
// 5. { type: "union", selectors: [ <sel>, <sel>, ... ] }
// -> union of evaluating each sub-selector
// 6. { type: "deaf_predicate", regions: [...] }
// -> records where (slug OR name contains "deaf", case-insensitive)
// AND wagf_region ∈ regions
// 7. { type: "affinity_bloc", rop1: "A017" } (slice 04t §1g)
// -> records where r.rop1.value === rop1
// NOTE: rop1 is NOT in normalize.js NESTED_FIELDS, so it stays a nested
// { value, label } dict on the record. This selector reads `.value`
// directly (and tolerates an already-flat rop1 string). The Deaf row's
// bloc code is "A017" (123 PGs, == the old deaf_predicate slug set, zero
// delta but field-authoritative). 📁 affinity-block.js:36, sources.json:68
//
// Design contract:
// - returns a Set<slug> (always; never null/undefined)
// - a bogus VALUE (e.g. country_code "ZZZ") -> empty Set, never a crash
// - an UNKNOWN selector type -> throw a clear Error (never silently empty)
// Collect the slug of a record. Records without a slug are skipped (cannot key a Set entry).
function slugOf(record) {
if (record == null || typeof record !== 'object') return null;
return record.slug ?? null;
}
// True if the haystack string contains "deaf" (case-insensitive).
function containsDeaf(s) {
return typeof s === 'string' && s.toLowerCase().includes('deaf');
}
// Read the affinity-bloc code off a record's `rop1` field. rop1 is intentionally
// NOT flattened by normalize.js, so it normally arrives as { value, label }; this
// also tolerates an already-flat string (defensive).
function rop1Code(record) {
const f = record == null ? null : record.rop1;
if (f == null) return null;
if (typeof f === 'object') return f.value ?? null;
return f; // already-flat string
}
/**
* evaluate(selector, records) -> Set<slug>
* @param {object} selector one of the 6 documented selector shapes
* @param {Array<object>} records ALREADY-normalized UPG records
* @returns {Set<string>} set of matching slugs
* @throws {Error} on an unknown / malformed selector type
*/
export function evaluate(selector, records) {
const out = new Set();
if (selector == null || typeof selector !== 'object' || typeof selector.type !== 'string') {
throw new Error(
`selector-evaluator: malformed selector (expected an object with a string \`type\`), got: ${JSON.stringify(selector)}`,
);
}
const rows = Array.isArray(records) ? records : [];
switch (selector.type) {
case 'country_code': {
const want = selector.value;
for (const r of rows) {
if (r && r.country_code === want) {
const slug = slugOf(r);
if (slug != null) out.add(slug);
}
}
return out;
}
case 'country_codes': {
// plural: match against the `values` array
const wanted = new Set(Array.isArray(selector.values) ? selector.values : []);
for (const r of rows) {
if (r && wanted.has(r.country_code)) {
const slug = slugOf(r);
if (slug != null) out.add(slug);
}
}
return out;
}
case 'wagf_block': {
const want = selector.value;
for (const r of rows) {
if (r && r.wagf_block === want) {
const slug = slugOf(r);
if (slug != null) out.add(slug);
}
}
return out;
}
case 'wagf_region': {
const want = selector.value;
for (const r of rows) {
if (r && r.wagf_region === want) {
const slug = slugOf(r);
if (slug != null) out.add(slug);
}
}
return out;
}
case 'union': {
const subs = Array.isArray(selector.selectors) ? selector.selectors : [];
for (const sub of subs) {
// recurse; sub-selector errors (unknown type) propagate up by design
const subSet = evaluate(sub, rows);
for (const slug of subSet) out.add(slug);
}
return out;
}
case 'deaf_predicate': {
const regions = new Set(Array.isArray(selector.regions) ? selector.regions : []);
for (const r of rows) {
if (!r) continue;
const isDeaf = containsDeaf(r.slug) || containsDeaf(r.name);
if (isDeaf && regions.has(r.wagf_region)) {
const slug = slugOf(r);
if (slug != null) out.add(slug);
}
}
return out;
}
case 'affinity_bloc': {
// slice 04t §1g — Deaf row: records whose rop1.value === selector.rop1 (e.g. "A017").
const want = selector.rop1;
for (const r of rows) {
if (!r) continue;
if (rop1Code(r) === want) {
const slug = slugOf(r);
if (slug != null) out.add(slug);
}
}
return out;
}
default:
throw new Error(
`selector-evaluator: unknown selector type "${selector.type}" ` +
`(supported: country_code, country_codes, wagf_block, wagf_region, union, deaf_predicate, affinity_bloc)`,
);
}
}
export default evaluate;

View file

@ -15,11 +15,11 @@ import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { evaluate } from './selector-evaluator.js';
import { evaluate } from '../../../library/packet/upg100Zones/selector-evaluator.js';
import { loadRecords } from './normalize.js';
import { acquireRop1Payload } from './recache-rop1.mjs';
import { buildLegendNodes, leafZones } from './legend-tree.js';
import { resolveZoneGeometry, resolveAllZones, memberCountryCodes } from './zone-geometry.js';
import { buildLegendNodes, leafZones } from '../../../library/packet/upg100Zones/legend-tree.js';
import { resolveZoneGeometry, resolveAllZones, memberCountryCodes } from '../../../library/packet/upg100Zones/zone-geometry.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const readJSON = (p) => JSON.parse(readFileSync(p, 'utf8'));
@ -40,7 +40,7 @@ const KNOWN_ADM0_GAP = new Set([
function loadDeps() {
const adm0Path = join(__dirname, 'adm0', 'world-adm0.geojson');
return {
clusters: readJSON(join(__dirname, 'clusters.json')),
clusters: readJSON(join(__dirname, '../../../library/packet/upg100Zones/clusters.json')),
adm0: existsSync(adm0Path) ? readJSON(adm0Path) : { type: 'FeatureCollection', features: [] },
adm1Ind: readJSON(join(__dirname, 'gb-IND-ADM1.geojson')),
adm1Chn: readJSON(join(__dirname, 'gb-CHN-ADM1.geojson')),

View file

@ -43,11 +43,11 @@ import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { evaluate } from './selector-evaluator.js';
import { evaluate } from '../../../library/packet/upg100Zones/selector-evaluator.js';
import { loadRecords } from './normalize.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLUSTERS = join(__dirname, 'clusters.json');
const CLUSTERS = join(__dirname, '../../../library/packet/upg100Zones/clusters.json');
const ROP1_CACHE = '/tmp/upg-live-records-rop1.json';
const PLAIN_CACHE = '/tmp/upg-live-records.json';
// Swap-back baseline: dropping `uncovered_lists` and re-serialising must reproduce

View file

@ -1215,7 +1215,7 @@ async function main() {
// hard-coded 'Rajasthan'/'Gujarat', which never matched any row (marked=[]).
// Derive the EXACT labels from the SAME data file the profile renders, so the
// check survives future regrouping of the India clusters.
const clustersData = JSON.parse(readFileSync(join(__dirname, 'clusters.json'), 'utf8'));
const clustersData = JSON.parse(readFileSync(join(__dirname, '../../../library/packet/upg100Zones/clusters.json'), 'utf8'));
const zoneLabel = (id) => {
const z = (clustersData.asia_lists || []).find((a) => a.id === id);
return z ? z.label : null;

View file

@ -1,138 +0,0 @@
// zone-geometry.js — slice 04t §1(b)
//
// resolveZoneGeometry(zone, deps) -> FeatureCollection
//
// Per legend row ("zone"), returns the polygons it needs, each feature stamped
// with the row's `zoneKey` + `zoneColor` so the tapestry's single Mapbox
// ['match', ['get','zoneKey'], …] fill expression can paint all 26 at once.
//
// Resolution rules (S04t-D1/D2/D3):
// - REGIONAL (13) + DEAF (1): ADM0 country polygons.
// member countries = evaluate(zone.selector, records) -> slugs
// -> distinct country_code over those member records
// -> ADM0 features whose props.iso3 ∈ that set.
// (deaf's selector is affinity_bloc/A017 → its 123 members' country_codes.)
// - ASIA (12): ADM1 features. Asia rows are state-CLUSTERS, so SELECT-MANY:
// every ADM1 feature whose props.listId === zone.id (the on-disk
// gb-IND-ADM1 / gb-CHN-ADM1 files are pre-tagged with listId).
//
// Design contract:
// - always returns a FeatureCollection (never null); empty features ⇒ a zone
// the proof will FLAG (every zone must resolve ≥1 polygon).
// - separate polygons sharing one zone color (S04t-D3); no dissolve here.
// - pure: no Mapbox / DOM; testable in plain Node.
import { evaluate } from './selector-evaluator.js';
const EMPTY_FC = () => ({ type: 'FeatureCollection', features: [] });
// Distinct country_code (ISO3) set for a zone's member UPGs.
// `records` are normalized (country_code flat); rop1 stays nested (selector reads it).
export function memberCountryCodes(zone, records) {
const slugs = evaluate(zone.selector, records);
const iso = new Set();
for (const r of records) {
if (r && slugs.has(r.slug) && r.country_code) iso.add(r.country_code);
}
return iso;
}
// True for the 12 Asia rows (admin-1 resolution).
export function isAsiaZone(zone) {
return typeof zone.id === 'string' && zone.id.startsWith('asia-');
}
function stamp(feature, zone) {
return {
type: 'Feature',
properties: {
...(feature.properties || {}),
zoneKey: zone.zoneKey ?? zone.id,
zoneColor: zone.color ?? zone.zoneColor ?? '#888',
zoneLabel: zone.label,
...(zone.id === 'deaf' ? { hatch: true } : {}),
},
geometry: feature.geometry,
};
}
/**
* resolveZoneGeometry(zone, deps) -> FeatureCollection
* @param {object} zone a leaf zone { id, zoneKey, label, color, selector? }
* @param {object} deps
* @param {Array} deps.records normalized UPG records (rop1-bearing for deaf)
* @param {object} deps.adm0 FeatureCollection of ADM0 (features props.iso3)
* @param {object} deps.adm1Ind FeatureCollection gb-IND-ADM1 (props.listId)
* @param {object} deps.adm1Chn FeatureCollection gb-CHN-ADM1 (props.listId)
*/
export function resolveZoneGeometry(zone, deps) {
const { records = [], adm0, adm1Ind, adm1Chn } = deps || {};
const fc = EMPTY_FC();
if (isAsiaZone(zone)) {
// ADM1 select-many: every feature pre-tagged with this row's listId.
const pools = [adm1Ind, adm1Chn].filter(Boolean);
for (const pool of pools) {
for (const f of pool.features || []) {
if (f.properties && f.properties.listId === zone.id) fc.features.push(stamp(f, zone));
}
}
// slice 09b-8 §1(c): the MIXED-GEOMETRY hook. An Asia zone may ALSO carry an
// optional `adm0` array of ISO3 codes (the Nepal-frontier cluster: ['NPL']).
// When present, union the matching ADM0 country polygons into the SAME zone so
// a single legend row paints both its India states (ADM1) and Nepal (ADM0) as
// one zone. Additive + opt-in: every other Asia zone lacks `adm0`, so this loop
// is skipped and their resolution is byte-identical to 09b-7.
if (Array.isArray(zone.adm0) && zone.adm0.length && adm0) {
const want = new Set(zone.adm0);
for (const f of adm0.features || []) {
if (f.properties && want.has(f.properties.iso3)) fc.features.push(stamp(f, zone));
}
}
return fc;
}
// Regional / deaf: ADM0 by member country_codes.
if (!zone.selector) return fc; // a parent (asia/india/china) — no geometry of its own
const iso = memberCountryCodes(zone, records);
if (adm0 && iso.size) {
for (const f of adm0.features || []) {
if (f.properties && iso.has(f.properties.iso3)) fc.features.push(stamp(f, zone));
}
}
return fc;
}
/**
* resolveAllZones(zones, deps) -> { tapestry: FeatureCollection, perZone: Map }
* Builds ONE FeatureCollection (all zones, each feature carrying its zoneKey) for
* the single-source tapestry paint, plus a per-zone resolved-count map for the proof.
*/
export function resolveAllZones(zones, deps) {
const tapestry = EMPTY_FC();
const perZone = new Map();
// deaf-polygon removal (design fix, an earlier fix): the `deaf` affinity overlay is an
// ADM0 double-stamp — its A017 selector re-paints land already owned by the 13 geographic
// zones (16 of oceania-indonesia's 18 Pacific ISO3 alone). That double-stamp showed deaf
// TWICE and underlapped every other region in blue (#18ffff), and the overlapping deaf land
// didn't color correctly until click-isolate re-filtered the source to one zone. The earlier
// 09b draw-order mitigation (resolve deaf FIRST) only re-stacked the same overlapping geometry;
// the directive is to REMOVE the deaf polygon/region layer entirely. So we EXCLUDE
// the deaf zone from the tapestry (region-fill) FeatureCollection here. deaf stays fully in
// the LIST/legend (built by legend-tree.js buildLegendNodes — untouched) and in the PINS
// (built from slugToZone selectors in upg-100-list.vue — untouched). Only the polygon/region
// fill loses deaf, which removes the overlap and the until-click coloring bug.
for (const zone of zones) {
if (zone.id === 'deaf') {
// record 0 polygons for the proof's per-zone map; deaf intentionally has no region fill now.
perZone.set(zone.zoneKey ?? zone.id, 0);
continue;
}
const fc = resolveZoneGeometry(zone, deps);
perZone.set(zone.zoneKey ?? zone.id, fc.features.length);
for (const f of fc.features) tapestry.features.push(f);
}
return { tapestry, perZone };
}
export default resolveZoneGeometry;

View file

@ -37,8 +37,8 @@ import { dirname, join } from 'node:path';
import { loadRecords } from './normalize.js';
import { acquireRop1Payload } from './recache-rop1.mjs';
import { buildLegendNodes, leafZones } from './legend-tree.js';
import { resolveAllZones } from './zone-geometry.js';
import { buildLegendNodes, leafZones } from '../../../library/packet/upg100Zones/legend-tree.js';
import { resolveAllZones } from '../../../library/packet/upg100Zones/zone-geometry.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const readJSON = (p) => JSON.parse(readFileSync(p, 'utf8'));
@ -50,7 +50,7 @@ const EXEMPT = new Set(['deaf', 'uncovered-scatter']);
function loadDeps() {
const adm0Path = join(__dirname, 'adm0', 'world-adm0.geojson');
return {
clusters: readJSON(join(__dirname, 'clusters.json')),
clusters: readJSON(join(__dirname, '../../../library/packet/upg100Zones/clusters.json')),
adm0: existsSync(adm0Path) ? readJSON(adm0Path) : { type: 'FeatureCollection', features: [] },
adm1Ind: readJSON(join(__dirname, 'gb-IND-ADM1.geojson')),
adm1Chn: readJSON(join(__dirname, 'gb-CHN-ADM1.geojson')),

View file

@ -1,41 +0,0 @@
/**
* zone-sheet-print.js — a UPG-100 zone's map sheet, GENERATED in the browser at the click (the print
* standard: generate, store only previews). One helper for every surface that prints a zone (the map's
* legend and packets, the poster wall), so they cannot draw the "same" sheet two ways.
*
* The zone and its pins come from zone-sheet.js — the SAME assignment generate.mjs makes — the geometry
* from the full-resolution boundaries generate.mjs draws, and the drawing from the library's generator.
*
* const zoneSheet = makeZoneSheet({ allRecords, assetsBase: () => base, profileConfig })
* usePosterPrints({ …, sheet: zoneSheet }) // (item, format) -> html
*
* Library imports are RELATIVE, not `@map/…`: the same file runs under the bundler, Node and the
* browser-equivalence check (bun), and only a relative path resolves identically in all three.
*/
import { upg100Zones, assignPins, seriesFacts } from './zone-sheet.js'
import { resolveZoneGeometry } from './zone-geometry.js'
import { zonesForList } from './zones-for-list.js'
import { resolvePeopleGroupList } from '../../../library/lists/peopleGroupList.js'
// `date` pins the stamp printed on the sheet (default: today) — how a check reproduces a sheet exactly.
export function makeZoneSheet({ allRecords, assetsBase, profileConfig, date = null }) {
return async function zoneSheet(item, format) {
const { generateSheet, loadBoundaries } = await import('../../../library/packet/posterSheets.js')
const [{ ISO3_NAME }, records, bounds] = await Promise.all([
import('../../../library/data/iso3-name.js'), allRecords(), loadBoundaries(assetsBase()),
])
const zones = upg100Zones(zonesForList(resolvePeopleGroupList(profileConfig)), records)
const zone = zones.find((z) => z.id === (item && item.id))
if (!zone) return '' // a selection or an unknown id has no zone sheet; the caller falls through
const pins = assignPins(zones, records).get(zone.zoneKey) || []
const zoneFC = resolveZoneGeometry(zone, { records, ...bounds })
// ★ THE PACKET'S PAGE 1 IS THE FULL UPG-100 POSTER (2026-10-02), not the map-only sheet: the letter-size
// poster with its title, the scripture banner, the three-column header and the footer. The packet asks
// for 'packet'; this series answers with the full letter sheet, which is the same 1500x1941 page.
const page1 = format === 'packet'
return generateSheet({
zone, pins, zoneFC, format: page1 ? 'letter' : format, ...(page1 ? { sparse: false } : {}), seriesId: 'upg100', ISO3_NAME, adm0: bounds.adm0, adm1Ind: bounds.adm1Ind, adm1Chn: bounds.adm1Chn,
series: { ...seriesFacts(zones), index: zones.indexOf(zone) + 1 }, date, assetsBase: assetsBase(),
})
}
}

View file

@ -1,58 +0,0 @@
/**
* zone-sheet.js — which people groups a map sheet pins, decided ONCE for the Node generator and the
* browser alike.
*
* generate.mjs used to hold this in its main routine: attach each leaf zone's selector, then give every
* people group to the FIRST zone that claims it and pin it there. A browser that draws the same sheet
* must make exactly the same assignment, or the "same" poster pins a group in a different zone
* depending on where it was printed. So both import this module; neither keeps its own copy.
*
* Pure: no fs, no fetch, no DOM. The caller supplies the records (with latitude/longitude).
*/
import { buildLegendNodes, leafZones, DEAF_SELECTOR } from './legend-tree.js';
import { evaluate } from './selector-evaluator.js';
/** The UPG-100 leaf zones, each carrying its selector (leaves do not carry it; the catalogue does). */
export function upg100Zones(clusters, records) {
const zones = leafZones(buildLegendNodes(clusters, { records }));
const byId = {};
for (const l of clusters.lists || []) byId[l.id] = l.selector;
for (const a of clusters.asia_lists || []) byId[a.id] = a.selector;
for (const u of clusters.uncovered_lists || []) byId[u.id] = u.selector;
byId['deaf'] = DEAF_SELECTOR; // defined once in legend-tree.js
for (const z of zones) z.selector = byId[z.id];
return zones;
}
/**
* zoneKey -> the pins of that zone. A group belongs to the FIRST zone (in `zones` order) whose selector
* claims it; groups without coordinates are not pinned. Pins keep the records' own order.
* `onSkip(zone, error)` hears about a selector that could not be evaluated (the zone then pins nothing).
*/
export function assignPins(zones, records, { onSkip = null } = {}) {
const slugToZone = {};
for (const z of zones) {
if (z.selector && z.selector.type === 'pg_slugs') {
for (const s of (z.selector.values || [])) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey;
} else if (z.selector) {
try { for (const s of evaluate(z.selector, records)) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey; }
catch (e) { if (onSkip) onSkip(z, e); }
}
}
const pinsByZone = new Map(zones.map((z) => [z.zoneKey, []]));
for (const r of records) {
const lat = Number(r.latitude), lng = Number(r.longitude);
if (!isFinite(lat) || !isFinite(lng)) continue;
const zk = slugToZone[r.slug];
if (zk && pinsByZone.has(zk)) pinsByZone.get(zk).push({
name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code,
population: r.population,
});
}
return pinsByZone;
}
/** "Map N of M" and the total the poster states, from the zones being generated. */
export function seriesFacts(zones) {
return { totalGroups: zones.reduce((a, z) => a + (z.count || 0), 0), totalLists: zones.length, index: 1 };
}

View file

@ -1,104 +0,0 @@
/**
* zones-for-list — which ZONES a project list is clustered into.
*
* THE GAP THIS CLOSES. Membership is already parameterised by list: library/lists/lists.json registers
* five (doxa = Project 2000, unreached = 7000, frontier = 4700, frontier-500k = 700, unengaged), and
* resolvePeopleGroupList(profileConfig) already picks one from `config.list` / `?list=` / window.MAP_LIST
* with a default. What NO list has is ZONES: a list file says WHICH people groups, and nothing says how
* to cluster them into the ~100-group printable zones a poster needs. clusters.json is UPG-100's, and it
* was the only answer available.
*
* ★ WHY THIS IS A RESOLVER AND NOT A DYNAMIC IMPORT. The two consumers do:
* import { lists, asia_lists, uncovered_lists } from './data/clusters.json'
* and that NAMED import is load-bearing — its own comment says so: only the zone arrays ship, while
* clusters.json's eight bookkeeping fields (version, generated, source_total, validation_run, …) are
* tree-shaken out. Swapping it for `await import(path)` would ship the whole file in every bundle, and
* would make the zone list asynchronous in code that reads it synchronously at setup. So the default
* stays a static named import, and alternates are REGISTERED rather than computed from a path.
*
* ⚠ AND THE DEFAULT MUST BE THE OLD BEHAVIOUR EXACTLY. A list with no zones of its own gets UPG-100's,
* which is what every caller gets today — so this file is inert until someone registers a second set.
* That is deliberate: the pattern has to be landable before any second list exists, or it cannot be the
* thing the second list is built against.
*
* ★ WHAT A NEW LIST ACTUALLY SHIPS, and this is the pattern for another agent to follow:
* 1. data/zones/<list-id>.json the four zone-bearing keys, same shape as clusters.json:
* lists / deaf_lists / asia_lists / uncovered_lists
* 2. data/zoning-declarations.<id>.json the facts a gate cannot infer — bloc zones, expected orphans,
* known geographic overlaps, page budget
* 3. one line in ZONES_BY_LIST below
* Then `npm run check:zoning-declared -- --list <id>` grades the zoning, and the legend, poster wall and
* packet all follow, because all three already enumerate through leafZones(buildLegendNodes(zones)).
*
* ZONING RULE (2026-09-27), recorded here because it is the judgement the format cannot
* capture: cluster by GEOGRAPHY FIRST so the poster maps make sense, balanced with LANGUAGE — languages
* usually cluster geographically, so the two mostly agree, and where they disagree geography wins for a
* map's sake. UPG-100's own 28 already mix the two: the country_codes zones are geographic, and the 12
* India/China pg_slugs zones are hand groupings where language and geography were traded off.
*
* ⚠ THE DEAF ZONE IS THE KNOWN EXCEPTION AND MUST NOT BE FORCED. `deaf` is affinity bloc A017: 123 groups
* spanning every region, global by construction, and the one poster whose zone cannot be a map extent. It
* breaks the geography-first rule the rest of the pattern rests on, and it is recorded as not yet
* solved. A list whose clusters include an affinity or language bloc inherits the same open question —
* declare it in bloc_zones so the gate treats it as a second partition, and do NOT invent a geography for
* it to make a validator happy.
*
* ⚠ THIS MODULE IS BROWSER/VITE-ONLY, BY CONSTRUCTION. The static JSON import below is what keeps the
* tree-shaking (see above), and bare `import … from './x.json'` is a bundler feature: plain Node throws
* ERR_IMPORT_ASSERTION_TYPE_MISSING without an `with { type: 'json' }` attribute. That is fine and is the
* existing convention here — NO Node-side file imports clusters.json as a module; packet.mjs and every
* gate read it with readFileSync + JSON.parse (packet.mjs:125). So a Node consumer that wants the same
* resolution should read the JSON itself and pass the object on, rather than importing this file.
*/
import { lists, deaf_lists, asia_lists, uncovered_lists } from './clusters.json'
/** UPG-100's zones — the default for every list until one registers its own. */
const UPG_100_ZONES = { lists, deaf_lists, asia_lists, uncovered_lists }
/**
* list id → its zone set.
* ★ A REGISTRY, NOT A DIRECTORY SCAN: each entry is a static import somewhere, so the bundler can see
* which zone files a bundle actually reaches and drop the rest. A scan would defeat that and would also
* make a missing file a runtime error instead of a build one.
*/
const ZONES_BY_LIST = {
// 'project-3000': PROJECT_3000_ZONES, ← the shape a second list adds
}
/**
* The zones for a list id.
*
* @param {string} [listId] as returned by resolvePeopleGroupList(profileConfig)
* @returns {{lists:Array, deaf_lists?:Array, asia_lists:Array, uncovered_lists:Array}}
*
* ⚠ FALLS BACK RATHER THAN THROWING, and says so once. A list without registered zones is the NORMAL
* state right now (five lists, one zone set), so throwing would break every caller for a condition that
* is expected. But it is silent-wrong-data territory — a reader would see UPG-100's zones under another
* list's name and have no way to tell — so it warns exactly once per id, naming what it did.
*/
const warned = new Set()
export function zonesForList(listId) {
const id = String(listId || '')
const own = ZONES_BY_LIST[id]
if (own) return own
if (id && !warned.has(id) && typeof console !== 'undefined') {
warned.add(id)
console.warn(`[zones] list "${id}" has no zones of its own; showing UPG-100's ${countZones(UPG_100_ZONES)} `
+ 'zones. Register a set in data/zones-for-list.js to give it its own poster clusters.')
}
return UPG_100_ZONES
}
/** Zone count across the zone-bearing keys. NOT a leaf count — buildLegendNodes collapses deaf_lists. */
export function countZones(zones) {
let n = 0
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) n += (zones?.[k] || []).length
// ★ deaf_lists is counted as ONE, not as its member count: legend-tree collapses its two entries into a
// single unified leaf (legend-tree.js:106, "13 + 12 + 2 + 1 deaf = 28"). Counting its members would
// report 29 for a 28-leaf tree — a number that disagrees with every other count in the app.
if ((zones?.deaf_lists || []).length) n += 1
return n
}
/** Every list id that has its own zones — for a gate or a diagnostic to enumerate. */
export function listsWithOwnZones() { return Object.keys(ZONES_BY_LIST) }

View file

@ -50,18 +50,18 @@ import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import visionFunnel from '../../data/people-groups/vision-funnel.json'
import { upgTotal, countInSentence } from './data/upg-total.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { zonesForList } from './data/zones-for-list.js'
import { zonesForList } from '@map/packet/upg100Zones/zones-for-list.js'
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { makeZoneSheet } from './data/zone-sheet-print.js'
import { REGION_KEY } from './data/legend-tree.js'
import { makeZoneSheet } from '@map/packet/upg100Zones/zone-sheet-print.js'
import { REGION_KEY } from '@map/packet/upg100Zones/legend-tree.js'
import { keyedError } from '@map/i18n/keyedError.js'
// A zone's title in the reader's language (tier A: its country; others keep their label) — the same
// generator the map's legend and the packet use, so a card, its alt text and its print say one name.
import { zoneTitle } from './data/zone-title.js'
import { countryName } from '@map/utils/countryName.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
import { evaluate as evaluateSelector } from '@map/packet/upg100Zones/selector-evaluator.js'
// The table the zone map sheets were generated from (generate.mjs labels each country with it), so a
// packet printed from this wall names page 1's countries in its own language, as the list's print does.

View file

@ -94,17 +94,17 @@ import { resolveNamedStyle } from '@map/styles/registry.js'
// change instead of an audit"; it was four, found by grepping the IDENTIFIER rather than trusting the note:
// allLists, bootstrapLegend (which also attaches selectors), and two legend rebuilds that pass the whole
// object so a `clusters.` grep misses them. Recorded so the next reader greps too.
import { zonesForList } from './data/zones-for-list.js'
import { zonesForList } from '@map/packet/upg100Zones/zones-for-list.js'
import { normalizeRecords, extractRecords } from './data/normalize.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
import { makeZoneSheet } from './data/zone-sheet-print.js'
import { evaluate as evaluateSelector } from '@map/packet/upg100Zones/selector-evaluator.js'
import { makeZoneSheet } from '@map/packet/upg100Zones/zone-sheet-print.js'
import { createPeopleGroupRecords, PRINT_FIELDS } from '@map/lists/peopleGroupRecords.js'
import { useI18n } from 'vue-i18n'
// The in-place print helper: a hidden same-page iframe, NOT window.open(). A popup
// is blockable and drops the user out of the map; the iframe opens the print dialog
// right here. Shared so the packet, the poster and the card deck all print the same way.
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR, REGION_KEY } from './data/legend-tree.js'
import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR, REGION_KEY } from '@map/packet/upg100Zones/legend-tree.js'
// Legend rows named in the reader's language: tier-A zones by their country (zone-title.js), the Deaf
// row by its bloc name. Both modules are already in this bundle for the packet's localizedZone().
import { zoneTitle, countryLabelIndex } from './data/zone-title.js'
@ -117,7 +117,7 @@ import { keyedError, errorText } from '@map/i18n/keyedError.js'
import { formatList } from '@map/i18n/index.js'
// The packet's text table, served and integrity-pinned rather than bundled (library/packet/packetStrings.js).
import { loadPacketStrings } from '@map/packet/packetStrings.js'
import { resolveAllZones } from './data/zone-geometry.js'
import { resolveAllZones } from '@map/packet/upg100Zones/zone-geometry.js'
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
// holds the region-inherited tapestry (purple ME, red Africa, green
// Europe, orange Oceania, yellow LatAm, deaf #18ffff). colorForZone(zoneKey,
@ -1885,7 +1885,7 @@ async function selectionPeople() {
throw keyedError('upgMap.nothingSelected', 'nothing is selected');
}
const records = await selectionRecordsLoad();
const { evaluate } = await import('./data/selector-evaluator.js');
const { evaluate } = await import('@map/packet/upg100Zones/selector-evaluator.js');
const bySlug = new Map(records.map((r) => [r.slug, r]));
const byId = new Map(printableZones().map((l) => [l.id, l]));
const from = new Map();
@ -2800,7 +2800,7 @@ onMounted(async () => {
// build slug→zoneKey using the same selectors (first match wins; regional before asia)
const pinFeatures = []
const slugToZone = {}
const { evaluate } = await import('./data/selector-evaluator.js')
const { evaluate } = await import('@map/packet/upg100Zones/selector-evaluator.js')
// BUG-A FIX (§1a): the Asia rows carry a `pg_slugs` selector the evaluator
// intentionally THROWS on. The old loop called evaluate() on every zone, so the
// first Asia zone threw → the whole pin build aborted in the catch → zero pins.

View file

@ -67,15 +67,18 @@ if (process.argv.includes('--self-test')) {
const body = real('lib', 'zone-sheet.js')
const at = body.indexOf('return ', body.indexOf('export function upg100Zones'))
const spec = real('lib', 'zone-sheet-print.js')
// The COPY-mode cases run on a SIMULATED copy tree (every original = its library copy), so they hold whichever
// mode the real tree is in: before UPG100's switch the originals exist, after it (C12b) they are gone.
const copyTree = Object.fromEntries(FILES.map((f) => [`plugin:${f}`, real('lib', f)]))
const cases = [
['a one-character edit inside a function body', { 'lib:zone-sheet.js': body.slice(0, at) + 'return ' + body.slice(at + 7) }, true],
['a changed clusters.json', { 'lib:clusters.json': real('lib', 'clusters.json').replace('"id"', '"id "') }, true],
['a missing copy', { 'lib:selector-evaluator.js': null }, true],
['a one-character edit inside a function body', { ...copyTree, 'lib:zone-sheet.js': body.slice(0, at) + 'return ' + body.slice(at + 7) }, true],
['a changed clusters.json', { ...copyTree, 'lib:clusters.json': real('lib', 'clusters.json').replace('"id"', '"id "') }, true],
['a missing copy', { ...copyTree, 'lib:selector-evaluator.js': null }, true],
// ONE-PATH (after UPG100 switches): no originals, and a plugin file that defines the zone code again must fail
['one path: a plugin file defining makeZoneSheet again', { ...Object.fromEntries(FILES.map((f) => [`plugin:${f}`, null])), 'pluginFiles:undefined': ['/fake/again.js'], 'pluginAbs:/fake/again.js': 'export function makeZoneSheet() {}' }, true],
['one path: no originals and no second definition', { ...Object.fromEntries(FILES.map((f) => [`plugin:${f}`, null])), 'pluginFiles:undefined': [] }, false],
['half switched: some originals deleted', { 'plugin:zone-geometry.js': null }, true],
['an import specifier changed alone', { 'lib:zone-sheet-print.js': spec.replace("from './zone-sheet.js'", "from './zone-sheet.js?v=2'") }, false],
['half switched: some originals deleted', { ...copyTree, 'plugin:zone-geometry.js': null }, true],
['an import specifier changed alone', { ...copyTree, 'lib:zone-sheet-print.js': spec.replace("from './zone-sheet.js'", "from './zone-sheet.js?v=2'") }, false],
]
let ok = true
for (const [name, ov, shouldFail] of cases) {