An overlay that cannot be placed must not be drawn at Infinity

The funnel computed each bracket with Math.min and Math.max over the step
indices an overlay touches. On an empty list those return Infinity and
-Infinity, so the bracket rendered at y=Infinity with height -Infinity: an
invalid rect that SVG discards without a word. The overlay vanished and took its
groups with it — no error, no gap, just a chart missing a band nobody knew to
look for.

An overlay is unplaceable when its `within` is missing, empty, or names a step
that does not exist, and all three are easy to write. `within` is the unusual
half of the API, so an overlay written as { id, label, groups } looks complete
and is silently undrawable, and a typo in a step id deletes a band rather than
failing.

bracketSpan now returns null for all three, the component lists them in ink
instead of dropping them, and the arithmetic moved out of the template where it
could be asserted. An overlay with no known relationship to the steps must not
be drawn, because a drawn bracket asserts a span.

Found by reading the template rather than by a test, which is worth recording:
these components are in L61 and had never been rendered once. The negative
control pins the cause — Math.min of an empty span really is Infinity — so the
assertions rest on the measured behaviour and not on my account of it.
This commit is contained in:
Doxa Maps 2026-09-28 15:04:26 +00:00
parent adb3e8d402
commit e3b77de4c7
3 changed files with 65 additions and 15 deletions

View file

@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
assertNested, assertOnePublisher, fractionFromPercent, overlayPlacement, scatterCells, scatterSpread, overlayCaption, cellCount, shareCells, peoplePerCell, shareOfLists,
assertNested, assertOnePublisher, bracketSpan, fractionFromPercent, overlayPlacement, scatterCells, scatterSpread, overlayCaption, cellCount, shareCells, peoplePerCell, shareOfLists,
} from '../library/components/charts/packetCharts.js'
const DATA = join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'people-groups')
@ -66,6 +66,25 @@ ok(cap.parts.map((p) => `${p.stepId}:${p.groups}`).join(' ') === 'unreached:2082
'as inkable parts in step order — an unexplained band on paper tells a reader nothing')
ok(cap.isSlice === false, 'and the caption says it is not a slice, so the component need not decide that itself')
// ── an overlay that cannot be placed must not be DRAWN (found by reading the template) ──────────
// ★ THIS SHIPPED IN L61 AND I CAUGHT IT BY READING, NOT BY A TEST. The component computed its
// bracket with Math.min(...idx) / Math.max(...idx) over the steps an overlay touches. On an EMPTY
// list those return Infinity and -Infinity, so the bracket rendered at y=Infinity with height
// -Infinity — an invalid rect that SVG discards without a word. The overlay vanished and took its
// groups with it: no error, no gap, just a chart missing a band nobody knew to look for.
// The input is easy to get wrong because `within` is the unusual half of the API — an overlay
// written as { id, label, groups } looks complete and is unplaceable.
ok(bracketSpan(DOXA, STEPS) !== null, 'a well-formed overlay places')
ok(bracketSpan({ id: 'list', groups: 100 }, STEPS) === null,
'an overlay with NO within returns null rather than a bracket at Infinity')
ok(bracketSpan({ id: 'list', groups: 100, within: { typo: 50 } }, STEPS) === null,
'and so does one whose within names a step that does not exist — a typo cannot silently delete a band')
ok(bracketSpan({ id: 'list', groups: 100, within: {} }, STEPS) === null, 'an empty within too')
// NEG CONTROL: the old expression really did produce the broken geometry, so this is not theory.
const emptyIdx = []
ok(Math.min(...emptyIdx) === Infinity && Math.max(...emptyIdx) === -Infinity,
'NEG CONTROL: Math.min/max of an empty span ARE Infinity/-Infinity, which is what drew the invisible rect')
// ── the calendars ───────────────────────────────────────────────────────────────────────────────
ok(cellCount(100, 52) === 5200, 'a 100-year life in weeks is 5,200 cells')
const share = readJson('christian-share.json')

View file

@ -21,7 +21,7 @@
* text-align: start) rather than left/right.
*/
import { computed } from 'vue'
import { assertNested, assertOnePublisher, overlayCaption, shareOfLists } from './packetCharts.js'
import { assertNested, assertOnePublisher, bracketSpan, overlayCaption, shareOfLists } from './packetCharts.js'
const props = defineProps({
/** Strictly nested, outermost first: [{ id, label, groups, population, source }] */
@ -68,19 +68,23 @@ const height = computed(() => PAD * 2 + props.steps.length * STEP_H)
* An overlay's bracket spans the steps it reaches into. It is positioned BESIDE the funnel, so it
* can cross two steps without asserting that either contains it.
*/
const brackets = computed(() => props.overlays.map((o) => {
const cap = overlayCaption(o, props.steps)
const idx = cap.parts.map((p) => props.steps.findIndex((s) => s.id === p.stepId)).filter((i) => i >= 0)
const from = Math.min(...idx), to = Math.max(...idx)
return {
...cap,
label: o.label,
population: o.population,
y: PAD + from * STEP_H,
h: (to - from + 1) * STEP_H,
isHighlight: o.id === props.highlight,
}
}))
const placed = computed(() => props.overlays.map((o) => ({ o, span: bracketSpan(o, props.steps) })))
const brackets = computed(() => placed.value.filter((x) => x.span).map(({ o, span }) => ({
...overlayCaption(o, props.steps),
label: o.label,
population: o.population,
y: PAD + span.from * STEP_H,
h: (span.to - span.from + 1) * STEP_H,
isHighlight: o.id === props.highlight,
})))
/**
* Overlays we cannot place. Named in ink rather than dropped: an overlay whose `within` is missing
* or names an unknown step has no known relationship to the steps, and silently omitting it loses
* its groups from the page with nothing to say so.
*/
const unplaced = computed(() => placed.value.filter((x) => !x.span).map(({ o }) => o))
const ofKText = computed(() => {
const k = props.ofK ? shareOfLists(props.ofK.index, props.ofK.total) : null
@ -115,6 +119,9 @@ const ofKText = computed(() => {
<span v-if="!b.isSlice && labels.notASlice" class="vf__caveat"> {{ labels.notASlice }}</span>
</li>
</ul>
<p v-for="o in unplaced" :key="o.id" class="vf__unplaced" role="alert">
{{ o.label || o.id }} — {{ o.groups }}{{ labels.unplaced ? ' · ' + labels.unplaced : ' · cannot be placed: no `within` naming a step in this funnel' }}
</p>
<p v-if="ofKText" class="vf__ofk">{{ labels.ofK || '' }} {{ ofKText.index }} / {{ ofKText.total }} · {{ ofKText.percent }}%</p>
</template>
</figure>
@ -132,6 +139,7 @@ const ofKText = computed(() => {
.vf__step, .vf__overlay, .vf__ofk { font: 400 12px Georgia, serif; margin-top: 4px; text-align: start; }
.vf__overlay--hi { font-weight: 700; }
.vf__caveat { opacity: 0.8; }
.vf__unplaced { font: 700 12px Georgia, serif; color: #8a1c1c; margin-top: 4px; text-align: start; }
.vf__error { font: 700 13px Georgia, serif; color: #8a1c1c; border: 2px solid #8a1c1c; padding: 8px; }
@media print { .vf__band, .vf__bracket { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
</style>

View file

@ -116,6 +116,29 @@ export function overlayCaption(overlay, steps) {
}
}
/**
* Where an overlay's bracket starts and ends, or null when it cannot be placed at all.
*
* ★ null IS THE POINT. An overlay whose `within` is missing, empty, or names a step that does not
* exist has no known relationship to the funnel — and "unknown" must never be drawn, because a
* drawn bracket asserts a span. This lived inline in the component as
* `Math.min(...idx)` / `Math.max(...idx)`, which on an empty list returns Infinity and -Infinity,
* so the bracket rendered at y=Infinity with height -Infinity: an invalid rect that SVG discards
* without complaint. The overlay simply vanished, and its groups vanished with it.
*
* It is an easy input to get wrong, too: `within` is the unusual half of the API, and an overlay
* written as { id, label, groups } alone looks complete. So the caller gets null and the component
* says so in ink rather than drawing nothing and calling it a chart.
*/
export function bracketSpan(overlay, steps) {
const cap = overlayCaption(overlay, steps)
const idx = cap.parts
.map((part) => (steps || []).findIndex((s) => s.id === part.stepId))
.filter((i) => i >= 0)
if (!idx.length) return null
return { from: Math.min(...idx), to: Math.max(...idx) }
}
/** 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)