From 4c4afc47834a853e23dfb13b2d6222b529a7ea98 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 10:51:56 +0200 Subject: [PATCH] fix(web): fit graph to node bounding box once the simulation settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh nodes (no prior x/y) get placed by d3-force's default init, which spirals out from the ORIGIN — not (width/2, height/2) — while the centering forces here are deliberately weak (0.04, so they don't fight the link/collide layout) and alphaDecay stops the sim before a weak force can always pull a far-off cluster back to center. Net effect: graphs could settle visibly off-center on load, cramped in a corner of the pane. Fixed by computing the actual node bounding box once the simulation's 'end' event fires and setting the view transform to fit it, instead of relying on the force balance to land on center by itself. Gated behind a `fit` flag so passive background reloads (live entity/relationship events) don't yank the view out from under someone actively panning or zoomed in on a specific area — only fresh loads (mount, root/depth change, reset, re-root) reframe. Co-Authored-By: Claude Sonnet 5 --- web/src/lib/components/EntityGraph.svelte | 37 +++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte index 028987e..831bad0 100644 --- a/web/src/lib/components/EntityGraph.svelte +++ b/web/src/lib/components/EntityGraph.svelte @@ -120,7 +120,37 @@ return typeCategory.get(type) === category } - async function load() { + // Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's + // default init spreads those via a spiral centered on the ORIGIN — not + // (width/2, height/2) — while the x/y centering forces below are + // deliberately weak (0.04, so they don't fight the link/collide layout). + // Together that meant the cluster could settle noticeably off-origin + // instead of centered. Fixed by explicitly fitting the viewport to the + // node bounding box once the simulation settles, rather than relying on + // the force balance to land on center by itself. + function fitToView() { + const placed = nodes.filter((n) => n.x != null && n.y != null) + if (!placed.length) return + const xs = placed.map((n) => n.x as number) + const ys = placed.map((n) => n.y as number) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + const pad = 70 + const bw = Math.max(maxX - minX, 1) + const bh = Math.max(maxY - minY, 1) + const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5) + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k } + } + + // fit=false for passive background reloads (live entity/relationship + // events) — those shouldn't yank the view out from under someone + // actively panning/zooming. Fresh loads (mount, root/depth change, + // reset, re-root) default to fit=true. + async function load(fit = true) { loading = true graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true }) loading = false @@ -163,6 +193,9 @@ .on('tick', () => { nodes = [...nodes] }) + .on('end', () => { + if (fit) fitToView() + }) } onMount(() => { @@ -191,7 +224,7 @@ const ev = $liveEvents[0] if (!ev) return if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') { - load() + load(false) } })