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 `
+ // ★ 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 `
${num(r.groups)}
${esc(r.label)}
${pop} -
+
`; }).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 `
+
${num(r.groups)}
+
${esc(r.equation || r.label)}
+ ${delta !== null ? `
\u2212 ${num(delta)}
` : ''} +
`; + }).join(''); + const sidebar = `
+
${esc(t('funnel_eq_title'))}
+ ${eqRows}
`; + + // ★ 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 ? '' : `
+
${esc(fill(t('funnel_tip'), { n: num(funnel.listCount) }))}
+
`; + return sheet(`
${esc(t('funnel_title'))}
${esc(t('funnel_intro'))}
-
${rows}${share}
+
${rows}${share}${tip}
+ ${sidebar}
${foot}
`); } - // ── 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(``); + } + return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `
${cells.join('')}
` }; + } + + // 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(` +
${esc(t('life_kicker'))}
+

${esc(t('life_title'))}

+

${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_kicker'))}
+

${esc(t('christian_title'))}

+

${esc(t('christian_lede'))}

+ ${g.html} +
+ ${esc(t('christian_reached'))} — ${esc(fmtPct(pct))}% · ${num(g.shadedCount)} ${esc(t('life_weeks'))} + ${esc(t('christian_lost'))} — ${esc(fmtPct(lostPct))}% · ${num(g.TOTAL - g.shadedCount)} ${esc(t('life_weeks'))} +
+

${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 })
${i + 1}. ${esc(g.name)}
${esc(flat(g.religion))}
${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}
- `; + +
${PRAY_COLS.map((k) => `${esc(t(k))}`).join('')}
+ `; }).join(''); pages.push(sheet(`
${esc(zone.label || zone.id)}
@@ -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 }) ${pages.join('\n')}`; } - 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 }; } diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json index d2e0568a4..7e7b38a1d 100644 --- a/library/scripts/posters/packet-strings.json +++ b/library/scripts/posters/packet-strings.json @@ -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", diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index 980a8d23b..441c783db 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -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