Fix zoomToPoint animation to smoothly fly-to target node

Interpolate both view center and zoom level together using rAF
instead of CSS transitions, so the target node smoothly slides
to screen center while zooming in simultaneously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:06:05 +03:00
parent 844866b489
commit e163575bac
2 changed files with 50 additions and 8 deletions

View File

@@ -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 };

View File

@@ -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;
}