fix(web): fit graph to node bounding box once the simulation settles
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user