/**
* _ ___ Another
* / |/ (_)______ __ _____
* / / / __(_- // (_-<
* /_/|_/_/\__/___/\_, /___/
* /___/
* production !
*
* Licensed under the MIT License:
* This code is free to use and modify,
* as long as the copyright notice and license are kept.
*/
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
}
// 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
const style = document.createElement('style')
//TODO kick this wart somewhere under a carpet
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;
}
`
this.mainContainer.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.mainContainer.append(this.wiresContainer)
this.mainContainer.append(this.nodesContainer)
this.append(this.hostContainer)
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)
const isIsolated = this.hasAttribute('isolated')
if(isIsolated || !BZgraflow._loadedNodeStyles.has(url)) {
const styles = doc.querySelectorAll('style')
styles.forEach(styleEl => {
const style = document.createElement('style')
style.textContent = styleEl.textContent
this.mainContainer.appendChild(style)
})
// In non-isolated (light DOM) mode, styles apply globally so we can de-dupe across instances.
// In isolated (shadow DOM) mode, styles must be injected per instance.
if(!isIsolated) 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 btnEnterSubflow = document.createElement('button')
btnEnterSubflow.classList.add('bzgf-zoom-in', 'icon-copy')
btnEnterSubflow.addEventListener('click', () => {
this.EnterSubflow(id)
})
this.stagedNodes[id].appendChild(btnEnterSubflow)
}
this.nodesContainer.append(this.stagedNodes[id])
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
const childEl = document.createElement('bz-graflow')
childEl.setAttribute('flow', flowUrl)
childEl.setAttribute('tension', this.getBZAttribute('tension') || '60')
// Match the clicked node's border so the transition feels like we're "expanding" it.
const nodeStyle = getComputedStyle(nodeEl)
childEl.style.border = nodeStyle.border
childEl.style.borderRadius = nodeStyle.borderRadius
// Put the child in the exact same viewport rect as the parent (fixed overlay)
this.Invade(this, childEl)
// 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
const tx0 = nodeBB.left - parentBB.left
const ty0 = nodeBB.top - parentBB.top
// Inline "scaler" (shadow styles don't apply to the child element)
childEl.style.transformOrigin = 'top left'
childEl.style.willChange = 'transform'
childEl.style.transition = 'transform 1000ms ease-in-out'
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()
requestAnimationFrame(() => {
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'
}, { once:true })
}
Invade(oldEl, newEl){
const r = oldEl.getBoundingClientRect()
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'
newEl.style.display = 'block'
oldEl.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, height and width
if(bb.width > bb.height) this.autoPlace('horizontal', 60, 60)
else this.autoPlace('vertical', 60, 60)
}
}
// 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 })
}
bezierNodes(idNode1, idPort1, idNode2, idPort2, tension=60) {
tension = parseInt(tension)
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 p1 = this.clientToSvg(bb1.x + (bb1.width/2), bb1.y + (bb1.height/2))
const p2 = this.clientToSvg(bb2.x + (bb2.width/2), bb2.y + (bb2.height/2))
const x1 = Math.floor(p1.x)
const y1 = Math.floor(p1.y)
const x2 = Math.floor(p2.x)
const y2 = Math.floor(p2.y)
const loop = (idNode1==idNode2) && (idPort1==idPort2)
const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1)
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', tension=60) {
tension = parseInt(tension)
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 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, tension)
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 = parseFloat(x1)
y1 = parseFloat(y1)
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(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)
}
const x3 = Math.floor(exit.x)
const y3 = Math.floor(exit.y)
path += ` L ${x3} ${y3} `
x1 = x3
y1 = y3
}
let [x2, y2] = endPath.split(' ')
x2 = parseFloat(x2)
y2 = parseFloat(y2)
path += ' '+makeCubicBezier(x1, y1, x2, y2, orientation, orientation)
return(path)
}
autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=500, align='center'){
// 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()){
// 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
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 'auto':
//TODO
y = ((maxHeight - layerHeights[idx]) / 2) + gapy
break
}
for(const nid of layer){
if(!nid.startsWith('longLinkPlaceHolder_')) {
const bb = this.stagedNodes[nid].getBoundingClientRect()
this.moveNode(nid, x, y, orientation, tween)
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)
y += gapy + fakeNodeHeight
}
}
x += wMax + gapx
}
} else if(orientation=='vertical'){
const fakeNodeWidth = 10
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 + (this.stagedNodes[nid].offsetWidth || bb.width)
} else {
this.addFakeNode(nid, x, y, fakeNodeWidth, hMax*0.75)
this.moveNode(nid, x, y, orientation, tween)
x += gapx + fakeNodeWidth
}
}
y += hMax + gapy
}
}
}
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(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)
// 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
}
}
}
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){
const real = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2)))
if(real) return(real)
const v = this._virtualLinks?.get(`${nid1}__${nid2}`)
if(v) return(v)
return(null)
}
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)