From 662e694f21f58509a691bd32c8a0e188c7918a92 Mon Sep 17 00:00:00 2001 From: STEINNI Date: Sun, 8 Mar 2026 13:44:15 +0000 Subject: [PATCH] remove embedded buildoz --- app/thirdparty/buildoz/buildoz.css | 250 ------ app/thirdparty/buildoz/buildoz.js | 343 ------- app/thirdparty/buildoz/bzGraflow.js | 1300 --------------------------- 3 files changed, 1893 deletions(-) delete mode 100644 app/thirdparty/buildoz/buildoz.css delete mode 100644 app/thirdparty/buildoz/buildoz.js delete mode 100644 app/thirdparty/buildoz/bzGraflow.js diff --git a/app/thirdparty/buildoz/buildoz.css b/app/thirdparty/buildoz/buildoz.css deleted file mode 100644 index 6f358e7..0000000 --- a/app/thirdparty/buildoz/buildoz.css +++ /dev/null @@ -1,250 +0,0 @@ -bz-select { - display: block; - margin: .5em 0 .5em 0; - position:relative; -} -bz-select[disabled] > button{ - opacity: 0.6; - cursor: not-allowed; - pointer-events: none; - filter: grayscale(30%); -} -bz-select > button{ - width:100%; - text-align: left; - font-family: sans; - font-size: .9em; - border-radius: 1em; - border: none; - padding: 0.2em .2em .3em .5em; - background: linear-gradient( to bottom, #555, #aaa 15%, #ccc 50%, #aaa 85%, #555 ); -} -bz-select > button::after { - content: "\00BB"; - transform: rotate(90deg); - position: absolute; - right: 0.5em; - top: 0; - pointer-events: none; - font-size: 1.5em; - color: #444; -} -bz-select > div.options-container{ - pointer-events: none; - position: absolute; - top: 1.8em; - left: 0; - width: 100%; - z-index: 99; - max-height: 0; - overflow: auto; - transition: max-height 0.4s ease; -} -bz-select > div.options-container.open{ pointer-events: auto; max-height: 10em;} -bz-select option{ - background-color: #DDD; - border: 1px solid black; - color: #000; - padding: 0.2em .2em .5em .5em; - margin: -1em 0 0 0; - border-radius: 1em; - height: 1em; - font-family: sans; - font-size: .9em; - opacity: 0; - pointer-events: none; - transition: - margin-top 0.3s ease, - opacity 0.3s ease; -} -bz-select option.open{ - margin: 0; - opacity: 1; - pointer-events: auto; -} -bz-select option:hover{ - background-color: #44F; - color: #FFF; -} - -/************************************************************************************/ - -bz-toggler{ - outline: none; - min-height: 1.5em; - padding: 0.75em; - display: inline-table; - max-width: fit-content; - transition: all 0.5s; -} -bz-toggler div.toggle-label-left{ - padding-right: 0.3em; - display: inline; - vertical-align: middle; - text-align: right; - font-size:.8em; -} -bz-toggler div.toggle-label-right{ - padding-left: 0.3em; - display: inline; - vertical-align: middle; - text-align: left; - font-size:.8em; -} -bz-toggler div.toggle-switch{ - user-select: none; - height: inherit; - cursor: pointer; - display: inline; - vertical-align: middle; - text-align: center; -} -bz-toggler div.toggle-switch span.toggle-bar { - display: inline-block; - position: relative; - background-color: #CCC; -} - -bz-toggler div.toggle-switch span.toggle-thumb { - display: inline-block; - border-radius: 50%; - background-color: white; - position: absolute; - z-index: 1; -} - -bz-toggler div.toggle-switch span.toggle-thumb:not(.turned-on) { - left: 0; - transition: all 0.4s; -} -bz-toggler div.toggle-switch span.toggle-thumb.turned-on { - transition: all 0.4s; -} -bz-toggler div.toggle-switch span.toggle-bar { - width: 2em; - height: 0.75em; - border-radius: .75em; -} - -bz-toggler div.toggle-switch span.toggle-thumb { - height: 1em; - width: 1em; - top: 50%; - transform: translateY(-50%); -} - -bz-toggler div.toggle-switch span.toggle-thumb.turned-on { - left : 1em; -} - -/************************************************************************************/ - -bz-slidepane { - display: block; - position: absolute; - background-color: #000A; -} -bz-slidepane[side="top"] { top:0; left:0; width: 100%; height:0; border-bottom: 2px solid #DDD; } -bz-slidepane[side="bottom"] { bottom:0; left:0; width: 100%; height:0; border-top: 2px solid #DDD;} -bz-slidepane[side="left"] { top:0; left:0; height:100%; width:0; border-right: 2px solid #DDD;} -bz-slidepane[side="right"] { top:0; right:0; height:100%; width:0; border-left: 2px solid #DDD; } -bz-slidepane[side="top"] div.handle { - position: absolute; - bottom: -12px; - left: 50%; - width: 40px; - height: 11px; - background: repeating-linear-gradient( to top, rgba(255,255,255,1) 0, rgba(255,255,255,1) 2px, rgba(0,0,0,0.2) 3px, rgba(0,0,0,0.2) 4px ); - transform: translateX(-50%); - cursor: ns-resize; -} -bz-slidepane[side="bottom"] div.handle { - position: absolute; - top: -12px; - left: 50%; - width: 40px; - height: 11px; - background: repeating-linear-gradient( to bottom, rgba(255,255,255,1) 0, rgba(255,255,255,1) 2px, rgba(0,0,0,0.2) 3px, rgba(0,0,0,0.2) 4px ); - transform: translateX(-50%); - cursor: ns-resize; -} -bz-slidepane[side="left"] div.handle { - position: absolute; - right: -12px; - top: 50%; - width: 11px; - height: 40px; - background: repeating-linear-gradient( to left, rgba(255,255,255,1) 0, rgba(255,255,255,1) 2px, rgba(0,0,0,0.2) 3px, rgba(0,0,0,0.2) 4px ); - transform: translateY(-50%); - cursor: ew-resize; -} -bz-slidepane[side="right"] div.handle { - position: absolute; - left: -12px; - top: 50%; - width: 11px; - height: 40px; - background: repeating-linear-gradient( to right, rgba(255,255,255,1) 0, rgba(255,255,255,1) 2px, rgba(0,0,0,0.2) 3px, rgba(0,0,0,0.2) 4px ); - transform: translateY(-50%); - cursor: ew-resize; -} - -/************************************************************************************/ - -bz-graflow { - position: relative; - display: block; - width: 100vw; - height: 50vh; - box-sizing: border-box; -} -bz-graflow .bzgf-main-container{ - width: 100%; - height: 100%; - position: relative; - box-sizing: border-box; -} - -/* BZGRAFLOW_CORE_START */ -/* Keep this commented section ! - bz-graflow internal layout rules (used in light DOM, and injected into shadow DOM when isolated) -*/ -bz-graflow .bzgf-wires-container, -bz-graflow .bzgf-nodes-container{ - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} -bz-graflow .bzgf-nodes-container{ /* used to keep the nodes container pointer-events: none, but allow the nodes to be moved ! */ - pointer-events: none; -} -bz-graflow .bzgf-nodes-container > * { /* allow the nodes to be moved ! */ - pointer-events: auto; - } - -bz-graflow .bzgf-nodes-container .bzgf-node{ position:absolute; } -bz-graflow .bzgf-nodes-container .bzgf-fake-node{ - position: absolute; - width: 5px; - height: 5px; - background: transparent; - border-style: none; -} -bz-graflow .bzgf-nodes-container button.bzgf-zoom-in{ - z-index: 999; - position: absolute; - top: -0.5em; - right: -1em; - color: black; - width: 2em; - height: 2em; - padding: 0; -} -bz-graflow button.bzgf-zoom-out{ - z-index: 999; - position: absolute; - left: 5px; - top: 5px; -} -/* BZGRAFLOW_CORE_END */ diff --git a/app/thirdparty/buildoz/buildoz.js b/app/thirdparty/buildoz/buildoz.js deleted file mode 100644 index 5718f0d..0000000 --- a/app/thirdparty/buildoz/buildoz.js +++ /dev/null @@ -1,343 +0,0 @@ -/** - * _ ___ Another - * / |/ (_)______ __ _____ - * / / / __(_-0) { - const opt = Array.from(this.options).find(opt => opt.value==v) - if(v || (opt && opt.textContent)) this.button.textContent = opt.textContent - else this.button.textContent = this.getBZAttribute('label') - this.dispatchEvent(new Event('change', { - bubbles: true, - composed: false, - cancelable: false - })) - } - } - - toggle(){ - for(const opt of this.options){ - if(this.open) { - opt.classList.remove('open') - this.optionscontainer.classList.remove('open') - } else { - document.querySelectorAll('bz-select').forEach((sel) => { - if((sel!==this) && sel.open) sel.toggle() - }) - opt.classList.add('open') - this.optionscontainer.classList.add('open') - } - } - this.open = !this.open - } - - onClick(evt){ - evt.stopPropagation() - const opt = evt.target.closest('option') - if(opt && opt.value) this.onOption(opt.value) - } - - onOption(value, silent=false){ - if(this.getAttribute('disabled') !== null) return - this.value = value - if(!silent) this.toggle() - } - - addOption(value, markup){ - // Caution: you cannot count on connectedCallback to have run already, because one might fill before adding to the DOM - const opt = document.createElement('option') - opt.setAttribute('value', value) - opt.innerHTML = markup - opt.addEventListener('click',this.onClick.bind(this)) - if(!this.optionscontainer) this.optionscontainer = document.createElement('div') - this.optionscontainer.append(opt) - this.options = this.querySelectorAll('option') - this.#fillFromMarkup = false - } - - fillOptions(opts, erase = true){ - // Caution: you cannot count on connectedCallback to have run already, because one might fill before adding to the DOM - if(erase){ - this.options = this.querySelectorAll('option') - this.options.forEach(node => { node.remove() }) - this.options = this.querySelectorAll('option') - this.onOption('', true) // unselect last - } - for(const opt of opts) this.addOption(opt.value, opt.markup) - } -} -Buildoz.define('select', BZselect) - - -class BZtoggler extends Buildoz { - #value - - constructor(){ - super() - this.open = false - this.defaultAttrs = { - labelLeft:'', - labelRight:'', - trueValue: 'yes', - falseValue: 'no', - classOn:'', - classOff:'', - tabindex:0, - disabled:false - } - } - - connectedCallback(){ - super.connectedCallback() - - this.labelRight = document.createElement('div') - this.labelRight.classList.add('toggle-label-right') - this.labelRight.innerHTML = this.getBZAttribute('labelRight') - - this.labelLeft = document.createElement('div') - this.labelLeft.classList.add('toggle-label-left') - this.labelLeft.innerHTML = this.getBZAttribute('labelLeft') - - this.switch = document.createElement('div') - this.switch.classList.add('toggle-switch') - - this.toggleBar = document.createElement('span') - this.toggleBar.classList.add('toggle-bar') - - this.thumb = document.createElement('span') - this.thumb.classList.add('toggle-thumb') - this.toggleBar.append(this.thumb) - - this.switch.append(this.toggleBar) - - this.appendChild(this.labelLeft) - this.appendChild(this.switch) - this.appendChild(this.labelRight) - this.setAttribute('tabindex', this.getBZAttribute('tabindex')) - - this.switch.addEventListener('click', this.toggle.bind(this)) - - } - - turnOn() { - if(this.getBZAttribute('classOff')) this.switch.classList.remove(this.getBZAttribute('classOff')) - if(this.getBZAttribute('classOn')) this.switch.classList.add(this.getBZAttribute('classOn')) - this.thumb.classList.add('turned-on') - this.#value = this.getBZAttribute('trueValue') - this.focus() - } - - turnOff() { - if(this.getBZAttribute('classOn')) this.switch.classList.remove(this.getBZAttribute('classOn')) - if(this.getBZAttribute('classOff'))this.switch.classList.add(this.getBZAttribute('classOff')) - this.thumb.classList.remove('turned-on') - this.#value = this.getBZAttribute('falseValue') - this.focus() - } - - toggle(event) { - if(event) { event.preventDefault(); event.stopPropagation(); } - if(this.getBZAttribute('disabled')) return - if(this.#value == this.getBZAttribute('trueValue')) this.turnOff() - else this.turnOn() - this.dispatchEvent(new Event('change', { - bubbles: true, - composed: false, - cancelable: false - })) - } - - get value(){ return(this.#value) } - - set value(v) { - this.#value = v - if(this.defaultAttrs){ - if(this.#value == this.getBZAttribute('trueValue')) this.turnOn() - else this.turnOff() - this.dispatchEvent(new Event('change', { - bubbles: true, - composed: false, - cancelable: false - })) - } - } -} -Buildoz.define('toggler', BZtoggler) - - - -class BZslidePane extends Buildoz { - - constructor(){ - super() - this.open = false - this.defaultAttrs = { - side: 'bottom' - } - this.dragMove = this.dragMove.bind(this) - this.dragEnd = this.dragEnd.bind(this) - // Fill with innerHTML or other DOM manip should not allow coating to be removed - this._observer = new MutationObserver(muts => { this.coat() }) - } - - connectedCallback(){ - super.connectedCallback() - this.coat() - this._observer.observe(this, { childList: true }) // Do this last - } - - disconnectedCallback() { - super.disconnectedCallback() - this._observer.disconnect() - } - - coat(){ - if(this.handle && this.querySelector(this.dispatchEvent.handle)) return - this._observer.disconnect() - if(this.querySelector(this.dispatchEvent.handle)) this.querySelector(this.dispatchEvent.handle).remove() - this.handle = document.createElement('div') - this.handle.classList.add('handle') - this.prepend(this.handle) - this.handle.addEventListener('pointerdown', this.dragStart.bind(this)) - this._observer.observe(this, { childList: true }) - } - - dragStart(evt){ - evt.target.setPointerCapture(evt.pointerId) - this.dragStartX = evt.clientX - this.dragStartY = evt.clientY - this.handle.addEventListener('pointermove', this.dragMove) - this.handle.addEventListener('pointerup', this.dragEnd) - } - - dragMove(evt){ - const box = this.getBoundingClientRect() - const parentBox = this.parentElement.getBoundingClientRect() - let width, height - switch(this.getAttribute('side')){ - case 'top': - height = (evt.clientY > box.top) ? (evt.clientY - box.top) : 0 - if(height>(parentBox.height/2)) height = Math.floor(parentBox.height/2) - this.style.height = height+'px' - break - case 'bottom': - height = (evt.clientY < box.bottom) ? (box.bottom - evt.clientY) : 0 - if(height>(parentBox.height/2)) height = Math.floor(parentBox.height/2) - this.style.height = height+'px' - break - case 'left': - width = (evt.clientX > box.left) ? (evt.clientX - box.left) : 0 - if(width>(parentBox.width/2)) width = Math.floor(parentBox.width/2) - this.style.width = width+'px' - break - case'right': - width = (evt.clientX < box.right) ? (box.right - evt.clientX) : 0 - if(width>(parentBox.width/2)) width = Math.floor(parentBox.width/2) - this.style.width = width+'px' - break - } - } - - dragEnd(evt){ - evt.target.releasePointerCapture(evt.pointerId) - this.handle.removeEventListener('pointermove', this.dragMove) - this.handle.removeEventListener('pointerup', this.dragEnd) - } -} -Buildoz.define('slidepane', BZslidePane) - diff --git a/app/thirdparty/buildoz/bzGraflow.js b/app/thirdparty/buildoz/bzGraflow.js deleted file mode 100644 index 3a48ff1..0000000 --- a/app/thirdparty/buildoz/bzGraflow.js +++ /dev/null @@ -1,1300 +0,0 @@ -/** - * _ ___ Another - * / |/ (_)______ __ _____ - * / / / __(_- { - const res = await fetch('/app/thirdparty/buildoz/buildoz.css') - const css = await res.text() - const m = css.match(/\/\*\s*BZGRAFLOW_CORE_START\s*\*\/([\s\S]*?)\/\*\s*BZGRAFLOW_CORE_END\s*\*\//) - const core = m ? m[1] : '' - return(core) - })() - return(await BZgraflow._coreCssPromise) - } - - async ensureIsolatedCoreStyles(){ - if(!this.hasAttribute('isolated')) return - if(this._isolatedCoreInjected) return - this._isolatedCoreInjected = true - const core = await BZgraflow.getCoreCss() - // Convert light-dom selectors (`bz-graflow ...`) to shadow-dom selectors (`:host ...`) - const shadowCss = core.replaceAll('bz-graflow', ':host') - const style = document.createElement('style') - style.textContent = shadowCss - this.mainContainer.appendChild(style) - } - - addIcon(el, name) { - el.innerHTML = `` - } - - connectedCallback() { - super.connectedCallback() - const flowUrl = this.getBZAttribute('flow') - if(!flowUrl) return // Be tolerant: maybe injected later from JS above - // If attribute "isolated" is present, render inside a shadow root. - // Otherwise, render in light DOM (no shadow DOM). - this.hostContainer = document.createElement('div') - this.hostContainer.classList.add('bzgf-main-container') - this.mainContainer = this.hasAttribute('isolated') - ? this.hostContainer.attachShadow({ mode: 'open' }) - : this.hostContainer - this.ensureIsolatedCoreStyles() - this.nodesContainer = document.createElement('div') - this.nodesContainer.classList.add('bzgf-nodes-container') - this.wiresContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - this.wiresContainer.setAttribute('overflow','visible') - this.wiresContainer.classList.add('bzgf-wires-container') - this.mainContainer.append(this.wiresContainer) - this.mainContainer.append(this.nodesContainer) - this.append(this.hostContainer) - this.loadFlow(flowUrl).then(() => { - if(this.getBZAttribute('edit')){ - const edit = this.getBZAttribute('edit').split(',') - if(edit.includes('nodesmove')){ - this.nodesMover = new MovingNodes(this) - this.nodesMover.enableMovingNodes('.bzgf-node') - } - if(edit.includes('wires')){ - this.WiresEditor = new EditWires(this) - this.WiresEditor.enableEditWires() - //this.WiresEditor.enableMovingNodes('.bzgf-wire') - } - if(edit.includes('dropnodes')){ - this.NodesReceiver = new DroppingNodes(this) - //this.NodesReceiver.enableDroppingNodes('.bzgf-node') - } - } - }) - } - - error(msg, err){ - this.innerHTML = `
${msg}
` - if(err) console.error(msg, err) - else console.error(msg) - } - - async loadFlow(url){ - const res = await fetch(url+'?'+crypto.randomUUID()) - const buf = await res.text() - let flowObj - try{ - flowObj = JSON.parse(buf) - } catch(err){ - this.error('Could not parse flow JSON!?', err) - - return - } - if(!flowObj.nodesFile){ - this.error('No nodesFile in JSON!?') - return - } - await this.loadNodes(flowObj.nodesFile) - this.flow = flowObj.flow - this.refresh() - this.dispatchEvent(new CustomEvent('flowLoaded', { - detail: { url }, - bubbles: true, - composed: true, - })) - } - - async loadNodes(url) { - const res = await fetch(url+'?'+crypto.randomUUID()) - const html = await res.text() - - // Get nodes - const doc = new DOMParser().parseFromString(html, 'text/html') - this.nodesRegistry = {} - for(const tpl of doc.querySelectorAll('template')){ - if(tpl.id=='svg-arrows'){ - this.arrowDefs = tpl.querySelector('defs').cloneNode(true) - this.wiresContainer.appendChild(this.arrowDefs) - } else { - const rootEl = tpl.content.querySelector('.bzgf-node') - if(!rootEl) continue - this.nodesRegistry[rootEl.dataset.nodetype] = rootEl - } - } - - // Now load styles - const isIsolated = this.hasAttribute('isolated') - const styles = doc.querySelectorAll('style') - if(isIsolated) { - // Shadow DOM: styles are per-instance - styles.forEach(styleEl => { - const style = document.createElement('style') - style.textContent = styleEl.textContent - this.mainContainer.appendChild(style) - }) - } else { - // Light DOM: inject into document.head once per nodesFile url - if(!BZgraflow._loadedNodeStyles.has(url)) { - styles.forEach(styleEl => { - const style = document.createElement('style') - style.textContent = styleEl.textContent - style.dataset.bzgfNodesStyle = url - document.head.appendChild(style) - }) - BZgraflow._loadedNodeStyles.add(url) - } - } - this.dispatchEvent(new CustomEvent('nodesLoaded', { - detail: { url }, - bubbles: true, - composed: true, - })) - } - - addNode(node){ - const id = node.id - if(!(node.nodeType in this.nodesRegistry)){ console.warn(`Unknown node type (${node.nodeType})`); return(null)} - const nodeDef = this.nodesRegistry[node.nodeType] - this.stagedNodes[id] = nodeDef.cloneNode(true) - for(const token in node.markup){ - this.stagedNodes[id].innerHTML = this.stagedNodes[id].innerHTML.replace(new RegExp(`\{${token}\}`, 'g'), node.markup[token]) - } - if(typeof(node.data)=='object') Object.assign(this.stagedNodes[id].dataset, node.data) - const portEls = this.stagedNodes[id].querySelectorAll('.port') - this.stagedNodes[id].style.left = (node.coords && node.coords.x) ? `${node.coords.x}px` : '10%' - this.stagedNodes[id].style.top = (node.coords && node.coords.y) ? `${node.coords.y}px` : '10%' - this.stagedNodes[id].dataset.id = id - this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }]))) - if(node.subflow) { - const btnEnterSubflow = document.createElement('button') - btnEnterSubflow.classList.add('bzgf-zoom-in') - this.addIcon(btnEnterSubflow, 'zoomin') - btnEnterSubflow.addEventListener('click', () => { - this.enterSubflow(id) - }) - this.stagedNodes[id].appendChild(btnEnterSubflow) - } - this.nodesContainer.append(this.stagedNodes[id]) - if(!this.flow.nodes.find(n => n.id === id)) { - this.flow.nodes.push(node) - } - return(this.stagedNodes[id]) - } - - enterSubflow(id){ - const nodeEl = this.stagedNodes[id] - if(!nodeEl) return - - // Create the child graflow first, place it above (foreground) the current graflow, - // scaled down so it fits exactly inside the clicked node, then animate it to full size. - const nodeBB = nodeEl.getBoundingClientRect() - const parentBB = this.getBoundingClientRect() - - const flowNode = this.flow?.nodes?.find(n => n.id === id) - const flowUrl = flowNode.subflow.url - - const childEl = document.createElement('bz-graflow') - childEl.isSubflow = true - childEl.currentOrientation = this.currentOrientation - childEl.setAttribute('flow', flowUrl) - childEl.setAttribute('tension', this.getBZAttribute('tension') || '60') - // Remember which node we "came from" so exitSubflow() can animate back to it. - childEl.dataset.enterNodeId = id - const btnExitSubflow = document.createElement('button') - btnExitSubflow.classList.add('bzgf-zoom-out') - this.addIcon(btnExitSubflow, 'zoomout') - btnExitSubflow.addEventListener('click', () => { - this.exitSubflow(childEl) - }) - // Put the child in the exact same viewport rect as the parent - this.invade(this, childEl) - childEl.hostContainer.appendChild(btnExitSubflow) - - childEl.addEventListener('flowLoaded', (e) => { - for(const portLink of flowNode.subflow.portLinks){ - const nid = crypto.randomUUID() - childEl.addNode({ - "nodeType": portLink.refNodeType, - "id": nid, - "markup": { "parentport": portLink.parentPort } - }) - if(portLink.direction=='in') { - childEl.addWire({ - "from": [nid, portLink.refnodePort], - "to": [portLink.subflowNode, portLink.subflowPort] - }) - } else if(portLink.direction=='out') { - childEl.addWire({ - "from": [portLink.subflowNode, portLink.subflowPort], - "to": [nid, portLink.refnodePort] - }) - } - } - - childEl.autoPlace(this.currentOrientation, 60, 60) - }, { once:true }) - - // Fade out the current (host) graflow while the child scales up - this.hostContainer.style.opacity = '1' - this.hostContainer.style.transition = 'opacity 1000ms ease-in-out' - - // Initial transform so the full-size child "fits" inside the node - const sx0 = nodeBB.width / parentBB.width - const sy0 = nodeBB.height / parentBB.height - // When the host graflow is scrollable, nodeBB is viewport-relative while the invading child - // is positioned inside `this` (absolute/inset=0). Add scroll offsets to keep coordinates consistent. - const tx0 = (nodeBB.left - parentBB.left) + (this.scrollLeft || 0) - const ty0 = (nodeBB.top - parentBB.top) + (this.scrollTop || 0) - - // Inline "scaler" (shadow styles don't apply to the child element) - childEl.style.border = 'none' - childEl.style.transformOrigin = 'top left' - childEl.style.willChange = 'transform' - childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))' - childEl.style.setProperty('--tx', tx0 + 'px') - childEl.style.setProperty('--ty', ty0 + 'px') - childEl.style.setProperty('--sx', sx0) - childEl.style.setProperty('--sy', sy0) - - // Force style flush, then animate back to identity (full parent size) - childEl.getBoundingClientRect() - childEl.style.transition = 'transform 1000ms ease-in-out' - requestAnimationFrame(() => { - childEl.style.top = 0; - childEl.style.left = 0; - childEl.style.setProperty('--tx', '0px') - childEl.style.setProperty('--ty', '0px') - childEl.style.setProperty('--sx', 1) - childEl.style.setProperty('--sy', 1) - this.hostContainer.style.opacity = '0' - }) - - childEl.addEventListener('transitionend', (e) => { - if(e.propertyName !== 'transform') return - this.hostContainer.style.visibility = 'hidden' - childEl.style.transform = 'none' // Important for nested subflows to position correctly - childEl.style.willChange = '' - newEl.style.overflow = 'auto' - this.dispatchEvent(new CustomEvent('subflowLoaded', { - detail: { subflow: childEl }, - bubbles: true, - composed: true, - })) - }, { once:true }) - } - - invade(oldEl, newEl){ - newEl.style.position = 'absolute' - const bbox = oldEl.getBoundingClientRect() - newEl.style.left = `${bbox.left+bbox.width/2}px` - newEl.style.top = `${bbox.top+bbox.height/2}px` - newEl.style.width = `${bbox.width}px` - newEl.style.height = `${bbox.height}px` - newEl.style.display = 'block' - newEl.style.overflow = 'hidden' - oldEl.appendChild(newEl) - } - - exitSubflow(childEl){ - if(!childEl) return - - const enterNodeId = childEl.dataset?.enterNodeId - const nodeEl = enterNodeId ? this.stagedNodes?.[enterNodeId] : null - if(!nodeEl){ - // Fallback: no context => just restore parent & remove child - this.hostContainer.style.opacity = '1' - this.hostContainer.style.visibility = 'visible' - if(childEl.parentNode === this) this.removeChild(childEl) - return - } - - // Compute target transform from full-size back to node rect (inverse of EnterSubflow) - const nodeBB = nodeEl.getBoundingClientRect() - const parentBB = this.getBoundingClientRect() - const sx0 = nodeBB.width / parentBB.width - const sy0 = nodeBB.height / parentBB.height - const tx0 = (nodeBB.left - parentBB.left) + (this.scrollLeft || 0) - const ty0 = (nodeBB.top - parentBB.top) + (this.scrollTop || 0) - - // Try to match duration to the child's transform transition (default 1000ms) - const transitionStr = childEl.style.transition || '' - const msMatch = transitionStr.match(/(\d+(?:\.\d+)?)ms/) - const durMs = msMatch ? parseFloat(msMatch[1]) : 1000 - - // Ensure parent is visible but faded-in during the shrink animation - this.hostContainer.style.visibility = 'visible' - this.hostContainer.style.opacity = '0' - this.hostContainer.style.transition = `opacity ${durMs}ms ease-in-out` - - // Ensure child animates (it may have had transform cleared after enter) - childEl.style.transformOrigin = 'top left' - childEl.style.willChange = 'transform' - childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))' - childEl.style.setProperty('--tx', '0px') - childEl.style.setProperty('--ty', '0px') - childEl.style.setProperty('--sx', 1) - childEl.style.setProperty('--sy', 1) - childEl.style.transition = `transform ${durMs}ms ease-in-out` - childEl.getBoundingClientRect() // flush - - requestAnimationFrame(() => { - // Shrink/move the child back into the original node - childEl.style.setProperty('--tx', tx0 + 'px') - childEl.style.setProperty('--ty', ty0 + 'px') - childEl.style.setProperty('--sx', sx0) - childEl.style.setProperty('--sy', sy0) - // Fade the parent back in - this.hostContainer.style.opacity = '1' - }) - - childEl.addEventListener('transitionend', (e) => { - if(e.propertyName !== 'transform') return - if(childEl.parentNode === this) this.removeChild(childEl) - // Cleanup: ensure parent is fully visible and no longer hidden - this.hostContainer.style.opacity = '1' - this.hostContainer.style.visibility = 'visible' - childEl.style.willChange = '' - this.dispatchEvent(new CustomEvent('subflowExited', { - detail: { subflow: childEl }, - bubbles: true, - composed: true, - })) - }, { once:true }) - } - - addFakeNode(nid, x, y, w, h){ - this.stagedNodes[nid] = document.createElement('div') - this.stagedNodes[nid].classList.add('bzgf-fake-node') - this.stagedNodes[nid].style.left = `${x}px` - this.stagedNodes[nid].style.top = `${y}px` - this.stagedNodes[nid].style.width = `${w}px` - this.stagedNodes[nid].style.height = `${h}px` - this.stagedNodes[nid].dataset.id = nid - this.nodesContainer.append(this.stagedNodes[nid]) - return(this.stagedNodes[nid]) - } - - addWire(link){ - const [idNode1, idPort1] = link.from - const [idNode2, idPort2] = link.to - const path = this.linkNodes(idNode1, idPort1, idNode2, idPort2) - const id = `${idNode1}_${idNode2}` - this.stagedWires[id] = document.createElementNS('http://www.w3.org/2000/svg', 'path') - this.stagedWires[id].setAttribute('d', path) - this.stagedWires[id].setAttribute('fill', 'none') - if(this.arrowDefs && link.endArrow) this.stagedWires[id].setAttribute('marker-end','url(#arrow)') - if(this.arrowDefs && link.startArrow) this.stagedWires[id].setAttribute('marker-start','url(#arrow)') - this.stagedWires[id].classList.add('bzgf-wire') - this.stagedWires[id].dataset.id = id - this.wiresContainer.append(this.stagedWires[id]) - if(!this.flow.links.find(l => l.from[0] === idNode1 && l.from[1] === idPort1 && l.to[0] === idNode2 && l.to[1] === idPort2)) { - this.flow.links.push(link) - } - return(this.stagedWires[id]) - } - - clear(){ - this.nodesContainer.innerHTML = '' - this.wiresContainer.innerHTML = '' - if(this.arrowDefs) this.wiresContainer.appendChild(this.arrowDefs) - } - - refresh(){ - this.clear() - let forceAutoplace = false - for(const node of this.flow.nodes){ - if((!node.coords) || (!node.coords.x) ||(!node.coords.y)) forceAutoplace=true - this.addNode(node) - } - for(const link of this.flow.links){ - this.addWire(link) - } - if(!this.currentOrientation) { - const bb=this.getBoundingClientRect() - if(bb.width > bb.height) this.currentOrientation = 'horizontal' - else this.currentOrientation = 'vertical' - } - if(forceAutoplace) this.autoPlace(this.currentOrientation) - } - - // Convert viewport (client) coordinates to this instance's SVG local coordinates. - // Required when the whole graflow is CSS-transformed (scale/translate), otherwise wire paths - // will be computed in the wrong coordinate space. - clientToSvg(x, y){ - const svg = this.wiresContainer - const ctm = svg?.getScreenCTM?.() - if(ctm && ctm.inverse){ - const inv = ctm.inverse() - if(svg?.createSVGPoint){ - const pt = svg.createSVGPoint() - pt.x = x - pt.y = y - const p = pt.matrixTransform(inv) - return({ x: p.x, y: p.y }) - } - if(typeof DOMPoint !== 'undefined'){ - const p = new DOMPoint(x, y).matrixTransform(inv) - return({ x: p.x, y: p.y }) - } - } - // Fallback: approximate using boundingClientRect (works only at scale=1) - const r = svg.getBoundingClientRect() - return({ x: x - r.left, y: y - r.top }) - } - - buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, dir1, dir2, tension, loop=false){ - if(loop) wireType = 'bezier' // loops only use bezier to look good - - const startAxis = ['n', 's'].includes(dir1) ? 'v' : 'h' - if(wireType == 'bezier'){ - return(`C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`) - } - if(wireType == 'straight'){ - return(`L ${c1x} ${c1y} L ${c2x} ${c2y} L ${x2} ${y2}`) - } - if(wireType == 'ortho'){ - const medianx = (x1 + x2) / 2 - const mediany = (y1 + y2) / 2 - if(startAxis == 'v') { - if( ((dir1 == 's') && (c1y < mediany)) || ((dir1 == 'n') && (c1y > mediany)) ){ - if( (dir2=='e') && (c2x > x1) || (dir2=='w') && (c2x < x1)) return(`V ${mediany} H ${c2x} V ${y2} H ${x2}`) - else if((dir2=='e') || (dir2=='w')) return(`V ${y2} H ${x2}`) - else if(dir2 == dir1) { // walk-around node - const deviation = node2.offsetWidth / 2 - if(x1>x2) { - if(x1>x2+deviation+tension) return(`V ${c2y} H ${x2} V ${y2}`) - else return(`V ${c1y} H ${x1+deviation+tension} V ${c2y} H ${x2} V ${y2}`) - } else { - if(x1 medianx)) ){ - if( (dir2=='s') && (c2y > y1) || (dir2=='n') && (c2y < y1)) return(`H ${medianx} V ${c2y} H ${x2} V ${y2}`) - else if((dir2=='n') || (dir2=='s')) return(`H ${x2} V ${y2}`) - else if(dir2 == dir1) { // walk-around node - const deviation = node2.offsetHeight / 2 - if(y1>y2) { - if(y1>y2+deviation+tension) return(`H ${c2x} V ${y2} H ${x2}`) - else return(`H ${c1x} V ${y1+deviation+tension} H ${c2x} V ${y2} H ${x2}`) - } else { - if(y1 { - const c1x = Math.floor(x1 + (this.dirVect[dir1].x * tension)) - const c1y = Math.floor(y1 + (this.dirVect[dir1].y * tension)) - const c2x = Math.floor(x2 + (this.dirVect[dir2].x * tension)) - const c2y = Math.floor(y2 + (this.dirVect[dir2].y * tension)) - return(this.buildSegment(x1, y1, c1x, c1y, c2x, c2y, x2, y2, wireType, node1, node2, dir1, dir2, tension, false)) - } - - // Start/end points in SVG coords (works for both bezier and line) - const bb1 = port1.el.getBoundingClientRect() - const bb2 = port2.el.getBoundingClientRect() - const sp = this.clientToSvg(bb1.x + (bb1.width/2), bb1.y + (bb1.height/2)) - const ep = this.clientToSvg(bb2.x + (bb2.width/2), bb2.y + (bb2.height/2)) - let x1 = Math.floor(sp.x) - let y1 = Math.floor(sp.y) - const xEnd = Math.floor(ep.x) - const yEnd = Math.floor(ep.y) - let path = `M ${x1} ${y1} ` - - const entryDir = (orientation == 'horizontal') ? 'w' : 'n' - const exitDir = (orientation == 'horizontal') ? 'e' : 's' - let firstPort = port1 - for(const interNode of interNodes){ - const bb = this.stagedNodes[interNode].getBoundingClientRect() - // Entry/exit points on the placeholder box, converted to SVG coords (handles CSS transforms) - let entryClient; let exitClient - if(orientation=='horizontal'){ - entryClient = { x: bb.left, y: bb.top + (bb.height/2) } - exitClient = { x: bb.right, y: bb.top + (bb.height/2) } - } else { - entryClient = { x: bb.left + (bb.width/2), y: bb.top } - exitClient = { x: bb.left + (bb.width/2), y: bb.bottom } - } - const entry = this.clientToSvg(entryClient.x, entryClient.y) - const exit = this.clientToSvg(exitClient.x, exitClient.y) - let x2 = Math.floor(entry.x) - let y2 = Math.floor(entry.y) - - if(firstPort){ - path += makeSegment(x1, y1, x2, y2, node1, node2, firstPort.direction, entryDir, tension) - firstPort = null - } else { - path += makeSegment(x1, y1, x2, y2, node1, node2, exitDir, entryDir, tension) - } - - const x3 = Math.floor(exit.x) - const y3 = Math.floor(exit.y) - path += ` L ${x3} ${y3} ` - - x1 = x3 - y1 = y3 - } - path += ' ' + makeSegment(x1, y1, xEnd, yEnd, node1, node2, exitDir, port2.direction, tension) - return(path) - } - - autoPlace(orientation = 'horizontal', gapx = null, gapy = null, tween = null, align = null){ - if(gapx == null) gapx = parseInt(this.getBZAttribute('gapx')) || 80 - if(gapy == null) gapy = parseInt(this.getBZAttribute('gapy')) || 80 - if(tween == null) tween = parseInt(this.getBZAttribute('tween')) || 500 - if(align == null) align = this.getBZAttribute('align') || 'center' - - this.currentOrientation = orientation - // Cancel any previous autoPlace() animations by bumping a token. - // moveNode() checks this token each frame and will no-op if superseded. - this._autoPlaceToken = (this._autoPlaceToken || 0) + 1 - const token = this._autoPlaceToken - - // Cleanup placeholders from previous autoPlace() runs. - // Each run creates new longLinkPlaceHolder_* IDs; without cleanup they accumulate in the DOM. - this.clearFakeNodes() - - // Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them ! - let linksWithoutBackEdges - if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ - const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links) - linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) - } else { - linksWithoutBackEdges = this.flow.links - } - const { parents, adj } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges) - - const layers = this.computeLayers(this.flow.nodes, parents) - - // Layer-0 nodes have no parents, so reorderLayers() (which uses parent ordering) cannot - // improve their order. This shows up strongly for "entry reference nodes" that are added - // programmatically: they all end up in layer 0 and keep insertion order, creating crossings. - // Pre-sort layer 0 by the average position of their children in layer 1. - if(layers.length > 1){ - const next = layers[1] - const nextPos = Object.fromEntries(next.map((nid, idx) => ([nid, idx]))) - const curPos0 = Object.fromEntries(layers[0].map((nid, idx) => ([nid, idx]))) - const key0 = (nid) => { - const kids = (adj?.[nid] || []).filter(k => (k in nextPos)) - if(kids.length === 0) return(curPos0[nid] ?? 0) - const sum = kids.reduce((acc, k) => acc + nextPos[k], 0) - return(sum / kids.length) - } - layers[0].sort((a, b) => { - const ka = key0(a), kb = key0(b) - if(ka !== kb) return(ka - kb) - return((curPos0[a] ?? 0) - (curPos0[b] ?? 0)) - }) - } - - // Compute indexes for each layer (int part) & add sub-index for ports - // Also compute max width/height for each layer - let maxHeight = 0 - let maxWidth = 0 - const layerHeights = [] - const layerWidths = [] - const indexes = {} // indexes[nid] = { base: , ports: { [portId]: } } - for(const layer of layers){ - let totHeight = 0 - let totWidth = 0 - for(const [idx, nid] of layer.entries()){ - // Use offset* (not impacted by CSS transforms) to keep autoPlace stable during zoom animations. - const bb = this.stagedNodes[nid].getBoundingClientRect() - const h = this.stagedNodes[nid].offsetHeight || bb.height - const w = this.stagedNodes[nid].offsetWidth || bb.width - totHeight += h + gapy - totWidth += w + gapx - indexes[nid] = { base: idx, ports: this.computePortOffsets(nid, orientation) } - } - if(totHeight>maxHeight) maxHeight = totHeight - layerHeights.push(totHeight) - if(totWidth>maxWidth) maxWidth = totWidth - layerWidths.push(totWidth) - } - - - // If any long-links, create placeholders for skipped layers - this._virtualLinks = new Map() - this.flow.longLinks = this.findLongLinks(this.flow.links) - for(const llink of this.flow.longLinks){ - let fakeParent = llink.link.from[0] - for(const layerIdx of llink.skippedLayers){ - const nid = `longLinkPlaceHolder_${crypto.randomUUID()}` - layers[layerIdx].push(nid) - llink.interNodes.push(nid) - // Placeholders are added after initial index computation; give them an index - // so reorderLayers() can take them into account (otherwise they default to base=0). - indexes[nid] = { base: layers[layerIdx].length - 1, ports: {} } - // Virtual link: treat placeholder as receiving the same "from port" as the original long-link. - // (Child port doesn't matter for placeholders since they have no ports.) - this._virtualLinks.set(`${fakeParent}__${nid}`, { - from: [fakeParent, llink.link.from[1]], - to: [nid, llink.link.to[1]], - }) - parents[nid] = [fakeParent] - fakeParent = nid - } - } - - // Reorder layers to avoid crossings thanks to indexes - this.reorderLayers(layers, parents, indexes, orientation) - delete this._virtualLinks - - // Finally place everything - if(orientation=='horizontal'){ - const fakeNodeHeight = 10 - const parentsY = {} - const nodeY = {} - const nodeX = {} - let x = gapx - for(const [idx, layer] of layers.entries()){ - let wMax = this.getMaxWidth(layer) - let y = 0 - switch(align){ - case 'center': - y = ((maxHeight - layerHeights[idx]) / 2) + gapy - break - case 'first': - y = gapy - break - case 'last': - y = maxHeight - layerHeights[idx] + gapy - break - case 'parent': // y will be absolutely positioned by the parent(s) but have fallback for 1st layer - y = ((maxHeight - layerHeights[idx]) / 2) + gapy - break - } - for(const nid of layer){ - let placedY - if(!nid.startsWith('longLinkPlaceHolder_')) { - const bb = this.stagedNodes[nid].getBoundingClientRect() - const nodeHeight = this.stagedNodes[nid].offsetHeight || bb.height - if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { - y = Math.max(parentsY[parents[nid][0]], y) //TODO handle multiple parents with avg - console.log('parent', nid, parents[nid], parentsY[parents[nid][0]]) - } - placedY = y - this.moveNode(nid, x, y, orientation, tween, null, token) - if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { - parentsY[parents[nid][0]] += gapy + nodeHeight - } else { - y += gapy + nodeHeight - } - y = Math.max(y, placedY + gapy + nodeHeight) - } else { - if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { - y = Math.max(parentsY[parents[nid][0]], y) - } - placedY = y - this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight) - this.moveNode(nid, x, y, orientation, tween, null, token) - // Never increment parentsY for fake nodes: they're placeholders and must not disalign real children - y = Math.max(y, placedY + gapy + fakeNodeHeight) - } - parentsY[nid] = placedY - nodeY[nid] = placedY - nodeX[nid] = x - } - x += wMax + gapx - } - // Correct parent positions: when fake nodes pushed children down, align parents with their first real child - if(align == 'parent'){ - for(let idx = 1; idx < layers.length; idx++){ - const layer = layers[idx] - const prevLayer = layers[idx - 1] - for(const pid of prevLayer){ - if(pid.startsWith('longLinkPlaceHolder_')) continue - const firstRealChild = layer.find(nid => - !nid.startsWith('longLinkPlaceHolder_') && nid in parents && parents[nid][0] === pid - ) - if(firstRealChild && nodeY[pid] !== nodeY[firstRealChild]){ - this.moveNode(pid, nodeX[pid], nodeY[firstRealChild], orientation, tween, null, token) - nodeY[pid] = nodeY[firstRealChild] - } - } - } - } - } else if(orientation=='vertical'){ - const fakeNodeWidth = 10 - let y = gapy - for(const [idx, layer] of layers.entries()){ - let hMax = this.getMaxHeight(layer) - let x = 0 - switch(align){ - case 'center': - x = ((maxWidth - layerWidths[idx]) / 2) + gapx - break - case 'first': - x = gapx - break - case 'last': - x = maxWidth - layerWidths[idx] + gapx - break - case 'parent': // x will be absolutely positioned by the parent(s) - //TODO - break - } - for(const nid of layer){ - if(!nid.startsWith('longLinkPlaceHolder_')){ - const bb = this.stagedNodes[nid].getBoundingClientRect() - this.moveNode(nid, x, y, orientation, tween, null, token) - x += gapx + (this.stagedNodes[nid].offsetWidth || bb.width) - } else { - this.addFakeNode(nid, x, y, fakeNodeWidth, hMax*0.75) - this.moveNode(nid, x, y, orientation, tween, null, token) - x += gapx + fakeNodeWidth - } - } - y += hMax + gapy - } - } - } - - clearFakeNodes(){ - for(const nid of Object.keys(this.stagedNodes || {})){ - if(!nid.startsWith('longLinkPlaceHolder_')) continue - const el = this.stagedNodes[nid] - if(el?.parentNode) el.parentNode.removeChild(el) - delete this.stagedNodes[nid] - } - } - - getMaxWidth(layer){ - return(layer.filter(nid => - !nid.startsWith('longLinkPlaceHolder_')) - // Use offsetWidth (not impacted by CSS transforms) to keep autoPlace stable during zoom animations. - .map(nid => (this.stagedNodes[nid].offsetWidth || this.stagedNodes[nid].getBoundingClientRect().width)) - .reduce((a, b) => a > b ? a : b, 0) - ) - } - - getMaxHeight(layer){ - return(layer.filter(nid => - !nid.startsWith('longLinkPlaceHolder_')) - // Use offsetHeight (not impacted by CSS transforms) to keep autoPlace stable during zoom animations. - .map(nid => (this.stagedNodes[nid].offsetHeight || this.stagedNodes[nid].getBoundingClientRect().height)) - .reduce((a, b) => a > b ? a : b, 0) - ) - } - - computePortOffsets(nid, orientation = 'horizontal'){ - const node = this.stagedNodes[nid] - if(!node || !node.ports) return({}) - const nodeRect = node.getBoundingClientRect() - const ports = Object.entries(node.ports) - .map(([pid, p]) => { - const r = p.el.getBoundingClientRect() - const pos = (orientation == 'vertical') - ? (r.left + (r.width / 2) - nodeRect.left) - : (r.top + (r.height / 2) - nodeRect.top) - return({ pid, pos }) - }) - .sort((a, b) => a.pos - b.pos) // smaller pos => "higher/left" => smaller offset - - const denom = ports.length + 1 - const offsets = {} - for(const [rank, item] of ports.entries()){ - offsets[item.pid] = rank / denom // always < 1 - } - return(offsets) - } - - reorderLayers(layers, parents, indexes, orientation = 'horizontal'){ - const swap = (vect, todo) => { - for(const s of todo){ - [vect[s[0]], vect[s[1]]] = [vect[s[1]], vect[s[0]]] - } - } - const adjIndex = (nid, portId) => { - const info = indexes?.[nid] - const base = (info && info.base !== undefined) ? info.base : 0 - const off = (portId && info?.ports?.[portId] !== undefined) ? info.ports[portId] : 0 - return(base + off) - } - // For nodes with multiple parents in the previous layer (common for "exit" ref nodes), - // using only the first parent can produce bad swaps. Use the average index over all - // parents that are actually in the previous layer. - const avgParentIndex = (nid, prevLayer) => { - const ps = (parents?.[nid] || []).filter(p => prevLayer.includes(p)) - if(ps.length === 0) return(adjIndex(nid)) - const sum = ps.reduce((acc, p) => { - const lnk = this.getLink(p, nid) - return(acc + adjIndex(p, lnk?.from?.[1])) - }, 0) - return(sum / ps.length) - } - const avgChildIndex = (nid, prevLayer) => { - const ps = (parents?.[nid] || []).filter(p => prevLayer.includes(p)) - if(ps.length === 0) return(adjIndex(nid)) - const sum = ps.reduce((acc, p) => { - const lnk = this.getLink(p, nid) - return(acc + adjIndex(nid, lnk?.to?.[1])) - }, 0) - return(sum / ps.length) - } - for(const [lidx, layer] of layers.entries()){ - if(lidx==0) continue - const prevLayer = layers[lidx-1] - - // Single-pass swapping is very sensitive to insertion order (especially with long-link - // placeholders and reference nodes). Do a few relaxation passes until stable. - const maxPasses = 8 - for(let pass = 0; pass < maxPasses; pass++){ - const toSwap = [] - for(let i=0; i (id.startsWith(nid+'_')||id.endsWith('_'+nid))) - .map(id => this.stagedWires[id]) - for(const wire of wires){ - const [nid1, nid2] = wire.dataset.id.split('_') - const lnk = this.getLink(nid1, nid2) - if(!lnk) continue - if(!this.flow?.longLinks) this.flow.longLinks = [] - const longLink = this.flow.longLinks.find(item => (item.link.from[0] == lnk.from[0] && item.link.from[1] == lnk.from[1] && item.link.to[0] == lnk.to[0] && item.link.to[1] == lnk.to[1])) - if(longLink && LondLinkfix) { - const path = this.linkInterNodes(nid1, lnk.from[1], nid2, lnk.to[1], longLink.interNodes, orientation) - wire.setAttribute('d', path) - } else { - const path = this.linkNodes(nid1, lnk.from[1], nid2, lnk.to[1]) - wire.setAttribute('d', path) - } - } - } - - getLink(nid1, nid2){ - let lnk = null - lnk = this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2))) - if(!lnk) { - lnk = this._virtualLinks?.get(`${nid1}__${nid2}`) - } - return(lnk) - } - - buildGraphStructures(nodes, links, includeLinkIndexes = false) { - const parents = {} - const adj = {} - nodes.forEach(n => { - parents[n.id] = [] - adj[n.id] = [] - }) - - links.forEach((link, idx) => { - const from = link.from[0] - const to = link.to[0] - if(link.from[0] !== link.to[0]) { // Skip self-loops - parents[to].push(from) - if(includeLinkIndexes) { - adj[from].push({ to, linkIdx: idx }) - } else { - adj[from].push(to) - } - } - }) - return({ parents, adj }) - } - - computeLayers(nodes, parents) { - const layer = {} - const dfs = (id) => { - if(layer[id] !== undefined) return(layer[id]) - - if(parents[id].length === 0) { - layer[id] = 0 - } else { - layer[id] = 1 + Math.max(...parents[id].map(dfs)) - } - return(layer[id]) - } - // Compute all layer indices - nodes.forEach(n => dfs(n.id)) - - // Build layers in the same order as `nodes` to keep results stable when nodes are appended - // programmatically (e.g. reference nodes). Avoid relying on object key enumeration order. - const t = [] - nodes.forEach(n => { - const l = layer[n.id] - if(l === undefined) return - if(!t[l]) t[l] = [] - t[l].push(n.id) - }) - return(t) - } - - hasAnyLoop(nodes, links) { - if(links.some(l => l.from[0] === l.to[0])) return(true) // self-loops - - const { adj } = this.buildGraphStructures(nodes, links) - const visiting = new Set() - const visited = new Set() - const dfs = (nid) => { - if(visiting.has(nid)) { - return(true) - } - - if(visited.has(nid)) return(false) - visiting.add(nid) - for(const m of adj[nid]) { - if(dfs(m)) { - return(true) - } - } - visiting.delete(nid) - visited.add(nid) - return(false) - } - return(nodes.map(n => n.id).some(dfs)) - } - - findBackEdges(nodes, links) { - const { adj } = this.buildGraphStructures(nodes, links, true) - const color = {} - nodes.forEach(n => color[n.id] = 'white') - const backEdges = [] - - function dfs(u) { - color[u] = 'gray' - for (const neighbor of adj[u]) { - const v = neighbor.to - const linkIdx = neighbor.linkIdx - if(color[v] === 'gray') { - backEdges.push(linkIdx) // 👈 cycle edge - return link index - } else if(color[v] === 'white') { - dfs(v) - } - } - color[u] = 'black' - } - - nodes.forEach(n => { - if(color[n.id] === 'white') dfs(n.id) - }) - - return(backEdges) - } - - findLongLinks(links) { - let linksWithoutBackEdges - if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ - const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links) - linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0])) - } else { - linksWithoutBackEdges = this.flow.links - } - /// Yes that means we ignore long & back links ! - const { parents } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges) - const layers = this.computeLayers(this.flow.nodes, parents) - const crossLayerLinks = [] - for(const link of links){ - const from = link.from[0] - const to = link.to[0] - const idx1 = layers.findIndex(layer => layer.includes(from)) - const idx2 = layers.findIndex(layer => layer.includes(to)) - if(Math.abs(idx1-idx2)>1) { - const lowerIdx = idx1idx2 ? idx1 : idx2 - crossLayerLinks.push({ - link, - linkIdx: link.linkIdx, - interNodes: [], - skippedLayers: Array.from({ length: higherIdx - lowerIdx - 1 }, (_, i) => lowerIdx + i + 1), - }) - } - } - return(crossLayerLinks) - } - - autofit(){ - const parentBB = this.parentElement.getBoundingClientRect() - // Use scroll dimensions for actual content extent (nodes can extend beyond element bounds) - const contentW = Math.max(this.scrollWidth || this.offsetWidth || 1, 1) - const contentH = Math.max(this.scrollHeight || this.offsetHeight || 1, 1) - const sx = parentBB.width / contentW - const sy = parentBB.height / contentH - const scale = Math.min(sx, sy) // uniform scale to fit inside parent - this.style.transformOrigin = 'top left' - this.style.transform = `scale(${scale})` - } -} -Buildoz.define('graflow', BZgraflow) - -class MovingNodes{ - constructor(graflow){ - this.graflow = graflow - this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') - this.state = null - - this.interactiveElementsSelector = ` - input, - textarea, - select, - button, - a[href] - ` - } - - enableMovingNodes(itemSelector, handleSelector = itemSelector) { - this.itemSelector = itemSelector - this.handleSelector = handleSelector - if(!this._handleCursorStyle){ - const style = document.createElement('style') - style.textContent = `${handleSelector}{ cursor: move }` - this.nodesContainer.appendChild(style) - this._handleCursorStyle = style - } - - this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.addEventListener('pointerdown', this.pointerDown.bind(this)) - ) - this.nodesContainer.addEventListener('pointermove', this.pointerMove.bind(this)) - this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.addEventListener('pointerup', this.pointerUp.bind(this)) - ) - } - - disableMovingNodes(){ - this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.removeEventListener('pointerdown', this.pointerDown.bind(this)) - ) - this.nodesContainer.removeEventListener('pointermove', this.pointerMove.bind(this)) - this.nodesContainer.querySelectorAll(this.handleSelector).forEach(item => - item.removeEventListener('pointerup', this.pointerUp.bind(this)) - ) - } - - pointerDown(e){ - this.graflow.clearFakeNodes() - console.log('=====> interactive element', e.target) - - const node = e.target.closest(this.itemSelector) - if(!node) return - - let handle - if(this.handleSelector == this.itemSelector) { - handle = node - if(e.target.closest(this.interactiveElementsSelector)) return - e.preventDefault() - } else { // If defined handle, then no need to care about interactive elements - handle = node.querySelector(this.handleSelector) - if(e.target != handle) return - } - - - - const rect = node.getBoundingClientRect() - - this.state = { - node, - handle, - startX: e.clientX, - startY: e.clientY, - offsetX: rect.left, - offsetY: rect.top - } - const x = e.clientX - this.state.startX + this.state.offsetX - const y = e.clientY - this.state.startY + this.state.offsetY - node.setPointerCapture(e.pointerId) - node.style.position = 'absolute' - node.style.left = `${x}px` - node.style.top = `${y}px` - node.style.margin = '0' - node.style.zIndex = '9999' - node.style.pointerEvents = 'none' - } - - pointerMove(e){ - if(!this.state) return - const { node, startX, startY, offsetX, offsetY } = this.state - const x = e.clientX - startX + offsetX - const y = e.clientY - startY + offsetY - node.style.left = `${x}px` - node.style.top = `${y}px` - this.graflow.updateWires(node.dataset.id, this.graflow.currentOrientation, false) - } - - pointerUp(e){ - if(!this.state) return - this.state.node.releasePointerCapture(e.pointerId) - this.state.node.style.pointerEvents = '' - this.state = null - } -} - -class EditWires{ - constructor(graflow){ - this.graflow = graflow - this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') - this.state = null - } - enableEditWires(){ - for(const ref in this.graflow.stagedWires ){ - this.graflow.stagedWires[ref].addEventListener('click', this.onSelectWire.bind(this)) - } - } - onSelectWire(e){ - const wire = e.target - console.log('wire', wire) - } -} - -class DroppingNodes{ - constructor(graflow){ - this.graflow = graflow - this.nodesContainer = this.graflow.mainContainer.querySelector('.bzgf-nodes-container') - this.state = null - } - -} \ No newline at end of file