@statelyai/layout 0.0.0 → 0.0.2
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 +9 -1
- package/README.md +50 -25
- package/dist/elkjs/index.d.mts +3 -0
- package/dist/elkjs/index.mjs +904 -97
- package/dist/index-8xYkohbz.d.mts +1212 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/layered-QwJn2gb2.mjs +8978 -0
- package/dist/{spore-cihb_Aht.mjs → spore-D15xIQKj.mjs} +1 -31
- package/package.json +59 -26
- package/src/box.ts +135 -0
- package/src/elkjs/index.ts +1825 -0
- package/src/elkjs/types.ts +103 -0
- package/src/errors.ts +16 -0
- package/src/fixed.ts +80 -0
- package/src/index.ts +92 -0
- package/src/java-random.ts +46 -0
- package/src/layered/bk-node-placement.ts +715 -0
- package/src/layered/elk-enum-values.ts +171 -0
- package/src/layered/elk-options.generated.ts +315 -0
- package/src/layered/elk-options.ts +98 -0
- package/src/layered/flexible-ports.ts +11 -0
- package/src/layered/high-degree.ts +127 -0
- package/src/layered/index.ts +2138 -0
- package/src/layered/layer-unzipping.ts +217 -0
- package/src/layered/linear-segments-node-placement.ts +447 -0
- package/src/layered/long-edges.ts +405 -0
- package/src/layered/min-width.ts +159 -0
- package/src/layered/multi-edge-wrapping.ts +460 -0
- package/src/layered/network-simplex-node-placement.ts +500 -0
- package/src/layered/network-simplex.ts +346 -0
- package/src/layered/node-promotion.ts +197 -0
- package/src/layered/spacing.ts +37 -0
- package/src/layered/spline-bezier.ts +102 -0
- package/src/layered/strategies.ts +5343 -0
- package/src/layered/stretch-width.ts +136 -0
- package/src/layered/types.ts +111 -0
- package/src/layout.ts +202 -0
- package/src/packing.ts +74 -0
- package/src/random.ts +142 -0
- package/src/spore.ts +103 -0
- package/src/types.ts +84 -0
- package/dist/index-v0P1Ake8.d.mts +0 -149
- package/dist/layered-ByNCZQgJ.mjs +0 -439
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2010, 2020 Kiel University and others.
|
|
3
|
+
*
|
|
4
|
+
* Translated from ELK v0.11.0 NetworkSimplexLayerer.java and NetworkSimplex.java.
|
|
5
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
6
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
7
|
+
*******************************************************************************/
|
|
8
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
9
|
+
import type { AcyclicOrientation, LayerAssigner, LayeredPhaseInput } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface SimplexNode {
|
|
12
|
+
id: string;
|
|
13
|
+
order: number;
|
|
14
|
+
layer: number;
|
|
15
|
+
incoming: SimplexEdge[];
|
|
16
|
+
outgoing: SimplexEdge[];
|
|
17
|
+
treeNode: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SimplexEdge {
|
|
21
|
+
id: string;
|
|
22
|
+
order: number;
|
|
23
|
+
source: SimplexNode;
|
|
24
|
+
target: SimplexNode;
|
|
25
|
+
weight: number;
|
|
26
|
+
delta: number;
|
|
27
|
+
treeEdge: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function connectedEdges(node: SimplexNode): SimplexEdge[] {
|
|
31
|
+
return [...node.incoming, ...node.outgoing];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function otherNode(edge: SimplexEdge, node: SimplexNode): SimplexNode {
|
|
35
|
+
return edge.source === node ? edge.target : edge.source;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getOrientedEndpoints(
|
|
39
|
+
edge: GraphEdge,
|
|
40
|
+
orientation: AcyclicOrientation,
|
|
41
|
+
): readonly [string, string] {
|
|
42
|
+
return orientation.reversedEdgeIds.has(edge.id)
|
|
43
|
+
? [edge.targetId, edge.sourceId]
|
|
44
|
+
: [edge.sourceId, edge.targetId];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getComponents(input: LayeredPhaseInput, orientation: AcyclicOrientation): string[][] {
|
|
48
|
+
const adjacent = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
|
|
49
|
+
for (const edge of input.graph.edges) {
|
|
50
|
+
const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
|
|
51
|
+
if (sourceId === targetId) continue;
|
|
52
|
+
adjacent.get(sourceId)?.push(targetId);
|
|
53
|
+
adjacent.get(targetId)?.push(sourceId);
|
|
54
|
+
}
|
|
55
|
+
const visited = new Set<string>();
|
|
56
|
+
const components: string[][] = [];
|
|
57
|
+
for (const node of input.graph.nodes) {
|
|
58
|
+
if (visited.has(node.id)) continue;
|
|
59
|
+
const component: string[] = [];
|
|
60
|
+
visited.add(node.id);
|
|
61
|
+
const stack: Array<{ id: string; index: number }> = [{ id: node.id, index: 0 }];
|
|
62
|
+
component.push(node.id);
|
|
63
|
+
while (stack.length > 0) {
|
|
64
|
+
const frame = stack.at(-1);
|
|
65
|
+
if (!frame) break;
|
|
66
|
+
const nextId = adjacent.get(frame.id)?.[frame.index++];
|
|
67
|
+
if (nextId === undefined) {
|
|
68
|
+
stack.pop();
|
|
69
|
+
} else if (!visited.has(nextId)) {
|
|
70
|
+
visited.add(nextId);
|
|
71
|
+
component.push(nextId);
|
|
72
|
+
stack.push({ id: nextId, index: 0 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (components.length === 0 || component.length > (components[0]?.length ?? 0)) {
|
|
76
|
+
components.unshift(component);
|
|
77
|
+
} else {
|
|
78
|
+
components.push(component);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return components;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createSimplexGraph(
|
|
85
|
+
input: LayeredPhaseInput,
|
|
86
|
+
orientation: AcyclicOrientation,
|
|
87
|
+
component: readonly string[],
|
|
88
|
+
): { nodes: SimplexNode[]; edges: SimplexEdge[] } {
|
|
89
|
+
const memberIds = new Set(component);
|
|
90
|
+
const nodes = component.map((id, order): SimplexNode => ({
|
|
91
|
+
id,
|
|
92
|
+
order,
|
|
93
|
+
layer: 0,
|
|
94
|
+
incoming: [],
|
|
95
|
+
outgoing: [],
|
|
96
|
+
treeNode: false,
|
|
97
|
+
}));
|
|
98
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
99
|
+
const edgeBySource = new Map(component.map((id) => [id, [] as GraphEdge[]]));
|
|
100
|
+
for (const edge of input.graph.edges) {
|
|
101
|
+
const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
|
|
102
|
+
if (sourceId !== targetId && memberIds.has(sourceId) && memberIds.has(targetId)) {
|
|
103
|
+
edgeBySource.get(sourceId)?.push(edge);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const edges: SimplexEdge[] = [];
|
|
107
|
+
for (const sourceId of component) {
|
|
108
|
+
for (const graphEdge of edgeBySource.get(sourceId) ?? []) {
|
|
109
|
+
const [orientedSourceId, targetId] = getOrientedEndpoints(graphEdge, orientation);
|
|
110
|
+
const source = nodeById.get(orientedSourceId);
|
|
111
|
+
const target = nodeById.get(targetId);
|
|
112
|
+
if (!source || !target) continue;
|
|
113
|
+
const edge: SimplexEdge = {
|
|
114
|
+
id: graphEdge.id,
|
|
115
|
+
order: edges.length,
|
|
116
|
+
source,
|
|
117
|
+
target,
|
|
118
|
+
weight: Math.max(1, input.edgeSettings?.(graphEdge)?.["priority.shortness"] ?? 1),
|
|
119
|
+
delta: 1,
|
|
120
|
+
treeEdge: false,
|
|
121
|
+
};
|
|
122
|
+
edges.push(edge);
|
|
123
|
+
source.outgoing.push(edge);
|
|
124
|
+
target.incoming.push(edge);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { nodes, edges };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assignInitialLayers(nodes: readonly SimplexNode[]): void {
|
|
131
|
+
const remainingIncoming = new Map(nodes.map((node) => [node, node.incoming.length]));
|
|
132
|
+
const queue = nodes.filter((node) => node.incoming.length === 0);
|
|
133
|
+
while (queue.length > 0) {
|
|
134
|
+
const node = queue.shift();
|
|
135
|
+
if (!node) continue;
|
|
136
|
+
for (const edge of node.outgoing) {
|
|
137
|
+
edge.target.layer = Math.max(edge.target.layer, node.layer + edge.delta);
|
|
138
|
+
const remaining = (remainingIncoming.get(edge.target) ?? 1) - 1;
|
|
139
|
+
remainingIncoming.set(edge.target, remaining);
|
|
140
|
+
if (remaining === 0) queue.push(edge.target);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function growTightTree(
|
|
146
|
+
nodes: readonly SimplexNode[],
|
|
147
|
+
edges: readonly SimplexEdge[],
|
|
148
|
+
): SimplexEdge[] {
|
|
149
|
+
if (nodes.length === 0) return [];
|
|
150
|
+
const treeEdges: SimplexEdge[] = [];
|
|
151
|
+
const visitTight = (start: SimplexNode): number => {
|
|
152
|
+
const visitedEdges = new Set<SimplexEdge>();
|
|
153
|
+
const visit = (node: SimplexNode): number => {
|
|
154
|
+
let count = 1;
|
|
155
|
+
node.treeNode = true;
|
|
156
|
+
for (const edge of connectedEdges(node)) {
|
|
157
|
+
if (visitedEdges.has(edge)) continue;
|
|
158
|
+
visitedEdges.add(edge);
|
|
159
|
+
const opposite = otherNode(edge, node);
|
|
160
|
+
if (edge.treeEdge) {
|
|
161
|
+
count += visit(opposite);
|
|
162
|
+
} else if (!opposite.treeNode && edge.target.layer - edge.source.layer === edge.delta) {
|
|
163
|
+
edge.treeEdge = true;
|
|
164
|
+
treeEdges.push(edge);
|
|
165
|
+
count += visit(opposite);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return count;
|
|
169
|
+
};
|
|
170
|
+
return visit(start);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
while (visitTight(nodes[0] as SimplexNode) < nodes.length) {
|
|
174
|
+
let minimumSlack = Number.POSITIVE_INFINITY;
|
|
175
|
+
let minimumEdge: SimplexEdge | undefined;
|
|
176
|
+
for (const edge of edges) {
|
|
177
|
+
if (edge.source.treeNode === edge.target.treeNode) continue;
|
|
178
|
+
const slack = edge.target.layer - edge.source.layer - edge.delta;
|
|
179
|
+
if (slack < minimumSlack) {
|
|
180
|
+
minimumSlack = slack;
|
|
181
|
+
minimumEdge = edge;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!minimumEdge) throw new Error("Network simplex could not grow a tight tree");
|
|
185
|
+
const shift = minimumEdge.target.treeNode ? -minimumSlack : minimumSlack;
|
|
186
|
+
for (const node of nodes) if (node.treeNode) node.layer += shift;
|
|
187
|
+
}
|
|
188
|
+
return treeEdges;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getHeadComponent(
|
|
192
|
+
nodes: readonly SimplexNode[],
|
|
193
|
+
treeEdges: ReadonlySet<SimplexEdge>,
|
|
194
|
+
removed: SimplexEdge,
|
|
195
|
+
): Set<SimplexNode> {
|
|
196
|
+
const head = new Set<SimplexNode>([removed.target]);
|
|
197
|
+
const stack = [removed.target];
|
|
198
|
+
while (stack.length > 0) {
|
|
199
|
+
const node = stack.pop();
|
|
200
|
+
if (!node) continue;
|
|
201
|
+
for (const edge of connectedEdges(node)) {
|
|
202
|
+
if (edge === removed || !treeEdges.has(edge)) continue;
|
|
203
|
+
const opposite = otherNode(edge, node);
|
|
204
|
+
if (!head.has(opposite)) {
|
|
205
|
+
head.add(opposite);
|
|
206
|
+
stack.push(opposite);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!head.has(removed.source)) return head;
|
|
211
|
+
const complement = new Set(nodes.filter((node) => !head.has(node)));
|
|
212
|
+
return complement;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getCutValue(
|
|
216
|
+
nodes: readonly SimplexNode[],
|
|
217
|
+
edges: readonly SimplexEdge[],
|
|
218
|
+
treeEdges: ReadonlySet<SimplexEdge>,
|
|
219
|
+
edge: SimplexEdge,
|
|
220
|
+
): number {
|
|
221
|
+
const head = getHeadComponent(nodes, treeEdges, edge);
|
|
222
|
+
let value = 0;
|
|
223
|
+
for (const candidate of edges) {
|
|
224
|
+
const sourceInHead = head.has(candidate.source);
|
|
225
|
+
const targetInHead = head.has(candidate.target);
|
|
226
|
+
if (!sourceInHead && targetInHead) value += candidate.weight;
|
|
227
|
+
else if (sourceInHead && !targetInHead) value -= candidate.weight;
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeAndBalance(
|
|
233
|
+
nodes: readonly SimplexNode[],
|
|
234
|
+
previousLayerCounts: readonly number[] | undefined,
|
|
235
|
+
): number[] {
|
|
236
|
+
const lowest = Math.min(...nodes.map((node) => node.layer));
|
|
237
|
+
const highest = Math.max(...nodes.map((node) => node.layer));
|
|
238
|
+
const filling = Array.from({ length: highest - lowest + 1 }, () => 0);
|
|
239
|
+
for (const node of nodes) {
|
|
240
|
+
node.layer -= lowest;
|
|
241
|
+
filling[node.layer] = (filling[node.layer] ?? 0) + 1;
|
|
242
|
+
}
|
|
243
|
+
for (const [layer, count] of previousLayerCounts?.entries() ?? []) {
|
|
244
|
+
if (layer >= filling.length) break;
|
|
245
|
+
filling[layer] = (filling[layer] ?? 0) + count;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const node of nodes) {
|
|
249
|
+
if (node.incoming.length !== node.outgoing.length) continue;
|
|
250
|
+
const minimumIncoming =
|
|
251
|
+
node.incoming.length === 0
|
|
252
|
+
? -1
|
|
253
|
+
: Math.min(...node.incoming.map((edge) => edge.target.layer - edge.source.layer));
|
|
254
|
+
const minimumOutgoing =
|
|
255
|
+
node.outgoing.length === 0
|
|
256
|
+
? -1
|
|
257
|
+
: Math.min(...node.outgoing.map((edge) => edge.target.layer - edge.source.layer));
|
|
258
|
+
let newLayer = node.layer;
|
|
259
|
+
for (
|
|
260
|
+
let layer = node.layer - minimumIncoming + 1;
|
|
261
|
+
layer < node.layer + minimumOutgoing;
|
|
262
|
+
layer++
|
|
263
|
+
) {
|
|
264
|
+
if ((filling[layer] ?? Infinity) < (filling[newLayer] ?? Infinity)) newLayer = layer;
|
|
265
|
+
}
|
|
266
|
+
if ((filling[newLayer] ?? Infinity) < (filling[node.layer] ?? Infinity)) {
|
|
267
|
+
filling[node.layer] = (filling[node.layer] ?? 1) - 1;
|
|
268
|
+
filling[newLayer] = (filling[newLayer] ?? 0) + 1;
|
|
269
|
+
node.layer = newLayer;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return filling;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function runNetworkSimplex(
|
|
276
|
+
nodes: SimplexNode[],
|
|
277
|
+
_edges: SimplexEdge[],
|
|
278
|
+
iterationLimit: number,
|
|
279
|
+
previousLayerCounts: readonly number[] | undefined,
|
|
280
|
+
balance = true,
|
|
281
|
+
): number[] {
|
|
282
|
+
// ELK reindexes edges by walking each auxiliary node's outgoing list.
|
|
283
|
+
// This order decides deterministic ties in tight-tree growth and pivots.
|
|
284
|
+
const orderedEdges = nodes.flatMap((node) => node.outgoing);
|
|
285
|
+
orderedEdges.forEach((edge, index) => (edge.order = index));
|
|
286
|
+
assignInitialLayers(nodes);
|
|
287
|
+
if (orderedEdges.length > 0) {
|
|
288
|
+
const orderedTreeEdges = growTightTree(nodes, orderedEdges);
|
|
289
|
+
const treeEdges = new Set(orderedTreeEdges);
|
|
290
|
+
for (let iteration = 0; iteration < iterationLimit; iteration++) {
|
|
291
|
+
const leave = orderedTreeEdges.find(
|
|
292
|
+
(edge) => edge.treeEdge && getCutValue(nodes, orderedEdges, treeEdges, edge) < -1e-10,
|
|
293
|
+
);
|
|
294
|
+
if (!leave) break;
|
|
295
|
+
const head = getHeadComponent(nodes, treeEdges, leave);
|
|
296
|
+
let entering: SimplexEdge | undefined;
|
|
297
|
+
let minimumSlack = Number.POSITIVE_INFINITY;
|
|
298
|
+
for (const edge of orderedEdges) {
|
|
299
|
+
if (head.has(edge.source) && !head.has(edge.target)) {
|
|
300
|
+
const slack = edge.target.layer - edge.source.layer - edge.delta;
|
|
301
|
+
if (slack < minimumSlack) {
|
|
302
|
+
minimumSlack = slack;
|
|
303
|
+
entering = edge;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!entering) break;
|
|
308
|
+
leave.treeEdge = false;
|
|
309
|
+
treeEdges.delete(leave);
|
|
310
|
+
entering.treeEdge = true;
|
|
311
|
+
treeEdges.add(entering);
|
|
312
|
+
orderedTreeEdges.splice(orderedTreeEdges.indexOf(leave), 1);
|
|
313
|
+
orderedTreeEdges.push(entering);
|
|
314
|
+
let delta = entering.target.layer - entering.source.layer - entering.delta;
|
|
315
|
+
if (!head.has(entering.target)) delta = -delta;
|
|
316
|
+
for (const node of nodes) if (!head.has(node)) node.layer += delta;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (balance) return normalizeAndBalance(nodes, previousLayerCounts);
|
|
320
|
+
const lowest = Math.min(...nodes.map((node) => node.layer));
|
|
321
|
+
const highest = Math.max(...nodes.map((node) => node.layer));
|
|
322
|
+
const filling = Array.from({ length: highest - lowest + 1 }, () => 0);
|
|
323
|
+
for (const node of nodes) {
|
|
324
|
+
node.layer -= lowest;
|
|
325
|
+
filling[node.layer] = (filling[node.layer] ?? 0) + 1;
|
|
326
|
+
}
|
|
327
|
+
return filling;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const assignLayersWithNetworkSimplex: LayerAssigner = (input, orientation) => {
|
|
331
|
+
const components = getComponents(input, orientation);
|
|
332
|
+
const layerByNodeId = new Map<string, number>();
|
|
333
|
+
let previousLayerCounts: number[] | undefined;
|
|
334
|
+
const thoroughness = input.settings.thoroughness ?? 7;
|
|
335
|
+
for (const component of components) {
|
|
336
|
+
const { nodes, edges } = createSimplexGraph(input, orientation, component);
|
|
337
|
+
previousLayerCounts = runNetworkSimplex(
|
|
338
|
+
nodes,
|
|
339
|
+
edges,
|
|
340
|
+
thoroughness * 4 * Math.floor(Math.sqrt(nodes.length)),
|
|
341
|
+
previousLayerCounts,
|
|
342
|
+
);
|
|
343
|
+
for (const node of nodes) layerByNodeId.set(node.id, node.layer);
|
|
344
|
+
}
|
|
345
|
+
return { layerByNodeId };
|
|
346
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
2
|
+
import type { AcyclicOrientation, LayerAssignment, LayeredPhaseInput } from "./types";
|
|
3
|
+
|
|
4
|
+
function endpoints(edge: GraphEdge, orientation: AcyclicOrientation): readonly [string, string] {
|
|
5
|
+
return orientation.reversedEdgeIds.has(edge.id)
|
|
6
|
+
? [edge.targetId, edge.sourceId]
|
|
7
|
+
: [edge.sourceId, edge.targetId];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function dummyCount(
|
|
11
|
+
input: LayeredPhaseInput,
|
|
12
|
+
orientation: AcyclicOrientation,
|
|
13
|
+
ranks: ReadonlyMap<string, number>,
|
|
14
|
+
): number {
|
|
15
|
+
return input.graph.edges.reduce((total, edge) => {
|
|
16
|
+
const [source, target] = endpoints(edge, orientation);
|
|
17
|
+
return total + Math.max(0, (ranks.get(target) ?? 0) - (ranks.get(source) ?? 0) - 1);
|
|
18
|
+
}, 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function layerWidths(
|
|
22
|
+
input: LayeredPhaseInput,
|
|
23
|
+
orientation: AcyclicOrientation,
|
|
24
|
+
ranks: ReadonlyMap<string, number>,
|
|
25
|
+
pixels: boolean,
|
|
26
|
+
): number[] {
|
|
27
|
+
const count = Math.max(0, ...ranks.values()) + 1;
|
|
28
|
+
const result = Array.from({ length: count }, () => 0);
|
|
29
|
+
for (const node of input.graph.nodes) {
|
|
30
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
31
|
+
result[rank] =
|
|
32
|
+
(result[rank] ?? 0) +
|
|
33
|
+
(pixels
|
|
34
|
+
? ((input.direction === "left" || input.direction === "right"
|
|
35
|
+
? input.sizes.get(node.id)?.height
|
|
36
|
+
: input.sizes.get(node.id)?.width) ?? 0) + input.spacing.node
|
|
37
|
+
: 1);
|
|
38
|
+
}
|
|
39
|
+
for (const edge of input.graph.edges) {
|
|
40
|
+
const [source, target] = endpoints(edge, orientation);
|
|
41
|
+
for (let rank = (ranks.get(source) ?? 0) + 1; rank < (ranks.get(target) ?? 0); rank++) {
|
|
42
|
+
result[rank] =
|
|
43
|
+
(result[rank] ?? 0) +
|
|
44
|
+
(pixels ? Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10) : 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalize(ranks: Map<string, number>): void {
|
|
51
|
+
const used = [...new Set(ranks.values())].sort((a, b) => a - b);
|
|
52
|
+
const normalized = new Map(used.map((rank, index) => [rank, index]));
|
|
53
|
+
for (const [id, rank] of ranks) ranks.set(id, normalized.get(rank) ?? rank);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Nikolov-style recursive promotion after phase-2 layering. */
|
|
57
|
+
export function applyNodePromotion(
|
|
58
|
+
input: LayeredPhaseInput,
|
|
59
|
+
orientation: AcyclicOrientation,
|
|
60
|
+
assignment: LayerAssignment,
|
|
61
|
+
): LayerAssignment {
|
|
62
|
+
const strategy = String(input.settings["layering.nodePromotion.strategy"] ?? "NONE");
|
|
63
|
+
if (strategy === "NONE") return assignment;
|
|
64
|
+
if (strategy === "MODEL_ORDER_LEFT_TO_RIGHT" || strategy === "MODEL_ORDER_RIGHT_TO_LEFT") {
|
|
65
|
+
return applyModelOrderPromotion(
|
|
66
|
+
input,
|
|
67
|
+
orientation,
|
|
68
|
+
assignment,
|
|
69
|
+
strategy.endsWith("LEFT_TO_RIGHT"),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
let ranks = new Map(assignment.layerByNodeId);
|
|
73
|
+
normalize(ranks);
|
|
74
|
+
const baselineCountWidth = Math.max(...layerWidths(input, orientation, ranks, false));
|
|
75
|
+
const baselinePixelWidth = Math.max(...layerWidths(input, orientation, ranks, true));
|
|
76
|
+
const initialDummyCount = dummyCount(input, orientation, ranks);
|
|
77
|
+
const incoming = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
|
|
78
|
+
for (const edge of input.graph.edges) {
|
|
79
|
+
const [source, target] = endpoints(edge, orientation);
|
|
80
|
+
incoming.get(target)?.push(source);
|
|
81
|
+
}
|
|
82
|
+
const maxIterations = Number(input.settings["layering.nodePromotion.maxIterations"] ?? 0);
|
|
83
|
+
const percentageLimit =
|
|
84
|
+
strategy === "NODECOUNT_PERCENTAGE"
|
|
85
|
+
? Math.ceil((input.graph.nodes.length * maxIterations) / 100)
|
|
86
|
+
: strategy === "DUMMYNODE_PERCENTAGE"
|
|
87
|
+
? Math.ceil((initialDummyCount * maxIterations) / 100)
|
|
88
|
+
: Number.POSITIVE_INFINITY;
|
|
89
|
+
let iterations = 0;
|
|
90
|
+
let reduced = 0;
|
|
91
|
+
let changed: boolean;
|
|
92
|
+
do {
|
|
93
|
+
changed = false;
|
|
94
|
+
for (const node of input.graph.nodes) {
|
|
95
|
+
if ((incoming.get(node.id)?.length ?? 0) === 0) continue;
|
|
96
|
+
const candidate = new Map(ranks);
|
|
97
|
+
const visiting = new Set<string>();
|
|
98
|
+
const promote = (id: string): boolean => {
|
|
99
|
+
if (visiting.has(id)) return false;
|
|
100
|
+
visiting.add(id);
|
|
101
|
+
const next = (candidate.get(id) ?? 0) - 1;
|
|
102
|
+
if (next < 0) return false;
|
|
103
|
+
candidate.set(id, next);
|
|
104
|
+
for (const predecessor of incoming.get(id) ?? []) {
|
|
105
|
+
if ((candidate.get(predecessor) ?? 0) >= next && !promote(predecessor)) return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
const before = dummyCount(input, orientation, ranks);
|
|
110
|
+
if (!promote(node.id)) continue;
|
|
111
|
+
normalize(candidate);
|
|
112
|
+
const after = dummyCount(input, orientation, candidate);
|
|
113
|
+
if (after >= before) continue;
|
|
114
|
+
const respectsBoundary =
|
|
115
|
+
strategy === "NIKOLOV"
|
|
116
|
+
? Math.max(...layerWidths(input, orientation, candidate, false)) <= baselineCountWidth
|
|
117
|
+
: strategy === "NIKOLOV_PIXEL"
|
|
118
|
+
? Math.max(...layerWidths(input, orientation, candidate, true)) <= baselinePixelWidth
|
|
119
|
+
: true;
|
|
120
|
+
if (!respectsBoundary) continue;
|
|
121
|
+
ranks = candidate;
|
|
122
|
+
reduced += before - after;
|
|
123
|
+
changed = true;
|
|
124
|
+
}
|
|
125
|
+
iterations++;
|
|
126
|
+
const belowBoundary =
|
|
127
|
+
strategy === "NODECOUNT_PERCENTAGE"
|
|
128
|
+
? iterations < percentageLimit
|
|
129
|
+
: strategy === "DUMMYNODE_PERCENTAGE"
|
|
130
|
+
? reduced < percentageLimit
|
|
131
|
+
: true;
|
|
132
|
+
if (!belowBoundary) break;
|
|
133
|
+
} while (changed && iterations <= input.graph.nodes.length * 2);
|
|
134
|
+
return { layerByNodeId: ranks };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function applyModelOrderPromotion(
|
|
138
|
+
input: LayeredPhaseInput,
|
|
139
|
+
orientation: AcyclicOrientation,
|
|
140
|
+
assignment: LayerAssignment,
|
|
141
|
+
leftToRight: boolean,
|
|
142
|
+
): LayerAssignment {
|
|
143
|
+
const ranks = new Map(assignment.layerByNodeId);
|
|
144
|
+
const order = new Map(input.graph.nodes.map((node, index) => [node.id, index]));
|
|
145
|
+
const edges = input.graph.edges.map((edge) => endpoints(edge, orientation));
|
|
146
|
+
if (
|
|
147
|
+
leftToRight &&
|
|
148
|
+
(input.settings["layering.strategy"] ?? "NETWORK_SIMPLEX") === "NETWORK_SIMPLEX"
|
|
149
|
+
) {
|
|
150
|
+
for (const [source, target] of edges) {
|
|
151
|
+
if ((order.get(source) ?? 0) <= (order.get(target) ?? 0)) continue;
|
|
152
|
+
ranks.set(target, ranks.get(source) ?? 0);
|
|
153
|
+
}
|
|
154
|
+
normalize(ranks);
|
|
155
|
+
return { layerByNodeId: ranks };
|
|
156
|
+
}
|
|
157
|
+
let changed = true;
|
|
158
|
+
while (changed) {
|
|
159
|
+
changed = false;
|
|
160
|
+
for (const node of leftToRight ? input.graph.nodes : [...input.graph.nodes].reverse()) {
|
|
161
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
162
|
+
const next = rank + (leftToRight ? 1 : -1);
|
|
163
|
+
if (next < 0) continue;
|
|
164
|
+
const peers = input.graph.nodes.filter(
|
|
165
|
+
(candidate) => (ranks.get(candidate.id) ?? 0) === rank,
|
|
166
|
+
);
|
|
167
|
+
const allows = peers.every((peer) =>
|
|
168
|
+
leftToRight
|
|
169
|
+
? (order.get(peer.id) ?? 0) <= (order.get(node.id) ?? 0)
|
|
170
|
+
: (order.get(peer.id) ?? 0) >= (order.get(node.id) ?? 0),
|
|
171
|
+
);
|
|
172
|
+
if (!allows) continue;
|
|
173
|
+
const nextPeers = input.graph.nodes.filter(
|
|
174
|
+
(candidate) => (ranks.get(candidate.id) ?? 0) === next,
|
|
175
|
+
);
|
|
176
|
+
if (
|
|
177
|
+
!nextPeers.some((peer) =>
|
|
178
|
+
leftToRight
|
|
179
|
+
? (order.get(peer.id) ?? 0) < (order.get(node.id) ?? 0)
|
|
180
|
+
: (order.get(peer.id) ?? 0) > (order.get(node.id) ?? 0),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
continue;
|
|
184
|
+
const candidate = new Map(ranks).set(node.id, next);
|
|
185
|
+
if (
|
|
186
|
+
edges.every(
|
|
187
|
+
([source, target]) => (candidate.get(source) ?? 0) < (candidate.get(target) ?? 0),
|
|
188
|
+
)
|
|
189
|
+
) {
|
|
190
|
+
ranks.set(node.id, next);
|
|
191
|
+
changed = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
normalize(ranks);
|
|
196
|
+
return { layerByNodeId: ranks };
|
|
197
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LayeredPhaseInput } from "./types";
|
|
2
|
+
|
|
3
|
+
export type IndividualSpacing = Readonly<Record<string, number>>;
|
|
4
|
+
|
|
5
|
+
/** ELK resolves local spacing as the maximum override on either graph element. */
|
|
6
|
+
export function nodeNodeSpacing(
|
|
7
|
+
input: LayeredPhaseInput,
|
|
8
|
+
firstId: string,
|
|
9
|
+
secondId: string,
|
|
10
|
+
): number {
|
|
11
|
+
const firstBreakingPoint = firstId.startsWith("__layout_breaking:");
|
|
12
|
+
const secondBreakingPoint = secondId.startsWith("__layout_breaking:");
|
|
13
|
+
const firstDummy = firstId.startsWith("__layout_dummy:") || firstBreakingPoint;
|
|
14
|
+
const secondDummy = secondId.startsWith("__layout_dummy:") || secondBreakingPoint;
|
|
15
|
+
const spacingName =
|
|
16
|
+
firstDummy && secondDummy && firstBreakingPoint === secondBreakingPoint
|
|
17
|
+
? "spacing.edgeEdge"
|
|
18
|
+
: firstDummy || secondDummy
|
|
19
|
+
? "spacing.edgeNode"
|
|
20
|
+
: "spacing.node";
|
|
21
|
+
let spacing =
|
|
22
|
+
spacingName === "spacing.edgeEdge"
|
|
23
|
+
? Number(input.settings["spacing.edgeEdge"] ?? 10)
|
|
24
|
+
: spacingName === "spacing.edgeNode"
|
|
25
|
+
? Number(input.settings["spacing.edgeNode"] ?? 10)
|
|
26
|
+
: input.spacing.node;
|
|
27
|
+
for (const id of [firstId, secondId]) {
|
|
28
|
+
const node = input.graph.nodes.find((candidate) => candidate.id === id);
|
|
29
|
+
if (!node) continue;
|
|
30
|
+
const individual = input.nodeSettings?.(node)?.["spacing.individual"];
|
|
31
|
+
if (individual && typeof individual === "object") {
|
|
32
|
+
const value = (individual as IndividualSpacing)[spacingName];
|
|
33
|
+
if (typeof value === "number" && Number.isFinite(value)) spacing = Math.max(spacing, value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return spacing;
|
|
37
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2014, 2018 Kiel University and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the Eclipse Public License 2.0 which is available at
|
|
6
|
+
* http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
*
|
|
8
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
9
|
+
*******************************************************************************/
|
|
10
|
+
import type { Point } from "@statelyai/graph";
|
|
11
|
+
|
|
12
|
+
function interpolate(left: Point, right: Point, ratio: number): Point {
|
|
13
|
+
return {
|
|
14
|
+
x: (1 - ratio) * left.x + ratio * right.x,
|
|
15
|
+
y: (1 - ratio) * left.y + ratio * right.y,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Convert ELK's clamped uniform cubic NUB spline to equivalent Bezier control points. */
|
|
20
|
+
export function uniformCubicSplineToBezier(values: readonly Point[]): Point[] {
|
|
21
|
+
const points = values.map((point) => ({ ...point }));
|
|
22
|
+
while (points.length < 4) points.unshift({ ...points[0]! });
|
|
23
|
+
const degree = 3;
|
|
24
|
+
const segmentCount = points.length - degree;
|
|
25
|
+
let knots = [
|
|
26
|
+
...Array.from({ length: degree + 1 }, () => 0),
|
|
27
|
+
...Array.from(
|
|
28
|
+
{ length: Math.max(0, segmentCount - 1) },
|
|
29
|
+
(_, index) => (index + 1) / segmentCount,
|
|
30
|
+
),
|
|
31
|
+
...Array.from({ length: degree + 1 }, () => 1),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const insertKnot = (knot: number) => {
|
|
35
|
+
const nodeCount = points.length - 1;
|
|
36
|
+
let span = nodeCount;
|
|
37
|
+
for (let index = degree; index <= nodeCount; index++) {
|
|
38
|
+
if (knot >= knots[index]! && knot < knots[index + 1]!) {
|
|
39
|
+
span = index;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const multiplicity = knots.filter((candidate) => Math.abs(candidate - knot) < 1e-6).length;
|
|
44
|
+
const next = Array.from({ length: points.length + 1 }, () => ({ x: 0, y: 0 }));
|
|
45
|
+
for (let index = 0; index <= span - degree; index++) next[index] = points[index]!;
|
|
46
|
+
for (let index = span - multiplicity; index <= nodeCount; index++) {
|
|
47
|
+
next[index + 1] = points[index]!;
|
|
48
|
+
}
|
|
49
|
+
for (let index = span - degree + 1; index <= span - multiplicity; index++) {
|
|
50
|
+
const ratio = (knot - knots[index]!) / (knots[index + degree]! - knots[index]!);
|
|
51
|
+
next[index] = interpolate(points[index - 1]!, points[index]!, ratio);
|
|
52
|
+
}
|
|
53
|
+
points.splice(0, points.length, ...next);
|
|
54
|
+
knots = [...knots.slice(0, span + 1), knot, ...knots.slice(span + 1)];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (let segment = 1; segment < segmentCount; segment++) {
|
|
58
|
+
const knot = segment / segmentCount;
|
|
59
|
+
for (let insertion = 1; insertion < degree; insertion++) insertKnot(knot);
|
|
60
|
+
}
|
|
61
|
+
return points;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function absMin(first: number, second: number): number {
|
|
65
|
+
return Math.abs(first) < Math.abs(second) ? first : second;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function conservativeSpline(
|
|
69
|
+
start: Point,
|
|
70
|
+
end: Point,
|
|
71
|
+
track: number,
|
|
72
|
+
horizontal: boolean,
|
|
73
|
+
softenNodeAttachments: boolean,
|
|
74
|
+
): Point[] {
|
|
75
|
+
const sourceCross = horizontal ? start.y : start.x;
|
|
76
|
+
const targetCross = horizontal ? end.y : end.x;
|
|
77
|
+
const straight = Math.abs(sourceCross - targetCross) < 1e-6;
|
|
78
|
+
const controlPoints = straight
|
|
79
|
+
? [{ ...start }, { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }, { ...end }]
|
|
80
|
+
: [
|
|
81
|
+
{ ...start },
|
|
82
|
+
{ ...start },
|
|
83
|
+
horizontal ? { x: track, y: start.y } : { x: start.x, y: track },
|
|
84
|
+
horizontal ? { x: track, y: end.y } : { x: end.x, y: track },
|
|
85
|
+
{ ...end },
|
|
86
|
+
{ ...end },
|
|
87
|
+
];
|
|
88
|
+
if (!softenNodeAttachments) {
|
|
89
|
+
const flowSign = Math.sign(horizontal ? end.x - start.x : end.y - start.y) || 1;
|
|
90
|
+
const second = controlPoints[1]!;
|
|
91
|
+
controlPoints.splice(1, 0, {
|
|
92
|
+
x: start.x + absMin(horizontal ? flowSign * 5 : 0, second.x - start.x),
|
|
93
|
+
y: start.y + absMin(horizontal ? 0 : flowSign * 5, second.y - start.y),
|
|
94
|
+
});
|
|
95
|
+
const secondLast = controlPoints.at(-2)!;
|
|
96
|
+
controlPoints.splice(-1, 0, {
|
|
97
|
+
x: end.x + absMin(horizontal ? -flowSign * 5 : 0, secondLast.x - end.x),
|
|
98
|
+
y: end.y + absMin(horizontal ? 0 : -flowSign * 5, secondLast.y - end.y),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return uniformCubicSplineToBezier(controlPoints);
|
|
102
|
+
}
|