Wall of People Groups: the poster is 5 by 8 feet by default, three stacked sheets glued on 6-inch borders, and other sizes fit their own grid

This commit is contained in:
Doxa Maps 2026-09-28 19:29:11 +00:00
parent 43992ccac3
commit b0d336d0d6
3 changed files with 136 additions and 62 deletions

View file

@ -1,79 +1,136 @@
/**
* blessPoster.js — the "Bless the nations in Jesus' name" poster: every people group (photo, name and a
* blessing verse) on ONE 8 × 5 ft poster, printed as THREE 3 × 5 ft sheets that overlap by 6 inches.
* blessPoster.js — the large printed poster engine, first used for "Bless the nations in Jesus' name":
* every people group (photo, name and a blessing verse) on ONE poster, sized for a print shop.
*
* 3 + 2.5 + 2.5 = 8 ft wide, 5 ft tall: sheet 2 is glued over the right-hand 6 in of sheet 1, and sheet 3
* over the right-hand 6 in of sheet 2. Each sheet is a 36-inch WINDOW onto one 96 × 60 in layout, so the
* content in an overlap strip is identical on both sheets and registers when glued.
* THE DEFAULT IS 5 ft WIDE × 8 ft TALL, printed as THREE 5 × 3 ft sheets stacked top to bottom:
* · every sheet has a 6-inch BORDER on its top, left and right;
* · the top two sheets' BOTTOM edge is FLAT (trimmed at the shop) and is glued onto the 6-inch top
* border of the sheet below, which is a blank GLUE band (marked "glue here");
* · the bottom sheet also has a 6-inch bottom border, so the finished poster has a 6-inch border all
* the way round. 36 + 30 + 30 = 96 in = 8 ft tall, 60 in = 5 ft wide.
* Content never straddles a glue line: rows are fitted to each sheet's band.
*
* Pure: layout maths + an HTML string for the shared in-place print frame (library printInPlace). No Vue.
* Only the cards that touch a sheet are placed on it, so the print document holds ~2,300 cards in total,
* not 3 × 2,107.
* Other presets reuse the same engine (a single sheet with a plain border). The card size and every
* text size are COMPUTED from the preset and the number of groups, and planPoster() reports the verse
* point size, so a preset can be judged by whether its verses are readable.
*
* Pure: plan maths + an HTML string for the shared in-place print frame (library printInPlace). No Vue.
*/
import { blessingFor, POSTER } from './blessings.js'
export const SHEET = { w: 36, h: 60, overlap: 6, count: 3 } // inches
export const POSTER_W = SHEET.w * SHEET.count - SHEET.overlap * (SHEET.count - 1) // 96
export const POSTER_H = SHEET.h // 60
const MARGIN = 0.5, BANNER_H = 5.2, FOOTER_H = 3
/** Where each of n cards goes, in inches on the 96 × 60 poster. */
export function posterLayout(n, { aspect = 1.18 } = {}) {
const gx = MARGIN, gy = MARGIN + BANNER_H, gw = POSTER_W - 2 * MARGIN, gh = POSTER_H - 2 * MARGIN - BANNER_H - FOOTER_H
// Columns so the cells come out near the target width/height aspect, then enough rows for n.
let cols = Math.max(1, Math.round(Math.sqrt((n * gw) / (gh * aspect))))
let rows = Math.ceil(n / cols)
while (rows * (gw / cols / aspect) > gh && cols < n) { cols++; rows = Math.ceil(n / cols) }
const cw = gw / cols, ch = Math.min(gh / rows, cw / aspect * 1.25)
const cards = []
for (let i = 0; i < n; i++) cards.push({ i, x: gx + (i % cols) * cw, y: gy + Math.floor(i / cols) * ch, w: cw, h: ch })
return { cols, rows, cw, ch, grid: { x: gx, y: gy, w: gw, h: gh }, cards }
// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border.
export const PRESETS = {
'5x8ft': { label: '5 × 8 ft', sheetW: 60, sheetH: 36, sheets: 3, border: 6 },
'3x5ft': { label: '3 × 5 ft', sheetW: 36, sheetH: 60, sheets: 1, border: 2 },
'24x36in': { label: '24 × 36 in', sheetW: 24, sheetH: 36, sheets: 1, border: 1 },
'18x24in': { label: '18 × 24 in', sheetW: 18, sheetH: 24, sheets: 1, border: 0.75 },
}
export const DEFAULT_PRESET = '5x8ft'
export const MIN_READABLE_PT = 6 // verse size below which a preset is flagged "verses not readable"
/** The x-window (in poster inches) that sheet s prints. */
export const sheetX = (s) => s * (SHEET.w - SHEET.overlap)
const CARD_ASPECT = 0.8 // card width / height: a portrait card (name, photo, verse)
const PT = 1 / 72 // one point in inches
/**
* The plan for n groups on a preset: per-sheet content bands, the card grid, and the text sizes.
* Banner + subtitle sit at the top of sheet 1's band; the footer at the bottom of the last sheet's.
*/
export function planPoster(presetId, n) {
const P = PRESETS[presetId] || PRESETS[DEFAULT_PRESET]
const b = P.border
const W = P.sheetW - 2 * b
const bannerH = Math.max(1.6, W * 0.11), footerH = Math.max(0.7, W * 0.045)
// Each sheet's content band, in the sheet's own coordinates: y0..y1.
const bands = []
for (let s = 0; s < P.sheets; s++) {
const last = s === P.sheets - 1
let y0 = b // sheet 1: outer border; the others: the glue band
let y1 = P.sheetH - (last ? b : 0) // flat bottom edge, except the last sheet's border
const top = s === 0 ? bannerH : 0, bottom = last ? footerH : 0
bands.push({ s, y0: y0 + top, y1: y1 - bottom, bannerY: s === 0 ? y0 : null, footerY: last ? y1 - footerH : null })
}
// The largest card (fewest columns) whose grid holds n when rows are fitted to each band.
let best = null
for (let cols = 4; cols <= 400 && !best; cols++) {
const cw = W / cols, ch = cw / CARD_ASPECT
const rowsPer = bands.map((bd) => Math.max(0, Math.floor((bd.y1 - bd.y0) / ch)))
if (cols * rowsPer.reduce((a, r) => a + r, 0) >= n) best = { cols, cw, ch, rowsPer }
}
const { cols, cw, ch, rowsPer } = best
// Place cards band by band, row by row.
const cards = []
let i = 0
for (let s = 0; s < bands.length && i < n; s++) {
for (let r = 0; r < rowsPer[s] && i < n; r++) {
for (let c = 0; c < cols && i < n; c++, i++) cards.push({ i, s, x: b + c * cw, y: bands[s].y0 + r * ch, w: cw, h: ch })
}
}
// Text sizes from the card: the verse gets ~4 lines of ~26 characters across the card width.
const versePt = (cw / 13.5) / PT // character width ≈ 0.5 em, 26 chars per line
return {
preset: P, presetId: PRESETS[presetId] ? presetId : DEFAULT_PRESET, n, cols, rowsPer, cw, ch, bands, cards,
bannerH, footerH, contentW: W,
posterW: P.sheetW, posterH: P.sheetH * P.sheets - (P.sheets - 1) * b, // after gluing: 60 × 96 for 5x8ft
text: { versePt: +versePt.toFixed(1), namePt: +(versePt * 1.25).toFixed(1), readable: versePt >= MIN_READABLE_PT },
}
}
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
/** The full print document: 3 pages of 36 × 60 in. groups: [{ name, imageUrl }]. */
export function buildBlessPosterHtml(groups, { footer = POSTER.footer, banner = POSTER.banner, subBanner = POSTER.subBanner } = {}) {
const L = posterLayout(groups.length)
/**
* The print document: one page per sheet. groups: [{ name, imageUrl }]. texts: { banner, subtitle,
* subBanner: {text, ref}, footer: {text, ref}, glueHere } (the wall passes translated UI strings).
*/
export function buildBlessPosterHtml(groups, presetId = DEFAULT_PRESET, texts = {}) {
const plan = planPoster(presetId, groups.length)
const P = plan.preset, b = P.border
const banner = texts.banner || POSTER.banner
const subtitle = texts.subtitle || ''
const sub = texts.subBanner || POSTER.subBanner
const footer = texts.footer || POSTER.footer
const glueHere = texts.glueHere || 'glue here'
const f = (v) => v.toFixed(3) + 'in'
const vPt = plan.text.versePt, nPt = plan.text.namePt
const card = (c) => {
const g = groups[c.i], b = blessingFor(c.i)
const g = groups[c.i], v = blessingFor(c.i)
const img = g.imageUrl ? `<img src="${esc(g.imageUrl)}" alt="">` : '<div class="noimg"></div>'
// Below MIN_READABLE_PT the verse would print as grey noise: that size shows name + photo only.
const verse = plan.text.readable ? `<div class="vs">${esc(v.text)}</div><div class="rf">${esc(v.ref)}</div>` : ''
return `<div class="card" style="left:${f(c.x)};top:${f(c.y)};width:${f(c.w)};height:${f(c.h)}">`
+ `<div class="nm">${esc(g.name || '')}</div>${img}<div class="vs">${esc(b.text)}</div><div class="rf">${esc(b.ref)}</div></div>`
+ `<div class="nm">${esc(g.name || '')}</div>${img}${verse}</div>`
}
const sheets = []
for (let s = 0; s < SHEET.count; s++) {
const x0 = sheetX(s), x1 = x0 + SHEET.w
const onSheet = L.cards.filter((c) => c.x < x1 && c.x + c.w > x0)
const glue = s < SHEET.count - 1
? `<div class="glue" style="left:${f(x1 - SHEET.overlap)}">▶ ${s + 2} / ${SHEET.count}</div>` : ''
sheets.push(`<section class="sheet"><div class="poster" style="left:${f(-x0)}">`
+ `<header class="ban"><h1>${esc(banner)}</h1><p>${esc(subBanner.text)} <span>${esc(subBanner.ref)}</span></p></header>`
+ onSheet.map(card).join('')
+ `<footer class="ftr"><p>${esc(footer.text)} <span>${esc(footer.ref)}</span></p></footer>`
+ glue + `</div><div class="tag">${s + 1} / ${SHEET.count}</div></section>`)
}
const css = `@page{size:${SHEET.w}in ${SHEET.h}in;margin:0}
const pages = plan.bands.map((bd) => {
const s = bd.s, last = s === plan.bands.length - 1
const glue = s > 0 ? `<div class="glue" style="top:0;height:${f(b)}">${esc(glueHere)} ▲ ${s} / ${P.sheets}</div>` : ''
const head = bd.bannerY != null
? `<header class="ban" style="top:${f(bd.bannerY)};height:${f(plan.bannerH)}"><h1>${esc(banner)}</h1>`
+ (subtitle ? `<p class="subt">${esc(subtitle)}</p>` : '')
+ `<p class="sb">${esc(sub.text)} <span>${esc(sub.ref)}</span></p></header>` : ''
const foot = bd.footerY != null
? `<footer class="ftr" style="top:${f(bd.footerY)};height:${f(plan.footerH)}"><p>${esc(footer.text)} <span>${esc(footer.ref)}</span></p></footer>` : ''
const trim = !last && P.sheets > 1 ? '<div class="trim"></div>' : ''
return `<section class="sheet">${glue}${head}${plan.cards.filter((c) => c.s === s).map(card).join('')}${foot}${trim}`
+ `<div class="tag">${s + 1} / ${P.sheets}</div></section>`
})
const W = plan.contentW
const css = `@page{size:${P.sheetW}in ${P.sheetH}in;margin:0}
*{box-sizing:border-box}html,body{margin:0;padding:0}
body{font-family:Georgia,'Times New Roman',serif;color:#1c1c1c;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${SHEET.w}in;height:${SHEET.h}in;overflow:hidden;page-break-after:always;break-after:page}
.poster{position:absolute;top:0;width:${POSTER_W}in;height:${POSTER_H}in}
.ban{position:absolute;left:${MARGIN}in;top:${MARGIN}in;width:${POSTER_W - 2 * MARGIN}in;height:${BANNER_H}in;text-align:center}
.ban h1{margin:0;font-size:2.35in;line-height:1.05;letter-spacing:.02em;font-weight:700}
.ban p{margin:.25in 0 0;font-size:.62in;font-style:italic;color:#444}.ban p span,.ftr p span{font-style:normal;font-size:.7em;color:#777}
.card{position:absolute;padding:.04in;display:flex;flex-direction:column;align-items:center;text-align:center;overflow:hidden}
.card .nm{font:700 .105in/1.1 -apple-system,'Segoe UI',Arial,sans-serif;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card img,.card .noimg{width:100%;height:58%;object-fit:cover;margin:.02in 0;background:#eee;flex:none}
.card .vs{font-size:.068in;line-height:1.15;font-style:italic}
.card .rf{font-size:.056in;color:#777;margin-top:.01in}
.ftr{position:absolute;left:${MARGIN}in;bottom:${MARGIN}in;width:${POSTER_W - 2 * MARGIN}in;height:${FOOTER_H - 0.4}in;display:flex;align-items:center;justify-content:center;text-align:center}
.ftr p{margin:0;font-size:.95in;font-style:italic}
.glue{position:absolute;top:0;width:${SHEET.overlap}in;height:${POSTER_H}in;border-left:.02in dashed #b00;display:flex;align-items:flex-end;justify-content:center;padding-bottom:.2in;font:700 .3in sans-serif;color:#b00;background:rgba(176,0,0,.03)}
.tag{position:absolute;right:.15in;bottom:.1in;font:600 .18in sans-serif;color:#999}`
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(banner)}</title><style>${css}</style></head><body>${sheets.join('')}</body></html>`
.sheet{position:relative;width:${P.sheetW}in;height:${P.sheetH}in;overflow:hidden;page-break-after:always;break-after:page}
.ban{position:absolute;left:${f(b)};width:${f(W)};text-align:center;display:flex;flex-direction:column;justify-content:center}
.ban h1{margin:0;font-size:${f(plan.bannerH * 0.42)};line-height:1.05;font-weight:700}
.ban .subt{margin:${f(plan.bannerH * 0.04)} auto 0;max-width:80%;font:${f(plan.bannerH * 0.075)}/1.3 -apple-system,'Segoe UI',Arial,sans-serif;color:#333}
.ban .sb{margin:${f(plan.bannerH * 0.04)} 0 0;font-size:${f(plan.bannerH * 0.09)};font-style:italic;color:#444}
.ban .sb span,.ftr span{font-style:normal;font-size:.7em;color:#777}
.card{position:absolute;padding:${f(plan.cw * 0.035)};display:flex;flex-direction:column;align-items:center;text-align:center;overflow:hidden}
.card .nm{font:700 ${nPt}pt/1.1 -apple-system,'Segoe UI',Arial,sans-serif;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:none}
.card img,.card .noimg{width:100%;height:${plan.text.readable ? 52 : 78}%;object-fit:cover;margin:${f(plan.cw * 0.02)} 0;background:#eee;flex:none}
.card .vs{font-size:${vPt}pt;line-height:1.15;font-style:italic}
.card .rf{font-size:${(vPt * 0.8).toFixed(1)}pt;color:#777;margin-top:1pt}
.ftr{position:absolute;left:${f(b)};width:${f(W)};display:flex;align-items:center;justify-content:center;text-align:center}
.ftr p{margin:0;font-size:${f(plan.footerH * 0.32)};font-style:italic}
.glue{position:absolute;left:0;width:100%;border-bottom:.02in dashed #b00;display:flex;align-items:center;justify-content:center;font:700 ${f(Math.min(0.5, b * 0.08))} sans-serif;color:#b00;background:rgba(176,0,0,.035);letter-spacing:.05em;text-transform:uppercase}
.trim{position:absolute;left:0;bottom:0;width:100%;border-bottom:.02in solid #999}
.tag{position:absolute;right:${f(Math.max(0.15, b * 0.1))};top:${f(Math.max(0.1, b * 0.08))};font:600 ${f(Math.max(0.12, b * 0.035))} sans-serif;color:#aaa}`
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(banner)}</title><style>${css}</style></head><body>${pages.join('')}</body></html>`
}

View file

@ -13,6 +13,12 @@
<button @click="resetZoom" class="control-btn">{{ $t('wall.resetZoom') }}</button>
<!-- BLESS (default): name, photo and a blessing verse under every group, made to print.
SOCIAL: the interactive card (pray, comments, share). -->
<label v-if="mode === 'bless'" class="poster-size">
<span>{{ $t('wall.posterSize') }}</span>
<select v-model="posterPreset" class="control-btn">
<option v-for="p in posterPresets" :key="p.id" :value="p.id">{{ p.label }}</option>
</select>
</label>
<div class="mode-toggle" role="group" :aria-label="$t('wall.modeLabel')">
<button type="button" class="control-btn" :class="{ on: mode === 'bless' }" :aria-pressed="mode === 'bless' ? 'true' : 'false'" @click="setMode('bless')">{{ $t('wall.modeBless') }}</button>
<button type="button" class="control-btn" :class="{ on: mode === 'social' }" :aria-pressed="mode === 'social' ? 'true' : 'false'" @click="setMode('social')">{{ $t('wall.modeSocial') }}</button>
@ -44,7 +50,8 @@
import { lodPlan } from '../wallLod.js'
import { wallD3 } from '../wallD3.js'
import { blessingFor, wrapText } from '../blessings.js'
import { buildBlessPosterHtml } from '../blessPoster.js'
import { buildBlessPosterHtml, PRESETS, DEFAULT_PRESET } from '../blessPoster.js'
import { POSTER } from '../blessings.js'
import { openPrintFrame } from '@map/composables/printInPlace.js'
// Wall of People Groups - D3 Zoomable Whiteboard
export default {
@ -54,6 +61,8 @@ export default {
data() {
return {
mode: 'bless', // 'bless' (default) | 'social'
posterPreset: DEFAULT_PRESET, // blessPoster.js PRESETS: 5×8 ft (3 sheets) by default
posterPresets: Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label + (p.sheets > 1 ? ' · ' + p.sheets + '×' : '') })),
printing: false, // the HD poster print is loading photos (drives the button's busy state)
svg: null,
zoom: null,
@ -482,7 +491,13 @@ export default {
const groups = (this.peopleGroups || []).map((d) => ({ name: d.name || d.post_title || '', imageUrl: d.imageUrl || '' }));
if (!groups.length) return;
this.printing = true;
const win = openPrintFrame(buildBlessPosterHtml(groups), { autoprint: true });
const texts = {
banner: this.$t('wall.blessBanner'),
subtitle: this.$t('wall.posterSubtitle', { n: groups.length.toLocaleString('en-US') }),
glueHere: this.$t('wall.glueHere'),
subBanner: POSTER.subBanner, footer: POSTER.footer,
};
const win = openPrintFrame(buildBlessPosterHtml(groups, this.posterPreset, texts), { autoprint: true });
// Busy until the photos have loaded and the print dialog is up (the frame prints on its own 'load').
const done = () => { this.printing = false; };
if (win) { try { win.addEventListener('load', () => setTimeout(done, 300), { once: true }); } catch (_) { done(); } setTimeout(done, 120000); }

View file

@ -85,6 +85,8 @@ body {
animation: wall-spin .8s linear infinite; }
@keyframes wall-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .control-btn.busy::after { animation: none; } }
.poster-size { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
.poster-size select { padding-inline-end: 8px; }
.mode-toggle { display: inline-flex; gap: 0; }
.mode-toggle .control-btn { border-radius: 0; }
.mode-toggle .control-btn:first-child { border-start-start-radius: 6px; border-end-start-radius: 6px; }