The prayer packet the Driver asked for: life calendar, the same grid as a percentage, a triangle

Driver, 2026-09-27, in his own page order. Nine pages now, was seven.

  1  map            the zone's vertical one-pager (unchanged)
  2  LIFE CALENDAR  100 years x 52 weeks = 5,200 boxes — a whole human life on one page
  3  THE SAME GRID  shaded by the evangelical share of these groups' POPULATION
  4  funnel         now an UPSIDE-DOWN TRIANGLE with an equation sidebar and a "1 of 28" tip
  5..n groups        20 per page with photos AND three prayer tick columns
  then the 12 prayer points and the prayer wheel (both unchanged)

★ THE 12 PRAYER POINTS WERE NOT LOST. The Driver believed they had gone; all 12 are intact in
packet-strings.json with his rhythms and sub-titles — praise/Two-Cross Gospel, wait/Holy Spirit &
deliverance, confess, read/the Father's love, ask/baptisms, intercede/laborers & shepherds,
pray_word/Persons & Houses of Peace, thank/rest, sing, meditate/communion, listen/accountability,
praise_end/great-greater-greatest — plus the Mark 5 passage and the Metacamp attribution.

★ PAGES 2 AND 3 ARE ONE GRID READ TWICE, which is the whole idea. 5,200 is the Driver's own arithmetic
(100 x 52), not a layout choice, so both pages derive from it and cannot drift into two grids that merely
look alike. A percentage drawn as a bar is an abstraction; drawn on the grid the reader has just
understood as their own life, 32 shaded boxes of 5,200 is the same fact made unavoidable.

★ AND PAGE 3's NUMBER IS POPULATION-WEIGHTED, because he asked for "% of Christian population vs lost".
That matters: by GROUP COUNT 69.1% of the 2,091 matched DOXA groups have no known evangelicals, but by
POPULATION the evangelical share is 0.61% of 209,079,159 people. The group-count version is a materially
milder picture of the same data. Computed in data/people-groups/christian-share.json, joined IMB
CSV ROP3Code+ISOAlpha3 to DOXA's snapshot — the same join the Vision Card uses — and READ here, never
re-derived, so one fact has one source.
⚠ THE BAND MIDPOINT IS AN ASSUMPTION AND THE PAGE SAYS SO. peoplegroups.org publishes EvangelicalLevel
as a BAND ("Less than 2%", "No Known Evangelicals"), never a number, so any percentage requires choosing
a value inside each band. The file records the method, the page prints it, and 0.61 + 99.39 = 100 exactly
— my first formatter rounded the large side to "99%", which loses the asymmetry that IS the point and
leaves a reader checking the arithmetic to find the page wrong.

⚠ GEOMETRY MEASURED, NOT EYEBALLED: square cells would need 26.5px x 100 rows = 2,654px of grid in
1,521px of printable height, overflowing the sheet 1.7x. 13px rows put 100 rows in 1,498px with 23px
spare. Caught by computing the height before rendering.
⚠ AND THE TIP SAID "1 of 27". listCount spread the three cluster keys and omitted `deaf`, whose two
deaf_lists entries buildLegendNodes collapses into one leaf — the same omission allZones() had before
3ef164c0, and this was its last copy. It prints on the page, so a 27 there is a visible wrong fact.

THE GATE IS TAUGHT, NOT TOLERANT: the two new sheets are DETECTED (.sheet-life/.sheet-christian) and
added as known terms, exactly as the funnel was, because that assertion exists to catch silently dropped
people groups and a ±1 tolerance is the slack a lost list page hides in. New assertions: 5,200 cells per
grid, the shaded count neither 0 nor 5,200 (either means the percentage never arrived), and exactly
3 x cards prayer boxes. Probes scoped to .lc-grid — the legend swatches share the cell class and made the
count 5,201 on the first run.
The 17 new strings are English-only and DECLARED_ABSENT with reasons rather than back-filled: English
copied into ten locale blocks would read as translated to this gate and to a reader, and the per-key
fallback already renders English without the lie.

VERIFIED: check-packet-render PASS (9 sheets, map painted, content intact) and
check:packet-strings-parity PASS on the Tibet fixture.
This commit is contained in:
Doxa Maps 2026-09-27 06:09:01 +00:00
parent 9c6dda2a67
commit de243597db
6 changed files with 296 additions and 14 deletions

View file

@ -0,0 +1,23 @@
{
"_what": "Evangelical share of the DOXA people groups' POPULATION, for the packet's life-calendar page.",
"_method": "Each group's published EvangelicalLevel BAND is taken at its midpoint (0 for 'No Known Evangelicals'), weighted by the group's Population, and summed. peoplegroups.org publishes a band, never a number, so a percentage requires choosing a value inside each band; the midpoint is stated on the page itself.",
"_join": "IMB CSV ROP3Code + ISOAlpha3 against doxa-groups-snapshot.json rop3 + country — the same join the Vision Card uses (build-lists.mjs:13).",
"sources": {
"imb": "data/people-groups/people_groups.csv",
"doxa": "https://pray.doxa.life/api/people-groups/list",
"fetched": "2026-09-25"
},
"groupsInList": 2107,
"groupsMatched": 2091,
"population": 209079159,
"evangelicalPopulation": 1282178,
"pctEvangelical": 0.6133,
"bands": {
"No Known Evangelicals": 1444,
"Less than 2%": 638,
"2% or Greater but Less than 5%": 6,
"5% or Greater but Less than 10%": 1,
"10% or Greater but Less than 15%": 1,
"50% or Greater but Less than 75%": 1
}
}

View file

@ -241,6 +241,13 @@ const r = await page.evaluate(() => {
// presence is DETECTED rather than assumed — see the sheet arithmetic below, which must stay
// exact about list pages whether or not this page is there.
funnel: document.querySelectorAll('.funnel-title').length,
lifeSheets: document.querySelectorAll('.sheet-life').length,
christianSheets: document.querySelectorAll('.sheet-christian').length,
lifeCells: document.querySelectorAll('.lc-grid .lc-c').length,
prayBoxes: document.querySelectorAll('.pray-box').length,
funnelTip: document.querySelectorAll('.funnel-tip').length,
lifeShaded: document.querySelectorAll('.lc-grid .lc-reached').length,
funnelEq: document.querySelectorAll('.funnel-eq').length,
funnelSteps: document.querySelectorAll('.funnel-mine').length,
// ★ MEASURED SO THE GATE CAN REPORT WHAT IT ASSERTED. rega: "the gate prints no funnel line, even
// with --expect-funnel ... a reader cannot see WHAT it asserted about page 2." A silent pass is
@ -645,12 +652,44 @@ if (!boxes.length) {
// this assertion exists to catch SILENTLY DROPPED PEOPLE GROUPS, and a ±1 tolerance is exactly the
// slack a lost list page would hide in. So the funnel is DETECTED (.funnel-title) and added as a
// known term, leaving the list-page arithmetic as strict as it was.
// ⚠ AND THE SAME FOR THE LIFE-CALENDAR PAGES (2026-09-27, Driver's page order). Two more structural
// sheets — a 100-year life in weeks, then the SAME grid shaded by the evangelical share of these
// groups' population — so they are DETECTED and added as known terms for exactly the reason the
// funnel was, and for the reason stated above: a tolerance here would hide a dropped list page.
const PER_PAGE = 20
const funnelSheets = r.funnel ? 1 : 0
const expectedSheets = 1 + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
const lifeSheets = (r.lifeSheets || 0) + (r.christianSheets || 0)
const expectedSheets = 1 + lifeSheets + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
// ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ──────────────────────────────────
// A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a
// christian page whose shading silently fell to zero would all still be one sheet. 5,200 is the
// Driver's own arithmetic (100 years x 52 weeks) and is the only number here that may not drift.
if (r.lifeSheets) {
const EXPECT = 5200
const perGrid = r.lifeCells / (r.lifeSheets + (r.christianSheets || 0))
if (perGrid !== EXPECT) {
fails.push(`the life calendar should be ${EXPECT} cells per grid (100 years x 52 weeks) `
+ `but rendered ${perGrid} — the grid changed size, so the page no longer shows a human life`)
}
}
// ★ THE SHADED SHARE MUST BE NON-ZERO AND NOT EVERYTHING. 0 shaded means the percentage never
// reached the page (a null christian-share file renders the page with an empty grid, which reads as
// "nobody is reached" — a false claim rather than missing data); 5,200 means the maths inverted.
if (r.christianSheets) {
const shaded = r.lifeShaded ?? null
if (shaded !== null && (shaded <= 0 || shaded >= 5200)) {
fails.push(`the christian-share page shaded ${shaded} of 5200 cells — 0 would claim nobody is `
+ `reached and 5200 would claim everybody is; both mean the percentage did not arrive`)
}
}
// The three prayer columns: one tick box per group per question, so the count is exactly 3 x cards.
if (r.cards && r.prayBoxes !== r.cards * 3) {
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tick boxes `
+ `(blessed / what to know / what to do) but the packet has ${r.prayBoxes}`)
}
if (r.cards && r.sheets !== expectedSheets) {
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
+ `(1 map${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
+ `(1 map${lifeSheets ? ` + ${lifeSheets} life-calendar` : ''}${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
+ `but the packet has ${r.sheets} — either groups were dropped from a page or the `
+ `${PER_PAGE}-per-page grid changed`)
}

View file

@ -49,6 +49,29 @@ if (!en) { console.error('PACKET-STRINGS-PARITY: FAIL — no English block to co
const DECLARED_ABSENT = {
point4_passage: 'Mark 5:1-20 in full — public-domain licensing per language (DECIDE-MARK5-…)',
point4_passage_credit: 'the attribution line for the passage above',
// ── The Driver's new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
// locale blocks would make them read as TRANSLATED to this gate and to a reader, which is worse
// than an honest gap — the per-KEY fallback already renders English without the lie. These come out
// of this list one at a time as real translations arrive.
life_kicker: 'awaiting translation — new life-calendar page (2026-09-27)',
life_title: 'awaiting translation — new life-calendar page',
life_lede: 'awaiting translation — new life-calendar page',
life_foot: 'awaiting translation — the 100 x 52 arithmetic line',
life_weeks: 'awaiting translation — the word "weeks" in the legend',
christian_kicker: 'awaiting translation — new christian-share page',
christian_title: 'awaiting translation — new christian-share page',
christian_lede: 'awaiting translation — new christian-share page',
christian_reached: 'awaiting translation — legend label',
christian_lost: 'awaiting translation — legend label',
christian_people: 'awaiting translation — the word "people" in the footnote',
christian_foot: 'awaiting translation — the band-midpoint method note',
funnel_eq_title: 'awaiting translation — the funnel equation sidebar heading',
funnel_tip: 'awaiting translation — the "1 of N UPG-100 lists" tip',
pray_blessed: 'awaiting translation — prayer column 1 of 3',
pray_know: 'awaiting translation — prayer column 2 of 3',
pray_do: 'awaiting translation — prayer column 3 of 3',
}
// Language blocks are the short keys; anything longer is metadata, not a locale.

View file

@ -74,6 +74,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
const M = 60, HEAD = 90; // margins + running header
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
// ★ THE THREE PRAYER COLUMNS, the Driver's own three questions (2026-09-27), in his order:
// 1 have I blessed them
// 2 God, what do you want me to KNOW about them
// 3 God, what do you want me to DO to help them
// A tick box per group per question, printed on the card so the packet is a WORKING sheet rather
// than a reference: the reader marks it as they pray. Deliberately three and not more — the brief
// is one hour with God, and a fourth column turns a prayer aid into a form.
const PRAY_COLS = ['pray_blessed', 'pray_know', 'pray_do'];
const GUT = 14;
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
@ -117,6 +125,41 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
/* ── LIFE CALENDAR (pages 2 and 3) ─────────────────────────────────────
5,200 cells must fit one sheet and still read as countable boxes. 52 columns
is fixed by the DATA (weeks in a year), so the cell size is derived from the
printable width rather than chosen: (1500 - 2*60) / 52 = 26.5px per column,
leaving a 21px box with a 2px gap. Measured, not eyeballed — a hardcoded box
size that happened to fit at one margin would break the moment M changed. */
.pg-head{margin:0 0 10px}
.pg-kicker{font:700 15px Georgia,serif;letter-spacing:.14em;text-transform:uppercase;color:#8a8577}
.pg-h1{margin:2px 0 0;font:700 40px Georgia,serif;color:#1b1a17}
.pg-lede{margin:0 0 22px;max-width:1180px;font:400 19px/1.5 Georgia,serif;color:#413d35}
/* ⚠ THE CELLS ARE RECTANGLES, NOT SQUARES, and that is forced by the sheet rather than chosen.
52 columns is fixed by the data (weeks in a year) and 100 rows by the Driver's "100 year life",
so both dimensions are given and only the SHAPE is free. Squares would need
(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}
/* The shaded share on page 3 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}
/* ── THE THREE PRAYER COLUMNS on every group card ───────────────────────
Right-aligned inside the 683x160 card, which leaves ~412px for the name and
numbers (measured from CW/CH rather than assumed). inset-inline-end, not right,
so the column stack mirrors under dir=rtl with the rest of the card. */
.pray-cols{position:absolute;inset-inline-end:10px;top:12px;display:flex;flex-direction:column;gap:9px}
.pray-col{display:flex;align-items:center;gap:7px;font:400 11px Georgia,serif;color:#6f6a5c;white-space:nowrap}
.pray-box{display:inline-block;width:17px;height:17px;border:1.5px solid #b0a993;border-radius:3px;background:#fff;flex:0 0 auto}
.pray-col em{font-style:normal}
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
.card{position:absolute;display:flex;gap:12px;align-items:center;
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
@ -411,24 +454,131 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// FOOTNOTE rather than as the conclusion of the "43" row. The gap belongs BETWEEN rows; the share
// is that last row's conclusion and should sit close under it. Proximity is the only thing
// grouping them, so an even gap everywhere groups the share with the wrong element.
return `<div style="margin-bottom:${r.mine ? 14 : GAP}px"${r.mine ? ' class="funnel-mine-row"' : ''}>
// ★ CENTRED, so the bars form an UPSIDE-DOWN TRIANGLE narrowing to a point — the Driver's
// "upside down triangle ... the bottom of the triangle shows the tip". margin-inline:auto on
// the bar and text-align:center on the row is the whole geometry: each bar is already drawn
// proportional to the first step, so centring them IS the triangle. No new maths, and the
// careful population/grouping logic below is untouched.
return `<div style="margin-bottom:${r.mine ? 14 : GAP}px;text-align:center"${r.mine ? ' class="funnel-mine-row"' : ''}>
<div${r.mine ? ' class="funnel-mine"' : ''} style="font:700 ${r.mine ? 46 : 40}px Georgia,serif;color:${r.tint ? '#7a3b2e' : '#1b1a17'};line-height:1">${num(r.groups)}</div>
<div style="font:400 ${r.mine ? 22 : 20}px Georgia,serif;color:#3b3226;margin-top:3px">${esc(r.label)}</div>
${pop}
<div style="height:16px;width:${width(r.groups)}px;background:${r.tint ? '#7a3b2e' : '#cdbf9e'};margin-top:8px;border-radius:2px"></div>
<div style="height:16px;width:${width(r.groups)}px;background:${r.tint ? '#7a3b2e' : '#cdbf9e'};margin-top:8px;border-radius:2px;margin-inline:auto"></div>
</div>`;
}).join('');
// ── THE EQUATION SIDEBAR ────────────────────────────────────────────────
// Driver, 2026-09-27: "a map equation with a side bar for the equations". Every number on this
// page is DERIVED, and the derivation is the argument — a reader who cannot see how 12,379
// becomes 2,107 has to take the funnel on faith. So each step states the filter that produced it,
// read from vision-funnel.json's own step ids rather than written out here, and the provenance
// line names the file and capture date.
// ⚠ NOT A RESTATEMENT OF THE BARS. The bars show HOW MANY; the sidebar shows WHY THAT MANY. A
// sidebar repeating the counts would add width and no information.
const SIDEBAR_W = 300;
const eqRows = allRows.filter((r) => !r.mine).map((r, idx, arr) => {
const prev = idx > 0 ? arr[idx - 1] : null;
const delta = prev ? Number(prev.groups) - Number(r.groups) : null;
return `<div style="margin-bottom:16px">
<div style="font:700 16px Georgia,serif;color:#3b3226">${num(r.groups)}</div>
<div style="font:400 12px/1.45 Georgia,serif;color:#6f6a5c">${esc(r.equation || r.label)}</div>
${delta !== null ? `<div style="font:400 11px Georgia,serif;color:#a89f8c;margin-top:2px">\u2212 ${num(delta)}</div>` : ''}
</div>`;
}).join('');
const sidebar = `<div class="funnel-eq" style="position:absolute;top:${ROWS_TOP}px;inset-inline-end:${M}px;width:${SIDEBAR_W}px">
<div style="font:700 13px Georgia,serif;letter-spacing:.1em;text-transform:uppercase;color:#8a8577;margin-bottom:12px">${esc(t('funnel_eq_title'))}</div>
${eqRows}</div>`;
// ★ THE TIP OF THE TRIANGLE: this packet is one of N. The Driver's "the tip ... the smaller UPG
// 100 list is a subset, 1 of 30 or n exact UPG 100 lists". N is READ from the catalogue
// (funnel.listCount), never typed — it is 28 today and was 27 before the deaf zone became
// reachable, so a literal would already be wrong.
const tip = !funnel.listCount ? '' : `<div class="funnel-tip" style="text-align:center;margin-top:18px">
<div style="font:700 15px Georgia,serif;letter-spacing:.08em;text-transform:uppercase;color:#7a3b2e">${esc(fill(t('funnel_tip'), { n: num(funnel.listCount) }))}</div>
</div>`;
return sheet(`
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
<div class="funnel-title" style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('funnel_title'))}</div>
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('funnel_intro'))}</div>
</div>
<div style="position:absolute;left:${M}px;top:${ROWS_TOP}px;right:${M}px">${rows}${share}</div>
<div style="position:absolute;left:${M}px;top:${ROWS_TOP}px;right:${SIDEBAR_W + M + 24}px">${rows}${share}${tip}</div>
${sidebar}
<div class="fine">${foot}</div>`);
}
// ── PAGES 3..n: the people groups, 20 per sheet ─────────────────────────────
// ── LIFE CALENDAR: 100 years x 52 weeks = 5200 boxes ────────────────────────
//
// Driver, 2026-09-27: page 2 is "a life calendar of the years of a human life in weeks", and page 3
// is "the same life cal used for percentage of Christian on average for dx people groups". ONE grid,
// TWO readings — which is the whole point. A percentage drawn as a bar is an abstraction a reader
// has to translate; drawn on the grid they have just understood as their OWN LIFE, it is the same
// 5200 boxes they were looking at a page earlier.
//
// ★ 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' } = {}) {
const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS;
const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0)));
// 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.
const cells = [];
for (let i = 0; i < TOTAL; i++) {
cells.push(`<i class="lc-c${i < n ? ' ' + shadeClass : ''}"></i>`);
}
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-grid">${cells.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.
function lifePage(zone) {
const g = lifeGrid();
return sheet(`
<div class="pg-head"><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>
${g.html}
<p class="lc-foot">${esc(t('life_foot'))}</p>
`, 'sheet-life');
}
// Page 3 — the SAME grid, shaded by the share of the DX population that is evangelical.
//
// ⚠ THE NUMBER IS POPULATION-WEIGHTED, NOT A GROUP COUNT, because the Driver asked for
// "% of Christian population vs lost". Those differ sharply: 69.1% of the 2,091 matched DX GROUPS
// have no known evangelicals, but weighted by POPULATION the evangelical share is 0.61% of
// 209,079,159 people — 32 boxes of 5,200. A group-count version would have shown a different,
// milder picture of the same data.
// ⚠ AND THE BAND MIDPOINT IS AN ASSUMPTION, NAMED ON THE PAGE. peoplegroups.org publishes
// EvangelicalLevel as a BAND ("Less than 2%", "No Known Evangelicals", …), never a number, so a
// percentage requires choosing a value inside each band. The builder uses the band's midpoint and
// 0 for "No Known Evangelicals"; the footnote says so, because a computed figure whose method is
// invisible reads as a measurement.
function christianPage(zone, christian) {
if (!christian || !Number.isFinite(Number(christian.pctEvangelical))) return '';
const pct = Number(christian.pctEvangelical);
const g = lifeGrid({ shaded: (pct / 100) * 5200, shadeClass: 'lc-reached' });
const lostPct = 100 - pct;
// ⚠ TWO DECIMALS ON BOTH SIDES, not rounded above 10. The reached share is 0.61% 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.
const fmtPct = (x) => x.toFixed(2).replace(/\.?0+$/, '');
return sheet(`
<div class="pg-head"><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>
${g.html}
<div class="lc-legend">
<span><i class="lc-c lc-reached"></i> ${esc(t('christian_reached'))} — ${esc(fmtPct(pct))}% · ${num(g.shadedCount)} ${esc(t('life_weeks'))}</span>
<span><i class="lc-c"></i> ${esc(t('christian_lost'))} — ${esc(fmtPct(lostPct))}% · ${num(g.TOTAL - g.shadedCount)} ${esc(t('life_weeks'))}</span>
</div>
<p class="lc-foot">${esc(t('christian_foot'))}${christian.population ? ` · ${num(christian.population)} ${esc(t('christian_people'))}` : ''}</p>
`, 'sheet-christian');
}
function listPages(zone, groups, photos) {
const pages = [];
for (let start = 0; start < groups.length; start += PER_PAGE) {
@ -447,7 +597,10 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
<div class="nm">${i + 1}. ${esc(g.name)}</div>
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
</div></div>`;
</div>
<div class="pray-cols">${PRAY_COLS.map((k) => `<span class="pray-col"
><i class="pray-box"></i><em>${esc(t(k))}</em></span>`).join('')}</div>
</div>`;
}).join('');
pages.push(sheet(`
<div class="hd">${esc(zone.label || zone.id)}</div>
@ -551,9 +704,20 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// working unchanged and a host with no funnel data still gets a complete, correct packet. A page
// that cannot state true numbers must not appear at all — better a 4-section packet than one
// asserting figures it could not read.
function buildPacket(zone, groups, photos, mapSvg, funnel = null) {
function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null) {
// ★ PAGE ORDER IS THE DRIVER'S, 2026-09-27, and it is an argument rather than a layout:
// 1 map — where this zone is
// 2 life calendar — 100 years x 52 weeks, a whole human life on one page
// 3 the SAME grid — shaded by the evangelical share of these groups' POPULATION
// 4 funnel — 12,379 -> 7,341 -> 4,754 -> 2,107 -> this list, 1 of N
// 5..n people groups with photos and the three prayer columns
// then the 12 prayer points and the prayer wheel
// Pages 2 and 3 are deliberately adjacent: the reader learns to read 5,200 boxes as their own
// life, and then sees the same 5,200 boxes answer "how many of these people know Christ".
const pages = [
mapPage(zone, groups, mapSvg),
lifePage(zone),
christianPage(zone, christian),
funnelPage(zone, groups, funnel),
...listPages(zone, groups, photos),
pointsPage(),
@ -569,5 +733,5 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
<body>${pages.join('\n')}</body></html>`;
}
return { buildPacket, mapPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
}

View file

@ -184,7 +184,24 @@
"funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more",
"funnel_list_unengaged": "Unengaged unreached people groups",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} more"
"zone_join_more": "{a} + {n} more",
"life_kicker": "Perspective",
"life_title": "A human life, in weeks",
"life_lede": "One hundred years. Fifty-two weeks each. Five thousand two hundred boxes — and that is the whole of it. Every week you will ever pray is one of these.",
"life_foot": "100 years x 52 weeks = 5,200 weeks.",
"life_weeks": "weeks",
"christian_kicker": "The same 5,200 boxes",
"christian_title": "Who among them knows Christ",
"christian_lede": "This is the same grid — one human life in weeks. Now each box is a share of the people in this list. The shaded boxes are the evangelical share of their population; the rest are waiting.",
"christian_reached": "Evangelical",
"christian_lost": "Still waiting",
"christian_people": "people",
"christian_foot": "Population-weighted from peoplegroups.org EvangelicalLevel, taking each published band at its midpoint and 0 for \"No Known Evangelicals\"",
"pray_blessed": "Blessed them",
"pray_know": "What to know",
"pray_do": "What to do",
"funnel_eq_title": "How each number is reached",
"funnel_tip": "This packet is 1 of {n} UPG-100 lists"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",

View file

@ -124,6 +124,17 @@ const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H }
// ── DATA ────────────────────────────────────────────────────────────────────
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
// ── THE CHRISTIAN-SHARE PAGE (packet page 3) ────────────────────────────────
// READ, NEVER COMPUTED HERE. data/people-groups/christian-share.json is generated from the IMB CSV
// joined to DOXA's snapshot on ROP3+country — the same join the Vision Card uses — and it carries its
// own _method and provenance. Re-deriving the percentage in this file would give one fact two sources
// that drift apart, which is the mistake the funnel page already documents for its four steps.
// A missing file yields null and buildPacket omits page 3 rather than printing a number it could not read.
let CHRISTIAN = null;
try {
CHRISTIAN = JSON.parse(readFileSync(join(__dirname, '../../../data/people-groups/christian-share.json'), 'utf8'));
} catch (_) { /* page 3 is omitted, not faked */ }
// ── THE PAGE-2 FUNNEL ───────────────────────────────────────────────────────
// The same context the browser path builds in upg-100-list.vue's packetFunnel(), so the Node
// generator and the in-page print cannot disagree about page 2 any more than they can about the
@ -153,10 +164,15 @@ function buildFunnel(membership) {
// resolve, and clusters.json's two DEAF lists are a second, overlapping partition that
// allLists() excludes. "In no list" has no single answer until someone says which partition is
// meant. Both paths must agree, so this one stops claiming it too.
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
...(clusters.uncovered_lists || [])];
// ⚠ COUNT THROUGH leafZones, NOT THE THREE CLUSTER KEYS. Spreading lists/asia_lists/
// uncovered_lists gives 27 and silently omits `deaf`, whose two deaf_lists entries
// buildLegendNodes collapses into ONE unified leaf — legend-tree.js:106 states the arithmetic
// ("13 + 12 + 2 + 1 deaf = 28"). The packet's triangle tip prints this number as "1 of N", so a
// 27 there is a visible wrong fact on the page. Same omission as allZones() had before 3ef164c0;
// this was the last copy of it.
const listCount = allZones().length;
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
return { ...raw, listCount: lists.length, listTotal };
return { ...raw, listCount, listTotal };
} catch (_) { return null; }
}
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
@ -289,7 +305,7 @@ async function fetchPhotos(groups, limit = 16) {
// the shared renderer, so the two packets cannot drift.
async function buildPacket(zone, groups, mapSvg) {
const photos = await fetchPhotos(groups);
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL);
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL, CHRISTIAN);
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
// ★ COUNT THE SHEETS IN THE EMITTED HTML, do not re-derive them from a formula. The old line
// computed `1 + ceil(n/PER_PAGE) + 2` while its own comment claimed the number was "asked of the