Each packet's % evangelical page states its own list's share
The page says each box is a share of the people in this list. It printed one DOXA-wide figure - 0.6133% of 209,079,159 people - in every packet, true of no single list. That figure had no builder in the repo. build-evangelical-share.mjs makes its method reproducible, per group: the IMB CSV joined to the DOXA snapshot by ROP3 + country, each group's EvangelicalLevel band at its midpoint (0 for No Known Evangelicals, the midpoint read from the band's own wording), weighted by IMB population. It writes evangelical-by-slug.json and refuses to write unless the table, summed over every DOXA group, reproduces christian-share.json exactly: 2,091 matched, 209,079,159 people, 1,282,178 evangelical, 0.6133%. Change a single band's midpoint rule and it refuses. Not the API's imb_gsec: GSEC puts 225 groups at "no evangelicals" where EvangelicalLevel puts 1,444, because its "less than 2%" band also carries church-planting status. A share from it sits on a different scale from the method the page's own footnote states. evangelicalShare.js sums a packet's own groups - pure, zero imports, so the browser print and the offline generator run the same sum. Across the 28 zones the page now carries 28 different figures, from 0.182% (Deaf) to 0.922%. A host that supplies its own figure still wins. A list with no published levels prints a dash and no number. The old guard, Number.isFinite(Number(x)), passes null as 0 and would have printed "0% evangelical" for no data. Where some of a list's groups lack a level, the footnote says what the figure is computed over; it says nothing when coverage is whole. That clause is in all eleven languages. check-evangelical-share (gates:pre): the table reproduces the committed figure; different lists differ; no data is a dash; coverage appears only when partial. Mutation-tested four ways, each caught - the fourth only once it corrupted an existing group rather than adding one no sum reads.
This commit is contained in:
parent
b2ad4607ee
commit
bc422bb678
9 changed files with 326 additions and 29 deletions
1
data/people-groups/evangelical-by-slug.json
Normal file
1
data/people-groups/evangelical-by-slug.json
Normal file
File diff suppressed because one or more lines are too long
93
internal/check-evangelical-share.mjs
Normal file
93
internal/check-evangelical-share.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-evangelical-share — the packet's "% evangelical" page states THIS list's share.
|
||||
*
|
||||
* The page says each box is a share of the people in this list. It used to print one DOXA-wide figure
|
||||
* in every packet. Each packet now sums its own groups from the per-group table
|
||||
* (data/people-groups/evangelical-by-slug.json), by the method the DOXA-wide figure was made with.
|
||||
*
|
||||
* (a) ANCHOR — summed over every DOXA group, the table reproduces christian-share.json exactly, so
|
||||
* the per-list figures and the committed figure are one method.
|
||||
* (b) NOT A CONSTANT — two different lists give different figures. A page that printed the same
|
||||
* number for every list would pass (a) and fail here.
|
||||
* (c) NO DATA IS A DASH — a list with no published levels prints "—" and no percentage. Presence is
|
||||
* tested, not finiteness: Number(null) is 0, and a finiteness guard prints "0%" for no data.
|
||||
* (d) COVERAGE ONLY WHEN PARTIAL — the "computed over N of M groups" clause appears when some of the
|
||||
* list's groups lack a level, and not when all have one.
|
||||
*
|
||||
* Exit 0 pass · 1 fail · 75 NOT MEASURED.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
|
||||
let evangelicalShareFor, createPacketRenderer, table, committed, snapshot, clusters, strings
|
||||
try {
|
||||
;({ evangelicalShareFor } = await import(join(REPO, 'library/packet/evangelicalShare.js')))
|
||||
;({ createPacketRenderer } = await import(join(REPO, 'library/packet/packet-render.js')))
|
||||
table = JSON.parse(readFileSync(join(REPO, 'data/people-groups/evangelical-by-slug.json'), 'utf8'))
|
||||
committed = JSON.parse(readFileSync(join(REPO, 'data/people-groups/christian-share.json'), 'utf8'))
|
||||
const snap = JSON.parse(readFileSync(join(REPO, 'data/people-groups/doxa-groups-snapshot.json'), 'utf8'))
|
||||
snapshot = Array.isArray(snap) ? snap : (snap.posts || snap.groups || snap.rows || [])
|
||||
clusters = JSON.parse(readFileSync(join(REPO, 'plugin-designs/my-upg-100-list/data/clusters.json'), 'utf8'))
|
||||
strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
|
||||
} catch (e) {
|
||||
console.error(`EVANGELICAL-SHARE: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
const fails = []
|
||||
const notes = []
|
||||
|
||||
// (a) ANCHOR
|
||||
{
|
||||
const all = evangelicalShareFor(snapshot.map((g) => ({ slug: g.slug })), table)
|
||||
const same = all.groupsMatched === committed.groupsMatched && all.population === committed.population
|
||||
&& all.evangelicalPopulation === committed.evangelicalPopulation
|
||||
&& Math.abs(all.pctEvangelical - committed.pctEvangelical) < 0.00005
|
||||
if (!same) fails.push(`summed over every DOXA group the table gives ${JSON.stringify({ m: all.groupsMatched, p: all.population, e: all.evangelicalPopulation, pct: all.pctEvangelical })}; christian-share.json says ${JSON.stringify({ m: committed.groupsMatched, p: committed.population, e: committed.evangelicalPopulation, pct: committed.pctEvangelical })}`)
|
||||
else notes.push(` ok summed over all ${all.groupsInList} DOXA groups it reproduces christian-share.json: ${all.pctEvangelical.toFixed(4)}%`)
|
||||
}
|
||||
|
||||
// (b) NOT A CONSTANT — two lists whose members are listed by slug in the catalogue, so no API is needed
|
||||
{
|
||||
const bySlugZones = (clusters.asia_lists || []).filter((z) => z.selector && z.selector.type === 'pg_slugs')
|
||||
const figures = bySlugZones.map((z) => evangelicalShareFor(z.selector.values.map((slug) => ({ slug })), table).pctEvangelical)
|
||||
const distinct = new Set(figures.map((f) => (f === null ? 'none' : f.toFixed(5)))).size
|
||||
if (bySlugZones.length < 2) fails.push('fewer than two slug-listed zones to compare — (b) cannot be measured')
|
||||
else if (distinct < 2) fails.push(`${bySlugZones.length} different lists all print the same figure — the page is a constant, not each list's share`)
|
||||
else notes.push(` ok ${bySlugZones.length} slug-listed lists give ${distinct} distinct figures (${Math.min(...figures.filter((f) => f !== null)).toFixed(3)}%–${Math.max(...figures.filter((f) => f !== null)).toFixed(3)}%)`)
|
||||
}
|
||||
|
||||
// (c) + (d) THE PAGE
|
||||
{
|
||||
const R = createPacketRenderer({ strings, lang: 'en' })
|
||||
const zone = { id: 'z', label: 'Z' }
|
||||
const page = (christian) => R.christianPage(zone, christian)
|
||||
const figure = (html) => ((html.match(/class="ev-pct">([^<]*)</) || [])[1] || '').trim()
|
||||
|
||||
const none = page({ groupsInList: 12, groupsMatched: 0, population: 0, evangelicalPopulation: 0, pctEvangelical: null })
|
||||
if (!none) fails.push('a list with no published levels omits the page instead of printing a dash')
|
||||
else if (figure(none) !== '—') fails.push(`a list with no published levels prints "${figure(none)}" where it must print a dash`)
|
||||
else if (/\d%/.test(figure(none))) fails.push('a list with no published levels prints a percentage')
|
||||
else notes.push(' ok no published levels: the page prints "—" and no percentage (not "0%")')
|
||||
|
||||
const partial = page({ groupsInList: 10, groupsMatched: 7, population: 1000, evangelicalPopulation: 5, pctEvangelical: 0.5 })
|
||||
const whole = page({ groupsInList: 10, groupsMatched: 10, population: 1000, evangelicalPopulation: 5, pctEvangelical: 0.5 })
|
||||
const cov = strings.en.christian_coverage.replace('{matched}', '7').replace('{total}', '10')
|
||||
if (!partial.includes(cov)) fails.push(`partial coverage (7 of 10) does not state it: expected "${cov}"`)
|
||||
else if (whole.includes('computed over')) fails.push('full coverage still prints the coverage clause')
|
||||
else notes.push(' ok coverage is stated when partial ("computed over 7 of the 10…") and absent when whole')
|
||||
if (figure(partial) !== '0.5% · 26 weeks') fails.push(`a 0.5% list prints "${figure(partial)}"`)
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(n)
|
||||
if (fails.length) {
|
||||
console.error('EVANGELICAL-SHARE: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('EVANGELICAL-SHARE: PASS — each packet states its own list\'s share, by the committed method, and a list without data prints a dash.')
|
||||
|
|
@ -259,21 +259,26 @@ export function usePosterPrints(opts = {}) {
|
|||
set(item, kind, 'fetch', 'fetching people groups', 30);
|
||||
try {
|
||||
const data = await rows(item);
|
||||
// `christian` is the flock-wide evangelical share behind the packet's %-evangelical page. The
|
||||
// Node generator passes it (packet.mjs reads data/people-groups/christian-share.json); this
|
||||
// path did not, so buildPacket's christianPage returned '' and a browser print had one sheet
|
||||
// fewer with nothing to say so. Same seam as strings: a host may supply its own figure.
|
||||
// `christian` is the evangelical share behind the packet's %-evangelical page — of THIS packet's
|
||||
// own groups, summed from the per-group table (evangelicalShare.js), because the page says each
|
||||
// box is a share of the people in this list. It used to be one DOXA-wide figure in every packet.
|
||||
// Same seam as strings: a host may still supply its own figure (opts.christian), which wins.
|
||||
// `nearTable` feeds the FULL packet's bounded/centered sets page. Same seam as strings and
|
||||
// christian: a host that can reach the served asset pool supplies a loader (opts.sets), and a
|
||||
// host that cannot simply prints a packet without that page — setsPage's ''-without-data rule.
|
||||
// .catch(() => null): the sets page is the one page whose absence must never block a print.
|
||||
const [{ createPacketRenderer }, { buildPacketSets }, strings, christian, nearTable] = await Promise.all([
|
||||
const [{ createPacketRenderer }, { buildPacketSets }, { evangelicalShareFor }, strings, hostChristian, evTable, nearTable] = await Promise.all([
|
||||
import('../packet/packet-render.js'),
|
||||
import('../packet/packetSets.js'),
|
||||
import('../packet/evangelicalShare.js'),
|
||||
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
opts.christian ? opts.christian() : import('../../data/people-groups/christian-share.json').then((m) => m.default || m),
|
||||
opts.christian ? opts.christian() : Promise.resolve(null),
|
||||
opts.christian ? Promise.resolve(null)
|
||||
: import('../../data/people-groups/evangelical-by-slug.json').then((m) => m.default || m).catch(() => null),
|
||||
opts.sets ? Promise.resolve().then(() => opts.sets()).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
// No table and no host figure: null, and the page is omitted — never a DOXA-wide stand-in.
|
||||
const christian = hostChristian || evangelicalShareFor(data, evTable);
|
||||
// ★ THE DESCRIBER IS COMPOSED HERE AND INJECTED, because this is the BROWSER path and only it
|
||||
// can reach the Vite-only pieces. packet-render.js runs under Node too (the offline generator),
|
||||
// where importing describeGroup's country resolver would be fatal — it resolves through the
|
||||
|
|
|
|||
41
library/packet/evangelicalShare.js
Normal file
41
library/packet/evangelicalShare.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* evangelicalShare — the evangelical share of ONE packet's own people groups.
|
||||
*
|
||||
* The packet's "% evangelical" page says each box is a share of the people in this list. It printed a
|
||||
* single DOXA-wide figure in every packet instead. This sums the packet's own groups, by the same method
|
||||
* the DOXA-wide figure uses (each group's published EvangelicalLevel band at its midpoint, weighted by
|
||||
* population — see library/scripts/people-groups/build-evangelical-share.mjs, whose table reproduces
|
||||
* that figure exactly when summed over every DOXA group).
|
||||
*
|
||||
* Pure, zero imports, so the browser print and the offline generator run the identical sum.
|
||||
*
|
||||
* @param {Array<{slug:string}>} groups the packet's rows
|
||||
* @param {{groups: Object<string,[number,number]>}} table evangelical-by-slug.json
|
||||
* @returns {{groupsInList:number, groupsMatched:number, population:number, evangelicalPopulation:number,
|
||||
* pctEvangelical:(number|null)}|null}
|
||||
* pctEvangelical is null when none of the packet's groups has a published level: the page then prints
|
||||
* a dash, never a number — above all never a DOXA-wide number presented as this list's.
|
||||
* null for no table at all, so the page is omitted rather than drawn from nothing.
|
||||
*/
|
||||
export function evangelicalShareFor(groups, table) {
|
||||
const bySlug = table && table.groups
|
||||
if (!bySlug || typeof bySlug !== 'object') return null
|
||||
const rows = Array.isArray(groups) ? groups : []
|
||||
let population = 0, evangelical = 0, matched = 0
|
||||
for (const g of rows) {
|
||||
const e = g && bySlug[g.slug]
|
||||
if (!Array.isArray(e)) continue
|
||||
const [pct, pop] = e
|
||||
if (!Number.isFinite(pct) || !Number.isFinite(pop)) continue
|
||||
matched++
|
||||
population += pop
|
||||
evangelical += (pop * pct) / 100
|
||||
}
|
||||
return {
|
||||
groupsInList: rows.length,
|
||||
groupsMatched: matched,
|
||||
population,
|
||||
evangelicalPopulation: Math.round(evangelical),
|
||||
pctEvangelical: population > 0 ? (100 * evangelical) / population : null,
|
||||
}
|
||||
}
|
||||
|
|
@ -782,25 +782,40 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
// 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.
|
||||
// ★ THIS LIST'S OWN SHARE. The lede says each box is a share of the people in this list, so the
|
||||
// number must be this packet's: the caller sums its own groups (evangelicalShare.js). A DOXA-wide
|
||||
// figure used to print here in every packet, true of no single list.
|
||||
// ⚠ PRESENCE, NOT FINITENESS. `Number.isFinite(Number(x))` passes null as 0 (Number(null) is 0), so a
|
||||
// list with no published levels would have printed "0% evangelical" — a confident falsehood. A list
|
||||
// that lacks the data prints a dash and no number, and its grid stays unshaded.
|
||||
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', seed: zone.id || zone.zoneKey || 'upg-100' });
|
||||
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+$/, '');
|
||||
if (!christian || typeof christian !== 'object') return '';
|
||||
const raw = christian.pctEvangelical;
|
||||
const known = raw !== null && raw !== undefined && raw !== '' && Number.isFinite(Number(raw));
|
||||
const pct = known ? Number(raw) : null;
|
||||
const g = lifeGrid({ shaded: known ? (pct / 100) * 5200 : 0, shadeClass: 'lc-reached', seed: zone.id || zone.zoneKey || 'upg-100' });
|
||||
// ⚠ TWO DECIMALS ON BOTH SIDES, not rounded above 10. The reached share is well under 1% 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+$/, '');
|
||||
const reached = known ? `${esc(fmtPct(pct))}% · ${num(g.shadedCount)} ${esc(t('life_weeks'))}` : '—';
|
||||
const lost = known ? `${esc(fmtPct(100 - pct))}% · ${num(g.TOTAL - g.shadedCount)} ${esc(t('life_weeks'))}` : '—';
|
||||
// Stated only when it is partial: how many of this list's groups the figure is computed over.
|
||||
const inList = Number(christian.groupsInList), matched = Number(christian.groupsMatched);
|
||||
const coverage = Number.isFinite(inList) && Number.isFinite(matched) && matched < inList
|
||||
? ` · ${esc(fill(t('christian_coverage'), { matched: num(matched), total: num(inList) }))}` : '';
|
||||
const people = known && Number(christian.population) > 0
|
||||
? ` · ${num(christian.population)} ${esc(t('christian_people'))}` : '';
|
||||
return sheet(`
|
||||
<div class="pg-head"><div class="pg-kicker">${esc(t('christian_kicker'))}</div>
|
||||
<h1 class="pg-h1">${esc(t('christian_title'))}</h1></div>
|
||||
<p class="pg-lede">${esc(t('christian_lede'))}</p>
|
||||
${g.html}
|
||||
<div class="lc-legend">
|
||||
<span><i class="lc-c lc-reached"></i> ${esc(t('christian_reached'))} — ${esc(fmtPct(pct))}% · ${num(g.shadedCount)} ${esc(t('life_weeks'))}</span>
|
||||
<span><i class="lc-c"></i> ${esc(t('christian_lost'))} — ${esc(fmtPct(lostPct))}% · ${num(g.TOTAL - g.shadedCount)} ${esc(t('life_weeks'))}</span>
|
||||
<span><i class="lc-c lc-reached"></i> ${esc(t('christian_reached'))} — <span class="ev-pct">${reached}</span></span>
|
||||
<span><i class="lc-c"></i> ${esc(t('christian_lost'))} — ${lost}</span>
|
||||
</div>
|
||||
<p class="lc-foot">${esc(t('christian_foot'))}${christian.population ? ` · ${num(christian.population)} ${esc(t('christian_people'))}` : ''}</p>
|
||||
<p class="lc-foot">${esc(t('christian_foot'))}${people}${coverage}</p>
|
||||
`, 'sheet-christian');
|
||||
}
|
||||
|
||||
|
|
|
|||
127
library/scripts/people-groups/build-evangelical-share.mjs
Normal file
127
library/scripts/people-groups/build-evangelical-share.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-evangelical-share.mjs — each DOXA people group's published evangelical share, so a prayer
|
||||
* packet can state the share for ITS OWN groups.
|
||||
*
|
||||
* Writes data/people-groups/evangelical-by-slug.json: { slug: [bandMidpointPercent, population] }.
|
||||
*
|
||||
* WHY. The packet's "% evangelical" page says each box is a share of the people IN THIS LIST, but it
|
||||
* printed one DOXA-wide figure (christian-share.json, 0.6133% of 209,079,159 people) in every packet.
|
||||
* That file has no builder in the repo; this script is its METHOD made reproducible, per group:
|
||||
* · the IMB people-group CSV joined to the DOXA snapshot by ROP3 + country (the Vision Card's join);
|
||||
* · each group's published EvangelicalLevel BAND taken at its midpoint, 0 for "No Known Evangelicals";
|
||||
* · the group's IMB population, which is what the share is weighted by.
|
||||
* The midpoint is derived from the band's own text, never typed per band, so a new band in the data
|
||||
* cannot silently take a wrong value.
|
||||
*
|
||||
* ⚠ NOT imb_gsec. The API's GSEC puts 225 groups at "no evangelicals" where EvangelicalLevel puts 1,444
|
||||
* at "No Known Evangelicals": GSEC's "less than 2%" band also carries church-planting status and absorbs
|
||||
* about 1,200 zero-evangelical groups. A share computed from it would sit on a different scale from the
|
||||
* page's own stated method and read higher than the figure it replaces.
|
||||
*
|
||||
* ★ SELF-CHECK: summed over every DOXA group, the table must reproduce christian-share.json exactly —
|
||||
* the same matched count, population, evangelical population and percentage. If it does not, the
|
||||
* per-group table is not the committed method and the script refuses to write.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const DATA = join(REPO, 'data', 'people-groups')
|
||||
|
||||
// A small CSV reader for quoted fields (the IMB file quotes names that contain commas).
|
||||
function parseCsv(text) {
|
||||
const out = []
|
||||
let row = [], field = '', q = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
if (q) {
|
||||
if (c === '"' && text[i + 1] === '"') { field += '"'; i++ }
|
||||
else if (c === '"') q = false
|
||||
else field += c
|
||||
} else if (c === '"') q = true
|
||||
else if (c === ',') { row.push(field); field = '' }
|
||||
else if (c === '\n') { row.push(field); out.push(row); row = []; field = '' }
|
||||
else if (c !== '\r') field += c
|
||||
}
|
||||
if (field || row.length) { row.push(field); out.push(row) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** The band's midpoint, from its own wording. null for a band this cannot read. */
|
||||
export function bandMidpoint(band) {
|
||||
const b = String(band || '').trim()
|
||||
if (b === 'No Known Evangelicals') return 0
|
||||
let m = b.match(/^(\d+(?:\.\d+)?)% or Greater but Less than (\d+(?:\.\d+)?)%$/)
|
||||
if (m) return (Number(m[1]) + Number(m[2])) / 2
|
||||
m = b.match(/^Less than (\d+(?:\.\d+)?)%$/)
|
||||
if (m) return Number(m[1]) / 2
|
||||
m = b.match(/^(\d+(?:\.\d+)?)% or Greater$/)
|
||||
if (m) return (Number(m[1]) + 100) / 2
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = parseCsv(readFileSync(join(DATA, 'people_groups.csv'), 'utf8'))
|
||||
const col = Object.fromEntries(rows[0].map((h, i) => [h.trim(), i]))
|
||||
for (const need of ['ROP3Code', 'ISOAlpha3', 'EvangelicalLevel', 'Population']) {
|
||||
if (col[need] === undefined) { console.error(`[evangelical-share] the IMB CSV has no ${need} column`); process.exit(1) }
|
||||
}
|
||||
const imb = new Map()
|
||||
for (const r of rows.slice(1)) {
|
||||
const key = `${String(r[col.ROP3Code] || '').trim()}|${String(r[col.ISOAlpha3] || '').trim()}`
|
||||
if (!imb.has(key)) imb.set(key, r)
|
||||
}
|
||||
const snapRaw = JSON.parse(readFileSync(join(DATA, 'doxa-groups-snapshot.json'), 'utf8'))
|
||||
const snap = Array.isArray(snapRaw) ? snapRaw : (snapRaw.posts || snapRaw.groups || snapRaw.rows || [])
|
||||
|
||||
const groups = {}
|
||||
const unreadable = new Set()
|
||||
let pop = 0, ev = 0
|
||||
for (const g of snap) {
|
||||
const r = imb.get(`${String(g.rop3 || '').trim()}|${String(g.country || '').trim()}`)
|
||||
if (!r || !g.slug) continue
|
||||
const mid = bandMidpoint(r[col.EvangelicalLevel])
|
||||
if (mid === null) { unreadable.add(r[col.EvangelicalLevel]); continue }
|
||||
const p = Math.round(Number(r[col.Population]) || 0)
|
||||
groups[g.slug] = [mid, p]
|
||||
pop += p; ev += (p * mid) / 100
|
||||
}
|
||||
if (unreadable.size) {
|
||||
console.error(`[evangelical-share] bands with no readable midpoint: ${[...unreadable].join(' | ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ★ THE SELF-CHECK: the per-group table must BE the committed method.
|
||||
const committed = JSON.parse(readFileSync(join(DATA, 'christian-share.json'), 'utf8'))
|
||||
const got = {
|
||||
groupsMatched: Object.keys(groups).length,
|
||||
population: pop,
|
||||
evangelicalPopulation: Math.round(ev),
|
||||
pctEvangelical: Math.round((10000 * ev) / pop) / 10000 * 100,
|
||||
}
|
||||
const want = {
|
||||
groupsMatched: committed.groupsMatched,
|
||||
population: committed.population,
|
||||
evangelicalPopulation: committed.evangelicalPopulation,
|
||||
pctEvangelical: committed.pctEvangelical,
|
||||
}
|
||||
const same = got.groupsMatched === want.groupsMatched && got.population === want.population
|
||||
&& got.evangelicalPopulation === want.evangelicalPopulation
|
||||
&& Math.abs((100 * ev) / pop - want.pctEvangelical) < 0.00005
|
||||
if (!same) {
|
||||
console.error('[evangelical-share] REFUSING TO WRITE: the per-group table does not reproduce christian-share.json')
|
||||
console.error(` computed : ${JSON.stringify(got)}`)
|
||||
console.error(` committed: ${JSON.stringify(want)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const out = {
|
||||
_what: 'Each DOXA people group\'s evangelical share (band midpoint, percent) and population, for a prayer packet\'s own-groups figure. Summed over every group it reproduces christian-share.json exactly.',
|
||||
_method: 'IMB people-group CSV joined to the DOXA snapshot by ROP3Code + ISOAlpha3; EvangelicalLevel band at its midpoint (0 for No Known Evangelicals); IMB Population.',
|
||||
sources: { imb: 'data/people-groups/people_groups.csv', doxa: 'data/people-groups/doxa-groups-snapshot.json' },
|
||||
groups,
|
||||
}
|
||||
writeFileSync(join(DATA, 'evangelical-by-slug.json'), JSON.stringify(out) + '\n')
|
||||
console.log(`[evangelical-share] ${got.groupsMatched} groups -> data/people-groups/evangelical-by-slug.json`)
|
||||
console.log(` reproduces christian-share.json: ${got.groupsMatched} matched · ${pop.toLocaleString('en')} people · ${Math.round(ev).toLocaleString('en')} evangelical · ${((100 * ev) / pop).toFixed(4)}%`)
|
||||
|
|
@ -201,6 +201,7 @@
|
|||
"christian_reached": "Evangelical",
|
||||
"christian_lost": "Still waiting",
|
||||
"christian_people": "people",
|
||||
"christian_coverage": "computed over {matched} of the {total} groups in this list",
|
||||
"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",
|
||||
|
|
@ -427,6 +428,7 @@
|
|||
"christian_reached": "Evangélicos",
|
||||
"christian_lost": "Aún esperando",
|
||||
"christian_people": "personas",
|
||||
"christian_coverage": "calculado sobre {matched} de los {total} grupos de esta lista",
|
||||
"christian_foot": "Ponderado por población a partir del EvangelicalLevel de peoplegroups.org, tomando cada banda publicada en su punto medio y 0 para «No Known Evangelicals»",
|
||||
"pray_blessed": "Los bendije",
|
||||
"pray_know": "Qué saber",
|
||||
|
|
@ -653,6 +655,7 @@
|
|||
"christian_reached": "Évangéliques",
|
||||
"christian_lost": "Toujours en attente",
|
||||
"christian_people": "personnes",
|
||||
"christian_coverage": "calculé sur {matched} des {total} groupes de cette liste",
|
||||
"christian_foot": "Pondéré par la population à partir du EvangelicalLevel de peoplegroups.org, chaque tranche publiée étant prise à son point médian et 0 pour « No Known Evangelicals »",
|
||||
"pray_blessed": "Je les ai bénis",
|
||||
"pray_know": "Ce qu’il faut savoir",
|
||||
|
|
@ -879,6 +882,7 @@
|
|||
"christian_reached": "Evangélicos",
|
||||
"christian_lost": "Ainda esperando",
|
||||
"christian_people": "pessoas",
|
||||
"christian_coverage": "calculado sobre {matched} dos {total} grupos desta lista",
|
||||
"christian_foot": "Ponderado por população a partir do EvangelicalLevel de peoplegroups.org, tomando cada faixa publicada no seu ponto médio e 0 para «No Known Evangelicals»",
|
||||
"pray_blessed": "Abençoei-os",
|
||||
"pray_know": "O que saber",
|
||||
|
|
@ -1103,6 +1107,7 @@
|
|||
"christian_reached": "Evangelikal",
|
||||
"christian_lost": "Warten noch",
|
||||
"christian_people": "Menschen",
|
||||
"christian_coverage": "berechnet über {matched} der {total} Gruppen dieser Liste",
|
||||
"christian_foot": "Bevölkerungsgewichtet aus dem EvangelicalLevel von peoplegroups.org, jede veröffentlichte Spanne in ihrer Mitte und 0 für „No Known Evangelicals“",
|
||||
"pray_blessed": "Gesegnet",
|
||||
"pray_know": "Was wissen",
|
||||
|
|
@ -1329,6 +1334,7 @@
|
|||
"christian_reached": "Evangelici",
|
||||
"christian_lost": "Ancora in attesa",
|
||||
"christian_people": "persone",
|
||||
"christian_coverage": "calcolato su {matched} dei {total} gruppi di questa lista",
|
||||
"christian_foot": "Ponderato per popolazione dall’EvangelicalLevel di peoplegroups.org, prendendo ogni fascia pubblicata al suo punto medio e 0 per «No Known Evangelicals»",
|
||||
"pray_blessed": "Li ho benedetti",
|
||||
"pray_know": "Cosa sapere",
|
||||
|
|
@ -1553,6 +1559,7 @@
|
|||
"christian_reached": "Evanghelici",
|
||||
"christian_lost": "Încă așteaptă",
|
||||
"christian_people": "persoane",
|
||||
"christian_coverage": "calculat pe {matched} din cele {total} grupuri din această listă",
|
||||
"christian_foot": "Ponderat după populație pornind de la EvangelicalLevel de pe peoplegroups.org, fiecare interval publicat luat la mijloc și 0 pentru „No Known Evangelicals”",
|
||||
"pray_blessed": "I-am binecuvântat",
|
||||
"pray_know": "Ce să știu",
|
||||
|
|
@ -1777,6 +1784,7 @@
|
|||
"christian_reached": "Евангельские",
|
||||
"christian_lost": "Всё ещё ждут",
|
||||
"christian_people": "человек",
|
||||
"christian_coverage": "рассчитано по {matched} из {total} групп этого списка",
|
||||
"christian_foot": "Взвешено по населению на основе EvangelicalLevel с peoplegroups.org: каждый опубликованный диапазон берётся по середине, «No Known Evangelicals» — как 0",
|
||||
"pray_blessed": "Благословил",
|
||||
"pray_know": "Что узнать",
|
||||
|
|
@ -2003,6 +2011,7 @@
|
|||
"christian_reached": "إنجيليون",
|
||||
"christian_lost": "ما زالوا ينتظرون",
|
||||
"christian_people": "نسمة",
|
||||
"christian_coverage": "محسوبة على {matched} من أصل {total} مجموعة في هذه القائمة",
|
||||
"christian_foot": "مرجّح بالسكان من EvangelicalLevel على peoplegroups.org، مع أخذ كل نطاق منشور عند نقطة منتصفه و٠ لـ«No Known Evangelicals»",
|
||||
"pray_blessed": "باركتهم",
|
||||
"pray_know": "ماذا أعرف",
|
||||
|
|
@ -2229,6 +2238,7 @@
|
|||
"christian_reached": "सुसमाचारी",
|
||||
"christian_lost": "अब भी प्रतीक्षा में",
|
||||
"christian_people": "लोग",
|
||||
"christian_coverage": "इस सूची के {total} समूहों में से {matched} के आधार पर",
|
||||
"christian_foot": "peoplegroups.org के EvangelicalLevel से जनसंख्या-भारित, हर प्रकाशित श्रेणी को उसके मध्यबिंदु पर और «No Known Evangelicals» को 0 मानते हुए",
|
||||
"pray_blessed": "आशीष दी",
|
||||
"pray_know": "क्या जानना है",
|
||||
|
|
@ -2453,6 +2463,7 @@
|
|||
"christian_reached": "福音派",
|
||||
"christian_lost": "仍在等待",
|
||||
"christian_people": "人",
|
||||
"christian_coverage": "按本名单 {total} 个族群中的 {matched} 个计算",
|
||||
"christian_foot": "依据 peoplegroups.org 的 EvangelicalLevel 按人口加权,每个已公布区间取其中点,\"No Known Evangelicals\" 计为 0",
|
||||
"pray_blessed": "已祝福",
|
||||
"pray_know": "当知道什么",
|
||||
|
|
|
|||
|
|
@ -188,16 +188,18 @@ 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;
|
||||
// ── THE %-EVANGELICAL PAGE — this packet's OWN groups ───────────────────────
|
||||
// The page says each box is a share of the people in this list, so each packet sums its own groups
|
||||
// from the per-group table (data/people-groups/evangelical-by-slug.json, built by
|
||||
// library/scripts/people-groups/build-evangelical-share.mjs, which refuses to write unless the table,
|
||||
// summed over every DOXA group, reproduces christian-share.json exactly). The sum itself is the shared
|
||||
// pure module the browser print uses, so neither path re-derives the method. A missing table yields
|
||||
// null and the page is omitted — never the DOXA-wide figure printed as a single list's.
|
||||
const { evangelicalShareFor } = await import('../../packet/evangelicalShare.js');
|
||||
let EV_TABLE = null;
|
||||
try {
|
||||
CHRISTIAN = JSON.parse(readFileSync(join(__dirname, '../../../data/people-groups/christian-share.json'), 'utf8'));
|
||||
} catch (_) { /* page 3 is omitted, not faked */ }
|
||||
EV_TABLE = JSON.parse(readFileSync(join(__dirname, '../../../data/people-groups/evangelical-by-slug.json'), 'utf8'));
|
||||
} catch (_) { /* the page 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
|
||||
|
|
@ -415,7 +417,7 @@ async function fetchPhotos(groups, limit = 16) {
|
|||
// (public/dx-maps/assets/tables/), not a plugin folder — the same copy the browser fetches, so the
|
||||
// offline packet and a printed-from-the-app packet chart identical rows. A missing table yields
|
||||
// null and buildPacketSets -> setsPage omit the page rather than failing the run: one way for the
|
||||
// page to be absent, not two (the FUNNEL/CHRISTIAN pattern above, applied unchanged).
|
||||
// page to be absent, not two (the FUNNEL / evangelical-share pattern above, applied unchanged).
|
||||
let NEAR_TABLE = null;
|
||||
try {
|
||||
NEAR_TABLE = JSON.parse(readFileSync(
|
||||
|
|
@ -427,7 +429,7 @@ try {
|
|||
async function buildPacket(zone, groups, mapSvg) {
|
||||
const photos = await fetchPhotos(groups);
|
||||
const sets = NEAR_TABLE ? buildPacketSets(groups, NEAR_TABLE) : null;
|
||||
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL, CHRISTIAN, VARIANT, sets);
|
||||
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL, evangelicalShareFor(groups, EV_TABLE), VARIANT, sets);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@
|
|||
"check:packet-variants": "node internal/check-packet-variants.mjs",
|
||||
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
|
||||
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
|
||||
"check:evangelical-share": "node internal/check-evangelical-share.mjs",
|
||||
"check:packets-all": "node internal/check-packets-all.mjs",
|
||||
"check:parse": "node internal/inline-parse-check.mjs",
|
||||
"check:pinned-umd": "node internal/check-pinned-umd.mjs",
|
||||
|
|
@ -310,7 +311,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -485,6 +486,7 @@
|
|||
"check:packet-variants": "the poster wall's Prayer List prints the PACKET (not the columnar list), and every variant it offers exists in packet-render's VARIANTS. A variant may drop fixed pages but never people: lightweight keeps every group. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.",
|
||||
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
|
||||
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",
|
||||
"check:evangelical-share": "the packet's % evangelical page states THIS list's share: summed over every DOXA group the per-group table reproduces christian-share.json exactly; different lists give different figures; a list with no published levels prints a dash, never 0% (Number(null) is 0); the coverage clause appears only when partial.",
|
||||
"check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
|
||||
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
|
||||
"check:pinned-umd": "library/utils/loadPinnedUmd.js (d3 for PPLR and the Wall) loads a pinned UMD with SRI and never touches the host page's global of the same name: a host's own d3 gains no keys, a failed load leaves it in place. Headless.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue