@statelyai/layout 0.2.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 +221 -13
- package/dist/elkjs/index.cjs +3 -2
- package/dist/elkjs/index.d.cts +13 -2
- package/dist/elkjs/index.d.mts +13 -2
- package/dist/elkjs/index.mjs +3 -3
- package/dist/elkjs/main.cjs +2 -2
- package/dist/elkjs/main.d.cts +2 -2
- package/dist/elkjs/main.d.mts +2 -2
- package/dist/elkjs/main.mjs +2 -2
- package/dist/elkjs/worker-api.cjs +1 -1
- package/dist/elkjs/worker-api.d.cts +2 -2
- package/dist/elkjs/worker-api.d.mts +2 -2
- package/dist/elkjs/worker-api.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-B6cWNrMw.d.cts +80 -0
- package/dist/index-CJQLUKD9.d.mts +80 -0
- package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
- package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
- package/dist/index.cjs +39 -9
- package/dist/index.d.cts +4 -3
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +4 -3
- package/dist/layered/index.cjs +3 -1
- package/dist/layered/index.d.cts +3 -2
- package/dist/layered/index.d.mts +3 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
- package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
- package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
- package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
- package/dist/routing/index.cjs +29 -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-CR8flv8b.mjs +2780 -0
- package/dist/routing-QIdGGFBe.cjs +2996 -0
- package/dist/types-CT2akdK0.d.mts +133 -0
- package/dist/types-uxPlGRof.d.cts +133 -0
- package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
- package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
- package/package.json +14 -3
- package/src/authoring/coordinates.ts +38 -10
- 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 +1397 -452
- 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/routes.ts +147 -0
- package/src/elkjs/statechart.ts +9 -3
- package/src/elkjs/types.ts +2 -0
- package/src/index.ts +8 -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 +445 -0
- 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 +911 -261
- 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 +34 -0
- package/src/layered/weighted-compaction.ts +84 -0
- package/src/routing/adapters.ts +143 -0
- package/src/routing/algorithms.ts +1088 -0
- package/src/routing/coordination.ts +91 -0
- package/src/routing/engine.ts +473 -0
- package/src/routing/index.ts +69 -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 +358 -0
- package/src/routing/spatial.ts +78 -0
- package/src/routing/types.ts +146 -0
- package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
- package/dist/elkjs-CFlfkEBz.mjs +0 -1528
- /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
- /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
- /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
- /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { orthogonalRouting, routeToPoints } from "../routing";
|
|
2
|
+
import { recordRouteGeometry } from "../routing/layout-cache";
|
|
3
|
+
import type { EdgeRoutingSettings, Route } from "../routing/types";
|
|
4
|
+
import type {
|
|
5
|
+
EntityRect,
|
|
6
|
+
Graph,
|
|
7
|
+
GraphEdge,
|
|
8
|
+
Point,
|
|
9
|
+
VisualGraph,
|
|
10
|
+
VisualNode,
|
|
11
|
+
} from "@statelyai/graph";
|
|
12
|
+
import type { CompoundLayoutGeometry, LayeredLayoutOptions, LayoutPadding } from "./types";
|
|
13
|
+
import { placePorts } from "./strategies";
|
|
14
|
+
|
|
15
|
+
export type CompoundVisualGraph<N = unknown, E = unknown, G = unknown, P = unknown> = VisualGraph<
|
|
16
|
+
N,
|
|
17
|
+
E,
|
|
18
|
+
G,
|
|
19
|
+
P
|
|
20
|
+
> & {
|
|
21
|
+
/** Nodes remain parent-relative; edge rectangles and points are world-space. */
|
|
22
|
+
readonly edgeCoordinateSpace?: "world";
|
|
23
|
+
readonly compoundGeometry: ReadonlyMap<string, CompoundLayoutGeometry>;
|
|
24
|
+
/** World-space route sections preserve label gaps and fallback diagnostics. */
|
|
25
|
+
readonly compoundRoutes: ReadonlyMap<string, Route>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const paddingOf = (value: number | Partial<LayoutPadding> | undefined): LayoutPadding => ({
|
|
29
|
+
top: typeof value === "number" ? value : (value?.top ?? 12),
|
|
30
|
+
right: typeof value === "number" ? value : (value?.right ?? 12),
|
|
31
|
+
bottom: typeof value === "number" ? value : (value?.bottom ?? 12),
|
|
32
|
+
left: typeof value === "number" ? value : (value?.left ?? 12),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Solve each scope's occupied envelope bottom-up; never repair returned sibling positions. */
|
|
36
|
+
export function layoutCompounds<N, E, G, P>(
|
|
37
|
+
graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
|
|
38
|
+
options: LayeredLayoutOptions,
|
|
39
|
+
flat: (graph: Graph<N, E, G, P>, options: LayeredLayoutOptions) => VisualGraph<N, E, G, P>,
|
|
40
|
+
): CompoundVisualGraph<N, E, G, P> {
|
|
41
|
+
const nodes = graph.nodes.map((node) => {
|
|
42
|
+
const size = options.measure?.(node);
|
|
43
|
+
return {
|
|
44
|
+
...node,
|
|
45
|
+
width: size?.width ?? node.width ?? 0,
|
|
46
|
+
height: size?.height ?? node.height ?? 0,
|
|
47
|
+
};
|
|
48
|
+
}) as VisualNode<N, P>[];
|
|
49
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
50
|
+
const ancestry = (id: string): (string | null)[] => {
|
|
51
|
+
const chain: (string | null)[] = [];
|
|
52
|
+
let current: string | null = id;
|
|
53
|
+
while (current != null) {
|
|
54
|
+
if (chain.includes(current)) throw new Error(`Cyclic compound hierarchy at ${current}`);
|
|
55
|
+
chain.push(current);
|
|
56
|
+
current = byId.get(current)?.parentId ?? null;
|
|
57
|
+
}
|
|
58
|
+
return [...chain, null];
|
|
59
|
+
};
|
|
60
|
+
const branch = (id: string, scope: string | null): string => {
|
|
61
|
+
const chain = ancestry(id);
|
|
62
|
+
return chain[chain.indexOf(scope) - 1] as string;
|
|
63
|
+
};
|
|
64
|
+
const owner = (edge: GraphEdge): string | null => {
|
|
65
|
+
const a = ancestry(edge.sourceId),
|
|
66
|
+
b = ancestry(edge.targetId);
|
|
67
|
+
if (edge.sourceId !== edge.targetId && b.includes(edge.sourceId)) return edge.sourceId;
|
|
68
|
+
if (edge.sourceId !== edge.targetId && a.includes(edge.targetId)) return edge.targetId;
|
|
69
|
+
return a.slice(1).find((id) => b.slice(1).includes(id)) ?? null;
|
|
70
|
+
};
|
|
71
|
+
const owned = new Map<string | null, GraphEdge<E>[]>();
|
|
72
|
+
for (const edge of graph.edges) {
|
|
73
|
+
const scope = owner(edge);
|
|
74
|
+
owned.set(scope, [...(owned.get(scope) ?? []), edge]);
|
|
75
|
+
}
|
|
76
|
+
const edges = new Map(
|
|
77
|
+
graph.edges.map((e) => [
|
|
78
|
+
e.id,
|
|
79
|
+
{ ...e, x: 0, y: 0, width: e.width ?? 0, height: e.height ?? 0 },
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
const localLabels = new Map<string, { scope: string | null; rect: EntityRect }>();
|
|
83
|
+
const compoundGeometry = new Map<string, CompoundLayoutGeometry>();
|
|
84
|
+
const childScopes = [
|
|
85
|
+
...new Set(nodes.flatMap((n) => (n.parentId == null ? [] : [n.parentId]))),
|
|
86
|
+
].sort((a, b) => ancestry(b).length - ancestry(a).length);
|
|
87
|
+
|
|
88
|
+
const solve = (scope: string | null) => {
|
|
89
|
+
const children = nodes.filter((n) => (n.parentId ?? null) === scope);
|
|
90
|
+
if (!children.length) return;
|
|
91
|
+
const container = scope == null ? undefined : byId.get(scope);
|
|
92
|
+
const config = container && options.compound?.(container);
|
|
93
|
+
const direction = config?.direction ?? options.direction ?? graph.direction ?? "right";
|
|
94
|
+
const horizontal = direction === "left" || direction === "right";
|
|
95
|
+
const padding = paddingOf(config?.padding ?? options.padding);
|
|
96
|
+
const insets = { ...padding };
|
|
97
|
+
const header = config?.header;
|
|
98
|
+
const side = header?.side ?? "top";
|
|
99
|
+
if (header) insets[side] += side === "top" || side === "bottom" ? header.height : header.width;
|
|
100
|
+
const scopeEdges = owned.get(scope) ?? [];
|
|
101
|
+
const boundary = new Map<string, GraphEdge<E>[]>();
|
|
102
|
+
const projected: GraphEdge<E>[] = [];
|
|
103
|
+
for (const edge of scopeEdges) {
|
|
104
|
+
if (edge.sourceId === scope || edge.targetId === scope) {
|
|
105
|
+
const id = branch(edge.sourceId === scope ? edge.targetId : edge.sourceId, scope);
|
|
106
|
+
boundary.set(id, [...(boundary.get(id) ?? []), edge]);
|
|
107
|
+
} else {
|
|
108
|
+
projected.push({
|
|
109
|
+
...edge,
|
|
110
|
+
sourceId: branch(edge.sourceId, scope),
|
|
111
|
+
targetId: branch(edge.targetId, scope),
|
|
112
|
+
points: undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const gap = options.settings?.["spacing.labelNode"] ?? options.spacing?.node ?? 20;
|
|
117
|
+
const labelGap = options.settings?.["spacing.labelLabel"] ?? 8;
|
|
118
|
+
const sizes = new Map(
|
|
119
|
+
children.map((n) => {
|
|
120
|
+
const labels = boundary.get(n.id) ?? [];
|
|
121
|
+
const labelWidth = Math.max(0, ...labels.map((e) => e.width ?? 0));
|
|
122
|
+
const labelHeight =
|
|
123
|
+
labels.reduce((sum, e) => sum + (e.height ?? 0), 0) +
|
|
124
|
+
Math.max(0, labels.length - 1) * labelGap;
|
|
125
|
+
return [
|
|
126
|
+
n.id,
|
|
127
|
+
{
|
|
128
|
+
width:
|
|
129
|
+
labels.length && !horizontal
|
|
130
|
+
? n.width + gap + labelWidth
|
|
131
|
+
: Math.max(n.width, labelWidth),
|
|
132
|
+
height:
|
|
133
|
+
labels.length && horizontal
|
|
134
|
+
? n.height + gap + labelHeight
|
|
135
|
+
: Math.max(n.height, labelHeight),
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
// A label is an occupied vertex in the scope's placement problem, including
|
|
141
|
+
// adjacent-layer and feedback edges. It cannot be added after node placement.
|
|
142
|
+
const labels = projected.filter((edge) => (edge.width ?? 0) > 0 && (edge.height ?? 0) > 0);
|
|
143
|
+
const used = new Set([...graph.nodes, ...graph.edges].map((entity) => entity.id));
|
|
144
|
+
const labelIds = new Map<string, string>();
|
|
145
|
+
for (const edge of labels) {
|
|
146
|
+
let id = `__layout_label_${edge.id}`;
|
|
147
|
+
while ([id, `${id}_source`, `${id}_target`].some((key) => used.has(key))) id += "_";
|
|
148
|
+
for (const key of [id, `${id}_source`, `${id}_target`]) used.add(key);
|
|
149
|
+
labelIds.set(edge.id, id);
|
|
150
|
+
sizes.set(id, { width: edge.width!, height: edge.height! });
|
|
151
|
+
}
|
|
152
|
+
const originalEdges = new Map(graph.edges.map((edge) => [edge.id, edge]));
|
|
153
|
+
for (const edge of labels) {
|
|
154
|
+
originalEdges.set(`${labelIds.get(edge.id)}_source`, edges.get(edge.id)!);
|
|
155
|
+
originalEdges.set(`${labelIds.get(edge.id)}_target`, edges.get(edge.id)!);
|
|
156
|
+
}
|
|
157
|
+
// Keep each label between its endpoint representatives in model order.
|
|
158
|
+
// Appending labels would reverse every label->target leg under MODEL_ORDER
|
|
159
|
+
// and collapse a forward chain into one layer of siblings.
|
|
160
|
+
const placementOrder = new Map(children.map((node, index) => [node.id, index]));
|
|
161
|
+
for (const edge of labels)
|
|
162
|
+
placementOrder.set(
|
|
163
|
+
labelIds.get(edge.id)!,
|
|
164
|
+
((placementOrder.get(edge.sourceId) ?? 0) + (placementOrder.get(edge.targetId) ?? 0)) / 2 +
|
|
165
|
+
(edge.sourceId === edge.targetId ? 0.25 : 0),
|
|
166
|
+
);
|
|
167
|
+
const result = flat(
|
|
168
|
+
{
|
|
169
|
+
...graph,
|
|
170
|
+
nodes: [
|
|
171
|
+
...children.map((n) => ({ ...n, parentId: null })),
|
|
172
|
+
...labels.map((edge) => ({
|
|
173
|
+
type: "node" as const,
|
|
174
|
+
id: labelIds.get(edge.id)!,
|
|
175
|
+
data: undefined as N,
|
|
176
|
+
})),
|
|
177
|
+
].sort((a, b) => placementOrder.get(a.id)! - placementOrder.get(b.id)!),
|
|
178
|
+
edges: projected.flatMap((edge) => {
|
|
179
|
+
const labelId = labelIds.get(edge.id);
|
|
180
|
+
return labelId
|
|
181
|
+
? [
|
|
182
|
+
{
|
|
183
|
+
...edge,
|
|
184
|
+
id: `${labelId}_source`,
|
|
185
|
+
targetId: labelId,
|
|
186
|
+
targetPort: undefined,
|
|
187
|
+
width: 0,
|
|
188
|
+
height: 0,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
...edge,
|
|
192
|
+
id: `${labelId}_target`,
|
|
193
|
+
sourceId: labelId,
|
|
194
|
+
sourcePort: undefined,
|
|
195
|
+
width: 0,
|
|
196
|
+
height: 0,
|
|
197
|
+
},
|
|
198
|
+
]
|
|
199
|
+
: [edge];
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
...options,
|
|
204
|
+
compound: undefined,
|
|
205
|
+
direction,
|
|
206
|
+
padding: insets,
|
|
207
|
+
measure: (node) => sizes.get(node.id)!,
|
|
208
|
+
nodeSettings: (node) => {
|
|
209
|
+
const original = byId.get(node.id);
|
|
210
|
+
return original ? options.nodeSettings?.(original) : undefined;
|
|
211
|
+
},
|
|
212
|
+
edgeSettings: (edge) => options.edgeSettings?.(originalEdges.get(edge.id) ?? edge),
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
for (const placed of result.nodes) {
|
|
216
|
+
const node = byId.get(placed.id);
|
|
217
|
+
if (node)
|
|
218
|
+
Object.assign(node, {
|
|
219
|
+
x: placed.x,
|
|
220
|
+
y: placed.y,
|
|
221
|
+
// Placement reserves an envelope for boundary labels. Ports belong to
|
|
222
|
+
// the actual node rectangle, never that larger occupied envelope.
|
|
223
|
+
ports: placePorts(
|
|
224
|
+
node.ports,
|
|
225
|
+
{ ...placed, width: node.width, height: node.height },
|
|
226
|
+
direction,
|
|
227
|
+
(port) => options.portSettings?.(port, node),
|
|
228
|
+
{ ...options.settings, ...options.nodeSettings?.(node) },
|
|
229
|
+
),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
for (const edge of projected) {
|
|
233
|
+
const labelId = labelIds.get(edge.id);
|
|
234
|
+
if (labelId) {
|
|
235
|
+
const label = result.nodes.find((n) => n.id === labelId)!;
|
|
236
|
+
localLabels.set(edge.id, {
|
|
237
|
+
scope,
|
|
238
|
+
rect: { x: label.x, y: label.y, width: label.width, height: label.height },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const [id, labels] of boundary) {
|
|
243
|
+
const node = byId.get(id)!;
|
|
244
|
+
const totalHeight =
|
|
245
|
+
labels.reduce((sum, e) => sum + (e.height ?? 0), 0) +
|
|
246
|
+
Math.max(0, labels.length - 1) * labelGap;
|
|
247
|
+
let cross = horizontal ? 0 : (Math.max(node.height, totalHeight) - totalHeight) / 2;
|
|
248
|
+
for (const edge of labels) {
|
|
249
|
+
const width = edge.width ?? 0,
|
|
250
|
+
height = edge.height ?? 0;
|
|
251
|
+
localLabels.set(edge.id, {
|
|
252
|
+
scope,
|
|
253
|
+
rect: {
|
|
254
|
+
x: node.x + (horizontal ? (Math.max(node.width, width) - width) / 2 : node.width + gap),
|
|
255
|
+
y: node.y + (horizontal ? node.height + gap : 0) + cross,
|
|
256
|
+
width,
|
|
257
|
+
height,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
cross += height + labelGap;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const boxes: EntityRect[] = [
|
|
264
|
+
...children.map((n) => ({ x: n.x, y: n.y, ...sizes.get(n.id)! })),
|
|
265
|
+
...[...localLabels.values()].filter((l) => l.scope === scope).map((l) => l.rect),
|
|
266
|
+
];
|
|
267
|
+
// One scope-local transform preserves labels relative to their nodes.
|
|
268
|
+
let dx = Math.max(0, insets.left - Math.min(...boxes.map((b) => b.x)));
|
|
269
|
+
let dy = Math.max(0, insets.top - Math.min(...boxes.map((b) => b.y)));
|
|
270
|
+
if (header && (side === "top" || side === "bottom")) {
|
|
271
|
+
const occupiedWidth =
|
|
272
|
+
Math.max(...boxes.map((b) => b.x + b.width)) -
|
|
273
|
+
Math.min(...boxes.map((b) => b.x)) +
|
|
274
|
+
padding.left +
|
|
275
|
+
padding.right;
|
|
276
|
+
dx += Math.max(0, (header.width - occupiedWidth) / 2);
|
|
277
|
+
}
|
|
278
|
+
if (header && (side === "left" || side === "right")) {
|
|
279
|
+
const occupiedHeight =
|
|
280
|
+
Math.max(...boxes.map((b) => b.y + b.height)) -
|
|
281
|
+
Math.min(...boxes.map((b) => b.y)) +
|
|
282
|
+
padding.top +
|
|
283
|
+
padding.bottom;
|
|
284
|
+
dy += Math.max(0, (header.height - occupiedHeight) / 2);
|
|
285
|
+
}
|
|
286
|
+
const right = Math.max(...boxes.map((b) => b.x + dx + b.width));
|
|
287
|
+
const bottom = Math.max(...boxes.map((b) => b.y + dy + b.height));
|
|
288
|
+
for (const n of children) {
|
|
289
|
+
n.x += dx;
|
|
290
|
+
n.y += dy;
|
|
291
|
+
}
|
|
292
|
+
for (const l of localLabels.values())
|
|
293
|
+
if (l.scope === scope) {
|
|
294
|
+
l.rect.x += dx;
|
|
295
|
+
l.rect.y += dy;
|
|
296
|
+
}
|
|
297
|
+
if (container) {
|
|
298
|
+
const width = Math.max(
|
|
299
|
+
header?.width ?? 0,
|
|
300
|
+
config?.minContentSize?.width
|
|
301
|
+
? config.minContentSize.width + insets.left + insets.right
|
|
302
|
+
: 0,
|
|
303
|
+
right + insets.right,
|
|
304
|
+
);
|
|
305
|
+
const height = Math.max(
|
|
306
|
+
header?.height ?? 0,
|
|
307
|
+
config?.minContentSize?.height
|
|
308
|
+
? config.minContentSize.height + insets.top + insets.bottom
|
|
309
|
+
: 0,
|
|
310
|
+
bottom + insets.bottom,
|
|
311
|
+
);
|
|
312
|
+
container.width = Math.max(container.width, width);
|
|
313
|
+
container.height = Math.max(container.height, height);
|
|
314
|
+
compoundGeometry.set(container.id, {
|
|
315
|
+
bounds: { x: 0, y: 0, width: container.width, height: container.height },
|
|
316
|
+
content: {
|
|
317
|
+
x: insets.left,
|
|
318
|
+
y: insets.top,
|
|
319
|
+
width: container.width - insets.left - insets.right,
|
|
320
|
+
height: container.height - insets.top - insets.bottom,
|
|
321
|
+
},
|
|
322
|
+
...(header
|
|
323
|
+
? {
|
|
324
|
+
header: {
|
|
325
|
+
x: side === "right" ? container.width - header.width : 0,
|
|
326
|
+
y: side === "bottom" ? container.height - header.height : 0,
|
|
327
|
+
width: side === "top" || side === "bottom" ? container.width : header.width,
|
|
328
|
+
height: side === "left" || side === "right" ? container.height : header.height,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
: {}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
for (const scope of childScopes) solve(scope);
|
|
336
|
+
const roots = nodes.filter((n) => n.parentId == null);
|
|
337
|
+
if (roots.length === 1 && compoundGeometry.has(roots[0]!.id) && !owned.get(null)?.length)
|
|
338
|
+
Object.assign(roots[0]!, { x: 0, y: 0 });
|
|
339
|
+
else solve(null);
|
|
340
|
+
const origin = (id: string | null): Point => {
|
|
341
|
+
if (id == null) return { x: 0, y: 0 };
|
|
342
|
+
return ancestry(id)
|
|
343
|
+
.slice(0, -1)
|
|
344
|
+
.reduce<Point>((p, key) => ({ x: p.x + byId.get(key!)!.x, y: p.y + byId.get(key!)!.y }), {
|
|
345
|
+
x: 0,
|
|
346
|
+
y: 0,
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
for (const [id, label] of localLabels) {
|
|
350
|
+
const offset = origin(label.scope);
|
|
351
|
+
Object.assign(edges.get(id)!, {
|
|
352
|
+
...label.rect,
|
|
353
|
+
x: label.rect.x + offset.x,
|
|
354
|
+
y: label.rect.y + offset.y,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Route the original endpoints, not their projected ancestor representatives.
|
|
358
|
+
const attachmentSettings: Record<string, EdgeRoutingSettings> = {};
|
|
359
|
+
for (const edge of edges.values()) {
|
|
360
|
+
const sourceGeometry = compoundGeometry.get(edge.sourceId),
|
|
361
|
+
targetGeometry = compoundGeometry.get(edge.targetId);
|
|
362
|
+
const intent = options.edgeAttachment?.(edge);
|
|
363
|
+
attachmentSettings[edge.id] = {
|
|
364
|
+
...(sourceGeometry &&
|
|
365
|
+
edge.sourceId !== edge.targetId &&
|
|
366
|
+
ancestry(edge.targetId).includes(edge.sourceId)
|
|
367
|
+
? {
|
|
368
|
+
sourceAttachment: {
|
|
369
|
+
bounds:
|
|
370
|
+
intent?.source === "outer"
|
|
371
|
+
? {
|
|
372
|
+
x: 0,
|
|
373
|
+
y: 0,
|
|
374
|
+
width: sourceGeometry.bounds.width,
|
|
375
|
+
height: sourceGeometry.bounds.height,
|
|
376
|
+
}
|
|
377
|
+
: sourceGeometry.content,
|
|
378
|
+
facing: intent?.source === "outer" ? "outward" : "inward",
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
: {}),
|
|
382
|
+
...(targetGeometry &&
|
|
383
|
+
edge.sourceId !== edge.targetId &&
|
|
384
|
+
(intent?.target === "content" || ancestry(edge.sourceId).includes(edge.targetId))
|
|
385
|
+
? {
|
|
386
|
+
targetAttachment: {
|
|
387
|
+
bounds:
|
|
388
|
+
intent?.target === "outer"
|
|
389
|
+
? {
|
|
390
|
+
x: 0,
|
|
391
|
+
y: 0,
|
|
392
|
+
width: targetGeometry.bounds.width,
|
|
393
|
+
height: targetGeometry.bounds.height,
|
|
394
|
+
}
|
|
395
|
+
: targetGeometry.content,
|
|
396
|
+
facing: intent?.target === "outer" ? "outward" : "inward",
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
: {}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const worldNodes = nodes.map((n) => ({ ...n, ...origin(n.id) }));
|
|
403
|
+
const usedIds = new Set(nodes.map((n) => n.id));
|
|
404
|
+
const headers = [...compoundGeometry].flatMap(([id, geometry]) => {
|
|
405
|
+
if (!geometry.header) return [];
|
|
406
|
+
const p = origin(id);
|
|
407
|
+
let key = `__layout_header_${id}`;
|
|
408
|
+
while (usedIds.has(key)) key += "_";
|
|
409
|
+
usedIds.add(key);
|
|
410
|
+
return [
|
|
411
|
+
{
|
|
412
|
+
type: "node" as const,
|
|
413
|
+
id: key,
|
|
414
|
+
parentId: id,
|
|
415
|
+
data: undefined as N,
|
|
416
|
+
...geometry.header,
|
|
417
|
+
x: p.x + geometry.header.x,
|
|
418
|
+
y: p.y + geometry.header.y,
|
|
419
|
+
},
|
|
420
|
+
];
|
|
421
|
+
});
|
|
422
|
+
const snapshot = orthogonalRouting.route(
|
|
423
|
+
{ ...graph, nodes: [...worldNodes, ...headers], edges: [...edges.values()] },
|
|
424
|
+
{ coordinateSpace: "world", edges: attachmentSettings, maxSearchNodes: 40000 },
|
|
425
|
+
);
|
|
426
|
+
for (const [id, route] of snapshot.routes) {
|
|
427
|
+
Object.assign(edges.get(id)!, {
|
|
428
|
+
points: routeToPoints(route),
|
|
429
|
+
routing: "orthogonal",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
for (const [id, geometry] of compoundGeometry)
|
|
433
|
+
geometry.bounds = { ...geometry.bounds, x: byId.get(id)!.x, y: byId.get(id)!.y };
|
|
434
|
+
const result: CompoundVisualGraph<N, E, G, P> = {
|
|
435
|
+
...graph,
|
|
436
|
+
edgeCoordinateSpace: "world",
|
|
437
|
+
direction: options.direction ?? graph.direction ?? "right",
|
|
438
|
+
nodes,
|
|
439
|
+
edges: graph.edges.map((e) => edges.get(e.id)!) as VisualGraph<N, E, G, P>["edges"],
|
|
440
|
+
compoundGeometry,
|
|
441
|
+
compoundRoutes: snapshot.routes,
|
|
442
|
+
};
|
|
443
|
+
recordRouteGeometry(result, snapshot.routes);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Derived from ELK v0.11.0 EdgeAndLayerConstraintEdgeReverser.java.
|
|
3
|
+
* Copyright (c) 2010, 2017 Kiel University and others.
|
|
4
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
5
|
+
*******************************************************************************/
|
|
6
|
+
import { createGraph } from "@statelyai/graph";
|
|
7
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
8
|
+
import type { AcyclicOrientation, CycleBreaker, LayeredPhaseInput } from "./types";
|
|
9
|
+
import { inheritCycleRandom } from "./cycle-random";
|
|
10
|
+
|
|
11
|
+
/** ELK handles outer constraints and whole feedback nodes before cycle breaking. */
|
|
12
|
+
export function orientConstrainedEdges(
|
|
13
|
+
input: LayeredPhaseInput,
|
|
14
|
+
initial: ReadonlySet<string>,
|
|
15
|
+
): AcyclicOrientation & { reversedNodeIds: readonly string[] } {
|
|
16
|
+
const reversedEdgeIds = new Set(initial);
|
|
17
|
+
const reversedNodeIds: string[] = [];
|
|
18
|
+
const constraints = new Map(
|
|
19
|
+
input.graph.nodes.map((n) => [n.id, input.nodeSettings?.(n)?.["layering.layerConstraint"]]),
|
|
20
|
+
);
|
|
21
|
+
const forward =
|
|
22
|
+
input.direction === "right"
|
|
23
|
+
? "EAST"
|
|
24
|
+
: input.direction === "left"
|
|
25
|
+
? "WEST"
|
|
26
|
+
: input.direction === "down"
|
|
27
|
+
? "SOUTH"
|
|
28
|
+
: "NORTH";
|
|
29
|
+
const backward =
|
|
30
|
+
input.direction === "right"
|
|
31
|
+
? "WEST"
|
|
32
|
+
: input.direction === "left"
|
|
33
|
+
? "EAST"
|
|
34
|
+
: input.direction === "down"
|
|
35
|
+
? "NORTH"
|
|
36
|
+
: "SOUTH";
|
|
37
|
+
const reverse = (nodeId: string, kind: "incoming" | "outgoing" | "all") => {
|
|
38
|
+
for (const edge of input.graph.edges) {
|
|
39
|
+
if (reversedEdgeIds.has(edge.id)) continue;
|
|
40
|
+
if (
|
|
41
|
+
edge.sourceId === nodeId &&
|
|
42
|
+
kind !== "incoming" &&
|
|
43
|
+
constraints.get(edge.targetId) !== "LAST_SEPARATE"
|
|
44
|
+
)
|
|
45
|
+
reversedEdgeIds.add(edge.id);
|
|
46
|
+
else if (
|
|
47
|
+
edge.targetId === nodeId &&
|
|
48
|
+
kind !== "outgoing" &&
|
|
49
|
+
constraints.get(edge.sourceId) !== "FIRST_SEPARATE"
|
|
50
|
+
)
|
|
51
|
+
reversedEdgeIds.add(edge.id);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
for (const node of input.graph.nodes) {
|
|
55
|
+
const c = constraints.get(node.id);
|
|
56
|
+
if (c === "FIRST" || c === "FIRST_SEPARATE") reverse(node.id, "incoming");
|
|
57
|
+
else if (c === "LAST" || c === "LAST_SEPARATE") reverse(node.id, "outgoing");
|
|
58
|
+
}
|
|
59
|
+
for (const node of input.graph.nodes) {
|
|
60
|
+
if (constraints.get(node.id) && constraints.get(node.id) !== "NONE") continue;
|
|
61
|
+
const fixed = input.nodeSettings?.(node)?.portConstraints;
|
|
62
|
+
if (
|
|
63
|
+
!["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(String(fixed)) ||
|
|
64
|
+
!node.ports?.length
|
|
65
|
+
)
|
|
66
|
+
continue;
|
|
67
|
+
const ports = new Map(
|
|
68
|
+
node.ports.map((p) => [
|
|
69
|
+
p.name,
|
|
70
|
+
{ side: input.portSettings?.(p, node)?.["port.side"], net: 0 },
|
|
71
|
+
]),
|
|
72
|
+
);
|
|
73
|
+
let protectedNeighbor = false;
|
|
74
|
+
for (const edge of input.graph.edges) {
|
|
75
|
+
const reversed = reversedEdgeIds.has(edge.id);
|
|
76
|
+
const source = reversed ? edge.targetId : edge.sourceId;
|
|
77
|
+
const target = reversed ? edge.sourceId : edge.targetId;
|
|
78
|
+
for (const incoming of [false, true]) {
|
|
79
|
+
if ((incoming ? target : source) !== node.id) continue;
|
|
80
|
+
const name = incoming
|
|
81
|
+
? reversed
|
|
82
|
+
? edge.sourcePort
|
|
83
|
+
: edge.targetPort
|
|
84
|
+
: reversed
|
|
85
|
+
? edge.targetPort
|
|
86
|
+
: edge.sourcePort;
|
|
87
|
+
const key = name ?? `__implicit:${edge.id}:${incoming}`;
|
|
88
|
+
const port = ports.get(key) ?? { side: incoming ? backward : forward, net: 0 };
|
|
89
|
+
port.net += incoming ? 1 : -1;
|
|
90
|
+
ports.set(key, port);
|
|
91
|
+
const otherConstraint = constraints.get(incoming ? source : target);
|
|
92
|
+
if (
|
|
93
|
+
incoming
|
|
94
|
+
? otherConstraint === "FIRST" || otherConstraint === "FIRST_SEPARATE"
|
|
95
|
+
: otherConstraint === "LAST" || otherConstraint === "LAST_SEPARATE"
|
|
96
|
+
)
|
|
97
|
+
protectedNeighbor = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Fixed-side UNDEFINED ports are assigned the side opposite their flow.
|
|
101
|
+
// An unused or non-flow port prevents whole-node feedback reversal.
|
|
102
|
+
const allReversed = [...ports.values()].every((p) => {
|
|
103
|
+
const side = p.side === "UNDEFINED" ? (p.net > 0 ? forward : backward) : p.side;
|
|
104
|
+
return (side === forward && p.net > 0) || (side === backward && p.net < 0);
|
|
105
|
+
});
|
|
106
|
+
if (allReversed && !protectedNeighbor) {
|
|
107
|
+
reversedNodeIds.push(node.id);
|
|
108
|
+
reverse(node.id, "all");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { reversedEdgeIds, reversedNodeIds };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Preserve original edge identities and carry the actual post-cycle RNG back. */
|
|
115
|
+
export function breakCyclesWithConstraints(
|
|
116
|
+
input: LayeredPhaseInput,
|
|
117
|
+
breaker: CycleBreaker,
|
|
118
|
+
): AcyclicOrientation {
|
|
119
|
+
const prepared = orientConstrainedEdges(input, new Set()).reversedEdgeIds;
|
|
120
|
+
if (!prepared.size) return breaker(input);
|
|
121
|
+
const edges: GraphEdge[] = input.graph.edges.map((e) =>
|
|
122
|
+
prepared.has(e.id)
|
|
123
|
+
? {
|
|
124
|
+
...e,
|
|
125
|
+
sourceId: e.targetId,
|
|
126
|
+
targetId: e.sourceId,
|
|
127
|
+
sourcePort: e.targetPort,
|
|
128
|
+
targetPort: e.sourcePort,
|
|
129
|
+
}
|
|
130
|
+
: e,
|
|
131
|
+
);
|
|
132
|
+
const phaseInput: LayeredPhaseInput = { ...input, graph: createGraph({ ...input.graph, edges }) };
|
|
133
|
+
const cycles = breaker(phaseInput);
|
|
134
|
+
inheritCycleRandom(phaseInput, input);
|
|
135
|
+
const reversedEdgeIds = new Set(prepared);
|
|
136
|
+
for (const id of cycles.reversedEdgeIds) {
|
|
137
|
+
if (reversedEdgeIds.has(id)) reversedEdgeIds.delete(id);
|
|
138
|
+
else reversedEdgeIds.add(id);
|
|
139
|
+
}
|
|
140
|
+
return { reversedEdgeIds };
|
|
141
|
+
}
|