fix(flow-canvas): sqrt-aspect dense buckets + tight grid clamps (#669 round 4)

Round-3 still piled the dense bucket at the right edge. Distribution
test on the founder's exact screenshot shape (1+1+30) showed the dense
slot occupied only 28% of total X-extent — better than round-2 (~13%)
but not enough.

Round-4 fix:
1. layoutMetrics targets a sqrt-aspect-ratio for dense buckets:
   targetRows = round(sqrt(count / 1.6))
   30 leaves → 4 rows × 8 cols → ~700 px slot at R=40, occupying
   >50% of total X-extent. The densest bucket's targetRows now sets
   R via vertical-fit, so wide buckets actually claim X-room rather
   than collapsing into thin tall columns.
2. gridTargets reads cols/rows from layoutMetrics.slotInfo instead
   of recomputing — guarantees the per-tick clamp uses the same
   sub-grid dimensions as the slot-width math.
3. Per-cell clamp window narrowed to ±(pitch/2 - R) so the bubble
   edge can never reach a neighbour's centre. Old clamp used the
   full pitch which let forceCollide push bubbles into a neighbour's
   territory and then ratcheted them in — centres could collapse to
   <2R apart.

Adds FlowCanvasOrganic.distribution.test.tsx replicating the founder's
UAT screenshot (depth 0: 1, depth 1: 1, depth 2: 30). Asserts:
- depth-0 X < depth-1 X < depth-2 X (left-to-right)
- dense leafSpan ≥ 30% of total layout extent
- no centre-to-centre distance < 2R

All tests green: distribution (2/2), bounded (17/17), JobDetail (5/5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-03 18:32:12 +02:00
parent 9ad931e626
commit 9d452cf4de
2 changed files with 285 additions and 69 deletions

View File

@ -0,0 +1,199 @@
/**
* FlowCanvasOrganic X-axis distribution regression (issue #669 round 3).
*
* Reproduces the exact shape from the founder's UAT screenshot:
* 1 bubble at depth 0 ("Provision Hetzner network")
* 1 bubble at depth 1 ("Provision Hetzner ...")
* 30 siblings at depth 2 (real provision graph has 30+ blueprint
* installs at the deepest depth)
*
* Round-2 (constant perDepthX) failed: the dense bucket piled into a
* thin sub-grid against the right edge while 60% of the canvas sat
* empty on the left. Round-3 introduces variable-width depth slots
* dense buckets claim more X-extent so the cluster spreads laterally.
*
* Asserts:
* 1. depth-0 X < depth-1 X < depth-2 (centerline) left-to-right.
* 2. Dense bucket spans 30% of total layout width not piled.
* 3. Sparse bubbles (depth 0/1) sit comfortably inside the canvas
* (i.e. X R, X totalWidth - R).
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, cleanup } from '@testing-library/react'
import { FlowCanvasOrganic } from './FlowCanvasOrganic'
afterEach(() => cleanup())
const FAMILIES = [{ id: 'catalyst', label: 'Catalyst', color: '#fff' }]
const REGIONS = [{ id: 'primary', label: 'Primary' }]
function makeNode(opts: {
id: string
depth: number
depRank: number
parentId?: string
dependsOn?: string[]
}) {
return {
id: opts.id,
depth: opts.depth,
depRank: opts.depRank,
regionId: 'primary',
familyId: 'catalyst',
label: opts.id,
subLabel: '',
status: 'pending' as const,
isGroup: false,
isFolded: false,
childCount: 0,
job: {
id: opts.id,
jobName: opts.id,
type: 'install' as const,
appId: 'x',
parentId: opts.parentId ?? '',
dependsOn: opts.dependsOn ?? [],
childIds: [],
status: 'pending' as const,
startedAt: null,
finishedAt: null,
durationMs: 0,
},
}
}
function buildScreenshotLayout() {
// Replicates the founder's screenshot:
// d0: 1 (provision-hetzner-network)
// d1: 1 (provision-hetzner-control-plane) depends-on d0
// d2: 30 leaves all depending on d1
const root = makeNode({ id: 'd0', depth: 0, depRank: 0 })
const phase1 = makeNode({ id: 'd1', depth: 1, depRank: 1, dependsOn: ['d0'] })
const leaves = Array.from({ length: 30 }, (_, i) =>
makeNode({
id: `leaf-${i}`,
depth: 2,
depRank: 2 + i,
dependsOn: ['d1'],
}),
)
const nodes = [root, phase1, ...leaves]
const edges = [
{ fromId: 'd0', toId: 'd1', fromStatus: 'pending' as const, crossRegion: false, kind: 'depends-on' as const },
...leaves.map((n) => ({
fromId: 'd1',
toId: n.id,
fromStatus: 'pending' as const,
crossRegion: false,
kind: 'depends-on' as const,
})),
]
return { nodes, edges, families: FAMILIES, regions: REGIONS, maxDepth: 2 }
}
function readPositions(container: HTMLElement) {
const groups = container.querySelectorAll<SVGGElement>('[data-flow-draggable]')
const out = new Map<string, { x: number; y: number }>()
for (const g of Array.from(groups)) {
const id = g.getAttribute('data-job-id') ?? ''
const t = g.getAttribute('transform') ?? ''
const m = t.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/)
if (!m) continue
out.set(id, { x: Number(m[1]), y: Number(m[2]) })
}
return out
}
describe('FlowCanvasOrganic — X-axis distribution under dense-bucket shape (#669 round 3)', () => {
it('does NOT pile the dense bucket against the right edge', async () => {
const layout = buildScreenshotLayout()
const { container } = render(
<FlowCanvasOrganic
layout={layout}
openJobId={null}
hostJobId={null}
onJobClick={() => {}}
onJobDoubleClick={() => {}}
onCanvasBackgroundClick={() => {}}
/>,
)
// Let the d3-force sim converge.
await new Promise((r) => setTimeout(r, 300))
const pos = readPositions(container)
expect(pos.size).toBe(32)
// 1. Depth ordering on X — left to right.
const x0 = pos.get('d0')!.x
const x1 = pos.get('d1')!.x
const leafXs = Array.from({ length: 30 }, (_, i) => pos.get(`leaf-${i}`)!.x)
expect(x0).toBeLessThan(x1)
for (const lx of leafXs) {
expect(x1).toBeLessThan(lx) // every leaf is right of the depth-1 anchor
}
// 2. Dense bucket spread — leaf cluster width ≥ 30% of total
// layout width. (Total layout width = max X across all bubbles.)
const allXs = [x0, x1, ...leafXs]
const totalWidth = Math.max(...allXs) - Math.min(...allXs)
const leafSpan = Math.max(...leafXs) - Math.min(...leafXs)
// Round-2 failure mode: leafSpan ≈ 0.13 × totalWidth (sub-grid
// crammed into 80% × perDepthX = ~128 px). Round-3 fix puts the
// dense bucket in its own slot whose width tracks sibling count,
// so leafSpan should occupy a meaningful fraction of the total.
expect(leafSpan / totalWidth).toBeGreaterThanOrEqual(0.3)
// 3. Sparse bubbles must NOT all share the same X as the leftmost
// leaf — i.e., depth 0/1 are visibly separated from the dense
// cluster, not crowding the same x-band.
const minLeafX = Math.min(...leafXs)
expect(x1).toBeLessThan(minLeafX)
// The gap between depth-1 anchor and the leftmost leaf is ≥ R
// (one bubble radius — visual breathing room).
const svg = container.querySelector<SVGSVGElement>('[data-testid="flow-canvas-svg"]')!
const someGroup = container.querySelector<SVGGElement>('[data-flow-draggable]')!
const lastCircle = someGroup.querySelectorAll('circle')[someGroup.querySelectorAll('circle').length - 1]
const r = Number(lastCircle?.getAttribute('r') ?? '40')
expect(minLeafX - x1).toBeGreaterThanOrEqual(r)
// 4. Sanity — every bubble is inside the SVG viewBox.
const vb = (svg.getAttribute('viewBox') ?? '').split(/\s+/).map(Number)
const [, , vbW, vbH] = vb
for (const [, p] of pos) {
expect(p.x).toBeGreaterThanOrEqual(0)
expect(p.x).toBeLessThanOrEqual(vbW)
expect(p.y).toBeGreaterThanOrEqual(0)
expect(p.y).toBeLessThanOrEqual(vbH)
}
})
it('keeps adjacent leaves at least 2R apart (forceCollide invariant under variable slots)', async () => {
const layout = buildScreenshotLayout()
const { container } = render(
<FlowCanvasOrganic
layout={layout}
openJobId={null}
hostJobId={null}
onJobClick={() => {}}
onJobDoubleClick={() => {}}
onCanvasBackgroundClick={() => {}}
/>,
)
await new Promise((r) => setTimeout(r, 300))
const pos = readPositions(container)
const someGroup = container.querySelector<SVGGElement>('[data-flow-draggable]')!
const lastCircle = someGroup.querySelectorAll('circle')[someGroup.querySelectorAll('circle').length - 1]
const r = Number(lastCircle?.getAttribute('r') ?? '40')
const minDist = 2 * r // bubble rims must not overlap (some collide-pad slack tolerated)
const TOL = 4
const ids = Array.from(pos.keys())
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = pos.get(ids[i])!
const b = pos.get(ids[j])!
const d = Math.hypot(a.x - b.x, a.y - b.y)
expect(d).toBeGreaterThanOrEqual(minDist - TOL)
}
}
})
})

View File

@ -339,6 +339,26 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
* animations don't perturb the metrics without snapping, every
* frame of a 220ms padding-right transition recomputes the
* `Math.floor(rFit)` value and restarts the sim never settles. */
/* Issue #669 round 4 sqrt-aspect-ratio dense-bucket grids.
*
* Round-3 grew dense slot width with bucket size, but used the
* vertical-fit COL_CAPACITY (~14 rows on a 700px host) which still
* forced wide buckets into thin tall columns. Result: 30 leaves in
* 3 sub-cols × 10 rows = ~120 px wide slot, only ~28% of total
* X-extent.
*
* Round-4: target a square-ish-but-wider aspect for dense buckets.
* targetRows = round(sqrt(count / 1.6))
* gives e.g. 30 4 rows, 8 cols ~700 px slot. Densest bucket's
* targetRows then sets R via vertical-fit so all rows of the
* tightest column fit in hostSize.h. With R = 40 (max), sparse
* depths get 2R = 80 px slots and dense buckets sprawl horizontally
* exactly enough to read as a block, not a pile.
*
* Stabilization (carries from round-3): R snaps to nearest 4, slot
* widths snap to nearest 8, ResizeObserver debounces 180ms with
* 8px epsilon sub-pixel flicker during pane-transition animations
* doesn't perturb the metrics. */
const layoutMetrics = useMemo(() => {
const buckets = new Map<number, number>()
let maxBucket = 0
@ -351,49 +371,50 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
}
const depthCount = maxDepth + 1
// Step 1 — adaptive R. Densest bucket must fit vertically.
// Target rows for a bucket of given size — slightly wider than tall.
const targetRowsFor = (count: number) =>
Math.max(1, Math.round(Math.sqrt(Math.max(1, count) / 1.6)))
// R derived from the densest bucket's *target* rows (NOT the host's
// raw vertical capacity), so wide buckets actually claim X-room.
const denseRows = targetRowsFor(maxBucket)
let r = MAX_NODE_RADIUS
if (maxBucket > 1) {
const usableH = Math.max(60, hostSize.h - 2 * (MAX_NODE_RADIUS + COLLIDE_PADDING))
const pitchAvail = usableH / maxBucket
const pitchAvail = usableH / denseRows
const rFit = (pitchAvail - COLLIDE_PADDING) / 2
r = Math.min(MAX_NODE_RADIUS, Math.max(MIN_NODE_RADIUS, Math.floor(rFit)))
}
// Snap R to nearest 4 — kills sub-pixel flicker during animations.
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 COL_CAPACITY = Math.max(1, Math.floor(Y_RANGE / ROW_PITCH))
const HARD_ROW_CAP = Math.max(1, Math.floor(Y_RANGE / ROW_PITCH))
// Step 2 — per-depth slot width.
// sparse (count ≤ COL_CAPACITY): width = 2R + minimal padding
// dense (count > COL_CAPACITY): width grows with sub-column count
const slotWidth = new Map<number, number>()
// Per-bucket sub-grid info (cols × rows + slot width).
const slotInfo = new Map<number, { cols: number; rows: number; width: number }>()
for (const [d, count] of buckets) {
const totalCols = Math.max(1, Math.ceil(count / COL_CAPACITY))
const naturalCols = totalCols > 1
? (totalCols - 1) * (r * 2 + COLLIDE_PADDING) + r * 2
// 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 cols = Math.max(1, Math.ceil(count / rows))
const naturalW = cols > 1
? (cols - 1) * (r * 2 + COLLIDE_PADDING) + r * 2
: r * 2
// Snap to nearest 8 px.
const w = Math.round(Math.max(naturalCols, r * 2) / 8) * 8
slotWidth.set(d, w)
const width = Math.round(Math.max(naturalW, r * 2) / 8) * 8
slotInfo.set(d, { cols, rows, width })
}
// Step 3 — gap between adjacent depth columns. The gap doubles as
// edge length when chains run depth-to-depth. Clamp to
// [MIN_PER_DEPTH_X, MAX_PER_DEPTH_X].
const gap = Math.max(MIN_PER_DEPTH_X, Math.min(MAX_PER_DEPTH_X, r * 4))
// Step 4 — place each depth at its own X. Cumulative cursor.
const xByDepth = new Map<number, number>()
let cursor = r + COLLIDE_PADDING // left margin
let cursor = r + COLLIDE_PADDING
for (let d = 0; d <= maxDepth; d++) {
const w = slotWidth.get(d) ?? r * 2
xByDepth.set(d, cursor + w / 2) // centerline of slot
const w = slotInfo.get(d)?.width ?? r * 2
xByDepth.set(d, cursor + w / 2)
cursor += w + gap
}
const totalWidth = cursor - gap + r + COLLIDE_PADDING // total layout extent
const totalWidth = cursor - gap + r + COLLIDE_PADDING
const linkDistance = gap * 0.625
const gr = r + GROUP_RADIUS_DELTA
@ -402,17 +423,17 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
r,
gr,
gap,
slotWidth,
slotInfo,
xByDepth,
totalWidth,
linkDistance,
maxBucket,
depthCount,
colCapacity: COL_CAPACITY,
hardRowCap: HARD_ROW_CAP,
}
}, [layout.nodes, hostSize.w, hostSize.h])
const { r: R, gr: GR, gap: PER_DEPTH_X, linkDistance: LINK_DISTANCE, xByDepth, slotWidth } = layoutMetrics
const { r: R, gr: GR, gap: PER_DEPTH_X, linkDistance: LINK_DISTANCE, xByDepth, slotInfo } = layoutMetrics
const depthToX = useCallback(
(depth: number) => xByDepth.get(depth) ?? (R + COLLIDE_PADDING + depth * (R * 4)),
@ -529,19 +550,14 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
*/
const gridTargets = useMemo(() => {
type GridCell = {
tx: number // absolute target X in layout coordinates
ty: number // absolute target Y in layout coordinates
tx: number
ty: number
totalCols: number
totalRows: number
}
const ROW_PITCH = R * 2 + COLLIDE_PADDING
const Y_MARGIN_LOCAL = R + COLLIDE_PADDING
const Y_RANGE_LOCAL = Math.max(R * 2, hostSize.h - Y_MARGIN_LOCAL * 2)
const Y_RANGE_LOCAL = Math.max(R * 2, hostSize.h - 2 * (R + COLLIDE_PADDING))
const Y_CENTER_LOCAL = hostSize.h / 2
// How many bubbles fit vertically with the no-overlap collision
// pitch (NODE_RADIUS*2 + COLLIDE_PADDING = 92px on 700px viewBox →
// 7 rows). Beyond that, we MUST add sub-columns or bubbles overlap.
const COL_CAPACITY = Math.max(1, Math.floor(Y_RANGE_LOCAL / ROW_PITCH))
const buckets = new Map<number, OrganicNode[]>()
for (const n of layout.nodes) {
let bucket = buckets.get(n.depth)
@ -553,21 +569,19 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
}
const cells = new Map<string, GridCell>()
for (const [depth, bucket] of buckets) {
if (bucket.length <= COL_CAPACITY) continue
// Issue #669 round 3 — anchor at the depth's CENTERLINE in the
// variable-width-slot layout, and use the bucket's own slot
// width for sub-column spread (NOT a fraction of perDepthX).
// This is what stops the dense cluster from piling at the
// right edge: dense buckets get a slot wide enough to spread,
// and the rest of the depth chain shifts further right to make
// room.
const info = slotInfo.get(depth)
// Sparse depths (cols=1, rows=1, slot=2R) skip the grid pre-pass —
// the force-anchor + per-bucket Y target handles them naturally.
if (!info || (info.cols <= 1 && info.rows <= 1)) continue
// Issue #669 round 4 — read sub-grid dims from layoutMetrics
// (sqrt-aspect target) instead of recomputing here. Guarantees
// gridTargets and slotWidth agree on cols/rows.
const baseX = xByDepth.get(depth) ?? (R + COLLIDE_PADDING + depth * (R * 4))
const totalCols = Math.max(1, Math.ceil(bucket.length / COL_CAPACITY))
const totalRows = Math.ceil(bucket.length / totalCols)
const slot = slotWidth.get(depth) ?? (R * 2)
// Sub-column inner span — full slot minus one bubble at each
// edge (so a sub-col centerline stays inside the slot).
const SUB_COL_SPAN = Math.max(0, slot - R * 2)
const totalCols = info.cols
const totalRows = info.rows
// Sub-column inner span = slot width minus one bubble at each
// edge (centerlines stay inside the slot).
const SUB_COL_SPAN = Math.max(0, info.width - R * 2)
const colStep = totalCols > 1 ? SUB_COL_SPAN / (totalCols - 1) : 0
// Issue #532 founder verbatim: "homogenously spread". Distribute
// rows evenly across the full Y range (not packed at the top).
@ -595,7 +609,7 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
})
}
return cells
}, [layout.nodes, hostSize.h, xByDepth, slotWidth, R])
}, [layout.nodes, hostSize.h, xByDepth, slotInfo, R])
const simNodes = useMemo<SimNode[]>(() => {
const next: SimNode[] = []
@ -741,27 +755,30 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
if (typeof n.fx === 'number' && typeof n.fy === 'number') continue
const cell = gridTargets.get(n.id)
if (cell) {
// Issue #532 — sub-grid clamping. Each sibling has an
// absolute (tx, ty) target inside the depth column's
// sub-grid. The clamp window is half the sub-column span
// wide and half the row step tall so adjacent siblings
// can settle without invading each other's slots, but
// the slot itself is large enough that forceCollide can
// resolve any tiny overlaps inside the slot without
// pushing the node outside.
const SUB_COL_SPAN = PER_DEPTH_X * 0.8
const colSlot = cell.totalCols > 1
? SUB_COL_SPAN / (cell.totalCols - 1)
: PER_DEPTH_X
const Y_MARGIN_LOCAL = R + COLLIDE_PADDING
const Y_RANGE_LOCAL = Math.max(R * 2, hostSize.h - Y_MARGIN_LOCAL * 2)
const rowSlot = cell.totalRows > 1
? Y_RANGE_LOCAL / (cell.totalRows - 1)
: Y_RANGE_LOCAL
const xMin = cell.tx - colSlot * 0.5
const xMax = cell.tx + colSlot * 0.5
const yMin = cell.ty - rowSlot * 0.5
const yMax = cell.ty + rowSlot * 0.5
// Issue #669 round 4 — narrow per-cell clamp.
//
// Each grid cell's clamp window is half the cell pitch in
// each axis, MINUS R, so the bubble's edge can never reach
// its neighbour's centre. Old code used colSlot = full
// pitch which let two adjacent cells' clamp windows
// overlap → forceCollide pushed bubbles into neighbour
// territory, the clamp ratcheted them in, and centres
// could collapse to <2R apart. The narrow window keeps
// forceCollide's pairwise floor intact at runtime. */
const xPitch = cell.totalCols > 1
? layoutMetrics.slotInfo.get(n.depth)?.width
? Math.max(1, ((layoutMetrics.slotInfo.get(n.depth)!.width - R * 2) / (cell.totalCols - 1)))
: R * 2 + COLLIDE_PADDING
: layoutMetrics.gap
const yPitch = cell.totalRows > 1
? Math.max(R * 2 + COLLIDE_PADDING, (hostSize.h - 2 * (R + COLLIDE_PADDING)) / (cell.totalRows - 1))
: hostSize.h - 2 * (R + COLLIDE_PADDING)
const xHalf = Math.max(R, xPitch * 0.5 - R)
const yHalf = Math.max(R, yPitch * 0.5 - R)
const xMin = cell.tx - xHalf
const xMax = cell.tx + xHalf
const yMin = cell.ty - yHalf
const yMax = cell.ty + yHalf
if (typeof n.x === 'number') {
if (n.x < xMin) n.x = xMin
else if (n.x > xMax) n.x = xMax