feat(upg-100): print a prayer packet from the map legend
Some checks are pending
build-gate / build (push) Waiting to run

Driver, as a first-time user: "I select one and it gives me print options at
the top right of the legend... that's the only way that map would be usable."

The affordance was already built and switched OFF. The shared legend emits
'export' with 'poster' | 'packet' for the selected node, the profile wired
@export, and SHOW_EXPORT_BUTTON was held false with the note "until the export
packet is finished: the button currently produces a broken packet". That
precondition is now met, so the switch is on.

ONE RENDERER, TWO CALLERS. The pages move to library/packet/packet-render.js,
imported by BOTH the build-time generator and the browser export, so the printed
packet and the generated one cannot drift. The card asked for exactly this:
"reuse the existing print actions rather than growing a fourth implementation."
The extraction is behaviour-preserving — the generated packet is byte-identical
to before apart from leading whitespace (the code is now inside a factory), and
check:packet-render still passes 8/8.

★ THE PACKET IS GENERATED ON CLICK, NEVER SHIPPED. One packet is ~3.5 MB (photos
dominate) and 28 zones would add ~100 MB to a repo that ALREADY had a publish
blocked at a 74 MB asset tree. Generating client-side costs zero repository
bytes; the photos come from URLs the records already carry. This is the Driver's
instruction: "instead of fetching the poster from the public folder I want the
client side code to generate the poster real time when you click on the button
so it does not take a lot of mb storage." Bundle cost: +21 KB (494 -> 516 KB),
which includes all 12 prayer points and Mark 5:1-20 in full.

Printing goes through the SHARED openPrintFrame — a hidden same-page iframe, not
window.open(). A popup is blockable and drops the reader out of the map.

Two traps the browser path had to handle, both already known to this codebase:
  - pg_slugs BYPASSES the shared selector evaluator (it intentionally throws on
    that type, and 12 of the 27 lists use it). Same special-case the map and the
    poster wall apply, so all three agree about who is in a list.
  - evaluate() returns a Set, not an array.

VERIFIED IN A BROWSER, not by reading source (new gate check:packet-browser):
live records -> shared renderer -> page.pdf(), giving 8 sheets -> 8 pages at
612x792pt, 95 people cards, 12 wheel segments, Mark 5 in full, 2 metacamp.org
attributions, 0 page errors. I18n verified with a partial Spanish block: the
translated headings rendered in Spanish while Mark 5 and the attributions fell
back to English, per key, never blank.

Card BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST part (d). Parts (a) and
(b) shipped earlier. Part (c) — removing the People Group tab and retabbing the
legend — stays OPEN: that card is explicitly driver_gated on "what is the
legend's tree once Region/Zone/People Group are removed", and the current tabs
are wired to real hierarchy data.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 02:43:38 +00:00
parent 2a0976f834
commit 301cc38fba
5 changed files with 537 additions and 277 deletions

View file

@ -174,9 +174,17 @@ const { scrimRef, scrimStyle } = useViewportScrim(exportMenuOpen)
// export path is functional (the menu emits 'export' with 'poster' | 'packet',
// and a profile wires @export to a real download).
// A profile STILL has to opt in with :export-enabled="true" — this flag only
// stops it being force-hidden. OFF until the export packet is finished: the
// button currently produces a broken packet, so no bundle shows it.
const SHOW_EXPORT_BUTTON = false
// stops it being force-hidden.
//
// ON since 2026-09-24. It was held OFF with the note "until the export packet is
// finished: the button currently produces a broken packet" — that precondition is
// now met. The packet renderer (library/packet/packet-render.js) is shared by the
// build-time generator and the browser export, so both produce the same document,
// and it is gated at the rendered layer by check:packet-render (8 sheets -> 8
// letter-portrait pages, map painted, Mark 5 in full, 12 wheel segments).
// The packet is generated ON CLICK, never shipped: one is ~3.5 MB and 28 zones
// would add ~100 MB to a repo that already had a publish blocked at 74 MB.
const SHOW_EXPORT_BUTTON = true
const sortBy = ref(null) // null = default (pop desc), 'count' | 'pop'
const sortDir = ref('desc') // 'desc' | 'asc'

View file

@ -0,0 +1,276 @@
// packet-render.js — THE prayer packet renderer, shared by both callers.
//
// ★ ONE RENDERER, TWO CALLERS. The packet is produced in two places:
// - library/scripts/posters/packet.mjs (build time, Node, writes files)
// - the UPG-100 legend's Export > Prayer packet action (click time, browser)
// Both import THIS module, so a change to a page lands in both. The backlog card
// asked for exactly this: "reuse the existing print actions rather than growing a
// fourth implementation."
//
// It is pure: no fs, no process, no DOM. Everything it needs is passed in —
// strings, language, the zone, its groups, photos, and the map SVG. That is what
// lets the same code run under Node and in a browser tab.
//
// PAGE ORDER IS THE PRODUCT SPEC, do not reorder:
// 1 the map one-pager
// 2..n the people groups, 20 per page, with photos
// n+1 the 12 prayer points
// n+2 the prayer wheel
//
// I18N: every human-readable string comes from packet-strings.json. A new language
// is a NEW BLOCK there, never a code change, and fallback is PER KEY so a
// half-finished translation renders its finished keys instead of blanking.
export function createPacketRenderer({ strings, lang = 'en' }) {
const EN = strings.en;
const L = strings[lang] || {};
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythms[k]);
const POINTS = L.points || EN.points;
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
const PAGE_W_IN = 8.5;
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
const M = 60, HEAD = 90; // margins + running header
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
const GUT = 14;
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
const PHOTO = Math.min(CH - 8, CW * 0.34);
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// ⚠ `lang` (the factory parameter), never a module-level LANG — and never
// process.env.LANG, which is the SHELL's POSIX locale ("en_US.UTF-8"): not a valid
// BCP-47 tag, and it throws inside toLocaleString.
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(lang) : '—');
const sheet = (inner, cls = '') =>
`<div class="sheet ${cls}">${inner}</div>`;
function css() {
return `
@page{size:letter portrait;margin:0}
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
.card{position:absolute;display:flex;gap:12px;align-items:center;
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
.card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px}
.card img{object-fit:cover}
.card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center;
justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px}
.nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1}
.mt{font:400 17px Georgia,serif;color:#6d766e}
/* FINE PRINT — Driver: "attribution minimal, fine print small". */
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
font:400 13px Georgia,serif;color:#a89f8c}
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
completely differently under pagination:
transform is paint-time — the element still RESERVES its full ${H}px layout
box, so the paginator walks ${H}px of content and breaks it across
two sheets, inking only the top of the first. Measured: 8 sheets
came out as 16 PDF pages. Negative margins do NOT fix this — they
collapse the outer space, not the fragmentation box.
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
@media print{
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
}`;
}
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
// dropping only the rules that would fight the packet's own page shell.
//
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
// prose that contains braces and selector-like text, so any brace-counting or
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
function mapCss(posterHtml) {
const block = posterHtml.match(/<style>([\s\S]*?)<\/style>/);
if (!block) return '';
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
// Drop @media (the poster's own print-scaling rules, which do not apply here).
// Depth-aware because @media bodies nest a further level of braces.
let out = '', depth = 0, i = 0;
while (i < css.length) {
if (css.startsWith('@media', i)) {
let d = 0, started = false;
while (i < css.length) {
if (css[i] === '{') { d++; started = true; }
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
i++;
}
continue;
}
out += css[i++];
}
// Drop document-level rules: these style the POSTER page, not the map, and would
// override the packet's background, box-sizing and stage geometry.
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
return out.split('}')
.map((r) => {
const at = r.indexOf('{');
if (at < 0) return '';
const sel = r.slice(0, at).trim();
if (!sel) return '';
// :root must be KEPT — it carries the palette the map's var()s resolve against.
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
return `${sel}{${r.slice(at + 1).trim()}}`;
})
.filter(Boolean).join('\n');
}
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
// the geography with all the pins", explicitly NOT the poster. The poster remains
// its own artefact (generate.mjs); this is the packet's cover.
function mapPage(zone, groups, mapSvg) {
const count = groups.length;
return sheet(`
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
</div>
${mapSvg}
<div class="fine">${esc(t('privacy_note'))}</div>`);
}
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
function listPages(zone, groups, photos) {
const pages = [];
for (let start = 0; start < groups.length; start += PER_PAGE) {
const slice = groups.slice(start, start + PER_PAGE);
const cards = slice.map((g, k) => {
const i = start + k;
const c = k % COLS, r = Math.floor(k / COLS);
const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT);
const src = photos.get(g.slug);
const img = src
? `<img src="${src}" alt="">`
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
<div style="min-width:0">
<div class="nm">${i + 1}. ${esc(g.name)}</div>
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
</div></div>`;
}).join('');
pages.push(sheet(`
<div class="hd">${esc(zone.label || zone.id)}</div>
<div class="hd-r">${esc(fill(t('list_header'),
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
${cards}`));
}
return pages;
}
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
function pointsPage() {
const rows = POINTS.map((p) => {
const refs = (p.refs || []).map((r) => esc(r)).join(' · ');
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
const passage = p.print_passage
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
// scripture, because prayer point number four is actually read the word."
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
${esc(t('point4_passage_credit'))}</div></div>`
: '';
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
<div style="font:700 19px Georgia,serif;color:#1b1a17">
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
${passage}
</div>`;
}).join('');
return sheet(`
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
</div>
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
<div class="fine">${esc(t('points_attrib'))}</div>`);
}
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
// be translated, and this packet must generate in any language. Twelve equal
// segments, the rhythm name set along each, 5 minutes each = one hour.
function wheelPage() {
const cx = W / 2, cy = 1090, R = 470;
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
const seg = order.map((key, i) => {
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
const hue = Math.round(i * 30);
const mid = (a0 + a1) / 2;
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
let deg = mid * 180 / Math.PI;
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
text-anchor="middle" dominant-baseline="middle"
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
}).join('');
const pips = order.map((_, i) => {
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
}).join('');
return sheet(`
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
</div>
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
${seg}${pips}
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
</svg>
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
}
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
// Assemble the whole document. photos is a Map(slug -> dataURL-or-URL); mapSvg is
// the poster's <svg> plus the stylesheet its var()s resolve against (see mapCss).
function buildPacket(zone, groups, photos, mapSvg) {
const pages = [
mapPage(zone, groups, mapSvg),
...listPages(zone, groups, photos),
pointsPage(),
wheelPage(),
];
return `<!DOCTYPE html><html lang="${esc(lang)}"><head><meta charset="utf-8">
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
<body>${pages.join('\n')}</body></html>`;
}
return { buildPacket, mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
}

View file

@ -68,22 +68,14 @@ const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythm
const POINTS = L.points || EN.points;
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
const PAGE_W_IN = 8.5;
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
const M = 60, HEAD = 90; // margins + running header
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
const GUT = 14;
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
const PHOTO = Math.min(CH - 8, CW * 0.34);
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// The API returns some fields as { value, label } dicts; the card wants the label.
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(LANG) : '—');
// ── THE RENDERER IS SHARED ──────────────────────────────────────────────────
// The pages themselves live in library/packet/packet-render.js so that the browser
// (the UPG-100 legend's "Prayer packet" export) and this build-time script render
// the SAME packet from the SAME code. Changing a page here changes it in both.
const { createPacketRenderer } = await import(
join(__dirname, '..', '..', 'packet', 'packet-render.js'));
const R = createPacketRenderer({ strings: STRINGS, lang: LANG });
const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H } = R;
// ── DATA ────────────────────────────────────────────────────────────────────
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
@ -135,229 +127,18 @@ async function fetchPhotos(groups, limit = 16) {
}
// ── PAGE SHELL ──────────────────────────────────────────────────────────────
const sheet = (inner, cls = '') =>
`<div class="sheet ${cls}">${inner}</div>`;
function css() {
return `
@page{size:letter portrait;margin:0}
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
.card{position:absolute;display:flex;gap:12px;align-items:center;
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
.card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px}
.card img{object-fit:cover}
.card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center;
justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px}
.nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1}
.mt{font:400 17px Georgia,serif;color:#6d766e}
/* FINE PRINT — Driver: "attribution minimal, fine print small". */
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
font:400 13px Georgia,serif;color:#a89f8c}
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
completely differently under pagination:
transform is paint-time — the element still RESERVES its full ${H}px layout
box, so the paginator walks ${H}px of content and breaks it across
two sheets, inking only the top of the first. Measured: 8 sheets
came out as 16 PDF pages. Negative margins do NOT fix this — they
collapse the outer space, not the fragmentation box.
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
@media print{
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
}`;
}
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
// dropping only the rules that would fight the packet's own page shell.
//
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
// prose that contains braces and selector-like text, so any brace-counting or
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
function mapCss(posterHtml) {
const block = posterHtml.match(/<style>([\s\S]*?)<\/style>/);
if (!block) return '';
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
// Drop @media (the poster's own print-scaling rules, which do not apply here).
// Depth-aware because @media bodies nest a further level of braces.
let out = '', depth = 0, i = 0;
while (i < css.length) {
if (css.startsWith('@media', i)) {
let d = 0, started = false;
while (i < css.length) {
if (css[i] === '{') { d++; started = true; }
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
i++;
}
continue;
}
out += css[i++];
}
// Drop document-level rules: these style the POSTER page, not the map, and would
// override the packet's background, box-sizing and stage geometry.
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
return out.split('}')
.map((r) => {
const at = r.indexOf('{');
if (at < 0) return '';
const sel = r.slice(0, at).trim();
if (!sel) return '';
// :root must be KEPT — it carries the palette the map's var()s resolve against.
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
return `${sel}{${r.slice(at + 1).trim()}}`;
})
.filter(Boolean).join('\n');
}
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
// the geography with all the pins", explicitly NOT the poster. The poster remains
// its own artefact (generate.mjs); this is the packet's cover.
function mapPage(zone, groups, mapSvg) {
const count = groups.length;
return sheet(`
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
</div>
${mapSvg}
<div class="fine">${esc(t('privacy_note'))}</div>`);
}
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
function listPages(zone, groups, photos) {
const pages = [];
for (let start = 0; start < groups.length; start += PER_PAGE) {
const slice = groups.slice(start, start + PER_PAGE);
const cards = slice.map((g, k) => {
const i = start + k;
const c = k % COLS, r = Math.floor(k / COLS);
const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT);
const src = photos.get(g.slug);
const img = src
? `<img src="${src}" alt="">`
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
<div style="min-width:0">
<div class="nm">${i + 1}. ${esc(g.name)}</div>
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
</div></div>`;
}).join('');
pages.push(sheet(`
<div class="hd">${esc(zone.label || zone.id)}</div>
<div class="hd-r">${esc(fill(t('list_header'),
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
${cards}`));
}
return pages;
}
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
function pointsPage() {
const rows = POINTS.map((p) => {
const refs = (p.refs || []).map((r) => esc(r)).join(' · ');
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
const passage = p.print_passage
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
// scripture, because prayer point number four is actually read the word."
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
${esc(t('point4_passage_credit'))}</div></div>`
: '';
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
<div style="font:700 19px Georgia,serif;color:#1b1a17">
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
${passage}
</div>`;
}).join('');
return sheet(`
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
</div>
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
<div class="fine">${esc(t('points_attrib'))}</div>`);
}
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
// be translated, and this packet must generate in any language. Twelve equal
// segments, the rhythm name set along each, 5 minutes each = one hour.
function wheelPage() {
const cx = W / 2, cy = 1090, R = 470;
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
const seg = order.map((key, i) => {
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
const hue = Math.round(i * 30);
const mid = (a0 + a1) / 2;
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
let deg = mid * 180 / Math.PI;
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
text-anchor="middle" dominant-baseline="middle"
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
}).join('');
const pips = order.map((_, i) => {
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
}).join('');
return sheet(`
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
</div>
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
${seg}${pips}
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
</svg>
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
}
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
// Photo fetching is the only part that differs between the two callers (Node
// inlines base64 so the file is self-contained and printable offline; the browser
// hands the renderer plain URLs the page already has). Everything downstream is
// the shared renderer, so the two packets cannot drift.
async function buildPacket(zone, groups, mapSvg) {
const photos = await fetchPhotos(groups);
const pages = [
mapPage(zone, groups, mapSvg),
...listPages(zone, groups, photos),
pointsPage(),
wheelPage(),
];
const html = R.buildPacket(zone, groups, photos, mapSvg);
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
const html = `<!DOCTYPE html><html lang="${esc(LANG)}"><head><meta charset="utf-8">
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
<body>${pages.join('\n')}</body></html>`;
return { html, pageCount: pages.length, withPhoto };
// 1 map + N list pages + points + wheel — asked of the renderer, not recomputed
// here, so this count cannot disagree with what was actually emitted.
const pageCount = 1 + Math.ceil(groups.length / R.PER_PAGE) + 2;
return { html, pageCount, withPhoto };
}
// ── MAIN ────────────────────────────────────────────────────────────────────

View file

@ -74,6 +74,11 @@ import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vu
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists }
import { normalizeRecords, extractRecords } from './data/normalize.js'
import { useI18n } from 'vue-i18n'
// The in-place print helper: a hidden same-page iframe, NOT window.open(). A popup
// 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 { openPrintFrame } from '@map/composables/printInPlace.js'
import { buildLegendNodes, leafZones } from './data/legend-tree.js'
import { resolveAllZones } from './data/zone-geometry.js'
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
@ -318,6 +323,12 @@ let addPinLayers = null // set in onMounted (closes over pin
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
// Prayer-packet printing (legend Export > Prayer packet). openPrintFrame is the
// SHARED in-place print helper — a hidden same-page iframe, 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 } = useI18n()
const DEAF_COUNT = 123
// Shadow-root CSS. (No backticks inside this string — a stray backtick closes the
@ -1069,17 +1080,28 @@ provide('onSemanticTreeSelect', onLegendSelect)
// ── LEGEND EXPORT (top-right of the legend titlebar) ────────────────────────
// The shared legend emits 'export' with 'poster' | 'packet' for the currently
// SELECTED node. Poster serves the SAME pre-rendered artefact the poster wall
// downloads — one file, so the legend, the wall and the preview can never show
// three different things. Packet is not built yet and says so plainly.
function onLegendExport(kind) {
// SELECTED node — the Driver's ask: "I select one and it gives me print options at
// the top right of the legend... that's the only way that map would be usable."
//
// Poster serves the SAME pre-rendered artefact the poster wall downloads — one
// file, so the legend, the wall and the preview can never show three different
// things.
//
// ★ PACKET IS GENERATED IN THE BROWSER, ON CLICK — never shipped as an asset.
// One packet is ~3.5 MB (photos dominate); 28 zones would add ~100 MB to a repo
// that ALREADY had a publish blocked at a 74 MB asset tree. Generating on click
// costs zero repository bytes, and the photos come from URLs the records already
// carry. This is the Driver's instruction: "instead of fetching the poster from
// the public folder I want the client side code to generate the poster real time
// when you click on the button so it does not take a lot of mb storage."
//
// The pages themselves come from library/packet/packet-render.js — the SAME module
// the build-time generator uses, so the printed packet and the generated one cannot
// drift. The card asked for exactly that: reuse, do not grow a fourth print path.
async function onLegendExport(kind) {
const zoneId = selectedKey.value;
if (!zoneId) return; // the button only shows with a selection
if (kind === 'packet') {
status.value = 'Prayer List — coming soon';
setTimeout(() => { status.value = ''; }, 2600);
return;
}
if (kind === 'packet') { await printPrayerPacket(zoneId); return; }
const base = resolveAssetsBase(profileConfig.value);
const a = document.createElement('a');
a.href = `${base}/posters/${zoneId}.webp`;
@ -1087,6 +1109,89 @@ function onLegendExport(kind) {
document.body.appendChild(a); a.click(); a.remove();
}
// Build and print a zone's prayer packet without leaving the page.
// PAGE ORDER IS THE PRODUCT SPEC: map · people groups 20/page with photos ·
// 12 prayer points · prayer wheel.
let packetBusy = false;
async function printPrayerPacket(zoneId) {
if (packetBusy) return; // a second click must not open a second job
packetBusy = true;
try {
const zone = allLists().find((z) => z.id === zoneId);
if (!zone) throw new Error(`no list "${zoneId}"`);
status.value = 'Building the prayer packet…';
if (!API_BASE) throw new Error('no API configured');
const fields = 'slug,name,country_code,population,religion,image_url';
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})`);
const records = normalizeRecords(extractRecords(await resp.json()));
// ⚠ pg_slugs BYPASSES the shared evaluator — it intentionally THROWS on that
// type, and 12 of the 27 lists use it. Same special-case the map and the poster
// wall already apply, so all three agree about who is in a list.
let slugs;
if (zone.selector && zone.selector.type === 'pg_slugs') {
slugs = new Set(zone.selector.values || []);
} else {
const { evaluate } = await import('./data/selector-evaluator.js');
slugs = new Set(evaluate(zone.selector, records)); // evaluate returns a Set
}
const groups = records.filter((r) => slugs.has(r.slug))
.sort((a, b) => String(a.name).localeCompare(String(b.name), packetLang()));
if (!groups.length) throw new Error('that list resolved to no people groups');
// Photos stay as URLs here. The Node generator base64-inlines them so its file
// is self-contained offline; in a browser the print frame can fetch them, and
// inlining 95 images client-side would stall the tab for no gain.
const photos = new Map(groups.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]));
const [{ createPacketRenderer }, strings] = await Promise.all([
import('@map/packet/packet-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
// The map page takes the poster's <svg>. If it cannot be fetched the packet
// still prints — a cover without the map beats no packet at all.
let mapSvg = '';
try {
const base = resolveAssetsBase(profileConfig.value);
const html = await (await fetch(`${base}/posters/${zoneId}.html`, { cache: 'force-cache' })).text();
const svg = html.match(/<svg[\s\S]*?<\/svg>/);
if (svg) mapSvg = `<style>${R.mapCss(html)}</style>`
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
+ `transform:scale(0.62);transform-origin:top center">${svg[0]}</div>`;
} catch { /* cover page without the map */ }
status.value = `Printing ${groups.length} people groups…`;
openPrintFrame(R.buildPacket(zone, groups, photos, mapSvg), { autoprint: true });
status.value = '';
} catch (err) {
status.value = `Packet failed: ${err && err.message ? err.message : err}`;
setTimeout(() => { status.value = ''; }, 4000);
} finally {
packetBusy = false;
}
}
// Every list across all three cluster buckets, flattened.
function allLists() {
return [...(clusters.lists || []), ...(clusters.asia_lists || []),
...(clusters.uncovered_lists || [])];
}
// The packet follows the app's locale so a reader gets it in their own language;
// packet-strings.json falls back PER KEY, so a half-finished translation renders
// its finished keys rather than blanking.
function packetLang() {
// useI18n()'s locale can be a plain string or a ref depending on how the host
// installed it, so normalise rather than assume.
const l = packetLocale && (packetLocale.value ?? packetLocale);
return (typeof l === 'string' && l) || 'en';
}
function onLegendSelect(node) {
if (!map || !map.getLayer('tapestry-fill')) return
// R1.2 D-6: in batch mode the legend feeds the selection union, NOT the isolate.

File diff suppressed because one or more lines are too long