297 lines
11 KiB
JavaScript
297 lines
11 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.loadFlow(flowUrl) // Let it load async while we coat
|
|
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.classList.add('bzgf-wires-container')
|
|
this.append(this.wiresContainer)
|
|
this.append(this.nodesContainer)
|
|
}
|
|
|
|
async loadFlow(url){
|
|
const res = await fetch(url+'?'+crypto.randomUUID())
|
|
const buf = await res.text()
|
|
let flowObj
|
|
try{
|
|
flowObj = JSON.parse(buf)
|
|
} catch(err){
|
|
console.error('Could not parse flow JSON!?', err)
|
|
return
|
|
}
|
|
if(!flowObj.nodesFile){
|
|
console.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')
|
|
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')){
|
|
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
|
|
document.head.appendChild(style)
|
|
})
|
|
BZgraflow._loadedNodeStyles.add(url)
|
|
}
|
|
}
|
|
|
|
addNode(type, id, x, y){
|
|
const nodeDef = this.nodesRegistry[type]
|
|
this.stagedNodes[id] = nodeDef.cloneNode(true)
|
|
const portEls = this.stagedNodes[id].querySelectorAll('.port')
|
|
this.stagedNodes[id].style.left = `${x}px`
|
|
this.stagedNodes[id].style.top = `${y}px`
|
|
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(idNode1, idPort1, idNode2, idPort2){
|
|
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')
|
|
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 x = 0
|
|
let y = 0
|
|
for(const node of this.flow.nodes){
|
|
const nodeEl = this.addNode(node.nodeType, node.id , node.coords.x, node.coords.y)
|
|
}
|
|
for(const link of this.flow.links){
|
|
const [nodeId1, portId1] = link.from
|
|
const [nodeId2, portId2] = link.to
|
|
this.addWire(nodeId1, portId1, nodeId2, portId2)
|
|
}
|
|
}
|
|
|
|
bezier(idNode1, idPort1, idNode2, idPort2, tensionMin=60) {
|
|
const dirVect = {
|
|
n: { x: 0, y: -1 },
|
|
s: { x: 0, y: 1 },
|
|
e: { x: 1, y: 0 },
|
|
w: { x: -1, y: 0 },
|
|
}
|
|
const node1 = this.stagedNodes[idNode1]
|
|
const port1 = node1.ports[idPort1]
|
|
const node2 = this.stagedNodes[idNode2]
|
|
const port2 = node2.ports[idPort2]
|
|
const bb1 = port1.el.getBoundingClientRect()
|
|
const bb2 = port2.el.getBoundingClientRect()
|
|
const x1 = Math.floor(bb1.x + (bb1.width/2))
|
|
const y1 = Math.floor(bb1.y + (bb1.height/2))
|
|
const x2 = Math.floor(bb2.x + (bb2.width/2))
|
|
const y2 = Math.floor(bb2.y + (bb2.height/2))
|
|
const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1)
|
|
let tension = dist * 0.4
|
|
if(tension < tensionMin) tension = tensionMin
|
|
|
|
const c1x = x1 + (dirVect[port1.direction].x * tension)
|
|
const c1y = y1 + (dirVect[port1.direction].y * tension)
|
|
|
|
const c2x = x2 + (dirVect[port2.direction].x * tension)
|
|
const c2y = y2 + (dirVect[port2.direction].y * tension)
|
|
return `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`
|
|
}
|
|
|
|
autoPlace(orientation = 'horizontal', gapx = 80, gapy = 30){
|
|
if(!['horizontal', 'vertical'].includes(orientation)) return
|
|
|
|
const parents = {}
|
|
const adj = {}
|
|
|
|
this.flow.nodes.forEach(n => {
|
|
parents[n.id] = []
|
|
adj[n.id] = []
|
|
})
|
|
|
|
this.flow.links.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
|
|
const layerHeights = []
|
|
const indexes = {} // Todo: a
|
|
for(const layer of layers){
|
|
let totHeight = 0
|
|
for(const [idx, nid] of layer.entries()){
|
|
const bb = this.stagedNodes[nid].getBoundingClientRect()
|
|
totHeight += bb.height+gapy
|
|
indexes[nid] = idx
|
|
}
|
|
if(totHeight>maxHeight) maxHeight = totHeight
|
|
layerHeights.push(totHeight)
|
|
}
|
|
|
|
this.reorderLayers(layers, parents, indexes)
|
|
|
|
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, 1000)
|
|
y += gapy + bb.height
|
|
}
|
|
x += wMax + gapx
|
|
}
|
|
}
|
|
|
|
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 x0=bb.x
|
|
const y0 = bb.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))
|
|
}
|
|
|
|
}
|
|
Buildoz.define('graflow', BZgraflow)
|