The prayer packet shows how its list was narrowed out of all the earth
Page 2 of every packet is now the funnel: 12,379 people groups on earth, 7,341
unreached, 4,754 frontier, 2,107 on this prayer network's list, and N of those
in the reader's hand. A partner holding one list can see where it sits in the
whole, which is the argument the packet is making.
★ IT RENDERS NUMBERS IT DID NOT COMPUTE. Every count is read from
data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates
from the IMB CSV and the live network list and which already drives the Vision
Card. Re-deriving them in the packet would give one fact two sources; they would
drift, and two internally-consistent surfaces disagreeing is worse than one
wrong number because a reader cannot tell which to trust.
K AND THE SHARE ARE MEASURED AT RENDER TIME, NEVER TYPED, and this mattered:
the brief said "~30 lists of 50-100" and the catalogue says K=27 with counts
from 8 to 118 (west-africa 118, asia-india-rajasthan 116, uncovered-scatter 8).
Only about half sit in that band, so no copy claims it. The page states what the
data can defend -- "1 of 27 lists" and "N of the 2,107" -- and a footnote states
plainly that 21 of the 2,107 are in no list, because a partner adding up rows
would find the gap anyway. Reported to the Driver; K=27 pending his ruling on
whether the two residual buckets count.
A SHARE MUST NEVER ROUND TO ZERO. One decimal gave the single-group fixture
"0.0%", which reads as "this packet covers none of them". Verified exhaustively
over all 2,108 possible list sizes against 2,107: no size prints 0 and none is
malformed. 1 group -> 0.05%, 20 -> 0.95%, 21 -> 1%, 43 -> 2%, 118 -> 5.6%.
NUMERALS STAY WESTERN, as required. num() is toLocaleString(lang), and CLDR's
default numbering system for the bare ar/hi tags is latn -- verified 12,379 and
2,107 in both. Reusing num() rather than writing a second formatter is also what
keeps the funnel's digits identical to every other page of the packet.
PAGE COUNT NOW COUNTS THE ARTIFACT. packet.mjs computed
`1 + ceil(n/PER_PAGE) + 2` under a comment claiming the number was "asked of the
renderer, not recomputed here". It WAS recomputed, and the funnel page silently
made it wrong by one -- exactly the drift the comment warned of. It now counts
the emitted sheets.
THE GATE IS TAUGHT, NOT LOOSENED. check-packet-render went red on every fixture
("43 cards should lay out as 6 sheets, but the packet has 7") -- correct
behaviour on a structure it had not been told about. The wrong fix is to accept
N or N+1: that assertion exists to catch silently dropped people groups, and a
±1 tolerance is the slack a lost list page hides in. So the funnel is DETECTED
via .funnel-title and added as a known term, and a funnel page that renders its
title but no share figure is now its own failure -- a number printed but not
asserted on is decoration.
The page is OPTIONAL: buildPacket omits it when no funnel data was supplied, so
every existing caller keeps working and a host without the data still gets a
correct packet. Better a 4-section packet than one asserting figures it could
not read. A multi-list batch selection keeps the steps but drops "1 of 27",
which is false for a custom selection, while "N of the 2,107" stays exactly true.
Verified: check:packet-strings-parity PASS (31 string keys x 11 locales, my 10
new ones present and translated in all 10 non-English blocks, 0 declared-absent
additions). A partial locale with 3 of 10 funnel keys translated renders those 3
translated and the other 7 in English, nothing blank. Arabic packet generated:
dir="rtl", funnel is sheet 2 of 7, all four steps carry Western digits. All 16
generated packets satisfy the corrected sheet arithmetic, including the 11 older
laos packets that have no funnel page. Mutation-tested: dropping a list sheet is
caught by the independent page-header witness (20 DROPPED), a funnel page with no
share figure is caught by the new assertion, and removing the funnel page passes.
BUNDLE=my-upg-100-list vite build 319 modules clean.
NOT verified in a browser: check:packet-render launches Chromium, which the
Playwright policy reserves to rega. The simulations above parse the same
attributes from the same generated HTML.
This commit is contained in:
parent
67730457f6
commit
65017fefa4
6 changed files with 341 additions and 26 deletions
|
|
@ -215,6 +215,11 @@ const r = await page.evaluate(() => {
|
|||
return {
|
||||
sheets: document.querySelectorAll('.sheet').length,
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
// The page-2 funnel is OPTIONAL (buildPacket omits it when no funnel data was supplied), so its
|
||||
// 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,
|
||||
funnelSteps: document.querySelectorAll('.funnel-mine').length,
|
||||
// ★ A CARD MUST NAME ITS PEOPLE GROUP. Counting .card proves the element exists; the spec says
|
||||
// pages 2..n carry "the people groups, 20 per page, with photos" — a card with a photo and a
|
||||
// BLANK NAME satisfies every count here: the total holds, the photo/typographic split holds,
|
||||
|
|
@ -562,12 +567,29 @@ if (!boxes.length) {
|
|||
// page arithmetic only if the pages drop too) and a mis-sized grid, which is the way
|
||||
// "20 per page WITH IMAGES" actually regresses. Verified against all 11 locale packets
|
||||
// of laos (95 cards -> 8 sheets) before being turned on.
|
||||
//
|
||||
// ⚠ THE PAGE-2 FUNNEL IS COUNTED, NOT TOLERATED. When it was added (2026-09-26) every fixture went
|
||||
// red here with "43 cards should lay out as 6 sheets, but the packet has 7" — the gate doing its job
|
||||
// on a structural change it had not been told about. The wrong fix is to accept N or N+1 sheets:
|
||||
// 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.
|
||||
const PER_PAGE = 20
|
||||
const expectedSheets = 1 + Math.ceil(r.cards / PER_PAGE) + 2
|
||||
const funnelSheets = r.funnel ? 1 : 0
|
||||
const expectedSheets = 1 + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
|
||||
if (r.cards && r.sheets !== expectedSheets) {
|
||||
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
|
||||
+ `(1 map + ${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`)
|
||||
+ `(1 map${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`)
|
||||
}
|
||||
// ★ AND IF THE FUNNEL PAGE IS THERE, IT MUST CARRY ITS NUMBERS. A sheet holding the title and no
|
||||
// figures would satisfy the arithmetic above perfectly — the same "a number printed but not asserted
|
||||
// on is decoration" trap the card-name check above exists for, one page earlier. .funnel-mine is the
|
||||
// reader's own share, the one figure on the page that is about THIS packet.
|
||||
if (r.funnel && !r.funnelSteps) {
|
||||
fails.push('the funnel page rendered its title but no share figure (.funnel-mine) — '
|
||||
+ 'page 2 is asserting a narrowing it never states')
|
||||
}
|
||||
|
||||
// ── ...AND THAT ALONE IS NOT ENOUGH: IT ONLY PROVES SELF-CONSISTENCY ────────
|
||||
|
|
|
|||
|
|
@ -224,7 +224,10 @@ export function usePosterPrints(opts = {}) {
|
|||
// The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because
|
||||
// only the bundle knows where its posters are served from. If it cannot be
|
||||
// fetched the packet still prints — a cover without the map beats no packet.
|
||||
async function printPacket(item, { mapSvg = '' } = {}) {
|
||||
// `funnel` is the page-2 narrowing data (see packet-render.funnelPage). It is passed THROUGH
|
||||
// untouched: this composable does not know how a host defines its lists, and deriving the counts
|
||||
// here would make a second source of truth for numbers the Vision Card already publishes.
|
||||
async function printPacket(item, { mapSvg = '', funnel = null } = {}) {
|
||||
if (busy()) return;
|
||||
const kind = 'Prayer Packet';
|
||||
set(item, kind, 'fetch', 'fetching people groups', 30);
|
||||
|
|
@ -242,7 +245,7 @@ export function usePosterPrints(opts = {}) {
|
|||
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
|
||||
);
|
||||
set(item, kind, 'render', `printing ${data.length} people groups`, 70);
|
||||
openPrintFrame(R.buildPacket(item, data, photos, mapSvg), { autoprint: true });
|
||||
openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel), { autoprint: true });
|
||||
done(item, kind);
|
||||
} catch (e) { fail(item, kind, e); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,7 +210,103 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
<div class="fine">${esc(t('privacy_note'))}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
|
||||
// ── PAGE 2: the funnel — how this list was narrowed out of every people group ─
|
||||
//
|
||||
// WHY THIS PAGE EXISTS. Driver 2026-09-26, via the shepherd: the packets go to ALL partners for
|
||||
// the largest prayer campaign, and a partner holding one list needs to see where their list sits
|
||||
// in the whole. The narrowing IS the argument: 12,379 people groups on earth, 7,341 of them
|
||||
// unreached, 4,754 frontier, 2,107 on this network's list, and N of those in the reader's hand.
|
||||
//
|
||||
// ★ IT RENDERS NUMBERS IT DID NOT COMPUTE, ON PURPOSE. Every count is READ from the caller's
|
||||
// `funnel` object, which comes from data/people-groups/vision-funnel.json — the file
|
||||
// library/scripts/people-groups/build-vision-funnel.mjs generates from the IMB CSV and the live
|
||||
// DOXA list, and which ALREADY feeds the Vision Card. Recomputing them here would create a second
|
||||
// source of truth for the same fact, and the two would drift into "two honest surfaces that
|
||||
// disagree" — each internally consistent, together telling a reader the project cannot count.
|
||||
// If a number is wrong, it is wrong in the generator, and it is wrong in the Vision Card too.
|
||||
//
|
||||
// ⚠ NO STEP IS HARDCODED AND NEITHER IS THE NUMBER OF STEPS. `funnel.steps` is walked as given,
|
||||
// so when the `list` prop lands (exp-shepherd, Tier A: profile-config.list / ?list= /
|
||||
// window.MAP_LIST, default 'doxa') and the 7k / 4.7k / 700 lists plug in, their own step chains
|
||||
// render with no change here. A step whose id has no string falls back to the id itself rather
|
||||
// than rendering blank.
|
||||
//
|
||||
// ⚠ K AND N ARE READ AT RENDER TIME, NEVER TYPED. Measured 2026-09-26: the brief said "~30 lists
|
||||
// of 50-100" and the catalogue says K=27 with counts from 8 to 118 — west-africa 118,
|
||||
// asia-india-rajasthan 116, uncovered-scatter 8. Only about half sit inside 50-100, so this page
|
||||
// never claims that band; it states the share the data can defend. Shepherd approved exactly this
|
||||
// (ENDTOKEN-DXS-UPG-KDECIDE), K=27 including the two residual buckets until the Driver rules.
|
||||
//
|
||||
// ⚠ NUMERALS STAY WESTERN. num() is toLocaleString(lang), and for the bare `ar`/`hi` tags CLDR's
|
||||
// default numbering system is `latn` — verified: 12,379 and 2,107 in both. Arabic-Indic digits
|
||||
// need an explicit -u-nu-arab, which nothing here asks for. The brief requires numerals untouched,
|
||||
// and reusing num() is also what keeps the funnel's digits identical to every other packet page.
|
||||
function funnelPage(zone, groups, funnel) {
|
||||
if (!funnel || !Array.isArray(funnel.steps) || !funnel.steps.length) return '';
|
||||
const steps = funnel.steps;
|
||||
const top = steps[0] && steps[0].groups;
|
||||
// The widest bar is the first step; every bar is drawn proportional to it, with a floor so the
|
||||
// smallest step is still a visible bar rather than a hairline.
|
||||
const BAR_W = W - 2 * M - 300;
|
||||
const width = (n) => Math.max(26, Math.round(BAR_W * (Number(n) / Number(top || 1))));
|
||||
|
||||
const rows = steps.map((s, i) => {
|
||||
const label = t(`funnel_${s.id}`) !== undefined ? t(`funnel_${s.id}`) : s.id;
|
||||
const pop = Number.isFinite(Number(s.population))
|
||||
? `<div style="font:400 15px Georgia,serif;color:#a89f8c;margin-top:1px">${num(s.population)} ${esc(t('funnel_people'))}</div>`
|
||||
: '';
|
||||
// The last IMB step is the network's own list — tint it so the eye lands where the reader is.
|
||||
const fillCol = i === steps.length - 1 ? '#7a3b2e' : '#cdbf9e';
|
||||
return `<div style="margin-bottom:22px">
|
||||
<div style="font:700 34px Georgia,serif;color:#1b1a17;line-height:1">${num(s.groups)}</div>
|
||||
<div style="font:400 18px Georgia,serif;color:#3b3226;margin-top:2px">${esc(label)}</div>
|
||||
${pop}
|
||||
<div style="height:12px;width:${width(s.groups)}px;background:${fillCol};margin-top:6px;border-radius:2px"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// THIS list, the step the reader is holding. `groups.length` is the packet's own row count, so
|
||||
// the page cannot disagree with the pages that follow it.
|
||||
const mine = groups.length;
|
||||
const total = Number(funnel.listTotal ?? (steps[steps.length - 1] || {}).groups) || 0;
|
||||
const pct = total ? (100 * mine / total) : 0;
|
||||
// ⚠ A SHARE MUST NEVER ROUND TO ZERO — zero reads as "this packet covers none of them", which is
|
||||
// false for every list. Measured against the real catalogue (counts 1..118 of 2,107): one decimal
|
||||
// gave the single-group fixture "0.0%" when the true value is 0.0475%. So: integer at or above
|
||||
// 10%, one decimal from 1%, two below that, then strip trailing zeros so 0.9967 prints "1" rather
|
||||
// than "1.00". Verified across 1, 2, 8, 20, 21, 43, 95, 118 and 300 groups.
|
||||
const pctStr = pct >= 10
|
||||
? String(Math.round(pct))
|
||||
: pct.toFixed(pct >= 1 ? 1 : 2).replace(/\.?0+$/, '');
|
||||
|
||||
const share = funnel.listCount
|
||||
? `<div class="funnel-share" style="font:700 22px Georgia,serif;color:#7a3b2e;margin-top:4px">
|
||||
${esc(fill(t('funnel_share'), { k: num(funnel.listCount), pct: pctStr, total: num(total) }))}</div>`
|
||||
: '';
|
||||
const foot = (funnel.sources && funnel.sources.imb)
|
||||
? esc(fill(t('funnel_foot'), {
|
||||
imb_pub: funnel.sources.imb.publisher || '',
|
||||
imb_date: funnel.sources.imb.captured || '',
|
||||
unassigned: num(funnel.unassigned ?? 0),
|
||||
total: num(total),
|
||||
}))
|
||||
: '';
|
||||
|
||||
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:${M + 96}px;right:${M}px">${rows}</div>
|
||||
<div style="position:absolute;left:${M}px;right:${M}px;bottom:74px">
|
||||
<div class="funnel-mine" style="font:700 34px Georgia,serif;color:#7a3b2e;line-height:1">${num(mine)}</div>
|
||||
<div style="font:400 18px Georgia,serif;color:#3b3226;margin-top:2px">${esc(t('funnel_this'))}</div>
|
||||
${share}
|
||||
</div>
|
||||
<div class="fine">${foot}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGES 3..n: the people groups, 20 per sheet ─────────────────────────────
|
||||
function listPages(zone, groups, photos) {
|
||||
const pages = [];
|
||||
for (let start = 0; start < groups.length; start += PER_PAGE) {
|
||||
|
|
@ -329,13 +425,18 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
|
||||
// Assemble the whole document. photos is a Map(slug -> dataURL-or-URL); mapSvg is
|
||||
// the poster's <svg> plus the stylesheet its var()s resolve against (see mapCss).
|
||||
function buildPacket(zone, groups, photos, mapSvg) {
|
||||
// `funnel` is OPTIONAL and the page is omitted when it is absent, so every existing caller keeps
|
||||
// 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) {
|
||||
const pages = [
|
||||
mapPage(zone, groups, mapSvg),
|
||||
funnelPage(zone, groups, funnel),
|
||||
...listPages(zone, groups, photos),
|
||||
pointsPage(),
|
||||
wheelPage(),
|
||||
];
|
||||
].filter(Boolean);
|
||||
// ⚠ dir MUST be set for right-to-left languages or the packet renders Arabic
|
||||
// left-aligned — readable glyphs in the wrong place, which looks like a
|
||||
// styling quirk rather than the bug it is. Verified in a browser: with
|
||||
|
|
@ -346,5 +447,5 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
<body>${pages.join('\n')}</body></html>`;
|
||||
}
|
||||
|
||||
return { buildPacket, mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
|
||||
return { buildPacket, mapPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,17 @@
|
|||
}
|
||||
],
|
||||
"point4_passage": "And they came to the other side of the sea, into the country of the Gadarenes. When he had come out of the boat, immediately a man with an unclean spirit met him out of the tombs. He lived in the tombs. Nobody could bind him any more, not even with chains, because he had been often bound with fetters and chains, and the chains had been torn apart by him, and the fetters broken in pieces. Nobody had the strength to tame him. Always, night and day, in the tombs and in the mountains, he was crying out, and cutting himself with stones. When he saw Jesus from afar, he ran and bowed down to him, and crying out with a loud voice, he said, “What have I to do with you, Jesus, you Son of the Most High God? I adjure you by God, don’t torment me.” For he said to him, “Come out of the man, you unclean spirit!” He asked him, “What is your name?” He said to him, “My name is Legion, for we are many.” He begged him much that he would not send them away out of the country. Now on the mountainside there was a great herd of pigs feeding. All the demons begged him, saying, “Send us into the pigs, that we may enter into them.” At once Jesus gave them permission. The unclean spirits came out and entered into the pigs. The herd of about two thousand rushed down the steep bank into the sea, and they were drowned in the sea. Those who fed them fled, and told it in the city and in the country. The people came to see what it was that had happened. They came to Jesus, and saw him who had been possessed by demons sitting, clothed, and in his right mind, even him who had the legion; and they were afraid. Those who saw it declared to them what happened to him who was possessed by demons, and about the pigs. They began to beg him to depart from their region. As he was entering into the boat, he who had been possessed by demons begged him that he might be with him. He didn’t allow him, but said to him, “Go to your house, to your friends, and tell them what great things the Lord has done for you and how he had mercy on you.” He went his way, and began to proclaim in Decapolis how Jesus had done great things for him, and everyone marveled.",
|
||||
"point4_passage_credit": "Mark 5:1–20 · World English Bible (public domain)"
|
||||
"point4_passage_credit": "Mark 5:1–20 · World English Bible (public domain)",
|
||||
"funnel_title": "How this list was found",
|
||||
"funnel_intro": "Every people group on earth, narrowed to the ones with no church and no worker.",
|
||||
"funnel_all": "people groups in the world",
|
||||
"funnel_unreached": "are unreached — under 2% evangelical",
|
||||
"funnel_frontier": "are frontier — 0.1% or less, and almost no one is going",
|
||||
"funnel_doxa": "are on this prayer network’s list",
|
||||
"funnel_this": "are in THIS packet",
|
||||
"funnel_share": "This packet is 1 of {k} lists · {pct}% of the {total}",
|
||||
"funnel_foot": "Counts from {imb_pub}, captured {imb_date}. {unassigned} of the {total} are not yet in any list.",
|
||||
"funnel_people": "people"
|
||||
},
|
||||
"es": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
|
||||
|
|
@ -323,7 +333,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Cómo se formó esta lista",
|
||||
"funnel_intro": "Todos los grupos étnicos de la tierra, reducidos a los que no tienen iglesia ni obrero.",
|
||||
"funnel_all": "grupos étnicos en el mundo",
|
||||
"funnel_unreached": "no están alcanzados: menos del 2% evangélico",
|
||||
"funnel_frontier": "son de frontera: 0,1% o menos, y casi nadie va",
|
||||
"funnel_doxa": "están en la lista de esta red de oración",
|
||||
"funnel_this": "están en ESTE cuadernillo",
|
||||
"funnel_share": "Este cuadernillo es 1 de {k} listas · {pct}% de los {total}",
|
||||
"funnel_foot": "Cifras de {imb_pub}, tomadas el {imb_date}. {unassigned} de los {total} aún no están en ninguna lista.",
|
||||
"funnel_people": "personas"
|
||||
},
|
||||
"fr": {
|
||||
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
|
||||
|
|
@ -481,7 +501,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Comment cette liste a été établie",
|
||||
"funnel_intro": "Tous les peuples de la terre, réduits à ceux qui n’ont ni église ni ouvrier.",
|
||||
"funnel_all": "peuples dans le monde",
|
||||
"funnel_unreached": "sont non atteints — moins de 2% d’évangéliques",
|
||||
"funnel_frontier": "sont des peuples frontières — 0,1% ou moins, et presque personne n’y va",
|
||||
"funnel_doxa": "sont sur la liste de ce réseau de prière",
|
||||
"funnel_this": "sont dans CE livret",
|
||||
"funnel_share": "Ce livret est 1 des {k} listes · {pct}% des {total}",
|
||||
"funnel_foot": "Chiffres de {imb_pub}, relevés le {imb_date}. {unassigned} des {total} ne figurent encore sur aucune liste.",
|
||||
"funnel_people": "personnes"
|
||||
},
|
||||
"pt": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
|
||||
|
|
@ -639,7 +669,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Como esta lista foi formada",
|
||||
"funnel_intro": "Todos os grupos étnicos da terra, reduzidos aos que não têm igreja nem obreiro.",
|
||||
"funnel_all": "grupos étnicos no mundo",
|
||||
"funnel_unreached": "não são alcançados — menos de 2% evangélicos",
|
||||
"funnel_frontier": "são de fronteira — 0,1% ou menos, e quase ninguém vai",
|
||||
"funnel_doxa": "estão na lista desta rede de oração",
|
||||
"funnel_this": "estão NESTE caderno",
|
||||
"funnel_share": "Este caderno é 1 de {k} listas · {pct}% dos {total}",
|
||||
"funnel_foot": "Números de {imb_pub}, coletados em {imb_date}. {unassigned} dos {total} ainda não estão em nenhuma lista.",
|
||||
"funnel_people": "pessoas"
|
||||
},
|
||||
"de": {
|
||||
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
|
||||
|
|
@ -797,7 +837,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Wie diese Liste entstand",
|
||||
"funnel_intro": "Alle Volksgruppen der Erde, eingegrenzt auf die ohne Gemeinde und ohne Mitarbeiter.",
|
||||
"funnel_all": "Volksgruppen weltweit",
|
||||
"funnel_unreached": "sind unerreicht — unter 2% evangelikal",
|
||||
"funnel_frontier": "sind Frontier — 0,1% oder weniger, und fast niemand geht hin",
|
||||
"funnel_doxa": "stehen auf der Liste dieses Gebetsnetzwerks",
|
||||
"funnel_this": "sind in DIESEM Heft",
|
||||
"funnel_share": "Dieses Heft ist 1 von {k} Listen · {pct}% der {total}",
|
||||
"funnel_foot": "Zahlen von {imb_pub}, erhoben am {imb_date}. {unassigned} der {total} sind noch auf keiner Liste.",
|
||||
"funnel_people": "Menschen"
|
||||
},
|
||||
"it": {
|
||||
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
|
||||
|
|
@ -955,7 +1005,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Come è nata questa lista",
|
||||
"funnel_intro": "Tutti i gruppi etnici della terra, ridotti a quelli senza chiesa e senza operai.",
|
||||
"funnel_all": "gruppi etnici nel mondo",
|
||||
"funnel_unreached": "non sono raggiunti — meno del 2% evangelici",
|
||||
"funnel_frontier": "sono di frontiera — 0,1% o meno, e quasi nessuno ci va",
|
||||
"funnel_doxa": "sono nella lista di questa rete di preghiera",
|
||||
"funnel_this": "sono in QUESTO libretto",
|
||||
"funnel_share": "Questo libretto è 1 di {k} liste · {pct}% dei {total}",
|
||||
"funnel_foot": "Dati da {imb_pub}, rilevati il {imb_date}. {unassigned} dei {total} non sono ancora in nessuna lista.",
|
||||
"funnel_people": "persone"
|
||||
},
|
||||
"ro": {
|
||||
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
|
||||
|
|
@ -1113,7 +1173,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Cum a fost alcătuită această listă",
|
||||
"funnel_intro": "Toate grupurile etnice ale pământului, restrânse la cele fără biserică și fără lucrător.",
|
||||
"funnel_all": "grupuri etnice în lume",
|
||||
"funnel_unreached": "sunt neatinse — sub 2% evanghelici",
|
||||
"funnel_frontier": "sunt de frontieră — 0,1% sau mai puțin, și aproape nimeni nu merge",
|
||||
"funnel_doxa": "sunt pe lista acestei rețele de rugăciune",
|
||||
"funnel_this": "sunt în ACEASTĂ broșură",
|
||||
"funnel_share": "Această broșură este 1 din {k} liste · {pct}% din {total}",
|
||||
"funnel_foot": "Cifre de la {imb_pub}, colectate la {imb_date}. {unassigned} din {total} nu sunt încă pe nicio listă.",
|
||||
"funnel_people": "persoane"
|
||||
},
|
||||
"ru": {
|
||||
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
|
||||
|
|
@ -1271,7 +1341,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "Как был составлен этот список",
|
||||
"funnel_intro": "Все народы земли, сведённые к тем, у кого нет ни церкви, ни работника.",
|
||||
"funnel_all": "народов в мире",
|
||||
"funnel_unreached": "неохваченные — менее 2% евангельских верующих",
|
||||
"funnel_frontier": "это народы-фронтир — 0,1% или меньше, и почти никто к ним не идёт",
|
||||
"funnel_doxa": "в списке этой молитвенной сети",
|
||||
"funnel_this": "в ЭТОМ буклете",
|
||||
"funnel_share": "Этот буклет — 1 из {k} списков · {pct}% от {total}",
|
||||
"funnel_foot": "Данные {imb_pub}, собраны {imb_date}. {unassigned} из {total} пока не входят ни в один список.",
|
||||
"funnel_people": "человек"
|
||||
},
|
||||
"ar": {
|
||||
"map_page_kicker": "شعوب غير مبلَّغة",
|
||||
|
|
@ -1429,7 +1509,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "كيف تكوّنت هذه القائمة",
|
||||
"funnel_intro": "كل المجموعات الشعبية على الأرض، مُصفّاة إلى التي بلا كنيسة وبلا عامل.",
|
||||
"funnel_all": "مجموعة شعبية في العالم",
|
||||
"funnel_unreached": "غير مبلَّغة — أقل من 2% إنجيليين",
|
||||
"funnel_frontier": "مجموعات حدودية — 0.1% أو أقل، ولا يكاد أحد يذهب إليها",
|
||||
"funnel_doxa": "مدرجة في قائمة شبكة الصلاة هذه",
|
||||
"funnel_this": "في هذا الكُتيّب",
|
||||
"funnel_share": "هذا الكُتيّب هو 1 من {k} قائمة · {pct}% من {total}",
|
||||
"funnel_foot": "الأرقام من {imb_pub}، جُمعت في {imb_date}. {unassigned} من {total} ليست بعد في أي قائمة.",
|
||||
"funnel_people": "نسمة"
|
||||
},
|
||||
"hi": {
|
||||
"map_page_kicker": "अनछुए जनसमूह",
|
||||
|
|
@ -1587,7 +1677,17 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "यह सूची कैसे बनी",
|
||||
"funnel_intro": "पृथ्वी के सभी जनसमूह, उन तक सीमित जिनके पास न कलीसिया है न कार्यकर्ता।",
|
||||
"funnel_all": "जनसमूह संसार में",
|
||||
"funnel_unreached": "अप्राप्त हैं — 2% से कम सुसमाचारी",
|
||||
"funnel_frontier": "सीमांत हैं — 0.1% या उससे कम, और लगभग कोई नहीं जाता",
|
||||
"funnel_doxa": "इस प्रार्थना नेटवर्क की सूची में हैं",
|
||||
"funnel_this": "इस पुस्तिका में हैं",
|
||||
"funnel_share": "यह पुस्तिका {k} सूचियों में से 1 है · {total} का {pct}%",
|
||||
"funnel_foot": "आंकड़े {imb_pub} से, {imb_date} को लिए गए। {total} में से {unassigned} अभी किसी सूची में नहीं हैं।",
|
||||
"funnel_people": "लोग"
|
||||
},
|
||||
"zh": {
|
||||
"map_page_kicker": "未得之民",
|
||||
|
|
@ -1745,6 +1845,16 @@
|
|||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"funnel_title": "这份名单是怎么来的",
|
||||
"funnel_intro": "地上所有的族群,收窄到那些没有教会、没有工人的族群。",
|
||||
"funnel_all": "个族群在世上",
|
||||
"funnel_unreached": "是未得之民——福音派不足 2%",
|
||||
"funnel_frontier": "是前沿族群——0.1% 或更少,几乎无人前往",
|
||||
"funnel_doxa": "在这个祷告网络的名单上",
|
||||
"funnel_this": "在这本祷告册里",
|
||||
"funnel_share": "这本祷告册是 {k} 份名单之一 · 占 {total} 的 {pct}%",
|
||||
"funnel_foot": "数据来自 {imb_pub},采集于 {imb_date}。{total} 中有 {unassigned} 还不在任何名单上。",
|
||||
"funnel_people": "人"
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +111,29 @@ const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H }
|
|||
|
||||
// ── DATA ────────────────────────────────────────────────────────────────────
|
||||
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
||||
|
||||
// ── 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
|
||||
// pages around it.
|
||||
//
|
||||
// ★ READ, NEVER COUNTED. The four steps come verbatim from vision-funnel.json, generated by
|
||||
// build-vision-funnel.mjs from the IMB CSV and the live network list, and already driving the
|
||||
// Vision Card. K and the unassigned count ARE measured here — but from the catalogue that defines
|
||||
// them, at build time, so no figure on the page is a typed constant.
|
||||
// A missing file yields null, and buildPacket omits page 2 rather than printing numbers it could
|
||||
// not read.
|
||||
const FUNNEL = (() => {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8'));
|
||||
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
|
||||
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
|
||||
...(clusters.uncovered_lists || [])];
|
||||
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
const assigned = lists.reduce((s, l) => s + (Number(l.count) || 0), 0);
|
||||
return { ...raw, listCount: lists.length, listTotal, unassigned: Math.max(0, listTotal - assigned) };
|
||||
} catch (_) { return null; }
|
||||
})();
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
|
||||
|
||||
|
|
@ -194,11 +217,14 @@ 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);
|
||||
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL);
|
||||
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
|
||||
// 1 map + N list pages + points + wheel — asked of the renderer, not recomputed
|
||||
// here, so this count cannot disagree with what was actually emitted.
|
||||
const pageCount = 1 + Math.ceil(groups.length / R.PER_PAGE) + 2;
|
||||
// ★ 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
|
||||
// renderer, not recomputed here" — it WAS recomputed, and adding the funnel page silently made it
|
||||
// wrong by one, which is precisely the drift the comment warned about. Counting the artifact
|
||||
// cannot disagree with the artifact.
|
||||
const pageCount = (html.match(/class="sheet /g) || []).length;
|
||||
return { html, pageCount, withPhoto };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1280,15 +1280,59 @@ async function printPrayerPacket(zoneId) {
|
|||
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
]);
|
||||
const R = createPacketRenderer({ strings, lang: packetLang() });
|
||||
await packetPrints.printPacket(zone, { mapSvg: await packetMapSvg(zoneId, R) });
|
||||
await packetPrints.printPacket(zone, {
|
||||
mapSvg: await packetMapSvg(zoneId, R),
|
||||
funnel: await packetFunnel(),
|
||||
});
|
||||
}
|
||||
|
||||
// Every list across all three cluster buckets, flattened.
|
||||
//
|
||||
// ★ THE ONE SEAM THE `list` PROP WILL FEED. Driver 2026-09-26 via the shepherd: UPG-100 is the mass
|
||||
// prayer mobiliser — a GENERATOR that takes a list in and puts posters, packets and cards out, so a
|
||||
// partner can generate their own. Every list-consuming path in this profile goes through THIS
|
||||
// function (allLists -> selectedLists, printPrayerPacket, the legend, the funnel), so when the
|
||||
// universal `list` prop lands (exp-shepherd Tier A: profile-config.list / ?list= / window.MAP_LIST,
|
||||
// default 'doxa') it replaces the body here and nothing else moves. Do not read `clusters` directly
|
||||
// anywhere else; that is what makes the swap a one-line change instead of an audit.
|
||||
function allLists() {
|
||||
return [...(clusters.lists || []), ...(clusters.asia_lists || []),
|
||||
...(clusters.uncovered_lists || [])];
|
||||
}
|
||||
|
||||
// ── THE PAGE-2 FUNNEL CONTEXT ───────────────────────────────────────────────
|
||||
// What the packet's funnel page needs that the packet itself cannot know: the nested IMB/network
|
||||
// steps, how many lists the catalogue holds, and how many groups are in no list at all.
|
||||
//
|
||||
// ★ IT READS, IT DOES NOT COUNT. The four steps come verbatim from
|
||||
// data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates from the IMB CSV
|
||||
// and the live network list and which ALREADY drives the Vision Card. Re-deriving them here would
|
||||
// give the same fact two sources that drift apart.
|
||||
//
|
||||
// ⚠ K AND THE UNASSIGNED COUNT ARE MEASURED FROM THE CATALOGUE AT CALL TIME, NEVER TYPED. Measured
|
||||
// 2026-09-26: K = 27 (13 + 12 + 2 buckets) with per-list counts from 8 to 118, against a brief that
|
||||
// said "~30 lists of 50-100" — only about half sit in that band, so no copy here claims it. The
|
||||
// 27 lists account for 2,086 of the 2,107, leaving 21 in no list; the footnote states that plainly
|
||||
// rather than letting a partner discover it by adding up rows (shepherd, ENDTOKEN-DXS-UPG-KDECIDE).
|
||||
async function packetFunnel() {
|
||||
try {
|
||||
const raw = (await import('../../data/people-groups/vision-funnel.json')).default;
|
||||
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
|
||||
const lists = allLists();
|
||||
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
const assigned = lists.reduce((sum, l) => sum + (Number(l.count) || 0), 0);
|
||||
return {
|
||||
...raw,
|
||||
listCount: lists.length,
|
||||
listTotal,
|
||||
unassigned: Math.max(0, listTotal - assigned),
|
||||
};
|
||||
} catch (_) {
|
||||
// A missing funnel file omits page 2 rather than failing the packet — see buildPacket.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── BATCH SELECTION MAKES A PACKET OR A POSTER ──────────────────────────────
|
||||
// Driver: the batch tool is "super buggy", and recolouring regions is not what is wanted —
|
||||
// "the batch selection tool should let you create your own poster or prayer packet based on
|
||||
|
|
@ -1356,7 +1400,16 @@ async function makeSelectionPacket() {
|
|||
const R = createPacketRenderer({ strings, lang: packetLang() });
|
||||
const lists = selectedLists();
|
||||
const mapSvg = lists.length === 1 ? await packetMapSvg(lists[0].id, R) : '';
|
||||
await packetPrints.printPacket(sel, { mapSvg });
|
||||
// ⚠ "1 OF 27 LISTS" IS FALSE FOR A CUSTOM SELECTION — it is not one of the catalogue's lists at
|
||||
// all. The NARROWING is still true and still the point, and "N of the 2,107" stays exactly true
|
||||
// because N is the selection's own row count. So a multi-list selection keeps the funnel steps
|
||||
// and drops listCount, which funnelPage already treats as "omit the share line". Same judgement
|
||||
// the map above makes: print nothing rather than something that reads true and is not.
|
||||
const funnel = await packetFunnel();
|
||||
await packetPrints.printPacket(sel, {
|
||||
mapSvg,
|
||||
funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null),
|
||||
});
|
||||
} catch (e) {
|
||||
status.value = `Selection packet failed: ${String(e?.message || e).slice(0, 90)}`;
|
||||
setTimeout(() => { if (status.value.startsWith('Selection packet failed')) status.value = ''; }, 4000);
|
||||
|
|
|
|||
Loading…
Reference in a new issue