@statelyai/layout 0.4.3 → 0.5.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/README.md +32 -3
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +1 -1
- package/dist/elkjs/index.d.mts +1 -1
- package/dist/elkjs/index.mjs +2 -2
- package/dist/elkjs/main.cjs +1 -1
- package/dist/elkjs/main.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/{elkjs-B5kN_zS6.mjs → elkjs-DBkalvt1.mjs} +3 -3
- package/dist/{elkjs-Cj-lWiK1.cjs → elkjs-DonMqGjf.cjs} +3 -3
- package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
- package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
- package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
- package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
- package/dist/index.cjs +7 -4
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +1 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-BsAdm_u8.mjs → layered-CmL7oynw.mjs} +245 -69
- package/dist/{layered-BJu8A21s.cjs → layered-SB2mlAqt.cjs} +245 -69
- package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-BXoIpYL4.mjs} +1 -1
- package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CAyJ-I-u.cjs} +1 -1
- package/dist/routing/index.cjs +5 -2
- package/dist/routing/index.d.cts +3 -3
- package/dist/routing/index.d.mts +3 -3
- package/dist/routing/index.mjs +2 -2
- package/dist/routing-D-LfMXOF.mjs +5189 -0
- package/dist/routing-Phxu1R0x.cjs +5423 -0
- package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
- package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
- package/package.json +4 -1
- package/src/authoring/partial.ts +1 -7
- package/src/authoring/routing.ts +64 -19
- package/src/layered/feedback-chains.ts +22 -4
- package/src/layered/index.ts +173 -41
- package/src/layered/north-south-ports.ts +17 -4
- package/src/layered/strategies.ts +143 -15
- package/src/routing/algorithms.ts +1436 -148
- package/src/routing/bezier-fit.ts +605 -0
- package/src/routing/bezier-visibility.ts +107 -0
- package/src/routing/bezier.ts +596 -0
- package/src/routing/engine.ts +65 -13
- package/src/routing/index.ts +14 -0
- package/src/routing/model.ts +185 -1
- package/src/routing/path.ts +1 -1
- package/src/routing/policy-geometry.ts +536 -0
- package/src/routing/search.ts +10 -2
- package/src/routing/types.ts +77 -1
- package/dist/routing-CR8flv8b.mjs +0 -2780
- package/dist/routing-QIdGGFBe.cjs +0 -2996
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { routeEdge, type Batch, type Metrics, type RoutingInput } from "./algorithms";
|
|
2
|
+
import { fitBezierTransition } from "./bezier-fit";
|
|
3
|
+
import { pathReservations } from "./coordination";
|
|
4
|
+
import { ancestors, center, labelRect, sideToward, worldRect, type EdgeGeometry } from "./model";
|
|
5
|
+
import {
|
|
6
|
+
distance,
|
|
7
|
+
flattenPath,
|
|
8
|
+
getPathBounds,
|
|
9
|
+
pathFromPoints,
|
|
10
|
+
roundCorners,
|
|
11
|
+
segmentCrossesRect,
|
|
12
|
+
} from "./path";
|
|
13
|
+
import { inflate, union } from "./spatial";
|
|
14
|
+
import type {
|
|
15
|
+
BezierEndpoint,
|
|
16
|
+
BezierGuide,
|
|
17
|
+
EdgeRoutingSettings,
|
|
18
|
+
Route,
|
|
19
|
+
RouteBounds,
|
|
20
|
+
RoutePoint,
|
|
21
|
+
RouteSide,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
const SIDES: readonly RouteSide[] = ["left", "right", "top", "bottom"];
|
|
25
|
+
|
|
26
|
+
export function labelFamily(id: string, state: RoutingInput): string[] {
|
|
27
|
+
const result: string[] = [],
|
|
28
|
+
seen = new Set<string>();
|
|
29
|
+
let current: string | undefined = id;
|
|
30
|
+
while (current !== undefined && !seen.has(current)) {
|
|
31
|
+
result.push(current);
|
|
32
|
+
seen.add(current);
|
|
33
|
+
current = state.settings.edges[current]?.sourceLabel;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function expandLabelFamilies(affected: Set<string>, state: RoutingInput): void {
|
|
39
|
+
const children = new Map<string, string[]>();
|
|
40
|
+
for (const [id, config] of Object.entries(state.settings.edges)) {
|
|
41
|
+
if (config.sourceLabel === undefined) continue;
|
|
42
|
+
const list = children.get(config.sourceLabel) ?? [];
|
|
43
|
+
list.push(id);
|
|
44
|
+
children.set(config.sourceLabel, list);
|
|
45
|
+
}
|
|
46
|
+
const queue = [...affected];
|
|
47
|
+
for (let i = 0; i < queue.length; i++) {
|
|
48
|
+
const id = queue[i]!;
|
|
49
|
+
const parent = state.settings.edges[id]?.sourceLabel;
|
|
50
|
+
for (const member of [...(parent === undefined ? [] : [parent]), ...(children.get(id) ?? [])]) {
|
|
51
|
+
if (affected.has(member)) continue;
|
|
52
|
+
affected.add(member);
|
|
53
|
+
queue.push(member);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function boundarySide(point: RoutePoint, bounds: RouteBounds): RouteSide {
|
|
59
|
+
const distances: { side: RouteSide; distance: number }[] = [
|
|
60
|
+
{ side: "left", distance: Math.abs(point.x - bounds.x) },
|
|
61
|
+
{ side: "right", distance: Math.abs(point.x - bounds.x - bounds.width) },
|
|
62
|
+
{ side: "top", distance: Math.abs(point.y - bounds.y) },
|
|
63
|
+
{ side: "bottom", distance: Math.abs(point.y - bounds.y - bounds.height) },
|
|
64
|
+
];
|
|
65
|
+
return distances.sort((a, b) => a.distance - b.distance)[0]!.side;
|
|
66
|
+
}
|
|
67
|
+
function guide(
|
|
68
|
+
path: BezierGuide["path"],
|
|
69
|
+
from: RouteBounds,
|
|
70
|
+
to: RouteBounds,
|
|
71
|
+
): BezierGuide | undefined {
|
|
72
|
+
if (!path) return;
|
|
73
|
+
const points = flattenPath(path);
|
|
74
|
+
const first = points[0],
|
|
75
|
+
last = points.at(-1);
|
|
76
|
+
return first && last
|
|
77
|
+
? {
|
|
78
|
+
points,
|
|
79
|
+
path,
|
|
80
|
+
sourceDirection: boundarySide(first, from),
|
|
81
|
+
targetDirection: boundarySide(last, to),
|
|
82
|
+
}
|
|
83
|
+
: undefined;
|
|
84
|
+
}
|
|
85
|
+
function reject(edge: EdgeGeometry, message: string): Route {
|
|
86
|
+
return {
|
|
87
|
+
edgeId: edge.id,
|
|
88
|
+
status: "fallback",
|
|
89
|
+
sections: [],
|
|
90
|
+
diagnostics: [{ code: "CONSTRAINT_VIOLATION", edgeId: edge.id, message }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function endpointBounds(
|
|
94
|
+
edge: EdgeGeometry,
|
|
95
|
+
state: RoutingInput,
|
|
96
|
+
source: boolean,
|
|
97
|
+
): RouteBounds | undefined {
|
|
98
|
+
const config = state.settings.edges[edge.id];
|
|
99
|
+
if (source && config?.sourceLabel !== undefined) {
|
|
100
|
+
const parent = state.edges.get(config.sourceLabel);
|
|
101
|
+
return parent && labelRect(parent, state);
|
|
102
|
+
}
|
|
103
|
+
const explicit = source ? config?.bezier?.source : config?.bezier?.target;
|
|
104
|
+
if (explicit) return explicit.kind === "floating" ? explicit.bounds : explicit.owner;
|
|
105
|
+
const node = state.nodes.get(source ? edge.sourceId : edge.targetId);
|
|
106
|
+
const outer = node && worldRect(node, state);
|
|
107
|
+
const attachment = source ? config?.sourceAttachment : config?.targetAttachment;
|
|
108
|
+
return outer && attachment
|
|
109
|
+
? { ...attachment.bounds, x: outer.x + attachment.bounds.x, y: outer.y + attachment.bounds.y }
|
|
110
|
+
: outer;
|
|
111
|
+
}
|
|
112
|
+
function choose(sides: readonly RouteSide[], bounds: RouteBounds, toward: RoutePoint): RouteSide {
|
|
113
|
+
const preferred = sideToward(center(bounds), toward);
|
|
114
|
+
return sides.includes(preferred) ? preferred : sides[0]!;
|
|
115
|
+
}
|
|
116
|
+
function rounded(value: BezierGuide, radius: number): BezierGuide {
|
|
117
|
+
return { ...value, path: value.path ?? roundCorners(pathFromPoints(value.points), { radius }) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function routeBezierEdge(
|
|
121
|
+
edge: EdgeGeometry,
|
|
122
|
+
state: RoutingInput,
|
|
123
|
+
routes: ReadonlyMap<string, Route>,
|
|
124
|
+
metrics: Metrics,
|
|
125
|
+
batch: Batch,
|
|
126
|
+
): Route {
|
|
127
|
+
const config = state.settings.edges[edge.id] ?? {};
|
|
128
|
+
const family =
|
|
129
|
+
config.sourceLabel !== undefined ||
|
|
130
|
+
Object.values(state.settings.edges).some((other) => other.sourceLabel === edge.id);
|
|
131
|
+
if (state.settings.bezier === undefined && config.bezier === undefined && !family)
|
|
132
|
+
return routeEdge(edge, state, "bezier", metrics, batch);
|
|
133
|
+
if (config.bezier?.disabled)
|
|
134
|
+
return reject(edge, "Bézier routing disabled by the edge constraints");
|
|
135
|
+
const ancestry = labelFamily(edge.id, state);
|
|
136
|
+
const repeatedParent = state.settings.edges[ancestry.at(-1)!]?.sourceLabel;
|
|
137
|
+
if (repeatedParent !== undefined && ancestry.includes(repeatedParent))
|
|
138
|
+
return reject(edge, "Cyclic source label reference");
|
|
139
|
+
const sourceBounds = endpointBounds(edge, state, true),
|
|
140
|
+
targetBounds = endpointBounds(edge, state, false),
|
|
141
|
+
label = labelRect(edge, state);
|
|
142
|
+
if (!label) {
|
|
143
|
+
if (family) return reject(edge, "A label chain requires positioned label geometry");
|
|
144
|
+
return routeEdge(edge, state, "bezier", metrics, batch);
|
|
145
|
+
}
|
|
146
|
+
if (!sourceBounds || !targetBounds)
|
|
147
|
+
return reject(edge, "Missing endpoint or source label geometry");
|
|
148
|
+
const declaredSource = config.sourceLabel === undefined ? config.bezier?.source : undefined;
|
|
149
|
+
const sourceSides =
|
|
150
|
+
config.sourceSides ??
|
|
151
|
+
(config.sourceSide
|
|
152
|
+
? [config.sourceSide]
|
|
153
|
+
: declaredSource?.kind === "floating"
|
|
154
|
+
? declaredSource.sides
|
|
155
|
+
: declaredSource?.kind === "port"
|
|
156
|
+
? [declaredSource.direction]
|
|
157
|
+
: SIDES);
|
|
158
|
+
const targetSides =
|
|
159
|
+
config.targetSides ??
|
|
160
|
+
(config.targetSide ? [config.targetSide] : (config.bezier?.target?.sides ?? SIDES));
|
|
161
|
+
const labelSides = config.labelSides ?? SIDES;
|
|
162
|
+
let allowedSource = [...sourceSides];
|
|
163
|
+
if (config.sourceLabel !== undefined) {
|
|
164
|
+
const parent = routes.get(config.sourceLabel);
|
|
165
|
+
if (!parent?.sections.length) return reject(edge, "Source label route was rejected");
|
|
166
|
+
const reserved = new Set<RouteSide>();
|
|
167
|
+
for (const section of parent.sections) {
|
|
168
|
+
if (section.from.kind === "label" && section.from.edgeId === config.sourceLabel)
|
|
169
|
+
reserved.add(boundarySide(section.path.start, sourceBounds));
|
|
170
|
+
if (section.to.kind === "label" && section.to.edgeId === config.sourceLabel)
|
|
171
|
+
reserved.add(
|
|
172
|
+
boundarySide(section.path.segments.at(-1)?.to ?? section.path.start, sourceBounds),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
for (const [id, route] of routes) {
|
|
176
|
+
if (id === edge.id || state.settings.edges[id]?.sourceLabel !== config.sourceLabel) continue;
|
|
177
|
+
if (id > edge.id) continue;
|
|
178
|
+
const section = route.sections[0];
|
|
179
|
+
if (section) reserved.add(boundarySide(section.path.start, sourceBounds));
|
|
180
|
+
}
|
|
181
|
+
allowedSource = allowedSource.filter((side) => !reserved.has(side));
|
|
182
|
+
}
|
|
183
|
+
if (!allowedSource.length || !targetSides.length || !labelSides.length)
|
|
184
|
+
return reject(edge, "No allowed attachment sides remain for this label chain");
|
|
185
|
+
const sourceSide = choose(allowedSource, sourceBounds, center(label));
|
|
186
|
+
const targetNode = state.nodes.get(edge.targetId);
|
|
187
|
+
const targetPort = targetNode?.ports?.find((port) => port.name === edge.targetPort);
|
|
188
|
+
const targetPortSide =
|
|
189
|
+
targetPort?.x !== undefined && targetPort.y !== undefined
|
|
190
|
+
? sideToward(center(targetBounds), {
|
|
191
|
+
x: targetBounds.x + targetPort.x + (targetPort.width ?? 0) / 2,
|
|
192
|
+
y: targetBounds.y + targetPort.y + (targetPort.height ?? 0) / 2,
|
|
193
|
+
})
|
|
194
|
+
: undefined;
|
|
195
|
+
const targetSide =
|
|
196
|
+
config.targetSide ?? targetPortSide ?? choose(targetSides, targetBounds, center(label));
|
|
197
|
+
if (!targetSides.includes(targetSide))
|
|
198
|
+
return reject(edge, "Target port is outside the allowed attachment sides");
|
|
199
|
+
const avoidObstacles =
|
|
200
|
+
config.bezier?.avoidObstacles ?? state.settings.bezier?.avoidObstacles ?? true;
|
|
201
|
+
const sourceEndpoint: BezierEndpoint =
|
|
202
|
+
config.sourceLabel !== undefined
|
|
203
|
+
? { kind: "floating", bounds: sourceBounds, sides: allowedSource }
|
|
204
|
+
: (config.bezier?.source ?? positionedSource(edge, state, sourceBounds, allowedSource));
|
|
205
|
+
const adjusted: EdgeRoutingSettings = {
|
|
206
|
+
...config,
|
|
207
|
+
sourceSide,
|
|
208
|
+
targetSide,
|
|
209
|
+
labelSides,
|
|
210
|
+
...(sourceEndpoint.kind === "port"
|
|
211
|
+
? { bezier: { ...config.bezier, source: sourceEndpoint } }
|
|
212
|
+
: {}),
|
|
213
|
+
...(config.sourceLabel === undefined ? {} : { sourceAttachment: undefined }),
|
|
214
|
+
};
|
|
215
|
+
let guideObstacles = state.obstacles;
|
|
216
|
+
if (config.sourceLabel === undefined)
|
|
217
|
+
guideObstacles = guideObstacles.set(`n:${edge.sourceId}`, sourceBounds);
|
|
218
|
+
guideObstacles = guideObstacles.set(`n:${edge.targetId}`, targetBounds);
|
|
219
|
+
if (avoidObstacles)
|
|
220
|
+
for (const [index, obstacle] of (config.bezier?.obstacles ?? []).entries()) {
|
|
221
|
+
if ([sourceBounds, targetBounds, label].some((bounds) => sameBounds(bounds, obstacle)))
|
|
222
|
+
continue;
|
|
223
|
+
guideObstacles = guideObstacles.set(`bezier:${edge.id}:${index}`, obstacle);
|
|
224
|
+
}
|
|
225
|
+
const guideState: RoutingInput = {
|
|
226
|
+
...state,
|
|
227
|
+
obstacles: guideObstacles,
|
|
228
|
+
settings: {
|
|
229
|
+
...state.settings,
|
|
230
|
+
clearance: Math.max(
|
|
231
|
+
state.settings.clearance,
|
|
232
|
+
edge.targetPort === undefined
|
|
233
|
+
? (config.bezier?.targetClearance ?? state.settings.bezier?.targetClearance ?? 10)
|
|
234
|
+
: 0,
|
|
235
|
+
),
|
|
236
|
+
edges: { ...state.settings.edges, [edge.id]: adjusted },
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const planned = routeEdge(edge, guideState, "orthogonal", metrics, batch);
|
|
240
|
+
const suppliedGuides = config.waypoints?.length ? undefined : config.bezier?.guides;
|
|
241
|
+
if (
|
|
242
|
+
family &&
|
|
243
|
+
!suppliedGuides &&
|
|
244
|
+
planned.diagnostics.some(
|
|
245
|
+
(diagnostic) =>
|
|
246
|
+
diagnostic.code === "ROUTE_BLOCKED" || diagnostic.code === "CONSTRAINT_VIOLATION",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
return reject(edge, "No obstacle-clear guide is available for this label chain");
|
|
250
|
+
let source = suppliedGuides?.source ?? guide(planned.sections[0]?.path, sourceBounds, label);
|
|
251
|
+
let target = suppliedGuides?.target ?? guide(planned.sections.at(-1)?.path, label, targetBounds);
|
|
252
|
+
if (!source || !target || planned.sections.length < 2)
|
|
253
|
+
return reject(edge, "No complete source-label-target guide is available");
|
|
254
|
+
if (!suppliedGuides && edge.targetPort === undefined)
|
|
255
|
+
target = shortenTarget(
|
|
256
|
+
target,
|
|
257
|
+
config.bezier?.targetClearance ?? state.settings.bezier?.targetClearance ?? 10,
|
|
258
|
+
);
|
|
259
|
+
if (sourceEndpoint.kind === "port") {
|
|
260
|
+
source = {
|
|
261
|
+
...source,
|
|
262
|
+
path: undefined,
|
|
263
|
+
sourceDirection: sourceEndpoint.direction,
|
|
264
|
+
points: [sourceEndpoint.point, sourceEndpoint.escape, ...source.points.slice(1)],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const excluded = new Set([
|
|
268
|
+
`e:${edge.id}`,
|
|
269
|
+
...ancestors(edge.sourceId, state).map((id) => `n:${id}`),
|
|
270
|
+
...ancestors(edge.targetId, state).map((id) => `n:${id}`),
|
|
271
|
+
]);
|
|
272
|
+
if (config.sourceLabel !== undefined) excluded.add(`e:${config.sourceLabel}`);
|
|
273
|
+
const obstacles =
|
|
274
|
+
config.bezier?.obstacles ??
|
|
275
|
+
[...guideObstacles.bounds].filter(([id]) => !excluded.has(id)).map(([, bounds]) => bounds);
|
|
276
|
+
const inwardSource =
|
|
277
|
+
config.sourceLabel === undefined &&
|
|
278
|
+
sourceEndpoint.kind !== "port" &&
|
|
279
|
+
config.sourceAttachment?.facing === "inward";
|
|
280
|
+
const inwardTarget = config.targetAttachment?.facing === "inward";
|
|
281
|
+
const terminalBounds = [
|
|
282
|
+
...(config.bezier?.terminalBounds ?? []),
|
|
283
|
+
...(inwardSource ? [] : [sourceBounds]),
|
|
284
|
+
...(inwardTarget ? [] : [targetBounds]),
|
|
285
|
+
];
|
|
286
|
+
const barriers = [...(avoidObstacles ? obstacles : []), ...terminalBounds];
|
|
287
|
+
const fit = (source: BezierGuide, target: BezierGuide, floating = true) =>
|
|
288
|
+
config.waypoints?.length || inwardSource || inwardTarget
|
|
289
|
+
? { source, target }
|
|
290
|
+
: fitBezierTransition({
|
|
291
|
+
source,
|
|
292
|
+
target,
|
|
293
|
+
label,
|
|
294
|
+
obstacles,
|
|
295
|
+
terminalBounds,
|
|
296
|
+
avoidObstacles,
|
|
297
|
+
attachmentMode: config.bezier?.attachmentMode ?? state.settings.bezier?.attachmentMode,
|
|
298
|
+
targetClearance: config.bezier?.targetClearance ?? state.settings.bezier?.targetClearance,
|
|
299
|
+
labelTangent: config.bezier?.labelTangent,
|
|
300
|
+
endpoints:
|
|
301
|
+
!floating ||
|
|
302
|
+
family ||
|
|
303
|
+
edge.targetPort !== undefined ||
|
|
304
|
+
(config.bezier?.attachmentMode ?? state.settings.bezier?.attachmentMode) ===
|
|
305
|
+
"orthogonal"
|
|
306
|
+
? undefined
|
|
307
|
+
: {
|
|
308
|
+
source: sourceEndpoint,
|
|
309
|
+
target: config.bezier?.target ?? {
|
|
310
|
+
kind: "floating",
|
|
311
|
+
bounds: targetBounds,
|
|
312
|
+
sides: targetSides,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const valid = (candidate: { source: BezierGuide; target: BezierGuide }): boolean => {
|
|
317
|
+
const sourcePath = candidate.source.path ?? pathFromPoints(candidate.source.points);
|
|
318
|
+
const targetPath = candidate.target.path ?? pathFromPoints(candidate.target.points);
|
|
319
|
+
const sourceEnd = sourcePath.segments.at(-1)?.to ?? sourcePath.start;
|
|
320
|
+
const targetEnd = targetPath.segments.at(-1)?.to ?? targetPath.start;
|
|
321
|
+
const sourceLabelSide = boundarySide(sourceEnd, label);
|
|
322
|
+
const targetLabelSide = boundarySide(targetPath.start, label);
|
|
323
|
+
return (
|
|
324
|
+
labelSides.includes(sourceLabelSide) &&
|
|
325
|
+
labelSides.includes(targetLabelSide) &&
|
|
326
|
+
(labelSides.length === 1 || sourceLabelSide !== targetLabelSide) &&
|
|
327
|
+
allowedSource.includes(candidate.source.sourceDirection) &&
|
|
328
|
+
(sourceEndpoint.kind === "port"
|
|
329
|
+
? allowedSource.includes(sourceEndpoint.direction) &&
|
|
330
|
+
followsPort(sourcePath, sourceEndpoint)
|
|
331
|
+
: allowedSource.includes(boundarySide(sourcePath.start, sourceBounds))) &&
|
|
332
|
+
targetSides.includes(candidate.target.targetDirection) &&
|
|
333
|
+
(targetPort?.x !== undefined && targetPort.y !== undefined
|
|
334
|
+
? targetEnd.x === targetBounds.x + targetPort.x + (targetPort.width ?? 0) / 2 &&
|
|
335
|
+
targetEnd.y === targetBounds.y + targetPort.y + (targetPort.height ?? 0) / 2 &&
|
|
336
|
+
candidate.target.targetDirection === targetSide
|
|
337
|
+
: targetSides.includes(arrivalSide(targetPath, targetBounds))) &&
|
|
338
|
+
safeGuides(candidate, barriers, sourceEndpoint)
|
|
339
|
+
);
|
|
340
|
+
};
|
|
341
|
+
const candidates = [fit(source, target)];
|
|
342
|
+
if (
|
|
343
|
+
!family &&
|
|
344
|
+
edge.targetPort === undefined &&
|
|
345
|
+
(config.bezier?.attachmentMode ?? state.settings.bezier?.attachmentMode) !== "orthogonal"
|
|
346
|
+
)
|
|
347
|
+
candidates.push(fit(source, target, false));
|
|
348
|
+
const guides = [
|
|
349
|
+
{
|
|
350
|
+
source: rounded(source, state.settings.radius),
|
|
351
|
+
target: rounded(target, state.settings.radius),
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
let retrySource = guide(planned.sections[0]?.path, sourceBounds, label);
|
|
355
|
+
let retryTarget = guide(planned.sections.at(-1)?.path, label, targetBounds);
|
|
356
|
+
if (retrySource && sourceEndpoint.kind === "port")
|
|
357
|
+
retrySource = {
|
|
358
|
+
...retrySource,
|
|
359
|
+
path: undefined,
|
|
360
|
+
sourceDirection: sourceEndpoint.direction,
|
|
361
|
+
points: [sourceEndpoint.point, sourceEndpoint.escape, ...retrySource.points.slice(1)],
|
|
362
|
+
};
|
|
363
|
+
if (retryTarget && edge.targetPort === undefined)
|
|
364
|
+
retryTarget = shortenTarget(
|
|
365
|
+
retryTarget,
|
|
366
|
+
config.bezier?.targetClearance ?? state.settings.bezier?.targetClearance ?? 10,
|
|
367
|
+
);
|
|
368
|
+
if (retrySource && retryTarget) {
|
|
369
|
+
guides.push({ source: retrySource, target: retryTarget });
|
|
370
|
+
if (suppliedGuides) candidates.push(fit(retrySource, retryTarget, false));
|
|
371
|
+
}
|
|
372
|
+
const assess = (candidate: { source: BezierGuide; target: BezierGuide }) => {
|
|
373
|
+
const paths = [candidate.source, candidate.target].map(
|
|
374
|
+
(guide) => guide.path ?? pathFromPoints(guide.points),
|
|
375
|
+
);
|
|
376
|
+
for (const path of paths) {
|
|
377
|
+
const bounds = inflate(getPathBounds(path), state.settings.clearance);
|
|
378
|
+
batch.dependencyBounds = batch.dependencyBounds
|
|
379
|
+
? union(batch.dependencyBounds, bounds)
|
|
380
|
+
: bounds;
|
|
381
|
+
}
|
|
382
|
+
if (!valid(candidate)) return [];
|
|
383
|
+
const segments = paths.flatMap(pathReservations);
|
|
384
|
+
const conflicts = segments.reduce(
|
|
385
|
+
(cost, segment) => cost + (batch.edgeCost?.(segment.a, segment.b) ?? 0),
|
|
386
|
+
0,
|
|
387
|
+
);
|
|
388
|
+
const length = segments.reduce((cost, segment) => cost + distance(segment.a, segment.b), 0);
|
|
389
|
+
return [{ candidate, conflicts, cost: length + conflicts }];
|
|
390
|
+
};
|
|
391
|
+
const isShaped = (candidate: { source: BezierGuide; target: BezierGuide }) =>
|
|
392
|
+
[candidate.source, candidate.target].some((guide) =>
|
|
393
|
+
guide.path?.segments.some((segment) => segment.kind !== "line"),
|
|
394
|
+
);
|
|
395
|
+
const shaped = candidates
|
|
396
|
+
.filter(isShaped)
|
|
397
|
+
.flatMap(assess)
|
|
398
|
+
.sort((a, b) => a.cost - b.cost);
|
|
399
|
+
const unshaped = [...guides, ...candidates.filter((candidate) => !isShaped(candidate))]
|
|
400
|
+
.flatMap(assess)
|
|
401
|
+
.sort((a, b) => a.cost - b.cost);
|
|
402
|
+
let best = shaped[0] ?? unshaped[0];
|
|
403
|
+
if (best && shaped.length)
|
|
404
|
+
for (const candidate of unshaped)
|
|
405
|
+
if (candidate.conflicts < best.conflicts && candidate.cost < best.cost) best = candidate;
|
|
406
|
+
if (!best) return reject(edge, "Bézier paths violate attachment or obstacle constraints");
|
|
407
|
+
const result = best.candidate;
|
|
408
|
+
const diagnostics = planned.diagnostics.filter(
|
|
409
|
+
(diagnostic) => diagnostic.code !== "ROUTE_CONFLICT",
|
|
410
|
+
);
|
|
411
|
+
if (
|
|
412
|
+
[result.source, result.target].some((guide) =>
|
|
413
|
+
pathReservations(guide.path ?? pathFromPoints(guide.points)).some((segment) =>
|
|
414
|
+
batch.conflicts?.(segment.a, segment.b),
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
diagnostics.push({
|
|
419
|
+
code: "ROUTE_CONFLICT",
|
|
420
|
+
edgeId: edge.id,
|
|
421
|
+
message: "Route crosses or overlaps another edge; retained a compact visible path",
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
...planned,
|
|
425
|
+
status: diagnostics.length ? "fallback" : "routed",
|
|
426
|
+
diagnostics,
|
|
427
|
+
sections: [
|
|
428
|
+
{
|
|
429
|
+
id: `${edge.id}:source-label`,
|
|
430
|
+
from:
|
|
431
|
+
config.sourceLabel === undefined
|
|
432
|
+
? {
|
|
433
|
+
kind: "node",
|
|
434
|
+
nodeId: edge.sourceId,
|
|
435
|
+
...(edge.sourcePort === undefined ? {} : { port: edge.sourcePort }),
|
|
436
|
+
}
|
|
437
|
+
: { kind: "label", edgeId: config.sourceLabel },
|
|
438
|
+
to: { kind: "label", edgeId: edge.id },
|
|
439
|
+
fromSide:
|
|
440
|
+
sourceEndpoint.kind === "port" ? sourceEndpoint.direction : result.source.sourceDirection,
|
|
441
|
+
toSide: boundarySide(
|
|
442
|
+
result.source.path?.segments.at(-1)?.to ?? result.source.points.at(-1)!,
|
|
443
|
+
label,
|
|
444
|
+
),
|
|
445
|
+
path: result.source.path ?? pathFromPoints(result.source.points),
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: `${edge.id}:label-target`,
|
|
449
|
+
from: { kind: "label", edgeId: edge.id },
|
|
450
|
+
to: {
|
|
451
|
+
kind: "node",
|
|
452
|
+
nodeId: edge.targetId,
|
|
453
|
+
...(edge.targetPort === undefined ? {} : { port: edge.targetPort }),
|
|
454
|
+
},
|
|
455
|
+
fromSide: boundarySide(result.target.path?.start ?? result.target.points[0]!, label),
|
|
456
|
+
toSide: result.target.targetDirection,
|
|
457
|
+
path: result.target.path ?? pathFromPoints(result.target.points),
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function positionedSource(
|
|
463
|
+
edge: EdgeGeometry,
|
|
464
|
+
state: RoutingInput,
|
|
465
|
+
bounds: RouteBounds,
|
|
466
|
+
sides: readonly RouteSide[],
|
|
467
|
+
): BezierEndpoint {
|
|
468
|
+
const node = state.nodes.get(edge.sourceId),
|
|
469
|
+
outer = node && worldRect(node, state),
|
|
470
|
+
port = node?.ports?.find((p) => p.name === edge.sourcePort);
|
|
471
|
+
if (outer && port && port.x !== undefined && port.y !== undefined) {
|
|
472
|
+
const point = {
|
|
473
|
+
x: outer.x + port.x + (port.width ?? 0) / 2,
|
|
474
|
+
y: outer.y + port.y + (port.height ?? 0) / 2,
|
|
475
|
+
};
|
|
476
|
+
const direction = state.settings.edges[edge.id]?.sourceSide ?? sideToward(center(outer), point);
|
|
477
|
+
const distance = state.settings.clearance + 1;
|
|
478
|
+
const escape = {
|
|
479
|
+
x:
|
|
480
|
+
direction === "right"
|
|
481
|
+
? Math.max(point.x, outer.x + outer.width + distance)
|
|
482
|
+
: direction === "left"
|
|
483
|
+
? Math.min(point.x, outer.x - distance)
|
|
484
|
+
: point.x,
|
|
485
|
+
y:
|
|
486
|
+
direction === "bottom"
|
|
487
|
+
? Math.max(point.y, outer.y + outer.height + distance)
|
|
488
|
+
: direction === "top"
|
|
489
|
+
? Math.min(point.y, outer.y - distance)
|
|
490
|
+
: point.y,
|
|
491
|
+
};
|
|
492
|
+
return { kind: "port", point, direction, escape, owner: outer };
|
|
493
|
+
}
|
|
494
|
+
return { kind: "floating", bounds, sides };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function shortenTarget(guide: BezierGuide, amount: number): BezierGuide {
|
|
498
|
+
const points = [...guide.points];
|
|
499
|
+
const end = points.at(-1),
|
|
500
|
+
before = points.at(-2);
|
|
501
|
+
if (end && before && amount > 0) {
|
|
502
|
+
const length = Math.hypot(end.x - before.x, end.y - before.y);
|
|
503
|
+
const fraction = length > 0 ? Math.min(amount / length, 1 - 1e-9) : 0;
|
|
504
|
+
points[points.length - 1] = {
|
|
505
|
+
x: end.x + (before.x - end.x) * fraction,
|
|
506
|
+
y: end.y + (before.y - end.y) * fraction,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return { ...guide, points, path: pathFromPoints(points) };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function arrivalSide(path: NonNullable<BezierGuide["path"]>, bounds: RouteBounds): RouteSide {
|
|
513
|
+
const segment = path.segments.at(-1);
|
|
514
|
+
if (!segment) return boundarySide(path.start, bounds);
|
|
515
|
+
const end = segment.to;
|
|
516
|
+
const before =
|
|
517
|
+
segment.kind === "cubic"
|
|
518
|
+
? segment.control2
|
|
519
|
+
: segment.kind === "quadratic"
|
|
520
|
+
? segment.control
|
|
521
|
+
: segment.kind === "arc"
|
|
522
|
+
? (flattenPath(path).at(-2) ?? path.start)
|
|
523
|
+
: (path.segments.at(-2)?.to ?? path.start);
|
|
524
|
+
const dx = end.x - before.x,
|
|
525
|
+
dy = end.y - before.y;
|
|
526
|
+
const hits: { side: RouteSide; t: number }[] = [];
|
|
527
|
+
for (const side of SIDES) {
|
|
528
|
+
const horizontal = side === "left" || side === "right";
|
|
529
|
+
const delta = horizontal ? dx : dy;
|
|
530
|
+
if (Math.abs(delta) < 1e-10) continue;
|
|
531
|
+
const border = horizontal
|
|
532
|
+
? side === "left"
|
|
533
|
+
? bounds.x
|
|
534
|
+
: bounds.x + bounds.width
|
|
535
|
+
: side === "top"
|
|
536
|
+
? bounds.y
|
|
537
|
+
: bounds.y + bounds.height;
|
|
538
|
+
const t = (border - (horizontal ? end.x : end.y)) / delta;
|
|
539
|
+
const cross = horizontal ? end.y + t * dy : end.x + t * dx;
|
|
540
|
+
const low = horizontal ? bounds.y : bounds.x;
|
|
541
|
+
const high = low + (horizontal ? bounds.height : bounds.width);
|
|
542
|
+
if (t >= -1e-6 && cross >= low - 1e-6 && cross <= high + 1e-6) hits.push({ side, t });
|
|
543
|
+
}
|
|
544
|
+
return hits.sort((a, b) => a.t - b.t)[0]?.side ?? boundarySide(end, bounds);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function sameBounds(a: RouteBounds, b: RouteBounds): boolean {
|
|
548
|
+
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
549
|
+
}
|
|
550
|
+
function followsPort(
|
|
551
|
+
path: NonNullable<BezierGuide["path"]>,
|
|
552
|
+
port: Extract<BezierEndpoint, { kind: "port" }>,
|
|
553
|
+
): boolean {
|
|
554
|
+
if (path.start.x !== port.point.x || path.start.y !== port.point.y) return false;
|
|
555
|
+
const first = path.segments[0];
|
|
556
|
+
if (!first) return false;
|
|
557
|
+
const toward =
|
|
558
|
+
first.kind === "cubic" ? first.control1 : first.kind === "quadratic" ? first.control : first.to;
|
|
559
|
+
const dx = toward.x - path.start.x;
|
|
560
|
+
const dy = toward.y - path.start.y;
|
|
561
|
+
switch (port.direction) {
|
|
562
|
+
case "left":
|
|
563
|
+
return dx < 0 && Math.abs(dy) < 1e-6;
|
|
564
|
+
case "right":
|
|
565
|
+
return dx > 0 && Math.abs(dy) < 1e-6;
|
|
566
|
+
case "top":
|
|
567
|
+
return dy < 0 && Math.abs(dx) < 1e-6;
|
|
568
|
+
case "bottom":
|
|
569
|
+
return dy > 0 && Math.abs(dx) < 1e-6;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function safeGuides(
|
|
573
|
+
guides: { source: BezierGuide; target: BezierGuide },
|
|
574
|
+
obstacles: readonly RouteBounds[],
|
|
575
|
+
source: BezierEndpoint,
|
|
576
|
+
): boolean {
|
|
577
|
+
return [guides.source, guides.target].every((guide, index) => {
|
|
578
|
+
const path = guide.path ?? pathFromPoints(guide.points);
|
|
579
|
+
let start = path.start;
|
|
580
|
+
for (const segment of path.segments) {
|
|
581
|
+
for (const obstacle of obstacles) {
|
|
582
|
+
const portPrefix =
|
|
583
|
+
index === 0 &&
|
|
584
|
+
source.kind === "port" &&
|
|
585
|
+
sameBounds(obstacle, source.owner) &&
|
|
586
|
+
start.x === source.point.x &&
|
|
587
|
+
start.y === source.point.y &&
|
|
588
|
+
segment.to.x === source.escape.x &&
|
|
589
|
+
segment.to.y === source.escape.y;
|
|
590
|
+
if (!portPrefix && segmentCrossesRect(start, segment, obstacle)) return false;
|
|
591
|
+
}
|
|
592
|
+
start = segment.to;
|
|
593
|
+
}
|
|
594
|
+
return true;
|
|
595
|
+
});
|
|
596
|
+
}
|