@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.
@@ -1,4 +1,4 @@
1
- const require_layered = require('./layered-5RxQhryb.cjs');
1
+ const require_layered = require('./layered-BJu8A21s.cjs');
2
2
  let _statelyai_graph_layout = require("@statelyai/graph/layout");
3
3
 
4
4
  //#region src/fixed.ts
@@ -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-DSesbctm.mjs";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statelyai/layout",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Extensible, graph-native layout algorithms for @statelyai/graph",
5
5
  "license": "EPL-2.0 OR GPL-3.0-or-later",
6
6
  "repository": {
@@ -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 getElementLayeredSettings(child?.layoutOptions ?? {});
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
- ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
1141
- ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
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
- ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
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
- const exactKeys = [suffix, `elk.${suffix}`, `org.eclipse.elk.${suffix}`];
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 input.graph.edges
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
- const indices =
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
- const nodes = new Map(graph.layers.flat().map((node) => [node.id, node]));
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
- for (const peer of connected(port.id)) {
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.get(port.dummy!)!.ports.map((peer) => ({ id: peer.id, degree: degree(peer.id) }))
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
- for (const port of [edge.source, edge.target])
357
- directions.set(port, (directions.get(port) ?? 0) | bit);
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 = new Map<string, string[]>();
31
- readonly #outgoing = new Map<string, string[]>();
32
- readonly #layerByPort = new Map<string, number>();
33
- readonly #ownerByPort = new Map<string, CrossingNode>();
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(graph: CrossingGraph, freeLayerIndex: number, oneSided?: "WEST" | "EAST") {
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
- for (const [layer, nodes] of graph.layers.entries())
46
- for (const node of nodes)
47
- for (const port of node.ports) {
48
- this.#layerByPort.set(port.id, layer);
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"],
@@ -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
- ...options.portSettings?.(port, node),
1441
- ...(parallelPortIndexByKey.has(`${node.id}\0${port.name}`)
1442
- ? { "port.index": parallelPortIndexByKey.get(`${node.id}\0${port.name}`) }
1443
- : {}),
1444
- ...(switchedSideByPort.has(`${node.id}\0${port.name}`)
1445
- ? { "port.side": switchedSideByPort.get(`${node.id}\0${port.name}`) }
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 = expanded.input.graph.nodes.find((n) => n.id === id),
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.find((candidate) => candidate.id === edge.sourceId);
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 = input.graph.edges.filter(
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
- const ports = node.ports.filter(
78
- (port) => (input ? this.incoming : this.outgoing).get(port.id)!.length > 0,
79
- );
80
- const increment = nodeRelative ? 1 / (ports.length + 1) : 1;
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 + ports.filter((port) => port.side === "NORTH").length * increment;
83
- let rest = nodeRelative ? consumed + 1 - increment : consumed + ports.length;
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 : ports.length;
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.filter((port) => port.side === face)) {
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 peers = [...outgoing.get(port.id)!, ...incoming.get(port.id)!];
139
- if (peers.some((peer) => layerByPort.get(peer) === layerIndex)) {
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
- const sideRank = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 };
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.find((candidate) => candidate.id === nodeId);
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
@@ -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.find((node) => node.id === firstId);
17
- const secondNode = input.graph.nodes.find((node) => node.id === secondId);
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.find((candidate) => candidate.id === id);
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") {