countrySheet.js WITHHELD now says WHAT of a whole country is withheld: { IND: { poster: false, rows: true } }, with
posterWithheld() and rowsWithheld() (sheetWithheld() keeps meaning the poster, the name Poster Design reads).
- India's whole-country POSTER prints again, 66x66 by default (60x96 and 36x60 on request); largeSheet() names it.
- Its ROWS (the prayer list, packet and cards: about a thousand pages) stay withheld with the same sentence, in the
wall, the Regions list and the 'country' poster type. India's state posters are unchanged.
- Country Posters gains an INDIA tab: the same wall filtered to India, labelled with India's own name in the
reader's language (Intl), between the posters and the map.
check:country-sheet-one-path arm G checks the split (poster live at 66x66, rows withheld, state cards never
withheld, both callers read the right half), with poisons for un-withheld rows and a wall that prints them;
check:poster-section arm C follows. The by-state, leaderless India layout follows as its own PR.
290 lines
18 KiB
Vue
290 lines
18 KiB
Vue
<script setup>
|
|
/**
|
|
* country-poster-wall.vue — the COUNTRY poster wall (pane 2 of country-posters).
|
|
*
|
|
* @element country-posters-map
|
|
* @description One printable card per country, grouped into COLLAPSIBLE regional sections so the
|
|
* wall stays navigable at real data volume (Asia alone is 28 countries / 1,309 groups). Rendering
|
|
* lives in the shared PosterWall component; this profile supplies only its data and taxonomy.
|
|
*
|
|
* See the note in countries-map.vue: the generator reads the `@description` TAG, not the prose
|
|
* below it, and an absent tag makes the cap-5 SPA fall back to labelling the tab "experimental
|
|
* bundle".
|
|
*
|
|
* One card per country, grouped into COLLAPSIBLE regional sections. All of the
|
|
* rendering lives in the shared @map/components/poster/PosterWall.vue — this
|
|
* profile supplies only its data and its taxonomy.
|
|
*
|
|
* The wall is organized by REGION, and each region collapses and shows how many countries it holds, so a
|
|
* reader never has to scroll through hundreds of countries.
|
|
*
|
|
* WHY COLLAPSE IS NOT A NICETY — measured from the live data:
|
|
* Asia 28 countries / 1309 groups
|
|
* Africa 41 / 314 Middle East 21 / 223
|
|
* LatAm 9 / 151 Europe 27 / 55
|
|
* Oceania 17 / 17 N.America 15 / 16 Other 18 / 21
|
|
* 92 of 175 countries have exactly ONE people group and 15 have two. Without
|
|
* collapsing, the wall is a scroll of mostly single-entry cards.
|
|
*
|
|
* SORTING IS THE ANSWER TO SPARSE SHEETS, not a template variant.
|
|
* A country with a single people group still gets its own map; it simply sorts to the bottom.
|
|
* 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, 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, 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, largeSheet, posterWithheld, rowsWithheld } from '@map/packet/countrySheet.js'
|
|
import { isPhonePrint } from '@map/composables/printInPlace.js'
|
|
import { keyedError } from '@map/i18n/keyedError.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'
|
|
|
|
const profileConfig = inject('profileConfig', ref(null))
|
|
|
|
const instanceId = computed(() => profileConfig.value?.instanceId || 'country-posters-wall')
|
|
const isDark = computed(() => profileConfig.value?.isDark === true)
|
|
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
|
|
|
|
// ★ EACH CARD NAMED IN THE READER'S LANGUAGE. item.label is the card's accessible name, the print HUD's
|
|
// title and the printed list's, deck's and packet's title, and the data carries it in English only.
|
|
// English keeps the data's own names: Intl's English differs from them for 32 of these 175 countries
|
|
// (library/utils/countryName.js says why that swap is a product decision, not a refactor).
|
|
// The i18n locale REF is read so the list is recomputed on a runtime language switch.
|
|
const { locale: wallLocale } = useOptionalI18n('')
|
|
const byId = new Map((data.countries || []).map((c) => [c.id, c]))
|
|
|
|
// ★ A HOST MAY ASK FOR SOME COUNTRIES, OR ONE REGION (2026-09-30). DX Life keeps its own /regions
|
|
// page and wants "View country posters" under each region card there, and a host page can only speak
|
|
// profile-config. So the profile takes, from profile-config, the filter regions-list.vue builds in code:
|
|
// countries the countries to show, in the host's order: ISO-3 codes ("IND,PAK" or ["IND","PAK"]) or the
|
|
// host's own records ({ code, name, count }). A code with no poster is NOT dropped: it gets the
|
|
// same not-yet reason regions-list.vue gives one (its postersFor), so no country silently
|
|
// disappears from a region.
|
|
// region one WAGF region as the data names it ("Asia"): every poster in that region.
|
|
// Either makes the wall FLAT — one section, nothing collapsed, two columns unless the host says `columns`, no
|
|
// heading, tag or intro unless the host passes `heading` — because it already sits under the host's own region
|
|
// heading. With neither key every binding below is what it was, so the wall is unchanged.
|
|
const requested = computed(() => {
|
|
const v = profileConfig.value?.countries
|
|
const list = Array.isArray(v) ? v : String(v || '').split(/[\s,]+/)
|
|
return list
|
|
.map((x) => (x && typeof x === 'object' ? { ...x, code: String(x.code || x.iso3 || '').toUpperCase() } : { code: String(x || '').toUpperCase() }))
|
|
.filter((x) => x.code)
|
|
})
|
|
const requestedRegion = computed(() => String(profileConfig.value?.region || '').trim())
|
|
const filtered = computed(() => requested.value.length > 0 || requestedRegion.value !== '')
|
|
const byIso3 = new Map((data.countries || []).map((c) => [String(c.iso3 || '').toUpperCase(), c]))
|
|
const selected = computed(() => {
|
|
const all = data.countries || []
|
|
if (requested.value.length) {
|
|
return requested.value.map((r) => byIso3.get(r.code) || {
|
|
id: `country-${r.code.toLowerCase()}`, iso3: r.code, label: r.name || r.label || r.code, count: Number(r.count) || 0, noPoster: true,
|
|
})
|
|
}
|
|
if (requestedRegion.value) return all.filter((c) => (c.region || 'Other') === requestedRegion.value)
|
|
return all
|
|
})
|
|
const flatGroup = () => 'countries'
|
|
const neverCollapsed = () => false
|
|
const hostHeading = computed(() => String(profileConfig.value?.heading || ''))
|
|
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
|
|
// 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
|
|
// library's print standard. The wall used to pass no records, so each card said "coming soon" while the
|
|
// people groups were one request away. Records come from the shared provider (one fetch per language
|
|
// for all 175 countries), selected by the country's ISO3.
|
|
const recordsFor = createPeopleGroupRecords({
|
|
apiBase: () => profileConfig.value?.apiBaseUrl || getApiBaseUrl(),
|
|
lang: () => getRuntimeLocale() || detectLocale(),
|
|
profileConfig,
|
|
})
|
|
// ★ A LARGE-SHEET COUNTRY (India: one 5x8 sheet naming all its groups; countrySheet.js's LARGE_SHEETS) is DESKTOP-ONLY:
|
|
// its poster, list, packet and cards (about a thousand pages) are offered on a desktop or laptop, and on a phone the
|
|
// card says so instead of failing. Same rule as the Regions list, from the same table.
|
|
const ON_PHONE = isPhonePrint()
|
|
const phoneBlocked = (c) => !c.indiaState && ON_PHONE && Boolean(largeSheet(c)?.desktopOnly)
|
|
// A blocked card's list, packet and cards REJECT with the notice (never resolve [], which the print path would
|
|
// report as "no people groups" on a card that has hundreds), before any record is fetched.
|
|
const desktopOnly = () => Promise.reject(keyedError('poster.phoneLimitAll', 'This needs a desktop or laptop computer.'))
|
|
// A WITHHELD whole-country poster (countrySheet.js WITHHELD: India) withholds its list, packet and cards too, with
|
|
// the same sentence the card shows (about a thousand pages, offered when the poster is).
|
|
const withheld = () => Promise.reject(keyedError('messages.countryPosterTooMany', 'Too many people groups to fit on one poster legibly — a solution is in progress.'))
|
|
const countryRecords = (c) => (rowsWithheld(c) ? withheld() : phoneBlocked(c) ? desktopOnly() : 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
|
|
// country. The series facts (map N of M over the countries on the same sheet size) come from the same
|
|
// list, so this wall and the Regions tab print the same numbers for one country.
|
|
// ⚠ THE SHEET GETS THE DATA'S OWN RECORD, not the card: the generated poster is English by recorded
|
|
// decision (series.json english_only_note), so it must not receive a localized label, and the series index
|
|
// is found by id because a language switch rebuilds the card objects.
|
|
const sheetForRecord = makeCountrySheet({
|
|
records: recordsFor,
|
|
assetsBase: () => assetsBase.value,
|
|
countries: data.countries || [],
|
|
})
|
|
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)
|
|
// A STORED fallback behind the generator: the country's preview (previews/<id>.webp, 174 of them), absolute because
|
|
// the print frame is its own document, and only when it is really there: a missing image falls through
|
|
// to the packet's "map not included" line rather than printing a broken picture.
|
|
async function countryMap(c) {
|
|
const url = new URL(`${assetsBase.value}/previews/${c.id}.webp`, document.baseURI).href // where the tree serves them
|
|
const ok = await fetch(url, { method: 'HEAD', cache: 'force-cache' }).then((r) => r.ok).catch(() => false)
|
|
return ok ? url : null
|
|
}
|
|
|
|
// The region is already ON each record (emit-countries.mjs resolves it from
|
|
// wagf_region_label), so the taxonomy is not duplicated here the way the 100-list's
|
|
// REGION_OF map has to be — the 100-list's zones carry no region field, countries do.
|
|
// The data carries the WAGF region as its ENGLISH label; the section heading is that region in the
|
|
// page's language, from the dashboard's own WAGF table (dashboard.wagfRegion.<label as a slug>), so the
|
|
// wall and the Regions dashboard cannot name one region two ways. A label with no key stays as drawn.
|
|
const t = useOptionalT('')
|
|
const regionSlug = (label) => String(label).toLowerCase().replace(/\s+/g, '_')
|
|
// ★ INDIA IS ITS OWN SECTION, FIRST: India's country card, one card per state and union territory, and the two
|
|
// with none listed. Its 855 groups are counted ONCE (sectionTotal below; the state cards are India's groups again),
|
|
// and that true total is also what puts it above every other section. Headed by the country's own name.
|
|
const wallLang = () => { const l = wallLocale && (wallLocale.value ?? wallLocale); return (typeof l === 'string' && l) || getRuntimeLocale() || detectLocale() || 'en' }
|
|
const indiaHeading = () => (/^en\b/i.test(wallLang()) ? 'India' : countryName('IND', wallLang()) || 'India')
|
|
const isIndia = (c) => c.indiaState || String(c.iso3 || '').toUpperCase() === 'IND'
|
|
const INDIA_COUNT = (byIso3.get('IND') || {}).count || 0
|
|
// The India section's item count names what its cards are (states and union territories, not countries): it counts
|
|
// the state cards only, India's own card being the country itself. Every other section keeps the countries count.
|
|
const sectionItemCountKey = (sec) => (sec.items.some(isIndia)
|
|
? { key: 'countryPosters.indiaItemCount', n: sec.items.filter((c) => c.indiaState).length }
|
|
: null)
|
|
const sectionTotal = (name, items) => (items.some(isIndia) && INDIA_COUNT ? INDIA_COUNT : items.reduce((n, x) => n + (x.count || 0), 0))
|
|
const groupBy = (c) => {
|
|
if (isIndia(c)) return indiaHeading()
|
|
if (!c.region || c.region === 'Other') return t('dashboard.other')
|
|
const key = `dashboard.wagfRegion.${regionSlug(c.region)}`
|
|
const v = t(key)
|
|
return v && v !== key ? v : c.region
|
|
}
|
|
|
|
// Low-density regions open COLLAPSED. Threshold is on the region's total, not its
|
|
// country count: Europe is 27 countries but only 55 groups, so it is long and thin —
|
|
// exactly what the design does not want to scroll through first.
|
|
const collapsedBy = (sec) => sec.total < 60
|
|
|
|
// The not-yet reason travels WITH the record, so the exact number the wall prints
|
|
// can never drift from the data: the wall states the exact number the data holds.
|
|
// The reason is shown on the card and in the print HUD, so it speaks the reader's language: the data's
|
|
// 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.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : posterWithheld(c) ? t('messages.countryPosterTooMany') : phoneBlocked(c) ? t('poster.phoneLimitAll') : c.unsupportedReason && !largeSheet(c) ? 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)
|
|
// so no translation carries markup; the emphasis is the same phrase the UPG-100 wall uses.
|
|
const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
const heading = computed(() => t('messages.countryWallHeading'))
|
|
const wallTag = computed(() => t('messages.upg100WallTag'))
|
|
const intro = computed(() => `${escHtml(t('messages.countryWallIntroA'))}<strong>${escHtml(t('messages.upg100WallIntroEmph'))}</strong>${escHtml(t('messages.countryWallIntroB'))}`
|
|
// Why India is first and different, in one line (only on the unfiltered wall, the only one that shows its section).
|
|
+ (INDIA_COUNT ? `<br>${escHtml(t('countryPosters.indiaNote', { count: INDIA_COUNT }))}` : ''))
|
|
const soonMessage = computed(() => t('messages.countryWallSoon'))
|
|
</script>
|
|
|
|
<template>
|
|
<PosterWall
|
|
:items="items"
|
|
:group-by="filtered ? flatGroup : groupBy"
|
|
:instance-id="instanceId"
|
|
:is-dark="isDark"
|
|
:assets-base="assetsBase"
|
|
event-name="doxa-country-posters:show-on-map"
|
|
:heading="filtered ? hostHeading : heading"
|
|
:tag="filtered ? '' : wallTag"
|
|
:intro="filtered ? '' : intro"
|
|
unit="UPG"
|
|
item-count-key="regions.countryCount"
|
|
:records="countryRecords"
|
|
:card-stocks="true"
|
|
:sheet="countrySheet"
|
|
:iso3-names="iso3Names"
|
|
:map-image="countryMap"
|
|
:collapsible="!filtered"
|
|
:collapsed-by="filtered ? neverCollapsed : collapsedBy"
|
|
:section-total="filtered ? null : sectionTotal"
|
|
:section-item-count-key="filtered ? null : sectionItemCountKey"
|
|
:section-heads="!filtered"
|
|
:embedded="filtered"
|
|
:columns="filtered ? hostColumns : 0"
|
|
:unsupported="unsupported"
|
|
:prayer-list-soon-message="soonMessage"
|
|
download-prefix="doxa-country"
|
|
/>
|
|
</template>
|