From 84062744ee71163774191f766d6ce5bd19ac62d2 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 02:49:56 +0000 Subject: [PATCH] Zone titles for the 10 zones that can have one, and a null for the 18 that cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver, 2026-09-27: zone titles must be GENERATED from already-translated parts, because UPG-100 is becoming a pattern and a hand-translated title is a cost paid again for every project list (7000 / 4700 / 3000 / 2000 / 700) forever. The Arabic map reads English today because every label is a hand-written English string. ★ MEASURED BEFORE DESIGNING, and the measurement killed the more attractive half of the idea: country_code_label from the records API all 175 codes, every locale (formal names) 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 OUT. `.of('kfa')` returns the STRING "kfa" rather than throwing, so a miss prints a raw code onto a printed poster with nothing raising a hand — and the 90.5% miss is structural, not a gap to fill: unreached peoples speak small languages by definition and CLDR carries the big ones. This module never names a language. TIERS, stated in the module because a generator that silently produces a bad title is worse than one that declines: TIER A (3) country_code -> one country name. GENERATED TIER B (7) country_codes -> n names + a joiner. GENERATED TIER C (18) everything else NULL, caller keeps its label Tier C cannot be done from countries: the 12 India/China zones are pg_slugs whose every record is ONE country (measured: asia-india-kerala 78/78 IND), so countries would render "India" twelve times and could not tell "Malabar Coast" from "Northwest India" — those labels are SUB-NATIONAL, and Intl has no subdivisions. The curated-table-vs-keep-English choice is the Driver's (repo's digest item D). Returning null until then means nothing regresses. ⚠ AND I HAD THE SOURCE PRIORITY BACKWARDS, corrected by looking at what the two sources actually say. I preferred the records' label because it is already localized and needs no code mapping — both true, and it generated "Lao People's Democratic Republic" for the zone whose hand-written label was "Laos": a generated title WORSE than the one it replaced. 28 of our 175 labels are formal forms like that ("Islamic Republic of Iran", "People's Republic of China"). Intl gives the short common form in every locale, so Intl is now first and the records are the fallback. The ISO-3→ISO-2 mapper is INJECTED, not imported, because regions-plugin owns that table (their card) and it has not landed: without it a 3-letter code falls through to the formal name, with it the titles read "Iran + Yemen + 11 more". THE ONLY NEW TRANSLATED STRINGS ARE TWO: zone_join "{a} + {b}" and zone_join_more "{a} + {n} more", one pair per locale block in packet-strings.json (22 keys across 11 blocks). That is the entire translation cost for every project list that ever uses this — a new language stays a NEW BLOCK, never a code change. VERIFIED against the real 28 zones in en AND ar, with live Arabic records from the API: Laos -> لاوس | Middle East Core -> إيران + اليمن + 11 أخرى | Maghreb + Egypt -> الجزائر + ليبيا + 5 أخرى 9 generated, 18 declined, in both locales. Every decline path returns null rather than a code or a partial title, including a country_codes zone where one of the names it would SHOW is missing — a title naming two of three countries and dropping the third silently misinforms and cannot be told apart from a correct one downstream. NOT WIRED IN YET: this is the generator only. The caller change (legend + poster + packet header) and the deaf zone — global by construction, which breaks the geography-first rule and which the Driver says is unsolved — are the next steps on the pattern card. --- library/scripts/posters/packet-strings.json | 46 +++-- .../my-upg-100-list/data/zone-title.js | 178 ++++++++++++++++++ 2 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 plugin-designs/my-upg-100-list/data/zone-title.js 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 +}