@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,200 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2012, 2017 Kiel University and others.
|
|
3
|
+
* Native adaptation of ELK LabelDummySwitcher (v0.11.0).
|
|
4
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
5
|
+
*/
|
|
6
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
7
|
+
import type { LayerOrder } from "./types";
|
|
8
|
+
import type { LongEdgeExpansion } from "./long-edges";
|
|
9
|
+
import type { CenterLabelExpansion } from "./center-labels";
|
|
10
|
+
import { inheritCycleRandom } from "./cycle-random";
|
|
11
|
+
|
|
12
|
+
/** Before placement: move labels along their own proper long-edge chain without changing crossing order. */
|
|
13
|
+
export function switchCenterLabelDummies(
|
|
14
|
+
expansion: LongEdgeExpansion,
|
|
15
|
+
labels: CenterLabelExpansion,
|
|
16
|
+
order: LayerOrder,
|
|
17
|
+
): { expansion: LongEdgeExpansion; order: LayerOrder } {
|
|
18
|
+
if (!labels.dummyById.size) return { expansion, order };
|
|
19
|
+
const input = expansion.input;
|
|
20
|
+
const horizontal = input.direction === "left" || input.direction === "right";
|
|
21
|
+
const layers = order.layers.map((layer) => [...layer]);
|
|
22
|
+
const layerByNodeId = new Map(expansion.assignment.layerByNodeId);
|
|
23
|
+
const widths = layers.map((layer) =>
|
|
24
|
+
Math.max(
|
|
25
|
+
0,
|
|
26
|
+
...layer
|
|
27
|
+
.filter((id) => !id.startsWith("__layout_dummy:"))
|
|
28
|
+
.map((id) => {
|
|
29
|
+
const size = input.sizes.get(id);
|
|
30
|
+
return horizontal ? (size?.width ?? 0) : (size?.height ?? 0);
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
let edges = [...input.graph.edges];
|
|
35
|
+
const alignments = new Map<string, "LEFT" | "RIGHT">();
|
|
36
|
+
const nodeIds = new Set(input.graph.nodes.map((node) => node.id));
|
|
37
|
+
const longDummy = (id: string) =>
|
|
38
|
+
nodeIds.has(id) &&
|
|
39
|
+
id.startsWith("__layout_dummy:") &&
|
|
40
|
+
!labels.dummyById.has(id) &&
|
|
41
|
+
!id.startsWith("__layout_dummy:inverted:") &&
|
|
42
|
+
!id.startsWith("__layout_dummy:north-south:");
|
|
43
|
+
const orientedSource = (edge: GraphEdge) =>
|
|
44
|
+
expansion.orientation.reversedEdgeIds.has(edge.id) ? edge.targetId : edge.sourceId;
|
|
45
|
+
const orientedTarget = (edge: GraphEdge) =>
|
|
46
|
+
expansion.orientation.reversedEdgeIds.has(edge.id) ? edge.sourceId : edge.targetId;
|
|
47
|
+
const infos = layers
|
|
48
|
+
.flatMap((layer) => layer.filter((id) => labels.dummyById.has(id)))
|
|
49
|
+
.map((id) => {
|
|
50
|
+
const label = labels.dummyById.get(id)!;
|
|
51
|
+
const walk = (backward: boolean) => {
|
|
52
|
+
const result: string[] = [],
|
|
53
|
+
seen = new Set([id]);
|
|
54
|
+
let current = id;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const edge = edges.find(
|
|
57
|
+
(edge) => (backward ? orientedTarget(edge) : orientedSource(edge)) === current,
|
|
58
|
+
);
|
|
59
|
+
if (!edge) break;
|
|
60
|
+
current = backward ? orientedSource(edge) : orientedTarget(edge);
|
|
61
|
+
if (!longDummy(current) || seen.has(current)) break;
|
|
62
|
+
seen.add(current);
|
|
63
|
+
result.push(current);
|
|
64
|
+
}
|
|
65
|
+
return backward ? result.reverse() : result;
|
|
66
|
+
};
|
|
67
|
+
const chain = [...walk(true), id, ...walk(false)];
|
|
68
|
+
const strategy = String(
|
|
69
|
+
input.edgeSettings?.(label.edge)?.["edgeLabels.centerLabelPlacementStrategy"] ??
|
|
70
|
+
input.settings["edgeLabels.centerLabelPlacementStrategy"] ??
|
|
71
|
+
"MEDIAN_LAYER",
|
|
72
|
+
);
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
chain,
|
|
76
|
+
strategy,
|
|
77
|
+
reversed: label.reversed,
|
|
78
|
+
width: horizontal ? input.sizes.get(id)!.width : input.sizes.get(id)!.height,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
type Info = (typeof infos)[number];
|
|
82
|
+
const assign = (info: Info, target: number) => {
|
|
83
|
+
const other = info.chain.find((id) => layerByNodeId.get(id) === target)!;
|
|
84
|
+
if (other !== info.id) {
|
|
85
|
+
const original = layerByNodeId.get(info.id)!;
|
|
86
|
+
const firstSlot = layers[original]!.indexOf(info.id),
|
|
87
|
+
secondSlot = layers[target]!.indexOf(other);
|
|
88
|
+
layers[original]![firstSlot] = other;
|
|
89
|
+
layers[target]![secondSlot] = info.id;
|
|
90
|
+
layerByNodeId.set(info.id, target);
|
|
91
|
+
layerByNodeId.set(other, original);
|
|
92
|
+
edges = edges.map((edge) => {
|
|
93
|
+
const swap = (id: string) => (id === info.id ? other : id === other ? info.id : id);
|
|
94
|
+
const sourceId = swap(edge.sourceId),
|
|
95
|
+
targetId = swap(edge.targetId);
|
|
96
|
+
const reversed = expansion.orientation.reversedEdgeIds.has(edge.id);
|
|
97
|
+
return {
|
|
98
|
+
...edge,
|
|
99
|
+
sourceId,
|
|
100
|
+
targetId,
|
|
101
|
+
sourcePort:
|
|
102
|
+
sourceId === info.id
|
|
103
|
+
? reversed
|
|
104
|
+
? "input"
|
|
105
|
+
: "output"
|
|
106
|
+
: edge.sourceId === info.id
|
|
107
|
+
? undefined
|
|
108
|
+
: edge.sourcePort,
|
|
109
|
+
targetPort:
|
|
110
|
+
targetId === info.id
|
|
111
|
+
? reversed
|
|
112
|
+
? "output"
|
|
113
|
+
: "input"
|
|
114
|
+
: edge.targetId === info.id
|
|
115
|
+
? undefined
|
|
116
|
+
: edge.targetPort,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
widths[target] = Math.max(widths[target]!, info.width);
|
|
121
|
+
};
|
|
122
|
+
const validLayers = (info: Info) =>
|
|
123
|
+
info.chain.map((id) => layerByNodeId.get(id)!).sort((a, b) => a - b);
|
|
124
|
+
const widest = (valid: number[]) =>
|
|
125
|
+
valid.reduce((best, layer) => (widths[layer]! > widths[best]! ? layer : best), valid[0]!);
|
|
126
|
+
// ELK processes strategies independent of width before width-dependent strategies.
|
|
127
|
+
for (const strategy of [
|
|
128
|
+
"MEDIAN_LAYER",
|
|
129
|
+
"HEAD_LAYER",
|
|
130
|
+
"TAIL_LAYER",
|
|
131
|
+
"CENTER_LAYER",
|
|
132
|
+
"WIDEST_LAYER",
|
|
133
|
+
]) {
|
|
134
|
+
for (const info of infos.filter((info) => info.strategy === strategy)) {
|
|
135
|
+
const valid = validLayers(info);
|
|
136
|
+
let target = valid[Math.floor((valid.length - 1) / 2)]!;
|
|
137
|
+
if (strategy === "HEAD_LAYER" || strategy === "TAIL_LAYER") {
|
|
138
|
+
const right = (strategy === "HEAD_LAYER") !== info.reversed;
|
|
139
|
+
target = right ? valid.at(-1)! : valid[0]!;
|
|
140
|
+
alignments.set(info.id, right ? "RIGHT" : "LEFT");
|
|
141
|
+
} else if (strategy === "WIDEST_LAYER") target = widest(valid);
|
|
142
|
+
else if (strategy === "CENTER_LAYER") {
|
|
143
|
+
const spacing = Math.max(
|
|
144
|
+
Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10) * 2,
|
|
145
|
+
input.spacing.layer,
|
|
146
|
+
);
|
|
147
|
+
let sum = -spacing;
|
|
148
|
+
const sums = valid.map((layer) => {
|
|
149
|
+
sum += widths[layer]! + spacing;
|
|
150
|
+
return sum;
|
|
151
|
+
});
|
|
152
|
+
target = valid[sums.findIndex((value) => value >= sum / 2)]!;
|
|
153
|
+
}
|
|
154
|
+
assign(info, target);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const pending: Info[] = [];
|
|
158
|
+
for (const info of infos.filter((info) => info.strategy === "SPACE_EFFICIENT_LAYER")) {
|
|
159
|
+
const valid = validLayers(info),
|
|
160
|
+
wide = valid.find((layer) => widths[layer]! >= info.width);
|
|
161
|
+
if (valid.length === 1 || wide !== undefined) assign(info, wide ?? valid[0]!);
|
|
162
|
+
else pending.push(info);
|
|
163
|
+
}
|
|
164
|
+
pending.sort((a, b) => b.width - a.width);
|
|
165
|
+
for (const [index, info] of pending.entries()) {
|
|
166
|
+
const valid = validLayers(info);
|
|
167
|
+
let target = valid[0]!,
|
|
168
|
+
best = 0;
|
|
169
|
+
for (const layer of valid) {
|
|
170
|
+
if (widths[layer]! >= info.width) {
|
|
171
|
+
target = layer;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
let potential = widths[layer]!;
|
|
175
|
+
// Preserve ELK's last eligible pending-label selection and first-layer tie breaking.
|
|
176
|
+
for (const other of pending.slice(index + 1))
|
|
177
|
+
if (validLayers(other).includes(layer)) potential = Math.max(widths[layer]!, other.width);
|
|
178
|
+
if (potential > best) {
|
|
179
|
+
target = layer;
|
|
180
|
+
best = potential;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
assign(info, target);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
expansion: {
|
|
187
|
+
...expansion,
|
|
188
|
+
assignment: { ...expansion.assignment, layerByNodeId },
|
|
189
|
+
input: inheritCycleRandom(input, {
|
|
190
|
+
...input,
|
|
191
|
+
graph: { ...input.graph, edges },
|
|
192
|
+
nodeSettings: (node) => ({
|
|
193
|
+
...input.nodeSettings?.(node),
|
|
194
|
+
...(alignments.has(node.id) ? { alignment: alignments.get(node.id)! } : {}),
|
|
195
|
+
}),
|
|
196
|
+
}),
|
|
197
|
+
},
|
|
198
|
+
order: { ...order, layers },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inheritCycleRandom } from "./cycle-random";
|
|
1
2
|
import type { GraphEdge, GraphNode, Point } from "@statelyai/graph";
|
|
2
3
|
import type {
|
|
3
4
|
AcyclicOrientation,
|
|
@@ -14,6 +15,8 @@ export interface LongEdgeExpansion {
|
|
|
14
15
|
assignment: LayerAssignment;
|
|
15
16
|
segmentIdsByEdgeId: ReadonlyMap<string, readonly string[]>;
|
|
16
17
|
labelDummyIdByEdgeId: ReadonlyMap<string, string>;
|
|
18
|
+
/** Physical incoming adjacency after cycle reversal and long-edge retargeting. */
|
|
19
|
+
incomingEdgeOrderByNodeId?: ReadonlyMap<string, readonly string[]>;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
function uniqueDummyId(usedIds: Set<string>, edgeId: string, layer: number): string {
|
|
@@ -41,50 +44,15 @@ export function splitLongEdges(
|
|
|
41
44
|
const originalEdgeBySegmentId = new Map<string, GraphEdge>();
|
|
42
45
|
const usedNodeIds = new Set(nodes.map((node) => node.id));
|
|
43
46
|
const originalNodeIds = new Set(usedNodeIds);
|
|
44
|
-
const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
|
|
45
|
-
const forwardSourceSide =
|
|
46
|
-
input.direction === "right"
|
|
47
|
-
? "EAST"
|
|
48
|
-
: input.direction === "left"
|
|
49
|
-
? "WEST"
|
|
50
|
-
: input.direction === "down"
|
|
51
|
-
? "SOUTH"
|
|
52
|
-
: "NORTH";
|
|
53
|
-
const forwardTargetSide =
|
|
54
|
-
input.direction === "right"
|
|
55
|
-
? "WEST"
|
|
56
|
-
: input.direction === "left"
|
|
57
|
-
? "EAST"
|
|
58
|
-
: input.direction === "down"
|
|
59
|
-
? "NORTH"
|
|
60
|
-
: "SOUTH";
|
|
61
47
|
|
|
62
48
|
for (const edge of input.graph.edges) {
|
|
63
49
|
const sourceLayer = layerByNodeId.get(edge.sourceId) ?? 0;
|
|
64
50
|
const targetLayer = layerByNodeId.get(edge.targetId) ?? 0;
|
|
65
51
|
const span = Math.abs(targetLayer - sourceLayer);
|
|
66
|
-
const source = nodeById.get(edge.sourceId);
|
|
67
|
-
const target = nodeById.get(edge.targetId);
|
|
68
|
-
const sourcePort = source?.ports?.find((port) => port.name === edge.sourcePort);
|
|
69
|
-
const targetPort = target?.ports?.find((port) => port.name === edge.targetPort);
|
|
70
|
-
const sourceSide =
|
|
71
|
-
source && sourcePort ? input.portSettings?.(sourcePort, source)?.["port.side"] : undefined;
|
|
72
|
-
const targetSide =
|
|
73
|
-
target && targetPort ? input.portSettings?.(targetPort, target)?.["port.side"] : undefined;
|
|
74
|
-
const hasFixedPortSide = (node: GraphNode | undefined): boolean => {
|
|
75
|
-
if (!node) return false;
|
|
76
|
-
const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
|
|
77
|
-
return constraints !== "UNDEFINED" && constraints !== "FREE";
|
|
78
|
-
};
|
|
79
|
-
const fixedSideFeedback =
|
|
80
|
-
sourceLayer > targetLayer &&
|
|
81
|
-
((hasFixedPortSide(source) && sourceSide === forwardSourceSide) ||
|
|
82
|
-
(hasFixedPortSide(target) && targetSide === forwardTargetSide));
|
|
83
52
|
if (
|
|
84
53
|
span <= 1 ||
|
|
85
54
|
edge.sourceId === edge.targetId ||
|
|
86
|
-
(input.settings.feedbackEdges === true && orientation.reversedEdgeIds.has(edge.id))
|
|
87
|
-
fixedSideFeedback
|
|
55
|
+
(input.settings.feedbackEdges === true && orientation.reversedEdgeIds.has(edge.id))
|
|
88
56
|
) {
|
|
89
57
|
edges.push(edge);
|
|
90
58
|
originalEdgeBySegmentId.set(edge.id, edge);
|
|
@@ -175,10 +143,58 @@ export function splitLongEdges(
|
|
|
175
143
|
segmentIdsByEdgeId.set(edge.id, segmentIds);
|
|
176
144
|
}
|
|
177
145
|
|
|
146
|
+
const incomingEdgeOrderByNodeId = new Map<string, string[]>();
|
|
147
|
+
const orientedSource = (edge: GraphEdge) =>
|
|
148
|
+
orientation.reversedEdgeIds.has(edge.id) ? edge.targetId : edge.sourceId;
|
|
149
|
+
const orientedTarget = (edge: GraphEdge) =>
|
|
150
|
+
orientation.reversedEdgeIds.has(edge.id) ? edge.sourceId : edge.targetId;
|
|
151
|
+
const appendIncoming = (edge: GraphEdge) => {
|
|
152
|
+
const target = orientedTarget(edge);
|
|
153
|
+
incomingEdgeOrderByNodeId.set(target, [
|
|
154
|
+
...(incomingEdgeOrderByNodeId.get(target) ?? []),
|
|
155
|
+
edge.id,
|
|
156
|
+
]);
|
|
157
|
+
};
|
|
158
|
+
for (const edge of input.graph.edges)
|
|
159
|
+
if (!orientation.reversedEdgeIds.has(edge.id)) appendIncoming(edge);
|
|
160
|
+
for (const id of orientation.reversedEdgeIds) {
|
|
161
|
+
const edge = input.graph.edges.find((candidate) => candidate.id === id);
|
|
162
|
+
if (edge) appendIncoming(edge);
|
|
163
|
+
}
|
|
164
|
+
// Upstream walks source layers, nodes, then their existing ports. Splitting
|
|
165
|
+
// retargets the last chain segment, appending it to its physical target port.
|
|
166
|
+
const sourceNodes = [...input.graph.nodes].sort(
|
|
167
|
+
(a, b) => (assignment.layerByNodeId.get(a.id) ?? 0) - (assignment.layerByNodeId.get(b.id) ?? 0),
|
|
168
|
+
);
|
|
169
|
+
for (const node of sourceNodes) {
|
|
170
|
+
const outgoing = input.graph.edges.filter((edge) => orientedSource(edge) === node.id);
|
|
171
|
+
const portIndex = (edge: GraphEdge) =>
|
|
172
|
+
node.ports?.findIndex(
|
|
173
|
+
(port) =>
|
|
174
|
+
port.name ===
|
|
175
|
+
(orientation.reversedEdgeIds.has(edge.id) ? edge.targetPort : edge.sourcePort),
|
|
176
|
+
) ?? -1;
|
|
177
|
+
outgoing.sort(
|
|
178
|
+
(a, b) =>
|
|
179
|
+
portIndex(a) - portIndex(b) ||
|
|
180
|
+
Number(orientation.reversedEdgeIds.has(a.id)) -
|
|
181
|
+
Number(orientation.reversedEdgeIds.has(b.id)),
|
|
182
|
+
);
|
|
183
|
+
for (const edge of outgoing) {
|
|
184
|
+
if ((segmentIdsByEdgeId.get(edge.id)?.length ?? 0) < 2) continue;
|
|
185
|
+
const target = orientedTarget(edge);
|
|
186
|
+
incomingEdgeOrderByNodeId.set(
|
|
187
|
+
target,
|
|
188
|
+
incomingEdgeOrderByNodeId.get(target)!.filter((id) => id !== edge.id),
|
|
189
|
+
);
|
|
190
|
+
appendIncoming(edge);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
178
194
|
// ELK creates long-edge dummies while walking layers. Thus dummies for an
|
|
179
195
|
// edge whose source is in the next layer can precede later parts of an edge
|
|
180
196
|
// that started in an earlier layer. Preserve that order for crossing ties.
|
|
181
|
-
const maximumLayer = Math.max(0, ...layerByNodeId.values());
|
|
197
|
+
const maximumLayer = Math.max(0, (assignment.layerCount ?? 1) - 1, ...layerByNodeId.values());
|
|
182
198
|
const nodesByLayer = Array.from({ length: maximumLayer + 1 }, () => [] as string[]);
|
|
183
199
|
for (const node of input.graph.nodes) {
|
|
184
200
|
nodesByLayer[layerByNodeId.get(node.id) ?? 0]?.push(node.id);
|
|
@@ -223,10 +239,25 @@ export function splitLongEdges(
|
|
|
223
239
|
),
|
|
224
240
|
];
|
|
225
241
|
const graph = { ...input.graph, nodes: orderedNodes, edges } as LayeredPhaseInput["graph"];
|
|
242
|
+
const edgeIdByLabelDummyId = new Map<string, string>();
|
|
243
|
+
for (const [edgeId, id] of labelDummyIdByEdgeId)
|
|
244
|
+
if (!edgeIdByLabelDummyId.has(id)) edgeIdByLabelDummyId.set(id, edgeId);
|
|
245
|
+
const inputEdgeById = new Map<string, (typeof input.graph.edges)[number]>();
|
|
246
|
+
for (const edge of input.graph.edges)
|
|
247
|
+
if (!inputEdgeById.has(edge.id)) inputEdgeById.set(edge.id, edge);
|
|
226
248
|
return {
|
|
227
|
-
input: {
|
|
249
|
+
input: inheritCycleRandom(input, {
|
|
228
250
|
...input,
|
|
229
251
|
graph,
|
|
252
|
+
modelOrderByEdgeId: new Map(
|
|
253
|
+
edges.map((edge) => {
|
|
254
|
+
const original = originalEdgeBySegmentId.get(edge.id) ?? edge;
|
|
255
|
+
return [
|
|
256
|
+
edge.id,
|
|
257
|
+
input.modelOrderByEdgeId?.get(original.id) ?? input.graph.edges.indexOf(original),
|
|
258
|
+
];
|
|
259
|
+
}),
|
|
260
|
+
),
|
|
230
261
|
sizes,
|
|
231
262
|
edgeSettings: (edge) => {
|
|
232
263
|
const original = originalEdgeBySegmentId.get(edge.id) ?? edge;
|
|
@@ -234,9 +265,9 @@ export function splitLongEdges(
|
|
|
234
265
|
},
|
|
235
266
|
nodeSettings: (node) => {
|
|
236
267
|
const original = input.nodeSettings?.(node);
|
|
237
|
-
const edgeId =
|
|
268
|
+
const edgeId = edgeIdByLabelDummyId.get(node.id);
|
|
238
269
|
if (!edgeId) return original;
|
|
239
|
-
const edge =
|
|
270
|
+
const edge = inputEdgeById.get(edgeId);
|
|
240
271
|
const strategy = String(
|
|
241
272
|
(edge && input.edgeSettings?.(edge)?.["edgeLabels.centerLabelPlacementStrategy"]) ??
|
|
242
273
|
input.settings["edgeLabels.centerLabelPlacementStrategy"] ??
|
|
@@ -257,7 +288,8 @@ export function splitLongEdges(
|
|
|
257
288
|
: {}),
|
|
258
289
|
};
|
|
259
290
|
},
|
|
260
|
-
},
|
|
291
|
+
}),
|
|
292
|
+
incomingEdgeOrderByNodeId,
|
|
261
293
|
orientation: { reversedEdgeIds },
|
|
262
294
|
assignment: { ...assignment, layerByNodeId },
|
|
263
295
|
segmentIdsByEdgeId,
|
|
@@ -289,6 +321,9 @@ export function joinLongEdgeRoutes(
|
|
|
289
321
|
preserveInternalDuplicates = false,
|
|
290
322
|
convertLongSplines = false,
|
|
291
323
|
longSplineEdgeNodeSpacing = 10,
|
|
324
|
+
preserveOrthogonalCorners = false,
|
|
325
|
+
restoredJunctionCounts?: ReadonlyMap<string, number>,
|
|
326
|
+
reversedSegmentIds?: ReadonlySet<string>,
|
|
292
327
|
): EdgeRoutes {
|
|
293
328
|
const simplify = (points: readonly Point[]): Point[] => {
|
|
294
329
|
const result: Point[] = [];
|
|
@@ -381,7 +416,36 @@ export function joinLongEdgeRoutes(
|
|
|
381
416
|
}
|
|
382
417
|
}
|
|
383
418
|
const points: Point[] = [];
|
|
384
|
-
|
|
419
|
+
// ELK joins segment bend lists without their dummy anchors. Orthogonal
|
|
420
|
+
// compaction can collapse bends; those original bends still remain public.
|
|
421
|
+
if (!preserveInternalDuplicates && preserveOrthogonalCorners && segmentIds.length > 1) {
|
|
422
|
+
const joined = segmentIds.flatMap((segmentId, segmentIndex) => {
|
|
423
|
+
const segment = routes.pointsByEdgeId.get(segmentId) ?? [];
|
|
424
|
+
return segment.map((point, index) => ({
|
|
425
|
+
point,
|
|
426
|
+
dummyAnchor:
|
|
427
|
+
(index === 0 && segmentIndex !== 0) ||
|
|
428
|
+
(index === segment.length - 1 && segmentIndex !== segmentIds.length - 1),
|
|
429
|
+
}));
|
|
430
|
+
});
|
|
431
|
+
// Native dummy anchors may supply a real corner. Discard only redundant
|
|
432
|
+
// anchors; retain segment bends even when compaction made them collinear.
|
|
433
|
+
for (let index = 1; index + 1 < joined.length;) {
|
|
434
|
+
const first = joined[index - 1]!.point;
|
|
435
|
+
const middle = joined[index]!;
|
|
436
|
+
const last = joined[index + 1]!.point;
|
|
437
|
+
const equal = (a: number, b: number) => Math.abs(a - b) < 1e-9;
|
|
438
|
+
if (
|
|
439
|
+
middle.dummyAnchor &&
|
|
440
|
+
((equal(first.x, middle.point.x) && equal(middle.point.x, last.x)) ||
|
|
441
|
+
(equal(first.y, middle.point.y) && equal(middle.point.y, last.y)))
|
|
442
|
+
) {
|
|
443
|
+
joined.splice(index, 1);
|
|
444
|
+
index = Math.max(1, index - 1);
|
|
445
|
+
} else index++;
|
|
446
|
+
}
|
|
447
|
+
points.push(...joined.map(({ point }) => point));
|
|
448
|
+
} else if (!preserveInternalDuplicates && segmentIds.length > 1) {
|
|
385
449
|
const segments = segmentIds.map((segmentId) => routes.pointsByEdgeId.get(segmentId) ?? []);
|
|
386
450
|
const firstPoint = segments[0]?.[0];
|
|
387
451
|
if (firstPoint) points.push(firstPoint);
|
|
@@ -399,8 +463,35 @@ export function joinLongEdgeRoutes(
|
|
|
399
463
|
}
|
|
400
464
|
pointsByEdgeId.set(
|
|
401
465
|
edgeId,
|
|
402
|
-
preserveInternalDuplicates || segmentIds.length === 1
|
|
466
|
+
preserveInternalDuplicates || preserveOrthogonalCorners || segmentIds.length === 1
|
|
467
|
+
? points
|
|
468
|
+
: simplify(points),
|
|
403
469
|
);
|
|
404
470
|
}
|
|
405
|
-
|
|
471
|
+
const junctionPointsByEdgeId =
|
|
472
|
+
routes.junctionPointsByEdgeId &&
|
|
473
|
+
new Map(
|
|
474
|
+
[...segmentIdsByEdgeId].map(([edgeId, ids]) => [
|
|
475
|
+
edgeId,
|
|
476
|
+
// ELK restores north/south branches after long-edge joining. Keep
|
|
477
|
+
// those appended junctions after all original routing junctions.
|
|
478
|
+
[
|
|
479
|
+
// Long-edge joining runs before edge-direction restoration. Its
|
|
480
|
+
// junction chain retains physical flow order on reversed edges.
|
|
481
|
+
...(reversedSegmentIds?.has(ids[0]!) ? [...ids].reverse() : ids).flatMap((id) => {
|
|
482
|
+
const points = routes.junctionPointsByEdgeId?.get(id) ?? [];
|
|
483
|
+
return points.slice(0, points.length - (restoredJunctionCounts?.get(id) ?? 0));
|
|
484
|
+
}),
|
|
485
|
+
...ids.flatMap((id) => {
|
|
486
|
+
const count = restoredJunctionCounts?.get(id) ?? 0;
|
|
487
|
+
return count ? (routes.junctionPointsByEdgeId?.get(id) ?? []).slice(-count) : [];
|
|
488
|
+
}),
|
|
489
|
+
],
|
|
490
|
+
]),
|
|
491
|
+
);
|
|
492
|
+
return {
|
|
493
|
+
pointsByEdgeId,
|
|
494
|
+
outsideFeedbackEdgeIds,
|
|
495
|
+
...(junctionPointsByEdgeId ? { junctionPointsByEdgeId } : {}),
|
|
496
|
+
};
|
|
406
497
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
2
|
+
import type { LayeredPhaseInput, NodePlacement } from "./types";
|
|
3
|
+
import { getPortPoint } from "./strategies";
|
|
4
|
+
import {
|
|
5
|
+
fixedSelfLoopLabels,
|
|
6
|
+
fixedSelfLoopSide,
|
|
7
|
+
routeFixedSelfLoop,
|
|
8
|
+
selfLoopTracks,
|
|
9
|
+
} from "./fixed-self-loop";
|
|
10
|
+
|
|
11
|
+
export interface LoopEnvelope {
|
|
12
|
+
before: number;
|
|
13
|
+
after: number;
|
|
14
|
+
flowBefore: number;
|
|
15
|
+
flowAfter: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Unconstrained implicit loop ports are restored on loop faces after the sweep. */
|
|
19
|
+
export function hasMovableLoopPorts(input: LayeredPhaseInput, edge: GraphEdge): boolean {
|
|
20
|
+
if (
|
|
21
|
+
edge.sourceId !== edge.targetId ||
|
|
22
|
+
edge.sourcePort !== undefined ||
|
|
23
|
+
edge.targetPort !== undefined
|
|
24
|
+
)
|
|
25
|
+
return false;
|
|
26
|
+
const node = input.graph.nodes.find((candidate) => candidate.id === edge.sourceId);
|
|
27
|
+
const constraints = node && input.nodeSettings?.(node)?.portConstraints;
|
|
28
|
+
return constraints === undefined || constraints === "FREE" || constraints === "UNDEFINED";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, LoopEnvelope> {
|
|
32
|
+
const envelopes = new Map<string, LoopEnvelope>();
|
|
33
|
+
if ((input.settings.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL") return envelopes;
|
|
34
|
+
for (const node of input.graph.nodes) {
|
|
35
|
+
const allLoops = input.graph.edges.filter(
|
|
36
|
+
(edge) => edge.sourceId === node.id && edge.targetId === node.id,
|
|
37
|
+
);
|
|
38
|
+
const constraints = input.nodeSettings?.(node)?.portConstraints;
|
|
39
|
+
if (
|
|
40
|
+
allLoops.length &&
|
|
41
|
+
constraints &&
|
|
42
|
+
constraints !== "FREE" &&
|
|
43
|
+
constraints !== "UNDEFINED" &&
|
|
44
|
+
allLoops.every(
|
|
45
|
+
(edge) =>
|
|
46
|
+
edge.sourcePort !== undefined &&
|
|
47
|
+
edge.targetPort !== undefined &&
|
|
48
|
+
input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true &&
|
|
49
|
+
(((edge.width ?? 0) === 0 && (edge.height ?? 0) === 0) ||
|
|
50
|
+
(input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER") === "CENTER"),
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
const size = input.sizes.get(node.id) ?? { width: 0, height: 0 };
|
|
54
|
+
const rect = { x: 0, y: 0, ...size };
|
|
55
|
+
const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
|
|
56
|
+
const horizontal = input.direction === "right" || input.direction === "left";
|
|
57
|
+
const reverse = input.direction === "left" || input.direction === "up";
|
|
58
|
+
const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
|
|
59
|
+
let supported = true;
|
|
60
|
+
const tracks = selfLoopTracks(allLoops);
|
|
61
|
+
for (const edge of allLoops) {
|
|
62
|
+
const from = node.ports?.find((port) => port.name === edge.sourcePort);
|
|
63
|
+
const to = node.ports?.find((port) => port.name === edge.targetPort);
|
|
64
|
+
const sourceSide =
|
|
65
|
+
from && fixedSelfLoopSide(input.portSettings?.(from, node)?.["port.side"]);
|
|
66
|
+
const targetSide = to && fixedSelfLoopSide(input.portSettings?.(to, node)?.["port.side"]);
|
|
67
|
+
const labeled = (edge.width ?? 0) > 0 || (edge.height ?? 0) > 0;
|
|
68
|
+
if (!sourceSide || !targetSide || (labeled && sourceSide !== targetSide)) {
|
|
69
|
+
supported = false;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
const start = getPortPoint(
|
|
73
|
+
node,
|
|
74
|
+
edge.sourcePort,
|
|
75
|
+
rect,
|
|
76
|
+
{ x: 0, y: 0 },
|
|
77
|
+
input.direction,
|
|
78
|
+
input,
|
|
79
|
+
);
|
|
80
|
+
const end = getPortPoint(
|
|
81
|
+
node,
|
|
82
|
+
edge.targetPort,
|
|
83
|
+
rect,
|
|
84
|
+
{ x: 0, y: 0 },
|
|
85
|
+
input.direction,
|
|
86
|
+
input,
|
|
87
|
+
);
|
|
88
|
+
const route = routeFixedSelfLoop(
|
|
89
|
+
rect,
|
|
90
|
+
start,
|
|
91
|
+
end,
|
|
92
|
+
sourceSide,
|
|
93
|
+
targetSide,
|
|
94
|
+
spacing * (tracks.get(edge.id)! + 1),
|
|
95
|
+
input.direction,
|
|
96
|
+
);
|
|
97
|
+
if (labeled) {
|
|
98
|
+
const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
|
|
99
|
+
const { width = 0, height = 0 } = edge;
|
|
100
|
+
for (const label of fixedSelfLoopLabels(route, sourceSide, width, height, labelSpacing))
|
|
101
|
+
route.push(label, { x: label.x + width, y: label.y + height });
|
|
102
|
+
}
|
|
103
|
+
for (const point of route) {
|
|
104
|
+
const cross = horizontal ? point.y : point.x;
|
|
105
|
+
const flow = horizontal ? point.x : point.y;
|
|
106
|
+
const flowSize = horizontal ? size.width : size.height;
|
|
107
|
+
envelope.before = Math.max(envelope.before, -cross);
|
|
108
|
+
envelope.after = Math.max(
|
|
109
|
+
envelope.after,
|
|
110
|
+
cross - (horizontal ? size.height : size.width),
|
|
111
|
+
);
|
|
112
|
+
envelope.flowBefore = Math.max(envelope.flowBefore, reverse ? flow - flowSize : -flow);
|
|
113
|
+
envelope.flowAfter = Math.max(envelope.flowAfter, reverse ? -flow : flow - flowSize);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (supported) {
|
|
117
|
+
envelopes.set(node.id, envelope);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const loops = allLoops.filter((edge) => hasMovableLoopPorts(input, edge));
|
|
122
|
+
// Inline labels remain owned by the existing labeled-loop phase. Move
|
|
123
|
+
// the entire node together when that phase is ported, rather than mixing
|
|
124
|
+
// two different loop distributions and reservations on the same node.
|
|
125
|
+
if (
|
|
126
|
+
loops.length === 0 ||
|
|
127
|
+
loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
|
|
128
|
+
)
|
|
129
|
+
continue;
|
|
130
|
+
const settings = input.nodeSettings?.(node);
|
|
131
|
+
const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
|
|
132
|
+
const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
|
|
133
|
+
const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
|
|
134
|
+
const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
|
|
135
|
+
const sides =
|
|
136
|
+
distribution === "EQUALLY"
|
|
137
|
+
? (["before", "after", "flowAfter", "flowBefore"] as const)
|
|
138
|
+
: distribution === "NORTH_SOUTH"
|
|
139
|
+
? (["before", "after"] as const)
|
|
140
|
+
: (["before"] as const);
|
|
141
|
+
for (const side of sides) {
|
|
142
|
+
const selected = loops.filter((_, index) => sides[index % sides.length] === side);
|
|
143
|
+
const horizontal = input.direction === "right" || input.direction === "left";
|
|
144
|
+
const crossSize =
|
|
145
|
+
side === "before" || side === "after"
|
|
146
|
+
? horizontal
|
|
147
|
+
? "height"
|
|
148
|
+
: "width"
|
|
149
|
+
: horizontal
|
|
150
|
+
? "width"
|
|
151
|
+
: "height";
|
|
152
|
+
const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
|
|
153
|
+
envelope[side] =
|
|
154
|
+
spacing * (ordering === "SEQUENCED" ? Math.min(1, selected.length) : selected.length) +
|
|
155
|
+
selected.reduce(
|
|
156
|
+
(sum, edge) =>
|
|
157
|
+
sum + ((edge[crossSize] ?? 0) > 0 ? (edge[crossSize] ?? 0) + labelSpacing : 0),
|
|
158
|
+
0,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
envelopes.set(node.id, envelope);
|
|
162
|
+
}
|
|
163
|
+
return envelopes;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const reservedByPlacement = new WeakMap<NodePlacement, ReadonlySet<string>>();
|
|
167
|
+
export function recordLoopEnvelopes(placement: NodePlacement, ids: ReadonlySet<string>): void {
|
|
168
|
+
reservedByPlacement.set(placement, ids);
|
|
169
|
+
}
|
|
170
|
+
export function hasPlacementLoopEnvelope(placement: NodePlacement, id: string): boolean {
|
|
171
|
+
return reservedByPlacement.get(placement)?.has(id) ?? false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const preparedByInput = new WeakMap<LayeredPhaseInput, ReadonlyMap<string, LoopEnvelope>>();
|
|
175
|
+
export function prepareLoopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, LoopEnvelope> {
|
|
176
|
+
const envelopes = loopEnvelopes(input);
|
|
177
|
+
preparedByInput.set(input, envelopes);
|
|
178
|
+
return envelopes;
|
|
179
|
+
}
|
|
180
|
+
export function preparedLoopEnvelopes(
|
|
181
|
+
input: LayeredPhaseInput,
|
|
182
|
+
): ReadonlyMap<string, LoopEnvelope> | undefined {
|
|
183
|
+
return preparedByInput.get(input);
|
|
184
|
+
}
|