Wall of People Groups: the poster comes in 3 by 4 ft (the default), 2 by 3 ft and 5 by 8 ft, and every size keeps its scripture readable

The brief's three sizes. At 3 x 4 and 2 x 3 ft a verse under each of ~2,100 cards would print at 3 pt and 2 pt, so those sizes use the strip layout: cards of name and photo, and one blessing verse per row on a strip under it at 9 pt (33 and 34 verses). 5 x 8 ft keeps a verse under every card at 6 pt. The footer's height now comes from its text (the whole passages), with the journey never below 6 pt; the screen shows the strips as it shows the rest of the poster.
This commit is contained in:
Doxa Maps 2026-09-29 03:55:47 +00:00
parent 9e82ac8dd1
commit daf8a335ea
2 changed files with 83 additions and 41 deletions

View file

@ -2,7 +2,8 @@
* 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.
*
* THE DEFAULT IS 5 ft WIDE × 8 ft TALL, printed as THREE 5 × 3 ft sheets stacked top to bottom:
* THREE SIZES (the brief): 3 × 4 ft, the DEFAULT, and 2 × 3 ft are one sheet each; 5 × 8 ft is the large one,
* 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");
@ -10,25 +11,27 @@
* 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.
*
* 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.
* The single-sheet sizes reuse the same engine (one sheet with a plain border). The card size and every
* text size are COMPUTED from the preset and the number of groups. The brief asks that the scripture stay
* readable at every size: where a verse under every card would print below MIN_READABLE_PT (3 × 4 and
* 2 × 3 ft, for ~2,100 groups), the size uses the STRIP layout, cards of name + photo with ONE verse per
* row on a strip under it at STRIP_PT, instead of dropping the verses.
*
* Pure: plan maths + an HTML string for the shared in-place print frame (library printInPlace), and the
* SAME layout for the screen (screenPoster: what the wall shows is what prints). No Vue.
*/
import { blessingFor, POSTER, THEMES } from './blessings.js'
// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border.
// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border. The brief's
// three: 3 × 4 ft (the default, a living-room wall), 2 × 3 ft (small), 5 × 8 ft (three glued panels).
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 },
'3x3ft': { label: '3 × 3 ft', sheetW: 36, sheetH: 36, 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 },
'3x4ft': { label: '3 × 4 ft', sheetW: 36, sheetH: 48, sheets: 1, border: 2 },
'2x3ft': { label: '2 × 3 ft', sheetW: 24, sheetH: 36, sheets: 1, border: 1 },
'5x8ft': { label: '5 × 8 ft', sheetW: 60, sheetH: 36, sheets: 3, border: 6 },
}
export const DEFAULT_PRESET = '5x8ft'
export const MIN_READABLE_PT = 6 // verse size below which a preset is flagged "verses not readable"
export const DEFAULT_PRESET = '3x4ft'
export const MIN_READABLE_PT = 6 // verse size below which a verse under every card is not readable
export const STRIP_PT = 9 // the one-verse strip under each row, on the sizes that use it
const CARD_ASPECT = 0.8 // card width / height: a portrait card (name, photo, verse)
const PT = 1 / 72 // one point in inches
@ -44,7 +47,7 @@ export function planPoster(presetId, n) {
// Top of sheet 1: the title + DOXA line, then the prayer-themes table. Bottom of the last sheet: the
// priestly blessing, the journey from Abraham to the church and the closing line. All scale with the width.
const headH = W * 0.075, tableH = W * 0.17
const bannerH = headH + tableH, footerH = W * 0.2
const bannerH = headH + tableH, footerH = footerHeight(W)
// Each sheet's content band, in the sheet's own coordinates: y0..y1.
const bands = []
for (let s = 0; s < P.sheets; s++) {
@ -54,32 +57,62 @@ export function planPoster(presetId, n) {
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 })
// The largest card (fewest columns) whose grid holds n when rows are fitted to each band; `strip` is the
// height of the verse strip under each row (0 = the card layout, a verse on every card).
const fit = (strip) => {
for (let cols = 4; cols <= 400; cols++) {
const cw = W / cols, ch = cw / CARD_ASPECT
const rowsPer = bands.map((bd) => Math.max(0, Math.floor((bd.y1 - bd.y0) / (ch + strip))))
if (cols * rowsPer.reduce((a, r) => a + r, 0) >= n) return { cols, cw, ch, rowsPer, strip }
}
}
// Text sizes from the card: the verse gets ~4 lines of ~26 characters across the card width.
const versePt = (cw / 12.5) / PT // character width ≈ 0.5 em, ~24 chars per line
const versePtOf = (cw) => (cw / 12.5) / PT // character width ≈ 0.5 em, ~24 chars per line
let grid = fit(0)
const stripLayout = versePtOf(grid.cw) < MIN_READABLE_PT
if (stripLayout) grid = fit((STRIP_PT * 1.35 + 4) * PT) // one line at STRIP_PT, plus air
const { cols, cw, ch, rowsPer, strip } = grid
// Place cards band by band, row by row; in the strip layout each row's strip sits under it.
const cards = [], strips = []
let i = 0, row = 0
for (let s = 0; s < bands.length && i < n; s++) {
for (let r = 0; r < rowsPer[s] && i < n; r++, row++) {
const y = bands[s].y0 + r * (ch + strip)
for (let c = 0; c < cols && i < n; c++, i++) cards.push({ i, s, x: b + c * cw, y, w: cw, h: ch })
if (strip) strips.push({ row, s, x: b, y: y + ch, w: W, h: strip })
}
}
const versePt = versePtOf(cw)
return {
preset: P, presetId: PRESETS[presetId] ? presetId : DEFAULT_PRESET, n, cols, rowsPer, cw, ch, bands, cards,
layout: stripLayout ? 'strip' : 'cards', strips,
bannerH, footerH, headH, tableH, 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 },
text: { versePt: +versePt.toFixed(1), namePt: +(versePt * 1.25).toFixed(1), readable: !stripLayout, stripPt: STRIP_PT },
}
}
// The footer's height at content width W (inches), FROM ITS TEXT: the priestly blessing (the small
// instruction, three large lines, the small promise), the journey's seven columns (as tall as its longest
// passage wraps), and the closing line. Characters are counted at ≈ 0.5 em each, with 12% slack for
// word breaks; the footer is never shallower than a fifth of the width. The same font sizes as the CSS.
// The journey's passages are the long text: never below 6 pt (2 × 3 ft), ~12 pt on the 5 × 8 ft poster, which
// keeps the 5 × 8's verse under every card at 6 pt (a larger footer would shrink the cards below it).
export const FOOTER_FONTS = (W) => ({ small: W * 0.0065, line: W * 0.012, ref: W * 0.0075, name: W * 0.0062, station: Math.max(6 / 72, W * 0.0034), close: W * 0.0085 })
function footerHeight(W) {
const F = FOOTER_FONTS(W), gap = W * 0.006
const lines = (text, width, fs) => Math.max(1, Math.ceil((String(text).length * 0.5 * fs * 1.12) / width))
const B = POSTER.blessing
const blessH = [...B.intro, ...B.outro].reduce((a, t) => a + lines(t, W * 0.8, F.small) * F.small * 1.35, 0)
+ B.lines.reduce((a, t) => a + lines(t, W, F.line) * F.line * 1.25, 0) + F.ref * 1.6
const colW = (W - 6 * gap) / 7 - W * 0.008
const stationH = Math.max(...POSTER.journey.map((v) => lines(v.text, colW, F.station) * F.station * 1.3)) + F.name * 1.6 + F.ref * 1.6 + W * 0.006
// the closing line's English sizes its row (its translations run within a few words of it); CLOSING_EN is
// declared below, which is fine: this runs when a poster is planned, long after the module has loaded
const closeH = lines(CLOSING_EN, W * 0.8, F.close) * F.close * 1.35
return Math.max(W * 0.2, (blessH + stationH + closeH + W * 0.03) * 1.05)
}
// The closing line under the footer (agent-written); translated via texts.labels.closing when available.
const CLOSING_EN = 'Jesus Christ is the cornerstone, and we are living stones. These people groups are the reward of his suffering. Take up our priestly calling: bless them in his name, and join the global family in prayer.'
// The journey's station names (agent-written); translated via texts.labels.journey_<id> when available.
@ -162,7 +195,7 @@ function posterParts(groups, presetId, texts, withCards = true) {
const upright = /^(zh|ja|ko)/.test(String(texts.lang || '')) // CJK sidebar labels stand upright
const glueHere = texts.glueHere || 'glue here'
const f = (v) => v.toFixed(3) + 'in'
const W = plan.contentW
const W = plan.contentW, FF = FOOTER_FONTS(W) // the footer's sizes: the same numbers footerHeight() plans with
const vPt = plan.text.versePt, rPt = cardText(plan, '').refPt
// The rotated sidebar labels of the chart are sized from their text: the longest word must fit the row's
// height (a CJK label stands upright, one em a character; other scripts ≈ 0.72 em a capital with the
@ -208,12 +241,17 @@ function posterParts(groups, presetId, texts, withCards = true) {
+ `<div style="height:${f(plan.tableH)}">${table}</div></header>` : ''
const foot = bd.footerY != null
? `<footer class="ftr" dir="${dir}" style="top:${f(bd.footerY)};height:${f(plan.footerH)}">`
+ `<div class="bless">${POSTER.blessing.lines.map((l) => `<p dir="auto">${esc(l)}</p>`).join('')}<p class="br" dir="auto">${esc(POSTER.blessing.ref)}</p></div>`
+ `<div class="bless">${POSTER.blessing.intro.map((l) => `<p class="bi" dir="auto">${esc(l)}</p>`).join('')}`
+ `${POSTER.blessing.lines.map((l) => `<p dir="auto">${esc(l)}</p>`).join('')}`
+ `${POSTER.blessing.outro.map((l) => `<p class="bi" dir="auto">${esc(l)}</p>`).join('')}<p class="br" dir="auto">${esc(POSTER.blessing.ref)}</p></div>`
+ `<div class="jny">${POSTER.journey.map((v) => `<div class="st${v.centre ? ' ctr' : ''}"><p class="sn" dir="auto">${esc(lab('journey_' + v.id, JOURNEY_EN[v.id]))}</p><p class="sv" dir="auto">${esc(v.text)}</p><p class="sr" dir="auto">${esc(v.ref)}</p></div>`).join('')}</div>`
+ `<p class="close" dir="auto">${esc(lab('closing', CLOSING_EN))}</p>`
+ `</footer>` : ''
const trim = !last && P.sheets > 1 ? '<div class="trim"></div>' : ''
return { cards: withCards ? plan.cards.filter((c) => c.s === s).map(card).join('') : '', chrome: `${glue}${head}${foot}${trim}<div class="tag">${s + 1} / ${P.sheets}</div>` }
// STRIP layout: one blessing verse per row, on a strip under the row (the same rotation as the cards').
const strips = plan.strips.filter((t) => t.s === s).map((t) => { const v = blessingFor(t.row)
return `<div class="vstrip" dir="auto" style="left:${f(t.x)};top:${f(t.y)};width:${f(t.w)};height:${f(t.h)}">${esc(v.text)}<span class="vref">${esc(v.ref)}</span></div>` }).join('')
return { cards: withCards ? plan.cards.filter((c) => c.s === s).map(card).join('') : '', chrome: `${glue}${head}${strips}${foot}${trim}<div class="tag">${s + 1} / ${P.sheets}</div>` }
})
const css = `@page{size:${P.sheetW}in ${P.sheetH}in;margin:0}
*{box-sizing:border-box}html,body{margin:0;padding:0}
@ -234,15 +272,19 @@ body,.poster{font-family:Georgia,'Times New Roman',serif;color:#1c1c1c;-webkit-p
.td .tv{margin:0;font-size:${f(W * 0.0078)};line-height:1.3;font-style:italic}
.td .tr{margin:${f(W * 0.002)} 0 0;font-size:${f(W * 0.0062)};color:#666}
.ftr{position:absolute;left:${f(b)};width:${f(W)};display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;border-top:.03in solid #1c1c1c}
.bless p{margin:0;font-size:${f(W * 0.0145)};line-height:1.25;font-weight:600}
.bless .br{font-size:${f(W * 0.0075)};font-weight:400;color:#666;margin-top:${f(W * 0.002)}}
.bless p{margin:0;font-size:${f(FF.line)};line-height:1.25;font-weight:600}
.bless p.bi{max-width:80%;margin:${f(W * 0.001)} auto;font-size:${f(FF.small)};line-height:1.35;font-weight:400;font-style:italic;color:#444}
.bless .br{font-size:${f(FF.ref)};font-weight:400;color:#666;margin-top:${f(W * 0.002)}}
.jny{display:grid;grid-template-columns:repeat(7,1fr);gap:${f(W * 0.006)};margin-top:${f(W * 0.01)};width:100%;align-items:stretch}
.st{display:flex;flex-direction:column;justify-content:center;padding:${f(W * 0.003)} ${f(W * 0.004)};border-top:.02in solid #c9b58a}
.st .sn{margin:0 0 ${f(W * 0.002)};font:700 ${f(W * 0.0062)} Georgia,serif;letter-spacing:.05em;text-transform:uppercase;color:#6b4f1d}
.st .sv{margin:0;font-size:${f(W * 0.0056)};font-style:italic;line-height:1.3}
.st .sr{margin:${f(W * 0.002)} 0 0;font-size:${f(W * 0.0048)};color:#666}
.st.ctr{background:#faf8f2;border:.03in solid #1c1c1c}.st.ctr .sv{font-size:${f(W * 0.0064)};font-weight:600}.st.ctr .sn{color:#1c1c1c}
.close{margin:${f(W * 0.008)} auto 0;max-width:80%;font:700 ${f(W * 0.0085)}/1.35 -apple-system,'Segoe UI',Arial,sans-serif}
.st .sn{margin:0 0 ${f(W * 0.002)};font:700 ${f(FF.name)} Georgia,serif;letter-spacing:.05em;text-transform:uppercase;color:#6b4f1d}
.st .sv{margin:0;font-size:${f(FF.station)};font-style:italic;line-height:1.3}
.st .sr{margin:${f(W * 0.002)} 0 0;font-size:${f(Math.max(6 / 72, W * 0.0048))};color:#666}
.st.ctr{background:#faf8f2;border:.03in solid #1c1c1c}.st.ctr .sv{font-weight:600}.st.ctr .sn{color:#1c1c1c}
.close{margin:${f(W * 0.008)} auto 0;max-width:80%;font:700 ${f(FF.close)}/1.35 -apple-system,'Segoe UI',Arial,sans-serif}
.vstrip{position:absolute;display:flex;align-items:center;justify-content:center;gap:.6em;font-size:${STRIP_PT}pt;font-style:italic;
color:#2d2d2d;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-top:.01in solid #e6e6e6}
.vstrip .vref{font-style:normal;color:#777;font-size:.85em}
.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-weight:700;line-height:1.1;font-family:-apple-system,'Segoe UI',Arial,sans-serif;width:100%;white-space:nowrap;overflow:hidden;flex:none}
.card .nm.w2{white-space:normal;overflow-wrap:anywhere;hyphens:auto;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}

View file

@ -68,7 +68,7 @@ export default {
data() {
return {
mode: 'bless', // 'bless' (default) | 'social'
posterPreset: DEFAULT_PRESET, // blessPoster.js PRESETS: 5×8 ft (3 sheets) by default
posterPreset: DEFAULT_PRESET, // blessPoster.js PRESETS: 3×4 ft by default; 2×3 ft; 5×8 ft on three glued sheets
// Each label is isolated left to right (U+2066 … U+2069): "5 × 8 ft · 3×" would otherwise be reordered
// by an RTL page into "×ft · 3 8 × 5" (an <option> cannot hold a <bdi>).
posterPresets: Object.entries(PRESETS).map(([id, p]) => ({ id, label: '\u2066' + p.label + (p.sheets > 1 ? ' · ' + p.sheets + '×' : '') + '\u2069' })),
@ -152,7 +152,7 @@ export default {
this.$nextTick(() => this._wc?.startView(96));
this.lodState = { mode: 'canvas', loaded: 0, total: this.peopleGroups.length, cardPx: 0 };
},
// Print always prints the poster (blessPoster.js): 5 × 8 ft on three stacked sheets by default.
// Print always prints the poster (blessPoster.js): 3 × 4 ft by default; 5 × 8 ft is three stacked sheets.
onPrint() { return this.printBlessPoster(); },
printBlessPoster() {
if (this.printing) return;