Poster Design: the Sizes tab draws every ladder size to scale; the header's Share is a rounded square

Sizes reads the one shared ladder (POSTER_LADDER in POSTER_LADDER_ORDER)
and feeds each size's sheets straight into the library's PanelSheets, the
component the Wall uses for its 5 x 8 ft preview: multi-sheet sizes show
their sheets pulled apart with the red glue strip. A lineup draws every
size at one scale on one baseline, its scale fitted to the row's width so
a phone keeps every size in view. Each card also states the sheets, the
glued overlap, whether photos fit beside names, and how it prints.

The sheets carry the 3 x 3 ft country poster's layout at that size
(src/layoutSvg.js: header, map, names, table, scripture footer, no words),
labelled as a layout, since the renderer does not draw the large sizes
yet. Every size is sized from panelSheets' own screenLayout, so the fit
computed here is the box PanelSheets renders.

The composite header's Share button takes the header form
(shape="rounded-square"), as Big Picture's top bar does.
This commit is contained in:
Doxa Maps 2026-10-01 00:02:24 +00:00
parent 29227cb6d3
commit 828fd22e86
3 changed files with 202 additions and 17 deletions

View file

@ -117,7 +117,8 @@ useShadowStyles(PANE_GRID_STYLES + `
<div class="pdmfe">
<header class="hdr">
<ViewSelect :views="spec.panes" :current="view" :legend="t('tabs.selectView')" :label="t('tabs.views')" @select="go" />
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
<!-- A HEADER button, so the header form: a rounded square (Big Picture's DashboardTopBar uses the same). -->
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" shape="rounded-square" />
</header>
<div class="stage" ref="stage"></div>
</div>

View file

@ -1,35 +1,164 @@
<script setup>
/**
* poster-sizes.vue: every poster size, each card a print preview at that size.
* poster-sizes.vue: every poster size, drawn to scale, with the sheets each one prints on.
*
* @element poster-design
* @description Every poster size the print picker offers (standard paper up to 5 × 8 ft), each drawn as a
* print preview, portrait only, from the one shared size table.
* @description Every poster size the print picker offers, from standard paper to 5 × 8 ft, drawn to scale.
* A size that prints on several sheets shows them pulled apart with the red strip where the next sheet is
* glued on, as the Wall of People Groups previews its 5 × 8 ft poster.
*
* The sizes come from the shared size table in library/constants/posterSizes.js and nowhere else: a
* second list here would drift from the print picker. Until that table carries the size ladder, this
* tab says so rather than inventing one.
* The sizes are the ONE shared ladder (library/constants/posterSizes.js POSTER_LADDER, in
* POSTER_LADDER_ORDER), never a second list: a size the ladder gains appears here by itself. Each size's
* `sheets` goes straight into the library's PanelSheets, the same component and the same geometry
* (panelSheets.js) the print uses. What fills the sheets is the 3 × 3 country poster's LAYOUT drawn at that
* size (src/layoutSvg.js), labelled as a layout, because the renderer does not draw the large sizes yet.
*/
import { computed } from 'vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import PanelSheets from '@map/components/poster/PanelSheets.vue'
import { planPanels, screenLayout } from '@map/components/poster/panelSheets.js'
import { POSTER_LADDER, POSTER_LADDER_ORDER, paperFor } from '@map/constants/posterSizes.js'
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { layoutSvgUrl } from './src/layoutSvg.js'
const t = useOptionalT('')
const heading = computed(() => t('posterDesign.sizes.heading'))
const waiting = computed(() => t('posterDesign.sizes.waiting'))
const { locale } = useOptionalI18n('')
// A number as the sizes are spoken: whole, or one decimal place (5.5).
const n = (x) => (Number.isInteger(x) ? String(x) : String(Math.round(x * 10) / 10))
// Feet once a side reaches a yard and both sides are whole or half feet (3 × 5.5 ft); inches below that (18 × 24 in).
function dims(w, h) {
const ft = (x) => x / 12
const halfFeet = (x) => Number.isInteger(ft(x) * 2)
if (Math.min(w, h) >= 36 && halfFeet(w) && halfFeet(h)) return t('posterDesign.sizes.dimsFt', { w: n(ft(w)), h: n(ft(h)) })
return t('posterDesign.sizes.dimsIn', { w: n(w), h: n(h) })
}
// Standard printer paper is Letter or A4 by the reader's region, as the print picker decides it.
const paperIsA4 = computed(() => {
const l = locale && (locale.value ?? locale)
const tag = (typeof navigator !== 'undefined' && navigator.language) || (typeof l === 'string' ? l : '') || 'en'
return paperFor(tag) === 'a4'
})
const sizes = computed(() => POSTER_LADDER_ORDER.map((key) => POSTER_LADDER[key]).filter(Boolean).map((s) => {
const { sheetW, sheetH, count, direction, joinIn } = s.sheets
return {
key: s.id,
w: s.inches.w,
h: s.inches.h,
sheets: { sheetW, sheetH, count, direction, joinIn },
title: s.paper ? t('posterDesign.sizes.paper') : dims(s.inches.w, s.inches.h),
subtitle: s.paper ? t(paperIsA4.value ? 'posterDesign.sizes.paperA4' : 'posterDesign.sizes.paperLetter') : '',
count,
sheetDims: dims(sheetW, sheetH),
overlap: count > 1 ? t('posterDesign.sizes.dimsInOne', { v: n(joinIn) }) : '',
photos: !!s.photos,
shop: s.path === 'pdf',
src: layoutSvgUrl(s.inches.w, s.inches.h),
}
}))
// The box PanelSheets draws, in inches: its own screenLayout of the same plan, so a fit computed here is the fit
// it renders (each sheet whole, pulled apart by the gap, which is taller than the finished poster).
const LINEUP_GAP_IN = 1.5
const CARD_GAP_IN = 0.75
function world(s, gapIn) {
const L = screenLayout(planPanels(s.sheets), { gapIn, pxPerIn: 1 })
return { w: L.worldW, h: L.worldH }
}
// THE LINEUP: every size at ONE scale, standing on one baseline, so 5 × 8 ft looks as big beside paper as it
// is. Each size gets an equal column; the scale is the largest at which the tallest drawing is LINEUP_H and
// the widest still fits its column, so on a phone every size stays in view and stays to scale.
const LINEUP_H = 240 // px for the tallest size, when there is room
const COL_GAP = 12
const rowEl = ref(null)
const rowW = ref(1000)
let ro = null
onMounted(() => {
if (!rowEl.value || typeof ResizeObserver === 'undefined') return
ro = new ResizeObserver(([e]) => { if (e && e.contentRect.width) rowW.value = e.contentRect.width })
ro.observe(rowEl.value)
})
onBeforeUnmount(() => { ro?.disconnect(); ro = null })
const pxPerIn = computed(() => {
const ws = sizes.value.map((s) => world(s, LINEUP_GAP_IN))
if (!ws.length) return 1
const col = (rowW.value - COL_GAP * (ws.length - 1)) / ws.length
return Math.min(LINEUP_H / Math.max(...ws.map((w) => w.h)), col / Math.max(...ws.map((w) => w.w)))
})
const lineupW = (s) => `${Math.max(4, world(s, LINEUP_GAP_IN).w * pxPerIn.value)}px`
useShadowStyles(`
:host{display:block;width:100%;height:100%;min-height:420px}
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:28px 20px;background:#f3f3f1;color:#3b463d;
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif}
.pd-sizes__h{margin:0 0 8px;font-size:22px;font-weight:700}
.pd-sizes__note{margin:0;max-width:60ch;color:#6d766e}
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:24px 20px 40px;background:#f3f3f1;color:#3b463d;
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;box-sizing:border-box}
.pd-sizes__h{margin:0 0 6px;font-size:22px;font-weight:700}
.pd-sizes__intro{margin:0 0 18px;max-width:70ch;color:#6d766e}
.pd-lineup{background:#fff;border:1px solid #d1d4cd;border-radius:12px;padding:16px 16px 10px;margin:0 0 24px}
.pd-lineup__h{margin:0 0 12px;font-size:14px;font-weight:700;color:#6d766e}
.pd-lineup__row{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:end;gap:12px;
padding-bottom:6px;border-bottom:2px solid #d1d4cd}
.pd-lineup__item{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0}
.pd-lineup__label{font-size:12px;font-weight:600;text-align:center;line-height:1.25;overflow-wrap:anywhere}
.pd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px}
.pd-card{background:#fff;border:1px solid #d1d4cd;border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0}
.pd-card__art{display:flex;justify-content:center;align-items:flex-end;height:220px;background:#f3f3f1;border-radius:8px;padding:10px;box-sizing:border-box}
.pd-card__art > .pd-card__fit{height:100%;display:flex;align-items:flex-end;justify-content:center;width:100%}
.pd-card__title{margin:0;font-size:17px;font-weight:700}
.pd-card__sub{margin:-8px 0 0;color:#6d766e;font-size:13px}
.pd-card__rows{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:0;font-size:13px}
.pd-card__rows dt{color:#6d766e}
.pd-card__rows dd{margin:0}
.pd-note{margin:20px 0 0;max-width:70ch;color:#6d766e;font-size:13px}
`, 'poster-design-sizes')
// A card's art box leaves 200 × 222 px for the drawing; it fits by whichever side binds first.
const ART_H = 200
const ART_W = 222
function artWidth(s) {
const { w, h } = world(s, CARD_GAP_IN)
return `${Math.floor(w * Math.min(ART_H / h, ART_W / w))}px`
}
</script>
<template>
<div class="pd-sizes">
<h2 class="pd-sizes__h">{{ heading }}</h2>
<p class="pd-sizes__note">{{ waiting }}</p>
<h2 class="pd-sizes__h">{{ t('posterDesign.sizes.heading') }}</h2>
<p class="pd-sizes__intro">{{ t('posterDesign.sizes.intro') }}</p>
<section class="pd-lineup" :aria-label="t('posterDesign.sizes.lineup')">
<h3 class="pd-lineup__h">{{ t('posterDesign.sizes.lineup') }}</h3>
<div ref="rowEl" class="pd-lineup__row">
<div v-for="s in sizes" :key="s.key" class="pd-lineup__item">
<div :style="{ width: lineupW(s) }">
<PanelSheets v-bind="s.sheets" :gap-in="LINEUP_GAP_IN" :src="s.src" />
</div>
<span class="pd-lineup__label">{{ s.title }}</span>
</div>
</div>
</section>
<div class="pd-grid">
<article v-for="s in sizes" :key="s.key" class="pd-card" :data-size="s.key">
<div class="pd-card__art">
<div class="pd-card__fit">
<div :style="{ width: artWidth(s) }"><PanelSheets v-bind="s.sheets" :gap-in="CARD_GAP_IN" :src="s.src" /></div>
</div>
</div>
<h3 class="pd-card__title">{{ s.title }}</h3>
<p v-if="s.subtitle" class="pd-card__sub">{{ s.subtitle }}</p>
<dl class="pd-card__rows">
<dt>{{ t('posterDesign.sizes.rowSheets') }}</dt><dd>{{ s.count }}</dd>
<template v-if="s.count > 1">
<dt>{{ t('posterDesign.sizes.rowSheet') }}</dt><dd>{{ s.sheetDims }}</dd>
<dt>{{ t('posterDesign.sizes.rowOverlap') }}</dt><dd>{{ s.overlap }}</dd>
</template>
<dt>{{ t('posterDesign.sizes.rowPhotos') }}</dt><dd>{{ s.photos ? t('posterDesign.sizes.yes') : t('posterDesign.sizes.no') }}</dd>
<dt>{{ t('posterDesign.sizes.rowPrint') }}</dt><dd>{{ s.shop ? t('posterDesign.sizes.printShop') : t('posterDesign.sizes.printHome') }}</dd>
</dl>
</article>
</div>
<p class="pd-note">{{ t('posterDesign.sizes.layoutNote') }}</p>
</div>
</template>

View file

@ -0,0 +1,55 @@
/**
* layoutSvg.js: the LAYOUT of the 3 × 3 country poster (a header band, the map, the names down both sides,
* a table beneath, and the bold scripture footer), drawn at any finished size as an SVG image.
*
* The Sizes tab shows how each ladder size prints before the renderer draws that size for real, so this is
* a layout, not a poster: blocks in the poster's own colours, no words (nothing to translate, nothing that
* reads as real data). Units are INCHES (the viewBox is the finished w × h), so PanelSheets can slice it
* across sheets exactly as it slices a real poster.
*/
const PAPER = '#f7f4ec'
const INK = '#3b463d'
const LINE = '#c9c3b3'
const LAND = '#dcd3ba'
const SEA = '#e6eef0'
const ACCENT = '#92b195'
/** @returns {string} an image URL (a data: URL of an SVG) of the layout at w × h inches. */
export function layoutSvgUrl(w, h) {
const m = Math.min(w, h) * 0.05 // outer margin
const headH = h * 0.09
const footH = h * 0.08
const bodyTop = m + headH + m * 0.6
const bodyBottom = h - m - footH - m * 0.6
const tableH = (bodyBottom - bodyTop) * 0.22
const mapBottom = bodyBottom - tableH - m * 0.5
const colW = (w - 2 * m) * 0.22 // a name column on each side of the map
const mapX = m + colW + m * 0.4
const mapW = w - 2 * mapX
const lineGap = Math.max(h / 80, 0.35)
const nameLines = (x) => {
let out = ''
for (let y = bodyTop + lineGap; y < mapBottom - lineGap / 2; y += lineGap) {
out += `<rect x="${x.toFixed(2)}" y="${(y - lineGap * 0.28).toFixed(2)}" width="${(colW * (0.55 + 0.4 * ((y * 7) % 1))).toFixed(2)}" height="${(lineGap * 0.4).toFixed(2)}" fill="${LINE}"/>`
}
return out
}
let table = ''
const rows = 4
for (let r = 0; r <= rows; r++) {
const y = mapBottom + m * 0.5 + (tableH * r) / rows
table += `<line x1="${m}" y1="${y.toFixed(2)}" x2="${(w - m).toFixed(2)}" y2="${y.toFixed(2)}" stroke="${LINE}" stroke-width="${(lineGap * 0.12).toFixed(3)}"/>`
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">`
+ `<rect width="${w}" height="${h}" fill="${PAPER}"/>`
+ `<rect x="${m}" y="${m}" width="${w - 2 * m}" height="${headH}" rx="${(headH * 0.12).toFixed(2)}" fill="${INK}"/>`
+ `<rect x="${(m + (w - 2 * m) * 0.25).toFixed(2)}" y="${(m + headH * 0.38).toFixed(2)}" width="${((w - 2 * m) * 0.5).toFixed(2)}" height="${(headH * 0.24).toFixed(2)}" fill="${PAPER}" opacity=".85"/>`
+ `<rect x="${mapX.toFixed(2)}" y="${bodyTop.toFixed(2)}" width="${mapW.toFixed(2)}" height="${(mapBottom - bodyTop).toFixed(2)}" fill="${SEA}"/>`
+ `<ellipse cx="${(mapX + mapW * 0.5).toFixed(2)}" cy="${((bodyTop + mapBottom) / 2).toFixed(2)}" rx="${(mapW * 0.36).toFixed(2)}" ry="${((mapBottom - bodyTop) * 0.34).toFixed(2)}" fill="${LAND}"/>`
+ nameLines(m) + nameLines(w - m - colW)
+ table
+ `<rect x="${m}" y="${(h - m - footH).toFixed(2)}" width="${w - 2 * m}" height="${footH}" rx="${(footH * 0.12).toFixed(2)}" fill="${ACCENT}"/>`
+ `<rect x="${(m + (w - 2 * m) * 0.15).toFixed(2)}" y="${(h - m - footH * 0.62).toFixed(2)}" width="${((w - 2 * m) * 0.7).toFixed(2)}" height="${(footH * 0.24).toFixed(2)}" fill="${INK}" opacity=".8"/>`
+ `</svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}