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)