diff --git a/server/src/wled_controller/static/js/core/graph-canvas.js b/server/src/wled_controller/static/js/core/graph-canvas.js index 963868e..8a0468f 100644 --- a/server/src/wled_controller/static/js/core/graph-canvas.js +++ b/server/src/wled_controller/static/js/core/graph-canvas.js @@ -105,14 +105,49 @@ export class GraphCanvas { if (this._onZoomChange) this._onZoomChange(this._zoom); } - /** Set zoom and center on a graph-space point in one animated step. */ - zoomToPoint(level, gx, gy) { + /** + * Animate zoom + pan to center on a graph-space point. + * Interpolates the view center in graph-space so the target smoothly + * slides to screen center while zoom changes simultaneously. + */ + zoomToPoint(level, gx, gy, duration = 500) { + if (this._zoomAnim) cancelAnimationFrame(this._zoomAnim); + const r = this.svg.getBoundingClientRect(); - this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level)); - this._vx = gx - (r.width / this._zoom) / 2; - this._vy = gy - (r.height / this._zoom) / 2; - this._applyTransform(true); - if (this._onZoomChange) this._onZoomChange(this._zoom); + const hw = r.width / 2; + const hh = r.height / 2; + + const startZoom = this._zoom; + const targetZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level)); + // Current view center in graph-space + const startCx = this._vx + hw / startZoom; + const startCy = this._vy + hh / startZoom; + const t0 = performance.now(); + + const step = (now) => { + const elapsed = now - t0; + const p = Math.min(elapsed / duration, 1); + // Ease-in-out cubic + const t = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; + + // Interpolate zoom and view center together + this._zoom = startZoom + (targetZoom - startZoom) * t; + const cx = startCx + (gx - startCx) * t; + const cy = startCy + (gy - startCy) * t; + // Convert center back to vx/vy for current zoom + this._vx = cx - hw / this._zoom; + this._vy = cy - hh / this._zoom; + + this._applyTransform(false); + if (this._onZoomChange) this._onZoomChange(this._zoom); + + if (p < 1) { + this._zoomAnim = requestAnimationFrame(step); + } else { + this._zoomAnim = null; + } + }; + this._zoomAnim = requestAnimationFrame(step); } zoomIn() { this.zoomTo(this._zoom * 1.25); } @@ -141,6 +176,7 @@ export class GraphCanvas { _onWheel(e) { e.preventDefault(); + if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } const delta = -e.deltaY * ZOOM_SENSITIVITY; const newZoom = this._zoom * (1 + delta); this.zoomTo(newZoom, e.clientX, e.clientY); @@ -197,6 +233,7 @@ export class GraphCanvas { } _startPan(e) { + if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } this._panning = true; this._panPending = false; this._panStart = { x: e.clientX, y: e.clientY }; diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index 7ea72a0..631e7c2 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -286,9 +286,14 @@ function _watchForNewEntity() { loadGraphEditor().then(() => { const node = _nodeMap?.get(newId); if (node && _canvas) { + // Animate zoom + pan together in one transition _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2); } - _navigateToNode(newId); + // Highlight the node and its chain (without re-panning) + const nodeGroup = document.querySelector('.graph-nodes'); + if (nodeGroup) { highlightNode(nodeGroup, newId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } + const edgeGroup = document.querySelector('.graph-edges'); + if (edgeGroup && _edges) { highlightChain(edgeGroup, newId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } }); return; }