Compare commits

...

4 commits

Author SHA1 Message Date
Doxa Maps
f487ed17d2 Country Posters: India's state and union territory cards named in the reader's language; the state table retried after a failure
countryPosters.stateNames x11, keyed by ISO 3166-2, for the India section's CARD labels (the wall, the print HUD, the
packet and list titles). The printed sheet stays English by recorded decision, like every country poster.
Generated, not typed: scripts/emit-india-state-names.mjs reads Wikidata labels by ISO 3166-2 (P300), dissolved items
excluded; Wikidata is CC0 (source, licence, query, date and every fallback in data/india-state-names.meta.json).
- Four codes ISO has renamed (IN-CT/OR/TG/UT -> CG/OD/TS/UK) are queried under the current code.
- English is the table's own name. Where the table is briefer than its item ("Delhi" vs "National Capital Territory
  of Delhi"), the same-named item's labels win, so hi/ru match the English.
- Reviewed by eye (hi, ar, zh): two explicit overrides with their evidence (ar Odisha = the ar.wikipedia title,
  not the pre-2011 label; zh Dadra and Nagar Haveli and Daman and Diu = Simplified, the only label being
  Traditional). A zh name from a plain "zh" label with no reviewed override FAILS the generator (poisoned: it did).
The India section retries a failed table fetch at 5 s, 20 s and 60 s (bounded, cancelled on unmount), instead of
staying hidden until the filter changes.
check:country-sheet-one-path ARM D: every locale's stateNames == the table's 36 codes (a runtime-built key the i18n
call-site gates cannot see), English == the table, and the licence recorded. Self-test 12/12.
2026-09-30 23:26:04 +00:00
Doxa Maps
6fe29c8ad0 Country Posters: one poster for each of India's states and union territories
The unfiltered wall gains an "India — states and union territories" section: 34 cards, one per state or union
territory with people groups listed (from the shared india-states table, fetched and sha-checked only when the
section is shown), each with its poster, prayer packet and cards. Nagaland and Mizoram get a card saying no
unreached people groups are listed there, so no division silently disappears. A host's filtered wall
(countries/region) is unchanged and never loads the table.

src/indiaStateSheet.js draws a state's sheet through ONE path: its records by slug, its outline from the
ADM1 boundaries the renderer already loads (matched on shapeName; the shared collection is copied, never
mutated), India's hue, and the indiaState series ("India State Maps", map N of 34). Show on map for a state
frames India, since the countries map draws countries.

check:country-sheet-one-path grows the state arms: inside this plugin only the helper calls generateSheet,
the wall must use makeIndiaStateSheet, and over the served table + ADM1 file: 36 divisions, the 34 with groups
numbered 1..34 each with an outline, the rest named, the ADM1 collection unmutated. Self-test 10/10.
Rendered headlessly through poster-render for Gujarat (90 pins), Lakshadweep (1), Andaman and Nicobar (9):
the state's name as title, "India State Maps", its series position, no country-series text, no "Province".
2026-09-30 22:57:24 +00:00
Doxa Maps
3be2ea856b i18n: a countryPosters area, with the India states section's heading and no-people-groups line, in all 11 locales
countryPosters.indiaStatesHeading ("India — states and union territories") and countryPosters.indiaStateNoGroups
("No unreached people groups are listed here."), for the Country Posters wall's India section. A NEW AREA that only
country-posters names, not the shared `messages` area: that one reaches 20 built bundles, so two strings only this
wall shows would have rebuilt nearly the whole tree. Each locale reuses its catalogue's term for unreached people
groups (lists.unreached) and its standard name for India's states and union territories; never "provinces".
2026-09-30 22:56:33 +00:00
Doxa Maps
940c65e85c A poster series for India's states and union territories; series-isolation sees shared wording and poster text correctly
series.json: a new `indiaState` series, modelled on `country`, so a state poster names itself honestly
("India State Maps", "What is a State Prayer Map?", one sheet for each state and union territory of India,
"which state God has put on their heart"). The country series' text would have called Gujarāt one of the
~170 Country Maps. Approved as the default wording; shown as editable.

check:series-isolation, two blind spots found by adding a third series:
- Shared wording was dropped from only the EARLIER series (sets were mutated while still being compared),
  so the later one kept it as a fingerprint and the earlier failed for carrying it. The shared set is now
  found on the original sets. Self-test: three series in any order; the old loop fails it.
- The contamination scan never read a series' `poster` block, where a country poster once printed the
  UPG-100 side box. It does now. The phrases that NAME country and indiaState are hand-listed, so a
  verbatim copy of one's framing into the other is caught even though symmetric dropping treats it as shared.
  Self-test 9/9, including both directions of a country/state copy.

countrySheet.js: the seriesFor comment no longer says "map 1 of 0" was printed; it was latent (the map-only
packet page prints no series line, and the India poster is gated), as a rendered check showed.
2026-09-30 22:56:21 +00:00
21 changed files with 929 additions and 24 deletions

View file

@ -63,8 +63,19 @@ const HAND_VOCAB = {
'Project 700',
'How this list was found',
],
// The phrases that NAME a series. Derived vocabulary drops any sentence two series share, so a verbatim
// copy of one series' framing into another would go unseen without these (the copy becomes "shared").
country: [
'PRAY FOR THIS COUNTRY',
'Country Prayer Map',
'one sheet for each country',
'which country God has put',
],
indiaState: [
'PRAY FOR THIS STATE',
'State Prayer Map',
'one sheet for each state',
'which state God has put',
],
}
@ -100,13 +111,13 @@ function deriveVocab(series) {
...strings(e.poster || {}),
].map((s) => s.trim()).filter((s) => s.length >= 12))
}
// Drop anything two series share — it cannot identify either of them.
for (const a of ids) {
for (const b of ids) {
if (a === b) continue
for (const s of own[b]) own[a].delete(s)
}
}
// Drop anything two series share — it cannot identify either of them. The shared set is found on the
// ORIGINAL sets first: deleting while still comparing pairs removed a shared string from the earlier
// series only, so the later one kept it as a "fingerprint" and the earlier one failed for carrying it.
// Invisible with two series; a third (indiaState, sharing country's networks) exposed it.
const shared = new Set()
for (const a of ids) for (const b of ids) if (a !== b) for (const s of own[a]) if (own[b].has(s)) shared.add(s)
for (const id of ids) for (const s of shared) own[id].delete(s)
const out = {}
for (const id of ids) out[id] = [...new Set([...(HAND_VOCAB[id] || []), ...own[id]])]
return out
@ -120,8 +131,10 @@ function* strings(v) {
}
/** The text a series OWNS: its overrides and its poster furniture. Not its prose documentation. */
// The poster header block too: that is where the country poster once printed the UPG-100 side box, and a scan
// of strings + networks alone could never see a series' framing copied into another's poster text.
function seriesText(entry) {
return [...strings(entry.strings || {}), ...strings(entry.networks || [])]
return [...strings(entry.strings || {}), ...strings(entry.networks || []), ...strings(entry.poster || {})]
}
function checkDeclarations(series) {
@ -208,6 +221,9 @@ function selfTest() {
['upg100 loses its funnel', (s) => { s.upg100.funnel = false }],
['a series loses a networks block', (s) => { s.country.networks.pop() }],
['dataDir points nowhere', (s) => { s.country.dataDir = 'no/such/folder' }],
['indiaState keeps the country side box', (s) => { s.indiaState.poster.sideBoxHeading = 'What is a Country Prayer Map?' }],
['country keeps the state network line', (s) => { s.country.networks[0][1] = s.indiaState.networks[0][1] }],
['indiaState keeps PRAY FOR THIS COUNTRY', (s) => { s.indiaState.strings.map_page_kicker = 'PRAY FOR THIS COUNTRY' }],
]
let pass = 0
for (const [name, mutate] of cases) {
@ -224,6 +240,19 @@ function selfTest() {
fails.length = 0
if (pass !== cases.length) fail(`self-test: caught ${pass}/${cases.length} known-bad inputs`)
else ok(`self-test: caught all ${cases.length} known-bad inputs`)
// Shared wording is nobody's fingerprint, in EITHER order: three series where the first and last share a
// sentence must neither flag it nor lose their own phrases.
const SHARED = 'A sentence two series both print, word for word.'
const tri = {
a: { strings: { k: 'Only series a says this phrase.' }, networks: [['h', SHARED]] },
b: { strings: { k: 'Only series b says this phrase.' }, networks: [] },
c: { strings: { k: 'Only series c says this phrase.' }, networks: [['h', SHARED]] },
}
const v = deriveVocab(tri)
const leaked = Object.entries(v).filter(([, p]) => p.includes(SHARED)).map(([id]) => id)
const lost = ['a', 'b', 'c'].filter((id) => !v[id].includes(`Only series ${id} says this phrase.`))
if (leaked.length || lost.length) fail(`self-test: shared wording kept as vocabulary of [${leaked}] / own phrases lost for [${lost}]`)
else ok('self-test: shared wording is dropped from every series, whatever their order')
}
// ── run ──────────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "الهند — الولايات والأقاليم الاتحادية",
"indiaStateNoGroups": "لا توجد هنا شعوب غير مُبشَّرة مُدرَجة.",
"stateNames": {
"IN-AN": "جزر أندمان وَ نِكُبَار",
"IN-AP": "أندرا برديش",
"IN-AR": "أروناجل برديش",
"IN-AS": "آسام",
"IN-BR": "بهار",
"IN-CH": "شَنديغار",
"IN-CT": "تشاتيسغار",
"IN-DH": "دادرا وناغار هافيلي ودامان وديو",
"IN-DL": "دلهي",
"IN-GA": "غوا",
"IN-GJ": "كجرات",
"IN-HP": "هماجل برديش",
"IN-HR": "هريانة",
"IN-JH": "جاركند",
"IN-JK": "إقليم جمون وكشمير",
"IN-KA": "كرناتك",
"IN-KL": "كيرلا",
"IN-LA": "لداخ",
"IN-LD": "لكشديب",
"IN-MH": "مهاراشترة",
"IN-ML": "ميكالية",
"IN-MN": "منيبور",
"IN-MP": "مدية برديش",
"IN-MZ": "مِزُورَم",
"IN-NL": "ناغالند",
"IN-OR": "أديشة",
"IN-PB": "بنجاب",
"IN-PY": "بُدُجيري",
"IN-RJ": "راجستان",
"IN-SK": "سِكِم",
"IN-TG": "تلنكانة",
"IN-TN": "تاميل نادو",
"IN-TR": "تريبورة",
"IN-UP": "أتر برديش",
"IN-UT": "أوتاراخند",
"IN-WB": "بنغال الغربية"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "Indien – Bundesstaaten und Unionsterritorien",
"indiaStateNoGroups": "Hier sind keine unerreichten Volksgruppen verzeichnet.",
"stateNames": {
"IN-AN": "Andamanen und Nikobaren",
"IN-AP": "Andhra Pradesh",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadra und Nagar Haveli und Daman und Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Gujarat",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu und Kashmir",
"IN-KA": "Karnataka",
"IN-KL": "Kerala",
"IN-LA": "Ladakh",
"IN-LD": "Lakshadweep",
"IN-MH": "Maharashtra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Odisha",
"IN-PB": "Punjab",
"IN-PY": "Puducherry",
"IN-RJ": "Rajasthan",
"IN-SK": "Sikkim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Uttar Pradesh",
"IN-UT": "Uttarakhand",
"IN-WB": "Westbengalen"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "India — states and union territories",
"indiaStateNoGroups": "No unreached people groups are listed here.",
"stateNames": {
"IN-AN": "Andaman and Nicobar Islands",
"IN-AP": "Andhra Pradesh",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadra and Nagar Haveli and Daman and Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Gujarat",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu and Kashmir",
"IN-KA": "Karnataka",
"IN-KL": "Kerala",
"IN-LA": "Ladakh",
"IN-LD": "Lakshadweep",
"IN-MH": "Maharashtra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Odisha",
"IN-PB": "Punjab",
"IN-PY": "Puducherry",
"IN-RJ": "Rajasthan",
"IN-SK": "Sikkim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Uttar Pradesh",
"IN-UT": "Uttarakhand",
"IN-WB": "West Bengal"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "India: estados y territorios de la unión",
"indiaStateNoGroups": "Aquí no figura ningún pueblo no alcanzado.",
"stateNames": {
"IN-AN": "Islas Andamán y Nicobar",
"IN-AP": "Andhra Pradesh",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadra y Nagar Haveli y Damán y Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Guyarat",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu y Cachemira",
"IN-KA": "Karnataka",
"IN-KL": "Kerala",
"IN-LA": "Ladakh",
"IN-LD": "Laquedivas",
"IN-MH": "Maharashtra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Orissa",
"IN-PB": "Punyab",
"IN-PY": "Puducherry",
"IN-RJ": "Rajastán",
"IN-SK": "Sikkim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Uttar Pradesh",
"IN-UT": "Uttarakhand",
"IN-WB": "Bengala Occidental"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "Inde — États et territoires de l’Union",
"indiaStateNoGroups": "Aucun peuple non atteint n’est répertorié ici.",
"stateNames": {
"IN-AN": "Îles Andaman-et-Nicobar",
"IN-AP": "Andhra Pradesh",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadra et Nagar Haveli et Daman et Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Gujarat",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu-et-Cachemire",
"IN-KA": "Karnataka",
"IN-KL": "Kerala",
"IN-LA": "Ladakh",
"IN-LD": "Lakshadweep",
"IN-MH": "Maharashtra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Odisha",
"IN-PB": "Pendjab",
"IN-PY": "Territoire de Pondichéry",
"IN-RJ": "Rajasthan",
"IN-SK": "Sikkim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Uttar Pradesh",
"IN-UT": "Uttarakhand",
"IN-WB": "Bengale-Occidental"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "भारत — राज्य और केंद्र शासित प्रदेश",
"indiaStateNoGroups": "यहाँ कोई अपहुँचा जनसमूह सूचीबद्ध नहीं है।",
"stateNames": {
"IN-AN": "अण्डमान और निकोबार द्वीपसमूह",
"IN-AP": "आन्ध्र प्रदेश",
"IN-AR": "अरुणाचल प्रदेश",
"IN-AS": "असम",
"IN-BR": "बिहार",
"IN-CH": "चण्डीगढ़",
"IN-CT": "छत्तीसगढ़",
"IN-DH": "दादरा और नगर हवेली और दमन और दीव",
"IN-DL": "दिल्ली",
"IN-GA": "गोवा",
"IN-GJ": "गुजरात",
"IN-HP": "हिमाचल प्रदेश",
"IN-HR": "हरियाणा",
"IN-JH": "झारखण्ड",
"IN-JK": "जम्मू और कश्मीर",
"IN-KA": "कर्नाटक",
"IN-KL": "केरल",
"IN-LA": "लद्दाख़",
"IN-LD": "लक्षद्वीप",
"IN-MH": "महाराष्ट्र",
"IN-ML": "मेघालय",
"IN-MN": "मणिपुर",
"IN-MP": "मध्य प्रदेश",
"IN-MZ": "मिज़ोरम",
"IN-NL": "नागालैण्ड",
"IN-OR": "ओडिशा",
"IN-PB": "पंजाब",
"IN-PY": "पुदुचेरी",
"IN-RJ": "राजस्थान",
"IN-SK": "सिक्किम",
"IN-TG": "तेलंगाना",
"IN-TN": "तमिल नाडु",
"IN-TR": "त्रिपुरा",
"IN-UP": "उत्तर प्रदेश",
"IN-UT": "उत्तराखण्ड",
"IN-WB": "पश्चिम बंगाल"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "India — stati e territori dell’Unione",
"indiaStateNoGroups": "Qui non è elencato alcun popolo non raggiunto.",
"stateNames": {
"IN-AN": "isole Andamane e Nicobare",
"IN-AP": "Andhra Pradesh",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadra e Nagar Haveli e Daman e Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Gujarat",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu e Kashmir",
"IN-KA": "Karnataka",
"IN-KL": "Kerala",
"IN-LA": "Ladakh",
"IN-LD": "Laccadive",
"IN-MH": "Maharashtra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Orissa",
"IN-PB": "Punjab",
"IN-PY": "Pondicherry",
"IN-RJ": "Rajasthan",
"IN-SK": "Sikkim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Uttar Pradesh",
"IN-UT": "Uttarakhand",
"IN-WB": "Bengala Occidentale"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "Índia — estados e territórios da união",
"indiaStateNoGroups": "Nenhum povo não alcançado está listado aqui.",
"stateNames": {
"IN-AN": "Andamão e Nicobar",
"IN-AP": "Andra Pradexe",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadrá e Nagar Aveli e Damão e Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Gujarate",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu e Caxemira",
"IN-KA": "Carnataca",
"IN-KL": "Kerala",
"IN-LA": "Ladaque",
"IN-LD": "Laquedivas",
"IN-MH": "Maarastra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Orissa",
"IN-PB": "Punjab",
"IN-PY": "Puducherry",
"IN-RJ": "Rajastão",
"IN-SK": "Siquim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Utar Pradexe",
"IN-UT": "Utaracanda",
"IN-WB": "Bengala Ocidental"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "India — state și teritorii ale Uniunii",
"indiaStateNoGroups": "Aici nu este listat niciun popor neatins.",
"stateNames": {
"IN-AN": "Insulele Andaman și Nicobar",
"IN-AP": "Andhra Pradesh",
"IN-AR": "Arunachal Pradesh",
"IN-AS": "Assam",
"IN-BR": "Bihar",
"IN-CH": "Teritoriul Unional Chandigarh",
"IN-CT": "Chhattisgarh",
"IN-DH": "Dadra și Nagar Haveli și Daman și Diu",
"IN-DL": "Delhi",
"IN-GA": "Goa",
"IN-GJ": "Gujarat",
"IN-HP": "Himachal Pradesh",
"IN-HR": "Haryana",
"IN-JH": "Jharkhand",
"IN-JK": "Jammu și Cașmir",
"IN-KA": "Karnataka",
"IN-KL": "Kerala",
"IN-LA": "Ladakh",
"IN-LD": "Lakshadweep",
"IN-MH": "Maharashtra",
"IN-ML": "Meghalaya",
"IN-MN": "Manipur",
"IN-MP": "Madhya Pradesh",
"IN-MZ": "Mizoram",
"IN-NL": "Nagaland",
"IN-OR": "Odisha",
"IN-PB": "Punjab",
"IN-PY": "Puducherry",
"IN-RJ": "Rajasthan",
"IN-SK": "Sikkim",
"IN-TG": "Telangana",
"IN-TN": "Tamil Nadu",
"IN-TR": "Tripura",
"IN-UP": "Uttar Pradesh",
"IN-UT": "Uttarakhand",
"IN-WB": "Bengalul de Vest"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "Индия — штаты и союзные территории",
"indiaStateNoGroups": "Здесь не указано ни одного недостигнутого народа.",
"stateNames": {
"IN-AN": "Андаманские и Никобарские острова",
"IN-AP": "Андхра-Прадеш",
"IN-AR": "Аруначал-Прадеш",
"IN-AS": "Ассам",
"IN-BR": "Бихар",
"IN-CH": "Чандигарх",
"IN-CT": "Чхаттисгарх",
"IN-DH": "Дадра и Нагар-Хавели и Даман и Диу",
"IN-DL": "Дели",
"IN-GA": "Гоа",
"IN-GJ": "Гуджарат",
"IN-HP": "Химачал-Прадеш",
"IN-HR": "Харьяна",
"IN-JH": "Джаркханд",
"IN-JK": "Джамму и Кашмир",
"IN-KA": "Карнатака",
"IN-KL": "Керала",
"IN-LA": "Ладакх",
"IN-LD": "Лакшадвип",
"IN-MH": "Махараштра",
"IN-ML": "Мегхалая",
"IN-MN": "Манипур",
"IN-MP": "Мадхья-Прадеш",
"IN-MZ": "Мизорам",
"IN-NL": "Нагаленд",
"IN-OR": "Одиша",
"IN-PB": "Пенджаб",
"IN-PY": "Пондичерри",
"IN-RJ": "Раджастхан",
"IN-SK": "Сикким",
"IN-TG": "Телингана",
"IN-TN": "Тамилнад",
"IN-TR": "Трипура",
"IN-UP": "Уттар-Прадеш",
"IN-UT": "Уттаракханд",
"IN-WB": "Западная Бенгалия"
}
}

View file

@ -0,0 +1,42 @@
{
"indiaStatesHeading": "印度——邦与中央直辖区",
"indiaStateNoGroups": "此处未列出任何未得之民族群。",
"stateNames": {
"IN-AN": "安达曼-尼科巴群岛",
"IN-AP": "安得拉邦",
"IN-AR": "阿鲁纳恰尔邦",
"IN-AS": "阿萨姆邦",
"IN-BR": "比哈尔邦",
"IN-CH": "昌迪加尔",
"IN-CT": "切蒂斯格尔邦",
"IN-DH": "达德拉-纳加尔哈维利和达曼-第乌",
"IN-DL": "德里",
"IN-GA": "果阿邦",
"IN-GJ": "古吉拉特邦",
"IN-HP": "喜马偕尔邦",
"IN-HR": "哈里亚纳邦",
"IN-JH": "贾坎德邦",
"IN-JK": "查谟和克什米尔",
"IN-KA": "卡纳塔克邦",
"IN-KL": "喀拉拉邦",
"IN-LA": "拉达克",
"IN-LD": "拉克沙群岛",
"IN-MH": "马哈拉施特拉邦",
"IN-ML": "梅加拉亚邦",
"IN-MN": "曼尼普尔邦",
"IN-MP": "中央邦",
"IN-MZ": "米佐拉姆邦",
"IN-NL": "那加兰邦",
"IN-OR": "奥迪沙邦",
"IN-PB": "旁遮普邦",
"IN-PY": "本地治里",
"IN-RJ": "拉贾斯坦邦",
"IN-SK": "锡金邦",
"IN-TG": "特伦甘纳邦",
"IN-TN": "泰米尔纳德邦",
"IN-TR": "特里普拉邦",
"IN-UP": "北方邦",
"IN-UT": "北阿坎德邦",
"IN-WB": "西孟加拉邦"
}
}

View file

@ -58,8 +58,9 @@
* keeps the renderer's own default rather than a fabricated position.
*
* A null sheet is a tier too: India (sheet null, too many groups for any paper size) is its own
* 1-of-1 series. Skipping it printed "map 1 of 0" on India's packet page 1 from the Regions tab
* while the wall's own copy printed "map 1 of 1" -- the two paths disagreeing again.
* 1-of-1 series. Skipping it gave India's sheet "map 1 of 0" on the Regions path and "map 1 of 1" on the
* wall's old copy. Neither was visible then (the map-only packet page prints no series line, and India's
* poster is gated), but any India sheet with a header would have shown it.
* Every null-sheet country shares that ONE oversized series: a second would make India "map 1 of 2".
* check:country-sheet-one-path fails when that happens, so it is decided, not drifted into.
*/

View file

@ -143,5 +143,49 @@
"emit-countries.mjs), so the SHAPE was replaced: not 'split the set' but 'ask which one,",
"then give them that poster'. The numbers are gone from the headings for the same reason."
]
},
"indiaState": {
"label": "India states and union territories — one poster for each of the 34 with people groups listed",
"dataDir": "plugin-designs/my-upg-100-list/data",
"countryMode": true,
"funnel": false,
"poster": {
"kicker": "Unreached People Groups",
"titleQualifier": "Pray for",
"seriesName": "India State Maps",
"sideBoxHeading": "What is a State Prayer Map?",
"sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — one sheet for each state and union territory of India, naming the unreached people groups who live there so a reader can pray for them by name."
},
"_poster_why": [
"A state poster is a country poster one level down: the Country Posters wall prints one for each of India's",
"states and union territories that has people groups listed (34 of 36; data/people-groups/india-states.json).",
"The country series' text cannot be reused: it would call Gujarāt's poster one of the ~170 'Country Maps',",
"one sheet for each country. The term is 'state and union territory', never 'province'. Approved as the",
"default wording 2026-09-30 and shown as editable; a change is a strings-only follow-up."
],
"strings": {
"map_page_kicker": "PRAY FOR THIS STATE",
"map_page_sub": "A prayer list of {count} unreached people groups who live here",
"points_intro": "One hour with God, five minutes each. Pray these twelve for the peoples of this state.",
"christian_lede": "This is the same grid — one human life in weeks. Now each box is a share of the people in this state. The shaded boxes are the evangelical share of their population; the rest are waiting."
},
"_strings_why": [
"The country series' four keys with 'country' read as 'state'. Used by the Node generator; the browser",
"packet reads the localized packet-strings.json, which is series-invariant."
],
"networks": [
[
"1 · Disciples.",
"Ask a disciple which state God has put on their heart, then give them that state's poster to print and prepare."
],
[
"2 · Churches.",
"List the churches you know and ask each the same question, so every church has a poster to pray over together."
],
[
"3 · Indigenous leaders.",
"Ask which leaders you know who live in one of these states, and give them their own state's map to pray for it specifically."
]
]
}
}

View file

@ -449,7 +449,7 @@
"check:composite-token-url": "a composite embedded BARE (no outer ProfileLoader) must honour a tokenUrl-only config: it reads tokenUrl, forwards it, and asks the token service only when no token is given. The expectation is derived from ProfileLoader, not restated. Headless; in gates:pre.",
"check:composites-mount": "every capability-4/5 composite's OWN setup() and mounted hooks run without throwing, in en and ar, headless (no browser): vite loads each composite as its bundle entry defines it (configureApp, and ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer. Exists because 08f89f55 shipped two composites calling getRuntimeLocale/detectLocale/RTL_LOCALES with no import: the build was green, setup threw a ReferenceError in every locale, and Big Picture and Simple Research rendered nothing. Proves setup runs; cannot prove the children render.",
"check:country-names": "every ISO 3166-1 alpha-3 our data uses must resolve to a localized country name. Guards the JOIN, not Intl: library/data/iso3-to-alpha2.json drifting from library/data/countries.json makes a country render as a bare code, which still looks like a value. Checks en/ar/zh (ar is our only RTL locale; zh makes an untranslated Latin code unmistakable) and asserts BOTH directions of drift. Carries two negative arms — an unassigned code and a malformed one — and FAILS if either resolves, because a coverage count cannot otherwise tell a hit from a lookup that returns its input.",
"check:country-sheet-one-path": "a country sheet is drawn through ONE path, @map/packet/countrySheet.js, so one country cannot print two posters. Parses (comments cannot count) every plugin source naming generateCountrySheet/makeCountrySheet: a direct generateCountrySheet in code FAILS, every makeCountrySheet call must pass `countries` (the series list), and both known callers (the Country Posters wall, regions-list) must be found. Also runs seriesFor over the real country-posters.json with named answers: India 1 of 1, every 3x3 country N of the 3x3 tier with no repeat, a code absent from the data no series.",
"check:country-sheet-one-path": "a country sheet, and an Indian state or union territory sheet, each draw through ONE path (@map/packet/countrySheet.js; country-posters/src/indiaStateSheet.js), so one place cannot print two posters. Parses (comments cannot count) plugin sources: a direct generateCountrySheet FAILS anywhere, a direct generateSheet FAILS inside country-posters outside the state helper, every makeCountrySheet call must pass `countries`, and the known callers must be found. Named data answers: seriesFor over country-posters.json (India 1 of 1, each 3x3 country N of 173, an absent code no series, exactly one no-sheet country); over the served india-states.json + gb-IND-ADM1.geojson: 36 divisions, the 34 with groups numbered 1..34 each with an outline, the rest named, the shared ADM1 never mutated.",
"check:country-sheet-one-path:self-test": "plants each defect check:country-sheet-one-path guards (a direct generateCountrySheet call, makeCountrySheet without countries, the old guard that skipped India, a position given to an absent code) and requires each to FAIL; a comment naming generateCountrySheet must NOT.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",

View file

@ -35,20 +35,23 @@
* The shared wall always sorts sections AND cards by descending count, so the
* dense countries lead and the long tail sinks. There is no floor.
*/
import { computed, inject, ref } from 'vue'
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
// A country's name in the reader's language (Intl, joined from our ISO-3 codes).
import { countryName } from '@map/utils/countryName.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import { getRuntimeLocale, detectLocale, tStatic } from '@map/i18n/index.js'
// MOVED to the shared library 2026-09-24: the regions-dashboard poster dropdown is a second
// consumer of this same dataset, and RULE 2 puts a dataset with a named second consumer in
// library/. Generated by library/scripts/posters/emit-countries.mjs, which now writes there.
import data from '@map/data/country-posters.json'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { makeCountrySheet } from '@map/packet/countrySheet.js'
// India's states and union territories: the shared table (fetched when the wall shows them) and their sheet.
import { loadIndiaStates } from '@map/data/indiaStates.js'
import { makeIndiaStateSheet, statesWithPosters } from './src/indiaStateSheet.js'
// The regions catalogue holds the not-yet sentence a host's country gets when no poster exists (all eleven locales);
// its keys are English sentences with dots, so it is read by literal key, the way regions-list.vue reads it.
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
@ -105,15 +108,48 @@ const hostColumns = computed(() => Number(profileConfig.value?.columns) || 2)
let tRegions = (key) => key
try { tRegions = useRegionsI18n().t } catch (_) { /* a host with no i18n reads the English sentence, like every string here */ }
// ★ INDIA'S STATES AND UNION TERRITORIES, one poster each, like a country one level down. The table is
// fetched (and sha-checked) only when the unfiltered wall is shown; a host's filtered wall never asks for it.
// A failure hides the section rather than showing cards that cannot print, and is retried a bounded number of
// times (the loader drops a failed fetch from its cache, so a retry really fetches again). States with no people
// groups listed (Nagaland, Mizoram) are NAMED with a card that says so, never silently left out.
const indiaStates = ref(null)
const STATE_RETRY_MS = [5000, 20000, 60000]
let stateRetry = null
function loadStates(attempt = 0) {
loadIndiaStates(assetsBase.value).then((table) => { indiaStates.value = table.states }).catch(() => {
if (attempt < STATE_RETRY_MS.length && !filtered.value) stateRetry = setTimeout(() => loadStates(attempt + 1), STATE_RETRY_MS[attempt])
})
}
watch(filtered, (f) => {
if (f || indiaStates.value || stateRetry) return
loadStates()
}, { immediate: true })
onBeforeUnmount(() => { clearTimeout(stateRetry) })
const INDIA_REGION = (byIso3.get('IND') || {}).region || 'Asia'
const stateItems = computed(() => {
const all = indiaStates.value
if (filtered.value || !all) return []
const withGroups = statesWithPosters(all).map((st) => ({
id: st.id, iso: st.iso, label: st.name, count: st.count, sheet: '3x3', region: INDIA_REGION, indiaState: true, slugs: st.slugs,
}))
const none = all.filter((st) => !st.count).map((st) => ({ id: st.id, iso: st.iso, label: st.name, count: 0, indiaState: true, noGroups: true, slugs: [] }))
return [...withGroups, ...none]
})
const items = computed(() => {
const l = wallLocale && (wallLocale.value ?? wallLocale)
const locale = (typeof l === 'string' && l) || getRuntimeLocale() || detectLocale() || 'en'
const countries = selected.value
if (/^en\b/i.test(locale)) return countries
return countries.map((c) => {
// A state's card is named in the reader's language from the catalogue (countryPosters.stateNames, generated from
// Wikidata by scripts/emit-india-state-names.mjs); its printed sheet stays English, like a country's.
const states = /^en\b/i.test(locale) ? stateItems.value
: stateItems.value.map((st) => ({ ...st, label: tStatic(`countryPosters.stateNames.${st.iso}`, locale) || st.label }))
if (/^en\b/i.test(locale)) return [...countries, ...states]
return [...countries.map((c) => {
const name = countryName(c.iso3, locale)
return name && name !== c.iso3 ? { ...c, label: name } : c
})
}), ...states]
})
// ★ EVERY COUNTRY PRINTS ITS PRAYER RESOURCES — the list, the card deck and the packet — through the
@ -125,7 +161,9 @@ const recordsFor = createPeopleGroupRecords({
lang: () => getRuntimeLocale() || detectLocale(),
profileConfig,
})
const countryRecords = (c) => recordsFor({ type: 'country', values: [c.iso3] })
const countryRecords = (c) => (c.indiaState
? (c.slugs.length ? recordsFor({ type: 'pg_slugs', values: c.slugs }) : Promise.resolve([]))
: recordsFor({ type: 'country', values: [c.iso3] }))
// ★ GENERATED AT THE CLICK, through the ONE shared path (@map/packet/countrySheet.js, which the Regions
// tab uses too): the country's sheet is drawn in the browser from its people groups and the world
// boundaries, for the poster print (letter, vector) and the packet's page 1. No stored sheet exists for a
@ -139,7 +177,12 @@ const sheetForRecord = makeCountrySheet({
assetsBase: () => assetsBase.value,
countries: data.countries || [],
})
const countrySheet = (card, format) => sheetForRecord(byId.get(card.id) || card, format)
const countryOnlySheet = (card, format) => sheetForRecord(byId.get(card.id) || card, format)
// A state's sheet through its own ONE path (src/indiaStateSheet.js), in India's hue and the indiaState series.
const stateSheet = makeIndiaStateSheet({
records: recordsFor, assetsBase: () => assetsBase.value, states: () => indiaStates.value, region: INDIA_REGION,
})
const countrySheet = (card, format) => (card.indiaState ? stateSheet(card, format) : countryOnlySheet(card, format))
// The name table the generated sheet was labelled from, so a packet in another language relabels page 1's
// country names (the same seam the UPG-100 wall passes).
const iso3Names = () => import('@map/data/iso3-name.js').then((m) => m.ISO3_NAME)
@ -161,6 +204,7 @@ async function countryMap(c) {
const t = useOptionalT('')
const regionSlug = (label) => String(label).toLowerCase().replace(/\s+/g, '_')
const groupBy = (c) => {
if (c.indiaState) return t('countryPosters.indiaStatesHeading')
if (!c.region || c.region === 'Other') return t('dashboard.other')
const key = `dashboard.wagfRegion.${regionSlug(c.region)}`
const v = t(key)
@ -178,7 +222,7 @@ const collapsedBy = (sec) => sec.total < 60
// unsupportedReason is an English note (and a flag), not display text. The card already states the
// count above it, so the sentence needs no number and no plural agreement.
// A requested country with no poster at all says so in regions-list.vue's words (the regions catalogue).
const unsupported = (c) => (c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason ? t('messages.countryPosterTooMany') : null)
const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason ? t('messages.countryPosterTooMany') : null)
// ★ THROUGH THE CATALOGUE, like the UPG-100 wall: the heading, the tag, the intro and the counts read
// in the page's language. The intro keeps its one emphasis as three plain keys (A, the emphasis, B)

View file

@ -183,7 +183,10 @@ function onShowOnMap(e) {
// The attribute carries the country ID, not its geometry: countries-map resolves the id against
// the same library/data/country-posters.json the wall renders from, so the bbox has exactly one
// source of truth rather than being copied through an attribute.
const countryId = d.listId ?? null
// An Indian state's card (the state table's ids are 'india-<code>') shows India: this map draws countries,
// not states, so framing the country is the honest answer rather than a world view with nothing selected.
const listId = d.listId ?? null
const countryId = typeof listId === 'string' && listId.startsWith('india-') ? 'country-ind' : listId
const el = paneEls.map
if (!el) return
if (countryId) el.setAttribute('select-country', String(countryId))

View file

@ -0,0 +1,44 @@
{
"what": "India's 36 states and union territories, named in the 11 UI locales, for the Country Posters wall's card labels.",
"source": "Wikidata (https://www.wikidata.org): labels of the items whose ISO 3166-2 code (P300) matches data/people-groups/india-states.json; dissolved items (P576) excluded.",
"licence": "CC0 1.0: Wikidata structured data is released under CC0 (https://creativecommons.org/publicdomain/zero/1.0/).",
"english": "the state table's own name (the English wall is unchanged); Wikidata's English is only a cross-check",
"retrieved": "2026-09-30",
"regenerate": "node plugin-designs/country-posters/scripts/emit-india-state-names.mjs",
"query": "SELECT ?code ?lang ?label WHERE {\n VALUES ?code { \"IN-AN\" \"IN-AP\" \"IN-AR\" \"IN-AS\" \"IN-BR\" \"IN-CG\" \"IN-CH\" \"IN-DH\" \"IN-DL\" \"IN-GA\" \"IN-GJ\" \"IN-HP\" \"IN-HR\" \"IN-JH\" \"IN-JK\" \"IN-KA\" \"IN-KL\" \"IN-LA\" \"IN-LD\" \"IN-MH\" \"IN-ML\" \"IN-MN\" \"IN-MP\" \"IN-MZ\" \"IN-NL\" \"IN-OD\" \"IN-PB\" \"IN-PY\" \"IN-RJ\" \"IN-SK\" \"IN-TN\" \"IN-TR\" \"IN-TS\" \"IN-UK\" \"IN-UP\" \"IN-WB\" }\n ?item wdt:P300 ?code .\n FILTER NOT EXISTS { ?item wdt:P576 ?dissolved }\n ?item rdfs:label ?label . BIND(LANG(?label) AS ?lang)\n FILTER(?lang IN (\"en\", \"es\", \"fr\", \"pt\", \"de\", \"it\", \"ro\", \"ru\", \"ar\", \"hi\", \"zh-hans\", \"zh-cn\", \"zh\"))\n}",
"codeAliases": {
"IN-CT": "IN-CG",
"IN-OR": "IN-OD",
"IN-TG": "IN-TS",
"IN-UT": "IN-UK"
},
"reviewedOverrides": [
"IN-DH zh: \"达德拉-纳加尔哈维利和达曼-第乌\" (Wikidata has only a Traditional-script zh label; converted character by character to the Simplified catalogue script)",
"IN-OR ar: \"أديشة\" (the label is the pre-2011 name Orissa (أريسة); ar.wikipedia's title for the item is أديشة (Odisha))"
],
"gaps": [],
"ambiguous": [],
"fallbacks": [
"IN-CH es: \"Chandigarh\" from the same-named item (1 of 1)",
"IN-CH fr: \"Chandigarh\" from the same-named item (1 of 1)",
"IN-CH pt: \"Chandigarh\" from the same-named item (1 of 1)",
"IN-CH de: \"Chandigarh\" from the same-named item (1 of 1)",
"IN-CH it: \"Chandigarh\" from the same-named item (1 of 1)",
"IN-CH ar: \"شَنديغار\" from the same-named item (1 of 1)",
"IN-CH hi: \"चण्डीगढ़\" from the same-named item (1 of 1)",
"IN-CH zh: \"昌迪加尔\" from the same-named item (1 of 1)",
"IN-DL es: \"Delhi\" from the same-named item (preferred)",
"IN-DL fr: \"Delhi\" from the same-named item (preferred)",
"IN-DL pt: \"Delhi\" from the same-named item (preferred)",
"IN-DL de: \"Delhi\" from the same-named item (preferred)",
"IN-DL it: \"Delhi\" from the same-named item (preferred)",
"IN-DL ro: \"Delhi\" from the same-named item (preferred)",
"IN-DL ru: \"Дели\" from the same-named item (preferred)",
"IN-DL ar: \"دلهي\" from the same-named item (preferred)",
"IN-DL hi: \"दिल्ली\" from the same-named item (preferred)",
"IN-DL zh: \"德里\" from the same-named item (preferred)"
],
"englishMismatches": [
"IN-DL: table \"Delhi\" vs Wikidata \"National Capital Territory of Delhi\"; same-named labels preferred"
]
}

View file

@ -25,6 +25,10 @@ import { parse as parseSfc, babelParse } from '@vue/compiler-sfc'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const PD = join(ROOT, 'plugin-designs')
const KNOWN_CALLERS = ['plugin-designs/country-posters/country-poster-wall.vue', 'plugin-designs/regions-dashboard/regions-list.vue']
// India's states and union territories draw through their own ONE path; inside this plugin only that helper may
// call the generator directly.
const STATE_HELPER = 'plugin-designs/country-posters/src/indiaStateSheet.js'
const STATE_CALLER = 'plugin-designs/country-posters/country-poster-wall.vue'
function scriptsOf(file, src) {
if (!file.endsWith('.vue')) return [src]
@ -78,9 +82,11 @@ function listSources(dir, out = []) {
export function armA(files, callers = []) {
const fails = []
for (const [rel, src] of Object.entries(files)) {
if (!/generateCountrySheet|makeCountrySheet/.test(src)) continue // a text prefilter; the parse decides
if (!/generateCountrySheet|makeCountrySheet|generateSheet|makeIndiaStateSheet/.test(src)) continue // a text prefilter; the parse decides
const { names, makeCalls } = codeFacts(rel, src)
if (names.has('generateCountrySheet')) fails.push(`${rel}: draws a country sheet directly (generateCountrySheet in code); use makeCountrySheet from @map/packet/countrySheet.js`)
if (rel.startsWith('plugin-designs/country-posters/') && rel !== STATE_HELPER && names.has('generateSheet')) fails.push(`${rel}: calls generateSheet directly; a state sheet goes through makeIndiaStateSheet (${STATE_HELPER})`)
if (rel === STATE_CALLER && names.has('makeIndiaStateSheet')) callers.push(`${rel}#state`)
if (makeCalls.length) callers.push(rel)
makeCalls.forEach((c, i) => { if (!c.hasCountries) fails.push(`${rel}: makeCountrySheet call #${i + 1} passes no \`countries\`, so its "map N of M" is not the wall's`) })
}
@ -111,6 +117,44 @@ export function armB(seriesFor, countries) {
return fails
}
// ARM C: the state posters over the SERVED table and the renderer's own ADM1 file (named expectations).
export function armC(H, states, adm1Ind) {
const fails = []
const before = JSON.stringify((adm1Ind.features || []).map((f) => f.properties && f.properties.listId)) // BEFORE any call
const withGroups = H.statesWithPosters(states)
const zero = (states || []).filter((st) => !st.count).map((st) => st.name)
if ((states || []).length !== 36) fails.push(`the state table has ${(states || []).length} divisions, expected 36 (28 states + 8 union territories)`)
if (withGroups.length + zero.length !== (states || []).length) fails.push('every division is either postered or named as having none')
const idx = withGroups.map((st) => (H.stateSeries(states, st) || {}).index)
if (new Set(idx).size !== withGroups.length || Math.min(...idx) !== 1 || Math.max(...idx) !== withGroups.length) fails.push(`state series indexes are not 1..${withGroups.length}, unique: ${idx.join(',')}`)
const noShape = withGroups.filter((st) => !H.stateGeometry(H.stateZone(st, '#000'), adm1Ind, st.shapeName)).map((st) => st.name)
if (noShape.length) fails.push(`states with people groups but no outline in gb-IND-ADM1.geojson (their poster would not draw): ${noShape.join(', ')}`)
withGroups.forEach((st) => H.stateGeometry(H.stateZone(st, '#000'), adm1Ind, st.shapeName))
if (JSON.stringify((adm1Ind.features || []).map((f) => f.properties && f.properties.listId)) !== before) fails.push('stateGeometry mutated the shared ADM1 collection')
return fails
}
// ARM D: the state cards' localized names cover EXACTLY the table's divisions in every locale (a runtime-built key,
// `countryPosters.stateNames.${iso}`, that the i18n call-site gates cannot see), English is the table's own name, and
// the generated names carry their source licence.
const NAME_LOCALES = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
export function armD(states, areas, meta) {
const fails = []
const want = new Set((states || []).map((st) => st.iso))
for (const l of NAME_LOCALES) {
const names = (areas[l] && areas[l].stateNames) || {}
const have = new Set(Object.keys(names))
const missing = [...want].filter((x) => !have.has(x)), extra = [...have].filter((x) => !want.has(x))
const empty = Object.entries(names).filter(([, v]) => typeof v !== 'string' || !v.trim()).map(([k]) => k)
if (missing.length || extra.length || empty.length) fails.push(`${l} countryPosters.stateNames: missing [${missing}] extra [${extra}] empty [${empty}]`)
}
const en = (areas.en && areas.en.stateNames) || {}
const drift = (states || []).filter((st) => en[st.iso] !== st.name).map((st) => st.iso)
if (drift.length) fails.push(`English state names differ from the table's: ${drift.join(', ')} (re-run emit-india-state-names.mjs)`)
if (!meta || !/CC0/.test(String(meta.licence || ''))) fails.push('india-state-names.meta.json must record the source licence (CC0)')
return fails
}
async function main() {
const selfTest = process.argv.includes('--self-test')
const { seriesFor } = await import(join(ROOT, 'library/packet/countrySheet.js'))
@ -118,6 +162,14 @@ async function main() {
const countries = data.countries || []
if (countries.length < 170) { console.log(`NOT MEASURED: country-posters.json parsed ${countries.length} countries (floor 170)`); process.exitCode = 75; return }
const H = await import(join(ROOT, STATE_HELPER))
const states = JSON.parse(readFileSync(join(ROOT, 'public/dx-maps/assets/people-groups/india-states.json'), 'utf8')).states || []
const adm1Ind = JSON.parse(readFileSync(join(ROOT, 'public/dx-maps/assets/boundaries/gb-IND-ADM1.geojson'), 'utf8'))
if (states.length < 30 || (adm1Ind.features || []).length < 30) { console.log(`NOT MEASURED: state table ${states.length} / ADM1 ${(adm1Ind.features || []).length} features (floor 30)`); process.exitCode = 75; return }
const areas = Object.fromEntries(NAME_LOCALES.map((l) => [l, JSON.parse(readFileSync(join(ROOT, `library/i18n/locales/${l}/countryPosters.json`), 'utf8'))]))
const meta = JSON.parse(readFileSync(join(ROOT, 'plugin-designs/country-posters/data/india-state-names.meta.json'), 'utf8'))
const files = {}
for (const p of listSources(PD)) files[relative(ROOT, p)] = readFileSync(p, 'utf8')
@ -129,6 +181,12 @@ async function main() {
['India skipped (the old guard)', () => armB((l, it) => (!it || !it.sheet ? undefined : seriesFor(l, it)), countries)],
['absent code given a position', () => armB((l, it) => seriesFor(l, it) || { index: 1, totalLists: 1, totalGroups: 0 }, countries)],
['a second no-sheet country joins India', () => armB(seriesFor, countries.map((c) => (c.iso3 === 'CHN' ? { ...c, sheet: null } : c)))],
['the wall calls generateSheet directly', () => armA({ ...files, [wall]: files[wall].replace('<script setup>', "<script setup>\nimport { generateSheet } from '@map/packet/posterSheets.js'\ngenerateSheet({})") })],
['a state with groups loses its outline', () => armC(H, states.map((st) => (st.id === 'india-gj' ? { ...st, shapeName: 'Nowhere' } : st)), adm1Ind)],
['a division silently dropped', () => armC(H, states.filter((st) => st.id !== 'india-mz'), adm1Ind)],
['a locale loses a state name', () => armD(states, { ...areas, ro: { ...areas.ro, stateNames: Object.fromEntries(Object.entries(areas.ro.stateNames).filter(([k]) => k !== 'IN-GJ')) } }, meta)],
['the names lose their licence line', () => armD(states, areas, { ...meta, licence: '' })],
['a helper that mutates the shared ADM1', () => armC({ ...H, stateGeometry: (z, a, n) => { (a.features || []).forEach((f) => { if (f.properties.shapeName === n) f.properties.listId = z.id }); return H.stateGeometry(z, a, n) } }, states, JSON.parse(JSON.stringify(adm1Ind)))],
]
let bad = 0
for (const [name, run] of cases) {
@ -145,12 +203,12 @@ async function main() {
}
const callers = []
const fails = [...armA(files, callers), ...armB(seriesFor, countries)]
const missing = KNOWN_CALLERS.filter((k) => !callers.includes(k))
const fails = [...armA(files, callers), ...armB(seriesFor, countries), ...armC(H, states, adm1Ind), ...armD(states, areas, meta)]
const missing = [...KNOWN_CALLERS, `${STATE_CALLER}#state`].filter((k) => !callers.includes(k))
if (missing.length) fails.push(`known callers not found calling makeCountrySheet: ${missing.join(', ')}`)
if (fails.length) { fails.forEach((f) => console.log(`FAIL ${f}`)); process.exitCode = 1; return }
const ind = seriesFor(countries, countries.find((c) => c.iso3 === 'IND'))
console.log(`PASS check:country-sheet-one-path — ${Object.keys(files).length} plugin sources parsed-or-skipped, makeCountrySheet callers: ${callers.join(', ')}; seriesFor over ${countries.length} countries (India ${ind.index} of ${ind.totalLists})`)
console.log(`PASS check:country-sheet-one-path — ${Object.keys(files).length} plugin sources parsed-or-skipped, makeCountrySheet callers: ${callers.join(', ')}; seriesFor over ${countries.length} countries (India ${ind.index} of ${ind.totalLists}); ${H.statesWithPosters(states).length} state posters, series 1..${H.statesWithPosters(states).length}, every outline found; state names in ${NAME_LOCALES.length} locales x ${states.length}`)
}
main().catch((e) => { console.error(`FAIL ${e.stack || e}`); process.exitCode = 1 })

View file

@ -0,0 +1,134 @@
#!/usr/bin/env node
// emit-india-state-names.mjs — India's 36 states and union territories named in the 11 UI locales, for the Country
// Posters wall's India section: the CARD labels, the print HUD and the packet/list titles. The printed sheet stays
// English by recorded decision (series.json english_only_note), like every country poster.
//
// SOURCE: Wikidata labels, matched on ISO 3166-2 (P300) against data/people-groups/india-states.json. Wikidata's
// structured data is CC0. The labels are human-edited, not machine translation. Dissolved items (P576) are excluded,
// so a retired code such as the pre-2020 Dadra and Nagar Haveli cannot win.
// English is the TABLE's own name, so the English wall does not change; Wikidata's English is only a cross-check.
// A locale Wikidata has no label for falls back to the English name AND is recorded in the meta file, never silently.
//
// Writes: library/i18n/locales/<locale>/countryPosters.json -> "stateNames": { "IN-GJ": "…", … } (other keys kept)
// plugin-designs/country-posters/data/india-state-names.meta.json (source, licence, query, date, gaps)
// Regenerate: node plugin-designs/country-posters/scripts/emit-india-state-names.mjs
import { readFileSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const LOCALES = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const WD_LANG = { zh: ['zh-hans', 'zh-cn', 'zh'] } // the zh catalogue is Simplified
const wdLangs = (l) => WD_LANG[l] || [l]
// The state table keeps geoBoundaries' ISO codes; ISO 3166-2:IN has since renamed four, and Wikidata's P300 holds
// the CURRENT code. Queried under the current code, keyed back to the table's.
const CODE_ALIAS = { 'IN-CT': 'IN-CG', 'IN-OR': 'IN-OD', 'IN-TG': 'IN-TS', 'IN-UT': 'IN-UK' }
const wdCode = (iso) => CODE_ALIAS[iso] || iso
// REVIEWED OVERRIDES (hi, ar, zh read by eye, 2026-09-30), each with its evidence. Kept small and explicit: neither
// Wikidata's labels nor its Wikipedia titles are uniformly current (zh's Odisha TITLE is the old Orissa while its
// label is current; ar's Odisha LABEL is the old Orissa while its title is current), so no blanket rule is safe.
const OVERRIDES = {
'IN-OR': { ar: ['أديشة', "the label is the pre-2011 name Orissa (أريسة); ar.wikipedia's title for the item is أديشة (Odisha)"] },
'IN-DH': { zh: ['达德拉-纳加尔哈维利和达曼-第乌', 'Wikidata has only a Traditional-script zh label; converted character by character to the Simplified catalogue script'] },
}
const states = JSON.parse(readFileSync(join(ROOT, 'data/people-groups/india-states.json'), 'utf8')).states
const isos = states.map((s) => wdCode(s.iso)).sort()
const langs = [...new Set(LOCALES.flatMap(wdLangs))]
const query = `SELECT ?code ?lang ?label WHERE {
VALUES ?code { ${isos.map((c) => `"${c}"`).join(' ')} }
?item wdt:P300 ?code .
FILTER NOT EXISTS { ?item wdt:P576 ?dissolved }
?item rdfs:label ?label . BIND(LANG(?label) AS ?lang)
FILTER(?lang IN (${langs.map((l) => `"${l}"`).join(', ')}))
}`
const res = await fetch('https://query.wikidata.org/sparql?format=json&query=' + encodeURIComponent(query), {
headers: { accept: 'application/sparql-results+json', 'user-agent': 'dx-maps country-posters emit-india-state-names' },
signal: AbortSignal.timeout(60000),
})
if (!res.ok) { console.error(`Wikidata ${res.status}`); process.exit(1) }
const rows = (await res.json()).results.bindings
const got = {} // iso -> Wikidata language -> Set(labels)
for (const r of rows) ((got[r.code.value] ||= {})[r.lang.value] ||= new Set()).add(r.label.value)
// FALLBACK for a language the matched item has no label in (e.g. the "National Capital Territory of Delhi" item):
// the labels of items in India (P17 = Q668) whose English label IS the table's name (the city of Delhi,
// Chandigarh). Used only for the missing languages, and recorded.
async function sameNameLabels(name) {
const q = `SELECT ?lang ?label WHERE { ?item rdfs:label ${JSON.stringify(name)}@en ; wdt:P17 wd:Q668 .
?item rdfs:label ?label . BIND(LANG(?label) AS ?lang) FILTER(?lang IN (${langs.map((l) => `"${l}"`).join(', ')})) }`
const r = await fetch('https://query.wikidata.org/sparql?format=json&query=' + encodeURIComponent(q), {
headers: { accept: 'application/sparql-results+json', 'user-agent': 'dx-maps country-posters emit-india-state-names' },
signal: AbortSignal.timeout(60000),
})
if (!r.ok) throw new Error(`Wikidata ${r.status}`)
const by = {}
for (const b of (await r.json()).results.bindings) {
const m = (by[b.lang.value] ||= new Map()); m.set(b.label.value, (m.get(b.label.value) || 0) + 1)
}
return by // lang -> Map(label -> how many same-named items carry it)
}
const fallbacks = [], overridden = [], unreviewedZh = []
const gaps = [], ambiguous = [], englishMismatches = []
const names = Object.fromEntries(LOCALES.map((l) => [l, {}]))
for (const st of [...states].sort((a, b) => a.iso.localeCompare(b.iso))) {
const wd = got[wdCode(st.iso)] || {}
if (!Object.keys(wd).length) gaps.push(`${st.iso}: no Wikidata item (queried as ${wdCode(st.iso)})`)
let same = null
const wdEn = wd.en && [...wd.en][0]
// The table names the division more briefly than its item ("Delhi" vs "National Capital Territory of Delhi"): then
// the SAME-NAMED item's labels (the city of Delhi) are preferred in every language, so hi and ru match the English.
const preferSame = Boolean(wdEn && wdEn !== st.name)
if (preferSame) englishMismatches.push(`${st.iso}: table "${st.name}" vs Wikidata "${wdEn}"; same-named labels preferred`)
for (const l of LOCALES) {
if (l === 'en') { names.en[st.iso] = st.name; continue }
const ov = OVERRIDES[st.iso] && OVERRIDES[st.iso][l]
if (ov) { names[l][st.iso] = ov[0]; overridden.push(`${st.iso} ${l}: "${ov[0]}" (${ov[1]})`); continue }
if (preferSame) {
same ||= await sameNameLabels(st.name)
const m = wdLangs(l).map((w) => same[w]).find((x) => x && x.size)
if (m) { const [label] = [...m].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]; names[l][st.iso] = label; fallbacks.push(`${st.iso} ${l}: "${label}" from the same-named item (preferred)`); continue }
}
const src = wdLangs(l).find((w) => wd[w] && wd[w].size)
if (l === 'zh' && src === 'zh') unreviewedZh.push(`${st.iso}: only a plain "zh" label (script unknown: may be Traditional); add a reviewed OVERRIDE`)
const hit = src && wd[src]
if (!hit) {
same ||= await sameNameLabels(st.name)
const m = wdLangs(l).map((w) => same[w]).find((x) => x && x.size)
if (m) {
const [label, n] = [...m].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]
names[l][st.iso] = label; fallbacks.push(`${st.iso} ${l}: "${label}" from the same-named item (${n} of ${[...m.values()].reduce((a, x) => a + x, 0)})`)
continue
}
names[l][st.iso] = st.name; gaps.push(`${st.iso} ${l}: no label, English used`); continue
}
if (hit.size > 1) ambiguous.push(`${st.iso} ${l}: ${[...hit].join(' | ')}`)
names[l][st.iso] = [...hit].sort()[0]
}
}
for (const l of LOCALES) {
const p = join(ROOT, `library/i18n/locales/${l}/countryPosters.json`)
const area = JSON.parse(readFileSync(p, 'utf8'))
area.stateNames = names[l]
writeFileSync(p, JSON.stringify(area, null, 2) + '\n')
}
const meta = {
what: "India's 36 states and union territories, named in the 11 UI locales, for the Country Posters wall's card labels.",
source: 'Wikidata (https://www.wikidata.org): labels of the items whose ISO 3166-2 code (P300) matches data/people-groups/india-states.json; dissolved items (P576) excluded.',
licence: 'CC0 1.0: Wikidata structured data is released under CC0 (https://creativecommons.org/publicdomain/zero/1.0/).',
english: "the state table's own name (the English wall is unchanged); Wikidata's English is only a cross-check",
retrieved: new Date().toISOString().slice(0, 10),
regenerate: 'node plugin-designs/country-posters/scripts/emit-india-state-names.mjs',
query,
codeAliases: CODE_ALIAS,
reviewedOverrides: overridden,
gaps, ambiguous, fallbacks, englishMismatches,
}
writeFileSync(join(ROOT, 'plugin-designs/country-posters/data/india-state-names.meta.json'), JSON.stringify(meta, null, 2) + '\n')
console.log(`states ${states.length}, Wikidata rows ${rows.length}; gaps ${gaps.length}, ambiguous ${ambiguous.length}, fallbacks ${fallbacks.length}, English mismatches ${englishMismatches.length}`)
for (const x of [...gaps, ...ambiguous, ...fallbacks, ...overridden, ...englishMismatches]) console.log(' ' + x)
// A zh name from a plain "zh" label may be Traditional script in a Simplified catalogue: refuse rather than ship it.
if (unreviewedZh.length) { unreviewedZh.forEach((x) => console.error('FAIL ' + x)); process.exitCode = 1 }

View file

@ -0,0 +1,86 @@
/**
* indiaStateSheet.js — one Indian state's or union territory's map sheet, GENERATED in the browser at the click,
* the same print standard as a country's (@map/packet/countrySheet.js): no sheet is stored, only previews.
*
* A state poster is a country poster one level down. Its people groups come from the shared state table
* (library/data/indiaStates.js: each state's slugs), its outline from the ADM1 boundaries the renderer
* already loads (boundaries/gb-IND-ADM1.geojson, matched on shapeName), and its text from the `indiaState`
* series (series.json), so it never calls itself one of the Country Maps.
*
* const sheet = makeIndiaStateSheet({ records, assetsBase: () => base, states })
* <PosterWall :sheet="sheet" /> // (item, format) -> html, '' when it cannot be drawn
*
* ONE PATH: the wall's state cards draw through this and nothing else (check:country-sheet-one-path).
*/
// The states that get a poster: those with people groups listed, in the wall's own order (most groups first,
// then by name), so a sheet's "map N of M" matches the card's place on the wall.
export function statesWithPosters(states) {
return (states || []).filter((s) => s && s.count > 0)
.slice().sort((a, b) => b.count - a.count || String(a.name).localeCompare(String(b.name), 'en'))
}
export function stateSeries(states, state) {
const tier = statesWithPosters(states)
const index = tier.findIndex((s) => s.id === state?.id) + 1
if (!index) return undefined
return { totalGroups: tier.reduce((a, s) => a + s.count, 0), totalLists: tier.length, index }
}
// The zone the renderer draws. `provinceLabel` makes the title the state's own name, and the header's
// "Administrative Areas" row lists it (the renderer never says "province" for India).
export function stateZone(state, color) {
return {
id: state.id, zoneKey: state.id, label: state.name, provinceLabel: state.name, provinces: [state.name],
count: state.count, color,
}
}
// The renderer knows a zone's OWN ADM1 shapes by `listId === zone.id`; the served file carries UPG-100's zone
// ids there. So it gets a COPY with this state's feature re-tagged (the cached collection is shared, never
// mutated), plus the stamped zone geometry. null when the state's outline is not in the file.
export function stateGeometry(zone, adm1Ind, shapeName) {
const feats = (adm1Ind && adm1Ind.features) || []
const own = feats.filter((f) => f.properties && f.properties.shapeName === shapeName)
if (!own.length) return null
const adm1 = { ...adm1Ind, features: feats.map((f) => (own.includes(f) ? { ...f, properties: { ...f.properties, listId: zone.id } } : f)) }
const zoneFC = {
type: 'FeatureCollection',
features: own.map((f) => ({
type: 'Feature',
properties: { ...f.properties, listId: zone.id, zoneKey: zone.zoneKey, zoneColor: zone.color, zoneLabel: zone.label },
geometry: f.geometry,
})),
}
return { adm1, zoneFC }
}
/**
* @param {object} o
* records (selector) => Promise<rows[]> the shared provider; asked for the state's slugs
* assetsBase () => string where boundaries/ is served from
* states () => states[] | null the state table's entries (library/data/indiaStates.js)
* region the WAGF region the hue comes from (India's: 'Asia'), so a state prints in India's hue
* @returns {(item, format) => Promise<string>}
*/
export function makeIndiaStateSheet({ records, assetsBase, states, region = 'Asia' }) {
return async function indiaStateSheet(item, format) {
const all = states() || []
const state = all.find((s) => s.id === (item && item.id))
if (!state || !state.count) return ''
const [{ generateSheet, loadBoundaries, pinsFrom, countryRegionColor }, { ISO3_NAME }, rows] = await Promise.all([
import('@map/packet/posterSheets.js'), import('@map/data/iso3-name.js'),
records({ type: 'pg_slugs', values: state.slugs }),
])
if (!rows || !rows.length) return ''
const bounds = await loadBoundaries(assetsBase())
const zone = stateZone(state, countryRegionColor(region))
const geo = stateGeometry(zone, bounds.adm1Ind, state.shapeName)
if (!geo) return '' // no outline to draw: fall through to the stored preview, never a guessed map
return generateSheet({
zone, pins: pinsFrom(rows), zoneFC: geo.zoneFC, format, seriesId: 'indiaState', ISO3_NAME,
adm0: bounds.adm0, adm1Ind: geo.adm1, adm1Chn: bounds.adm1Chn,
series: stateSeries(all, state), assetsBase: assetsBase(),
})
}
}