@statelyai/layout 0.1.3 → 0.3.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 +153 -6
- package/dist/elkjs/index.cjs +9 -0
- package/dist/elkjs/index.d.cts +155 -0
- package/dist/elkjs/index.d.mts +51 -39
- package/dist/elkjs/index.mjs +3 -1484
- 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-DaHMISnQ.mjs +1677 -0
- package/dist/elkjs-dMghiiWW.cjs +1706 -0
- package/dist/index-BEA_pzeT.d.cts +72 -0
- package/dist/{index-8xYkohbz.d.mts → index-D9dhxrRl.d.cts} +111 -5
- package/dist/index-DNTLRsDl.d.mts +72 -0
- package/dist/index-DNixtE3h.d.mts +1318 -0
- package/dist/index.cjs +215 -0
- package/dist/index.d.cts +78 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +25 -25
- package/dist/layered/index.cjs +38 -0
- package/dist/layered/index.d.cts +3 -0
- package/dist/layered/index.d.mts +3 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/layered-B2buJqMf.cjs +12009 -0
- package/dist/{layered-CoeH9LRO.mjs → layered-DGFTeQSl.mjs} +2323 -149
- package/dist/{spore-BtTSZbb6.mjs → layout-engine-Cs5tatA6.mjs} +159 -5
- package/dist/layout-engine-Dn1PiFwP.cjs +712 -0
- package/dist/public-types-9NCPoqbp.d.mts +109 -0
- package/dist/public-types-BcRZoJE3.d.cts +109 -0
- package/dist/routing/index.cjs +28 -0
- package/dist/routing/index.d.cts +3 -0
- package/dist/routing/index.d.mts +3 -0
- package/dist/routing/index.mjs +3 -0
- package/dist/routing-A2hrK9MP.mjs +2396 -0
- package/dist/routing-ujEP7Qh6.cjs +2606 -0
- package/dist/types-CT2akdK0.d.mts +133 -0
- package/dist/types-uxPlGRof.d.cts +133 -0
- package/dist/worker-client-BNjwT1AJ.mjs +92 -0
- package/dist/worker-client-BwGGRD5y.d.mts +19 -0
- package/dist/worker-client-D4oEvhAl.cjs +98 -0
- package/dist/worker-client-D4zNyPEI.d.cts +19 -0
- package/package.json +45 -10
- package/src/authoring/coordinates.ts +114 -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 +104 -39
- package/src/elkjs/main.ts +19 -0
- package/src/elkjs/public-types.ts +125 -0
- package/src/elkjs/routes.ts +147 -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 +19 -0
- package/src/internal/elkjs-compatibility.ts +17 -0
- package/src/internal/layout-engine.ts +126 -0
- package/src/layered/compound.ts +417 -0
- package/src/layered/index.ts +58 -105
- package/src/layered/types.ts +21 -0
- package/src/layout.ts +35 -28
- package/src/packing.ts +22 -0
- package/src/random.ts +75 -3
- package/src/routing/adapters.ts +111 -0
- package/src/routing/algorithms.ts +676 -0
- package/src/routing/coordination.ts +86 -0
- package/src/routing/engine.ts +473 -0
- package/src/routing/index.ts +68 -0
- package/src/routing/layout-cache.ts +65 -0
- package/src/routing/model.ts +218 -0
- package/src/routing/path.ts +324 -0
- package/src/routing/persistent.ts +117 -0
- package/src/routing/search.ts +276 -0
- package/src/routing/spatial.ts +78 -0
- package/src/routing/types.ts +146 -0
- package/src/types.ts +17 -2
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Route,
|
|
3
|
+
RouteBounds,
|
|
4
|
+
RouteEndpoint,
|
|
5
|
+
RoutePath,
|
|
6
|
+
RoutePoint,
|
|
7
|
+
RouteSection,
|
|
8
|
+
RouteSide,
|
|
9
|
+
RouteStyle,
|
|
10
|
+
RoutingDiagnostic,
|
|
11
|
+
RoutingMetrics,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import {
|
|
14
|
+
anchor,
|
|
15
|
+
ancestors,
|
|
16
|
+
center,
|
|
17
|
+
groupKey,
|
|
18
|
+
labelRect,
|
|
19
|
+
sideToward,
|
|
20
|
+
vector,
|
|
21
|
+
worldRect,
|
|
22
|
+
type EdgeGeometry,
|
|
23
|
+
type State,
|
|
24
|
+
} from "./model";
|
|
25
|
+
import { pathReservations } from "./coordination";
|
|
26
|
+
import { distance, getPathBounds, pathFromPoints, roundCorners, segmentCrossesRect } from "./path";
|
|
27
|
+
import { inflate, union } from "./spatial";
|
|
28
|
+
import {
|
|
29
|
+
clear,
|
|
30
|
+
findPath,
|
|
31
|
+
pointsBounds,
|
|
32
|
+
segmentBounds,
|
|
33
|
+
simplify,
|
|
34
|
+
type SearchContext,
|
|
35
|
+
} from "./search";
|
|
36
|
+
|
|
37
|
+
type RoutingInput = Pick<
|
|
38
|
+
State,
|
|
39
|
+
"nodes" | "edges" | "groups" | "obstacles" | "settings" | "incident"
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
export type Metrics = { -readonly [K in keyof RoutingMetrics]: RoutingMetrics[K] };
|
|
43
|
+
interface Terminal {
|
|
44
|
+
point: RoutePoint;
|
|
45
|
+
side: RouteSide;
|
|
46
|
+
ref: RouteEndpoint;
|
|
47
|
+
}
|
|
48
|
+
interface Plan {
|
|
49
|
+
path: RoutePath;
|
|
50
|
+
id: string;
|
|
51
|
+
}
|
|
52
|
+
export interface Batch {
|
|
53
|
+
readonly plans: Map<string, Plan | null>;
|
|
54
|
+
dependencyBounds?: RouteBounds;
|
|
55
|
+
edgeCost?(a: RoutePoint, b: RoutePoint): number;
|
|
56
|
+
conflicts?(a: RoutePoint, b: RoutePoint): boolean;
|
|
57
|
+
guides?(bounds: RouteBounds): readonly RouteBounds[];
|
|
58
|
+
}
|
|
59
|
+
function contextFor(
|
|
60
|
+
state: RoutingInput,
|
|
61
|
+
excluded: ReadonlySet<string>,
|
|
62
|
+
endpoints: ReadonlySet<string>,
|
|
63
|
+
metrics: Metrics,
|
|
64
|
+
batch: Batch,
|
|
65
|
+
padding = 0,
|
|
66
|
+
): SearchContext {
|
|
67
|
+
return {
|
|
68
|
+
edgeCost: (a, b) => batch.edgeCost?.(a, b) ?? 0,
|
|
69
|
+
guides: (bounds) => batch.guides?.(bounds) ?? [],
|
|
70
|
+
maxSearchNodes: state.settings.maxSearchNodes,
|
|
71
|
+
bendPenalty: state.settings.bendPenalty,
|
|
72
|
+
visited: 0,
|
|
73
|
+
budgetExceeded: false,
|
|
74
|
+
obstacles(bounds) {
|
|
75
|
+
const queried = inflate(bounds, state.settings.clearance + padding);
|
|
76
|
+
batch.dependencyBounds = batch.dependencyBounds
|
|
77
|
+
? union(batch.dependencyBounds, queried)
|
|
78
|
+
: queried;
|
|
79
|
+
const ids = state.obstacles.query(queried);
|
|
80
|
+
metrics.obstacleCandidates += ids.length;
|
|
81
|
+
return ids
|
|
82
|
+
.filter((id) => !excluded.has(id))
|
|
83
|
+
.map((id) =>
|
|
84
|
+
inflate(
|
|
85
|
+
state.obstacles.bounds.get(id)!,
|
|
86
|
+
endpoints.has(id) ? 0 : state.settings.clearance + padding,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function pathLength(path: RoutePath): number {
|
|
93
|
+
return pathReservations(path).reduce((sum, segment) => sum + distance(segment.a, segment.b), 0);
|
|
94
|
+
}
|
|
95
|
+
function pathCost(path: RoutePath, context: SearchContext): number {
|
|
96
|
+
return pathReservations(path).reduce(
|
|
97
|
+
(sum, segment) =>
|
|
98
|
+
sum + distance(segment.a, segment.b) + (context.edgeCost?.(segment.a, segment.b) ?? 0),
|
|
99
|
+
0,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function safe(path: RoutePath, context: SearchContext): boolean {
|
|
103
|
+
let start = path.start;
|
|
104
|
+
for (const segment of path.segments) {
|
|
105
|
+
const bounds = getPathBounds({ start, segments: [segment] });
|
|
106
|
+
if (context.obstacles(bounds).some((rect) => segmentCrossesRect(start, segment, rect)))
|
|
107
|
+
return false;
|
|
108
|
+
start = segment.to;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
function cubic(start: Terminal, end: Terminal): RoutePath {
|
|
113
|
+
const amount = Math.max(20, distance(start.point, end.point) / 2),
|
|
114
|
+
a = vector(start.side),
|
|
115
|
+
b = vector(end.side);
|
|
116
|
+
return {
|
|
117
|
+
start: start.point,
|
|
118
|
+
segments: [
|
|
119
|
+
{
|
|
120
|
+
kind: "cubic",
|
|
121
|
+
control1: { x: start.point.x + a.x * amount, y: start.point.y + a.y * amount },
|
|
122
|
+
control2: { x: end.point.x + b.x * amount, y: end.point.y + b.y * amount },
|
|
123
|
+
to: end.point,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function curve(
|
|
129
|
+
points: readonly RoutePoint[],
|
|
130
|
+
state: RoutingInput,
|
|
131
|
+
context: SearchContext,
|
|
132
|
+
): RoutePath {
|
|
133
|
+
const path = pathFromPoints(points);
|
|
134
|
+
for (let radius = state.settings.radius; radius > 0.01; radius /= 2) {
|
|
135
|
+
const rounded = roundCorners(path, { radius });
|
|
136
|
+
if (safe(rounded, context)) return rounded;
|
|
137
|
+
}
|
|
138
|
+
return path;
|
|
139
|
+
}
|
|
140
|
+
/** Elastic string relaxation, constrained by collision checks on each proposed move. */
|
|
141
|
+
function organic(
|
|
142
|
+
points: readonly RoutePoint[],
|
|
143
|
+
state: RoutingInput,
|
|
144
|
+
context: SearchContext,
|
|
145
|
+
): RoutePath {
|
|
146
|
+
let samples: RoutePoint[] = [];
|
|
147
|
+
for (let i = 1; i < points.length; i++) {
|
|
148
|
+
const a = points[i - 1]!,
|
|
149
|
+
b = points[i]!,
|
|
150
|
+
count = Math.min(16, Math.max(2, Math.ceil(distance(a, b) / 32)));
|
|
151
|
+
for (let j = 0; j < count; j++)
|
|
152
|
+
samples.push({ x: a.x + ((b.x - a.x) * j) / count, y: a.y + ((b.y - a.y) * j) / count });
|
|
153
|
+
}
|
|
154
|
+
samples.push(points.at(-1)!);
|
|
155
|
+
for (let iteration = 0; iteration < state.settings.organicIterations; iteration++) {
|
|
156
|
+
const next = [...samples];
|
|
157
|
+
for (let i = 1; i < samples.length - 1; i++) {
|
|
158
|
+
const p = samples[i]!,
|
|
159
|
+
a = samples[i - 1]!,
|
|
160
|
+
b = samples[i + 1]!;
|
|
161
|
+
let x = (a.x + b.x - 2 * p.x) * 0.2,
|
|
162
|
+
y = (a.y + b.y - 2 * p.y) * 0.2;
|
|
163
|
+
for (const r of context.obstacles(inflate({ ...p, width: 0, height: 0 }, 24))) {
|
|
164
|
+
const q = {
|
|
165
|
+
x: Math.max(r.x, Math.min(r.x + r.width, p.x)),
|
|
166
|
+
y: Math.max(r.y, Math.min(r.y + r.height, p.y)),
|
|
167
|
+
},
|
|
168
|
+
d = distance(p, q);
|
|
169
|
+
if (d > 0 && d < 24) {
|
|
170
|
+
x += ((p.x - q.x) / d) * (24 - d) * 0.2;
|
|
171
|
+
y += ((p.y - q.y) / d) * (24 - d) * 0.2;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const candidate = { x: p.x + x, y: p.y + y };
|
|
175
|
+
if (clear(next[i - 1]!, candidate, context) && clear(candidate, b, context))
|
|
176
|
+
next[i] = candidate;
|
|
177
|
+
}
|
|
178
|
+
samples = next;
|
|
179
|
+
}
|
|
180
|
+
return curve(simplify(samples), state, context);
|
|
181
|
+
}
|
|
182
|
+
function groupPlan(
|
|
183
|
+
key: string,
|
|
184
|
+
state: RoutingInput,
|
|
185
|
+
metrics: Metrics,
|
|
186
|
+
batch: Batch,
|
|
187
|
+
style: RouteStyle,
|
|
188
|
+
): Plan | null {
|
|
189
|
+
if (batch.plans.has(key)) return batch.plans.get(key)!;
|
|
190
|
+
const edges = [...(state.groups.get(key)?.keys() ?? [])].map((id) => state.edges.get(id)!);
|
|
191
|
+
const nodes = [...new Set(edges.flatMap((e) => [e.sourceId, e.targetId]))];
|
|
192
|
+
const rectangles = nodes
|
|
193
|
+
.map((id) => state.nodes.get(id))
|
|
194
|
+
.map((n) => n && worldRect(n, state))
|
|
195
|
+
.filter((r): r is RouteBounds => r !== undefined);
|
|
196
|
+
if (rectangles.length !== nodes.length || !rectangles.length) {
|
|
197
|
+
batch.plans.set(key, null);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const common = new Set(ancestors(nodes[0]!, state));
|
|
201
|
+
for (const id of nodes.slice(1)) {
|
|
202
|
+
const set = new Set(ancestors(id, state));
|
|
203
|
+
for (const parent of common) if (!set.has(parent)) common.delete(parent);
|
|
204
|
+
}
|
|
205
|
+
const context = contextFor(
|
|
206
|
+
state,
|
|
207
|
+
new Set([...common].map((id) => `n:${id}`)),
|
|
208
|
+
new Set(),
|
|
209
|
+
metrics,
|
|
210
|
+
batch,
|
|
211
|
+
);
|
|
212
|
+
const bounds = rectangles.reduce(union),
|
|
213
|
+
spacing = state.settings.clearance + state.settings.edgeSpacing + 1;
|
|
214
|
+
let result: Plan | null = null;
|
|
215
|
+
const targets = edges.map(
|
|
216
|
+
(e) => labelRect(e, state) ?? worldRect(state.nodes.get(e.targetId)!, state)!,
|
|
217
|
+
);
|
|
218
|
+
const sources = edges.map((e) => worldRect(state.nodes.get(e.sourceId)!, state)!);
|
|
219
|
+
const candidates: { start: RoutePoint; end: RoutePoint }[] = [];
|
|
220
|
+
if (new Set(edges.map((e) => e.sourceId)).size === 1) {
|
|
221
|
+
const source = sources[0]!,
|
|
222
|
+
target = targets.reduce(union);
|
|
223
|
+
const side = sideToward(center(source), center(target)),
|
|
224
|
+
v = vector(side);
|
|
225
|
+
const port = edges[0]!.sourcePort;
|
|
226
|
+
const named =
|
|
227
|
+
port === undefined
|
|
228
|
+
? undefined
|
|
229
|
+
: state.nodes.get(edges[0]!.sourceId)!.ports?.find((p) => p.name === port);
|
|
230
|
+
const attachment =
|
|
231
|
+
named && Number.isFinite(named.x) && Number.isFinite(named.y)
|
|
232
|
+
? {
|
|
233
|
+
x: source.x + named.x! + (named.width ?? 0) / 2,
|
|
234
|
+
y: source.y + named.y! + (named.height ?? 0) / 2,
|
|
235
|
+
}
|
|
236
|
+
: anchor(source, side);
|
|
237
|
+
const start = { x: attachment.x + v.x * spacing, y: attachment.y + v.y * spacing };
|
|
238
|
+
const end =
|
|
239
|
+
side === "right"
|
|
240
|
+
? { x: target.x - spacing, y: start.y }
|
|
241
|
+
: side === "left"
|
|
242
|
+
? { x: target.x + target.width + spacing, y: start.y }
|
|
243
|
+
: side === "bottom"
|
|
244
|
+
? { x: start.x, y: target.y - spacing }
|
|
245
|
+
: { x: start.x, y: target.y + target.height + spacing };
|
|
246
|
+
if ((end.x - start.x) * v.x + (end.y - start.y) * v.y > 1e-8) candidates.push({ start, end });
|
|
247
|
+
}
|
|
248
|
+
// Try the source-facing interior corridor before exterior alternatives.
|
|
249
|
+
const xs = [bounds.x - spacing, bounds.x + bounds.width + spacing];
|
|
250
|
+
const ys = [bounds.y - spacing, bounds.y + bounds.height + spacing];
|
|
251
|
+
const exterior = [
|
|
252
|
+
...xs.map((x) => ({ start: { x, y: ys[0]! }, end: { x, y: ys[1]! } })),
|
|
253
|
+
...ys.map((y) => ({ start: { x: xs[0]!, y }, end: { x: xs[1]!, y } })),
|
|
254
|
+
].flatMap((p) => [p, { start: p.end, end: p.start }]);
|
|
255
|
+
const score = (p: { start: RoutePoint; end: RoutePoint }) =>
|
|
256
|
+
distance(p.start, p.end) +
|
|
257
|
+
sources.reduce((sum, r) => sum + distance(center(r), p.start), 0) +
|
|
258
|
+
targets.reduce((sum, r) => sum + distance(p.end, center(r)), 0);
|
|
259
|
+
exterior.sort((a, b) => score(a) - score(b));
|
|
260
|
+
candidates.push(...exterior);
|
|
261
|
+
for (const candidate of candidates) {
|
|
262
|
+
const points = findPath(candidate.start, candidate.end, "orthogonal", context);
|
|
263
|
+
if (!points) continue;
|
|
264
|
+
result = {
|
|
265
|
+
id: `trunk:${key}`,
|
|
266
|
+
path: style === "bundle" ? curve(points, state, context) : pathFromPoints(points),
|
|
267
|
+
};
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
metrics.searchNodes += context.visited;
|
|
271
|
+
batch.plans.set(key, result);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
export function routeEdge(
|
|
275
|
+
edge: EdgeGeometry,
|
|
276
|
+
state: RoutingInput,
|
|
277
|
+
style: RouteStyle,
|
|
278
|
+
metrics: Metrics,
|
|
279
|
+
batch: Batch,
|
|
280
|
+
): Route {
|
|
281
|
+
const config = state.settings,
|
|
282
|
+
perEdge = config.edges[edge.id] ?? {},
|
|
283
|
+
diagnostics: RoutingDiagnostic[] = [];
|
|
284
|
+
const report = (code: RoutingDiagnostic["code"], message: string) => {
|
|
285
|
+
if (!diagnostics.some((d) => d.code === code))
|
|
286
|
+
diagnostics.push({ code, message, edgeId: edge.id });
|
|
287
|
+
};
|
|
288
|
+
const sourceNode = state.nodes.get(edge.sourceId),
|
|
289
|
+
targetNode = state.nodes.get(edge.targetId);
|
|
290
|
+
const sourceRect = sourceNode && worldRect(sourceNode, state),
|
|
291
|
+
targetRect = targetNode && worldRect(targetNode, state);
|
|
292
|
+
if (!sourceRect || !targetRect)
|
|
293
|
+
report("MISSING_GEOMETRY", "Missing endpoint geometry; rendered a fallback connection");
|
|
294
|
+
const sourceBounds = sourceRect ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
295
|
+
targetBounds = targetRect ?? {
|
|
296
|
+
x: sourceBounds.x + 40,
|
|
297
|
+
y: sourceBounds.y + 40,
|
|
298
|
+
width: 0,
|
|
299
|
+
height: 0,
|
|
300
|
+
};
|
|
301
|
+
const loop = edge.sourceId === edge.targetId;
|
|
302
|
+
const group = groupKey(edge, style, config),
|
|
303
|
+
peers = group ? [...(state.groups.get(group)?.keys() ?? [])] : [];
|
|
304
|
+
const grouped = peers.length > 1 && ["bus", "bundle", "fan"].includes(style);
|
|
305
|
+
const lane = !grouped && peers.length > 1 ? peers.length - 1 - peers.indexOf(edge.id) : 0;
|
|
306
|
+
function terminal(source: boolean): Terminal {
|
|
307
|
+
const node = source ? sourceNode : targetNode,
|
|
308
|
+
outer = source ? sourceBounds : targetBounds,
|
|
309
|
+
attachment = source ? perEdge.sourceAttachment : perEdge.targetAttachment,
|
|
310
|
+
bounds = attachment
|
|
311
|
+
? {
|
|
312
|
+
...attachment.bounds,
|
|
313
|
+
x: outer.x + attachment.bounds.x,
|
|
314
|
+
y: outer.y + attachment.bounds.y,
|
|
315
|
+
}
|
|
316
|
+
: outer,
|
|
317
|
+
other = source ? targetBounds : sourceBounds;
|
|
318
|
+
const name = source ? edge.sourcePort : edge.targetPort;
|
|
319
|
+
let side =
|
|
320
|
+
(source ? perEdge.sourceSide : perEdge.targetSide) ??
|
|
321
|
+
(loop ? (source ? "right" : "top") : sideToward(center(bounds), center(other)));
|
|
322
|
+
let point = anchor(bounds, side);
|
|
323
|
+
if (attachment) {
|
|
324
|
+
const toward = labelRect(edge, state) ?? other;
|
|
325
|
+
side =
|
|
326
|
+
(source ? perEdge.sourceSide : perEdge.targetSide) ??
|
|
327
|
+
sideToward(center(bounds), center(toward));
|
|
328
|
+
point = anchor(bounds, side);
|
|
329
|
+
const margin = Math.min(config.clearance, Math.min(bounds.width, bounds.height) / 2);
|
|
330
|
+
if (side === "left" || side === "right")
|
|
331
|
+
point = {
|
|
332
|
+
...point,
|
|
333
|
+
y: Math.max(
|
|
334
|
+
bounds.y + margin,
|
|
335
|
+
Math.min(bounds.y + bounds.height - margin, center(toward).y),
|
|
336
|
+
),
|
|
337
|
+
};
|
|
338
|
+
else
|
|
339
|
+
point = {
|
|
340
|
+
...point,
|
|
341
|
+
x: Math.max(
|
|
342
|
+
bounds.x + margin,
|
|
343
|
+
Math.min(bounds.x + bounds.width - margin, center(toward).x),
|
|
344
|
+
),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (!attachment && name === undefined && !["bus", "fan", "bundle"].includes(style) && !loop) {
|
|
348
|
+
const nodeId = source ? edge.sourceId : edge.targetId;
|
|
349
|
+
const adjacent = [...(state.incident.get(nodeId)?.keys() ?? [])].filter((id) => {
|
|
350
|
+
const e = state.edges.get(id)!;
|
|
351
|
+
if (e.sourceId === e.targetId) return false;
|
|
352
|
+
const from = e.sourceId === nodeId;
|
|
353
|
+
if ((from ? e.sourcePort : e.targetPort) !== undefined) return false;
|
|
354
|
+
const otherNode = state.nodes.get(from ? e.targetId : e.sourceId);
|
|
355
|
+
const rect = otherNode && worldRect(otherNode, state);
|
|
356
|
+
if (!rect) return false;
|
|
357
|
+
const explicit = from ? config.edges[id]?.sourceSide : config.edges[id]?.targetSide;
|
|
358
|
+
return (explicit ?? sideToward(center(bounds), center(rect))) === side;
|
|
359
|
+
});
|
|
360
|
+
const horizontal = side === "left" || side === "right";
|
|
361
|
+
const projected = (id: string) => {
|
|
362
|
+
const e = state.edges.get(id)!;
|
|
363
|
+
const n = state.nodes.get(e.sourceId === nodeId ? e.targetId : e.sourceId)!;
|
|
364
|
+
const p = center(worldRect(n, state)!);
|
|
365
|
+
return horizontal ? p.y : p.x;
|
|
366
|
+
};
|
|
367
|
+
adjacent.sort((a, b) => projected(a) - projected(b) || (a < b ? -1 : a > b ? 1 : 0));
|
|
368
|
+
const span = horizontal ? bounds.height : bounds.width;
|
|
369
|
+
const spacing = Math.min(config.edgeSpacing, (span * 0.7) / Math.max(1, adjacent.length - 1));
|
|
370
|
+
const shift = (adjacent.indexOf(edge.id) - (adjacent.length - 1) / 2) * spacing;
|
|
371
|
+
point = horizontal ? { x: point.x, y: point.y + shift } : { x: point.x + shift, y: point.y };
|
|
372
|
+
}
|
|
373
|
+
if (name !== undefined) {
|
|
374
|
+
const port = node?.ports?.find((p) => p.name === name);
|
|
375
|
+
if (
|
|
376
|
+
port &&
|
|
377
|
+
Number.isFinite(port.x) &&
|
|
378
|
+
Number.isFinite(port.y) &&
|
|
379
|
+
Number.isFinite(port.width ?? 0) &&
|
|
380
|
+
Number.isFinite(port.height ?? 0)
|
|
381
|
+
) {
|
|
382
|
+
point = {
|
|
383
|
+
x: outer.x + port.x! + (port.width ?? 0) / 2,
|
|
384
|
+
y: outer.y + port.y! + (port.height ?? 0) / 2,
|
|
385
|
+
};
|
|
386
|
+
if (!(source ? perEdge.sourceSide : perEdge.targetSide))
|
|
387
|
+
side = sideToward(center(outer), point);
|
|
388
|
+
} else
|
|
389
|
+
report("MISSING_PORT", `Port ${name} has no positioned geometry; used the node boundary`);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
point,
|
|
393
|
+
side:
|
|
394
|
+
attachment?.facing === "inward"
|
|
395
|
+
? ({ left: "right", right: "left", top: "bottom", bottom: "top" } as const)[side]
|
|
396
|
+
: side,
|
|
397
|
+
ref: {
|
|
398
|
+
kind: "node",
|
|
399
|
+
nodeId: source ? edge.sourceId : edge.targetId,
|
|
400
|
+
...(name === undefined ? {} : { port: name }),
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const source = terminal(true),
|
|
405
|
+
target = terminal(false),
|
|
406
|
+
label = labelRect(edge, state);
|
|
407
|
+
const excluded = new Set(
|
|
408
|
+
[...ancestors(edge.sourceId, state), ...ancestors(edge.targetId, state)]
|
|
409
|
+
.filter((id) => id !== edge.sourceId && id !== edge.targetId)
|
|
410
|
+
.map((id) => `n:${id}`),
|
|
411
|
+
);
|
|
412
|
+
if (perEdge.sourceAttachment !== undefined) excluded.add(`n:${edge.sourceId}`);
|
|
413
|
+
if (perEdge.targetAttachment !== undefined) excluded.add(`n:${edge.targetId}`);
|
|
414
|
+
const context = contextFor(
|
|
415
|
+
state,
|
|
416
|
+
excluded,
|
|
417
|
+
new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
|
|
418
|
+
metrics,
|
|
419
|
+
batch,
|
|
420
|
+
lane * config.edgeSpacing,
|
|
421
|
+
);
|
|
422
|
+
const searchContext = ["curved", "organic", "bundle", "bezier"].includes(style)
|
|
423
|
+
? contextFor(
|
|
424
|
+
state,
|
|
425
|
+
excluded,
|
|
426
|
+
new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
|
|
427
|
+
metrics,
|
|
428
|
+
batch,
|
|
429
|
+
config.radius * 2 + lane * config.edgeSpacing,
|
|
430
|
+
)
|
|
431
|
+
: context;
|
|
432
|
+
const labelAnchor = (toward: RoutePoint) => {
|
|
433
|
+
const side = sideToward(center(label!), toward),
|
|
434
|
+
point = anchor(label!, side);
|
|
435
|
+
// Border attachments may slide within the label; avoid tiny center-offset jogs.
|
|
436
|
+
if (
|
|
437
|
+
(side === "left" || side === "right") &&
|
|
438
|
+
toward.y > label!.y &&
|
|
439
|
+
toward.y < label!.y + label!.height
|
|
440
|
+
)
|
|
441
|
+
return { x: point.x, y: toward.y };
|
|
442
|
+
if (
|
|
443
|
+
(side === "top" || side === "bottom") &&
|
|
444
|
+
toward.x > label!.x &&
|
|
445
|
+
toward.x < label!.x + label!.width
|
|
446
|
+
)
|
|
447
|
+
return { x: toward.x, y: point.y };
|
|
448
|
+
return point;
|
|
449
|
+
};
|
|
450
|
+
const firstTarget: Terminal = label
|
|
451
|
+
? {
|
|
452
|
+
point: labelAnchor(source.point),
|
|
453
|
+
side: sideToward(center(label), source.point),
|
|
454
|
+
ref: { kind: "label", edgeId: edge.id },
|
|
455
|
+
}
|
|
456
|
+
: target;
|
|
457
|
+
const lastSource: Terminal | undefined = label
|
|
458
|
+
? {
|
|
459
|
+
point: labelAnchor(target.point),
|
|
460
|
+
side: sideToward(center(label), target.point),
|
|
461
|
+
ref: { kind: "label", edgeId: edge.id },
|
|
462
|
+
}
|
|
463
|
+
: undefined;
|
|
464
|
+
metrics.routedEdges++;
|
|
465
|
+
const sections: RouteSection[] = [];
|
|
466
|
+
const stub = (t: Terminal) => {
|
|
467
|
+
if (t.ref.kind === "junction") return t.point;
|
|
468
|
+
const v = vector(t.side),
|
|
469
|
+
length =
|
|
470
|
+
config.clearance +
|
|
471
|
+
1 +
|
|
472
|
+
(!grouped && peers.length > 1 ? peers.indexOf(edge.id) : 0) * config.edgeSpacing;
|
|
473
|
+
return { x: t.point.x + v.x * length, y: t.point.y + v.y * length };
|
|
474
|
+
};
|
|
475
|
+
function connection(a: Terminal, b: Terminal, via: readonly RoutePoint[] = []): RoutePath {
|
|
476
|
+
let path: RoutePath | undefined, preferred: RoutePath | undefined;
|
|
477
|
+
if (distance(a.point, b.point) < 1e-8 && !via.length) {
|
|
478
|
+
report("CONSTRAINT_VIOLATION", "Coincident terminals; rendered a visible loop fallback");
|
|
479
|
+
const gap = config.clearance + config.edgeSpacing + 10;
|
|
480
|
+
return pathFromPoints([
|
|
481
|
+
a.point,
|
|
482
|
+
{ x: a.point.x + gap, y: a.point.y },
|
|
483
|
+
{ x: a.point.x + gap, y: a.point.y - gap },
|
|
484
|
+
{ x: a.point.x, y: a.point.y - gap },
|
|
485
|
+
b.point,
|
|
486
|
+
]);
|
|
487
|
+
}
|
|
488
|
+
if (
|
|
489
|
+
loop &&
|
|
490
|
+
!via.length &&
|
|
491
|
+
a.ref.kind === "node" &&
|
|
492
|
+
b.ref.kind === "node" &&
|
|
493
|
+
a.side === "right" &&
|
|
494
|
+
b.side === "top"
|
|
495
|
+
) {
|
|
496
|
+
const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
|
|
497
|
+
const points = [
|
|
498
|
+
a.point,
|
|
499
|
+
{ x: r.x + r.width, y: a.point.y },
|
|
500
|
+
{ x: r.x + r.width, y: r.y },
|
|
501
|
+
{ x: b.point.x, y: r.y },
|
|
502
|
+
b.point,
|
|
503
|
+
];
|
|
504
|
+
const loopPath = pathFromPoints(points);
|
|
505
|
+
if (safe(loopPath, context))
|
|
506
|
+
return ["curved", "bezier", "bundle"].includes(style)
|
|
507
|
+
? curve(points, state, context)
|
|
508
|
+
: style === "organic"
|
|
509
|
+
? organic(points, state, context)
|
|
510
|
+
: loopPath;
|
|
511
|
+
}
|
|
512
|
+
if (style === "straight" && !via.length && !loop) path = pathFromPoints([a.point, b.point]);
|
|
513
|
+
else if (style === "bezier" && !via.length && !loop) {
|
|
514
|
+
const direct = cubic(a, b);
|
|
515
|
+
if (safe(direct, context)) {
|
|
516
|
+
if (pathCost(direct, context) === pathLength(direct)) return direct;
|
|
517
|
+
preferred = direct;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (!path) {
|
|
521
|
+
const start = stub(a),
|
|
522
|
+
end = stub(b),
|
|
523
|
+
waypoints = [...via];
|
|
524
|
+
if (loop && !waypoints.length) {
|
|
525
|
+
const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
|
|
526
|
+
waypoints.push({ x: r.x + r.width, y: r.y });
|
|
527
|
+
}
|
|
528
|
+
if (style === "parallel" && peers.length > 1) {
|
|
529
|
+
// Find a feasible corridor before choosing lanes; an arbitrary midpoint
|
|
530
|
+
// may be inside an obstacle even when good parallel routes exist.
|
|
531
|
+
const base = findPath(start, end, "polyline", context);
|
|
532
|
+
const mid =
|
|
533
|
+
base && base.length > 2
|
|
534
|
+
? base[Math.floor(base.length / 2)]!
|
|
535
|
+
: center(segmentBounds(start, end));
|
|
536
|
+
const d = Math.max(1, distance(start, end)),
|
|
537
|
+
rank = peers.indexOf(edge.id) + 1;
|
|
538
|
+
const orientation = edge.sourceId < edge.targetId ? 1 : -1;
|
|
539
|
+
if (base && base.length > 2) waypoints.push(mid);
|
|
540
|
+
for (const sign of base && base.length > 2 ? [] : [orientation, -orientation]) {
|
|
541
|
+
const lane = {
|
|
542
|
+
x: mid.x - ((end.y - start.y) / d) * config.edgeSpacing * rank * sign,
|
|
543
|
+
y: mid.y + ((end.x - start.x) / d) * config.edgeSpacing * rank * sign,
|
|
544
|
+
};
|
|
545
|
+
if (clear(lane, lane, context)) {
|
|
546
|
+
waypoints.push(lane);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (waypoints.length === via.length)
|
|
551
|
+
report("CONSTRAINT_VIOLATION", "No clear parallel lane; rendered an individual route");
|
|
552
|
+
}
|
|
553
|
+
const points: RoutePoint[] = [a.point],
|
|
554
|
+
anchors = [start, ...waypoints, end];
|
|
555
|
+
const searchStyle =
|
|
556
|
+
style === "polyline" || style === "organic" || style === "parallel"
|
|
557
|
+
? "polyline"
|
|
558
|
+
: style === "octilinear"
|
|
559
|
+
? "octilinear"
|
|
560
|
+
: "orthogonal";
|
|
561
|
+
let ok = clear(a.point, start, context) && clear(end, b.point, context);
|
|
562
|
+
for (let i = 1; ok && i < anchors.length; i++) {
|
|
563
|
+
const directions = {
|
|
564
|
+
incoming: i === 1 && a.ref.kind !== "junction" ? vector(a.side) : undefined,
|
|
565
|
+
outgoing:
|
|
566
|
+
i === anchors.length - 1 ? { x: -vector(b.side).x, y: -vector(b.side).y } : undefined,
|
|
567
|
+
};
|
|
568
|
+
let found = findPath(anchors[i - 1]!, anchors[i]!, searchStyle, searchContext, directions);
|
|
569
|
+
if (!found && searchContext !== context && !searchContext.budgetExceeded) {
|
|
570
|
+
// Extra curve room is optional: tight channels may use safe line segments.
|
|
571
|
+
const narrow = { ...searchContext, obstacles: context.obstacles };
|
|
572
|
+
found = findPath(anchors[i - 1]!, anchors[i]!, searchStyle, narrow, directions);
|
|
573
|
+
searchContext.visited = narrow.visited;
|
|
574
|
+
searchContext.budgetExceeded = narrow.budgetExceeded;
|
|
575
|
+
}
|
|
576
|
+
if (found) points.push(...found.slice(i === 1 ? 0 : 1));
|
|
577
|
+
else ok = false;
|
|
578
|
+
}
|
|
579
|
+
points.push(b.point);
|
|
580
|
+
if (ok) {
|
|
581
|
+
const simple = simplify(points);
|
|
582
|
+
path =
|
|
583
|
+
style === "curved" || style === "bundle" || style === "bezier"
|
|
584
|
+
? curve(simple, state, context)
|
|
585
|
+
: style === "organic"
|
|
586
|
+
? organic(simple, state, context)
|
|
587
|
+
: pathFromPoints(simple);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (
|
|
591
|
+
preferred &&
|
|
592
|
+
(!path || !safe(path, context) || pathCost(preferred, context) <= pathCost(path, context))
|
|
593
|
+
)
|
|
594
|
+
return preferred;
|
|
595
|
+
if (path && safe(path, context)) return path;
|
|
596
|
+
report(
|
|
597
|
+
searchContext.budgetExceeded ? "SEARCH_BUDGET" : "ROUTE_BLOCKED",
|
|
598
|
+
"Preferred route unavailable; fallback may cross obstacles or violate routing constraints",
|
|
599
|
+
);
|
|
600
|
+
// Always keep every endpoint and authored waypoint visible, even if no feasible path exists.
|
|
601
|
+
if (loop && !via.length) {
|
|
602
|
+
const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
|
|
603
|
+
return pathFromPoints([
|
|
604
|
+
a.point,
|
|
605
|
+
{ x: r.x + r.width, y: a.point.y },
|
|
606
|
+
{ x: r.x + r.width, y: r.y },
|
|
607
|
+
{ x: b.point.x, y: r.y },
|
|
608
|
+
b.point,
|
|
609
|
+
]);
|
|
610
|
+
}
|
|
611
|
+
return pathFromPoints([a.point, ...via, b.point]);
|
|
612
|
+
}
|
|
613
|
+
const via = perEdge.waypoints ?? [];
|
|
614
|
+
if (via.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
|
|
615
|
+
throw new RangeError(`Non-finite waypoint on ${edge.id}`);
|
|
616
|
+
const plan = grouped ? groupPlan(group!, state, metrics, batch, style) : undefined;
|
|
617
|
+
if (grouped && !plan)
|
|
618
|
+
report("ROUTE_BLOCKED", "Shared trunk unavailable; used individual fallback routing");
|
|
619
|
+
if (plan) {
|
|
620
|
+
const start = plan.path.start,
|
|
621
|
+
end = plan.path.segments.at(-1)?.to ?? start;
|
|
622
|
+
const j0: Terminal = {
|
|
623
|
+
point: start,
|
|
624
|
+
side: sideToward(start, source.point),
|
|
625
|
+
ref: { kind: "junction", id: `${plan.id}:start` },
|
|
626
|
+
};
|
|
627
|
+
const j1: Terminal = {
|
|
628
|
+
point: end,
|
|
629
|
+
side: sideToward(end, firstTarget.point),
|
|
630
|
+
ref: { kind: "junction", id: `${plan.id}:end` },
|
|
631
|
+
};
|
|
632
|
+
sections.push(
|
|
633
|
+
{ id: `${edge.id}:source`, from: source.ref, to: j0.ref, path: connection(source, j0, via) },
|
|
634
|
+
{ id: `${edge.id}:trunk`, sharedId: plan.id, from: j0.ref, to: j1.ref, path: plan.path },
|
|
635
|
+
{
|
|
636
|
+
id: `${edge.id}:target`,
|
|
637
|
+
from: j1.ref,
|
|
638
|
+
to: firstTarget.ref,
|
|
639
|
+
path: connection(j1, firstTarget),
|
|
640
|
+
},
|
|
641
|
+
);
|
|
642
|
+
} else
|
|
643
|
+
sections.push({
|
|
644
|
+
id: `${edge.id}:0`,
|
|
645
|
+
from: source.ref,
|
|
646
|
+
to: firstTarget.ref,
|
|
647
|
+
path: connection(source, firstTarget, via),
|
|
648
|
+
});
|
|
649
|
+
if (lastSource)
|
|
650
|
+
sections.push({
|
|
651
|
+
id: `${edge.id}:label-target`,
|
|
652
|
+
from: lastSource.ref,
|
|
653
|
+
to: target.ref,
|
|
654
|
+
path: connection(lastSource, target),
|
|
655
|
+
});
|
|
656
|
+
if (
|
|
657
|
+
sections.some((section) =>
|
|
658
|
+
pathReservations(section.path).some((segment) => batch.conflicts?.(segment.a, segment.b)),
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
report(
|
|
662
|
+
"ROUTE_CONFLICT",
|
|
663
|
+
"Route crosses or overlaps another edge; retained a compact visible path",
|
|
664
|
+
);
|
|
665
|
+
metrics.searchNodes += searchContext.visited + (searchContext === context ? 0 : context.visited);
|
|
666
|
+
return {
|
|
667
|
+
edgeId: edge.id,
|
|
668
|
+
sections,
|
|
669
|
+
status: diagnostics.length ? "fallback" : "routed",
|
|
670
|
+
diagnostics,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
export function routeBounds(route: Route): RouteBounds {
|
|
674
|
+
return route.sections.map((s) => getPathBounds(s.path)).reduce(union);
|
|
675
|
+
}
|
|
676
|
+
export { pointsBounds };
|