doxa-maps/plugin-designs/country-posters/country-poster-wall.vue

290 lines
19 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, opts) => sheetForRecord(byId.get(card.id) || card, format, opts)
// 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, opts) => (card.indiaState ? stateSheet(card, format, opts) : countryOnlySheet(card, format, opts))
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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>