/** * 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, 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 (0–1) 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(); } }