import type { CanvasNode } from "~/types/canvas"; import type { CanvasPreferences } from "~/types/general"; import type { Position, Box, Direction, CanvasEditor } from "./canvas.util"; interface SnapPoint { pos: Position; type: TYPE; side?: Direction; } export interface SnapHint { start: Position, end?: Position, } interface SnapConfig { preferences: CanvasPreferences; threshold: number; cellSize: number; gridSize: number; } const enum TYPE { CENTER, CORNER, EDGE, } export function overlapsBoxes(a: Box, b: Box): boolean { return overlaps(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height); } export function overlaps(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean { return !(bx > (ax + aw) || (bx + bw) < ax || by > (ay + ah) || (by + bh) < ay); } export class SpatialGrid { private cells: Map>> = new Map(); private cellSize: number; private minx: number = Infinity; private miny: number = Infinity; private maxx: number = -Infinity; private maxy: number = -Infinity; private cacheSet: Set = new Set(); constructor(cellSize: number) { this.cellSize = cellSize; } private updateBorders(startX: number, startY: number, endX: number, endY: number) { this.minx = Math.min(this.minx, startX); this.miny = Math.min(this.miny, startY); this.maxx = Math.max(this.maxx, endX); this.maxy = Math.max(this.maxy, endY); } insert(node: T): void { const startX = Math.floor(node.x / this.cellSize); const startY = Math.floor(node.y / this.cellSize); const endX = Math.ceil((node.x + node.width) / this.cellSize); const endY = Math.ceil((node.y + node.height) / this.cellSize); this.updateBorders(startX, startY, endX, endY); for (let i = startX; i <= endX; i++) { let gridX = this.cells.get(i); if (!gridX) { gridX = new Map>(); this.cells.set(i, gridX); } for (let j = startY; j <= endY; j++) { let gridY = gridX.get(j); if (!gridY) { gridY = new Set(); gridX.set(j, gridY); } gridY.add(node); } } } remove(node: T): void { const startX = Math.floor(node.x / this.cellSize); const startY = Math.floor(node.y / this.cellSize); const endX = Math.ceil((node.x + node.width) / this.cellSize); const endY = Math.ceil((node.y + node.height) / this.cellSize); for (let i = startX; i <= endX; i++) { const gridX = this.cells.get(i); if (gridX) { for (let j = startY; j <= endY; j++) { gridX.get(j)?.delete(node); } } } } fetch(x: number, y: number): Set | undefined { return this.query(x, y, x, y); } query(x1: number, y1: number, x2: number, y2: number): Set { this.cacheSet.clear(); const startX = Math.floor(x1 / this.cellSize); const startY = Math.floor(y1 / this.cellSize); const endX = Math.ceil(x2 / this.cellSize); const endY = Math.ceil(y2 / this.cellSize); for (let dx = startX; dx <= endX; dx++) { const gridX = this.cells.get(dx); if (gridX) { for (let dy = startY; dy <= endY; dy++) { const cellNodes = gridX.get(dy); if (cellNodes) { cellNodes.forEach(neighbor => !this.cacheSet.has(neighbor) && (overlaps(x1, y1, x2 - x1, y2 - y1, neighbor.x, neighbor.y, neighbor.width, neighbor.height)) && this.cacheSet.add(neighbor)); } } } } return this.cacheSet; } getViewportNeighbors(node: T, viewport?: Box): Set { this.cacheSet.clear(); const startX = Math.floor(node.x / this.cellSize); const startY = Math.floor(node.y / this.cellSize); const endX = Math.ceil((node.x + node.width) / this.cellSize); const endY = Math.ceil((node.y + node.height) / this.cellSize); const minX = Math.max(viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx, startX - 8); const minY = Math.max(viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny, startY - 8); const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.width) / this.cellSize)) : this.maxx, endX + 8); const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.height) / this.cellSize)) : this.maxy, endY + 8); for (let dx = minX; dx <= maxX; dx++) { const gridX = this.cells.get(dx); if (gridX) { for (let dy = startY; dy <= endY; dy++) { const cellNodes = gridX.get(dy); if (cellNodes) { cellNodes.forEach(neighbor => { if (neighbor !== node) this.cacheSet.add(neighbor); }); } } } } for (let dx = startX; dx <= endX; dx++) { const gridX = this.cells.get(dx); if (gridX) { for (let dy = minY; dy <= maxY; dy++) { const cellNodes = gridX.get(dy); if (cellNodes) { cellNodes.forEach(neighbor => { if (neighbor !== node) this.cacheSet.add(neighbor); }); } } } } return this.cacheSet; } } class SnapPointCache { private cache: Map; constructor() { this.cache = new Map(); } getSnapPoints(node: string): SnapPoint[] | undefined { return this.cache.get(node); } private calculateSnapPoints(node: CanvasNode): SnapPoint[] { const centerX = node.x + node.width / 2; const centerY = node.y + node.height / 2; return [ { pos: { x: centerX, y: centerY }, type: TYPE.CENTER }, { pos: { x: node.x, y: node.y }, type: TYPE.CORNER }, { pos: { x: node.x + node.width, y: node.y }, type: TYPE.CORNER }, { pos: { x: node.x, y: node.y + node.height }, type: TYPE.CORNER }, { pos: { x: node.x + node.width, y: node.y + node.height }, type: TYPE.CORNER }, { pos: { x: centerX, y: node.y }, type: TYPE.EDGE, side: 'top' }, { pos: { x: node.x, y: centerY }, type: TYPE.EDGE, side: 'left' }, { pos: { x: centerX, y: node.y + node.height }, type: TYPE.EDGE, side: 'bottom' }, { pos: { x: node.x + node.width, y: centerY }, type: TYPE.EDGE, side: 'right' }, ]; } insert(node: CanvasNode): void { if (!this.cache.has(node.id)) { this.cache.set(node.id, this.calculateSnapPoints(node)); } } invalidate(node: CanvasNode): void { this.cache.delete(node.id); } } export class SnapFinder { private spatialGrid: SpatialGrid; private snapPointCache: SnapPointCache; private config: SnapConfig; private canvas: CanvasEditor; private hints: SnapHint[] = []; constructor(canvas: CanvasEditor, config: SnapConfig) { this.spatialGrid = new SpatialGrid(config.cellSize); this.snapPointCache = new SnapPointCache(); this.config = config; this.canvas = canvas; } add(node: CanvasNode): void { this.spatialGrid.insert(node); this.snapPointCache.insert(node); this.hints.length = 0; } remove(node: CanvasNode): void { this.spatialGrid.remove(node); this.snapPointCache.invalidate(node); this.hints.length = 0; } update(node: CanvasNode): void { this.remove(node); this.add(node); } findEdgeSnapPosition(node: string, x: number, y: number): { x: number, y: number, node: string, direction: Direction } | undefined { const near = [...this.spatialGrid.fetch(x, y)?.values().filter(e => e.id !== node).flatMap(e => this.snapPointCache.getSnapPoints(e.id)?.map(_e => ({ ..._e, node: e })) ?? []) ?? []].filter(e => e.type === TYPE.EDGE); let nearestDistance = this.config.threshold, nearest = undefined; for (const point of near) { const distance = Math.hypot(point.pos.x - x, point.pos.y - y); if (distance < nearestDistance) { nearestDistance = distance; nearest = { ...point.pos, node: point.node.id, direction: point.side! }; } } return nearest; } findNodeSnapPosition(node: CanvasNode, resizeHandle?: Box): Partial { const result: Partial = { x: undefined, y: undefined, width: undefined, height: undefined, }; if(!this.config.preferences.neighborSnap) { result.x = this.snapToGrid(node.x); result.width = this.snapToGrid(node.width); result.y = this.snapToGrid(node.y); result.height = this.snapToGrid(node.height); return result; } this.hints.length = 0; this.snapPointCache.invalidate(node); this.snapPointCache.insert(node); const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.canvas.viewport)].flatMap(e => this.snapPointCache.getSnapPoints(e.id)).filter(e => !!e); const bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle); return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle); } private findBestSnap(activePoints: SnapPoint[], otherPoints: SnapPoint[], threshold: number, resizeHandle?: Box): Partial { let bestSnap: Partial = {}; let bestDiffX = threshold, bestDiffY = threshold; let xHints: SnapHint[] = [], yHints: SnapHint[] = []; for (const activePoint of activePoints) { if (activePoint.type === TYPE.EDGE) continue; if (!!resizeHandle && activePoint.type !== TYPE.CORNER) continue; for (const otherPoint of otherPoints) { if (otherPoint.type === TYPE.EDGE) continue; if (!!resizeHandle && otherPoint.type !== TYPE.CORNER) continue; const diffX = Math.abs(otherPoint.pos.x - activePoint.pos.x); const diffY = Math.abs(otherPoint.pos.y - activePoint.pos.y); if (diffX < bestDiffX) { bestDiffX = diffX; bestSnap.x = otherPoint.pos.x - activePoint.pos.x; xHints = [{ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }]; } else if(diffX === bestDiffX) { xHints.push({ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }); } if (diffY < bestDiffY) { bestDiffY = diffY; bestSnap.y = otherPoint.pos.y - activePoint.pos.y; yHints = [{ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }]; } else if(diffY === bestDiffY) { yHints.push({ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }); } } } if(bestSnap.x && bestSnap.y) { xHints.forEach(e => e.start.y += bestSnap.y!); yHints.forEach(e => e.start.x += bestSnap.x!); } this.hints = [...xHints, ...yHints]; return bestSnap; } private snapToGrid(pos?: number): number | undefined { return pos && this.config.preferences.gridSnap && this.config.preferences.spacing ? Math.round(pos / this.config.preferences.spacing) * this.config.preferences.spacing : undefined; } private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial { const result: Partial = { x: undefined, y: undefined, width: undefined, height: undefined }; if (resizeHandle) { result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x); result.width = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width); result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y); result.height = offsety ? node.height - offsety * resizeHandle.height : this.snapToGrid(node.height); } else { result.x = offsetx ? node.x + offsetx : this.snapToGrid(node.x); result.y = offsety ? node.y + offsety : this.snapToGrid(node.y); } return result; } }