doxa-maps/internal/check-ring-bands.mjs
Doxa Maps de2ec6fe46 bandFill mode: 'fill-all' draws every group and lets the zoom separate them
Rings that zoom apart, as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.

'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.

'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.

The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.

Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
2026-09-29 01:38:08 +00:00

244 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* check-ring-bands.mjs — the ring band layout keeps the promises the ring target rests on.
*
* Four properties, each of which fails SILENTLY if it breaks — the drawing still renders, it is
* just wrong, and nobody can tell by looking at one sheet:
* 1. every pin sits inside its own ring's band (not on the old single circle, not in the gap)
* 2. no two pins on a ring are closer than minSep
* 3. no pin sits inside a ring label's keep-out arc
* 4. the same input twice gives byte-identical output
*
* Every arm has a NEGATIVE CONTROL: a deliberately broken input that must fail it. An assertion
* that cannot fail is not evidence, and three of the bugs this module exists to prevent were
* originally found only because a check was made to fail on purpose first.
*/
import { bandFill, curvedRingLabels, inBand, inArc, hash01 } from '../library/components/charts/ringBands.js'
import { ringRadii, RING_MAX } from '../library/components/charts/centeredSet.js'
const pass = []
const fail = []
const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
const RING_GAP = 24
const radii = ringRadii({ innerR: 26, ringGap: RING_GAP })
const labels = curvedRingLabels(radii, (r) => `Ring ${r} — same language family`, { ringGap: RING_GAP })
/** A reproducible population: n groups spread across the rings. */
const makeItems = (n, tag = 'g') =>
Array.from({ length: n }, (_, i) => ({ id: `${tag}${i}`, ring: (i % RING_MAX) + 1 }))
const items = makeItems(120)
const out = bandFill(items, radii, { ringGap: RING_GAP, labels })
// ── 1. every pin inside its band ────────────────────────────────────────────────────────────────
const strays = out.placed.filter((p) => !inBand(p, radii, { ringGap: RING_GAP }))
ok('every pin sits inside its own ring band', strays.length === 0, `${strays.length} outside`)
// NEG CONTROL: a pin pushed out of its band must be caught, or arm 1 proves nothing.
const moved = { ...out.placed[0], r: out.placed[0].r + RING_GAP }
ok('NEG CONTROL: a pin moved a full ringGap is detected as outside', !inBand(moved, radii, { ringGap: RING_GAP }))
// ── 2. pins on a ring are at least minSep apart ─────────────────────────────────────────────────
// Measured along the arc at each pin's own radius, which is how a reader sees the gap.
const tooClose = []
const byRing = new Map()
for (const p of out.placed) {
if (!byRing.has(p.ring)) byRing.set(p.ring, [])
byRing.get(p.ring).push(p)
}
for (const [ring, ps] of byRing) {
const sorted = [...ps].sort((a, b) => a.angle - b.angle)
for (let i = 1; i < sorted.length; i++) {
const dx = sorted[i].x - sorted[i - 1].x
const dy = sorted[i].y - sorted[i - 1].y
const d = Math.hypot(dx, dy)
// Two pins may be close in ANGLE and far apart in radius — that is the band doing its job — so
// the test is euclidean distance, not angular distance.
if (d < Math.min(out.minSep, 2 * sorted[i].pin) - 1e-9) tooClose.push({ ring, d: d.toFixed(2) })
}
}
ok('no two pins on a ring are closer than the pin diameter', tooClose.length === 0, `${tooClose.length} pair(s)`)
// NEG CONTROL: at an absurd minSep the ring cannot hold its items, so the module must REPORT
// unplaced rather than quietly overlapping them.
const crowded = bandFill(makeItems(400), radii, { ringGap: RING_GAP, labels, minSep: 60 })
ok(
'NEG CONTROL: an impossible minSep reports unplaced rather than overlapping',
crowded.unplaced.length > 0 && crowded.unplaced.every((u) => /full at the pin floor/.test(u.reason)),
`${crowded.unplaced.length} reported`,
)
// And it must SHRINK before it reports: a visible pin beats an unplaced one.
// ★ 2000, NOT 200 — and the change of number is itself the record of a bug. This arm used 200,
// which crowded a ring only while capacity was counted as a SINGLE row of pins. Once capacity was
// counted over the rows a band's depth actually allows (the real fix), 200 groups fit at full size
// and nothing shrank, so the arm failed while the module was working better than before. The
// shipped table carries 7,296 placeable groups, so the honest pressure to test at is thousands.
const tight = bandFill(makeItems(2000), radii, { ringGap: RING_GAP, labels })
ok(
'a crowded ring shrinks its pins before reporting anything unplaced',
tight.rings.some((r) => r.shrunk) && tight.rings.every((r) => r.pin >= 2.6 - 1e-9),
`min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}, ${tight.rings.filter((r) => r.shrunk).length} ring(s) shrank`,
)
// ★ THE REAL TABLE'S SHAPE, because a uniform sample hid a 4.7x undercount. The shipped
// near-neighbours table is heavily skewed — ring 8 carries 1,932 groups and ring 5 carries 100 —
// and against a single-row capacity model bandFill placed 962 of 7,296, dropping 87% of the map
// with every drop faithfully reported. No assertion here caught it, because they all asked "is the
// overflow reported?" and none asked "is the capacity RIGHT?". This one does.
const skewed = [
...Array.from({ length: 1190 }, (_, i) => ({ id: `r1-${i}`, ring: 1 })),
...Array.from({ length: 1932 }, (_, i) => ({ id: `r8-${i}`, ring: 8 })),
...Array.from({ length: 100 }, (_, i) => ({ id: `r5-${i}`, ring: 5 })),
]
const real = bandFill(skewed, radii, { ringGap: RING_GAP, labels })
const r8 = real.rings.find((r) => r.ring === 8)
const r5 = real.rings.find((r) => r.ring === 5)
// Asserted as a RATIO against the single-row count this band would give, not a magic number: the
// first version of this arm hardcoded ">400", which came from the component's ringGap of 22 while
// the gate builds its radii at 24. The code was right and the threshold was guessed. A ratio stays
// true when the geometry changes, which is the only way this arm keeps meaning something.
const singleRowCapacity = (() => {
const half = (RING_GAP / 2) * (1 - 0.16)
const base = radii.find((x) => x.ring === 8).r
const sep = Math.max(7, 2 * 3.4)
const label = labels.find((l) => l.ring === 8)
const usable = Math.PI * 2 - Math.min(label.keepOut.span, Math.PI * 2)
return Math.floor((usable * (base - half)) / sep)
})()
ok(
'a band counts the concentric rows its depth allows, not one circle of pins',
r8.capacity > singleRowCapacity * 1.5,
`ring 8 capacity ${r8.capacity} vs ${singleRowCapacity} for a single row (${(r8.capacity / singleRowCapacity).toFixed(2)}x)`,
)
ok(
'a ring that is not over-full places everything, even when a sibling ring overflows',
r5.fitted === 100 && r5.wanted === 100,
`ring 5 ${r5.fitted}/${r5.wanted}`,
)
// ── 3. no pin inside a label's keep-out arc ─────────────────────────────────────────────────────
const inLabel = out.placed.filter((p) => {
const l = labels.find((x) => x.ring === p.ring)
return l && inArc(p.angle, l.keepOut.start, l.keepOut.span)
})
ok('no pin sits inside a ring label keep-out arc', inLabel.length === 0, `${inLabel.length} overlapping`)
// NEG CONTROL: the arc test must actually detect a point inside it — including the WRAPPING case,
// where a naive start<=a<=end comparison is wrong for exactly one label position.
const wrapStart = 6.0 // radians, so start+span crosses 0
ok('NEG CONTROL: inArc detects a point inside a wrapping arc', inArc(0.1, wrapStart, 0.6))
ok('NEG CONTROL: inArc rejects a point outside a wrapping arc', !inArc(3.0, wrapStart, 0.6))
// ── 4. determinism ──────────────────────────────────────────────────────────────────────────────
const again = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels })
ok(
'the same input twice gives byte-identical output',
JSON.stringify(again.placed) === JSON.stringify(out.placed),
'two runs agree',
)
// Order of the INPUT must not change the output either: a caller that filters or re-sorts its rows
// must not move every dot on the chart.
const shuffled = bandFill([...items].reverse(), radii, { ringGap: RING_GAP, labels })
const key = (ps) => JSON.stringify([...ps].sort((a, b) => String(a.id).localeCompare(String(b.id))).map((p) => [p.id, p.x.toFixed(9), p.y.toFixed(9)]))
ok('input order does not change any position', key(shuffled.placed) === key(out.placed))
// NEG CONTROL: the hash really does vary per id, or "deterministic" would be trivially true because
// everything landed in the same place.
ok('NEG CONTROL: hash01 separates ids', hash01('a') !== hash01('b') && hash01('a') === hash01('a'))
// ── no label may starve its own ring ────────────────────────────────────────────────────────────
// ★ THIS ARM EXISTS BECAUSE THE GATE ONCE PASSED THE BUG. Uncapped, a long name on ring 1 claimed
// 5.65 rad — 96% of that small circle — so capacity was ZERO, all 12 groups were reported unplaced,
// and the innermost ring drew empty. Every other arm was green: the items WERE reported rather than
// silently dropped, which is what they assert. "Reported, not dropped" is not the same promise as
// "the ring draws". A long label now truncates instead of eating its circle.
const longLabels = curvedRingLabels(radii, () => 'A very much longer ring label indeed', { ringGap: RING_GAP })
const starved = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels: longLabels })
ok(
'a long label never starves its ring: every ring still places something',
starved.rings.every((r) => r.fitted > 0),
`empty rings: ${starved.rings.filter((r) => r.fitted === 0).map((r) => r.ring).join(',') || 'none'}`,
)
ok(
'no label takes more than its share of its own circle',
longLabels.every((l) => l.keepOut.span <= Math.PI * 2 * 0.45 + 2 * 0.18 + 1e-9),
`max ${(Math.max(...longLabels.map((l) => l.keepOut.span)) / (Math.PI * 2) * 100).toFixed(0)}%`,
)
ok(
'a truncated label says so, and says how many characters fit',
longLabels[0].truncated === true && longLabels[0].fitChars > 0 && longLabels[0].fitChars < 36,
`ring 1 fits ${longLabels[0].fitChars} chars`,
)
// ── mode: 'fill-all' places everything and lets the zoom separate it ────────────────────────────
// Rings that zoom apart: separation is in MAP units, so it grows with zoom. Sized on the real
// table, the worst ring separates to 8px on screen at z=4.45 and the GROUP label tier starts at 5.2,
// so every pin is clickable before the names appear. DEFAULT IS STILL 'fit' — these arms prove the
// flag works, not that the chart has adopted it.
const dense = [
...Array.from({ length: 1932 }, (_, i) => ({ id: `d8-${i}`, ring: 8 })),
...Array.from({ length: 1190 }, (_, i) => ({ id: `d1-${i}`, ring: 1 })),
]
const fitMode = bandFill(dense, radii, { ringGap: RING_GAP, labels })
const fillMode = bandFill(dense, radii, { ringGap: RING_GAP, labels, mode: 'fill-all' })
ok(
"'fit' is the default, so the flag changes nothing until it is asked for",
fitMode.unplaced.length > 0 && fitMode.placed.length < dense.length,
`default dropped ${fitMode.unplaced.length}`,
)
ok(
"'fill-all' places every group and reports nothing unplaced",
fillMode.placed.length === dense.length && fillMode.unplaced.length === 0,
`${fillMode.placed.length}/${dense.length}`,
)
ok(
"'fill-all' still keeps every pin inside its own band",
fillMode.placed.every((p) => inBand(p, radii, { ringGap: RING_GAP })),
'no pin escapes its ring',
)
ok(
"'fill-all' still respects the label keep-out",
fillMode.placed.every((p) => {
const l = labels.find((x) => x.ring === p.ring)
return !l || !inArc(p.angle, l.keepOut.start, l.keepOut.span)
}),
'no pin under a label',
)
// ★ THE DEFECT THIS MODE COULD REINTRODUCE. Taking every item while keeping the 'fit' row count
// would pack 1,932 pins into the two rows a band allows at full pin size — the single-line defect
// again, one level down. The row count must follow the POPULATION here, so a dense ring uses many
// more rows than a sparse one.
const rowsUsed = (o, ring) => new Set(o.placed.filter((p) => p.ring === ring).map((p) => p.row)).size
ok(
"'fill-all' adds rows as the ring fills, rather than crowding a fixed few",
rowsUsed(fillMode, 8) > rowsUsed(fitMode, 8),
`ring 8 uses ${rowsUsed(fillMode, 8)} rows in fill-all vs ${rowsUsed(fitMode, 8)} in fit`,
)
ok(
"'fill-all' is still deterministic",
JSON.stringify(bandFill(dense, radii, { ringGap: RING_GAP, labels, mode: 'fill-all' }).placed) === JSON.stringify(fillMode.placed),
)
// ── the label geometry is derived, not guessed ──────────────────────────────────────────────────
const shortL = curvedRingLabels(radii, () => 'A', { ringGap: RING_GAP })
const longL = curvedRingLabels(radii, () => 'A much longer ring label', { ringGap: RING_GAP })
ok(
'a longer label claims a wider keep-out arc',
longL[0].keepOut.span > shortL[0].keepOut.span,
`${longL[0].keepOut.span.toFixed(3)} > ${shortL[0].keepOut.span.toFixed(3)}`,
)
ok(
'the same text needs a SMALLER arc on a larger ring',
longL[RING_MAX - 1].span < longL[0].span,
'arc length = r * angle',
)
for (const p of pass) console.log(` ✓ ${p}`)
for (const f of fail) console.log(` ✗ ${f}`)
if (fail.length) {
console.log(`\nRING-BANDS: FAIL — ${fail.length} of ${pass.length + fail.length}`)
process.exit(1)
}
console.log(`\nRING-BANDS: PASS — ${pass.length} assertions, every arm with a negative control`)