From 4ec5905492a6f5d6b889f178fc3615cdc382b90e Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 08:46:36 +0000 Subject: [PATCH] UPG100 poster header: the three columns end at about the same height (WIP: verifying) --- library/packet/poster-render.js | 38 +++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/library/packet/poster-render.js b/library/packet/poster-render.js index 25b7a95a7..c70adf5f8 100644 --- a/library/packet/poster-render.js +++ b/library/packet/poster-render.js @@ -875,15 +875,24 @@ function boldLines(text, width) { } function headColumnsBand(top, facts, approxLists) { const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G; - const CW = (GRID_W - 2 * HC.gut) / 3; + const AVAIL = GRID_W - 2 * HC.gut; // The measure leaves room for a fallback font (see HC_FALLBACK): a block set to it stays inside its column. - const TW = (CW - 10 - BAND_TEXT_PAD / 2) * 0.92; + const measure = (cw) => (cw - 10 - BAND_TEXT_PAD / 2) * 0.92; + // ★ EVEN COLUMNS (2026-10-03): when the facts column runs longer than the two text columns it is WIDENED (and + // shorter, it is narrowed) so the three end at about the same height. The two side columns stay equal, so they + // still mirror each other round the centre. The centre's share is searched from 26% to 46% of the three-column + // width; the split whose TALLEST column is shortest wins, and a tie goes to the split nearest equal widths, so a + // zone that is already balanced keeps equal columns. measureHeader calls this same function, so the map below + // gets exactly the height that is left. // Uppercase, letterspaced keys: measured as capitals plus the .14em tracking. const keyW = (k) => textW(k.toUpperCase(), HC.keyFs) + k.length * HC.keyFs * 0.14; + // Each column's height at a given measure (the same arithmetic the drawing below advances y by). + const proseH = (col, tw) => HC.headLh + col[0].paras.reduce((h, t, k) => h + (k ? HC.gap : 0) + boldLines(t, tw).length * HC.lh, 0); + const factsH = (tw) => facts.reduce((h, [, value], k) => h + (k ? HC.gap : 0) + HC.keyLh + wrapPx(value, HC.fs, tw).length * HC.lh, 0); // Column order (2026-10-02): Jesus' strategy | the facts | what the series is. const cols = [ [{ head: 'Jesus’ Strategy to Feed the 5000', paras: strategyParas() }], - facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, TW) })), + null, // the facts column, wrapped below at the centre's measure [{ head: POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?', paras: [ (POSTER_TEXT.sideBoxBody || 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.') @@ -891,9 +900,22 @@ function headColumnsBand(top, facts, approxLists) { ...whatIsMore(), ] }], ]; + // The split: equal thirds first, then centre shares on a 1% step ordered by their distance from a third, so + // among splits whose tallest column is equally short, the one nearest equal widths is found first and kept. + const shares = Array.from({ length: 21 }, (_, k) => 0.26 + k * 0.01).sort((a, b) => Math.abs(a - 1 / 3) - Math.abs(b - 1 / 3)); + let best = null; + for (const cwC of [1 / 3, ...shares].map((f) => AVAIL * f)) { + const cwS = (AVAIL - cwC) / 2; + const tall = Math.max(proseH(cols[0], measure(cwS)), factsH(measure(cwC)), proseH(cols[2], measure(cwS))); + if (!best || tall < best.tall - 0.5) best = { cwC, cwS, tall }; + } + const widths = [best.cwS, best.cwC, best.cwS]; + const lefts = [GRID_L, GRID_L + best.cwS + HC.gut, GRID_L + best.cwS + HC.gut + best.cwC + HC.gut]; + cols[1] = facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, measure(best.cwC)) })); let frags = '', bottom = top; cols.forEach((col, i) => { - const centre = GRID_L + i * (CW + HC.gut) + CW / 2; + const CW = widths[i], TW = measure(CW); + const centre = lefts[i] + CW / 2; if (col[0] && col[0].key !== undefined) { // the facts column: a bold key above each value const blockW = Math.min(TW, Math.max(...col.map((r) => Math.max(keyW(r.key), ...r.lines.map((ln) => textW(ln, HC.fs)))))); const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1); @@ -922,7 +944,7 @@ function headColumnsBand(top, facts, approxLists) { } }); for (let i = 1; i < 3; i++) { - const rx = (GRID_L + i * (CW + HC.gut) - HC.gut / 2).toFixed(1); + const rx = (lefts[i] - HC.gut / 2).toFixed(1); frags += ``; } return { frags, bottom }; @@ -2368,7 +2390,7 @@ async function renderPoster(zone, pins, zoneFC) { let cy0 = bodyTop; strategyParas().forEach((text, k) => { if (k) cy0 += 5; - cy0 = paraInto(push, text, { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); + cy0 = paraInto(push, String(text).replace(/\*\*/g, ''), { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); }); let cy1 = bodyTop; @@ -2379,9 +2401,11 @@ async function renderPoster(zone, pins, zoneFC) { || 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.') .replace('{approxLists}', String(approxLists)) .replace('{seriesName}', SERIES_NAME); + // A **...** bold marker is the band's (boldLines); this classic header prints plain, so the markers are dropped. + const plain = (t) => String(t).replace(/\*\*/g, ''); [whatIs, ...whatIsMore()].forEach((text, k) => { if (k) cy1 += 5; - cy1 = paraInto(push, text, { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); + cy1 = paraInto(push, plain(text), { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); }); const b1Bot = Math.max(cy0, cy1);