UPG100 packet: full-width life charts, a shorter page-1 strategy, self-fitting points

- The life calendar and the reached chart share one grid at the page's
  full printable width: each week a 22x11 box, bold year numbers every
  5 years (larger every 10), week numbers 1/13/26/39/52, a clear rule
  every 10 years and light rules at the quarters. The reached chart has
  no week or year numbers; each 10-row block is labelled with its share
  of the list's people, 10% to 100%.
- Page 1: the UPG100 series sets its own one-paragraph Jesus column
  (series.json strategyBody) and prints the explainer's first paragraph
  only (sideBoxMore), so the three columns balance and the map grows.
  The long text is one constant shared by both header layouts; the
  Country and India-state posters print it unchanged.
- The points page takes, per language, the largest size tier that fits
  one sheet, up to the earlier two-sheet sizes. The tier comes from a
  character-count estimate; the render gate fails if the real page is
  ever taller than that estimate.
This commit is contained in:
Doxa Maps 2026-10-03 00:20:58 +00:00
parent 649038a917
commit e3d56d2f15
6 changed files with 259 additions and 84 deletions

View file

@ -90,17 +90,28 @@ const notes = []
if (!partial.includes(`5 ${ppl}`) || !partial.includes(`995 ${ppl}`)) {
fails.push(`a 0.5% list of 1,000 people does not state both totals ("5 ${ppl}" who follow Jesus, "995 ${ppl}" who do not yet)`)
} else notes.push(' ok both totals are stated in people: 5 who follow Jesus, 995 who do not yet')
// The grid is one SVG now (lifeGrid): its week and year numbers are <text class="lc-t">, and its markup names
// `data-weeks` as geometry, so the WORDS a reader sees are read with the tags stripped.
if (/week/i.test(partial.replace(/<[^>]+>/g, ' ')) || /class="lc-t/.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis')
// The grid is one SVG now (lifeGrid): its week numbers are <text class="lc-t lc-tw">, its year numbers
// "lc-t lc-ty", and its markup names `data-weeks` as geometry, so the WORDS a reader sees are read with the
// tags stripped.
const axis = /class="lc-t lc-t[wy]\b/
if (/week/i.test(partial.replace(/<[^>]+>/g, ' ')) || axis.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis')
else notes.push(' ok no weeks and no week/year axis on the page')
// ★ ITS SIDE LABELS ARE SHARES (2026-10-02): each 10-row block is 520 boxes, a tenth of the list's people,
// labelled 10% ... 100% in the page's own numerals; exactly those ten, in order.
const shares = [...partial.matchAll(/class="lc-t lc-tp[^"]*"[^>]*>([^<]*)</g)].map((m) => m[1])
const want = Array.from({ length: 10 }, (_, i) => `${((i + 1) * 10).toLocaleString('en')}%`)
if (shares.join('|') !== want.join('|')) fails.push(`the people grid's side labels should read ${want.join(' ')}, got ${shares.join(' ') || 'none'}`)
else notes.push(' ok the people grid labels each 10-row block with its share: 10% ... 100%')
// A TINY SHARE: the totals come from the unrounded figure. 0.004% of 1,000,000,000 is 40,000 people,
// which must not print as 0 because the percentage rounds to 0.00.
const tiny = page({ groupsInList: 5, groupsMatched: 5, population: 1e9, evangelicalPopulation: 40000, pctEvangelical: 0.004 })
if (!tiny.includes(`40,000 ${ppl}`) || !/ev-pct">&lt;0\.01%</.test(tiny)) fails.push('a 0.004% share of a billion does not print 40,000 people and "<0.01%"')
else notes.push(' ok a share below the printed digits keeps its real total (40,000 people) and reads "<0.01%"')
// NEG CONTROL: the life page itself keeps its axes, so the axis test above can see one when it is there.
if (!/class="lc-t"/.test(R.lifePage(zone))) fails.push('the life page lost its week axis (the axis check above cannot fail)')
// NEG CONTROL: the life page itself keeps both axes, so the axis test above can see one when it is there,
// and it carries no share labels, so the share test above is not satisfied by any grid.
const life = R.lifePage(zone)
if (!/class="lc-t lc-tw"/.test(life) || !/class="lc-t lc-ty/.test(life)) fails.push('the life page lost its week or year axis (the axis check above cannot fail)')
if (/class="lc-t lc-tp/.test(life)) fails.push('the life page carries share labels: a week of a life is not a share of people')
}
for (const n of notes) console.log(n)

View file

@ -188,12 +188,13 @@ await page.evaluate((words) => { window.__EXPECTED_RHYTHMS = words }, expected)
// the SVG declares. Used by the life-calendar checks below.
await page.evaluate(() => {
window.__lcBoxes = (svg) => {
const [x0, y0, p, dec] = String(svg.dataset.geom || '').split(',').map(Number)
// data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent).
const [x0, y0, px, dec, py = px] = String(svg.dataset.geom || '').split(',').map(Number)
const weeks = Number(svg.dataset.weeks)
const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => {
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0
const b = Math.floor(y / (10 * p + dec))
return (b * 10 + Math.round((y - b * (10 * p + dec)) / p)) * weeks + Math.round(x / p)
const b = Math.floor(y / (10 * py + dec))
return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px)
})
const off = idx((svg.querySelector('path.lc-off') || {}).getAttribute?.('d'))
const sh = svg.querySelector('path.lc-on, path.lc-reached')
@ -271,7 +272,7 @@ const r = await page.evaluate(() => {
setsSheets: document.querySelectorAll('.sheet-sets').length,
christianSheets: document.querySelectorAll('.sheet-christian').length,
// ★ THE LIFE GRIDS ARE ONE SVG EACH (2026-10-02): two paths of square subpaths (empty and shaded) placed by
// the geometry the SVG states (data-geom x0,y0,pitch,decadeGap). Each subpath is decoded back to its box index
// the geometry the SVG states (data-geom x0,y0,px,decadeGap,py). Each subpath is decoded back to its box index
// in reading order, so the checks below still see box by box what is drawn, not a count the renderer asserts.
lifeCells: [...document.querySelectorAll('.lc-frame .lc-svg')].reduce((a, g) => a + window.__lcBoxes(g).total, 0),
prayBoxes: document.querySelectorAll('.pray-box').length,
@ -284,6 +285,12 @@ const r = await page.evaluate(() => {
return Math.max(...bs.map((b) => { const top = b.getBoundingClientRect().top
return Math.round(Math.max(b.scrollHeight - b.clientHeight, ...[...b.children].map((k) => k.getBoundingClientRect().bottom - top - b.clientHeight))) })) })(),
pointsSheets: document.querySelectorAll('.sheet-points').length,
// ★ THE POINTS PAGE SIZES ITSELF FROM AN ESTIMATE (2026-10-03, data-pt-est): the page must never be taller
// than the estimate it was sized by, or the estimate is drifting and only its 3% margin still saves the page.
pointsUnder: (() => { const b = document.querySelector('.points-body[data-pt-est]'); if (!b) return null
const top = b.getBoundingClientRect().top, kids = [...b.children]
const real = Math.max(...kids.map((k) => k.getBoundingClientRect().bottom - top)) + parseFloat(getComputedStyle(kids[kids.length - 1]).marginBottom)
return { real: Math.round(real), est: Number(b.dataset.ptEst), tier: Number(b.dataset.ptTier) } })(),
funnelTip: document.querySelectorAll('.funnel-tip').length,
lifeShaded: (() => { const g = document.querySelector('.sheet-christian .lc-svg'); return g ? window.__lcBoxes(g).shaded.length : null })(),
// ★ FILLED FROM THE TOP, in reading order (the packet's design), measured on the DOM order of the cells:
@ -782,6 +789,7 @@ const listSheets = r.cards ? fdPages(r.cards) : wsPages(r.wsRows)
// The Full detail's first page reserves a block for the explanation and CLIPS what outgrows it (rather than
// let it overlap the rows): only a measurement sees a clipped line, so measure it.
if (r.pointsOver !== null && r.pointsOver > 0) fails.push(`the prayer-points page outgrows its sheet by ${r.pointsOver}px — the twelve points and the themes must fit ONE page in every language`)
if (r.pointsUnder && r.pointsUnder.real > r.pointsUnder.est) fails.push(`the prayer-points page is ${r.pointsUnder.real}px tall, over the ${r.pointsUnder.est}px its size tier ${r.pointsUnder.tier} was chosen by — the estimate (packet-render pointsHeight) under-measures this language`)
if (r.fdTopOver > 0) fails.push(`the Full detail's explanation block outgrows its reserved height by ${r.fdTopOver}px — text is clipped (raise FD_TOP_H)`)
if (r.wsRows && r.wsSheets !== wsPages(r.wsRows)) fails.push(`${r.wsRows} worksheet rows should take ${wsPages(r.wsRows)} page(s) but the packet has ${r.wsSheets}`)
// + 2 again (2026-09-30, the second revision): point 4's Mark 5:18-20 and the Father's-heart list are back ON the
@ -962,6 +970,7 @@ console.log(` funnel (page 2) : ${r.funnel
? `${r.funnelRows} row(s), ${r.funnelBars} bar(s)${r.funnelShare ? ` — "${r.funnelShare}"` : ' — no share line'}`
: 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`)
console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`)
if (r.pointsUnder) console.log(` points size tier : ${r.pointsUnder.tier} (estimated ${r.pointsUnder.est}px, measured ${r.pointsUnder.real}px)`)
console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`)
console.log(` point 4 : Mark 5:18-20 ${r.mark5Len} chars, verse 19 bold ${r.v19Len} chars, ${r.heartItems} readings`)
console.log(` metacamp.org : ${r.metacamp}`)

View file

@ -163,11 +163,12 @@ ok(!/<em>[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't
return a < 0 || i < 0 ? '' : html.slice(a, html.indexOf('</svg>', a))
}
const boxes = (svg) => {
const [x0, y0, p, dec] = ((svg.match(/data-geom="([^"]+)"/) || [])[1] || '').split(',').map(Number)
// data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent).
const [x0, y0, px, dec, py = px] = ((svg.match(/data-geom="([^"]+)"/) || [])[1] || '').split(',').map(Number)
const weeks = Number((svg.match(/data-weeks="(\d+)"/) || [])[1])
const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => {
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0, b = Math.floor(y / (10 * p + dec))
return (b * 10 + Math.round((y - b * (10 * p + dec)) / p)) * weeks + Math.round(x / p)
const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0, b = Math.floor(y / (10 * py + dec))
return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px)
})
const off = idx((svg.match(/<path class="lc-off" d="([^"]*)"/) || [])[1])
const on = idx((svg.match(/<path class="lc-(?:on|reached)" d="([^"]*)"/) || [])[1]).sort((a, b) => a - b)
@ -185,6 +186,27 @@ ok(!/<em>[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't
ok(vb(grid(full, 'sheet-life')) && vb(grid(full, 'sheet-life')) === vb(grid(full, 'sheet-christian')), 'the people grid should have the life grid\'s exact shape')
}
// ★ THE POINTS PAGE FITS ITSELF (2026-10-03): per language it takes the LARGEST size tier whose estimate fits
// one sheet, from the one-sheet sizes up to the two-sheet sizes before them (the cap). Asserted both ways: the
// tier chosen is the largest that fits, and SHORTER words print LARGER (halving the English points raises
// the English tier) while the real words keep theirs. check:packet-render holds the estimate to the real page.
{
for (const lang of ['en', 'ar', 'zh', 'fr']) {
const R = createPacketRenderer({ strings, lang })
const f = R.pointsFit, k = f.chosen(), budget = f.bodyH * f.safe
ok(k === f.tiers || f.height(k + 1) > budget, `${lang}: points tier ${k} is not the largest that fits (tier ${k + 1} estimates ${Math.round(f.height(k + 1))}px of ${Math.round(budget)})`)
ok(k === 0 || f.height(k) <= budget, `${lang}: points tier ${k} estimates ${Math.round(f.height(k))}px, over the ${Math.round(budget)}px budget`)
ok(R.pointsPage().includes(`data-pt-tier="${k}"`), `${lang}: the points page does not print at the tier it chose (${k})`)
}
const top = (lang, s) => createPacketRenderer({ strings: s, lang }).pointsFit
const short = JSON.parse(JSON.stringify(strings))
short.en.points = short.en.points.map((p) => ({ ...p, text: p.text.slice(0, Math.ceil(p.text.length / 2)) }))
const k0 = top('en', strings).chosen(), k1 = top('en', short).chosen()
ok(k1 > k0, `halving the English points' text should raise the English points tier, got ${k0} -> ${k1}`)
// the cap is the two-sheet sizes: the largest tier prints the point text at 22.5px
ok(/font:400 22\.5px Georgia/.test(createPacketRenderer({ strings, lang: 'en' }).pointsPage(top('en', strings).tiers)), 'the largest points tier should print the text at 22.5px (the two-sheet size, the cap)')
}
// ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no
// x/y — deliberately, since `reached` undefined means we do not know which side they belong on and
// drawing them anywhere would assert exactly what is unknown. On a printed page there is no hover,

View file

@ -212,16 +212,17 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
Height budget, measured on paper not guessed: head+lede end near y=210, the frame is ~1,560px,
the legend and foot ~80px, which lands above the ${H - M}px margin with room to spare. */
.pg-head-c,.pg-lede-c{text-align:center;margin-inline:auto}
.lc-frame{width:max-content;margin:0 auto;padding:14px 22px 18px;border:1.5px solid #3b463d;background:#fff}
.lc-frame{width:max-content;margin:0 auto;padding:10px 3px;border:1.5px solid #3b463d;background:#fff}
/* The life grid is one SVG (lifeGrid): two paths of boxes, hairlines between decades, small numbers. */
.lc-svg{display:block}
.lc-svg .lc-off{fill:#fff;stroke:#8f8a78;stroke-width:1}
.lc-svg .lc-on{fill:#c9a13b;stroke:#a8832a;stroke-width:1}
.lc-svg .lc-reached{fill:#2f6d4f;stroke:#2f6d4f;stroke-width:1}
.lc-svg .lc-rule{stroke:#d8d3c4;stroke-width:1}
.lc-svg .lc-t{font:400 10px Georgia,serif;fill:#6f6a5c}
.lc-svg .lc-ty{fill:#8a8577}
.lc-c{display:block;box-sizing:border-box;width:11px;height:11px;border:1px solid #8f8a78;background:#fff}
.lc-svg .lc-rule{stroke:#3b463d;stroke-width:1.6}
.lc-svg .lc-rule-q{stroke:#cfc8b4;stroke-width:1;stroke-dasharray:3 3}
.lc-svg .lc-t{font:700 18px Georgia,serif;fill:#3b463d}
.lc-svg .lc-big{font-size:20px;fill:#1b1a17}
.lc-c{display:block;box-sizing:border-box;width:22px;height:11px;border:1px solid #8f8a78;background:#fff}
/* Jesus' 33 years: warm gold, the one shading on the life page. */
.lc-on{background:#c9a13b;border-color:#a8832a}
/* The shaded share on the %-evangelical grid is the REACHED share, so it takes the accent colour and the
@ -287,12 +288,12 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
.xb-ex-note{margin-top:4px;font-size:14px;color:#6f6a5c}
/* The prayer themes and scenes above the twelve points: a thin numbered list, ruled off below. */
.pt-themes{margin:0 0 10px;padding:0 0 6px;border-bottom:1.5px solid #c9bfa4}
.pt-th-h{font:400 19px/1.3 Georgia,serif;color:#413d35}
.pt-th-h b,.pt-sc-h b{font:700 20px Georgia,serif;color:#1b1a17;letter-spacing:.02em}
.pt-th-h{font:400 var(--pt-th,19px)/1.3 Georgia,serif;color:#413d35}
.pt-th-h b,.pt-sc-h b{font:700 var(--pt-thb,20px) Georgia,serif;color:#1b1a17;letter-spacing:.02em}
.pt-th-i{font-style:italic;color:#6d766e}
.pt-th-list{display:flex;flex-wrap:wrap;gap:1px 20px;margin:2px 0 0;padding:0;list-style:none;font:400 19px/1.3 Georgia,serif;color:#3b3226}
.pt-th-list{display:flex;flex-wrap:wrap;gap:1px 20px;margin:2px 0 0;padding:0;list-style:none;font:400 var(--pt-th,19px)/1.3 Georgia,serif;color:#3b3226}
.pt-n{display:inline-block;min-width:1.3em;font-weight:700;color:#7a3b2e}
.pt-sc-r{font:600 18px Georgia,serif;color:#7a3b2e}
.pt-sc-r{font:600 var(--pt-scr,18px) Georgia,serif;color:#7a3b2e}
.ws-cols{margin:0;padding:0;list-style:none}
.ws-cols li{margin:0 0 6px;font:400 19px/1.45 Georgia,serif;color:#413d35}
.ws-cols li b{color:#1b1a17}
@ -975,46 +976,64 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// contiguous run looked like a bar; the opposite has now been asked for, and the grid is the
// product's picture. Filling from the top also makes the two pages read the same way: Jesus' 33 years
// are the first rows of a life, and the evangelical share is the first boxes of the same life.
// ★ axes:false DRAWS THE SAME BOXES WITH NO WEEK OR YEAR LABELS, for a page where a box is a share
// ★ people:true DRAWS THE SAME BOXES WITH NO WEEK OR YEAR NUMBERS, for a page where a box is a share
// of PEOPLE rather than a week of a life (the knows-Christ page): an axis numbered in weeks and years
// there would describe a unit the page is not using.
// there would describe a unit the page is not using. Its side labels state each 10-row block's share.
// ★ ONE PICTURE, NOT 5,200 BOXES (2026-10-02). The grid was 5,200 bordered HTML boxes in a CSS grid, twice
// in the full packet: heavy to lay out and to hold in a phone's memory. It is now ONE inline SVG per page whose
// boxes are two <path>s (the empty weeks and the shaded ones, a square subpath each): the same 100 x 52 boxes,
// vector on paper, a handful of nodes in the page. Both pages use the SAME geometry (LC), so the people grid
// has exactly the life grid's shape; it simply prints no week or year numbers, since its box is a share of
// people. data-* attributes state what is drawn, and the gates read the subpaths back, box by box.
const LC = { cell: 11, pitch: 14, x0: 36, y0: 22, dec: 12 };
function lifeGrid({ shaded = 0, shadeClass = 'lc-on', axes = true } = {}) {
// ★ FULL WIDTH, ONE PAGE (2026-10-02). The calendar now spans the page's printable width: each week is a box
// twice as wide as it is tall (22 x 11px, ~3.2 x 1.6mm on letter). It could not be a bigger SQUARE: 100 rows
// are what limit a box, and 26px squares would need ~2,650px of grid on a ~1,550px page. Read at a glance:
// bold year numbers every 5 years (larger every 10), week numbers 1 / 13 / 26 / 39 / 52 across the top,
// a clear rule every 10 years and light rules at the quarters. Labels are 18-20px (7.3-8.2pt on letter,
// 7.1-8pt on A4, which prints the letter sheet at 97%).
// ONE PATH, TWO PAGES: the reached chart is this grid with the same boxes, rules and shape; its box is a share
// of the list's PEOPLE, so it has no week numbers and labels each 10-row block with its share: 10% ... 100%.
// cw x ch is a box, px / py the pitch, x0 the label column, y0 the week-number row, dec the extra gap per decade;
// pr / pb keep the last column's week number and the last row's label inside the picture.
const LC = { cw: 22, ch: 11, px: 25, py: 14, x0: 66, y0: 30, dec: 8, pr: 8, pb: 8 };
function lifeGrid({ shaded = 0, shadeClass = 'lc-on', people = false } = {}) {
const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS;
const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0)));
// Without axes the label margins are split evenly round the boxes, so the grid sits centred in the same frame.
const x0 = axes ? LC.x0 : LC.x0 / 2, y0 = axes ? LC.y0 : LC.y0 / 2;
const at = (i) => { const r = Math.floor(i / WEEKS), c = i % WEEKS; return [x0 + c * LC.pitch, y0 + r * LC.pitch + Math.floor(r / 10) * LC.dec]; };
const sq = (i) => { const [x, y] = at(i); return `M${x + 0.5} ${y + 0.5}h${LC.cell - 1}v${LC.cell - 1}h${1 - LC.cell}z`; };
// The people grid has no week-number row: its top and bottom margins are evened out, so it sits centred in the same frame.
const x0 = LC.x0, y0 = people ? (LC.y0 + LC.pb) / 2 : LC.y0;
const at = (i) => { const r = Math.floor(i / WEEKS), c = i % WEEKS; return [x0 + c * LC.px, y0 + r * LC.py + Math.floor(r / 10) * LC.dec]; };
const sq = (i) => { const [x, y] = at(i); return `M${x + 0.5} ${y + 0.5}h${LC.cw - 1}v${LC.ch - 1}h${1 - LC.cw}z`; };
let on = '', off = '';
for (let i = 0; i < TOTAL; i++) { if (i < n) on += sq(i); else off += sq(i); }
const gap = LC.pitch - LC.cell;
const W = LC.x0 + WEEKS * LC.pitch - gap, H = LC.y0 + YEARS * LC.pitch + (YEARS / 10 - 1) * LC.dec - gap;
const gx = LC.px - LC.cw, gy = LC.py - LC.ch;
const gridW = WEEKS * LC.px - gx, gridH = YEARS * LC.py + (YEARS / 10 - 1) * LC.dec - gy;
const W = LC.x0 + gridW + LC.pr, H = LC.y0 + gridH + LC.pb;
// A side label sits beside its row, vertically centred on the box.
const side = (row, text, cls) => { const [, top] = at(row * WEEKS); return `<text class="lc-t ${cls}" x="${x0 - 6}" y="${top + LC.ch / 2}" dominant-baseline="central" text-anchor="end">${text}</text>`; };
let marks = '';
if (axes) {
// Week numbers over their own column; year numbers (1, 5, 10, …) beside their row.
for (const w of [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]) marks += `<text class="lc-t" x="${LC.x0 + (w - 1) * LC.pitch + LC.cell / 2}" y="12" text-anchor="middle">${w}</text>`;
for (let y = 1; y <= YEARS; y++) {
if (y !== 1 && y % 5) continue;
const [, top] = at((y - 1) * WEEKS);
marks += `<text class="lc-t lc-ty" x="${LC.x0 - 6}" y="${top + LC.cell - 1.5}" text-anchor="end">${num(y)}</text>`;
if (!people) {
for (const w of [1, 13, 26, 39, 52]) marks += `<text class="lc-t lc-tw" x="${x0 + (w - 1) * LC.px + LC.cw / 2}" y="${y0 - 10}" text-anchor="middle">${num(w)}</text>`;
for (let y = 1; y <= YEARS; y++) if (y === 1 || y % 5 === 0) marks += side(y - 1, num(y), y % 10 ? 'lc-ty' : 'lc-ty lc-big');
} else {
// lc-tp, not lc-ty: a share of the people, never a year (check:evangelical-share tells the two apart).
// 18px like the 5-year labels: "100%" at 20px overran the label column in a fallback serif.
for (let d = 1; d <= YEARS / 10; d++) marks += side(d * 10 - 1, `${num(d * 10)}%`, 'lc-tp');
}
}
// A hairline between decades, in the extra gap, so ten years read as one block.
// A clear rule between decades, in the extra gap, so ten years read as one block; light rules at the quarters.
let rules = '';
for (const w of [13, 26, 39]) {
const x = x0 + w * LC.px - gx / 2;
rules += `<line class="lc-rule-q" x1="${x}" x2="${x}" y1="${y0 - 4}" y2="${y0 + gridH + 4}"/>`;
}
for (let d = 1; d < YEARS / 10; d++) {
const [, top] = at(d * 10 * WEEKS);
rules += `<line class="lc-rule" x1="${x0}" x2="${x0 + WEEKS * LC.pitch - gap}" y1="${top - LC.dec / 2 - gap / 2}" y2="${top - LC.dec / 2 - gap / 2}"/>`;
const y = top - LC.dec / 2 - gy / 2;
rules += `<line class="lc-rule" x1="${x0 - 4}" x2="${x0 + gridW + 4}" y1="${y}" y2="${y}"/>`;
}
const svg = `<svg class="lc-svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" data-total="${TOTAL}" data-shaded="${n}" data-weeks="${WEEKS}" data-geom="${x0},${y0},${LC.pitch},${LC.dec}">`
+ `${marks}${rules}<path class="lc-off" d="${off}"/>${on ? `<path class="${shadeClass}" d="${on}"/>` : ''}</svg>`;
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-frame${axes ? '' : ' lc-plain'}">${svg}</div>` };
// data-geom: x0,y0,px,dec,py. The gates decode every box back to its index from it.
const svg = `<svg class="lc-svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" data-total="${TOTAL}" data-shaded="${n}" data-weeks="${WEEKS}" data-geom="${x0},${y0},${LC.px},${LC.dec},${LC.py}">`
+ `${rules}${marks}<path class="lc-off" d="${off}"/>${on ? `<path class="${shadeClass}" d="${on}"/>` : ''}</svg>`;
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-frame${people ? ' lc-plain' : ''}">${svg}</div>` };
}
// Page 2 — a human life, unshaded except for the weeks already lived if the caller supplies an age.
@ -1072,7 +1091,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// people), and drift every other total. A real share below the last printed digit reads "<0.01%".
const share = known ? Number(raw) : null;
const pct = known ? Math.round(share * 100) / 100 : null;
const g = lifeGrid({ shaded: known ? (share / 100) * 5200 : 0, shadeClass: 'lc-reached', axes: false });
const g = lifeGrid({ shaded: known ? (share / 100) * 5200 : 0, shadeClass: 'lc-reached', people: true });
const fmtPct = (x) => `${Number(x).toLocaleString(lang, { maximumFractionDigits: 2 })}%`;
const tiny = known && share > 0 && pct === 0;
const pop = known && Number(christian.population) > 0 ? Number(christian.population) : null;
@ -1356,7 +1375,102 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// the point text 18.5px (~7.5pt on letter), the themes 19px, 5px between points, the Mark 5 passage 16px; the
// credit line stays 0.28in above the edge. Measured in all 11 locales: the tightest (fr 75px, hi 79px) keep
// that much under the last point; check:packet-render fails overflow (pointsOver).
const PT = { head: 22, sub: 19, text: 18.5, refs: 16, gap: 5, passage: 16, top: 82 };
// ★ THE PAGE FITS ITSELF (2026-10-03). In its own language the page takes the LARGEST sizes that still fit
// ONE sheet: from PT_MIN (the one-sheet sizes above, measured to fit all 11 locales) up to PT_MAX (the
// two-sheet sizes before them, the cap), in PT_TIERS even steps, so shorter words print larger by
// themselves. The credits keep their size. th / thb / scr are the themes' text, titles and scene refs.
// ★ ESTIMATED, NEVER MEASURED, like the description cell (FIT BEFORE CLAMP): this module also runs under
// Node. pointsHeight() lays the page out by character count, each block's lines from a greedy word wrap at
// its own width, with per-script advances measured on this page in the packet's fonts and rounded UP; a
// tier is taken only when the estimate fits with PT_SAFE to spare. check:packet-render measures the real
// page in all 11 locales behind it (pointsOver).
const PT_MIN = { head: 22, sub: 19, text: 18.5, refs: 16, gap: 5, passage: 16, th: 19, thb: 20, scr: 18 };
const PT_MAX = { head: 25, sub: 21, text: 22.5, refs: 18.5, gap: 18, passage: 20, th: 21, thb: 22, scr: 19 };
const PT_TOP = 82, PT_TIERS = 10, PT_SAFE = 0.97;
const PT_BODY_H = H - (M + PT_TOP) - 80; // the .points-body box: 1719px
const ptTier = (k) => {
const o = {};
for (const n of Object.keys(PT_MIN)) o[n] = Math.round((PT_MIN[n] + (PT_MAX[n] - PT_MIN[n]) * k / PT_TIERS) * 4) / 4;
return o;
};
// em per character by script, measured on this page (2026-10-03: Latin text 0.50-0.53, Cyrillic 0.58,
// Arabic 0.44, Devanagari 0.54, Chinese 1.03) and rounded up; bold and semibold run wider.
const CJK = /[\u2E80-\u9FFF\u3000-\u303F\uF900-\uFAFF\uFF00-\uFFEF]/;
const ptAdv = (ch) => {
const c = ch.codePointAt(0);
if (CJK.test(ch)) return 1.05;
if (c >= 0x0400 && c <= 0x052F) return 0.6;
if ((c >= 0x0600 && c <= 0x06FF) || (c >= 0xFB50 && c <= 0xFEFF)) return 0.47;
if (c >= 0x0900 && c <= 0x097F) return 0.56;
return 0.54;
};
const PT_BOLD = 1.2, PT_SEMI = 1.12;
const ptW = (str, fs, wt) => [...str].reduce((a, ch) => a + ptAdv(ch), 0) * fs * wt;
// Lines a run of segments ([text, px, weight]) takes at `width`: words wrap at spaces, and a word with Chinese
// in it breaks between any two characters, as the browser breaks it.
function ptLines(segs, width) {
let lines = 0, x = 0;
const put = (w) => { if (!lines) lines = 1; if (x > 0 && x + w > width) { lines++; x = 0; } x += w; };
for (const [str, fs, wt] of segs) {
const words = String(str || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
for (const word of words) {
if (CJK.test(word)) for (const ch of word) put(ptW(ch, fs, wt));
else put(ptW(word, fs, wt));
x += ptW(' ', fs, wt);
}
}
return lines;
}
// The flex rows a wrapping list takes: items of the given widths, 20px apart.
const ptRows = (widths, width) => {
let rows = 1, x = 0;
for (const w of widths) { if (x > 0 && x + 20 + w > width) { rows++; x = 0; } x += (x > 0 ? 20 : 0) + w; }
return rows;
};
// A line's "normal" height, measured: 1.18-1.19 x its size in the serif faces, 1.31-1.32 where it is Chinese.
const ptNormal = (str) => (CJK.test(String(str || '')) ? 1.33 : 1.19);
function pointsHeight(sz) {
const BW = W - 2 * M; // the body's width
const themes = Array.isArray(L.themes) && L.themes.length === 7 ? L.themes : EN.themes || [];
const scenes = Array.isArray(L.scenes) && L.scenes.length === 3 ? L.scenes : EN.scenes || [];
const sref = EN.scene_refs || [];
let h = 0;
if (themes.length) {
const row = sz.th * 1.3, numW = 1.3 * sz.th + ptW(' ', sz.th, 1);
h += ptLines([[t('themes_title'), sz.thb, PT_BOLD], [t('themes_intro'), sz.th, 1]], BW) * Math.max(row, sz.thb * ptNormal(t('themes_title')));
const tRows = ptRows(themes.map((x) => numW + ptW(String(x), sz.th, 1)), BW);
const sRows = ptRows([ptW(String(t('scenes_title')), sz.thb, PT_BOLD), ...scenes.map((x, i) => numW + ptW(String(x), sz.th, 1)
+ ptW(` ${(sref[i] || []).map(refText).join(' · ')}`, sz.scr, PT_SEMI))], BW);
h += 2 + tRows * row + (tRows - 1) + 2 + sRows * row + (sRows - 1) + 6 + 1.5 + 10;
}
for (const p of POINTS) {
const refs = (p.refs || []).map(refText).join(' · ');
h += ptLines([[`${p.n}. ${tr(p.rhythm)}`, sz.head, PT_BOLD], ...(p.sub ? [[`— ${p.sub}`, sz.sub, 1]] : [])], BW) * sz.head * ptNormal(tr(p.rhythm));
h += 2 + ptLines([[p.text, sz.text, 1]], BW) * sz.text * 1.3;
h += 2 + ptLines([[refs, sz.refs, PT_SEMI]], BW) * sz.refs * ptNormal(refs);
if (p.print_passage) {
const IW = BW - 15; // the passage and heart blocks' 12px inset and 3px rule
const passage = String(t('point4_passage') || ''), bold = String(t('point4_passage_bold') || '');
const at = bold ? passage.indexOf(bold) : -1;
const segs = at < 0 ? [[passage, sz.passage, 1]]
: [[passage.slice(0, at), sz.passage, 1], [bold, sz.passage, PT_BOLD], [passage.slice(at + bold.length), sz.passage, 1]];
h += 6 + ptLines(segs, IW) * sz.passage * 1.4 + 2 + ptLines([[t('point4_passage_credit'), 13, 1]], IW) * 13 * ptNormal(t('point4_passage_credit'));
h += 8 + ptLines([[t('point4_heart_title'), sz.refs, PT_BOLD + 0.15]], IW) * sz.refs * ptNormal(t('point4_heart_title'));
h += 2 + ptLines([[t('point4_heart_lede'), sz.passage, 1]], IW) * sz.passage * 1.35;
h += 6 + ptLines([[t('point4_heart_leadin'), sz.passage - 1, 1]], IW) * (sz.passage - 1) * 1.3;
const CWH = (IW - 28 - 40) / 2; // the heart list's two columns
const items = HEART.reduce((a, x) => a + ptLines([[x.who, sz.passage - 1, PT_BOLD], [` · ${refText(x.ref)}`, sz.passage - 1, 1]], CWH), 0);
h += 3 + Math.ceil(items / 2) * (sz.passage - 1) * 1.4;
}
h += sz.gap;
}
return h;
}
// The largest tier whose estimate fits; the floor when none does (the gate then reports the overflow).
const fitPoints = () => {
for (let k = PT_TIERS; k > 0; k--) if (pointsHeight(ptTier(k)) <= PT_BODY_H * PT_SAFE) return { k, sz: ptTier(k) };
return { k: 0, sz: ptTier(0) };
};
// ⚠ ISOLATE EACH REFERENCE FOR BIDI. Scripture references stay in English
// (a reader looks them up in their own Bible), so on an RTL page they are
// LTR runs inside an RTL paragraph. Without isolation the Unicode bidi
@ -1368,10 +1482,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// time from its `books` table: a Chinese packet cited "Matthew 27:27", English in every locale.
// A localized name in a right-to-left script is isolated rtl, so the book reads on the right and
// the chapter:verse keeps its digit order; a Latin reference stays the ltr isolate described above.
const refSpan = (r) => {
const refBook = (r) => {
const m = String(r).match(/^(.*?)\s+(\d[\d:–\-,; ]*)$/);
const book = m && (L.books || {})[m[1]];
const text = book ? `${book} ${m[2]}` : String(r);
return { book, text: book ? `${book} ${m[2]}` : String(r) };
};
const refText = (r) => refBook(r).text;
const refSpan = (r) => {
const { book, text } = refBook(r);
const dir = book && RTL.has(lang) ? 'rtl' : 'ltr';
return `<span dir="${dir}" style="unicode-bidi:isolate">${esc(text)}</span>`;
};
@ -1394,7 +1512,11 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
`${esc(x)} <span class="pt-sc-r">${(sref[i] || []).map(refSpan).join(' · ')}</span>`)).join('')}</ol>
</div>`;
}
function pointsPage() {
// `tier` forces a size tier (0..PT_TIERS); probes and gates only. data-pt-tier states the tier chosen and
// data-pt-est its estimated height, so check:packet-render can hold the estimate to the real page.
function pointsPage(tier) {
const k = Number.isInteger(tier) ? Math.max(0, Math.min(PT_TIERS, tier)) : fitPoints().k;
const PT = ptTier(k);
const rows = POINTS.map((p) => {
const refs = (p.refs || []).map(refSpan).join(' · ');
const sub = p.sub ? ` <span style="font:italic ${PT.sub}px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
@ -1403,7 +1525,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
<div style="font:400 ${PT.text}px Georgia,serif;color:#3b3226;line-height:1.3;margin-top:2px">${esc(p.text)}</div>
<div style="font:600 ${PT.refs}px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
${p.print_passage ? passageBlock() + heartBlock() : ''}
${p.print_passage ? passageBlock(PT) + heartBlock(PT) : ''}
</div>`;
});
return sheet(`
@ -1411,7 +1533,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
<div class="points-title" style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
</div>
<div class="points-body" style="position:absolute;left:${M}px;top:${M + PT.top}px;right:${M}px;bottom:80px">${themesBlock()}${rows.join('')}</div>
<div class="points-body" data-pt-tier="${k}" data-pt-est="${Math.round(pointsHeight(PT))}" style="position:absolute;left:${M}px;top:${M + PT_TOP}px;right:${M}px;bottom:80px;--pt-th:${PT.th}px;--pt-thb:${PT.thb}px;--pt-scr:${PT.scr}px">${themesBlock()}${rows.join('')}</div>
<div class="fine">${esc(t('points_attrib'))}</div>`, 'sheet-points');
}
@ -1420,7 +1542,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// through one person. The person is named in the packet's language; the passage is addressed in the
// reader's own Bible (refs stay canonical English in the data and the book is localized, like the point
// refs). class="p4-heart" is a TEST HOOK: the gates count its seven items.
const heartBlock = () => `<div class="p4-heart" style="margin-top:8px;padding-inline-start:12px;border-inline-start:3px solid #d9d3c4">
const heartBlock = (PT) => `<div class="p4-heart" style="margin-top:8px;padding-inline-start:12px;border-inline-start:3px solid #d9d3c4">
<div class="p4-heart-h" style="font:700 ${PT.refs}px Georgia,serif;letter-spacing:.08em;color:#7a3b2e">${esc(t('point4_heart_title'))}</div>
<div class="p4-heart-lede" style="font:400 ${PT.passage}px/1.35 Georgia,serif;color:#2f2a22;margin-top:2px">${esc(t('point4_heart_lede'))}</div>
<div class="p4-heart-leadin" style="font:italic ${PT.passage - 1}px/1.3 Georgia,serif;color:#4a4a45;margin-top:6px">${esc(t('point4_heart_leadin'))}</div>
@ -1434,7 +1556,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// WEB text where a language has none on file, as the packet always did). Verse 19 in bold:
// point4_passage_bold holds it verbatim from the same text, so the bold is a substring match and never a
// second rendering of scripture. class="p4-passage" / "p4-v19" are TEST HOOKS.
function passageBlock() {
function passageBlock(PT) {
const passage = String(t('point4_passage') || '');
const bold = String(t('point4_passage_bold') || '');
const at = bold ? passage.indexOf(bold) : -1;
@ -1774,5 +1896,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// VARIANTS is exported so the UI enumerates the packets it can print instead of hardcoding two
// strings in a menu — add a third variant here and the caret gains an entry with no UI change.
return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, liftMap, mapFromImage, mapSheetFiles, PER_PAGE, FD_FIRST, WS_FIRST, WS_REST, W, H, VARIANTS };
// pointsFit: the points page's estimate per tier, for the gates and probes that check it against the real page.
const pointsFit = { tiers: PT_TIERS, bodyH: PT_BODY_H, safe: PT_SAFE, height: (k) => pointsHeight(ptTier(k)), chosen: () => fitPoints().k };
return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, pointsFit, wheelPage, css, mapCss, liftMap, mapFromImage, mapSheetFiles, PER_PAGE, FD_FIRST, WS_FIRST, WS_REST, W, H, VARIANTS };
}

View file

@ -835,6 +835,22 @@ const HC = { gut: 32, fs: 22, lh: 27, keyFs: 19, keyLh: 24, headLh: 30, gap: 8,
// these columns 2-6% wider (measured 1.05-1.06x in DejaVu Sans). Each block is centred on its
// width at 1.03x, halfway, so any overrun splits across both sides instead of all running right.
const HC_FALLBACK = 1.03;
// The two explainer columns' text, ONE copy for both header layouts (the head-columns band and the classic
// header), so the two can no longer drift apart. A series may set its own Jesus column in series.json
// (poster.strategyBody: one paragraph, or a list of them), as UPG100 does; the series that do not, the
// Country and India-state posters, print this default.
// COPY IS THE OWN (05-poster-vision/source-material/01). It KEEPS THE MIRACLE: earlier drafts reduced the
// account to a seating arrangement, when the point is that Jesus supernaturally MULTIPLIED the offering
// and the grouping is how it was distributed.
const STRATEGY_DEFAULT = [
'Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.',
'Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).',
];
const strategyParas = () => (POSTER_TEXT.strategyBody ? [].concat(POSTER_TEXT.strategyBody) : STRATEGY_DEFAULT);
// The explainer's further paragraphs (its first is series.json's sideBoxBody). A series may set its own in
// series.json (poster.sideBoxMore: one paragraph, or a list; [] prints none), as UPG100 does to keep its band even.
const whatIsMore = () => (POSTER_TEXT.sideBoxMore !== undefined ? [].concat(POSTER_TEXT.sideBoxMore) : [WHAT_IS_MORE]);
const WHAT_IS_MORE = 'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.';
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;
@ -844,16 +860,13 @@ function headColumnsBand(top, facts, approxLists) {
const keyW = (k) => textW(k.toUpperCase(), HC.keyFs) + k.length * HC.keyFs * 0.14;
// Column order (2026-10-02): Jesus' strategy | the facts | what the series is.
const cols = [
[{ head: 'Jesus’ Strategy to Feed the 5000', paras: [
'Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.',
'Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).',
] }],
[{ head: 'Jesus’ Strategy to Feed the 5000', paras: strategyParas() }],
facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, TW) })),
[{ 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.')
.replace('{approxLists}', String(approxLists)).replace('{seriesName}', SERIES_NAME),
'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.',
...whatIsMore(),
] }],
];
let frags = '', bottom = top;
@ -2327,33 +2340,25 @@ async function renderPoster(zone, pins, zoneFC) {
headBlock += `<text x="${b1x[1]}" y="${py}" class="hvh">${esc(POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?')}</text>`;
const bodyTop = py + 20;
// COPY IS THE OWN (05-poster-vision/source-material/01). It KEEPS THE
// MIRACLE — earlier drafts reduced the account to a seating arrangement, when
// the point is that Jesus supernaturally MULTIPLIED the offering and the
// grouping is how it was distributed.
// The text is STRATEGY_DEFAULT unless the series sets its own (see strategyParas).
let cy0 = bodyTop;
cy0 = paraInto(push,
`Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.`,
{ x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
cy0 += 5;
cy0 = paraInto(push,
`Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).`,
{ x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
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' });
});
let cy1 = bodyTop;
cy1 = paraInto(push,
// {approxLists} and {seriesName} are filled here rather than in series.json, so the sentence
// still states the MEASURED list count (approxLists is derived from the zones actually built)
// and can never carry a typed number that drifts from the sheet it is printed on.
(POSTER_TEXT.sideBoxBody
const whatIs = (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.')
.replace('{approxLists}', String(approxLists))
.replace('{seriesName}', SERIES_NAME),
{ x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
cy1 += 5;
cy1 = paraInto(push,
`Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.`,
{ x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
.replace('{seriesName}', SERIES_NAME);
[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' });
});
const b1Bot = Math.max(cy0, cy1);
headBlock += colRule(GRID_L + B1_CW + B1_GUT / 2, b1Top - 12, b1Bot - 12);

View file

@ -29,7 +29,9 @@
"titleQualifier": "the coloured words before the region name, e.g. 'Unengaged of' (:2427)",
"seriesName": "the name in quotes on the Series line and in the side box (:1877, :1907, :2115)",
"sideBoxHeading": "the right-hand explainer's heading (:2097)",
"sideBoxBody": "that explainer's first paragraph (:2115)"
"sideBoxBody": "that explainer's first paragraph (:2115)",
"strategyBody": "the Jesus column's text: one paragraph, or a list of them; absent = poster-render's STRATEGY_DEFAULT",
"sideBoxMore": "the explainer's further paragraphs: one, or a list; [] prints none; absent = poster-render's WHAT_IS_MORE"
},
"one_switch": [
"SERIES is the ONE switch (2026-09-28). SERIES=country implies the country zone",
@ -67,6 +69,8 @@
"seriesName": "UPG100 List",
"sideBoxHeading": "What is a UPG100 List?",
"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.",
"strategyBody": "Jesus fed the 5,000 by multiplying a small offering. He had the crowd sit in groups of hundreds and fifties (Mark 6:40). So we pray by name, a few groups at a time.",
"sideBoxMore": [],
"headColumns": true
},
"networks": [