fix(flow-canvas): variable-slot widths + fit-to-host + zigzag (#669 round 5)

Round-5 fixes from founder UAT:

1. Variable-width depth slots — round-3 used a constant perDepthX
   per depth column, causing dense buckets (30+ siblings) to pile in
   ~80% × perDepthX while sparse depths got the same width for one
   bubble. Now each depth's slot tracks its bucket's natural extent
   at radius R; sparse depths shrink, dense depths claim the room
   they need.

2. sqrt-aspect dense buckets — targetRows = round(sqrt(count/1.6))
   gives a slightly-wider-than-tall grid (e.g. 30 nodes → 4 rows ×
   8 cols → ~700 px slot at R=40) instead of round-3's vertical-cap-
   bound thin grids.

3. Aggressive fit-to-host horizontally — Stage 1 tightens the
   inter-depth gap to a tight floor (R*2.2). Stage 2 shrinks R by 4
   px when even tight gaps overflow. Stage 3 escalates dense-bucket
   row-multiplier (more rows, fewer cols, narrower slot) at MIN_R.
   Past that, SVG width grows with totalLayoutWidth and the parent
   host overflows horizontally.

4. Y-axis zigzag for singleton depths — depth-parity offset of
   min(R*3, h*0.12) so a long sparse chain (Phase 0 + cluster-
   bootstrap + cilium) reads as a gentle wave that uses vertical
   space instead of a flat horizontal timeline. Founder verbatim:
   "even if it is kept short, it would give similar result and
   look nicer".

5. ResizeObserver debounce 60ms + 4 px epsilon — sub-pixel flicker
   during pane-transition animations doesn't restart the sim, but
   real window resizes feel responsive (was 180ms / 8px).

6. Per-cell clamp window narrowed to (pitch/2 - R) so adjacent grid
   cells never extend into each other's territory; forceCollide
   floor of 2R + COLLIDE_PADDING holds in screen space.

Force strengths bumped: X 0.12 → 0.18, Y 0.10 → 0.22 so per-bucket
Y targets actually settle.

Tests: bounded test asserts vbH bounded + vbW positive (vbW can
exceed host when chain doesn't fit, parent scrolls). Distribution
test (1+1+30 shape) green from round 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-03 20:29:59 +02:00
parent 9d452cf4de
commit 78f3d03d8a
2 changed files with 199 additions and 46 deletions

View File

@ -385,8 +385,12 @@ describe('FlowCanvasOrganic — Bug #481 bounded layout', () => {
)!
const vb = svg.getAttribute('viewBox') ?? ''
const [vbX, vbY, vbW, vbH] = vb.split(/\s+/).map(Number)
expect(vbW).toBeLessThanOrEqual(1200)
/* Issue #669 round 5 viewBox width can exceed host width when
* the dep chain doesn't fit horizontally even at MIN_NODE_RADIUS;
* the parent host scrolls. Assert vbH still bounded (Y always
* fits) and vbW positive. */
expect(vbH).toBeLessThanOrEqual(700)
expect(vbW).toBeGreaterThan(0)
const groups = container.querySelectorAll<SVGGElement>(
'[data-flow-draggable]',
)

View File

@ -169,8 +169,8 @@ const MAX_PER_DEPTH_X = 200
/** Force strengths re-tuned post-#483. Gentle X-anchor lets the link
* force pull connected nodes together without the X-force fighting
* back and producing the oscillation that read as "infinite stretch". */
const FORCE_X_STRENGTH = 0.12
const FORCE_Y_STRENGTH = 0.10
const FORCE_X_STRENGTH = 0.18
const FORCE_Y_STRENGTH = 0.22
const FORCE_LINK_STRENGTH = 0.18
/** Selection palette distinct from any status colour AND distinct
@ -252,8 +252,8 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
* effect downstream from running unnecessarily). */
let timer: ReturnType<typeof setTimeout> | null = null
let pending: { w: number; h: number } | null = null
const RESIZE_DEBOUNCE_MS = 180
const RESIZE_EPSILON_PX = 8
const RESIZE_DEBOUNCE_MS = 60
const RESIZE_EPSILON_PX = 4
const ro = new ResizeObserver((entries) => {
const e = entries[0]
if (!e) return
@ -387,34 +387,155 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
}
r = Math.max(MIN_NODE_RADIUS, Math.min(MAX_NODE_RADIUS, Math.round(r / 4) * 4))
const ROW_PITCH = r * 2 + COLLIDE_PADDING
const Y_RANGE = Math.max(r * 2, hostSize.h - 2 * (r + COLLIDE_PADDING))
const HARD_ROW_CAP = Math.max(1, Math.floor(Y_RANGE / ROW_PITCH))
// Per-bucket sub-grid info (cols × rows + slot width).
/* computeAt(radius, rowMultiplier) recomputes slot widths at the
* given radius. rowMultiplier > 1 forces dense buckets to use MORE
* rows (and fewer columns) per bucket, compressing the slot
* horizontally at the cost of more vertical space. Used when the
* chain still overflows the host width even at R = MIN_NODE_RADIUS:
* we trade horizontal sprawl for vertical until totalWidth fits. */
const computeAt = (radius: number, rowMultiplier = 1) => {
const ROW_PITCH = radius * 2 + COLLIDE_PADDING
const Y_RANGE = Math.max(radius * 2, hostSize.h - 2 * (radius + COLLIDE_PADDING))
const hardRowCap = Math.max(1, Math.floor(Y_RANGE / ROW_PITCH))
const slotInfo = new Map<number, { cols: number; rows: number; width: number }>()
for (const [d, count] of buckets) {
// Cap target rows by what fits vertically — defensive when R
// got pushed to MIN_NODE_RADIUS by an unusually tight host.
const rows = Math.min(targetRowsFor(count), HARD_ROW_CAP)
const baseRows = targetRowsFor(count)
const rows = Math.min(Math.max(1, Math.ceil(baseRows * rowMultiplier)), hardRowCap, count)
const cols = Math.max(1, Math.ceil(count / rows))
const naturalW = cols > 1
? (cols - 1) * (r * 2 + COLLIDE_PADDING) + r * 2
: r * 2
const width = Math.round(Math.max(naturalW, r * 2) / 8) * 8
? (cols - 1) * (radius * 2 + COLLIDE_PADDING) + radius * 2
: radius * 2
const width = Math.round(Math.max(naturalW, radius * 2) / 8) * 8
slotInfo.set(d, { cols, rows, width })
}
const gap = Math.max(MIN_PER_DEPTH_X, Math.min(MAX_PER_DEPTH_X, r * 4))
const gap = Math.max(MIN_PER_DEPTH_X, Math.min(MAX_PER_DEPTH_X, radius * 4))
const xByDepth = new Map<number, number>()
let cursor = r + COLLIDE_PADDING
let cursor = radius + COLLIDE_PADDING
for (let d = 0; d <= maxDepth; d++) {
const w = slotInfo.get(d)?.width ?? r * 2
const w = slotInfo.get(d)?.width ?? radius * 2
xByDepth.set(d, cursor + w / 2)
cursor += w + gap
}
const totalWidth = cursor - gap + r + COLLIDE_PADDING
const totalWidth = cursor - gap + radius + COLLIDE_PADDING
return { slotInfo, gap, xByDepth, totalWidth, hardRowCap }
}
/* Issue #669 round 5 aggressive fit-to-host horizontally.
*
* Founder UAT: "the graph is not fitting into the full page,
* getting out of the full screen with huge extended x axis".
*
* Two-stage fit:
* Stage 1 shrink the inter-depth gap proportionally toward
* a tight floor (R*2.2) before touching R. Wide
* gaps are the first thing to give up.
* Stage 2 if even a tight gap doesn't fit, shrink R by 4
* and recompute everything. R has its own MIN floor;
* past that we accept horizontal scroll.
*/
let attempt = computeAt(r)
// Stage 1: tighten gap if it would let us fit.
if (attempt.totalWidth > hostSize.w && depthCount > 1) {
const slotsTotal = Array.from(attempt.slotInfo.values()).reduce((acc, s) => acc + s.width, 0)
const margin = 2 * (r + COLLIDE_PADDING)
const fitGap = (hostSize.w - slotsTotal - margin) / (depthCount - 1)
// Floor on inter-depth gap = just enough to prevent adjacent
// depth bubbles from visually touching (R*2.2 → 9.6px clear gap
// at R=16). MIN_PER_DEPTH_X is the *aesthetic* preference for
// sparse layouts; when the chain doesn't fit, function over form.
const minGap = r * 2.2
if (fitGap >= minGap) {
// Recompute xByDepth + totalWidth with the tighter gap.
const newGap = Math.floor(fitGap)
const newX = new Map<number, number>()
let cursor = r + COLLIDE_PADDING
for (let d = 0; d <= maxDepth; d++) {
const w = attempt.slotInfo.get(d)?.width ?? r * 2
newX.set(d, cursor + w / 2)
cursor += w + newGap
}
attempt = {
...attempt,
gap: newGap,
xByDepth: newX,
totalWidth: cursor - newGap + r + COLLIDE_PADDING,
}
}
}
// Stage 2: shrink R if still overflows.
while (attempt.totalWidth > hostSize.w && r > MIN_NODE_RADIUS) {
r = Math.max(MIN_NODE_RADIUS, r - 4)
attempt = computeAt(r)
if (attempt.totalWidth > hostSize.w && depthCount > 1) {
const slotsTotal = Array.from(attempt.slotInfo.values()).reduce((acc, s) => acc + s.width, 0)
const margin = 2 * (r + COLLIDE_PADDING)
const fitGap = (hostSize.w - slotsTotal - margin) / (depthCount - 1)
// Floor on inter-depth gap = just enough to prevent adjacent
// depth bubbles from visually touching (R*2.2 → 9.6px clear gap
// at R=16). MIN_PER_DEPTH_X is the *aesthetic* preference for
// sparse layouts; when the chain doesn't fit, function over form.
const minGap = r * 2.2
if (fitGap >= minGap) {
const newGap = Math.floor(fitGap)
const newX = new Map<number, number>()
let cursor = r + COLLIDE_PADDING
for (let d = 0; d <= maxDepth; d++) {
const w = attempt.slotInfo.get(d)?.width ?? r * 2
newX.set(d, cursor + w / 2)
cursor += w + newGap
}
attempt = {
...attempt,
gap: newGap,
xByDepth: newX,
totalWidth: cursor - newGap + r + COLLIDE_PADDING,
}
}
}
}
/* Stage 3 at MIN_R with min gap and STILL overflowing? Trade
* horizontal extent for vertical: increase the row-multiplier on
* dense buckets so they pile MORE vertically and less
* horizontally. Each step multiplies targetRows by 1.4 (so a
* bucket that wanted 3 rows × 4 cols becomes 5 rows × 3 cols,
* shrinking slot from 356 264 px). Stops when fits or
* rowMultiplier exceeds the bucket's hard row cap. */
let rowMul = 1
while (attempt.totalWidth > hostSize.w && rowMul < 4) {
rowMul *= 1.4
const next = computeAt(r, rowMul)
// Re-apply gap tightening at the new radius/rowMul.
let stage1 = next
if (next.totalWidth > hostSize.w && depthCount > 1) {
const slotsTotal = Array.from(next.slotInfo.values()).reduce((acc, s) => acc + s.width, 0)
const margin = 2 * (r + COLLIDE_PADDING)
const fitGap = (hostSize.w - slotsTotal - margin) / (depthCount - 1)
const minGap = r * 2.2
if (fitGap >= minGap) {
const newGap = Math.floor(fitGap)
const newX = new Map<number, number>()
let cursor = r + COLLIDE_PADDING
for (let d = 0; d <= maxDepth; d++) {
const w = next.slotInfo.get(d)?.width ?? r * 2
newX.set(d, cursor + w / 2)
cursor += w + newGap
}
stage1 = {
...next,
gap: newGap,
xByDepth: newX,
totalWidth: cursor - newGap + r + COLLIDE_PADDING,
}
}
}
// Only adopt if it actually improves fit (or fits exactly).
if (stage1.totalWidth <= attempt.totalWidth) {
attempt = stage1
} else {
break
}
}
const { slotInfo, gap, xByDepth, totalWidth, hardRowCap } = attempt
const linkDistance = gap * 0.625
const gr = r + GROUP_RADIUS_DELTA
@ -429,7 +550,7 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
linkDistance,
maxBucket,
depthCount,
hardRowCap: HARD_ROW_CAP,
hardRowCap,
}
}, [layout.nodes, hostSize.w, hostSize.h])
@ -512,14 +633,44 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
/* Per-bucket centred Y target sibling at index `i` of `n` lands
* at h/2 + (i - (n-1)/2) * pitch. Median sibling on the X-axis
* centerline; rest spread symmetrically above and below. */
* centerline; rest spread symmetrically above and below.
*
* Issue #669 round 5 for SINGLETON depth buckets (size === 1) we
* zigzag Y by depth parity so a long sequential chain (e.g. Phase 0
* cluster-bootstrap cilium cert-manager) reads as a gentle
* wave that uses the vertical canvas instead of a flat horizontal
* line. The amplitude (R*1.5) keeps adjacent depths visually
* connected while filling more of the host height. */
const depthByNodeId = useMemo(() => {
const m = new Map<string, number>()
for (const n of layout.nodes) m.set(n.id, n.depth)
return m
}, [layout.nodes])
/* Zigzag amplitude sized to USE the host height, not R. With a
* 900-px host, amplitude is ~270 px singletons sit at
* y = h/2 ± 270, filling the upper and lower fifths of the host.
* Bounded at host_h × 0.32 (so the bubble's full diameter never
* crosses the edge) and at host_h/2 - R - COLLIDE_PADDING (hard
* floor to keep bubble inside viewBox). */
/* Modest amplitude just enough to lift singletons off the
* centerline so the chain reads as a gentle wave instead of a flat
* timeline, but short enough that adjacent depths stay visually
* connected without dragging the eye across the canvas. Founder
* UAT 2026-05-03: "even if it is kept short, it would give similar
* result and look nicer". */
const ZIGZAG_AMPLITUDE = Math.min(R * 3, hostSize.h * 0.12)
const yForBucket = useCallback(
(id: string) => {
const b = bucketRank.get(id)
if (!b) return Y_CENTER
if (b.size === 1) {
const d = depthByNodeId.get(id) ?? 0
const sign = d % 2 === 0 ? -1 : 1
return Y_CENTER + sign * ZIGZAG_AMPLITUDE
}
return Y_CENTER + (b.idx - (b.size - 1) / 2) * PITCH
},
[bucketRank, Y_CENTER, PITCH],
[bucketRank, Y_CENTER, PITCH, ZIGZAG_AMPLITUDE, depthByNodeId],
)
const familyById = useMemo(() => {
@ -803,7 +954,7 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
* pairwise spacing. Keep the sim inside the visible window
* end-to-end. */
const xMin = Math.max(R, baseX - PER_DEPTH_X)
const xMax = Math.min(hostSize.w - R, baseX + PER_DEPTH_X)
const xMax = Math.min(layoutMetrics.totalWidth - R, baseX + PER_DEPTH_X)
if (typeof n.x === 'number') {
if (n.x < xMin) n.x = xMin
else if (n.x > xMax) n.x = xMax
@ -935,19 +1086,16 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
* within the visible area. We do NOT compress positions; we let the
* sim clamp positions, which preserves the no-overlap guarantee.
*/
/* Issue #669 round 4 viewBox tracks the LARGER of host width or
* total layout extent. When the dep chain doesn't fit horizontally
* even after R shrinks to MIN_NODE_RADIUS, the SVG renders at full
* layout width and the parent host scrolls horizontally instead of
* piling bubbles at the right edge. Y still tracks host height. */
const vbX = 0
const vbY = 0
const vbW = hostSize.w
const vbW = Math.max(hostSize.w, layoutMetrics.totalWidth)
const vbH = hostSize.h
/* Hard-clamp positions to viewBox. The per-tick clamp inside the sim
* also clamps, but ResizeObserver may shrink hostSize between sim
* ticks (drag of the LogPane); applying the clamp here ensures
* rendering never paints a bubble outside the visible area, even
* one frame. forceCollide already guarantees pairwise spacing of
* NODE_RADIUS*2 + COLLIDE_PADDING (= 92 px), and clamping to a
* single edge cannot reduce two clamped nodes' distance below that
* threshold (the collide pass owns it pre-render). */
const CLAMP_INSET = R + 8
const project = (p: { x: number; y: number }) => {
const x = Math.min(vbW - CLAMP_INSET, Math.max(CLAMP_INSET, p.x))
@ -969,12 +1117,13 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
height: '100%',
minHeight: 0,
minWidth: 0,
overflow: 'hidden',
overflowX: layoutMetrics.totalWidth > hostSize.w ? 'auto' : 'hidden',
overflowY: 'hidden',
}}
>
<svg
ref={svgRef}
width="100%"
width={vbW}
height="100%"
viewBox={`${vbX} ${vbY} ${vbW} ${vbH}`}
preserveAspectRatio="xMinYMin meet"
@ -982,7 +1131,7 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
data-testid="flow-canvas-svg"
role="img"
aria-label="Provisioning dependency flow"
style={{ display: 'block', width: '100%', height: '100%' }}
style={{ display: 'block', minWidth: '100%', height: '100%' }}
onClick={(e) => {
if (e.target === e.currentTarget) onCanvasBackgroundClick()
}}