@archcode-io/engine 0.2.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/dist/src/capacity.d.ts +67 -0
- package/dist/src/capacity.js +227 -0
- package/dist/src/check.d.ts +21 -0
- package/dist/src/check.js +192 -0
- package/dist/src/compiled.d.ts +61 -0
- package/dist/src/compiled.js +91 -0
- package/dist/src/cst.d.ts +76 -0
- package/dist/src/cst.js +1 -0
- package/dist/src/diagnostics.d.ts +9 -0
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/edit.d.ts +48 -0
- package/dist/src/edit.js +442 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +24 -0
- package/dist/src/layout/balance.d.ts +34 -0
- package/dist/src/layout/balance.js +327 -0
- package/dist/src/layout/elk.d.ts +64 -0
- package/dist/src/layout/elk.js +267 -0
- package/dist/src/layout/host.d.ts +23 -0
- package/dist/src/layout/host.js +23 -0
- package/dist/src/layout/label.d.ts +49 -0
- package/dist/src/layout/label.js +113 -0
- package/dist/src/layout/measure.d.ts +11 -0
- package/dist/src/layout/measure.js +27 -0
- package/dist/src/layout/ortho.d.ts +54 -0
- package/dist/src/layout/ortho.js +206 -0
- package/dist/src/layout/route.d.ts +57 -0
- package/dist/src/layout/route.js +230 -0
- package/dist/src/lens.d.ts +83 -0
- package/dist/src/lens.js +377 -0
- package/dist/src/lexer.d.ts +7 -0
- package/dist/src/lexer.js +135 -0
- package/dist/src/model.d.ts +63 -0
- package/dist/src/model.js +114 -0
- package/dist/src/parser.d.ts +7 -0
- package/dist/src/parser.js +305 -0
- package/dist/src/render/svg.d.ts +56 -0
- package/dist/src/render/svg.js +289 -0
- package/dist/src/serialize.d.ts +10 -0
- package/dist/src/serialize.js +12 -0
- package/dist/src/tokens.d.ts +26 -0
- package/dist/src/tokens.js +15 -0
- package/dist/src/vocab.d.ts +38 -0
- package/dist/src/vocab.js +58 -0
- package/package.json +61 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
let factory = null;
|
|
2
|
+
let instance = null;
|
|
3
|
+
/** Replace how ELK instances are made. Takes effect for the next layout. */
|
|
4
|
+
export function configureElk(make) {
|
|
5
|
+
factory = make;
|
|
6
|
+
instance = null;
|
|
7
|
+
}
|
|
8
|
+
/** The shared ELK instance — created on first use, reused after. */
|
|
9
|
+
export function elkInstance() {
|
|
10
|
+
instance ??= factory
|
|
11
|
+
? Promise.resolve(factory())
|
|
12
|
+
: import('elkjs/lib/elk.bundled.js').then(m => new m.default());
|
|
13
|
+
return instance;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run ELK in a Web Worker: `url` is where the host serves `elk-worker.min.js`
|
|
17
|
+
* from elkjs. The API shim on this side is small; the algorithms load in the
|
|
18
|
+
* worker, off the UI thread.
|
|
19
|
+
*/
|
|
20
|
+
export async function useElkWorker(url) {
|
|
21
|
+
const { default: ELK } = await import('elkjs/lib/elk-api.js');
|
|
22
|
+
configureElk(() => new ELK({ workerUrl: url }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface Pt {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Where a connector's label belongs: the midpoint **by length**, not the middle
|
|
7
|
+
* element of the point list. An orthogonal route is made of segments of wildly
|
|
8
|
+
* different lengths, so picking the middle vertex parks the text in a corner.
|
|
9
|
+
*/
|
|
10
|
+
export declare function labelAnchor(points: readonly Pt[], at?: number): {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
horizontal: boolean;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Box and text placement for a label of the given width at that anchor.
|
|
17
|
+
* `flip` puts it on the other side of the line: below a horizontal run
|
|
18
|
+
* instead of above, left of a vertical run instead of right.
|
|
19
|
+
*/
|
|
20
|
+
export declare function labelBox(points: readonly Pt[], width: number, at?: number, flip?: boolean): {
|
|
21
|
+
rectX: number;
|
|
22
|
+
rectY: number;
|
|
23
|
+
textX: number;
|
|
24
|
+
textY: number;
|
|
25
|
+
anchor: "middle";
|
|
26
|
+
} | {
|
|
27
|
+
rectX: number;
|
|
28
|
+
rectY: number;
|
|
29
|
+
textX: number;
|
|
30
|
+
textY: number;
|
|
31
|
+
anchor: "end";
|
|
32
|
+
} | {
|
|
33
|
+
rectX: number;
|
|
34
|
+
rectY: number;
|
|
35
|
+
textX: number;
|
|
36
|
+
textY: number;
|
|
37
|
+
anchor: "start";
|
|
38
|
+
};
|
|
39
|
+
/** Which side of the nearest segment a point is on: true = the flipped side (below / left). */
|
|
40
|
+
export declare function flippedSide(points: readonly Pt[], p: Pt): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* An orthogonal polyline as an SVG path with softly rounded corners. Straight
|
|
43
|
+
* runs stay straight; each bend becomes a small quadratic arc, so parallel
|
|
44
|
+
* connectors stay readable where several turn together. The radius shrinks on
|
|
45
|
+
* short segments so two bends never overlap.
|
|
46
|
+
*/
|
|
47
|
+
export declare function roundedPath(points: readonly Pt[], radius?: number): string;
|
|
48
|
+
/** Where along the polyline (0…1 by length) a point projects: the inverse of `labelAnchor`. */
|
|
49
|
+
export declare function fractionAlong(points: readonly Pt[], p: Pt): number;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a connector's label belongs: the midpoint **by length**, not the middle
|
|
3
|
+
* element of the point list. An orthogonal route is made of segments of wildly
|
|
4
|
+
* different lengths, so picking the middle vertex parks the text in a corner.
|
|
5
|
+
*/
|
|
6
|
+
export function labelAnchor(points, at = 0.5) {
|
|
7
|
+
const frac = Math.max(0.02, Math.min(0.98, at));
|
|
8
|
+
if (points.length < 2) {
|
|
9
|
+
const p = points[0] ?? { x: 0, y: 0 };
|
|
10
|
+
return { x: p.x, y: p.y, horizontal: true };
|
|
11
|
+
}
|
|
12
|
+
const seg = [];
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (let i = 1; i < points.length; i++) {
|
|
15
|
+
const d = Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
|
16
|
+
seg.push(d);
|
|
17
|
+
total += d;
|
|
18
|
+
}
|
|
19
|
+
let walked = 0;
|
|
20
|
+
for (let i = 0; i < seg.length; i++) {
|
|
21
|
+
const d = seg[i];
|
|
22
|
+
if (walked + d >= total * frac || i === seg.length - 1) {
|
|
23
|
+
const t = d === 0 ? 0 : (total * frac - walked) / d;
|
|
24
|
+
const a = points[i], b = points[i + 1];
|
|
25
|
+
return {
|
|
26
|
+
x: a.x + (b.x - a.x) * t,
|
|
27
|
+
y: a.y + (b.y - a.y) * t,
|
|
28
|
+
horizontal: Math.abs(b.x - a.x) >= Math.abs(b.y - a.y),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
walked += d;
|
|
32
|
+
}
|
|
33
|
+
const p = points[0];
|
|
34
|
+
return { x: p.x, y: p.y, horizontal: true };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Box and text placement for a label of the given width at that anchor.
|
|
38
|
+
* `flip` puts it on the other side of the line: below a horizontal run
|
|
39
|
+
* instead of above, left of a vertical run instead of right.
|
|
40
|
+
*/
|
|
41
|
+
export function labelBox(points, width, at = 0.5, flip = false) {
|
|
42
|
+
const a = labelAnchor(points, at);
|
|
43
|
+
if (a.horizontal)
|
|
44
|
+
return flip
|
|
45
|
+
? { rectX: a.x - width / 2, rectY: a.y + 4, textX: a.x, textY: a.y + 15, anchor: 'middle' }
|
|
46
|
+
: { rectX: a.x - width / 2, rectY: a.y - 17, textX: a.x, textY: a.y - 6, anchor: 'middle' };
|
|
47
|
+
return flip
|
|
48
|
+
? { rectX: a.x - 7 - width, rectY: a.y - 8, textX: a.x - 12, textY: a.y + 3.5, anchor: 'end' }
|
|
49
|
+
: { rectX: a.x + 7, rectY: a.y - 8, textX: a.x + 12, textY: a.y + 3.5, anchor: 'start' };
|
|
50
|
+
}
|
|
51
|
+
/** Which side of the nearest segment a point is on: true = the flipped side (below / left). */
|
|
52
|
+
export function flippedSide(points, p) {
|
|
53
|
+
let best = { d: Infinity, flip: false };
|
|
54
|
+
for (let i = 1; i < points.length; i++) {
|
|
55
|
+
const a = points[i - 1], b = points[i];
|
|
56
|
+
const dx = b.x - a.x, dy = b.y - a.y, len = dx * dx + dy * dy;
|
|
57
|
+
const t = len ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len)) : 0;
|
|
58
|
+
const q = { x: a.x + dx * t, y: a.y + dy * t };
|
|
59
|
+
const d = Math.hypot(p.x - q.x, p.y - q.y);
|
|
60
|
+
if (d < best.d)
|
|
61
|
+
best = { d, flip: Math.abs(dx) >= Math.abs(dy) ? p.y > q.y : p.x < q.x };
|
|
62
|
+
}
|
|
63
|
+
return best.flip;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* An orthogonal polyline as an SVG path with softly rounded corners. Straight
|
|
67
|
+
* runs stay straight; each bend becomes a small quadratic arc, so parallel
|
|
68
|
+
* connectors stay readable where several turn together. The radius shrinks on
|
|
69
|
+
* short segments so two bends never overlap.
|
|
70
|
+
*/
|
|
71
|
+
export function roundedPath(points, radius = 8) {
|
|
72
|
+
if (points.length < 2)
|
|
73
|
+
return '';
|
|
74
|
+
const p = points;
|
|
75
|
+
let d = `M${p[0].x} ${p[0].y}`;
|
|
76
|
+
for (let i = 1; i < p.length - 1; i++) {
|
|
77
|
+
const a = p[i - 1], b = p[i], c = p[i + 1];
|
|
78
|
+
const inLen = Math.hypot(b.x - a.x, b.y - a.y), outLen = Math.hypot(c.x - b.x, c.y - b.y);
|
|
79
|
+
const r = Math.min(radius, inLen / 2, outLen / 2);
|
|
80
|
+
if (r < 1) {
|
|
81
|
+
d += ` L${b.x} ${b.y}`;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const ux = (b.x - a.x) / inLen, uy = (b.y - a.y) / inLen;
|
|
85
|
+
const vx = (c.x - b.x) / outLen, vy = (c.y - b.y) / outLen;
|
|
86
|
+
d += ` L${b.x - ux * r} ${b.y - uy * r} Q${b.x} ${b.y} ${b.x + vx * r} ${b.y + vy * r}`;
|
|
87
|
+
}
|
|
88
|
+
const last = p[p.length - 1];
|
|
89
|
+
d += ` L${last.x} ${last.y}`;
|
|
90
|
+
return d;
|
|
91
|
+
}
|
|
92
|
+
/** Where along the polyline (0…1 by length) a point projects: the inverse of `labelAnchor`. */
|
|
93
|
+
export function fractionAlong(points, p) {
|
|
94
|
+
let total = 0, best = { d: Infinity, at: 0.5 }, walked = 0;
|
|
95
|
+
const seg = [];
|
|
96
|
+
for (let i = 1; i < points.length; i++) {
|
|
97
|
+
const d = Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
|
98
|
+
seg.push(d);
|
|
99
|
+
total += d;
|
|
100
|
+
}
|
|
101
|
+
if (!total)
|
|
102
|
+
return 0.5;
|
|
103
|
+
for (let i = 0; i < seg.length; i++) {
|
|
104
|
+
const a = points[i], b = points[i + 1], d = seg[i];
|
|
105
|
+
const t = d === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / (d * d)));
|
|
106
|
+
const q = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
107
|
+
const dist = Math.hypot(p.x - q.x, p.y - q.y);
|
|
108
|
+
if (dist < best.d)
|
|
109
|
+
best = { d: dist, at: (walked + d * t) / total };
|
|
110
|
+
walked += d;
|
|
111
|
+
}
|
|
112
|
+
return best.at;
|
|
113
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function textWidth(s: string, size: number): number;
|
|
2
|
+
export interface Box {
|
|
3
|
+
w: number;
|
|
4
|
+
h: number;
|
|
5
|
+
}
|
|
6
|
+
/** Extra room a shape needs beyond its text: a head, a cylinder cap. */
|
|
7
|
+
export declare const SHAPE_PAD: Record<string, {
|
|
8
|
+
top: number;
|
|
9
|
+
bottom: number;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function nodeBox(label: string, tech: string | undefined, kind: string, meta?: string, agents?: string[]): Box;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { footerOf } from '../lens.js';
|
|
2
|
+
/** Text metrics without a browser: good-enough advance widths for the UI font. */
|
|
3
|
+
const NARROW = new Set([...'iljtfrI1.,:;\'"|!()[]{}']);
|
|
4
|
+
const WIDE = new Set([...'MWmw@']);
|
|
5
|
+
export function textWidth(s, size) {
|
|
6
|
+
let u = 0;
|
|
7
|
+
for (const ch of s)
|
|
8
|
+
u += NARROW.has(ch) ? 0.40 : WIDE.has(ch) ? 0.95 : 0.56;
|
|
9
|
+
return u * size;
|
|
10
|
+
}
|
|
11
|
+
/** Extra room a shape needs beyond its text: a head, a cylinder cap. */
|
|
12
|
+
export const SHAPE_PAD = {
|
|
13
|
+
actor: { top: 50, bottom: 10 },
|
|
14
|
+
datastore: { top: 24, bottom: 16 },
|
|
15
|
+
cache: { top: 24, bottom: 16 },
|
|
16
|
+
};
|
|
17
|
+
export function nodeBox(label, tech, kind, meta, agents) {
|
|
18
|
+
const title = textWidth(label, 13);
|
|
19
|
+
const sub = tech ? textWidth(`[${kind}: ${tech}]`, 10) : textWidth(`[${kind}]`, 10);
|
|
20
|
+
const third = meta ? textWidth(meta, 10) : 0;
|
|
21
|
+
const pad = SHAPE_PAD[kind] ?? { top: 0, bottom: 0 };
|
|
22
|
+
const w = Math.max(kind === 'actor' ? 150 : 158, Math.min(236, Math.max(title, sub, third) + 38));
|
|
23
|
+
// a host card with agents carries a row of chips under its text
|
|
24
|
+
const footer = footerOf({ agents, w, isBoundary: false });
|
|
25
|
+
const h = (tech ? 72 : 60) + (meta ? 14 : 0) + pad.top + pad.bottom + (footer ? footer + 4 : 0);
|
|
26
|
+
return { w, h };
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orthogonal connectors with a memory — the geometry behind "drag a card and
|
|
3
|
+
* the arrow stretches instead of being redrawn" (draw.io's behaviour).
|
|
4
|
+
*
|
|
5
|
+
* A route is a polyline from a point on the source's border to a point on the
|
|
6
|
+
* target's border, every segment horizontal or vertical. Three operations:
|
|
7
|
+
*
|
|
8
|
+
* sidePoint / autoSides where an arrow leaves and enters a box;
|
|
9
|
+
* orthoThrough a route through the waypoints a person set;
|
|
10
|
+
* repairRoute the old route with only its end segments moved,
|
|
11
|
+
* after one or both boxes moved.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here avoids obstacles: that is the A* router's job when there is no
|
|
14
|
+
* route yet. Once a route exists, a person owns its shape.
|
|
15
|
+
*/
|
|
16
|
+
import type { Pt, Rect, Side } from './route.js';
|
|
17
|
+
export type { Pt, Rect, Side };
|
|
18
|
+
/** Where an arrow attaches: a side of the box and a position along it (0…1, default the middle). */
|
|
19
|
+
export interface Anchor {
|
|
20
|
+
side?: Side;
|
|
21
|
+
t?: number;
|
|
22
|
+
}
|
|
23
|
+
/** The point on `side` of `r`, `t` of the way along it (left→right, top→bottom). */
|
|
24
|
+
export declare function sidePoint(r: Rect, side: Side, t?: number): Pt;
|
|
25
|
+
/** Which side of `r` a border point sits on (the nearest edge), and how far along it. */
|
|
26
|
+
export declare function anchorOf(p: Pt, r: Rect): Required<Anchor>;
|
|
27
|
+
/** Sides for an arrow from `a` to `b` by where they sit: leave towards the target, enter from the source. */
|
|
28
|
+
export declare function autoSides(a: Rect, b: Rect): [Side, Side];
|
|
29
|
+
/**
|
|
30
|
+
* Repeated points go, and so does a point that merely sits on a straight run.
|
|
31
|
+
* A point where the run turns back on itself stays: it is a waypoint someone
|
|
32
|
+
* put there, and a spike is at least honest about it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function tidy(points: Pt[]): Pt[];
|
|
35
|
+
/**
|
|
36
|
+
* A route from `a` to `b` through `hints`, in order. Consecutive points that
|
|
37
|
+
* do not share an axis get a corner; the corner continues the direction the
|
|
38
|
+
* route was already travelling, so a person's waypoints read as "go here,
|
|
39
|
+
* then here" rather than as a puzzle. With no hints the result is the
|
|
40
|
+
* straight line, the L or the Z the two sides call for.
|
|
41
|
+
*/
|
|
42
|
+
export declare function orthoThrough(a: Rect, b: Rect, hints?: Pt[], from?: Anchor, to?: Anchor, stub?: number): Pt[];
|
|
43
|
+
/**
|
|
44
|
+
* Move only what must move. `points` is the previous route (border to border),
|
|
45
|
+
* `a` and `b` are the boxes where they are now, `prevA`/`prevB` where they were
|
|
46
|
+
* (so the arrow keeps leaving from the same spot on the same side). Interior
|
|
47
|
+
* bends stay; the first and last bends slide along their own axis so the end
|
|
48
|
+
* segments still meet the borders squarely — and stay at least a stub's length
|
|
49
|
+
* outside the box. When the old shape cannot hold, the route is rebuilt from
|
|
50
|
+
* the same two sides.
|
|
51
|
+
*/
|
|
52
|
+
export declare function repairRoute(points: Pt[], a: Rect, b: Rect, prevA?: Rect, prevB?: Rect, stub?: number): Pt[];
|
|
53
|
+
/** True when every segment is horizontal or vertical. */
|
|
54
|
+
export declare const isOrthogonal: (pts: Pt[]) => boolean;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const EPS = 0.01;
|
|
2
|
+
const same = (a, b) => Math.abs(a - b) < EPS;
|
|
3
|
+
/** The point on `side` of `r`, `t` of the way along it (left→right, top→bottom). */
|
|
4
|
+
export function sidePoint(r, side, t = 0.5) {
|
|
5
|
+
const k = Math.max(0.02, Math.min(0.98, t));
|
|
6
|
+
switch (side) {
|
|
7
|
+
case 'top': return { x: r.x + r.w * k, y: r.y };
|
|
8
|
+
case 'bottom': return { x: r.x + r.w * k, y: r.y + r.h };
|
|
9
|
+
case 'left': return { x: r.x, y: r.y + r.h * k };
|
|
10
|
+
case 'right': return { x: r.x + r.w, y: r.y + r.h * k };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Which side of `r` a border point sits on (the nearest edge), and how far along it. */
|
|
14
|
+
export function anchorOf(p, r) {
|
|
15
|
+
const d = { left: Math.abs(p.x - r.x), right: Math.abs(p.x - (r.x + r.w)), top: Math.abs(p.y - r.y), bottom: Math.abs(p.y - (r.y + r.h)) };
|
|
16
|
+
const side = Object.keys(d).reduce((a, b) => (d[b] < d[a] ? b : a));
|
|
17
|
+
const t = side === 'left' || side === 'right' ? (p.y - r.y) / (r.h || 1) : (p.x - r.x) / (r.w || 1);
|
|
18
|
+
return { side, t: Math.max(0, Math.min(1, t)) };
|
|
19
|
+
}
|
|
20
|
+
/** Sides for an arrow from `a` to `b` by where they sit: leave towards the target, enter from the source. */
|
|
21
|
+
export function autoSides(a, b) {
|
|
22
|
+
const ax = a.x + a.w / 2, ay = a.y + a.h / 2, bx = b.x + b.w / 2, by = b.y + b.h / 2;
|
|
23
|
+
const dx = bx - ax, dy = by - ay;
|
|
24
|
+
// overlap in one axis means the boxes are stacked in the other
|
|
25
|
+
const gapX = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w);
|
|
26
|
+
const gapY = Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h);
|
|
27
|
+
if (gapY >= 0 && (gapY >= gapX || gapX < 0))
|
|
28
|
+
return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom'];
|
|
29
|
+
return dx >= 0 ? ['right', 'left'] : ['left', 'right'];
|
|
30
|
+
}
|
|
31
|
+
const outward = (side) => side === 'left' ? { x: -1, y: 0 } : side === 'right' ? { x: 1, y: 0 } : side === 'top' ? { x: 0, y: -1 } : { x: 0, y: 1 };
|
|
32
|
+
const horizontal = (side) => side === 'left' || side === 'right';
|
|
33
|
+
/**
|
|
34
|
+
* Repeated points go, and so does a point that merely sits on a straight run.
|
|
35
|
+
* A point where the run turns back on itself stays: it is a waypoint someone
|
|
36
|
+
* put there, and a spike is at least honest about it.
|
|
37
|
+
*/
|
|
38
|
+
export function tidy(points) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const p of points) {
|
|
41
|
+
const q = out[out.length - 1];
|
|
42
|
+
if (!q || !same(q.x, p.x) || !same(q.y, p.y))
|
|
43
|
+
out.push({ x: p.x, y: p.y });
|
|
44
|
+
}
|
|
45
|
+
for (let i = out.length - 2; i > 0; i--) {
|
|
46
|
+
const a = out[i - 1], b = out[i], c = out[i + 1];
|
|
47
|
+
const colH = same(a.y, b.y) && same(b.y, c.y) && (b.x - a.x) * (c.x - b.x) >= 0;
|
|
48
|
+
const colV = same(a.x, b.x) && same(b.x, c.x) && (b.y - a.y) * (c.y - b.y) >= 0;
|
|
49
|
+
if (colH || colV)
|
|
50
|
+
out.splice(i, 1);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A route from `a` to `b` through `hints`, in order. Consecutive points that
|
|
56
|
+
* do not share an axis get a corner; the corner continues the direction the
|
|
57
|
+
* route was already travelling, so a person's waypoints read as "go here,
|
|
58
|
+
* then here" rather than as a puzzle. With no hints the result is the
|
|
59
|
+
* straight line, the L or the Z the two sides call for.
|
|
60
|
+
*/
|
|
61
|
+
export function orthoThrough(a, b, hints = [], from = {}, to = {}, stub = 16) {
|
|
62
|
+
const [autoA, autoB] = autoSides(a, b);
|
|
63
|
+
const sa = from.side ?? autoA, sb = to.side ?? autoB;
|
|
64
|
+
const P = sidePoint(a, sa, from.t), E = sidePoint(b, sb, to.t);
|
|
65
|
+
const oa = outward(sa), ob = outward(sb);
|
|
66
|
+
const S = { x: P.x + oa.x * stub, y: P.y + oa.y * stub };
|
|
67
|
+
const T = { x: E.x + ob.x * stub, y: E.y + ob.y * stub };
|
|
68
|
+
// the stubs keep the first and last legs perpendicular to their sides; a
|
|
69
|
+
// waypoint already sitting on that line, further out, makes the stub redundant
|
|
70
|
+
const onStubLine = (h, from, o) => (o.x ? same(h.y, from.y) && (h.x - from.x) * o.x >= 0 : same(h.x, from.x) && (h.y - from.y) * o.y >= 0);
|
|
71
|
+
const first = hints[0], last = hints[hints.length - 1];
|
|
72
|
+
const pts = first && onStubLine(first, P, oa) ? [P] : [P, S];
|
|
73
|
+
let horiz = horizontal(sa); // the direction the route is travelling
|
|
74
|
+
const walk = (n, preferHoriz = null) => {
|
|
75
|
+
const c = pts[pts.length - 1];
|
|
76
|
+
if (same(c.x, n.x) || same(c.y, n.y)) {
|
|
77
|
+
pts.push(n);
|
|
78
|
+
horiz = same(c.y, n.y) ? !same(c.x, n.x) : false;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const goH = preferHoriz ?? horiz;
|
|
82
|
+
pts.push(goH ? { x: n.x, y: c.y } : { x: c.x, y: n.y });
|
|
83
|
+
pts.push(n);
|
|
84
|
+
horiz = !goH;
|
|
85
|
+
};
|
|
86
|
+
for (const h of hints)
|
|
87
|
+
walk(h);
|
|
88
|
+
if (!hints.length) {
|
|
89
|
+
// no waypoints: an L when the sides face each other on the diagonal, a Z otherwise
|
|
90
|
+
const hA = horizontal(sa), hB = horizontal(sb);
|
|
91
|
+
if (hA === hB) {
|
|
92
|
+
// the Z between the stubs — unless a stub points away from the other box,
|
|
93
|
+
// in which case the route goes around the outside (leaving by the top to
|
|
94
|
+
// reach something below means: up, across, down past it, in from below)
|
|
95
|
+
const facing = hA ? (T.x - S.x) * oa.x >= 0 && (S.x - T.x) * ob.x >= 0 : (T.y - S.y) * oa.y >= 0 && (S.y - T.y) * ob.y >= 0;
|
|
96
|
+
if (facing) {
|
|
97
|
+
const mid = hA ? { x: (S.x + T.x) / 2, y: 0 } : { x: 0, y: (S.y + T.y) / 2 };
|
|
98
|
+
if (hA) {
|
|
99
|
+
pts.push({ x: mid.x, y: S.y });
|
|
100
|
+
pts.push({ x: mid.x, y: T.y });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
pts.push({ x: S.x, y: mid.y });
|
|
104
|
+
pts.push({ x: T.x, y: mid.y });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (hA) {
|
|
108
|
+
const lo = Math.min(a.y, b.y) - 24, hi = Math.max(a.y + a.h, b.y + b.h) + 24;
|
|
109
|
+
const y = Math.abs((S.y + T.y) / 2 - lo) < Math.abs((S.y + T.y) / 2 - hi) ? lo : hi;
|
|
110
|
+
pts.push({ x: S.x, y });
|
|
111
|
+
pts.push({ x: T.x, y });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const lo = Math.min(a.x, b.x) - 24, hi = Math.max(a.x + a.w, b.x + b.w) + 24;
|
|
115
|
+
const x = Math.abs((S.x + T.x) / 2 - lo) < Math.abs((S.x + T.x) / 2 - hi) ? lo : hi;
|
|
116
|
+
pts.push({ x, y: S.y });
|
|
117
|
+
pts.push({ x, y: T.y });
|
|
118
|
+
}
|
|
119
|
+
pts.push(T);
|
|
120
|
+
pts.push(E);
|
|
121
|
+
return tidy(pts);
|
|
122
|
+
}
|
|
123
|
+
pts.push(hA ? { x: T.x, y: S.y } : { x: S.x, y: T.y });
|
|
124
|
+
pts.push(T);
|
|
125
|
+
pts.push(E);
|
|
126
|
+
return tidy(pts);
|
|
127
|
+
}
|
|
128
|
+
// the last leg arrives at T along the axis the side needs, then steps onto the border
|
|
129
|
+
if (last && onStubLine(last, E, ob)) {
|
|
130
|
+
pts.push(E);
|
|
131
|
+
return tidy(pts);
|
|
132
|
+
}
|
|
133
|
+
walk(T, !horizontal(sb));
|
|
134
|
+
pts.push(E);
|
|
135
|
+
return tidy(pts);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Move only what must move. `points` is the previous route (border to border),
|
|
139
|
+
* `a` and `b` are the boxes where they are now, `prevA`/`prevB` where they were
|
|
140
|
+
* (so the arrow keeps leaving from the same spot on the same side). Interior
|
|
141
|
+
* bends stay; the first and last bends slide along their own axis so the end
|
|
142
|
+
* segments still meet the borders squarely — and stay at least a stub's length
|
|
143
|
+
* outside the box. When the old shape cannot hold, the route is rebuilt from
|
|
144
|
+
* the same two sides.
|
|
145
|
+
*/
|
|
146
|
+
export function repairRoute(points, a, b, prevA, prevB, stub = 16) {
|
|
147
|
+
const old = tidy(points);
|
|
148
|
+
if (old.length < 2)
|
|
149
|
+
return orthoThrough(a, b);
|
|
150
|
+
const fa = anchorOf(old[0], prevA ?? a), fb = anchorOf(old[old.length - 1], prevB ?? b);
|
|
151
|
+
const P = sidePoint(a, fa.side, fa.t), E = sidePoint(b, fb.side, fb.t);
|
|
152
|
+
const inner = old.slice(1, -1).map(p => ({ x: p.x, y: p.y }));
|
|
153
|
+
// when the old shape cannot hold, the boxes have moved enough that the sides are re-chosen too
|
|
154
|
+
const fallback = () => orthoThrough(a, b, [], {}, {}, stub);
|
|
155
|
+
if (!inner.length)
|
|
156
|
+
return same(P.x, E.x) || same(P.y, E.y) ? [P, E] : fallback();
|
|
157
|
+
const n = inner.length;
|
|
158
|
+
const hA = horizontal(fa.side), hB = horizontal(fb.side);
|
|
159
|
+
if (n === 1 && hA === hB)
|
|
160
|
+
return fallback(); // one bend cannot serve two parallel sides
|
|
161
|
+
// was each interior segment horizontal? (old[i] → old[i+1], i = 1 … n-1)
|
|
162
|
+
const segH = inner.map((_, i) => i < n - 1 && same(old[i + 1].y, old[i + 2].y));
|
|
163
|
+
// the source end: the first bend sits on the axis leaving P, at least a stub away
|
|
164
|
+
const oa = outward(fa.side);
|
|
165
|
+
if (hA) {
|
|
166
|
+
inner[0].y = P.y;
|
|
167
|
+
inner[0].x = oa.x > 0 ? Math.max(inner[0].x, P.x + stub) : Math.min(inner[0].x, P.x - stub);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
inner[0].x = P.x;
|
|
171
|
+
inner[0].y = oa.y > 0 ? Math.max(inner[0].y, P.y + stub) : Math.min(inner[0].y, P.y - stub);
|
|
172
|
+
}
|
|
173
|
+
for (let i = 0; i < n - 1; i++) {
|
|
174
|
+
if (segH[i])
|
|
175
|
+
inner[i + 1].y = inner[i].y;
|
|
176
|
+
else
|
|
177
|
+
inner[i + 1].x = inner[i].x;
|
|
178
|
+
}
|
|
179
|
+
// the target end, the same way, walking back
|
|
180
|
+
const ob = outward(fb.side), L = inner[n - 1];
|
|
181
|
+
if (hB) {
|
|
182
|
+
L.y = E.y;
|
|
183
|
+
L.x = ob.x > 0 ? Math.max(L.x, E.x + stub) : Math.min(L.x, E.x - stub);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
L.x = E.x;
|
|
187
|
+
L.y = ob.y > 0 ? Math.max(L.y, E.y + stub) : Math.min(L.y, E.y - stub);
|
|
188
|
+
}
|
|
189
|
+
for (let i = n - 1; i > 0; i--) {
|
|
190
|
+
if (segH[i - 1])
|
|
191
|
+
inner[i - 1].y = inner[i].y;
|
|
192
|
+
else
|
|
193
|
+
inner[i - 1].x = inner[i].x;
|
|
194
|
+
}
|
|
195
|
+
// did walking back disturb the source end, or pull the first bend back into the box?
|
|
196
|
+
const first = inner[0];
|
|
197
|
+
if (hA ? !same(first.y, P.y) : !same(first.x, P.x))
|
|
198
|
+
return fallback();
|
|
199
|
+
const away = hA ? (first.x - P.x) * oa.x : (first.y - P.y) * oa.y;
|
|
200
|
+
if (away < stub - 1)
|
|
201
|
+
return fallback();
|
|
202
|
+
const out = tidy([P, ...inner, E]);
|
|
203
|
+
return isOrthogonal(out) ? out : fallback();
|
|
204
|
+
}
|
|
205
|
+
/** True when every segment is horizontal or vertical. */
|
|
206
|
+
export const isOrthogonal = (pts) => pts.every((p, i) => i === 0 || same(p.x, pts[i - 1].x) || same(p.y, pts[i - 1].y));
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obstacle-aware orthogonal router: a Hanan grid drawn at every obstacle
|
|
3
|
+
* border (± clearance, two lanes) plus the endpoints, searched with A* and a
|
|
4
|
+
* bend penalty. Deterministic — ties break by insertion order. A shared
|
|
5
|
+
* `used` map makes later edges prefer lanes nobody has taken yet.
|
|
6
|
+
*/
|
|
7
|
+
export interface Pt {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Rect {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
}
|
|
17
|
+
export type Side = 'top' | 'bottom' | 'left' | 'right';
|
|
18
|
+
export declare const M = 16, LANE = 14, BEND = 70, REUSE = 40;
|
|
19
|
+
export interface Grid {
|
|
20
|
+
X: number[];
|
|
21
|
+
Y: number[];
|
|
22
|
+
obstacles: Rect[];
|
|
23
|
+
}
|
|
24
|
+
export declare function makeGrid(obstacles: Rect[], extra?: Pt[]): Grid;
|
|
25
|
+
type Blocked = (mid: Pt, i: number, j: number) => boolean;
|
|
26
|
+
/** A* from grid point s to t; `blocked(mid)` rejects a move whose midpoint is inside an obstacle. */
|
|
27
|
+
export declare function astar(grid: Grid, s: Pt, t: Pt, blocked: Blocked, used?: Map<string, number>): Pt[] | null;
|
|
28
|
+
/** Remember which grid segments a committed path occupies. */
|
|
29
|
+
export declare function markUsed(grid: Grid, pts: Pt[], used: Map<string, number>): void;
|
|
30
|
+
/** Drop repeated and collinear points. */
|
|
31
|
+
export declare function simplify(pts: readonly Pt[]): Pt[];
|
|
32
|
+
/** Point on a node's side, and the same point pushed `M` outward. */
|
|
33
|
+
export declare function stub(n: Rect, side: Side): {
|
|
34
|
+
on: Pt;
|
|
35
|
+
out: Pt;
|
|
36
|
+
};
|
|
37
|
+
export interface RouteSpec {
|
|
38
|
+
a: Rect;
|
|
39
|
+
b: Rect;
|
|
40
|
+
pref?: [Side, Side];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Route edges `{ a, b, pref }` around the rectangles `obstaclesFor(edge)`
|
|
44
|
+
* returns. Preferred sides go first; when they only give a detour (more than
|
|
45
|
+
* two bends) every side pair is tried and the cheapest wins. One polyline (or
|
|
46
|
+
* null) per edge, in order.
|
|
47
|
+
*/
|
|
48
|
+
export declare function routeAll(edges: RouteSpec[], obstaclesFor: (e: RouteSpec) => Rect[]): (Pt[] | null)[];
|
|
49
|
+
/** The plain two-bend route between two boxes — the fallback, and the live-drag router. */
|
|
50
|
+
export declare function routeSimple(a: Rect, b: Rect): Pt[];
|
|
51
|
+
/**
|
|
52
|
+
* Route one edge between two boxes around obstacles — what the canvas uses
|
|
53
|
+
* while a card is being dragged. Falls back to the plain route when the
|
|
54
|
+
* search finds nothing.
|
|
55
|
+
*/
|
|
56
|
+
export declare function routeAround(a: Rect, b: Rect, obstacles: Rect[]): Pt[];
|
|
57
|
+
export {};
|