From e3b77de4c7cf8f2c58c831c7970a776f1d38bcfa Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 15:04:26 +0000 Subject: [PATCH] An overlay that cannot be placed must not be drawn at Infinity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-packet-charts.mjs | 21 ++++++++++++- library/components/charts/VisionFunnel.vue | 36 +++++++++++++--------- library/components/charts/packetCharts.js | 23 ++++++++++++++ 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/internal/check-packet-charts.mjs b/internal/check-packet-charts.mjs index 710172b0e..9d268f3dd 100644 --- a/internal/check-packet-charts.mjs +++ b/internal/check-packet-charts.mjs @@ -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') diff --git a/library/components/charts/VisionFunnel.vue b/library/components/charts/VisionFunnel.vue index 7e1c39192..34ad5a807 100644 --- a/library/components/charts/VisionFunnel.vue +++ b/library/components/charts/VisionFunnel.vue @@ -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(() => { {{ labels.notASlice }} +

{{ labels.ofK || '' }} {{ ofKText.index }} / {{ ofKText.total }} · {{ ofKText.percent }}%

@@ -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; } } diff --git a/library/components/charts/packetCharts.js b/library/components/charts/packetCharts.js index 5afa502da..88944cf71 100644 --- a/library/components/charts/packetCharts.js +++ b/library/components/charts/packetCharts.js @@ -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)