import type { CanvasLayer, CanvasSnapItem, SnapCandidate } from './types'; export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; export const CANVAS_ZOOM_IN_FACTOR = 1.16; export const CANVAS_ZOOM_OUT_FACTOR = 0.86; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const FIT_VIEW_PADDING = 10; export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; export const MINIMAP_DRAG_SENSITIVITY = 0.3; export const MAX_HISTORY_STEPS = 60; export const SNAP_THRESHOLD_SCREEN_PX = 18; export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1; const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3; export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } export function formatCanvasDisplayScalePercent(scale: number) { const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE; return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE); } export function canvasDisplayScaleToViewportScale(displayScale: number) { const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1; return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE; } export function viewportScaleToCanvasDisplayScale(scale: number) { const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE; return safeScale / CANVAS_DISPLAY_SCALE_BASE; } export function getLayerBounds(targetLayers: CanvasLayer[]) { if (targetLayers.length === 0) { return null; } return targetLayers.reduce( (current, layer) => ({ minX: Math.min(current.minX, layer.x), minY: Math.min(current.minY, layer.y), maxX: Math.max(current.maxX, layer.x + layer.width), maxY: Math.max(current.maxY, layer.y + layer.height), }), { minX: Number.POSITIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY, }, ); } export function resolveSnappedItemPosition( movingItem: CanvasSnapItem, proposedX: number, proposedY: number, items: CanvasSnapItem[], scale: number, ) { const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE); const visibleItems = items.filter( (item) => item.id !== movingItem.id && !item.hidden, ); const verticalTargets = [ 0, CANVAS_WORLD_ORIGIN, ...visibleItems.flatMap((item) => [ item.x, item.x + item.width / 2, item.x + item.width, ]), ]; const horizontalTargets = [ 0, CANVAS_WORLD_ORIGIN, ...visibleItems.flatMap((item) => [ item.y, item.y + item.height / 2, item.y + item.height, ]), ]; const xAlignmentSnap = findNearestSnap( proposedX, [0, movingItem.width / 2, movingItem.width], verticalTargets, threshold, ); const yAlignmentSnap = findNearestSnap( proposedY, [0, movingItem.height / 2, movingItem.height], horizontalTargets, threshold, ); const xDistributionSnap = findNearestEqualSpacingSnap({ axis: 'x', proposedStart: proposedX, proposedCrossStart: proposedY, movingSize: movingItem.width, movingCrossSize: movingItem.height, items: visibleItems, threshold, }); const yDistributionSnap = findNearestEqualSpacingSnap({ axis: 'y', proposedStart: proposedY, proposedCrossStart: proposedX, movingSize: movingItem.height, movingCrossSize: movingItem.width, items: visibleItems, threshold, }); const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap); const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap); return { x: xSnap ? xSnap.position : proposedX, y: ySnap ? ySnap.position : proposedY, guide: xSnap || ySnap ? { vertical: xSnap?.guide, horizontal: ySnap?.guide } : null, }; } function chooseNearestSnap( first: SnapCandidate | null, second: SnapCandidate | null, ) { if (!first) return second; if (!second) return first; return second.distance < first.distance ? second : first; } function findNearestEqualSpacingSnap({ axis, proposedStart, proposedCrossStart, movingSize, movingCrossSize, items, threshold, }: { axis: 'x' | 'y'; proposedStart: number; proposedCrossStart: number; movingSize: number; movingCrossSize: number; items: CanvasSnapItem[]; threshold: number; }): SnapCandidate | null { const orderedItems = items .filter((item) => snapItemsOverlapOnCrossAxis( proposedCrossStart, movingCrossSize, item, axis, ), ) .sort( (firstItem, secondItem) => getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis), ); let nearest: SnapCandidate | null = null; for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) { const firstItem = orderedItems[firstIndex]; if (!firstItem) continue; for ( let secondIndex = firstIndex + 1; secondIndex < orderedItems.length && secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD; secondIndex += 1 ) { const secondItem = orderedItems[secondIndex]; if (!secondItem) continue; const firstStart = getSnapItemStart(firstItem, axis); const firstEnd = getSnapItemEnd(firstItem, axis); const secondStart = getSnapItemStart(secondItem, axis); const secondEnd = getSnapItemEnd(secondItem, axis); const pairGap = secondStart - firstEnd; if (pairGap < 0) continue; nearest = chooseNearestSnap( nearest, createDistributionSnapCandidate({ position: firstStart - pairGap - movingSize, proposedStart, movingSize, threshold, }), ); nearest = chooseNearestSnap( nearest, createDistributionSnapCandidate({ position: secondEnd + pairGap, proposedStart, movingSize, threshold, }), ); const betweenGap = (pairGap - movingSize) / 2; if (betweenGap >= 0) { nearest = chooseNearestSnap( nearest, createDistributionSnapCandidate({ position: firstEnd + betweenGap, proposedStart, movingSize, threshold, }), ); } } } return nearest; } function createDistributionSnapCandidate({ position, proposedStart, movingSize, threshold, }: { position: number; proposedStart: number; movingSize: number; threshold: number; }): SnapCandidate | null { const distance = Math.abs(position - proposedStart); return distance > threshold ? null : { position, guide: position + movingSize / 2, distance }; } function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') { return axis === 'x' ? item.x : item.y; } function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') { return axis === 'x' ? item.width : item.height; } function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') { return getSnapItemStart(item, axis) + getSnapItemSize(item, axis); } function snapItemsOverlapOnCrossAxis( proposedCrossStart: number, movingCrossSize: number, item: CanvasSnapItem, axis: 'x' | 'y', ) { const itemCrossStart = axis === 'x' ? item.y : item.x; const itemCrossSize = axis === 'x' ? item.height : item.width; return ( proposedCrossStart <= itemCrossStart + itemCrossSize - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE && proposedCrossStart + movingCrossSize >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE ); } export function findNearestSnap( origin: number, offsets: number[], targets: number[], threshold: number, ): SnapCandidate | null { let nearest: SnapCandidate | null = null; for (const offset of offsets) { for (const target of targets) { const distance = Math.abs(target - (origin + offset)); if (distance <= threshold && (!nearest || distance < nearest.distance)) { nearest = { position: target - offset, guide: target, distance }; } } } return nearest; }