@statelyai/layout 0.3.0 → 0.4.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 +4 -2
- package/README.md +150 -11
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +2 -0
- package/dist/elkjs/index.d.mts +2 -0
- 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-UciYx7lD.mjs +2973 -0
- package/dist/elkjs-ggjsBuVP.cjs +3002 -0
- package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
- package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
- package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
- package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
- package/dist/index.cjs +6 -3
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +3 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/{layered-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
- package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
- package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
- package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
- package/dist/routing/index.cjs +2 -1
- package/dist/routing/index.d.cts +2 -2
- package/dist/routing/index.d.mts +2 -2
- package/dist/routing/index.mjs +2 -2
- package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
- package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
- package/package.json +8 -2
- package/src/authoring/coordinates.ts +28 -8
- package/src/authoring/partial.ts +7 -2
- package/src/authoring/routing.ts +4 -1
- package/src/constraints.ts +174 -6
- package/src/elkjs/index.ts +1392 -449
- package/src/elkjs/layout-quality.ts +219 -0
- package/src/elkjs/opposing-tracks.ts +161 -0
- package/src/elkjs/orthogonal-junctions.ts +174 -0
- package/src/elkjs/statechart.ts +9 -3
- package/src/elkjs/types.ts +2 -0
- package/src/index.ts +2 -0
- package/src/internal/elkjs-compatibility.ts +4 -1
- package/src/internal/layered-trace.ts +48 -0
- package/src/internal/layout-engine.ts +2 -1
- package/src/java-random.ts +6 -0
- package/src/layered/bk-node-placement.ts +403 -92
- package/src/layered/center-labels.ts +398 -0
- package/src/layered/compaction-bounds.ts +26 -0
- package/src/layered/compaction-errors.ts +2 -0
- package/src/layered/compaction-scanline.ts +63 -0
- package/src/layered/compound-boundaries.ts +99 -0
- package/src/layered/compound.ts +35 -7
- package/src/layered/constraint-orientation.ts +141 -0
- package/src/layered/crossing-constraints.ts +243 -0
- package/src/layered/crossing-counter.ts +221 -0
- package/src/layered/crossing-graph.ts +401 -0
- package/src/layered/cycle-random.ts +22 -0
- package/src/layered/elk-port-lists.ts +243 -0
- package/src/layered/end-labels.ts +124 -0
- package/src/layered/external-port-dummy.ts +137 -0
- package/src/layered/feedback-chains.ts +114 -0
- package/src/layered/fixed-self-loop.ts +150 -0
- package/src/layered/greedy-switch-decider.ts +356 -0
- package/src/layered/grouped-compaction.ts +787 -0
- package/src/layered/gwt-sort.ts +53 -0
- package/src/layered/hierarchical-port-phases.ts +242 -0
- package/src/layered/hierarchical-port-restoration.ts +233 -0
- package/src/layered/hierarchical-port-routing.ts +179 -0
- package/src/layered/hierarchy-crossing.ts +121 -0
- package/src/layered/hierarchy-sweepiness.ts +83 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +886 -161
- package/src/layered/initial-model-order.ts +404 -0
- package/src/layered/inverted-ports.ts +207 -0
- package/src/layered/label-dummy-switcher.ts +200 -0
- package/src/layered/long-edges.ts +135 -44
- package/src/layered/loop-envelopes.ts +184 -0
- package/src/layered/native-routing.ts +275 -0
- package/src/layered/network-simplex.ts +11 -15
- package/src/layered/node-margins.ts +59 -0
- package/src/layered/north-south-ports.ts +271 -0
- package/src/layered/opposing-tracks.ts +944 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +92 -0
- package/src/layered/orthogonal-junctions.ts +202 -0
- package/src/layered/orthogonal-segments.ts +243 -0
- package/src/layered/placement-orientation.ts +22 -0
- package/src/layered/port-distributor.ts +192 -0
- package/src/layered/port-edge-order.ts +20 -0
- package/src/layered/replace-routing.ts +45 -0
- package/src/layered/routing-coordinates.ts +32 -0
- package/src/layered/separate-exterior-labels.ts +24 -6
- package/src/layered/spacing.ts +34 -12
- package/src/layered/splitter-order.ts +106 -0
- package/src/layered/strategies.ts +2024 -883
- package/src/layered/types.ts +13 -0
- package/src/layered/weighted-compaction.ts +84 -0
- package/src/routing/adapters.ts +32 -0
- package/src/routing/algorithms.ts +433 -21
- package/src/routing/coordination.ts +8 -3
- package/src/routing/index.ts +1 -0
- package/src/routing/search.ts +93 -11
- package/dist/elkjs-DaHMISnQ.mjs +0 -1677
- package/dist/elkjs-dMghiiWW.cjs +0 -1706
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { GraphNode, VisualGraph } from "@statelyai/graph";
|
|
2
|
+
import type { LayeredLayoutOptions } from "./types";
|
|
3
|
+
import { routeCrosses } from "../authoring/routing";
|
|
4
|
+
import {
|
|
5
|
+
orthogonalRouting,
|
|
6
|
+
routeToPoints,
|
|
7
|
+
type EdgeRoutingSettings,
|
|
8
|
+
type RouteSide,
|
|
9
|
+
type RouteEndpoint,
|
|
10
|
+
} from "../routing";
|
|
11
|
+
import { recordRouteGeometry } from "../routing/layout-cache";
|
|
12
|
+
|
|
13
|
+
/** Keep valid layered tracks; repair infeasible port leads and obstacle crossings. */
|
|
14
|
+
export function repairFlatRouting<N, E, G, P>(
|
|
15
|
+
graph: VisualGraph<N, E, G, P>,
|
|
16
|
+
options: LayeredLayoutOptions,
|
|
17
|
+
): VisualGraph<N, E, G, P> {
|
|
18
|
+
if (
|
|
19
|
+
graph.nodes.some((n) => n.parentId != null) ||
|
|
20
|
+
options.settings?.noLayout ||
|
|
21
|
+
(options.settings?.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL" ||
|
|
22
|
+
options.strategies?.routeEdges
|
|
23
|
+
)
|
|
24
|
+
return graph;
|
|
25
|
+
const overlaps = (
|
|
26
|
+
a: { x: number; y: number; width: number; height: number },
|
|
27
|
+
b: { x: number; y: number; width: number; height: number },
|
|
28
|
+
) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
29
|
+
// Leave room for port centers outside the node and their 9px exit leads.
|
|
30
|
+
const portClearance = (n: (typeof graph.nodes)[number]) => {
|
|
31
|
+
const reach = Math.max(
|
|
32
|
+
0,
|
|
33
|
+
...(n.ports ?? []).flatMap((p) => [
|
|
34
|
+
-(p.x ?? 0) - (p.width ?? 0) / 2,
|
|
35
|
+
-(p.y ?? 0) - (p.height ?? 0) / 2,
|
|
36
|
+
(p.x ?? 0) + (p.width ?? 0) / 2 - n.width,
|
|
37
|
+
(p.y ?? 0) + (p.height ?? 0) / 2 - n.height,
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
const margin = 9 + reach;
|
|
41
|
+
return {
|
|
42
|
+
x: n.x - margin,
|
|
43
|
+
y: n.y - margin,
|
|
44
|
+
width: n.width + 2 * margin,
|
|
45
|
+
height: n.height + 2 * margin,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const labels = graph.edges.filter((e) => e.width > 0 && e.height > 0);
|
|
49
|
+
const labelCollision = labels.some(
|
|
50
|
+
(e, i) =>
|
|
51
|
+
graph.nodes.some((n) => overlaps(e, portClearance(n))) ||
|
|
52
|
+
labels.slice(i + 1).some((other) => overlaps(e, other)),
|
|
53
|
+
);
|
|
54
|
+
const routingDefect = graph.edges.some((edge) => {
|
|
55
|
+
if (
|
|
56
|
+
graph.nodes.some((node) => routeCrosses(edge.points, node)) ||
|
|
57
|
+
labels.some((label) => label.id !== edge.id && routeCrosses(edge.points, label))
|
|
58
|
+
)
|
|
59
|
+
return true;
|
|
60
|
+
const points = edge.points ?? [];
|
|
61
|
+
const loopNode =
|
|
62
|
+
edge.sourceId === edge.targetId ? graph.nodes.find((n) => n.id === edge.sourceId) : undefined;
|
|
63
|
+
const terminals = [
|
|
64
|
+
...(loopNode
|
|
65
|
+
? [
|
|
66
|
+
{
|
|
67
|
+
x: loopNode.x - 12,
|
|
68
|
+
y: loopNode.y - 12,
|
|
69
|
+
width: loopNode.width + 24,
|
|
70
|
+
height: loopNode.height + 24,
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
: []),
|
|
74
|
+
...(edge.width > 0 && edge.height > 0 ? [edge] : []),
|
|
75
|
+
];
|
|
76
|
+
for (let i = 1; i < points.length; i++) {
|
|
77
|
+
const a = points[i - 1]!,
|
|
78
|
+
b = points[i]!;
|
|
79
|
+
if (Math.abs(a.x - b.x) > 1e-8 && Math.abs(a.y - b.y) > 1e-8) return true;
|
|
80
|
+
for (let j = i + 1; j < points.length; j++) {
|
|
81
|
+
const c = points[j - 1]!,
|
|
82
|
+
d = points[j]!;
|
|
83
|
+
const horizontal =
|
|
84
|
+
Math.abs(a.y - b.y) < 1e-8 && Math.abs(c.y - d.y) < 1e-8 && Math.abs(a.y - c.y) < 1e-8;
|
|
85
|
+
const vertical =
|
|
86
|
+
Math.abs(a.x - b.x) < 1e-8 && Math.abs(c.x - d.x) < 1e-8 && Math.abs(a.x - c.x) < 1e-8;
|
|
87
|
+
if (!horizontal && !vertical) continue;
|
|
88
|
+
const axis = horizontal ? "x" : "y",
|
|
89
|
+
fixed = horizontal ? "y" : "x";
|
|
90
|
+
const lo = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
|
|
91
|
+
const hi = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]));
|
|
92
|
+
if (hi - lo < 1e-8) continue;
|
|
93
|
+
let intervals = [[lo, hi]];
|
|
94
|
+
for (const rect of terminals) {
|
|
95
|
+
const span = horizontal ? rect.height : rect.width;
|
|
96
|
+
if (a[fixed] < rect[fixed] || a[fixed] > rect[fixed] + span) continue;
|
|
97
|
+
const lower = rect[axis],
|
|
98
|
+
upper = lower + (horizontal ? rect.width : rect.height);
|
|
99
|
+
intervals = intervals
|
|
100
|
+
.flatMap(([start, end]) => [
|
|
101
|
+
[start!, Math.min(end!, lower)],
|
|
102
|
+
[Math.max(start!, upper), end!],
|
|
103
|
+
])
|
|
104
|
+
.filter(([start, end]) => end! - start! > 1e-8);
|
|
105
|
+
}
|
|
106
|
+
if (intervals.length) return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
});
|
|
111
|
+
if (!labelCollision && !routingDefect) return graph;
|
|
112
|
+
const edges = graph.edges.map((edge) => ({ ...edge }));
|
|
113
|
+
// The original track may put an exterior label partly inside a node. Move
|
|
114
|
+
// only colliding labels before routing their actual boundary connections.
|
|
115
|
+
for (const edge of edges.filter((e) => e.width > 0 && e.height > 0)) {
|
|
116
|
+
const obstacles = [
|
|
117
|
+
...graph.nodes.map(portClearance),
|
|
118
|
+
...edges.filter((e) => e.id !== edge.id && e.width > 0 && e.height > 0),
|
|
119
|
+
];
|
|
120
|
+
if (!obstacles.some((rect) => overlaps(edge, rect))) continue;
|
|
121
|
+
const xs = [edge.x, ...obstacles.flatMap((r) => [r.x - edge.width - 8, r.x + r.width + 8])];
|
|
122
|
+
const ys = [edge.y, ...obstacles.flatMap((r) => [r.y - edge.height - 8, r.y + r.height + 8])];
|
|
123
|
+
let best: { x: number; y: number; distance: number } | undefined;
|
|
124
|
+
for (const x of xs)
|
|
125
|
+
for (const y of ys) {
|
|
126
|
+
const distance = Math.abs(x - edge.x) + Math.abs(y - edge.y);
|
|
127
|
+
if (best && distance >= best.distance) continue;
|
|
128
|
+
if (!obstacles.some((rect) => overlaps({ ...edge, x, y }, rect))) best = { x, y, distance };
|
|
129
|
+
}
|
|
130
|
+
if (best) {
|
|
131
|
+
edge.x = best.x;
|
|
132
|
+
edge.y = best.y;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
136
|
+
const fixedImplicitSide = (
|
|
137
|
+
nodeId: string,
|
|
138
|
+
portName: string | undefined,
|
|
139
|
+
point: { x: number; y: number } | undefined,
|
|
140
|
+
away: { x: number; y: number } | undefined,
|
|
141
|
+
): RouteSide | undefined => {
|
|
142
|
+
const node = nodes.get(nodeId);
|
|
143
|
+
if (
|
|
144
|
+
!node ||
|
|
145
|
+
portName !== undefined ||
|
|
146
|
+
!point ||
|
|
147
|
+
!["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(
|
|
148
|
+
String(options.nodeSettings?.(node)?.portConstraints),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return undefined;
|
|
152
|
+
// Fixed implicit attachments are assigned before reversal. Repair must
|
|
153
|
+
// preserve the initial face, including special self-loop attachments.
|
|
154
|
+
const faces: [RouteSide, number][] = [
|
|
155
|
+
["left", Math.abs(point.x - node.x)],
|
|
156
|
+
["right", Math.abs(point.x - node.x - node.width)],
|
|
157
|
+
["top", Math.abs(point.y - node.y)],
|
|
158
|
+
["bottom", Math.abs(point.y - node.y - node.height)],
|
|
159
|
+
];
|
|
160
|
+
const leadSide: RouteSide | undefined =
|
|
161
|
+
away && (Math.abs(away.x - point.x) > 1e-8 || Math.abs(away.y - point.y) > 1e-8)
|
|
162
|
+
? Math.abs(away.x - point.x) >= Math.abs(away.y - point.y)
|
|
163
|
+
? away.x > point.x
|
|
164
|
+
? "right"
|
|
165
|
+
: "left"
|
|
166
|
+
: away.y > point.y
|
|
167
|
+
? "bottom"
|
|
168
|
+
: "top"
|
|
169
|
+
: undefined;
|
|
170
|
+
faces.sort((a, b) => a[1] - b[1] || Number(b[0] === leadSide) - Number(a[0] === leadSide));
|
|
171
|
+
return faces[0]![1] < 1e-6 ? faces[0]![0] : undefined;
|
|
172
|
+
};
|
|
173
|
+
const attachments: Record<string, EdgeRoutingSettings> = Object.fromEntries(
|
|
174
|
+
graph.edges.map((edge) => [
|
|
175
|
+
edge.id,
|
|
176
|
+
{
|
|
177
|
+
sourceSide: fixedImplicitSide(
|
|
178
|
+
edge.sourceId,
|
|
179
|
+
edge.sourcePort,
|
|
180
|
+
edge.points?.[0],
|
|
181
|
+
edge.points?.[1],
|
|
182
|
+
),
|
|
183
|
+
targetSide: fixedImplicitSide(
|
|
184
|
+
edge.targetId,
|
|
185
|
+
edge.targetPort,
|
|
186
|
+
edge.points?.at(-1),
|
|
187
|
+
edge.points?.at(-2),
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
]),
|
|
191
|
+
);
|
|
192
|
+
const routingNodes = new Map<string, GraphNode>(graph.nodes.map((node) => [node.id, node]));
|
|
193
|
+
const syntheticPorts = new Map<string, Set<string>>();
|
|
194
|
+
const attach = (
|
|
195
|
+
edgeId: string,
|
|
196
|
+
nodeId: string,
|
|
197
|
+
portName: string | undefined,
|
|
198
|
+
point: { x: number; y: number } | undefined,
|
|
199
|
+
source: boolean,
|
|
200
|
+
) => {
|
|
201
|
+
const side = source ? attachments[edgeId]?.sourceSide : attachments[edgeId]?.targetSide;
|
|
202
|
+
if (
|
|
203
|
+
!side ||
|
|
204
|
+
!point ||
|
|
205
|
+
!["FIXED_POS", "FIXED_RATIO"].includes(
|
|
206
|
+
String(options.nodeSettings?.(nodes.get(nodeId)!)?.portConstraints),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
return portName;
|
|
210
|
+
const node = routingNodes.get(nodeId)!;
|
|
211
|
+
let name = `__layout_implicit:${edgeId}:${source ? "source" : "target"}`;
|
|
212
|
+
while (node.ports?.some((p) => p.name === name)) name += ":";
|
|
213
|
+
const names = syntheticPorts.get(nodeId) ?? new Set<string>();
|
|
214
|
+
names.add(name);
|
|
215
|
+
syntheticPorts.set(nodeId, names);
|
|
216
|
+
routingNodes.set(nodeId, {
|
|
217
|
+
...node,
|
|
218
|
+
ports: [
|
|
219
|
+
...(node.ports ?? []),
|
|
220
|
+
{
|
|
221
|
+
name,
|
|
222
|
+
data: undefined,
|
|
223
|
+
direction: "inout",
|
|
224
|
+
x: point.x - node.x!,
|
|
225
|
+
y: point.y - node.y!,
|
|
226
|
+
width: 0,
|
|
227
|
+
height: 0,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
return name;
|
|
232
|
+
};
|
|
233
|
+
const routingEdges = edges.map((edge) => ({
|
|
234
|
+
...edge,
|
|
235
|
+
sourcePort: attach(edge.id, edge.sourceId, edge.sourcePort, edge.points?.[0], true),
|
|
236
|
+
targetPort: attach(edge.id, edge.targetId, edge.targetPort, edge.points?.at(-1), false),
|
|
237
|
+
}));
|
|
238
|
+
const snapshot = orthogonalRouting.route(
|
|
239
|
+
{ ...graph, nodes: [...routingNodes.values()], edges: routingEdges },
|
|
240
|
+
{ coordinateSpace: "world", maxSearchNodes: 40000, edges: attachments },
|
|
241
|
+
);
|
|
242
|
+
const authoredEndpoint = (endpoint: RouteEndpoint): RouteEndpoint => {
|
|
243
|
+
if (
|
|
244
|
+
endpoint.kind !== "node" ||
|
|
245
|
+
endpoint.port === undefined ||
|
|
246
|
+
!syntheticPorts.get(endpoint.nodeId)?.has(endpoint.port)
|
|
247
|
+
)
|
|
248
|
+
return endpoint;
|
|
249
|
+
const { port: _privatePort, ...authored } = endpoint;
|
|
250
|
+
return authored;
|
|
251
|
+
};
|
|
252
|
+
const routes = new Map(
|
|
253
|
+
[...snapshot.routes].map(([id, route]) => [
|
|
254
|
+
id,
|
|
255
|
+
{
|
|
256
|
+
...route,
|
|
257
|
+
sections: route.sections.map((section) => ({
|
|
258
|
+
...section,
|
|
259
|
+
from: authoredEndpoint(section.from),
|
|
260
|
+
to: authoredEndpoint(section.to),
|
|
261
|
+
})),
|
|
262
|
+
},
|
|
263
|
+
]),
|
|
264
|
+
);
|
|
265
|
+
const result = {
|
|
266
|
+
...graph,
|
|
267
|
+
edges: edges.map((edge) => ({
|
|
268
|
+
...edge,
|
|
269
|
+
points: routeToPoints(routes.get(edge.id)!),
|
|
270
|
+
})),
|
|
271
|
+
compoundRoutes: routes,
|
|
272
|
+
};
|
|
273
|
+
recordRouteGeometry(result, routes);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
6
6
|
* SPDX-License-Identifier: EPL-2.0
|
|
7
7
|
*******************************************************************************/
|
|
8
|
+
import { connectedEdgesInPortOrder } from "./port-edge-order";
|
|
8
9
|
import type { GraphEdge } from "@statelyai/graph";
|
|
9
10
|
import type { AcyclicOrientation, LayerAssigner, LayeredPhaseInput } from "./types";
|
|
10
11
|
|
|
@@ -44,13 +45,13 @@ function getOrientedEndpoints(
|
|
|
44
45
|
: [edge.sourceId, edge.targetId];
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function
|
|
48
|
+
export function networkSimplexComponents(input: LayeredPhaseInput): string[][] {
|
|
48
49
|
const adjacent = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
|
|
49
|
-
for (const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
for (const node of input.graph.nodes) {
|
|
51
|
+
for (const edge of connectedEdgesInPortOrder(input, node.id)) {
|
|
52
|
+
if (edge.sourceId === edge.targetId) continue;
|
|
53
|
+
adjacent.get(node.id)?.push(edge.sourceId === node.id ? edge.targetId : edge.sourceId);
|
|
54
|
+
}
|
|
54
55
|
}
|
|
55
56
|
const visited = new Set<string>();
|
|
56
57
|
const components: string[][] = [];
|
|
@@ -96,17 +97,12 @@ function createSimplexGraph(
|
|
|
96
97
|
treeNode: false,
|
|
97
98
|
}));
|
|
98
99
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
99
|
-
const edgeBySource = new Map(component.map((id) => [id, [] as GraphEdge[]]));
|
|
100
|
-
for (const edge of input.graph.edges) {
|
|
101
|
-
const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
|
|
102
|
-
if (sourceId !== targetId && memberIds.has(sourceId) && memberIds.has(targetId)) {
|
|
103
|
-
edgeBySource.get(sourceId)?.push(edge);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
100
|
const edges: SimplexEdge[] = [];
|
|
107
101
|
for (const sourceId of component) {
|
|
108
|
-
for (const graphEdge of
|
|
102
|
+
for (const graphEdge of connectedEdgesInPortOrder(input, sourceId)) {
|
|
109
103
|
const [orientedSourceId, targetId] = getOrientedEndpoints(graphEdge, orientation);
|
|
104
|
+
if (orientedSourceId !== sourceId || sourceId === targetId || !memberIds.has(targetId))
|
|
105
|
+
continue;
|
|
110
106
|
const source = nodeById.get(orientedSourceId);
|
|
111
107
|
const target = nodeById.get(targetId);
|
|
112
108
|
if (!source || !target) continue;
|
|
@@ -328,7 +324,7 @@ export function runNetworkSimplex(
|
|
|
328
324
|
}
|
|
329
325
|
|
|
330
326
|
export const assignLayersWithNetworkSimplex: LayerAssigner = (input, orientation) => {
|
|
331
|
-
const components =
|
|
327
|
+
const components = networkSimplexComponents(input);
|
|
332
328
|
const layerByNodeId = new Map<string, number>();
|
|
333
329
|
let previousLayerCounts: number[] | undefined;
|
|
334
330
|
const thoroughness = input.settings.thoroughness ?? 7;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getOrientedPortDirection, placePorts } from "./strategies";
|
|
2
|
+
import type { AcyclicOrientation, LayeredPhaseInput } from "./types";
|
|
3
|
+
|
|
4
|
+
interface CrossMargins {
|
|
5
|
+
before: number;
|
|
6
|
+
after: number;
|
|
7
|
+
flowBefore: number;
|
|
8
|
+
flowAfter: number;
|
|
9
|
+
}
|
|
10
|
+
const preparedMargins = new WeakMap<LayeredPhaseInput, ReadonlyMap<string, CrossMargins>>();
|
|
11
|
+
|
|
12
|
+
/** Physical port boxes contribute to node margins before BK alignment and compaction. */
|
|
13
|
+
export function preparePortMargins(
|
|
14
|
+
input: LayeredPhaseInput,
|
|
15
|
+
sizes = input.sizes,
|
|
16
|
+
orientation?: AcyclicOrientation,
|
|
17
|
+
): void {
|
|
18
|
+
const horizontal = input.direction === "right" || input.direction === "left";
|
|
19
|
+
const margins = new Map<string, CrossMargins>();
|
|
20
|
+
for (const node of input.graph.nodes) {
|
|
21
|
+
const size = sizes.get(node.id) ?? { width: 0, height: 0 };
|
|
22
|
+
const ports = placePorts(
|
|
23
|
+
node.ports,
|
|
24
|
+
{ x: 0, y: 0, width: size.width, height: size.height },
|
|
25
|
+
input.direction,
|
|
26
|
+
(port) => input.portSettings?.(port, node),
|
|
27
|
+
{ ...input.settings, ...input.nodeSettings?.(node) },
|
|
28
|
+
// Free ports take their side from the acyclic edge direction, as in final placement.
|
|
29
|
+
orientation ? (port) => getOrientedPortDirection(input, orientation, node, port) : undefined,
|
|
30
|
+
);
|
|
31
|
+
let before = 0,
|
|
32
|
+
after = 0,
|
|
33
|
+
flowBefore = 0,
|
|
34
|
+
flowAfter = 0;
|
|
35
|
+
for (const port of ports ?? []) {
|
|
36
|
+
const start = horizontal ? (port.y ?? 0) : (port.x ?? 0);
|
|
37
|
+
const end = start + (horizontal ? (port.height ?? 0) : (port.width ?? 0));
|
|
38
|
+
before = Math.max(before, -start);
|
|
39
|
+
after = Math.max(after, end - (horizontal ? size.height : size.width));
|
|
40
|
+
const flowStart = horizontal ? (port.x ?? 0) : (port.y ?? 0);
|
|
41
|
+
const flowEnd = flowStart + (horizontal ? (port.width ?? 0) : (port.height ?? 0));
|
|
42
|
+
flowBefore = Math.max(flowBefore, -flowStart);
|
|
43
|
+
flowAfter = Math.max(flowAfter, flowEnd - (horizontal ? size.width : size.height));
|
|
44
|
+
}
|
|
45
|
+
if (before || after || flowBefore || flowAfter) {
|
|
46
|
+
const reverse = input.direction === "left" || input.direction === "up";
|
|
47
|
+
margins.set(node.id, {
|
|
48
|
+
before,
|
|
49
|
+
after,
|
|
50
|
+
flowBefore: reverse ? flowAfter : flowBefore,
|
|
51
|
+
flowAfter: reverse ? flowBefore : flowAfter,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
preparedMargins.set(input, margins);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const portCrossMargins = (input: LayeredPhaseInput, id: string): CrossMargins | undefined =>
|
|
59
|
+
preparedMargins.get(input)?.get(id);
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2011, 2020 Kiel University and others.
|
|
3
|
+
* Adapted from ELK v0.11.0 NorthSouthPortPreprocessor.java (new approach).
|
|
4
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
5
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
6
|
+
*******************************************************************************/
|
|
7
|
+
import type { GraphNode, GraphPort, Point } from "@statelyai/graph";
|
|
8
|
+
import type { LongEdgeExpansion } from "./long-edges";
|
|
9
|
+
import { inheritCycleRandom } from "./cycle-random";
|
|
10
|
+
|
|
11
|
+
export interface CrossPortOrigin {
|
|
12
|
+
node: GraphNode;
|
|
13
|
+
port: GraphPort;
|
|
14
|
+
/** Physical port face. */
|
|
15
|
+
side: "NORTH" | "SOUTH" | "WEST" | "EAST";
|
|
16
|
+
/** Position relative to the owner's canonical cross coordinate. */
|
|
17
|
+
beforeOwner: boolean;
|
|
18
|
+
input: boolean;
|
|
19
|
+
output: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface NorthSouthPortExpansion {
|
|
22
|
+
expansion: LongEdgeExpansion;
|
|
23
|
+
originsByDummyId: ReadonlyMap<string, CrossPortOrigin>;
|
|
24
|
+
incomingEdgeOrderByDummyId?: ReadonlyMap<string, readonly string[]>;
|
|
25
|
+
successorsByNodeId: ReadonlyMap<string, readonly string[]>;
|
|
26
|
+
layoutUnitByNodeId: ReadonlyMap<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Detach fixed cross-axis ports into same-layer units before crossing minimization. */
|
|
30
|
+
export function insertNorthSouthPortDummies(expansion: LongEdgeExpansion): NorthSouthPortExpansion {
|
|
31
|
+
const { input, orientation } = expansion;
|
|
32
|
+
const vertical = input.direction === "down" || input.direction === "up";
|
|
33
|
+
const forward =
|
|
34
|
+
input.direction === "right"
|
|
35
|
+
? "EAST"
|
|
36
|
+
: input.direction === "left"
|
|
37
|
+
? "WEST"
|
|
38
|
+
: input.direction === "down"
|
|
39
|
+
? "SOUTH"
|
|
40
|
+
: "NORTH";
|
|
41
|
+
const backward = ({ EAST: "WEST", WEST: "EAST", NORTH: "SOUTH", SOUTH: "NORTH" } as const)[
|
|
42
|
+
forward
|
|
43
|
+
];
|
|
44
|
+
const nodes = [...input.graph.nodes];
|
|
45
|
+
const used = new Set(nodes.map((n) => n.id));
|
|
46
|
+
const sizes = new Map(input.sizes);
|
|
47
|
+
const layers = new Map(expansion.assignment.layerByNodeId);
|
|
48
|
+
const origins = new Map<string, CrossPortOrigin>();
|
|
49
|
+
const byPort = new Map<GraphPort, string>();
|
|
50
|
+
const successors = new Map<string, string[]>();
|
|
51
|
+
const units = new Map<string, string>();
|
|
52
|
+
// Upstream gives every fixed-side normal node its own layout unit, even
|
|
53
|
+
// when it has no connected cross-axis ports.
|
|
54
|
+
for (const node of input.graph.nodes) {
|
|
55
|
+
const constraints = input.nodeSettings?.(node)?.portConstraints;
|
|
56
|
+
if (
|
|
57
|
+
!node.id.startsWith("__layout_dummy:") &&
|
|
58
|
+
constraints &&
|
|
59
|
+
constraints !== "UNDEFINED" &&
|
|
60
|
+
constraints !== "FREE"
|
|
61
|
+
)
|
|
62
|
+
units.set(node.id, node.id);
|
|
63
|
+
}
|
|
64
|
+
const attach = (
|
|
65
|
+
owner: GraphNode,
|
|
66
|
+
port: GraphPort,
|
|
67
|
+
side: CrossPortOrigin["side"],
|
|
68
|
+
incoming: boolean,
|
|
69
|
+
) => {
|
|
70
|
+
let id = byPort.get(port);
|
|
71
|
+
if (!id) {
|
|
72
|
+
id = `__layout_dummy:north-south:${owner.id}:${port.name}`;
|
|
73
|
+
while (used.has(id)) id += ":";
|
|
74
|
+
used.add(id);
|
|
75
|
+
byPort.set(port, id);
|
|
76
|
+
// Canonical cross coordinates remain physical x in vertical layouts, y otherwise.
|
|
77
|
+
const beforeOwner = vertical ? side === "WEST" : side === "NORTH";
|
|
78
|
+
origins.set(id, { node: owner, port, side, beforeOwner, input: false, output: false });
|
|
79
|
+
nodes.push({ type: "node", id, data: undefined, width: 0, height: 0, ports: [] });
|
|
80
|
+
sizes.set(id, { width: 0, height: 0 });
|
|
81
|
+
layers.set(id, layers.get(owner.id) ?? 0);
|
|
82
|
+
units.set(owner.id, owner.id);
|
|
83
|
+
units.set(id, owner.id);
|
|
84
|
+
const from = beforeOwner ? id : owner.id;
|
|
85
|
+
const to = beforeOwner ? owner.id : id;
|
|
86
|
+
if (input.portSettings?.(port, owner)?.allowNonFlowPortsToSwitchSides !== true)
|
|
87
|
+
successors.set(from, [...(successors.get(from) ?? []), to]);
|
|
88
|
+
}
|
|
89
|
+
const origin = origins.get(id)!;
|
|
90
|
+
const role = incoming ? "input" : "output";
|
|
91
|
+
if (!origin[role]) {
|
|
92
|
+
origin[role] = true;
|
|
93
|
+
nodes
|
|
94
|
+
.find((n) => n.id === id)!
|
|
95
|
+
.ports!.push({
|
|
96
|
+
name: role,
|
|
97
|
+
direction: incoming ? "in" : "out",
|
|
98
|
+
x: 0,
|
|
99
|
+
y: 0,
|
|
100
|
+
width: 0,
|
|
101
|
+
height: 0,
|
|
102
|
+
data: undefined,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return { id, role };
|
|
106
|
+
};
|
|
107
|
+
const nodeById = new Map(input.graph.nodes.map((n) => [n.id, n]));
|
|
108
|
+
const edges = input.graph.edges.map((edge) => {
|
|
109
|
+
const reverse = orientation.reversedEdgeIds.has(edge.id);
|
|
110
|
+
const endpoint = (nodeId: string, portName: string | undefined, incoming: boolean) => {
|
|
111
|
+
const owner = nodeById.get(nodeId);
|
|
112
|
+
if (!owner || owner.id.startsWith("__layout_dummy:")) return undefined;
|
|
113
|
+
const constraints = input.nodeSettings?.(owner)?.portConstraints;
|
|
114
|
+
if (!constraints || constraints === "UNDEFINED" || constraints === "FREE") return undefined;
|
|
115
|
+
const port = owner.ports?.find((p) => p.name === portName);
|
|
116
|
+
if (!port) return undefined;
|
|
117
|
+
const side = input.portSettings?.(port, owner)?.["port.side"];
|
|
118
|
+
if (side !== "NORTH" && side !== "SOUTH" && side !== "WEST" && side !== "EAST")
|
|
119
|
+
return undefined;
|
|
120
|
+
if (vertical ? side !== "WEST" && side !== "EAST" : side !== "NORTH" && side !== "SOUTH")
|
|
121
|
+
return undefined;
|
|
122
|
+
return attach(owner, port, side, incoming);
|
|
123
|
+
};
|
|
124
|
+
// Dedicated same-side and north/south loop dummies are a separate upstream phase.
|
|
125
|
+
if (edge.sourceId === edge.targetId) return edge;
|
|
126
|
+
const source = endpoint(edge.sourceId, edge.sourcePort, reverse);
|
|
127
|
+
const target = endpoint(edge.targetId, edge.targetPort, !reverse);
|
|
128
|
+
return source || target
|
|
129
|
+
? {
|
|
130
|
+
...edge,
|
|
131
|
+
sourceId: source?.id ?? edge.sourceId,
|
|
132
|
+
sourcePort: source?.role ?? edge.sourcePort,
|
|
133
|
+
targetId: target?.id ?? edge.targetId,
|
|
134
|
+
targetPort: target?.role ?? edge.targetPort,
|
|
135
|
+
}
|
|
136
|
+
: edge;
|
|
137
|
+
});
|
|
138
|
+
if (!origins.size)
|
|
139
|
+
return {
|
|
140
|
+
expansion,
|
|
141
|
+
originsByDummyId: origins,
|
|
142
|
+
successorsByNodeId: successors,
|
|
143
|
+
layoutUnitByNodeId: units,
|
|
144
|
+
};
|
|
145
|
+
// The upstream mixed-role dummy creates its WEST input before its EAST output.
|
|
146
|
+
for (const node of nodes)
|
|
147
|
+
if (origins.has(node.id))
|
|
148
|
+
node.ports?.sort((a, b) => Number(a.name === "output") - Number(b.name === "output"));
|
|
149
|
+
// ELK creates each side's input, output, then mixed-role dummies in
|
|
150
|
+
// canonical flow order. Barycenter associates retain that creation order;
|
|
151
|
+
// insertion at one northern index reverses it in the layer itself.
|
|
152
|
+
const roleOrder = (origin: CrossPortOrigin) =>
|
|
153
|
+
origin.input && origin.output ? 2 : origin.input ? 0 : 1;
|
|
154
|
+
const negativeFlow = input.direction === "left" || input.direction === "up";
|
|
155
|
+
const orderedOrigins = input.graph.nodes.flatMap((node) => {
|
|
156
|
+
const entries = [...origins].filter(([, origin]) => origin.node.id === node.id);
|
|
157
|
+
const constraints = input.nodeSettings?.(node)?.portConstraints;
|
|
158
|
+
const coordinate = (origin: CrossPortOrigin) => {
|
|
159
|
+
if (constraints === "FIXED_POS" || constraints === "FIXED_RATIO")
|
|
160
|
+
return (vertical ? (origin.port.y ?? 0) : (origin.port.x ?? 0)) * (negativeFlow ? -1 : 1);
|
|
161
|
+
const index = node.ports?.indexOf(origin.port) ?? 0;
|
|
162
|
+
return origin.beforeOwner ? index : -index;
|
|
163
|
+
};
|
|
164
|
+
return entries.sort(
|
|
165
|
+
([, a], [, b]) =>
|
|
166
|
+
Number(!a.beforeOwner) - Number(!b.beforeOwner) ||
|
|
167
|
+
roleOrder(a) - roleOrder(b) ||
|
|
168
|
+
coordinate(a) - coordinate(b),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
const incomingEdgeOrderByDummyId = new Map<string, readonly string[]>();
|
|
172
|
+
for (const [dummy, origin] of origins) {
|
|
173
|
+
if (!origin.input) continue;
|
|
174
|
+
const ordered = expansion.incomingEdgeOrderByNodeId?.get(origin.node.id) ?? [];
|
|
175
|
+
if (!ordered.length) continue;
|
|
176
|
+
const ids = ordered.flatMap((id) => expansion.segmentIdsByEdgeId.get(id) ?? [id]);
|
|
177
|
+
incomingEdgeOrderByDummyId.set(
|
|
178
|
+
dummy,
|
|
179
|
+
ids.filter((id) =>
|
|
180
|
+
edges.some(
|
|
181
|
+
(edge) =>
|
|
182
|
+
edge.id === id &&
|
|
183
|
+
(orientation.reversedEdgeIds.has(id) ? edge.sourceId : edge.targetId) === dummy,
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
origins.clear();
|
|
189
|
+
for (const [id, origin] of orderedOrigins) origins.set(id, origin);
|
|
190
|
+
const seed = expansion.assignment.seedOrder ?? input.graph.nodes.map((n) => n.id);
|
|
191
|
+
const seedOrder = seed.flatMap((id) => [
|
|
192
|
+
...[...origins]
|
|
193
|
+
.filter(([, o]) => o.node.id === id && o.beforeOwner)
|
|
194
|
+
.map(([dummy]) => dummy)
|
|
195
|
+
.reverse(),
|
|
196
|
+
id,
|
|
197
|
+
...[...origins].filter(([, o]) => o.node.id === id && !o.beforeOwner).map(([dummy]) => dummy),
|
|
198
|
+
]);
|
|
199
|
+
return {
|
|
200
|
+
expansion: {
|
|
201
|
+
...expansion,
|
|
202
|
+
input: inheritCycleRandom(input, {
|
|
203
|
+
...input,
|
|
204
|
+
graph: { ...input.graph, nodes, edges },
|
|
205
|
+
sizes,
|
|
206
|
+
nodeSettings: (n) =>
|
|
207
|
+
origins.has(n.id) ? { portConstraints: "FIXED_POS" } : input.nodeSettings?.(n),
|
|
208
|
+
portSettings: (p, n) =>
|
|
209
|
+
origins.has(n.id)
|
|
210
|
+
? { "port.side": p.name === "input" ? backward : forward }
|
|
211
|
+
: input.portSettings?.(p, n),
|
|
212
|
+
}),
|
|
213
|
+
assignment: { ...expansion.assignment, layerByNodeId: layers, seedOrder },
|
|
214
|
+
},
|
|
215
|
+
originsByDummyId: origins,
|
|
216
|
+
incomingEdgeOrderByDummyId,
|
|
217
|
+
successorsByNodeId: successors,
|
|
218
|
+
layoutUnitByNodeId: units,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Reconnect orthogonal/polyline endpoints after routing, preserving the dummy-row bend. */
|
|
223
|
+
export function restoreNorthSouthPortRoutes(
|
|
224
|
+
phase: NorthSouthPortExpansion,
|
|
225
|
+
placement: import("./types").NodePlacement,
|
|
226
|
+
routes: import("./types").EdgeRoutes,
|
|
227
|
+
anchor: (origin: CrossPortOrigin) => import("@statelyai/graph").Point,
|
|
228
|
+
restoredJunctionCounts?: Map<string, number>,
|
|
229
|
+
): import("./types").EdgeRoutes {
|
|
230
|
+
if (phase.originsByDummyId.size === 0) return routes;
|
|
231
|
+
const vertical =
|
|
232
|
+
phase.expansion.input.direction === "down" || phase.expansion.input.direction === "up";
|
|
233
|
+
const pointsByEdgeId = new Map(routes.pointsByEdgeId);
|
|
234
|
+
const junctionPointsByEdgeId = new Map(routes.junctionPointsByEdgeId);
|
|
235
|
+
const addJunction = (edge: string, origin: CrossPortOrigin, bend: Point) => {
|
|
236
|
+
// ELK marks every restored branch when both dummy faces refer to one port.
|
|
237
|
+
// Spline restoration records its cross coordinate instead of junctions.
|
|
238
|
+
if (origin.input && origin.output && phase.expansion.input.settings.edgeRouting !== "SPLINES") {
|
|
239
|
+
junctionPointsByEdgeId.set(edge, [...(junctionPointsByEdgeId.get(edge) ?? []), bend]);
|
|
240
|
+
restoredJunctionCounts?.set(edge, (restoredJunctionCounts.get(edge) ?? 0) + 1);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
for (const edge of phase.expansion.input.graph.edges) {
|
|
244
|
+
const original = pointsByEdgeId.get(edge.id);
|
|
245
|
+
if (!original?.length) continue;
|
|
246
|
+
const source = phase.originsByDummyId.get(edge.sourceId);
|
|
247
|
+
const target = phase.originsByDummyId.get(edge.targetId);
|
|
248
|
+
if (!source && !target) continue;
|
|
249
|
+
// Routing can collapse coincident physical endpoints to one coordinate.
|
|
250
|
+
// Reconnecting one face must not consume the opposite endpoint's identity.
|
|
251
|
+
let points = original.length === 1 ? [{ ...original[0]! }, { ...original[0]! }] : [...original];
|
|
252
|
+
if (source) {
|
|
253
|
+
const endpoint = anchor(source);
|
|
254
|
+
const rect = placement.rectByNodeId.get(edge.sourceId);
|
|
255
|
+
if (!rect) throw new Error(`Missing cross-port dummy placement: ${edge.sourceId}`);
|
|
256
|
+
const bend = vertical ? { x: rect.x, y: endpoint.y } : { x: endpoint.x, y: rect.y };
|
|
257
|
+
points = [endpoint, bend, ...points.slice(1)];
|
|
258
|
+
addJunction(edge.id, source, bend);
|
|
259
|
+
}
|
|
260
|
+
if (target) {
|
|
261
|
+
const endpoint = anchor(target);
|
|
262
|
+
const rect = placement.rectByNodeId.get(edge.targetId);
|
|
263
|
+
if (!rect) throw new Error(`Missing cross-port dummy placement: ${edge.targetId}`);
|
|
264
|
+
const bend = vertical ? { x: rect.x, y: endpoint.y } : { x: endpoint.x, y: rect.y };
|
|
265
|
+
points = [...points.slice(0, -1), bend, endpoint];
|
|
266
|
+
addJunction(edge.id, target, bend);
|
|
267
|
+
}
|
|
268
|
+
pointsByEdgeId.set(edge.id, points);
|
|
269
|
+
}
|
|
270
|
+
return { ...routes, pointsByEdgeId, junctionPointsByEdgeId };
|
|
271
|
+
}
|