Files
lit.ruv.wtf/website/scripts/WorkspaceNavigator.js

920 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Handles pan and zoom for the graph workspace canvas.
* Ported directly from WorkspaceNavigationManager in Picograph.
* Grid is CSS-driven; this class updates CSS custom properties and background position.
*/
export class WorkspaceNavigator {
/** @type {HTMLElement} */
#canvas;
/** @type {HTMLElement | null} */
#worldLayer = null;
/** @type {(() => void) | null} */
#onAfterTransform = null;
/** @type {{ x: number, y: number }} */
#backgroundOffset = { x: 0, y: 0 };
/** @type {{ x: number, y: number }} */
#pendingLayerOffset = { x: 0, y: 0 };
/** @type {number} */
#zoomLevel = 1;
/** @type {number} */
#targetZoomLevel = 1;
/** @type {{ min: number, max: number, step: number }} */
#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
/** @type {number | null} */
#smoothZoomRafId = null;
/** @type {number | null} */
#focusAnimRafId = null;
/** @type {number} */
#focusAnimGen = 0;
/** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
#smoothZoomFocus = null;
/** @type {number | null} */
#smoothPanRafId = null;
/** @type {{ x: number, y: number }} */
#targetPanOffset = { x: 0, y: 0 };
/** @type {{ pointerId: number, startX: number, startY: number, currentClientX: number, currentClientY: number, hasMoved: boolean, backgroundOrigin: { x: number, y: number }, lastDelta: { x: number, y: number } } | null} */
#panState = null;
/** @type {number} */
#lastPanTimestamp = 0;
/** @type {{ width: number, height: number }} */
#lastCanvasSize = { width: 0, height: 0 };
/** @type {{ touches: Map<number, {x: number, y: number}>, initialDistance: number, initialZoom: number, initialBackgroundOffset: {x: number, y: number}, center: {x: number, y: number} } | null} */
#pinchState = null;
/** @type {(() => boolean) | null} */
#isDragInProgress = null;
/** @type {(() => void) | null} */
#onPinchStart = null;
/**
* @param {HTMLElement} canvas - The `.workspace-canvas` element.
* @param {HTMLElement | null} [worldLayer] - Optional world-space layer to transform with pan/zoom.
* @param {(() => void) | null} [onAfterTransform] - Called after every transform update.
*/
constructor(canvas, worldLayer = null, onAfterTransform = null) {
this.#canvas = canvas;
this.#worldLayer = worldLayer;
this.#onAfterTransform = onAfterTransform;
const initialRect = canvas.getBoundingClientRect();
this.#lastCanvasSize = { width: initialRect.width, height: initialRect.height };
this.#updateZoomDisplay();
this.#bindEvents();
this.#bindResizeObserver();
}
/**
* Sets the callback fired after every transform update.
* Assign this AFTER constructing dependent renderers to avoid temporal dead zone issues.
*
* @param {(() => void) | null} cb
*/
setOnAfterTransform(cb) {
this.#onAfterTransform = cb;
}
/**
* Registers a callback that returns true when a node drag is actively in progress.
* When true, single-finger touch and pointer pan will be suppressed so the drag
* can move the node without competing with the pan handler.
*
* @param {(() => boolean) | null} fn
*/
setDragInProgressChecker(fn) {
this.#isDragInProgress = fn;
}
/**
* Registers a callback fired when a two-finger pinch gesture begins.
* Use this to cancel any active node drag before the zoom takes over.
*
* @param {(() => void) | null} fn
*/
setOnPinchStart(fn) {
this.#onPinchStart = fn;
}
/** True while a two-finger pinch gesture is in progress. */
get isPinching() { return this.#pinchState !== null; }
/**
* Pans the viewport by a world-space delta.
* Used by ItemDragger's edge-pan loop to scroll while dragging a node.
*
* @param {number} worldDx
* @param {number} worldDy
*/
panBy(worldDx, worldDy) {
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + worldDx,
y: this.#backgroundOffset.y + worldDy,
});
}
// ─── Public ───────────────────────────────────────────────────────────────
/** Current zoom level. */
get zoom() { return this.#zoomLevel; }
/** @returns {number} */
get zoomLevel() { return this.#zoomLevel; }
/**
* Checks if a pan operation occurred recently (within the threshold).
* Useful for suppressing context menus after right-click drag pans.
*
* @param {number} [thresholdMs=100] - Time window in milliseconds
* @returns {boolean}
*/
didPanRecently(thresholdMs = 100) {
return (this.#timestamp() - this.#lastPanTimestamp) < thresholdMs;
}
/**
* Returns the total effective offset (backgroundOffset + pendingLayerOffset).
* Matches WorkspaceNavigationManager.getEffectiveOffset() in Picograph.
*
* @returns {{ x: number, y: number }}
*/
getEffectiveOffset() {
return {
x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
};
}
/** Reset view to origin, zoom 1. */
reset() {
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
this.#backgroundOffset = { x: 0, y: 0 };
this.#pendingLayerOffset = { x: 0, y: 0 };
this.#zoomLevel = 1;
this.#targetZoomLevel = 1;
this.#updateZoomDisplay();
}
/**
* Animates the viewport to frame a world-space rect.
* Respects a minimum framing fraction so nodes never appear too small.
*
* @param {{ x: number, y: number, width: number, height: number }} worldRect World-space bounds to frame.
* @param {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number }} [options]
* anchorX/anchorY are screen-space fractions (01) for where the rect centre lands.
* 0.5,0.5 = screen centre (default). 0.0,0.5 = left edge centre. 0.0,0.0 = top-left.
* responsiveWorldBox allows overriding minWorldBox, anchorX, anchorY based on viewport width.
* Can be a single breakpoint object or an array of breakpoints (evaluated largest to smallest).
*/
animateFocusOnRect(worldRect, options = {}) {
let {
paddingFraction = 0.0,
durationMs = 550,
minWorldBox = null, // { width, height } — minimum world-space area that must be visible
responsiveWorldBox = null, // { minViewportWidth, minWorldBox, anchorX?, anchorY? } — overrides when viewport is smaller
anchorX = 0.5, // screen-space X fraction where rect centre is placed
anchorY = 0.5, // screen-space Y fraction where rect centre is placed
} = options;
// Cancel any ongoing smooth zoom and commit pending offset
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
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,
});
}
}
const canvasRect = this.#canvas.getBoundingClientRect();
const vw = canvasRect.width;
const vh = canvasRect.height;
// Check if we should use responsive settings
if (responsiveWorldBox) {
const breakpoints = Array.isArray(responsiveWorldBox) ? responsiveWorldBox : [responsiveWorldBox];
// Sort ascending — smallest threshold that still exceeds window width wins (most specific match)
const sorted = [...breakpoints].sort((a, b) => (a.minViewportWidth ?? 0) - (b.minViewportWidth ?? 0));
// Find first matching breakpoint where window is smaller than threshold
const windowWidth = window.innerWidth;
for (const bp of sorted) {
if (windowWidth < (bp.minViewportWidth ?? Infinity)) {
minWorldBox = bp.minWorldBox ?? minWorldBox;
anchorX = bp.anchorX ?? anchorX;
anchorY = bp.anchorY ?? anchorY;
break;
}
}
}
const pad = Math.min(vw, vh) * paddingFraction;
const availW = Math.max(1, vw - pad * 2);
const availH = Math.max(1, vh - pad * 2);
// The framing rect is the union of worldRect and the minWorldBox centred on it
let frameW = Math.max(1, worldRect.width);
let frameH = Math.max(1, worldRect.height);
if (minWorldBox) {
// Expand frame to at least minWorldBox dimensions, centred on worldRect centre
frameW = Math.max(frameW, minWorldBox.width ?? 0);
frameH = Math.max(frameH, minWorldBox.height ?? 0);
}
// Zoom to fit the frame rect inside the available viewport
let targetZoom = Math.min(availW / frameW, availH / frameH);
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
// Calculate the target world center based on node and minWorldBox
const worldCx = worldRect.x + Math.max(1, worldRect.width) / 2;
const worldCy = worldRect.y + Math.max(1, worldRect.height) / 2;
let targetWorldCenterX = worldCx;
let targetWorldCenterY = worldCy;
if (minWorldBox) {
// When minWorldBox exists, anchor positions the node WITHIN the box.
// The box itself should be centered on screen (ignoring anchor for box positioning).
// Calculate worldbox bounds with node positioned at anchor within it
const boxX = worldCx - minWorldBox.width * anchorX;
const boxY = worldCy - minWorldBox.height * anchorY;
targetWorldCenterX = boxX + minWorldBox.width / 2;
targetWorldCenterY = boxY + minWorldBox.height / 2;
}
// Position the target point at viewport center
const centerScreenX = pad + availW / 2;
const centerScreenY = pad + availH / 2;
const targetOffsetX = centerScreenX / targetZoom - targetWorldCenterX;
const targetOffsetY = centerScreenY / targetZoom - targetWorldCenterY;
const fromZoom = this.#zoomLevel;
const fromOffsetX = this.#backgroundOffset.x;
const fromOffsetY = this.#backgroundOffset.y;
const gen = ++this.#focusAnimGen;
const start = this.#timestamp();
const tick = () => {
if (this.#focusAnimGen !== gen) return;
const elapsed = this.#timestamp() - start;
const t = Math.min(elapsed / durationMs, 1);
const eased = 1 - Math.pow(1 - t, 3);
this.#zoomLevel = fromZoom + (targetZoom - fromZoom) * eased;
this.#targetZoomLevel = this.#zoomLevel;
this.#setBackgroundOffset({
x: fromOffsetX + (targetOffsetX - fromOffsetX) * eased,
y: fromOffsetY + (targetOffsetY - fromOffsetY) * eased,
});
this.#updateZoomDisplay();
if (t < 1) {
this.#focusAnimRafId = requestAnimationFrame(tick);
} else {
this.#focusAnimRafId = null;
this.#zoomLevel = targetZoom;
this.#targetZoomLevel = targetZoom;
}
};
this.#focusAnimRafId = requestAnimationFrame(tick);
}
/**
* Instantly fits a world-space rect into the viewport with optional padding.
* The zoom is chosen to fit the rect; aspect-ratio mismatches are letter-boxed.
*
* @param {{ x: number, y: number, width: number, height: number }} worldRect
* @param {{ paddingFraction?: number }} [options]
*/
focusOnRect(worldRect, { paddingFraction = 0, anchorX = 0.5, anchorY = 0.5 } = {}) {
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
if (this.#focusAnimRafId !== null) {
cancelAnimationFrame(this.#focusAnimRafId);
this.#focusAnimRafId = null;
this.#focusAnimGen++;
}
this.#pendingLayerOffset = { x: 0, y: 0 };
const canvas = this.#canvas.getBoundingClientRect();
const vw = canvas.width;
const vh = canvas.height;
const pad = Math.min(vw, vh) * paddingFraction;
const availW = Math.max(1, vw - pad * 2);
const availH = Math.max(1, vh - pad * 2);
const zoom = Math.max(
this.#zoomConfig.min,
Math.min(this.#zoomConfig.max,
Math.min(availW / Math.max(1, worldRect.width), availH / Math.max(1, worldRect.height))
)
);
const centerScreenX = pad + anchorX * availW;
const centerScreenY = pad + anchorY * availH;
const cx = worldRect.x + worldRect.width / 2;
const cy = worldRect.y + worldRect.height / 2;
this.#zoomLevel = zoom;
this.#targetZoomLevel = zoom;
this.#setBackgroundOffset({
x: centerScreenX / zoom - cx,
y: centerScreenY / zoom - cy,
});
this.#updateZoomDisplay();
}
/**
* Immediately centres the viewport on a world-space point at the given zoom.
* @param {{ x: number, y: number }} worldPoint World-space coordinates to centre on.
* @param {number} [zoom] Zoom level to snap to.
*/
focusOnWorld(worldPoint, zoom = 1) {
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
this.#pendingLayerOffset = { x: 0, y: 0 };
const clamped = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, zoom));
this.#zoomLevel = clamped;
this.#targetZoomLevel = clamped;
const rect = this.#canvas.getBoundingClientRect();
this.#setBackgroundOffset({
x: rect.width / 2 / clamped - worldPoint.x,
y: rect.height / 2 / clamped - worldPoint.y,
});
this.#updateZoomDisplay();
}
/**
* Returns the current viewport info in world space.
*
* @returns {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }}
*/
getViewInfo() {
const rect = this.#canvas.getBoundingClientRect();
const zoom = this.#zoomLevel || 1;
const eff = this.getEffectiveOffset();
const vx = -eff.x;
const vy = -eff.y;
const vw = rect.width / zoom;
const vh = rect.height / zoom;
return {
zoom,
viewbox: { x: vx, y: vy, width: vw, height: vh },
center: { x: vx + vw / 2, y: vy + vh / 2 },
};
}
// ─── Events ───────────────────────────────────────────────────────────────
#bindResizeObserver() {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize: newW, blockSize: newH } = entry.contentBoxSize[0];
const dw = newW - this.#lastCanvasSize.width;
const dh = newH - this.#lastCanvasSize.height;
this.#lastCanvasSize = { width: newW, height: newH };
if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue;
const zoom = this.#zoomLevel || 1;
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + dw / 2 / zoom,
y: this.#backgroundOffset.y + dh / 2 / zoom,
});
this.#updateZoomDisplay();
}
});
observer.observe(this.#canvas);
}
#bindEvents() {
this.#canvas.addEventListener("wheel", (e) => this.#onWheel(e), { passive: false });
this.#canvas.addEventListener("pointerdown", (e) => this.#onPointerDown(e));
this.#canvas.addEventListener("touchstart", (e) => this.#onTouchStart(e), { passive: false });
this.#canvas.addEventListener("touchmove", (e) => this.#onTouchMove(e), { passive: false });
this.#canvas.addEventListener("touchend", (e) => this.#onTouchEnd(e), { passive: false });
this.#canvas.addEventListener("touchcancel", (e) => this.#onTouchEnd(e), { passive: false });
this.#canvas.addEventListener("contextmenu", (e) => {
// Suppress context menu if pan just ended (matches original 200ms threshold)
if (this.#timestamp() - this.#lastPanTimestamp < 200) {
e.preventDefault();
return;
}
e.preventDefault();
});
}
/** @param {WheelEvent} e */
#onWheel(e) {
if (!Number.isFinite(e.deltaY) || e.deltaY === 0) return;
const rect = this.#canvas.getBoundingClientRect();
const screenPoint = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
if (screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > rect.width || screenPoint.y > rect.height) return;
// Check if scrolling over a code block with max-height - let it scroll
const codeBlock = e.target.closest('.md-pre');
if (codeBlock && codeBlock.scrollHeight > codeBlock.clientHeight) {
const scrollTop = codeBlock.scrollTop;
const scrollHeight = codeBlock.scrollHeight;
const clientHeight = codeBlock.clientHeight;
const tolerance = 1; // Add tolerance for precision issues
const canScrollDown = scrollTop < (scrollHeight - clientHeight - tolerance);
const canScrollUp = scrollTop > tolerance;
// Allow scrolling if there's room to scroll in that direction
if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
return; // Don't prevent default, let the element scroll
}
// If at the edge, fall through to camera pan
}
// Check if scrolling over an info or blog_post node - pan the camera vertically
const blueprintNode = e.target.closest('.blueprint-node');
if (blueprintNode) {
const nodeType = blueprintNode.dataset.nodeType;
if (nodeType === 'info' || nodeType === 'blog_post') {
e.preventDefault();
const panAmount = e.deltaY * 0.5;
this.#pushSmoothPan(panAmount);
return;
}
}
// Not over a scrollable node or code block, proceed with zoom
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY });
}
/** @param {PointerEvent} e */
#onPointerDown(e) {
if (e.button !== 0 && e.button !== 2) return;
// Allow text selection in blog/info nodes
const nodeBody = e.target.closest('.node-body');
if (nodeBody) {
const blueprintNode = nodeBody.closest('.blueprint-node');
if (blueprintNode) {
const nodeType = blueprintNode.dataset.nodeType;
if (nodeType === 'info' || nodeType === 'blog_post') {
return; // Don't pan, allow text selection
}
}
}
this.#beginPan(e);
}
// ─── Touch (Pinch-to-Zoom) ────────────────────────────────────────────────
/** @param {TouchEvent} e */
#onTouchStart(e) {
if (e.touches.length === 2) {
e.preventDefault();
// Drop any active node drag before pinch-zoom takes control
this.#onPinchStart?.();
const rect = this.#canvas.getBoundingClientRect();
const t1 = e.touches[0];
const t2 = e.touches[1];
const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top };
const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top };
const distance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const center = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
this.#pinchState = {
touches: new Map([
[t1.identifier, p1],
[t2.identifier, p2]
]),
initialDistance: distance,
initialZoom: this.#zoomLevel,
initialBackgroundOffset: { ...this.#backgroundOffset },
center: center
};
// Cancel pan state when pinch starts
this.#panState = null;
} else if (e.touches.length === 1 && !this.#panState) {
// If a node drag is in progress, suppress single-finger pan so the node moves instead
if (this.#isDragInProgress?.()) return;
// Single finger pan - only if not already panning
const touch = e.touches[0];
this.#panState = {
pointerId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
currentClientX: touch.clientX,
currentClientY: touch.clientY,
hasMoved: false,
backgroundOrigin: { ...this.#backgroundOffset },
lastDelta: { x: 0, y: 0 },
};
}
}
/** @param {TouchEvent} e */
#onTouchMove(e) {
// Handle two-finger pinch + pan
if (this.#pinchState && e.touches.length === 2) {
e.preventDefault();
const rect = this.#canvas.getBoundingClientRect();
const t1 = e.touches[0];
const t2 = e.touches[1];
const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top };
const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top };
// Calculate current distance for zoom
const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const scale = currentDistance / this.#pinchState.initialDistance;
let newZoom = this.#pinchState.initialZoom * scale;
newZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, newZoom));
// Calculate current center for pan
const currentCenter = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
// Pan delta in screen space
const panDeltaScreen = {
x: currentCenter.x - this.#pinchState.center.x,
y: currentCenter.y - this.#pinchState.center.y
};
const previousZoom = this.#zoomLevel;
const initialCenter = this.#pinchState.center;
// World point under initial pinch center (using initial background offset)
const worldPoint = {
x: initialCenter.x / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.x,
y: initialCenter.y / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.y
};
this.#zoomLevel = newZoom;
this.#targetZoomLevel = newZoom;
// Adjust offset to keep world point under pinch center AND apply pan
this.#setBackgroundOffset({
x: initialCenter.x / newZoom - worldPoint.x + panDeltaScreen.x / newZoom,
y: initialCenter.y / newZoom - worldPoint.y + panDeltaScreen.y / newZoom
});
this.#updateZoomDisplay();
return;
}
// Handle single-finger pan
if (this.#panState && e.touches.length === 1) {
const touch = e.touches[0];
this.#panState.currentClientX = touch.clientX;
this.#panState.currentClientY = touch.clientY;
const deltaXScreen = touch.clientX - this.#panState.startX;
const deltaYScreen = touch.clientY - this.#panState.startY;
const zoom = this.#zoomLevel || 1;
const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom };
if (!this.#panState.hasMoved) {
if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return;
e.preventDefault();
this.#panState.hasMoved = true;
}
if (this.#panState.hasMoved) {
e.preventDefault();
}
this.#panState.lastDelta = worldDelta;
this.#setBackgroundOffset({
x: this.#panState.backgroundOrigin.x + worldDelta.x,
y: this.#panState.backgroundOrigin.y + worldDelta.y,
});
this.#updateZoomDisplay();
}
}
/** @param {TouchEvent} e */
#onTouchEnd(e) {
// Clear pinch state if we go below 2 touches
if (this.#pinchState && e.touches.length < 2) {
this.#pinchState = null;
}
// Clear pan state if no touches remain
if (this.#panState && e.touches.length === 0) {
const finalState = this.#panState;
this.#panState = null;
if (finalState.hasMoved) {
this.#lastPanTimestamp = this.#timestamp();
}
}
}
// ─── Pan ─────────────────────────────────────────────────────────────────
/** @param {PointerEvent} event */
#beginPan(event) {
if (this.#panState || this.#pinchState) return;
// Do not start a pan while a node drag is actively in progress
if (this.#isDragInProgress?.()) return;
// Cancel any in-progress smooth pan
if (this.#smoothPanRafId !== null) {
cancelAnimationFrame(this.#smoothPanRafId);
this.#smoothPanRafId = null;
}
const state = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
currentClientX: event.clientX,
currentClientY: event.clientY,
hasMoved: false,
backgroundOrigin: { ...this.#backgroundOffset },
lastDelta: { x: 0, y: 0 },
};
/** @param {PointerEvent} moveEvent */
const handlePointerMove = (moveEvent) => {
if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) return;
this.#panState.currentClientX = moveEvent.clientX;
this.#panState.currentClientY = moveEvent.clientY;
const deltaXScreen = moveEvent.clientX - this.#panState.startX;
const deltaYScreen = moveEvent.clientY - this.#panState.startY;
const zoom = this.#zoomLevel || 1;
const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom };
if (!this.#panState.hasMoved) {
if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return;
moveEvent.preventDefault();
this.#panState.hasMoved = true;
}
this.#panState.lastDelta = worldDelta;
this.#setBackgroundOffset({
x: this.#panState.backgroundOrigin.x + worldDelta.x,
y: this.#panState.backgroundOrigin.y + worldDelta.y,
});
this.#updateZoomDisplay();
};
/** @param {PointerEvent} upEvent */
const handlePointerUp = (upEvent) => {
if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) return;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
const finalState = this.#panState;
this.#panState = null;
if (finalState.hasMoved) {
this.#lastPanTimestamp = this.#timestamp();
}
};
this.#panState = state;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
// ─── Smooth Zoom ──────────────────────────────────────────────────────────
/**
* @param {{ x: number, y: number }} screenPoint
* @param {number} direction
* @param {{ clientX?: number, clientY?: number }} [pointer]
*/
#pushSmoothZoom(screenPoint, direction, pointer = {}) {
if (!direction) return;
// Cancel any in-progress rect focus animation
if (this.#focusAnimRafId !== null) {
cancelAnimationFrame(this.#focusAnimRafId);
this.#focusAnimRafId = null;
this.#focusAnimGen++;
}
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());
}
}
#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 } = this.#smoothZoomFocus;
const previousZoom = current;
// World point under cursor (using old zoom)
const worldPoint = {
x: screenPoint.x / previousZoom,
y: screenPoint.y / previousZoom,
};
this.#zoomLevel = newZoom;
const scale = previousZoom / newZoom;
const shift = {
x: worldPoint.x * (scale - 1),
y: worldPoint.y * (scale - 1),
};
this.#pendingLayerOffset.x += shift.x;
this.#pendingLayerOffset.y += shift.y;
// Rebase active pan so world deltas stay stable mid-zoom
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
this.#rebasePanAfterZoom(this.#smoothZoomFocus.pointer);
}
this.#canvas.style.setProperty("--workspace-zoom", `${newZoom}`);
this.#applyBackgroundOffset();
if (settled) {
this.#smoothZoomFocus = null;
const finalOffset = this.#pendingLayerOffset;
this.#pendingLayerOffset = { x: 0, y: 0 };
if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
// Commit pivot into backgroundOffset (no nodes to translate on the website)
this.#setBackgroundOffset({
x: this.#backgroundOffset.x + finalOffset.x,
y: this.#backgroundOffset.y + finalOffset.y,
});
// Also rebase pan origin so pan doesn't jump
if (this.#panState) {
this.#panState.backgroundOrigin.x += finalOffset.x;
this.#panState.backgroundOrigin.y += finalOffset.y;
}
}
this.#updateZoomDisplay();
} else {
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
}
}
// ─── Smooth Pan ───────────────────────────────────────────────────────────
/**
* @param {number} deltaY
*/
#pushSmoothPan(deltaY) {
// Cancel any in-progress zoom or focus animation
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
if (this.#focusAnimRafId !== null) {
cancelAnimationFrame(this.#focusAnimRafId);
this.#focusAnimRafId = null;
this.#focusAnimGen++;
}
// Accumulate the pan delta
this.#targetPanOffset.y -= deltaY / this.#zoomLevel;
// Start animation loop if not already running
if (this.#smoothPanRafId === null) {
this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
}
}
#tickSmoothPan() {
this.#smoothPanRafId = null;
const targetY = this.#targetPanOffset.y;
const currentY = this.#backgroundOffset.y;
const diffY = targetY - currentY;
const settled = Math.abs(diffY) < 0.01;
if (settled) {
// Snap to target and stop
this.#backgroundOffset.y = targetY;
this.#targetPanOffset.y = targetY;
this.#applyBackgroundOffset();
} else {
// Ease toward target
this.#backgroundOffset.y += diffY * 0.25;
this.#applyBackgroundOffset();
this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
}
}
// ─── Transform helpers ────────────────────────────────────────────────────
/** @param {{ x: number, y: number }} offset */
#setBackgroundOffset(offset) {
this.#backgroundOffset = {
x: Number.isFinite(offset.x) ? offset.x : 0,
y: Number.isFinite(offset.y) ? offset.y : 0,
};
// Keep target pan offset in sync
this.#targetPanOffset = { ...this.#backgroundOffset };
this.#applyBackgroundOffset();
}
#applyBackgroundOffset() {
const zoom = this.#zoomLevel || 1;
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.#canvas.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
this.#applyWorldLayerTransform();
}
#updateZoomDisplay() {
const zoom = this.#zoomLevel || 1;
this.#canvas.style.setProperty("--workspace-zoom", `${zoom}`);
this.#applyBackgroundOffset(); // also calls #applyWorldLayerTransform
}
#applyWorldLayerTransform() {
if (!this.#worldLayer) return;
const zoom = this.#zoomLevel || 1;
const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
this.#worldLayer.style.transformOrigin = "0 0";
this.#worldLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
this.#onAfterTransform?.();
}
/** @param {{ clientX?: number, clientY?: number }} [pointer] */
#rebasePanAfterZoom(pointer) {
if (!this.#panState) return;
const zoom = this.#zoomLevel || 1;
const clientX = Number.isFinite(this.#panState.currentClientX)
? this.#panState.currentClientX
: pointer?.clientX;
const clientY = Number.isFinite(this.#panState.currentClientY)
? this.#panState.currentClientY
: pointer?.clientY;
if (Number.isFinite(clientX)) {
this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom;
}
if (Number.isFinite(clientY)) {
this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom;
}
}
/** @returns {number} */
#timestamp() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
}