From b0d336d0d62a2f388ee04a833ea65fcb69e232db Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 19:29:11 +0000 Subject: [PATCH] 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 --- .../wall-of-people-groups/src/blessPoster.js | 177 ++++++++++++------ .../src/components/PeopleGroupsWall.vue | 19 +- .../wall-of-people-groups/src/wall.css | 2 + 3 files changed, 136 insertions(+), 62 deletions(-) diff --git a/plugin-designs/wall-of-people-groups/src/blessPoster.js b/plugin-designs/wall-of-people-groups/src/blessPoster.js index 63a32b1ea..41bcb97bc 100644 --- a/plugin-designs/wall-of-people-groups/src/blessPoster.js +++ b/plugin-designs/wall-of-people-groups/src/blessPoster.js @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ? `` : '
' + // Below MIN_READABLE_PT the verse would print as grey noise: that size shows name + photo only. + const verse = plan.text.readable ? `
${esc(v.text)}
${esc(v.ref)}
` : '' return `
` - + `
${esc(g.name || '')}
${img}
${esc(b.text)}
${esc(b.ref)}
` + + `
${esc(g.name || '')}
${img}${verse}` } - 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 - ? `
▶ ${s + 2} / ${SHEET.count}
` : '' - sheets.push(`
` - + `

${esc(banner)}

${esc(subBanner.text)} ${esc(subBanner.ref)}

` - + onSheet.map(card).join('') - + `

${esc(footer.text)} ${esc(footer.ref)}

` - + glue + `
${s + 1} / ${SHEET.count}
`) - } - 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 ? `
${esc(glueHere)} ▲ ${s} / ${P.sheets}
` : '' + const head = bd.bannerY != null + ? `

${esc(banner)}

` + + (subtitle ? `

${esc(subtitle)}

` : '') + + `

${esc(sub.text)} ${esc(sub.ref)}

` : '' + const foot = bd.footerY != null + ? `` : '' + const trim = !last && P.sheets > 1 ? '
' : '' + return `
${glue}${head}${plan.cards.filter((c) => c.s === s).map(card).join('')}${foot}${trim}` + + `
${s + 1} / ${P.sheets}
` + }) + 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 `${esc(banner)}${sheets.join('')}` +.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 `${esc(banner)}${pages.join('')}` } diff --git a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue index b4a151013..4340c4bd1 100644 --- a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue +++ b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue @@ -13,6 +13,12 @@ +
@@ -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); } diff --git a/plugin-designs/wall-of-people-groups/src/wall.css b/plugin-designs/wall-of-people-groups/src/wall.css index 63a86a7cc..ed5f681d5 100644 --- a/plugin-designs/wall-of-people-groups/src/wall.css +++ b/plugin-designs/wall-of-people-groups/src/wall.css @@ -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; }