/**
* _ ___ 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 },
}
btnIcons = {
zoomin:'M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM448 768h-128v-128h-128v-128h128v-128h128v128h128v128h-128z',
zoomout:'M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM320 380v384h128V448z',
}
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
this.currentOrientation = null
}
static _coreCssPromise = null
async getCoreCss(){
if(BZgraflow._coreCssPromise) return(await BZgraflow._coreCssPromise)
BZgraflow._coreCssPromise = (async() => {
const url = new URL('./buildoz.css', this.buildozUrl)
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 this.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()
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.fireEvent('domConnected', { graflow: this })
const flowUrl = this.getBZAttribute('flow')
if(flowUrl) this.loadFlow(flowUrl)
else this.initFlow()
}
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.fireEvent('flowLoaded', { url: url })
}
initFlow(){
this.flow = { nodes: [], links: [] }
}
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.fireEvent('nodesLoaded', { url: 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')
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('bz:graflow: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 = ''
childEl.style.overflow = 'auto'
this.fireEvent('subflowLoaded', { subflow: childEl })
}, { 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.fireEvent('subflowExited', { subflow: childEl })
}, { 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){
const [idNode1, idPort1] = link.from
const [idNode2, idPort2] = link.to
if(this.getLink(idNode1,idNode2)) {
console.warn('Current version of graflow does not allow multiple wires between same nodes',this.getLink(idNode1,idNode2),idNode1,idNode2)
return
}
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.stagedWires[id].link = link
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.stagedNodes = {}
this.stagedWires = {}
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.fireEvent('refreshed', { })
}
// 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()
let links = Object.values(this.stagedWires).map(w => w?.link).filter(Boolean)
links = links.length ? links : (this.flow?.links || [])
// Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them !
let linksWithoutBackEdges
if(this.hasAnyLoop(this.flow.nodes, links)){
const backEdges = this.findBackEdges(this.flow.nodes, links)
linksWithoutBackEdges = links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0]))
} else {
linksWithoutBackEdges = 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(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
}
console.log('======>',gapy, y)
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 n.id === nid)
if(flowNode) {
if(!flowNode.coords) flowNode.coords = {}
flowNode.coords.x = x
flowNode.coords.y = y
}
this.fireEvent('nodeMoved', { nid, x, y })
}
}
requestAnimationFrame(frame.bind(this))
}
updateWires(nid, orientation, LondLinkfix = false){
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)
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.fireEvent('wiresUpdated', { nid, orientation, LondLinkfix })
}
getLink(nid1, nid2){
const wire = this.stagedWires[`${nid1}_${nid2}`]
if(wire?.link) return wire.link
return this._virtualLinks?.get(`${nid1}__${nid2}`) ?? 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])
}
// 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, links)){
const backEdges = this.findBackEdges(this.flow.nodes, links)
linksWithoutBackEdges = links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0]))
} else {
linksWithoutBackEdges = 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.interactiveElementsSelector = `
.port,
input,
textarea,
select,
button,
a[href]
`
this._boundPointerDown = this.pointerDown.bind(this)
this._boundPointerMove = this.pointerMove.bind(this)
this._boundPointerUp = this.pointerUp.bind(this)
this.graflow.addEventListener('bz:graflow: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._boundPointerDown)
)
this.nodesContainer.addEventListener('pointermove', this._boundPointerMove)
this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
item.addEventListener('pointerup', this._boundPointerUp)
)
}
disableMovingNodes(){
this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
item.removeEventListener('pointerdown', this._boundPointerDown)
)
this.nodesContainer.removeEventListener('pointermove', this._boundPointerMove)
this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item =>
item.removeEventListener('pointerup', this._boundPointerUp)
)
}
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()
const parentBB = this.nodesContainer.getBoundingClientRect()
const offsetX = rect.left - parentBB.left + this.nodesContainer.scrollLeft
const offsetY = rect.top - parentBB.top + this.nodesContainer.scrollTop
this.state = {
node,
handle,
startX: e.clientX,
startY: e.clientY,
offsetX,
offsetY
}
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
const { node, startX, startY, offsetX, offsetY } = this.state
const x = e.clientX - startX + offsetX
const y = e.clientY - startY + offsetY
node.releasePointerCapture(e.pointerId)
node.style.pointerEvents = ''
for(const n of this.graflow.flow.nodes){
if(n.id == node.dataset.id){
n.coords.x = x
n.coords.y = y
break
}
}
this.graflow.fireEvent('nodeMoved', { nodeId: node.dataset.id, x, y })
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._boundPointerMove = this.pointerMove.bind(this)
this.graflow.addEventListener('bz:graflow:refreshed', this.enableEditWires.bind(this))
this.graflow.addEventListener('bz:graflow:refreshed', this.enableSelectPorts.bind(this))
this.graflow.addEventListener('bz:graflow: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(){
this.currentlyHoveredPort = null
const portEls = this.graflow.nodesContainer.querySelectorAll('.port')
for(const port of portEls){
port.addEventListener('click', this.onSelectPort.bind(this))
port.addEventListener('pointerenter', this._onPortPointerEnter.bind(this))
port.addEventListener('pointerleave', this._onPortPointerLeave.bind(this))
port.classList.add('selectable')
}
}
_onPortPointerEnter(e){
this.currentlyHoveredPort = e.target.closest('.port')
}
_onPortPointerLeave(e){
if(this.currentlyHoveredPort === e.target.closest('.port')) this.currentlyHoveredPort = null
}
onSelectPort(e){
const port = e.target
if(this.currentlySelectedPort == port) {
this.currentlySelectedPort.style.removeProperty('border')
this.currentlySelectedPort = null
this.state = null
this._setWirecoatsPointerEvents('')
this.graflow.mainContainer.removeEventListener('pointermove', this._boundPointerMove)
if(this.tempwire) this.tempwire.remove()
return
}
if(this.currentlySelectedPort) {
this.tempwire.remove()
this.tempwire = null
this.makeWireBetweenPorts(this.currentlySelectedPort, port)
this.enableEditWires()
this.currentlySelectedPort.style.removeProperty('border')
this.currentlySelectedPort = null
this.state = null
this._setWirecoatsPointerEvents('')
this.graflow.mainContainer.removeEventListener('pointermove', this._boundPointerMove)
} else {
this.tension = parseInt(this.graflow.getBZAttribute('tension')) || 60
this.wireType = this.graflow.getBZAttribute('wiretype') || 'bezier'
this.currentlySelectedPort = port
port.style.setProperty('border', '5px solid #FF0', 'important')
this.state = {
startX: e.clientX,
startY: e.clientY,
port
}
this.tempwire = document.createElementNS('http://www.w3.org/2000/svg', 'path')
this.tempwire.setAttribute('fill', 'none')
this.tempwire.style.pointerEvents = 'none'
this.graflow.wiresContainer.appendChild(this.tempwire)
this.tempwire.classList.add('bzgf-wire')
this._setWirecoatsPointerEvents('none')
this.graflow.mainContainer.addEventListener('pointermove', this._boundPointerMove)
}
}
_setWirecoatsPointerEvents(value){
this.graflow.wiresContainer.querySelectorAll('.bzgf-wirecoat').forEach(el => { el.style.pointerEvents = value })
}
pointerMove(e){
if(!this.state) return
const { port } = this.state
const bb = port.getBoundingClientRect()
const p1 = this.graflow.clientToSvg(bb.x + bb.width / 2, bb.y + bb.height / 2)
const p2 = this.graflow.clientToSvg(e.clientX, e.clientY)
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 dir1 = port.dataset.direction
const oppositeDir = { n: 's', s: 'n', e: 'w', w: 'e' }
const hovered = this.currentlyHoveredPort
const dir2 = (hovered && hovered !== port) ? hovered.dataset.direction : oppositeDir[dir1]
const c1x = x1 + this.tension * this.graflow.dirVect[dir1].x
const c1y = y1 + this.tension * this.graflow.dirVect[dir1].y
const c2x = x2 + this.tension * this.graflow.dirVect[dir2].x
const c2y = y2 + this.tension * this.graflow.dirVect[dir2].y
const node1 = port.closest('.bzgf-node')
const node2 = hovered?.closest('.bzgf-node') ?? { offsetWidth: 0, offsetHeight: 0 }
const seg = this.graflow.buildSegment(
x1, y1,
c1x, c1y,
c2x, c2y,
x2, y2,
this.wireType,
node1, node2,
dir1, dir2,
this.tension)
if(!seg) return
this.tempwire.setAttribute('d', `M ${x1} ${y1} ${seg}`)
}
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] })
this.graflow.fireEvent('wireAdded', { from: [idNode1, idPort1], to: [idNode2, idPort2], id: `${idNode1}_${idNode2}` })
}
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) {
const wireId = this.currentlySelectedWire.dataset.id
const linkToRemove = this.graflow.stagedWires[wireId]?.link
this.graflow.flow.links = this.graflow.flow.links.filter(link =>
linkToRemove ? link !== linkToRemove : (link.from[0] + '_' + link.to[0] !== wireId)
)
this.graflow.stagedWires[wireId]?.remove()
delete this.graflow.stagedWires[wireId]
this.currentlySelectedWire.remove()
this.currentlySelectedWire = null
this.graflow.fireEvent('wireRemoved', { wireId })
return
}
if(e.key == 'Escape') {
if(this.currentlySelectedWire) {
this.currentlySelectedWire.style.setProperty('stroke', '#0000', 'important')
this.currentlySelectedWire = null
}
if(this.currentlySelectedPort) {
this.currentlySelectedPort.style.removeProperty('border')
this.currentlySelectedPort = null
}
if(this.tempwire) {
this.tempwire.remove()
this.tempwire = null
this.graflow.mainContainer.removeEventListener('pointermove', this._boundPointerMove)
}
return
}
}
}
class DroppingNodes{
constructor(graflow){
this.graflow = graflow
this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container')
this.state = null
}
}