Speak the reader's language on the UPG-100 map, its wall and the print HUD
The legend's region sections, its Deaf row and its one-country zones, the wall's card names, the print progress and error messages, the map's status badge and the Population column all rendered English in every locale. None of them was visible to check:hardcoded-english: they were single words, .js modules, Error() arguments, library components or number formatting. - Legend: region rows through one REGION_KEY table shared with the wall (poster.region_*, new region_deaf); zones through zoneTitle (tier A by country, the Deaf row by its bloc name); node ids stay English-derived. - Wall: card labels through zoneTitle, so the Show-on-map name, alt text, tooltip, HUD title and printed title agree with the legend and packet. - Errors a reader sees are keyedError(key, English, params); errorText(e, t) shows them translated, and an unkeyed error still shows its own message. Developer detail for a failed data load goes to the console; the badge says upgMap.mapDataFailed. - Population: Intl compact notation in the app's locale, same precision. - Country Posters: the unsupported card's reason is messages.countryPosterTooMany, not the data file's English note. - Strings only the UPG-100 map shows move to a new upgMap area, so other bundles do not carry them (country-posters 1,490,907 bytes). - check:hardcoded-english judges keyedError's English as a fallback (quiet when the key resolves everywhere, a finding when it does not); check:label-key-coverage follows REGION_KEY to legend-tree.js and covers the legend's table and its regionFor producer. Both self-tests two-sided.
This commit is contained in:
parent
3233df2bc1
commit
03839179b7
56 changed files with 711 additions and 127 deletions
|
|
@ -132,7 +132,12 @@ function localizedNames(code) {
|
|||
// tStatic is the library's own translator for code outside a component's setup (imported from
|
||||
// @map/i18n), and leaving it out meant `tStatic('grid.search') || 'English'` — correct, guarded,
|
||||
// fully translated code — read as a leak wherever it was used directly rather than behind a wrapper.
|
||||
const names = new Set(['t', 'tf', '$t', 'tStatic'])
|
||||
// keyedError(key, 'English', params) (library/i18n/keyedError.js) is a translator call too: its English is
|
||||
// the FALLBACK of a catalogue key that errorText() shows in the reader's language, so it is judged like
|
||||
// T(key, 'English') — quiet when the key resolves in every locale, a finding when it does not. As a plain
|
||||
// call it would have counted every keyed error as a leak, and exempting it like new Error() would have
|
||||
// hidden a typo'd key.
|
||||
const names = new Set(['t', 'tf', '$t', 'tStatic', 'keyedError'])
|
||||
for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1])
|
||||
// WRAPPERS ARE TRANSLATORS TOO. `const T = (key, en) => tStatic(key) || en` is the shape most of
|
||||
// these components use, and matching only `const X = t` missed every one of them — the wrapper's
|
||||
|
|
@ -700,6 +705,7 @@ const PLANTED_BAD = `<template>
|
|||
import { a } from './a.js'
|
||||
import { b } from '../b/c.js'
|
||||
const msg = 'No results were found'
|
||||
const kerr = keyedError('dashboard.no_such_key_at_all', 'this sentence has no catalogue key')
|
||||
const split = \`\${a} polys · \${b} zones · \`
|
||||
const ctx = \`\${n} countries\`
|
||||
</script>`
|
||||
|
|
@ -725,6 +731,7 @@ const lone = 'Other'
|
|||
const proxyMiss = t.search || 'Search names, country, religion…'
|
||||
const keyMiss = msg !== 'messages.engineMissing' ? msg : 'The map engine did not load.'
|
||||
const devThrow = () => { throw new Error('table not found for this request') }
|
||||
const keyed = () => { throw keyedError('dashboard.map_loading', 'Loading the map for you') }
|
||||
const devWarn = () => console.warn('boundaries failed to load for this map')
|
||||
const host = document.querySelector('gospel-resources-dashboard, gospel-resources-map')
|
||||
const one = el.closest('regions-dashboard, doxa-simple-map')
|
||||
|
|
@ -951,6 +958,8 @@ const a = greeting + 'Welcome to the prayer map'
|
|||
ok(!good.some((f) => f.kind === 'bind:interpolation'), 'QUIET on interpolations that are translator calls or bare values')
|
||||
ok(!good.some((f) => /map engine did not load/.test(f.text)), 'QUIET on the "did t() return the key?" miss idiom (msg !== \'a.dotted.key\')')
|
||||
ok(!good.some((f) => /table not found/.test(f.text)), 'QUIET on a throw new Error(…) argument — exempt by CALL SITE')
|
||||
ok(!good.some((f) => /Loading the map for you/.test(f.text)), 'QUIET on keyedError(key, English) whose key resolves in every locale')
|
||||
ok(bad.some((f) => /this sentence has no catalogue key/.test(f.text)), 'SEES keyedError(key, English) whose key does NOT resolve — its English is what the reader gets')
|
||||
ok(!good.some((f) => /boundaries failed/.test(f.text)), 'QUIET on a console.warn(…) argument')
|
||||
ok(!good.some((f) => /gospel-resources-dashboard|regions-dashboard, doxa/.test(f.text)),
|
||||
'QUIET on a DOM query selector, including a comma-separated list of custom-element tags')
|
||||
|
|
|
|||
|
|
@ -45,20 +45,47 @@ const ok = (cond, msg) => { if (!cond) fails.push(msg) }
|
|||
// would be guessing about code it has never seen, and a wrong guess here reports a defect that is
|
||||
// not there. Each entry names the file, the table that PRODUCES labels, the table that maps a label
|
||||
// to a key, the helper that reads them, and the area those keys live in.
|
||||
// `mapsFile` names the file the key table lives in when it is not `file` (the wall and the legend share
|
||||
// one REGION_KEY in legend-tree.js). `producerFn` names a function whose ternary arms return labels
|
||||
// directly (legend-tree's regionFor returns 'India' / 'China + Mongolia' / 'Other' without a helper call).
|
||||
const TABLES = [
|
||||
{
|
||||
file: 'plugin-designs/my-upg-100-list/poster-wall.vue',
|
||||
produces: 'REGION_OF',
|
||||
mapsFile: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
|
||||
maps: 'REGION_KEY',
|
||||
via: 'regionLabel',
|
||||
area: 'poster',
|
||||
},
|
||||
{
|
||||
// the legend's region rows: the same REGION_KEY, through buildLegendNodes' opts.regionTitle
|
||||
file: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
|
||||
produces: 'REGION_OF',
|
||||
maps: 'REGION_KEY',
|
||||
via: 'regionTitleOf',
|
||||
producerFn: 'regionFor',
|
||||
area: 'poster',
|
||||
},
|
||||
]
|
||||
|
||||
/** The body of `const NAME = { ... }`, matched to its first closing brace at column 0. */
|
||||
/** The body of `const NAME = { ... }`, to its MATCHING closing brace. Brace-balanced rather than "the first
|
||||
* `}` at column 0": a table declared inside a function (legend-tree's REGION_OF) is indented, and a
|
||||
* column-0 match would run on to the end of the function and read every later `: '…'` as a label. */
|
||||
function objectBody(src, name) {
|
||||
const m = new RegExp(`const ${name}\\s*=\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src)
|
||||
return m ? m[1] : null
|
||||
const m = new RegExp(`const ${name}\\s*=\\s*\\{`).exec(src)
|
||||
if (!m) return null
|
||||
let depth = 1
|
||||
const start = m.index + m[0].length
|
||||
for (let i = start; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++
|
||||
else if (src[i] === '}' && --depth === 0) return src.slice(start, i)
|
||||
}
|
||||
return null
|
||||
}
|
||||
/** The labels a producer function returns from its ternary arms: `? 'India'` / `: 'Other'`. */
|
||||
function producerLabels(src, fn) {
|
||||
const m = new RegExp(`const ${fn}\\s*=\\s*\\([^)]*\\)\\s*=>([\\s\\S]*?);\\n`).exec(src)
|
||||
return m ? [...m[1].matchAll(/[?:]\s*'([^']+)'/g)].map((x) => x[1]) : null
|
||||
}
|
||||
/** Quoted values on the right of a colon — the English labels a table produces. */
|
||||
const valuesOf = (body) => new Set([...body.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1]))
|
||||
|
|
@ -71,8 +98,12 @@ for (const t of TABLES) {
|
|||
continue
|
||||
}
|
||||
const src = read(t.file)
|
||||
if (t.mapsFile && !existsSync(resolve(ROOT, t.mapsFile))) {
|
||||
ok(false, `${t.mapsFile} is missing — the key table this gate watches moved or was renamed`)
|
||||
continue
|
||||
}
|
||||
const produces = objectBody(src, t.produces)
|
||||
const maps = objectBody(src, t.maps)
|
||||
const maps = objectBody(t.mapsFile ? read(t.mapsFile) : src, t.maps)
|
||||
// ★ A MISSING TABLE IS A BLIND GATE, NOT A PASS. If either object is renamed away, every set below
|
||||
// is empty and the coverage check passes while measuring nothing — the failure mode a gate must
|
||||
// never have. So absence is the loud case.
|
||||
|
|
@ -81,7 +112,14 @@ for (const t of TABLES) {
|
|||
|
||||
const fromTable = valuesOf(produces)
|
||||
const fromCalls = new Set([...src.matchAll(new RegExp(`${t.via}\\('([^']+)'\\)`, 'g'))].map((m) => m[1]))
|
||||
const produced = new Set([...fromTable, ...fromCalls])
|
||||
let fromFn = []
|
||||
if (t.producerFn) {
|
||||
const got = producerLabels(src, t.producerFn)
|
||||
// the same blind-gate rule as the tables: a renamed producer must fail, not contribute nothing
|
||||
if (!got || !got.length) { ok(false, `${t.file}: no \`const ${t.producerFn} = (…) =>\` returning labels — this gate has gone blind`); continue }
|
||||
fromFn = got
|
||||
}
|
||||
const produced = new Set([...fromTable, ...fromCalls, ...fromFn])
|
||||
const covered = keysOf(maps)
|
||||
|
||||
ok(produced.size > 0, `${t.file}: ${t.produces} yielded no labels at all — the shape it is parsed with no longer matches`)
|
||||
|
|
@ -132,6 +170,13 @@ for (const t of TABLES) {
|
|||
t.push([JSON.stringify(probe(VIA_CALL, 'label')) === '["Other"]', 'a label passed as a literal argument is reported'])
|
||||
// and the blind-gate case: a renamed table must not read as agreement
|
||||
t.push([objectBody(`const OTHER = {\n 'a': 'b',\n}`, 'OF') === null, 'a renamed table is detected, not silently skipped'])
|
||||
// an INDENTED table inside a function ends at its own brace, not at the function's
|
||||
const NESTED = `function f() {\n const OF = {\n 'a': 'Africa',\n };\n const x = { label: 'Not a region' };\n}\n`
|
||||
t.push([JSON.stringify([...valuesOf(objectBody(NESTED, 'OF'))]) === '["Africa"]', 'an indented table is read to its own closing brace'])
|
||||
// a producer's ternary arms are labels; its startsWith() arguments are not
|
||||
const FN = `const regionFor = (id) =>\n OF[id] || (id.startsWith('asia-in-') ? 'India'\n : 'Other');\n`
|
||||
t.push([JSON.stringify(producerLabels(FN, 'regionFor')) === '["India","Other"]', 'a producer function yields its returned labels only'])
|
||||
t.push([producerLabels(FN, 'renamedFor') === null, 'a renamed producer is detected, not silently skipped'])
|
||||
const bad = t.filter(([pass]) => !pass)
|
||||
for (const [, msg] of bad) fails.push(`SELF-TEST: ${msg}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const humanizeKey = (k) => String(k).split('.').pop()
|
|||
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback -- NOT the
|
||||
// catalogue -- so a bare host still reads 'Name' for legend.columns.name, and `te` still answers false
|
||||
// so defaultTab and columnLabel keep falling through to their own defaults.
|
||||
const { t, te } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
|
||||
const { t, te, locale: i18nLocale } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
|
||||
|
||||
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
|
||||
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded
|
||||
|
|
@ -634,13 +634,24 @@ watch(selectedId, async id => {
|
|||
})
|
||||
|
||||
// ── Formatters ─────────────────────────────────────────────────────────────────
|
||||
// The app's locale (a ref or a string, depending on how the host installed i18n); undefined lets Intl
|
||||
// use the browser's, which is what toLocaleString() did before.
|
||||
function numLocale() {
|
||||
const l = i18nLocale && (i18nLocale.value ?? i18nLocale)
|
||||
return typeof l === 'string' && l ? l : undefined
|
||||
}
|
||||
// ★ COMPACT IN THE READER'S NOTATION. This was `toFixed(1) + 'M'` / `+ 'k'`: English abbreviations with
|
||||
// an English decimal point on every Population cell in every locale. Intl's compact notation writes each
|
||||
// language's own form (ru "12,3 млн", de "12,3 Mio.", ar "12.3 مليون", zh "1234.6万") at the same
|
||||
// precision as before: one decimal for millions, whole thousands.
|
||||
function fmtPop(n) {
|
||||
if (!n) return '—'
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
||||
if (n >= 1e3) return Math.round(n / 1e3) + 'k'
|
||||
return n.toLocaleString()
|
||||
if (n >= 1e3) {
|
||||
return new Intl.NumberFormat(numLocale(), { notation: 'compact', maximumFractionDigits: n >= 1e6 ? 1 : 0 }).format(n)
|
||||
}
|
||||
return n.toLocaleString(numLocale())
|
||||
}
|
||||
function fmtCount(n) { return n != null ? n.toLocaleString() : '—' }
|
||||
function fmtCount(n) { return n != null ? n.toLocaleString(numLocale()) : '—' }
|
||||
|
||||
function rowClasses(row) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const T = (key, en) => tStatic(key) || en
|
|||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { fetchPosterRaster, fetchPosterSource } from '@map/packet/poster-fetch.js'
|
||||
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
|
||||
import { keyedError, errorText } from '@map/i18n/keyedError.js'
|
||||
import { useOptionalI18n, useOptionalT } from '@map/composables/useOptionalT.js'
|
||||
// Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell
|
||||
// (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable
|
||||
|
|
@ -257,6 +258,13 @@ const plural = computed(() => props.itemNounPlural || props.itemNoun)
|
|||
// wrong for a count — so counts use a catalogue translator of their own (tStatic when no i18n).
|
||||
const tCount = useOptionalT('')
|
||||
const countOf = (key, n) => tCount(key, { n: Number(n || 0).toLocaleString() }, Number(n || 0))
|
||||
// The HUD's kinds and progress messages, and the poster image's alt text (what a screen reader announces
|
||||
// for a card), in the page's language; English only where a key is absent. The same translator goes to
|
||||
// usePosterPrints (opts.t) so the messages IT sets are localized too.
|
||||
const hud = (key, en, params = null, n = undefined) => {
|
||||
const v = tCount(key, params || {}, n)
|
||||
return v && v !== key ? v : String(en).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m))
|
||||
}
|
||||
const itemCount = (n) => (props.itemCountKey ? countOf(props.itemCountKey, n) : `${n} ${n === 1 ? props.itemNoun : plural.value}`)
|
||||
const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, ''))
|
||||
|
||||
|
|
@ -373,11 +381,11 @@ async function exportPoster(item) {
|
|||
if (exportState.value) return // one at a time
|
||||
const reason = props.unsupported(item)
|
||||
if (reason) {
|
||||
exportState.value = { label: item.label, kind: 'Poster', phase: 'soon', message: reason, pct: 0 }
|
||||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'soon', message: reason, pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch',
|
||||
message: 'fetching poster', pct: 20 }
|
||||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'fetch',
|
||||
message: hud('wall.hud.fetchingPoster', 'fetching the poster'), pct: 20 }
|
||||
try {
|
||||
// ★ GENERATED FIRST (the print standard): a host that can draw the sheet now (the `sheet` prop,
|
||||
// packet/posterSheets.js) prints or downloads THAT — no stored file involved, so a country, which has
|
||||
|
|
@ -385,7 +393,7 @@ async function exportPoster(item) {
|
|||
// for a home printer), the item's own poster size for a download. A generation that fails falls through
|
||||
// to the stored image and HTML below rather than to an error.
|
||||
if (props.sheet) {
|
||||
exportState.value = { ...exportState.value, phase: 'render', message: 'generating the poster', pct: 35 }
|
||||
exportState.value = { ...exportState.value, phase: 'render', message: hud('wall.hud.generatingPoster', 'generating the poster'), pct: 35 }
|
||||
const format = props.posterAction === 'print' ? 'letter' : (item.sheet === '3x3' ? '3x3' : '3x4')
|
||||
const html = await Promise.resolve().then(() => props.sheet(item, format)).catch(() => '')
|
||||
if (html) {
|
||||
|
|
@ -415,22 +423,22 @@ async function exportPoster(item) {
|
|||
// This is the same move the prayer packet already makes: generate on click,
|
||||
// ship nothing. A 404 used to end here as "poster not found".
|
||||
exportState.value = { ...exportState.value, phase: 'render',
|
||||
message: 'generating the poster', pct: 45 }
|
||||
message: hud('wall.hud.generatingPoster', 'generating the poster'), pct: 45 }
|
||||
const source = await fetchPosterSource(`${base.value}/posters/${item.id}.html`)
|
||||
if (!source.html) throw new Error(`poster not found (${source.status})`)
|
||||
if (!source.html) throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: source.status })
|
||||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
|
||||
const blob = await rasterPoster(source.html, { scale: 2 })
|
||||
// Fail LOUDLY rather than hand over a black rectangle: an SVG in an <img> is
|
||||
// an isolated document, so a missing stylesheet makes every fill fall back to
|
||||
// black with no error at all.
|
||||
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
|
||||
if (!(await looksRendered(blob))) throw keyedError('wall.hud.posterBlank', 'the generated poster came out blank')
|
||||
await deliverPoster(blob, item)
|
||||
return
|
||||
}
|
||||
await deliverPoster(raster, item)
|
||||
} catch (e) {
|
||||
exportState.value = { label: item.label, kind: 'Poster', phase: 'error',
|
||||
message: String(e?.message || e).slice(0, 140), pct: 0 }
|
||||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'error',
|
||||
message: errorText(e, (k, p) => hud(k, k, p)).slice(0, 140), pct: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +514,7 @@ if (props.cardStocks) {
|
|||
}
|
||||
|
||||
const prints = usePosterPrints({
|
||||
t: (key, params, n) => tCount(key, params, n),
|
||||
state: exportState,
|
||||
records: props.records ? (item) => props.records(item) : null,
|
||||
// useI18n()'s locale can be a ref or a plain string depending on how the host
|
||||
|
|
@ -572,7 +581,7 @@ function downloadPacket(item) {
|
|||
// error — unbuilt, not broken. phase:'error' reads to a user as "something went
|
||||
// wrong" rather than "not yet".
|
||||
if (!canPrintRows.value) {
|
||||
exportState.value = { label: item.label, kind: 'Prayer List', phase: 'soon',
|
||||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPrayerList', 'Prayer List'), phase: 'soon',
|
||||
message: props.prayerListSoonMessage, pct: 0 }
|
||||
return
|
||||
}
|
||||
|
|
@ -617,7 +626,7 @@ function printPacketAs(item, variantId) {
|
|||
// browser-rasterised). Both callers in exportPoster() route through here.
|
||||
async function deliverPoster(blob, item) {
|
||||
if (props.posterAction === 'print') {
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: 'opening the print dialog', pct: 80 }
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
|
||||
const { openPrintFrame } = await import('@map/composables/printInPlace.js')
|
||||
const url = URL.createObjectURL(blob)
|
||||
posterObjectUrls.push(url)
|
||||
|
|
@ -637,11 +646,11 @@ async function deliverPoster(blob, item) {
|
|||
{ autoprint: true },
|
||||
)
|
||||
} else {
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.saving', 'saving'), pct: 80 }
|
||||
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done',
|
||||
message: props.posterAction === 'print' ? 'print dialog opened' : 'saved', pct: 100 }
|
||||
message: props.posterAction === 'print' ? hud('wall.hud.printOpened', 'print dialog opened') : hud('wall.hud.saved', 'saved'), pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
}
|
||||
|
||||
|
|
@ -1174,8 +1183,8 @@ useShadowStyles(
|
|||
<img v-else-if="!missing[item.id]" class="img"
|
||||
:class="{ 'is-clickable': previewIsControl }" :src="previewUrl(item.id)"
|
||||
:alt="previewIsControl
|
||||
? `${item.label} — prayer poster (click to preview and print)`
|
||||
: `${item.label} — prayer poster`"
|
||||
? hud('wall.posterAltAction', '{label} — prayer poster (click to preview and print)', { label: item.label })
|
||||
: hud('wall.posterAlt', '{label} — prayer poster', { label: item.label })"
|
||||
loading="lazy" decoding="async"
|
||||
:role="previewIsControl ? 'button' : null"
|
||||
:tabindex="previewIsControl ? 0 : null"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
import { ref } from 'vue';
|
||||
import { openPrintFrame } from './printInPlace.js';
|
||||
import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js';
|
||||
import { keyedError, errorText } from '../i18n/keyedError.js';
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
|
@ -72,16 +73,28 @@ export function usePosterPrints(opts = {}) {
|
|||
const label = opts.label || ((item) => item?.label || item?.id || '');
|
||||
const lang = opts.lang || (() => 'en');
|
||||
const stock = opts.stock || (() => 'poker');
|
||||
// ★ THE HUD SPEAKS THE READER'S LANGUAGE. Every kind and progress message this composable sets is shown
|
||||
// in the host's HUD (a dialog a screen reader announces) and was English in every locale. A host that
|
||||
// passes `t` (key, params, count) — PosterWall passes its plural-aware translator — gets them
|
||||
// localized at the source; without one they stay English, exactly as before. Counts pass their number
|
||||
// so each locale picks its own plural form.
|
||||
const tr = (key, en, params = null, n = undefined) => {
|
||||
const fillEn = () => String(en).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m));
|
||||
if (typeof opts.t !== 'function') return fillEn();
|
||||
const v = opts.t(key, params || {}, n);
|
||||
return v && v !== key ? v : fillEn();
|
||||
};
|
||||
|
||||
const set = (item, kind, phase, message, pct) => {
|
||||
exportState.value = { label: label(item), kind, phase, message, pct };
|
||||
};
|
||||
const done = (item, kind) => {
|
||||
set(item, kind, 'done', 'print dialog opened', 100);
|
||||
set(item, kind, 'done', tr('wall.hud.printOpened', 'print dialog opened'), 100);
|
||||
setTimeout(() => { exportState.value = null; }, 900);
|
||||
};
|
||||
const fail = (item, kind, e) => {
|
||||
set(item, kind, 'error', String(e?.message || e).slice(0, 140), 0);
|
||||
// a keyed error (keyedError.js) in the reader's language; anything else as its own message
|
||||
set(item, kind, 'error', errorText(e, (k, p) => tr(k, k, p)).slice(0, 140), 0);
|
||||
};
|
||||
function dismiss() { exportState.value = null; }
|
||||
|
||||
|
|
@ -90,7 +103,7 @@ export function usePosterPrints(opts = {}) {
|
|||
throw new Error('this wall supplies no records provider');
|
||||
}
|
||||
const r = await opts.records(item);
|
||||
if (!r || !r.length) throw new Error('that list resolved to no records');
|
||||
if (!r || !r.length) throw keyedError('wall.hud.noRecords', 'that list has no people groups to print');
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
@ -117,20 +130,20 @@ export function usePosterPrints(opts = {}) {
|
|||
// stays ignorant of where a bundle serves its posters from.
|
||||
async function printPoster(item) {
|
||||
if (busy()) return;
|
||||
const kind = 'Poster';
|
||||
const kind = tr('wall.hud.kindPoster', 'Poster');
|
||||
const base = opts.assetsBase ? opts.assetsBase() : null;
|
||||
const id = item?.id ?? item;
|
||||
set(item, kind, 'fetch', 'fetching the poster', 25);
|
||||
set(item, kind, 'fetch', tr('wall.hud.fetchingPoster', 'fetching the poster'), 25);
|
||||
try {
|
||||
// ★ GENERATED FIRST (the print standard: generate at the click, store only previews). A host
|
||||
// that can draw the sheet now (opts.sheet -> packet/posterSheets.js) prints it as a VECTOR page on
|
||||
// letter paper — no stored file involved. The stored image and HTML remain the fallback for a host
|
||||
// that cannot generate, and a generation that fails falls through to them rather than to nothing.
|
||||
if (opts.sheet) {
|
||||
set(item, kind, 'render', 'generating the poster', 35);
|
||||
set(item, kind, 'render', tr('wall.hud.generatingPoster', 'generating the poster'), 35);
|
||||
const html = await Promise.resolve().then(() => opts.sheet(item, 'letter')).catch(() => '');
|
||||
if (html) {
|
||||
set(item, kind, 'save', 'opening the print dialog', 80);
|
||||
set(item, kind, 'save', tr('wall.hud.openingPrint', 'opening the print dialog'), 80);
|
||||
openPrintFrame(html, { autoprint: true });
|
||||
done(item, kind);
|
||||
return;
|
||||
|
|
@ -143,15 +156,15 @@ export function usePosterPrints(opts = {}) {
|
|||
// Only a real image counts: a host's 200 fallback page is a miss (poster-fetch.js).
|
||||
const raster = webp ? await fetchPosterRaster(webp) : null;
|
||||
if (raster) { await deliverPoster(raster, item, kind); return; }
|
||||
if (!base) throw new Error('no poster available for this item');
|
||||
if (!base) throw keyedError('wall.hud.noPoster', 'no poster is available for this list');
|
||||
// NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are
|
||||
// not shipped by default; the HTML is the SOURCE and is smaller than the image it makes.
|
||||
set(item, kind, 'render', 'generating the poster', 45);
|
||||
set(item, kind, 'render', tr('wall.hud.generatingPoster', 'generating the poster'), 45);
|
||||
const source = await fetchPosterSource(`${base}/posters/${id}.html`);
|
||||
if (!source.html) throw new Error(`poster not found (${source.status})`);
|
||||
if (!source.html) throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: source.status });
|
||||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js');
|
||||
const blob = await rasterPoster(source.html, { scale: 2 });
|
||||
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank');
|
||||
if (!(await looksRendered(blob))) throw keyedError('wall.hud.posterBlank', 'the generated poster came out blank');
|
||||
await deliverPoster(blob, item, kind);
|
||||
} catch (e) { fail(item, kind, e); }
|
||||
}
|
||||
|
|
@ -161,7 +174,7 @@ export function usePosterPrints(opts = {}) {
|
|||
// Constrain both axes and let object-fit letterbox it.
|
||||
const posterObjectUrls = [];
|
||||
async function deliverPoster(blob, item, kind) {
|
||||
set(item, kind, 'save', 'opening the print dialog', 80);
|
||||
set(item, kind, 'save', tr('wall.hud.openingPrint', 'opening the print dialog'), 80);
|
||||
const url = URL.createObjectURL(blob);
|
||||
posterObjectUrls.push(url);
|
||||
const title = `${opts.downloadPrefix || 'doxa'} — ${label(item)}`;
|
||||
|
|
@ -184,8 +197,8 @@ export function usePosterPrints(opts = {}) {
|
|||
// print-paginated table with a prayer checkbox per row.
|
||||
async function printList(item, { columns, title } = {}) {
|
||||
if (busy()) return;
|
||||
const kind = 'List';
|
||||
set(item, kind, 'fetch', 'fetching records', 30);
|
||||
const kind = tr('wall.hud.kindList', 'List');
|
||||
set(item, kind, 'fetch', tr('wall.hud.fetchingRecords', 'fetching records'), 30);
|
||||
try {
|
||||
const data = await rows(item);
|
||||
const cols = columns || [
|
||||
|
|
@ -202,7 +215,7 @@ export function usePosterPrints(opts = {}) {
|
|||
return `<td${c.cls ? ` class="${c.cls}"` : ''}>${esc(v)}</td>`;
|
||||
}).join('') + '<td class="c-pray">□</td></tr>').join('');
|
||||
|
||||
set(item, kind, 'render', `laying out ${data.length} rows`, 70);
|
||||
set(item, kind, 'render', tr('wall.hud.layingOutRows', 'laying out {n} rows', { n: data.length }, data.length), 70);
|
||||
const docTitle = title || `${label(item)} — ${data.length}`;
|
||||
openPrintFrame(
|
||||
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(docTitle)}</title>`
|
||||
|
|
@ -235,15 +248,15 @@ export function usePosterPrints(opts = {}) {
|
|||
// than letter paper. The geometry lives in the shared card renderer.
|
||||
async function printCardDeck(item) {
|
||||
if (busy()) return;
|
||||
const kind = 'Card Deck';
|
||||
set(item, kind, 'fetch', 'fetching records', 30);
|
||||
const kind = tr('wall.hud.kindCardDeck', 'Card Deck');
|
||||
set(item, kind, 'fetch', tr('wall.hud.fetchingRecords', 'fetching records'), 30);
|
||||
try {
|
||||
const data = await rows(item);
|
||||
const [{ createCardRenderer }, strings] = await Promise.all([
|
||||
import('../packet/card-render.js'),
|
||||
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
]);
|
||||
set(item, kind, 'render', `laying out ${data.length} cards`, 70);
|
||||
set(item, kind, 'render', tr('wall.hud.layingOutCards', 'laying out {n} cards', { n: data.length }, data.length), 70);
|
||||
const R = createCardRenderer({ strings, lang: lang(), stock: stock() });
|
||||
openPrintFrame(R.buildDeck(item, data), { autoprint: true });
|
||||
done(item, kind);
|
||||
|
|
@ -307,8 +320,8 @@ export function usePosterPrints(opts = {}) {
|
|||
|
||||
async function printPacket(item, { mapSvg = '', funnel = null, variant = 'full' } = {}) {
|
||||
if (busy()) return;
|
||||
const kind = 'Prayer Packet';
|
||||
set(item, kind, 'fetch', 'fetching people groups', 30);
|
||||
const kind = tr('wall.hud.kindPacket', 'Prayer Packet');
|
||||
set(item, kind, 'fetch', tr('wall.hud.fetchingGroups', 'fetching people groups'), 30);
|
||||
try {
|
||||
const data = await rows(item);
|
||||
// `christian` is the evangelical share behind the packet's %-evangelical page — of THIS packet's
|
||||
|
|
@ -352,7 +365,7 @@ export function usePosterPrints(opts = {}) {
|
|||
const photos = new Map(
|
||||
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
|
||||
);
|
||||
set(item, kind, 'render', `printing ${data.length} people groups`, 70);
|
||||
set(item, kind, 'render', tr('wall.hud.printingGroups', 'printing {n} people groups', { n: data.length }, data.length), 70);
|
||||
// The join runs over the SAME rows the packet prints, so the sets page cannot describe a
|
||||
// different population than the profile table beside it.
|
||||
// ── DEFAULT SUPPLIERS for hosts that pass nothing — the wall printed a packet MISSING two
|
||||
|
|
|
|||
|
|
@ -25,3 +25,10 @@ is the one translated line that says so.
|
|||
|
||||
When a human-reviewed translation of that prose exists, it gets its own area file and that
|
||||
line comes out.
|
||||
|
||||
## upgMap.json holds strings only the UPG-100 map shows
|
||||
|
||||
A bundle carries an area when its code names it (`internal/i18n-areas.mjs`), so strings that only one
|
||||
plugin shows belong in an area of their own rather than in `messages`, which every bundle carries.
|
||||
`upgMap` is that area for the UPG-100 map's selection and data-failure messages: 11 locales × those keys
|
||||
stay out of country-posters, which sits just under the bundle size ceiling.
|
||||
|
|
|
|||
32
library/i18n/keyedError.js
Normal file
32
library/i18n/keyedError.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* keyedError — an Error a READER may see, carrying the catalogue key that says it in their language.
|
||||
*
|
||||
* Code that throws does not know the reader's locale, and the UI that catches does not know what went
|
||||
* wrong, so the message used to cross that gap as English: "poster not found (404)" in an Arabic HUD. The
|
||||
* error now carries both halves: `message` stays English (logs, consoles, developers), and `i18nKey` +
|
||||
* `i18nParams` let whoever DISPLAYS it translate it.
|
||||
*
|
||||
* throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: 404 })
|
||||
* status.value = errorText(e, t) // the reader's language; English for an unkeyed error
|
||||
*
|
||||
* An error without a key (a TypeError, a browser's own message) is shown as before: errorText never hides
|
||||
* a failure because it has no translation for it.
|
||||
*/
|
||||
|
||||
const fill = (s, params) => String(s).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m))
|
||||
|
||||
export function keyedError(key, en, params = {}) {
|
||||
const e = new Error(fill(en, params))
|
||||
e.i18nKey = key
|
||||
e.i18nParams = params
|
||||
return e
|
||||
}
|
||||
|
||||
/** The text to show a reader for `e`: its catalogue entry through `t` when keyed and present, else its message. */
|
||||
export function errorText(e, t) {
|
||||
if (e && e.i18nKey && typeof t === 'function') {
|
||||
const v = t(e.i18nKey, e.i18nParams || {})
|
||||
if (typeof v === 'string' && v && v !== e.i18nKey) return v
|
||||
}
|
||||
return String((e && e.message) || e)
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "ملصقات الدول — قوائم صلاة وملصقات",
|
||||
"largerScreenNotice": "هذه الخريطة التجريبية مُعدّة للشاشات الكبيرة. تُفتح هنا، لكن بعض أجزائها قد لا يتّسع أو لا يعمل جيدًا على الهاتف.",
|
||||
"desktopOnlyNotice": "{label} يعمل على الحاسوب فقط — افتح هذه الصفحة على حاسوب لاستخدامه.",
|
||||
"noticeDismiss": "إغلاق"
|
||||
"noticeDismiss": "إغلاق",
|
||||
"countryPosterTooMany": "عدد الشعوب أكبر من أن يتّسع له ملصق واحد بوضوح — ونعمل على حلّ لذلك."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "الهند",
|
||||
"region_other": "أخرى",
|
||||
"region_americas": "الأمريكتان",
|
||||
"region_europe": "أوروبا"
|
||||
"region_europe": "أوروبا",
|
||||
"region_deaf": "الصمّ",
|
||||
"zone_deaf": "مجموعات الصمّ"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/ar/upgMap.json
Normal file
7
library/i18n/locales/ar/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "لا توجد بيانات شعوب لـ {names} — اختر الدولة بدلًا من ذلك",
|
||||
"nothingSelected": "لم يُحدَّد شيء",
|
||||
"selectionCardsFailed": "فشل إنشاء بطاقات التحديد: {reason}",
|
||||
"selectionPosterNeedsZone": "الملصقات متاحة لكل منطقة في مفتاح الخريطة — استخدم «قائمة الصلاة» لتحديد على الخريطة",
|
||||
"mapDataFailed": "تعذّر تحميل بيانات الخريطة"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "لا قوائم | قائمة واحدة | قائمتان | {n} قوائم | {n} قائمة | {n} قائمة",
|
||||
"stockCard": "بطاقة",
|
||||
"stockPostcard": "بطاقة بريدية",
|
||||
"stockLandscape": "أفقي"
|
||||
"stockLandscape": "أفقي",
|
||||
"hud": {
|
||||
"kindPoster": "ملصق",
|
||||
"kindList": "قائمة",
|
||||
"kindPacket": "حزمة صلاة",
|
||||
"fetchingPoster": "جارٍ جلب الملصق",
|
||||
"generatingPoster": "جارٍ إنشاء الملصق",
|
||||
"openingPrint": "جارٍ فتح نافذة الطباعة",
|
||||
"printOpened": "فُتحت نافذة الطباعة",
|
||||
"saving": "جارٍ الحفظ",
|
||||
"fetchingRecords": "جارٍ جلب البيانات",
|
||||
"fetchingGroups": "جارٍ جلب الشعوب",
|
||||
"layingOutRows": "لا صفوف | صف واحد قيد الترتيب | صفّان قيد الترتيب | {n} صفوف قيد الترتيب | {n} صفًا قيد الترتيب | {n} صف قيد الترتيب",
|
||||
"layingOutCards": "لا بطاقات | بطاقة واحدة قيد الترتيب | بطاقتان قيد الترتيب | {n} بطاقات قيد الترتيب | {n} بطاقةً قيد الترتيب | {n} بطاقة قيد الترتيب",
|
||||
"printingGroups": "لا شعوب | طباعة شعب واحد | طباعة شعبين | طباعة {n} شعوب | طباعة {n} شعبًا | طباعة {n} شعب",
|
||||
"kindCardDeck": "مجموعة البطاقات",
|
||||
"kindPrayerList": "قائمة الصلاة",
|
||||
"saved": "تم الحفظ",
|
||||
"noRecords": "لا تحتوي هذه القائمة على شعوب للطباعة",
|
||||
"noPoster": "لا يتوفر ملصق لهذه القائمة",
|
||||
"posterNotFound": "لم يُعثر على الملصق ({status})",
|
||||
"posterBlank": "خرج الملصق المُنشأ فارغًا",
|
||||
"noApi": "لم تُهيَّأ خدمة الشعوب",
|
||||
"fetchFailed": "تعذّر تحميل الشعوب ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — ملصق صلاة",
|
||||
"posterAltAction": "{label} — ملصق صلاة (انقر للمعاينة والطباعة)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Länderposter — Gebetslisten und Poster",
|
||||
"largerScreenNotice": "Diese experimentelle Karte ist für große Bildschirme gebaut. Sie öffnet sich hier, aber Teile passen oder funktionieren auf einem Telefon womöglich nicht gut.",
|
||||
"desktopOnlyNotice": "{label} funktioniert nur am Desktop – öffnen Sie diese Seite an einem Computer.",
|
||||
"noticeDismiss": "Schließen"
|
||||
"noticeDismiss": "Schließen",
|
||||
"countryPosterTooMany": "Zu viele Volksgruppen, um lesbar auf ein Plakat zu passen – an einer Lösung wird gearbeitet."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "Indien",
|
||||
"region_other": "Sonstige",
|
||||
"region_americas": "Amerika",
|
||||
"region_europe": "Europa"
|
||||
"region_europe": "Europa",
|
||||
"region_deaf": "Gehörlose",
|
||||
"zone_deaf": "Gruppen gehörloser Menschen"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/de/upgMap.json
Normal file
7
library/i18n/locales/de/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "Keine Volksgruppen-Daten für {names} – wählen Sie stattdessen das Land",
|
||||
"nothingSelected": "Nichts ausgewählt",
|
||||
"selectionCardsFailed": "Karten der Auswahl fehlgeschlagen: {reason}",
|
||||
"selectionPosterNeedsZone": "Plakate gibt es pro Gebiet in der Legende – nutzen Sie Gebetsliste für eine Auswahl auf der Karte",
|
||||
"mapDataFailed": "Die Kartendaten konnten nicht geladen werden"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} Liste | {n} Listen",
|
||||
"stockCard": "Karte",
|
||||
"stockPostcard": "Postkarte",
|
||||
"stockLandscape": "Querformat"
|
||||
"stockLandscape": "Querformat",
|
||||
"hud": {
|
||||
"kindPoster": "Poster",
|
||||
"kindList": "Liste",
|
||||
"kindPacket": "Gebetsheft",
|
||||
"fetchingPoster": "Poster wird geladen",
|
||||
"generatingPoster": "Poster wird erstellt",
|
||||
"openingPrint": "Druckdialog wird geöffnet",
|
||||
"printOpened": "Druckdialog geöffnet",
|
||||
"saving": "wird gespeichert",
|
||||
"fetchingRecords": "Daten werden geladen",
|
||||
"fetchingGroups": "Volksgruppen werden geladen",
|
||||
"layingOutRows": "{n} Zeile wird gesetzt | {n} Zeilen werden gesetzt",
|
||||
"layingOutCards": "{n} Karte wird gesetzt | {n} Karten werden gesetzt",
|
||||
"printingGroups": "{n} Volksgruppe wird gedruckt | {n} Volksgruppen werden gedruckt",
|
||||
"kindCardDeck": "Kartenset",
|
||||
"kindPrayerList": "Gebetsliste",
|
||||
"saved": "gespeichert",
|
||||
"noRecords": "diese Liste enthält keine Volksgruppen zum Drucken",
|
||||
"noPoster": "für diese Liste ist kein Plakat verfügbar",
|
||||
"posterNotFound": "Plakat nicht gefunden ({status})",
|
||||
"posterBlank": "das erzeugte Plakat ist leer",
|
||||
"noApi": "kein Volksgruppen-Dienst konfiguriert",
|
||||
"fetchFailed": "Volksgruppen konnten nicht geladen werden ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — Gebetsposter",
|
||||
"posterAltAction": "{label} — Gebetsposter (zum Ansehen und Drucken klicken)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Country Posters — Prayer Lists & Posters",
|
||||
"largerScreenNotice": "This experimental map is built for larger screens. It opens here, but parts of it may not fit or work well on a phone.",
|
||||
"desktopOnlyNotice": "{label} only supports desktop — open this page on a desktop to use it.",
|
||||
"noticeDismiss": "Dismiss"
|
||||
"noticeDismiss": "Dismiss",
|
||||
"countryPosterTooMany": "Too many people groups to fit on one poster legibly — a solution is in progress."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "India",
|
||||
"region_other": "Other",
|
||||
"region_americas": "Americas",
|
||||
"region_europe": "Europe"
|
||||
"region_europe": "Europe",
|
||||
"region_deaf": "Deaf",
|
||||
"zone_deaf": "Deaf people groups"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/en/upgMap.json
Normal file
7
library/i18n/locales/en/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "No people-group data for {names} — select the country instead",
|
||||
"nothingSelected": "Nothing is selected",
|
||||
"selectionCardsFailed": "Selection card deck failed: {reason}",
|
||||
"selectionPosterNeedsZone": "Posters exist per territory on the legend — use Prayer List for a map selection",
|
||||
"mapDataFailed": "The map data could not be loaded"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} list | {n} lists",
|
||||
"stockCard": "Card",
|
||||
"stockPostcard": "Postcard",
|
||||
"stockLandscape": "landscape"
|
||||
"stockLandscape": "landscape",
|
||||
"hud": {
|
||||
"kindPoster": "Poster",
|
||||
"kindList": "List",
|
||||
"kindPacket": "Prayer Packet",
|
||||
"fetchingPoster": "fetching the poster",
|
||||
"generatingPoster": "generating the poster",
|
||||
"openingPrint": "opening the print dialog",
|
||||
"printOpened": "print dialog opened",
|
||||
"saving": "saving",
|
||||
"fetchingRecords": "fetching records",
|
||||
"fetchingGroups": "fetching people groups",
|
||||
"layingOutRows": "laying out {n} row | laying out {n} rows",
|
||||
"layingOutCards": "laying out {n} card | laying out {n} cards",
|
||||
"printingGroups": "printing {n} people group | printing {n} people groups",
|
||||
"kindCardDeck": "Card Deck",
|
||||
"kindPrayerList": "Prayer List",
|
||||
"saved": "saved",
|
||||
"noRecords": "that list has no people groups to print",
|
||||
"noPoster": "no poster is available for this list",
|
||||
"posterNotFound": "poster not found ({status})",
|
||||
"posterBlank": "the generated poster came out blank",
|
||||
"noApi": "no people-groups service is configured",
|
||||
"fetchFailed": "could not load the people groups ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — prayer poster",
|
||||
"posterAltAction": "{label} — prayer poster (click to preview and print)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Carteles por país — Listas de oración y carteles",
|
||||
"largerScreenNotice": "Este mapa experimental está pensado para pantallas grandes. Se abre aquí, pero algunas partes pueden no caber o no funcionar bien en un teléfono.",
|
||||
"desktopOnlyNotice": "{label} solo funciona en escritorio: abra esta página en un ordenador para usarlo.",
|
||||
"noticeDismiss": "Cerrar"
|
||||
"noticeDismiss": "Cerrar",
|
||||
"countryPosterTooMany": "Demasiados pueblos para que quepan legiblemente en un solo cartel; estamos trabajando en una solución."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "India",
|
||||
"region_other": "Otros",
|
||||
"region_americas": "América",
|
||||
"region_europe": "Europa"
|
||||
"region_europe": "Europa",
|
||||
"region_deaf": "Sordos",
|
||||
"zone_deaf": "Grupos de personas sordas"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/es/upgMap.json
Normal file
7
library/i18n/locales/es/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "No hay datos de pueblos para {names}: selecciona el país",
|
||||
"nothingSelected": "No hay nada seleccionado",
|
||||
"selectionCardsFailed": "Error al generar las tarjetas de la selección: {reason}",
|
||||
"selectionPosterNeedsZone": "Hay carteles por territorio en la leyenda; usa Lista de oración para una selección en el mapa",
|
||||
"mapDataFailed": "No se pudieron cargar los datos del mapa"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} lista | {n} de listas | {n} listas",
|
||||
"stockCard": "Tarjeta",
|
||||
"stockPostcard": "Postal",
|
||||
"stockLandscape": "horizontal"
|
||||
"stockLandscape": "horizontal",
|
||||
"hud": {
|
||||
"kindPoster": "Cartel",
|
||||
"kindList": "Lista",
|
||||
"kindPacket": "Paquete de oración",
|
||||
"fetchingPoster": "obteniendo el cartel",
|
||||
"generatingPoster": "generando el cartel",
|
||||
"openingPrint": "abriendo el cuadro de impresión",
|
||||
"printOpened": "cuadro de impresión abierto",
|
||||
"saving": "guardando",
|
||||
"fetchingRecords": "obteniendo registros",
|
||||
"fetchingGroups": "obteniendo pueblos",
|
||||
"layingOutRows": "maquetando {n} fila | maquetando {n} de filas | maquetando {n} filas",
|
||||
"layingOutCards": "maquetando {n} tarjeta | maquetando {n} de tarjetas | maquetando {n} tarjetas",
|
||||
"printingGroups": "imprimiendo {n} pueblo | imprimiendo {n} de pueblos | imprimiendo {n} pueblos",
|
||||
"kindCardDeck": "Baraja de tarjetas",
|
||||
"kindPrayerList": "Lista de oración",
|
||||
"saved": "guardado",
|
||||
"noRecords": "esa lista no tiene pueblos para imprimir",
|
||||
"noPoster": "no hay un cartel disponible para esta lista",
|
||||
"posterNotFound": "cartel no encontrado ({status})",
|
||||
"posterBlank": "el cartel generado salió en blanco",
|
||||
"noApi": "no hay un servicio de pueblos configurado",
|
||||
"fetchFailed": "no se pudieron cargar los pueblos ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — cartel de oración",
|
||||
"posterAltAction": "{label} — cartel de oración (haz clic para ver e imprimir)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Affiches par pays — Listes de prière et affiches",
|
||||
"largerScreenNotice": "Cette carte expérimentale est conçue pour les grands écrans. Elle s’ouvre ici, mais certaines parties peuvent mal s’afficher ou mal fonctionner sur un téléphone.",
|
||||
"desktopOnlyNotice": "{label} ne fonctionne que sur ordinateur : ouvrez cette page sur un ordinateur pour l’utiliser.",
|
||||
"noticeDismiss": "Fermer"
|
||||
"noticeDismiss": "Fermer",
|
||||
"countryPosterTooMany": "Trop de peuples pour tenir lisiblement sur une seule affiche — une solution est en préparation."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "Inde",
|
||||
"region_other": "Autres",
|
||||
"region_americas": "Amériques",
|
||||
"region_europe": "Europe"
|
||||
"region_europe": "Europe",
|
||||
"region_deaf": "Sourds",
|
||||
"zone_deaf": "Groupes de personnes sourdes"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/fr/upgMap.json
Normal file
7
library/i18n/locales/fr/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "Aucune donnée de peuples pour {names} — sélectionnez plutôt le pays",
|
||||
"nothingSelected": "Rien n’est sélectionné",
|
||||
"selectionCardsFailed": "Échec des cartes de la sélection : {reason}",
|
||||
"selectionPosterNeedsZone": "Les affiches existent par territoire dans la légende — utilisez Liste de prière pour une sélection sur la carte",
|
||||
"mapDataFailed": "Les données de la carte n’ont pas pu être chargées"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} liste | {n} de listes | {n} listes",
|
||||
"stockCard": "Carte",
|
||||
"stockPostcard": "Carte postale",
|
||||
"stockLandscape": "paysage"
|
||||
"stockLandscape": "paysage",
|
||||
"hud": {
|
||||
"kindPoster": "Affiche",
|
||||
"kindList": "Liste",
|
||||
"kindPacket": "Dossier de prière",
|
||||
"fetchingPoster": "récupération de l’affiche",
|
||||
"generatingPoster": "génération de l’affiche",
|
||||
"openingPrint": "ouverture de la boîte d’impression",
|
||||
"printOpened": "boîte d’impression ouverte",
|
||||
"saving": "enregistrement",
|
||||
"fetchingRecords": "récupération des données",
|
||||
"fetchingGroups": "récupération des peuples",
|
||||
"layingOutRows": "mise en page de {n} ligne | mise en page de {n} de lignes | mise en page de {n} lignes",
|
||||
"layingOutCards": "mise en page de {n} carte | mise en page de {n} de cartes | mise en page de {n} cartes",
|
||||
"printingGroups": "impression de {n} peuple | impression de {n} de peuples | impression de {n} peuples",
|
||||
"kindCardDeck": "Jeu de cartes",
|
||||
"kindPrayerList": "Liste de prière",
|
||||
"saved": "enregistré",
|
||||
"noRecords": "cette liste ne contient aucun peuple à imprimer",
|
||||
"noPoster": "aucune affiche n’est disponible pour cette liste",
|
||||
"posterNotFound": "affiche introuvable ({status})",
|
||||
"posterBlank": "l’affiche générée est sortie vide",
|
||||
"noApi": "aucun service de peuples n’est configuré",
|
||||
"fetchFailed": "impossible de charger les peuples ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — affiche de prière",
|
||||
"posterAltAction": "{label} — affiche de prière (cliquez pour prévisualiser et imprimer)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "देश पोस्टर — प्रार्थना सूचियाँ और पोस्टर",
|
||||
"largerScreenNotice": "यह प्रायोगिक नक्शा बड़ी स्क्रीन के लिए बना है। यह यहाँ खुलता है, पर फ़ोन पर इसके कुछ हिस्से ठीक से न दिखें या न चलें।",
|
||||
"desktopOnlyNotice": "{label} केवल डेस्कटॉप पर चलता है — इसे इस्तेमाल करने के लिए यह पेज कंप्यूटर पर खोलें।",
|
||||
"noticeDismiss": "बंद करें"
|
||||
"noticeDismiss": "बंद करें",
|
||||
"countryPosterTooMany": "एक पोस्टर पर साफ़-साफ़ आने के लिए जनसमूह बहुत अधिक हैं — समाधान पर काम चल रहा है।"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "भारत",
|
||||
"region_other": "अन्य",
|
||||
"region_americas": "अमेरिका महाद्वीप",
|
||||
"region_europe": "यूरोप"
|
||||
"region_europe": "यूरोप",
|
||||
"region_deaf": "बधिर",
|
||||
"zone_deaf": "बधिर लोगों के समूह"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/hi/upgMap.json
Normal file
7
library/i18n/locales/hi/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "{names} के लिए कोई जनसमूह डेटा नहीं है — इसके बजाय देश चुनें",
|
||||
"nothingSelected": "कुछ भी चुना नहीं गया है",
|
||||
"selectionCardsFailed": "चयन के कार्ड विफल: {reason}",
|
||||
"selectionPosterNeedsZone": "पोस्टर लेजेंड में हर क्षेत्र के लिए हैं — मानचित्र पर चयन के लिए प्रार्थना सूची का उपयोग करें",
|
||||
"mapDataFailed": "मानचित्र का डेटा लोड नहीं हो सका"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} सूची | {n} सूचियाँ",
|
||||
"stockCard": "कार्ड",
|
||||
"stockPostcard": "पोस्टकार्ड",
|
||||
"stockLandscape": "लैंडस्केप"
|
||||
"stockLandscape": "लैंडस्केप",
|
||||
"hud": {
|
||||
"kindPoster": "पोस्टर",
|
||||
"kindList": "सूची",
|
||||
"kindPacket": "प्रार्थना पैकेट",
|
||||
"fetchingPoster": "पोस्टर लाया जा रहा है",
|
||||
"generatingPoster": "पोस्टर बनाया जा रहा है",
|
||||
"openingPrint": "प्रिंट विंडो खुल रही है",
|
||||
"printOpened": "प्रिंट विंडो खुल गई",
|
||||
"saving": "सहेजा जा रहा है",
|
||||
"fetchingRecords": "डेटा लाया जा रहा है",
|
||||
"fetchingGroups": "जनसमूह लाए जा रहे हैं",
|
||||
"layingOutRows": "{n} पंक्ति सजाई जा रही है | {n} पंक्तियाँ सजाई जा रही हैं",
|
||||
"layingOutCards": "{n} कार्ड सजाया जा रहा है | {n} कार्ड सजाए जा रहे हैं",
|
||||
"printingGroups": "{n} जनसमूह छापा जा रहा है | {n} जनसमूह छापे जा रहे हैं",
|
||||
"kindCardDeck": "कार्ड सेट",
|
||||
"kindPrayerList": "प्रार्थना सूची",
|
||||
"saved": "सहेजा गया",
|
||||
"noRecords": "इस सूची में छापने के लिए कोई जनसमूह नहीं है",
|
||||
"noPoster": "इस सूची के लिए कोई पोस्टर उपलब्ध नहीं है",
|
||||
"posterNotFound": "पोस्टर नहीं मिला ({status})",
|
||||
"posterBlank": "बनाया गया पोस्टर खाली निकला",
|
||||
"noApi": "कोई जनसमूह सेवा कॉन्फ़िगर नहीं है",
|
||||
"fetchFailed": "जनसमूह लोड नहीं हो सके ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — प्रार्थना पोस्टर",
|
||||
"posterAltAction": "{label} — प्रार्थना पोस्टर (देखने और छापने के लिए क्लिक करें)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Poster per paese — Liste di preghiera e manifesti",
|
||||
"largerScreenNotice": "Questa mappa sperimentale è pensata per schermi grandi. Si apre qui, ma alcune parti potrebbero non entrare o non funzionare bene su un telefono.",
|
||||
"desktopOnlyNotice": "{label} funziona solo su desktop: apri questa pagina da un computer per usarlo.",
|
||||
"noticeDismiss": "Chiudi"
|
||||
"noticeDismiss": "Chiudi",
|
||||
"countryPosterTooMany": "Troppi popoli per stare in modo leggibile su un solo manifesto: una soluzione è in arrivo."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "India",
|
||||
"region_other": "Altro",
|
||||
"region_americas": "Americhe",
|
||||
"region_europe": "Europa"
|
||||
"region_europe": "Europa",
|
||||
"region_deaf": "Sordi",
|
||||
"zone_deaf": "Gruppi di persone sorde"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/it/upgMap.json
Normal file
7
library/i18n/locales/it/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "Nessun dato sui popoli per {names}: seleziona invece il paese",
|
||||
"nothingSelected": "Nessuna selezione",
|
||||
"selectionCardsFailed": "Carte della selezione non riuscite: {reason}",
|
||||
"selectionPosterNeedsZone": "I manifesti esistono per territorio nella legenda: usa Lista di preghiera per una selezione sulla mappa",
|
||||
"mapDataFailed": "Impossibile caricare i dati della mappa"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} lista | {n} di liste | {n} liste",
|
||||
"stockCard": "Carta",
|
||||
"stockPostcard": "Cartolina",
|
||||
"stockLandscape": "orizzontale"
|
||||
"stockLandscape": "orizzontale",
|
||||
"hud": {
|
||||
"kindPoster": "Poster",
|
||||
"kindList": "Lista",
|
||||
"kindPacket": "Pacchetto di preghiera",
|
||||
"fetchingPoster": "recupero del poster",
|
||||
"generatingPoster": "generazione del poster",
|
||||
"openingPrint": "apertura della finestra di stampa",
|
||||
"printOpened": "finestra di stampa aperta",
|
||||
"saving": "salvataggio",
|
||||
"fetchingRecords": "recupero dei dati",
|
||||
"fetchingGroups": "recupero dei popoli",
|
||||
"layingOutRows": "impaginazione di {n} riga | impaginazione di {n} di righe | impaginazione di {n} righe",
|
||||
"layingOutCards": "impaginazione di {n} carta | impaginazione di {n} di carte | impaginazione di {n} carte",
|
||||
"printingGroups": "stampa di {n} popolo | stampa di {n} di popoli | stampa di {n} popoli",
|
||||
"kindCardDeck": "Mazzo di carte",
|
||||
"kindPrayerList": "Lista di preghiera",
|
||||
"saved": "salvato",
|
||||
"noRecords": "quell’elenco non ha popoli da stampare",
|
||||
"noPoster": "nessun manifesto disponibile per questo elenco",
|
||||
"posterNotFound": "manifesto non trovato ({status})",
|
||||
"posterBlank": "il manifesto generato è risultato vuoto",
|
||||
"noApi": "nessun servizio dei popoli è configurato",
|
||||
"fetchFailed": "impossibile caricare i popoli ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — poster di preghiera",
|
||||
"posterAltAction": "{label} — poster di preghiera (fai clic per vedere e stampare)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Cartazes por país — Listas de oração e cartazes",
|
||||
"largerScreenNotice": "Este mapa experimental foi feito para telas maiores. Ele abre aqui, mas algumas partes podem não caber ou não funcionar bem no celular.",
|
||||
"desktopOnlyNotice": "{label} só funciona no computador — abra esta página num computador para usá-lo.",
|
||||
"noticeDismiss": "Fechar"
|
||||
"noticeDismiss": "Fechar",
|
||||
"countryPosterTooMany": "Povos demais para caber de forma legível em um só cartaz — uma solução está a caminho."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "Índia",
|
||||
"region_other": "Outros",
|
||||
"region_americas": "Américas",
|
||||
"region_europe": "Europa"
|
||||
"region_europe": "Europa",
|
||||
"region_deaf": "Surdos",
|
||||
"zone_deaf": "Grupos de pessoas surdas"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/pt/upgMap.json
Normal file
7
library/i18n/locales/pt/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "Não há dados de povos para {names} — selecione o país",
|
||||
"nothingSelected": "Nada está selecionado",
|
||||
"selectionCardsFailed": "Falha ao gerar os cartões da seleção: {reason}",
|
||||
"selectionPosterNeedsZone": "Há cartazes por território na legenda — use Lista de oração para uma seleção no mapa",
|
||||
"mapDataFailed": "Não foi possível carregar os dados do mapa"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} lista | {n} de listas | {n} listas",
|
||||
"stockCard": "Cartão",
|
||||
"stockPostcard": "Cartão-postal",
|
||||
"stockLandscape": "paisagem"
|
||||
"stockLandscape": "paisagem",
|
||||
"hud": {
|
||||
"kindPoster": "Cartaz",
|
||||
"kindList": "Lista",
|
||||
"kindPacket": "Pacote de oração",
|
||||
"fetchingPoster": "obtendo o cartaz",
|
||||
"generatingPoster": "gerando o cartaz",
|
||||
"openingPrint": "abrindo a caixa de impressão",
|
||||
"printOpened": "caixa de impressão aberta",
|
||||
"saving": "salvando",
|
||||
"fetchingRecords": "obtendo registros",
|
||||
"fetchingGroups": "obtendo povos",
|
||||
"layingOutRows": "diagramando {n} linha | diagramando {n} de linhas | diagramando {n} linhas",
|
||||
"layingOutCards": "diagramando {n} cartão | diagramando {n} de cartões | diagramando {n} cartões",
|
||||
"printingGroups": "imprimindo {n} povo | imprimindo {n} de povos | imprimindo {n} povos",
|
||||
"kindCardDeck": "Baralho de cartões",
|
||||
"kindPrayerList": "Lista de oração",
|
||||
"saved": "salvo",
|
||||
"noRecords": "essa lista não tem povos para imprimir",
|
||||
"noPoster": "não há cartaz disponível para esta lista",
|
||||
"posterNotFound": "cartaz não encontrado ({status})",
|
||||
"posterBlank": "o cartaz gerado saiu em branco",
|
||||
"noApi": "nenhum serviço de povos está configurado",
|
||||
"fetchFailed": "não foi possível carregar os povos ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — cartaz de oração",
|
||||
"posterAltAction": "{label} — cartaz de oração (clique para visualizar e imprimir)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Afișe pe țări — Liste de rugăciune și afișe",
|
||||
"largerScreenNotice": "Această hartă experimentală este făcută pentru ecrane mari. Se deschide aici, dar unele părți s-ar putea să nu încapă sau să nu funcționeze bine pe telefon.",
|
||||
"desktopOnlyNotice": "{label} funcționează doar pe desktop — deschideți pagina pe un computer pentru a-l folosi.",
|
||||
"noticeDismiss": "Închide"
|
||||
"noticeDismiss": "Închide",
|
||||
"countryPosterTooMany": "Prea multe popoare pentru a încăpea lizibil pe un singur afiș — lucrăm la o soluție."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "India",
|
||||
"region_other": "Altele",
|
||||
"region_americas": "Americi",
|
||||
"region_europe": "Europa"
|
||||
"region_europe": "Europa",
|
||||
"region_deaf": "Surzi",
|
||||
"zone_deaf": "Grupuri de persoane surde"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/ro/upgMap.json
Normal file
7
library/i18n/locales/ro/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "Nu există date despre popoare pentru {names} — selectați țara",
|
||||
"nothingSelected": "Nimic nu este selectat",
|
||||
"selectionCardsFailed": "Cărțile selecției au eșuat: {reason}",
|
||||
"selectionPosterNeedsZone": "Afișele există pe teritoriu în legendă — folosiți Listă de rugăciune pentru o selecție pe hartă",
|
||||
"mapDataFailed": "Datele hărții nu au putut fi încărcate"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} listă | {n} liste | {n} de liste",
|
||||
"stockCard": "Card",
|
||||
"stockPostcard": "Carte poștală",
|
||||
"stockLandscape": "orizontal"
|
||||
"stockLandscape": "orizontal",
|
||||
"hud": {
|
||||
"kindPoster": "Afiș",
|
||||
"kindList": "Listă",
|
||||
"kindPacket": "Pachet de rugăciune",
|
||||
"fetchingPoster": "se preia afișul",
|
||||
"generatingPoster": "se generează afișul",
|
||||
"openingPrint": "se deschide fereastra de tipărire",
|
||||
"printOpened": "fereastra de tipărire s-a deschis",
|
||||
"saving": "se salvează",
|
||||
"fetchingRecords": "se preiau datele",
|
||||
"fetchingGroups": "se preiau popoarele",
|
||||
"layingOutRows": "se așază {n} rând | se așază {n} rânduri | se așază {n} de rânduri",
|
||||
"layingOutCards": "se așază {n} carte | se așază {n} cărți | se așază {n} de cărți",
|
||||
"printingGroups": "se tipărește {n} popor | se tipăresc {n} popoare | se tipăresc {n} de popoare",
|
||||
"kindCardDeck": "Pachet de cartonașe",
|
||||
"kindPrayerList": "Listă de rugăciune",
|
||||
"saved": "salvat",
|
||||
"noRecords": "lista nu conține popoare de tipărit",
|
||||
"noPoster": "nu există un afiș disponibil pentru această listă",
|
||||
"posterNotFound": "afișul nu a fost găsit ({status})",
|
||||
"posterBlank": "afișul generat a ieșit gol",
|
||||
"noApi": "niciun serviciu de popoare nu este configurat",
|
||||
"fetchFailed": "popoarele nu au putut fi încărcate ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — afiș de rugăciune",
|
||||
"posterAltAction": "{label} — afiș de rugăciune (clic pentru previzualizare și tipărire)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "Плакаты по странам — молитвенные списки и плакаты",
|
||||
"largerScreenNotice": "Эта экспериментальная карта рассчитана на большие экраны. Она откроется, но на телефоне часть элементов может не поместиться или работать плохо.",
|
||||
"desktopOnlyNotice": "{label} работает только на компьютере — откройте эту страницу на компьютере.",
|
||||
"noticeDismiss": "Закрыть"
|
||||
"noticeDismiss": "Закрыть",
|
||||
"countryPosterTooMany": "Слишком много народов, чтобы разборчиво уместить их на одном плакате, — решение готовится."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "Индия",
|
||||
"region_other": "Другие",
|
||||
"region_americas": "Америка",
|
||||
"region_europe": "Европа"
|
||||
"region_europe": "Европа",
|
||||
"region_deaf": "Глухие",
|
||||
"zone_deaf": "Группы глухих людей"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/ru/upgMap.json
Normal file
7
library/i18n/locales/ru/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "Нет данных о народах для {names} — выберите страну",
|
||||
"nothingSelected": "Ничего не выбрано",
|
||||
"selectionCardsFailed": "Не удалось создать карточки выбора: {reason}",
|
||||
"selectionPosterNeedsZone": "Плакаты есть для каждой территории в легенде — для выбора на карте используйте «Молитвенный список»",
|
||||
"mapDataFailed": "Не удалось загрузить данные карты"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} список | {n} списка | {n} списков | {n} списка",
|
||||
"stockCard": "Карточка",
|
||||
"stockPostcard": "Открытка",
|
||||
"stockLandscape": "альбомная"
|
||||
"stockLandscape": "альбомная",
|
||||
"hud": {
|
||||
"kindPoster": "Плакат",
|
||||
"kindList": "Список",
|
||||
"kindPacket": "Молитвенный пакет",
|
||||
"fetchingPoster": "загрузка плаката",
|
||||
"generatingPoster": "создание плаката",
|
||||
"openingPrint": "открытие окна печати",
|
||||
"printOpened": "окно печати открыто",
|
||||
"saving": "сохранение",
|
||||
"fetchingRecords": "загрузка данных",
|
||||
"fetchingGroups": "загрузка народов",
|
||||
"layingOutRows": "раскладка {n} строки | раскладка {n} строк | раскладка {n} строк | раскладка {n} строки",
|
||||
"layingOutCards": "раскладка {n} карточки | раскладка {n} карточек | раскладка {n} карточек | раскладка {n} карточки",
|
||||
"printingGroups": "печать {n} народа | печать {n} народов | печать {n} народов | печать {n} народа",
|
||||
"kindCardDeck": "Набор карточек",
|
||||
"kindPrayerList": "Молитвенный список",
|
||||
"saved": "сохранено",
|
||||
"noRecords": "в этом списке нет народов для печати",
|
||||
"noPoster": "для этого списка нет плаката",
|
||||
"posterNotFound": "плакат не найден ({status})",
|
||||
"posterBlank": "созданный плакат получился пустым",
|
||||
"noApi": "сервис народов не настроен",
|
||||
"fetchFailed": "не удалось загрузить народы ({status})"
|
||||
},
|
||||
"posterAlt": "{label} — молитвенный плакат",
|
||||
"posterAltAction": "{label} — молитвенный плакат (нажмите, чтобы просмотреть и распечатать)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
"countryWallHeading": "国家海报 — 祷告名单与海报",
|
||||
"largerScreenNotice": "这张实验性地图为大屏幕设计。它可以在这里打开,但部分内容在手机上可能显示不全或无法正常使用。",
|
||||
"desktopOnlyNotice": "{label} 仅支持桌面端——请在电脑上打开此页面使用。",
|
||||
"noticeDismiss": "关闭"
|
||||
"noticeDismiss": "关闭",
|
||||
"countryPosterTooMany": "族群数量过多,无法清晰地放在一张海报上——我们正在制定解决方案。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@
|
|||
"region_india": "印度",
|
||||
"region_other": "其他",
|
||||
"region_americas": "美洲",
|
||||
"region_europe": "欧洲"
|
||||
"region_europe": "欧洲",
|
||||
"region_deaf": "聋人",
|
||||
"zone_deaf": "聋人群体"
|
||||
}
|
||||
|
|
|
|||
7
library/i18n/locales/zh/upgMap.json
Normal file
7
library/i18n/locales/zh/upgMap.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"selectionAdm1NoData": "{names} 没有族群数据——请改为选择国家",
|
||||
"nothingSelected": "未选择任何内容",
|
||||
"selectionCardsFailed": "所选内容的卡片生成失败:{reason}",
|
||||
"selectionPosterNeedsZone": "海报按图例中的区域提供——地图上的选择请使用“祷告名单”",
|
||||
"mapDataFailed": "无法加载地图数据"
|
||||
}
|
||||
|
|
@ -68,5 +68,31 @@
|
|||
"listCount": "{n} 份名单",
|
||||
"stockCard": "卡片",
|
||||
"stockPostcard": "明信片",
|
||||
"stockLandscape": "横向"
|
||||
"stockLandscape": "横向",
|
||||
"hud": {
|
||||
"kindPoster": "海报",
|
||||
"kindList": "名单",
|
||||
"kindPacket": "祷告手册",
|
||||
"fetchingPoster": "正在获取海报",
|
||||
"generatingPoster": "正在生成海报",
|
||||
"openingPrint": "正在打开打印窗口",
|
||||
"printOpened": "打印窗口已打开",
|
||||
"saving": "正在保存",
|
||||
"fetchingRecords": "正在获取数据",
|
||||
"fetchingGroups": "正在获取族群",
|
||||
"layingOutRows": "正在排版 {n} 行",
|
||||
"layingOutCards": "正在排版 {n} 张卡片",
|
||||
"printingGroups": "正在打印 {n} 个族群",
|
||||
"kindCardDeck": "祷告卡组",
|
||||
"kindPrayerList": "祷告名单",
|
||||
"saved": "已保存",
|
||||
"noRecords": "该名单中没有可打印的族群",
|
||||
"noPoster": "此名单没有可用的海报",
|
||||
"posterNotFound": "未找到海报({status})",
|
||||
"posterBlank": "生成的海报为空白",
|
||||
"noApi": "未配置族群服务",
|
||||
"fetchFailed": "无法加载族群({status})"
|
||||
},
|
||||
"posterAlt": "{label} — 祷告海报",
|
||||
"posterAltAction": "{label} — 祷告海报(点击预览并打印)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
* countries costs one request, not 175. A failed fetch is not cached, so the next click retries.
|
||||
*/
|
||||
import { applyPeopleGroupList, resolvePeopleGroupList } from './peopleGroupList.js'
|
||||
import { keyedError } from '../i18n/keyedError.js'
|
||||
|
||||
// What the prints read: the card's face and language, the profile's description (imb_gsec feeds its
|
||||
// reachedness sentence), the sets page's joins (slug), and the country for the page-1 map and captions.
|
||||
|
|
@ -102,9 +103,9 @@ export function createPeopleGroupRecords({ apiBase, lang = () => 'en', profileCo
|
|||
if (!cache.has(key)) {
|
||||
const p = (async () => {
|
||||
const base = typeof apiBase === 'function' ? apiBase() : apiBase
|
||||
if (!base) throw new Error('no people-groups API configured')
|
||||
if (!base) throw keyedError('wall.hud.noApi', 'no people-groups service is configured')
|
||||
const resp = await doFetch(`${base}/api/people-groups/list?fields=${PRINT_FIELDS}&lang=${encodeURIComponent(lc)}`, { cache: 'no-store' })
|
||||
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
|
||||
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status })
|
||||
const rows = extractRecords(await resp.json())
|
||||
const member = profileConfig ? await applyPeopleGroupList(rows, profileConfig) : rows
|
||||
if (rows.length && !member.length) cache.delete(key) // a failed membership: retry next time
|
||||
|
|
|
|||
|
|
@ -112,7 +112,10 @@ 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. Brief: "it should say the exact number in our data."
|
||||
const unsupported = (c) => c.unsupportedReason || null
|
||||
// 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.
|
||||
const unsupported = (c) => (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)
|
||||
|
|
|
|||
|
|
@ -89,6 +89,23 @@ function leafPopulation(row, records) {
|
|||
// the Deaf packet could not be printed from the UI: the zone lookup found nothing and returned silently.
|
||||
export const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' };
|
||||
|
||||
// The catalogue key (poster.<key>) that names each REGION_OF grouping in the reader's language. The
|
||||
// English values stay the grouping identity (ids are derived from them), so the heading is translated
|
||||
// at render: here for the legend via opts.regionTitle, and by poster-wall.vue for the wall's sections.
|
||||
// One table for both surfaces, so a region added here cannot be translated on one and English on the other.
|
||||
export const REGION_KEY = {
|
||||
'Africa': 'region_africa',
|
||||
'Middle East': 'region_middle_east',
|
||||
'Central Asia': 'region_central_asia',
|
||||
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
|
||||
'China + Mongolia': 'region_china_mongolia',
|
||||
'India': 'region_india',
|
||||
'Other': 'region_other',
|
||||
'Americas': 'region_americas',
|
||||
'Europe': 'region_europe',
|
||||
'Deaf': 'region_deaf',
|
||||
};
|
||||
|
||||
export function buildLegendNodes(clusters, opts = {}) {
|
||||
const deafCount = opts.deafCount ?? 123;
|
||||
const records = Array.isArray(opts.records) ? opts.records : null;
|
||||
|
|
@ -102,6 +119,11 @@ export function buildLegendNodes(clusters, opts = {}) {
|
|||
const titleOf = typeof opts.title === 'function'
|
||||
? (zone, fallback) => opts.title(zone) || fallback
|
||||
: (_zone, fallback) => fallback;
|
||||
// `opts.regionTitle(english)` names a region section in the reader's language (see REGION_KEY); the
|
||||
// node id stays derived from the English grouping key, so selection and ids never depend on a locale.
|
||||
const regionTitleOf = typeof opts.regionTitle === 'function'
|
||||
? (english) => opts.regionTitle(english) || english
|
||||
: (english) => english;
|
||||
const lists = clusters.lists || [];
|
||||
const asia = clusters.asia_lists || [];
|
||||
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
|
||||
|
|
@ -172,7 +194,9 @@ export function buildLegendNodes(clusters, opts = {}) {
|
|||
{
|
||||
id: 'deaf',
|
||||
zoneKey: 'deaf',
|
||||
label: 'Deaf (affinity bloc A017)',
|
||||
// Named like every other leaf: opts.title reads the bloc's name from the strings it was given
|
||||
// (zone-title.js, zone_affinity_A017); the English label is only the no-title fallback.
|
||||
label: titleOf({ id: 'deaf', selector: DEAF_SELECTOR }, 'Deaf (affinity bloc A017)'),
|
||||
color: colorOf('deaf'),
|
||||
count: deafCount,
|
||||
info: 'Unified deaf row — affinity bloc rop1=A017 (collapses the two deaf_lists; zero-delta vs old deaf_predicate). Population double-counts vs geographic rows (design-approved, slice 08).',
|
||||
|
|
@ -327,7 +351,7 @@ export function buildLegendNodes(clusters, opts = {}) {
|
|||
// whole zones, while every surviving zone still looked populated. Fix the class, not the six
|
||||
// spellings we happen to know.
|
||||
id: 'region-' + label.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
label,
|
||||
label: regionTitleOf(label),
|
||||
count: children.reduce((n, c) => n + (c.count || 0), 0),
|
||||
children,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ import { zonesForList } from './data/zones-for-list.js'
|
|||
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
|
||||
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
|
||||
import { makeZoneSheet } from './data/zone-sheet-print.js'
|
||||
import { REGION_KEY } from './data/legend-tree.js'
|
||||
import { keyedError } from '@map/i18n/keyedError.js'
|
||||
// A zone's title in the reader's language (tier A: its country; others keep their label) — the same
|
||||
// generator the map's legend and the packet use, so a card, its alt text and its print say one name.
|
||||
import { zoneTitle } from './data/zone-title.js'
|
||||
import { countryName } from '@map/utils/countryName.js'
|
||||
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
|
||||
|
||||
// The table the zone map sheets were generated from (generate.mjs labels each country with it), so a
|
||||
|
|
@ -116,27 +122,9 @@ const REGION_OF = {
|
|||
// 0 cards, with nothing wrong at the source layer.
|
||||
// ★ THE ENGLISH VALUES ABOVE ARE GROUPING KEYS; THIS TRANSLATES AT RENDER (studio's split).
|
||||
// REGION_OF keeps its English values as stable identity — changing them would re-key the
|
||||
// grouping — and the heading a reader sees is resolved here, per locale.
|
||||
//
|
||||
// ⚠ TWO OF THE SEVEN HEADINGS HAVE NO KEY YET: 'India' and 'Other'. check:hardcoded-english
|
||||
// reported five, not seven, and it was honest about why — its header states it CANNOT SEE
|
||||
// SINGLE-WORD STRINGS, since a bare word carries too many non-prose meanings to flag without
|
||||
// drowning the signal. So the spec was built from five and these two are the blind spot
|
||||
// materialising. They fall through to their English text until poster.region_india /
|
||||
// poster.region_other exist; raised with studio rather than coined here.
|
||||
const REGION_KEY = {
|
||||
'Africa': 'region_africa',
|
||||
'Middle East': 'region_middle_east',
|
||||
'Central Asia': 'region_central_asia',
|
||||
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
|
||||
'China + Mongolia': 'region_china_mongolia',
|
||||
'India': 'region_india',
|
||||
'Other': 'region_other',
|
||||
// Two regions the zone map uses had no key, so their headings printed in English in every locale
|
||||
// (single words, which the hardcoded-English gate cannot see by design).
|
||||
'Americas': 'region_americas',
|
||||
'Europe': 'region_europe',
|
||||
}
|
||||
// grouping — and the heading a reader sees is resolved here, per locale, through REGION_KEY
|
||||
// (data/legend-tree.js), the one table the legend's region rows translate through as well. Every
|
||||
// heading has a poster.region_* key in all 11 locales, India and Other included.
|
||||
const regionLabel = (english) => {
|
||||
const key = REGION_KEY[english]
|
||||
return key ? t(`poster.${key}`) : english // no key yet -> the English heading, not a raw key
|
||||
|
|
@ -165,11 +153,17 @@ const regionFor = (id) => {
|
|||
// this list IS, and the old comment was describing the wrong thing.
|
||||
const items = computed(() => {
|
||||
const z = zonesForList(resolvePeopleGroupList(profileConfig))
|
||||
const locale = getRuntimeLocale() || detectLocale()
|
||||
return [
|
||||
...(z.lists || []),
|
||||
...(z.asia_lists || []),
|
||||
...(z.uncovered_lists || []),
|
||||
].filter(l => l && l.id)
|
||||
// ★ THE CARD'S NAME IN THE READER'S LANGUAGE. item.label is the Show-on-map button's accessible
|
||||
// name, every poster's alt text and tooltip, the print HUD's title and the printed documents' title
|
||||
// — all five were English in every locale. zoneTitle declines (null) for a zone it cannot name, and
|
||||
// that zone keeps its hand-written label, exactly as on the legend and the packet.
|
||||
.map(l => { const title = zoneTitle(l, null, { countryName, locale }); return title ? { ...l, label: title } : l })
|
||||
})
|
||||
|
||||
// ─── THE RECORDS PROVIDER — now the library's (lists/peopleGroupRecords.js) ─────────
|
||||
|
|
@ -185,7 +179,7 @@ const recordsFor = createPeopleGroupRecords({
|
|||
evaluate: (selector, records) => evaluateSelector(selector, records),
|
||||
})
|
||||
async function fetchListRecords(list) {
|
||||
if (!API_BASE) throw new Error('no API configured')
|
||||
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured')
|
||||
return recordsFor(list.selector)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,14 @@ import { useI18n } from 'vue-i18n'
|
|||
// is blockable and drops the user out of the map; the iframe opens the print dialog
|
||||
// right here. Shared so the packet, the poster and the card deck all print the same way.
|
||||
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
|
||||
import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR } from './data/legend-tree.js'
|
||||
import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR, REGION_KEY } from './data/legend-tree.js'
|
||||
// Legend rows named in the reader's language: tier-A zones by their country (zone-title.js), the Deaf
|
||||
// row by its bloc name. Both modules are already in this bundle for the packet's localizedZone().
|
||||
import { zoneTitle, countryLabelIndex } from './data/zone-title.js'
|
||||
import { countryName } from '@map/utils/countryName.js'
|
||||
// Errors a reader sees carry their catalogue key; errorText() shows them in the reader's language.
|
||||
// Strings only this map shows live in their own area, upgMap.json, so no other bundle carries them.
|
||||
import { keyedError, errorText } from '@map/i18n/keyedError.js'
|
||||
import { resolveAllZones } from './data/zone-geometry.js'
|
||||
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
|
||||
// holds the region-inherited tapestry (purple ME, red Africa, green
|
||||
|
|
@ -190,7 +197,9 @@ const GEOCODER_SOURCE = 'upg-search'
|
|||
const uiStore = inject('uiStore', null)
|
||||
|
||||
const mapEl = ref(null)
|
||||
const status = ref('loading…')
|
||||
// Set to the translated "Loading data…" as soon as t() exists (below useI18n) — before first render,
|
||||
// so no reader ever sees an English placeholder while the map engine downloads.
|
||||
const status = ref('')
|
||||
// P3-1: the MAP CELL's own error. Distinct from `status` (the small bottom-right
|
||||
// telemetry badge): this is the user-facing "the map is unavailable" notice drawn
|
||||
// INSIDE the map cell, so a tile/token/WebGL failure degrades to a missing map
|
||||
|
|
@ -403,6 +412,25 @@ const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|
|||
// so the dialog opens in this tab rather than a popup a browser may block. The
|
||||
// packet's locale follows the app's, so a reader gets it in their own language.
|
||||
const { locale: packetLocale, t } = useI18n()
|
||||
status.value = t('legend.loadingData')
|
||||
|
||||
// ★ THE LEGEND SPEAKS THE READER'S LANGUAGE. Its rows were clusters.json's English labels and its
|
||||
// section rows the English grouping keys, while the packet printed from the same row was localized
|
||||
// (localizedZone): السودان on paper, 'Sudan' on screen. Region sections go through REGION_KEY (the
|
||||
// table the wall uses too); tier-A zones through zoneTitle, preferring the records' own localized
|
||||
// country_code_label once they are loaded; the Deaf row through its bloc name. A zone the generator
|
||||
// declines (tier B/C) keeps its hand-written label, as on every other surface.
|
||||
// A function, not a const: DEAF_COUNT and packetLang are declared further down and are read per call.
|
||||
function legendOpts(records) {
|
||||
const strings = { zone_affinity_A017: t('poster.zone_deaf') }
|
||||
const byCode = records ? countryLabelIndex(records) : undefined
|
||||
return {
|
||||
deafCount: DEAF_COUNT,
|
||||
...(records ? { records } : {}),
|
||||
title: (zone) => zoneTitle(zone, strings, { byCode, countryName, locale: packetLang() }),
|
||||
regionTitle: (english) => (REGION_KEY[english] ? t(`poster.${REGION_KEY[english]}`) : english),
|
||||
}
|
||||
}
|
||||
// ⚠ IT WAS A HARDCODED ENGLISH LITERAL, and SemanticTreeLegend passes `title` through RAW —
|
||||
// the caller owns localizing it. MEASURED 2026-09-25 on the live Chinese render: the legend
|
||||
// header read "My UPG 100 List — Legend" in full English inside an otherwise localized panel,
|
||||
|
|
@ -1371,6 +1399,8 @@ async function onLegendExport(kind) {
|
|||
// (the people-groups API, the selector evaluator, and the pg_slugs special case
|
||||
// that 12 of the 27 lists need), and where this bundle's posters are served from.
|
||||
const packetPrints = usePosterPrints({
|
||||
// The print HUD's messages in the reader's language (vue-i18n's t takes the count for plurals).
|
||||
t: (key, params, n) => (typeof n === 'number' ? t(key, params, n) : t(key, params)),
|
||||
records: (zone) => packetRecords(zone),
|
||||
lang: () => packetLang(),
|
||||
label: (zone) => zone?.label || zone?.id || '',
|
||||
|
|
@ -1399,13 +1429,20 @@ const packetPrints = usePosterPrints({
|
|||
// that and displays `status` instead. Without this watcher the packet would print
|
||||
// SILENTLY — no "building…", and a failure would vanish with nothing said — which
|
||||
// is the mechanism working while the feedback does not.
|
||||
// ⚠ CLEARED BY IDENTITY, NOT BY READING THE PROSE. This used to clear the status when it matched /packet/i —
|
||||
// true of the English message only. With the messages now translated (the print composable localizes them
|
||||
// through `t`), that test would never match in ten languages and the status would stick. So the line this
|
||||
// watcher wrote is remembered and cleared exactly — the same rule flashRef follows above.
|
||||
let printStatus = ''
|
||||
watch(packetPrints.exportState, (st) => {
|
||||
if (!st) { if (/packet/i.test(status.value)) status.value = ''; return; }
|
||||
if (!st) { if (printStatus && status.value === printStatus) status.value = ''; printStatus = ''; return; }
|
||||
if (st.phase === 'error') {
|
||||
printStatus = '';
|
||||
flashStatus(t('messages.packetFailed', { reason: st.message }), 4000);
|
||||
return;
|
||||
}
|
||||
status.value = st.phase === 'done' ? '' : `${st.message}…`;
|
||||
printStatus = st.phase === 'done' ? '' : `${st.message}…`;
|
||||
status.value = printStatus;
|
||||
});
|
||||
|
||||
// BUNDLE-SPECIFIC: who is in this list. ⚠ pg_slugs BYPASSES the shared evaluator —
|
||||
|
|
@ -1424,7 +1461,7 @@ const recordsFor = createPeopleGroupRecords({
|
|||
evaluate: (selector, records) => evaluateSelector(selector, records),
|
||||
});
|
||||
async function packetRecords(zone) {
|
||||
if (!API_BASE) throw new Error('no API configured');
|
||||
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
|
||||
return recordsFor(zone.selector);
|
||||
}
|
||||
// The zone's sheet, generated at the click, for the legend's Poster and the packet's page 1.
|
||||
|
|
@ -1701,10 +1738,10 @@ async function selectionAsList() {
|
|||
if (!lists.length && !codes.length) {
|
||||
// Name WHY when the only thing selected is a province — "nothing selected" would be a lie the
|
||||
// reader can see is false, with their selection visibly highlighted on the map.
|
||||
if (adm1.length) throw new Error(`no people-group data for ${adm1.join(', ')} — select the country instead`);
|
||||
throw new Error('nothing selected');
|
||||
if (adm1.length) throw keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names} — select the country instead', { names: adm1.join(', ') });
|
||||
throw keyedError('upgMap.nothingSelected', 'nothing is selected');
|
||||
}
|
||||
if (!API_BASE) throw new Error('no API configured');
|
||||
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
|
||||
// ★ primary_language + imb_gsec FEED THE GENERATED PROFILE DESCRIPTION (Driver brief A1.5 col 2,
|
||||
// 2026-09-28). describeGroup builds a localized sentence — who, where, what language, how reached —
|
||||
// and GSEC is the only reachedness field that is a NUMBER: EvangelicalLevel is English prose from the
|
||||
|
|
@ -1722,7 +1759,7 @@ async function selectionAsList() {
|
|||
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
|
||||
{ cache: 'no-store' });
|
||||
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
|
||||
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
|
||||
const { evaluate } = await import('./data/selector-evaluator.js');
|
||||
const slugs = new Set();
|
||||
|
|
@ -1788,7 +1825,7 @@ async function makeSelectionPacket() {
|
|||
funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null),
|
||||
});
|
||||
} catch (e) {
|
||||
flashStatus(t('messages.selectionPacketFailed', { reason: String(e?.message || e).slice(0, 90) }), 4000);
|
||||
flashStatus(t('messages.selectionPacketFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
|
||||
} finally { batchBusy.value = false; busyAction.value = ''; }
|
||||
}
|
||||
|
||||
|
|
@ -1801,7 +1838,7 @@ async function makeSelectionCards() {
|
|||
try {
|
||||
await packetPrints.printCardDeck(await selectionAsList());
|
||||
} catch (e) {
|
||||
flashStatus(e?.message || String(e), 4000);
|
||||
flashStatus(t('upgMap.selectionCardsFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
|
||||
} finally {
|
||||
batchBusy.value = false;
|
||||
busyAction.value = '';
|
||||
|
|
@ -1820,9 +1857,9 @@ async function makeSelectionPoster() {
|
|||
// selection on the map. The packet is the thing that works for a country selection, so point at it.
|
||||
if (!lists.length) {
|
||||
if (selectedCountryCodes().length || selectedAdm1Names().length) {
|
||||
throw new Error('posters exist per territory on the legend — use Prayer list for a map selection');
|
||||
throw keyedError('upgMap.selectionPosterNeedsZone', 'posters exist per territory on the legend — use Prayer list for a map selection');
|
||||
}
|
||||
throw new Error('nothing selected');
|
||||
throw keyedError('upgMap.nothingSelected', 'nothing is selected');
|
||||
}
|
||||
// ⚠ A POSTER IS PRE-RENDERED PER ZONE — there is no artwork for an arbitrary union, and
|
||||
// generating one would be the new pipeline the brief rules out. With one zone selected this
|
||||
|
|
@ -1834,7 +1871,7 @@ async function makeSelectionPoster() {
|
|||
}
|
||||
await packetPrints.printPoster(lists[0]);
|
||||
} catch (e) {
|
||||
flashStatus(t('messages.selectionPosterFailed', { reason: String(e?.message || e).slice(0, 90) }), 4000);
|
||||
flashStatus(t('messages.selectionPosterFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
|
||||
} finally { batchBusy.value = false; busyAction.value = ''; }
|
||||
}
|
||||
|
||||
|
|
@ -2120,7 +2157,7 @@ function bootstrapLegend() {
|
|||
// the two would disagree with nothing reporting it. Found by grepping for `clusters.` rather than by
|
||||
// trusting the convention comment.
|
||||
const z = listZones()
|
||||
const nodes = buildLegendNodes(z, { deafCount: DEAF_COUNT })
|
||||
const nodes = buildLegendNodes(z, legendOpts(null))
|
||||
legendNodes.value = nodes
|
||||
leafZoneList = leafZones(nodes)
|
||||
const byId = {}
|
||||
|
|
@ -2151,7 +2188,7 @@ async function enrichLegendFromRecords() {
|
|||
if (!resp.ok) throw new Error(`list ${resp.status}`)
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
|
||||
countryNameMap = buildCountryNameMap(records)
|
||||
legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records })
|
||||
legendNodes.value = buildLegendNodes(listZones(), legendOpts(records))
|
||||
} catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ }
|
||||
}
|
||||
|
||||
|
|
@ -2373,7 +2410,7 @@ onMounted(async () => {
|
|||
toggleLegendRow: (label) => {
|
||||
const find = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
if (n.label === label) return n
|
||||
if (n.label === label || n.id === label) return n
|
||||
if (n.children && n.children.length) {
|
||||
const hit = find(n.children); if (hit) return hit
|
||||
}
|
||||
|
|
@ -2416,7 +2453,7 @@ onMounted(async () => {
|
|||
zoneKeysForRow: (label) => {
|
||||
const find = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
if (n.label === label) return n
|
||||
if (n.label === label || n.id === label) return n
|
||||
if (n.children && n.children.length) { const h = find(n.children); if (h) return h }
|
||||
}
|
||||
return null
|
||||
|
|
@ -2514,7 +2551,7 @@ onMounted(async () => {
|
|||
|
||||
// Re-shape the legend tree WITH records so every node carries `pop` (slice
|
||||
// 04u §1b). Selectors/colors/counts are unchanged; only `pop` is added.
|
||||
legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records })
|
||||
legendNodes.value = buildLegendNodes(listZones(), legendOpts(records))
|
||||
|
||||
|
||||
// ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ──
|
||||
|
|
@ -2738,7 +2775,10 @@ onMounted(async () => {
|
|||
const resolved = paintable.filter(([, n]) => n >= 1).length
|
||||
status.value = t('messages.mapStatusCounts', { polys: tapestry.features.length, zones: resolved, total: paintable.length, pins: pinFeatures.length })
|
||||
} catch (e) {
|
||||
status.value = '⚠ ' + (e && e.message ? e.message : String(e))
|
||||
// The reader gets a sentence in their language; the cause (a boundary 404, a missing API base —
|
||||
// developer configuration text) goes to the console, where the person who can fix it looks.
|
||||
console.warn('[upg-100-list] map data failed:', e)
|
||||
status.value = '⚠ ' + t('upgMap.mapDataFailed')
|
||||
}
|
||||
|
||||
// R1.3 (j): once the legend tree paints its rows, start the MutationObserver
|
||||
|
|
|
|||
Loading…
Reference in a new issue