diff --git a/bzGraflow.js b/bzGraflow.js index 98766be..ac6044a 100644 --- a/bzGraflow.js +++ b/bzGraflow.js @@ -65,13 +65,6 @@ class BZgraflow extends Buildoz{ right: -1em; color: #A00; } - .bzgf-nodes-container .bzgf-node.scaler { - transform-origin: top left; - transform: - translate(var(--tx, 0), var(--ty, 0)) - scale(var(--sx, 1), var(--sy, 1)); - transition: transform 300ms ease-in-out; - } ` this.mainContainer.appendChild(style) this.nodesContainer = document.createElement('div') @@ -159,18 +152,18 @@ class BZgraflow extends Buildoz{ 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 btnZoomIn = document.createElement('button') - btnZoomIn.classList.add('bzgf-zoom-in', 'icon-copy') - btnZoomIn.addEventListener('click', () => { - this.zoomIn(id) + const btnEnterSubflow = document.createElement('button') + btnEnterSubflow.classList.add('bzgf-zoom-in', 'icon-copy') + btnEnterSubflow.addEventListener('click', () => { + this.EnterSubflow(id) }) - this.stagedNodes[id].appendChild(btnZoomIn) + this.stagedNodes[id].appendChild(btnEnterSubflow) } this.nodesContainer.append(this.stagedNodes[id]) return(this.stagedNodes[id]) } - zoomIn(id){ + EnterSubflow(id){ const nodeEl = this.stagedNodes[id] if(!nodeEl) return @@ -185,10 +178,17 @@ class BZgraflow extends Buildoz{ const childEl = document.createElement('bz-graflow') childEl.setAttribute('flow', flowUrl) childEl.setAttribute('tension', this.getBZAttribute('tension') || '60') - childEl.style.zIndex = '9999' + // Match the clicked node's border so the transition feels like we're "expanding" it. + const nodeStyle = getComputedStyle(nodeEl) + childEl.style.border = nodeStyle.border + childEl.style.borderRadius = nodeStyle.borderRadius // Put the child in the exact same viewport rect as the parent (fixed overlay) - this.Invade(this, childEl, { hideOld:false }) + this.Invade(this, childEl) + + // Fade out the current (host) graflow while the child scales up + this.hostContainer.style.opacity = '1' + this.hostContainer.style.transition = 'opacity 1000ms ease-in-out' // Initial transform so the full-size child "fits" inside the node const sx0 = nodeBB.width / parentBB.width @@ -199,7 +199,7 @@ class BZgraflow extends Buildoz{ // Inline "scaler" (shadow styles don't apply to the child element) childEl.style.transformOrigin = 'top left' childEl.style.willChange = 'transform' - childEl.style.transition = 'transform 300ms ease-in-out' + childEl.style.transition = 'transform 1000ms ease-in-out' childEl.style.transform = 'translate(var(--tx, 0px), var(--ty, 0px)) scale(var(--sx, 1), var(--sy, 1))' childEl.style.setProperty('--tx', tx0 + 'px') childEl.style.setProperty('--ty', ty0 + 'px') @@ -213,24 +213,24 @@ class BZgraflow extends Buildoz{ 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.style.visibility = 'hidden' + this.hostContainer.style.visibility = 'hidden' }, { once:true }) } - Invade(oldEl, newEl, { hideOld=true } = {}){ + Invade(oldEl, newEl){ const r = oldEl.getBoundingClientRect() - if(hideOld) oldEl.style.visibility = 'hidden' newEl.style.position = 'fixed' newEl.style.left = r.left + 'px' newEl.style.top = r.top + 'px' newEl.style.width = r.width + 'px' newEl.style.height = r.height + 'px' newEl.style.display = 'block' - oldEl.parentNode.appendChild(newEl) + oldEl.appendChild(newEl) } Evade(oldEl, newEl){ @@ -291,15 +291,39 @@ class BZgraflow extends Buildoz{ 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) + //TODO compute tensions from ports, height and width + if(bb.width > bb.height) this.autoPlace('horizontal', 60, 60) + else this.autoPlace('vertical', 60, 60) } } + // Convert viewport (client) coordinates to this instance's SVG local coordinates. + // Required when the whole graflow is CSS-transformed (scale/translate), otherwise wire paths + // will be computed in the wrong coordinate space. + clientToSvg(x, y){ + const svg = this.wiresContainer + const ctm = svg?.getScreenCTM?.() + if(ctm && ctm.inverse){ + const inv = ctm.inverse() + if(svg?.createSVGPoint){ + const pt = svg.createSVGPoint() + pt.x = x + pt.y = y + const p = pt.matrixTransform(inv) + return({ x: p.x, y: p.y }) + } + if(typeof DOMPoint !== 'undefined'){ + const p = new DOMPoint(x, y).matrixTransform(inv) + return({ x: p.x, y: p.y }) + } + } + // Fallback: approximate using boundingClientRect (works only at scale=1) + const r = svg.getBoundingClientRect() + return({ x: x - r.left, y: y - r.top }) + } + bezierNodes(idNode1, idPort1, idNode2, idPort2, tension=60) { tension = parseInt(tension) - const svgRect = this.wiresContainer.getBoundingClientRect() const node1 = this.stagedNodes[idNode1] const port1 = node1.ports[idPort1] const node2 = this.stagedNodes[idNode2] @@ -310,10 +334,12 @@ class BZgraflow extends Buildoz{ } 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 p1 = this.clientToSvg(bb1.x + (bb1.width/2), bb1.y + (bb1.height/2)) + const p2 = this.clientToSvg(bb2.x + (bb2.width/2), bb2.y + (bb2.height/2)) + const x1 = Math.floor(p1.x) + const y1 = Math.floor(p1.y) + const x2 = Math.floor(p2.x) + const y2 = Math.floor(p2.y) const loop = (idNode1==idNode2) && (idPort1==idPort2) const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1) @@ -336,7 +362,6 @@ class BZgraflow extends Buildoz{ bezierInterNodes(idNode1, idPort1, idNode2, idPort2, interNodes, orientation='horizontal', tension=60) { tension = parseInt(tension) - const svgRect = this.wiresContainer.getBoundingClientRect() const node1 = this.stagedNodes[idNode1] let port1 = node1.ports[idPort1] @@ -365,18 +390,23 @@ class BZgraflow extends Buildoz{ const endPath = directPath.substring(directPath.lastIndexOf(',')+1).trim() let path = startPath let [ , x1, y1] = startPath.split(' ') - x1 = parseInt(x1) - y1 = parseInt(y1) + x1 = parseFloat(x1) + y1 = parseFloat(y1) for(const interNode of interNodes){ const bb = this.stagedNodes[interNode].getBoundingClientRect() - let x2; let y2; + // Entry/exit points on the placeholder box, converted to SVG coords (handles CSS transforms) + let entryClient; let exitClient if(orientation=='horizontal'){ - x2 = bb.x -svgRect.left - y2 =Math.floor(bb.y + (bb.height/2)) - svgRect.top + entryClient = { x: bb.left, y: bb.top + (bb.height/2) } + exitClient = { x: bb.right, y: bb.top + (bb.height/2) } } else { - x2 = Math.floor(bb.x + (bb.width/2)) - svgRect.left - y2 = bb.y - svgRect.top + entryClient = { x: bb.left + (bb.width/2), y: bb.top } + exitClient = { x: bb.left + (bb.width/2), y: bb.bottom } } + const entry = this.clientToSvg(entryClient.x, entryClient.y) + const exit = this.clientToSvg(exitClient.x, exitClient.y) + let x2 = Math.floor(entry.x) + let y2 = Math.floor(entry.y) if(port1){ path += makeCubicBezier(x1, y1, x2, y2, ['w','e'].includes(port1.direction) ? 'horizontal' : 'vertical', orientation) @@ -385,26 +415,21 @@ class BZgraflow extends Buildoz{ path += makeCubicBezier(x1, y1, x2, y2, orientation, orientation) } - if(orientation=='horizontal'){ - x2 += bb.width - path += ` L ${x2} ${y2} ` - } else { - y2 += bb.height - path += ` L ${x2} ${y2} ` - } + const x3 = Math.floor(exit.x) + const y3 = Math.floor(exit.y) + path += ` L ${x3} ${y3} ` - x1 = x2 - y1 = y2 + x1 = x3 + y1 = y3 } let [x2, y2] = endPath.split(' ') - x2 = parseInt(x2) - y2 = parseInt(y2) + x2 = parseFloat(x2) + y2 = parseFloat(y2) path += ' '+makeCubicBezier(x1, y1, x2, y2, orientation, orientation) return(path) } - autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=1000, align='center'){ - console.log('autoPlace', orientation, gapx, gapy, tween, align) + autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=500, align='center'){ // Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them ! let linksWithoutBackEdges if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){ @@ -425,9 +450,12 @@ class BZgraflow extends Buildoz{ 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() - totHeight += bb.height + gapy - totWidth += bb.width + gapx + 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 @@ -488,9 +516,9 @@ class BZgraflow extends Buildoz{ } for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')) { - const bb = this.stagedNodes[nid].getBoundingClientRect() + const bb = this.stagedNodes[nid].getBoundingClientRect() this.moveNode(nid, x, y, orientation, tween) - y += gapy + bb.height + y += gapy + (this.stagedNodes[nid].offsetHeight || bb.height) } else { this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight) this.moveNode(nid, x, y, orientation, tween) @@ -509,7 +537,7 @@ class BZgraflow extends Buildoz{ if(!nid.startsWith('longLinkPlaceHolder_')){ const bb = this.stagedNodes[nid].getBoundingClientRect() this.moveNode(nid, x, y, orientation, tween) - x += gapx + bb.width + 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) @@ -524,7 +552,8 @@ class BZgraflow extends Buildoz{ getMaxWidth(layer){ return(layer.filter(nid => !nid.startsWith('longLinkPlaceHolder_')) - .map(nid => this.stagedNodes[nid].getBoundingClientRect().width) + // 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) ) } @@ -532,7 +561,8 @@ class BZgraflow extends Buildoz{ getMaxHeight(layer){ return(layer.filter(nid => !nid.startsWith('longLinkPlaceHolder_')) - .map(nid => this.stagedNodes[nid].getBoundingClientRect().height) + // 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) ) }