diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json index e9f544767..d2e0568a4 100644 --- a/library/scripts/posters/packet-strings.json +++ b/library/scripts/posters/packet-strings.json @@ -182,7 +182,9 @@ "funnel_list_unreached": "Project 7000: unreached people groups", "funnel_list_frontier": "Project 4700: frontier people groups", "funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more", - "funnel_list_unengaged": "Unengaged unreached people groups" + "funnel_list_unengaged": "Unengaged unreached people groups", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} more" }, "es": { "map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS", @@ -357,7 +359,9 @@ "funnel_list_unreached": "Proyecto 7000: grupos étnicos no alcanzados", "funnel_list_frontier": "Proyecto 4700: grupos étnicos de frontera", "funnel_list_frontier-500k": "Proyecto 700: grupos étnicos de frontera de 500.000 o más", - "funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros" + "funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} más" }, "fr": { "map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS", @@ -532,7 +536,9 @@ "funnel_list_unreached": "Projet 7000 : peuples non atteints", "funnel_list_frontier": "Projet 4700 : peuples frontières", "funnel_list_frontier-500k": "Projet 700 : peuples frontières de 500 000 personnes ou plus", - "funnel_list_unengaged": "Peuples non atteints et sans ouvrier" + "funnel_list_unengaged": "Peuples non atteints et sans ouvrier", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} autres" }, "pt": { "map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS", @@ -707,7 +713,9 @@ "funnel_list_unreached": "Projeto 7000: grupos étnicos não alcançados", "funnel_list_frontier": "Projeto 4700: grupos étnicos de fronteira", "funnel_list_frontier-500k": "Projeto 700: grupos étnicos de fronteira de 500.000 ou mais", - "funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros" + "funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} mais" }, "de": { "map_page_kicker": "UNERREICHTE VOLKSGRUPPEN", @@ -882,7 +890,9 @@ "funnel_list_unreached": "Projekt 7000: unerreichte Volksgruppen", "funnel_list_frontier": "Projekt 4700: Frontier-Volksgruppen", "funnel_list_frontier-500k": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr", - "funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter" + "funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} weitere" }, "it": { "map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI", @@ -1057,7 +1067,9 @@ "funnel_list_unreached": "Progetto 7000: gruppi etnici non raggiunti", "funnel_list_frontier": "Progetto 4700: gruppi etnici di frontiera", "funnel_list_frontier-500k": "Progetto 700: gruppi etnici di frontiera di 500.000 o più", - "funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai" + "funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + altri {n}" }, "ro": { "map_page_kicker": "GRUPURI ETNICE NEATINSE", @@ -1232,7 +1244,9 @@ "funnel_list_unreached": "Proiectul 7000: grupuri etnice neatinse", "funnel_list_frontier": "Proiectul 4700: grupuri etnice de frontieră", "funnel_list_frontier-500k": "Proiectul 700: grupuri etnice de frontieră de 500.000 sau mai multe", - "funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători" + "funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + încă {n}" }, "ru": { "map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ", @@ -1407,7 +1421,9 @@ "funnel_list_unreached": "Проект 7000: неохваченные народы", "funnel_list_frontier": "Проект 4700: народы-фронтир", "funnel_list_frontier-500k": "Проект 700: народы-фронтир численностью 500 000 и более", - "funnel_list_unengaged": "Неохваченные народы без работников" + "funnel_list_unengaged": "Неохваченные народы без работников", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + ещё {n}" }, "ar": { "map_page_kicker": "شعوب غير مبلَّغة", @@ -1582,7 +1598,9 @@ "funnel_list_unreached": "مشروع 7000: المجموعات الشعبية غير المبلَّغة", "funnel_list_frontier": "مشروع 4700: المجموعات الشعبية الحدودية", "funnel_list_frontier-500k": "مشروع 700: المجموعات الشعبية الحدودية التي يبلغ عددها 500,000 أو أكثر", - "funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين" + "funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} أخرى" }, "hi": { "map_page_kicker": "अनछुए जनसमूह", @@ -1757,7 +1775,9 @@ "funnel_list_unreached": "परियोजना 7000: अप्राप्त जनसमूह", "funnel_list_frontier": "परियोजना 4700: सीमांत जनसमूह", "funnel_list_frontier-500k": "परियोजना 700: 500,000 या अधिक के सीमांत जनसमूह", - "funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं" + "funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + {n} और" }, "zh": { "map_page_kicker": "未得之民", @@ -1932,6 +1952,8 @@ "funnel_list_unreached": "7000 计划:未得之民族群", "funnel_list_frontier": "4700 计划:前沿族群", "funnel_list_frontier-500k": "700 计划:50 万人以上的前沿族群", - "funnel_list_unengaged": "无人服事的未得之民族群" + "funnel_list_unengaged": "无人服事的未得之民族群", + "zone_join": "{a} + {b}", + "zone_join_more": "{a} + 另外 {n} 个" } -} \ No newline at end of file +} diff --git a/plugin-designs/my-upg-100-list/data/zone-title.js b/plugin-designs/my-upg-100-list/data/zone-title.js new file mode 100644 index 000000000..cba1e8923 --- /dev/null +++ b/plugin-designs/my-upg-100-list/data/zone-title.js @@ -0,0 +1,178 @@ +/** + * zone-title — compose a zone's DISPLAY TITLE from parts that are ALREADY translated, so adding a + * project list costs no new translation and adding a locale costs no code change. + * + * THE PROBLEM (Driver, 2026-09-27). Every zone label is a hand-written English string — "Middle East + * Core", "Malabar Coast" — so the Arabic map reads English. Hand-translating them does not scale, + * because UPG-100 is becoming a PATTERN: Project 7000 / 4700 / 3000 / 2000 / 700 each invent their own + * ~100 clusters, and a per-title translation is a cost paid again for every list, forever. The Driver's + * question was precisely "how do you generate a title without a new translation each time". + * + * ★ WHAT THE DATA ACTUALLY ALLOWS, measured before any of this was written (2026-09-27): + * + * SOURCE COVERAGE OF OUR DATA FAILURE MODE + * country_code_label from the records API all 175 codes, every locale (absent field) + * Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3 + * Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE + * + * ⚠ LANGUAGE IS NOT A USABLE TITLE SOURCE, which kills the more attractive half of the original idea. + * `new Intl.DisplayNames(['en'],{type:'language'}).of('kfa')` returns the STRING "kfa" — it does not + * throw — so a miss prints a raw code onto a printed poster with nothing raising a hand. And the 90.5% + * miss is STRUCTURAL rather than a gap someone can fill: unreached peoples speak small languages by + * definition, and CLDR carries the big ones. So this module never names a language. + * + * ★ AND COUNTRY NAMES COME FROM THE RECORDS, NOT FROM Intl, which was the measurement that simplified + * the whole design. The records API already returns `country_code_label` localized by its own `lang` + * parameter — verified live: IND is الهند / भारत / 印度 / Индия for lang=ar/hi/zh/ru, matching Intl + * exactly. Since the packet and the page ALREADY fetch these records at the reader's locale, the + * localized name is free and needs no ISO-3166 alpha-3 → alpha-2 mapping at all. Intl is kept only as a + * FALLBACK for a caller who has a code and no record. + * + * WHICH ZONES THIS CAN AND CANNOT TITLE — stated here because a generator that silently produces a bad + * title is worse than one that declines: + * TIER A selector country_code → one country name. GENERATED + * TIER B selector country_codes → 2..n country names + a joiner. GENERATED + * TIER C wagf_block / wagf_region / pg_slugs / union / affinity_bloc → null. DECLINED + * Tier C is 18 of our 28 zones and it cannot be done from country names: the 12 India/China zones are + * `pg_slugs` whose every record is ONE country (measured: asia-india-kerala is 78/78 IND), so countries + * would render "India" twelve times and could not tell "Malabar Coast" from "Northwest India" — those + * labels are SUB-NATIONAL. Naming them needs a province/subdivision source, which Intl has none of; the + * curated-table-vs-keep-English choice is with the Driver (repo's digest item D). Until it is decided + * this returns null for tier C and the caller keeps the existing label, so nothing regresses. + * + * THE ONLY TRANSLATED STRINGS ARE THE TWO JOINERS — `zone_join` ("{a} + {b}") and `zone_join_more` + * ("{a} + {n} more") — in packet-strings.json, one pair per locale block. That is the entire + * translation cost for every project list that ever uses this. + */ + +/** Tier A/B selector types. Anything else is tier C and declined. */ +const COUNTRY_SELECTORS = new Set(['country_code', 'country_codes']) + +/** + * The ISO-3 codes a selector names, in the order the selector lists them. + * ⚠ ORDER IS THE AUTHOR'S, NOT ALPHABETICAL. clusters.json lists the countries of a zone with the + * biggest/most representative first (middle-east-core starts IRN,YEM,IRQ), and that order is editorial + * information: it is what makes "Iran + Yemen + 8 more" a better title than "Bahrain + Iran + 8 more". + * Sorting here would discard a judgement someone already made. + */ +export function zoneCountryCodes(selector) { + if (!selector || !COUNTRY_SELECTORS.has(selector.type)) return [] + if (selector.type === 'country_code') return selector.value ? [selector.value] : [] + return Array.isArray(selector.values) ? selector.values.filter(Boolean) : [] +} + +/** + * ISO-3 → a localized country name. + * + * @param {string} iso3 + * @param {object} opts + * @param {Map} [opts.byCode] ISO-3 → country_code_label, built from the records this + * page/packet already loaded AT THE READER'S LOCALE. The preferred source: no alpha-2 mapping, + * no second service, and it agrees with Intl where both have an answer. + * @param {string} [opts.locale] used only by the Intl fallback. + * @param {(iso3:string, locale:string)=>string} [opts.countryName] regions-plugin's coming library + * helper (their card BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2). Injected rather than imported + * so this module does not depend on an unlanded file; when it lands, pass it and it wins over + * the local Intl fallback. + * @returns {string|null} null rather than the raw code — see below. + */ +export function countryLabel(iso3, opts = {}) { + const code = String(iso3 || '').toUpperCase() + if (!code) return null + // ★ ORDER: regions' helper (or Intl) FIRST, the records' label SECOND — and this order is the + // opposite of the one I wrote first, corrected by measuring what the two sources actually say. + // The records API returns the FORMAL country name, which is wrong for a poster header: + // LAO "Lao People's Democratic Republic" vs Intl "Laos" + // IRN "Islamic Republic of Iran" vs Intl "Iran" + // CHN "People's Republic of China" vs Intl "China" + // 28 of our 175 country labels are long formal forms like these. My first version preferred the + // records because they were already localized and needed no code mapping — true, and it produced + // "Lao People's Democratic Republic" as the title of the zone whose hand-written label was "Laos", + // i.e. a generated title WORSE than the one it replaced. Intl gives the short common form in every + // locale, which is what a header and a 380px legend row want. + // The records stay as the fallback because they need no alpha-2 mapping and are always present. + if (typeof opts.countryName === 'function') { + const n = opts.countryName(code, opts.locale || 'en') + if (n && n !== code) return n + } + // ⚠ Intl REGIONS TAKE ALPHA-2 AND THROW ON ALPHA-3 — `new Intl.DisplayNames(['en'],{type:'region'}) + // .of('IND')` is a RangeError, not a miss. So a 3-letter code needs a mapping, and `alpha2` is that + // mapping, INJECTED rather than imported: regions-plugin owns the ISO-3→ISO-2 table (their card + // BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2) and it has not landed, so this module must work without + // it and improve when it arrives. With no mapper a 3-letter code simply falls through to the records + // label below — the formal name, but a real localized name. + // That THROW-vs-ECHO asymmetry with the language type is also why every lookup here ends in null + // rather than in a code: one failure is loud, the other silent, and null normalises both. + const two = code.length === 2 ? code + : (typeof opts.alpha2 === 'function' ? String(opts.alpha2(code) || '').toUpperCase() : '') + if (two.length === 2) { + try { + const n = new Intl.DisplayNames([opts.locale || 'en'], { type: 'region' }).of(two) + if (n && n !== two) return n + } catch (_) { /* not a region Intl knows */ } + } + // The records' own label: always localized, always present, but the FORMAL form. Second choice, not + // no choice. + const fromRecords = opts.byCode?.get(code) + if (fromRecords) return fromRecords + // ★ NEVER RETURN THE CODE. "SDN + 3 more" on a poster looks like a data bug to a reader and, worse, + // reads as a successful translation to a gate that only checks the string is non-empty. A null makes + // the caller fall back to the existing label, which is English but is at least a WORD. + return null +} + +/** + * The generated title for a zone, or null if this zone is not the kind that can be generated. + * + * @param {object} zone a clusters.json zone (needs .selector) + * @param {object} strings the resolved locale block from packet-strings.json (per-KEY fallback to en + * already applied by the caller, as packet.mjs does) + * @param {object} opts as countryLabel, plus: + * @param {number} [opts.maxParts=2] how many countries to name before collapsing to "+ n more". + * 2 by default because these titles print on a poster header and in a legend row: three names + * already wrap at the legend's 380px panel width in ar and hi, where the words are longer. + * @returns {string|null} + */ +export function zoneTitle(zone, strings, opts = {}) { + const codes = zoneCountryCodes(zone?.selector) + if (!codes.length) return null // tier C — declined, caller keeps its label + const maxParts = Math.max(1, opts.maxParts ?? 2) + + const named = codes.map((c) => countryLabel(c, opts)) + // ★ ALL-OR-NOTHING ON THE NAMED HEAD. If any of the countries we would SHOW has no name, decline the + // whole title rather than printing a partial one: a title that names two of three countries and drops + // the third silently misinforms, and it cannot be distinguished from a correct title downstream. + const head = named.slice(0, maxParts) + if (head.some((n) => !n)) return null + + if (codes.length === 1) return head[0] + if (codes.length <= maxParts) { + // Fold left so three names read "a + b + c" using the same two-part joiner, rather than needing a + // separate three-part string per locale. + return head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`) + } + const rest = codes.length - maxParts + const joined = head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`) + return fill(strings?.zone_join_more, { a: joined, n: rest }) ?? `${joined} + ${rest} more` +} + +/** `{a}` / `{b}` / `{n}` interpolation. Returns null on a missing pattern so the caller can default. */ +function fill(pattern, params) { + if (!pattern) return null + return String(pattern).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m)) +} + +/** + * ISO-3 → localized country label, from records the caller already has. + * Built once per render rather than scanned per zone: 2107 records x 28 zones is 59k scans for data + * that does not change during a render. + */ +export function countryLabelIndex(records) { + const m = new Map() + for (const r of records || []) { + const cc = r?.country_code + const label = r?.country_code_label + if (cc && label && !m.has(cc)) m.set(String(cc).toUpperCase(), label) + } + return m +}