Every poster, prayer list and card deck printed from a hidden iframe (openPrintFrame), and phone browsers do not honour a print() inside a subframe: no print dialog ever opened on a phone. printDocument() keeps that iframe path unchanged on desktop; on a phone or tablet it mounts the document into the page under a print-only stylesheet that hides everything else, and calls the page's own window.print(). When preparing the document outlasted the tap's activation, the wall's progress dialog shows a Print button so the print comes from a fresh tap. The poster wall (Regions, UPG-100, Country Posters) is the first user.
431 lines
27 KiB
JavaScript
431 lines
27 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 { retryFetch } from './retryFetch.js'
|
|
import { ref } from 'vue';
|
|
import { printDocument } from './printInPlace.js';
|
|
import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js';
|
|
import { keyedError, errorText } from '../i18n/keyedError.js';
|
|
// The packet/card text, fetched from the served tree at print time (English bundled as the fallback).
|
|
import { loadPacketStrings } from '../packet/packetStrings.js';
|
|
|
|
const esc = (s) => String(s ?? '')
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
/**
|
|
* 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 the served, pinned
|
|
* packet-strings.json (packet/packetStrings.js, English fallback).
|
|
* 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() {
|
|
const s = exportState.value;
|
|
exportState.value = null;
|
|
try { if (s && typeof s.cancel === 'function') s.cancel(); } catch (_) { /* already gone */ }
|
|
}
|
|
|
|
// ★ EVERY DOCUMENT THIS COMPOSABLE PRINTS LEAVES THROUGH HERE (poster, list, card deck, packet). printDocument
|
|
// opens the in-place frame exactly as before, except on a phone, where it prints the PAGE (printInPlace.js says
|
|
// why). A phone whose tap has expired by the time the document is ready gets phase 'ready' with an `action` for a
|
|
// Print button, when the host renders one (opts.tapToPrint); `cancel` removes the document if the reader leaves.
|
|
async function deliver(html, item, kind) {
|
|
const how = await printDocument(html, {
|
|
onNeedsTap: opts.tapToPrint ? (printNow, cancel) => {
|
|
exportState.value = {
|
|
label: label(item), kind, phase: 'ready', message: tr('wall.hud.readyToPrint', 'ready to print'), pct: 100,
|
|
action: () => { exportState.value = null; printNow(); }, cancel,
|
|
};
|
|
} : null,
|
|
});
|
|
if (how !== 'needs-tap') done(item, kind);
|
|
}
|
|
|
|
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);
|
|
await deliver(html, 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)}`;
|
|
await deliver(
|
|
'<!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>`,
|
|
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}`;
|
|
await deliver(
|
|
`<!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>',
|
|
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() : loadPacketStrings(opts.assetsBase ? opts.assetsBase() : null),
|
|
]);
|
|
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() });
|
|
await deliver(R.buildDeck(item, data), 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() : loadPacketStrings(opts.assetsBase ? opts.assetsBase() : null),
|
|
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;
|
|
await deliver(R.buildPacket(item, data, photos, mapSvg, funnel, christian, variant, sets), item, kind);
|
|
} catch (e) { fail(item, kind, e); }
|
|
}
|
|
|
|
return { exportState, dismiss, printPoster, printList, printCardDeck, printPacket };
|
|
}
|