diff --git a/internal/check-packets-all.mjs b/internal/check-packets-all.mjs index 8deb2e2cf..259904de4 100644 --- a/internal/check-packets-all.mjs +++ b/internal/check-packets-all.mjs @@ -83,7 +83,10 @@ if (!files.length) { const CLUSTERS = resolve(REPO, 'plugin-designs/my-upg-100-list/data/clusters.json') if (existsSync(CLUSTERS)) { const z = JSON.parse(readFileSync(CLUSTERS, 'utf8')) - const zones = [...(z.lists || []), ...(z.asia_lists || []), ...(z.uncovered_lists || [])].map((l) => l.id) + // ★ 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 zones = leafZones(buildLegendNodes(z, { records: null })).map((l) => l.id) const have = new Set(all.map((f) => f.replace(/\.html$/, ''))) for (const id of zones) if (!have.has(id)) failures.push(`zone '${id}' has NO generated packet at all`) } diff --git a/library/scripts/posters/generate.mjs b/library/scripts/posters/generate.mjs index 6862827f3..52b5ae867 100644 --- a/library/scripts/posters/generate.mjs +++ b/library/scripts/posters/generate.mjs @@ -69,7 +69,7 @@ const SERIES_DEF = SERIES_DEF_ALL[SERIES_ID]; function readJSON_early(p) { return JSON.parse(readFileSync(p, 'utf8')); } // ── the map's own logic, reused read-only ──────────────────────────────────── -const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js')); +const { buildLegendNodes, leafZones, DEAF_SELECTOR } = await import(join(DATA, 'legend-tree.js')); const { resolveZoneGeometry } = await import(join(DATA, 'zone-geometry.js')); const { evaluate } = await import(join(DATA, 'selector-evaluator.js')); const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js')); @@ -2863,7 +2863,7 @@ 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'] = { type: 'affinity_bloc', rop1: 'A017' }; +byId['deaf'] = DEAF_SELECTOR; // defined once in legend-tree.js // Country zones already carry their own country_code selector — reassigning from // byId (which only knows 100-list ids) would blank every one of them. if (!COUNTRIES && !CITIES) for (const z of zones) z.selector = byId[z.id]; diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index 962b1adfa..e4820f53b 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -240,7 +240,7 @@ const { evaluate } = await import(join(DATA, 'selector-evaluator.js')); const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js')); // The zone enumeration itself, so this generator, the poster generator and upg-100-list.vue cannot // disagree about which zones exist (see allZones below). -const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js')); +const { buildLegendNodes, leafZones, zonesWithSelectors } = await import(join(DATA, 'legend-tree.js')); async function loadRecords() { // ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the @@ -353,17 +353,10 @@ function allZones() { selector: { type: 'country_codes', values: [c.iso3] }, })); } - const tops = buildLegendNodes(clusters, { records: null }); - const byId = {}; - for (const k of ['lists', 'asia_lists', 'uncovered_lists']) { - for (const z of clusters[k] || []) byId[z.id] = z.selector; - } - // The unified deaf leaf's selector. DUPLICATED from legend-tree.js:99's DEAF_SELECTOR, which is - // function-scoped rather than exported, and from generate.mjs:2805 which hardcodes the same literal. - // Kept as a literal here rather than exporting a third consumer's dependency mid-hold; if a fourth - // caller ever needs it, export it once from legend-tree.js and delete all three copies. - byId.deaf = { type: 'affinity_bloc', rop1: 'A017' }; - return leafZones(tops).map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] })); + // The legend's leaves with their selectors, the unified deaf leaf included — ONE enumeration + // (legend-tree.js zonesWithSelectors) shared with the browser packet and the map, replacing the + // id->selector table and deaf literal this function used to type out itself. + return zonesWithSelectors(clusters, { records: null }); } // ── PHOTOS: parallel fetch, inlined as data URIs ──────────────────────────── diff --git a/plugin-designs/my-upg-100-list/data/legend-tree.js b/plugin-designs/my-upg-100-list/data/legend-tree.js index f3228563d..ea45390e2 100644 --- a/plugin-designs/my-upg-100-list/data/legend-tree.js +++ b/plugin-designs/my-upg-100-list/data/legend-tree.js @@ -82,6 +82,13 @@ function leafPopulation(row, records) { * 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' }; + export function buildLegendNodes(clusters, opts = {}) { const deafCount = opts.deafCount ?? 123; const records = Array.isArray(opts.records) ? opts.records : null; @@ -105,8 +112,7 @@ export function buildLegendNodes(clusters, opts = {}) { const uncovered = clusters.uncovered_lists || []; // Deaf row's selector is implicit (affinity bloc A017), so population can be - // summed the same way as the regional rows. - const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' }; + // 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 @@ -379,4 +385,28 @@ export function leafZones(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; diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index 2c7d58706..8acb794d1 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -101,7 +101,7 @@ import { useI18n } from 'vue-i18n' // 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 } from './data/legend-tree.js' +import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR } from './data/legend-tree.js' import { resolveAllZones } from './data/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 @@ -1336,7 +1336,7 @@ async function onLegendExport(kind) { // The legend selects by KEY, while the print actions label by item — hand over the zone // object so the print dialog's title says the list's name rather than its slug. // Same resolution printPrayerPacket uses, so both print actions name the list identically. - const zone = allLists().find((z) => z.id === zoneId) || { id: zoneId }; + const zone = printableZones().find((z) => z.id === zoneId) || { id: zoneId }; await packetPrints.printPoster(zone); } finally { // Cleared on EVERY exit path including the early `return` inside the try — a throw would @@ -1410,7 +1410,7 @@ async function packetRecords(zone) { // fetch (:1988) has always carried both. Measured: a wagf_block selector returns 0 members on // packet rows and 1 on map rows. oceania-indonesia was the worst of the six because its union // still matched IDN, so it printed a PLAUSIBLE packet that was silently missing all of Oceania. - const fields = 'slug,name,country_code,wagf_region,wagf_block,population,religion,image_url,primary_language,imb_gsec'; + const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,image_url,primary_language,imb_gsec'; const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`, { cache: 'no-store' }); if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`); @@ -1456,7 +1456,7 @@ const flashStatus = (text, ms) => flashRef(status, text, ms); // profiles, sets, points, wheel) or 'lightweight' (map, triangle, profiles, points, wheel). // Defaults to 'full', so every caller that does not pass one prints exactly what it printed before. async function printPrayerPacket(zoneId, variant = 'full') { - const zone = allLists().find((z) => z.id === zoneId); + const zone = printableZones().find((z) => z.id === zoneId); if (!zone) return; // The map svg needs the renderer's own mapCss/W, so it is resolved here and // handed in rather than fetched inside the shared action, which knows nothing @@ -1500,6 +1500,24 @@ function allLists() { ...(z.uncovered_lists || [])]; } +// Every zone a reader can PICK — the legend's leaves — as the object the print actions need. +// +// ★ allLists() IS 27 AND THE LEGEND IS 28. It spreads the three geographic buckets and cannot see +// `deaf`, which the legend shows as one row (the two deaf_lists collapse into a single affinity-bloc +// leaf). Every place that turned a picked id back into a zone went through allLists(), so Deaf was +// unreachable from all of them, silently: the Prayer packet returned on `if (!zone)`, the poster +// titled itself by its slug, a selection containing Deaf dropped it in `.filter(Boolean)`, and the +// packet count read 27 while 28 packets exist. +// The 27 keep EXACTLY the catalogue objects they had; only a leaf the catalogue lacks is added, as the +// legend's own leaf carrying its selector (legend-tree.js zonesWithSelectors). One enumeration for +// every pick, so the four sites cannot disagree about which zones exist. +function printableZones() { + const base = allLists(); + const have = new Set(base.map((z) => z.id)); + return [...base, ...zonesWithSelectors(listZones(), { deafCount: DEAF_COUNT }) + .filter((z) => !have.has(z.id))]; +} + // ── THE PAGE-2 FUNNEL CONTEXT ─────────────────────────────────────────────── // What the packet's funnel page needs that the packet itself cannot know: the nested IMB/network // steps, how many lists the catalogue holds, and how many groups are in no list at all. @@ -1561,7 +1579,9 @@ async function packetFunnel() { // Carded for the Driver: 27 geographic / 29 counting deaf, and whether "Central Asia + Pakistan" // (55 groups, contiguous, poster-walled as a real region) is a prayer list rather than a residual. if (listId === LIST_DEFAULT) { - return { ...raw, listCount: allLists().length, listTotal: doxaTotal }; + // printableZones, not allLists: the count prints on page 1 ("one of N") and at the triangle's + // tip, and allLists() is 27 while the legend offers — and the generator builds — 28 packets. + return { ...raw, listCount: printableZones().length, listTotal: doxaTotal }; } // A NON-DEFAULT LIST: read its own DOXA-side total from the membership file the build writes. @@ -1613,7 +1633,7 @@ function selectedLists() { const ids = selectedTerritories.value .filter((t) => t.kind === 'zone') .map((t) => String(t.key).replace(/^zone:/, '')); - const byId = new Map(allLists().map((l) => [l.id, l])); + const byId = new Map(printableZones().map((l) => [l.id, l])); return ids.map((id) => byId.get(id)).filter(Boolean); } @@ -1692,7 +1712,7 @@ async function selectionAsList() { // fetch (:1988) has always carried both. Measured: a wagf_block selector returns 0 members on // packet rows and 1 on map rows. oceania-indonesia was the worst of the six because its union // still matched IDN, so it printed a PLAUSIBLE packet that was silently missing all of Oceania. - const fields = 'slug,name,country_code,wagf_region,wagf_block,population,religion,image_url,primary_language,imb_gsec'; + const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,image_url,primary_language,imb_gsec'; const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`, { cache: 'no-store' }); if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`); @@ -2088,7 +2108,7 @@ function bootstrapLegend() { for (const a of z.asia_lists || []) byId[a.id] = a.selector // slice 09b-6 §1a: the 7 Frontier-Asia uncovered zones (country_codes → ADM0). for (const u of z.uncovered_lists || []) byId[u.id] = u.selector - byId['deaf'] = { type: 'affinity_bloc', rop1: 'A017' } + byId['deaf'] = DEAF_SELECTOR for (const z of leafZoneList) z.selector = byId[z.id] matchColorExpr = buildMatchColorExpr(leafZoneList) }