Merge remote-tracking branch 'upstream/main' into bundler-1/work
This commit is contained in:
commit
b0bd797689
12 changed files with 1242 additions and 322 deletions
117
internal/check-card-deck.mjs
Normal file
117
internal/check-card-deck.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// check-card-deck — a DECK must print as CARDS, not as paper.
|
||||
//
|
||||
// THE BUG THIS EXISTS FOR: the previous deck used @page{size:letter portrait} with
|
||||
// height:100% per card, so one card stretched across a whole 8.5x11 sheet — 95
|
||||
// mostly-empty letter pages, and no photo at all. "The print dialog needs to show
|
||||
// all the cards not in a paper format but a CARD format."
|
||||
//
|
||||
// ⚠ IT ASSERTS THE MEASURED MediaBox, NOT THE CSS. Chromium SILENTLY falls back to
|
||||
// letter for a named size it does not know: `size: A6 portrait` yields 612x792pt
|
||||
// with no error, while `size: 2.5in 3.5in` yields exactly 180x252pt. A gate that
|
||||
// grepped the stylesheet would pass on a deck that prints as letter paper.
|
||||
//
|
||||
// Usage: node internal/check-card-deck.mjs [lang] [stock]
|
||||
// PACKET_ORIGIN (default http://127.0.0.1:4898) · PACKET_API · PACKET_ISO3
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
|
||||
const API = process.env.PACKET_API || 'https://pray.doxa.life'
|
||||
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
|
||||
const LANG = process.argv[2] || 'en'
|
||||
const STOCK = process.argv[3] || 'poker'
|
||||
// Expected MediaBox in points, derived from the stock's inches (72pt = 1in).
|
||||
// a6 is 4.13x5.83in, the inch rounding of 105x148mm -> ~297.4x419.8pt. An earlier
|
||||
// constant here said 396.5x559.7 and FAILED a correct render: the gate's own
|
||||
// arithmetic was wrong, not the renderer. Derive, do not hand-copy.
|
||||
const IN = { poker: [2.5, 3.5], postcard: [4, 6], a6: [4.13, 5.83] }[STOCK]
|
||||
if (!IN) { console.error(`unknown stock "${STOCK}"`); process.exit(2) }
|
||||
const EXPECT = IN.map(v => +(v * 72).toFixed(2))
|
||||
|
||||
const b = await chromium.launch()
|
||||
const pg = await b.newPage({ viewport: { width: 900, height: 1200 } })
|
||||
const errs = []; pg.on('pageerror', e => errs.push(String(e).slice(0, 140)))
|
||||
await pg.goto(ORIGIN + '/')
|
||||
|
||||
const out = await pg.evaluate(async ({ ORIGIN, API, ISO3, LANG, STOCK }) => {
|
||||
const [{ createCardRenderer }, strings, norm] = await Promise.all([
|
||||
import(`${ORIGIN}/library/packet/card-render.js`),
|
||||
fetch(`${ORIGIN}/library/scripts/posters/packet-strings.json`).then(r => r.json()),
|
||||
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
|
||||
])
|
||||
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url,primary_language&lang=en`, { cache: 'no-store' })
|
||||
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
|
||||
const groups = recs.filter(r => String(r.country_code) === ISO3)
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG))
|
||||
const R = createCardRenderer({ strings, lang: LANG, stock: STOCK })
|
||||
return { html: R.buildDeck({ id: ISO3.toLowerCase(), label: ISO3 }, groups), n: groups.length }
|
||||
}, { ORIGIN, API, ISO3, LANG, STOCK })
|
||||
|
||||
await pg.setContent(out.html, { waitUntil: 'load' })
|
||||
await pg.waitForTimeout(4000)
|
||||
const r = await pg.evaluate(() => {
|
||||
const cards = [...document.querySelectorAll('.card')]
|
||||
const imgs = [...document.querySelectorAll('img.ph')]
|
||||
const qrs = [...document.querySelectorAll('.qr svg')]
|
||||
// Does the QR carry a real symbol, or an empty path?
|
||||
const qrPaths = qrs.map(s => (s.querySelector('path')?.getAttribute('d') || '').length)
|
||||
return {
|
||||
cards: cards.length,
|
||||
imgs: imgs.length,
|
||||
broken: imgs.filter(i => !i.complete || i.naturalWidth === 0).length,
|
||||
nophoto: document.querySelectorAll('.ph-none').length,
|
||||
qrs: qrs.length,
|
||||
qrEmpty: qrPaths.filter(n => n < 50).length,
|
||||
urls: [...document.querySelectorAll('.url')].slice(0, 2).map(e => e.textContent),
|
||||
// ⚠ ASSERT THE VALUES ARE HUMAN-READABLE, not merely present. The first deck
|
||||
// rendered "LAO" / "thm" / "EAN" because normalizeRecords() flattens each
|
||||
// { value, label } dict to its VALUE and parks the label under <field>_label.
|
||||
// A gate counting rows passed it: the rows were there, just unreadable.
|
||||
// An all-caps or all-lowercase token of 2-4 chars with no space is a code.
|
||||
codeish: [...document.querySelectorAll('.v')]
|
||||
.map(e => (e.textContent || '').trim())
|
||||
.filter(v => /^[A-Z]{2,4}$|^[a-z]{2,4}$/.test(v)),
|
||||
cta: (document.querySelector('.cta') || {}).textContent || '',
|
||||
overflow: cards.filter(c => c.scrollHeight > c.clientHeight + 2).length,
|
||||
}
|
||||
})
|
||||
const pdf = await pg.pdf({ preferCSSPageSize: true, printBackground: true })
|
||||
await b.close()
|
||||
|
||||
const raw = pdf.toString('latin1')
|
||||
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m => [+m[1], +m[2]])
|
||||
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
|
||||
const [pw, ph] = boxes[0] || [0, 0]
|
||||
|
||||
const fails = []
|
||||
if (!r.cards) fails.push('no cards rendered')
|
||||
if (pages !== r.cards) fails.push(`${pages} PDF pages for ${r.cards} cards — one card must be exactly one page`)
|
||||
if (Math.abs(pw - EXPECT[0]) > 2 || Math.abs(ph - EXPECT[1]) > 2) {
|
||||
fails.push(`page is ${pw}x${ph}pt, expected ${EXPECT[0]}x${EXPECT[1]}pt for "${STOCK}"`
|
||||
+ (Math.abs(pw - 612) < 2 ? ' — this is LETTER: the @page size silently fell back' : ''))
|
||||
}
|
||||
if (r.broken) fails.push(`${r.broken} broken portrait(s)`)
|
||||
if (!r.imgs && !r.nophoto) fails.push('no portraits at all — a prayer card needs a face')
|
||||
if (r.qrs !== r.cards) fails.push(`${r.qrs} QR codes for ${r.cards} cards`)
|
||||
if (r.qrEmpty) fails.push(`${r.qrEmpty} QR code(s) render an empty symbol`)
|
||||
if (r.overflow) fails.push(`${r.overflow} card(s) overflow their own box`)
|
||||
if (r.codeish.length) {
|
||||
fails.push(`${r.codeish.length} field(s) show a raw CODE instead of a label `
|
||||
+ `(e.g. ${[...new Set(r.codeish)].slice(0, 4).join(', ')}) — read <field>_label`)
|
||||
}
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
|
||||
console.log(` stock / lang : ${STOCK} / ${LANG}`)
|
||||
console.log(` cards / PDF pages: ${r.cards} / ${pages}`)
|
||||
console.log(` page size : ${pw} x ${ph} pt (${(pw / 72).toFixed(2)} x ${(ph / 72).toFixed(2)} in)`)
|
||||
console.log(` portraits : ${r.imgs} (${r.broken} broken, ${r.nophoto} placeholder)`)
|
||||
console.log(` QR codes : ${r.qrs} (${r.qrEmpty} empty)`)
|
||||
console.log(` sample URL : ${r.urls[0] || '—'}`)
|
||||
console.log(` call to action : ${r.cta}`)
|
||||
console.log(` raw codes shown : ${r.codeish.length}${r.codeish.length ? ' (' + [...new Set(r.codeish)].slice(0,4).join(', ') + ')' : ''}`)
|
||||
console.log(` overflowing cards: ${r.overflow}`)
|
||||
if (fails.length) {
|
||||
console.error(`\nCARD-DECK: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nCARD-DECK: PASS — ${r.cards} cards, one card per ${(pw / 72).toFixed(2)}x${(ph / 72).toFixed(2)}in page.`)
|
||||
82
internal/check-packet-browser.mjs
Normal file
82
internal/check-packet-browser.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// check-packet-browser — the packet the READER gets, not the one Node writes.
|
||||
//
|
||||
// WHY A SECOND PACKET GATE. check-packet-render verifies a packet FILE produced by
|
||||
// the build-time generator. This one verifies the BROWSER path: the legend's
|
||||
// Export > Prayer packet action imports library/packet/packet-render.js, fetches
|
||||
// live records, and prints in-place. Both callers share that one renderer, and this
|
||||
// gate is what proves the shared module actually runs in a browser — a module can
|
||||
// be perfectly correct under Node and still fail in a tab (a bare `process.env`
|
||||
// reference, a Node-only import, a JSON import assertion).
|
||||
//
|
||||
// It also covers I18N, which is a product requirement rather than a nicety: pass a
|
||||
// language and the gate reports which headings came back translated. Fallback is
|
||||
// PER KEY, so a half-finished translation must render its finished keys and leave
|
||||
// the rest in English — never blank.
|
||||
//
|
||||
// Usage: node internal/check-packet-browser.mjs [lang]
|
||||
// PACKET_ORIGIN http origin serving the repo root (default http://127.0.0.1:4898)
|
||||
// PACKET_API records API (default https://pray.doxa.life)
|
||||
// PACKET_ISO3 country to build a test packet for (default LAO)
|
||||
// Needs a static server over the repo root, e.g.: python3 -m http.server 4898
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
const b = await chromium.launch()
|
||||
const pg = await b.newPage({ viewport:{width:1500,height:1941} })
|
||||
const errs=[]; pg.on('pageerror',e=>errs.push(String(e).slice(0,140)))
|
||||
|
||||
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
|
||||
const API = process.env.PACKET_API || 'https://pray.doxa.life'
|
||||
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
|
||||
const RENDER = `${ORIGIN}/library/packet/packet-render.js`
|
||||
const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
|
||||
const LANG = process.argv[2] || 'en'
|
||||
|
||||
await pg.goto(ORIGIN + '/')
|
||||
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
|
||||
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
|
||||
import(RENDER),
|
||||
fetch(STRINGS).then(r=>r.json()),
|
||||
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
|
||||
])
|
||||
const R = createPacketRenderer({ strings, lang: LANG })
|
||||
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url&lang=en`, {cache:'no-store'})
|
||||
// Use the SAME normalizer the app uses — the API returns { posts, total }, and
|
||||
// hand-rolling the extraction is how a probe ends up testing something the
|
||||
// product never does.
|
||||
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
|
||||
const groups = recs.filter(r=>String(r.country_code)===ISO3)
|
||||
.sort((a,b)=>String(a.name).localeCompare(String(b.name), LANG))
|
||||
const photos = new Map(groups.filter(g=>g.image_url).map(g=>[g.slug,g.image_url]))
|
||||
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, '')
|
||||
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3})
|
||||
|
||||
await pg.setContent(html, { waitUntil:'load' })
|
||||
await pg.waitForTimeout(3000)
|
||||
const r = await pg.evaluate(()=>({
|
||||
sheets: document.querySelectorAll('.sheet').length,
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
wheel: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
||||
mark5: document.body.innerText.includes('Gadarenes'),
|
||||
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
|
||||
firstCard: (document.querySelector('.card')||{}).innerText||'',
|
||||
wheelTitle: (document.body.innerText.match(/^.*Wheel.*$/m)||[''])[0],
|
||||
pointsTitle: (document.body.innerText.match(/^.*Prayer Points.*$|^.*Puntos.*$/m)||[''])[0],
|
||||
}))
|
||||
const pdf = await pg.pdf({ preferCSSPageSize:true })
|
||||
const t = pdf.toString('latin1')
|
||||
const pages=(t.match(/\/Type\s*\/Page[^s]/g)||[]).length
|
||||
const boxes=[...t.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m=>[+m[1],+m[2]])
|
||||
await b.close()
|
||||
console.log(` lang : ${LANG}`)
|
||||
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
||||
console.log(` people cards : ${r.cards}`)
|
||||
console.log(` wheel segments : ${r.wheel}`)
|
||||
console.log(` Mark 5 in full : ${r.mark5}`)
|
||||
console.log(` metacamp.org : ${r.metacamp}`)
|
||||
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
|
||||
console.log(` points heading : ${r.pointsTitle.trim()}`)
|
||||
console.log(` wheel heading : ${r.wheelTitle.trim()}`)
|
||||
console.log(` page errors : ${errs.length} ${errs[0]||''}`)
|
||||
const bad = !r.sheets || pages!==r.sheets || !r.cards || r.wheel!==12 || !r.mark5 || r.metacamp!==2 || errs.length
|
||||
console.log(bad?'\nFAIL':'\nPASS — browser-rendered packet is correct')
|
||||
process.exit(bad?1:0)
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
175
library/packet/card-render.js
Normal file
175
library/packet/card-render.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// card-render.js — THE prayer-card deck renderer, shared by every caller.
|
||||
//
|
||||
// ★ THE POINT OF THIS FILE: A DECK PRINTS AS CARDS, NOT AS PAPER.
|
||||
// The previous deck set `@page{size:letter portrait}` and gave each card
|
||||
// `height:100%`, so one card was stretched across a whole 8.5x11 sheet — 95
|
||||
// mostly-empty letter pages, with no photo. The Driver: "the print dialog that
|
||||
// opens up needs to show all the cards not in a paper format but a CARD format,
|
||||
// the aspect ratio that is used in the card generator."
|
||||
//
|
||||
// So the page IS the card: @page takes the card's own dimensions, and every card
|
||||
// fills exactly one page. Print two-sided on cardstock and cut, or print to PDF
|
||||
// and send to a card printer.
|
||||
//
|
||||
// ⚠ @page size MUST be given in explicit units. MEASURED: `size: A6 portrait`
|
||||
// SILENTLY falls back to letter in Chromium (612x792pt) with no error, while
|
||||
// `size: 2.5in 3.5in` yields exactly 180x252pt. Named sizes beyond letter/A4 are
|
||||
// not reliable — always use explicit inches, and always ASSERT the measured
|
||||
// MediaBox rather than trusting the CSS.
|
||||
//
|
||||
// The card content follows doxa-life/promotional-material-generation, which is a
|
||||
// data pipeline feeding an InDesign template: per language it emits Description,
|
||||
// Religion, Area, Country, a QR code and a URL. Description and Area are NOT
|
||||
// available from this API (measured: 0 of 2107 records carry either), so the card
|
||||
// is built from what genuinely exists — portrait, name, country, population,
|
||||
// language, religion (with its explanatory sentence) and the QR back to the live
|
||||
// profile. An empty block reserved for data that never arrives is worse than a
|
||||
// card designed around the data there is.
|
||||
//
|
||||
// I18N: every string comes from the caller's strings object, per-key fallback to
|
||||
// English, same contract as the packet. The QR encodes the LOCALISED URL, so a
|
||||
// Spanish card scans to the Spanish page.
|
||||
|
||||
import { qrSvg } from './qr.js';
|
||||
|
||||
// ── CARD STOCK ──────────────────────────────────────────────────────────────
|
||||
// Poker-size trading card (2.5 x 3.5in) — the format commercial card printers,
|
||||
// sleeves and boxes are built around, and the one people already associate with a
|
||||
// deck you hold and deal out. Bleed is included in the page so a trimmed card
|
||||
// keeps its full-bleed portrait.
|
||||
export const STOCK = {
|
||||
poker: { w: 2.5, h: 3.5, label: 'Poker (2.5 x 3.5 in)' },
|
||||
postcard: { w: 4, h: 6, label: 'Postcard (4 x 6 in)' },
|
||||
a6: { w: 4.13, h: 5.83, label: 'A6 (105 x 148 mm)' }, // explicit in, NOT "A6"
|
||||
};
|
||||
|
||||
const PX = 96; // CSS px per inch
|
||||
|
||||
export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
|
||||
siteBase = 'https://pray.doxa.life' } = {}) {
|
||||
const EN = strings.en;
|
||||
const L = strings[lang] || {};
|
||||
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
|
||||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
// ⚠ PREFER THE LABEL, AND KNOW WHERE IT LIVES.
|
||||
// The API sends { value, label } dicts, but records reach this renderer AFTER
|
||||
// normalizeRecords(), which flattens each field to its `.value` and parks the
|
||||
// human-readable text under `<field>_label`. Reading the field alone therefore
|
||||
// yields "LAO", "thm", "EAN" — codes, on a card a person is meant to pray from.
|
||||
// Measured: the first deck shipped exactly that, and a gate counting "4 rows
|
||||
// rendered" passed it, because the rows were present, just unreadable.
|
||||
// Handles all three shapes: an un-normalized dict, a normalized value+label
|
||||
// pair, and a plain string.
|
||||
const label = (g, key) => {
|
||||
const lab = g[`${key}_label`];
|
||||
if (lab) return String(lab);
|
||||
const v = g[key];
|
||||
if (v && typeof v === 'object') return String(v.label ?? v.value ?? '');
|
||||
return v == null ? '' : String(v);
|
||||
};
|
||||
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(lang) : '—');
|
||||
|
||||
const S = STOCK[stock] || STOCK.poker;
|
||||
const W = Math.round(S.w * PX); // card box in CSS px
|
||||
const H = Math.round(S.h * PX);
|
||||
|
||||
// The live profile URL for this group, in this language. English has no prefix
|
||||
// (the generator's own rule); every other language does.
|
||||
const profileUrl = (slug) => (lang === 'en'
|
||||
? `${siteBase}/${slug}`
|
||||
: `${siteBase}/${lang}/${slug}`);
|
||||
|
||||
function css() {
|
||||
return `
|
||||
/* THE PAGE IS THE CARD. Explicit inches — a named size silently falls back to
|
||||
letter in Chromium, which is exactly the bug this renderer exists to fix. */
|
||||
@page{size:${S.w}in ${S.h}in;margin:0}
|
||||
html,body{margin:0;padding:0;background:#6f6f6f;
|
||||
-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
.card{position:relative;width:${W}px;height:${H}px;overflow:hidden;
|
||||
background:#f7f4ec;color:#1b1a17;
|
||||
font-family:Georgia,'Palatino Linotype',serif;
|
||||
page-break-after:always;break-after:page}
|
||||
.card:last-child{page-break-after:auto;break-after:auto}
|
||||
|
||||
/* PORTRAIT: full-bleed across the top. A face is what makes this a prayer card
|
||||
rather than a data tile — it is the largest thing on the card by design. */
|
||||
.ph{position:absolute;left:0;top:0;width:${W}px;height:${Math.round(H * 0.46)}px;
|
||||
object-fit:cover;display:block;background:#ddd6c6}
|
||||
.ph-none{position:absolute;left:0;top:0;width:${W}px;height:${Math.round(H * 0.46)}px;
|
||||
background:linear-gradient(160deg,#e6dfcd,#cfc6ad);display:flex;
|
||||
align-items:center;justify-content:center;
|
||||
font:italic 11px Georgia,serif;color:#8c8170}
|
||||
|
||||
/* A soft scrim under the name so it stays legible over any photo. */
|
||||
.scrim{position:absolute;left:0;top:${Math.round(H * 0.30)}px;width:${W}px;
|
||||
height:${Math.round(H * 0.16)}px;
|
||||
background:linear-gradient(to bottom,rgba(24,22,18,0),rgba(24,22,18,.72))}
|
||||
.nm{position:absolute;left:14px;right:14px;top:${Math.round(H * 0.355)}px;
|
||||
font:700 19px Georgia,serif;color:#fff;line-height:1.05;
|
||||
text-shadow:0 1px 3px rgba(0,0,0,.5)}
|
||||
.nm.long{font-size:15px}
|
||||
|
||||
.body{position:absolute;left:14px;right:14px;top:${Math.round(H * 0.48)}px;
|
||||
bottom:${Math.round(H * 0.20)}px;overflow:hidden}
|
||||
.row{display:flex;gap:6px;font:400 9.5px Georgia,serif;line-height:1.5;margin-bottom:1px}
|
||||
.k{color:#8c8170;min-width:52px;flex:0 0 auto}
|
||||
.v{color:#3b3a34;font-weight:600}
|
||||
.rel{margin-top:5px;font:italic 8.5px Georgia,serif;color:#6d665a;line-height:1.35}
|
||||
|
||||
/* FOOTER: the QR is the card's whole reason to survive the day it is handed
|
||||
over — it carries the reader to the live profile in their own language. */
|
||||
.ft{position:absolute;left:14px;right:14px;bottom:10px;
|
||||
display:flex;align-items:flex-end;gap:8px}
|
||||
.qr{flex:0 0 auto;line-height:0}
|
||||
.ftx{flex:1 1 auto;min-width:0}
|
||||
.cta{font:600 8px Georgia,serif;color:#7a3b2e;letter-spacing:.04em;
|
||||
text-transform:uppercase}
|
||||
.url{font:400 7px Georgia,serif;color:#a89f8c;word-break:break-all;margin-top:1px}
|
||||
.brand{position:absolute;right:14px;bottom:10px;
|
||||
font:400 7px Georgia,serif;color:#b9ae99;text-align:right}`;
|
||||
}
|
||||
|
||||
// One card = one page.
|
||||
function card(g) {
|
||||
const name = String(g.name || g.slug || '');
|
||||
const photo = g.image_url
|
||||
? `<img class="ph" src="${esc(g.image_url)}" alt="">`
|
||||
: `<div class="ph-none">${esc(t('no_photo'))}</div>`;
|
||||
// The religion's explanatory sentence, when the caller kept it. normalizeRecords()
|
||||
// keeps only { value, label } and DROPS `description`, so this is present only
|
||||
// when a caller passes records straight from the API. Absent is normal, not a bug —
|
||||
// the card simply gives that space back to the rows.
|
||||
const relDesc = (g.religion && typeof g.religion === 'object' && g.religion.description)
|
||||
|| g.religion_description || '';
|
||||
const relHtml = relDesc
|
||||
? `<div class="rel">${esc(String(relDesc).slice(0, 170))}</div>` : '';
|
||||
const url = profileUrl(g.slug);
|
||||
const rows = [
|
||||
[t('card_country'), label(g, 'country_code')],
|
||||
[t('card_population'), `${num(g.population)} ${t('people_suffix')}`],
|
||||
[t('card_language'), label(g, 'primary_language')],
|
||||
[t('card_religion'), label(g, 'religion')],
|
||||
].filter(([, v]) => v && String(v).trim() && String(v) !== '—')
|
||||
.map(([k, v]) => `<div class="row"><div class="k">${esc(k)}</div>`
|
||||
+ `<div class="v">${esc(v)}</div></div>`).join('');
|
||||
|
||||
return `<div class="card">${photo}<div class="scrim"></div>`
|
||||
+ `<div class="nm${name.length > 16 ? ' long' : ''}">${esc(name)}</div>`
|
||||
+ `<div class="body">${rows}${relHtml}</div>`
|
||||
+ `<div class="ft"><div class="qr">${qrSvg(url, { size: 54, margin: 1 })}</div>`
|
||||
+ `<div class="ftx"><div class="cta">${esc(t('card_cta'))}</div>`
|
||||
+ `<div class="url">${esc(url.replace(/^https?:\/\//, ''))}</div></div></div>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
function buildDeck(zone, groups) {
|
||||
const cards = groups.map(card).join('');
|
||||
return `<!DOCTYPE html><html lang="${esc(lang)}"><head><meta charset="utf-8">
|
||||
<title>${esc(zone.label || zone.id)} — ${esc(t('deck_title'))}</title>
|
||||
<style>${css()}</style></head><body>${cards}</body></html>`;
|
||||
}
|
||||
|
||||
return { buildDeck, card, css, profileUrl, W, H, stock: S };
|
||||
}
|
||||
276
library/packet/packet-render.js
Normal file
276
library/packet/packet-render.js
Normal 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
// ⚠ `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 };
|
||||
}
|
||||
211
library/packet/qr.js
Normal file
211
library/packet/qr.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// qr.js — a minimal, self-contained QR encoder. No dependency, no network.
|
||||
//
|
||||
// WHY HAND-ROLLED. The prayer cards need a QR per group linking back to that
|
||||
// group's live page, and the Driver's standing constraint is "as much data as you
|
||||
// can without compromising speed and without adding a lot of dependencies on the
|
||||
// internet". A QR symbol is pure computation — Reed-Solomon over GF(256) and a bit
|
||||
// layout — so it costs one small file instead of a package, and it runs identically
|
||||
// in Node (build-time decks) and the browser (print-on-click).
|
||||
//
|
||||
// SCOPE, deliberately narrow: byte mode, error-correction level M, the smallest
|
||||
// version that fits, mask 0. That is all a short https URL needs
|
||||
// (https://pray.doxa.life/<slug> is ~40 chars -> version 3). It is NOT a general
|
||||
// QR library: no kanji mode, no ECI, no structured append. Encoding anything much
|
||||
// longer than a URL will throw rather than silently produce a symbol that will not
|
||||
// scan.
|
||||
//
|
||||
// ⚠ A QR that does not scan looks exactly like one that does. Do not "improve" this
|
||||
// by eye — verify by decoding the output.
|
||||
|
||||
// ── GF(256) arithmetic, the field QR's Reed-Solomon works over ───────────────
|
||||
const EXP = new Uint8Array(512);
|
||||
const LOG = new Uint8Array(256);
|
||||
(() => {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) {
|
||||
EXP[i] = x;
|
||||
LOG[x] = i;
|
||||
x <<= 1;
|
||||
if (x & 0x100) x ^= 0x11d; // the QR generator polynomial
|
||||
}
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
const mul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
|
||||
|
||||
// Reed-Solomon error-correction codewords for one block.
|
||||
function rsEncode(data, ecLen) {
|
||||
// Build the generator polynomial for ecLen codewords.
|
||||
let gen = [1];
|
||||
for (let i = 0; i < ecLen; i++) {
|
||||
const next = new Array(gen.length + 1).fill(0);
|
||||
for (let j = 0; j < gen.length; j++) {
|
||||
next[j] ^= gen[j];
|
||||
next[j + 1] ^= mul(gen[j], EXP[i]);
|
||||
}
|
||||
gen = next;
|
||||
}
|
||||
const res = new Uint8Array(ecLen);
|
||||
for (const byte of data) {
|
||||
const factor = byte ^ res[0];
|
||||
res.copyWithin(0, 1);
|
||||
res[ecLen - 1] = 0;
|
||||
for (let j = 0; j < ecLen; j++) res[j] ^= mul(gen[j + 1], factor);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Version table (level M only) — [version, totalCodewords, ecPerBlock, blocks]
|
||||
// Versions 1..6 cover every URL this app produces; beyond that we throw rather
|
||||
// than guess, because a wrong table silently yields an unscannable symbol.
|
||||
const VERSIONS = [
|
||||
[1, 26, 10, 1], [2, 44, 16, 1], [3, 70, 26, 1],
|
||||
[4, 100, 18, 2], [5, 134, 24, 2], [6, 172, 16, 4],
|
||||
];
|
||||
|
||||
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34] };
|
||||
|
||||
/**
|
||||
* Encode `text` as a QR matrix.
|
||||
* @returns {{size:number, modules:Uint8Array}} row-major, 1 = dark.
|
||||
*/
|
||||
export function qrMatrix(text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
|
||||
// Pick the smallest version whose data capacity fits.
|
||||
let spec = null;
|
||||
for (const v of VERSIONS) {
|
||||
const [, total, ecPer, blocks] = v;
|
||||
const dataCw = total - ecPer * blocks;
|
||||
if (bytes.length + 2 + 1 <= dataCw) { spec = v; break; } // +mode nibble, +length
|
||||
}
|
||||
if (!spec) {
|
||||
throw new Error(`qr: ${bytes.length} bytes exceeds this encoder's range (max ~${
|
||||
VERSIONS.at(-1)[1] - VERSIONS.at(-1)[2] * VERSIONS.at(-1)[3] - 3} bytes). `
|
||||
+ 'It is scoped to short URLs on purpose.');
|
||||
}
|
||||
const [version, totalCw, ecPerBlock, blocks] = spec;
|
||||
const dataCw = totalCw - ecPerBlock * blocks;
|
||||
|
||||
// ── Bit stream: mode(0100) + length(8 bits) + payload + terminator ──────────
|
||||
const bits = [];
|
||||
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
||||
push(0b0100, 4);
|
||||
push(bytes.length, 8);
|
||||
for (const b of bytes) push(b, 8);
|
||||
for (let i = 0; i < 4 && bits.length < dataCw * 8; i++) bits.push(0);
|
||||
while (bits.length % 8) bits.push(0);
|
||||
const data = [];
|
||||
for (let i = 0; i < bits.length; i += 8) {
|
||||
data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
|
||||
}
|
||||
// Pad alternately with the two spec-mandated bytes.
|
||||
const PAD = [0xec, 0x11];
|
||||
for (let i = 0; data.length < dataCw; i++) data.push(PAD[i % 2]);
|
||||
|
||||
// ── Split into blocks, compute EC, interleave ───────────────────────────────
|
||||
const perBlock = Math.floor(dataCw / blocks);
|
||||
const dBlocks = [];
|
||||
const eBlocks = [];
|
||||
for (let i = 0; i < blocks; i++) {
|
||||
const chunk = data.slice(i * perBlock, (i + 1) * perBlock);
|
||||
dBlocks.push(chunk);
|
||||
eBlocks.push(rsEncode(chunk, ecPerBlock));
|
||||
}
|
||||
const final = [];
|
||||
for (let i = 0; i < perBlock; i++) for (const b of dBlocks) final.push(b[i]);
|
||||
for (let i = 0; i < ecPerBlock; i++) for (const b of eBlocks) final.push(b[i]);
|
||||
|
||||
// ── Lay out the matrix ──────────────────────────────────────────────────────
|
||||
const size = version * 4 + 17;
|
||||
const m = new Int8Array(size * size).fill(-1); // -1 = free
|
||||
const at = (r, c) => r * size + c;
|
||||
const set = (r, c, v) => { if (r >= 0 && c >= 0 && r < size && c < size) m[at(r, c)] = v; };
|
||||
|
||||
const finder = (r0, c0) => {
|
||||
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
|
||||
const inRing = (r === 0 || r === 6 || c === 0 || c === 6);
|
||||
const inCore = (r >= 2 && r <= 4 && c >= 2 && c <= 4);
|
||||
set(r0 + r, c0 + c, (r >= 0 && r <= 6 && c >= 0 && c <= 6 && (inRing || inCore)) ? 1 : 0);
|
||||
}
|
||||
};
|
||||
finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
|
||||
|
||||
for (let i = 8; i < size - 8; i++) { // timing patterns
|
||||
const v = i % 2 === 0 ? 1 : 0;
|
||||
if (m[at(6, i)] === -1) set(6, i, v);
|
||||
if (m[at(i, 6)] === -1) set(i, 6, v);
|
||||
}
|
||||
for (const r of ALIGN[version] || []) { // alignment patterns
|
||||
for (const c of ALIGN[version] || []) {
|
||||
if (m[at(r, c)] !== -1) continue;
|
||||
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
|
||||
set(r + dr, c + dc, (Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
set(size - 8, 8, 1); // the always-dark module
|
||||
|
||||
// Reserve format-information cells so data skips them.
|
||||
const fmtCells = [];
|
||||
for (let i = 0; i <= 8; i++) { if (i !== 6) fmtCells.push([8, i], [i, 8]); }
|
||||
for (let i = 0; i < 8; i++) fmtCells.push([8, size - 1 - i], [size - 1 - i, 8]);
|
||||
for (const [r, c] of fmtCells) if (m[at(r, c)] === -1) m[at(r, c)] = -2; // -2 = reserved
|
||||
|
||||
// ── Place data, zig-zag upward in 2-column strips, with mask 0 applied ───────
|
||||
let bi = 0;
|
||||
let up = true;
|
||||
for (let col = size - 1; col > 0; col -= 2) {
|
||||
if (col === 6) col--; // skip the vertical timing line
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = up ? size - 1 - i : i;
|
||||
for (const c of [col, col - 1]) {
|
||||
if (m[at(row, c)] !== -1) continue;
|
||||
const bit = bi < final.length * 8
|
||||
? (final[bi >> 3] >> (7 - (bi & 7))) & 1
|
||||
: 0;
|
||||
bi++;
|
||||
// Mask 0: invert where (row + col) is even. Applied HERE rather than in a
|
||||
// second pass so reserved/function modules are never touched.
|
||||
m[at(row, c)] = bit ^ ((row + c) % 2 === 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
up = !up;
|
||||
}
|
||||
|
||||
// ── Format information: level M (00) + mask 0 (000), BCH(15,5) + XOR mask ────
|
||||
let fmt = 0b00000; // ecc M = 00, mask 0 = 000
|
||||
let rem = fmt;
|
||||
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ (((rem >> 9) & 1) * 0x537);
|
||||
const fmtBits = ((fmt << 10) | rem) ^ 0b101010000010010;
|
||||
const fmtBit = (i) => (fmtBits >> i) & 1;
|
||||
for (let i = 0; i <= 5; i++) { set(8, i, fmtBit(i)); set(size - 1 - i, 8, fmtBit(i)); }
|
||||
set(8, 7, fmtBit(6)); set(8, 8, fmtBit(7)); set(7, 8, fmtBit(8));
|
||||
for (let i = 9; i <= 14; i++) { set(8, size - 15 + i, fmtBit(i)); set(14 - i, 8, fmtBit(i)); }
|
||||
set(size - 8, 8, 1);
|
||||
|
||||
const modules = new Uint8Array(size * size);
|
||||
for (let i = 0; i < m.length; i++) modules[i] = m[i] === 1 ? 1 : 0;
|
||||
return { size, modules };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `text` as an inline SVG QR code.
|
||||
* Drawn as ONE path of rectangles rather than per-module <rect>s: a version-3
|
||||
* symbol is 29x29 = 841 modules, and 841 elements per card times 95 cards is a
|
||||
* document a print dialog struggles with.
|
||||
*/
|
||||
export function qrSvg(text, { size = 120, margin = 2, dark = '#14110d' } = {}) {
|
||||
const { size: n, modules } = qrMatrix(text);
|
||||
const total = n + margin * 2;
|
||||
let d = '';
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (modules[r * n + c]) d += `M${c + margin} ${r + margin}h1v1h-1z`;
|
||||
}
|
||||
}
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" `
|
||||
+ `width="${size}" height="${size}" shape-rendering="crispEdges" role="img" `
|
||||
+ `aria-label="QR code">`
|
||||
+ `<rect width="${total}" height="${total}" fill="#fff"/>`
|
||||
+ `<path d="${d}" fill="${dark}"/></svg>`;
|
||||
}
|
||||
|
|
@ -10,6 +10,12 @@
|
|||
"list_header": "{from}–{to} of {count} · pray for them by name",
|
||||
"people_suffix": "people",
|
||||
"no_photo": "no photo available",
|
||||
"deck_title": "Prayer Cards",
|
||||
"card_country": "Country",
|
||||
"card_population": "People",
|
||||
"card_language": "Language",
|
||||
"card_religion": "Religion",
|
||||
"card_cta": "Pray for them",
|
||||
"points_title": "12 Prayer Points",
|
||||
"points_intro": "One hour with God, five minutes each. Pray these twelve for the people groups in this packet.",
|
||||
"points_attrib": "Prayer rhythms inspired by Metacamp · metacamp.org",
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
// 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 ────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@
|
|||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"check:card-deck": "node internal/check-card-deck.mjs",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs",
|
||||
"check:packet-render": "node internal/check-packet-render.mjs",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
*/
|
||||
import { computed, inject, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// Export-modal geometry — see the note at .modal-scrim below, and library/utils/viewportScrim.js.
|
||||
import { useViewportScrim } from '@map/utils/viewportScrim.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
|
|
@ -263,6 +264,27 @@ async function renderFor(list, spec) {
|
|||
|
||||
const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canvas, phone-safe
|
||||
const exportState = ref(null) // { label, phase, message, pct, kind }
|
||||
|
||||
// CARD STOCK for the prayer-card deck. The Driver asked to be able to "choose the
|
||||
// layout of the print dialog to choose the aspect ratio of these prayer cards", so
|
||||
// the stock is a user choice, not a constant. Poker (2.5x3.5in) is the default: it
|
||||
// is what commercial card printers, sleeves and boxes are built around.
|
||||
// @page takes these as explicit inches — a NAMED size silently falls back to letter
|
||||
// in Chromium, which is the exact bug this deck is being fixed for.
|
||||
const DECK_STOCKS = [
|
||||
{ id: 'poker', label: 'Card 2.5 × 3.5 in' },
|
||||
{ id: 'postcard', label: 'Postcard 4 × 6 in' },
|
||||
{ id: 'a6', label: 'A6 105 × 148 mm' },
|
||||
]
|
||||
const deckStock = ref('poker')
|
||||
// The deck follows the app's locale; packet-strings.json falls back PER KEY, and
|
||||
// the QR on each card encodes the LOCALISED profile URL, so a Spanish card scans
|
||||
// through to the Spanish page.
|
||||
const { locale: deckLocale } = useI18n()
|
||||
function deckLang() {
|
||||
const l = deckLocale && (deckLocale.value ?? deckLocale)
|
||||
return (typeof l === 'string' && l) || 'en'
|
||||
}
|
||||
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
|
||||
|
||||
// exportPoster()/downloadPacket()/download() REMOVED (2026-09-23) — the Driver's brief was
|
||||
|
|
@ -411,7 +433,12 @@ async function printPoster(list) {
|
|||
// disagree between the map, the list print and the card deck.
|
||||
async function fetchListRecords(list) {
|
||||
if (!API_BASE) throw new Error('no API configured')
|
||||
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion'
|
||||
// image_url + primary_language are what make a PRAYER CARD rather than a data
|
||||
// tile: a face and the tongue the gospel has to reach them in. The deck shipped
|
||||
// without either because they were never requested here — measured 95/95 groups
|
||||
// DO have a portrait, so the omission cost every card its photo.
|
||||
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,'
|
||||
+ 'image_url,primary_language'
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
|
||||
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
|
||||
const records = normalizeRecords(extractRecords(await resp.json()))
|
||||
|
|
@ -505,51 +532,25 @@ async function printCardDeck(list) {
|
|||
message: 'fetching people-group records', pct: 30 }
|
||||
try {
|
||||
const rows = await fetchListRecords(list)
|
||||
// ★ THE PAGE IS THE CARD. This used to set @page{size:letter portrait} and give
|
||||
// each card height:100%, so a "deck" printed as 95 mostly-empty 8.5x11 sheets
|
||||
// with no photo. The Driver: "the print dialog that opens up needs to show all
|
||||
// the cards not in a paper format but a CARD format, the aspect ratio that is
|
||||
// used in the card generator."
|
||||
//
|
||||
// The pages now come from library/packet/card-render.js — one renderer, every
|
||||
// caller — which sets @page to the card stock's own inches, so one card is
|
||||
// exactly one page at 2.5x3.5in. Print on cardstock and cut, or send the PDF
|
||||
// to a card printer.
|
||||
const [{ createCardRenderer }, strings] = await Promise.all([
|
||||
import('@map/packet/card-render.js'),
|
||||
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
])
|
||||
const R = createCardRenderer({ strings, lang: deckLang(), stock: deckStock.value })
|
||||
const title = `Doxa UPG prayer cards — ${escapeHtml(list.label || list.id)}`
|
||||
const cardsHtml = rows.map((r) => (
|
||||
'<section class="card">' +
|
||||
'<div class="tape">' + escapeHtml(list.label || list.id) + '</div>' +
|
||||
'<h2>' + escapeHtml(r.name) + '</h2>' +
|
||||
'<dl>' +
|
||||
'<dt>Country</dt><dd>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</dd>' +
|
||||
'<dt>Population</dt><dd>' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</dd>' +
|
||||
'<dt>Affinity bloc</dt><dd>' + escapeHtml((r.rop1 && r.rop1.label) || '—') + '</dd>' +
|
||||
'<dt>Religion</dt><dd>' + escapeHtml(r.religion_label || '—') + '</dd>' +
|
||||
'</dl>' +
|
||||
'<p class="verse">"Ask the Lord of the harvest to send out workers into his harvest field." — Matthew 9:38</p>' +
|
||||
'</section>'
|
||||
)).join('')
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>' +
|
||||
'@page{size:letter portrait;margin:10mm}' +
|
||||
'html,body{margin:0;background:#f3f3f1;color:#3b463d;' +
|
||||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
|
||||
// Screen preview: a responsive grid so the deck can be reviewed before printing.
|
||||
'.deck{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;padding:18px}' +
|
||||
'.card{position:relative;background:#fff;border:1px solid #d1d4cd;border-radius:6px;' +
|
||||
'padding:22px 18px 16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}' +
|
||||
'.tape{position:absolute;top:-9px;left:50%;transform:translateX(-50%);' +
|
||||
'background:#92b195;color:#1c2a1f;font-size:9px;font-weight:700;text-transform:uppercase;' +
|
||||
'letter-spacing:.06em;padding:3px 10px;border-radius:999px;white-space:nowrap}' +
|
||||
'.card h2{margin:6px 0 10px;font-size:16px;font-weight:700}' +
|
||||
'.card dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11.5px}' +
|
||||
'.card dt{color:#6d766e;font-weight:600}' +
|
||||
'.card dd{margin:0}' +
|
||||
'.card .verse{margin:12px 0 0;padding-top:10px;border-top:1px solid #d1d4cd;' +
|
||||
'font-size:10.5px;font-style:italic;color:#6d766e;line-height:1.4}' +
|
||||
// PRINT PAGINATION: one card per sheet. `break-after:page` on every card except
|
||||
// the last is what makes a "deck" out of a document instead of a wall of cards
|
||||
// shrunk to fit — the Driver's ask was "a real printer produces a usable deck".
|
||||
'@media print{' +
|
||||
'.deck{display:block;padding:0}' +
|
||||
'.card{box-shadow:none;border-radius:0;border:2px solid #3b463d;' +
|
||||
'break-after:page;page-break-after:always;height:100%;box-sizing:border-box}' +
|
||||
'.card:last-child{break-after:auto;page-break-after:auto}' +
|
||||
'}' +
|
||||
'</style></head><body>' +
|
||||
'<div class="deck">' + cardsHtml + '</div>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
exportState.value = { ...exportState.value, phase: 'render',
|
||||
message: `laying out ${rows.length} cards`, pct: 70 }
|
||||
const w = openPrintWindow(title, R.buildDeck(list, rows), { autoprint: true })
|
||||
if (!w) {
|
||||
// See the note in the list path: an iframe cannot be pop-up blocked.
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
|
|
@ -692,6 +693,14 @@ useShadowStyles(
|
|||
'border-radius:2px;background:transparent;transition:background .15s}' +
|
||||
'.pbtn:hover{color:#10b981}' +
|
||||
'.pbtn:hover::after{background:#10b981}' +
|
||||
// CARD STOCK picker — sits with the print buttons because it changes what the
|
||||
// print dialog PRODUCES (it becomes the @page size). Quieter than the buttons:
|
||||
// it is a modifier, not an action.
|
||||
'.pstock{flex:0 1 auto;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
|
||||
'background:#fff;color:#5b6672;font:inherit;font-size:9.5px;font-weight:600;' +
|
||||
'padding:2px 4px;cursor:pointer;line-height:1.25}' +
|
||||
'.pstock:hover{border-color:#10b981;color:#10b981}' +
|
||||
'.is-dark .pstock{background:#12211a;border-color:#2c4a3b;color:#9fb3a8}' +
|
||||
'.is-dark .pbtn{color:#9fb3a8}' +
|
||||
// SECTIONS.
|
||||
// Sections need more separation than the cards within them, or the regional
|
||||
|
|
@ -840,6 +849,15 @@ useShadowStyles(
|
|||
:aria-label="`Print the ${list.label} UPG prayer poster`">UPG prayer poster</button>
|
||||
<button type="button" class="pbtn pbtn-print" @click="printCardDeck(list)"
|
||||
:aria-label="`Print the ${list.label} UPG card deck`">card deck</button>
|
||||
<!-- CARD STOCK. The Driver asked to "choose the layout of the print
|
||||
dialog to choose the aspect ratio of these prayer cards" — so the
|
||||
size is picked here and becomes the @page size, which is what makes
|
||||
the print dialog show CARDS instead of letter paper. -->
|
||||
<select class="pstock" v-model="deckStock"
|
||||
aria-label="Prayer card size"
|
||||
@click.stop>
|
||||
<option v-for="s in DECK_STOCKS" :key="s.id" :value="s.id">{{ s.label }}</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
197
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
197
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue