fix(web): fit graph to node bounding box once the simulation settles
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 10:51:56 +02:00
parent 604b608fa8
commit 4c4afc4783

View File

@@ -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)
}
})