Poster Design draws UPG100's zone posters, from a library copy of UPG100's zone code
UPG100's zone-poster code lived only inside its plugin, so Poster Design (which draws from the library) could list UPG100's posters but never show them. It is now COPIED, unchanged, into library/packet/upg100Zones/ (zone-sheet-print, zone-sheet, legend-tree, selector-evaluator, zone-geometry, zones-for-list, clusters.json; only the library import paths are shorter). UPG100's own files are untouched. Poster Design adds a UPG100 zone posters family drawn from that copy with a real zone (middle-east-core) and all the records, as UPG100's own wall does: the letter, 3 x 3 and 3 x 4 zone posters under UPG100's own design names (so the drawn card replaces the no-preview plugin card), and the prayer packet's page 1 (the full letter zone poster). Keys in 11 locales. check:upg100-zone-copy (in gates:pre) keeps the copy equal to UPG100's originals beyond the import paths; once UPG100 switches to the library copy and deletes its originals it becomes a one-path gate. Its self-test proves a one-character edit, a changed clusters.json, a missing file, a half switch and a re-definition each fail. check:poster-design-catalogue allows a template to carry a plugin's design name only when it declares that plugin (poisoned). Source only: poster-design is rebuilt when the heavy slot reopens.
This commit is contained in:
parent
e043402006
commit
1e53b531b0
24 changed files with 2798 additions and 30 deletions
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "ألواح الجدار",
|
||||
"mapPrint": "طباعة الخريطة",
|
||||
"plugins": "من إضافات أخرى",
|
||||
"experimental": "تجريبية (قيد العمل)"
|
||||
"experimental": "تجريبية (قيد العمل)",
|
||||
"upgZone": "ملصقات مناطق UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "صفحة خريطة الدولة",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "حزمة الصلاة، مختصرة",
|
||||
"cards": "بطاقات الصلاة",
|
||||
"blessPanels": "ألواح جدار البركة",
|
||||
"mapPrint": "طباعة الخريطة"
|
||||
"mapPrint": "طباعة الخريطة",
|
||||
"upgZonePoster": "ملصق منطقة UPG100",
|
||||
"upgZonePacket": "حزمة صلاة UPG100، الصفحة 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Wandtafeln",
|
||||
"mapPrint": "Kartendruck",
|
||||
"plugins": "Aus anderen Plugins",
|
||||
"experimental": "Experimentell (in Arbeit)"
|
||||
"experimental": "Experimentell (in Arbeit)",
|
||||
"upgZone": "UPG100-Gebietsplakate"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Länder-Kartenblatt",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Gebetsmappe, kompakt",
|
||||
"cards": "Gebetskarten",
|
||||
"blessPanels": "Tafeln der Segnen-Wand",
|
||||
"mapPrint": "Kartendruck"
|
||||
"mapPrint": "Kartendruck",
|
||||
"upgZonePoster": "UPG100-Gebietsplakat",
|
||||
"upgZonePacket": "UPG100-Gebetsmappe, Seite 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Die Bögen, die Klebestreifen und die Lage der Kartenreihen stammen aus der gemeinsamen Tafelgeometrie; die Karten sind als Layout dargestellt, weil die Segnen-Wand ihre Karten in ihrem eigenen Plugin zeichnet.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Wall panels",
|
||||
"mapPrint": "Map print",
|
||||
"plugins": "From other plugins",
|
||||
"experimental": "Experimental (in progress)"
|
||||
"experimental": "Experimental (in progress)",
|
||||
"upgZone": "UPG100 zone posters"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Country map sheet",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Prayer packet, lightweight",
|
||||
"cards": "Prayer cards",
|
||||
"blessPanels": "Bless wall panels",
|
||||
"mapPrint": "Map print"
|
||||
"mapPrint": "Map print",
|
||||
"upgZonePoster": "UPG100 zone poster",
|
||||
"upgZonePacket": "UPG100 prayer packet, page 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "The sheets, the glue strips and where the card rows go come from the shared panel geometry; the cards are shown as a layout, because the Bless wall draws its cards in its own plugin.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Paneles de pared",
|
||||
"mapPrint": "Impresión del mapa",
|
||||
"plugins": "De otros complementos",
|
||||
"experimental": "Experimentales (en desarrollo)"
|
||||
"experimental": "Experimentales (en desarrollo)",
|
||||
"upgZone": "Carteles de zonas UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Hoja de mapa del país",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Paquete de oración, ligero",
|
||||
"cards": "Tarjetas de oración",
|
||||
"blessPanels": "Paneles del muro Bendecir",
|
||||
"mapPrint": "Impresión del mapa"
|
||||
"mapPrint": "Impresión del mapa",
|
||||
"upgZonePoster": "Cartel de zona UPG100",
|
||||
"upgZonePacket": "Paquete de oración UPG100, página 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Las hojas, las franjas de pegado y el lugar de las filas de tarjetas vienen de la geometría de paneles compartida; las tarjetas se muestran como un diseño, porque el muro Bendecir dibuja sus tarjetas en su propio complemento.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Panneaux muraux",
|
||||
"mapPrint": "Impression de la carte",
|
||||
"plugins": "D’autres plugins",
|
||||
"experimental": "Expérimentaux (en cours)"
|
||||
"experimental": "Expérimentaux (en cours)",
|
||||
"upgZone": "Affiches de zones UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Feuille de carte du pays",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Dossier de prière, allégé",
|
||||
"cards": "Cartes de prière",
|
||||
"blessPanels": "Panneaux du mur Bénir",
|
||||
"mapPrint": "Impression de la carte"
|
||||
"mapPrint": "Impression de la carte",
|
||||
"upgZonePoster": "Affiche de zone UPG100",
|
||||
"upgZonePacket": "Dossier de prière UPG100, page 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Les feuilles, les bandes de collage et l'emplacement des rangées de cartes viennent de la géométrie de panneaux partagée ; les cartes sont montrées comme une mise en page, car le mur Bénir dessine ses cartes dans son propre module.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "दीवार पैनल",
|
||||
"mapPrint": "मानचित्र प्रिंट",
|
||||
"plugins": "अन्य प्लगइन से",
|
||||
"experimental": "प्रयोगात्मक (काम जारी)"
|
||||
"experimental": "प्रयोगात्मक (काम जारी)",
|
||||
"upgZone": "UPG100 क्षेत्र पोस्टर"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "देश का मानचित्र पृष्ठ",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "प्रार्थना पैकेट, हल्का",
|
||||
"cards": "प्रार्थना कार्ड",
|
||||
"blessPanels": "आशीष दीवार के पैनल",
|
||||
"mapPrint": "मानचित्र प्रिंट"
|
||||
"mapPrint": "मानचित्र प्रिंट",
|
||||
"upgZonePoster": "UPG100 क्षेत्र का पोस्टर",
|
||||
"upgZonePacket": "UPG100 प्रार्थना पैकेट, पेज 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Pannelli da parete",
|
||||
"mapPrint": "Stampa della mappa",
|
||||
"plugins": "Da altri plugin",
|
||||
"experimental": "Sperimentali (in corso)"
|
||||
"experimental": "Sperimentali (in corso)",
|
||||
"upgZone": "Poster delle zone UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Foglio mappa del paese",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Fascicolo di preghiera, leggero",
|
||||
"cards": "Schede di preghiera",
|
||||
"blessPanels": "Pannelli del muro Benedici",
|
||||
"mapPrint": "Stampa della mappa"
|
||||
"mapPrint": "Stampa della mappa",
|
||||
"upgZonePoster": "Poster di zona UPG100",
|
||||
"upgZonePacket": "Fascicolo di preghiera UPG100, pagina 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "I fogli, le strisce da incollare e la posizione delle file di schede vengono dalla geometria dei pannelli condivisa; le schede sono mostrate come impaginazione, perché il muro Benedici disegna le sue schede nel proprio plugin.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Painéis de parede",
|
||||
"mapPrint": "Impressão do mapa",
|
||||
"plugins": "De outros plugins",
|
||||
"experimental": "Experimentais (em andamento)"
|
||||
"experimental": "Experimentais (em andamento)",
|
||||
"upgZone": "Cartazes de zonas UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Folha de mapa do país",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Pacote de oração, leve",
|
||||
"cards": "Cartões de oração",
|
||||
"blessPanels": "Painéis do mural Abençoar",
|
||||
"mapPrint": "Impressão do mapa"
|
||||
"mapPrint": "Impressão do mapa",
|
||||
"upgZonePoster": "Cartaz de zona UPG100",
|
||||
"upgZonePacket": "Pacote de oração UPG100, página 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "As folhas, as faixas de colagem e a posição das fileiras de cartões vêm da geometria de painéis compartilhada; os cartões aparecem como um layout, porque o mural Abençoar desenha os seus cartões no seu próprio plugin.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Panouri de perete",
|
||||
"mapPrint": "Tipărirea hărții",
|
||||
"plugins": "Din alte pluginuri",
|
||||
"experimental": "Experimentale (în lucru)"
|
||||
"experimental": "Experimentale (în lucru)",
|
||||
"upgZone": "Postere de zone UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Foaie de hartă a țării",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Pachet de rugăciune, ușor",
|
||||
"cards": "Cartonașe de rugăciune",
|
||||
"blessPanels": "Panourile peretelui Binecuvântează",
|
||||
"mapPrint": "Tipărirea hărții"
|
||||
"mapPrint": "Tipărirea hărții",
|
||||
"upgZonePoster": "Poster de zonă UPG100",
|
||||
"upgZonePacket": "Pachet de rugăciune UPG100, pagina 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Colile, benzile de lipire și locul rândurilor de cartonașe vin din geometria comună a panourilor; cartonașele sunt arătate ca machetă, pentru că peretele Binecuvântează își desenează cartonașele în propriul modul.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "Настенные панели",
|
||||
"mapPrint": "Печать карты",
|
||||
"plugins": "Из других плагинов",
|
||||
"experimental": "Экспериментальные (в работе)"
|
||||
"experimental": "Экспериментальные (в работе)",
|
||||
"upgZone": "Плакаты зон UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Лист карты страны",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "Молитвенный пакет, облегчённый",
|
||||
"cards": "Молитвенные карточки",
|
||||
"blessPanels": "Панели стены «Благословить»",
|
||||
"mapPrint": "Печать карты"
|
||||
"mapPrint": "Печать карты",
|
||||
"upgZonePoster": "Плакат зоны UPG100",
|
||||
"upgZonePacket": "Молитвенный пакет UPG100, страница 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"wall": "墙面展板",
|
||||
"mapPrint": "地图打印",
|
||||
"plugins": "来自其他插件",
|
||||
"experimental": "实验性(进行中)"
|
||||
"experimental": "实验性(进行中)",
|
||||
"upgZone": "UPG100 区域海报"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "国家地图页",
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
"packetLightweight": "祷告资料包(精简版)",
|
||||
"cards": "祷告卡",
|
||||
"blessPanels": "祝福墙展板",
|
||||
"mapPrint": "地图打印"
|
||||
"mapPrint": "地图打印",
|
||||
"upgZonePoster": "UPG100 区域海报(单张)",
|
||||
"upgZonePacket": "UPG100 祷告资料包,第 1 页"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。",
|
||||
|
|
|
|||
1687
library/packet/upg100Zones/clusters.json
Normal file
1687
library/packet/upg100Zones/clusters.json
Normal file
File diff suppressed because it is too large
Load diff
401
library/packet/upg100Zones/legend-tree.js
Normal file
401
library/packet/upg100Zones/legend-tree.js
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
// 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;
|
||||
165
library/packet/upg100Zones/selector-evaluator.js
Normal file
165
library/packet/upg100Zones/selector-evaluator.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// 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;
|
||||
138
library/packet/upg100Zones/zone-geometry.js
Normal file
138
library/packet/upg100Zones/zone-geometry.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// 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;
|
||||
41
library/packet/upg100Zones/zone-sheet-print.js
Normal file
41
library/packet/upg100Zones/zone-sheet-print.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* 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 '../../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('../../packet/posterSheets.js')
|
||||
const [{ ISO3_NAME }, records, bounds] = await Promise.all([
|
||||
import('../../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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
58
library/packet/upg100Zones/zone-sheet.js
Normal file
58
library/packet/upg100Zones/zone-sheet.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
104
library/packet/upg100Zones/zones-for-list.js
Normal file
104
library/packet/upg100Zones/zones-for-list.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* 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) }
|
||||
|
|
@ -262,6 +262,8 @@
|
|||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
|
||||
"check:poster-design-catalogue": "node plugin-designs/poster-design/scripts/check-catalogue.mjs",
|
||||
"check:poster-design-catalogue:self-test": "node plugin-designs/poster-design/scripts/check-catalogue.mjs --self-test",
|
||||
"check:upg100-zone-copy": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs",
|
||||
"check:upg100-zone-copy:self-test": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs --self-test",
|
||||
"check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
|
||||
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
|
||||
|
|
@ -357,7 +359,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test check:pinned-fetch-fresh:self-test",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -620,6 +622,8 @@
|
|||
"check:poster-degrades": "with NO stored poster, NO source and NO generator (forced in the browser: every posters/*.webp and *.html answers 404, lib/poster-render.js is aborted), nothing must print and the reader must be told why. Measures any served tree; NOT MEASURED without a wall url.",
|
||||
"check:poster-design-catalogue": "Poster Design's configuration catalogue (plugin-designs/poster-design/src/templates.js) says only true things about the library: its sheet formats are exactly poster-render.js FORMATS at the same pixel sizes (the generator is a pinned asset, so the table cannot be imported); every configuration name is unique and well formed; the card entries are exactly card-render STOCK; every catalogue key it names exists in en/posterDesign.json; the sample country can be drawn. Six mutants (px drift, a missing format, a duplicate name, a bad key, an undrawable sample, a dropped stock) each FAIL. It also validates every plugin's posterDesign block (plugin-designs/<plugin>/<profile>.meta.json: family, name, size, mobileFriendly, optional labelKey and instance), keeps names unique across templates.js and all blocks, and fails when a name in the names ledger (src/catalogue-names.json) was renamed or removed.",
|
||||
"check:poster-design-catalogue:self-test": "an invalid posterDesign block, a name that clashes with templates.js, a renamed ledger name a block naming a preset that does not exist, and a sample that exists but cannot be drawn (India with withholding off) must each make check:poster-design-catalogue fail, and the documented example block must pass.",
|
||||
"check:upg100-zone-copy": "library/packet/upg100Zones/ is UPG100's zone-poster code copied into the library (so Poster Design draws UPG100 zone posters). COPY mode while UPG100's originals exist: each file equals its original beyond its import specifiers, clusters.json byte for byte. ONE-PATH mode once UPG100 switches and deletes its originals: no plugin file defines the zone code again. Half switched fails.",
|
||||
"check:upg100-zone-copy:self-test": "a one-character edit in a function body, a changed clusters.json, a missing copy, a half switch, and (one-path) a plugin re-defining makeZoneSheet must each fail; an import-specifier-only change and a clean one-path tree must pass.",
|
||||
"check:poster-fetch-guard": "a host's 200 fallback page is never taken for a poster. On a drop-in dev host a missing posters/<id>.webp answered 200 text/html, and export handed that page to the print dialog as the image. Asserts library/packet/poster-fetch.js accepts a raster only if it is an image (by type or by its first bytes) and a source only if it is a poster document (class=\"stage\"), and that neither usePosterPrints nor PosterWall fetches posters/ directly. Node only, no server.",
|
||||
"check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.",
|
||||
"check:poster-print-fallback": "clicks the REAL poster print control with the served poster generator and every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.",
|
||||
|
|
|
|||
|
|
@ -151,7 +151,17 @@ function panels(sizeKey) {
|
|||
const size = resolvePosterSize(sizeKey)
|
||||
return size ? { image: panelsSvgUrl(size.sheets) } : null
|
||||
}
|
||||
const ctx = { countrySheet, cardDeck, packetDocument, panels }
|
||||
// A UPG100 zone's sheet, from the library copy of UPG100's zone code: ALL the records (a zone's pins depend on which
|
||||
// earlier zone claimed a group first, as on UPG100's own wall), the list from this page's config. Loaded on first use.
|
||||
let zoneSheetFn = null
|
||||
async function zoneSheet(format, zoneId) {
|
||||
if (!zoneSheetFn) {
|
||||
const { makeZoneSheet } = await import('@map/packet/upg100Zones/zone-sheet-print.js')
|
||||
zoneSheetFn = makeZoneSheet({ allRecords: () => recordsFor.all(), assetsBase: () => assetsBase.value, profileConfig })
|
||||
}
|
||||
return zoneSheetFn({ id: zoneId }, format)
|
||||
}
|
||||
const ctx = { countrySheet, cardDeck, packetDocument, panels, zoneSheet }
|
||||
|
||||
// The printed document's size, measured when it is drawn: what "file size" means in the details.
|
||||
// render() answers a poster's HTML (a map sheet) or a document { html, page, width, height } (a deck, a packet).
|
||||
|
|
|
|||
|
|
@ -102,9 +102,12 @@ for (const c of configs) if (!cat.FAMILY_KEYS[c.family]) fail(`'${c.name}' has f
|
|||
// 5. the samples
|
||||
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
|
||||
const samples = [...new Set([cat.SAMPLE_COUNTRY_ID, ...configs.map((c) => c.sample).filter(Boolean)])]
|
||||
// A UPG100 zone sample (a template with `plugin: 'my-upg-100-list'`) is one of the zones in the library copy's catalogue.
|
||||
const ZONE_IDS = (() => { try { const z = JSON.parse(readFileSync(join(ROOT, 'library/packet/upg100Zones/clusters.json'), 'utf8')); return new Set(['lists', 'asia_lists', 'uncovered_lists'].flatMap((k) => (z[k] || []).map((x) => x.id))) } catch { return new Set() } })()
|
||||
function sampleArm(ids, countries, withheld) {
|
||||
const fs = []
|
||||
for (const id of ids) {
|
||||
if (ZONE_IDS.has(id)) continue // a real UPG100 zone (drawn from the library copy)
|
||||
const s = countries.find((c) => c.id === id)
|
||||
if (!s) fs.push(`5: sample '${id}' is not in library/data/country-posters.json`)
|
||||
else if (withheld(s)) continue // its card shows the withheld sentence; it is never drawn here
|
||||
|
|
@ -134,7 +137,7 @@ function gatherBlocks() {
|
|||
}
|
||||
return out
|
||||
}
|
||||
function designArms({ names, blocks, ledger }) {
|
||||
function designArms({ names, blocks, ledger, drawnFor = new Map() }) {
|
||||
const fs = []
|
||||
for (const { where, block, presets } of blocks) {
|
||||
const probs = PD.blockProblems(block)
|
||||
|
|
@ -143,9 +146,15 @@ function designArms({ names, blocks, ledger }) {
|
|||
if (block.labelKey && !enKey(block.labelKey)) fs.push(`6: ${where} labelKey '${block.labelKey}' is not in the English catalogue`)
|
||||
if (block.instance && !presets.includes(block.instance)) fs.push(`6: ${where} instance '${block.instance}' is not one of its presets`)
|
||||
}
|
||||
const all = [...names, ...blocks.filter((b) => !PD.blockProblems(b.block).length).map((b) => b.block.name)]
|
||||
const seen = new Set()
|
||||
for (const n of all) { if (seen.has(n)) fs.push(`7: two designs are named '${n}'`); seen.add(n) }
|
||||
// A template that DRAWS a plugin's design (`plugin: '<dir>'`) carries that plugin's own name, so the same name in that
|
||||
// plugin's block is the same design, counted once. Anywhere else a repeated name is a clash.
|
||||
const seen = new Set(names)
|
||||
for (const n of names.filter((x, i) => names.indexOf(x) !== i)) fs.push(`7: two templates are named '${n}'`)
|
||||
for (const b of blocks.filter((x) => !PD.blockProblems(x.block).length)) {
|
||||
const n = b.block.name, plugin = String(b.where).split('/')[0]
|
||||
if (seen.has(n) && drawnFor.get(n) !== plugin) fs.push(`7: two designs are named '${n}'`)
|
||||
seen.add(n)
|
||||
}
|
||||
for (const n of ledger) if (!seen.has(n)) fs.push(`8: '${n}' is in the names ledger and no longer exists: a shipped name is never renamed or removed (keep it, as a legacy entry if retired)`)
|
||||
return { fails: fs, fresh: [...seen].filter((n) => !ledger.includes(n)) }
|
||||
}
|
||||
|
|
@ -153,7 +162,7 @@ const LEDGER_FILE = join(ROOT, 'plugin-designs/poster-design/src/catalogue-names
|
|||
const ledger = existsSync(LEDGER_FILE) ? JSON.parse(readFileSync(LEDGER_FILE, 'utf8')).names || [] : []
|
||||
if (!ledger.length) fail('8: the names ledger (src/catalogue-names.json) is missing or empty')
|
||||
const blocks = gatherBlocks()
|
||||
const real = { names: configs.map((c) => c.name), blocks, ledger }
|
||||
const real = { names: configs.map((c) => c.name), blocks, ledger, drawnFor: new Map(configs.filter((c) => c.plugin).map((c) => [c.name, c.plugin])) }
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const example = { family: 'regionPoster', name: 'region-poster.66x66', size: '66x66', mobileFriendly: true }
|
||||
|
|
@ -162,6 +171,7 @@ if (process.argv.includes('--self-test')) {
|
|||
['an invalid block (mobileFriendly missing)', { ...real, blocks: [...blocks, B({ family: 'regionPoster', name: 'region-poster.x', size: '66x66' })] }, /^6: .*mobileFriendly/],
|
||||
['a name clash with templates.js', { ...real, blocks: [...blocks, B({ ...example, name: real.names[0] })] }, /^7: /],
|
||||
['a renamed ledger name', { ...real, names: real.names.map((n, i) => (i === 0 ? `${n}-renamed` : n)) }, /^8: /],
|
||||
['another plugin reusing a name a template draws for UPG100', { ...real, blocks: [...blocks, B({ ...example, name: 'upg100-territory-poster.3x3' })] }, /^7: /],
|
||||
['a block naming a preset that does not exist', { ...real, blocks: [...blocks, B({ ...example, instance: 'nowhere' }, ['africa'])] }, /^6: .*instance/],
|
||||
]
|
||||
let ok = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check:upg100-zone-copy — library/packet/upg100Zones/ is UPG100's zone-poster code, copied so Poster Design (and any
|
||||
* page) can draw a UPG100 zone poster from the library. Two modes, chosen by what is on the tree:
|
||||
*
|
||||
* COPY while UPG100's originals (plugin-designs/my-upg-100-list/data/<file>) exist: every library file equals
|
||||
* its original with ONLY the import specifiers normalised (the copy's library paths are shorter), and
|
||||
* clusters.json byte for byte. A changed literal, a renamed variable, any edit in a function body FAILS:
|
||||
* change both, or change the plugin and copy it again.
|
||||
* ONE-PATH once UPG100 has switched to the library copy and deleted its originals: no plugin file may define the
|
||||
* zone functions again (one definition, one owner), as check:country-sheet-one-path does for countries.
|
||||
* Half the originals present = FAIL (a switch left halfway).
|
||||
*
|
||||
* --self-test: a one-character edit inside a function body, a changed clusters.json and a missing copy each FAIL; a
|
||||
* change to an import specifier alone PASSES (the normalisation is no wider than the specifiers).
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const LIB = join(ROOT, 'library/packet/upg100Zones')
|
||||
const PLUGIN = join(ROOT, 'plugin-designs/my-upg-100-list')
|
||||
const FILES = ['zone-sheet-print.js', 'zone-sheet.js', 'legend-tree.js', 'selector-evaluator.js', 'zone-geometry.js', 'zones-for-list.js', 'clusters.json']
|
||||
const DEFINES = /export\s+(?:async\s+)?function\s+(makeZoneSheet|upg100Zones|assignPins|seriesFacts|resolveZoneGeometry|zonesForList|buildLegendNodes)\b/
|
||||
|
||||
// Only the quoted specifier of a static import/export-from, a dynamic import() or a bare import is replaced.
|
||||
export const normalise = (s) => s
|
||||
.replace(/(\bfrom\s*)(['"])[^'"\n]+\2/g, '$1$2§$2')
|
||||
.replace(/(\bimport\(\s*)(['"])[^'"\n]+\2/g, '$1$2§$2')
|
||||
.replace(/(^\s*import\s*)(['"])[^'"\n]+\2/gm, '$1$2§$2')
|
||||
|
||||
export function arms(read) {
|
||||
const fails = []
|
||||
const originals = FILES.filter((f) => read('plugin', f) != null)
|
||||
if (originals.length && originals.length < FILES.length) fails.push(`half switched: UPG100 still has ${originals.join(', ')} but not ${FILES.filter((f) => !originals.includes(f)).join(', ')}`)
|
||||
for (const f of FILES) if (read('lib', f) == null) fails.push(`the library copy has no ${f}`)
|
||||
if (originals.length === FILES.length) {
|
||||
for (const f of FILES) {
|
||||
const a = read('plugin', f), b = read('lib', f)
|
||||
if (b == null) continue
|
||||
const same = f.endsWith('.json') ? a === b : normalise(a) === normalise(b)
|
||||
if (!same) fails.push(`${f}: the library copy differs from UPG100's original beyond its import paths (change both, or copy again)`)
|
||||
}
|
||||
return { mode: 'COPY', fails }
|
||||
}
|
||||
if (!originals.length) {
|
||||
for (const f of read('pluginFiles')) { const m = (read('pluginAbs', f) || '').match(DEFINES); if (m) fails.push(`one path: ${f} defines ${m[1]} again; the library copy is the only one`) }
|
||||
return { mode: 'ONE-PATH', fails }
|
||||
}
|
||||
return { mode: 'HALF', fails }
|
||||
}
|
||||
|
||||
const disk = (overrides = {}) => (where, f) => {
|
||||
const key = `${where}:${f}`
|
||||
if (key in overrides) return overrides[key]
|
||||
if (where === 'pluginFiles') {
|
||||
const out = []; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) { if (!/node_modules|assets/.test(e.name)) walk(p) } else if (/\.(m?js|vue)$/.test(e.name)) out.push(p) } }
|
||||
walk(PLUGIN); return out
|
||||
}
|
||||
const p = where === 'pluginAbs' ? f : join(where === 'lib' ? LIB : join(PLUGIN, 'data'), f)
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : null
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const real = disk()
|
||||
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')
|
||||
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],
|
||||
// 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],
|
||||
]
|
||||
let ok = true
|
||||
for (const [name, ov, shouldFail] of cases) {
|
||||
const { fails } = arms(disk(ov))
|
||||
const good = shouldFail ? fails.length > 0 : fails.length === 0
|
||||
console.log(` ${good ? '✓' : '✗'} ${name}: ${fails.length ? 'FAILS' : 'passes'} (${shouldFail ? 'must fail' : 'must pass'})${fails[0] ? ' -> ' + fails[0] : ''}`)
|
||||
if (!good) ok = false
|
||||
}
|
||||
console.log(ok ? 'UPG100-ZONE-COPY SELF-TEST: PASS' : 'UPG100-ZONE-COPY SELF-TEST: FAIL'); process.exit(ok ? 0 : 1)
|
||||
}
|
||||
const { mode, fails } = arms(disk())
|
||||
if (fails.length) { console.log(`UPG100-ZONE-COPY: FAIL (${mode}, ${fails.length})`); fails.forEach((f) => console.log(` - ${f}`)); process.exit(1) }
|
||||
console.log(`UPG100-ZONE-COPY: PASS (${mode}) — the ${FILES.length} files of library/packet/upg100Zones/ ${mode === 'COPY' ? "equal UPG100's originals beyond their import paths" : 'are the only definition of the zone code'}.`)
|
||||
|
|
@ -24,6 +24,8 @@
|
|||
"wall.bless-36x48",
|
||||
"wall.bless-24x36",
|
||||
"wall.bless-60x96",
|
||||
"bible-poster.36x60"
|
||||
"bible-poster.36x60",
|
||||
"upg100-territory-packet.page-1",
|
||||
"bless-india.range-badge-60x96"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const SHEET_FORMATS = {
|
|||
// assembled, so the catalogue checks and the i18n area build can see each one.
|
||||
export const FAMILY_KEYS = {
|
||||
mapSheet: 'posterDesign.family.mapSheet',
|
||||
upgZone: 'posterDesign.family.upgZone',
|
||||
packet: 'posterDesign.family.packet',
|
||||
cards: 'posterDesign.family.cards',
|
||||
wall: 'posterDesign.family.wall',
|
||||
|
|
@ -71,10 +72,33 @@ function sheetEntry(format) {
|
|||
}
|
||||
}
|
||||
|
||||
// UPG100's zone posters, drawn from the LIBRARY COPY of UPG100's zone code (library/packet/upg100Zones/, kept equal to
|
||||
// the plugin's own by check:upg100-zone-copy). They carry UPG100's own design names (`plugin`), so a drawn card here
|
||||
// replaces that plugin's no-preview card, and the names never change. The sample zone is one of UPG100's real zones.
|
||||
export const SAMPLE_ZONE_ID = 'middle-east-core'
|
||||
const ZONE_RENDERER = 'library/packet/upg100Zones/zone-sheet-print.js makeZoneSheet → library/packet/posterSheets.js generateSheet'
|
||||
function zoneEntry(format) {
|
||||
const f = SHEET_FORMATS[format]
|
||||
const page1 = format === 'packet'
|
||||
return {
|
||||
name: page1 ? 'upg100-territory-packet.page-1' : `upg100-territory-poster.${format}`,
|
||||
family: 'upgZone',
|
||||
labelKey: page1 ? 'posterDesign.tpl.upgZonePacket' : 'posterDesign.tpl.upgZonePoster',
|
||||
plugin: 'my-upg-100-list',
|
||||
size: f.size,
|
||||
px: f.px,
|
||||
renderer: ZONE_RENDERER,
|
||||
settings: { format },
|
||||
sample: SAMPLE_ZONE_ID,
|
||||
render: (ctx) => ctx.zoneSheet(format, SAMPLE_ZONE_ID),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} every configuration, in the gallery's order. */
|
||||
export function listConfigurations() {
|
||||
return [
|
||||
...Object.keys(SHEET_FORMATS).map(sheetEntry),
|
||||
...['letter', '3x3', '3x4', 'packet'].map(zoneEntry),
|
||||
...['full', 'lightweight'].map((variant) => ({
|
||||
name: `packet.${variant}`,
|
||||
family: 'packet',
|
||||
|
|
|
|||
Loading…
Reference in a new issue