graflow: several bigfixes, for subflows, longlinks-fakenodes cleanup, multipass layerordering

This commit is contained in:
STEINNI
2026-02-27 21:42:02 +00:00
parent 7267c1f277
commit 9e91252ad0

View File

@@ -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<layer.length; i++){
const nid1 = layer[i]
// some parents can be in far layers, but at least one is in the prev layer (by definition of layer)
const pnid1 = parents[nid1].find((nid => layers[lidx-1].includes(nid)))
for(let j=i+1; j<layer.length; j++){
const nid2 = layer[j]
const pnid2 = parents[nid2].find((nid => 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<layer.length; i++){
const nid1 = layer[i]
for(let j=i+1; j<layer.length; j++){
const nid2 = layer[j]
const p1 = avgParentIndex(nid1, prevLayer)
const p2 = avgParentIndex(nid2, prevLayer)
const c1 = avgChildIndex(nid1, prevLayer)
const c2 = avgChildIndex(nid2, prevLayer)
if(((p1 - p2) * (c1 - c2)) < 0) { // crossing (now refined by per-port ordering)
toSwap.push([i, j])
}
}
}
}
swap(layer, toSwap)
// Keep bases in sync with the new order so later layers use the updated positions
for(const [idx, nid] of layer.entries()){
if(!indexes[nid]) indexes[nid] = { base: idx, ports: {} }
else indexes[nid].base = idx
if(toSwap.length === 0) break
swap(layer, toSwap)
// Keep bases in sync with the new order so later passes/layers use updated positions
for(const [idx, nid] of layer.entries()){
if(!indexes[nid]) indexes[nid] = { base: idx, ports: {} }
else indexes[nid].base = idx
}
}
}
}
moveNode(nid, destx, desty, orientation, duration = 200, cb) {
moveNode(nid, destx, desty, orientation, duration = 200, autoPlaceToken = null) {
const t0 = performance.now()
const bb = this.stagedNodes[nid].getBoundingClientRect()
const parentbb = this.stagedNodes[nid].parentElement.getBoundingClientRect()
const el0 = this.stagedNodes?.[nid]
if(!el0) return
const bb = el0.getBoundingClientRect()
const parentbb = el0.parentElement.getBoundingClientRect()
const x0=bb.x - parentbb.x
const y0 = bb.y - parentbb.y
function frame(t) {
if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) return
const el = this.stagedNodes?.[nid]
if(!el) return
const p = Math.min((t - t0) / duration, 1)
const k = p * p * (3 - 2 * p) // smoothstep
const x = x0 + (destx - x0) * k
const y = y0 + (desty - y0) * k
this.stagedNodes[nid].style.left = `${x}px`
this.stagedNodes[nid].style.top = `${y}px`
el.style.left = `${x}px`
el.style.top = `${y}px`
this.updateWires(nid, orientation)
if(p < 1) requestAnimationFrame(frame.bind(this))
else{
this.dispatchEvent(new CustomEvent('nodeMoved', {
detail: { nid, x, y },
bubbles: true,
composed: true,
}))
}
}
requestAnimationFrame(frame.bind(this))
}
@@ -785,6 +860,8 @@ class BZgraflow extends Buildoz{
for(const wire of wires){
const [nid1, nid2] = wire.dataset.id.split('_')
const lnk = this.getLink(nid1, nid2)
if(!lnk) continue
if(!this.flow?.longLinks) this.flow.longLinks = []
const longLink = this.flow.longLinks.find(item => (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)
}