The packet follows the printed Module 1 sheets: page 1 is the map alone, the calendar holds Jesus' 33 years, the sets share one page

A review of the full-detail packet, page by page:
- Page 1: a poster carries its own title, so the packet's title above it printed two headers and
  shrank the map to fit beneath them. A poster sheet now fills the page inside the white margin by
  itself; in another language its kicker and title become the packet's own, fitted to the English
  width. A map-only sheet keeps the packet's title in the band it leaves empty.
- The life calendar is laid out like the Module 1 "Life Calendar in Weeks": square boxes, week
  numbers across the top, the year down the side, a rule between decades, framed and centred. The
  first 33 years are shaded as the life of Jesus (1,716 weeks).
- The %-evangelical grid fills from the top in reading order instead of scattering.
- The flow pages had no page padding at all, so their headings sat on the sheet's edge; they keep
  the same margin as every other page now.
- The bounded and centered sets are one page, two square panels side by side as on the Module 1
  list of 100, each headed by name with a cross at its centre. The centered set is a target of nine
  numbered bands, its dots spread through their bands by the shared ringBands geometry (no overlaps,
  none on the numbers), and the legend explains every ring with its count.
- The lightweight packet lists every group as a worksheet: a short note, one column header per page,
  numbered rows sized for paper with room to tick, no photos or descriptions.

Seven new strings in all eleven languages. check:packet-a1-structure and check:packet-variants
assert the new contract two-sided (a poster page 1 has one header, a map-only page 1 still has the
packet's; every ring is named, a populated one with its count and an empty one with a dash).
This commit is contained in:
Doxa Maps 2026-09-29 03:48:46 +00:00
parent ff0c433fce
commit 4e2685cdad
4 changed files with 410 additions and 173 deletions

View file

@ -70,13 +70,13 @@ function sequence(html) {
: /funnel-title/.test(seg) ? 'triangle'
: /sheet-life/.test(cls) ? 'life'
: /sheet-christian/.test(cls) ? 'christian'
: /sheet-sets-bounded/.test(cls) ? 'bounded'
: /sheet-sets-centered/.test(cls) ? 'centered'
: /sheet-sets\b/.test(cls) ? 'sets'
: /sheet-worksheet/.test(cls) ? 'worksheet'
: /wheel-title/.test(seg) ? 'wheel'
: /class="card"/.test(seg) ? 'profiles'
: /p4-passage|points_title|Prayer Points/.test(seg) ? 'points'
: `?${cls.trim()}`
if (kind === 'profiles' && out[out.length - 1] === 'profiles') return // one entry per run
if ((kind === 'profiles' || kind === 'worksheet') && out[out.length - 1] === kind) return // one entry per run
out.push(kind)
})
return out.join(' · ')
@ -84,8 +84,8 @@ function sequence(html) {
const liftedMap = R.liftMap(MAP_ONLY_SHEET)
const full = R.buildPacket(zone, groups, new Map(), liftedMap, withK, christian, 'full', sets)
const light = R.buildPacket(zone, groups, new Map(), liftedMap, withK, christian, 'lightweight', sets)
const FULL = 'map · triangle · life · christian · profiles · bounded · centered · wheel · points'
const LIGHT = 'map · triangle · profiles · wheel · points'
const FULL = 'map · triangle · life · christian · profiles · sets · wheel · points'
const LIGHT = 'map · triangle · worksheet · wheel · points'
ok(sequence(full) === FULL, `full-detail order is\n ${sequence(full)}\n expected\n ${FULL}`, `full detail: ${FULL}`)
ok(sequence(light) === LIGHT, `lightweight order is\n ${sequence(light)}\n expected\n ${LIGHT}`, `lightweight: ${LIGHT}`)
@ -101,21 +101,29 @@ const box = (html) => {
if (b) ok(Math.abs(b.x + 34 * b.s - M) < 1 && b.y + 34 * b.s >= M - 1,
`the map-only sheet's names start ${(b.x + 34 * b.s).toFixed(1)}px from the edge; every packet page keeps ${M}px`,
`its names land on the same ${M}px margin as every other page (scale ${b.s})`)
// ★ A POSTER FILLS THE PAGE inside the white margin M (Driver 2026-09-29: page 1 is the map alone).
const p = box(R.liftMap(POSTER_SHEET))
ok(p && p.kind === 'poster' && p.y >= 250 && p.y + p.h <= H - M + 0.5 && p.w <= W - 2 * M + 0.5,
`a 3x4 poster was not fitted under the title: ${JSON.stringify(p)}`, `a 3x4 poster falls back fitted under the title (scale ${p && p.s})`)
ok(p && p.kind === 'poster' && p.x >= M - 0.5 && p.y >= M - 0.5 && p.x + p.w <= W - M + 0.5 && p.y + p.h <= H - M + 0.5
&& (Math.abs(p.w - (W - 2 * M)) < 1 || Math.abs(p.h - (H - 2 * M)) < 1),
`a 3x4 poster does not fill the page inside the margin: ${JSON.stringify(p)}`, `a 3x4 poster fills the page inside the ${M}px margin (scale ${p && p.s})`)
// the lifted artwork is laid out left-to-right and must not inherit an rtl page's direction
ok(/class="map-sheet" dir="ltr"/.test(liftedMap) && /class="map-sheet" dir="ltr"/.test(R.liftMap(POSTER_SHEET)),
'the lifted map sheet does not pin dir="ltr", so an Arabic packet mirrors the English artwork', 'the lifted sheet pins dir="ltr" inside an rtl page')
ok(R.liftMap(ICON_PAGE) === '', 'an icon-sized <svg> (an SPA fallback page) was lifted as if it were a map', 'an icon-sized <svg> is refused, not printed as the map')
ok(R.liftMap('<html>no svg</html>') === '', 'a page with no <svg> did not lift to empty')
const p1 = full.slice(0, full.indexOf('<div class="sheet', full.indexOf('sheet-map') + 10))
// A MAP-ONLY sheet leaves its top band empty for the packet's title, so there the title is drawn — after the map.
const firstSheet = (html) => { const a = html.indexOf('<div class="sheet sheet-map'); return html.slice(a, html.indexOf('<div class="sheet', a + 10)) }
const p1 = firstSheet(full)
ok(p1.indexOf('class="map-sheet"') >= 0 && p1.indexOf('class="map-sheet"') < p1.indexOf('map-series'),
'page 1 draws its title BEFORE the map, so the map-only sheet\'s background paints over it', 'the map is drawn first, the title over it')
'page 1 draws its title BEFORE the map, so the map-only sheet\'s background paints over it', 'on a map-only sheet the map is drawn first, the title over it')
// two-sided: the series line WITH a packet count, and NOT without one
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG 100 prayer lists/.test(p1), 'page 1 does not say it is one of the 28 UPG 100 prayer lists')
ok(/class="map-series"[\s\S]{0,200}One of 28 UPG 100 prayer lists/.test(p1), 'page 1 (map-only) does not say it is one of the 28 UPG 100 prayer lists')
const noKp1 = R.mapPage(zone, groups, liftedMap, noK)
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'page 1 says "One of 28 UPG 100 prayer lists" — and stays silent without a count')
ok(!/map-series/.test(noKp1), 'page 1 prints a series line with NO packet count to state', 'a map-only page 1 says "One of 28 UPG 100 prayer lists" — and stays silent without a count')
// ★ A POSTER PAGE 1 HAS ONE HEADER, THE POSTER'S — two-sided against the map-only page above, which has the packet's.
const posterP1 = R.mapPage(zone, groups, R.liftMap(POSTER_SHEET), withK)
ok(/sheet-map-poster/.test(posterP1) && !/map-series/.test(posterP1) && !posterP1.includes(strings.en.map_page_sub.split('{count}')[0]),
'a poster page 1 still draws the packet\'s own title over the poster — two headers', 'a poster page 1 is the poster alone: no second title, no series line')
ok(/class="map-missing"/.test(R.mapPage(zone, groups, R.liftMap(ICON_PAGE), withK)), 'a refused sheet does not fall through to the missing-map notice')
}

View file

@ -85,17 +85,23 @@ ok(count(light, /sheet-christian/g) === 0, 'lightweight still carries the %-evan
ok(count(light, /funnel-title/g) === count(full, /funnel-title/g), 'lightweight lost the funnel (the narrowing triangle)')
// …and the SETS page, which is back matter a reader sits with rather than carries — the same
// reason the life calendar and the %-evangelical grid are dropped.
// ★ TWO SETS PAGES, ONE PER CHART — the full packet has four back pages (bounded, centered,
// wheel, points). Asserted by each page's own class, so losing EITHER chart fails by name rather than
// being hidden inside a total.
ok(count(full, /sheet-sets-bounded/g) === 1, 'the full packet has no bounded-set page')
ok(count(full, /sheet-sets-centered/g) === 1, 'the full packet has no centered-set page')
// ★ ONE SETS PAGE CARRYING BOTH CHARTS (Driver 2026-09-29: two square panels side by side), asserted
// by each chart's own class, so losing EITHER chart fails by name rather than inside a total.
ok(count(full, /<div class="sheet [^"]*sheet-sets\b/g) === 1, 'the full packet should have exactly ONE sets page')
ok(count(full, /sheet-sets-bounded/g) === 1, 'the full packet has no bounded-set chart')
ok(count(full, /sheet-sets-centered/g) === 1, 'the full packet has no centered-set chart')
ok(count(full, /class="set-cross"/g) === 2, 'both set charts must carry the cross at their centre')
ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carries a sets page')
// ⚠ FOUR, not three: the delta grew again when the sets page split into its two charts, and this
// number is meant to be UPDATED in the commit that adds a page, never relaxed into a range. A
// tolerance here would make the assertion unable to see the next page that goes missing.
ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 4,
`lightweight should be exactly 4 sheets shorter, got ${count(full, /class="sheet/g)} vs ${count(light, /class="sheet/g)}`)
// ⚠ THE DELTA IS DERIVED, never relaxed into a range: lightweight drops three back pages (life, %
// grid, sets) AND lists the same people as a worksheet, whose pages hold more rows than profile pages.
{
const profilePages = Math.ceil(45 / R.PER_PAGE)
const worksheetPages = count(light, /<div class="sheet sheet-worksheet/g)
const want = 3 + profilePages - worksheetPages
ok(worksheetPages >= 1 && count(light, /class="ws-row"/g) === 45, `the lightweight worksheet should list all 45 groups, got ${count(light, /class="ws-row"/g)}`)
ok(count(full, /<div class="sheet/g) - count(light, /<div class="sheet/g) === want,
`lightweight should be exactly ${want} sheets shorter, got ${count(full, /<div class="sheet/g)} vs ${count(light, /<div class="sheet/g)}`)
}
// ★ 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,
@ -115,27 +121,27 @@ ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 4,
const only = (rings) => {
const html = R.buildPacket(zone, groups, new Map(), '<svg/>', funnel, christian, 'full',
{ bounded: [{ id: 'x', reached: true }], centered: rings.map((r, i) => ({ id: `r${i}`, ring: r })) })
return (html.match(/<li><b>[^<]*<\/b>([^<]*)<\/li>/g) || []).map((s) => s.replace(/<[^>]+>/g, '').trim())
return [...html.matchAll(/<li><span class="ring-n">[^<]*<\/span>([^<]*)<span class="ring-c">([^<]*)<\/span><\/li>/g)]
.map((m) => ({ name: m[1].trim(), count: m[2].trim() }))
}
const all = only([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
ok(all.length === 10, `all ten rings populated should list ten names, got ${all.length}`)
ok(all.length === 10, `the legend should name all ten rings, got ${all.length}`)
// ⚠ THE CATALOGUE IS AUTHORITATIVE OVER THE COMMENT. CenteredSetTarget.vue's header abbreviates
// ring 7-9 as "Same family"; the shipping string in gospelInternet.json is "Same language family",
// and that is the word the SCREEN renders. I hardcoded the header's shorthand here and this
// assertion caught it the moment the packet adopted the screen's wording verbatim. A summary in a
// comment is a description of the data, never a second source of it.
// ring 7-9 as "Same family"; the shipping string is "Same language family", and that is the word the
// SCREEN renders, so the printed legend must use it too.
const want = ['Same dialect, same people', 'Same dialect, near culture', 'Same dialect, different culture',
'Same language, same people', 'Same language, near culture', 'Same language, different culture',
'Same language family, same people', 'Same language family, near culture',
'Same language family, different culture', 'Nearby only']
want.forEach((w, i) => ok((all[i] || '').endsWith(w),
want.forEach((w, i) => ok(((all[i] || {}).name || '') === w,
`ring ${i + 1} should read ${JSON.stringify(w)}, got ${JSON.stringify(all[i])}`))
// ⚠ ONLY THE POPULATED RINGS. Ten rows of vocabulary beside a figure is a glossary, not a legend,
// and a named ring with no dot invites the reader to hunt for something that is not there.
// ★ EVERY RING IS EXPLAINED (Driver 2026-09-29: "a legend to explain each ring"), and each row
// states its own count. Two-sided: a populated ring shows its number, an empty ring shows a dash.
const sparse = only([1, 1, 3, 7, 7, 7])
ok(sparse.length === 3, `only the populated rings should be listed, got ${sparse.length}: ${JSON.stringify(sparse)}`)
ok(/^2 /.test(sparse[0]) && /^1 /.test(sparse[1]) && /^3 /.test(sparse[2]),
`the legend should carry each ring's own count, got ${JSON.stringify(sparse)}`)
ok(sparse.length === 10, `every ring should be explained even when empty, got ${sparse.length} rows`)
ok(sparse[0].count === '2' && sparse[2].count === '1' && sparse[6].count === '3',
`the legend should carry each populated ring's own count, got ${JSON.stringify(sparse.map((r) => r.count))}`)
ok(sparse[1].count === '—' && sparse[9].count === '—', `an empty ring should read as a dash, got ${JSON.stringify(sparse.map((r) => r.count))}`)
// ⚠ NO VERTICAL-OVERFLOW ASSERTION HERE, AND THAT IS DELIBERATE — I wrote one and deleted it.
// gospel hit exactly this failure on their screen panel (21ebb4f2, "Stop the ring legend pushing
@ -206,4 +212,4 @@ if (fails.length) {
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('PACKET-VARIANTS: PASS — both packets print every group, lightweight drops exactly the four back-matter sheets (life calendar, % grid, bounded set, centered set), an unknown variant falls back to full, the Prayer List prints the PACKET and not the columnar list, the Cards button and its size row are unchanged, and one ref means one open tape.')
console.log('PACKET-VARIANTS: PASS — both packets print every group, lightweight drops exactly the three back-matter sheets (life calendar, % grid, sets) and lists everyone as a worksheet, an unknown variant falls back to full, the Prayer List prints the PACKET and not the columnar list, the Cards button and its size row are unchanged, and one ref means one open tape.')

View file

@ -44,13 +44,13 @@ const SCRIPT_FONT = {
file: 'noto-serif-devanagari-400-devanagari.woff2' }, // Nepali, same script
};
import { scatterCells } from '../components/charts/packetCharts.js'
// The set LAYOUTS (gospel's figures, promoted to library/ by regions). Imported rather than injected
// because — unlike describeGroup's country resolver — these have ZERO imports of their own and run
// under plain node, so they cannot break `node internal/check-*.mjs`. Verified by importing and
// CALLING them, not by reading them. They return data; the SVG is ours.
import { layoutBoundedSet, boundedSetTally } from '../components/charts/boundedSet.js'
import { layoutCenteredSet, ringTally, RING_MAX } from '../components/charts/centeredSet.js'
import { layoutCenteredSet, ringTally, ringRadii, RING_MAX } from '../components/charts/centeredSet.js'
import { bandFill } from '../components/charts/ringBands.js'
// `describe` is INJECTED at the same seam as `strings`, and deliberately not imported. This module
// has (almost) no imports on purpose: the SAME file runs under Node for the offline generator and
@ -119,6 +119,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// The three checkbox cells. Each holds a box plus its short label; sized so all three fit beside
// the description without the labels wrapping in the longest locale we ship.
const PRAY_CELL = 84;
const SET_PANEL = 660; // one square sets panel: two + a 40px gap fit 1380px
const PRAY_W = PRAY_CELL * PRAY_COLS.length; // 252px
// What is left is the description — the widest column on the page, and the point of the table.
// For comparison an ENTIRE card in the old 2x10 grid was 683px wide; the description alone is 50%
@ -180,40 +181,85 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
(1380/52)=26.5px per side x 100 rows = 2,654px of grid in 1,521px of printable height — it
overflows the page by 1.7x. Caught by computing the height before rendering rather than by
looking at an overflowing PDF. 13px rows put 100 rows in 1,300px with room for the legend. */
.lc-grid{display:grid;grid-template-columns:repeat(52,1fr);grid-auto-rows:13px;gap:2px;width:100%}
.lc-c{display:block;width:100%;height:100%;border:1px solid #d8d3c4;border-radius:1px;background:#fff}
.lc-on{background:#b9b2a0;border-color:#b9b2a0}
/* ★ SUPERSEDED BY THE PRINTED LIFE CALENDAR'S LAYOUT (2026-09-29). Squares now, because the grid no
longer stretches to the page width: 11px boxes on a 14px pitch make 100 rows + 9 decade rules
~1,500px tall and 52 columns ~730px wide, centred in a frame like the Module 1 sheet.
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-wk,.lc-dec{display:grid;grid-template-columns:30px repeat(52,11px);column-gap:3px}
.lc-wk{height:16px;margin-bottom:4px;font:400 10px Georgia,serif;color:#6f6a5c}
.lc-wk span{text-align:center;line-height:16px}
.lc-dec{grid-auto-rows:11px;row-gap:3px}
.lc-dec+.lc-dec{margin-top:6px;padding-top:6px;border-top:1px solid #d8d3c4}
.lc-y{font:400 10px/11px Georgia,serif;color:#8a8577;text-align:end;padding-inline-end:6px}
.lc-c{display:block;box-sizing:border-box;width:11px;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
unshaded majority stays quiet — the visual weight belongs to what is still waiting. */
.lc-reached{background:#2f6d4f;border-color:#2f6d4f}
.lc-legend{display:flex;gap:34px;margin:20px 0 0;font:400 17px Georgia,serif;color:#413d35}
.lc-legend i{display:inline-block;width:15px;height:15px;vertical-align:-2px;margin-inline-end:8px}
.lc-foot{margin:14px 0 0;font:400 14px Georgia,serif;color:#6f6a5c}
.lc-legend{display:flex;justify-content:center;gap:34px;margin:16px 0 0;font:400 17px Georgia,serif;color:#413d35}
.lc-legend i{display:inline-block;vertical-align:-1px;margin-inline-end:8px}
.lc-foot{margin:12px auto 0;max-width:1180px;text-align:center;font:400 14px Georgia,serif;color:#6f6a5c}
.sheet-map-poster{background:#fff}
/* ⚠ THE FLOW PAGES HAD NO PADDING. .sheet is a bare box, and the pages laid out in flow (the life
calendar, the % grid, the sets) started their heading at the sheet's very edge, where a printer's
unprintable margin cuts it. Every flow page now keeps the same margin the absolute pages do. */
.sheet-flow{box-sizing:border-box;padding:${M + 12}px ${M}px ${M}px}
/* ── THE LIGHTWEIGHT WORKSHEET ── white paper, one header row per page, rows sized to tick. */
.sheet-worksheet{background:#fff}
.ws-top{display:flex;justify-content:space-between;margin:0 0 14px;font:400 15px Georgia,serif;color:#8a8577}
.ws-h1{font-size:34px}
.ws-intro{margin:8px 0 20px;max-width:1180px;font:400 22px/1.5 Georgia,serif;color:#413d35}
.ws-head,.ws-row{display:grid;grid-template-columns:58px 1fr repeat(${PRAY_COLS.length},120px);align-items:center;column-gap:8px}
.ws-head{padding:0 0 8px;border-bottom:2px solid #3b463d;font:700 16px Georgia,serif;color:#6f6a5c;text-align:center}
.ws-head span:nth-child(2){text-align:start}
.ws-row{height:${WS_ROW}px;box-sizing:border-box;border-bottom:1px solid #e2ddd0}
.ws-n{font:700 19px Georgia,serif;color:#8a8577;text-align:end;padding-inline-end:10px}
.ws-name{min-width:0}
.ws-name b{display:block;font:700 26px/1.2 Georgia,serif;color:#1b1a17;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ws-name small{display:block;font:400 18px/1.3 Georgia,serif;color:#6f6a5c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ws-box{justify-self:center;width:28px;height:28px;box-sizing:border-box;border:2px solid #8f8a78;border-radius:4px;background:#fff}
.ws-foot{position:absolute;left:0;right:0;bottom:22px;text-align:center;font:400 12px Georgia,serif;color:#a89f8c}
/* ── THE BOUNDED + CENTERED SETS (back matter) ──────────────────────────
Two figures side by side. The SVGs carry no width/height — they scale to their
column through the viewBox, which is what keeps gospel's boundary gap intact:
the gap lives in the LAYOUT's coordinates and scaling the viewBox cannot
compress it, whereas nudging r or spread would. */
.set-row{display:flex;gap:60px;align-items:flex-start;margin-top:26px}
.set-col{width:var(--set-col,600px);flex:0 0 auto;text-align:center}
.set-h{margin:0 0 10px;font:700 22px Georgia,serif;color:#1b1a17}
.set-svg{width:100%;height:auto;overflow:visible}
.set-ring{fill:none;stroke:#3b463d;stroke-width:2}
.set-ring-faint{fill:none;stroke:#d8d3c4;stroke-width:1}
.set-in{fill:#2f6d4f}
.set-out{fill:#b9b2a0}
/* ⚠ UNKNOWN IS INKED, NOT OMITTED — regions' rule, and it is right for print: a printed page
has no hover, so a category the reader cannot SEE is one they will read as zero. Hollow so it
is visibly neither inside nor outside rather than a third opinion. */
.set-unk{fill:#fff;stroke:#b9b2a0;stroke-width:1.5}
.set-centre{fill:#2f6d4f}
.set-cap{margin:10px 0 0;font:400 15px Georgia,serif;color:#413d35}
/* The ring legend: only the rings this zone actually fills, in ring order. Left-aligned inside
the centred column because a ragged-right list of names reads far better than centred prose. */
.set-rings{margin:10px 0 0;padding:0;list-style:none;text-align:start;
font:400 13px Georgia,serif;color:#6f6a5c;line-height:1.5}
.set-rings b{color:#1b1a17;font-weight:700}
/* ★ ONE PAGE, TWO SQUARE PANELS SIDE BY SIDE (Driver 2026-09-29, after the Module 1 "list of 100"
sheet, which closes on BOUNDED SET | CENTERED SET as two squares). Each panel is a framed square
with its heading on top; the ring legend runs under both at full width. */
.set-row{display:flex;gap:40px;justify-content:center;align-items:stretch;margin-top:30px}
.set-panel{width:${SET_PANEL}px;flex:0 0 auto;box-sizing:border-box;border:1.5px solid #3b463d;
background:#fff;padding:18px 18px 16px;text-align:center}
.set-h{margin:0;font:700 26px Georgia,serif;letter-spacing:.06em;text-transform:uppercase;color:#1b1a17}
.set-sub{margin:4px 0 8px;font:italic 17px Georgia,serif;color:#6f6a5c}
.set-svg{display:block;width:100%;height:auto}
.set-ring{fill:none;stroke:#b8453a;stroke-width:3}
.set-band-a{fill:#f1eee4;stroke:#b9b2a0;stroke-width:1}
.set-band-b{fill:#fff;stroke:#b9b2a0;stroke-width:1}
.set-band-out{fill:none;stroke:#b9b2a0;stroke-width:1;stroke-dasharray:5 5}
.set-num{fill:#fff;stroke:#8f8a78;stroke-width:1}
.set-num-t{font:700 12px Georgia,serif;fill:#3b463d;text-anchor:middle;dominant-baseline:central}
.set-in{fill:#2f6d4f;stroke:#fff;stroke-width:1}
.set-out{fill:#9d977f;stroke:#fff;stroke-width:1}
/* The centre is Christ: a cross at the heart of both figures, over a soft gold disc. */
.set-core{fill:#fbf3dc;stroke:#c9a13b;stroke-width:2}
.set-cross rect{fill:#7a4f1d;stroke:#fff;stroke-width:2;paint-order:stroke}
.set-cap{margin:10px 0 0;font:400 16px Georgia,serif;color:#413d35}
/* The legend names EVERY ring, numbered as the figure numbers them, so a reader can read the scale
from the same dialect outward to the language family — including rings this list leaves empty. */
.set-legend{margin:26px auto 0;max-width:${2 * SET_PANEL + 40}px}
.set-legend-h{font:700 17px Georgia,serif;color:#1b1a17;margin:0 0 8px}
.set-rings{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:1fr 1fr;column-gap:40px;
row-gap:6px;font:400 16px Georgia,serif;color:#413d35}
.set-rings li{display:flex;align-items:center;gap:10px}
.set-rings .ring-n{flex:0 0 24px;height:24px;border-radius:50%;border:1px solid #8f8a78;background:#fff;
font:700 12px/22px Georgia,serif;text-align:center;color:#3b463d;box-sizing:border-box}
.set-rings .ring-c{margin-inline-start:auto;font-weight:700;color:#1b1a17}
/* ── THE THREE PRAYER COLUMNS, now a HORIZONTAL strip at the end of each row ──────────
They were a vertical stack in the old 683x160 card. In a ${CW}x${CH.toFixed(0)} ROW there is no
@ -336,16 +382,15 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// the sheet — land on the same M margin every other packet page keeps, so no printer's
// unprintable edge can clip a name.
// · any other sheet (a 3x4 or 3x3 POSTER, the fallback when no map-only sheet was generated):
// fitted, aspect preserved, into the box under the title. For a 3x4 that is 0.8255, not the old
// 0.62 — a third larger — because the old box also left the page's bottom third empty.
// THE WHOLE PAGE, aspect preserved, inside the packet's white margin M. A poster carries its own
// kicker and title, so page 1 draws nothing over it (mapPage): it used to fit the poster UNDER a
// second title block, which printed two headers and shrank the map to 663x884 of the page.
// Returns '' for input with no <svg>, which mapPage turns into the missing-map notice.
// The files a zone's page-1 map may come from, BEST FIRST: the map-only vertical sheet
// (generate.mjs FORMAT=packet), then the poster. Every fetcher — the browser composable, the
// UPG-100 profile, the offline generator — walks this list, so the preference exists once.
const mapSheetFiles = (id) => [`${id}.packet.html`, `${id}.html`];
const SHEET_INSET = 34; // generate.mjs SHEET_EDGE (26) + 8: where a sheet's label columns begin
const MAP_TOP = 256; // below mapPage's title block (with the series line it ends near y=238)
const MAP_BOTTOM = H - M; // above the fine print
// ── THE MAP'S OWN LABELS, IN THE PACKET'S LANGUAGE ──────────────────────────
// A map sheet is generated once, in English. In a translated packet its country names are replaced
// on the way in (labelFor: see mapLabels.js), and the map's privacy line, which is word for word this
@ -365,18 +410,20 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
};
const cased = (str) => /[A-ZÀ-ÞА-ЯЁ]/.test(str);
const estimate = (str) => [...String(str).replace(/&[a-z#0-9]+;/g, 'x')].reduce((a, ch) => a + advance(ch, cased(str)), 0);
// A replacement text never runs wider than the English it replaces: shrink by the width ratio, and
// drop the tracking for scripts without case (tracked Arabic breaks its joins).
const fit = (en, tr, px) => {
const k = Math.min(1, estimate(en) / Math.max(estimate(tr), 1e-6));
const style = [];
if (k < 1) style.push(`font-size:${(px * k).toFixed(1)}px`);
if (!cased(tr)) style.push('letter-spacing:0');
return style.join(';');
};
const withStyle = (attrs, style) => (!style ? attrs
: /\sstyle="/.test(attrs) ? attrs.replace(/\sstyle="([^"]*)"/, (q, v) => ` style="${v};${style}"`)
: `${attrs} style="${style}"`);
function relabelSheet(svg, css, labelFor) {
const base = Number((css.match(/\.ctry-name\{[^}]*font-size:([\d.]+)px/) || [])[1]) || 15;
const fit = (en, tr, px) => {
const k = Math.min(1, estimate(en) / Math.max(estimate(tr), 1e-6));
const style = [];
if (k < 1) style.push(`font-size:${(px * k).toFixed(1)}px`);
if (!cased(tr)) style.push('letter-spacing:0');
return style.join(';');
};
const withStyle = (attrs, style) => (!style ? attrs
: /\sstyle="/.test(attrs) ? attrs.replace(/\sstyle="([^"]*)"/, (q, v) => ` style="${v};${style}"`)
: `${attrs} style="${style}"`);
let out = svg.replace(/<text([^>]*class="ctry-name(?:-halo|-own)?"[^>]*)>([^<]*)<\/text>/g, (whole, attrs, text) => {
const tr = labelFor(text);
return tr ? `<text${withStyle(attrs, fit(text, tr, base))}>${tr}</text>` : whole;
@ -407,8 +454,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
s = Math.min(1, (W / 2 - M) / (sw / 2 - SHEET_INSET), (H / 2 - M) / (sh / 2 - SHEET_INSET));
x = (W - sw * s) / 2; y = (H - sh * s) / 2;
} else {
s = Math.min((W - 2 * M) / sw, (MAP_BOTTOM - MAP_TOP) / sh);
x = (W - sw * s) / 2; y = MAP_TOP;
s = Math.min((W - 2 * M) / sw, (H - 2 * M) / sh);
x = (W - sw * s) / 2; y = (H - sh * s) / 2;
}
// The root's own width/height would pin it at authored size; the wrapper sets the size instead,
// and the viewBox (default preserveAspectRatio, meet) scales the drawing to it.
@ -434,7 +481,34 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// omitted when the caller cannot supply it: a page that cannot state a true number leaves it out.
// ⚠ THE MAP IS DRAWN FIRST, the title and fine print after. A map-only sheet covers the whole page
// and begins with a paper-coloured background rect, so a title drawn BEFORE it was painted over.
// A poster names itself in English (its kicker and "Unengaged of <zone>"). In another language the
// page's ONLY header is that one, so it is swapped for the packet's own kicker and zone title, fitted
// to the English width like every other lifted label. English prints the poster exactly as drawn.
function posterHeadInLang(svg, zone) {
if (String(lang || 'en').split('-')[0].toLowerCase() === 'en') return svg;
const plain = (s) => String(s).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
const kicker = esc(t('map_page_kicker') || ''), title = esc(zone.label || zone.id || '');
let out = svg;
if (kicker) {
out = out.replace(/<text([^>]*class="h1over"[^>]*)>([^<]*)<\/text>/, (whole, attrs, text) =>
`<text${withStyle(attrs, fit(text, kicker, 22))}>${kicker}</text>`);
}
if (title) {
out = out.replace(/<text([^>]*class="h1"[^>]*)>([\s\S]*?)<\/text>/, (whole, attrs, inner) => {
const px = Number((attrs.match(/font-size="([\d.]+)"/) || [])[1]) || 56;
return `<text${withStyle(attrs, fit(plain(inner), title, px))}>${title}</text>`;
});
}
return out;
}
function mapPage(zone, groups, mapSvg, funnel = null) {
// ★ A POSTER IS PAGE 1 BY ITSELF (Driver 2026-09-29): the map fills the page inside a white margin,
// under its own title only. Drawing the packet's title over it as well printed TWO headers and
// shrank the map to fit beneath them. The set it belongs to is the triangle page's job.
if (mapSvg && /data-map-kind="poster"/.test(mapSvg)) {
return sheet(posterHeadInLang(mapSvg, zone), 'sheet-map sheet-map-poster');
}
const count = groups.length;
const k = Number(funnel && funnel.listCount);
const series = Number.isFinite(k) && k > 0
@ -770,52 +844,57 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// ★ 5,200 IS NOT ROUNDED OR CHOSEN FOR LAYOUT: 100 years x 52 weeks, the Driver's own numbers
// ("a 100 year life in weeks"). Everything else here is derived from it, so the two pages cannot
// drift apart into two grids that merely look alike.
function lifeGrid({ shaded = 0, shadeClass = 'lc-on', seed = '' } = {}) {
// ★ LAID OUT LIKE THE PRINTED LIFE CALENDAR (the Module 1 "Life Calendar in Weeks" sheet, which the
// Driver asked this page to imitate): square boxes, week numbers across the top, the year down the
// side, a thin rule between decades, the whole grid framed and centred on the page. The earlier grid
// stretched 52 columns across the full width, so its boxes were flattened rectangles with no axes.
//
// ★ SHADED FROM THE TOP, IN READING ORDER (Driver 2026-09-29: "show the highlighted dots started from
// the top"). This grid used to SCATTER the shaded boxes, a choice made from a contact sheet in which a
// contiguous run looked like a bar; the Driver has now asked for the opposite, and the grid is the
// Driver'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.
function lifeGrid({ shaded = 0, shadeClass = 'lc-on' } = {}) {
const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS;
const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0)));
// ⚠ SCATTERED, NOT STACKED — and the comment that used to sit here said the opposite. It read:
// "Row-major, so the shaded run reads as a contiguous block from the top — a scattered shading of the
// same COUNT would be honest arithmetic and an unreadable picture." That was reasoning about a picture
// I had never looked at, and it was exactly backwards.
// ★ WHAT THE RENDER ACTUALLY SHOWED (rega's contact sheet, 2026-09-28): 32 contiguous cells fill most
// of row 1 of 52, so the page reads as ONE SOLID GREEN BAR across the header — the ordinary chart the
// whole life-grid idea exists to avoid. The Driver's intent is "32 boxes lost in 5,200"; rarity is the
// message, and a bar cannot say it. Scattering makes the eye hunt for them, which IS the point.
// ⚠ AND NO GATE COULD SEE IT. check-packet-render asserts the shaded COUNT is strictly between 0 and
// 5,200 — true of a scatter and equally true of a bar. A count cannot distinguish a distribution.
//
// DETERMINISTIC, NEVER Math.random(): the same packet must print identically every time, or two copies
// of one zone disagree and neither is reproducible. A 32-bit FNV-1a hash of the seed (the zone id) feeds
// a mulberry32 PRNG, so the pattern is stable per zone and DIFFERENT between zones — which also avoids
// every packet in a print run carrying the same constellation.
// ★ ONE IMPLEMENTATION, shared with the library chart components. The algorithm and both
// comments above were written here first; scatterCells() in components/charts/packetCharts.js
// now holds them, so the printed packet and the reusable chart cannot drift apart — and the
// distribution is finally under a gate (check:packet-charts asserts the SPREAD, which the
// count-based check here structurally cannot see).
const picked = scatterCells(TOTAL, n, seed);
const cells = [];
for (let i = 0; i < TOTAL; i++) {
cells.push(`<i class="lc-c${picked.has(i) ? ' ' + shadeClass : ''}"></i>`);
// Week labels sit in the column of the week they name (column 1 is the year labels).
const WEEK_MARKS = [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50];
const head = `<div class="lc-wk">${WEEK_MARKS.map((w) => `<span style="grid-column:${w + 1}">${w}</span>`).join('')}</div>`;
const decades = [];
for (let d = 0; d < YEARS / 10; d++) {
const rows = [];
for (let y = d * 10; y < d * 10 + 10; y++) {
const year = y + 1;
rows.push(`<b class="lc-y">${year === 1 || year % 5 === 0 ? num(year) : ''}</b>`);
for (let w = 0; w < WEEKS; w++) {
rows.push(`<i class="lc-c${y * WEEKS + w < n ? ' ' + shadeClass : ''}"></i>`);
}
}
decades.push(`<div class="lc-dec">${rows.join('')}</div>`);
}
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-grid">${cells.join('')}</div>` };
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-frame">${head}${decades.join('')}</div>` };
}
// Page 2 — a human life, unshaded except for the weeks already lived if the caller supplies an age.
// No age is supplied today: the packet is printed for a reader we do not know, so shading their life
// would be a guess presented as a fact. The empty grid is the honest version and still lands the
// point — 5,200 boxes is a whole life on one page.
// ★ THE FIRST 33 YEARS ARE JESUS' LIFE (Driver 2026-09-29: "the boxes highlighted need to show 33
// years representing Jesus' life"). Still no reader's age: shading the reader's own weeks would be a
// guess presented as a fact, while 33 x 52 is the same for every reader.
const JESUS_YEARS = 33;
function lifePage(zone) {
const g = lifeGrid();
const g = lifeGrid({ shaded: JESUS_YEARS * 52, shadeClass: 'lc-on' });
return sheet(`
<div class="pg-head"><div class="pg-kicker">${esc(t('life_kicker'))}</div>
<div class="pg-head pg-head-c"><div class="pg-kicker">${esc(t('life_kicker'))}</div>
<h1 class="pg-h1">${esc(t('life_title'))}</h1></div>
<p class="pg-lede">${esc(t('life_lede'))}</p>
<p class="pg-lede pg-lede-c">${esc(t('life_lede'))}</p>
${g.html}
<p class="lc-foot">${esc(t('life_foot'))}</p>
`, 'sheet-life');
<div class="lc-legend">
<span><i class="lc-c lc-on"></i> ${esc(t('life_jesus'))} — ${num(g.shadedCount)} ${esc(t('life_weeks'))}</span>
<span><i class="lc-c"></i> ${esc(t('life_full'))} — ${num(g.TOTAL)} ${esc(t('life_weeks'))}</span>
</div>
`, 'sheet-life sheet-flow');
}
// Page 3 — the SAME grid, shaded by the share of the DX population that is evangelical.
@ -841,7 +920,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const raw = christian.pctEvangelical;
const known = raw !== null && raw !== undefined && raw !== '' && Number.isFinite(Number(raw));
const pct = known ? Number(raw) : null;
const g = lifeGrid({ shaded: known ? (pct / 100) * 5200 : 0, shadeClass: 'lc-reached', seed: zone.id || zone.zoneKey || 'upg-100' });
const g = lifeGrid({ shaded: known ? (pct / 100) * 5200 : 0, shadeClass: 'lc-reached' });
// ⚠ TWO DECIMALS ON BOTH SIDES, not rounded above 10. The reached share is well under 1% and rounding
// the other side to "99%" loses the asymmetry that is the whole point: 99.39 and 0.61 sum to 100,
// while 99 and 0.61 do not, and a reader checking the arithmetic finds the page wrong.
@ -855,16 +934,16 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const people = known && Number(christian.population) > 0
? ` · ${num(christian.population)} ${esc(t('christian_people'))}` : '';
return sheet(`
<div class="pg-head"><div class="pg-kicker">${esc(t('christian_kicker'))}</div>
<div class="pg-head pg-head-c"><div class="pg-kicker">${esc(t('christian_kicker'))}</div>
<h1 class="pg-h1">${esc(t('christian_title'))}</h1></div>
<p class="pg-lede">${esc(t('christian_lede'))}</p>
<p class="pg-lede pg-lede-c">${esc(t('christian_lede'))}</p>
${g.html}
<div class="lc-legend">
<span><i class="lc-c lc-reached"></i> ${esc(t('christian_reached'))} — <span class="ev-pct">${reached}</span></span>
<span><i class="lc-c"></i> ${esc(t('christian_lost'))} — ${lost}</span>
</div>
<p class="lc-foot">${esc(t('christian_foot'))}${people}${coverage}</p>
`, 'sheet-christian');
`, 'sheet-christian sheet-flow');
}
// ── THE DESCRIPTION CELL: FIT BEFORE CLAMP ───────────────────────────────────────────────────
@ -957,6 +1036,43 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
return pages;
}
// ── THE LIGHTWEIGHT LIST: a worksheet, not a profile book ───────────────────
// Driver 2026-09-29: the lightweight packet is less about fitting information in and more a
// printable, instructive sheet — every people group, less text, a kind rather than commanding
// voice, and type and spacing sized for paper. Modelled on the Module 1 list-of-100 worksheet: a
// short note on the first page, one column header per page, numbered rows with room to tick. No
// photos and no descriptions; those are what the full packet is for.
// ⚠ STILL EVERY GROUP. A variant may change HOW people are listed, never WHETHER they are.
// Sizes in sheet px (1500px = 8.5in, so 1px ~ 0.41pt): names 26px ~ 10.6pt, rows 70px ~ 0.4in.
const WS_ROW = 70, WS_FIRST = 20, WS_REST = 24;
function worksheetPages(zone, groups) {
const pages = [];
const head = `<div class="ws-head"><span></span><span>${esc(t('light_col_group'))}</span>${
PRAY_COLS.map((key) => `<span>${esc(t(key))}</span>`).join('')}</div>`;
for (let start = 0, page = 0; start < groups.length; page += 1) {
const per = page === 0 ? WS_FIRST : WS_REST;
const slice = groups.slice(start, start + per);
const rows = slice.map((g, k) => {
const where = [flat(g.country) || flat(g.country_code),
Number(g.population) > 0 ? `${num(g.population)} ${t('people_suffix')}` : ''].filter(Boolean).join(' · ');
return `<div class="ws-row"><span class="ws-n">${num(start + k + 1)}</span>`
+ `<span class="ws-name"><b>${esc(g.name)}</b><small>${esc(where)}</small></span>`
+ PRAY_COLS.map(() => '<i class="ws-box"></i>').join('') + '</div>';
}).join('');
const top = page === 0
? `<div class="pg-head"><div class="pg-kicker">${esc(zone.label || zone.id)}</div>
<h1 class="pg-h1 ws-h1">${esc(fill(t('map_page_sub'), { count: num(groups.length) }))}</h1></div>
<p class="ws-intro">${esc(t('light_intro'))}</p>`
: `<div class="ws-top"><span>${esc(zone.label || zone.id)}</span><span>${esc(fill(t('list_header'),
{ from: num(start + 1), to: num(Math.min(start + per, groups.length)), count: num(groups.length) }))}</span></div>`;
pages.push(sheet(`${top}${head}${rows}<div class="ws-foot">${esc(t('privacy_note'))}</div>`, 'sheet-worksheet sheet-flow'));
start += per;
}
return pages;
}
// Which list a variant prints. Keyed like VARIANTS; an unknown variant gets the profile pages.
const LIST_STYLE = { lightweight: worksheetPages };
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
function pointsPage() {
const rows = POINTS.map((p) => {
@ -1043,16 +1159,24 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// per-ring counts, so the legend states what this zone actually contains, in ring order.
function ringLegend(tally) {
const counts = tally && tally.counts;
if (!counts || typeof counts.get !== 'function') return '';
const get = (n) => (counts && typeof counts.get === 'function' ? Number(counts.get(n) || 0) : 0);
const rows = [];
for (let n = 1; n <= RING_MAX; n++) {
const c = Number(counts.get(n) || 0);
if (!c) continue;
const name = ringName(n);
if (!name) continue;
rows.push(`<li><b>${num(c)}</b> ${esc(name)}</li>`);
const c = get(n);
// Ring 10 is not a ring of the target: it is everything past ring 9 (nearby, no shared tongue).
rows.push(`<li><span class="ring-n">${n === RING_MAX ? '+' : n}</span> ${esc(name)}`
+ ` <span class="ring-c">${c ? num(c) : '—'}</span></li>`);
}
return rows.length ? `<ul class="set-rings">${rows.join('')}</ul>` : '';
return rows.length ? `<ol class="set-rings">${rows.join('')}</ol>` : '';
}
// A cross, centred on (cx, cy), `h` tall: the centre of both figures is Christ.
function cross(cx, cy, h) {
const bar = h * 0.2, arm = h * 0.66;
return `<g class="set-cross"><rect x="${(cx - bar / 2).toFixed(1)}" y="${(cy - h / 2).toFixed(1)}" width="${bar.toFixed(1)}" height="${h.toFixed(1)}" rx="1.5"/>`
+ `<rect x="${(cx - arm / 2).toFixed(1)}" y="${(cy - h / 2 + h * 0.24).toFixed(1)}" width="${arm.toFixed(1)}" height="${bar.toFixed(1)}" rx="1.5"/></g>`;
}
function setsPage(zone, sets) {
@ -1062,31 +1186,26 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const bounded = Array.isArray(sets.bounded) ? sets.bounded : [];
const centered = Array.isArray(sets.centered) ? sets.centered : [];
if (!bounded.length && !centered.length) return '';
const colW = 1100; // one chart per page: most of the 1380px usable width, centred
const parts = [];
const panels = [];
let legend = '';
if (bounded.length) {
const L = layoutBoundedSet(bounded, { r: 100, spread: 220 });
const T = boundedSetTally(L);
// The viewBox is sized from the layout's own extent so the figure scales to the column
// without any coordinate being touched — the gap survives by construction.
// ⚠ UNKNOWN POINTS CARRY NO x/y, AND THAT IS THE MODULE BEING CORRECT. `reached` undefined
// means we do not know which side of the boundary a people belongs on, so there is no honest
// place to draw it — plotting it anywhere would assert the very thing that is unknown. My
// first version mapped over L.unknown with the same dot() helper and crashed on
// `p.x.toFixed`, which is a better outcome than silently drawing them at (0,0) inside the
// circle. They are STATED in the tally instead (regions: an un-inked category on a printed
// page, where there is no hover, reads as zero).
const plotted = [...L.inside, ...L.outside];
const ext = Math.max(...plotted.map((p) => Math.hypot(p.x - L.cx, p.y - L.cy)), L.r * 1.2) * 1.08;
const dot = (p, cls) => `<circle class="${cls}" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="5"/>`;
parts.push({ kind: 'bounded', html: `<div class="set-col">
<h2 class="set-h">${esc(t('sets_bounded_title'))}</h2>
<svg class="set-svg" viewBox="${(L.cx - ext).toFixed(1)} ${(L.cy - ext).toFixed(1)} ${(ext * 2).toFixed(1)} ${(ext * 2).toFixed(1)}">
<circle class="set-ring" cx="${L.cx}" cy="${L.cy}" r="${L.r}"/>
${L.inside.map((p) => dot(p, 'set-in')).join('')}
// ⚠ UNKNOWN POINTS CARRY NO x/y, AND THAT IS THE MODULE BEING CORRECT: we do not know which side of
// the boundary they belong on, so there is no honest place to draw them. They are STATED in the
// tally instead (an un-inked category on a printed page reads as zero).
// A square view with PADDING: the outermost dot is spread*1 from the centre, so 250 leaves room.
const S = 250;
const dot = (p, cls) => `<circle class="${cls}" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="6"/>`;
panels.push({ kind: 'bounded', html: `<div class="set-panel">
<h2 class="set-h">${esc(t('sets_head_bounded'))}</h2>
<div class="set-sub">${esc(t('sets_bounded_title'))}</div>
<svg class="set-svg" viewBox="${L.cx - S} ${L.cy - S} ${2 * S} ${2 * S}">
${L.outside.map((p) => dot(p, 'set-out')).join('')}
${L.inside.map((p) => dot(p, 'set-in')).join('')}
<circle class="set-ring" cx="${L.cx}" cy="${L.cy}" r="${L.r}"/>
${cross(L.cx, L.cy, 44)}
</svg>
<p class="set-cap">${esc(fill(t('sets_bounded_tally'),
{ inside: num(T.inside), outside: num(T.outside), unknown: num(T.unknown) }))}</p>
@ -1096,34 +1215,60 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
if (centered.length) {
const L = layoutCenteredSet(centered, {});
const T = ringTally(L);
const ext = (L.innerR + RING_MAX * L.ringGap) * 1.12;
parts.push({ kind: 'centered', html: `<div class="set-col">
<h2 class="set-h">${esc(t('sets_centered_title'))}</h2>
<svg class="set-svg" viewBox="${(L.cx - ext).toFixed(1)} ${(L.cy - ext).toFixed(1)} ${(ext * 2).toFixed(1)} ${(ext * 2).toFixed(1)}">
${(L.radii || []).map((r) => `<circle class="set-ring-faint" cx="${L.cx}" cy="${L.cy}" r="${r.r}"/>`).join('')}
${L.placed.map((p) => `<circle class="set-in" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="5"/>`).join('')}
<circle class="set-centre" cx="${L.cx}" cy="${L.cy}" r="${(L.innerR * 0.6).toFixed(1)}"/>
// ★ A TARGET, NOT RINGS OF DOTS. The layout places every group ON its ring's line, so one ring's
// groups printed as a circle of overlapping dots and the figure read as rings of people rather than
// as a target. Here each ring is a BAND, numbered, and each group sits somewhere inside its band:
// the layout's own angle (stable per group), and a radius inside the band from the group's id.
const R0 = 34, BW = 25, RINGS = RING_MAX - 1; // 9 numbered bands; ring 10 lies past them
const outer = R0 + RINGS * BW, far = outer + BW;
const S = far + 14, DOT = 5.5;
const bands = [];
bands.push(`<circle class="set-band-out" cx="0" cy="0" r="${far}"/>`);
for (let n = RINGS; n >= 1; n--) {
bands.push(`<circle class="${n % 2 ? 'set-band-a' : 'set-band-b'}" cx="0" cy="0" r="${R0 + n * BW}"/>`);
}
bands.push(`<circle class="set-core" cx="0" cy="0" r="${R0}"/>`);
// Numbers ride the vertical axis above the centre, one per band; dots keep clear of that lane.
const nums = [];
for (let n = 1; n <= RINGS; n++) {
const y = -(R0 + (n - 0.5) * BW);
nums.push(`<circle class="set-num" cx="0" cy="${y}" r="9"/><text class="set-num-t" x="0" y="${y}">${n}</text>`);
}
// THE SHARED BAND GEOMETRY (charts/ringBands.js bandFill, the same module the screen set charts
// use): even spread through each band, no two dots overlapping, none in the number lane straight
// up. Bands are centred on ringRadii, so innerR is the middle of band 1. 'fit' mode, the one meant
// for print: a ring too full to separate reports its remainder instead of overlapping it.
const radii = ringRadii({ innerR: R0 + BW / 2, ringGap: BW });
const lane = { start: -Math.PI / 2 - 0.2, span: 0.4 };
const bf = bandFill(L.placed, radii, {
ringGap: BW, minSep: 2 * DOT + 2, pinRadius: DOT, minPinFloor: 3,
labels: radii.map((x) => ({ ring: x.ring, keepOut: lane })),
});
const dots = bf.placed.map((p) =>
`<circle class="set-in" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${(p.pin || DOT).toFixed(1)}"/>`).join('');
panels.push({ kind: 'centered', html: `<div class="set-panel">
<h2 class="set-h">${esc(t('sets_head_centered'))}</h2>
<div class="set-sub">${esc(t('sets_centered_title'))}</div>
<svg class="set-svg" viewBox="${-S} ${-S} ${2 * S} ${2 * S}">
${bands.join('')}${dots}${nums.join('')}${cross(0, 0, 40)}
</svg>
<p class="set-cap">${esc(fill(t('sets_centered_tally'),
{ placed: num(T.placed), centre: num(T.centre), unplaced: num(T.unplaced) }))}</p>
${ringLegend(T)}
</div>` });
const rows = ringLegend(T);
if (rows) legend = `<div class="set-legend"><div class="set-legend-h">${esc(t('sets_rings_head'))}</div>${rows}</div>`;
}
// ★ ONE CHART PER PAGE — the full-detail packet has FOUR back pages: the bounded set, the
// centered set, the prayer wheel, the 12 points. Side by side, each chart got half the width and
// the centered set's nine rings and their legend were squeezed into ~660px. Now each is a page of
// its own at twice the width. Both pages carry the pair's heading so they read as one spread; the
// first also carries the lede that introduces both. No new strings: every one below was already
// translated for the combined page.
// ⚠ BOTH STAY UNDER THE ONE PAGE KEY 'sets', so the lightweight variant's keyed filter still drops
// them together — a variant must never keep one half of a pair.
return parts.map((col, i) => sheet(`
<div class="pg-head"><div class="pg-kicker">${esc(t('sets_kicker'))}</div>
// ★ ONE PAGE (Driver 2026-09-29), superseding the one-chart-per-page split: the two figures are a
// PAIR and read as one, square panels side by side in the middle of the page, the legend under both.
// The page key stays 'sets', so the lightweight variant still drops it whole.
return sheet(`
<div class="pg-head pg-head-c"><div class="pg-kicker">${esc(t('sets_kicker'))}</div>
<h1 class="pg-h1">${esc(t('sets_title'))}</h1></div>
${i === 0 ? `<p class="pg-lede">${esc(t('sets_lede'))}</p>` : ''}
<div class="set-row" style="--set-col:${colW.toFixed(0)}px;justify-content:center">${col.html}</div>
`, `sheet-sets sheet-sets-${col.kind}`)).join('');
<p class="pg-lede pg-lede-c">${esc(t('sets_lede'))}</p>
<div class="set-row">${panels.map((p) => p.html).join('')}</div>
${legend}
`, `sheet-sets sheet-flow ${panels.map((p) => `sheet-sets-${p.kind}`).join(' ')}`);
}
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
@ -1252,8 +1397,9 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
keep('funnel') && funnelPage(zone, groups, funnel),
keep('life') && lifePage(zone),
keep('christian') && christianPage(zone, christian),
// NOT behind keep() — see the VARIANTS comment: dropping list pages drops people.
...listPages(zone, groups, photos),
// NOT behind keep() — see the VARIANTS comment: dropping list pages drops people. The lightweight
// packet lists the SAME groups as a worksheet (LIST_STYLE); the full packet as profiles.
...(LIST_STYLE[variant] ? LIST_STYLE[variant](zone, groups) : listPages(zone, groups, photos)),
// BACK MATTER: after the profiles (brief A1). Full-detail only.
keep('sets') && setsPage(zone, sets),
keep('wheel') && wheelPage(),

View file

@ -233,7 +233,14 @@
"ring_culture_same": "same people",
"ring_culture_near": "near culture",
"ring_culture_different": "different culture",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "The life of Jesus, about 33 years",
"life_full": "A full life of 100 years",
"sets_head_bounded": "Bounded set",
"sets_head_centered": "Centered set",
"sets_rings_head": "The rings, from the centre outward",
"light_intro": "You might take one people group a day and pray for them by name. A tick in a box is a simple way to remember who you have carried to God — there is no pace to keep.",
"light_col_group": "People group"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@ -461,7 +468,14 @@
"ring_culture_same": "mismo pueblo",
"ring_culture_near": "cultura cercana",
"ring_culture_different": "cultura distinta",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "La vida de Jesús, unos 33 años",
"life_full": "Una vida completa de 100 años",
"sets_head_bounded": "Conjunto delimitado",
"sets_head_centered": "Conjunto centrado",
"sets_rings_head": "Los anillos, del centro hacia fuera",
"light_intro": "Puedes tomar un pueblo cada día y orar por él por su nombre. Una marca en la casilla es una forma sencilla de recordar a quién has llevado ante Dios; no hay ningún ritmo que cumplir.",
"light_col_group": "Pueblo"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@ -689,7 +703,14 @@
"ring_culture_same": "même peuple",
"ring_culture_near": "culture proche",
"ring_culture_different": "culture différente",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "La vie de Jésus, environ 33 ans",
"life_full": "Une vie entière de 100 ans",
"sets_head_bounded": "Ensemble délimité",
"sets_head_centered": "Ensemble centré",
"sets_rings_head": "Les anneaux, du centre vers l’extérieur",
"light_intro": "Vous pouvez prendre un peuple par jour et prier pour lui en l’appelant par son nom. Cocher une case est une façon simple de vous souvenir de ceux que vous avez portés devant Dieu — il n’y a aucun rythme à tenir.",
"light_col_group": "Peuple"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@ -915,7 +936,14 @@
"ring_culture_same": "mesmo povo",
"ring_culture_near": "cultura próxima",
"ring_culture_different": "cultura diferente",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "A vida de Jesus, cerca de 33 anos",
"life_full": "Uma vida inteira de 100 anos",
"sets_head_bounded": "Conjunto delimitado",
"sets_head_centered": "Conjunto centrado",
"sets_rings_head": "Os anéis, do centro para fora",
"light_intro": "Você pode escolher um povo por dia e orar por ele pelo nome. Marcar uma caixa é uma forma simples de lembrar quem você levou a Deus — não há ritmo a cumprir.",
"light_col_group": "Povo"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@ -1143,7 +1171,14 @@
"ring_culture_same": "gleiches Volk",
"ring_culture_near": "nahe Kultur",
"ring_culture_different": "andere Kultur",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "Das Leben Jesu, etwa 33 Jahre",
"life_full": "Ein volles Leben von 100 Jahren",
"sets_head_bounded": "Begrenzte Menge",
"sets_head_centered": "Zentrierte Menge",
"sets_rings_head": "Die Ringe, von der Mitte nach außen",
"light_intro": "Sie können sich jeden Tag eine Volksgruppe vornehmen und mit Namen für sie beten. Ein Häkchen im Kästchen hilft, sich zu erinnern, wen Sie vor Gott gebracht haben — es gibt kein Tempo einzuhalten.",
"light_col_group": "Volksgruppe"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@ -1369,7 +1404,14 @@
"ring_culture_same": "stesso popolo",
"ring_culture_near": "cultura vicina",
"ring_culture_different": "cultura diversa",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "La vita di Gesù, circa 33 anni",
"life_full": "Una vita intera di 100 anni",
"sets_head_bounded": "Insieme delimitato",
"sets_head_centered": "Insieme centrato",
"sets_rings_head": "Gli anelli, dal centro verso l’esterno",
"light_intro": "Puoi prendere un popolo al giorno e pregare per lui chiamandolo per nome. Una spunta nella casella è un modo semplice per ricordare chi hai portato davanti a Dio: non c’è alcun ritmo da mantenere.",
"light_col_group": "Popolo"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@ -1595,7 +1637,14 @@
"ring_culture_same": "același popor",
"ring_culture_near": "cultură apropiată",
"ring_culture_different": "cultură diferită",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "Viața lui Isus, aproximativ 33 de ani",
"life_full": "O viață întreagă de 100 de ani",
"sets_head_bounded": "Mulțime delimitată",
"sets_head_centered": "Mulțime centrată",
"sets_rings_head": "Inelele, de la centru spre exterior",
"light_intro": "Poți lua câte un popor în fiecare zi și să te rogi pentru el pe nume. O bifă în căsuță este un mod simplu de a-ți aminti pe cine ai adus înaintea lui Dumnezeu — nu există niciun ritm de respectat.",
"light_col_group": "Popor"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@ -1823,7 +1872,14 @@
"ring_culture_same": "тот же народ",
"ring_culture_near": "близкая культура",
"ring_culture_different": "другая культура",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "Жизнь Иисуса, около 33 лет",
"life_full": "Целая жизнь в 100 лет",
"sets_head_bounded": "Ограниченное множество",
"sets_head_centered": "Центрированное множество",
"sets_rings_head": "Кольца — от центра наружу",
"light_intro": "Можно брать по одному народу в день и молиться за него, называя по имени. Галочка в клетке — простой способ помнить, кого вы принесли к Богу; никакого темпа выдерживать не нужно.",
"light_col_group": "Народ"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@ -2051,7 +2107,14 @@
"ring_culture_same": "الشعب نفسه",
"ring_culture_near": "ثقافة قريبة",
"ring_culture_different": "ثقافة مختلفة",
"ring_join": "{band}، {culture}"
"ring_join": "{band}، {culture}",
"life_jesus": "حياة يسوع، نحو ٣٣ سنة",
"life_full": "حياة كاملة من ١٠٠ سنة",
"sets_head_bounded": "مجموعة محدودة",
"sets_head_centered": "مجموعة متمركزة",
"sets_rings_head": "الحلقات، من المركز إلى الخارج",
"light_intro": "يمكنك أن تأخذ شعبًا واحدًا كل يوم وتصلّي لأجله باسمه. علامة في المربّع طريقة بسيطة لتتذكّر من حملته إلى الله — ليس هناك إيقاع عليك أن تلتزم به.",
"light_col_group": "الشعب"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@ -2277,7 +2340,14 @@
"ring_culture_same": "वही जनसमूह",
"ring_culture_near": "निकट संस्कृति",
"ring_culture_different": "भिन्न संस्कृति",
"ring_join": "{band}, {culture}"
"ring_join": "{band}, {culture}",
"life_jesus": "यीशु का जीवन, लगभग 33 वर्ष",
"life_full": "100 वर्षों का पूरा जीवन",
"sets_head_bounded": "सीमित समूह",
"sets_head_centered": "केंद्रित समूह",
"sets_rings_head": "वलय, केंद्र से बाहर की ओर",
"light_intro": "आप हर दिन एक जनसमूह को चुनकर उसके नाम से उसके लिए प्रार्थना कर सकते हैं। खाने में सही का निशान यह याद रखने का सरल तरीका है कि आपने किसे परमेश्वर के सामने रखा — कोई गति बनाए रखने की ज़रूरत नहीं।",
"light_col_group": "जनसमूह"
},
"zh": {
"map_page_kicker": "未得之民",
@ -2503,6 +2573,13 @@
"ring_culture_same": "同一族群",
"ring_culture_near": "文化相近",
"ring_culture_different": "文化不同",
"ring_join": "{band},{culture}"
"ring_join": "{band},{culture}",
"life_jesus": "耶稣的一生,约 33 年",
"life_full": "完整的一生,100 年",
"sets_head_bounded": "边界集合",
"sets_head_centered": "中心集合",
"sets_rings_head": "各环,由中心向外",
"light_intro": "你可以每天选一个族群,按着名字为他们祷告。在方框里打个勾,是记住你把谁带到神面前的简单方式——不必赶任何进度。",
"light_col_group": "族群"
}
}