369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import type { CanvasContent, 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<T extends Box> {
|
|
private cells: Map<number, Map<number, Set<T>>> = new Map();
|
|
private cellSize: number;
|
|
|
|
private minx: number = Infinity;
|
|
private miny: number = Infinity;
|
|
private maxx: number = -Infinity;
|
|
private maxy: number = -Infinity;
|
|
|
|
private cacheSet: Set<T> = new Set<T>();
|
|
|
|
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<number, Set<T>>();
|
|
this.cells.set(i, gridX);
|
|
}
|
|
|
|
for (let j = startY; j <= endY; j++) {
|
|
let gridY = gridX.get(j);
|
|
if (!gridY) {
|
|
gridY = new Set<T>();
|
|
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<T> | undefined {
|
|
return this.query(x, y, x, y);
|
|
}
|
|
|
|
query(x1: number, y1: number, x2: number, y2: number): Set<T> {
|
|
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<T> {
|
|
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<string, SnapPoint[]>;
|
|
|
|
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<CanvasNode>;
|
|
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 !== node).flatMap(e => this.snapPointCache.getSnapPoints(e)?.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, direction: point.side! };
|
|
}
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
findNodeSnapPosition(node: CanvasNode, resizeHandle?: Box): Partial<Box>
|
|
{
|
|
const result: Partial<Box> = {
|
|
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)).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<Position>
|
|
{
|
|
let bestSnap: Partial<Position> = {};
|
|
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<Box>
|
|
{
|
|
const result: Partial<Box> = { x: undefined, y: undefined, width: undefined, height: undefined };
|
|
|
|
if (resizeHandle)
|
|
{
|
|
result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x);
|
|
result.w = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width);
|
|
result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y);
|
|
result.h = 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;
|
|
}
|
|
} |