@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +202 -0
  3. package/README.md +88 -0
  4. package/dist/src/capacity.d.ts +67 -0
  5. package/dist/src/capacity.js +227 -0
  6. package/dist/src/check.d.ts +21 -0
  7. package/dist/src/check.js +192 -0
  8. package/dist/src/compiled.d.ts +61 -0
  9. package/dist/src/compiled.js +91 -0
  10. package/dist/src/cst.d.ts +76 -0
  11. package/dist/src/cst.js +1 -0
  12. package/dist/src/diagnostics.d.ts +9 -0
  13. package/dist/src/diagnostics.js +1 -0
  14. package/dist/src/edit.d.ts +48 -0
  15. package/dist/src/edit.js +442 -0
  16. package/dist/src/index.d.ts +25 -0
  17. package/dist/src/index.js +24 -0
  18. package/dist/src/layout/balance.d.ts +34 -0
  19. package/dist/src/layout/balance.js +327 -0
  20. package/dist/src/layout/elk.d.ts +64 -0
  21. package/dist/src/layout/elk.js +267 -0
  22. package/dist/src/layout/host.d.ts +23 -0
  23. package/dist/src/layout/host.js +23 -0
  24. package/dist/src/layout/label.d.ts +49 -0
  25. package/dist/src/layout/label.js +113 -0
  26. package/dist/src/layout/measure.d.ts +11 -0
  27. package/dist/src/layout/measure.js +27 -0
  28. package/dist/src/layout/ortho.d.ts +54 -0
  29. package/dist/src/layout/ortho.js +206 -0
  30. package/dist/src/layout/route.d.ts +57 -0
  31. package/dist/src/layout/route.js +230 -0
  32. package/dist/src/lens.d.ts +83 -0
  33. package/dist/src/lens.js +377 -0
  34. package/dist/src/lexer.d.ts +7 -0
  35. package/dist/src/lexer.js +135 -0
  36. package/dist/src/model.d.ts +63 -0
  37. package/dist/src/model.js +114 -0
  38. package/dist/src/parser.d.ts +7 -0
  39. package/dist/src/parser.js +305 -0
  40. package/dist/src/render/svg.d.ts +56 -0
  41. package/dist/src/render/svg.js +289 -0
  42. package/dist/src/serialize.d.ts +10 -0
  43. package/dist/src/serialize.js +12 -0
  44. package/dist/src/tokens.d.ts +26 -0
  45. package/dist/src/tokens.js +15 -0
  46. package/dist/src/vocab.d.ts +38 -0
  47. package/dist/src/vocab.js +58 -0
  48. 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 {};