The funnel states the list it was actually drawn from

A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.

THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.

AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.

── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.

THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.

THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.

Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.

NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
This commit is contained in:
Doxa Maps 2026-09-26 07:15:20 +00:00
parent cea9104203
commit 127a2e0930
4 changed files with 213 additions and 39 deletions

View file

@ -236,6 +236,29 @@ const r = await page.evaluate(() => {
// exact about list pages whether or not this page is there.
funnel: document.querySelectorAll('.funnel-title').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
// the mirror of a printed-but-unasserted number: the check is real and the evidence is invisible.
// ⚠ ZERO POPULATION IS COUNTED ON PURPOSE. The reader's row printed "0 people" in every packet
// (Number(null) is 0, so the finite-number guard admitted a deliberate no-data marker). Fixed in
// the renderer — and counted here so the fix cannot silently regress.
funnelRows: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
return sheet ? sheet.querySelectorAll('div[style*="line-height:1"]').length : 0
})(),
funnelBars: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
return sheet ? sheet.querySelectorAll('div[style*="height:16px"]').length : 0
})(),
funnelZeroPop: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
if (!sheet) return 0
return [...sheet.querySelectorAll('div')]
.filter((d) => !d.children.length && /(^|\D)0(\D|$)/.test(d.textContent.trim())
&& d.textContent.trim().length < 24 && /\d/.test(d.textContent))
.filter((d) => /^0(\.0+)?\s+\D/.test(d.textContent.trim())).length
})(),
funnelShare: (document.querySelector('.funnel-share')?.textContent || '').trim().slice(0, 80),
// ★ 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,
@ -610,6 +633,21 @@ if (r.funnel && !r.funnelSteps) {
// ★ AND IF THE CALLER DECLARED IT, ITS ABSENCE IS A FAILURE. See the --expect-funnel note at the top:
// a lost call site produces a packet that is CORRECT but missing page 2, which is why nothing caught
// a merge deleting one.
// ★ EVERY FUNNEL ROW MUST HAVE A BAR. rega measured the first version: the reader's step had no bar,
// so the funnel never visibly narrowed to "yours" — a funnel whose final term is not drawn as part of
// the funnel fails at the one thing it is for. Rows and bars are counted separately so the next
// regression is a mismatch rather than an invisible omission.
if (r.funnel && r.funnelRows !== r.funnelBars) {
fails.push(`the funnel page has ${r.funnelRows} step row(s) but ${r.funnelBars} bar(s) — `
+ 'every step must be drawn as part of the funnel, including the reader\'s own')
}
// ★ AND NO STEP MAY PRINT A ZERO POPULATION. "0 people" appeared on the reader's row of every packet
// because Number(null) is 0 and the renderer's guard tested finiteness rather than PRESENCE. A people
// group with no people is not a fact; it is missing data wearing a number's clothes.
if (r.funnel && r.funnelZeroPop) {
fails.push(`${r.funnelZeroPop} funnel row(s) print a ZERO population — absent data must be hidden, `
+ 'not rendered as the number 0 (Number(null) === 0, so test presence, not finiteness)')
}
if (EXPECT_FUNNEL && !r.funnel) {
fails.push('--expect-funnel was passed but this packet has NO funnel page (.funnel-title) — '
+ 'the caller that supplies funnel data to buildPacket is missing or passing null, '
@ -670,6 +708,9 @@ console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` printed artefact : ${(pdfBytes / 1024 / 1024).toFixed(1)}MB, ${pdfFonts} embedded font(s), `
+ `${pdfImages} image(s) for ${r.distinctImgSrc} distinct source(s)`)
console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`)
console.log(` funnel (page 2) : ${r.funnel
? `${r.funnelRows} row(s), ${r.funnelBars} bar(s)${r.funnelShare ? ` — "${r.funnelShare}"` : ' — no share line'}`
: 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`)
console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`)
console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`)
console.log(` Mark 5 in full : ${r.mark5Len} chars`

View file

@ -253,7 +253,20 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// 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;
// ⚠ THE DENOMINATOR MUST BE THE LIST THE PACKET IS ACTUALLY DRAWN FROM, and it is an EXPLICIT
// input for that reason. An earlier version fell back to the last funnel step
// (`funnel.listTotal ?? steps[last].groups`), which is 2,107 — DOXA's list. That is right today
// because every map shows DOXA, and it becomes WRONG the moment the universal `list` prop lands
// (exp-shepherd, library/lists/peopleGroupList.js): under ?list=frontier a Tier A map shows the
// DOXA groups that are ALSO frontier, which SPEC-PEOPLE-GROUP-LIST-PROP.md measures at 1,753. The
// page would have printed "N of the 2,107" with total confidence while the map showed a different
// set — a faithful proxy that silently changes meaning when a new path appears, which is the same
// family as inferring "did it work" from a field that only answers "did it isolate".
//
// So a caller that knows the active list says so. A caller that does not gets NO SHARE LINE rather
// than a plausible wrong one: `mine` is always trustworthy (it is this packet's own row count) but
// a percentage needs both terms, and half a fraction is not a fact.
const total = Number(funnel.listTotal) || 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
@ -264,18 +277,40 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
? String(Math.round(pct))
: pct.toFixed(pct >= 1 ? 1 : 2).replace(/\.?0+$/, '');
const share = funnel.listCount
? `<div class="funnel-share" style="font:700 26px Georgia,serif;color:#7a3b2e;margin-top:10px">
${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),
}))
: '';
// ⚠ THE SHARE HAS TWO CLAUSES AND THEY ARE PROVABLE SEPARATELY. "1 of K lists" needs the zone
// catalogue, which only describes the DEFAULT list; "{pct}% of the {total}" needs only this
// packet's own row count and the active list's total, both of which are always known. A first
// version suppressed the whole line whenever listCount was absent, which threw away a true
// statement to avoid an unprovable one — the same over-correction as defaulting the unassigned
// count to 0, in the opposite direction.
const share = !total ? '' : `<div class="funnel-share" style="font:700 26px Georgia,serif;color:#7a3b2e;margin-top:10px">
${esc(funnel.listCount
? fill(t('funnel_share'), { k: num(funnel.listCount), pct: pctStr, total: num(total) })
: fill(t('funnel_share_pct'), { pct: pctStr, total: num(total) }))}</div>`;
// ⚠ THE FOOTNOTE IS TWO INDEPENDENT FACTS AND ONLY ONE IS ALWAYS KNOWABLE. Provenance ("counts
// from X, captured Y") holds for every list; coverage ("N of the M are not yet in any list") is a
// fact about the UPG-100 zone catalogue, which partitions DOXA and nothing else. The first version
// filled it with `funnel.unassigned ?? 0`, so a non-default list would have printed
// "0 of the 1,745 are not yet in any list" — a confident falsehood, and the zero makes it read as
// a completeness claim rather than a missing measurement.
// So an unknown coverage drops to the provenance-only string rather than defaulting the number.
// ⚠ NOT BY SPLITTING THE TRANSLATED SENTENCE on punctuation: hi ends with "।" and zh with "。",
// and parsing translated prose for structure is the same trap as reading a localized page header
// positionally (see check-packet-render's "PARSE THE NUMBERS, NEVER THE WORD ORDER").
const imbSrc = funnel.sources && funnel.sources.imb;
const foot = !imbSrc ? '' : esc(
Number.isFinite(Number(funnel.unassigned))
? fill(t('funnel_foot'), {
imb_pub: imbSrc.publisher || '',
imb_date: imbSrc.captured || '',
unassigned: num(funnel.unassigned),
total: num(total),
})
: fill(t('funnel_foot_source'), {
imb_pub: imbSrc.publisher || '',
imb_date: imbSrc.captured || '',
}),
);
// ★ THE READER'S OWN STEP IS THE LAST ROW OF THE FUNNEL, NOT A FOOTER. rega measured the first
// version in print (2026-09-26): the four global steps ended at y≈330 of 1056 while "43 · are in
@ -288,15 +323,33 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// 1605px between the intro and the footer, i.e. 28% of the page.
const ROWS_TOP = M + 110;
const FOOT_RESERVE = 210; // share line + source footnote
// ★ A NON-DEFAULT LIST GETS ITS OWN STEP, so the narrowing does not jump from one list's total to
// another list's packet. The four IMB steps stay true under every list — they are global counts,
// unaffected by which list the reader holds — but the LAST one is DOXA's 2,107, and going straight
// from it to "71 in THIS packet" implies DOXA is the parent set of the reader's rows. It is not:
// under ?list=frontier-500k the parent is that list's own DOXA-side total. packetFunnel supplies
// it as listTotal + listLabel when the active list is not the default; when it IS the default,
// listTotal equals the DOXA step and inserting it would repeat the row.
// ⚠ AND IT IS OMITTED WHEN IT WOULD REPEAT A NUMBER ALREADY ON THE PAGE — either the DOXA step
// above it (the default list, where listTotal IS that step) or the reader's row below it. Measured:
// under ?list=frontier-500k the whole list is 71 DOXA groups and a Laos packet drawn from it holds
// all 71, so the page rendered "71 -> 71", two identical bars reading as a funnel that narrows to
// nothing. A step that repeats its neighbour is not a step.
const lastImb = (steps[steps.length - 1] || {}).groups;
const ownStep = (funnel.listLabel && total && total !== lastImb && total !== mine)
? [{ groups: total, population: null, label: funnel.listLabel, tint: true }]
: [];
const allRows = [
...steps.map((s, i) => ({
groups: s.groups,
population: s.population,
label: t(`funnel_${s.id}`) !== undefined ? t(`funnel_${s.id}`) : s.id,
// The network's own list is the hinge between "the world" and "yours" — tint it, and tint
// the reader's row below it the same, so the last two read as one thought.
tint: i === steps.length - 1,
// the reader's row below it the same, so the last two read as one thought. With a
// non-default list the hinge moves down to that list's own row instead.
tint: i === steps.length - 1 && !ownStep.length,
})),
...ownStep,
{ groups: mine, population: null, label: t('funnel_this'), tint: true, mine: true },
];
// ⚠ THE GAP IS DERIVED FROM THE MEASURED ROW HEIGHTS, NOT FROM A GUESS AT THEM. A first version
@ -316,10 +369,25 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
const GAP = Math.max(18, Math.floor((H - ROWS_TOP - FOOT_RESERVE - rowsHeight) / (allRows.length - 1)));
const rows = allRows.map((r) => {
const pop = Number.isFinite(Number(r.population))
? `<div style="font:400 17px Georgia,serif;color:#a89f8c;margin-top:2px">${num(r.population)} ${esc(t('funnel_people'))}</div>`
// ⚠ `Number.isFinite(Number(x))` IS THE WRONG TEST and it printed "0 people" on the reader's row
// of every packet (rega, rendered layer, 2026-09-26). Number(null) is 0 — only Number(undefined)
// is NaN — so the deliberate `population: null` marker on the reader's row passed the guard and
// rendered as the number zero. I asked "is this a finite number?" when the question was "did
// anyone give me a population?". Presence and value are different properties; this is the same
// confusion as inferring "did it work" from a field that answers "did it isolate".
// AND ZERO IS ALSO SUPPRESSED: a people group with no people is not a fact, so a genuine 0 is
// missing data wearing a number's clothes.
const popN = (r.population === null || r.population === undefined || r.population === '')
? null : Number(r.population);
const pop = (popN !== null && Number.isFinite(popN) && popN > 0)
? `<div style="font:400 17px Georgia,serif;color:#a89f8c;margin-top:2px">${num(popN)} ${esc(t('funnel_people'))}</div>`
: '';
return `<div style="margin-bottom:${GAP}px"${r.mine ? ' class="funnel-mine-row"' : ''}>
// ⚠ THE LAST ROW DOES NOT KEEP THE FULL GAP. Measured in print (rega): the share line sat a full
// 218px below the reader's row but only 42px above the footnote, so it read as part of the
// 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"' : ''}>
<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}

View file

@ -175,7 +175,9 @@
"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"
"funnel_people": "people",
"funnel_foot_source": "Counts from {imb_pub}, captured {imb_date}.",
"funnel_share_pct": "This packet is {pct}% of the {total}"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@ -343,7 +345,9 @@
"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"
"funnel_people": "personas",
"funnel_foot_source": "Cifras de {imb_pub}, tomadas el {imb_date}.",
"funnel_share_pct": "Este cuadernillo es el {pct}% de los {total}"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@ -511,7 +515,9 @@
"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"
"funnel_people": "personnes",
"funnel_foot_source": "Chiffres de {imb_pub}, relevés le {imb_date}.",
"funnel_share_pct": "Ce livret représente {pct}% des {total}"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@ -679,7 +685,9 @@
"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"
"funnel_people": "pessoas",
"funnel_foot_source": "Números de {imb_pub}, coletados em {imb_date}.",
"funnel_share_pct": "Este caderno é {pct}% dos {total}"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@ -847,7 +855,9 @@
"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"
"funnel_people": "Menschen",
"funnel_foot_source": "Zahlen von {imb_pub}, erhoben am {imb_date}.",
"funnel_share_pct": "Dieses Heft ist {pct}% der {total}"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@ -1015,7 +1025,9 @@
"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"
"funnel_people": "persone",
"funnel_foot_source": "Dati da {imb_pub}, rilevati il {imb_date}.",
"funnel_share_pct": "Questo libretto è il {pct}% dei {total}"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@ -1183,7 +1195,9 @@
"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"
"funnel_people": "persoane",
"funnel_foot_source": "Cifre de la {imb_pub}, colectate la {imb_date}.",
"funnel_share_pct": "Această broșură reprezintă {pct}% din {total}"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@ -1351,7 +1365,9 @@
"funnel_this": "в ЭТОМ буклете",
"funnel_share": "Этот буклет — 1 из {k} списков · {pct}% от {total}",
"funnel_foot": "Данные {imb_pub}, собраны {imb_date}. {unassigned} из {total} пока не входят ни в один список.",
"funnel_people": "человек"
"funnel_people": "человек",
"funnel_foot_source": "Данные {imb_pub}, собраны {imb_date}.",
"funnel_share_pct": "Этот буклет — {pct}% от {total}"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@ -1519,7 +1535,9 @@
"funnel_this": "في هذا الكُتيّب",
"funnel_share": "هذا الكُتيّب هو 1 من {k} قائمة · {pct}% من {total}",
"funnel_foot": "الأرقام من {imb_pub}، جُمعت في {imb_date}. {unassigned} من {total} ليست بعد في أي قائمة.",
"funnel_people": "نسمة"
"funnel_people": "نسمة",
"funnel_foot_source": "الأرقام من {imb_pub}، جُمعت في {imb_date}.",
"funnel_share_pct": "هذا الكُتيّب يمثل {pct}% من {total}"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@ -1687,7 +1705,9 @@
"funnel_this": "इस पुस्तिका में हैं",
"funnel_share": "यह पुस्तिका {k} सूचियों में से 1 है · {total} का {pct}%",
"funnel_foot": "आंकड़े {imb_pub} से, {imb_date} को लिए गए। {total} में से {unassigned} अभी किसी सूची में नहीं हैं।",
"funnel_people": "लोग"
"funnel_people": "लोग",
"funnel_foot_source": "आंकड़े {imb_pub} से, {imb_date} को लिए गए।",
"funnel_share_pct": "यह पुस्तिका {total} का {pct}% है"
},
"zh": {
"map_page_kicker": "未得之民",
@ -1855,6 +1875,8 @@
"funnel_this": "在这本祷告册里",
"funnel_share": "这本祷告册是 {k} 份名单之一 · 占 {total} 的 {pct}%",
"funnel_foot": "数据来自 {imb_pub},采集于 {imb_date}。{total} 中有 {unassigned} 还不在任何名单上。",
"funnel_people": "人"
"funnel_people": "人",
"funnel_foot_source": "数据来自 {imb_pub},采集于 {imb_date}。",
"funnel_share_pct": "这本祷告册占 {total} 的 {pct}%"
}
}

View file

@ -23,7 +23,11 @@
*/
import { inject, provide, ref, shallowRef, markRaw, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// resolvePeopleGroupList/listInfo/LIST_DEFAULT are for the PACKET'S FUNNEL page, which must state the
// total of the list the packet was actually drawn from — see packetFunnel().
import {
applyPeopleGroupList, resolvePeopleGroupList, listInfo, LIST_DEFAULT,
} from '@map/lists/peopleGroupList.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
// The shared mobile bottom-sheet. Fed this profile's own tree through the `nodes`
@ -1326,19 +1330,58 @@ function allLists() {
// 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).
// ⚠ IT IS LIST-AWARE, AND IT HAS TO BE — THIS WAS A LIVE WRONG NUMBER, not a future risk. This
// profile already honours the universal `list` prop: applyPeopleGroupList() filters the rows at all
// four fetch sites, including packetRecords (:1258), and the membership files are on disk. So under
// ?list=frontier-500k the packet's rows are the 71 DOXA groups that are Project 700 — while the
// funnel, which took its denominator from the last step of vision-funnel.json, printed
// "of the 2,107". Off by a factor of 30, with nothing on the page looking broken.
// Measured 2026-09-26 from data/people-groups/lists/*.json (counts.doxa, what a Tier A map shows):
// frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 · doxa (default) 2,107
//
// ★ THREE NUMBERS WERE WRONG, NOT ONE, because all three were derived from DOXA's total:
// listTotal — the share's denominator;
// unassigned — the footnote's "N are in no list", computed as listTotal minus the assigned sum;
// listCount — 27, which is a property of the UPG-100 ZONE CATALOGUE, not of the active list. The
// zones partition DOXA; they do not partition frontier.
// So under a non-default list this states only what it can prove: the real total from the membership
// file, and NO listCount and NO unassigned — funnelPage omits the share line and the footnote rather
// than print a plausible wrong one. Half a fraction is not a fact.
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),
};
const listId = resolvePeopleGroupList(profileConfig);
const doxaTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
// THE DEFAULT LIST is the one the zone catalogue actually describes, so it is the only one that
// can honestly claim "1 of K lists" and an unassigned count.
if (listId === LIST_DEFAULT) {
const lists = allLists();
const assigned = lists.reduce((sum, l) => sum + (Number(l.count) || 0), 0);
return {
...raw,
listCount: lists.length,
listTotal: doxaTotal,
unassigned: Math.max(0, doxaTotal - assigned),
};
}
// A NON-DEFAULT LIST: read its own DOXA-side total from the membership file the build writes.
// counts.doxa is precisely "how many of this list DOXA carries", i.e. what a Tier A map shows.
// If it cannot be read, the share line is omitted — the steps above it are still true.
let listTotal = 0;
try {
const base = resolveAssetsBase(profileConfig.value);
const res = await fetch(`${base}/people-groups/lists/${listId}.json`);
if (res.ok) {
const m = await res.json();
listTotal = Number(m?.counts?.doxa) || (Array.isArray(m?.slugs) ? m.slugs.length : 0);
}
} catch (_) { /* leave listTotal 0 -> no share line */ }
return { ...raw, listTotal, listId, listLabel: (listInfo(listId) || {}).label || listId };
} catch (_) {
// A missing funnel file omits page 2 rather than failing the packet — see buildPacket.
return null;