doxa-maps/library/components/charts/packetCharts.js
Doxa Maps 4e2ea5ed2c Packet chart arithmetic, and the three ways it was quietly wrong
These numbers print. A packet is handed to a reader on paper: no hover, no
tooltip, no correction, no redeploy. So the arithmetic lives in its own module
and is asserted against the shipped data rather than a fixture.

DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are
frontier, so 25 are not unreached at all and 362 are not frontier. That is one
group in six. A chart drawing DOXA as a fourth band nested inside frontier
misstates 17.2% of the list with a clean shape and no error of any kind, which
is why steps and overlays are different props and an overlay reports whether it
is a slice rather than being assumed to be one. upg100 asked for the numbers to
reach the caption as text too, because a printed band nobody can hover over
explains nothing.

Three bugs I wrote, each caught by an assertion rather than by review:

The publisher warning sat in the wrong function. I put "Joshua Project counts
16,426 where IMB counts 12,379" into the nesting error, and the test proved the
claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the
same kind of count, and no comparison of magnitudes can see that, so it needed a
separate check reading what the numbers are OF. An error message naming a cause
its own code cannot detect sends the next reader to look for the wrong thing.

A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My
heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a
valid fraction and a valid percent with nothing in the value to separate them.
The refusal I added catches a percent of 1 or more and provably cannot catch
ours, and every share this packet prints is below 1% because the subject is
unreached peoples. So the guard is not the protection, the name is:
fractionFromPercent states the unit in the source line. Both halves are
asserted, including the half that does not work, because a guard believed to
cover more than it does is the worse failure.

And the rounding flags were untested until the test used values that could
reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see
31.9, so the exact value goes in the caption and a share too small to fill one
cell is flagged rather than drawn as one.
2026-09-28 14:40:59 +00:00

192 lines
9.2 KiB
JavaScript

/**
* packetCharts.js — the arithmetic behind the packet's inverted triangle and its two calendars.
*
* Split out of the components so the numbers can be tested without mounting anything, the same way
* listSelection.js sits beside ListSelectorLegend.vue. Every figure a reader sees on a printed
* packet passes through here, and a printed page cannot be corrected after it is handed out.
*/
/**
* ★ THE STEPS MUST NEST, AND THIS REFUSES THEM WHEN THEY DO NOT.
*
* The triangle's whole rhetoric is containment: of all peoples, these are unreached; of those,
* these are frontier. That reading is only honest while each step is a SUBSET of the one above,
* and the research names a live way to break it — Joshua Project counts 16,426 peoples-by-country
* where IMB counts 12,379, and putting JP's number on top of IMB's steps produces a funnel whose
* first step does not contain its second (UPG100-RESEARCH-FINDINGS.md §3).
*
* That failure has no visual symptom. The triangle still draws, still narrows, still looks correct;
* only the claim is wrong. So this throws rather than clamping or sorting: given a shape someone
* will print and hand to a reader, a loud refusal beats a plausible picture.
*/
export function assertNested(steps) {
const s = steps || []
for (let i = 1; i < s.length; i++) {
const prev = s[i - 1]
const cur = s[i]
if (!(cur.groups <= prev.groups)) {
throw new Error(
`funnel steps do not nest: "${cur.id}" has ${cur.groups} groups but sits inside "${prev.id}" with ${prev.groups}. ` +
'A funnel claims containment, so a step can never be larger than the one above it.')
}
if (cur.population != null && prev.population != null && !(cur.population <= prev.population)) {
throw new Error(
`funnel populations do not nest: "${cur.id}" has ${cur.population} people inside "${prev.id}" with ${prev.population}.`)
}
}
return s
}
/**
* ★ ONE PUBLISHER PER FUNNEL, and this is a SEPARATE check from nesting because the numbers alone
* cannot catch it.
*
* The research names mixing publishers as the live hazard: Joshua Project counts 16,426
* peoples-by-country where IMB counts 12,379 (UPG100-RESEARCH-FINDINGS.md §3). I first wrote that
* warning into assertNested's error message and the test proved the claim false — 16,426 on top of
* 12,379 IS monotonic, so nesting arithmetic accepts it happily. The two counts are not the same
* KIND of thing (peoples-by-country vs peoples), and no comparison of magnitudes can tell you that.
*
* So the detector has to read what the numbers are OF, not what they are. Each step declares its
* source; if they disagree, the funnel is comparing two universes and is refused. A message that
* names a cause its own code cannot detect is worse than no message: it sends the next reader to
* look for the wrong thing.
*/
export function assertOnePublisher(steps) {
const named = (steps || []).filter((s) => s && s.source)
const sources = [...new Set(named.map((s) => String(s.source)))]
if (sources.length > 1) {
throw new Error(
`funnel steps mix publishers: ${sources.join(' and ')}. A funnel claims containment, and two ` +
'publishers count different things — Joshua Project counts peoples-by-country, IMB counts peoples — ' +
'so one cannot be a subset of the other however the totals happen to compare. Use one source for every step.')
}
return steps || []
}
/**
* ★ AN OVERLAY IS NOT A STEP, and conflating them is the error this file exists to prevent.
*
* DOXA is not a subset of frontier: of its 2,107 groups, 1,745 are frontier and 2,082 unreached
* (vision-funnel.json records both). So drawing DOXA as a fourth nested band asserts a containment
* that is false. An overlay therefore carries `within` — how much of it falls inside each step —
* and is drawn crossing the steps rather than under them.
*
* Returns the overlay's position WITHOUT ever implying containment: the caller gets the parts, and
* a `spans` list naming every step the overlay reaches into, so a legend can say so in words.
*/
export function overlayPlacement(overlay, steps) {
const within = (overlay && overlay.within) || {}
const ids = (steps || []).map((s) => s.id)
const spans = ids.filter((id) => Number(within[id]) > 0)
const inside = spans.reduce((n, id) => Math.max(n, Number(within[id]) || 0), 0)
const total = Number(overlay && overlay.groups) || 0
return {
id: overlay && overlay.id,
groups: total,
spans, // every step it reaches into, in step order
outside: Math.max(0, total - inside), // the part no step contains
// True only when the overlay falls entirely inside ONE step. Anything else must not be drawn
// as a slice, and the component asks this rather than deciding for itself.
isSlice: spans.length === 1 && inside === total,
}
}
/**
* The overlay's numbers as TEXT, because a printed page has no hover.
*
* upg100's constraint, and it is the one that decides whether the overlay is honest or merely
* pretty: an unexplained band crossing the triangle tells a reader nothing. The parts have to be
* inked — "2,107 in this network — 2,082 unreached, 1,745 frontier" — or the overlay is decoration.
* Returns the parts in step order with the labels the caller supplies; it never writes prose itself,
* because the packet prints in every locale we ship.
*/
export function overlayCaption(overlay, steps) {
const place = overlayPlacement(overlay, steps)
const within = (overlay && overlay.within) || {}
return {
id: place.id,
total: place.groups,
parts: (steps || [])
.filter((s) => Number(within[s.id]) > 0)
.map((s) => ({ stepId: s.id, groups: Number(within[s.id]) })),
// The part of the overlay that NO step contains, which is the whole reason it is not a slice.
outside: place.outside,
isSlice: place.isSlice,
}
}
/** Cells in a calendar grid. 100 years x 52 weeks is 5,200. */
export function cellCount(rows, columns) {
return (Number(rows) || 0) * (Number(columns) || 0)
}
/**
* How many people one cell stands for, and how many cells a share fills.
*
* ★ THE ROUNDED CELL COUNT IS NOT THE MEASUREMENT, so both come back. At 0.6133% of 5,200 the
* honest number is 31.9 cells; a reader sees 32 squares and there is no way to draw 31.9. The
* caption carries `exact` so the drawn integer never reads as precise, and `roundedUpFromBelowOne`
* flags the one case where rounding changes the KIND of statement being made: a share small enough
* to fill less than a single cell, drawn as one cell, says "at least one" where the data said
* "almost none".
*/
export function shareCells(rows, columns, share) {
const total = cellCount(rows, columns)
const s = Number(share) || 0
// ★ A FRACTION, NEVER A PERCENT, and it refuses rather than guessing. The shipped field is named
// pctEvangelical and holds 0.6133 meaning 0.6133% — so the same numeral is a plausible FRACTION
// (61.33%) and a plausible PERCENT (0.61%), and nothing in the value says which. I wrote a
// `share > 1 ? share/100 : share` heuristic here and it silently produced 3,189 filled cells
// instead of 32: a hundredfold error, on a printed page, with no symptom but a darker chart.
// No heuristic can tell these apart, so the caller converts and this refuses the impossible half.
if (s > 1) {
throw new Error(
`share must be a FRACTION, got ${s}. A share above 1 would be more than 100%. ` +
'If this came from a field named pct… it is a percent — use fractionFromPercent().')
}
const exact = total * s
const filled = Math.round(exact)
return {
total,
exact,
filled,
roundedUpFromBelowOne: exact > 0 && exact < 1 && filled >= 1,
roundedToZero: exact > 0 && filled === 0,
}
}
/**
* ★ THE UNIT LIVES IN THE FUNCTION NAME, because for our actual numbers no check can recover it.
*
* The refusal above catches a percent of 1 or more. It CANNOT catch ours: pctEvangelical is 0.6133,
* which is a perfectly valid fraction meaning 61.33%, and a perfectly valid percent meaning 0.61%.
* Nothing in the value distinguishes them, so every share below 1% — which is every share this
* packet will ever print, since the whole point is unreached peoples — passes any guard silently
* and draws a chart that is a hundred times too dark.
*
* So the guard is not the protection; the NAME is. Call sites read
* `shareCells(100, 52, fractionFromPercent(data.pctEvangelical))`, where the unit is stated twice
* and a mismatch is visible in the source line rather than in a printed packet.
*/
export function fractionFromPercent(percent) {
return (Number(percent) || 0) / 100
}
/** People per cell, for the caption the brief asks for explicitly. */
export function peoplePerCell(population, rows, columns) {
const total = cellCount(rows, columns)
if (!total) return 0
return (Number(population) || 0) / total
}
/**
* "1 of K lists = N%". K is unsettled (27 or 29, DECISION-SHEET row 41), so it is a prop and this
* asserts nothing about its value — it only refuses a division that would print Infinity or NaN on
* a page nobody can recall.
*/
export function shareOfLists(index, total) {
const k = Number(total) || 0
if (k <= 0) return null
return { index: Number(index) || 0, total: k, percent: 100 / k }
}