@statelyai/layout 0.4.1 → 0.4.3
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/README.md +8 -0
- package/dist/elkjs/index.cjs +2 -2
- 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 → elkjs-B5kN_zS6.mjs} +40 -8
- package/dist/{elkjs-DNfXDPup.cjs → elkjs-Cj-lWiK1.cjs} +40 -8
- package/dist/index.cjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/layered/index.cjs +1 -1
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-5RxQhryb.cjs → layered-BJu8A21s.cjs} +231 -121
- package/dist/{layered-DSesbctm.mjs → layered-BsAdm_u8.mjs} +231 -121
- package/dist/{layout-engine-CLFySz3R.cjs → layout-engine-BFP1TZbF.cjs} +1 -1
- package/dist/{layout-engine-GpBIX6ng.mjs → layout-engine-DnlmGxHL.mjs} +1 -1
- package/package.json +1 -1
- package/src/elkjs/index.ts +49 -5
- package/src/layered/bk-node-placement.ts +10 -7
- package/src/layered/crossing-counter.ts +10 -4
- package/src/layered/crossing-graph.ts +2 -2
- package/src/layered/greedy-switch-decider.ts +44 -17
- package/src/layered/index.ts +15 -10
- package/src/layered/loop-envelopes.ts +10 -4
- package/src/layered/node-lookup.ts +29 -0
- package/src/layered/port-distributor.ts +26 -14
- package/src/layered/port-edge-order.ts +2 -1
- package/src/layered/spacing.ts +4 -3
- package/src/layered/strategies.ts +176 -96
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { K as placePorts, X as JavaRandom, i as layeredAlgorithm, n as elkjs0111LayeredAlgorithm, x as setElkjs0111ResultPolicy, z as getPolylineMidpoint } from "./layered-
|
|
1
|
+
import { K as placePorts, X as JavaRandom, i as layeredAlgorithm, n as elkjs0111LayeredAlgorithm, x as setElkjs0111ResultPolicy, z as getPolylineMidpoint } from "./layered-BsAdm_u8.mjs";
|
|
2
2
|
import { getNodeSize } from "@statelyai/graph/layout";
|
|
3
3
|
|
|
4
4
|
//#region src/fixed.ts
|
package/package.json
CHANGED
package/src/elkjs/index.ts
CHANGED
|
@@ -96,6 +96,31 @@ interface PreparedElkScope {
|
|
|
96
96
|
finish(orders?: ReadonlyMap<PreparedElkScope, LayerOrder>): Promise<ElkNode>;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
const LAYOUT_UNITS_PER_PX = 64;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Measured sizes carry float noise (a browser rect divided by zoom), and a
|
|
103
|
+
* layered layout is full of exact ties that noise decides. Snap sizes to the
|
|
104
|
+
* browser's 1/64px layout unit so the same graph always lays out the same way;
|
|
105
|
+
* sizes already on that grid are unchanged.
|
|
106
|
+
*/
|
|
107
|
+
function snapSizes(node: ElkNode): void {
|
|
108
|
+
const snap = (shape: { width?: number; height?: number }) => {
|
|
109
|
+
if (typeof shape.width === "number")
|
|
110
|
+
shape.width = Math.round(shape.width * LAYOUT_UNITS_PER_PX) / LAYOUT_UNITS_PER_PX;
|
|
111
|
+
if (typeof shape.height === "number")
|
|
112
|
+
shape.height = Math.round(shape.height * LAYOUT_UNITS_PER_PX) / LAYOUT_UNITS_PER_PX;
|
|
113
|
+
};
|
|
114
|
+
snap(node);
|
|
115
|
+
for (const label of node.labels ?? []) snap(label);
|
|
116
|
+
for (const port of node.ports ?? []) {
|
|
117
|
+
snap(port);
|
|
118
|
+
for (const label of port.labels ?? []) snap(label);
|
|
119
|
+
}
|
|
120
|
+
for (const edge of node.edges ?? []) for (const label of edge.labels ?? []) snap(label);
|
|
121
|
+
for (const child of node.children ?? []) snapSizes(child);
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
export default class ELK {
|
|
100
125
|
readonly #options: ElkConstructorArguments;
|
|
101
126
|
readonly #algorithmIds: ReadonlySet<string>;
|
|
@@ -171,6 +196,7 @@ export default class ELK {
|
|
|
171
196
|
graph: T,
|
|
172
197
|
arguments_: ElkLayoutArguments = {},
|
|
173
198
|
): Promise<LaidOutElkNode<T>> {
|
|
199
|
+
snapSizes(graph);
|
|
174
200
|
const options = { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions };
|
|
175
201
|
const seeds = crossingVariants(options, graph);
|
|
176
202
|
const probe = mayShareTracks(graph, options)
|
|
@@ -1096,6 +1122,14 @@ export default class ELK {
|
|
|
1096
1122
|
getOption(layoutOptions, "padding"),
|
|
1097
1123
|
algorithm === "layered" ? 12 : 0,
|
|
1098
1124
|
);
|
|
1125
|
+
// Element options are replaced, never mutated, while a scope lays out; parse each once.
|
|
1126
|
+
const parsedSettings = new WeakMap<object, ElkLayeredOptionValueByName>();
|
|
1127
|
+
const elementSettings = (elementOptions: Readonly<Record<string, unknown>> | undefined) => {
|
|
1128
|
+
const key = elementOptions ?? noElementOptions;
|
|
1129
|
+
let settings = parsedSettings.get(key);
|
|
1130
|
+
if (!settings) parsedSettings.set(key, (settings = getElementLayeredSettings(key)));
|
|
1131
|
+
return settings;
|
|
1132
|
+
};
|
|
1099
1133
|
const options: LayeredLayoutOptions = {
|
|
1100
1134
|
direction: getDirection(layoutOptions),
|
|
1101
1135
|
spacing: {
|
|
@@ -1132,13 +1166,13 @@ export default class ELK {
|
|
|
1132
1166
|
},
|
|
1133
1167
|
nodeSettings: (node) => {
|
|
1134
1168
|
const child = findById(graph.children, node.id)?.item;
|
|
1135
|
-
return
|
|
1169
|
+
return elementSettings(child?.layoutOptions);
|
|
1136
1170
|
},
|
|
1137
1171
|
edgeSettings: (edge) => {
|
|
1138
1172
|
const elkEdge = findById(graph.edges, edge.id)?.item;
|
|
1139
1173
|
return {
|
|
1140
|
-
...
|
|
1141
|
-
...
|
|
1174
|
+
...elementSettings(elkEdge?.layoutOptions),
|
|
1175
|
+
...elementSettings(elkEdge?.labels?.[0]?.layoutOptions),
|
|
1142
1176
|
// ELK appends hierarchy segments to port edge lists in creation order.
|
|
1143
1177
|
"edge.hierarchyRank": elkEdge && hierarchySegmentRank.get(elkEdge),
|
|
1144
1178
|
};
|
|
@@ -1148,7 +1182,7 @@ export default class ELK {
|
|
|
1148
1182
|
const found = findById(child?.ports, port.name);
|
|
1149
1183
|
const elkPort = found?.item;
|
|
1150
1184
|
return {
|
|
1151
|
-
...
|
|
1185
|
+
...elementSettings(elkPort?.layoutOptions),
|
|
1152
1186
|
// ELK imports ports in authored order and sorts sides after dummy insertion.
|
|
1153
1187
|
"port.authoredIndex": found?.position,
|
|
1154
1188
|
"port.labelWidth": Math.max(
|
|
@@ -1746,8 +1780,16 @@ function applyNodeMicroLayout(
|
|
|
1746
1780
|
}
|
|
1747
1781
|
}
|
|
1748
1782
|
|
|
1783
|
+
// Accepted spellings per option suffix, built once.
|
|
1784
|
+
const exactKeysBySuffix = new Map<string, readonly string[]>();
|
|
1785
|
+
|
|
1749
1786
|
function getOption(options: Readonly<Record<string, unknown>>, suffix: string): unknown {
|
|
1750
|
-
|
|
1787
|
+
let exactKeys = exactKeysBySuffix.get(suffix);
|
|
1788
|
+
if (!exactKeys)
|
|
1789
|
+
exactKeysBySuffix.set(
|
|
1790
|
+
suffix,
|
|
1791
|
+
(exactKeys = [suffix, `elk.${suffix}`, `org.eclipse.elk.${suffix}`]),
|
|
1792
|
+
);
|
|
1751
1793
|
for (const key of exactKeys) {
|
|
1752
1794
|
if (options[key] !== undefined) return options[key];
|
|
1753
1795
|
}
|
|
@@ -1832,6 +1874,8 @@ function findById<T extends { id?: ElkId }>(items: readonly T[] | undefined, id:
|
|
|
1832
1874
|
return position === undefined ? undefined : { item: items[position]!, position };
|
|
1833
1875
|
}
|
|
1834
1876
|
|
|
1877
|
+
const noElementOptions: Readonly<Record<string, unknown>> = {};
|
|
1878
|
+
|
|
1835
1879
|
function getElementLayeredSettings(
|
|
1836
1880
|
options: Readonly<Record<string, unknown>>,
|
|
1837
1881
|
): ElkLayeredOptionValueByName {
|
|
@@ -328,11 +328,17 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
|
|
|
328
328
|
};
|
|
329
329
|
// Routing gives fixed-side self-loop terminals on shared-slot ports a slot
|
|
330
330
|
// among the side's other edges (after them here), so anchors reserve it.
|
|
331
|
+
const loopsByNodeId = new Map<string, GraphEdge[]>();
|
|
332
|
+
for (const edge of input.graph.edges)
|
|
333
|
+
if (edge.sourceId === edge.targetId) {
|
|
334
|
+
const loops = loopsByNodeId.get(edge.sourceId);
|
|
335
|
+
if (loops) loops.push(edge);
|
|
336
|
+
else loopsByNodeId.set(edge.sourceId, [edge]);
|
|
337
|
+
}
|
|
331
338
|
const loopTerminals = (id: string, side: string): number => {
|
|
332
339
|
const node = nodeById.get(id);
|
|
333
340
|
if (!node) return 0;
|
|
334
|
-
return
|
|
335
|
-
.filter((edge) => edge.sourceId === id && edge.targetId === id)
|
|
341
|
+
return (loopsByNodeId.get(id) ?? [])
|
|
336
342
|
.flatMap((edge) => [edge.sourcePort, edge.targetPort])
|
|
337
343
|
.filter((name) => {
|
|
338
344
|
const port = node.ports?.find((candidate) => candidate.name === name);
|
|
@@ -515,11 +521,8 @@ function alignBlocks(
|
|
|
515
521
|
const adjacent = (bal.hdir === "LEFT" ? neighbors.right : neighbors.left).get(id) ?? [];
|
|
516
522
|
const low = Math.floor((adjacent.length + 1) / 2) - 1;
|
|
517
523
|
const high = Math.ceil((adjacent.length + 1) / 2) - 1;
|
|
518
|
-
|
|
519
|
-
bal.vdir === "UP"
|
|
520
|
-
? Array.from({ length: Math.max(0, high - low + 1) }, (_, i) => high - i)
|
|
521
|
-
: Array.from({ length: Math.max(0, high - low + 1) }, (_, i) => low + i);
|
|
522
|
-
for (const median of indices) {
|
|
524
|
+
for (let i = 0; i < high - low + 1; i++) {
|
|
525
|
+
const median = bal.vdir === "UP" ? high - i : low + i;
|
|
523
526
|
if (bal.align.get(id) !== id) break;
|
|
524
527
|
const neighbor = adjacent[median];
|
|
525
528
|
if (neighbor === undefined) continue;
|
|
@@ -66,7 +66,8 @@ export function countAllCrossings(
|
|
|
66
66
|
/** "separated" counts hyperedges like ELK unless they would share a track (see scoreOrthogonalHypersegments). */
|
|
67
67
|
boundaryMode: "hyperedges" | "separated" | "edges" = "hyperedges",
|
|
68
68
|
): CrossingScore {
|
|
69
|
-
|
|
69
|
+
// Only north/south counting resolves nodes by id.
|
|
70
|
+
let nodes: Map<string, CrossingNode> | undefined;
|
|
70
71
|
const layerByPort = new Map<string, number>();
|
|
71
72
|
const incoming = new Map<string, string[]>(),
|
|
72
73
|
outgoing = new Map<string, string[]>();
|
|
@@ -81,7 +82,6 @@ export function countAllCrossings(
|
|
|
81
82
|
outgoing.get(edge.source)!.push(edge.target);
|
|
82
83
|
incoming.get(edge.target)!.push(edge.source);
|
|
83
84
|
}
|
|
84
|
-
const connected = (id: string) => [...incoming.get(id)!, ...outgoing.get(id)!];
|
|
85
85
|
const degree = (id: string) => incoming.get(id)!.length + outgoing.get(id)!.length;
|
|
86
86
|
const face = (layer: readonly CrossingNode[], side: "EAST" | "WEST", topDown = true) =>
|
|
87
87
|
(topDown ? layer : [...layer].reverse()).flatMap((node) => {
|
|
@@ -95,7 +95,11 @@ export function countAllCrossings(
|
|
|
95
95
|
for (const [position, port] of ports.entries()) {
|
|
96
96
|
tree.remove(position);
|
|
97
97
|
const pending: number[] = [];
|
|
98
|
-
|
|
98
|
+
// Incoming then outgoing peers, without concatenating them.
|
|
99
|
+
const before = incoming.get(port.id)!,
|
|
100
|
+
after = outgoing.get(port.id)!;
|
|
101
|
+
for (let index = 0; index < before.length + after.length; index++) {
|
|
102
|
+
const peer = index < before.length ? before[index]! : after[index - before.length]!;
|
|
99
103
|
if (inLayerOnly && layerByPort.get(peer) !== layerByPort.get(port.id)) {
|
|
100
104
|
crossings += tree.size;
|
|
101
105
|
continue;
|
|
@@ -144,7 +148,9 @@ export function countAllCrossings(
|
|
|
144
148
|
const node = ownerByPort.get(port.id)!;
|
|
145
149
|
const targets =
|
|
146
150
|
node.type === "NORMAL"
|
|
147
|
-
? nodes.
|
|
151
|
+
? (nodes ??= new Map(graph.layers.flat().map((node) => [node.id, node])))
|
|
152
|
+
.get(port.dummy!)!
|
|
153
|
+
.ports.map((peer) => ({ id: peer.id, degree: degree(peer.id) }))
|
|
148
154
|
: node.type === "LONG_EDGE"
|
|
149
155
|
? node.ports
|
|
150
156
|
.filter((peer) => peer.id !== port.id)
|
|
@@ -353,8 +353,8 @@ export function splitMixedCrossingPorts(
|
|
|
353
353
|
const directions = new Map<string, number>();
|
|
354
354
|
graph.edges.forEach((edge, index) => {
|
|
355
355
|
const bit = reversed[index] ? 2 : 1;
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
directions.set(edge.source, (directions.get(edge.source) ?? 0) | bit);
|
|
357
|
+
directions.set(edge.target, (directions.get(edge.target) ?? 0) | bit);
|
|
358
358
|
});
|
|
359
359
|
const mixed = new Set<string>();
|
|
360
360
|
for (const layer of graph.layers)
|
|
@@ -15,6 +15,36 @@ function sidePorts(node: CrossingNode, side: Side): CrossingPort[] {
|
|
|
15
15
|
return side === "WEST" || side === "SOUTH" ? ports.reverse() : ports;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Port connectivity and layers; switches within layers leave it unchanged. */
|
|
19
|
+
export interface GreedySwitchTopology {
|
|
20
|
+
incoming: Map<string, string[]>;
|
|
21
|
+
outgoing: Map<string, string[]>;
|
|
22
|
+
layerByPort: Map<string, number>;
|
|
23
|
+
ownerByPort: Map<string, CrossingNode>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function greedySwitchTopology(graph: CrossingGraph): GreedySwitchTopology {
|
|
27
|
+
const topology: GreedySwitchTopology = {
|
|
28
|
+
incoming: new Map(),
|
|
29
|
+
outgoing: new Map(),
|
|
30
|
+
layerByPort: new Map(),
|
|
31
|
+
ownerByPort: new Map(),
|
|
32
|
+
};
|
|
33
|
+
for (const [layer, nodes] of graph.layers.entries())
|
|
34
|
+
for (const node of nodes)
|
|
35
|
+
for (const port of node.ports) {
|
|
36
|
+
topology.layerByPort.set(port.id, layer);
|
|
37
|
+
topology.ownerByPort.set(port.id, node);
|
|
38
|
+
topology.incoming.set(port.id, []);
|
|
39
|
+
topology.outgoing.set(port.id, []);
|
|
40
|
+
}
|
|
41
|
+
for (const edge of graph.edges) {
|
|
42
|
+
topology.outgoing.get(edge.source)?.push(edge.target);
|
|
43
|
+
topology.incoming.get(edge.target)?.push(edge.source);
|
|
44
|
+
}
|
|
45
|
+
return topology;
|
|
46
|
+
}
|
|
47
|
+
|
|
18
48
|
interface Adjacency {
|
|
19
49
|
position: number;
|
|
20
50
|
cardinality: number;
|
|
@@ -27,10 +57,10 @@ interface Adjacency {
|
|
|
27
57
|
*/
|
|
28
58
|
export class GreedySwitchDecider {
|
|
29
59
|
readonly #free: readonly CrossingNode[];
|
|
30
|
-
readonly #incoming
|
|
31
|
-
readonly #outgoing
|
|
32
|
-
readonly #layerByPort
|
|
33
|
-
readonly #ownerByPort
|
|
60
|
+
readonly #incoming: ReadonlyMap<string, readonly string[]>;
|
|
61
|
+
readonly #outgoing: ReadonlyMap<string, readonly string[]>;
|
|
62
|
+
readonly #layerByPort: ReadonlyMap<string, number>;
|
|
63
|
+
readonly #ownerByPort: ReadonlyMap<string, CrossingNode>;
|
|
34
64
|
readonly #neighbourPositions = new Map<string, number>();
|
|
35
65
|
readonly #adjacencies = new Map<string, { sides: Map<Side, Adjacency[]> }>();
|
|
36
66
|
readonly #matrix = new Map<string, [number, number]>();
|
|
@@ -39,21 +69,18 @@ export class GreedySwitchDecider {
|
|
|
39
69
|
readonly #inLayerSize = new Map<Side, number>();
|
|
40
70
|
readonly #oneSided?: "WEST" | "EAST";
|
|
41
71
|
|
|
42
|
-
constructor(
|
|
72
|
+
constructor(
|
|
73
|
+
graph: CrossingGraph,
|
|
74
|
+
freeLayerIndex: number,
|
|
75
|
+
oneSided?: "WEST" | "EAST",
|
|
76
|
+
topology = greedySwitchTopology(graph),
|
|
77
|
+
) {
|
|
43
78
|
this.#free = graph.layers[freeLayerIndex] ?? [];
|
|
44
79
|
this.#oneSided = oneSided;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this.#ownerByPort.set(port.id, node);
|
|
50
|
-
this.#incoming.set(port.id, []);
|
|
51
|
-
this.#outgoing.set(port.id, []);
|
|
52
|
-
}
|
|
53
|
-
for (const edge of graph.edges) {
|
|
54
|
-
this.#outgoing.get(edge.source)?.push(edge.target);
|
|
55
|
-
this.#incoming.get(edge.target)?.push(edge.source);
|
|
56
|
-
}
|
|
80
|
+
this.#incoming = topology.incoming;
|
|
81
|
+
this.#outgoing = topology.outgoing;
|
|
82
|
+
this.#layerByPort = topology.layerByPort;
|
|
83
|
+
this.#ownerByPort = topology.ownerByPort;
|
|
57
84
|
for (const [layerIndex, side] of [
|
|
58
85
|
[freeLayerIndex - 1, "EAST"],
|
|
59
86
|
[freeLayerIndex + 1, "WEST"],
|
package/src/layered/index.ts
CHANGED
|
@@ -1436,15 +1436,16 @@ export function* createLayeredScopePipeline<N, E, G, P>(
|
|
|
1436
1436
|
settings: options.settings ?? {},
|
|
1437
1437
|
...(options.nodeSettings === undefined ? {} : { nodeSettings: options.nodeSettings }),
|
|
1438
1438
|
...(options.edgeSettings === undefined ? {} : { edgeSettings: options.edgeSettings }),
|
|
1439
|
-
portSettings: (port, node) =>
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
: {}),
|
|
1447
|
-
|
|
1439
|
+
portSettings: (port, node) => {
|
|
1440
|
+
const key = `${node.id}\0${port.name}`;
|
|
1441
|
+
return {
|
|
1442
|
+
...options.portSettings?.(port, node),
|
|
1443
|
+
...(parallelPortIndexByKey.has(key)
|
|
1444
|
+
? { "port.index": parallelPortIndexByKey.get(key) }
|
|
1445
|
+
: {}),
|
|
1446
|
+
...(switchedSideByPort.has(key) ? { "port.side": switchedSideByPort.get(key) } : {}),
|
|
1447
|
+
};
|
|
1448
|
+
},
|
|
1448
1449
|
};
|
|
1449
1450
|
const measure = <T>(id: string, run: () => T): T => {
|
|
1450
1451
|
context?.throwIfAborted();
|
|
@@ -1697,12 +1698,16 @@ export function* createLayeredScopePipeline<N, E, G, P>(
|
|
|
1697
1698
|
southEdges = new Set<string>();
|
|
1698
1699
|
const upper = direction === "right" || direction === "left" ? "NORTH" : "WEST";
|
|
1699
1700
|
const lower = direction === "right" || direction === "left" ? "SOUTH" : "EAST";
|
|
1701
|
+
// First node per id, as `find` returns.
|
|
1702
|
+
const endpointNodes = new Map<string, GraphNode>();
|
|
1703
|
+
for (const node of expanded.input.graph.nodes)
|
|
1704
|
+
if (!endpointNodes.has(node.id)) endpointNodes.set(node.id, node);
|
|
1700
1705
|
for (const edge of expanded.input.graph.edges)
|
|
1701
1706
|
for (const [id, name] of [
|
|
1702
1707
|
[edge.sourceId, edge.sourcePort],
|
|
1703
1708
|
[edge.targetId, edge.targetPort],
|
|
1704
1709
|
]) {
|
|
1705
|
-
const node =
|
|
1710
|
+
const node = endpointNodes.get(id!),
|
|
1706
1711
|
port = node?.ports?.find((p) => p.name === name);
|
|
1707
1712
|
const side =
|
|
1708
1713
|
node && port ? expanded.input.portSettings?.(port, node)?.["port.side"] : undefined;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
routeFixedSelfLoop,
|
|
8
8
|
selfLoopTracks,
|
|
9
9
|
} from "./fixed-self-loop";
|
|
10
|
+
import { findNode } from "./node-lookup";
|
|
10
11
|
|
|
11
12
|
export interface LoopEnvelope {
|
|
12
13
|
before: number;
|
|
@@ -23,7 +24,7 @@ export function hasMovableLoopPorts(input: LayeredPhaseInput, edge: GraphEdge):
|
|
|
23
24
|
edge.targetPort !== undefined
|
|
24
25
|
)
|
|
25
26
|
return false;
|
|
26
|
-
const node = input.graph.nodes
|
|
27
|
+
const node = findNode(input.graph.nodes, edge.sourceId);
|
|
27
28
|
const constraints = node && input.nodeSettings?.(node)?.portConstraints;
|
|
28
29
|
return constraints === undefined || constraints === "FREE" || constraints === "UNDEFINED";
|
|
29
30
|
}
|
|
@@ -31,10 +32,15 @@ export function hasMovableLoopPorts(input: LayeredPhaseInput, edge: GraphEdge):
|
|
|
31
32
|
export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, LoopEnvelope> {
|
|
32
33
|
const envelopes = new Map<string, LoopEnvelope>();
|
|
33
34
|
if ((input.settings.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL") return envelopes;
|
|
35
|
+
const loopsByNodeId = new Map<string, GraphEdge[]>();
|
|
36
|
+
for (const edge of input.graph.edges)
|
|
37
|
+
if (edge.sourceId === edge.targetId) {
|
|
38
|
+
const loops = loopsByNodeId.get(edge.sourceId);
|
|
39
|
+
if (loops) loops.push(edge);
|
|
40
|
+
else loopsByNodeId.set(edge.sourceId, [edge]);
|
|
41
|
+
}
|
|
34
42
|
for (const node of input.graph.nodes) {
|
|
35
|
-
const allLoops =
|
|
36
|
-
(edge) => edge.sourceId === node.id && edge.targetId === node.id,
|
|
37
|
-
);
|
|
43
|
+
const allLoops = loopsByNodeId.get(node.id)?.slice() ?? [];
|
|
38
44
|
const constraints = input.nodeSettings?.(node)?.portConstraints;
|
|
39
45
|
if (
|
|
40
46
|
allLoops.length &&
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { GraphNode } from "@statelyai/graph";
|
|
2
|
+
|
|
3
|
+
// First position of each id per node list, rebuilt when the list's length
|
|
4
|
+
// changes. Layout code only appends to node lists, never replaces entries in
|
|
5
|
+
// place; positions are still checked on use and a miss scans like `find`.
|
|
6
|
+
const indexByNodes = new WeakMap<
|
|
7
|
+
readonly GraphNode[],
|
|
8
|
+
{ length: number; positions: Map<string, number> }
|
|
9
|
+
>();
|
|
10
|
+
|
|
11
|
+
function indexNodes(nodes: readonly GraphNode[]): Map<string, number> {
|
|
12
|
+
const positions = new Map<string, number>();
|
|
13
|
+
for (const [index, node] of nodes.entries())
|
|
14
|
+
if (!positions.has(node.id)) positions.set(node.id, index);
|
|
15
|
+
indexByNodes.set(nodes, { length: nodes.length, positions });
|
|
16
|
+
return positions;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Same as `nodes.find((node) => node.id === id)`, indexed per list. */
|
|
20
|
+
export function findNode(nodes: readonly GraphNode[], id: string): GraphNode | undefined {
|
|
21
|
+
const cached = indexByNodes.get(nodes);
|
|
22
|
+
let position = (
|
|
23
|
+
cached && cached.length === nodes.length ? cached.positions : indexNodes(nodes)
|
|
24
|
+
).get(id);
|
|
25
|
+
if (position !== undefined && nodes[position]?.id !== id) position = indexNodes(nodes).get(id);
|
|
26
|
+
return position !== undefined && nodes[position]?.id === id
|
|
27
|
+
? nodes[position]
|
|
28
|
+
: nodes.find((node) => node.id === id);
|
|
29
|
+
}
|
|
@@ -18,6 +18,8 @@ export interface PortDistributionOptions {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Stateful clockwise port distribution across successive layer sweeps. */
|
|
21
|
+
const sideRank = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 };
|
|
22
|
+
|
|
21
23
|
export class CanonicalPortDistributor {
|
|
22
24
|
readonly state: PortDistributionState;
|
|
23
25
|
private topologyGraph?: CrossingGraph;
|
|
@@ -73,27 +75,36 @@ export class CanonicalPortDistributor {
|
|
|
73
75
|
): void {
|
|
74
76
|
this.prepareTopology(graph);
|
|
75
77
|
let consumed = 0;
|
|
78
|
+
// Only ports with edges toward the free layer take ranks.
|
|
79
|
+
const ends = input ? this.incoming : this.outgoing;
|
|
80
|
+
const ranked = (port: CrossingPort) => ends.get(port.id)!.length > 0;
|
|
76
81
|
for (const node of layer) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
82
|
+
let count = 0,
|
|
83
|
+
northCount = 0;
|
|
84
|
+
for (const port of node.ports)
|
|
85
|
+
if (ranked(port)) {
|
|
86
|
+
count++;
|
|
87
|
+
if (port.side === "NORTH") northCount++;
|
|
88
|
+
}
|
|
89
|
+
const increment = nodeRelative ? 1 / (count + 1) : 1;
|
|
81
90
|
if (input) {
|
|
82
|
-
let north = consumed +
|
|
83
|
-
let rest = nodeRelative ? consumed + 1 - increment : consumed +
|
|
84
|
-
for (const port of ports) {
|
|
91
|
+
let north = consumed + northCount * increment;
|
|
92
|
+
let rest = nodeRelative ? consumed + 1 - increment : consumed + count;
|
|
93
|
+
for (const port of node.ports) {
|
|
94
|
+
if (!ranked(port)) continue;
|
|
85
95
|
this.state.ranks[port.id] = port.side === "NORTH" ? north : rest;
|
|
86
96
|
if (port.side === "NORTH") north -= increment;
|
|
87
97
|
else rest -= increment;
|
|
88
98
|
}
|
|
89
99
|
} else {
|
|
90
100
|
let position = consumed + increment;
|
|
91
|
-
for (const port of ports) {
|
|
101
|
+
for (const port of node.ports) {
|
|
102
|
+
if (!ranked(port)) continue;
|
|
92
103
|
this.state.ranks[port.id] = position;
|
|
93
104
|
position += increment;
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
|
-
consumed += nodeRelative ? 1 :
|
|
107
|
+
consumed += nodeRelative ? 1 : count;
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
110
|
|
|
@@ -109,14 +120,15 @@ export class CanonicalPortDistributor {
|
|
|
109
120
|
const ranks = (layer: CrossingNode[], input: boolean) =>
|
|
110
121
|
this.calculatePortRanks(graph, layer, input, options.nodeRelative);
|
|
111
122
|
const distributeNode = (node: CrossingNode, side: "EAST" | "WEST") => {
|
|
112
|
-
if (options.fixedOrder.has(node.id)) return;
|
|
123
|
+
if (options.fixedOrder.has(node.id) || !node.ports.length) return;
|
|
113
124
|
const layerIndex = node.ports.length ? layerByPort.get(node.ports[0]!.id)! : index;
|
|
114
125
|
const size = graph.layers[layerIndex]!.length;
|
|
115
126
|
for (const face of [side, "SOUTH", "NORTH"] as const) {
|
|
116
127
|
let minimum = 0,
|
|
117
128
|
maximum = 0;
|
|
118
129
|
const inLayer: CrossingPort[] = [];
|
|
119
|
-
for (const port of node.ports
|
|
130
|
+
for (const port of node.ports) {
|
|
131
|
+
if (port.side !== face) continue;
|
|
120
132
|
let sum = 0;
|
|
121
133
|
const cross = port.side === "NORTH" || port.side === "SOUTH";
|
|
122
134
|
if (cross) {
|
|
@@ -135,8 +147,8 @@ export class CanonicalPortDistributor {
|
|
|
135
147
|
else if (output && !input) sum += position + 1;
|
|
136
148
|
else if (input && output) sum += port.side === "NORTH" ? 0 : large / 2;
|
|
137
149
|
} else {
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
150
|
+
const sameLayer = (peer: string) => layerByPort.get(peer) === layerIndex;
|
|
151
|
+
if (outgoing.get(port.id)!.some(sameLayer) || incoming.get(port.id)!.some(sameLayer)) {
|
|
140
152
|
inLayer.push(port);
|
|
141
153
|
continue;
|
|
142
154
|
}
|
|
@@ -168,7 +180,7 @@ export class CanonicalPortDistributor {
|
|
|
168
180
|
: minimum - (size + 1 - value);
|
|
169
181
|
}
|
|
170
182
|
}
|
|
171
|
-
|
|
183
|
+
if (node.ports.length < 2) return;
|
|
172
184
|
node.ports.sort((a, b) => {
|
|
173
185
|
const side = sideRank[a.side] - sideRank[b.side];
|
|
174
186
|
if (side) return side;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { GraphEdge } from "@statelyai/graph";
|
|
2
2
|
import type { LayeredPhaseInput } from "./types";
|
|
3
|
+
import { findNode } from "./node-lookup";
|
|
3
4
|
|
|
4
5
|
/** ELK traverses connected edges through the node's port list, preserving each port's edge order. */
|
|
5
6
|
export function connectedEdgesInPortOrder(input: LayeredPhaseInput, nodeId: string): GraphEdge[] {
|
|
6
|
-
const node = input.graph.nodes
|
|
7
|
+
const node = findNode(input.graph.nodes, nodeId);
|
|
7
8
|
const portRank = new Map(node?.ports?.map((port, index) => [port.name, index]));
|
|
8
9
|
const explicitCount = portRank.size;
|
|
9
10
|
return input.graph.edges
|
package/src/layered/spacing.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { externalPortDummyOf } from "./external-port-dummy";
|
|
2
2
|
import type { LayeredPhaseInput } from "./types";
|
|
3
|
+
import { findNode } from "./node-lookup";
|
|
3
4
|
|
|
4
5
|
export type IndividualSpacing = Readonly<Record<string, number>>;
|
|
5
6
|
|
|
@@ -13,8 +14,8 @@ export function nodeNodeSpacing(
|
|
|
13
14
|
const secondBreakingPoint = secondId.startsWith("__layout_breaking:");
|
|
14
15
|
const firstLabel = firstId.startsWith("__layout_dummy:label:");
|
|
15
16
|
const secondLabel = secondId.startsWith("__layout_dummy:label:");
|
|
16
|
-
const firstNode = input.graph.nodes
|
|
17
|
-
const secondNode = input.graph.nodes
|
|
17
|
+
const firstNode = findNode(input.graph.nodes, firstId);
|
|
18
|
+
const secondNode = findNode(input.graph.nodes, secondId);
|
|
18
19
|
const firstExternal = firstNode !== undefined && externalPortDummyOf(firstNode) !== undefined;
|
|
19
20
|
const secondExternal = secondNode !== undefined && externalPortDummyOf(secondNode) !== undefined;
|
|
20
21
|
const firstDummy = firstId.startsWith("__layout_dummy:") || firstBreakingPoint || firstExternal;
|
|
@@ -47,7 +48,7 @@ export function nodeNodeSpacing(
|
|
|
47
48
|
? Number(input.settings["spacing.edgeNode"] ?? 10)
|
|
48
49
|
: input.spacing.node;
|
|
49
50
|
for (const id of [firstId, secondId]) {
|
|
50
|
-
const node = input.graph.nodes
|
|
51
|
+
const node = findNode(input.graph.nodes, id);
|
|
51
52
|
if (!node) continue;
|
|
52
53
|
const individual = input.nodeSettings?.(node)?.["spacing.individual"];
|
|
53
54
|
if (individual && typeof individual === "object") {
|