@statelyai/layout 0.3.0 → 0.4.1
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-BBfKN4Dw.mjs +3075 -0
- package/dist/elkjs-DNfXDPup.cjs +3104 -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-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
- package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
- package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
- package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.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 +1476 -449
- package/src/elkjs/layout-quality.ts +267 -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 +233 -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 +79 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +875 -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 +1044 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +154 -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 +2124 -886
- 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,398 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2012, 2017, 2020 Kiel University and others.
|
|
3
|
+
* Native adaptation of ELK LabelDummyInserter, LabelSideSelector and LabelDummyRemover (v0.11.0).
|
|
4
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
5
|
+
*/
|
|
6
|
+
import type { GraphNode, GraphEdge, Point, EntityRect } from "@statelyai/graph";
|
|
7
|
+
import type { LayeredPhaseInput, AcyclicOrientation, LayerOrder, EdgeRoutes } from "./types";
|
|
8
|
+
import type { LongEdgeExpansion } from "./long-edges";
|
|
9
|
+
import { inheritCycleRandom } from "./cycle-random";
|
|
10
|
+
|
|
11
|
+
type LabelSide = "ABOVE" | "BELOW" | "INLINE";
|
|
12
|
+
interface LabelDummy {
|
|
13
|
+
edge: GraphEdge;
|
|
14
|
+
thickness: number;
|
|
15
|
+
spacing: number;
|
|
16
|
+
inline: boolean;
|
|
17
|
+
reversed: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface CenterLabelExpansion {
|
|
20
|
+
input: LayeredPhaseInput;
|
|
21
|
+
orientation: AcyclicOrientation;
|
|
22
|
+
segmentIdsByEdgeId: ReadonlyMap<string, readonly string[]>;
|
|
23
|
+
labelDummyIdByEdgeId: ReadonlyMap<string, string>;
|
|
24
|
+
dummyById: ReadonlyMap<string, LabelDummy>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Before layering: reserve raw label dimensions, then select sides after crossing minimization. */
|
|
28
|
+
export function insertCenterLabelDummies(
|
|
29
|
+
input: LayeredPhaseInput,
|
|
30
|
+
orientation: AcyclicOrientation,
|
|
31
|
+
): CenterLabelExpansion {
|
|
32
|
+
const nodes: GraphNode[] = [...input.graph.nodes],
|
|
33
|
+
edges: GraphEdge[] = [];
|
|
34
|
+
const sizes = new Map(input.sizes),
|
|
35
|
+
ids = new Set(nodes.map((n) => n.id)),
|
|
36
|
+
edgeIds = new Set(input.graph.edges.map((e) => e.id));
|
|
37
|
+
const dummyById = new Map<string, LabelDummy>(),
|
|
38
|
+
originals = new Map<string, GraphEdge>();
|
|
39
|
+
const reversed = new Set<string>(),
|
|
40
|
+
segments = new Map<string, string[]>(),
|
|
41
|
+
labels = new Map<string, string>();
|
|
42
|
+
const horizontal = input.direction === "right" || input.direction === "left";
|
|
43
|
+
const forward =
|
|
44
|
+
input.direction === "right"
|
|
45
|
+
? "EAST"
|
|
46
|
+
: input.direction === "left"
|
|
47
|
+
? "WEST"
|
|
48
|
+
: input.direction === "down"
|
|
49
|
+
? "SOUTH"
|
|
50
|
+
: "NORTH";
|
|
51
|
+
const negativeFlow = input.direction === "left" || input.direction === "up";
|
|
52
|
+
const backward = ({ EAST: "WEST", WEST: "EAST", SOUTH: "NORTH", NORTH: "SOUTH" } as const)[
|
|
53
|
+
forward
|
|
54
|
+
];
|
|
55
|
+
for (const edge of input.graph.edges) {
|
|
56
|
+
const settings = input.edgeSettings?.(edge);
|
|
57
|
+
if (
|
|
58
|
+
edge.sourceId === edge.targetId ||
|
|
59
|
+
!((edge.width ?? 0) > 0 || (edge.height ?? 0) > 0) ||
|
|
60
|
+
(settings?.["edgeLabels.placement"] ?? "CENTER") !== "CENTER"
|
|
61
|
+
) {
|
|
62
|
+
edges.push(edge);
|
|
63
|
+
segments.set(edge.id, [edge.id]);
|
|
64
|
+
if (orientation.reversedEdgeIds.has(edge.id)) reversed.add(edge.id);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let id = `__layout_dummy:label:${edge.id}`;
|
|
68
|
+
while (ids.has(id)) id += ":";
|
|
69
|
+
ids.add(id);
|
|
70
|
+
const inline = settings?.["edgeLabels.inline"] === true;
|
|
71
|
+
const thickness = Math.max(0, Number(settings?.["edge.thickness"] ?? 1));
|
|
72
|
+
const spacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
|
|
73
|
+
// ELK stacks horizontal label heights on the initial edge thickness,
|
|
74
|
+
// but takes the maximum for vertical labels before adding edge spacing.
|
|
75
|
+
const width = horizontal
|
|
76
|
+
? (edge.width ?? 0)
|
|
77
|
+
: Math.max(edge.width ?? 0, thickness) + spacing + thickness;
|
|
78
|
+
const height = (edge.height ?? 0) + (horizontal ? spacing + 2 * thickness : 0);
|
|
79
|
+
const anchor = Math.floor(thickness / 2);
|
|
80
|
+
const rect = { width, height };
|
|
81
|
+
const node: GraphNode = {
|
|
82
|
+
type: "node",
|
|
83
|
+
id,
|
|
84
|
+
data: undefined,
|
|
85
|
+
...rect,
|
|
86
|
+
ports: [
|
|
87
|
+
{
|
|
88
|
+
name: "input",
|
|
89
|
+
direction: "in",
|
|
90
|
+
x: horizontal ? (negativeFlow ? width : 0) : anchor,
|
|
91
|
+
y: horizontal ? anchor : negativeFlow ? height : 0,
|
|
92
|
+
width: 0,
|
|
93
|
+
height: 0,
|
|
94
|
+
data: undefined,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "output",
|
|
98
|
+
direction: "out",
|
|
99
|
+
x: horizontal ? (negativeFlow ? 0 : width) : anchor,
|
|
100
|
+
y: horizontal ? anchor : negativeFlow ? 0 : height,
|
|
101
|
+
width: 0,
|
|
102
|
+
height: 0,
|
|
103
|
+
data: undefined,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
nodes.push(node);
|
|
108
|
+
sizes.set(id, rect);
|
|
109
|
+
labels.set(edge.id, id);
|
|
110
|
+
dummyById.set(id, {
|
|
111
|
+
edge,
|
|
112
|
+
thickness,
|
|
113
|
+
spacing,
|
|
114
|
+
inline,
|
|
115
|
+
reversed: orientation.reversedEdgeIds.has(edge.id),
|
|
116
|
+
});
|
|
117
|
+
const chain = [edge.sourceId, id, edge.targetId],
|
|
118
|
+
partIds: string[] = [];
|
|
119
|
+
const rev = orientation.reversedEdgeIds.has(edge.id);
|
|
120
|
+
for (let i = 0; i < 2; i++) {
|
|
121
|
+
let partId = `${edge.id}::label:${i}`;
|
|
122
|
+
while (edgeIds.has(partId)) partId += ":";
|
|
123
|
+
edgeIds.add(partId);
|
|
124
|
+
partIds.push(partId);
|
|
125
|
+
const part: GraphEdge = {
|
|
126
|
+
...edge,
|
|
127
|
+
id: partId,
|
|
128
|
+
sourceId: chain[i]!,
|
|
129
|
+
targetId: chain[i + 1]!,
|
|
130
|
+
sourcePort: i === 0 ? edge.sourcePort : rev ? "input" : "output",
|
|
131
|
+
targetPort: i === 1 ? edge.targetPort : rev ? "output" : "input",
|
|
132
|
+
width: 0,
|
|
133
|
+
height: 0,
|
|
134
|
+
points: undefined,
|
|
135
|
+
};
|
|
136
|
+
edges.push(part);
|
|
137
|
+
originals.set(partId, edge);
|
|
138
|
+
if (rev) reversed.add(partId);
|
|
139
|
+
}
|
|
140
|
+
segments.set(edge.id, partIds);
|
|
141
|
+
}
|
|
142
|
+
const prepared = labels.size
|
|
143
|
+
? inheritCycleRandom(input, {
|
|
144
|
+
...input,
|
|
145
|
+
modelOrderByEdgeId: new Map(
|
|
146
|
+
edges.map((edge) => {
|
|
147
|
+
const original = originals.get(edge.id) ?? edge;
|
|
148
|
+
return [
|
|
149
|
+
edge.id,
|
|
150
|
+
input.modelOrderByEdgeId?.get(original.id) ?? input.graph.edges.indexOf(original),
|
|
151
|
+
];
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
graph: { ...input.graph, nodes, edges },
|
|
155
|
+
sizes,
|
|
156
|
+
nodeSettings: (n: GraphNode) =>
|
|
157
|
+
dummyById.has(n.id) ? { portConstraints: "FIXED_POS" as const } : input.nodeSettings?.(n),
|
|
158
|
+
portSettings: (p: NonNullable<GraphNode["ports"]>[number], n: GraphNode) =>
|
|
159
|
+
dummyById.has(n.id)
|
|
160
|
+
? { "port.side": p.name === "input" ? backward : forward }
|
|
161
|
+
: input.portSettings?.(p, n),
|
|
162
|
+
edgeSettings: (e: GraphEdge) => input.edgeSettings?.(originals.get(e.id) ?? e),
|
|
163
|
+
})
|
|
164
|
+
: input;
|
|
165
|
+
return {
|
|
166
|
+
input: prepared,
|
|
167
|
+
orientation: labels.size ? { reversedEdgeIds: reversed } : orientation,
|
|
168
|
+
segmentIdsByEdgeId: segments,
|
|
169
|
+
labelDummyIdByEdgeId: labels,
|
|
170
|
+
dummyById,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Before node placement: reproduce direction and consecutive-dummy label-side rules. */
|
|
175
|
+
export function selectCenterLabelSides(
|
|
176
|
+
expansion: LongEdgeExpansion,
|
|
177
|
+
labels: CenterLabelExpansion,
|
|
178
|
+
order: LayerOrder,
|
|
179
|
+
): { expansion: LongEdgeExpansion; sides: ReadonlyMap<string, LabelSide> } {
|
|
180
|
+
const input = expansion.input;
|
|
181
|
+
const mode = String(input.settings["edgeLabels.sideSelection"] ?? "SMART_DOWN");
|
|
182
|
+
const horizontal = input.direction === "right" || input.direction === "left";
|
|
183
|
+
// ELK transposes the default side before vertical layout. Smart run choices
|
|
184
|
+
// already refer to the canonical cross axis and must not be transposed again.
|
|
185
|
+
const defaultSide: LabelSide = mode.endsWith("_UP") === horizontal ? "ABOVE" : "BELOW";
|
|
186
|
+
const sides = new Map<string, LabelSide>();
|
|
187
|
+
const set = (id: string, side: LabelSide) => {
|
|
188
|
+
const info = labels.dummyById.get(id);
|
|
189
|
+
if (info) sides.set(id, info.inline ? "INLINE" : side);
|
|
190
|
+
};
|
|
191
|
+
// ELK pairs consecutive dummies of any kind whose long edges connect the
|
|
192
|
+
// same nodes (LONG_EDGE_SOURCE/TARGET), in their cycle-broken direction.
|
|
193
|
+
const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
|
|
194
|
+
const endpointsByDummy = new Map<string, string>();
|
|
195
|
+
for (const [original, ids] of expansion.segmentIdsByEdgeId) {
|
|
196
|
+
const chain = ids.flatMap((id) => edgeById.get(id) ?? []);
|
|
197
|
+
if (chain.length < 2) continue;
|
|
198
|
+
const reversed =
|
|
199
|
+
expansion.orientation.reversedEdgeIds.has(original) ||
|
|
200
|
+
expansion.orientation.reversedEdgeIds.has(chain[0]!.id);
|
|
201
|
+
const [source, target] = [chain[0]!.sourceId, chain.at(-1)!.targetId];
|
|
202
|
+
const key = reversed ? `${target}\0${source}` : `${source}\0${target}`;
|
|
203
|
+
for (const edge of chain)
|
|
204
|
+
for (const id of [edge.sourceId, edge.targetId])
|
|
205
|
+
if (id.startsWith("__layout_dummy:")) endpointsByDummy.set(id, key);
|
|
206
|
+
}
|
|
207
|
+
const endpoints = (id: string) => {
|
|
208
|
+
const info = labels.dummyById.get(id);
|
|
209
|
+
if (!info) return endpointsByDummy.get(id);
|
|
210
|
+
return info.reversed
|
|
211
|
+
? `${info.edge.targetId}\0${info.edge.sourceId}`
|
|
212
|
+
: `${info.edge.sourceId}\0${info.edge.targetId}`;
|
|
213
|
+
};
|
|
214
|
+
const sameEndpoints = (first: string, second: string) => {
|
|
215
|
+
const a = endpoints(first);
|
|
216
|
+
return a !== undefined && a === endpoints(second);
|
|
217
|
+
};
|
|
218
|
+
for (const layer of order.layers) {
|
|
219
|
+
if (!mode.startsWith("SMART_")) {
|
|
220
|
+
for (const id of layer) {
|
|
221
|
+
const info = labels.dummyById.get(id);
|
|
222
|
+
if (info)
|
|
223
|
+
set(
|
|
224
|
+
id,
|
|
225
|
+
mode.startsWith("DIRECTION_") && info.reversed
|
|
226
|
+
? defaultSide === "ABOVE"
|
|
227
|
+
? "BELOW"
|
|
228
|
+
: "ABOVE"
|
|
229
|
+
: defaultSide,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const applyRun = (run: string[], top: boolean, bottom: boolean) => {
|
|
235
|
+
const labelIds = run.filter((id) => labels.dummyById.has(id));
|
|
236
|
+
if (
|
|
237
|
+
top &&
|
|
238
|
+
(!bottom || run.length > 1) &&
|
|
239
|
+
labelIds.length === 1 &&
|
|
240
|
+
labels.dummyById.has(run[0]!)
|
|
241
|
+
)
|
|
242
|
+
set(run[0]!, "ABOVE");
|
|
243
|
+
else if (
|
|
244
|
+
bottom &&
|
|
245
|
+
(!top || run.length > 1) &&
|
|
246
|
+
labelIds.length === 1 &&
|
|
247
|
+
labels.dummyById.has(run.at(-1)!)
|
|
248
|
+
)
|
|
249
|
+
set(run.at(-1)!, "BELOW");
|
|
250
|
+
else if (run.length === 2) {
|
|
251
|
+
set(run[0]!, "ABOVE");
|
|
252
|
+
set(run[1]!, "BELOW");
|
|
253
|
+
} else {
|
|
254
|
+
let group: string[] = [];
|
|
255
|
+
const flush = () => {
|
|
256
|
+
if (group.length === 2) {
|
|
257
|
+
set(group[0]!, "ABOVE");
|
|
258
|
+
set(group[1]!, "BELOW");
|
|
259
|
+
} else for (const id of group) set(id, defaultSide);
|
|
260
|
+
group = [];
|
|
261
|
+
};
|
|
262
|
+
for (const id of run) {
|
|
263
|
+
if (group.length && !sameEndpoints(group.at(-1)!, id)) flush();
|
|
264
|
+
group.push(id);
|
|
265
|
+
}
|
|
266
|
+
flush();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
let run: string[] = [],
|
|
270
|
+
top = true;
|
|
271
|
+
for (const id of layer) {
|
|
272
|
+
if (id.startsWith("__layout_dummy:")) run.push(id);
|
|
273
|
+
else {
|
|
274
|
+
if (run.length) applyRun(run, top, false);
|
|
275
|
+
run = [];
|
|
276
|
+
top = false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (run.length) applyRun(run, top, true);
|
|
280
|
+
}
|
|
281
|
+
const sizes = new Map(input.sizes);
|
|
282
|
+
const nodes = input.graph.nodes.map((node) => {
|
|
283
|
+
const info = labels.dummyById.get(node.id),
|
|
284
|
+
side = sides.get(node.id);
|
|
285
|
+
if (!info || !side) return node;
|
|
286
|
+
const original = sizes.get(node.id)!;
|
|
287
|
+
const cross =
|
|
288
|
+
(horizontal ? original.height : original.width) -
|
|
289
|
+
(side === "INLINE" ? info.spacing + info.thickness : 0);
|
|
290
|
+
const anchor =
|
|
291
|
+
side === "INLINE"
|
|
292
|
+
? Math.ceil(cross) / 2
|
|
293
|
+
: side === "ABOVE"
|
|
294
|
+
? cross - Math.ceil(info.thickness / 2)
|
|
295
|
+
: Math.floor(info.thickness / 2);
|
|
296
|
+
const rect = horizontal ? { ...original, height: cross } : { ...original, width: cross };
|
|
297
|
+
sizes.set(node.id, rect);
|
|
298
|
+
return {
|
|
299
|
+
...node,
|
|
300
|
+
...rect,
|
|
301
|
+
ports: node.ports?.map((port) => ({
|
|
302
|
+
...port,
|
|
303
|
+
x: horizontal ? port.x : anchor,
|
|
304
|
+
y: horizontal ? anchor : port.y,
|
|
305
|
+
})),
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
return {
|
|
309
|
+
expansion: {
|
|
310
|
+
...expansion,
|
|
311
|
+
input: inheritCycleRandom(input, { ...input, graph: { ...input.graph, nodes }, sizes }),
|
|
312
|
+
},
|
|
313
|
+
// Label restoration consumes sides in the original layout direction.
|
|
314
|
+
sides: horizontal
|
|
315
|
+
? sides
|
|
316
|
+
: new Map(
|
|
317
|
+
[...sides].map(([id, side]) => [
|
|
318
|
+
id,
|
|
319
|
+
side === "INLINE" ? side : side === "ABOVE" ? "BELOW" : "ABOVE",
|
|
320
|
+
]),
|
|
321
|
+
),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function composeCenterLabelExpansion(
|
|
326
|
+
labels: CenterLabelExpansion,
|
|
327
|
+
expanded: LongEdgeExpansion,
|
|
328
|
+
): LongEdgeExpansion {
|
|
329
|
+
return {
|
|
330
|
+
...expanded,
|
|
331
|
+
segmentIdsByEdgeId: new Map(
|
|
332
|
+
[...labels.segmentIdsByEdgeId].map(([id, parts]) => [
|
|
333
|
+
id,
|
|
334
|
+
parts.flatMap((p) => expanded.segmentIdsByEdgeId.get(p) ?? [p]),
|
|
335
|
+
]),
|
|
336
|
+
),
|
|
337
|
+
labelDummyIdByEdgeId: new Map([
|
|
338
|
+
...expanded.labelDummyIdByEdgeId,
|
|
339
|
+
...labels.labelDummyIdByEdgeId,
|
|
340
|
+
]),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** LABEL junctions do not introduce auxiliary orthogonal bend points. */
|
|
345
|
+
export function removeCenterLabelJunctions(
|
|
346
|
+
routes: EdgeRoutes,
|
|
347
|
+
expansion: LongEdgeExpansion,
|
|
348
|
+
labels: ReadonlyMap<string, string>,
|
|
349
|
+
routing: string,
|
|
350
|
+
): EdgeRoutes {
|
|
351
|
+
if (routing !== "ORTHOGONAL" || !labels.size) return routes;
|
|
352
|
+
const horizontal = expansion.input.direction === "right" || expansion.input.direction === "left";
|
|
353
|
+
const cross = (point: Point) => (horizontal ? point.y : point.x);
|
|
354
|
+
const ids = new Set<string>();
|
|
355
|
+
for (const id of labels.values()) {
|
|
356
|
+
const neighbors: Point[] = [];
|
|
357
|
+
for (const edge of expansion.input.graph.edges) {
|
|
358
|
+
const route = routes.pointsByEdgeId.get(edge.id);
|
|
359
|
+
if (!route || route.length < 2) continue;
|
|
360
|
+
if (edge.sourceId === id) neighbors.push(route[1]!);
|
|
361
|
+
if (edge.targetId === id) neighbors.push(route.at(-2)!);
|
|
362
|
+
}
|
|
363
|
+
// Remove straight synthetic junctions only. A compacted track can still
|
|
364
|
+
// turn at a label port; deleting that corner would create a diagonal.
|
|
365
|
+
if (neighbors.length === 2 && cross(neighbors[0]!) === cross(neighbors[1]!)) ids.add(id);
|
|
366
|
+
}
|
|
367
|
+
const points = new Map(routes.pointsByEdgeId);
|
|
368
|
+
for (const edge of expansion.input.graph.edges) {
|
|
369
|
+
const route = points.get(edge.id);
|
|
370
|
+
if (!route) continue;
|
|
371
|
+
const first = ids.has(edge.sourceId) ? 1 : 0;
|
|
372
|
+
const last = route.length - (ids.has(edge.targetId) ? 1 : 0);
|
|
373
|
+
if (first || last !== route.length) points.set(edge.id, route.slice(first, last));
|
|
374
|
+
}
|
|
375
|
+
return { ...routes, pointsByEdgeId: points };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function centerLabelPosition(
|
|
379
|
+
rect: EntityRect,
|
|
380
|
+
info: LabelDummy,
|
|
381
|
+
side: LabelSide | undefined,
|
|
382
|
+
direction: string,
|
|
383
|
+
): Point {
|
|
384
|
+
const horizontal = direction === "right" || direction === "left";
|
|
385
|
+
const crossOffset = side === "BELOW" ? info.thickness + info.spacing : 0;
|
|
386
|
+
return horizontal
|
|
387
|
+
? { x: rect.x + (rect.width - (info.edge.width ?? 0)) / 2, y: rect.y + crossOffset }
|
|
388
|
+
: {
|
|
389
|
+
x:
|
|
390
|
+
rect.x +
|
|
391
|
+
(side === "INLINE"
|
|
392
|
+
? (rect.width - (info.edge.width ?? 0)) / 2
|
|
393
|
+
: side === "ABOVE"
|
|
394
|
+
? info.thickness + info.spacing
|
|
395
|
+
: 0),
|
|
396
|
+
y: rect.y + (info.inline ? (rect.height - (info.edge.height ?? 0)) / 2 : 0),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NodePlacement } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface CompactionBounds {
|
|
4
|
+
left: number;
|
|
5
|
+
top: number;
|
|
6
|
+
right: number;
|
|
7
|
+
bottom: number;
|
|
8
|
+
}
|
|
9
|
+
const boundsByPlacement = new WeakMap<NodePlacement, CompactionBounds>();
|
|
10
|
+
export function recordCompactionBounds(placement: NodePlacement, bounds: CompactionBounds): void {
|
|
11
|
+
boundsByPlacement.set(placement, bounds);
|
|
12
|
+
}
|
|
13
|
+
export function compactionBounds(placement: NodePlacement): CompactionBounds | undefined {
|
|
14
|
+
return boundsByPlacement.get(placement);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const crossBoundsByPlacement = new WeakMap<NodePlacement, { axis: "x" | "y"; maximum: number }>();
|
|
18
|
+
export function recordPlacementCrossBounds(
|
|
19
|
+
placement: NodePlacement,
|
|
20
|
+
bounds: { axis: "x" | "y"; maximum: number },
|
|
21
|
+
): void {
|
|
22
|
+
crossBoundsByPlacement.set(placement, bounds);
|
|
23
|
+
}
|
|
24
|
+
export function placementCrossBounds(placement: NodePlacement) {
|
|
25
|
+
return crossBoundsByPlacement.get(placement);
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2017 Kiel University and others.
|
|
3
|
+
* Adapted from ELK v0.11.0 ScanlineConstraintCalculator.java.
|
|
4
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
5
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
6
|
+
*******************************************************************************/
|
|
7
|
+
import type { EntityRect } from "@statelyai/graph";
|
|
8
|
+
import { InfeasibleCompactionError } from "./compaction-errors";
|
|
9
|
+
|
|
10
|
+
export interface ScanlineItem extends EntityRect {
|
|
11
|
+
group: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Visibility constraints, rather than all pairs of overlapping intervals. */
|
|
15
|
+
export function scanlineConstraints<T extends ScanlineItem>(
|
|
16
|
+
items: readonly T[],
|
|
17
|
+
includeDegenerate = false,
|
|
18
|
+
): [T, T][] {
|
|
19
|
+
const events = items.flatMap((item) => [
|
|
20
|
+
{ item, low: true, y: item.y },
|
|
21
|
+
{ item, low: false, y: item.y + item.height },
|
|
22
|
+
]);
|
|
23
|
+
events.sort((a, b) => a.y - b.y || Number(a.low) - Number(b.low));
|
|
24
|
+
const active: T[] = [];
|
|
25
|
+
const candidates = new Map<T, T | undefined>();
|
|
26
|
+
const constraints: [T, T][] = [];
|
|
27
|
+
const center = (item: T) => item.x + item.width / 2;
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
const { item } = event;
|
|
30
|
+
// Geometric callers omit empty intervals. ELK's compactor retains them
|
|
31
|
+
// because grouped hitbox restoration can invert an endpoint lead.
|
|
32
|
+
if (!includeDegenerate && item.height <= 0) continue;
|
|
33
|
+
if (event.low) {
|
|
34
|
+
const index = active.findIndex((other) => center(other) >= center(item));
|
|
35
|
+
const position = index < 0 ? active.length : index;
|
|
36
|
+
if (active[position] && center(active[position]!) === center(item))
|
|
37
|
+
throw new InfeasibleCompactionError(
|
|
38
|
+
"Invalid hitboxes for scanline constraint calculation.",
|
|
39
|
+
{
|
|
40
|
+
cause: { first: active[position], second: item },
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
active.splice(position, 0, item);
|
|
44
|
+
candidates.set(item, active[position - 1]);
|
|
45
|
+
const right = active[position + 1];
|
|
46
|
+
if (right) candidates.set(right, item);
|
|
47
|
+
} else {
|
|
48
|
+
// ELK queries the tree by center even when an end event precedes its
|
|
49
|
+
// start (zero or negative hitbox height after grouped restoration).
|
|
50
|
+
const position = active.indexOf(item);
|
|
51
|
+
const insertion = active.findIndex((other) => center(other) >= center(item));
|
|
52
|
+
const boundary = insertion < 0 ? active.length : insertion;
|
|
53
|
+
const left = active[boundary - 1],
|
|
54
|
+
right = active[boundary + (position >= 0 ? 1 : 0)];
|
|
55
|
+
if (left && candidates.get(item) === left && left.group !== item.group)
|
|
56
|
+
constraints.push([left, item]);
|
|
57
|
+
if (right && candidates.get(right) === item && right.group !== item.group)
|
|
58
|
+
constraints.push([item, right]);
|
|
59
|
+
if (position >= 0) active.splice(position, 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return constraints;
|
|
63
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2011, 2020 Kiel University and others.
|
|
3
|
+
* Native adaptations of ELK getExternalPortPosition and CompoundGraphPostprocessor.
|
|
4
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
5
|
+
*/
|
|
6
|
+
import type { Point } from "@statelyai/graph";
|
|
7
|
+
import type { ExternalPortSide } from "./external-port-dummy";
|
|
8
|
+
import type { LayoutPadding, NodeSize } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface ExternalPortTransfer {
|
|
11
|
+
contentSize: NodeSize;
|
|
12
|
+
padding: LayoutPadding;
|
|
13
|
+
offset: Point;
|
|
14
|
+
dummy: Point & NodeSize;
|
|
15
|
+
side: ExternalPortSide;
|
|
16
|
+
borderOffset: number;
|
|
17
|
+
portSize: NodeSize;
|
|
18
|
+
}
|
|
19
|
+
/** Return both effects of ELK's transfer: parent port and corrected content-space dummy. */
|
|
20
|
+
export function transferExternalPort(input: ExternalPortTransfer): { port: Point; dummy: Point } {
|
|
21
|
+
const { contentSize: size, padding: p, offset: o, borderOffset: b, portSize } = input;
|
|
22
|
+
const dummy = { x: input.dummy.x, y: input.dummy.y };
|
|
23
|
+
const port = { x: dummy.x + input.dummy.width / 2, y: dummy.y + input.dummy.height / 2 };
|
|
24
|
+
switch (input.side) {
|
|
25
|
+
case "NORTH":
|
|
26
|
+
port.x += p.left + o.x - portSize.width / 2;
|
|
27
|
+
port.y = -portSize.height - b;
|
|
28
|
+
dummy.y = -(p.top + b + o.y);
|
|
29
|
+
break;
|
|
30
|
+
case "EAST":
|
|
31
|
+
port.x = size.width + p.left + p.right + b;
|
|
32
|
+
port.y += p.top + o.y - portSize.height / 2;
|
|
33
|
+
dummy.x = size.width + p.right + b - o.x;
|
|
34
|
+
break;
|
|
35
|
+
case "SOUTH":
|
|
36
|
+
port.x += p.left + o.x - portSize.width / 2;
|
|
37
|
+
port.y = size.height + p.top + p.bottom + b;
|
|
38
|
+
dummy.y = size.height + p.bottom + b - o.y;
|
|
39
|
+
break;
|
|
40
|
+
case "WEST":
|
|
41
|
+
port.x = -portSize.width - b;
|
|
42
|
+
port.y += p.top + o.y - portSize.height / 2;
|
|
43
|
+
dummy.x = -(p.left + b + o.x);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
return { port, dummy };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CompoundRouteSegment {
|
|
50
|
+
points: readonly Point[];
|
|
51
|
+
offset: Point;
|
|
52
|
+
}
|
|
53
|
+
/** Segments are directed source-to-target and translated to one reference graph. */
|
|
54
|
+
export function joinCompoundRouteSegments(
|
|
55
|
+
segments: readonly CompoundRouteSegment[],
|
|
56
|
+
unnecessaryBendpoints = false,
|
|
57
|
+
): Point[] {
|
|
58
|
+
const bends: Point[] = [];
|
|
59
|
+
let start: Point | undefined, end: Point | undefined, last: Point | undefined;
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
if (segment.points.length < 2) throw new Error("Compound route segment needs two anchors");
|
|
62
|
+
const points = segment.points.map((p) => ({
|
|
63
|
+
x: p.x + segment.offset.x,
|
|
64
|
+
y: p.y + segment.offset.y,
|
|
65
|
+
}));
|
|
66
|
+
const source = points[0]!,
|
|
67
|
+
target = points.at(-1)!;
|
|
68
|
+
const inner = points.slice(1, -1);
|
|
69
|
+
if (!start) start = source;
|
|
70
|
+
if (last) {
|
|
71
|
+
const next = inner[0] ?? target;
|
|
72
|
+
const dx = Math.abs(last.x - next.x) > 1e-6;
|
|
73
|
+
const dy = Math.abs(last.y - next.y) > 1e-6;
|
|
74
|
+
if (unnecessaryBendpoints ? dx || dy : dx && dy) bends.push(source);
|
|
75
|
+
}
|
|
76
|
+
bends.push(...inner);
|
|
77
|
+
last = inner.at(-1) ?? source;
|
|
78
|
+
end = target;
|
|
79
|
+
}
|
|
80
|
+
if (!start || !end) return [];
|
|
81
|
+
// Child and parent boundary anchors can disagree on both axes. Join them
|
|
82
|
+
// with an elbow collinear with a neighboring segment instead of a diagonal.
|
|
83
|
+
const route = [start, ...bends, end];
|
|
84
|
+
const straight = (a: Point, b: Point) =>
|
|
85
|
+
Math.abs(a.x - b.x) <= 1e-6 || Math.abs(a.y - b.y) <= 1e-6;
|
|
86
|
+
for (let index = 0; index + 1 < route.length; index++) {
|
|
87
|
+
const a = route[index]!,
|
|
88
|
+
b = route[index + 1]!;
|
|
89
|
+
if (straight(a, b)) continue;
|
|
90
|
+
const next = route[index + 2],
|
|
91
|
+
previous = route[index - 1];
|
|
92
|
+
const verticalFirst =
|
|
93
|
+
next !== undefined
|
|
94
|
+
? Math.abs(b.y - next.y) <= 1e-6
|
|
95
|
+
: previous !== undefined && Math.abs(previous.x - a.x) <= 1e-6;
|
|
96
|
+
route.splice(index + 1, 0, verticalFirst ? { x: a.x, y: b.y } : { x: b.x, y: a.y });
|
|
97
|
+
}
|
|
98
|
+
return route;
|
|
99
|
+
}
|
package/src/layered/compound.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { orthogonalRouting,
|
|
1
|
+
import { orthogonalRouting, routeToPoints } from "../routing";
|
|
2
2
|
import { recordRouteGeometry } from "../routing/layout-cache";
|
|
3
3
|
import type { EdgeRoutingSettings, Route } from "../routing/types";
|
|
4
4
|
import type {
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
VisualNode,
|
|
11
11
|
} from "@statelyai/graph";
|
|
12
12
|
import type { CompoundLayoutGeometry, LayeredLayoutOptions, LayoutPadding } from "./types";
|
|
13
|
+
import { placePorts } from "./strategies";
|
|
13
14
|
|
|
14
15
|
export type CompoundVisualGraph<N = unknown, E = unknown, G = unknown, P = unknown> = VisualGraph<
|
|
15
16
|
N,
|
|
@@ -213,7 +214,20 @@ export function layoutCompounds<N, E, G, P>(
|
|
|
213
214
|
);
|
|
214
215
|
for (const placed of result.nodes) {
|
|
215
216
|
const node = byId.get(placed.id);
|
|
216
|
-
if (node)
|
|
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
|
+
});
|
|
217
231
|
}
|
|
218
232
|
for (const edge of projected) {
|
|
219
233
|
const labelId = labelIds.get(edge.id);
|
|
@@ -365,8 +379,23 @@ export function layoutCompounds<N, E, G, P>(
|
|
|
365
379
|
},
|
|
366
380
|
}
|
|
367
381
|
: {}),
|
|
368
|
-
...(targetGeometry &&
|
|
369
|
-
|
|
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
|
+
}
|
|
370
399
|
: {}),
|
|
371
400
|
};
|
|
372
401
|
}
|
|
@@ -392,12 +421,11 @@ export function layoutCompounds<N, E, G, P>(
|
|
|
392
421
|
});
|
|
393
422
|
const snapshot = orthogonalRouting.route(
|
|
394
423
|
{ ...graph, nodes: [...worldNodes, ...headers], edges: [...edges.values()] },
|
|
395
|
-
{ coordinateSpace: "world", edges: attachmentSettings },
|
|
424
|
+
{ coordinateSpace: "world", edges: attachmentSettings, maxSearchNodes: 40000 },
|
|
396
425
|
);
|
|
397
426
|
for (const [id, route] of snapshot.routes) {
|
|
398
|
-
const polylines = routeToPolylines(route);
|
|
399
427
|
Object.assign(edges.get(id)!, {
|
|
400
|
-
points:
|
|
428
|
+
points: routeToPoints(route),
|
|
401
429
|
routing: "orthogonal",
|
|
402
430
|
});
|
|
403
431
|
}
|