@statelyai/layout 0.1.2 → 0.2.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/NOTICE.md +43 -0
- package/README.md +82 -4
- package/dist/elkjs/index.cjs +8 -0
- package/dist/elkjs/index.d.cts +146 -0
- package/dist/elkjs/index.d.mts +42 -39
- package/dist/elkjs/index.mjs +2 -1483
- package/dist/elkjs/main.cjs +17 -0
- package/dist/elkjs/main.d.cts +10 -0
- package/dist/elkjs/main.d.mts +11 -0
- package/dist/elkjs/main.mjs +17 -0
- package/dist/elkjs/worker-api.cjs +3 -0
- package/dist/elkjs/worker-api.d.cts +3 -0
- package/dist/elkjs/worker-api.d.mts +3 -0
- package/dist/elkjs/worker-api.mjs +3 -0
- package/dist/elkjs/worker.cjs +86 -0
- package/dist/elkjs/worker.d.cts +17 -0
- package/dist/elkjs/worker.d.mts +17 -0
- package/dist/elkjs/worker.mjs +86 -0
- package/dist/elkjs-BVnDwnBQ.cjs +1551 -0
- package/dist/elkjs-CFlfkEBz.mjs +1528 -0
- package/dist/{index-8xYkohbz.d.mts → index-BSH2bloA.d.mts} +77 -3
- package/dist/index-CG712jzV.d.cts +1286 -0
- package/dist/index.cjs +188 -0
- package/dist/index.d.cts +77 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +24 -25
- package/dist/layered/index.cjs +38 -0
- package/dist/layered/index.d.cts +2 -0
- package/dist/layered/index.d.mts +1 -1
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-COIyXvwb.mjs → layered-Bz2nWmVp.mjs} +2298 -45
- package/dist/layered-Cf9Rn8l7.cjs +12061 -0
- package/dist/layout-engine-BNex9jxg.cjs +712 -0
- package/dist/{spore-BeKUA1Mb.mjs → layout-engine-WbC0YjjT.mjs} +159 -5
- package/dist/public-types-B8rAALJq.d.mts +109 -0
- package/dist/public-types-CVB0hXGk.d.cts +109 -0
- package/dist/worker-client-D3HMK33r.cjs +98 -0
- package/dist/worker-client-DAT_z2OR.d.mts +19 -0
- package/dist/worker-client-DVhklF6A.d.cts +19 -0
- package/dist/worker-client-DkwS4sHx.mjs +92 -0
- package/package.json +39 -9
- package/src/authoring/coordinates.ts +106 -0
- package/src/authoring/partial.ts +584 -0
- package/src/authoring/routing.ts +211 -0
- package/src/box.ts +22 -0
- package/src/constraints.ts +222 -0
- package/src/elkjs/index.ts +100 -37
- package/src/elkjs/main.ts +19 -0
- package/src/elkjs/public-types.ts +125 -0
- package/src/elkjs/worker-api.ts +29 -0
- package/src/elkjs/worker-client.ts +138 -0
- package/src/elkjs/worker.ts +117 -0
- package/src/index.ts +13 -0
- package/src/internal/elkjs-compatibility.ts +17 -0
- package/src/internal/layout-engine.ts +126 -0
- package/src/layered/index.ts +31 -3
- package/src/layered/strategies.ts +68 -3
- package/src/layout.ts +35 -28
- package/src/packing.ts +22 -0
- package/src/random.ts +75 -3
- package/src/types.ts +17 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { EntityRect, Point, VisualNode, GraphEdge } from "@statelyai/graph";
|
|
2
|
+
import { LayoutError } from "../errors";
|
|
3
|
+
import type { LayoutDirection } from "../types";
|
|
4
|
+
|
|
5
|
+
export function overlaps(a: EntityRect, b: EntityRect, gap = 0): boolean {
|
|
6
|
+
return (
|
|
7
|
+
a.width > 0 &&
|
|
8
|
+
a.height > 0 &&
|
|
9
|
+
b.width > 0 &&
|
|
10
|
+
b.height > 0 &&
|
|
11
|
+
a.x < b.x + b.width + gap &&
|
|
12
|
+
a.x + a.width + gap > b.x &&
|
|
13
|
+
a.y < b.y + b.height + gap &&
|
|
14
|
+
a.y + a.height + gap > b.y
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Open-interior intersection: travelling along a rectangle boundary is allowed. */
|
|
19
|
+
export function crossesRect(a: Point, b: Point, rect: EntityRect): boolean {
|
|
20
|
+
let low = 0,
|
|
21
|
+
high = 1;
|
|
22
|
+
for (const axis of ["x", "y"] as const) {
|
|
23
|
+
const delta = b[axis] - a[axis];
|
|
24
|
+
const min = rect[axis],
|
|
25
|
+
max = min + (axis === "x" ? rect.width : rect.height);
|
|
26
|
+
if (Math.abs(delta) < 1e-10) {
|
|
27
|
+
if (a[axis] <= min || a[axis] >= max) return false;
|
|
28
|
+
} else {
|
|
29
|
+
const t1 = (min - a[axis]) / delta,
|
|
30
|
+
t2 = (max - a[axis]) / delta;
|
|
31
|
+
low = Math.max(low, Math.min(t1, t2));
|
|
32
|
+
high = Math.min(high, Math.max(t1, t2));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return low < high - 1e-10;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function routeCrosses(points: readonly Point[] | undefined, rect: EntityRect): boolean {
|
|
39
|
+
return points?.some((point, i) => i > 0 && crossesRect(points[i - 1]!, point, rect)) ?? false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function endpoint(
|
|
43
|
+
node: VisualNode,
|
|
44
|
+
portName: string | undefined,
|
|
45
|
+
direction: LayoutDirection,
|
|
46
|
+
source: boolean,
|
|
47
|
+
): Point {
|
|
48
|
+
if (portName !== undefined) {
|
|
49
|
+
const port = node.ports?.find((p) => p.name === portName);
|
|
50
|
+
if (port?.x !== undefined && port.y !== undefined) {
|
|
51
|
+
return {
|
|
52
|
+
x: node.x + port.x + (port.width ?? 0) / 2,
|
|
53
|
+
y: node.y + port.y + (port.height ?? 0) / 2,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
throw new LayoutError(
|
|
57
|
+
`Port ${portName} on ${node.id} requires positioned geometry`,
|
|
58
|
+
"MISSING_GEOMETRY",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const horizontal = direction === "left" || direction === "right";
|
|
62
|
+
const forward = direction === "right" || direction === "down";
|
|
63
|
+
const far = source === forward;
|
|
64
|
+
return horizontal
|
|
65
|
+
? { x: node.x + (far ? node.width : 0), y: node.y + node.height / 2 }
|
|
66
|
+
: { x: node.x + node.width / 2, y: node.y + (far ? node.height : 0) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Deterministic orthogonal visibility-grid search around fixed rectangles. */
|
|
70
|
+
function connect(
|
|
71
|
+
start: Point,
|
|
72
|
+
end: Point,
|
|
73
|
+
obstacles: readonly EntityRect[],
|
|
74
|
+
check: () => void,
|
|
75
|
+
): Point[] | undefined {
|
|
76
|
+
if (start.x === end.x && start.y === end.y) return [start];
|
|
77
|
+
// Distant geometry must not exhaust the local grid budget. Each attempt still
|
|
78
|
+
// collision-checks against the entire scene, including excluded rectangles.
|
|
79
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
80
|
+
check();
|
|
81
|
+
const margin = 64 * 2 ** attempt;
|
|
82
|
+
const left = Math.min(start.x, end.x) - margin;
|
|
83
|
+
const right = Math.max(start.x, end.x) + margin;
|
|
84
|
+
const top = Math.min(start.y, end.y) - margin;
|
|
85
|
+
const bottom = Math.max(start.y, end.y) + margin;
|
|
86
|
+
const local = obstacles.filter(
|
|
87
|
+
(r) => r.x <= right && r.x + r.width >= left && r.y <= bottom && r.y + r.height >= top,
|
|
88
|
+
);
|
|
89
|
+
const result = search(start, end, local, obstacles, check);
|
|
90
|
+
if (result || local.length === obstacles.length) return result;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function search(
|
|
96
|
+
start: Point,
|
|
97
|
+
end: Point,
|
|
98
|
+
gridObstacles: readonly EntityRect[],
|
|
99
|
+
obstacles: readonly EntityRect[],
|
|
100
|
+
check: () => void,
|
|
101
|
+
): Point[] | undefined {
|
|
102
|
+
const xs = [
|
|
103
|
+
...new Set([start.x, end.x, ...gridObstacles.flatMap((r) => [r.x, r.x + r.width])]),
|
|
104
|
+
].sort((a, b) => a - b);
|
|
105
|
+
const ys = [
|
|
106
|
+
...new Set([start.y, end.y, ...gridObstacles.flatMap((r) => [r.y, r.y + r.height])]),
|
|
107
|
+
].sort((a, b) => a - b);
|
|
108
|
+
// Bound interactive work; the caller returns a diagnostic without inventing a route.
|
|
109
|
+
if (xs.length * ys.length > 40000) return undefined;
|
|
110
|
+
const width = xs.length;
|
|
111
|
+
const point = (id: number): Point => ({ x: xs[id % width]!, y: ys[Math.floor(id / width)]! });
|
|
112
|
+
const from = ys.indexOf(start.y) * width + xs.indexOf(start.x);
|
|
113
|
+
const to = ys.indexOf(end.y) * width + xs.indexOf(end.x);
|
|
114
|
+
const distances = new Map<number, number>([[from, 0]]);
|
|
115
|
+
const previous = new Map<number, number>();
|
|
116
|
+
const queue: { id: number; cost: number }[] = [];
|
|
117
|
+
function push(item: { id: number; cost: number }) {
|
|
118
|
+
queue.push(item);
|
|
119
|
+
let i = queue.length - 1;
|
|
120
|
+
while (i > 0) {
|
|
121
|
+
const parent = (i - 1) >> 1;
|
|
122
|
+
if (queue[parent]!.cost <= item.cost) break;
|
|
123
|
+
queue[i] = queue[parent]!;
|
|
124
|
+
i = parent;
|
|
125
|
+
}
|
|
126
|
+
queue[i] = item;
|
|
127
|
+
}
|
|
128
|
+
function pop() {
|
|
129
|
+
const first = queue[0]!;
|
|
130
|
+
const last = queue.pop()!;
|
|
131
|
+
if (queue.length) {
|
|
132
|
+
let i = 0;
|
|
133
|
+
while (i * 2 + 1 < queue.length) {
|
|
134
|
+
let child = i * 2 + 1;
|
|
135
|
+
if (child + 1 < queue.length && queue[child + 1]!.cost < queue[child]!.cost) child++;
|
|
136
|
+
if (last.cost <= queue[child]!.cost) break;
|
|
137
|
+
queue[i] = queue[child]!;
|
|
138
|
+
i = child;
|
|
139
|
+
}
|
|
140
|
+
queue[i] = last;
|
|
141
|
+
}
|
|
142
|
+
return first;
|
|
143
|
+
}
|
|
144
|
+
push({ id: from, cost: 0 });
|
|
145
|
+
let visits = 0;
|
|
146
|
+
while (queue.length) {
|
|
147
|
+
if (++visits % 128 === 0) check();
|
|
148
|
+
const current = pop();
|
|
149
|
+
if (current.cost !== distances.get(current.id)) continue;
|
|
150
|
+
if (current.id === to) {
|
|
151
|
+
const result = [end];
|
|
152
|
+
let id = to;
|
|
153
|
+
while (id !== from) {
|
|
154
|
+
id = previous.get(id)!;
|
|
155
|
+
result.push(point(id));
|
|
156
|
+
}
|
|
157
|
+
return result.reverse();
|
|
158
|
+
}
|
|
159
|
+
const x = current.id % width,
|
|
160
|
+
y = Math.floor(current.id / width);
|
|
161
|
+
const neighbors = [
|
|
162
|
+
x > 0 ? current.id - 1 : -1,
|
|
163
|
+
x + 1 < width ? current.id + 1 : -1,
|
|
164
|
+
y > 0 ? current.id - width : -1,
|
|
165
|
+
y + 1 < ys.length ? current.id + width : -1,
|
|
166
|
+
];
|
|
167
|
+
const a = point(current.id);
|
|
168
|
+
for (const next of neighbors) {
|
|
169
|
+
if (next < 0) continue;
|
|
170
|
+
const b = point(next);
|
|
171
|
+
if (obstacles.some((rect) => crossesRect(a, b, rect))) continue;
|
|
172
|
+
const cost = current.cost + Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
173
|
+
if (cost >= (distances.get(next) ?? Infinity)) continue;
|
|
174
|
+
distances.set(next, cost);
|
|
175
|
+
previous.set(next, current.id);
|
|
176
|
+
push({ id: next, cost });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function routeOrthogonal(
|
|
183
|
+
edge: GraphEdge,
|
|
184
|
+
source: VisualNode,
|
|
185
|
+
target: VisualNode,
|
|
186
|
+
direction: LayoutDirection,
|
|
187
|
+
obstacles: readonly EntityRect[],
|
|
188
|
+
waypoints: readonly Point[],
|
|
189
|
+
check: () => void,
|
|
190
|
+
): Point[] | undefined {
|
|
191
|
+
const anchors = [
|
|
192
|
+
endpoint(source, edge.sourcePort, direction, true),
|
|
193
|
+
...waypoints,
|
|
194
|
+
endpoint(target, edge.targetPort, direction, false),
|
|
195
|
+
];
|
|
196
|
+
const points: Point[] = [];
|
|
197
|
+
for (let i = 1; i < anchors.length; i++) {
|
|
198
|
+
const segment = connect(anchors[i - 1]!, anchors[i]!, obstacles, check);
|
|
199
|
+
if (!segment) return undefined;
|
|
200
|
+
points.push(...(points.length ? segment.slice(1) : segment));
|
|
201
|
+
}
|
|
202
|
+
return points.filter((p, i) => {
|
|
203
|
+
if (i === 0 || i === points.length - 1) return true;
|
|
204
|
+
const before = points[i - 1]!,
|
|
205
|
+
after = points[i + 1]!;
|
|
206
|
+
return !(
|
|
207
|
+
(before.x === p.x && p.x === after.x && (p.y - before.y) * (after.y - p.y) >= 0) ||
|
|
208
|
+
(before.y === p.y && p.y === after.y && (p.x - before.x) * (after.x - p.x) >= 0)
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
}
|
package/src/box.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Graph, GraphNode, VisualGraph, VisualNode } from "@statelyai/graph";
|
|
9
9
|
import { getNodeSize, type LayoutOptions } from "@statelyai/graph/layout";
|
|
10
10
|
import { getFixedLayout } from "./fixed";
|
|
11
|
+
import { setElkjs0111ResultPolicy } from "./internal/elkjs-compatibility";
|
|
11
12
|
import type { LayoutPadding } from "./layered";
|
|
12
13
|
import type { LayoutAlgorithm } from "./types";
|
|
13
14
|
|
|
@@ -133,3 +134,24 @@ export const boxAlgorithm: LayoutAlgorithm<BoxLayoutOptions> = {
|
|
|
133
134
|
return getBoxLayout(graph, options ?? {});
|
|
134
135
|
},
|
|
135
136
|
};
|
|
137
|
+
|
|
138
|
+
/** Pinned behavior used only by the elkjs 0.11.1 compatibility adapter. */
|
|
139
|
+
export const elkjs0111BoxAlgorithm: LayoutAlgorithm<BoxLayoutOptions> = {
|
|
140
|
+
...boxAlgorithm,
|
|
141
|
+
layout(graph, options = {}) {
|
|
142
|
+
const result = getBoxLayout(graph, options);
|
|
143
|
+
const padding = getPadding(options.padding);
|
|
144
|
+
return setElkjs0111ResultPolicy(result, {
|
|
145
|
+
providerBounds:
|
|
146
|
+
result.nodes.length === 0
|
|
147
|
+
? { width: 0, height: 0 }
|
|
148
|
+
: {
|
|
149
|
+
width: Math.max(...result.nodes.map((node) => node.x + node.width)) + padding.right,
|
|
150
|
+
height:
|
|
151
|
+
Math.max(...result.nodes.map((node) => node.y + node.height)) + padding.bottom,
|
|
152
|
+
},
|
|
153
|
+
preserveEdgeSections: true,
|
|
154
|
+
skipBoundsNormalization: true,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Constraint, Expression, Operator, Solver, Strength, Variable } from "@lume/kiwi";
|
|
2
|
+
import type { VisualGraph } from "@statelyai/graph";
|
|
3
|
+
import { LayoutError } from "./errors";
|
|
4
|
+
import type { LayoutDiagnostic } from "./types";
|
|
5
|
+
|
|
6
|
+
export type GeometryReference = { nodeId: string } | { edgeId: string; part: "label" };
|
|
7
|
+
export type ConstraintStrength = "required" | "strong" | "medium" | "weak";
|
|
8
|
+
export type GeometryAttribute = "x" | "y" | "centerX" | "centerY" | "right" | "bottom";
|
|
9
|
+
interface ConstraintBase {
|
|
10
|
+
id: string;
|
|
11
|
+
strength?: ConstraintStrength;
|
|
12
|
+
}
|
|
13
|
+
export interface AlignConstraint extends ConstraintBase {
|
|
14
|
+
kind: "align";
|
|
15
|
+
entities: readonly GeometryReference[];
|
|
16
|
+
axis: "x" | "y";
|
|
17
|
+
anchor?: "start" | "center" | "end";
|
|
18
|
+
}
|
|
19
|
+
export interface DistributeConstraint extends ConstraintBase {
|
|
20
|
+
kind: "distribute";
|
|
21
|
+
entities: readonly GeometryReference[];
|
|
22
|
+
axis: "x" | "y";
|
|
23
|
+
spacing?: "gaps" | "centers";
|
|
24
|
+
gap?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface PinConstraint extends ConstraintBase {
|
|
27
|
+
kind: "pin";
|
|
28
|
+
entity: GeometryReference;
|
|
29
|
+
x?: number;
|
|
30
|
+
y?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface LinearConstraint extends ConstraintBase {
|
|
33
|
+
kind: "linear";
|
|
34
|
+
terms: readonly {
|
|
35
|
+
entity: GeometryReference;
|
|
36
|
+
attribute: GeometryAttribute;
|
|
37
|
+
coefficient: number;
|
|
38
|
+
}[];
|
|
39
|
+
relation: "eq" | "le" | "ge";
|
|
40
|
+
value: number;
|
|
41
|
+
}
|
|
42
|
+
export interface WaypointConstraint extends ConstraintBase {
|
|
43
|
+
kind: "waypoint";
|
|
44
|
+
edgeId: string;
|
|
45
|
+
point: { x: number; y: number };
|
|
46
|
+
}
|
|
47
|
+
export type LayoutConstraint =
|
|
48
|
+
| AlignConstraint
|
|
49
|
+
| DistributeConstraint
|
|
50
|
+
| PinConstraint
|
|
51
|
+
| LinearConstraint
|
|
52
|
+
| WaypointConstraint;
|
|
53
|
+
|
|
54
|
+
/** Serializable constraint constructors; graph geometry stays in @statelyai/graph. */
|
|
55
|
+
export const c = {
|
|
56
|
+
align: (value: Omit<AlignConstraint, "kind">): AlignConstraint => ({ ...value, kind: "align" }),
|
|
57
|
+
distribute: (value: Omit<DistributeConstraint, "kind">): DistributeConstraint => ({
|
|
58
|
+
...value,
|
|
59
|
+
kind: "distribute",
|
|
60
|
+
}),
|
|
61
|
+
pin: (value: Omit<PinConstraint, "kind">): PinConstraint => ({ ...value, kind: "pin" }),
|
|
62
|
+
linear: (value: Omit<LinearConstraint, "kind">): LinearConstraint => ({
|
|
63
|
+
...value,
|
|
64
|
+
kind: "linear",
|
|
65
|
+
}),
|
|
66
|
+
waypoint: (value: Omit<WaypointConstraint, "kind">): WaypointConstraint => ({
|
|
67
|
+
...value,
|
|
68
|
+
kind: "waypoint",
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function constraintFailure(constraint: LayoutConstraint, message: string): never {
|
|
73
|
+
throw new LayoutError(`${constraint.id}: ${message}`, "UNSATISFIED_CONSTRAINT");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Solve positions only. Fixed geometry is represented by constants, never edit variables. */
|
|
77
|
+
export function solveGeometryConstraints<N, E, G, P>(
|
|
78
|
+
graph: VisualGraph<N, E, G, P>,
|
|
79
|
+
constraints: readonly LayoutConstraint[],
|
|
80
|
+
movableNodes: ReadonlySet<string>,
|
|
81
|
+
movableLabels: ReadonlySet<string>,
|
|
82
|
+
diagnostics: LayoutDiagnostic[],
|
|
83
|
+
): VisualGraph<N, E, G, P> {
|
|
84
|
+
const solver = new Solver();
|
|
85
|
+
const variables = new Map<string, { x: Variable; y: Variable }>();
|
|
86
|
+
const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
87
|
+
const edges = new Map(graph.edges.map((e) => [e.id, e]));
|
|
88
|
+
function geometry(ref: GeometryReference) {
|
|
89
|
+
const node = "nodeId" in ref;
|
|
90
|
+
const id = node ? ref.nodeId : ref.edgeId;
|
|
91
|
+
const rect = node ? nodes.get(id) : edges.get(id);
|
|
92
|
+
if (!rect) throw new LayoutError(`Unknown constraint entity: ${id}`, "INVALID_CONSTRAINT");
|
|
93
|
+
const key = JSON.stringify([node ? "node" : "label", id]);
|
|
94
|
+
const movable = (node ? movableNodes : movableLabels).has(id);
|
|
95
|
+
if (movable && !variables.has(key)) {
|
|
96
|
+
const pair = { x: new Variable(`${key}.x`), y: new Variable(`${key}.y`) };
|
|
97
|
+
variables.set(key, pair);
|
|
98
|
+
solver.addConstraint(new Constraint(pair.x, Operator.Eq, rect.x, Strength.weak / 10));
|
|
99
|
+
solver.addConstraint(new Constraint(pair.y, Operator.Eq, rect.y, Strength.weak / 10));
|
|
100
|
+
}
|
|
101
|
+
return { rect, variables: variables.get(key) };
|
|
102
|
+
}
|
|
103
|
+
function expression(ref: GeometryReference, attribute: GeometryAttribute): Expression {
|
|
104
|
+
const { rect, variables: pair } = geometry(ref);
|
|
105
|
+
const axis = attribute === "y" || attribute === "centerY" || attribute === "bottom" ? "y" : "x";
|
|
106
|
+
const extent = axis === "x" ? rect.width : rect.height;
|
|
107
|
+
const offset = attribute.startsWith("center")
|
|
108
|
+
? extent / 2
|
|
109
|
+
: attribute === "right" || attribute === "bottom"
|
|
110
|
+
? extent
|
|
111
|
+
: 0;
|
|
112
|
+
return new Expression(pair?.[axis] ?? rect[axis], offset);
|
|
113
|
+
}
|
|
114
|
+
const checks: { constraint: LayoutConstraint; expression: Expression; operator: Operator }[] = [];
|
|
115
|
+
const ids = new Set<string>();
|
|
116
|
+
for (const constraint of constraints) {
|
|
117
|
+
if (ids.has(constraint.id))
|
|
118
|
+
throw new LayoutError(`Duplicate constraint ID: ${constraint.id}`, "INVALID_CONSTRAINT");
|
|
119
|
+
ids.add(constraint.id);
|
|
120
|
+
if (constraint.kind === "waypoint") continue;
|
|
121
|
+
const strength = Strength[constraint.strength ?? "required"];
|
|
122
|
+
const add = (lhs: Expression, operator = Operator.Eq, rhs: Expression | number = 0) => {
|
|
123
|
+
const difference = lhs.minus(rhs);
|
|
124
|
+
if (
|
|
125
|
+
!Number.isFinite(difference.constant()) ||
|
|
126
|
+
!difference.terms().array.every((item) => Number.isFinite(item.second))
|
|
127
|
+
) {
|
|
128
|
+
throw new LayoutError(`${constraint.id}: non-finite expression`, "INVALID_CONSTRAINT");
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
solver.addConstraint(new Constraint(difference, operator, 0, strength));
|
|
132
|
+
} catch {
|
|
133
|
+
constraintFailure(
|
|
134
|
+
constraint,
|
|
135
|
+
"conflicts with fixed geometry or another required constraint",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
checks.push({ constraint, expression: difference, operator });
|
|
139
|
+
};
|
|
140
|
+
if (constraint.kind === "linear") {
|
|
141
|
+
let value = new Expression();
|
|
142
|
+
for (const term of constraint.terms)
|
|
143
|
+
value = value.plus(expression(term.entity, term.attribute).multiply(term.coefficient));
|
|
144
|
+
add(
|
|
145
|
+
value,
|
|
146
|
+
{ eq: Operator.Eq, le: Operator.Le, ge: Operator.Ge }[constraint.relation],
|
|
147
|
+
constraint.value,
|
|
148
|
+
);
|
|
149
|
+
} else if (constraint.kind === "pin") {
|
|
150
|
+
geometry(constraint.entity);
|
|
151
|
+
if (constraint.x !== undefined)
|
|
152
|
+
add(expression(constraint.entity, "x"), Operator.Eq, constraint.x);
|
|
153
|
+
if (constraint.y !== undefined)
|
|
154
|
+
add(expression(constraint.entity, "y"), Operator.Eq, constraint.y);
|
|
155
|
+
} else {
|
|
156
|
+
const refs = constraint.entities;
|
|
157
|
+
// Validate even a singleton reference.
|
|
158
|
+
for (const ref of refs) geometry(ref);
|
|
159
|
+
const axis = constraint.axis;
|
|
160
|
+
const end = axis === "x" ? "right" : "bottom";
|
|
161
|
+
const center = axis === "x" ? "centerX" : "centerY";
|
|
162
|
+
if (constraint.kind === "align") {
|
|
163
|
+
const attr =
|
|
164
|
+
constraint.anchor === "start" ? axis : constraint.anchor === "end" ? end : center;
|
|
165
|
+
for (const ref of refs.slice(1))
|
|
166
|
+
add(expression(ref, attr), Operator.Eq, expression(refs[0]!, attr));
|
|
167
|
+
} else {
|
|
168
|
+
const gaps = refs
|
|
169
|
+
.slice(1)
|
|
170
|
+
.map((ref, i) =>
|
|
171
|
+
constraint.spacing === "centers"
|
|
172
|
+
? expression(ref, center).minus(expression(refs[i]!, center))
|
|
173
|
+
: expression(ref, axis).minus(expression(refs[i]!, end)),
|
|
174
|
+
);
|
|
175
|
+
for (const gap of gaps) {
|
|
176
|
+
add(gap, Operator.Ge, 0);
|
|
177
|
+
add(gap, Operator.Eq, constraint.gap ?? gaps[0]!);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
solver.updateVariables();
|
|
183
|
+
const warned = new Set<string>();
|
|
184
|
+
for (const check of checks) {
|
|
185
|
+
const residual = check.expression.value();
|
|
186
|
+
const violated =
|
|
187
|
+
check.operator === Operator.Eq
|
|
188
|
+
? Math.abs(residual) > 1e-6
|
|
189
|
+
: check.operator === Operator.Le
|
|
190
|
+
? residual > 1e-6
|
|
191
|
+
: residual < -1e-6;
|
|
192
|
+
if (violated && !warned.has(check.constraint.id)) {
|
|
193
|
+
warned.add(check.constraint.id);
|
|
194
|
+
diagnostics.push({
|
|
195
|
+
severity: "warning",
|
|
196
|
+
code: "CONSTRAINT_VIOLATION",
|
|
197
|
+
message: `Constraint ${check.constraint.id} could not be satisfied`,
|
|
198
|
+
constraintIds: [check.constraint.id],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const coordinate = (variable: Variable): number => {
|
|
203
|
+
const value = variable.value();
|
|
204
|
+
if (!Number.isFinite(value))
|
|
205
|
+
throw new LayoutError(
|
|
206
|
+
"Constraint solution contains non-finite geometry",
|
|
207
|
+
"INVALID_CONSTRAINT",
|
|
208
|
+
);
|
|
209
|
+
return value === 0 ? 0 : value;
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
...graph,
|
|
213
|
+
nodes: graph.nodes.map((node) => {
|
|
214
|
+
const pair = variables.get(JSON.stringify(["node", node.id]));
|
|
215
|
+
return pair ? { ...node, x: coordinate(pair.x), y: coordinate(pair.y) } : node;
|
|
216
|
+
}),
|
|
217
|
+
edges: graph.edges.map((edge) => {
|
|
218
|
+
const pair = variables.get(JSON.stringify(["label", edge.id]));
|
|
219
|
+
return pair ? { ...edge, x: coordinate(pair.x), y: coordinate(pair.y) } : edge;
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
222
|
+
}
|