diff --git a/plugin-designs/wall-of-people-groups/src/blessPoster.js b/plugin-designs/wall-of-people-groups/src/blessPoster.js new file mode 100644 index 000000000..63a32b1ea --- /dev/null +++ b/plugin-designs/wall-of-people-groups/src/blessPoster.js @@ -0,0 +1,79 @@ +/** + * 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. + * + * 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. + * + * 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. + */ +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 } +} + +/** The x-window (in poster inches) that sheet s prints. */ +export const sheetX = (s) => s * (SHEET.w - SHEET.overlap) + +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) + const f = (v) => v.toFixed(3) + 'in' + const card = (c) => { + const g = groups[c.i], b = blessingFor(c.i) + const img = g.imageUrl ? `` : '
' + return `
` + + `
${esc(g.name || '')}
${img}
${esc(b.text)}
${esc(b.ref)}
` + } + 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} +*{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('')}` +} 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 33b205f9c..7db8dbb54 100644 --- a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue +++ b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue @@ -9,7 +9,7 @@ - + @@ -44,6 +44,8 @@ import { lodPlan } from '../wallLod.js' import { wallD3 } from '../wallD3.js' import { blessingFor, wrapText } from '../blessings.js' +import { buildBlessPosterHtml } from '../blessPoster.js' +import { openPrintFrame } from '@map/composables/printInPlace.js' // Wall of People Groups - D3 Zoomable Whiteboard export default { name: 'PeopleGroupsWall', @@ -472,6 +474,20 @@ export default { }, + // BLESS prints the 8 × 5 ft poster (3 sheets of 3 × 5 ft, 6-inch overlaps: blessPoster.js); + // SOCIAL keeps the HD print of the wall as it is on screen. + onPrint() { return this.mode === 'bless' ? this.printBlessPoster() : this.printWall(); }, + printBlessPoster() { + if (this.printing) return; + 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 }); + // 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); } + else done(); + }, setMode(m) { if (this.mode === m) return; this.mode = m;