diff --git a/internal/profile-meta.mjs b/internal/profile-meta.mjs index 3da0f7b94..57e9b2a71 100644 --- a/internal/profile-meta.mjs +++ b/internal/profile-meta.mjs @@ -12,9 +12,42 @@ * posterDesign { family, name, size, mobileFriendly, labelKey?, instance? } or an array of them: the poster * design(s) this profile shows, listed by Poster Design's Styles. Carried as written, ONLY when present * (every other manifest keeps its bytes); check:poster-design-catalogue validates it. + * posterDesignLabels GENERATED beside it: { : { : title } } for every block with a labelKey, + * resolved here from library/i18n/locales//.json. Poster Design's bundle ships only the + * catalogue areas its own code names, so a plugin's own area (upgMap, …) would be missing there and the card + * would read English; carrying the titles in the manifest works for any area, with no Poster Design rebuild. */ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'library', 'i18n', 'locales') +// A catalogue key ("area.a.b") in every locale that has it: { en: '…', ar: '…' }; {} when no locale does. +function catalogueTitles(key) { + const [area, ...path] = String(key || '').split('.') + const out = {} + if (!area || !path.length || !existsSync(LOCALES)) return out + for (const loc of readdirSync(LOCALES)) { + const f = join(LOCALES, loc, `${area}.json`) + if (!existsSync(f)) continue + try { + let o = JSON.parse(readFileSync(f, 'utf8')) + for (const p of path) o = o && typeof o === 'object' ? o[p] : undefined + if (typeof o === 'string' && o.trim()) out[loc] = o + } catch { /* a locale file that does not parse names nothing */ } + } + return out +} +function posterDesignLabels(pd) { + const out = {} + for (const b of Array.isArray(pd) ? pd : [pd]) { + if (b && typeof b.name === 'string' && typeof b.labelKey === 'string') { + const titles = catalogueTitles(b.labelKey) + if (Object.keys(titles).length) out[b.name] = titles + } + } + return out +} const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'] @@ -82,7 +115,7 @@ export function profileMetaFields(dir, profileName) { const m = readProfileMeta(dir, profileName) // visibility is emitted ONLY when it is "public": absent means team, so every manifest in the // tree today keeps the bytes it has and nothing becomes visible by upgrading the generator. - return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}) } + return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}), ...(m.posterDesign && Object.keys(posterDesignLabels(m.posterDesign)).length ? { posterDesignLabels: posterDesignLabels(m.posterDesign) } : {}) } } /** A bundle is production if ANY of its profiles says so. */ diff --git a/library/i18n/locales/ar/biblePosters.json b/library/i18n/locales/ar/biblePosters.json index ea729d9ba..cbaafd680 100644 --- a/library/i18n/locales/ar/biblePosters.json +++ b/library/i18n/locales/ar/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "كورنثوس الأولى", "1PE": "بطرس الأولى", "JOS": "يشوع" + }, + "posterDesign": { + "label": "ملصق سفر من الكتاب المقدس" } } diff --git a/library/i18n/locales/ar/posterDesign.json b/library/i18n/locales/ar/posterDesign.json index 284c2d1cc..02816daec 100644 --- a/library/i18n/locales/ar/posterDesign.json +++ b/library/i18n/locales/ar/posterDesign.json @@ -28,7 +28,11 @@ "no": "لا", "printHome": "أي طابعة منزلية", "printShop": "مطبعة (ملف PDF، صفحة لكل ورقة)", - "layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره." + "layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره.", + "sideLanes": "{dims}، أعمدة جانبية", + "packetPage1": "{paper} (الصفحة 1 من حزمة الصلاة)", + "panels": "{dims}، عدد الألواح: {n}", + "anySize": "أي حجم من جدول الأحجام المشترك" }, "family": { "mapSheet": "صفحات الخرائط", @@ -36,7 +40,9 @@ "cards": "بطاقات الصلاة", "wall": "ألواح الجدار", "mapPrint": "طباعة الخريطة", - "plugins": "من إضافات أخرى" + "plugins": "من إضافات أخرى", + "experimental": "تجريبية (قيد العمل)", + "upgZone": "ملصقات مناطق UPG100" }, "tpl": { "mapSheetCountry": "صفحة خريطة الدولة", @@ -45,7 +51,9 @@ "packetLightweight": "حزمة الصلاة، مختصرة", "cards": "بطاقات الصلاة", "blessPanels": "ألواح جدار البركة", - "mapPrint": "طباعة الخريطة" + "mapPrint": "طباعة الخريطة", + "upgZonePoster": "ملصق منطقة UPG100", + "upgZonePacket": "حزمة صلاة UPG100، الصفحة 1" }, "missing": { "bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.", diff --git a/library/i18n/locales/ar/upgMap.json b/library/i18n/locales/ar/upgMap.json index 40ff2c2c7..bd77e9a42 100644 --- a/library/i18n/locales/ar/upgMap.json +++ b/library/i18n/locales/ar/upgMap.json @@ -19,5 +19,6 @@ "displayName": "ملصقات UPG100", "wallHeading": "ملصقات UPG100", "wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ", - "peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة" + "peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة", + "posterDesignTerritory": "ملصق منطقة UPG-100" } diff --git a/library/i18n/locales/de/biblePosters.json b/library/i18n/locales/de/biblePosters.json index 575b0b3f3..2825f95db 100644 --- a/library/i18n/locales/de/biblePosters.json +++ b/library/i18n/locales/de/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1. Korinther", "1PE": "1. Petrus", "JOS": "Josua" + }, + "posterDesign": { + "label": "Bibelbuch-Poster" } } diff --git a/library/i18n/locales/de/posterDesign.json b/library/i18n/locales/de/posterDesign.json index 20a0879b7..f99745d86 100644 --- a/library/i18n/locales/de/posterDesign.json +++ b/library/i18n/locales/de/posterDesign.json @@ -28,7 +28,11 @@ "no": "Nein", "printHome": "Jeder Drucker zu Hause", "printShop": "Eine Druckerei (ein PDF, eine Seite pro Bogen)", - "layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist." + "layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist.", + "sideLanes": "{dims}, seitliche Namensspalten", + "packetPage1": "{paper} (Seite 1 der Gebetsmappe)", + "panels": "{dims}, Tafeln: {n}", + "anySize": "Jede Größe aus der gemeinsamen Größentabelle" }, "family": { "mapSheet": "Kartenblätter", @@ -36,7 +40,9 @@ "cards": "Gebetskarten", "wall": "Wandtafeln", "mapPrint": "Kartendruck", - "plugins": "Aus anderen Plugins" + "plugins": "Aus anderen Plugins", + "experimental": "Experimentell (in Arbeit)", + "upgZone": "UPG100-Gebietsplakate" }, "tpl": { "mapSheetCountry": "Länder-Kartenblatt", @@ -45,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/de/upgMap.json b/library/i18n/locales/de/upgMap.json index c41e3ab33..c247491fe 100644 --- a/library/i18n/locales/de/upgMap.json +++ b/library/i18n/locales/de/upgMap.json @@ -19,5 +19,6 @@ "displayName": "UPG100-Poster", "wallHeading": "UPG100-Poster", "wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ", - "peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal" + "peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal", + "posterDesignTerritory": "UPG-100-Gebietsplakat" } diff --git a/library/i18n/locales/en/biblePosters.json b/library/i18n/locales/en/biblePosters.json index 0d7d5c6af..fa7719bc2 100644 --- a/library/i18n/locales/en/biblePosters.json +++ b/library/i18n/locales/en/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Corinthians", "1PE": "1 Peter", "JOS": "Joshua" + }, + "posterDesign": { + "label": "Bible book poster" } } diff --git a/library/i18n/locales/en/posterDesign.json b/library/i18n/locales/en/posterDesign.json index b970eaa7e..960706f52 100644 --- a/library/i18n/locales/en/posterDesign.json +++ b/library/i18n/locales/en/posterDesign.json @@ -28,7 +28,11 @@ "no": "No", "printHome": "Any home printer", "printShop": "A print shop (a PDF, one page per sheet)", - "layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available." + "layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available.", + "sideLanes": "{dims}, side lanes", + "packetPage1": "{paper} (packet page 1)", + "panels": "{dims}, {n} panels", + "anySize": "Any size from the shared size table" }, "family": { "mapSheet": "Map sheets", @@ -36,7 +40,9 @@ "cards": "Prayer cards", "wall": "Wall panels", "mapPrint": "Map print", - "plugins": "From other plugins" + "plugins": "From other plugins", + "experimental": "Experimental (in progress)", + "upgZone": "UPG100 zone posters" }, "tpl": { "mapSheetCountry": "Country map sheet", @@ -45,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/en/upgMap.json b/library/i18n/locales/en/upgMap.json index 6b1902161..916e5e21e 100644 --- a/library/i18n/locales/en/upgMap.json +++ b/library/i18n/locales/en/upgMap.json @@ -19,5 +19,6 @@ "displayName": "UPG100 Posters", "wallHeading": "UPG100 Posters", "wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ", - "peopleStillLoading": "the people groups are still loading, try again in a moment" + "peopleStillLoading": "the people groups are still loading, try again in a moment", + "posterDesignTerritory": "UPG-100 territory poster" } diff --git a/library/i18n/locales/es/biblePosters.json b/library/i18n/locales/es/biblePosters.json index e63aed324..8eebcdc7f 100644 --- a/library/i18n/locales/es/biblePosters.json +++ b/library/i18n/locales/es/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Corintios", "1PE": "1 Pedro", "JOS": "Josué" + }, + "posterDesign": { + "label": "Cartel de un libro de la Biblia" } } diff --git a/library/i18n/locales/es/posterDesign.json b/library/i18n/locales/es/posterDesign.json index 2edc7e748..55147e63c 100644 --- a/library/i18n/locales/es/posterDesign.json +++ b/library/i18n/locales/es/posterDesign.json @@ -28,7 +28,11 @@ "no": "No", "printHome": "Cualquier impresora doméstica", "printShop": "Una imprenta (un PDF, una página por hoja)", - "layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible." + "layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible.", + "sideLanes": "{dims}, columnas laterales", + "packetPage1": "{paper} (página 1 del paquete de oración)", + "panels": "{dims}, paneles: {n}", + "anySize": "Cualquier tamaño de la tabla de tamaños compartida" }, "family": { "mapSheet": "Hojas de mapa", @@ -36,7 +40,9 @@ "cards": "Tarjetas de oración", "wall": "Paneles de pared", "mapPrint": "Impresión del mapa", - "plugins": "De otros complementos" + "plugins": "De otros complementos", + "experimental": "Experimentales (en desarrollo)", + "upgZone": "Carteles de zonas UPG100" }, "tpl": { "mapSheetCountry": "Hoja de mapa del país", @@ -45,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/es/upgMap.json b/library/i18n/locales/es/upgMap.json index 02fe57c1b..da700b62f 100644 --- a/library/i18n/locales/es/upgMap.json +++ b/library/i18n/locales/es/upgMap.json @@ -19,5 +19,6 @@ "displayName": "Carteles UPG100", "wallHeading": "Carteles UPG100", "wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ", - "peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento" + "peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento", + "posterDesignTerritory": "Cartel de territorio UPG-100" } diff --git a/library/i18n/locales/fr/biblePosters.json b/library/i18n/locales/fr/biblePosters.json index dc90054cd..a552e1f71 100644 --- a/library/i18n/locales/fr/biblePosters.json +++ b/library/i18n/locales/fr/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Corinthiens", "1PE": "1 Pierre", "JOS": "Josué" + }, + "posterDesign": { + "label": "Affiche d'un livre de la Bible" } } diff --git a/library/i18n/locales/fr/posterDesign.json b/library/i18n/locales/fr/posterDesign.json index 79c66a6b8..545cd8c51 100644 --- a/library/i18n/locales/fr/posterDesign.json +++ b/library/i18n/locales/fr/posterDesign.json @@ -28,7 +28,11 @@ "no": "Non", "printHome": "N'importe quelle imprimante", "printShop": "Un imprimeur (un PDF, une page par feuille)", - "layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible." + "layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible.", + "sideLanes": "{dims}, colonnes latérales", + "packetPage1": "{paper} (page 1 du dossier de prière)", + "panels": "{dims}, panneaux : {n}", + "anySize": "Toute taille du tableau de tailles commun" }, "family": { "mapSheet": "Feuilles de carte", @@ -36,7 +40,9 @@ "cards": "Cartes de prière", "wall": "Panneaux muraux", "mapPrint": "Impression de la carte", - "plugins": "D’autres plugins" + "plugins": "D’autres plugins", + "experimental": "Expérimentaux (en cours)", + "upgZone": "Affiches de zones UPG100" }, "tpl": { "mapSheetCountry": "Feuille de carte du pays", @@ -45,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/fr/upgMap.json b/library/i18n/locales/fr/upgMap.json index 72ad03057..06bd814ca 100644 --- a/library/i18n/locales/fr/upgMap.json +++ b/library/i18n/locales/fr/upgMap.json @@ -19,5 +19,6 @@ "displayName": "Affiches UPG100", "wallHeading": "Affiches UPG100", "wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ", - "peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant" + "peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant", + "posterDesignTerritory": "Affiche de territoire UPG-100" } diff --git a/library/i18n/locales/hi/biblePosters.json b/library/i18n/locales/hi/biblePosters.json index e628bd9f4..e8bb01075 100644 --- a/library/i18n/locales/hi/biblePosters.json +++ b/library/i18n/locales/hi/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 कुरिन्थियों", "1PE": "1 पतरस", "JOS": "यहोशू" + }, + "posterDesign": { + "label": "बाइबल पुस्तक पोस्टर" } } diff --git a/library/i18n/locales/hi/posterDesign.json b/library/i18n/locales/hi/posterDesign.json index d17457e22..38bdc75af 100644 --- a/library/i18n/locales/hi/posterDesign.json +++ b/library/i18n/locales/hi/posterDesign.json @@ -28,7 +28,11 @@ "no": "नहीं", "printHome": "कोई भी घरेलू प्रिंटर", "printShop": "प्रिंटिंग प्रेस (PDF, हर शीट के लिए एक पेज)", - "layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।" + "layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।", + "sideLanes": "{dims}, किनारे के कॉलम", + "packetPage1": "{paper} (प्रार्थना पैकेट का पेज 1)", + "panels": "{dims}, पैनल: {n}", + "anySize": "साझा आकार तालिका का कोई भी आकार" }, "family": { "mapSheet": "मानचित्र पृष्ठ", @@ -36,7 +40,9 @@ "cards": "प्रार्थना कार्ड", "wall": "दीवार पैनल", "mapPrint": "मानचित्र प्रिंट", - "plugins": "अन्य प्लगइन से" + "plugins": "अन्य प्लगइन से", + "experimental": "प्रयोगात्मक (काम जारी)", + "upgZone": "UPG100 क्षेत्र पोस्टर" }, "tpl": { "mapSheetCountry": "देश का मानचित्र पृष्ठ", @@ -45,7 +51,9 @@ "packetLightweight": "प्रार्थना पैकेट, हल्का", "cards": "प्रार्थना कार्ड", "blessPanels": "आशीष दीवार के पैनल", - "mapPrint": "मानचित्र प्रिंट" + "mapPrint": "मानचित्र प्रिंट", + "upgZonePoster": "UPG100 क्षेत्र का पोस्टर", + "upgZonePacket": "UPG100 प्रार्थना पैकेट, पेज 1" }, "missing": { "bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।", diff --git a/library/i18n/locales/hi/upgMap.json b/library/i18n/locales/hi/upgMap.json index 6af3ab010..d96462b7c 100644 --- a/library/i18n/locales/hi/upgMap.json +++ b/library/i18n/locales/hi/upgMap.json @@ -19,5 +19,6 @@ "displayName": "UPG100 पोस्टर", "wallHeading": "UPG100 पोस्टर", "wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ", - "peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें" + "peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें", + "posterDesignTerritory": "UPG-100 क्षेत्र पोस्टर" } diff --git a/library/i18n/locales/it/biblePosters.json b/library/i18n/locales/it/biblePosters.json index 3435b16e9..a8ca49d82 100644 --- a/library/i18n/locales/it/biblePosters.json +++ b/library/i18n/locales/it/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Corinzi", "1PE": "1 Pietro", "JOS": "Giosuè" + }, + "posterDesign": { + "label": "Poster di un libro della Bibbia" } } diff --git a/library/i18n/locales/it/posterDesign.json b/library/i18n/locales/it/posterDesign.json index 4aec62b44..0e42f6f12 100644 --- a/library/i18n/locales/it/posterDesign.json +++ b/library/i18n/locales/it/posterDesign.json @@ -28,7 +28,11 @@ "no": "No", "printHome": "Qualsiasi stampante di casa", "printShop": "Una tipografia (un PDF, una pagina per foglio)", - "layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile." + "layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile.", + "sideLanes": "{dims}, colonne laterali", + "packetPage1": "{paper} (pagina 1 del fascicolo di preghiera)", + "panels": "{dims}, pannelli: {n}", + "anySize": "Qualsiasi formato dalla tabella dei formati comune" }, "family": { "mapSheet": "Fogli mappa", @@ -36,7 +40,9 @@ "cards": "Schede di preghiera", "wall": "Pannelli da parete", "mapPrint": "Stampa della mappa", - "plugins": "Da altri plugin" + "plugins": "Da altri plugin", + "experimental": "Sperimentali (in corso)", + "upgZone": "Poster delle zone UPG100" }, "tpl": { "mapSheetCountry": "Foglio mappa del paese", @@ -45,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/it/upgMap.json b/library/i18n/locales/it/upgMap.json index 03586fe84..c9f8d025e 100644 --- a/library/i18n/locales/it/upgMap.json +++ b/library/i18n/locales/it/upgMap.json @@ -19,5 +19,6 @@ "displayName": "Poster UPG100", "wallHeading": "Poster UPG100", "wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ", - "peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento" + "peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento", + "posterDesignTerritory": "Manifesto di territorio UPG-100" } diff --git a/library/i18n/locales/pt/biblePosters.json b/library/i18n/locales/pt/biblePosters.json index fdb0f3d60..748793864 100644 --- a/library/i18n/locales/pt/biblePosters.json +++ b/library/i18n/locales/pt/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Coríntios", "1PE": "1 Pedro", "JOS": "Josué" + }, + "posterDesign": { + "label": "Cartaz de um livro da Bíblia" } } diff --git a/library/i18n/locales/pt/posterDesign.json b/library/i18n/locales/pt/posterDesign.json index 69af6993a..24f86ca1d 100644 --- a/library/i18n/locales/pt/posterDesign.json +++ b/library/i18n/locales/pt/posterDesign.json @@ -28,7 +28,11 @@ "no": "Não", "printHome": "Qualquer impressora doméstica", "printShop": "Uma gráfica (um PDF, uma página por folha)", - "layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível." + "layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível.", + "sideLanes": "{dims}, colunas laterais", + "packetPage1": "{paper} (página 1 do pacote de oração)", + "panels": "{dims}, painéis: {n}", + "anySize": "Qualquer tamanho da tabela de tamanhos compartilhada" }, "family": { "mapSheet": "Folhas de mapa", @@ -36,7 +40,9 @@ "cards": "Cartões de oração", "wall": "Painéis de parede", "mapPrint": "Impressão do mapa", - "plugins": "De outros plugins" + "plugins": "De outros plugins", + "experimental": "Experimentais (em andamento)", + "upgZone": "Cartazes de zonas UPG100" }, "tpl": { "mapSheetCountry": "Folha de mapa do país", @@ -45,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/pt/upgMap.json b/library/i18n/locales/pt/upgMap.json index a4de2505f..9cffd9585 100644 --- a/library/i18n/locales/pt/upgMap.json +++ b/library/i18n/locales/pt/upgMap.json @@ -19,5 +19,6 @@ "displayName": "Cartazes UPG100", "wallHeading": "Cartazes UPG100", "wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ", - "peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante" + "peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante", + "posterDesignTerritory": "Cartaz de território UPG-100" } diff --git a/library/i18n/locales/ro/biblePosters.json b/library/i18n/locales/ro/biblePosters.json index f9923ca81..b29b36b23 100644 --- a/library/i18n/locales/ro/biblePosters.json +++ b/library/i18n/locales/ro/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Corinteni", "1PE": "1 Petru", "JOS": "Iosua" + }, + "posterDesign": { + "label": "Poster cu o carte a Bibliei" } } diff --git a/library/i18n/locales/ro/posterDesign.json b/library/i18n/locales/ro/posterDesign.json index 86183bb77..fc9a39279 100644 --- a/library/i18n/locales/ro/posterDesign.json +++ b/library/i18n/locales/ro/posterDesign.json @@ -28,7 +28,11 @@ "no": "Nu", "printHome": "Orice imprimantă de acasă", "printShop": "O tipografie (un PDF, o pagină pe coală)", - "layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil." + "layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil.", + "sideLanes": "{dims}, coloane laterale", + "packetPage1": "{paper} (pagina 1 a pachetului de rugăciune)", + "panels": "{dims}, panouri: {n}", + "anySize": "Orice dimensiune din tabelul comun de dimensiuni" }, "family": { "mapSheet": "Foi de hartă", @@ -36,7 +40,9 @@ "cards": "Cartonașe de rugăciune", "wall": "Panouri de perete", "mapPrint": "Tipărirea hărții", - "plugins": "Din alte pluginuri" + "plugins": "Din alte pluginuri", + "experimental": "Experimentale (în lucru)", + "upgZone": "Postere de zone UPG100" }, "tpl": { "mapSheetCountry": "Foaie de hartă a țării", @@ -45,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/ro/upgMap.json b/library/i18n/locales/ro/upgMap.json index c803919e5..331a40a1e 100644 --- a/library/i18n/locales/ro/upgMap.json +++ b/library/i18n/locales/ro/upgMap.json @@ -19,5 +19,6 @@ "displayName": "Afișe UPG100", "wallHeading": "Afișe UPG100", "wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ", - "peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă" + "peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă", + "posterDesignTerritory": "Afiș de teritoriu UPG-100" } diff --git a/library/i18n/locales/ru/biblePosters.json b/library/i18n/locales/ru/biblePosters.json index 820196ccc..6288a3645 100644 --- a/library/i18n/locales/ru/biblePosters.json +++ b/library/i18n/locales/ru/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "1 Коринфянам", "1PE": "1 Петра", "JOS": "Иисус Навин" + }, + "posterDesign": { + "label": "Плакат книги Библии" } } diff --git a/library/i18n/locales/ru/posterDesign.json b/library/i18n/locales/ru/posterDesign.json index d5f05a992..2652a74bb 100644 --- a/library/i18n/locales/ru/posterDesign.json +++ b/library/i18n/locales/ru/posterDesign.json @@ -28,7 +28,11 @@ "no": "Нет", "printHome": "Любой домашний принтер", "printShop": "Типография (PDF, одна страница на лист)", - "layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов." + "layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов.", + "sideLanes": "{dims}, боковые колонки", + "packetPage1": "{paper} (страница 1 молитвенного пакета)", + "panels": "{dims}, панелей: {n}", + "anySize": "Любой размер из общей таблицы размеров" }, "family": { "mapSheet": "Листы карт", @@ -36,7 +40,9 @@ "cards": "Молитвенные карточки", "wall": "Настенные панели", "mapPrint": "Печать карты", - "plugins": "Из других плагинов" + "plugins": "Из других плагинов", + "experimental": "Экспериментальные (в работе)", + "upgZone": "Плакаты зон UPG100" }, "tpl": { "mapSheetCountry": "Лист карты страны", @@ -45,7 +51,9 @@ "packetLightweight": "Молитвенный пакет, облегчённый", "cards": "Молитвенные карточки", "blessPanels": "Панели стены «Благословить»", - "mapPrint": "Печать карты" + "mapPrint": "Печать карты", + "upgZonePoster": "Плакат зоны UPG100", + "upgZonePacket": "Молитвенный пакет UPG100, страница 1" }, "missing": { "bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.", diff --git a/library/i18n/locales/ru/upgMap.json b/library/i18n/locales/ru/upgMap.json index d69a560fe..3e91bde32 100644 --- a/library/i18n/locales/ru/upgMap.json +++ b/library/i18n/locales/ru/upgMap.json @@ -19,5 +19,6 @@ "displayName": "Плакаты UPG100", "wallHeading": "Плакаты UPG100", "wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ", - "peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение" + "peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение", + "posterDesignTerritory": "Плакат территории UPG-100" } diff --git a/library/i18n/locales/zh/biblePosters.json b/library/i18n/locales/zh/biblePosters.json index 1f6f60fe3..9a05bbac4 100644 --- a/library/i18n/locales/zh/biblePosters.json +++ b/library/i18n/locales/zh/biblePosters.json @@ -23,5 +23,8 @@ "1CO": "哥林多前书", "1PE": "彼得前书", "JOS": "约书亚记" + }, + "posterDesign": { + "label": "圣经书卷海报" } } diff --git a/library/i18n/locales/zh/posterDesign.json b/library/i18n/locales/zh/posterDesign.json index f54ea4e85..bc5890019 100644 --- a/library/i18n/locales/zh/posterDesign.json +++ b/library/i18n/locales/zh/posterDesign.json @@ -28,7 +28,11 @@ "no": "否", "printHome": "任何家用打印机", "printShop": "印刷店(PDF,每张纸一页)", - "layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。" + "layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。", + "sideLanes": "{dims},两侧名单栏", + "packetPage1": "{paper}(祷告资料包第 1 页)", + "panels": "{dims},展板数:{n}", + "anySize": "共享尺寸表中的任意尺寸" }, "family": { "mapSheet": "地图页", @@ -36,7 +40,9 @@ "cards": "祷告卡", "wall": "墙面展板", "mapPrint": "地图打印", - "plugins": "来自其他插件" + "plugins": "来自其他插件", + "experimental": "实验性(进行中)", + "upgZone": "UPG100 区域海报" }, "tpl": { "mapSheetCountry": "国家地图页", @@ -45,7 +51,9 @@ "packetLightweight": "祷告资料包(精简版)", "cards": "祷告卡", "blessPanels": "祝福墙展板", - "mapPrint": "地图打印" + "mapPrint": "地图打印", + "upgZonePoster": "UPG100 区域海报(单张)", + "upgZonePacket": "UPG100 祷告资料包,第 1 页" }, "missing": { "bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。", diff --git a/library/i18n/locales/zh/upgMap.json b/library/i18n/locales/zh/upgMap.json index f94d74b21..326f50cdb 100644 --- a/library/i18n/locales/zh/upgMap.json +++ b/library/i18n/locales/zh/upgMap.json @@ -19,5 +19,6 @@ "displayName": "UPG100 海报", "wallHeading": "UPG100 海报", "wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ", - "peopleStillLoading": "族群仍在加载中,请稍后再试" + "peopleStillLoading": "族群仍在加载中,请稍后再试", + "posterDesignTerritory": "UPG-100 区域海报" } 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 9f82dfbc5..131e61399 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 check:view-address check:view-address: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: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 check:view-address check:view-address:self-test", "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", @@ -622,6 +624,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/bible-posters/bible-posters.meta.json b/plugin-designs/bible-posters/bible-posters.meta.json index a59dea557..6b35d9918 100644 --- a/plugin-designs/bible-posters/bible-posters.meta.json +++ b/plugin-designs/bible-posters/bible-posters.meta.json @@ -3,5 +3,12 @@ "type": "single", "label": "Bible Posters", "targets": ["web"], - "note": "A generator that shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups. Text is fetched at render time from the Free Use Bible API (public-domain / openly-licensed only); no Bibles are stored in the repo. Lives in the Posterverse Experimental shelf." + "posterDesign": { + "family": "biblePoster", + "name": "bible-poster.36x60", + "size": "36x60", + "mobileFriendly": false, + "labelKey": "biblePosters.posterDesign.label" + }, + "note": "A generator that shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups. Text is fetched at render time from the Free Use Bible API (public-domain / openly-licensed only); no Bibles are stored in the repo. Lives in the Posterverse Experimental shelf. posterDesign: the whole-book poster is a large-format print (even standard paper is too small for a whole book), so mobileFriendly is false; the card/preview still renders on a phone." } diff --git a/plugin-designs/country-posters/country-poster-wall.meta.json b/plugin-designs/country-posters/country-poster-wall.meta.json index 94a3b2e0a..1067f1bf0 100644 --- a/plugin-designs/country-posters/country-poster-wall.meta.json +++ b/plugin-designs/country-posters/country-poster-wall.meta.json @@ -1,5 +1,12 @@ { "type": "single", "label": "Country Posters — poster wall", - "note": "The printable poster wall pane of the Country Posters element (country-posters-mfe composes it); also embeddable alone. profile-config `countries` (ISO-3 list or {code,name,count} records) or `region` (a WAGF region label) shows only those, flat, under a host's own region heading (DX Life's /regions, 2026-09-30); without them the wall is unchanged." + "note": "The printable poster wall pane of the Country Posters element (country-posters-mfe composes it); also embeddable alone. profile-config `countries` (ISO-3 list or {code,name,count} records) or `region` (a WAGF region label) shows only those, flat, under a host's own region heading (DX Life's /regions, 2026-09-30); without them the wall is unchanged.", + "posterDesign": { + "family": "countryPoster", + "name": "country-poster.letter", + "size": "letter", + "mobileFriendly": true, + "labelKey": "tabs.countryPosters" + } } diff --git a/plugin-designs/my-upg-100-list/poster-wall.meta.json b/plugin-designs/my-upg-100-list/poster-wall.meta.json index e8f2766d0..997a16fd4 100644 --- a/plugin-designs/my-upg-100-list/poster-wall.meta.json +++ b/plugin-designs/my-upg-100-list/poster-wall.meta.json @@ -1,4 +1,27 @@ { "_comment": "The printable half of UPG-100: a wall of prayer-card posters, one per 100-list entry, with the three print paths. Declared so the Studio rail names it rather than falling back to a humanized id (check:studio-rail).", - "label": "UPG100 Posters — Poster Wall" + "label": "UPG100 Posters — Poster Wall", + "posterDesign": [ + { + "family": "upgList", + "name": "upg100-territory-poster.letter", + "size": "letter", + "mobileFriendly": true, + "labelKey": "upgMap.posterDesignTerritory" + }, + { + "family": "upgList", + "name": "upg100-territory-poster.3x3", + "size": "3x3", + "mobileFriendly": false, + "labelKey": "upgMap.posterDesignTerritory" + }, + { + "family": "upgList", + "name": "upg100-territory-poster.3x4", + "size": "3x4", + "mobileFriendly": false, + "labelKey": "upgMap.posterDesignTerritory" + } + ] } diff --git a/plugin-designs/poster-design/poster-sizes.vue b/plugin-designs/poster-design/poster-sizes.vue index ec2c674b2..19f031522 100644 --- a/plugin-designs/poster-design/poster-sizes.vue +++ b/plugin-designs/poster-design/poster-sizes.vue @@ -20,19 +20,14 @@ import { POSTER_LADDER, POSTER_LADDER_ORDER, paperFor } from '@map/constants/pos import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { layoutSvgUrl } from './src/layoutSvg.js' +import { num, dimsText } from './src/sizeText.js' const t = useOptionalT('') const { locale } = useOptionalI18n('') -// A number as the sizes are spoken: whole, or one decimal place (5.5). -const n = (x) => (Number.isInteger(x) ? String(x) : String(Math.round(x * 10) / 10)) -// Feet once a side reaches a yard and both sides are whole or half feet (3 × 5.5 ft); inches below that (18 × 24 in). -function dims(w, h) { - const ft = (x) => x / 12 - const halfFeet = (x) => Number.isInteger(ft(x) * 2) - if (Math.min(w, h) >= 36 && halfFeet(w) && halfFeet(h)) return t('posterDesign.sizes.dimsFt', { w: n(ft(w)), h: n(ft(h)) }) - return t('posterDesign.sizes.dimsIn', { w: n(w), h: n(h) }) -} +// Sizes are named the one way Poster Design names them (src/sizeText.js, shared with Styles). +const n = num +const dims = (w, h) => dimsText(w, h, t) // Standard printer paper is Letter or A4 by the reader's region, as the print picker decides it. const paperIsA4 = computed(() => { const l = locale && (locale.value ?? locale) diff --git a/plugin-designs/poster-design/poster-styles.vue b/plugin-designs/poster-design/poster-styles.vue index 217031190..2c195b2be 100644 --- a/plugin-designs/poster-design/poster-styles.vue +++ b/plugin-designs/poster-design/poster-styles.vue @@ -32,6 +32,7 @@ import { resolvePosterSize } from '@map/constants/posterSizes.js' import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js' import FullDetailsDialog from './src/FullDetailsDialog.vue' import { loadPluginDesigns } from './src/pluginDesigns.js' +import { sizeText, declaredSizeText } from './src/sizeText.js' const profileConfig = inject('profileConfig', ref(null)) const instanceId = computed(() => profileConfig.value?.instanceId || 'poster-design-styles') @@ -49,7 +50,7 @@ function stockLabel(s) { const dims = `${s.w} × ${s.h} in` return s.side ? `${noun} ${dims} (${t('wall.stockLandscape')})` : `${noun} ${dims}` } -const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${c.size}`) +const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${sizeText(c.size, t)}`) // Designs other plugins declare (their meta's posterDesign, via manifest.json): the built tree's manifest, or the dev // server's. A name already in templates.js stays the library's (the check refuses the clash; the page never doubles it). @@ -60,17 +61,34 @@ onMounted(async () => { const { designs } = await loadPluginDesigns(urls) pluginDesigns.value = designs.filter((d) => !byName.has(d.name)) }) -const pluginLabel = (d) => { +// The design's title in the reader's language: the build's per-locale titles (posterDesignLabels; any catalogue +// area), then this bundle's own catalogue, then English, then the profile's label. +const pluginTitle = (d) => { + const lang = String(getRuntimeLocale() || detectLocale() || 'en').split('-')[0] + if (d.titles && d.titles[lang]) return d.titles[lang] const v = d.labelKey ? t(d.labelKey) : '' - return `${v && v !== d.labelKey ? v : d.label}: ${d.size}` + if (v && v !== d.labelKey) return v + return (d.titles && d.titles.en) || d.label +} +const pluginLabel = (d) => { + return `${pluginTitle(d)}: ${declaredSizeText(d.size, t)}` } +// PRODUCTION designs first, by family. EXPERIMENTAL ones (marked in templates.js: still being designed, or no +// preview) and every other plugin's design (made by its own plugin, so drawn nowhere here) go last, in ONE +// Experimental group that starts collapsed. +const isExperimental = (id) => byPluginName.value.has(id) || byName.get(id)?.experimental === true +const experimentalHeading = computed(() => t('posterDesign.family.experimental')) // No `count`: these are templates, not lists of people, so the sticker reads "Show full details" alone. -const items = computed(() => [ - ...configs.map((c) => ({ id: c.name, label: labelOf(c) })), - ...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })), -]) -const groupBy = (item) => (byPluginName.value.has(item.id) ? t('posterDesign.family.plugins') : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet)) +const items = computed(() => { + const all = [ + ...configs.map((c) => ({ id: c.name, label: labelOf(c) })), + ...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })), + ] + return [...all.filter((x) => !isExperimental(x.id)), ...all.filter((x) => isExperimental(x.id))] +}) +const groupBy = (item) => (isExperimental(item.id) ? experimentalHeading.value : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet)) +const collapsedBy = (sec) => (sec && sec.name) === experimentalHeading.value // PosterWall passes the section // ── drawing a configuration from the library ────────────────────────────────────────────────── const countryById = (id) => (countryData.countries || []).find((c) => c.id === id) @@ -133,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). @@ -175,15 +203,15 @@ async function sheet(item) { // ── "Show full details" ─────────────────────────────────────────────────────────────────────── const open = ref(null) -const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(open.value))) +const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(byName.get(open.value.name)))) // A plugin's design opens in the same dialog, as a configuration: its renderer is the plugin's page, and the dialog // shows that page live. function showDetails(item) { const d = byPluginName.value.get(item.id) open.value = d - ? { name: d.name, size: d.size, settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) }, + ? { name: d.name, size: declaredSizeText(d.size, t), settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) }, renderer: `plugin-designs/${d.bundle}/${d.profile}.vue`, pageUrl: d.pageUrl, design: d } - : byName.get(item.id) || null + : byName.has(item.id) ? { ...byName.get(item.id), size: sizeText(byName.get(item.id).size, t) } : null } const heading = computed(() => t('posterDesign.styles.heading')) @@ -231,6 +259,7 @@ useShadowStyles(` unit="" item-count-key="posterDesign.templateCount" :collapsible="true" + :collapsed-by="collapsedBy" :header-action="{ labelKey: 'posterDesign.showFullDetails', ariaKey: 'posterDesign.showFullDetailsAria' }" :preview-src="previewSrc" :unsupported="withheld" 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/FullDetailsDialog.vue b/plugin-designs/poster-design/src/FullDetailsDialog.vue index 06d73ab3a..cdf6d3e42 100644 --- a/plugin-designs/poster-design/src/FullDetailsDialog.vue +++ b/plugin-designs/poster-design/src/FullDetailsDialog.vue @@ -63,7 +63,7 @@ async function copyName() {
{{ config.name }}
{{ t('posterDesign.details.size') }}
-
{{ config.size }} · {{ config.px[0] }} × {{ config.px[1] }} px
+
{{ config.size }} · {{ ltr(`${config.px[0]} × ${config.px[1]} px`) }}