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:
parent
9c6dda2a67
commit
de243597db
6 changed files with 296 additions and 14 deletions
23
data/people-groups/christian-share.json
Normal file
23
data/people-groups/christian-share.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue