diff --git a/data/people-groups/christian-share.json b/data/people-groups/christian-share.json new file mode 100644 index 000000000..09e38aa8b --- /dev/null +++ b/data/people-groups/christian-share.json @@ -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 + } +} diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index c3f40eefe..41ca53712 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -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`) } diff --git a/internal/check-packet-strings-parity.mjs b/internal/check-packet-strings-parity.mjs index ca3080b34..a9ece4701 100644 --- a/internal/check-packet-strings-parity.mjs +++ b/internal/check-packet-strings-parity.mjs @@ -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. diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index 65d79b0eb..d32fdd1ac 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -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 `
${esc(t('life_lede'))}
+ ${g.html} +${esc(t('life_foot'))}
+ `, '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(` +${esc(t('christian_lede'))}
+ ${g.html} +${esc(t('christian_foot'))}${christian.population ? ` · ${num(christian.population)} ${esc(t('christian_people'))}` : ''}
+ `, '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 })