From 1946e0a46e1b20410b724dcee86c0f715dd2b0f8 Mon Sep 17 00:00:00 2001 From: e3mrah <81884938+emrahbaysal@users.noreply.github.com> Date: Sun, 3 May 2026 20:38:44 +0400 Subject: [PATCH] fix(flow-canvas): variable-width depth columns + ResizeObserver debounce (#669 round 3) (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: hatiyildiz Co-authored-by: Claude Opus 4.7 (1M context) --- .../FlowCanvasOrganic.distribution.test.tsx | 199 ++++++++++++++ .../src/pages/sovereign/FlowCanvasOrganic.tsx | 260 ++++++++++++------ 2 files changed, 380 insertions(+), 79 deletions(-) create mode 100644 products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.distribution.test.tsx diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.distribution.test.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.distribution.test.tsx new file mode 100644 index 00000000..830e3e6a --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.distribution.test.tsx @@ -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('[data-flow-draggable]') + const out = new Map() + 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( + {}} + 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('[data-testid="flow-canvas-svg"]')! + const someGroup = container.querySelector('[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( + {}} + onJobDoubleClick={() => {}} + onCanvasBackgroundClick={() => {}} + />, + ) + await new Promise((r) => setTimeout(r, 300)) + const pos = readPositions(container) + const someGroup = container.querySelector('[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) + } + } + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.tsx index 8481e4cd..1ff8d5e5 100644 --- a/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/FlowCanvasOrganic.tsx @@ -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 | 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() let maxBucket = 0 let maxDepth = 0 - const buckets = new Map() 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() + 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() + 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() 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() 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(() => { 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