Files
2026-01-18 00:13:02 +09:00

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