class BZgraflow extends Buildoz{ constructor(){ super() this.defaultAttrs = { tension: 100 } this.stagedNodes = { } this.stagedWires = { } } static _loadedNodeStyles = new Set() // Allow multi instances or re-loadNodes, but avoid reinjecting same styles ! connectedCallback() { super.connectedCallback() const flowUrl = this.getBZAttribute('flow') if(!flowUrl) { console.warn('BZgraflow: No flow URL !?') return } this.mainContainer = document.createElement('div') this.mainContainer.classList.add('bzgf-main-container') this.shadow = this.mainContainer.attachShadow({ mode: 'open' }) this.nodesContainer = document.createElement('div') this.nodesContainer.classList.add('bzgf-nodes-container') this.wiresContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg') this.wiresContainer.setAttribute('overflow','visible') this.wiresContainer.classList.add('bzgf-wires-container') this.shadow.append(this.wiresContainer) this.shadow.append(this.nodesContainer) this.append(this.mainContainer) this.loadFlow(flowUrl) // Let it load async } async loadFlow(url){ const res = await fetch(url+'?'+crypto.randomUUID()) const buf = await res.text() let flowObj try{ flowObj = JSON.parse(buf) } catch(err){ console.error('Could not parse flow JSON!?', err) return } if(!flowObj.nodesFile){ console.error('No nodesFile in JSON!?') return } await this.loadNodes(flowObj.nodesFile) this.flow = flowObj.flow if(this.hasAnyLoop()) console.warn('This flow has loops.Optimization not available') this.refresh() } async loadNodes(url) { const res = await fetch(url+'?'+crypto.randomUUID()) const html = await res.text() // Get nodes const doc = new DOMParser().parseFromString(html, 'text/html') this.nodesRegistry = {} for(const tpl of doc.querySelectorAll('template')){ if(tpl.id=='svg-arrows'){ this.wiresContainer.appendChild(tpl.querySelector('defs').cloneNode(true)) this.arrowDefined = true } else { const rootEl = tpl.content.querySelector('.bzgf-node') if(!rootEl) continue this.nodesRegistry[rootEl.dataset.nodetype] = rootEl } } // Now load styles (once) if(!BZgraflow._loadedNodeStyles.has(url)) { const styles = doc.querySelectorAll('style') styles.forEach(styleEl => { const style = document.createElement('style') style.textContent = styleEl.textContent this.shadow.appendChild(style) }) BZgraflow._loadedNodeStyles.add(url) } } 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 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(link){ const [idNode1, idPort1] = link.from const [idNode2, idPort2] = link.to 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', path) this.stagedWires[id].setAttribute('fill', 'none') if(this.arrowDefined && link.endArrow) this.stagedWires[id].setAttribute('marker-end','url(#arrow)') if(this.arrowDefined && link.startArrow) this.stagedWires[id].setAttribute('marker-start','url(#arrow)') this.stagedWires[id].classList.add('bzgf-wire') this.stagedWires[id].dataset.id = id this.wiresContainer.append(this.stagedWires[id]) return(this.stagedWires[id]) } refresh(){ let x = 0 let y = 0 for(const node of this.flow.nodes){ const nodeEl = this.addNode(node.nodeType, node.id , node.coords.x, node.coords.y) } for(const link of this.flow.links){ this.addWire(link) } } 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 svgRect = this.wiresContainer.getBoundingClientRect() const node1 = this.stagedNodes[idNode1] const port1 = node1.ports[idPort1] const node2 = this.stagedNodes[idNode2] const port2 = node2.ports[idPort2] if(!node1 || !node2 || !port1 || !port2) { console.warn('bezier on bad node / port!', idNode1, idPort1, idNode2, idPort2) return('') } const bb1 = port1.el.getBoundingClientRect() const bb2 = port2.el.getBoundingClientRect() const x1 = Math.floor(bb1.x + (bb1.width/2)) - svgRect.left const y1 = Math.floor(bb1.y + (bb1.height/2)) - svgRect.top const x2 = Math.floor(bb2.x + (bb2.width/2)) - svgRect.left const y2 = Math.floor(bb2.y + (bb2.height/2)) - svgRect.top const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1) let tension = dist * 0.4 if(tension < tensionMin) tension = tensionMin const c1x = x1 + (dirVect[port1.direction].x * tension) const c1y = y1 + (dirVect[port1.direction].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(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ console.warn('Loop(s) detected... Cannot auto-place !') 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) let maxHeight = 0; let maxWidth = 0 const layerHeights = []; const layerWidths = []; const indexes = {} // Todo: a for(const layer of layers){ let totHeight = 0; let totWidth = 0 for(const [idx, nid] of layer.entries()){ const bb = this.stagedNodes[nid].getBoundingClientRect() totHeight += bb.height + gapy totWidth += bb.width + gapx indexes[nid] = idx } if(totHeight>maxHeight) maxHeight = totHeight layerHeights.push(totHeight) if(totWidth>maxWidth) maxWidth = totWidth layerWidths.push(totWidth) } this.reorderLayers(layers, parents, indexes) if(orientation=='horizontal'){ let x = gapx for(const [idx, layer] of layers.entries()){ 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 } } else if(orientation=='vertical'){ let y = gapy for(const [idx, layer] of layers.entries()){ let hMax = 0 let x = ((maxWidth - layerWidths[idx]) / 2) + gapx for(const nid of layer){ const bb = this.stagedNodes[nid].getBoundingClientRect() hMax = (bb.height > hMax) ? bb.width : hMax this.moveNode(nid, x, y, 1000) x += gapx + bb.width } y += hMax + gapy } } } reorderLayers(layers, parents, indexes){ const swap = (vect, todo) => { for(const s of todo){ [vect[s[0]], vect[s[1]]] = [vect[s[1]], vect[s[0]]] } } 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))) if(((indexes[pnid1] - indexes[pnid2]) * (indexes[nid1] - indexes[nid2])) < 0) { // crossing ! toSwap.push([i, j]) } } } swap(layer, toSwap) } } moveNode(nid, destx, desty, duration = 200, cb) { 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)