1
7
8import { ANCHORS, type Anchor, type CanvasElement, type ConnectorElement } from "./schema.js";
9
10export interface Rect {
11 x: number;
12 y: number;
13 w: number;
14 h: number;
15}
16
17
18export function unionRect(a: Rect, b: Rect): Rect {
19 const x = Math.min(a.x, b.x);
20 const y = Math.min(a.y, b.y);
21 const x2 = Math.max(a.x + a.w, b.x + b.w);
22 const y2 = Math.max(a.y + a.h, b.y + b.h);
23 return { x, y, w: x2 - x, h: y2 - y };
24}
25
26export function unionAll(rects: Rect[]): Rect {
27 return rects.reduce((acc, r) => unionRect(acc, r), { x: 0, y: 0, w: 0, h: 0 });
28}
29
30
31export function elementBounds(el: CanvasElement): Rect {
32 return { x: el.x, y: el.y, w: el.w, h: el.h };
33}
34
35
36export function pointInRect(px: number, py: number, r: Rect): boolean {
37 return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
38}
39
40const EPS = 1e-9;
41
42function distToSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
43 const dx = bx - ax;
44 const dy = by - ay;
45 const len2 = dx * dx + dy * dy;
46 let t = len2 === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / len2;
47 t = Math.max(0, Math.min(1, t));
48 const cx = ax + t * dx;
49 const cy = ay + t * dy;
50 return Math.hypot(px - cx, py - cy);
51}
52
53
58export function hitTest(el: CanvasElement, px: number, py: number, tolerance = 6, elements?: Record<string, CanvasElement>): boolean {
59 switch (el.type) {
60 case "sticky":
61 case "text":
62 case "rect":
63 case "ellipse":
64 case "group":
65 return pointInRect(px, py, elementBounds(el));
66 case "freehand": {
67 const pts = el.points;
68 for (let i = 0; i + 3 < pts.length; i += 2) {
69 if (distToSegment(px, py, pts[i]!, pts[i + 1]!, pts[i + 2]!, pts[i + 3]!) <= tolerance + el.strokeWidth / 2) return true;
70 }
71 return false;
72 }
73 case "connector": {
74 if (!elements) return false;
75 const a = elements[el.from.elementId];
76 const b = elements[el.to.elementId];
77 if (!a || !b) return false;
78 const p1 = anchorPoint(a, el.from.anchor);
79 const p2 = anchorPoint(b, el.to.anchor);
80 if (el.kind === "straight") return distToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= tolerance;
81
82 const midX = p1.x + (p2.x - p1.x) / 2;
83 const midY = p1.y + (p2.y - p1.y) / 2;
84 const horizontal = Math.abs(p2.x - p1.x) >= Math.abs(p2.y - p1.y);
85 const segs: [number, number, number, number][] = horizontal
86 ? [
87 [p1.x, p1.y, midX, p1.y],
88 [midX, p1.y, midX, p2.y],
89 [midX, p2.y, p2.x, p2.y]
90 ]
91 : [
92 [p1.x, p1.y, p1.x, midY],
93 [p1.x, midY, p2.x, midY],
94 [p2.x, midY, p2.x, p2.y]
95 ];
96 return segs.some(([ax, ay, bx, by]) => distToSegment(px, py, ax, ay, bx, by) <= tolerance);
97 }
98 }
99}
100
101
102export function anchorPoint(el: CanvasElement, anchor: Anchor): { x: number; y: number } {
103 const cx = el.x + el.w / 2;
104 const cy = el.y + el.h / 2;
105 switch (anchor) {
106 case "top":
107 return { x: cx, y: el.y };
108 case "bottom":
109 return { x: cx, y: el.y + el.h };
110 case "left":
111 return { x: el.x, y: cy };
112 case "right":
113 return { x: el.x + el.w, y: cy };
114 }
115}
116
117
121export function nearestAnchor(el: CanvasElement, p: { x: number; y: number }): Anchor {
122 let best: Anchor = "top";
123 let bestD = Infinity;
124 for (const a of ANCHORS) {
125 const ap = anchorPoint(el, a);
126 const d = (ap.x - p.x) ** 2 + (ap.y - p.y) ** 2;
127 if (d < bestD) {
128 bestD = d;
129 best = a;
130 }
131 }
132 return best;
133}
134
135
141export function connectorPath(from: { x: number; y: number }, to: { x: number; y: number }, kind: "straight" | "orthogonal"): string {
142 if (kind === "straight") return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
143 const dx = to.x - from.x;
144 const dy = to.y - from.y;
145 if (Math.abs(dx) >= Math.abs(dy)) {
146 const midX = from.x + dx / 2;
147 return `M ${from.x} ${from.y} L ${midX} ${from.y} L ${midX} ${to.y} L ${to.x} ${to.y}`;
148 }
149 const midY = from.y + dy / 2;
150 return `M ${from.x} ${from.y} L ${from.x} ${midY} L ${to.x} ${midY} L ${to.x} ${to.y}`;
151}
152
153
154export function arrowHead(to: { x: number; y: number }, dir: { x: number; y: number }, size = 10): [number, number][] {
155 const len = Math.hypot(dir.x, dir.y) || 1;
156 const ux = dir.x / len;
157 const uy = dir.y / len;
158 const px = -uy;
159 const py = ux;
160 const bx = to.x - ux * size;
161 const by = to.y - uy * size;
162 return [
163 [to.x, to.y],
164 [bx + px * size * 0.5, by + py * size * 0.5],
165 [bx - px * size * 0.5, by - py * size * 0.5]
166 ];
167}
168
169
174export function decimatePoints(points: number[], tolerance: number): number[] {
175 const n = points.length / 2;
176 if (n <= 2) return points.slice();
177 const keep = new Array<boolean>(n).fill(false);
178 keep[0] = true;
179 keep[n - 1] = true;
180 const stack: [number, number][] = [[0, n - 1]];
181 while (stack.length > 0) {
182 const [s, e] = stack.pop()!;
183 let maxD = -1;
184 let idx = -1;
185 const ax = points[s * 2]!;
186 const ay = points[s * 2 + 1]!;
187 const bx = points[e * 2]!;
188 const by = points[e * 2 + 1]!;
189 for (let i = s + 1; i < e; i++) {
190 const d = distToSegment(points[i * 2]!, points[i * 2 + 1]!, ax, ay, bx, by);
191 if (d > maxD) {
192 maxD = d;
193 idx = i;
194 }
195 }
196 if (maxD > tolerance && idx !== -1) {
197 keep[idx] = true;
198 stack.push([s, idx], [idx, e]);
199 }
200 }
201 const out: number[] = [];
202 for (let i = 0; i < n; i++) {
203 if (keep[i]) out.push(points[i * 2]!, points[i * 2 + 1]!);
204 }
205 return out;
206}
207
208
209export interface Viewport {
210 scale: number;
211 tx: number;
212 ty: number;
213}
214
215export function worldToScreen(vp: Viewport, x: number, y: number): { x: number; y: number } {
216 return { x: x * vp.scale + vp.tx, y: y * vp.scale + vp.ty };
217}
218export function screenToWorld(vp: Viewport, x: number, y: number): { x: number; y: number } {
219 return { x: (x - vp.tx) / vp.scale, y: (y - vp.ty) / vp.scale };
220}
221
222
223export function zoomAt(vp: Viewport, anchorScreenX: number, anchorScreenY: number, factor: number, minScale = 0.05, maxScale = 8): Viewport {
224 const scale = Math.min(maxScale, Math.max(minScale, vp.scale * factor));
225 const world = screenToWorld(vp, anchorScreenX, anchorScreenY);
226 return { scale, tx: anchorScreenX - world.x * scale, ty: anchorScreenY - world.y * scale };
227}
228
229
238export function viewportToWorldRect(vp: Viewport, width: number, height: number): Rect | null {
239 if (!(width > 0) || !(height > 0)) return null;
240 const a = screenToWorld(vp, 0, 0);
241 const b = screenToWorld(vp, width, height);
242 return {
243 x: Math.min(a.x, b.x),
244 y: Math.min(a.y, b.y),
245 w: Math.abs(b.x - a.x),
246 h: Math.abs(b.y - a.y)
247 };
248}
249
250
252export function rectsIntersect(a: Rect, b: Rect): boolean {
253 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
254}
255
256
263export function displayBounds(el: CanvasElement, elements: Record<string, CanvasElement>): Rect {
264 if (el.type === "connector") {
265 const from = elements[el.from.elementId];
266 const to = elements[el.to.elementId];
267 if (from && to) return unionRect(elementBounds(from), elementBounds(to));
268 return elementBounds(el);
269 }
270 return elementBounds(el);
271}
272
273
278export function transformElement(el: CanvasElement, dx: number, dy: number): CanvasElement {
279 if (el.type === "freehand") {
280 const points = el.points.slice();
281 for (let i = 0; i < points.length; i += 2) {
282 points[i] = (points[i] ?? 0) + dx;
283 points[i + 1] = (points[i + 1] ?? 0) + dy;
284 }
285 return { ...el, x: el.x + dx, y: el.y + dy, points };
286 }
287 return { ...el, x: el.x + dx, y: el.y + dy };
288}
289
290
291export function isConnector(el: CanvasElement): el is ConnectorElement {
292 return el.type === "connector";
293}
294