450 lines
14 KiB
JavaScript
450 lines
14 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|