From 26ff467015ff864eb226e5f65e8761906a5291cc Mon Sep 17 00:00:00 2001 From: STEINNI Date: Wed, 24 Dec 2025 11:13:13 +0000 Subject: [PATCH] graflow, autoplacement 1.0 no layer-ordering --- buildoz.css | 3 +- bzGraflow.js | 194 +++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 159 insertions(+), 38 deletions(-) diff --git a/buildoz.css b/buildoz.css index 84bc42d..4b999ed 100644 --- a/buildoz.css +++ b/buildoz.css @@ -201,4 +201,5 @@ bz-graflow .bzgf-nodes-container{ height: 100%; } bz-graflow .bzgf-nodes-container{ z-index:10; } -bz-graflow .bzgf-wires-container{ z-index:9; } \ No newline at end of file +bz-graflow .bzgf-wires-container{ z-index:9; } +bz-graflow .bzgf-nodes-container .bzgf-node{ position:absolute; } \ No newline at end of file diff --git a/bzGraflow.js b/bzGraflow.js index 0e813f5..cbfacc7 100644 --- a/bzGraflow.js +++ b/bzGraflow.js @@ -2,7 +2,7 @@ class BZgraflow extends Buildoz{ constructor(){ super() - this.defaultAttrs = { } + this.defaultAttrs = { tension: 100 } this.stagedNodes = { } this.stagedWires = { } } @@ -15,9 +15,7 @@ class BZgraflow extends Buildoz{ console.warn('BZgraflow: No flow URL !?') return } - - this.loadFlow(flowUrl) // Let it load async while we coat - + this.loadFlow(flowUrl) // Let it load async while we coat this.nodesContainer = document.createElement('div') this.nodesContainer.classList.add('bzgf-nodes-container') this.wiresContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg') @@ -42,6 +40,8 @@ class BZgraflow extends Buildoz{ } await this.loadNodes(flowObj.nodesFile) this.flow = flowObj.flow + + if(this.hasAnyLoop()) console.warn('This flow has loops.Optimization not available') this.refresh() } @@ -73,31 +73,20 @@ class BZgraflow extends Buildoz{ addNode(type, id, x, y){ const nodeDef = this.nodesRegistry[type] this.stagedNodes[id] = nodeDef.cloneNode(true) + const portEls = this.stagedNodes[id].querySelectorAll('.port') this.stagedNodes[id].style.left = `${x}px` this.stagedNodes[id].style.top = `${y}px` this.stagedNodes[id].dataset.id = id - const portEls = this.stagedNodes[id].querySelectorAll('.port') this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }]))) this.nodesContainer.append(this.stagedNodes[id]) return(this.stagedNodes[id]) } addWire(idNode1, idPort1, idNode2, idPort2){ - const node1 = this.stagedNodes[idNode1] - const port1 = node1.ports[idPort1] - const node2 = this.stagedNodes[idNode2] - const port2 = node2.ports[idPort2] - const id = `${node1.dataset.id}_${node2.dataset.id}` - const bb1 = port1.el.getBoundingClientRect() - const bb2 = port2.el.getBoundingClientRect() - const x1 = Math.floor(bb1.x + (bb1.width/2)) - const y1 = Math.floor(bb1.y + (bb1.height/2)) - const x2 = Math.floor(bb2.x + (bb2.width/2)) - const y2 = Math.floor(bb2.y + (bb2.height/2)) - - console.log('====>', x1, y1, x2, y2) + const path = this.bezier(idNode1, idPort1, idNode2, idPort2, this.getBZAttribute('tension')) + const id = `${idNode1}_${idNode2}` this.stagedWires[id] = document.createElementNS('http://www.w3.org/2000/svg', 'path') - this.stagedWires[id].setAttribute('d', this.bezier(x1, y1, port1.direction , x2, y2, port2.direction, 60)) + this.stagedWires[id].setAttribute('d', path) this.stagedWires[id].setAttribute('fill', 'none') this.stagedWires[id].classList.add('bzgf-wire') this.stagedWires[id].dataset.id = id @@ -118,41 +107,172 @@ class BZgraflow extends Buildoz{ } } - - bezier(x1, y1, dir1, x2, y2, dir2, tensionMin=60) { + bezier(idNode1, idPort1, idNode2, idPort2, tensionMin=60) { const dirVect = { n: { x: 0, y: -1 }, s: { x: 0, y: 1 }, e: { x: 1, y: 0 }, w: { x: -1, y: 0 }, } + const node1 = this.stagedNodes[idNode1] + const port1 = node1.ports[idPort1] + const node2 = this.stagedNodes[idNode2] + const port2 = node2.ports[idPort2] + const bb1 = port1.el.getBoundingClientRect() + const bb2 = port2.el.getBoundingClientRect() + const x1 = Math.floor(bb1.x + (bb1.width/2)) + const y1 = Math.floor(bb1.y + (bb1.height/2)) + const x2 = Math.floor(bb2.x + (bb2.width/2)) + const y2 = Math.floor(bb2.y + (bb2.height/2)) const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1) let tension = dist * 0.4 - if (tension < tensionMin) tension = tensionMin + if(tension < tensionMin) tension = tensionMin - const c1x = x1 + (dirVect[dir1].x * tension) - const c1y = y1 + (dirVect[dir1].y * tension) + const c1x = x1 + (dirVect[port1.direction].x * tension) + const c1y = y1 + (dirVect[port1.direction].y * tension) - const c2x = x2 + (dirVect[dir2].x * tension) - const c2y = y2 + (dirVect[dir2].y * tension) + const c2x = x2 + (dirVect[port2.direction].x * tension) + const c2y = y2 + (dirVect[port2.direction].y * tension) return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}` } + + autoPlace(orientation = 'horizontal', gapx = 80, gapy = 30){ + if(!['horizontal', 'vertical'].includes(orientation)) return + + const parents = {} + const adj = {} + + this.flow.nodes.forEach(n => { + parents[n.id] = [] + adj[n.id] = [] + }) + + this.flow.links.forEach(link => { + const from = link.from[0] + const to = link.to[0] + parents[to].push(from) + adj[from].push(to) + }) + const layers = this.computeLayers(this.flow.nodes, parents) +// const maxLayers = Math.max(...layers.map(item=>item.length)) + let maxHeight = 0 + const layerHeights = [] + for(const layer of layers){ + let totHeight = 0 + for(const nid of layer){ + const bb = this.stagedNodes[nid].getBoundingClientRect() + totHeight += bb.height+gapy + } + if(totHeight>maxHeight) maxHeight = totHeight + layerHeights.push(totHeight) + } -/* -portPosition(nodeEl, portEl) { - const nodeRect = nodeEl.getBoundingClientRect() - const portRect = portEl.getBoundingClientRect() - const canvasRect = this.canvas.getBoundingClientRect() + let x = gapx + for(const [idx, layer] of layers.entries()){ + for(const nid of layer){ + // some parents can be in far layers, but at least one is in the prev layer (by definition of layer) + const localParent = parents[nid].find((nid => layers[idx-1].includes(nid))) + if(localParent){ //undefined for 1st node + const ports = this.stagedNodes[localParent].ports + console.log(`parent of ${nid} is ${localParent}`, ports) + } + } - return { - x: portRect.left - canvasRect.left + portRect.width / 2, - y: portRect.top - canvasRect.top + portRect.height / 2 - } -} + let wMax = 0 + let y = ((maxHeight - layerHeights[idx]) / 2) + gapy + for(const nid of layer){ + const bb = this.stagedNodes[nid].getBoundingClientRect() + wMax = (bb.width > wMax) ? bb.width : wMax + this.moveNode(nid, x, y, 1000) + y += gapy + bb.height + } + x += wMax + gapx + } + } -*/ + moveNode(nid, destx, desty, duration = 200) { + const t0 = performance.now() + const bb = this.stagedNodes[nid].getBoundingClientRect() + const x0=bb.x + const y0 = bb.y + function frame(t) { + 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` + this.updateWires(nid) + + if(p < 1) requestAnimationFrame(frame.bind(this)) + } + requestAnimationFrame(frame.bind(this)) + } + updateWires(nid){ + const wires = Object.keys(this.stagedWires) + .filter(id => (id.startsWith(nid+'_')||id.endsWith('_'+nid))) + .map(id => this.stagedWires[id]) + for(const wire of wires){ + const [nid1, nid2] = wire.dataset.id.split('_') + const lnk = this.getLink(nid1, nid2) + const path = this.bezier(nid1, lnk.from[1], nid2, lnk.to[1], this.getBZAttribute('tension')) + wire.setAttribute('d', path) + } + } + + getLink(nid1, nid2){ + return(this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2)))) + } + + computeLayers(nodes, parents) { + const layer = {} + const dfs = (id) => { + if (layer[id] !== undefined) return(layer[id]) + + if (parents[id].length === 0) { + layer[id] = 0 + } else { + layer[id] = 1 + Math.max(...parents[id].map(dfs)) + } + return(layer[id]) + } + nodes.forEach(n => dfs(n.id)) + const t = [] + for(const nid in layer) { + if(!t[layer[nid]]) t[layer[nid]]=[] + t[layer[nid]].push(nid) + } + return(t) + } + + hasAnyLoop(nodes, links) { + // self-loops + if(this.flow.links.some(l => l.from[0] === l.to[0])) return(true) + + // multi-node cycles + const adj = {}; + this.flow.nodes.forEach(node => adj[node.id] = []) + this.flow.links.forEach(link => adj[link.from[0]].push(link.to[0])) + + const visiting = new Set(); + const visited = new Set() + const dfs = (nid) => { + if(visiting.has(nid)) return(true) + if(visited.has(nid)) return(false) + visiting.add(nid) + for(const m of adj[nid]) { + if(dfs(m)) return(true) + } + visiting.delete(nid) + visited.add(nid) + return(false) + } + return(this.flow.nodes.map(n => n.id).some(dfs)) + } + } Buildoz.define('graflow', BZgraflow)