/** * _ ___ Another * / |/ (_)______ __ _____ * / / / __(_- { const url = new URL('./buildoz.css', scriptUrl) const res = await fetch(url) const css = await res.text() const m = css.match(/\/\*\s*BZGRAFLOW_CORE_START\s*\*\/([\s\S]*?)\/\*\s*BZGRAFLOW_CORE_END\s*\*\//) const core = m ? m[1] : '' return(core) })() return(await BZgraflow._coreCssPromise) } async ensureIsolatedCoreStyles(){ if(!this.hasAttribute('isolated')) return if(this._isolatedCoreInjected) return this._isolatedCoreInjected = true const core = await BZgraflow.getCoreCss() // Convert light-dom selectors (`bz-graflow ...`) to shadow-dom selectors (`:host ...`) const shadowCss = core.replaceAll('bz-graflow', ':host') const style = document.createElement('style') style.textContent = shadowCss this.mainContainer.appendChild(style) } addIcon(el, name) { el.innerHTML = `` } connectedCallback() { super.connectedCallback() const flowUrl = this.getBZAttribute('flow') if(!flowUrl) return // Be tolerant: maybe injected later from JS above // If attribute "isolated" is present, render inside a shadow root. // Otherwise, render in light DOM (no shadow DOM). this.hostContainer = document.createElement('div') this.hostContainer.classList.add('bzgf-main-container') this.mainContainer = this.hasAttribute('isolated') ? this.hostContainer.attachShadow({ mode: 'open' }) : this.hostContainer this.ensureIsolatedCoreStyles() 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.mainContainer.append(this.wiresContainer) this.mainContainer.append(this.nodesContainer) this.append(this.hostContainer) if(this.getBZAttribute('edit')){ const edit = this.getBZAttribute('edit').split(',') if(edit.includes('nodesmove')){ this.nodesMover = new MovingNodes(this, '.bzgf-node') } if(edit.includes('editwires')){ this.WiresEditor = new EditWires(this, '.bzgf-wire') } if(edit.includes('dropnodes')){ this.NodesReceiver = new DroppingNodes(this, '.bzgf-node') } } this.loadFlow(flowUrl) } 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() this.dispatchEvent(new CustomEvent('flowLoaded', { detail: { url }, bubbles: true, composed: true, })) } 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 const isIsolated = this.hasAttribute('isolated') const styles = doc.querySelectorAll('style') if(isIsolated) { // Shadow DOM: styles are per-instance styles.forEach(styleEl => { const style = document.createElement('style') style.textContent = styleEl.textContent this.mainContainer.appendChild(style) }) } else { // Light DOM: inject into document.head once per nodesFile url if(!BZgraflow._loadedNodeStyles.has(url)) { styles.forEach(styleEl => { const style = document.createElement('style') style.textContent = styleEl.textContent style.dataset.bzgfNodesStyle = url document.head.appendChild(style) }) BZgraflow._loadedNodeStyles.add(url) } } this.dispatchEvent(new CustomEvent('nodesLoaded', { detail: { url }, bubbles: true, composed: true, })) } 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 btnEnterSubflow = document.createElement('button') btnEnterSubflow.classList.add('bzgf-zoom-in') this.addIcon(btnEnterSubflow, 'zoomin') btnEnterSubflow.addEventListener('click', () => { this.enterSubflow(id) }) this.stagedNodes[id].appendChild(btnEnterSubflow) } this.nodesContainer.append(this.stagedNodes[id]) if(!this.flow.nodes.find(n => n.id === id)) { this.flow.nodes.push(node) } return(this.stagedNodes[id]) } enterSubflow(id){ const nodeEl = this.stagedNodes[id] if(!nodeEl) return // Create the child graflow first, place it above (foreground) the current graflow, // scaled down so it fits exactly inside the clicked node, then animate it to full size. const nodeBB = nodeEl.getBoundingClientRect() const parentBB = this.getBoundingClientRect() const flowNode = this.flow?.nodes?.find(n => n.id === id) 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. childEl.dataset.enterNodeId = id const btnExitSubflow = document.createElement('button') btnExitSubflow.classList.add('bzgf-zoom-out') this.addIcon(btnExitSubflow, 'zoomout') btnExitSubflow.addEventListener('click', () => { this.exitSubflow(childEl) }) // Put the child in the exact same viewport rect as the parent this.invade(this, childEl) childEl.hostContainer.appendChild(btnExitSubflow) childEl.addEventListener('flowLoaded', (e) => { for(const portLink of flowNode.subflow.portLinks){ const nid = crypto.randomUUID() childEl.addNode({ "nodeType": portLink.refNodeType, "id": nid, "markup": { "parentport": portLink.parentPort } }) if(portLink.direction=='in') { childEl.addWire({ "from": [nid, portLink.refnodePort], "to": [portLink.subflowNode, portLink.subflowPort] }) } else if(portLink.direction=='out') { childEl.addWire({ "from": [portLink.subflowNode, portLink.subflowPort], "to": [nid, portLink.refnodePort] }) } } childEl.autoPlace(this.currentOrientation, 60, 60) }, { once:true }) // Fade out the current (host) graflow while the child scales up this.hostContainer.style.opacity = '1' this.hostContainer.style.transition = 'opacity 1000ms ease-in-out' // Initial transform so the full-size child "fits" inside the node const sx0 = nodeBB.width / parentBB.width const sy0 = nodeBB.height / parentBB.height // When the host graflow is scrollable, nodeBB is viewport-relative while the invading child // is positioned inside `this` (absolute/inset=0). Add scroll offsets to keep coordinates consistent. const tx0 = (nodeBB.left - parentBB.left) + (this.scrollLeft || 0) const ty0 = (nodeBB.top - parentBB.top) + (this.scrollTop || 0) // Inline "scaler" (shadow styles don't apply to the child element) childEl.style.border = 'none' childEl.style.transformOrigin = 'top left' childEl.style.willChange = 'transform' childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))' childEl.style.setProperty('--tx', tx0 + 'px') childEl.style.setProperty('--ty', ty0 + 'px') childEl.style.setProperty('--sx', sx0) childEl.style.setProperty('--sy', sy0) // Force style flush, then animate back to identity (full parent size) childEl.getBoundingClientRect() childEl.style.transition = 'transform 1000ms ease-in-out' requestAnimationFrame(() => { childEl.style.top = 0; childEl.style.left = 0; childEl.style.setProperty('--tx', '0px') childEl.style.setProperty('--ty', '0px') childEl.style.setProperty('--sx', 1) childEl.style.setProperty('--sy', 1) this.hostContainer.style.opacity = '0' }) childEl.addEventListener('transitionend', (e) => { if(e.propertyName !== 'transform') return this.hostContainer.style.visibility = 'hidden' childEl.style.transform = 'none' // Important for nested subflows to position correctly childEl.style.willChange = '' newEl.style.overflow = 'auto' this.dispatchEvent(new CustomEvent('subflowLoaded', { detail: { subflow: childEl }, bubbles: true, composed: true, })) }, { once:true }) } invade(oldEl, newEl){ newEl.style.position = 'absolute' const bbox = oldEl.getBoundingClientRect() newEl.style.left = `${bbox.left+bbox.width/2}px` newEl.style.top = `${bbox.top+bbox.height/2}px` newEl.style.width = `${bbox.width}px` newEl.style.height = `${bbox.height}px` newEl.style.display = 'block' newEl.style.overflow = 'hidden' oldEl.appendChild(newEl) } exitSubflow(childEl){ if(!childEl) return const enterNodeId = childEl.dataset?.enterNodeId const nodeEl = enterNodeId ? this.stagedNodes?.[enterNodeId] : null if(!nodeEl){ // Fallback: no context => just restore parent & remove child this.hostContainer.style.opacity = '1' this.hostContainer.style.visibility = 'visible' if(childEl.parentNode === this) this.removeChild(childEl) return } // Compute target transform from full-size back to node rect (inverse of EnterSubflow) const nodeBB = nodeEl.getBoundingClientRect() const parentBB = this.getBoundingClientRect() const sx0 = nodeBB.width / parentBB.width const sy0 = nodeBB.height / parentBB.height const tx0 = (nodeBB.left - parentBB.left) + (this.scrollLeft || 0) const ty0 = (nodeBB.top - parentBB.top) + (this.scrollTop || 0) // Try to match duration to the child's transform transition (default 1000ms) const transitionStr = childEl.style.transition || '' const msMatch = transitionStr.match(/(\d+(?:\.\d+)?)ms/) const durMs = msMatch ? parseFloat(msMatch[1]) : 1000 // Ensure parent is visible but faded-in during the shrink animation this.hostContainer.style.visibility = 'visible' this.hostContainer.style.opacity = '0' this.hostContainer.style.transition = `opacity ${durMs}ms ease-in-out` // Ensure child animates (it may have had transform cleared after enter) childEl.style.transformOrigin = 'top left' childEl.style.willChange = 'transform' childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))' childEl.style.setProperty('--tx', '0px') childEl.style.setProperty('--ty', '0px') childEl.style.setProperty('--sx', 1) childEl.style.setProperty('--sy', 1) childEl.style.transition = `transform ${durMs}ms ease-in-out` childEl.getBoundingClientRect() // flush requestAnimationFrame(() => { // Shrink/move the child back into the original node childEl.style.setProperty('--tx', tx0 + 'px') childEl.style.setProperty('--ty', ty0 + 'px') childEl.style.setProperty('--sx', sx0) childEl.style.setProperty('--sy', sy0) // Fade the parent back in this.hostContainer.style.opacity = '1' }) childEl.addEventListener('transitionend', (e) => { if(e.propertyName !== 'transform') return if(childEl.parentNode === this) this.removeChild(childEl) // Cleanup: ensure parent is fully visible and no longer hidden this.hostContainer.style.opacity = '1' this.hostContainer.style.visibility = 'visible' childEl.style.willChange = '' this.dispatchEvent(new CustomEvent('subflowExited', { detail: { subflow: childEl }, bubbles: true, composed: true, })) }, { once:true }) } 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){ console.log('addWire', link) const [idNode1, idPort1] = link.from const [idNode2, idPort2] = link.to const path = this.linkNodes(idNode1, idPort1, idNode2, idPort2) 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]) if(!this.flow.links.find(l => l.from[0] === idNode1 && l.from[1] === idPort1 && l.to[0] === idNode2 && l.to[1] === idPort2)) { this.flow.links.push(link) } 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(!this.currentOrientation) { const bb=this.getBoundingClientRect() if(bb.width > bb.height) this.currentOrientation = 'horizontal' else this.currentOrientation = 'vertical' } if(forceAutoplace) this.autoPlace(this.currentOrientation) this.dispatchEvent(new CustomEvent('refreshed', { detail: { }, bubbles: true, composed: true, })) } // Convert viewport (client) coordinates to this instance's SVG local coordinates. // Required when the whole graflow is CSS-transformed (scale/translate), otherwise wire paths // will be computed in the wrong coordinate space. clientToSvg(x, y){ const svg = this.wiresContainer const ctm = svg?.getScreenCTM?.() if(ctm && ctm.inverse){ const inv = ctm.inverse() if(svg?.createSVGPoint){ const pt = svg.createSVGPoint() pt.x = x pt.y = y const p = pt.matrixTransform(inv) return({ x: p.x, y: p.y }) } if(typeof DOMPoint !== 'undefined'){ const p = new DOMPoint(x, y).matrixTransform(inv) return({ x: p.x, y: p.y }) } } // Fallback: approximate using boundingClientRect (works only at scale=1) const r = svg.getBoundingClientRect() return({ x: x - r.left, y: y - r.top }) } buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, dir1, dir2, tension, loop=false){ if(loop) wireType = 'bezier' // loops only use bezier to look good const startAxis = ['n', 's'].includes(dir1) ? 'v' : 'h' if(wireType == 'bezier'){ return(`C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`) } if(wireType == 'straight'){ return(`L ${c1x} ${c1y} L ${c2x} ${c2y} L ${x2} ${y2}`) } if(wireType == 'ortho'){ const medianx = (x1 + x2) / 2 const mediany = (y1 + y2) / 2 if(startAxis == 'v') { if( ((dir1 == 's') && (c1y < mediany)) || ((dir1 == 'n') && (c1y > mediany)) ){ if( (dir2=='e') && (c2x > x1) || (dir2=='w') && (c2x < x1)) return(`V ${mediany} H ${c2x} V ${y2} H ${x2}`) else if((dir2=='e') || (dir2=='w')) return(`V ${y2} H ${x2}`) else if(dir2 == dir1) { // walk-around node const deviation = node2.offsetWidth / 2 if(x1>x2) { if(x1>x2+deviation+tension) return(`V ${c2y} H ${x2} V ${y2}`) else return(`V ${c1y} H ${x1+deviation+tension} V ${c2y} H ${x2} V ${y2}`) } else { if(x1 medianx)) ){ if( (dir2=='s') && (c2y > y1) || (dir2=='n') && (c2y < y1)) return(`H ${medianx} V ${c2y} H ${x2} V ${y2}`) else if((dir2=='n') || (dir2=='s')) return(`H ${x2} V ${y2}`) else if(dir2 == dir1) { // walk-around node const deviation = node2.offsetHeight / 2 if(y1>y2) { if(y1>y2+deviation+tension) return(`H ${c2x} V ${y2} H ${x2}`) else return(`H ${c1x} V ${y1+deviation+tension} H ${c2x} V ${y2} H ${x2}`) } else { if(y1 { const c1x = Math.floor(x1 + (this.dirVect[dir1].x * tension)) const c1y = Math.floor(y1 + (this.dirVect[dir1].y * tension)) const c2x = Math.floor(x2 + (this.dirVect[dir2].x * tension)) const c2y = Math.floor(y2 + (this.dirVect[dir2].y * tension)) return(this.buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, dir1, dir2, tension, false)) } // Start/end points in SVG coords (works for both bezier and line) const bb1 = port1.el.getBoundingClientRect() const bb2 = port2.el.getBoundingClientRect() const sp = this.clientToSvg(bb1.x + (bb1.width/2), bb1.y + (bb1.height/2)) const ep = this.clientToSvg(bb2.x + (bb2.width/2), bb2.y + (bb2.height/2)) let x1 = Math.floor(sp.x) let y1 = Math.floor(sp.y) const xEnd = Math.floor(ep.x) const yEnd = Math.floor(ep.y) let path = `M ${x1} ${y1} ` const entryDir = (orientation == 'horizontal') ? 'w' : 'n' const exitDir = (orientation == 'horizontal') ? 'e' : 's' let firstPort = port1 for(const interNode of interNodes){ const bb = this.stagedNodes[interNode].getBoundingClientRect() // Entry/exit points on the placeholder box, converted to SVG coords (handles CSS transforms) let entryClient; let exitClient if(orientation=='horizontal'){ entryClient = { x: bb.left, y: bb.top + (bb.height/2) } exitClient = { x: bb.right, y: bb.top + (bb.height/2) } } else { entryClient = { x: bb.left + (bb.width/2), y: bb.top } exitClient = { x: bb.left + (bb.width/2), y: bb.bottom } } const entry = this.clientToSvg(entryClient.x, entryClient.y) const exit = this.clientToSvg(exitClient.x, exitClient.y) let x2 = Math.floor(entry.x) let y2 = Math.floor(entry.y) if(firstPort){ path += makeSegment(x1, y1, x2, y2, node1, node2, firstPort.direction, entryDir, tension) firstPort = null } else { path += makeSegment(x1, y1, x2, y2, node1, node2, exitDir, entryDir, tension) } const x3 = Math.floor(exit.x) const y3 = Math.floor(exit.y) path += ` L ${x3} ${y3} ` x1 = x3 y1 = y3 } path += ' ' + makeSegment(x1, y1, xEnd, yEnd, node1, node2, exitDir, port2.direction, tension) return(path) } autoPlace(orientation = 'horizontal', gapx = null, gapy = null, tween = null, align = null){ if(gapx == null) gapx = parseInt(this.getBZAttribute('gapx')) || 80 if(gapy == null) gapy = parseInt(this.getBZAttribute('gapy')) || 80 if(tween == null) tween = parseInt(this.getBZAttribute('tween')) || 500 if(align == null) align = this.getBZAttribute('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. this.clearFakeNodes() // 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) // 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 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()){ // Use offset* (not impacted by CSS transforms) to keep autoPlace stable during zoom animations. const bb = this.stagedNodes[nid].getBoundingClientRect() const h = this.stagedNodes[nid].offsetHeight || bb.height const w = this.stagedNodes[nid].offsetWidth || bb.width totHeight += h + gapy totWidth += w + 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._virtualLinks = new Map() this.flow.longLinks = this.findLongLinks(this.flow.links) for(const llink of this.flow.longLinks){ let fakeParent = llink.link.from[0] for(const layerIdx of llink.skippedLayers){ const nid = `longLinkPlaceHolder_${crypto.randomUUID()}` layers[layerIdx].push(nid) llink.interNodes.push(nid) // Placeholders are added after initial index computation; give them an index // so reorderLayers() can take them into account (otherwise they default to base=0). indexes[nid] = { base: layers[layerIdx].length - 1, ports: {} } // Virtual link: treat placeholder as receiving the same "from port" as the original long-link. // (Child port doesn't matter for placeholders since they have no ports.) this._virtualLinks.set(`${fakeParent}__${nid}`, { from: [fakeParent, llink.link.from[1]], to: [nid, llink.link.to[1]], }) parents[nid] = [fakeParent] fakeParent = nid } } // Reorder layers to avoid crossings thanks to indexes this.reorderLayers(layers, parents, indexes, orientation) delete this._virtualLinks // Finally place everything if(orientation=='horizontal'){ const fakeNodeHeight = 10 const parentsY = {} const nodeY = {} const nodeX = {} let x = gapx for(const [idx, layer] of layers.entries()){ let wMax = this.getMaxWidth(layer) let y = 0 switch(align){ case 'center': y = ((maxHeight - layerHeights[idx]) / 2) + gapy break case 'first': y = gapy break case 'last': y = maxHeight - layerHeights[idx] + gapy break case 'parent': // y will be absolutely positioned by the parent(s) but have fallback for 1st layer y = ((maxHeight - layerHeights[idx]) / 2) + gapy break } for(const nid of layer){ let placedY if(!nid.startsWith('longLinkPlaceHolder_')) { const bb = this.stagedNodes[nid].getBoundingClientRect() const nodeHeight = this.stagedNodes[nid].offsetHeight || bb.height if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { y = Math.max(parentsY[parents[nid][0]], y) //TODO handle multiple parents with avg } placedY = y this.moveNode(nid, x, y, orientation, tween, null, token) if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { parentsY[parents[nid][0]] += gapy + nodeHeight } else { y += gapy + nodeHeight } y = Math.max(y, placedY + gapy + nodeHeight) } else { if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { y = Math.max(parentsY[parents[nid][0]], y) } placedY = y this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight) this.moveNode(nid, x, y, orientation, tween, null, token) // Never increment parentsY for fake nodes: they're placeholders and must not disalign real children y = Math.max(y, placedY + gapy + fakeNodeHeight) } parentsY[nid] = placedY nodeY[nid] = placedY nodeX[nid] = x } x += wMax + gapx } // Correct parent positions: when fake nodes pushed children down, align parents with their first real child if(align == 'parent'){ for(let idx = 1; idx < layers.length; idx++){ const layer = layers[idx] const prevLayer = layers[idx - 1] for(const pid of prevLayer){ if(pid.startsWith('longLinkPlaceHolder_')) continue const firstRealChild = layer.find(nid => !nid.startsWith('longLinkPlaceHolder_') && nid in parents && parents[nid][0] === pid ) if(firstRealChild && nodeY[pid] !== nodeY[firstRealChild]){ this.moveNode(pid, nodeX[pid], nodeY[firstRealChild], orientation, tween, null, token) nodeY[pid] = nodeY[firstRealChild] } } } } } else if(orientation=='vertical'){ const fakeNodeWidth = 10 let y = gapy for(const [idx, layer] of layers.entries()){ let hMax = this.getMaxHeight(layer) let x = 0 switch(align){ case 'center': x = ((maxWidth - layerWidths[idx]) / 2) + gapx break case 'first': x = gapx break case 'last': x = maxWidth - layerWidths[idx] + gapx break case 'parent': // x will be absolutely positioned by the parent(s) //TODO break } for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')){ const bb = this.stagedNodes[nid].getBoundingClientRect() 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, null, token) x += gapx + fakeNodeWidth } } y += hMax + gapy } } } clearFakeNodes(){ 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] } } getMaxWidth(layer){ return(layer.filter(nid => !nid.startsWith('longLinkPlaceHolder_')) // Use offsetWidth (not impacted by CSS transforms) to keep autoPlace stable during zoom animations. .map(nid => (this.stagedNodes[nid].offsetWidth || this.stagedNodes[nid].getBoundingClientRect().width)) .reduce((a, b) => a > b ? a : b, 0) ) } getMaxHeight(layer){ return(layer.filter(nid => !nid.startsWith('longLinkPlaceHolder_')) // Use offsetHeight (not impacted by CSS transforms) to keep autoPlace stable during zoom animations. .map(nid => (this.stagedNodes[nid].offsetHeight || 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 nodeRect = node.getBoundingClientRect() const ports = Object.entries(node.ports) .map(([pid, p]) => { const r = p.el.getBoundingClientRect() const pos = (orientation == 'vertical') ? (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 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 prevLayer = layers[lidx-1] // 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 (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) 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 && LondLinkfix) { const path = this.linkInterNodes(nid1, lnk.from[1], nid2, lnk.to[1], longLink.interNodes, orientation) wire.setAttribute('d', path) } else { const path = this.linkNodes(nid1, lnk.from[1], nid2, lnk.to[1]) wire.setAttribute('d', path) } } this.dispatchEvent(new CustomEvent('wiresUpdated', { detail: { nid, orientation, LondLinkfix }, bubbles: true, composed: true, })) } getLink(nid1, nid2){ let lnk = null lnk = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2))) if(!lnk) { lnk = this._virtualLinks?.get(`${nid1}__${nid2}`) } return(lnk) } 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]) } // 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 = [] nodes.forEach(n => { const l = layer[n.id] if(l === undefined) return if(!t[l]) t[l] = [] t[l].push(n.id) }) 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) } autofit(){ const parentBB = this.parentElement.getBoundingClientRect() // Use scroll dimensions for actual content extent (nodes can extend beyond element bounds) const contentW = Math.max(this.scrollWidth || this.offsetWidth || 1, 1) const contentH = Math.max(this.scrollHeight || this.offsetHeight || 1, 1) const sx = parentBB.width / contentW const sy = parentBB.height / contentH const scale = Math.min(sx, sy) // uniform scale to fit inside parent this.style.transformOrigin = 'top left' this.style.transform = `scale(${scale})` } } Buildoz.define('graflow', BZgraflow) class MovingNodes{ constructor(graflow, itemSelector, handleSelector = itemSelector){ this.graflow = graflow this.itemSelector = itemSelector this.handleSelector = handleSelector this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') this.state = null this.interactiveElementsSelector = ` .port, input, textarea, select, button, a[href] ` this.graflow.addEventListener('refreshed', this.enableMovingNodes.bind(this)) } enableMovingNodes() { if(!this._handleCursorStyle){ const style = document.createElement('style') const selector = `${this.handleSelector}:not(${this.interactiveElementsSelector.replace(/\s+/g, ' ').trim()})` style.textContent = `${selector}{ cursor: move }` this.nodesContainer.appendChild(style) this._handleCursorStyle = style } this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => item.addEventListener('pointerdown', this.pointerDown.bind(this)) ) this.nodesContainer.addEventListener('pointermove', this.pointerMove.bind(this)) this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => item.addEventListener('pointerup', this.pointerUp.bind(this)) ) } disableMovingNodes(){ this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => item.removeEventListener('pointerdown', this.pointerDown.bind(this)) ) this.nodesContainer.removeEventListener('pointermove', this.pointerMove.bind(this)) this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => item.removeEventListener('pointerup', this.pointerUp.bind(this)) ) } pointerDown(e){ this.graflow.clearFakeNodes() const node = e.target.closest(this.itemSelector) if(!node) return let handle if(this.handleSelector == this.itemSelector) { handle = node if(e.target.closest(this.interactiveElementsSelector)) return e.preventDefault() } else { // If defined handle, then no need to care about interactive elements handle = node.querySelector(this.handleSelector) if(e.target != handle) return } const rect = node.getBoundingClientRect() this.state = { node, handle, startX: e.clientX, startY: e.clientY, offsetX: rect.left, offsetY: rect.top } const x = e.clientX - this.state.startX + this.state.offsetX const y = e.clientY - this.state.startY + this.state.offsetY node.setPointerCapture(e.pointerId) node.style.position = 'absolute' node.style.left = `${x}px` node.style.top = `${y}px` node.style.margin = '0' node.style.zIndex = '9999' node.style.pointerEvents = 'none' } pointerMove(e){ if(!this.state) return const { node, startX, startY, offsetX, offsetY } = this.state const x = e.clientX - startX + offsetX const y = e.clientY - startY + offsetY node.style.left = `${x}px` node.style.top = `${y}px` this.graflow.updateWires(node.dataset.id, this.graflow.currentOrientation, false) } pointerUp(e){ if(!this.state) return this.state.node.releasePointerCapture(e.pointerId) this.state.node.style.pointerEvents = '' this.state = null } } class EditWires{ constructor(graflow){ this.graflow = graflow this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') this.state = null this.graflow.tabIndex = 0 // Make keyboard reactive this.graflow.addEventListener('refreshed', this.enableEditWires.bind(this)) this.graflow.addEventListener('refreshed', this.enableSelectPorts.bind(this)) this.graflow.addEventListener('wiresUpdated', this.enableEditWires.bind(this)) this.graflow.addEventListener('keyup', this.onKeyUp.bind(this)) } enableEditWires(){ this.graflow.wiresContainer.querySelectorAll('.bzgf-wirecoat').forEach(item => item.remove()) for(const ref in this.graflow.stagedWires ){ const clone = this.graflow.stagedWires[ref].cloneNode(true) clone.classList.add('bzgf-wirecoat') this.graflow.wiresContainer.appendChild(clone) clone.addEventListener('click', this.onSelectWire.bind(this)) if(clone.dataset.id == this.currentlySelectedWire?.dataset.id) this.onSelectWire({target: clone}) } } enableSelectPorts(){ const portEls = this.graflow.nodesContainer.querySelectorAll('.port') for(const port of portEls){ port.addEventListener('click', this.onSelectPort.bind(this)) port.classList.add('selectable') } } onSelectPort(e){ const port = e.target if(this.currentlySelectedPort == port) { this.currentlySelectedPort.style.removeProperty('border') this.currentlySelectedPort = null return } if(this.currentlySelectedPort) { this.makeWireBetweenPorts(this.currentlySelectedPort, port) this.enableEditWires() this.currentlySelectedPort.style.removeProperty('border') this.currentlySelectedPort = null } else { this.currentlySelectedPort = port port.style.setProperty('border', '5px solid #FF0', 'important') } } makeWireBetweenPorts(port1, port2){ const node1 = port1.closest('.bzgf-node') const node2 = port2.closest('.bzgf-node') const idNode1 = node1.dataset.id const idNode2 = node2.dataset.id const idPort1 = port1.dataset.id const idPort2 = port2.dataset.id if(!node1 || !node2 || !port1 || !port2) { console.warn('Link on bad node / port ', idNode1, idPort1, idNode2, idPort2) return('') } this.graflow.addWire({ from: [idNode1, idPort1], to: [idNode2, idPort2] }) } onSelectWire(e){ const wire = e.target if(this.currentlySelectedWire) this.currentlySelectedWire.style.removeProperty('stroke') //this.currentlySelectedWire.style.setProperty('stroke', '#0000', 'important') if(wire==this.currentlySelectedWire) { this.currentlySelectedWire = null return } this.currentlySelectedWire = wire wire.style.setProperty('stroke', '#FF0F', 'important') } onKeyUp(e){ if((e.key == 'Delete') && this.currentlySelectedWire) { this.graflow.flow.links = this.graflow.flow.links.filter(link => link.id != this.currentlySelectedWire.dataset.id) this.graflow.stagedWires[this.currentlySelectedWire.dataset.id].remove() delete(this.graflow.stagedWires[this.currentlySelectedWire.dataset.id]) this.currentlySelectedWire.remove() this.currentlySelectedWire = null return } if(e.key == 'Escape') { if(this.currentlySelectedWire) this.currentlySelectedWire.style.setProperty('stroke', '#0000', 'important') this.currentlySelectedWire = null return } } } class DroppingNodes{ constructor(graflow){ this.graflow = graflow this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') this.state = null } }