obsidian-visualiser/shared/physics.util.ts

332 lines
12 KiB
TypeScript

import type { CanvasContent, CanvasNode } from "~/types/canvas";
import type { CanvasPreferences } from "~/types/general";
import type { Position, Box, Direction } 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,
}
class SpatialGrid {
private cells: Map<number, Map<number, Set<string>>> = new Map();
private cellSize: number;
private minx: number = Infinity;
private miny: number = Infinity;
private maxx: number = -Infinity;
private maxy: number = -Infinity;
private cacheSet: Set<string> = new Set<string>();
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: CanvasNode): 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<string>>();
this.cells.set(i, gridX);
}
for (let j = startY; j <= endY; j++) {
let gridY = gridX.get(j);
if (!gridY) {
gridY = new Set<string>();
gridX.set(j, gridY);
}
gridY.add(node.id);
}
}
}
remove(node: CanvasNode): 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.id);
}
}
}
}
fetch(x: number, y: number): Set<string> | undefined {
return this.query(x, y, x, y);
}
query(x1: number, y1: number, x2: number, y2: number): Set<string> {
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.add(neighbor));
}
}
}
}
return this.cacheSet;
}
getViewportNeighbors(node: CanvasNode, viewport?: Box): Set<string> {
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 = viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx;
const minY = viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny;
const maxX = viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.w) / this.cellSize)) : this.maxx;
const maxY = viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.h) / this.cellSize)) : this.maxy;
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.id) 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.id) 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;
private snapPointCache: SnapPointCache;
private config: SnapConfig;
hints: Ref<SnapHint[]>;
viewport: Ref<Box>;
constructor(hints: Ref<SnapHint[]>, viewport: Ref<Box>, config: SnapConfig) {
this.spatialGrid = new SpatialGrid(config.cellSize);
this.snapPointCache = new SnapPointCache();
this.config = config;
this.hints = hints;
this.viewport = viewport;
}
add(node: CanvasNode): void {
this.spatialGrid.insert(node);
this.snapPointCache.insert(node);
this.hints.value.length = 0;
}
remove(node: CanvasNode): void {
this.spatialGrid.remove(node);
this.snapPointCache.invalidate(node);
this.hints.value.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,
w: undefined,
h: undefined,
};
this.hints.value.length = 0;
this.snapPointCache.invalidate(node);
this.snapPointCache.insert(node);
const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.viewport.value)].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.value = [...xHints, ...yHints];
return bestSnap;
}
private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial<Box> {
const result: Partial<Box> = { x: undefined, y: undefined, w: undefined, h: undefined };
if (resizeHandle) {
if (offsetx) result.x = node.x + offsetx * resizeHandle.x;
if (offsetx) result.w = node.width + offsetx * resizeHandle.w;
if (offsety) result.y = node.y + offsety * resizeHandle.y;
if (offsety) result.h = node.height - offsety * resizeHandle.h;
} else {
if (offsetx) result.x = node.x + offsetx;
if (offsety) result.y = node.y + offsety;
}
//console.log(result, offsetx, offsety);
return result;
}
}