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".
This commit is contained in:
Doxa Maps 2026-09-30 22:52:01 +00:00
parent 8ded6a6481
commit be28132d8f
5 changed files with 166 additions and 13 deletions

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,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)

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

@ -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('<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 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 +177,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)]
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`)
}
main().catch((e) => { console.error(`FAIL ${e.stack || e}`); 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(),
})
}
}