diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs index 16fc9a4f0..e865f4a65 100644 --- a/internal/check-ring-bands.mjs +++ b/internal/check-ring-bands.mjs @@ -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 }) diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js index 18291aec2..276e3e031 100644 --- a/library/components/charts/ringBands.js +++ b/library/components/charts/ringBands.js @@ -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