@statelyai/layout 0.4.1 → 0.4.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/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-CSi7oB5J.mjs} +18 -8
- package/dist/{elkjs-DNfXDPup.cjs → elkjs-CnBvdyBu.cjs} +18 -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 +23 -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
|
@@ -1096,6 +1096,14 @@ export default class ELK {
|
|
|
1096
1096
|
getOption(layoutOptions, "padding"),
|
|
1097
1097
|
algorithm === "layered" ? 12 : 0,
|
|
1098
1098
|
);
|
|
1099
|
+
// Element options are replaced, never mutated, while a scope lays out; parse each once.
|
|
1100
|
+
const parsedSettings = new WeakMap<object, ElkLayeredOptionValueByName>();
|
|
1101
|
+
const elementSettings = (elementOptions: Readonly<Record<string, unknown>> | undefined) => {
|
|
1102
|
+
const key = elementOptions ?? noElementOptions;
|
|
1103
|
+
let settings = parsedSettings.get(key);
|
|
1104
|
+
if (!settings) parsedSettings.set(key, (settings = getElementLayeredSettings(key)));
|
|
1105
|
+
return settings;
|
|
1106
|
+
};
|
|
1099
1107
|
const options: LayeredLayoutOptions = {
|
|
1100
1108
|
direction: getDirection(layoutOptions),
|
|
1101
1109
|
spacing: {
|
|
@@ -1132,13 +1140,13 @@ export default class ELK {
|
|
|
1132
1140
|
},
|
|
1133
1141
|
nodeSettings: (node) => {
|
|
1134
1142
|
const child = findById(graph.children, node.id)?.item;
|
|
1135
|
-
return
|
|
1143
|
+
return elementSettings(child?.layoutOptions);
|
|
1136
1144
|
},
|
|
1137
1145
|
edgeSettings: (edge) => {
|
|
1138
1146
|
const elkEdge = findById(graph.edges, edge.id)?.item;
|
|
1139
1147
|
return {
|
|
1140
|
-
...
|
|
1141
|
-
...
|
|
1148
|
+
...elementSettings(elkEdge?.layoutOptions),
|
|
1149
|
+
...elementSettings(elkEdge?.labels?.[0]?.layoutOptions),
|
|
1142
1150
|
// ELK appends hierarchy segments to port edge lists in creation order.
|
|
1143
1151
|
"edge.hierarchyRank": elkEdge && hierarchySegmentRank.get(elkEdge),
|
|
1144
1152
|
};
|
|
@@ -1148,7 +1156,7 @@ export default class ELK {
|
|
|
1148
1156
|
const found = findById(child?.ports, port.name);
|
|
1149
1157
|
const elkPort = found?.item;
|
|
1150
1158
|
return {
|
|
1151
|
-
...
|
|
1159
|
+
...elementSettings(elkPort?.layoutOptions),
|
|
1152
1160
|
// ELK imports ports in authored order and sorts sides after dummy insertion.
|
|
1153
1161
|
"port.authoredIndex": found?.position,
|
|
1154
1162
|
"port.labelWidth": Math.max(
|
|
@@ -1746,8 +1754,16 @@ function applyNodeMicroLayout(
|
|
|
1746
1754
|
}
|
|
1747
1755
|
}
|
|
1748
1756
|
|
|
1757
|
+
// Accepted spellings per option suffix, built once.
|
|
1758
|
+
const exactKeysBySuffix = new Map<string, readonly string[]>();
|
|
1759
|
+
|
|
1749
1760
|
function getOption(options: Readonly<Record<string, unknown>>, suffix: string): unknown {
|
|
1750
|
-
|
|
1761
|
+
let exactKeys = exactKeysBySuffix.get(suffix);
|
|
1762
|
+
if (!exactKeys)
|
|
1763
|
+
exactKeysBySuffix.set(
|
|
1764
|
+
suffix,
|
|
1765
|
+
(exactKeys = [suffix, `elk.${suffix}`, `org.eclipse.elk.${suffix}`]),
|
|
1766
|
+
);
|
|
1751
1767
|
for (const key of exactKeys) {
|
|
1752
1768
|
if (options[key] !== undefined) return options[key];
|
|
1753
1769
|
}
|
|
@@ -1832,6 +1848,8 @@ function findById<T extends { id?: ElkId }>(items: readonly T[] | undefined, id:
|
|
|
1832
1848
|
return position === undefined ? undefined : { item: items[position]!, position };
|
|
1833
1849
|
}
|
|
1834
1850
|
|
|
1851
|
+
const noElementOptions: Readonly<Record<string, unknown>> = {};
|
|
1852
|
+
|
|
1835
1853
|
function getElementLayeredSettings(
|
|
1836
1854
|
options: Readonly<Record<string, unknown>>,
|
|
1837
1855
|
): 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") {
|