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:
parent
789d90eb2f
commit
c91c284ce4
46 changed files with 115 additions and 2684 deletions
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1687
plugin-designs/my-upg-100-list/data/clusters.json
vendored
1687
plugin-designs/my-upg-100-list/data/clusters.json
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' +
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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' +
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
401
plugin-designs/my-upg-100-list/data/legend-tree.js
vendored
401
plugin-designs/my-upg-100-list/data/legend-tree.js
vendored
|
|
@ -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;
|
||||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
138
plugin-designs/my-upg-100-list/data/zone-geometry.js
vendored
138
plugin-designs/my-upg-100-list/data/zone-geometry.js
vendored
|
|
@ -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;
|
||||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue