From 1e53b531b004703315a5cf7955def1ed0d3ce1c8 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 04:26:50 +0000 Subject: [PATCH] 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. --- library/i18n/locales/ar/posterDesign.json | 7 +- library/i18n/locales/de/posterDesign.json | 7 +- library/i18n/locales/en/posterDesign.json | 7 +- library/i18n/locales/es/posterDesign.json | 7 +- library/i18n/locales/fr/posterDesign.json | 7 +- library/i18n/locales/hi/posterDesign.json | 7 +- library/i18n/locales/it/posterDesign.json | 7 +- library/i18n/locales/pt/posterDesign.json | 7 +- library/i18n/locales/ro/posterDesign.json | 7 +- library/i18n/locales/ru/posterDesign.json | 7 +- library/i18n/locales/zh/posterDesign.json | 7 +- library/packet/upg100Zones/clusters.json | 1687 +++++++++++++++++ library/packet/upg100Zones/legend-tree.js | 401 ++++ .../packet/upg100Zones/selector-evaluator.js | 165 ++ library/packet/upg100Zones/zone-geometry.js | 138 ++ .../packet/upg100Zones/zone-sheet-print.js | 41 + library/packet/upg100Zones/zone-sheet.js | 58 + library/packet/upg100Zones/zones-for-list.js | 104 + package.json | 6 +- .../poster-design/poster-styles.vue | 12 +- .../poster-design/scripts/check-catalogue.mjs | 20 +- .../scripts/check-upg100-zone-copy.mjs | 91 + .../poster-design/src/catalogue-names.json | 4 +- plugin-designs/poster-design/src/templates.js | 24 + 24 files changed, 2798 insertions(+), 30 deletions(-) create mode 100644 library/packet/upg100Zones/clusters.json create mode 100644 library/packet/upg100Zones/legend-tree.js create mode 100644 library/packet/upg100Zones/selector-evaluator.js create mode 100644 library/packet/upg100Zones/zone-geometry.js create mode 100644 library/packet/upg100Zones/zone-sheet-print.js create mode 100644 library/packet/upg100Zones/zone-sheet.js create mode 100644 library/packet/upg100Zones/zones-for-list.js create mode 100644 plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs diff --git a/library/i18n/locales/ar/posterDesign.json b/library/i18n/locales/ar/posterDesign.json index ab83863f5..02816daec 100644 --- a/library/i18n/locales/ar/posterDesign.json +++ b/library/i18n/locales/ar/posterDesign.json @@ -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": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.", diff --git a/library/i18n/locales/de/posterDesign.json b/library/i18n/locales/de/posterDesign.json index b4823a4b0..f99745d86 100644 --- a/library/i18n/locales/de/posterDesign.json +++ b/library/i18n/locales/de/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/en/posterDesign.json b/library/i18n/locales/en/posterDesign.json index 4f94c53aa..960706f52 100644 --- a/library/i18n/locales/en/posterDesign.json +++ b/library/i18n/locales/en/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/es/posterDesign.json b/library/i18n/locales/es/posterDesign.json index e7ebef669..55147e63c 100644 --- a/library/i18n/locales/es/posterDesign.json +++ b/library/i18n/locales/es/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/fr/posterDesign.json b/library/i18n/locales/fr/posterDesign.json index 92d86bbad..545cd8c51 100644 --- a/library/i18n/locales/fr/posterDesign.json +++ b/library/i18n/locales/fr/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/hi/posterDesign.json b/library/i18n/locales/hi/posterDesign.json index f20ff68aa..38bdc75af 100644 --- a/library/i18n/locales/hi/posterDesign.json +++ b/library/i18n/locales/hi/posterDesign.json @@ -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": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।", diff --git a/library/i18n/locales/it/posterDesign.json b/library/i18n/locales/it/posterDesign.json index a93daddee..0e42f6f12 100644 --- a/library/i18n/locales/it/posterDesign.json +++ b/library/i18n/locales/it/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/pt/posterDesign.json b/library/i18n/locales/pt/posterDesign.json index cf6c4ec2e..24f86ca1d 100644 --- a/library/i18n/locales/pt/posterDesign.json +++ b/library/i18n/locales/pt/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/ro/posterDesign.json b/library/i18n/locales/ro/posterDesign.json index a15bab493..fc9a39279 100644 --- a/library/i18n/locales/ro/posterDesign.json +++ b/library/i18n/locales/ro/posterDesign.json @@ -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.", diff --git a/library/i18n/locales/ru/posterDesign.json b/library/i18n/locales/ru/posterDesign.json index 5bfbc12c2..2652a74bb 100644 --- a/library/i18n/locales/ru/posterDesign.json +++ b/library/i18n/locales/ru/posterDesign.json @@ -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": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.", diff --git a/library/i18n/locales/zh/posterDesign.json b/library/i18n/locales/zh/posterDesign.json index a004ec6bd..bc5890019 100644 --- a/library/i18n/locales/zh/posterDesign.json +++ b/library/i18n/locales/zh/posterDesign.json @@ -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": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。", diff --git a/library/packet/upg100Zones/clusters.json b/library/packet/upg100Zones/clusters.json new file mode 100644 index 000000000..cb07a68e8 --- /dev/null +++ b/library/packet/upg100Zones/clusters.json @@ -0,0 +1,1687 @@ +{ + "version": 1, + "generated": "2026-05-28", + "source_total": 2106, + "source_endpoint": "/api/people-groups/list", + "validation_run": "2026-05-28 — all 13 selectors validated exact-match against the live source", + "lists": [ + { + "id": "west-africa", + "label": "West Africa", + "count": 118, + "selector": { + "type": "wagf_block", + "value": "west_africa" + }, + "bbox": [ + -21.3926, + 4.2189, + 27.2705, + 17.4735 + ] + }, + { + "id": "sudan", + "label": "Sudan", + "count": 105, + "selector": { + "type": "country_code", + "value": "SDN" + }, + "bbox": [ + 20.1672, + 9.0707, + 39.723, + 20.499 + ] + }, + { + "id": "brazil", + "label": "Brazil", + "count": 103, + "selector": { + "type": "country_code", + "value": "BRA" + }, + "bbox": [ + -77.9152, + -25.4802, + -32.2913, + 7.3352 + ] + }, + { + "id": "east-africa", + "label": "East Africa", + "count": 96, + "selector": { + "type": "wagf_block", + "value": "east_africa" + }, + "bbox": [ + 22.3455, + -14.3059, + 47.7947, + 20.0984 + ] + }, + { + "id": "laos", + "label": "Laos", + "count": 95, + "selector": { + "type": "country_code", + "value": "LAO" + }, + "bbox": [ + 99.8741, + 13.6759, + 108.2843, + 23.2186 + ] + }, + { + "id": "middle-east-core", + "label": "Middle East Core", + "count": 71, + "selector": { + "type": "country_codes", + "values": [ + "IRN", + "YEM", + "IRQ", + "OMN", + "SYR", + "LBN", + "PSE", + "QAT", + "SAU", + "JOR", + "BHR", + "ARE", + "KWT" + ] + }, + "bbox": [ + 31.1468, + 9.3265, + 63.7915, + 42.0085 + ] + }, + { + "id": "andean-caribbean", + "label": "Andean + Caribbean", + "count": 63, + "selector": { + "type": "country_codes", + "values": [ + "COL", + "PER", + "VEN", + "ECU", + "BOL", + "PRY", + "PAN", + "CUB", + "SUR", + "ATG", + "ABW", + "BHS", + "BRB", + "CYM", + "CUW", + "GRD", + "JAM", + "LCA", + "SXM", + "TCA", + "VCT", + "GUY" + ] + }, + "bbox": [ + -85.7034, + -31.2233, + -51.7537, + 31.0655 + ] + }, + { + "id": "central-africa", + "label": "Central Africa", + "count": 61, + "selector": { + "type": "wagf_block", + "value": "central_africa" + }, + "bbox": [ + 0.6063, + -11.2806, + 32.2325, + 15.7303 + ] + }, + { + "id": "europe", + "label": "Europe", + "count": 55, + "selector": { + "type": "wagf_region", + "value": "europe" + }, + "bbox": [ + -42.2659, + -27.2213, + 168.5645, + 85 + ] + }, + { + "id": "oceania-indonesia", + "label": "Oceania + Indonesia", + "count": 53, + "selector": { + "type": "union", + "selectors": [ + { + "type": "wagf_region", + "value": "oceania" + }, + { + "type": "country_code", + "value": "IDN" + } + ] + }, + "bbox": [ + 83.9392, + -43.091, + -136.017, + 21.4266 + ] + }, + { + "id": "mainland-se-asia-philippines", + "label": "Mainland SE Asia + Philippines", + "count": 50, + "selector": { + "type": "country_codes", + "values": [ + "VNM", + "MYS", + "THA", + "MMR", + "BRN", + "KHM", + "PHL" + ] + }, + "bbox": [ + 88.6434, + 0.084, + 129.7854, + 26.789 + ] + }, + { + "id": "maghreb-egypt", + "label": "Maghreb + Egypt", + "count": 47, + "selector": { + "type": "country_codes", + "values": [ + "DZA", + "LBY", + "MAR", + "MRT", + "ESH", + "EGY", + "TUN" + ] + }, + "bbox": [ + -21.8716, + 14.0878, + 37.0339, + 39.1426 + ] + }, + { + "id": "southern-africa-islands", + "label": "Southern Africa + Islands of East Africa", + "count": 39, + "selector": { + "type": "union", + "selectors": [ + { + "type": "wagf_block", + "value": "southern_africa" + }, + { + "type": "wagf_block", + "value": "islands_of_east_africa" + } + ] + }, + "bbox": [ + 0.638, + -38.0929, + 63.5885, + 4.4534 + ] + } + ], + "deaf_lists": [ + { + "id": "deaf-old-world", + "label": "Deaf — Old World (Africa + Europe + Middle East)", + "expected_count": 61, + "selector": { + "type": "deaf_predicate", + "regions": [ + "africa", + "middle_east", + "europe" + ] + } + }, + { + "id": "deaf-new-world-asia-pacific", + "label": "Deaf — New World + Asia + Pacific", + "expected_count": 62, + "selector": { + "type": "deaf_predicate", + "regions": [ + "north_america_&_non-spanish_caribbean", + "na", + "asia", + "latin_america_&_caribbean", + "oceania" + ] + } + } + ], + "asia_lists": [ + { + "id": "asia-india-rajasthan", + "label": "Northwest India", + "count": 116, + "selector": { + "type": "pg_slugs", + "values": [ + "arain-dogri", + "arain-punjabi", + "badi-marwari", + "bania-dogri", + "bania-kashmiri", + "baret", + "bargi", + "bhand-kashmiri", + "bhojki", + "bhopa", + "bhutta", + "bomba", + "brukpa", + "budhan", + "campa", + "cejara", + "chadwa", + "cheintz", + "chheepi", + "chobdar", + "chopan", + "chungar", + "cudigar", + "dadupanthi", + "damami", + "daoli", + "daule", + "dhaki", + "dhobi-dogri", + "dhola", + "dhyar", + "dokhpa", + "domal", + "dosali", + "gagra", + "galwan", + "ganz", + "gardi", + "ghihara", + "gorkun", + "gosain-kangri", + "gosain-rajasthani", + "gurru", + "hadi", + "hajjam-haryanvi", + "hajjam-kashmiri", + "hesi", + "hiranbaj", + "kawoj", + "khadim", + "kharol", + "khatik-chambeali", + "khatik-punjabi", + "kraal", + "kriyagar", + "kucband", + "lakhera-haryanvi", + "laskari", + "lohar-dogri", + "malaneese", + "mangnyar-muslims", + "manhas", + "manjygur-muslims", + "masku", + "moghol-dogri", + "moghol-marwari", + "mogia", + "mon-india", + "murhid", + "nagbansi-dogri", + "nagda", + "nai-mewari", + "najjar", + "namdagur", + "naqqas", + "nar", + "nicar", + "nyaria", + "pajabgar", + "pakhimara", + "pangwal-buddhists", + "pangwala", + "pinjara-punjabi", + "pohul", + "purig-pa", + "racband", + "rajkumhar", + "rajpurohit", + "ratal", + "rath", + "rehar", + "runwalas", + "sain", + "sanchia", + "sanhai", + "sansoi", + "santia", + "saraogi", + "sayyid-pahari", + "seok", + "shaksaz", + "shikari", + "shina-india", + "singiwala", + "sofi", + "sorgars", + "sudhan", + "sunga", + "swangla", + "tak", + "thavi", + "usta", + "van-baoriya", + "ved", + "vijabargiya", + "waza" + ] + }, + "bbox": [ + 69.9484, + 22.3586, + 79.972, + 35.9424 + ], + "provinceLabel": "Rājasthān + Haryāna + Himāchal Pradesh + Jammu and Kashmīr + Punjab + Chandīgarh + Ladākh" + }, + { + "id": "asia-india-andhra-pradesh", + "label": "South-Central India", + "count": 107, + "selector": { + "type": "pg_slugs", + "values": [ + "accukattavandlu", + "aguru", + "aiyarakulu", + "andhra-vaisnawa", + "ansari-momin-telugu", + "arakala", + "are-bondili", + "are-kapu", + "are-katika", + "atollu", + "attagara", + "bandollu", + "bania-agarwal-telegu", + "bayita-kammara", + "berad", + "bhatrajulu", + "bhondari", + "bindla", + "bolla", + "budakukkala", + "cakketakare", + "camadollu", + "cippollu", + "cundollu", + "dammali", + "dasari", + "gandalla", + "gandla", + "gangireddula", + "ganta-sayebulu", + "garadi", + "gavara", + "godagali", + "godari", + "gosangi", + "guddi-eluguwallu", + "gurika", + "hajjam-telugu", + "hatgar", + "jains-telugu", + "jakkula", + "jandra", + "jethi", + "kachi", + "kaikali", + "karnam", + "karnibhakthulu", + "katipamula", + "khatik-telugu", + "kommu-dasari", + "konda-kammara", + "kuliya", + "kurakula", + "lakkamari-kapu", + "madiga-dasu", + "majjula", + "mala-masti", + "malis", + "mandula", + "manna-dhora", + "matangi", + "medari", + "mitha-ayyalavar", + "moghal-telugu", + "mondivogula", + "muchi-chamar", + "muli", + "musti-chenchu", + "nagavamsam", + "nakhasa", + "neyyala", + "ontaris", + "padigarajulu", + "pagativeshagallu", + "paky", + "pambala", + "pamidi", + "pamula", + "pancaratra-srivaisnavas-brahmans", + "pariki-muggula", + "patkars", + "rajannalu", + "reddi-dora", + "reddika", + "runjalavallu", + "sadaru", + "sadhu-chetty", + "sakunapaksollu", + "samayavallu", + "saradagallu", + "senapathi", + "silavantulu", + "sindhollu", + "swakulasali-koshti", + "tamboli-telugu", + "thogataveera-kshatriya", + "tirugatigantla", + "tolakari", + "udupula", + "uppara", + "vaikhanasa-brahmans", + "verra-musti", + "vipravinodulu", + "yata", + "yatagiri", + "yatla", + "yite" + ] + }, + "bbox": [ + 76.462, + 12.5091, + 84.8294, + 19.785 + ], + "provinceLabel": "Andhra Pradesh + Telangāna" + }, + { + "id": "asia-india-odisha", + "label": "East India", + "count": 88, + "selector": { + "type": "pg_slugs", + "values": [ + "badhai-chhattisgarhi", + "baha-i", + "bairagi-chhattisgarhi", + "bairagi-odia", + "bangti", + "banka", + "bargah", + "bari-odia", + "bauti", + "bavuri", + "beldar-chhattisgarhi", + "bhangi-chhattisgarhi", + "bharewa", + "bharia-bhumia-chhattisgarhi", + "bhattra-bhatri", + "bhoi-odia", + "bhunjia", + "bondo", + "brahman-jyoti", + "candhai-maru", + "chaikwa", + "chatarkheya", + "cherua", + "citrakar", + "daitapati", + "damla", + "darji-odia", + "dhakad-halbi", + "dhera", + "didayi", + "dom-chhattisgarhi", + "eneti-dora", + "ghadwa", + "ghantarghada", + "ghogia", + "ghusuria", + "godra", + "gosain-chhattisgarhi", + "guna", + "irika", + "janughanta", + "jogi-halbi", + "jogi-odia", + "kachchi-alia", + "kalal-sadri", + "kamila", + "kapasiya", + "kasar-odia", + "khadura", + "khairwar-chhattisgarhi", + "khandwal", + "khati-odia", + "khatik-chhattisgarhi", + "kobari", + "kol-chhattisgarhi", + "koraga", + "kuda", + "kuli", + "kurunga", + "kusta", + "liari", + "maghia", + "mahuria", + "malia", + "markande-kanjia", + "mundapotta", + "munnewar", + "nai-odia", + "naksia", + "noliya", + "oriya", + "panara", + "pando", + "panka-odia", + "parenga", + "radhi", + "rana", + "sabakhia", + "samasi", + "sanei", + "sayyid-chhattisgarhi", + "sayyid-odia", + "sinduria", + "son-koli", + "suda", + "thanapati", + "tharua", + "tula-vina" + ] + }, + "bbox": [ + 80.3325, + 17.5357, + 87.6538, + 23.9578 + ], + "provinceLabel": "Odisha + Chhattīsgarh" + }, + { + "id": "asia-india-gujarat", + "label": "West India", + "count": 90, + "selector": { + "type": "pg_slugs", + "values": [ + "agaria", + "agri", + "anjana-patel-gujarati", + "bafan", + "bairagi-gujarati", + "baloch", + "bamcha", + "banjania", + "barot", + "barthari", + "bhodala", + "borpi", + "caki", + "champta", + "cunara", + "dafer", + "dangasiya", + "dasnami-bawa", + "depala", + "dudhwala", + "gadhai", + "galiara", + "garmatan", + "garvi", + "gavli", + "ghantiya", + "golarana", + "gosain-gujarati", + "hajjam-gujarati", + "halaypotra", + "hingora", + "hingorza", + "jat-muslim", + "juneja", + "kahar-gujarati", + "kamalia", + "kambodia-bhagat", + "kapadi", + "kasar-gujarati", + "kasbati", + "kathi", + "ker", + "khalpa", + "khaskeli", + "khatik-gujarati", + "langa", + "machhiyara", + "majothi", + "makrani", + "mandali-muslims", + "mer", + "mir", + "miyana", + "moghol-gujarati", + "mujavar", + "muslim-khatri", + "muslim-lakhera", + "muslim-patel", + "mutwa", + "narsinghpura", + "nayak-muslims", + "node", + "padhar", + "panar", + "paradhi", + "patanwadia", + "patni-jamat", + "patni-vankars", + "pomla", + "rathodia", + "raysipotra", + "royma", + "sagar", + "sandhai-muslims", + "saranias", + "sayyid-gujarati", + "shravan", + "siddi", + "sompura-salat", + "tai", + "targala", + "theba", + "tirgar", + "turi-barot", + "vadi", + "vahivanca-barot", + "vayati", + "vyapari", + "wagheri", + "wandharo" + ] + }, + "bbox": [ + 68.1752, + 20.1033, + 74.3448, + 24.7906 + ], + "provinceLabel": "Gujarāt" + }, + { + "id": "asia-india-karnataka", + "label": "Southwest India", + "count": 86, + "selector": { + "type": "pg_slugs", + "values": [ + "adiyan", + "agasa", + "ansari-momin-kannada", + "arasu", + "ayri", + "bagga", + "baira", + "balasantoshi", + "bandi", + "bania-kannada", + "bania-konkani", + "batada", + "bayil-kammara", + "bayil-pattar", + "bellara", + "bestha", + "chaptegar", + "charodi", + "chenna-dasar", + "dombi-dasa", + "durga-murga", + "gabit", + "galada-kokani", + "ganti-chores", + "gatti", + "gauli", + "ghadi", + "godda", + "gomantak-maratha", + "gond-kannada", + "gudikara", + "guraw", + "hajjam-kannada", + "halleer", + "harkantra", + "hasalaru", + "helawa", + "holeya-dasari", + "jatigar", + "jaweri", + "jogi-telugu", + "jogi-tulu", + "khakar", + "kolari", + "komara-panta", + "koosa", + "koraga-korra", + "kottari", + "koyava", + "kudiya", + "kudubi", + "kummara-kumbara", + "kurahina-setti", + "ladar", + "maila", + "malaikudi", + "maleru", + "manser", + "meda", + "mhalo", + "mith-gauda", + "mogaveera", + "mundala", + "nadors", + "nai-kannada", + "nairy", + "natua", + "nawaiyat", + "padarthis", + "padiyar", + "pambada", + "pattegar", + "pendar", + "pinjara-kannada", + "rachavar", + "raddi", + "rajapuri", + "raneyar", + "raval", + "raya-rawat", + "sapalyas", + "sayyid-kannada", + "soligaru", + "sudir", + "thammadi", + "vilyagaras" + ] + }, + "bbox": [ + 73.2695, + 11.4418, + 78.7305, + 18.4466 + ], + "provinceLabel": "Karnātaka + Goa" + }, + { + "id": "asia-india-kerala", + "label": "Malabar Coast", + "count": 78, + "selector": { + "type": "pg_slugs", + "values": [ + "allar", + "ambalavasi", + "anthur-nair", + "arayavati", + "ayyanavar", + "bakuda", + "bania-malayalam", + "bowis-mogeyar", + "cakkala", + "cakkyar-nambiyar", + "chakkiyar", + "chamar-muchi-malayalam", + "chengazhi-nambiar", + "cingatanmar", + "dheevara", + "erawallan", + "ezhavathi", + "ilayatu", + "indo-french", + "indo-vietnamese", + "kakkalan", + "kalanadies", + "kalladi", + "kallattu-kurup", + "karimpalan", + "kavara", + "kavuthiyan", + "kidaran", + "kocu-wellan", + "kollan", + "kootan", + "kumar-kshatriya", + "kummara-kusuvan", + "kunduvadian", + "kurukkal", + "laccadive", + "malai-pandaram", + "malayalar", + "maran-nairs", + "marasari", + "mavilan", + "moger-malayalam", + "moothan", + "mukkari", + "musari", + "muttatu", + "nalkadaya", + "nambutiris", + "narsanna", + "nasuvan", + "nayadi", + "otans", + "padannan", + "palakad-tandans", + "pandithar", + "pathinaickans", + "pathiyan", + "pisaroti", + "poduval", + "pulisari", + "puspakan-unni", + "reddi", + "samanthan", + "sonavar", + "thachanad-moopan", + "tiyattunni", + "urindavan", + "vadugan", + "valinjiyar", + "vetan", + "vettalaikarans", + "vettiyan", + "vilkurup", + "visavan", + "wainadan-cetti", + "wainadan-kader", + "wainadan-pulayan", + "wayanad-pathiyan" + ] + }, + "bbox": [ + 72.8992, + 7.9875, + 80.5765, + 13.0102 + ], + "provinceLabel": "Kerala + Puducherry + Lakshadweep" + }, + { + "id": "asia-india-tamil-nadu", + "label": "Southeast India + Andaman Islands", + "count": 73, + "selector": { + "type": "pg_slugs", + "values": [ + "alavan", + "andamanese", + "ansari-momin-tamil", + "anuppan", + "aranadan", + "baig", + "bhatu-andamani", + "bondili", + "brahmin-andamani", + "cekkala", + "chamar-muchi-tamil", + "chayakkaran", + "coorgi-andamanese", + "dasa-palanjika", + "dayare", + "dhobi-tamil", + "gangawar", + "great-andamanese", + "gudugudupandi", + "gurukkal", + "jains-tamil", + "jarawas", + "jati-pillai", + "kambalattan", + "kambar", + "kannada-naicker", + "kannada-saineegar", + "karaiyar", + "karlar", + "karna-sale", + "katesar", + "kattiakaran", + "kayalar", + "kepmari", + "koliyan", + "kongu-vaisnawa", + "kongu-vellala-gounder", + "kota", + "krishnavaka", + "kudumi", + "kuluvan", + "kummara-kummari", + "kunnuva-vellalar", + "malai-vellala", + "maleyan", + "maniyattikkaran", + "mudugar", + "nakkala", + "namdeo-maratha", + "nanjil-mudali", + "onge", + "pannadi", + "panniandi", + "parkavakulam", + "parvatarajakulam", + "pattanavan", + "perumkollar", + "salivahanam", + "savalakkarar", + "sayyid-tamil", + "semman", + "senaithalaivar", + "sentinelese", + "siviar", + "tambiran", + "telengi", + "tholuva-naicker", + "thoraiyar", + "tondman", + "uppillian", + "urikkar-naicker", + "vellaiyan-kuppam-padaiyaci", + "vettaikkaran" + ] + }, + "bbox": [ + 74.7588, + 7.4577, + 94.7701, + 13.968 + ], + "provinceLabel": "Tamil Nādu + Andaman and Nicobar Islands" + }, + { + "id": "asia-india-madhya-pradesh", + "label": "Central India", + "count": 88, + "selector": { + "type": "pg_slugs", + "values": [ + "anjana", + "attar-saibulu", + "baharupi", + "banchada", + "baraua", + "barda", + "bari-hindi", + "bari-marathi", + "basudeva", + "berar", + "bhanumati", + "bharwad", + "bhoi-marathi", + "bhurtia", + "chari", + "chitari", + "citrakathi", + "dhawad", + "dhed", + "dom-marathi", + "gachha", + "garodi", + "garpagari", + "gondhali", + "gopal", + "hajjam-hindi", + "hardas", + "hatkar", + "jadam", + "jains-hindi", + "jhadi", + "kachchi-kushwaha", + "kachera-bagheli", + "kaghzi", + "kandera", + "kapewar", + "kasar-hindi", + "kasikapadi", + "kathar", + "kharvi", + "khatik-marathi", + "kokaniz", + "kolhati", + "kudimahan", + "ladia", + "ladsi", + "lahangir", + "lajjhar", + "lodha-marathi", + "lonar", + "mana", + "manbhav", + "mankar", + "mitna", + "mondiwar", + "muskhan", + "muslim-gond", + "muslim-kachera", + "nahal", + "naikpod", + "naita", + "namdeo", + "nandiwala", + "naoda", + "nat-bagheli", + "neeli", + "nema", + "nilkanth", + "pangul", + "phularis", + "pinjara-marathi", + "pochammalollu", + "prabhu-patane", + "rujjhar", + "sagharias", + "sahi", + "sais", + "sanghar", + "sarodi", + "sikligar", + "sisgars", + "soria", + "sutar-mistry", + "takari", + "velip", + "waidu", + "yelmar", + "zamral" + ] + }, + "bbox": [ + 69.6832, + 14.969, + 83.1757, + 26.995 + ], + "provinceLabel": "Madhya Pradesh + Mahārāshtra + Dādra and Nagar Haveli and Damān and Diu" + }, + { + "id": "asia-india-uttarakhand", + "label": "North India + Nepal", + "count": 92, + "selector": { + "type": "pg_slugs", + "values": [ + "ahmadiya", + "anwal", + "arain-kannauji", + "badhik", + "badi", + "balahar", + "banpar", + "bargala", + "bargujar", + "bhuiyar", + "bisati", + "camma", + "charwaha", + "chaudangsi", + "dhamin", + "dhekaro", + "gadhia", + "gadihar", + "gandharv", + "habura", + "hijra", + "hindu-santarash", + "jadupetia", + "jains-maithili", + "jarkhandi-hajjam", + "jarkhandi-julaha", + "jharkhandi-nai", + "jimdar", + "jogi-angika", + "kadar", + "kankali", + "khangar-bundeli", + "khatbune", + "kuthliya-bora", + "modaks", + "mujavir", + "mukeri", + "nagbansi", + "nalband", + "pankhiya", + "patari", + "puran", + "putliwale", + "rai-bhat-muslims", + "rajdhop", + "raji-india", + "rastogi", + "rave", + "sahisia", + "sayyid-garhwali", + "sheikhzade", + "shershabadia", + "sukiyar", + "tagewale", + "thakurai", + "tikulhara", + "tilli", + "turuk-pasi", + "baniya", + "barae", + "bhand", + "bhediyar", + "bodo", + "byangsi", + "chidmar", + "churaute", + "dhobi", + "dhunia", + "dura", + "gangai", + "gyasumdoba", + "halkhor", + "haluwai", + "jaine", + "kaike", + "kamar", + "kanu", + "kayastha", + "lodha", + "makalu-sherpa", + "mali", + "mallah", + "marwadi", + "nurang", + "pahari-nagarkotis", + "puma", + "rajbhar", + "raji-nepal", + "shaikh-bhojpuri", + "sudhi", + "surel", + "tseku" + ] + }, + "extraCountryCodes": [ + "NPL" + ], + "bbox": [ + 76.0644, + 22.1727, + 88.8858, + 31.2428 + ], + "adm0": [ + "NPL" + ], + "provinces": [ + "Uttarākhand", + "Uttar Pradesh", + "Bihār", + "Jhārkhand", + "Delhi" + ], + "provinceLabel": "North India + Nepal" + }, + { + "id": "asia-india-west-bengal", + "label": "Northeast India + Bangladesh + Bhutan", + "count": 87, + "selector": { + "type": "pg_slugs", + "values": [ + "aiton", + "angarok", + "ansari-momin-bengali", + "bagti", + "baida", + "baisya", + "bajandar", + "banai", + "banik-bania", + "barua", + "bhakta", + "bhalla", + "bhujel", + "bilaspuri", + "byadh", + "chamar-assamese", + "chunari", + "dalu-india", + "dhal-kstriya", + "dhimal", + "ghazi", + "hajjam-assamese", + "hajjam-bengali", + "haluadas", + "jains-bengali", + "kakmara", + "kanda", + "kapuria", + "kasar-bengali", + "khairwar-bengali", + "khalifa", + "khamba", + "khamyang", + "khatik-bengali", + "khowa", + "koiren", + "kumhar-assamese", + "kurol", + "lois", + "mahisyadas", + "malakar", + "man", + "maria-muslims", + "matak", + "mirshikar", + "momba", + "mukhi", + "na", + "nai-assamese", + "namasudra-assamese", + "paharia", + "pangans", + "pinjara-dhunia-bengali", + "sabar", + "saha", + "serdukpen", + "shabdakar", + "siyalgar", + "sudra-das", + "sunuwar", + "sutradhar", + "taiphake", + "tamboli-bengali", + "teli-assamese", + "thanga", + "tripuri-kumhar", + "tripuri-namasudra", + "turun", + "yakha", + "yatibi", + "zakhrin", + "brokkat", + "chali", + "dalu-bangladesh", + "deaf-bhutanese", + "doya", + "dzalakha", + "gongduk", + "koda", + "kurtop", + "lakha", + "lunape", + "matpa", + "nupbi", + "nyenpa", + "ole", + "ushoi" + ] + }, + "extraCountryCodes": [ + "BGD", + "BTN" + ], + "bbox": [ + 84.6438, + 21.028, + 98.3478, + 29.4168 + ], + "provinceLabel": "West Bengal + Sikkim + Meghālaya + Assam + Arunāchal Pradesh + Manipur + Tripura + Bangladesh + Bhutan", + "adm0": [ + "BGD", + "BTN" + ] + }, + { + "id": "asia-china-yunnan-province", + "label": "Southern China", + "count": 109, + "selector": { + "type": "pg_slugs", + "values": [ + "a-ou", + "ai-cham", + "akeu", + "aling", + "alu", + "angku", + "ani", + "aoka", + "apu", + "baonuo", + "beidongnuo", + "bit-china", + "black-thai", + "bugan", + "buyang", + "cai", + "changpao", + "chesu", + "dianbao", + "diao", + "doupo", + "eastern-samadu", + "eastern-xiangxi-miao", + "ga-mong", + "gao", + "gelao", + "guaigun", + "ha-li", + "hagei", + "hongjin-tai", + "hu", + "huishui-miao", + "jiamao-li", + "jiasou", + "jino", + "khmu", + "kong-ge", + "kucong", + "laba", + "labapo", + "laowu", + "lati-china", + "lawu", + "liang-e", + "linghua", + "liping-baheng", + "liude", + "liujia", + "longjia", + "lu", + "lupanshui-miao", + "mang-china", + "manmet", + "mashan-miao", + "meifu-li", + "mengwu", + "mishaba-laluo", + "mo", + "mosuo", + "mozhihei", + "muda", + "muzi", + "naheng", + "nanjingren", + "northern-awu", + "northern-dong", + "northern-muji", + "numao", + "palyu", + "panxian-nasu", + "pingdi-yao", + "popei", + "pubiao", + "puman", + "pusha", + "qanu", + "qiubei-zhuang", + "qixingmin", + "rao", + "rumai-de-ang", + "sanda", + "sanqiao", + "shenzhou", + "sichuan-mongols", + "ta-er", + "tagu", + "tai-lu", + "tai-pong", + "tulao", + "tusu", + "wenma-zhuang", + "western-gaisu", + "western-samadu", + "wopu", + "wunai", + "xi", + "xialusi", + "xiaoliangshan-nosu", + "xijima", + "xinping-lalu", + "xuzhang-lalu", + "ya", + "yanghuang", + "yerong", + "yizi", + "youmai", + "younuo-bunu", + "yunnan-mongol", + "yunnan-shui" + ] + }, + "bbox": [ + 96.3204, + 17.3374, + 113.2066, + 29.9169 + ], + "provinceLabel": "Yunnan Province + Guizhou Province + Guangxi Zhuang Autonomous Region + Hunan Province + Hainan Province", + "provinces": [ + "Yunnan Province", + "Guizhou Province", + "Guangxi Zhuang Autonomous Region", + "Hunan Province", + "Hainan Province" + ] + }, + { + "id": "asia-china-tibet-autonomous-region", + "label": "Mainland China + Mongolia", + "count": 53, + "selector": { + "type": "pg_slugs", + "values": [ + "adi", + "ainu-china", + "alxa-mongols", + "bogar-lhoba", + "bogol", + "boyu-tibetans", + "bunan", + "buryat", + "butuo-nosu", + "cona-monba", + "darang-deng", + "enger-yugur", + "ersu", + "geman-deng", + "heishui", + "henan-mongols", + "keji", + "kyrgyz", + "luzu", + "manyak", + "medog-monba", + "miguba", + "ming", + "minyak", + "naju", + "namuyi", + "nghari-tibetans", + "olot", + "ongkor", + "puroik", + "saman", + "saragh-yugur", + "sarikoli-tajiks", + "shixing", + "suodi-nosu", + "tongren-bonan", + "uzbeks", + "wakhi-china", + "western-xibe", + "wutun", + "yidu-lhoba", + "yinuo-nosu", + "za" + ] + }, + "extraCountryCodes": [ + "MNG", + "PRK", + "JPN" + ], + "bbox": [ + 69.2237, + 24.2385, + 130.4094, + 51.3865 + ], + "provinceLabel": "Tibet Autonomous Region + Sichuan Province + Xinjiang Uyghur Autonomous Region + Gansu Province + Inner Mongolia Autonomous Region + Qinghai Province + Heilongjiang Province + Mongolia + Japan + North Korea", + "provinces": [ + "Tibet Autonomous Region", + "Sichuan Province", + "Xinjiang Uyghur Autonomous Region", + "Gansu Province", + "Inner Mongolia Autonomous Region", + "Qinghai Province", + "Heilongjiang Province" + ], + "adm0": [ + "MNG" + ] + } + ], + "asia_unsolved": { + "blocked_by": "admin-2 boundaries", + "south_asia_total": 952, + "south_asia_india_only": 855, + "north_asia_total": 159, + "north_asia_china_only": 154, + "central_asia_total": 10, + "north_east_asia_total": 7, + "unlock_path": "Regions tab → admin-2 layer (GADM India + China)", + "solved": true, + "solved_on": "2026-06-06", + "solved_note": "12 admin-1 lists (10 India + 2 China) wired into lists via pg_slugs selector; India 855 / China 154; each list <=100. Resolved name->slug against geo-pgs-full.json (2106 rows, slug 1:1)." + }, + "uncovered_scatter": { + "n_a_region_total": 19, + "null_region_total": 3, + "decision_pending": "fold into nearest geographic neighbour OR create a 'Diaspora and Micro-territories' list. The product decision is open." + }, + "asia_solved_validation": "2026-06-06 — pg_slugs predicate exact-count for all 12 Asia lists against live 2106-row source; 0 cross-country leaks", + "uncovered_lists": [ + { + "id": "uncovered-scatter", + "label": "Scattered / Diaspora", + "count": 8, + "contiguous": false, + "contiguityExempt": true, + "flag": "allowed non-contiguous bucket (S5-D3) — diaspora/island/micro-territory strays + 2 unstamped CHN border PGs", + "selector": { + "type": "country_codes", + "values": [ + "AND", + "CCK", + "GUF", + "USA" + ] + }, + "extra_slugs": [ + "jing", + "pana-china" + ], + "bbox": [ + -150.7534, + -18.855, + 123.4288, + 50.0639 + ] + }, + { + "id": "uncovered-nas-a", + "label": "Central Asia + Pakistan", + "count": 55, + "contiguous": true, + "contiguityExempt": false, + "flag": "NAS-A real frontier zone (09b-asia-contiguity-v2 N-D6): MERGE of the former uncovered-sa-west (AFG+PAK) + uncovered-central-asia (AZE/KGZ/TJK/UZB) into ONE zone owning BOTH the legend count AND the ADM0 render coverage. AFG-PAK-UZB-TJK-KGZ form one contiguous land-border core; AZE is the flagged Caspian-separated near-member (S5-D2). KAZ stays deaf-only (its only PG is deaf-kazakhstanis). Target coverage 55 (design directive; uncovered coverage counts are carried through, not recomputable from admin-membership — NOTE-COUNT). ID kept in the uncovered-* family (NOT asia-frontier-nas-a) because zone-geometry.isAsiaZone() routes any asia-*-prefixed id down the ADM1 listId path — an asia-* id would resolve 0 ADM0 polys and render NOTHING. uncovered-* keeps it on the country_codes/ADM0 render path; re-prefixing is the render-layer's NEXT slice, not this one.", + "selector": { + "type": "country_codes", + "values": [ + "AFG", + "AZE", + "KGZ", + "PAK", + "TJK", + "UZB" + ] + }, + "bbox": [ + 42.6538, + 23.2204, + 79.3424, + 44.9954 + ], + "provinceLabel": "Pakistan + Afghanistan + Uzbekistan + Tajikistan + Kyrgyzstan + Kazakhstan + Azerbaijan" + } + ] +} \ No newline at end of file diff --git a/library/packet/upg100Zones/legend-tree.js b/library/packet/upg100Zones/legend-tree.js new file mode 100644 index 000000000..b6cc163e1 --- /dev/null +++ b/library/packet/upg100Zones/legend-tree.js @@ -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 → 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.) 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; diff --git a/library/packet/upg100Zones/selector-evaluator.js b/library/packet/upg100Zones/selector-evaluator.js new file mode 100644 index 000000000..abdd5a2a2 --- /dev/null +++ b/library/packet/upg100Zones/selector-evaluator.js @@ -0,0 +1,165 @@ +// selector-evaluator.js — slice 01 (my-upg-100-list) +// +// Pure function: evaluate(selector, records) -> Set +// +// `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: [ , , ... ] } +// -> 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 (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 + * @param {object} selector one of the 6 documented selector shapes + * @param {Array} records ALREADY-normalized UPG records + * @returns {Set} 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; diff --git a/library/packet/upg100Zones/zone-geometry.js b/library/packet/upg100Zones/zone-geometry.js new file mode 100644 index 000000000..d729f1e7d --- /dev/null +++ b/library/packet/upg100Zones/zone-geometry.js @@ -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; diff --git a/library/packet/upg100Zones/zone-sheet-print.js b/library/packet/upg100Zones/zone-sheet-print.js new file mode 100644 index 000000000..802b15daa --- /dev/null +++ b/library/packet/upg100Zones/zone-sheet-print.js @@ -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(), + }) + } +} diff --git a/library/packet/upg100Zones/zone-sheet.js b/library/packet/upg100Zones/zone-sheet.js new file mode 100644 index 000000000..1f614d8a7 --- /dev/null +++ b/library/packet/upg100Zones/zone-sheet.js @@ -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 }; +} diff --git a/library/packet/upg100Zones/zones-for-list.js b/library/packet/upg100Zones/zones-for-list.js new file mode 100644 index 000000000..cacb420b1 --- /dev/null +++ b/library/packet/upg100Zones/zones-for-list.js @@ -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/.json the four zone-bearing keys, same shape as clusters.json: + * lists / deaf_lists / asia_lists / uncovered_lists + * 2. data/zoning-declarations..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 ` 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) } diff --git a/package.json b/package.json index c7069314b..9ddde7c9d 100644 --- a/package.json +++ b/package.json @@ -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//.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/.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/.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.", diff --git a/plugin-designs/poster-design/poster-styles.vue b/plugin-designs/poster-design/poster-styles.vue index d83478d8c..2c195b2be 100644 --- a/plugin-designs/poster-design/poster-styles.vue +++ b/plugin-designs/poster-design/poster-styles.vue @@ -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). diff --git a/plugin-designs/poster-design/scripts/check-catalogue.mjs b/plugin-designs/poster-design/scripts/check-catalogue.mjs index 7a4a7fe17..b020ec949 100644 --- a/plugin-designs/poster-design/scripts/check-catalogue.mjs +++ b/plugin-designs/poster-design/scripts/check-catalogue.mjs @@ -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: ''`) 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 diff --git a/plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs b/plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs new file mode 100644 index 000000000..ea8837194 --- /dev/null +++ b/plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs @@ -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/) 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'}.`) diff --git a/plugin-designs/poster-design/src/catalogue-names.json b/plugin-designs/poster-design/src/catalogue-names.json index 2f65f2cf3..0e94236c0 100644 --- a/plugin-designs/poster-design/src/catalogue-names.json +++ b/plugin-designs/poster-design/src/catalogue-names.json @@ -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" ] } diff --git a/plugin-designs/poster-design/src/templates.js b/plugin-designs/poster-design/src/templates.js index 183167327..f68009740 100644 --- a/plugin-designs/poster-design/src/templates.js +++ b/plugin-designs/poster-design/src/templates.js @@ -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} 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',