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,