Files
buildoz/bzGraflow.js
2026-01-14 19:57:50 +00:00

499 lines
19 KiB
JavaScript

class BZgraflow extends Buildoz{
constructor(){
super()
this.defaultAttrs = { tension: 100 }
this.stagedNodes = { }
this.stagedWires = { }
}
static _loadedNodeStyles = new Set() // Allow multi instances or re-loadNodes, but avoid reinjecting same styles !
connectedCallback() {
super.connectedCallback()
const flowUrl = this.getBZAttribute('flow')
if(!flowUrl) {
console.warn('BZgraflow: No flow URL !?')
return
}
this.mainContainer = document.createElement('div')
this.mainContainer.classList.add('bzgf-main-container')
this.shadow = this.mainContainer.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = `
.bzgf-wires-container,
.bzgf-nodes-container{ position: absolute; inset: 0; width: 100%; height: 100%; }
.bzgf-nodes-container .bzgf-node{ position:absolute; }
`
this.shadow.appendChild(style)
this.nodesContainer = document.createElement('div')
this.nodesContainer.classList.add('bzgf-nodes-container')
this.wiresContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
this.wiresContainer.setAttribute('overflow','visible')
this.wiresContainer.classList.add('bzgf-wires-container')
this.shadow.append(this.wiresContainer)
this.shadow.append(this.nodesContainer)
this.append(this.mainContainer)
this.loadFlow(flowUrl) // Let it load async
}
error(msg, err){
this.innerHTML = `<div style="background:red;color:black;margin: auto;width: fit-content;">${msg}</div>`
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
if(this.hasAnyLoop()) {
console.warn('This flow has loops.Optimization not available')
console.log('===Loops:===>', this.getLoopingLinks())
}
this.refresh()
}
async loadNodes(url) {
const res = await fetch(url+'?'+crypto.randomUUID())
const html = await res.text()
// Get nodes
const doc = new DOMParser().parseFromString(html, 'text/html')
this.nodesRegistry = {}
for(const tpl of doc.querySelectorAll('template')){
if(tpl.id=='svg-arrows'){
this.wiresContainer.appendChild(tpl.querySelector('defs').cloneNode(true))
this.arrowDefined = true
} else {
const rootEl = tpl.content.querySelector('.bzgf-node')
if(!rootEl) continue
this.nodesRegistry[rootEl.dataset.nodetype] = rootEl
}
}
// Now load styles (once)
if(!BZgraflow._loadedNodeStyles.has(url)) {
const styles = doc.querySelectorAll('style')
styles.forEach(styleEl => {
const style = document.createElement('style')
style.textContent = styleEl.textContent
this.shadow.appendChild(style)
})
BZgraflow._loadedNodeStyles.add(url)
}
}
addNode(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 }])))
this.nodesContainer.append(this.stagedNodes[id])
return(this.stagedNodes[id])
}
addWire(link){
const [idNode1, idPort1] = link.from
const [idNode2, idPort2] = link.to
const path = this.bezier(idNode1, idPort1, idNode2, idPort2, this.getBZAttribute('tension'))
const id = `${idNode1}_${idNode2}`
this.stagedWires[id] = document.createElementNS('http://www.w3.org/2000/svg', 'path')
this.stagedWires[id].setAttribute('d', path)
this.stagedWires[id].setAttribute('fill', 'none')
if(this.arrowDefined && link.endArrow) this.stagedWires[id].setAttribute('marker-end','url(#arrow)')
if(this.arrowDefined && link.startArrow) this.stagedWires[id].setAttribute('marker-start','url(#arrow)')
this.stagedWires[id].classList.add('bzgf-wire')
this.stagedWires[id].dataset.id = id
this.wiresContainer.append(this.stagedWires[id])
return(this.stagedWires[id])
}
refresh(){
let forceAutoplace = false
for(const node of this.flow.nodes){
if((!node.coords) || (!node.coords.x) ||(!node.coords.y)) forceAutoplace=true
this.addNode(node)
}
for(const link of this.flow.links){
this.addWire(link)
}
if(forceAutoplace){
const bb=this.getBoundingClientRect()
//TODO compute tensions from ports
if(bb.width > bb.height) this.autoPlace('horizontal', 80, 30, 500)
else this.autoPlace('vertical', 80, 30, 200)
}
}
bezier(idNode1, idPort1, idNode2, idPort2, tensionMin=60) {
const svgRect = this.wiresContainer.getBoundingClientRect()
const node1 = this.stagedNodes[idNode1]
const port1 = node1.ports[idPort1]
const node2 = this.stagedNodes[idNode2]
const port2 = node2.ports[idPort2]
if(!node1 || !node2 || !port1 || !port2) {
console.warn('Link on bad node / port!', idNode1, idPort1, idNode2, idPort2)
return('')
}
const bb1 = port1.el.getBoundingClientRect()
const bb2 = port2.el.getBoundingClientRect()
const x1 = Math.floor(bb1.x + (bb1.width/2)) - svgRect.left
const y1 = Math.floor(bb1.y + (bb1.height/2)) - svgRect.top
const x2 = Math.floor(bb2.x + (bb2.width/2)) - svgRect.left
const y2 = Math.floor(bb2.y + (bb2.height/2)) - svgRect.top
const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1)
let tension = dist * 0.4
if(tension < tensionMin) tension = tensionMin
const dirVect = {
n: { x: 0, y: -1 },
s: { x: 0, y: 1 },
e: { x: 1, y: 0 },
w: { x: -1, y: 0 },
}
let c1x = x1 + (dirVect[port1.direction].x * tension)
let c1y = y1 + (dirVect[port1.direction].y * tension)
let c2x = x2 + (dirVect[port2.direction].x * tension)
let c2y = y2 + (dirVect[port2.direction].y * tension)
if((idNode1==idNode2) && (idPort1==idPort2)){ // Special case of self-loop: correct intermediary points
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}`
}
autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=1000){
let linksWithoutBackEdges
if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){
console.warn('Loop(s) detected... Cannot auto-place !')
const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links)
console.log('===BackEdges:===>', backEdges)
linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0]))
} else {
linksWithoutBackEdges = this.flow.links
}
const parents = {}
const adj = {}
this.flow.nodes.forEach(n => {
parents[n.id] = []
adj[n.id] = []
})
linksWithoutBackEdges.forEach(link => {
const from = link.from[0]
const to = link.to[0]
parents[to].push(from)
adj[from].push(to)
})
const layers = this.computeLayers(this.flow.nodes, parents)
let maxHeight = 0; let maxWidth = 0
const layerHeights = []; const layerWidths = [];
const indexes = {} // Todo: a
for(const layer of layers){
let totHeight = 0; let totWidth = 0
for(const [idx, nid] of layer.entries()){
const bb = this.stagedNodes[nid].getBoundingClientRect()
totHeight += bb.height + gapy
totWidth += bb.width + gapx
indexes[nid] = idx
}
if(totHeight>maxHeight) maxHeight = totHeight
layerHeights.push(totHeight)
if(totWidth>maxWidth) maxWidth = totWidth
layerWidths.push(totWidth)
}
this.reorderLayers(layers, parents, indexes)
if(orientation=='horizontal'){
let x = gapx
for(const [idx, layer] of layers.entries()){
let wMax = 0
let y = ((maxHeight - layerHeights[idx]) / 2) + gapy
for(const nid of layer){
const bb = this.stagedNodes[nid].getBoundingClientRect()
wMax = (bb.width > wMax) ? bb.width : wMax
this.moveNode(nid, x, y, tween)
y += gapy + bb.height
}
x += wMax + gapx
}
} else if(orientation=='vertical'){
let y = gapy
for(const [idx, layer] of layers.entries()){
let hMax = 0
let x = ((maxWidth - layerWidths[idx]) / 2) + gapx
for(const nid of layer){
const bb = this.stagedNodes[nid].getBoundingClientRect()
hMax = (bb.height > hMax) ? bb.height : hMax
this.moveNode(nid, x, y, tween)
x += gapx + bb.width
}
y += hMax + gapy
console.log(y, hMax, gapy )
}
}
}
reorderLayers(layers, parents, indexes){
const swap = (vect, todo) => {
for(const s of todo){
[vect[s[0]], vect[s[1]]] = [vect[s[1]], vect[s[0]]]
}
}
for(const [lidx, layer] of layers.entries()){
if(lidx==0) continue
const toSwap = []
for(let i=0; i<layer.length; i++){
const nid1 = layer[i]
// some parents can be in far layers, but at least one is in the prev layer (by definition of layer)
const pnid1 = parents[nid1].find((nid => layers[lidx-1].includes(nid)))
for(let j=i+1; j<layer.length; j++){
const nid2 = layer[j]
const pnid2 = parents[nid2].find((nid => layers[lidx-1].includes(nid)))
if(((indexes[pnid1] - indexes[pnid2]) * (indexes[nid1] - indexes[nid2])) < 0) { // crossing !
toSwap.push([i, j])
}
}
}
swap(layer, toSwap)
}
}
moveNode(nid, destx, desty, duration = 200, cb) {
const t0 = performance.now()
const bb = this.stagedNodes[nid].getBoundingClientRect()
const 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)
if(p < 1) requestAnimationFrame(frame.bind(this))
}
requestAnimationFrame(frame.bind(this))
}
updateWires(nid){
const wires = Object.keys(this.stagedWires)
.filter(id => (id.startsWith(nid+'_')||id.endsWith('_'+nid)))
.map(id => this.stagedWires[id])
for(const wire of wires){
const [nid1, nid2] = wire.dataset.id.split('_')
const lnk = this.getLink(nid1, nid2)
const path = this.bezier(nid1, lnk.from[1], nid2, lnk.to[1], this.getBZAttribute('tension'))
wire.setAttribute('d', path)
}
}
getLink(nid1, nid2){
return(this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2))))
}
computeLayers(nodes, parents) {
const layer = {}
const dfs = (id) => {
if (layer[id] !== undefined) return(layer[id])
if (parents[id].length === 0) {
layer[id] = 0
} else {
layer[id] = 1 + Math.max(...parents[id].map(dfs))
}
return(layer[id])
}
nodes.forEach(n => dfs(n.id))
const t = []
for(const nid in layer) {
if(!t[layer[nid]]) t[layer[nid]]=[]
t[layer[nid]].push(nid)
}
return(t)
}
hasAnyLoop(nodes, links) {
// self-loops
if(this.flow.links.some(l => l.from[0] === l.to[0])) return(true)
// multi-node cycles
const adj = {};
this.flow.nodes.forEach(node => adj[node.id] = [])
this.flow.links.forEach(link => adj[link.from[0]].push(link.to[0]))
const visiting = new Set();
const visited = new Set()
const dfs = (nid) => {
if(visiting.has(nid)) {
return(true)
}
if(visited.has(nid)) return(false)
visiting.add(nid)
for(const m of adj[nid]) {
if(dfs(m)) {
return(true)
}
}
visiting.delete(nid)
visited.add(nid)
return(false)
}
return(this.flow.nodes.map(n => n.id).some(dfs))
}
getLoopingLinks(nodes, links) {
const loops = []
// Handle self-loops (links where from[0] === to[0])
for(let [idx, link] of this.flow.links.entries()){
if(link.from[0] === link.to[0]) {
loops.push([idx])
}
}
// Build adjacency list with link indexes
const adj = {};
this.flow.nodes.forEach(node => adj[node.id] = [])
for(let [idx, link] of this.flow.links.entries()){
if(link.from[0] !== link.to[0]) { // Skip self-loops, already handled
adj[link.from[0]].push({ to: link.to[0], linkIdx: idx })
}
}
// Track visited nodes globally to avoid revisiting completed subtrees
const visited = new Set()
// DFS to find cycles
const dfs = (nid, path, pathLinkIndexes) => {
// If already fully visited, skip
if(visited.has(nid)) return
// Add current node to path
path.push(nid)
// Explore neighbors
for(const neighbor of adj[nid]) {
// Check if this neighbor creates a cycle
const cycleStartIdx = path.indexOf(neighbor.to)
if(cycleStartIdx !== -1) {
// Found a cycle: from path[cycleStartIdx] to path[path.length-1] and back via this link
// The cycle links are: pathLinkIndexes[cycleStartIdx] to pathLinkIndexes[pathLinkIndexes.length-1], plus the current link
const cycleLinkIndexes = [...pathLinkIndexes.slice(cycleStartIdx), neighbor.linkIdx]
// Normalize: represent cycle as sorted array of link indexes for uniqueness
const normalized = [...cycleLinkIndexes].sort((a, b) => a - b)
// Check if we already have this cycle (avoid duplicates)
const alreadyFound = loops.some(loop => {
const loopNormalized = [...loop].sort((a, b) => a - b)
if(loopNormalized.length !== normalized.length) return false
return loopNormalized.every((idx, i) => idx === normalized[i])
})
if(!alreadyFound && cycleLinkIndexes.length > 0) {
loops.push(cycleLinkIndexes)
}
continue // Don't recurse into this neighbor
}
pathLinkIndexes.push(neighbor.linkIdx)
dfs(neighbor.to, path, pathLinkIndexes)
pathLinkIndexes.pop()
}
// Remove current node from path
path.pop()
// Mark as fully visited
visited.add(nid)
}
// Start DFS from each unvisited node
for(const node of this.flow.nodes) {
if(!visited.has(node.id)) {
dfs(node.id, [], [])
}
}
return loops
}
findBackEdges(nodes, links) {
// Build adjacency list with link indexes
const adj = {};
nodes.forEach(node => adj[node.id] = [])
for(let [idx, link] of links.entries()){
if(link.from[0] !== link.to[0]) { // Skip self-loops
adj[link.from[0]].push({ to: link.to[0], linkIdx: idx })
}
}
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
}
}
Buildoz.define('graflow', BZgraflow)