From d073a2e80414cc857c463de49bbdb603e528a692 Mon Sep 17 00:00:00 2001 From: STEINNI Date: Thu, 4 Jun 2026 11:22:39 +0000 Subject: [PATCH] better layout complete events --- bzGraflow.js | 111 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 30 deletions(-) diff --git a/bzGraflow.js b/bzGraflow.js index 02fb486..352a92e 100644 --- a/bzGraflow.js +++ b/bzGraflow.js @@ -518,8 +518,12 @@ class BZgraflow extends Buildoz{ else this._scheduleLayoutWhenReady(() => this.autofit(autofitPercent)) } } - if(forceAutoplace) this._scheduleAutoPlaceWhenReady(this.currentOrientation, gapx, gapy, finishRefresh) - else finishRefresh() + const onLayoutComplete = () => { + this.fireEvent('layoutComplete', { }) + finishRefresh() + } + if(forceAutoplace) this._scheduleAutoPlaceWhenReady(this.currentOrientation, gapx, gapy, onLayoutComplete) + else onLayoutComplete() } disconnectedCallback(){ @@ -580,13 +584,20 @@ class BZgraflow extends Buildoz{ requestAnimationFrame(rafPoll) } - _scheduleAutoPlaceWhenReady(orientation, gapx, gapy, onDone){ + _scheduleAutoPlaceWhenReady(orientation, gapx, gapy, onLayoutComplete){ this._scheduleLayoutWhenReady(() => { - this.autoPlace(orientation, gapx, gapy) - if(typeof onDone === 'function') onDone() + this.autoPlace(orientation, gapx, gapy, null, null, onLayoutComplete) }) } + _maybeFireLayoutComplete(){ + if(this._layoutMovePending > 0) return + if(!this._layoutCompleteHandler) return + const fn = this._layoutCompleteHandler + this._layoutCompleteHandler = null + fn() + } + // 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. @@ -774,7 +785,7 @@ class BZgraflow extends Buildoz{ return(path) } - autoPlace(orientation = null, gapx = null, gapy = null, tween = null, align = null){ + autoPlace(orientation = null, gapx = null, gapy = null, tween = null, align = null, onLayoutComplete = null){ if(orientation == null) orientation = this.getBZAttribute('orientation') || this.currentOrientation || 'horizontal' if(gapx == null) gapx = parseInt(this.getBZAttribute('gapx')) || 80 if(gapy == null) gapy = parseInt(this.getBZAttribute('gapy')) || 80 @@ -786,6 +797,9 @@ class BZgraflow extends Buildoz{ // moveNode() checks this token each frame and will no-op if superseded. this._autoPlaceToken = (this._autoPlaceToken || 0) + 1 const token = this._autoPlaceToken + this._layoutMovePending = 0 + this._layoutCompleteToken = token + this._layoutCompleteHandler = (typeof onLayoutComplete === 'function') ? onLayoutComplete : null // Cleanup placeholders from previous autoPlace() runs. // Each run creates new longLinkPlaceHolder_* IDs; without cleanup they accumulate in the DOM. @@ -909,7 +923,7 @@ class BZgraflow extends Buildoz{ 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) + this.moveNode(nid, x, y, orientation, tween, token) if((align == 'parent') && (nid in parents) && (parents[nid][0] in parentsY)) { parentsY[parents[nid][0]] += gapy + nodeHeight } else { @@ -922,7 +936,7 @@ class BZgraflow extends Buildoz{ } placedY = y this.addFakeNode(nid, x, y, wMax*0.75, fakeNodeHeight) - this.moveNode(nid, x, y, orientation, tween, null, token) + this.moveNode(nid, x, y, orientation, tween, token) // Never increment parentsY for fake nodes: they're placeholders and must not disalign real children y = Math.max(y, placedY + gapy + fakeNodeHeight) } @@ -943,7 +957,7 @@ class BZgraflow extends Buildoz{ !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) + this.moveNode(pid, nodeX[pid], nodeY[firstRealChild], orientation, tween, token) nodeY[pid] = nodeY[firstRealChild] } } @@ -972,17 +986,18 @@ class BZgraflow extends Buildoz{ for(const nid of layer){ if(!nid.startsWith('longLinkPlaceHolder_')){ const bb = this.stagedNodes[nid].getBoundingClientRect() - this.moveNode(nid, x, y, orientation, tween, null, token) + this.moveNode(nid, x, y, orientation, tween, 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) + this.moveNode(nid, x, y, orientation, tween, token) x += gapx + fakeNodeWidth } } y += hMax + gapy } } + this._maybeFireLayoutComplete() } clearFakeNodes(){ @@ -1102,17 +1117,34 @@ class BZgraflow extends Buildoz{ } moveNode(nid, destx, desty, orientation, duration = 200, autoPlaceToken = null) { - const t0 = performance.now() const el0 = this.stagedNodes?.[nid] if(!el0) return + let layoutTracked = false + if(autoPlaceToken != null && autoPlaceToken === this._layoutCompleteToken) { + layoutTracked = true + this._layoutMovePending++ + } + const finishLayoutMove = () => { + if(!layoutTracked) return + layoutTracked = false + this._layoutMovePending = Math.max(0, this._layoutMovePending - 1) + this._maybeFireLayoutComplete() + } + const t0 = performance.now() const bb = el0.getBoundingClientRect() const parentbb = el0.parentElement.getBoundingClientRect() const x0=bb.x - parentbb.x const y0 = bb.y - parentbb.y function frame(t) { - if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) return + if(autoPlaceToken && autoPlaceToken !== this._autoPlaceToken) { + finishLayoutMove() + return + } const el = this.stagedNodes?.[nid] - if(!el) return + if(!el) { + finishLayoutMove() + return + } const p = Math.min((t - t0) / duration, 1) const k = p * p * (3 - 2 * p) // smoothstep const x = x0 + (destx - x0) * k @@ -1130,6 +1162,7 @@ class BZgraflow extends Buildoz{ flowNode.coords.y = y } this.fireEvent('nodeMoved', { nid, x, y }) + finishLayoutMove() } } requestAnimationFrame(frame.bind(this)) @@ -1299,15 +1332,11 @@ class BZgraflow extends Buildoz{ return(crossLayerLinks) } - autofit(percent=100){ - if(!this.parentElement) return - - const prevTransformOrigin = this.style.transformOrigin - this.style.transform = 'none' - this.style.transformOrigin = 'top left' - - // Measure real content by unioning viewport-space bounding boxes. - // This is robust with overflow:auto and absolute-positioned layers. + /** + * Union bounding boxes of nodes and wires (viewport coords). + * Same measurement strategy as autofit(). Call with transform cleared for stable sizes. + */ + getContentSize(){ let left = Infinity let top = Infinity let right = -Infinity @@ -1324,18 +1353,40 @@ class BZgraflow extends Buildoz{ this.nodesContainer?.querySelectorAll?.('.bzgf-node').forEach(nodeEl => includeBB(nodeEl.getBoundingClientRect())) this.wiresContainer?.querySelectorAll?.('path.bzgf-wire').forEach(path => includeBB(path.getBoundingClientRect())) - const parentBB = this.parentElement.getBoundingClientRect() const gapx = parseInt(this.getBZAttribute('gapx')) || 80 const gapy = parseInt(this.getBZAttribute('gapy')) || 80 - const rawW = Number.isFinite(left) && Number.isFinite(right) ? Math.max(right - left, 1) : Math.max(this.mainContainer?.clientWidth || this.offsetWidth || 1, 1) - const rawH = Number.isFinite(top) && Number.isFinite(bottom) ? Math.max(bottom - top, 1) : Math.max(this.mainContainer?.clientHeight || this.offsetHeight || 1, 1) - const contentW = rawW + (2 * gapx) - const contentH = rawH + (2 * gapy) + const hasBounds = Number.isFinite(left) && Number.isFinite(right) && Number.isFinite(top) && Number.isFinite(bottom) + const rawWidth = hasBounds ? Math.max(right - left, 1) : Math.max(this.mainContainer?.clientWidth || this.offsetWidth || 1, 1) + const rawHeight = hasBounds ? Math.max(bottom - top, 1) : Math.max(this.mainContainer?.clientHeight || this.offsetHeight || 1, 1) + + return({ + left: hasBounds ? left : null, + top: hasBounds ? top : null, + right: hasBounds ? right : null, + bottom: hasBounds ? bottom : null, + rawWidth, + rawHeight, + width: rawWidth + (2 * gapx), + height: rawHeight + (2 * gapy), + gapx, + gapy, + }) + } + + autofit(percent=100){ + if(!this.parentElement) return + + const prevTransformOrigin = this.style.transformOrigin + this.style.transform = 'none' + this.style.transformOrigin = 'top left' + + const { left, top, width: contentW, height: contentH, gapx, gapy } = this.getContentSize() + const parentBB = this.parentElement.getBoundingClientRect() const sx = parentBB.width / contentW const sy = parentBB.height / contentH const scale = Math.min(sx, sy)*(percent/100) // uniform scale to fit inside parent - const tx = Number.isFinite(left) ? (-left + gapx) : gapx - const ty = Number.isFinite(top) ? (-top + gapy) : gapy + const tx = left != null ? (-left + gapx) : gapx + const ty = top != null ? (-top + gapy) : gapy if(!this.isSubflow) { this.style.transformOrigin = prevTransformOrigin || 'top left' this.style.transform = `scale(${scale}) translate(${tx}px, ${ty}px)`