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".
This commit is contained in:
Doxa Maps 2026-09-29 01:29:37 +00:00
parent 16aa440851
commit de2ec6fe46
2 changed files with 81 additions and 3 deletions

View file

@ -172,6 +172,55 @@ ok(
`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 })

View file

@ -119,6 +119,22 @@ export function bandFill(items, radii, {
minPinFloor = 2.6,
pinRadius = 3.4,
bandInset = 0.16, // keep pins off the band's own edges, so rings stay legible as rings
// ★ 'fit' (default) vs 'fill-all'. THE DIFFERENCE IS WHAT THE CHART CLAIMS, not a tuning knob.
//
// 'fit' keeps every pin at least minSep apart AT THE CHART'S OWN SCALE, so a ring that cannot
// hold its groups reports the remainder as unplaced. Honest, and right 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 EVERY group and lets the zoom do the separating: separation is in map units,
// so it grows with zoom. Measured on the shipped table (7,296 placeable), the worst ring is ring 1
// at 1,190 groups in the smallest circle, separated by 8px on screen at z=4.45; ring 8 (1,932) at
// z=3.50. The GROUP label tier already starts at 5.2, so every ring is clickable-separated 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.
//
// Default stays 'fit' until row 130 is decided, so this flag changes nothing today.
mode = 'fit',
labels = [],
cx = 0,
cy = 0,
@ -179,8 +195,13 @@ export function bandFill(items, radii, {
shrunk = true
}
const fitting = sorted.slice(0, capacity)
const overflow = sorted.slice(capacity)
// In 'fill-all' the ring takes everything: capacity stops being a limit and becomes a REPORT of
// how many would have been separable at this scale, which is what tells a caller when the chart
// needs zoom to be readable. The pins still shrink to the floor first, so the dense case is
// drawn as small as it legibly can be rather than as overlapping full-size dots.
const takeAll = mode === 'fill-all'
const fitting = takeAll ? sorted : sorted.slice(0, capacity)
const overflow = takeAll ? [] : sorted.slice(capacity)
// Spread the ones that fit through the usable arc, stepping past the label.
//
@ -190,7 +211,15 @@ export function bandFill(items, radii, {
// 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 rows = rowsAt(pin)
// ★ IN 'fill-all' THE ROW COUNT FOLLOWS THE POPULATION, not the pin size. Keeping the 'fit' row
// count would pack 1,932 groups into the two rows a band's depth allows at full pin size, and
// they would sit almost on top of each other along the arc — the single-line defect again, one
// level down. Enough rows for the band's own depth to be shared evenly: the pins end up closer
// than minSep at the START zoom, which is exactly what this mode accepts and what zooming
// resolves. Capped so a ring can never ask for more rows than it has items.
const rows = takeAll
? Math.max(1, Math.min(fitting.length, Math.ceil(fitting.length / Math.max(capacityAt(minPinFloor) / rowsAt(minPinFloor), 1))))
: 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