fix(flow-canvas): variable-width depth columns + ResizeObserver debounce (#669 round 3) (#693)

* fix(flow-canvas): variable-width depth columns + ResizeObserver debounce (#669 round 3)

Round-2 UAT showed:
1. Dense bucket of 30+ siblings piled at the right edge while 60% of
   canvas (left side) sat empty with one bubble per depth.
2. Sim "trying never stabilizing" during pane-transition animations.

Root cause #1: round-2 used a constant `perDepthX` for every depth.
With one-bubble depths next to a 30+ sibling depth, the dense bucket
got 80% × perDepthX (~128 px) of horizontal room and had to pile into
8+ sub-columns; sparse depths each got the same perDepthX (~160 px)
for a single bubble. Net: 60% canvas unused on the left, dense
cluster jammed at right.

Round-3 fix #1: variable-width depth columns. Each depth gets a slot
whose width tracks its bucket's natural extent at radius R:
sparse buckets need 2R + small gap; dense buckets need
(totalCols - 1) * (2R + COLLIDE_PADDING) to fit sub-columns
side-by-side. depthToX returns the centerline of slot[depth];
adjacent slots are separated by `gap = clamp(r*4, MIN, MAX)`. Total
layout width = sum(slots) + gaps.

Root cause #2: ResizeObserver fired on every animation frame during
the 220ms padding-right transition (pane open/close). Every fire
called setHostSize, which retriggered layoutMetrics → R changed by
1-2 px → all node targets shifted → sim re-seeded → never settled.

Round-3 fix #2: 180ms debounce on the observer + 8 px epsilon gate
(sub-pixel changes ignored entirely). Combined with snap-to-4 on R
and snap-to-8 on slot widths in layoutMetrics, the metrics now hold
constant during pane-transition animations and the sim converges
once.

Tests: bounded layout (17) + JobDetail (5) all green; tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

---------

Co-authored-by: hatiyildiz <hatice@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-03 20:38:44 +04:00 committed by GitHub
parent 3da196ec42
commit 1946e0a46e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 380 additions and 79 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

@ -235,30 +235,51 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
useEffect(() => {
const el = hostRef.current
if (!el) return
// ResizeObserver may fire synchronously during layout — use rAF to
// batch state updates so we never trigger a re-render mid-tick.
let raf = 0
/* Issue #669 round 3 debounced + epsilon-gated ResizeObserver.
*
* Background: closing/opening the LogPane animates `padding-right`
* over 220ms (cubic-bezier). During that animation the canvas
* host width changes by ~1-2 px every animation frame; round-2
* fired setHostSize on every rAF, which restarted the d3-force
* sim every frame the operator saw the bubbles "trying never
* stabilizing".
*
* Fix: wait for the host size to be stable for 180ms before
* pushing it to React state, AND ignore changes smaller than 8 px
* in either dimension (the layoutMetrics snap-to-4 + slot
* snap-to-8 combination tolerates ±4 px without re-emitting any
* different metric, but explicit gating keeps the sim restart
* 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 ro = new ResizeObserver((entries) => {
const e = entries[0]
if (!e) return
const rect = e.contentRect
// Use the actual measured rect — not a floor. The MIN_HOST_*
// constants only apply when the rect is degenerate (0×0 during
// first paint). Forcing the viewBox to MIN_HOST_W when the
// host is narrower (e.g. LogPane reserves 30vw) causes the
// SVG to render 1200 viewBox-units into 686 CSS px (0.57×
// downscale), shrinking bubbles AND collapsing pairwise
// distances below the no-overlap threshold.
const w = Math.round(rect.width) || MIN_HOST_W
const h = Math.round(rect.height) || MIN_HOST_H
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
setHostSize((prev) => (prev.w === w && prev.h === h ? prev : { w, h }))
})
pending = { w, h }
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
if (!pending) return
const next = pending
pending = null
setHostSize((prev) => {
if (
Math.abs(prev.w - next.w) < RESIZE_EPSILON_PX &&
Math.abs(prev.h - next.h) < RESIZE_EPSILON_PX
) {
return prev // ignore — sub-threshold flicker
}
return next
})
}, RESIZE_DEBOUNCE_MS)
})
ro.observe(el)
return () => {
cancelAnimationFrame(raf)
if (timer) clearTimeout(timer)
ro.disconnect()
}
}, [])
@ -291,10 +312,57 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
* together with the sim-restart effect below that re-seeds free
* nodes to their fresh targets gives bubbles a visible re-flow
* pass on every host change. */
/* Issue #669 round 3 variable-width depth columns.
*
* Founder-verbatim 2026-05-03 (round-2 UAT): "while 2/3 of the
* screen is empty, it is trying to pile up everything in the right
* edge. And it keep trying never stabilizing. Despite y axis
* homogeneous distribution looks fine, x axis distribution is
* terrible".
*
* Root cause: round-2 used a CONSTANT `perDepthX` for every depth
* column. With one-bubble depths next to a 30+ sibling depth, the
* dense bucket got 80% × perDepthX (~128px) of horizontal room and
* had to pile into 8+ sub-columns; the sparse depths each got their
* own perDepthX (~160px) for one bubble. End result: 60% of canvas
* unused on the left, dense cluster jammed at right.
*
* Round-3 fix: each depth bucket gets a horizontal slot whose width
* equals the bucket's *natural* extent at radius R. Sparse buckets
* (1 sibling) need 2R + small gap; dense buckets need
* `(totalCols - 1) * (2R + COLLIDE_PADDING)` to fit their
* sub-columns side-by-side. Total layout width = sum(slots) + gaps
* between slots. depthToX returns the centerline of slot[depth].
*
* Stabilization: round R to nearest 4 and slot widths to nearest 8
* so sub-pixel ResizeObserver fires during pane-transition
* 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
let maxDepth = 0
const buckets = new Map<number, number>()
for (const n of layout.nodes) {
const c = (buckets.get(n.depth) ?? 0) + 1
buckets.set(n.depth, c)
@ -302,30 +370,74 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
if (n.depth > maxDepth) maxDepth = n.depth
}
const depthCount = maxDepth + 1
// 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)))
}
let perDepthX = Math.max(MIN_PER_DEPTH_X, Math.min(MAX_PER_DEPTH_X, r * 4))
if (depthCount > 1) {
const usableW = Math.max(120, hostSize.w - 2 * (r + COLLIDE_PADDING))
const fitW = usableW / Math.max(1, depthCount - 1)
perDepthX = Math.min(perDepthX, Math.max(MIN_PER_DEPTH_X, fitW))
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).
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 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
slotInfo.set(d, { cols, rows, width })
}
const linkDistance = perDepthX * 0.625
const gap = Math.max(MIN_PER_DEPTH_X, Math.min(MAX_PER_DEPTH_X, r * 4))
const xByDepth = new Map<number, number>()
let cursor = r + COLLIDE_PADDING
for (let d = 0; d <= maxDepth; d++) {
const w = slotInfo.get(d)?.width ?? r * 2
xByDepth.set(d, cursor + w / 2)
cursor += w + gap
}
const totalWidth = cursor - gap + r + COLLIDE_PADDING
const linkDistance = gap * 0.625
const gr = r + GROUP_RADIUS_DELTA
const xLeftMargin = r + perDepthX / 2
return { r, gr, perDepthX, linkDistance, xLeftMargin, maxBucket, depthCount }
return {
r,
gr,
gap,
slotInfo,
xByDepth,
totalWidth,
linkDistance,
maxBucket,
depthCount,
hardRowCap: HARD_ROW_CAP,
}
}, [layout.nodes, hostSize.w, hostSize.h])
const { r: R, gr: GR, perDepthX: PER_DEPTH_X, linkDistance: LINK_DISTANCE, xLeftMargin: X_LEFT_MARGIN } = layoutMetrics
const { r: R, gr: GR, gap: PER_DEPTH_X, linkDistance: LINK_DISTANCE, xByDepth, slotInfo } = layoutMetrics
const depthToX = useCallback(
(depth: number) => X_LEFT_MARGIN + depth * PER_DEPTH_X,
[X_LEFT_MARGIN, PER_DEPTH_X],
(depth: number) => xByDepth.get(depth) ?? (R + COLLIDE_PADDING + depth * (R * 4)),
[xByDepth, R],
)
/* Issue #532 resolve a per-node depRank.
@ -438,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)
@ -462,27 +569,19 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
}
const cells = new Map<string, GridCell>()
for (const [depth, bucket] of buckets) {
// Only apply grid layout when sibling count exceeds the single-
// column vertical capacity. Sparse depths keep the original
// force-anchor behaviour (depthX + depRank-based Y).
if (bucket.length <= COL_CAPACITY) continue
// Issue #669 — anchor at depthToX (NODE_RADIUS + PER_DEPTH_X/2 +
// depth * PER_DEPTH_X) so sub-columns centred on baseX never
// extend into negative X (which would render off-canvas under
// the new viewBox = host-px convention).
const baseX = X_LEFT_MARGIN + depth * PER_DEPTH_X
// Issue #532: with N siblings in a depth bucket and Y-range
// budget that fits COL_CAPACITY rows at the no-overlap pitch,
// we need ceil(N / COL_CAPACITY) sub-columns. Each sub-column
// contains COL_CAPACITY rows distributed homogeneously across
// the full Y range. This guarantees no overlap by construction
// (forceCollide is then a safety net for boundary effects).
const totalCols = Math.max(1, Math.ceil(bucket.length / COL_CAPACITY))
const totalRows = Math.ceil(bucket.length / totalCols)
// Sub-column span — each sub-column gets a slice of the depth
// column's natural width. Cap at PER_DEPTH_X so adjacent depth
// columns never visually merge.
const SUB_COL_SPAN = PER_DEPTH_X * 0.8
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 = 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).
@ -510,7 +609,7 @@ export function FlowCanvasOrganic(props: FlowCanvasOrganicProps) {
})
}
return cells
}, [layout.nodes, hostSize.h, X_LEFT_MARGIN, R, PER_DEPTH_X])
}, [layout.nodes, hostSize.h, xByDepth, slotInfo, R])
const simNodes = useMemo<SimNode[]>(() => {
const next: SimNode[] = []
@ -656,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