From b3238db361ee99138fbd4f3bd9b8b5aa1ff46e5f Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 01:25:22 +0000 Subject: [PATCH] A band holds concentric rows, so it holds four times what I was counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the SHIPPED table through the layout instead of the uniform sample the module was built against. The real near-neighbours data is heavily skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was faithfully reported, which is exactly why no assertion caught it: all 16 asked "is the overflow reported?" and none asked "is the CAPACITY right?". The capacity model counted one circle of pins at the band's innermost radius. A band has depth, so it holds several concentric rows, each with its own circumference. Counting them is a ~2x correction at ringGap 24 and more at larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's geometry and every under-full ring now places everything it is given. Placement had to follow: a hashed radius with one angular step was fine while a ring held a dozen pins and wrong once it held hundreds, because two pins could take nearly the same angle at nearly the same radius while the capacity sum insisted there was room. Pins are now dealt round-robin into rows, each row evenly spaced across the usable arc, staggered a quarter slot so adjacent rows do not line up radially and read as spokes. Two bugs found on the way, both by the gate failing honestly: - the stagger first added half a step to every odd row, which pushed that row's last pin past the end of the arc and into the label's keep-out — 7 overlapping. Staggering WITHIN the slot keeps every pin inside its own slot. - the shrink arm tested at 200 groups, which only crowded a ring while capacity was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row table, and still reaches the pin floor and reports. The new capacity arm asserts a RATIO against the single-row count, not a magic number: its first version hardcoded ">400" from the component's ringGap of 22 while the gate builds radii at 24 — the code was right and my threshold was guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms. --- internal/check-ring-bands.mjs | 45 +++++++++++++++++++- library/components/charts/ringBands.js | 58 +++++++++++++++++++++----- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs index f23ba394f..16fc9a4f0 100644 --- a/internal/check-ring-bands.mjs +++ b/internal/check-ring-bands.mjs @@ -70,11 +70,52 @@ ok( ) // And it must SHRINK before it reports: a visible pin beats an unplaced one. -const tight = bandFill(makeItems(200), radii, { ringGap: RING_GAP, labels }) +// ★ 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)}`, + `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 ───────────────────────────────────────────────────── diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js index 39d0d743a..18291aec2 100644 --- a/library/components/charts/ringBands.js +++ b/library/components/charts/ringBands.js @@ -149,13 +149,26 @@ export function bandFill(items, radii, { // clumping together, while staying a pure function of the data. const sorted = [...group].sort((a, b) => hash01(a.id) - hash01(b.id) || String(a.id).localeCompare(String(b.id))) - // How many fit at this pin size? Two pins must be minSep apart along the arc at the band's - // INNERMOST usable radius, where the circumference is smallest and crowding is worst. + // ★ A BAND HAS DEPTH, SO IT HOLDS CONCENTRIC ROWS — counting one circle of pins was a 4.7x + // undercount and it only showed on the real data. On the uniform 12-per-ring sample this module + // was built against, every ring fitted and the model looked right. The shipped table is heavily + // skewed (ring 8 carries 1,932 groups, ring 5 carries 100), and there the single-row count + // placed 962 of 7,296 — it dropped 87% of the map, every drop faithfully REPORTED, which is why + // no assertion caught it. Capacity is now summed over the rows the band's depth actually allows. const innerUsableR = Math.max(baseR - half, 1e-6) let pin = pinRadius + const rowsAt = (p) => Math.max(Math.floor((2 * half) / Math.max(minSep, 2 * p)), 1) const capacityAt = (p) => { const sep = Math.max(minSep, 2 * p) - return Math.max(Math.floor((usable * innerUsableR) / sep), 0) + const rows = rowsAt(p) + let total = 0 + for (let k = 0; k < rows; k++) { + // Each row sits at its own radius, so each has its own circumference — an outer row of the + // same band legitimately holds more pins than an inner one. + const r = Math.max(baseR - half + ((k + 0.5) * (2 * half)) / rows, 1e-6) + total += Math.floor((usable * r) / sep) + } + return Math.max(total, 0) } let capacity = capacityAt(pin) let shrunk = false @@ -169,18 +182,43 @@ export function bandFill(items, radii, { const fitting = sorted.slice(0, capacity) const overflow = sorted.slice(capacity) - // Spread the ones that fit evenly through the usable arc, stepping past the label. + // Spread the ones that fit through the usable arc, stepping past the label. + // + // ★ PLACED ROW BY ROW, because capacity is counted row by row. A hashed radius plus one angular + // step used to decide position; that was fine while a ring held a dozen pins and wrong as soon + // as it held hundreds, because two pins could take nearly the same angle at nearly the same + // radius while the capacity sum insisted there was room. Rows are assigned deterministically + // from the item's own id, so a group still lands in the same place on every render. const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0 - const step = fitting.length > 0 ? usable / fitting.length : 0 + const rows = rowsAt(pin) + const rowOf = [] // how many pins ended up in each row + for (let k = 0; k < rows; k++) rowOf.push(0) + // Deal round-robin by the item's hashed order so every row fills evenly rather than the first + // row taking everything and the outer rows staying empty. fitting.forEach((it, i) => { - const a = norm(startA + step * (i + 0.5)) - // Radius WITHIN the band, deterministic per item: this is what turns a line of dots into a - // filled band. 0.5 would be the old single-circle behaviour. - const t = hash01(`${it.id}:r`) - const r = baseR - half + t * (2 * half) + const row = i % rows + rowOf[row]++ + }) + const rowCursor = rowOf.map(() => 0) + fitting.forEach((it, i) => { + const row = i % rows + const perRow = Math.max(rowOf[row], 1) + const r = Math.max(baseR - half + ((row + 0.5) * (2 * half)) / rows, 1e-6) + // Each ROW gets its own even spacing across the usable arc. A half-step offset per row keeps + // adjacent rows from lining their pins up radially, which would read as spokes rather than a + // filled band. + const idx = rowCursor[row]++ + // ★ THE STAGGER MUST STAY INSIDE THE USABLE ARC. Adding half a step to every odd row pushed + // that row's last pin past the end of the arc and into the label's keep-out — measured at 7 + // pins overlapping. Staggering WITHIN the slot keeps every pin inside its own slot, so the + // row still ends before the label begins however many pins it holds. + const slot = usable / perRow + const stagger = row % 2 ? 0.25 : -0.25 // a quarter slot either side of its centre + const a = norm(startA + slot * (idx + 0.5 + stagger)) placed.push({ ...it, ring, + row, angle: a, r, pin,