From be28132d8faf42c28d18650b7913bfc36f86cfc9 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 22:52:01 +0000 Subject: [PATCH] Country Posters: one poster for each of India's states and union territories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- package.json | 2 +- .../country-posters/country-poster-wall.vue | 46 ++++++++-- .../country-posters/country-posters-mfe.vue | 5 +- .../scripts/check-country-sheet-one-path.mjs | 40 ++++++++- .../country-posters/src/indiaStateSheet.js | 86 +++++++++++++++++++ 5 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 plugin-designs/country-posters/src/indiaStateSheet.js diff --git a/package.json b/package.json index e81ddbcd5..a76063101 100644 --- a/package.json +++ b/package.json @@ -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.", diff --git a/plugin-designs/country-posters/country-poster-wall.vue b/plugin-designs/country-posters/country-poster-wall.vue index 9aedaa2bd..0cf9c2c0d 100644 --- a/plugin-designs/country-posters/country-poster-wall.vue +++ b/plugin-designs/country-posters/country-poster-wall.vue @@ -35,7 +35,7 @@ * 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, 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). @@ -49,6 +49,9 @@ import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js' 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,36 @@ 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. States with no people groups +// listed (Nagaland, Mizoram) are NAMED with a card that says so, never silently left out. +const indiaStates = ref(null) +watch(filtered, (f) => { + if (f || indiaStates.value) return + loadIndiaStates(assetsBase.value).then((table) => { indiaStates.value = table.states }).catch(() => { indiaStates.value = null }) +}, { immediate: true }) +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, 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, 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) => { + // State names stay as the table has them (place names from the data); only country names are localized. + if (/^en\b/i.test(locale)) return [...countries, ...stateItems.value] + return [...countries.map((c) => { const name = countryName(c.iso3, locale) return name && name !== c.iso3 ? { ...c, label: name } : c - }) + }), ...stateItems.value] }) // ★ EVERY COUNTRY PRINTS ITS PRAYER RESOURCES — the list, the card deck and the packet — through the @@ -125,7 +149,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 +165,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 +192,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('messages.indiaStatesHeading') if (!c.region || c.region === 'Other') return t('dashboard.other') const key = `dashboard.wagfRegion.${regionSlug(c.region)}` const v = t(key) @@ -178,7 +210,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('messages.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) diff --git a/plugin-designs/country-posters/country-posters-mfe.vue b/plugin-designs/country-posters/country-posters-mfe.vue index 92dc88aab..87a5dfbc4 100644 --- a/plugin-designs/country-posters/country-posters-mfe.vue +++ b/plugin-designs/country-posters/country-posters-mfe.vue @@ -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-') 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)) diff --git a/plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs b/plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs index 8f8b00de8..e30ac2cca 100644 --- a/plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs +++ b/plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs @@ -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,23 @@ 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 +} + async function main() { const selfTest = process.argv.includes('--self-test') const { seriesFor } = await import(join(ROOT, 'library/packet/countrySheet.js')) @@ -118,6 +141,11 @@ 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 files = {} for (const p of listSources(PD)) files[relative(ROOT, p)] = readFileSync(p, 'utf8') @@ -129,6 +157,10 @@ 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('