From 9e91252ad07ea05da78cfd751e94fdc3b951ebd0 Mon Sep 17 00:00:00 2001 From: STEINNI Date: Fri, 27 Feb 2026 21:42:02 +0000 Subject: [PATCH] graflow: several bigfixes, for subflows, longlinks-fakenodes cleanup, multipass layerordering --- bzGraflow.js | 171 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 127 insertions(+), 44 deletions(-) diff --git a/bzGraflow.js b/bzGraflow.js index 4c634f7..8f61618 100644 --- a/bzGraflow.js +++ b/bzGraflow.js @@ -31,6 +31,7 @@ class BZgraflow extends Buildoz{ this.stagedNodes = { } this.stagedWires = { } this.arrowDefs = null + this.currentOrientation = null } addIcon(el, name) { @@ -205,6 +206,8 @@ class BZgraflow extends Buildoz{ const flowUrl = flowNode.subflow.url const childEl = document.createElement('bz-graflow') + childEl.isSubflow = true + childEl.currentOrientation = this.currentOrientation childEl.setAttribute('flow', flowUrl) childEl.setAttribute('tension', this.getBZAttribute('tension') || '60') // Remember which node we "came from" so exitSubflow() can animate back to it. @@ -239,8 +242,9 @@ class BZgraflow extends Buildoz{ }) } } - childEl.autoPlace('horizontal', 60, 60) - }) + + childEl.autoPlace(this.currentOrientation, 60, 60) + }, { once:true }) // Fade out the current (host) graflow while the child scales up this.hostContainer.style.opacity = '1' @@ -291,7 +295,7 @@ class BZgraflow extends Buildoz{ invade(oldEl, newEl){ const r = oldEl.getBoundingClientRect() - newEl.style.position = 'fixed' + newEl.style.position = 'fixed' //TODO This is bad: not scroll-proof !! newEl.style.left = r.left + 'px' newEl.style.top = r.top + 'px' newEl.style.width = r.width + 'px' @@ -414,13 +418,12 @@ class BZgraflow extends Buildoz{ for(const link of this.flow.links){ this.addWire(link) } - - if(forceAutoplace){ + if(!this.currentOrientation) { const bb=this.getBoundingClientRect() - //TODO compute tensions from ports, height and width - if(bb.width > bb.height) this.autoPlace('horizontal', 60, 60) - else this.autoPlace('vertical', 60, 60) - } + if(bb.width > bb.height) this.currentOrientation = 'horizontal' + else this.currentOrientation = 'vertical' + } + if(forceAutoplace) this.autoPlace(this.currentOrientation, 60, 60) } // Convert viewport (client) coordinates to this instance's SVG local coordinates. @@ -556,6 +559,21 @@ class BZgraflow extends Buildoz{ } autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=500, align='center'){ + this.currentOrientation = orientation + // Cancel any previous autoPlace() animations by bumping a token. + // moveNode() checks this token each frame and will no-op if superseded. + this._autoPlaceToken = (this._autoPlaceToken || 0) + 1 + const token = this._autoPlaceToken + + // Cleanup placeholders from previous autoPlace() runs. + // Each run creates new longLinkPlaceHolder_* IDs; without cleanup they accumulate in the DOM. + for(const nid of Object.keys(this.stagedNodes || {})){ + if(!nid.startsWith('longLinkPlaceHolder_')) continue + const el = this.stagedNodes[nid] + if(el?.parentNode) el.parentNode.removeChild(el) + delete this.stagedNodes[nid] + } + // Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them ! let linksWithoutBackEdges if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ @@ -568,6 +586,27 @@ class BZgraflow extends Buildoz{ const layers = this.computeLayers(this.flow.nodes, parents) + // Layer-0 nodes have no parents, so reorderLayers() (which uses parent ordering) cannot + // improve their order. This shows up strongly for "entry reference nodes" that are added + // programmatically: they all end up in layer 0 and keep insertion order, creating crossings. + // Pre-sort layer 0 by the average position of their children in layer 1. + if(layers.length > 1){ + const next = layers[1] + const nextPos = Object.fromEntries(next.map((nid, idx) => ([nid, idx]))) + const curPos0 = Object.fromEntries(layers[0].map((nid, idx) => ([nid, idx]))) + const key0 = (nid) => { + const kids = (adj?.[nid] || []).filter(k => (k in nextPos)) + if(kids.length === 0) return(curPos0[nid] ?? 0) + const sum = kids.reduce((acc, k) => acc + nextPos[k], 0) + return(sum / kids.length) + } + layers[0].sort((a, b) => { + const ka = key0(a), kb = key0(b) + if(ka !== kb) return(ka - kb) + return((curPos0[a] ?? 0) - (curPos0[b] ?? 0)) + }) + } + // Compute indexes for each layer (int part) & add sub-index for ports // Also compute max width/height for each layer let maxHeight = 0; let maxWidth = 0 @@ -643,11 +682,11 @@ class BZgraflow extends Buildoz{ for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')) { const bb = this.stagedNodes[nid].getBoundingClientRect() - this.moveNode(nid, x, y, orientation, tween) + this.moveNode(nid, x, y, orientation, tween, null, token) y += gapy + (this.stagedNodes[nid].offsetHeight || bb.height) } else { this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight) - this.moveNode(nid, x, y, orientation, tween) + this.moveNode(nid, x, y, orientation, tween, null, token) y += gapy + fakeNodeHeight } } @@ -662,11 +701,11 @@ class BZgraflow extends Buildoz{ for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')){ const bb = this.stagedNodes[nid].getBoundingClientRect() - this.moveNode(nid, x, y, orientation, tween) + this.moveNode(nid, x, y, orientation, tween, null, token) x += gapx + (this.stagedNodes[nid].offsetWidth || bb.width) } else { this.addFakeNode(nid, x, y, fakeNodeWidth, hMax*0.75) - this.moveNode(nid, x, y, orientation, tween) + this.moveNode(nid, x, y, orientation, tween, null, token) x += gapx + fakeNodeWidth } } @@ -727,53 +766,89 @@ class BZgraflow extends Buildoz{ const off = (portId && info?.ports?.[portId] !== undefined) ? info.ports[portId] : 0 return(base + off) } + // For nodes with multiple parents in the previous layer (common for "exit" ref nodes), + // using only the first parent can produce bad swaps. Use the average index over all + // parents that are actually in the previous layer. + const avgParentIndex = (nid, prevLayer) => { + const ps = (parents?.[nid] || []).filter(p => prevLayer.includes(p)) + if(ps.length === 0) return(adjIndex(nid)) + const sum = ps.reduce((acc, p) => { + const lnk = this.getLink(p, nid) + return(acc + adjIndex(p, lnk?.from?.[1])) + }, 0) + return(sum / ps.length) + } + const avgChildIndex = (nid, prevLayer) => { + const ps = (parents?.[nid] || []).filter(p => prevLayer.includes(p)) + if(ps.length === 0) return(adjIndex(nid)) + const sum = ps.reduce((acc, p) => { + const lnk = this.getLink(p, nid) + return(acc + adjIndex(nid, lnk?.to?.[1])) + }, 0) + return(sum / ps.length) + } for(const [lidx, layer] of layers.entries()){ if(lidx==0) continue - const toSwap = [] - for(let i=0; i layers[lidx-1].includes(nid))) - for(let j=i+1; j layers[lidx-1].includes(nid))) - const link1 = (pnid1) ? this.getLink(pnid1, nid1) : null - const link2 = (pnid2) ? this.getLink(pnid2, nid2) : null - const p1 = adjIndex(pnid1, link1?.from?.[1]) - const p2 = adjIndex(pnid2, link2?.from?.[1]) - const c1 = adjIndex(nid1, link1?.to?.[1]) - const c2 = adjIndex(nid2, link2?.to?.[1]) + const prevLayer = layers[lidx-1] - if(((p1 - p2) * (c1 - c2)) < 0) { // crossing (now refined by per-port ordering) - toSwap.push([i, j]) + // Single-pass swapping is very sensitive to insertion order (especially with long-link + // placeholders and reference nodes). Do a few relaxation passes until stable. + const maxPasses = 8 + for(let pass = 0; pass < maxPasses; pass++){ + const toSwap = [] + for(let i=0; i (item.link.from[0] == lnk.from[0] && item.link.from[1] == lnk.from[1] && item.link.to[0] == lnk.to[0] && item.link.to[1] == lnk.to[1])) if(longLink) { const path = this.bezierInterNodes(nid1, lnk.from[1], nid2, lnk.to[1], longLink.interNodes, orientation, this.getBZAttribute('tension')) @@ -840,12 +917,18 @@ class BZgraflow extends Buildoz{ } return(layer[id]) } + // Compute all layer indices nodes.forEach(n => dfs(n.id)) + + // Build layers in the same order as `nodes` to keep results stable when nodes are appended + // programmatically (e.g. reference nodes). Avoid relying on object key enumeration order. const t = [] - for(const nid in layer) { - if(!t[layer[nid]]) t[layer[nid]]=[] - t[layer[nid]].push(nid) - } + nodes.forEach(n => { + const l = layer[n.id] + if(l === undefined) return + if(!t[l]) t[l] = [] + t[l].push(n.id) + }) return(t) }