doxa-maps/library/composables/usePosterPrints.js
Doxa Maps 03839179b7 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.
2026-09-29 08:33:43 +00:00

412 lines
26 KiB
JavaScript

// usePosterPrints.js — THE print actions every poster wall shares.
//
// ★ WHY THIS EXISTS. The poster wall was generalised into
// library/components/poster/PosterWall.vue and two bundles were migrated onto it —
// but the ORIGINAL, my-upg-100-list/poster-wall.vue (903 lines), was left behind.
// So the one wall carrying the real print actions is the one wall nothing else can
// benefit from, and the shared component's packet path is still a "coming soon"
// stub. Every print fix made in the 100-list is invisible to the three bundles on
// the shared component, and vice versa. That divergence is the bug.
//
// ── THE SEAM ────────────────────────────────────────────────────────────────
// The PRINT ACTIONS are shareable; the RECORD FETCHING is not. fetchListRecords in
// the 100-list knows the people-groups API, the selector evaluator and the pg_slugs
// special case; Key Cities and Country Posters have entirely different data shapes.
// So this composable takes a `records(item)` PROVIDER and owns everything after it:
// the HUD state machine, the in-place print frame, and the three documents.
//
// That is also what keeps the bundles honest — a wall that cannot supply records
// simply does not pass a provider, and the actions that need them stay hidden
// rather than failing at click time.
//
// ⚠ PRINTS IN PLACE, never window.open(). A popup is blockable and drops the reader
// out of the map; the shared openPrintFrame mounts a hidden same-page iframe. This
// is the mechanism asked for by name: no new browser window, the print dialog
// opening directly in the page.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
/**
* THE PRINT STANDARD — one call gives any map the poster, the card deck, the prayer list and the
* prayer packet (full or lightweight) for ANY item: a zone, a country, or a custom list of people.
* An item is `{ id, label }`; everything specific to the host arrives through the options below,
* and every one except `records` is optional (an absent option drops only what depends on it).
*
* @param {object} opts
* records async (item) => rows[] — REQUIRED for the list, deck and packet. A row is one people
* group: { name, slug, population, religion, country_code (ISO3),
* image_url }. `slug` is the people-groups slug the evangelical
* and near-neighbour tables join on.
* lang () => string — the app locale; every print follows it.
* label (item) => string
* stock () => string — card stock id (see card-render.js STOCK).
* state ref — the host's HUD state ref, when it renders its own HUD.
* strings () => Promise<object> — the packet/card catalog; defaults to packet-strings.json.
* sheet async (item, format) => html — a map SHEET the host generates now (posterSheets.js
* generateSheet): 'letter' for the poster print (a vector page),
* 'packet' for the packet's page 1. Tried FIRST by both.
* PAGE 1'S MAP, first that yields wins (after `sheet`):
* mapImage async (item) => url — a map the host GENERATES (a live map's raster / data: URL).
* assetsBase () => string — where a STORED sheet lives (posters/<id>.html), lifted.
* iso3Names () => {ISO3: name} — the table a stored sheet was labelled from, so its country
* names print in the packet's language.
* DATA PAGES, each omitted when its source is absent:
* christian () => {pctEvangelical…} — overrides the per-list evangelical share computed from rows.
* sets () => near-neighbour table — the bounded/centered sets page (full packet only).
* posterUrl (item) => string|null — a pre-rendered poster image, when one exists.
*/
export function usePosterPrints(opts = {}) {
// ⚠ ACCEPT the caller's state ref when it has one. A wall that renders its OWN
// exportState while these actions write to a private ref shows no progress and
// swallows errors — the mechanism works and the feedback does not. Owning a ref
// here is only the standalone default.
const exportState = opts.state || ref(null);
const busy = () => exportState.value !== null;
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', tr('wall.hud.printOpened', 'print dialog opened'), 100);
setTimeout(() => { exportState.value = null; }, 900);
};
const fail = (item, kind, e) => {
// 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; }
async function rows(item) {
if (typeof opts.records !== 'function') {
throw new Error('this wall supplies no records provider');
}
const r = await opts.records(item);
if (!r || !r.length) throw keyedError('wall.hud.noRecords', 'that list has no people groups to print');
return r;
}
// ── 1. THE POSTER ─────────────────────────────────────────────────────────
// Prefers the pre-rendered image; falls back to rasterising the poster's own
// HTML in the browser, which is what lets the repo stop shipping 41 MB of webp.
// ★ THIS IS THE WALL'S posterAction='print' PATH, PROMOTED. It was a private function in
// PosterWall.vue, which meant the MAP LEGEND's own "print this list's poster" had no way to
// reach it — and the legend had grown a fourth implementation instead: a hand-rolled
// `<a download>` of the .webp. That was wrong twice over. It silently downloads a file,
// which is ruled out explicitly (no silent file download as the default action), and it has
// NO fallback, so on the 27 lists whose .webp does not ship it simply
// saved a 404.
//
// Two guards below were each learned the hard way and MUST survive any future move:
// 1. a THROWN fetch is a MISS, not an error (blocked/offline/refused reject rather than
// returning !ok). Letting it escape skips the rasterise fallback entirely — measured
// during the wall migration, when check-poster-print-fallback went PASS -> FAIL with 0
// .html requests.
// 2. a blank raster must fail LOUDLY. An SVG inside an <img> is an isolated document, so a
// missing stylesheet paints every fill black with no error at all.
//
// `assetsBase` is the seam: the wall and the map profile resolve their own base, and this
// stays ignorant of where a bundle serves its posters from.
async function printPoster(item) {
if (busy()) return;
const kind = tr('wall.hud.kindPoster', 'Poster');
const base = opts.assetsBase ? opts.assetsBase() : null;
const id = item?.id ?? item;
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', 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', tr('wall.hud.openingPrint', 'opening the print dialog'), 80);
openPrintFrame(html, { autoprint: true });
done(item, kind);
return;
}
}
// An explicit posterUrl still wins — it is how a caller points at a poster that is not
// <base>/posters/<id>.webp at all.
const direct = opts.posterUrl ? opts.posterUrl(item) : null;
const webp = direct || (base ? `${base}/posters/${id}.webp` : null);
// 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 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', tr('wall.hud.generatingPoster', 'generating the poster'), 45);
const source = await fetchPosterSource(`${base}/posters/${id}.html`);
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 keyedError('wall.hud.posterBlank', 'the generated poster came out blank');
await deliverPoster(blob, item, kind);
} catch (e) { fail(item, kind, e); }
}
// A 3ft SQUARE on a PORTRAIT sheet must fit by its SHORTER axis: scaling to the full page
// WIDTH makes it taller than the printable height and it overflows onto a second page.
// Constrain both axes and let object-fit letterbox it.
const posterObjectUrls = [];
async function deliverPoster(blob, item, kind) {
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)}`;
openPrintFrame(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title>'
+ '<style>@page{size:letter portrait;margin:12mm}'
+ 'html,body{margin:0;height:100%;background:#fff}'
+ 'body{display:flex;align-items:center;justify-content:center}'
+ 'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}'
+ '@media print{body{height:auto}'
+ 'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>'
+ `<body><img src="${url}" alt="${esc(label(item))}"></body></html>`,
{ autoprint: true },
);
done(item, kind);
}
// ── 2. THE LIST ───────────────────────────────────────────────────────────
// The FIRST option, and what the whole app is about: the list itself as a
// print-paginated table with a prayer checkbox per row.
async function printList(item, { columns, title } = {}) {
if (busy()) return;
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 || [
{ key: 'name', head: 'Name', cls: 'c-name' },
{ key: 'country_code_label', head: 'Country' },
{ key: 'population', head: 'People', cls: 'c-num', num: true },
{ key: 'religion_label', head: 'Religion' },
];
const head = cols.map((c) => `<th>${esc(c.head)}</th>`).join('') + '<th class="c-pray">✓</th>';
const body = data.map((r) => '<tr>' + cols.map((c) => {
const raw = r[c.key] ?? r[`${c.key}_label`] ?? '—';
const v = c.num && Number.isFinite(Number(raw))
? Number(raw).toLocaleString(lang()) : raw;
return `<td${c.cls ? ` class="${c.cls}"` : ''}>${esc(v)}</td>`;
}).join('') + '<td class="c-pray">□</td></tr>').join('');
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>`
+ '<style>'
+ '@page{size:letter portrait;margin:14mm 12mm}'
+ 'html,body{margin:0;background:#fff;color:#0f1419;'
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}"
+ '.hd{margin-bottom:14px}.hd h1{margin:0 0 3px;font-size:18px}'
+ '.hd p{margin:0;font-size:11px;color:#5b6672}'
+ 'table{width:100%;border-collapse:collapse;font-size:11px}'
+ 'thead th{text-align:left;border-bottom:2px solid #0f1419;padding:5px 6px;'
+ 'font-size:9.5px;text-transform:uppercase;letter-spacing:.05em}'
+ 'tbody td{padding:5px 6px;border-bottom:1px solid #e3e6ea}'
+ '.c-name{font-weight:600}.c-num{font-variant-numeric:tabular-nums}'
+ '.c-pray{width:26px;text-align:center;font-size:13px}'
+ 'tbody tr{break-inside:avoid}'
+ '</style></head><body>'
+ `<div class="hd"><h1>${esc(docTitle)}</h1>`
+ `<p>${data.length} entries · pray for them by name</p></div>`
+ `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
+ '</body></html>',
{ autoprint: true },
);
done(item, kind);
} catch (e) { fail(item, kind, e); }
}
// ── 3. THE CARD DECK ──────────────────────────────────────────────────────
// One card per PAGE at the chosen stock, so the print dialog shows CARDS rather
// than letter paper. The geometry lives in the shared card renderer.
async function printCardDeck(item) {
if (busy()) return;
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', 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);
} catch (e) { fail(item, kind, e); }
}
// ── 4. THE PRAYER PACKET ──────────────────────────────────────────────────
// The full booklet: map one-pager, people groups 20-per-page with photos, the 12
// prayer points, the prayer wheel. Shares the renderer with the build-time
// generator (library/packet/packet-render.js), so a printed packet and a
// generated one cannot diverge.
//
// The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because
// only the bundle knows where its posters are served from. If it cannot be
// fetched the packet still prints — a cover without the map beats no packet.
// `funnel` is the page-2 narrowing data (see packet-render.funnelPage). It is passed THROUGH
// untouched: this composable does not know how a host defines its lists, and deriving the counts
// here would make a second source of truth for numbers the Vision Card already publishes.
// `variant` selects WHICH packet ('full' | 'lightweight' — packet-render's VARIANTS). It defaults
// to 'full', so every existing caller keeps printing exactly what it prints today.
// The poster's <svg>, lifted into the packet's map page. Exported-shape helper so the lift is
// unit-testable against a real generated poster without a fetch.
// ★ DELEGATES TO THE RENDERER. This used to carry its own copy of the page-1 wrapper (250px down,
// scale 0.62), one of three identical copies; packet-render.js liftMap is now the only one.
function liftPosterSvg(html, R, labelFor = null) {
return R.liftMap(html, { labelFor });
}
// The map's country names in the packet's language (packet/mapLabels.js). The sheet is drawn in
// English from the generator's ISO3 -> name table, and that table is PLUGIN data this library cannot
// import, so the host supplies it (opts.iso3Names, a loader). Without it, or in English, or on any
// failure: null, and the sheet prints exactly as drawn. The wall path had no such seam, so a zh
// packet printed from the wall kept INDIA, CHINA … on page 1 while the list's own path relabelled.
async function mapLabelFor() {
if (!opts.iso3Names) return null;
try {
const [iso3Name, { makeCountryLabelFor }, countryName] = await Promise.all([
Promise.resolve().then(() => opts.iso3Names()),
import('../packet/mapLabels.js'),
import('../utils/countryName.js').then((m) => m.countryName || m.default),
]);
return makeCountryLabelFor({ iso3Name, countryName, lang: lang() });
} catch (_) { return null; }
}
// The first page-1 map sheet this bundle serves, in the renderer's preference order (the map-only
// vertical sheet, then the poster). '' when none is reachable — the packet still prints, and page 1
// says the map is missing.
async function fetchMapSheet(base, id, R) {
for (const file of R.mapSheetFiles(id)) {
try {
const res = await fetch(`${base}/posters/${file}`, { cache: 'force-cache' });
if (!res.ok) continue;
const html = await res.text();
// A 200 that is not a map sheet (an SPA fallback page) lifts to '' — keep looking.
if (R.liftMap(html)) return html;
} catch (_) { /* try the next file */ }
}
return '';
}
async function printPacket(item, { mapSvg = '', funnel = null, variant = 'full' } = {}) {
if (busy()) return;
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
// own groups, summed from the per-group table (evangelicalShare.js), because the page says each
// box is a share of the people in this list. It used to be one DOXA-wide figure in every packet.
// Same seam as strings: a host may still supply its own figure (opts.christian), which wins.
// `nearTable` feeds the FULL packet's bounded/centered sets page. Same seam as strings and
// christian: a host that can reach the served asset pool supplies a loader (opts.sets), and a
// host that cannot simply prints a packet without that page — setsPage's ''-without-data rule.
// .catch(() => null): the sets page is the one page whose absence must never block a print.
const [{ createPacketRenderer }, { buildPacketSets }, { evangelicalShareFor }, strings, hostChristian, evTable, nearTable] = await Promise.all([
import('../packet/packet-render.js'),
import('../packet/packetSets.js'),
import('../packet/evangelicalShare.js'),
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
opts.christian ? opts.christian() : Promise.resolve(null),
opts.christian ? Promise.resolve(null)
: import('../../data/people-groups/evangelical-by-slug.json').then((m) => m.default || m).catch(() => null),
opts.sets ? Promise.resolve().then(() => opts.sets()).catch(() => null) : Promise.resolve(null),
]);
// No table and no host figure: null, and the page is omitted — never a DOXA-wide stand-in.
const christian = hostChristian || evangelicalShareFor(data, evTable);
// ★ THE DESCRIBER IS COMPOSED HERE AND INJECTED, because this is the BROWSER path and only it
// can reach the Vite-only pieces. packet-render.js runs under Node too (the offline generator),
// where importing describeGroup's country resolver would be fatal — it resolves through the
// `@map/` alias and imports JSON without an import attribute. So the renderer takes `describe`
// the same way it takes `strings`, and each host supplies the one it can build.
// ⚠ Loaded with `.catch(() => null)` rather than awaited unconditionally: a packet that cannot
// resolve its describer must still PRINT, degrading to the metadata line. The description is
// the widest column on the page, but it is not worth failing the whole packet for.
const describe = await Promise.all([
import('../packets/describeGroup.js').then((m) => m.describeGroup).catch(() => null),
import('../utils/countryName.js').then((m) => m.countryName || m.default).catch(() => null),
]).then(([describeGroup, countryName]) => (describeGroup
? (g) => describeGroup(g, lang(), { strings, countryName: countryName || undefined })
: null));
const R = createPacketRenderer({ strings, lang: lang(), describe });
// Photos stay as URLs. The Node generator base64-inlines them so its file is
// self-contained offline; in a browser the print frame fetches them, and
// inlining ~95 images client-side would stall the tab for no gain.
const photos = new Map(
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
);
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
// of its five card pages (no map, no triangle) because only the legend path supplied them.
// A capability behind an argument nobody passes is a feature that never runs.
// The funnel default states ONLY the steps: listCount/unassigned stay absent, so funnelPage
// omits the share line rather than printing a denominator that is wrong under ?list=…
// (the measured off-by-30 class). Half a fraction is not a fact.
if (!funnel) {
funnel = await import('../../data/people-groups/vision-funnel.json')
.then((m) => { const raw = m.default || m; return raw && Array.isArray(raw.steps) && raw.steps.length ? { steps: raw.steps } : null })
.catch(() => null);
}
// PAGE 1'S MAP, in order: the caller's explicit mapSvg; then a map the HOST GENERATES
// (opts.mapImage(item) -> an image URL / data: URL of its live map, e.g. a country map that has
// no stored sheet); then this bundle's own stored sheet. A failed or empty mapImage falls through
// to the sheet, and a packet with neither says so on page 1 rather than printing it blank.
// A sheet the host GENERATES now (opts.sheet, the map-only 'packet' format) comes before any
// image or stored sheet, and its drawn country names relabel like a stored sheet's.
// `item.mapId` names the sheet when it is not the item's own id (a custom list built from one
// zone prints that zone's map); `mapId: null` says the item has no sheet (a multi-zone selection),
// so nothing is generated or fetched and page 1 says the map is missing.
const mapId = item && 'mapId' in item ? item.mapId : item?.id;
if (!mapSvg && opts.sheet && mapId) {
const target = mapId === item?.id ? item : { ...item, id: mapId };
const html = await Promise.resolve().then(() => opts.sheet(target, 'packet')).catch(() => '');
if (html) mapSvg = R.liftMap(html, { labelFor: await mapLabelFor() });
}
if (!mapSvg && opts.mapImage) {
const src = await Promise.resolve().then(() => opts.mapImage(item)).catch(() => null);
mapSvg = src ? R.mapFromImage(src) : '';
}
if (!mapSvg && opts.assetsBase && mapId) {
const [sheet, labelFor] = await Promise.all([fetchMapSheet(opts.assetsBase(), mapId, R), mapLabelFor()]);
mapSvg = liftPosterSvg(sheet, R, labelFor);
}
const sets = nearTable ? buildPacketSets(data, nearTable) : null;
openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel, christian, variant, sets), { autoprint: true });
done(item, kind);
} catch (e) { fail(item, kind, e); }
}
return { exportState, dismiss, printPoster, printList, printCardDeck, printPacket };
}