/** * Coordinate Utilities Module * * Handles coordinate transformations between screen space and canvas space. * * ## Coordinate Behavior: World-Space Annotations (Option B) * * Annotations are stored in LiteGraph canvas coordinates (world-space). * This means: * - Strokes are anchored to positions in the node editor coordinate system * - When you zoom in, strokes get bigger (they scale with content) * - When you pan, strokes move with the canvas (they stay in place relative to nodes) * - Drawing near a node will keep the annotation near that node * * This is ideal for annotating specific parts of a workflow. * * ## LiteGraph Coordinate System * * LiteGraph uses a display system (ds) with: * - offset: [x, y] pan offset in canvas coordinates * - scale: zoom level (1.0 = 100%, 0.5 = 50%, 2.0 = 200%) * * ## Transform Formulas * * Screen Position = (Canvas Position + Offset) × Scale * Canvas Position = Screen Position ÷ Scale - Offset * * ## Coordinate Spaces * * 1. Screen Space: Pixel coordinates relative to the viewport (0,0 = top-left) * 2. Canvas Space: LiteGraph world coordinates (where nodes live) * * Strokes are stored in Canvas Space and transformed to Screen Space for rendering. */ // Cache for transform values let cachedTransform = { scale: 1, offsetX: 0, offsetY: 0 }; // Zoom limits for safety const MIN_SCALE = 0.01; // Prevent division by zero / extreme values const MAX_SCALE = 100; // Prevent overflow issues /** * Gets the current LiteGraph display transform. * Clamps scale to safe values to prevent rendering issues at extreme zooms. * @returns {{ scale: number, offsetX: number, offsetY: number }} */ export function getTransform() { try { const ds = window.app?.canvas?.ds; if (ds) { // Clamp scale to safe range let scale = ds.scale || 1; scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale)); // Guard against NaN/Infinity if (!Number.isFinite(scale)) scale = 1; cachedTransform.scale = scale; cachedTransform.offsetX = Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : 0; cachedTransform.offsetY = Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : 0; } } catch (e) { // Use cached values if access fails console.warn('[Coordinates] Failed to get transform:', e); } return { ...cachedTransform }; } /** * Converts screen coordinates to canvas coordinates. * Screen coords are relative to the overlay canvas element. * * @param {number} screenX - X position in screen/CSS pixels * @param {number} screenY - Y position in screen/CSS pixels * @returns {{ x: number, y: number }} Position in canvas coordinate space */ export function screenToCanvas(screenX, screenY) { const { scale, offsetX, offsetY } = getTransform(); return { x: screenX / scale - offsetX, y: screenY / scale - offsetY }; } /** * Converts canvas coordinates to screen coordinates. * * @param {number} canvasX - X position in canvas coordinate space * @param {number} canvasY - Y position in canvas coordinate space * @returns {{ x: number, y: number }} Position in screen/CSS pixels */ export function canvasToScreen(canvasX, canvasY) { const { scale, offsetX, offsetY } = getTransform(); return { x: (canvasX + offsetX) * scale, y: (canvasY + offsetY) * scale }; } /** * Applies the current LiteGraph transform to a 2D canvas context. * After calling this, drawing operations use canvas coordinates. * * @param {CanvasRenderingContext2D} ctx - The rendering context * @param {number} [dpr=1] - Device pixel ratio (already applied to context) */ export function applyTransformToContext(ctx, dpr = 1) { const { scale, offsetX, offsetY } = getTransform(); // Note: DPR scaling is already handled by the overlay canvas setup // We just need to apply the LiteGraph transform ctx.setTransform( scale * dpr, // Horizontal scaling 0, // Horizontal skewing 0, // Vertical skewing scale * dpr, // Vertical scaling offsetX * scale * dpr, // Horizontal translation offsetY * scale * dpr // Vertical translation ); } /** * Resets the context transform to identity (accounting for DPR). * * @param {CanvasRenderingContext2D} ctx * @param {number} [dpr=1] */ export function resetContextTransform(ctx, dpr = 1) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } /** * Converts a distance in screen pixels to canvas units. * * @param {number} screenDistance * @returns {number} Distance in canvas units */ export function screenDistanceToCanvas(screenDistance) { const { scale } = getTransform(); return screenDistance / scale; } /** * Converts a distance in canvas units to screen pixels. * * @param {number} canvasDistance * @returns {number} Distance in screen pixels */ export function canvasDistanceToScreen(canvasDistance) { const { scale } = getTransform(); return canvasDistance * scale; } /** * Gets the visible canvas area in canvas coordinates. * Useful for determining what strokes are visible. * * @param {number} viewportWidth - Width of viewport in CSS pixels * @param {number} viewportHeight - Height of viewport in CSS pixels * @returns {{ minX: number, minY: number, maxX: number, maxY: number }} */ export function getVisibleCanvasArea(viewportWidth, viewportHeight) { const topLeft = screenToCanvas(0, 0); const bottomRight = screenToCanvas(viewportWidth, viewportHeight); return { minX: topLeft.x, minY: topLeft.y, maxX: bottomRight.x, maxY: bottomRight.y }; } /** * Checks if a point in canvas coordinates is within the visible viewport. * * @param {number} canvasX * @param {number} canvasY * @param {number} viewportWidth * @param {number} viewportHeight * @param {number} [padding=0] - Extra padding around viewport * @returns {boolean} */ export function isPointVisible(canvasX, canvasY, viewportWidth, viewportHeight, padding = 0) { const screen = canvasToScreen(canvasX, canvasY); return ( screen.x >= -padding && screen.x <= viewportWidth + padding && screen.y >= -padding && screen.y <= viewportHeight + padding ); } /** * Creates a transform tracking loop using requestAnimationFrame. * Calls the callback whenever the transform changes. * * @param {(transform: { scale: number, offsetX: number, offsetY: number }) => void} onChange * @returns {() => void} Cleanup function to stop tracking */ export function createTransformTracker(onChange) { let lastScale = -1; let lastOffsetX = -Infinity; let lastOffsetY = -Infinity; let rafId = null; let isRunning = true; function checkTransform() { if (!isRunning) return; const transform = getTransform(); if ( transform.scale !== lastScale || transform.offsetX !== lastOffsetX || transform.offsetY !== lastOffsetY ) { lastScale = transform.scale; lastOffsetX = transform.offsetX; lastOffsetY = transform.offsetY; onChange(transform); } rafId = requestAnimationFrame(checkTransform); } // Start tracking checkTransform(); // Return cleanup function return () => { isRunning = false; if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }; } // ============================================================================ // Debug Visualizer // ============================================================================ /** * Debug visualizer state */ let debugVisualizerEnabled = false; let debugRenderCallback = null; /** * Draws debug visualization showing coordinate system info. * Call this from the main render loop when debug mode is enabled. * * @param {CanvasRenderingContext2D} ctx - The rendering context * @param {number} width - Viewport width in CSS pixels * @param {number} height - Viewport height in CSS pixels * @param {number} dpr - Device pixel ratio */ export function drawDebugOverlay(ctx, width, height, dpr) { if (!debugVisualizerEnabled) return; const transform = getTransform(); // Reset to screen coordinates for debug UI ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Draw origin crosshair (where canvas 0,0 appears on screen) const origin = canvasToScreen(0, 0); ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); // Vertical line through origin ctx.beginPath(); ctx.moveTo(origin.x, 0); ctx.lineTo(origin.x, height); ctx.stroke(); // Horizontal line through origin ctx.beginPath(); ctx.moveTo(0, origin.y); ctx.lineTo(width, origin.y); ctx.stroke(); ctx.setLineDash([]); // Draw origin marker ctx.fillStyle = '#00FF00'; ctx.beginPath(); ctx.arc(origin.x, origin.y, 8, 0, Math.PI * 2); ctx.fill(); // Draw coordinate grid in canvas space drawDebugGrid(ctx, width, height, dpr, transform); // Draw info panel const padding = 10; const lineHeight = 18; const panelX = padding; const panelY = height - 120; // Background ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; ctx.fillRect(panelX, panelY, 280, 110); // Text ctx.fillStyle = '#00FF00'; ctx.font = '12px monospace'; const lines = [ `Scale: ${transform.scale.toFixed(3)}`, `Offset: [${transform.offsetX.toFixed(1)}, ${transform.offsetY.toFixed(1)}]`, `Origin on screen: (${origin.x.toFixed(1)}, ${origin.y.toFixed(1)})`, `Viewport: ${width}x${height} (DPR: ${dpr})`, `Mode: World-Space (strokes follow canvas)` ]; lines.forEach((line, i) => { ctx.fillText(line, panelX + 8, panelY + 20 + i * lineHeight); }); } /** * Draws a coordinate grid in canvas space. */ function drawDebugGrid(ctx, width, height, dpr, transform) { const gridSize = 100; // Grid spacing in canvas units // Get visible area in canvas coordinates const visible = getVisibleCanvasArea(width, height); // Calculate grid bounds (snap to grid) const startX = Math.floor(visible.minX / gridSize) * gridSize; const endX = Math.ceil(visible.maxX / gridSize) * gridSize; const startY = Math.floor(visible.minY / gridSize) * gridSize; const endY = Math.ceil(visible.maxY / gridSize) * gridSize; ctx.strokeStyle = 'rgba(0, 255, 0, 0.2)'; ctx.lineWidth = 1; // Draw vertical lines for (let x = startX; x <= endX; x += gridSize) { const screen = canvasToScreen(x, 0); ctx.beginPath(); ctx.moveTo(screen.x, 0); ctx.lineTo(screen.x, height); ctx.stroke(); // Label if (x !== 0) { ctx.fillStyle = 'rgba(0, 255, 0, 0.5)'; ctx.font = '10px monospace'; ctx.fillText(x.toString(), screen.x + 2, 12); } } // Draw horizontal lines for (let y = startY; y <= endY; y += gridSize) { const screen = canvasToScreen(0, y); ctx.beginPath(); ctx.moveTo(0, screen.y); ctx.lineTo(width, screen.y); ctx.stroke(); // Label if (y !== 0) { ctx.fillStyle = 'rgba(0, 255, 0, 0.5)'; ctx.font = '10px monospace'; ctx.fillText(y.toString(), 2, screen.y - 2); } } } /** * Enables or disables the debug visualizer. * @param {boolean} enabled * @param {() => void} [renderCallback] - Function to call to trigger re-render */ export function setDebugVisualizerEnabled(enabled, renderCallback = null) { debugVisualizerEnabled = enabled; debugRenderCallback = renderCallback; console.log(`[Coordinates] Debug visualizer ${enabled ? 'enabled' : 'disabled'}`); if (renderCallback) { renderCallback(); } } /** * Returns whether debug visualizer is enabled. * @returns {boolean} */ export function isDebugVisualizerEnabled() { return debugVisualizerEnabled; } /** * Runs coordinate transform tests and logs results. * Use this to verify the transform math is correct. */ export function runCoordinateTests() { console.log('=== Coordinate Transform Tests ==='); const transform = getTransform(); console.log('Current transform:', transform); // Test 1: Round-trip conversion console.log('\n--- Test 1: Round-trip Screen -> Canvas -> Screen ---'); const testPoints = [ { x: 0, y: 0 }, { x: 100, y: 100 }, { x: 500, y: 300 } ]; let allPassed = true; for (const pt of testPoints) { const canvas = screenToCanvas(pt.x, pt.y); const back = canvasToScreen(canvas.x, canvas.y); const dx = Math.abs(back.x - pt.x); const dy = Math.abs(back.y - pt.y); const passed = dx < 0.001 && dy < 0.001; if (!passed) allPassed = false; console.log(` Screen(${pt.x}, ${pt.y}) -> Canvas(${canvas.x.toFixed(2)}, ${canvas.y.toFixed(2)}) -> Screen(${back.x.toFixed(2)}, ${back.y.toFixed(2)}) ${passed ? '✓' : '✗'}`); } // Test 2: Origin check console.log('\n--- Test 2: Canvas Origin Position ---'); const originScreen = canvasToScreen(0, 0); console.log(` Canvas(0, 0) appears at Screen(${originScreen.x.toFixed(2)}, ${originScreen.y.toFixed(2)})`); console.log(` Expected: Screen(${(transform.offsetX * transform.scale).toFixed(2)}, ${(transform.offsetY * transform.scale).toFixed(2)})`); // Test 3: Scale check console.log('\n--- Test 3: Distance Scaling ---'); const screenDist = 100; const canvasDist = screenDistanceToCanvas(screenDist); const backDist = canvasDistanceToScreen(canvasDist); const distPassed = Math.abs(backDist - screenDist) < 0.001; console.log(` Screen distance ${screenDist} -> Canvas distance ${canvasDist.toFixed(2)} -> Screen distance ${backDist.toFixed(2)} ${distPassed ? '✓' : '✗'}`); console.log(` At scale ${transform.scale}, 100 screen pixels = ${canvasDist.toFixed(2)} canvas units`); console.log('\n=== Tests Complete ==='); return allPassed; }