diff --git a/internal/check-packet-charts.mjs b/internal/check-packet-charts.mjs index 9d268f3dd..e96094e73 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, bracketSpan, fractionFromPercent, overlayPlacement, scatterCells, scatterSpread, overlayCaption, cellCount, shareCells, peoplePerCell, shareOfLists, + anchorPlacement, 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') @@ -159,6 +159,26 @@ ok(sorted.slice(0, 5).join(',') === '139,140,221,399,418' && sorted[sorted.lengt ok(scatterCells(5200, 99999, 'x').size === 5200 && scatterCells(5200, -5, 'x').size === 0, 'and the count is clamped both ways rather than throwing on a page mid-print') +// ── an anchor that cannot be drawn is NAMED, not dropped (same class as the bracket) ──────────── +// ★ FOUND BY SWEEPING FOR THE CLASS, not by waiting for a second report. After the Infinity bracket +// I read the other two templates asking the same question — what does this expression do with an +// edge-case input? LifeCalendar matched its anchor with only an isFinite guard, so week 6000 on a +// 5,200-cell grid, a negative week, or a fractional one matched NO cell and drew nothing. On paper +// that is indistinguishable from having chosen not to mark it, which is the packet's own reason for +// leaving the anchor un-defaulted in the first place — so the silent version quietly tells the +// reader the opposite of what a missing anchor is supposed to mean. +const TOTAL_CELLS = cellCount(100, 52) +ok(anchorPlacement({ week: 0 }, TOTAL_CELLS).index === 0, 'week 0 is drawable — the first cell') +ok(anchorPlacement({ week: 5199 }, TOTAL_CELLS).index === 5199, 'and the last cell is 5,199, not 5,200') +ok(anchorPlacement({ week: 5200 }, TOTAL_CELLS).reason === 'out-of-range', + 'week 5,200 is one past the end and says so rather than drawing nothing') +ok(anchorPlacement({ week: -1 }, TOTAL_CELLS).reason === 'out-of-range', 'a negative week is named') +ok(anchorPlacement({ week: 1.5 }, TOTAL_CELLS).reason === 'not-a-whole-week', 'a fractional week is named') +ok(anchorPlacement({ week: 'soon' }, TOTAL_CELLS).reason === 'not-a-number', 'a non-numeric week is named') +// The deliberate case must stay SILENT: no anchor asked for is not an error, it is the default. +ok(anchorPlacement(null, TOTAL_CELLS).reason === null && anchorPlacement({}, TOTAL_CELLS).reason === null, + 'and NO anchor at all reports nothing — the packet supplies none on purpose, so that is not a fault') + // ── "1 of K lists", where K is unsettled and this asserts nothing about it ────────────────────── ok(shareOfLists(1, 27).percent.toFixed(2) === '3.70' && shareOfLists(1, 29).percent.toFixed(2) === '3.45', 'K is a prop: 27 and 29 both compute, and this check takes no side on DECISION-SHEET row 41') diff --git a/library/components/charts/LifeCalendar.vue b/library/components/charts/LifeCalendar.vue index d946f4931..c6315e5f3 100644 --- a/library/components/charts/LifeCalendar.vue +++ b/library/components/charts/LifeCalendar.vue @@ -11,7 +11,7 @@ * the component and which from the renderer. */ import { computed } from 'vue' -import { cellCount, scatterCells } from './packetCharts.js' +import { anchorPlacement, cellCount, scatterCells } from './packetCharts.js' const props = defineProps({ rows: { type: Number, default: 100 }, @@ -32,7 +32,8 @@ const props = defineProps({ const total = computed(() => cellCount(props.rows, props.columns)) const shadedSet = computed(() => (props.shaded > 0 ? scatterCells(total.value, props.shaded, props.seed) : new Set())) -const anchorIndex = computed(() => (props.anchor && Number.isFinite(Number(props.anchor.week)) ? Number(props.anchor.week) : -1)) +const placedAnchor = computed(() => anchorPlacement(props.anchor, total.value)) +const anchorIndex = computed(() => placedAnchor.value.index) @@ -66,5 +72,6 @@ const anchorIndex = computed(() => (props.anchor && Number.isFinite(Number(props .lcal__c--on { background: var(--pk-on, #2f6d4f); border-color: var(--pk-on, #2f6d4f); } /* The anchor is a RING, never a fill: it marks a position without claiming the cell's meaning. */ .lcal__c--anchor { border-color: var(--pk-ink, #1b1a17); border-width: 2px; } +.lcal__unplaced { font: 700 12px Georgia, serif; color: #8a1c1c; margin: 6px 0 0; } @media print { .lcal__c { print-color-adjust: exact; -webkit-print-color-adjust: exact; } } diff --git a/library/components/charts/packetCharts.js b/library/components/charts/packetCharts.js index 88944cf71..ae9ff2fd6 100644 --- a/library/components/charts/packetCharts.js +++ b/library/components/charts/packetCharts.js @@ -255,6 +255,27 @@ export function scatterSpread(picked, columns) { return { cells: picked.size, rows: rows.size } } +/** + * Where an anchor week sits in the grid, or a reason it does not. + * + * ★ SAME SILENT-DROP CLASS AS THE FUNNEL'S INFINITY BRACKET, found by sweeping for it rather than + * waiting for it. The component matched the anchor by comparing a cell index to Number(anchor.week) + * with only an isFinite guard, so week 6000 on a 5,200-cell grid, a negative week, or a fractional + * one matched NO cell and drew nothing — the anchor the caller asked for simply was not there, with + * no error and no gap. On a printed page that is indistinguishable from "we chose not to mark it". + * + * Returns { index } when it can be drawn, or { index: -1, reason } when it cannot, so the component + * can say so instead of silently omitting it. + */ +export function anchorPlacement(anchor, total) { + if (!anchor || anchor.week == null) return { index: -1, reason: null } // no anchor asked for + const w = Number(anchor.week) + if (!Number.isFinite(w)) return { index: -1, reason: 'not-a-number' } + if (!Number.isInteger(w)) return { index: -1, reason: 'not-a-whole-week' } + if (w < 0 || w >= (Number(total) || 0)) return { index: -1, reason: 'out-of-range' } + return { index: w, reason: null } +} + /** People per cell, for the caption the brief asks for explicitly. */ export function peoplePerCell(population, rows, columns) { const total = cellCount(rows, columns)