@statelyai/layout 0.4.0 → 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.
Files changed (33) hide show
  1. package/dist/elkjs/index.cjs +2 -2
  2. package/dist/elkjs/index.mjs +2 -2
  3. package/dist/elkjs/main.cjs +1 -1
  4. package/dist/elkjs/main.mjs +1 -1
  5. package/dist/elkjs/worker.cjs +2 -2
  6. package/dist/elkjs/worker.mjs +2 -2
  7. package/dist/{elkjs-UciYx7lD.mjs → elkjs-CSi7oB5J.mjs} +181 -69
  8. package/dist/{elkjs-ggjsBuVP.cjs → elkjs-CnBvdyBu.cjs} +181 -69
  9. package/dist/index.cjs +2 -2
  10. package/dist/index.mjs +2 -2
  11. package/dist/layered/index.cjs +1 -1
  12. package/dist/layered/index.mjs +1 -1
  13. package/dist/{layered-CBPg6_6_.cjs → layered-BJu8A21s.cjs} +474 -158
  14. package/dist/{layered-C7ERzZjR.mjs → layered-BsAdm_u8.mjs} +469 -153
  15. package/dist/{layout-engine-DAw06RhN.cjs → layout-engine-BFP1TZbF.cjs} +1 -1
  16. package/dist/{layout-engine-DC3IYkIy.mjs → layout-engine-DnlmGxHL.mjs} +1 -1
  17. package/package.json +1 -1
  18. package/src/elkjs/index.ts +135 -33
  19. package/src/elkjs/layout-quality.ts +81 -33
  20. package/src/layered/bk-node-placement.ts +10 -7
  21. package/src/layered/crossing-counter.ts +27 -9
  22. package/src/layered/crossing-graph.ts +2 -2
  23. package/src/layered/greedy-switch-decider.ts +44 -17
  24. package/src/layered/hierarchy-sweepiness.ts +37 -41
  25. package/src/layered/index.ts +15 -21
  26. package/src/layered/loop-envelopes.ts +10 -4
  27. package/src/layered/node-lookup.ts +29 -0
  28. package/src/layered/opposing-tracks.ts +108 -8
  29. package/src/layered/orthogonal-hypersegments.ts +69 -7
  30. package/src/layered/port-distributor.ts +26 -14
  31. package/src/layered/port-edge-order.ts +2 -1
  32. package/src/layered/spacing.ts +4 -3
  33. package/src/layered/strategies.ts +288 -111
@@ -7,7 +7,7 @@ import {
7
7
  } from "./initial-model-order";
8
8
  import { insertionSort } from "./gwt-sort";
9
9
  import { CanonicalPortDistributor } from "./port-distributor";
10
- import { countAllCrossings } from "./crossing-counter";
10
+ import { countAllCrossings, type CrossingGraph } from "./crossing-counter";
11
11
  import {
12
12
  createCrossingGraphBuilder,
13
13
  crossingGraph,
@@ -60,7 +60,13 @@ import type { ElkLayeredOptionValueByName } from "./elk-options";
60
60
  import { conservativeSpline } from "./spline-bezier";
61
61
  import { getFlexiblePortPosition } from "./flexible-ports";
62
62
  import { traceLayeredPhase } from "../internal/layered-trace";
63
- import { assertNorthSouthPortSides, GreedySwitchDecider } from "./greedy-switch-decider";
63
+ import {
64
+ assertNorthSouthPortSides,
65
+ GreedySwitchDecider,
66
+ greedySwitchTopology,
67
+ type GreedySwitchTopology,
68
+ } from "./greedy-switch-decider";
69
+ import { findNode } from "./node-lookup";
64
70
 
65
71
  const modelSweepInputs = new WeakSet<LayeredPhaseInput>();
66
72
 
@@ -124,6 +130,24 @@ function cycleModelOrder(input: LayeredPhaseInput): Map<string, number> {
124
130
  );
125
131
  }
126
132
 
133
+ /**
134
+ * Internal setting: how crossing minimization models ports and tracks.
135
+ * `mixedPorts` (default true) counts a port carrying both directions as two
136
+ * ports and groups its neighbours by direction. `separateSharedTracks`
137
+ * (default false) counts a hyperedge joining two or more ports on each side
138
+ * of its gap edge by edge, since routing never lets edges run opposite ways
139
+ * along one track. A `probe` records whether either choice can change the
140
+ * order. Each helps on average but steers the sweeps into worse orders on
141
+ * some graphs, so the elkjs facade also lays such graphs out with the
142
+ * alternatives and keeps the best result.
143
+ */
144
+ export const CROSSING_MODEL = "internal.crossingModel";
145
+ export interface CrossingModel {
146
+ mixedPorts?: boolean;
147
+ separateSharedTracks?: boolean;
148
+ probe?: { mixedPorts: boolean; sharedTracks: boolean };
149
+ }
150
+
127
151
  export const breakCyclesWithDepthFirstSearch: CycleBreaker = (input) => {
128
152
  const outgoing = new Map<string, GraphEdge[]>();
129
153
  for (const node of input.graph.nodes) outgoing.set(node.id, []);
@@ -818,14 +842,7 @@ export function applyLayerConstraintOrder(input: LayeredPhaseInput, order: Layer
818
842
  ...order,
819
843
  layers: order.layers.map((layer) => {
820
844
  const unconstrained = layer.filter((id) => !constrained.has(id));
821
- const mergedOrder =
822
- input.settings.mergeEdges === true
823
- ? [
824
- ...unconstrained.filter((id) => id.startsWith("__layout_dummy:")),
825
- ...unconstrained.filter((id) => !id.startsWith("__layout_dummy:")),
826
- ]
827
- : unconstrained;
828
- return [...mergedOrder, ...layer.filter((id) => constrained.has(id))];
845
+ return [...unconstrained, ...layer.filter((id) => constrained.has(id))];
829
846
  }),
830
847
  };
831
848
  }
@@ -1022,6 +1039,8 @@ export function applyGreedySwitch(
1022
1039
  (layerIndex + 1 < layers.length ? countBoundary(layerIndex) : 0)
1023
1040
  );
1024
1041
  };
1042
+ // Switches stay within layers, so port connectivity and layers are fixed.
1043
+ let greedyTopology: GreedySwitchTopology | undefined;
1025
1044
  const sweep = (forward: boolean): boolean => {
1026
1045
  let changed = false;
1027
1046
  const indices = forward
@@ -1038,6 +1057,7 @@ export function applyGreedySwitch(
1038
1057
  greedyGraph,
1039
1058
  layerIndex,
1040
1059
  type === "ONE_SIDED" ? (forward ? "WEST" : "EAST") : undefined,
1060
+ (greedyTopology ??= greedySwitchTopology(greedyGraph)),
1041
1061
  );
1042
1062
  do {
1043
1063
  improved = false;
@@ -1897,6 +1917,8 @@ export interface LayerSweepSession {
1897
1917
  currentLayers(): readonly (readonly string[])[];
1898
1918
  /** Current canonical port ids of one node, as {@link snapshot} records them. */
1899
1919
  physicalPortOrder(nodeId: string): string[];
1920
+ /** Canonical crossing graph in the current order; callers must not mutate it. */
1921
+ crossingGraph(): CrossingGraph;
1900
1922
  /** Live incident edge orders of one node; callers must not mutate them. */
1901
1923
  edgeOrders(nodeId: string): { incoming: readonly string[]; outgoing: readonly string[] };
1902
1924
  /** Same as restoring a snapshot that differs only in one layer. */
@@ -1911,7 +1933,9 @@ export interface LayerSweepSession {
1911
1933
  }
1912
1934
 
1913
1935
  function clonePortOrders(orders?: ReadonlyMap<string, readonly string[]>): Map<string, string[]> {
1914
- return new Map([...(orders ?? [])].map(([id, edges]) => [id, [...edges]]));
1936
+ const clone = new Map<string, string[]>();
1937
+ if (orders) for (const [id, edges] of orders) clone.set(id, edges.slice());
1938
+ return clone;
1915
1939
  }
1916
1940
 
1917
1941
  function minimizeCrossingsWithLayerSweep(
@@ -1984,6 +2008,32 @@ export function createLayerSweepSession(
1984
2008
  }
1985
2009
  const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
1986
2010
  const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
2011
+ // ELK lists a compound's boundary ports in creation order: edges on them
2012
+ // take their slots in that order, in model order within a port. Authored
2013
+ // ports already arrive in ELK's sorted order and keep their edge order.
2014
+ const boundaryPortIndex = (nodeId: string, edgeId: string, incoming: boolean) => {
2015
+ const edge = edgeById.get(edgeId)!;
2016
+ const name =
2017
+ incoming !== orientation.reversedEdgeIds.has(edgeId) ? edge.targetPort : edge.sourcePort;
2018
+ if (!name?.startsWith("__native_hierarchy")) return -1;
2019
+ return nodeById.get(nodeId)!.ports?.findIndex((port) => port.name === name) ?? -1;
2020
+ };
2021
+ for (const [orders, incoming] of [
2022
+ [outputPortOrder, false],
2023
+ [inputPortOrder, true],
2024
+ ] as const)
2025
+ for (const [nodeId, edgeIds] of orders) {
2026
+ if (edgeIds.length < 2) continue;
2027
+ const slots = edgeIds.flatMap((id, slot) =>
2028
+ boundaryPortIndex(nodeId, id, incoming) < 0 ? [] : [slot],
2029
+ );
2030
+ const sorted = slots
2031
+ .map((slot) => edgeIds[slot]!)
2032
+ .sort(
2033
+ (a, b) => boundaryPortIndex(nodeId, a, incoming) - boundaryPortIndex(nodeId, b, incoming),
2034
+ );
2035
+ slots.forEach((slot, index) => (edgeIds[slot] = sorted[index]!));
2036
+ }
1987
2037
  const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
1988
2038
  const hierarchicalNodes = new Set<string>();
1989
2039
  const fixedOrderNodes = new Set(
@@ -2037,6 +2087,12 @@ export function createLayerSweepSession(
2037
2087
  }
2038
2088
  }
2039
2089
  const buildCrossingGraph = createCrossingGraphBuilder(input);
2090
+ const crossingModel: CrossingModel =
2091
+ (input.settings as { [CROSSING_MODEL]?: CrossingModel })[CROSSING_MODEL] ?? {};
2092
+ const mixedPorts = crossingModel.mixedPorts !== false,
2093
+ probe = crossingModel.probe;
2094
+ // Reused per count: physical port positions of one node.
2095
+ const physicalSlot = new Map<string, number>();
2040
2096
  const countCrossings = (candidateLayers: readonly (readonly string[])[]): number => {
2041
2097
  if (exactPortSweep) {
2042
2098
  const graph = buildCrossingGraph(
@@ -2045,7 +2101,27 @@ export function createLayerSweepSession(
2045
2101
  inputPortOrder,
2046
2102
  outputPortOrder,
2047
2103
  );
2048
- let total = countAllCrossings(splitMixedCrossingPorts(graph, canonicalReversed)).total;
2104
+ // Edge orders rank a side's ports per direction; a fixed-side port with
2105
+ // a reversed edge mixes both, so count the sweep's physical port order.
2106
+ for (const layer of graph.layers)
2107
+ for (const node of layer) {
2108
+ const physical = node.ports.length > 1 ? canonicalNodes.get(node.id)?.ports : undefined;
2109
+ if (physical?.length !== node.ports.length) continue;
2110
+ physicalSlot.clear();
2111
+ for (const [index, port] of physical.entries()) physicalSlot.set(port.id, index);
2112
+ if (node.ports.every((port) => physicalSlot.has(port.id)))
2113
+ node.ports.sort((a, b) => physicalSlot.get(a.id)! - physicalSlot.get(b.id)!);
2114
+ }
2115
+ const split = mixedPorts ? splitMixedCrossingPorts(graph, canonicalReversed) : graph;
2116
+ const score = countAllCrossings(
2117
+ split,
2118
+ crossingModel.separateSharedTracks ? "separated" : "hyperedges",
2119
+ );
2120
+ if (probe) {
2121
+ if (split !== graph) probe.mixedPorts = true;
2122
+ if (score.sharedTracks) probe.sharedTracks = true;
2123
+ }
2124
+ let total = score.total;
2049
2125
  const nodeInfluence = Number(
2050
2126
  input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0,
2051
2127
  );
@@ -2151,12 +2227,13 @@ export function createLayerSweepSession(
2151
2227
  !forward,
2152
2228
  nodeRelativePortRanks,
2153
2229
  );
2154
- const fixedIds = new Set(fixedLayer);
2155
- for (let index = 0; index < canonicalEdges.length; index++) {
2230
+ // The fixed layer's edges toward the free layer, in edge order.
2231
+ const byEnd = forward ? canonicalEdgesBySource : canonicalEdgesByTarget;
2232
+ const indices = fixedLayer.flatMap((id) => byEnd.get(id) ?? []).sort((a, b) => a - b);
2233
+ for (const index of indices) {
2156
2234
  const edge = canonicalEdges[index]!;
2157
2235
  const [source, target] = canonicalOrientedEndpoints[index]!;
2158
- if (!fixedIds.has(forward ? source : target) || !freeIds.has(forward ? target : source))
2159
- continue;
2236
+ if (!freeIds.has(forward ? target : source)) continue;
2160
2237
  const endpoint = canonicalEndpoints.get(edge.id)!;
2161
2238
  const rank =
2162
2239
  canonicalDistributor.state.ranks[forward ? endpoint.source : endpoint.target]!;
@@ -2223,6 +2300,20 @@ export function createLayerSweepSession(
2223
2300
  // and same-layer recursion must not be separated into independent sums.
2224
2301
  for (const id of freeLayer) {
2225
2302
  const order = (forward ? inputPortOrder : outputPortOrder).get(id) ?? [];
2303
+ const visit = (edgeId: string) => {
2304
+ const edge = edgeById.get(edgeId)!;
2305
+ const [source, target] = getOrientedEndpoints(edge, orientation);
2306
+ const neighbor = forward ? source : target;
2307
+ const rank = edgeRanks.get(edgeId);
2308
+ if (assignment.layerByNodeId.get(neighbor) === assignment.layerByNodeId.get(id))
2309
+ visits.get(id)!.push(neighbor);
2310
+ else if (rank !== undefined) visits.get(id)!.push(rank);
2311
+ };
2312
+ // One edge forms one group; nothing to sort.
2313
+ if (order.length < 2) {
2314
+ for (const edgeId of order) visit(edgeId);
2315
+ continue;
2316
+ }
2226
2317
  const groups = new Map<string, string[]>();
2227
2318
  for (const edgeId of order) {
2228
2319
  const edge = edgeById.get(edgeId)!;
@@ -2233,25 +2324,18 @@ export function createLayerSweepSession(
2233
2324
  if (group) group.push(edgeId);
2234
2325
  else groups.set(key, [edgeId]);
2235
2326
  }
2327
+ // Port sorting reorders ports, not the edges on one port; those keep
2328
+ // ELK's edge list order. Expanded segments keep their listed edge id.
2329
+ const listed = units?.edgeListRanks?.[forward ? "incoming" : "outgoing"].get(id);
2330
+ const rank = (edgeId: string) => listed?.get(listedEdgeId(edgeId));
2236
2331
  for (const group of groups.values()) {
2237
- // Port sorting reorders ports, not the edges on one port; those keep
2238
- // ELK's edge list order. Expanded segments keep their listed edge id.
2239
- const listed = units?.edgeListRanks?.[forward ? "incoming" : "outgoing"].get(id);
2240
- const rank = (edgeId: string) => listed?.get(listedEdgeId(edgeId));
2241
- group.sort(
2242
- (a, b) =>
2243
- (rank(a) !== undefined && rank(b) !== undefined ? rank(a)! - rank(b)! : 0) ||
2244
- edgeModelOrder.get(a)! - edgeModelOrder.get(b)!,
2245
- );
2246
- for (const edgeId of group) {
2247
- const edge = edgeById.get(edgeId)!;
2248
- const [source, target] = getOrientedEndpoints(edge, orientation);
2249
- const neighbor = forward ? source : target;
2250
- const rank = edgeRanks.get(edgeId);
2251
- if (assignment.layerByNodeId.get(neighbor) === assignment.layerByNodeId.get(id))
2252
- visits.get(id)!.push(neighbor);
2253
- else if (rank !== undefined) visits.get(id)!.push(rank);
2254
- }
2332
+ if (group.length > 1)
2333
+ group.sort(
2334
+ (a, b) =>
2335
+ (rank(a) !== undefined && rank(b) !== undefined ? rank(a)! - rank(b)! : 0) ||
2336
+ edgeModelOrder.get(a)! - edgeModelOrder.get(b)!,
2337
+ );
2338
+ for (const edgeId of group) visit(edgeId);
2255
2339
  }
2256
2340
  }
2257
2341
  return { ranks, visits };
@@ -2288,6 +2372,18 @@ export function createLayerSweepSession(
2288
2372
  const canonicalOrientedEndpoints = canonicalEdges.map((edge) =>
2289
2373
  getOrientedEndpoints(edge, orientation),
2290
2374
  );
2375
+ const canonicalEdgesBySource = new Map<string, number[]>(),
2376
+ canonicalEdgesByTarget = new Map<string, number[]>();
2377
+ canonicalOrientedEndpoints.forEach(([source, target], index) => {
2378
+ for (const [byEnd, id] of [
2379
+ [canonicalEdgesBySource, source],
2380
+ [canonicalEdgesByTarget, target],
2381
+ ] as const) {
2382
+ const list = byEnd.get(id);
2383
+ if (list) list.push(index);
2384
+ else byEnd.set(id, [index]);
2385
+ }
2386
+ });
2291
2387
  const canonicalEndpoints = new Map(
2292
2388
  canonicalEdges.map((edge, index) => [edge.id, canonicalGraph.edges[index]!]),
2293
2389
  );
@@ -2316,6 +2412,13 @@ export function createLayerSweepSession(
2316
2412
  });
2317
2413
  for (const index of touched)
2318
2414
  for (const node of canonicalGraph.layers[index]!) {
2415
+ // One port leaves every edge order tied, and one edge has no order.
2416
+ if (
2417
+ node.ports.length < 2 ||
2418
+ ((outputPortOrder.get(node.id)?.length ?? 0) < 2 &&
2419
+ (inputPortOrder.get(node.id)?.length ?? 0) < 2)
2420
+ )
2421
+ continue;
2319
2422
  const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
2320
2423
  for (const [orders, source] of [
2321
2424
  [outputPortOrder, true],
@@ -2386,6 +2489,7 @@ export function createLayerSweepSession(
2386
2489
  fixed: readonly string[],
2387
2490
  beyond: readonly string[] = [],
2388
2491
  ) => {
2492
+ if (!mixedPorts) return;
2389
2493
  const fixedIds = new Set(fixed);
2390
2494
  const groups = new Map<string, Array<{ id: string; reversed: boolean }>>();
2391
2495
  for (const id of free) {
@@ -2408,6 +2512,7 @@ export function createLayerSweepSession(
2408
2512
  }
2409
2513
  if (![...groups.values()].some((g) => g.some((m) => m.reversed) && g.some((m) => !m.reversed)))
2410
2514
  return;
2515
+ if (probe) probe.mixedPorts = true;
2411
2516
  const index = new Map(free.map((id, i) => [id, i]));
2412
2517
  // The direction whose nodes lead further toward the start of the layer
2413
2518
  // beyond (from the previous sweep) goes first; forward edges by default.
@@ -2638,17 +2743,27 @@ export function createLayerSweepSession(
2638
2743
  shuffleFirstLayer,
2639
2744
  sweep,
2640
2745
  countCrossings: () => countCrossings(working),
2641
- snapshot: () => ({
2642
- physicalPortOrderByNodeId: new Map(
2643
- [...canonicalNodes].map(([id, node]) => [id, node.ports.map((port) => port.id)]),
2644
- ),
2645
- layers: working.map((layer) => [...layer]),
2646
- inputPortOrderByNodeId: clonePortOrders(inputPortOrder),
2647
- outputPortOrderByNodeId: clonePortOrders(outputPortOrder),
2648
- }),
2746
+ snapshot: () => {
2747
+ const physicalPortOrderByNodeId = new Map<string, string[]>();
2748
+ for (const [id, node] of canonicalNodes)
2749
+ physicalPortOrderByNodeId.set(
2750
+ id,
2751
+ node.ports.map((port) => port.id),
2752
+ );
2753
+ return {
2754
+ physicalPortOrderByNodeId,
2755
+ layers: working.map((layer) => [...layer]),
2756
+ inputPortOrderByNodeId: clonePortOrders(inputPortOrder),
2757
+ outputPortOrderByNodeId: clonePortOrders(outputPortOrder),
2758
+ };
2759
+ },
2649
2760
  restore,
2650
2761
  finish,
2651
2762
  currentLayers: () => working,
2763
+ crossingGraph: () => ({
2764
+ layers: working.map((layer) => layer.map((id) => canonicalNodes.get(id)!)),
2765
+ edges: canonicalGraph.edges,
2766
+ }),
2652
2767
  physicalPortOrder: (id) => canonicalNodes.get(id)?.ports.map((port) => port.id) ?? [],
2653
2768
  edgeOrders: (id) => ({
2654
2769
  incoming: inputPortOrder.get(id) ?? [],
@@ -3341,7 +3456,7 @@ function nodeFlowOffset(
3341
3456
  layerByNodeId: ReadonlyMap<string, number>,
3342
3457
  margins?: { leading: number; trailing: number; maxLeading: number; maxTrailing: number },
3343
3458
  ): number {
3344
- const node = input.graph.nodes.find((candidate) => candidate.id === id);
3459
+ const node = findNode(input.graph.nodes, id);
3345
3460
  const alignment = node
3346
3461
  ? (input.nodeSettings?.(node)?.alignment ?? input.settings.alignment)
3347
3462
  : input.settings.alignment;
@@ -3707,7 +3822,85 @@ function implicitEdgeEndpoints(
3707
3822
  ): ReadonlyMap<string, { source: Point; target: Point }> {
3708
3823
  const horizontal = input.direction === "left" || input.direction === "right";
3709
3824
  const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
3710
- const groups = new Map<string, Array<{ edge: GraphEdge; endpoint: "source" | "target" }>>();
3825
+ // Settings stay fixed for the call; resolve each node's and port's once.
3826
+ const nodeSettingsByNode = new Map<
3827
+ GraphNode,
3828
+ ReturnType<NonNullable<typeof input.nodeSettings>>
3829
+ >();
3830
+ const nodeSettingsOf = (node: GraphNode) => {
3831
+ if (!nodeSettingsByNode.has(node)) nodeSettingsByNode.set(node, input.nodeSettings?.(node));
3832
+ return nodeSettingsByNode.get(node);
3833
+ };
3834
+ // Port settings depend on the owning node too, so cache them per node.
3835
+ const portSettingsByNode = new Map<
3836
+ GraphNode,
3837
+ Map<GraphPort, ReturnType<NonNullable<typeof input.portSettings>>>
3838
+ >();
3839
+ const portSettingsOf = (port: GraphPort, node: GraphNode) => {
3840
+ let byPort = portSettingsByNode.get(node);
3841
+ if (!byPort) portSettingsByNode.set(node, (byPort = new Map()));
3842
+ if (!byPort.has(port)) byPort.set(port, input.portSettings?.(port, node));
3843
+ return byPort.get(port);
3844
+ };
3845
+ // Endpoint groups by side and node, in first-entry order.
3846
+ type EndpointGroup = {
3847
+ nodeId: string;
3848
+ side: string;
3849
+ entries: Array<{ edge: GraphEdge; endpoint: "source" | "target" }>;
3850
+ };
3851
+ const groups: EndpointGroup[] = [];
3852
+ const groupsBySide = new Map<string, Map<string, EndpointGroup>>();
3853
+ const modelOrderPromotion =
3854
+ input.settings["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT";
3855
+ const directionReversed = input.direction === "left" || input.direction === "up";
3856
+ const endpointSide = (nodeId: string, portName: string | undefined): string | undefined => {
3857
+ const node = nodeById.get(nodeId);
3858
+ const port = node?.ports?.find((candidate) => candidate.name === portName);
3859
+ const constraints = node ? nodeSettingsOf(node)?.portConstraints : undefined;
3860
+ if (
3861
+ !node ||
3862
+ !port ||
3863
+ (constraints !== "FIXED_SIDE" &&
3864
+ constraints !== "FIXED_ORDER" &&
3865
+ constraints !== "FIXED_RATIO" &&
3866
+ constraints !== "FIXED_POS")
3867
+ ) {
3868
+ return undefined;
3869
+ }
3870
+ const configured = portSettingsOf(port, node)?.["port.side"];
3871
+ return configured === "WEST" || configured === "NORTH"
3872
+ ? "before"
3873
+ : configured === "EAST" || configured === "SOUTH"
3874
+ ? "after"
3875
+ : undefined;
3876
+ };
3877
+ const fixedImplicitSide = (nodeId: string, portName: string | undefined, source: boolean) => {
3878
+ const node = nodeById.get(nodeId);
3879
+ const constraints = node && nodeSettingsOf(node)?.portConstraints;
3880
+ if (
3881
+ portName !== undefined ||
3882
+ !["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(String(constraints))
3883
+ )
3884
+ return undefined;
3885
+ // ELK assigns implicit port sides before cycle breaking. Fixed sides
3886
+ // survive edge reversal; a feedback edge cannot move its endpoint.
3887
+ return source !== directionReversed ? "after" : "before";
3888
+ };
3889
+ const addEntry = (
3890
+ nodeId: string,
3891
+ side: string,
3892
+ entry: { edge: GraphEdge; endpoint: "source" | "target" },
3893
+ ) => {
3894
+ let byNode = groupsBySide.get(side);
3895
+ if (!byNode) groupsBySide.set(side, (byNode = new Map()));
3896
+ const group = byNode.get(nodeId);
3897
+ if (group) group.entries.push(entry);
3898
+ else {
3899
+ const created = { nodeId, side, entries: [entry] };
3900
+ byNode.set(nodeId, created);
3901
+ groups.push(created);
3902
+ }
3903
+ };
3711
3904
  for (const edge of input.graph.edges) {
3712
3905
  if (hasMovableLoopPorts(input, edge)) continue;
3713
3906
  const sourceRect = placement.rectByNodeId.get(edge.sourceId);
@@ -3715,8 +3908,6 @@ function implicitEdgeEndpoints(
3715
3908
  if (!sourceRect || !targetRect) continue;
3716
3909
  const sourceFlow = horizontal ? sourceRect.x : sourceRect.y;
3717
3910
  const targetFlow = horizontal ? targetRect.x : targetRect.y;
3718
- const modelOrderPromotion =
3719
- input.settings["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT";
3720
3911
  const sourceBoundary = externalPortDummyOf(nodeById.get(edge.sourceId) ?? {});
3721
3912
  const targetBoundary = externalPortDummyOf(nodeById.get(edge.targetId) ?? {});
3722
3913
  // A zero-gap boundary can coincide with the node's flow origin.
@@ -3729,40 +3920,6 @@ function implicitEdgeEndpoints(
3729
3920
  : sourceFlow <= targetFlow);
3730
3921
  const feedback =
3731
3922
  input.settings.feedbackEdges === true && orientation?.reversedEdgeIds.has(edge.id) === true;
3732
- const directionReversed = input.direction === "left" || input.direction === "up";
3733
- const endpointSide = (nodeId: string, portName: string | undefined): string | undefined => {
3734
- const node = nodeById.get(nodeId);
3735
- const port = node?.ports?.find((candidate) => candidate.name === portName);
3736
- const constraints = node ? input.nodeSettings?.(node)?.portConstraints : undefined;
3737
- if (
3738
- !node ||
3739
- !port ||
3740
- (constraints !== "FIXED_SIDE" &&
3741
- constraints !== "FIXED_ORDER" &&
3742
- constraints !== "FIXED_RATIO" &&
3743
- constraints !== "FIXED_POS")
3744
- ) {
3745
- return undefined;
3746
- }
3747
- const configured = input.portSettings?.(port, node)?.["port.side"];
3748
- return configured === "WEST" || configured === "NORTH"
3749
- ? "before"
3750
- : configured === "EAST" || configured === "SOUTH"
3751
- ? "after"
3752
- : undefined;
3753
- };
3754
- const fixedImplicitSide = (nodeId: string, portName: string | undefined, source: boolean) => {
3755
- const node = nodeById.get(nodeId);
3756
- const constraints = node && input.nodeSettings?.(node)?.portConstraints;
3757
- if (
3758
- portName !== undefined ||
3759
- !["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(String(constraints))
3760
- )
3761
- return undefined;
3762
- // ELK assigns implicit port sides before cycle breaking. Fixed sides
3763
- // survive edge reversal; a feedback edge cannot move its endpoint.
3764
- return source !== directionReversed ? "after" : "before";
3765
- };
3766
3923
  const sourceSide =
3767
3924
  endpointSide(edge.sourceId, edge.sourcePort) ??
3768
3925
  fixedImplicitSide(edge.sourceId, edge.sourcePort, true) ??
@@ -3771,18 +3928,24 @@ function implicitEdgeEndpoints(
3771
3928
  endpointSide(edge.targetId, edge.targetPort) ??
3772
3929
  fixedImplicitSide(edge.targetId, edge.targetPort, false) ??
3773
3930
  (feedback ? (directionReversed ? "after" : "before") : forward ? "before" : "after");
3774
- const sourceKey = `${edge.sourceId}:${sourceSide}`;
3775
- const targetKey = `${edge.targetId}:${targetSide}`;
3776
- const sourceGroup = groups.get(sourceKey) ?? [];
3777
- sourceGroup.push({ edge, endpoint: "source" });
3778
- groups.set(sourceKey, sourceGroup);
3779
- const targetGroup = groups.get(targetKey) ?? [];
3780
- targetGroup.push({ edge, endpoint: "target" });
3781
- groups.set(targetKey, targetGroup);
3931
+ addEntry(edge.sourceId, sourceSide, { edge, endpoint: "source" });
3932
+ addEntry(edge.targetId, targetSide, { edge, endpoint: "target" });
3782
3933
  }
3783
3934
 
3784
3935
  const result = new Map<string, { source: Point; target: Point }>();
3785
3936
  const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
3937
+ // First positions in swept port orders, as `indexOf` finds them.
3938
+ const orderIndexes = new Map<readonly string[], Map<string, number>>();
3939
+ const orderIndex = (list: readonly string[], id: string) => {
3940
+ let positions = orderIndexes.get(list);
3941
+ if (!positions) {
3942
+ positions = new Map();
3943
+ for (const [index, item] of list.entries())
3944
+ if (!positions.has(item)) positions.set(item, index);
3945
+ orderIndexes.set(list, positions);
3946
+ }
3947
+ return positions.get(id) ?? -1;
3948
+ };
3786
3949
  const placedPorts = new Map<GraphNode, { rect: EntityRect; ports: GraphPort[] | undefined }>();
3787
3950
  const portSideCounts = new Map<GraphNode, Map<unknown, number>>();
3788
3951
  const portSideCount = (node: GraphNode, side: unknown): number => {
@@ -3790,22 +3953,19 @@ function implicitEdgeEndpoints(
3790
3953
  if (!counts) {
3791
3954
  counts = new Map();
3792
3955
  for (const port of node.ports ?? []) {
3793
- const portSide = input.portSettings?.(port, node)?.["port.side"];
3956
+ const portSide = portSettingsOf(port, node)?.["port.side"];
3794
3957
  counts.set(portSide, (counts.get(portSide) ?? 0) + 1);
3795
3958
  }
3796
3959
  portSideCounts.set(node, counts);
3797
3960
  }
3798
3961
  return counts.get(side) ?? 0;
3799
3962
  };
3800
- for (const [key, entries] of groups) {
3801
- const split = key.lastIndexOf(":");
3802
- const nodeId = key.slice(0, split);
3803
- const side = key.slice(split + 1);
3963
+ for (const { nodeId, side, entries } of groups) {
3804
3964
  const rect = placement.rectByNodeId.get(nodeId);
3805
3965
  if (!rect) continue;
3806
3966
  const mergeEdges = input.settings.mergeEdges === true;
3807
3967
  const node = nodeById.get(nodeId);
3808
- const nodeSettings = node ? input.nodeSettings?.(node) : undefined;
3968
+ const nodeSettings = node ? nodeSettingsOf(node) : undefined;
3809
3969
  const hypernode = nodeSettings?.hypernode;
3810
3970
  const unzipping = (input.settings["layerUnzipping.strategy"] ?? "NONE") === "ALTERNATING";
3811
3971
  const crossingStrategy = input.settings["crossingMinimization.strategy"] ?? "LAYER_SWEEP";
@@ -3860,8 +4020,8 @@ function implicitEdgeEndpoints(
3860
4020
  const selected = (
3861
4021
  leftIncoming ? sweptOrder?.inputPortOrderByNodeId : sweptOrder?.outputPortOrderByNodeId
3862
4022
  )?.get(nodeId);
3863
- const a = selected?.indexOf(left.edge.id) ?? -1,
3864
- b = selected?.indexOf(right.edge.id) ?? -1;
4023
+ const a = selected ? orderIndex(selected, left.edge.id) : -1,
4024
+ b = selected ? orderIndex(selected, right.edge.id) : -1;
3865
4025
  if (a >= 0 && b >= 0 && a !== b) return (leftIncoming ? -1 : 1) * (a - b);
3866
4026
  }
3867
4027
  if (interactiveLeftSourceOrder) {
@@ -4006,7 +4166,7 @@ function implicitEdgeEndpoints(
4006
4166
  const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
4007
4167
  const explicitPort = node?.ports?.find((port) => port.name === portName);
4008
4168
  const physicalSide =
4009
- node && explicitPort ? input.portSettings?.(explicitPort, node)?.["port.side"] : undefined;
4169
+ node && explicitPort ? portSettingsOf(explicitPort, node)?.["port.side"] : undefined;
4010
4170
  const singleFixedSidePort =
4011
4171
  constraints === "FIXED_SIDE" &&
4012
4172
  physicalSide !== undefined &&
@@ -4015,9 +4175,7 @@ function implicitEdgeEndpoints(
4015
4175
  // Movable port ordering comes from the layer sweep above. Fixed coordinates
4016
4176
  // and authored anchors must replace the synthesized node-origin fallback.
4017
4177
  const authoredAnchor =
4018
- node && explicitPort
4019
- ? input.portSettings?.(explicitPort, node)?.["port.anchor"]
4020
- : undefined;
4178
+ node && explicitPort ? portSettingsOf(explicitPort, node)?.["port.anchor"] : undefined;
4021
4179
  if (
4022
4180
  portName !== undefined &&
4023
4181
  node &&
@@ -4346,6 +4504,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4346
4504
  flowLayerByNodeId.set(interval.id, flowLayers.length - 1);
4347
4505
  }
4348
4506
  const bodyFlowLayers = flowLayers.map((bounds) => ({ ...bounds }));
4507
+ // Placed node ids per flow layer, in placement order. Layer loops only
4508
+ // move these nodes, so a grouping stays valid while its loop runs.
4509
+ const placedIdsByFlowLayer = () => {
4510
+ const ids = flowLayers.map(() => [] as string[]);
4511
+ for (const id of placement.rectByNodeId.keys()) {
4512
+ const layer = flowLayerByNodeId.get(id);
4513
+ if (layer !== undefined) ids[layer]!.push(id);
4514
+ }
4515
+ return ids;
4516
+ };
4349
4517
  const labelFlowLayers = new Set(
4350
4518
  [...flowLayerByNodeId]
4351
4519
  .filter(([id]) => id.startsWith("__layout_dummy:label:"))
@@ -4473,10 +4641,11 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4473
4641
  labelExtraByGap[gap] = Math.max(labelExtraByGap[gap] ?? 0, extra);
4474
4642
  }
4475
4643
  let labelShift = 0;
4644
+ const labelShiftIds = placedIdsByFlowLayer();
4476
4645
  for (const [layerNo, bounds] of flowLayers.entries()) {
4477
4646
  if (labelShift !== 0) {
4478
- for (const [id, rect] of placement.rectByNodeId) {
4479
- if (flowLayerByNodeId.get(id) !== layerNo) continue;
4647
+ for (const id of labelShiftIds[layerNo]!) {
4648
+ const rect = placement.rectByNodeId.get(id)!;
4480
4649
  mutableRects.set(
4481
4650
  id,
4482
4651
  horizontal ? { ...rect, x: rect.x + labelShift } : { ...rect, y: rect.y + labelShift },
@@ -4931,9 +5100,13 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4931
5100
  candidate.straight = Math.abs(segment.start - segment.end) < 1e-3;
4932
5101
  candidate.slot = increasing ? segment.slot : maximumSlot - segment.slot;
4933
5102
  if (segment.partner !== undefined) {
5103
+ // The route leaves the graph source on the half holding that end.
4934
5104
  const partner = result.segments[segment.partner]!;
4935
- candidate.secondSlot = increasing ? partner.slot : maximumSlot - segment.slot;
4936
- if (!increasing) candidate.slot = maximumSlot - partner.slot;
5105
+ const [first, second] = physicalPort(candidate, "source").graphSource
5106
+ ? [segment, partner]
5107
+ : [partner, segment];
5108
+ candidate.slot = increasing ? first.slot : maximumSlot - first.slot;
5109
+ candidate.secondSlot = increasing ? second.slot : maximumSlot - second.slot;
4937
5110
  const sourcePort = portByCandidate.get(candidate)!.source;
4938
5111
  const offset =
4939
5112
  (increasing ? candidate.sourceCross : candidate.targetCross) -
@@ -4964,11 +5137,13 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4964
5137
  leadingSlots > 0 ? edgeNodeSpacing + (leadingSlots - 1) * edgeEdgeSpacing : 0;
4965
5138
  let nextStart = (flowLayers[0]?.start ?? 0) + leadingWidth;
4966
5139
  // ELK omits ordinary node spacing beside an external-port-only layer.
5140
+ const flowLayerIds = flowLayers.map(() => [] as string[]);
5141
+ for (const [id, layer] of flowLayerByNodeId) flowLayerIds[layer]!.push(id);
4967
5142
  const externalFlowLayers = new Set(
4968
5143
  flowLayers.flatMap((_, layer) => {
4969
- const ids = [...flowLayerByNodeId].filter(([, index]) => index === layer);
5144
+ const ids = flowLayerIds[layer]!;
4970
5145
  return ids.length &&
4971
- ids.every(([id]) => {
5146
+ ids.every((id) => {
4972
5147
  const node = nodeById.get(id);
4973
5148
  const side = node && externalPortDummyOf(node)?.side;
4974
5149
  return horizontal
@@ -4979,10 +5154,11 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4979
5154
  : [];
4980
5155
  }),
4981
5156
  );
5157
+ const shiftedIds = placedIdsByFlowLayer();
4982
5158
  for (const [layerNo, bounds] of flowLayers.entries()) {
4983
5159
  const shift = nextStart - bounds.start;
4984
- for (const [id, rect] of placement.rectByNodeId) {
4985
- if (flowLayerByNodeId.get(id) !== layerNo) continue;
5160
+ for (const id of shiftedIds[layerNo]!) {
5161
+ const rect = placement.rectByNodeId.get(id)!;
4986
5162
  mutableRects.set(
4987
5163
  id,
4988
5164
  horizontal ? { ...rect, x: rect.x + shift } : { ...rect, y: rect.y + shift },
@@ -5340,10 +5516,11 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5340
5516
  }
5341
5517
  }
5342
5518
  let nextStart = flowLayers[0]?.start ?? 0;
5519
+ const shiftedIds = placedIdsByFlowLayer();
5343
5520
  for (const [layerNo, bounds] of flowLayers.entries()) {
5344
5521
  const shift = nextStart - bounds.start;
5345
- for (const [id, rect] of placement.rectByNodeId) {
5346
- if (flowLayerByNodeId.get(id) !== layerNo) continue;
5522
+ for (const id of shiftedIds[layerNo]!) {
5523
+ const rect = placement.rectByNodeId.get(id)!;
5347
5524
  mutableRects.set(
5348
5525
  id,
5349
5526
  horizontal ? { ...rect, x: rect.x + shift } : { ...rect, y: rect.y + shift },
@@ -6677,7 +6854,7 @@ export function normalizePlacementForPortExtents(
6677
6854
  [nextLayer, false],
6678
6855
  ] as const) {
6679
6856
  for (const id of ids) {
6680
- const node = input.graph.nodes.find((candidate) => candidate.id === id);
6857
+ const node = findNode(input.graph.nodes, id);
6681
6858
  const rect = placement.rectByNodeId.get(id);
6682
6859
  if (!node || !rect) continue;
6683
6860
  const bodyStart = horizontal ? rect.x : rect.y;