statebuddy/src/statecharts/concrete_syntax.ts

144 lines
4 KiB
TypeScript

import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox, subtractV2D } from "../util/geometry";
import { ARROW_SNAP_THRESHOLD, HISTORY_RADIUS, ROUNTANGLE_RADIUS, TEXT_SNAP_THRESHOLD } from "../App/parameters";
import { VisualEditorState } from "../App/VisualEditor/VisualEditor";
import { sides } from "@/util/geometry";
export type Rountangle = {
uid: string;
kind: "and" | "or";
} & Rect2D;
export type Diamond = {
uid: string;
} & Rect2D;
export type Text = {
uid: string;
topLeft: Vec2D;
text: string;
};
export type Arrow = {
uid: string;
} & Line2D;
export type History = {
uid: string;
kind: "shallow" | "deep";
topLeft: Vec2D;
};
export type ConcreteSyntax = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
};
// independently moveable parts of our shapes:
export type RectSide = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end";
export const emptyState: VisualEditorState = {
rountangles: [], texts: [], arrows: [], diamonds: [], history: [], nextID: 0, selection: [],
};
// used to find which rountangle an arrow connects to (src/tgt)
export function findNearestSide(arrow: Line2D, arrowPart: "start" | "end", candidates: (Rountangle|Diamond)[]): {uid: string, part: RectSide} | undefined {
let best = Infinity;
let bestSide: undefined | {uid: string, part: RectSide};
for (const rountangle of candidates) {
for (const [side, getSide] of sides) {
const asLine = getSide(rountangle);
const intersection = intersectLines(arrow, asLine);
if (intersection !== null) {
const bbox = lineBBox(asLine, ARROW_SNAP_THRESHOLD);
const dist = euclideanDistance(arrow[arrowPart], intersection);
if (isWithin(arrow[arrowPart], bbox) && dist < best) {
best = dist;
bestSide = { uid: rountangle.uid, part: side };
}
}
}
}
return bestSide;
}
export function point2LineDistance(point: Vec2D, {start, end}: Line2D): number {
const A = point.x - start.x;
const B = point.y - start.y;
const C = end.x - start.x;
const D = end.y - start.y;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let t = lenSq ? dot / lenSq : -1;
if (t < 0) t = 0;
else if (t > 1) t = 1;
const closestX = start.x + t * C;
const closestY = start.y + t * D;
const dx = point.x - closestX;
const dy = point.y - closestY;
const distance = Math.hypot(dx, dy);
return distance;
}
// used to find which arrow a text label belongs to (if any)
// author: ChatGPT
export function findNearestArrow(point: Vec2D, candidates: Arrow[]): Arrow | undefined {
let best;
let bestDistance = Infinity
for (const arrow of candidates) {
const distance = point2LineDistance(point, arrow);
if (distance < TEXT_SNAP_THRESHOLD && distance < bestDistance) {
bestDistance = distance;
best = arrow;
}
}
return best;
}
// precondition: candidates are sorted from big to small
export function findRountangle(point: Vec2D, candidates: Rountangle[]): Rountangle | undefined {
for (let i=candidates.length-1; i>=0; i--) {
if (isWithin(point, candidates[i])) {
return candidates[i];
}
}
}
export function findNearestHistory(point: Vec2D, candidates: History[]): History | undefined {
let best;
let bestDistance = Infinity;
for (const h of candidates) {
const diff = subtractV2D(point, {x: h.topLeft.x+HISTORY_RADIUS, y: h.topLeft.y+HISTORY_RADIUS});
const euclideanDistance = Math.hypot(diff.x, diff.y) - HISTORY_RADIUS;
if (euclideanDistance < ARROW_SNAP_THRESHOLD) {
if (euclideanDistance < bestDistance) {
best = h;
bestDistance = euclideanDistance;
}
}
}
return best;
}
export function rountangleMinSize(size: Vec2D): Vec2D {
const minSize = ROUNTANGLE_RADIUS * 2;
if (size.x >= minSize && size.y >= minSize) {
return size;
}
return {
x: Math.max(minSize, size.x),
y: Math.max(minSize, size.y),
};
}