class BZgraflow extends Buildoz{ dirVect = { n: { x: 0, y: -1 }, s: { x: 0, y: 1 }, e: { x: 1, y: 0 }, w: { x: -1, y: 0 }, } static _loadedNodeStyles = new Set() // Allow multi instances or re-loadNodes, but avoid reinjecting same styles ! constructor(){ super() this.defaultAttrs = { tension: 100 } this.stagedNodes = { } this.stagedWires = { } this.arrowDefs = null } 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' }) const style = document.createElement('style') style.textContent = ` @import '/app/assets/styles/icons.css'; .bzgf-wires-container, .bzgf-nodes-container{ position: absolute; inset: 0; width: 100%; height: 100%; } .bzgf-nodes-container .bzgf-node{ position:absolute; } .bzgf-nodes-container .bzgf-fake-node{ position: absolute; width: 5px; height: 5px; backgrround: transparent; border-style: none; } .bzgf-nodes-container button.bzgf-zoom-in{ width: 2em; height: 2em; z-index: 999; position: absolute; top: -1em; right: -1em; color: #A00; } .bzgf-nodes-container .bzgf-node.scaler { transform-origin: top left; transform: translate(var(--tx, 0), var(--ty, 0)) scale(var(--sx, 1), var(--sy, 1)); transition: transform 300ms ease-in-out; } ` this.shadow.appendChild(style) 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 } error(msg, err){ this.innerHTML = `
${msg}
` if(err) console.error(msg, err) else console.error(msg) } async loadFlow(url){ const res = await fetch(url+'?'+crypto.randomUUID()) const buf = await res.text() let flowObj try{ flowObj = JSON.parse(buf) } catch(err){ this.error('Could not parse flow JSON!?', err) return } if(!flowObj.nodesFile){ this.error('No nodesFile in JSON!?') return } await this.loadNodes(flowObj.nodesFile) this.flow = flowObj.flow 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.arrowDefs = tpl.querySelector('defs').cloneNode(true) this.wiresContainer.appendChild(this.arrowDefs) } 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(node){ const id = node.id if(!(node.nodeType in this.nodesRegistry)){ console.warn(`Unknown node type (${node.nodeType})`); return(null)} const nodeDef = this.nodesRegistry[node.nodeType] this.stagedNodes[id] = nodeDef.cloneNode(true) for(const token in node.markup){ this.stagedNodes[id].innerHTML = this.stagedNodes[id].innerHTML.replace(new RegExp(`\{${token}\}`, 'g'), node.markup[token]) } if(typeof(node.data)=='object') Object.assign(this.stagedNodes[id].dataset, node.data) const portEls = this.stagedNodes[id].querySelectorAll('.port') this.stagedNodes[id].style.left = (node.coords && node.coords.x) ? `${node.coords.x}px` : '10%' this.stagedNodes[id].style.top = (node.coords && node.coords.y) ? `${node.coords.y}px` : '10%' this.stagedNodes[id].dataset.id = id this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }]))) if(node.subflow) { const btnZoomIn = document.createElement('button') btnZoomIn.classList.add('bzgf-zoom-in', 'icon-copy') btnZoomIn.addEventListener('click', () => { this.zoomIn(id) }) this.stagedNodes[id].appendChild(btnZoomIn) } this.nodesContainer.append(this.stagedNodes[id]) return(this.stagedNodes[id]) } zoomIn(id){ console.log('==============================>ZOOM IN:', id) const node = this.stagedNodes[id] const nodeBB = node.getBoundingClientRect() const parentBB = this.nodesContainer.getBoundingClientRect() const sx = parentBB.width / nodeBB.width const sy = parentBB.height / nodeBB.height const tx = parentBB.left - nodeBB.left + this.nodesContainer.scrollLeft // TODO Should have a meth to accumulate scrolls in ancestors const ty = parentBB.top - nodeBB.top + this.nodesContainer.scrollTop // TODO Should have a meth to accumulate scrolls in ancestors node.style.setProperty('--tx', tx + 'px') node.style.setProperty('--ty', ty + 'px') node.style.setProperty('--sx', sx) node.style.setProperty('--sy', sy) node.style.zIndex = '9999' node.classList.add('scaler') Promise.all(node.getAnimations().map(a => a.finished)).then((transitions) => { const testEl = document.createElement('bz-graflow') testEl.setAttribute('flow', '/app/assets/json/bzGraflow/testFlowEic.json') testEl.setAttribute('tension', '60') testEl.classList.add('eic') this.Invade(this, testEl) node.classList.remove('scaler') }) } Invade(oldEl, newEl){ const r = oldEl.getBoundingClientRect() oldEl.style.visibility = 'hidden' newEl.style.position = 'fixed' newEl.style.left = r.left + 'px' newEl.style.top = r.top + 'px' newEl.style.width = r.width + 'px' newEl.style.height = r.height + 'px' oldEl.parentNode.appendChild(newEl) } Evade(oldEl, newEl){ oldEl.style.visibility = 'visible' oldEl.style.position = 'absolute' oldEl.style.left = '0' oldEl.style.top = '0' oldEl.style.width = '0' oldEl.style.height = '0' newEl.parentNode.removeChild(newEl) } addFakeNode(nid, x, y, w, h){ this.stagedNodes[nid] = document.createElement('div') this.stagedNodes[nid].classList.add('bzgf-fake-node') this.stagedNodes[nid].style.left = `${x}px` this.stagedNodes[nid].style.top = `${y}px` this.stagedNodes[nid].style.width = `${w}px` this.stagedNodes[nid].style.height = `${h}px` this.stagedNodes[nid].dataset.id = nid this.nodesContainer.append(this.stagedNodes[nid]) return(this.stagedNodes[nid]) } addWire(link){ const [idNode1, idPort1] = link.from const [idNode2, idPort2] = link.to const path = this.bezierNodes(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.arrowDefs && link.endArrow) this.stagedWires[id].setAttribute('marker-end','url(#arrow)') if(this.arrowDefs && 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]) } clear(){ this.nodesContainer.innerHTML = '' this.wiresContainer.innerHTML = '' if(this.arrowDefs) this.wiresContainer.appendChild(this.arrowDefs) } refresh(){ this.clear() let forceAutoplace = false for(const node of this.flow.nodes){ if((!node.coords) || (!node.coords.x) ||(!node.coords.y)) forceAutoplace=true this.addNode(node) } for(const link of this.flow.links){ this.addWire(link) } if(forceAutoplace){ const bb=this.getBoundingClientRect() //TODO compute tensions from ports if(bb.width > bb.height) this.autoPlace('horizontal', 80, 30, 500) else this.autoPlace('vertical', 80, 30, 200) } } bezierNodes(idNode1, idPort1, idNode2, idPort2, tensionMin=60) { 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('Link 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 loop = (idNode1==idNode2) && (idPort1==idPort2) const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1) let tension = dist * 0.4 if(tension < tensionMin) tension = parseInt(tensionMin) let c1x = Math.floor(x1 + (this.dirVect[port1.direction].x * tension)) let c1y = Math.floor(y1 + (this.dirVect[port1.direction].y * tension)) let c2x = Math.floor(x2 + (this.dirVect[port2.direction].x * tension)) let c2y = Math.floor(y2 + (this.dirVect[port2.direction].y * tension)) if(loop){ if(['n', 's'].includes(port1.direction)) { c1x += tension c2x -= tension } if(['e', 'w'].includes(port1.direction)){ c1y += 1*tension c2y -= 1*tension } } return(`M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`) } bezierInterNodes(idNode1, idPort1, idNode2, idPort2, interNodes, orientation='horizontal', tensionMin=60) { const svgRect = this.wiresContainer.getBoundingClientRect() const node1 = this.stagedNodes[idNode1] let port1 = node1.ports[idPort1] const makeCubicBezier = (x1, y1, x2, y2, orientation1, orientation2) => { const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1) let tension = dist * 0.4 // if(tension < tensionMin) tension = parseInt(tensionMin) let c1x, c1y, c2x, c2y if(orientation1=='horizontal'){ c1x = Math.floor(x1 + tension) c1y = y1 } else { c1x = x1 c1y = Math.floor(y1 + tension) } if(orientation2=='horizontal'){ c2x = Math.floor(x2 - tension) c2y = y2 } else { c2x = x2 c2y = Math.floor(y2 - tension) } return(`C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`) } const directPath = this.bezierNodes(idNode1, idPort1, idNode2, idPort2, tensionMin) const startPath = directPath.substring(0,directPath.indexOf('C')) const endPath = directPath.substring(directPath.lastIndexOf(',')+1).trim() let path = startPath let [ , x1, y1] = startPath.split(' ') x1 = parseInt(x1) y1 = parseInt(y1) for(const interNode of interNodes){ const bb = this.stagedNodes[interNode].getBoundingClientRect() let x2; let y2; if(orientation=='horizontal'){ x2 = bb.x -svgRect.left y2 =Math.floor(bb.y + (bb.height/2)) - svgRect.top } else { x2 = Math.floor(bb.x + (bb.width/2)) - svgRect.left y2 = bb.y - svgRect.top } if(port1){ path += makeCubicBezier(x1, y1, x2, y2, ['w','e'].includes(port1.direction) ? 'horizontal' : 'vertical', orientation) port1 = false } else { path += makeCubicBezier(x1, y1, x2, y2, orientation, orientation) } if(orientation=='horizontal'){ x2 += bb.width path += ` L ${x2} ${y2} ` } else { y2 += bb.height path += ` L ${x2} ${y2} ` } x1 = x2 y1 = y2 } let [x2, y2] = endPath.split(' ') x2 = parseInt(x2) y2 = parseInt(y2) path += ' '+makeCubicBezier(x1, y1, x2, y2, orientation, orientation) return(path) } autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=1000){ // Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them ! let linksWithoutBackEdges if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links) linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) } else { linksWithoutBackEdges = this.flow.links } const { parents, adj } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges) const layers = this.computeLayers(this.flow.nodes, parents) // 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 const layerHeights = []; const layerWidths = []; const indexes = {} // indexes[nid] = { base: , ports: { [portId]: } } 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] = { base: idx, ports: this.computePortOffsets(nid, orientation) } } if(totHeight>maxHeight) maxHeight = totHeight layerHeights.push(totHeight) if(totWidth>maxWidth) maxWidth = totWidth layerWidths.push(totWidth) } // If any long-links, create placeholders for skipped layers this.flow.longLinks = this.findLongLinks(this.flow.links) for(const link of this.flow.longLinks){ for(const layerIdx of link.skippedLayers){ const nid = `longLinkPlaceHolder_${crypto.randomUUID()}` layers[layerIdx].push(nid) link.interNodes.push(nid) } } // Reorder layers to avoid crossings thanks to indexes this.reorderLayers(layers, parents, indexes, orientation) // Finally place everything if(orientation=='horizontal'){ let x = gapx for(const [idx, layer] of layers.entries()){ let wMax = this.getMaxWidth(layer) let y = ((maxHeight - layerHeights[idx]) / 2) + gapy for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')) { const bb = this.stagedNodes[nid].getBoundingClientRect() this.moveNode(nid, x, y, orientation, tween) y += gapy + bb.height } else { this.addFakeNode(nid, x, y, wMax*0.75, 10) this.moveNode(nid, x, y, orientation, tween) y += gapy + 10 //TODO } } x += wMax + gapx } } else if(orientation=='vertical'){ let y = gapy for(const [idx, layer] of layers.entries()){ let hMax = this.getMaxHeight(layer) let x = ((maxWidth - layerWidths[idx]) / 2) + gapx for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')){ const bb = this.stagedNodes[nid].getBoundingClientRect() this.moveNode(nid, x, y, orientation, tween) x += gapx + bb.width } else { this.addFakeNode(nid, x, y, 10, hMax*0.75) this.moveNode(nid, x, y, orientation, tween) x += gapx + 10 //TODO } } y += hMax + gapy } } } getMaxWidth(layer){ return(layer.filter(nid => !nid.startsWith('longLinkPlaceHolder_')) .map(nid => this.stagedNodes[nid].getBoundingClientRect().width) .reduce((a, b) => a > b ? a : b, 0) ) } getMaxHeight(layer){ return(layer.filter(nid => !nid.startsWith('longLinkPlaceHolder_')) .map(nid => this.stagedNodes[nid].getBoundingClientRect().height) .reduce((a, b) => a > b ? a : b, 0) ) } computePortOffsets(nid, orientation = 'horizontal'){ const node = this.stagedNodes[nid] if(!node || !node.ports) return({}) const axis = (orientation === 'vertical') ? 'x' : 'y' const nodeRect = node.getBoundingClientRect() const ports = Object.entries(node.ports) .map(([pid, p]) => { const r = p.el.getBoundingClientRect() const pos = (axis === 'x') ? (r.left + (r.width / 2) - nodeRect.left) : (r.top + (r.height / 2) - nodeRect.top) return({ pid, pos }) }) .sort((a, b) => a.pos - b.pos) // smaller pos => "higher/left" => smaller offset const denom = ports.length + 1 const offsets = {} for(const [rank, item] of ports.entries()){ offsets[item.pid] = rank / denom // always < 1 } return(offsets) } reorderLayers(layers, parents, indexes, orientation = 'horizontal'){ const swap = (vect, todo) => { for(const s of todo){ [vect[s[0]], vect[s[1]]] = [vect[s[1]], vect[s[0]]] } } const adjIndex = (nid, portId) => { const info = indexes?.[nid] const base = (info && info.base !== undefined) ? info.base : 0 const off = (portId && info?.ports?.[portId] !== undefined) ? info.ports[portId] : 0 return(base + off) } 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]) if(((p1 - p2) * (c1 - c2)) < 0) { // crossing (now refined by per-port ordering) toSwap.push([i, j]) } } } swap(layer, toSwap) } } moveNode(nid, destx, desty, orientation, duration = 200, cb) { const t0 = performance.now() const bb = this.stagedNodes[nid].getBoundingClientRect() const parentbb = this.stagedNodes[nid].parentElement.getBoundingClientRect() const x0=bb.x - parentbb.x const y0 = bb.y - parentbb.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, orientation) if(p < 1) requestAnimationFrame(frame.bind(this)) } requestAnimationFrame(frame.bind(this)) } updateWires(nid, orientation){ 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 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')) wire.setAttribute('d', path) } else { const path = this.bezierNodes(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)))) } buildGraphStructures(nodes, links, includeLinkIndexes = false) { const parents = {} const adj = {} nodes.forEach(n => { parents[n.id] = [] adj[n.id] = [] }) links.forEach((link, idx) => { const from = link.from[0] const to = link.to[0] if(link.from[0] !== link.to[0]) { // Skip self-loops parents[to].push(from) if(includeLinkIndexes) { adj[from].push({ to, linkIdx: idx }) } else { adj[from].push(to) } } }) return({ parents, adj }) } 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) { if(links.some(l => l.from[0] === l.to[0])) return(true) // self-loops const { adj } = this.buildGraphStructures(nodes, links) 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(nodes.map(n => n.id).some(dfs)) } findBackEdges(nodes, links) { const { adj } = this.buildGraphStructures(nodes, links, true) const color = {}; nodes.forEach(n => color[n.id] = 'white') const backEdges = [] function dfs(u) { color[u] = 'gray' for (const neighbor of adj[u]) { const v = neighbor.to const linkIdx = neighbor.linkIdx if (color[v] === 'gray') { backEdges.push(linkIdx) // 👈 cycle edge - return link index } else if (color[v] === 'white') { dfs(v) } } color[u] = 'black' } nodes.forEach(n => { if (color[n.id] === 'white') dfs(n.id) }) return(backEdges) } findLongLinks(links) { let linksWithoutBackEdges if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links) linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) } else { linksWithoutBackEdges = this.flow.links } /// Yes that means we ignore long & back links ! const { parents } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges) const layers = this.computeLayers(this.flow.nodes, parents) const crossLayerLinks = [] for(const link of links){ const from = link.from[0] const to = link.to[0] const idx1 = layers.findIndex(layer => layer.includes(from)) const idx2 = layers.findIndex(layer => layer.includes(to)) if(Math.abs(idx1-idx2)>1) { const lowerIdx = idx1idx2 ? idx1 : idx2 crossLayerLinks.push({ link, linkIdx: link.linkIdx, interNodes: [], skippedLayers: Array.from({ length: higherIdx - lowerIdx - 1 }, (_, i) => lowerIdx + i + 1), }) } } return(crossLayerLinks) } } Buildoz.define('graflow', BZgraflow)