updated graph rendering to be more efficient. added smooth scrolling, added color type, mmb drag for cutting

This commit is contained in:
2026-03-21 06:57:26 +11:00
parent 1f5da03474
commit ae374db9fb
54 changed files with 9147 additions and 138 deletions

View File

@@ -13,6 +13,10 @@ export class WorkspaceNavigationManager {
#zoomConfig;
#panState;
#lastPanTimestamp;
#targetZoomLevel;
#smoothZoomFocus;
#smoothZoomRafId;
#pendingLayerOffset;
/**
* @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
@@ -39,6 +43,10 @@ export class WorkspaceNavigationManager {
this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
this.#panState = null;
this.#lastPanTimestamp = 0;
this.#targetZoomLevel = 1;
this.#smoothZoomFocus = null;
this.#smoothZoomRafId = null;
this.#pendingLayerOffset = { x: 0, y: 0 };
}
/**
@@ -48,18 +56,29 @@ export class WorkspaceNavigationManager {
return this.#zoomLevel;
}
/**
* Returns the accumulated node-layer translate offset that is pending commit
* during a smooth zoom animation. This must be accounted for when converting
* screen/client coordinates to world coordinates mid-lerp.
*
* @returns {{ x: number, y: number }}
*/
getPendingLayerOffset() {
return { x: this.#pendingLayerOffset.x, y: this.#pendingLayerOffset.y };
}
/**
* Adjusts the zoom level within configured bounds.
*
* @param {number} nextZoom Requested zoom multiplier.
* @param {{ silent?: boolean }} [options] Behaviour overrides.
* @param {{ silent?: boolean, skipConnectionRender?: boolean }} [options] Behaviour overrides.
* @returns {boolean} Whether the zoom value changed.
*/
setZoomLevel(nextZoom, options = {}) {
if (!Number.isFinite(nextZoom)) {
return false;
}
const { silent = false } = options;
const { silent = false, skipConnectionRender = false } = options;
const clamped = Math.max(
this.#zoomConfig.min,
Math.min(this.#zoomConfig.max, nextZoom)
@@ -68,8 +87,25 @@ export class WorkspaceNavigationManager {
return false;
}
// Cancel any running smooth zoom so programmatic calls take immediate effect.
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
// Commit any pending layer offset before the snap.
const pending = this.#pendingLayerOffset;
this.#pendingLayerOffset = { x: 0, y: 0 };
if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) {
this.setBackgroundOffset({
x: this.#backgroundOffset.x - pending.x,
y: this.#backgroundOffset.y - pending.y,
});
this.translateWorkspace(pending);
}
}
this.#targetZoomLevel = clamped;
this.#zoomLevel = clamped;
this.updateZoomDisplay();
this.updateZoomDisplay(skipConnectionRender);
if (!silent) {
this.#markViewDirty("view", 200);
this.#schedulePersist();
@@ -86,24 +122,31 @@ export class WorkspaceNavigationManager {
/**
* Updates workspace transforms to match the stored zoom level.
*
* @param {boolean} [skipConnectionRender] When true, skips the connection re-render pass.
*/
updateZoomDisplay() {
updateZoomDisplay(skipConnectionRender = false) {
const zoom = this.#zoomLevel || 1;
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
if (nodeLayer) {
nodeLayer.style.transformOrigin = "0 0";
nodeLayer.style.transform = `scale(${zoom})`;
nodeLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
}
if (connectionLayer) {
connectionLayer.style.transformOrigin = "0 0";
connectionLayer.style.transform = `scale(${zoom})`;
connectionLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
}
workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
this.#applyBackgroundOffset();
this.#renderConnections();
if (!skipConnectionRender) {
this.#renderConnections();
}
}
/**
@@ -134,6 +177,109 @@ export class WorkspaceNavigationManager {
};
}
/**
* Queues a smooth animated zoom step centred on a screen-space point.
* Subsequent calls before the animation settles accumulate the target zoom.
*
* @param {{ x: number, y: number }} screenPoint Focus point in workspace-relative screen coordinates.
* @param {number} direction Positive to zoom in, negative to zoom out.
* @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates relative to the viewport.
*/
pushSmoothZoom(screenPoint, direction, pointer = {}) {
if (!direction) {
return;
}
const step = direction * this.#zoomConfig.step;
this.#targetZoomLevel = Math.max(
this.#zoomConfig.min,
Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step)
);
this.#smoothZoomFocus = { screenPoint, pointer };
if (this.#smoothZoomRafId === null) {
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
}
}
/**
* Advances one frame of the smooth zoom animation, easing toward the target.
*/
#tickSmoothZoom() {
this.#smoothZoomRafId = null;
if (!this.#smoothZoomFocus) {
return;
}
const target = this.#targetZoomLevel;
const current = this.#zoomLevel;
const diff = target - current;
const settled = Math.abs(diff) < 0.001;
const newZoom = settled
? target
: Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25));
const { screenPoint, pointer } = this.#smoothZoomFocus;
const previousZoom = current;
// screenPointToWorld still uses the old zoom at this point, which is correct.
const worldPoint = this.screenPointToWorld(screenPoint);
this.#zoomLevel = newZoom;
const scale = previousZoom / newZoom;
const shift = {
x: worldPoint.x * (scale - 1),
y: worldPoint.y * (scale - 1),
};
// Accumulate into a pending layer offset — O(1), no per-node work each frame.
this.#pendingLayerOffset.x += shift.x;
this.#pendingLayerOffset.y += shift.y;
// Rebase active interactions if needed.
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
const { dragManager } = this.#workspace;
if (dragManager?.rebaseActiveDragPointer) {
dragManager.rebaseActiveDragPointer(pointer);
}
this.#rebasePanGestureAfterZoom(pointer);
}
// Apply zoom + pending pivot as a single CSS transform on the node layer — O(1).
// Combined with background offset for pan.
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
const ox = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * newZoom;
const oy = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * newZoom;
if (nodeLayer) {
nodeLayer.style.transformOrigin = "0 0";
nodeLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
}
if (connectionLayer) {
connectionLayer.style.transformOrigin = "0 0";
connectionLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
}
workspaceElement.style.setProperty("--workspace-zoom", `${newZoom}`);
this.#applyBackgroundOffset();
this.#renderConnections();
if (settled) {
this.#smoothZoomFocus = null;
// Commit accumulated pivot to node positions in one pass, then restore clean transforms.
const finalOffset = this.#pendingLayerOffset;
this.#pendingLayerOffset = { x: 0, y: 0 };
if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
this.translateWorkspace(finalOffset);
}
// Restore clean scale-only transform now that node positions are committed.
this.updateZoomDisplay(false);
this.#markViewDirty("view", 0);
this.#schedulePersist();
} else {
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
}
}
/**
* Applies zoom centred at a screen-space location.
*
@@ -149,7 +295,7 @@ export class WorkspaceNavigationManager {
const previousZoom = this.#zoomLevel || 1;
const worldPoint = this.screenPointToWorld(screenPoint);
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
const changed = this.setZoomLevel(nextZoom);
const changed = this.setZoomLevel(nextZoom, { skipConnectionRender: true });
if (!changed) {
return;
}
@@ -172,8 +318,9 @@ export class WorkspaceNavigationManager {
dragManager.rebaseActiveDragPointer(pointer);
}
this.#rebasePanGestureAfterZoom(pointer);
this.#renderConnections();
}
this.#renderConnections();
}
/**
@@ -197,6 +344,18 @@ export class WorkspaceNavigationManager {
return this.#backgroundOffset;
}
/**
* Returns the total effective viewport offset, including any pending zoom pivot.
*
* @returns {{ x: number, y: number }}
*/
getEffectiveOffset() {
return {
x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
};
}
/**
* Applies a workspace translation to all nodes and the background.
*
@@ -212,22 +371,7 @@ export class WorkspaceNavigationManager {
y: this.#backgroundOffset.y + delta.y,
});
const { graph, dragManager } = this.#workspace;
const panState = this.#panState;
const panDelta = panState ? panState.lastDelta : null;
graph.nodes.forEach((node) => {
const origin = panState?.nodeOrigins.get(node.id) ?? node.position;
const baseDelta = panDelta ?? { x: 0, y: 0 };
const next = {
x: origin.x + baseDelta.x + delta.x,
y: origin.y + baseDelta.y + delta.y,
};
graph.setNodePosition(node.id, next);
if (dragManager?.updateNodeDomPosition) {
dragManager.updateNodeDomPosition(node.id, next);
}
});
const { dragManager } = this.#workspace;
if (dragManager?.applyActiveDragShift) {
dragManager.applyActiveDragShift(delta);
@@ -381,24 +525,16 @@ export class WorkspaceNavigationManager {
return;
}
const { dragManager, contextMenuManager, graph, nodeElements } =
this.#workspace;
const { dragManager, contextMenuManager } = this.#workspace;
dragManager?.removeNodeGhost();
dragManager?.removePaletteGhost();
const nodeOrigins = new Map();
graph.nodes.forEach((node) => {
nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
});
const state = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
lastDelta: { x: 0, y: 0 },
hasMoved: false,
nodeOrigins,
backgroundOrigin: { ...this.#backgroundOffset },
};
@@ -413,7 +549,6 @@ export class WorkspaceNavigationManager {
x: deltaXScreen,
y: deltaYScreen,
});
this.#panState.lastDelta = worldDelta;
if (!this.#panState.hasMoved) {
const distance = Math.hypot(deltaXScreen, deltaYScreen);
@@ -422,7 +557,7 @@ export class WorkspaceNavigationManager {
}
moveEvent.preventDefault();
this.#panState.hasMoved = true;
contextMenuManager?.hide();
contextMenuManager?.hide();
}
if (!this.#panState.hasMoved) {
@@ -434,20 +569,7 @@ export class WorkspaceNavigationManager {
y: this.#panState.backgroundOrigin.y + worldDelta.y,
};
this.setBackgroundOffset(backgroundOffset);
this.#panState.nodeOrigins.forEach((origin, nodeId) => {
const element = nodeElements.get(nodeId);
if (!element) {
return;
}
const next = {
x: origin.x + worldDelta.x,
y: origin.y + worldDelta.y,
};
element.style.transform = WorkspaceGeometry.positionToTransform(next);
});
this.#renderConnections();
this.updateZoomDisplay();
};
const handlePointerUp = (upEvent) => {
@@ -467,17 +589,6 @@ export class WorkspaceNavigationManager {
}
this.#lastPanTimestamp = this.#timestamp();
const delta = finalState.lastDelta;
this.setBackgroundOffset({
x: finalState.backgroundOrigin.x + delta.x,
y: finalState.backgroundOrigin.y + delta.y,
});
finalState.nodeOrigins.forEach((origin, nodeId) => {
graph.setNodePosition(nodeId, {
x: origin.x + delta.x,
y: origin.y + delta.y,
});
});
};
this.#panState = state;
@@ -527,8 +638,8 @@ export class WorkspaceNavigationManager {
*/
#applyBackgroundOffset() {
const zoom = this.#zoomLevel || 1;
const scaledX = this.#backgroundOffset.x * zoom;
const scaledY = this.#backgroundOffset.y * zoom;
const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
const position = `${scaledX}px ${scaledY}px`;
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
}
@@ -551,13 +662,6 @@ export class WorkspaceNavigationManager {
x: this.#panState.backgroundOrigin.x + shift.x,
y: this.#panState.backgroundOrigin.y + shift.y,
};
this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
map.set(nodeId, {
x: origin.x + shift.x,
y: origin.y + shift.y,
});
});
}
/**