@statelyai/layout 0.4.2 → 0.5.0

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 (56) hide show
  1. package/README.md +40 -3
  2. package/dist/elkjs/index.cjs +2 -2
  3. package/dist/elkjs/index.d.cts +1 -1
  4. package/dist/elkjs/index.d.mts +1 -1
  5. package/dist/elkjs/index.mjs +2 -2
  6. package/dist/elkjs/main.cjs +1 -1
  7. package/dist/elkjs/main.mjs +1 -1
  8. package/dist/elkjs/worker.cjs +2 -2
  9. package/dist/elkjs/worker.mjs +2 -2
  10. package/dist/{elkjs-CSi7oB5J.mjs → elkjs-DBkalvt1.mjs} +25 -3
  11. package/dist/{elkjs-CnBvdyBu.cjs → elkjs-DonMqGjf.cjs} +25 -3
  12. package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
  13. package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
  14. package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
  15. package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
  16. package/dist/index.cjs +7 -4
  17. package/dist/index.d.cts +4 -4
  18. package/dist/index.d.mts +4 -4
  19. package/dist/index.mjs +4 -4
  20. package/dist/layered/index.cjs +1 -1
  21. package/dist/layered/index.d.cts +2 -2
  22. package/dist/layered/index.d.mts +2 -2
  23. package/dist/layered/index.mjs +1 -1
  24. package/dist/{layered-BsAdm_u8.mjs → layered-CmL7oynw.mjs} +245 -69
  25. package/dist/{layered-BJu8A21s.cjs → layered-SB2mlAqt.cjs} +245 -69
  26. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-BXoIpYL4.mjs} +1 -1
  27. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CAyJ-I-u.cjs} +1 -1
  28. package/dist/routing/index.cjs +5 -2
  29. package/dist/routing/index.d.cts +3 -3
  30. package/dist/routing/index.d.mts +3 -3
  31. package/dist/routing/index.mjs +2 -2
  32. package/dist/routing-D-LfMXOF.mjs +5189 -0
  33. package/dist/routing-Phxu1R0x.cjs +5423 -0
  34. package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
  35. package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
  36. package/package.json +4 -1
  37. package/src/authoring/partial.ts +1 -7
  38. package/src/authoring/routing.ts +64 -19
  39. package/src/elkjs/index.ts +26 -0
  40. package/src/layered/feedback-chains.ts +22 -4
  41. package/src/layered/index.ts +173 -41
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/strategies.ts +143 -15
  44. package/src/routing/algorithms.ts +1436 -148
  45. package/src/routing/bezier-fit.ts +605 -0
  46. package/src/routing/bezier-visibility.ts +107 -0
  47. package/src/routing/bezier.ts +596 -0
  48. package/src/routing/engine.ts +65 -13
  49. package/src/routing/index.ts +14 -0
  50. package/src/routing/model.ts +185 -1
  51. package/src/routing/path.ts +1 -1
  52. package/src/routing/policy-geometry.ts +536 -0
  53. package/src/routing/search.ts +10 -2
  54. package/src/routing/types.ts +77 -1
  55. package/dist/routing-CR8flv8b.mjs +0 -2780
  56. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -82,6 +82,8 @@ import {
82
82
  placeNodesInLayers,
83
83
  placeNodesInteractively,
84
84
  placePorts,
85
+ recordSweptPortOrder,
86
+ trackLabelPortSideFeedback,
85
87
  routeEdgesOrthogonally,
86
88
  routeEdgesWithPolylines,
87
89
  routeEdgesWithSplines,
@@ -1199,53 +1201,101 @@ function runSeparatedComponents<N, E, G, P>(
1199
1201
  bottom: options.padding?.bottom ?? 12,
1200
1202
  left: options.padding?.left ?? 12,
1201
1203
  };
1204
+ // Components are packed in reading order, row by row. For UP and LEFT layouts
1205
+ // the packing is mirrored along the flow so later components follow the first
1206
+ // one's flow instead of preceding it (e.g. an isolated state below an UP
1207
+ // statechart's start): LEFT mirrors each row, UP mirrors the stack of rows.
1208
+ // Compaction measures every shape in that mirrored frame.
1209
+ const direction = options.direction ?? graph.direction ?? "right";
1210
+ type Rect = { x: number; y: number; width: number; height: number };
1211
+ // Everything a component draws, so compaction keeps other components off its
1212
+ // routes and labels as well as its nodes.
1213
+ const drawnRects = (result: VisualGraph<N, E, G, P>): Rect[] => {
1214
+ const rects: Rect[] = [];
1215
+ for (const node of result.nodes) {
1216
+ const x = node.x ?? 0,
1217
+ y = node.y ?? 0;
1218
+ rects.push({ x, y, width: node.width ?? 0, height: node.height ?? 0 });
1219
+ for (const port of node.ports ?? [])
1220
+ rects.push({
1221
+ x: x + (port.x ?? 0),
1222
+ y: y + (port.y ?? 0),
1223
+ width: port.width ?? 0,
1224
+ height: port.height ?? 0,
1225
+ });
1226
+ }
1227
+ for (const edge of result.edges) {
1228
+ const points = edge.points ?? [];
1229
+ for (let index = 1; index < points.length; index++) {
1230
+ const from = points[index - 1]!,
1231
+ to = points[index]!;
1232
+ const x = Math.min(from.x, to.x),
1233
+ y = Math.min(from.y, to.y);
1234
+ rects.push({ x, y, width: Math.max(from.x, to.x) - x, height: Math.max(from.y, to.y) - y });
1235
+ }
1236
+ if ((edge.width ?? 0) > 0 || (edge.height ?? 0) > 0)
1237
+ rects.push({
1238
+ x: edge.x ?? 0,
1239
+ y: edge.y ?? 0,
1240
+ width: edge.width ?? 0,
1241
+ height: edge.height ?? 0,
1242
+ });
1243
+ }
1244
+ return rects;
1245
+ };
1246
+ const rows: Array<Array<{ component: (typeof components)[number]; x: number }>> = [];
1202
1247
  let x = 0;
1248
+ for (const component of components) {
1249
+ if (x > 0 && x + component.bounds.width > maxRowWidth) x = 0;
1250
+ if (x === 0) rows.push([]);
1251
+ rows.at(-1)!.push({ component, x });
1252
+ x += component.bounds.width + componentSpacing;
1253
+ }
1203
1254
  let y = 0;
1204
- let rowHeight = 0;
1205
1255
  const compactConnectedComponents = options.settings?.["compaction.connectedComponents"] === true;
1206
1256
  const placedShapes: Array<{ left: number; right: number; top: number; bottom: number }> = [];
1207
1257
  const nodeResults = new Map<string, VisualNode<N, P>>();
1208
1258
  const edgeResults = new Map<string, (typeof components)[number]["result"]["edges"][number]>();
1209
1259
  const placements: Array<{ component: (typeof components)[number]; x: number; y: number }> = [];
1210
- for (const component of components) {
1211
- if (x > 0 && x + component.bounds.width > maxRowWidth) {
1212
- x = 0;
1213
- y += rowHeight + componentSpacing;
1214
- rowHeight = 0;
1215
- }
1216
- let compactedY = y;
1217
- if (compactConnectedComponents && y > 0) {
1218
- let requiredY = 0;
1219
- for (const node of component.result.nodes) {
1220
- const localLeft = x + (node.x ?? 0) - component.bounds.left;
1221
- const localRight = localLeft + (node.width ?? 0);
1222
- const localTop = (node.y ?? 0) - component.bounds.top;
1223
- for (const placed of placedShapes) {
1224
- if (localRight <= placed.left || localLeft >= placed.right) continue;
1225
- requiredY = Math.max(requiredY, placed.bottom + componentSpacing - localTop);
1226
- }
1260
+ for (const row of rows) {
1261
+ const rowWidth = Math.max(...row.map((entry) => entry.x + entry.component.bounds.width));
1262
+ let rowHeight = 0;
1263
+ for (const { component, x: packedX } of row) {
1264
+ const { bounds } = component;
1265
+ const left = direction === "left" ? rowWidth - packedX - bounds.width : packedX;
1266
+ const shapes = drawnRects(component.result).map((rect) => ({
1267
+ left: left + rect.x - bounds.left,
1268
+ top:
1269
+ direction === "up"
1270
+ ? bounds.top + bounds.height - rect.y - rect.height
1271
+ : rect.y - bounds.top,
1272
+ width: rect.width,
1273
+ height: rect.height,
1274
+ }));
1275
+ let compactedY = y;
1276
+ if (compactConnectedComponents && y > 0) {
1277
+ let requiredY = 0;
1278
+ for (const shape of shapes)
1279
+ for (const placed of placedShapes) {
1280
+ if (shape.left + shape.width <= placed.left || shape.left >= placed.right) continue;
1281
+ requiredY = Math.max(requiredY, placed.bottom + componentSpacing - shape.top);
1282
+ }
1283
+ compactedY = Math.min(y, requiredY);
1227
1284
  }
1228
- compactedY = Math.min(y, requiredY);
1285
+ for (const shape of shapes)
1286
+ placedShapes.push({
1287
+ left: shape.left,
1288
+ right: shape.left + shape.width,
1289
+ top: compactedY + shape.top,
1290
+ bottom: compactedY + shape.top + shape.height,
1291
+ });
1292
+ placements.push({ component, x: left, y: compactedY });
1293
+ rowHeight = Math.max(rowHeight, bounds.height);
1229
1294
  }
1230
- for (const node of component.result.nodes)
1231
- placedShapes.push({
1232
- left: x + (node.x ?? 0) - component.bounds.left,
1233
- right: x + (node.x ?? 0) - component.bounds.left + (node.width ?? 0),
1234
- top: compactedY + (node.y ?? 0) - component.bounds.top,
1235
- bottom: compactedY + (node.y ?? 0) - component.bounds.top + (node.height ?? 0),
1236
- });
1237
- placements.push({ component, x, y: compactedY });
1238
- x += component.bounds.width + componentSpacing;
1239
- rowHeight = Math.max(rowHeight, component.bounds.height);
1295
+ y += rowHeight + componentSpacing;
1240
1296
  }
1241
- // Components are packed in reading order. For UP and LEFT layouts, mirror the
1242
- // packing along the flow so later components follow the first one's flow
1243
- // instead of preceding it (e.g. an isolated state below an UP statechart's start).
1244
- const direction = options.direction ?? graph.direction ?? "right";
1245
- const packedWidth = Math.max(0, ...placements.map((p) => p.x + p.component.bounds.width));
1246
1297
  const packedHeight = Math.max(0, ...placements.map((p) => p.y + p.component.bounds.height));
1247
- for (const { component, x: packedX, y: packedY } of placements) {
1248
- const left = direction === "left" ? packedWidth - packedX - component.bounds.width : packedX;
1298
+ for (const { component, x: left, y: packedY } of placements) {
1249
1299
  const top = direction === "up" ? packedHeight - packedY - component.bounds.height : packedY;
1250
1300
  const dx = padding.left + left - component.bounds.left;
1251
1301
  const dy = padding.top + top - component.bounds.top;
@@ -1324,7 +1374,10 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1324
1374
  graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
1325
1375
  options: LayeredLayoutOptions,
1326
1376
  context?: LayoutExecutionContext,
1377
+ /** Pairs and segments that keep their former routing because the new one bent them diagonally. */
1378
+ fallbacks: OrthogonalFallbacks = { antiparallelPairs: new Set(), feedbackSegments: new Set() },
1327
1379
  ): Generator<LayeredCrossingPhase, VisualGraph<N, E, G, P>, LayerOrder> {
1380
+ const scopeOptions = options;
1328
1381
  if ((options.settings?.["spacing.edgeEdge"] ?? 10) < 2) {
1329
1382
  options = { ...options, settings: { ...options.settings, "spacing.edgeEdge": 2 } };
1330
1383
  }
@@ -1683,7 +1736,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1683
1736
  },
1684
1737
  };
1685
1738
  }
1686
- const northSouth = insertNorthSouthPortDummies(expanded);
1739
+ const northSouth = insertNorthSouthPortDummies(expanded, preparedPorts);
1687
1740
  expanded = northSouth.expansion;
1688
1741
  if (prepareModelOrder) markInitialModelOrderPrepared(expanded.input, preparedPorts!);
1689
1742
  const associates = new Map<string, string[]>(),
@@ -1789,6 +1842,8 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1789
1842
  minimize: minimizeScope,
1790
1843
  finishOrder: finishCrossingOrder,
1791
1844
  };
1845
+ const crossingOrder = order;
1846
+ recordSweptPortOrder(graph, direction, order);
1792
1847
  traceLayeredPhase(() => ({
1793
1848
  kind: "crossing-order",
1794
1849
  scope: graph.id,
@@ -2154,6 +2209,10 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2154
2209
  // rather than moving or orthogonalizing its routes afterwards.
2155
2210
  if (options.strategies?.routeEdges)
2156
2211
  measure("post-compaction", () => applyPostCompaction(expanded.input, placement));
2212
+ const labelPortSideSegments = trackLabelPortSideFeedback(
2213
+ expanded.input,
2214
+ fallbacks.feedbackSegments,
2215
+ );
2157
2216
  let expandedRoutes = measure("edge-routing", () =>
2158
2217
  edgeRouter(expanded.input, expanded.orientation, placement),
2159
2218
  );
@@ -2218,6 +2277,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2218
2277
  const outerAntiparallelLabelIds = new Set<string>();
2219
2278
  const parallelLabelPositions = new Map<string, Point>();
2220
2279
  const postCompactionNodeCrossDeltas = new Map<string, number>();
2280
+ const flowOrderedPairs = new Map<string, readonly [string, string]>();
2221
2281
  const horizontalAntiparallelFlow = direction === "left" || direction === "right";
2222
2282
  const usesVizInteractivePlacementProfile =
2223
2283
  options.strategies?.routeEdges === undefined &&
@@ -2313,6 +2373,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2313
2373
  const horizontal = horizontalAntiparallelFlow;
2314
2374
  const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
2315
2375
  const pairEdges = new Map<string, GraphEdge[]>();
2376
+ const flowOrder = direction === "left" || direction === "up";
2316
2377
  for (const edge of graph.edges) {
2317
2378
  if (edge.sourceId === edge.targetId || (edge.width ?? 0) <= 0 || (edge.height ?? 0) <= 0) {
2318
2379
  continue;
@@ -2322,7 +2383,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2322
2383
  pair.push(edge);
2323
2384
  pairEdges.set(key, pair);
2324
2385
  }
2325
- for (const pair of pairEdges.values()) {
2386
+ for (const [pairKey, pair] of pairEdges) {
2326
2387
  if (
2327
2388
  pair.length !== 2 ||
2328
2389
  pair[0]!.sourceId !== pair[1]!.targetId ||
@@ -2363,9 +2424,13 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2363
2424
  const hasFlexiblePairPorts = hasFlexiblePorts(firstNode) && hasFlexiblePorts(secondNode);
2364
2425
  if (isolatedAntiparallelPair && !hasFlexiblePairPorts) continue;
2365
2426
 
2366
- const firstBeforeSecond = horizontal
2367
- ? firstRect.x + firstRect.width / 2 < secondRect.x + secondRect.width / 2
2368
- : firstRect.y + firstRect.height / 2 < secondRect.y + secondRect.height / 2;
2427
+ // "Before" is in flow order: UP and LEFT layouts flow toward smaller coordinates.
2428
+ const pairFlowOrder = flowOrder && !fallbacks.antiparallelPairs.has(pairKey);
2429
+ const firstBeforeSecond =
2430
+ (horizontal
2431
+ ? firstRect.x + firstRect.width / 2 < secondRect.x + secondRect.width / 2
2432
+ : firstRect.y + firstRect.height / 2 < secondRect.y + secondRect.height / 2) !==
2433
+ pairFlowOrder;
2369
2434
  const beforeRect = firstBeforeSecond ? firstRect : secondRect;
2370
2435
  const afterRect = firstBeforeSecond ? secondRect : firstRect;
2371
2436
  const forward = pair.find((edge) =>
@@ -2374,6 +2439,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2374
2439
  : edge.sourceId === pair[0]!.targetId,
2375
2440
  );
2376
2441
  if (!forward?.sourcePort || !forward.targetPort) continue;
2442
+ if (pairFlowOrder) flowOrderedPairs.set(pairKey, [firstNode.id, secondNode.id]);
2377
2443
 
2378
2444
  let cross = Math.min(
2379
2445
  horizontal ? firstRect.y : firstRect.x,
@@ -2554,6 +2620,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2554
2620
  routes.pointsByEdgeId as Map<string, readonly Point[]>,
2555
2621
  placement.rectByNodeId as Map<string, EntityRect>,
2556
2622
  horizontal,
2623
+ Number(options.settings?.["spacing.edgeEdge"] ?? 10),
2557
2624
  );
2558
2625
  }
2559
2626
  if (options.settings?.["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT") {
@@ -2667,8 +2734,21 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2667
2734
  };
2668
2735
  }
2669
2736
 
2737
+ // Stacking the pair's labels from the padding and aligning both endpoints
2738
+ // moves the nodes without regard for their layer neighbors, so it only
2739
+ // applies when the two nodes connect to nothing else.
2740
+ const connectsOnlyTo = (nodeId: string, otherId: string) =>
2741
+ graph.edges.every(
2742
+ (edge) =>
2743
+ edge.sourceId === edge.targetId ||
2744
+ (edge.sourceId !== nodeId && edge.targetId !== nodeId) ||
2745
+ edge.sourceId === otherId ||
2746
+ edge.targetId === otherId,
2747
+ );
2670
2748
  for (const pair of labeledEdgesByPair.values()) {
2671
2749
  if (pair.length < 2) continue;
2750
+ const { sourceId, targetId } = pair[0]!;
2751
+ if (!connectsOnlyTo(sourceId, targetId) || !connectsOnlyTo(targetId, sourceId)) continue;
2672
2752
  const sourceRect = mutableRects.get(pair[0]!.sourceId);
2673
2753
  const targetRect = mutableRects.get(pair[0]!.targetId);
2674
2754
  if (!sourceRect || !targetRect) continue;
@@ -3249,6 +3329,41 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3249
3329
  });
3250
3330
  }
3251
3331
 
3332
+ // Diagonals are never valid orthogonal routes. A flow-ordered antiparallel
3333
+ // pair or a label-port-side feedback chain that came out diagonal falls back
3334
+ // to its former routing when that has fewer diagonals.
3335
+ if (edgeRouting === "ORTHOGONAL") {
3336
+ const diagonalEdges = edges.filter((edge) => countDiagonals(edge.points) > 0);
3337
+ const antiparallelPairs = [...flowOrderedPairs].flatMap(([key, nodeIds]) =>
3338
+ diagonalEdges.some(
3339
+ (edge) => nodeIds.includes(edge.sourceId) || nodeIds.includes(edge.targetId),
3340
+ )
3341
+ ? [key]
3342
+ : [],
3343
+ );
3344
+ const feedbackSegments = diagonalEdges.flatMap((edge) => {
3345
+ const segments = expanded.segmentIdsByEdgeId.get(edge.id) ?? [edge.id];
3346
+ return segments.some((id) => labelPortSideSegments.has(id)) ? segments : [];
3347
+ });
3348
+ if (antiparallelPairs.length > 0 || feedbackSegments.length > 0) {
3349
+ const retry = createLayeredScopePipeline(
3350
+ graph,
3351
+ scopeOptions,
3352
+ context && { ...context, diagnostics: [] },
3353
+ {
3354
+ antiparallelPairs: new Set([...fallbacks.antiparallelPairs, ...antiparallelPairs]),
3355
+ feedbackSegments: new Set([...fallbacks.feedbackSegments, ...feedbackSegments]),
3356
+ },
3357
+ );
3358
+ const prepared = retry.next();
3359
+ const resumed = prepared.done ? prepared : retry.next(crossingOrder);
3360
+ if (!resumed.done) throw new Error("Layered scope yielded more than one crossing phase");
3361
+ const diagonals = (routed: readonly { points?: readonly Point[] }[]) =>
3362
+ routed.reduce((sum, edge) => sum + countDiagonals(edge.points), 0);
3363
+ if (diagonals(resumed.value.edges) < diagonals(edges)) return resumed.value;
3364
+ }
3365
+ }
3366
+
3252
3367
  return setElkjs0111ResultPolicy(
3253
3368
  { ...graph, direction, nodes, edges },
3254
3369
  {
@@ -3261,6 +3376,23 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3261
3376
  );
3262
3377
  }
3263
3378
 
3379
+ interface OrthogonalFallbacks {
3380
+ /** Antiparallel pair keys that keep coordinate order instead of flow order. */
3381
+ antiparallelPairs: ReadonlySet<string>;
3382
+ /** Feedback segments that keep the plain detour instead of label-dummy port sides. */
3383
+ feedbackSegments: ReadonlySet<string>;
3384
+ }
3385
+
3386
+ function countDiagonals(points: readonly Point[] | undefined): number {
3387
+ let count = 0;
3388
+ for (let index = 1; index < (points?.length ?? 0); index++) {
3389
+ const a = points![index - 1]!;
3390
+ const b = points![index]!;
3391
+ if (Math.abs(a.x - b.x) > 1e-6 && Math.abs(a.y - b.y) > 1e-6) count++;
3392
+ }
3393
+ return count;
3394
+ }
3395
+
3264
3396
  /**
3265
3397
  * Deterministic native layered layout for an `@statelyai/graph` graph.
3266
3398
  *
@@ -27,7 +27,11 @@ export interface NorthSouthPortExpansion {
27
27
  }
28
28
 
29
29
  /** Detach fixed cross-axis ports into same-layer units before crossing minimization. */
30
- export function insertNorthSouthPortDummies(expansion: LongEdgeExpansion): NorthSouthPortExpansion {
30
+ export function insertNorthSouthPortDummies(
31
+ expansion: LongEdgeExpansion,
32
+ /** Physical port order after ELK's model-order sort, by node id. */
33
+ sortedPorts?: ReadonlyMap<string, readonly string[]>,
34
+ ): NorthSouthPortExpansion {
31
35
  const { input, orientation } = expansion;
32
36
  const vertical = input.direction === "down" || input.direction === "up";
33
37
  const forward =
@@ -152,14 +156,23 @@ export function insertNorthSouthPortDummies(expansion: LongEdgeExpansion): North
152
156
  const roleOrder = (origin: CrossPortOrigin) =>
153
157
  origin.input && origin.output ? 2 : origin.input ? 0 : 1;
154
158
  const negativeFlow = input.direction === "left" || input.direction === "up";
159
+ // With model order, ELK reverses each side's output-only ports before
160
+ // creating dummies (modelOrderNorthSouthInputReversing).
161
+ const modelOrder = (input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE";
155
162
  const orderedOrigins = input.graph.nodes.flatMap((node) => {
156
163
  const entries = [...origins].filter(([, origin]) => origin.node.id === node.id);
157
164
  const constraints = input.nodeSettings?.(node)?.portConstraints;
165
+ const sorted = sortedPorts?.get(node.id);
158
166
  const coordinate = (origin: CrossPortOrigin) => {
167
+ const flip = modelOrder && origin.output && !origin.input ? -1 : 1;
159
168
  if (constraints === "FIXED_POS" || constraints === "FIXED_RATIO")
160
- return (vertical ? (origin.port.y ?? 0) : (origin.port.x ?? 0)) * (negativeFlow ? -1 : 1);
161
- const index = node.ports?.indexOf(origin.port) ?? 0;
162
- return origin.beforeOwner ? index : -index;
169
+ return (
170
+ (vertical ? (origin.port.y ?? 0) : (origin.port.x ?? 0)) * (negativeFlow ? -1 : 1) * flip
171
+ );
172
+ const index = sorted
173
+ ? sorted.indexOf(JSON.stringify([node.id, origin.port.name]))
174
+ : (node.ports?.indexOf(origin.port) ?? 0);
175
+ return (origin.beforeOwner ? index : -index) * flip;
163
176
  };
164
177
  return entries.sort(
165
178
  ([, a], [, b]) =>
@@ -72,6 +72,24 @@ const modelSweepInputs = new WeakSet<LayeredPhaseInput>();
72
72
 
73
73
  const nodeRelativePortRanksByInput = new WeakMap<LayeredPhaseInput, boolean>();
74
74
 
75
+ const labelPortSideFeedbackByInput = new WeakMap<
76
+ LayeredPhaseInput,
77
+ { exempt: ReadonlySet<string>; routed: Set<string> }
78
+ >();
79
+
80
+ /**
81
+ * @internal Collect the feedback segments routed to a center-label dummy's port
82
+ * sides. Exempt segments keep the plain feedback detour instead.
83
+ */
84
+ export function trackLabelPortSideFeedback(
85
+ input: LayeredPhaseInput,
86
+ exempt: ReadonlySet<string>,
87
+ ): ReadonlySet<string> {
88
+ const routed = new Set<string>();
89
+ labelPortSideFeedbackByInput.set(input, { exempt, routed });
90
+ return routed;
91
+ }
92
+
75
93
  function getOrientedEndpoints(
76
94
  edge: GraphEdge,
77
95
  orientation: AcyclicOrientation,
@@ -1794,6 +1812,8 @@ function sortLayerByAdjacentPosition(
1794
1812
  sameLayerNeighbors: ReadonlyMap<string, readonly string[]> = new Map(),
1795
1813
  orderedVisits?: ReadonlyMap<string, readonly (number | string)[]>,
1796
1814
  forcedCompare?: (left: string, right: string) => number,
1815
+ /** Nodes ELK's forced model-order comparator ties with everything. */
1816
+ forcedTies?: ReadonlySet<string>,
1797
1817
  ): void {
1798
1818
  const originalIndex = new Map(layer.map((nodeId, index) => [nodeId, index] as const));
1799
1819
  const adjacentPosition = new Map<string, number | undefined>();
@@ -1870,6 +1890,7 @@ function sortLayerByAdjacentPosition(
1870
1890
  }
1871
1891
 
1872
1892
  const compare = (a: string, b: string) => {
1893
+ if (forcedTies?.has(a) || forcedTies?.has(b)) return 0;
1873
1894
  return (
1874
1895
  forcedCompare?.(a, b) ||
1875
1896
  (adjacentPosition.get(a) ?? 0) - (adjacentPosition.get(b) ?? 0) ||
@@ -2185,6 +2206,18 @@ export function createLayerSweepSession(
2185
2206
  usesInitialModelOrder && input.settings["crossingMinimization.forceNodeModelOrder"] === true
2186
2207
  ? normalModelComparator(input)
2187
2208
  : undefined;
2209
+ // ELK's forced comparator returns 0 for separate-layer nodes before
2210
+ // consulting barycenters, so insertion sort never moves past them.
2211
+ const forcedTies = forcedCompare
2212
+ ? new Set(
2213
+ input.graph.nodes
2214
+ .filter((node) => {
2215
+ const constraint = input.nodeSettings?.(node)?.["layering.layerConstraint"];
2216
+ return constraint === "FIRST_SEPARATE" || constraint === "LAST_SEPARATE";
2217
+ })
2218
+ .map((node) => node.id),
2219
+ )
2220
+ : undefined;
2188
2221
  const usePortRanks = true;
2189
2222
  const medianWeights = new Map<string, number>();
2190
2223
  const listedEdgeIds = new Map<string, string>();
@@ -2440,8 +2473,10 @@ export function createLayerSweepSession(
2440
2473
  (working[firstLayerIndex] ?? []).map((id) => [id, random.nextDouble()]),
2441
2474
  );
2442
2475
  const compare = (left: string, right: string) =>
2443
- forcedCompare?.(left, right) ||
2444
- (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0);
2476
+ forcedTies?.has(left) || forcedTies?.has(right)
2477
+ ? 0
2478
+ : forcedCompare?.(left, right) ||
2479
+ (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0);
2445
2480
  const first = working[firstLayerIndex];
2446
2481
  if (first) {
2447
2482
  if (forcedCompare)
@@ -2596,6 +2631,7 @@ export function createLayerSweepSession(
2596
2631
  sameLayerNeighbors(isForward),
2597
2632
  adjacent.visits,
2598
2633
  forcedCompare,
2634
+ forcedTies,
2599
2635
  );
2600
2636
  }
2601
2637
  groupMixedPortDirections(current, previous, working[layer + 1]);
@@ -2625,6 +2661,7 @@ export function createLayerSweepSession(
2625
2661
  sameLayerNeighbors(isForward),
2626
2662
  adjacent.visits,
2627
2663
  forcedCompare,
2664
+ forcedTies,
2628
2665
  );
2629
2666
  }
2630
2667
  groupMixedPortDirections(current, next, working[layer - 1]);
@@ -5845,6 +5882,25 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5845
5882
  input,
5846
5883
  )
5847
5884
  : targetFallback;
5885
+ // A reversed chain enters a center-label dummy through its forward-side
5886
+ // port and leaves through its backward-side port: route to those sides.
5887
+ const [forwardSide, backwardSide] = (
5888
+ {
5889
+ right: ["EAST", "WEST"],
5890
+ left: ["WEST", "EAST"],
5891
+ down: ["SOUTH", "NORTH"],
5892
+ up: ["NORTH", "SOUTH"],
5893
+ } as const
5894
+ )[input.direction];
5895
+ const labelPortSides = labelPortSideFeedbackByInput.get(input);
5896
+ const faces = (node: GraphNode, side: unknown, away: string) =>
5897
+ !fixedSideFeedback &&
5898
+ !labelPortSides?.exempt.has(edge.id) &&
5899
+ node.id.startsWith("__layout_dummy:label:") &&
5900
+ side === away;
5901
+ const sourceFace = faces(source, feedbackSourcePortSide, backwardSide) ? -1 : 1;
5902
+ const targetFace = faces(target, feedbackTargetPortSide, forwardSide) ? -1 : 1;
5903
+ if (sourceFace < 0 || targetFace < 0) labelPortSides?.routed.add(edge.id);
5848
5904
  if (horizontal) {
5849
5905
  const sign = fixedSideFeedback
5850
5906
  ? Math.sign(sourceRect.x - targetRect.x) || 1
@@ -5859,16 +5915,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5859
5915
  fixedSideFeedback && input.settings.unnecessaryBendpoints === true;
5860
5916
  pointsByEdgeId.set(edge.id, [
5861
5917
  start,
5862
- { x: start.x + sign * flowSpacing, y: start.y },
5863
- { x: start.x + sign * flowSpacing, y: outerCross },
5918
+ { x: start.x + sourceFace * sign * flowSpacing, y: start.y },
5919
+ { x: start.x + sourceFace * sign * flowSpacing, y: outerCross },
5864
5920
  ...(preserveCenters
5865
5921
  ? [
5866
5922
  { x: sourceRect.x + sourceRect.width / 2, y: outerCross },
5867
5923
  { x: targetRect.x + targetRect.width / 2, y: outerCross },
5868
5924
  ]
5869
5925
  : []),
5870
- { x: end.x - sign * flowSpacing, y: outerCross },
5871
- { x: end.x - sign * flowSpacing, y: end.y },
5926
+ { x: end.x - targetFace * sign * flowSpacing, y: outerCross },
5927
+ { x: end.x - targetFace * sign * flowSpacing, y: end.y },
5872
5928
  end,
5873
5929
  ]);
5874
5930
  } else {
@@ -5885,16 +5941,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5885
5941
  fixedSideFeedback && input.settings.unnecessaryBendpoints === true;
5886
5942
  pointsByEdgeId.set(edge.id, [
5887
5943
  start,
5888
- { x: start.x, y: start.y + sign * flowSpacing },
5889
- { x: outerCross, y: start.y + sign * flowSpacing },
5944
+ { x: start.x, y: start.y + sourceFace * sign * flowSpacing },
5945
+ { x: outerCross, y: start.y + sourceFace * sign * flowSpacing },
5890
5946
  ...(preserveCenters
5891
5947
  ? [
5892
5948
  { x: outerCross, y: sourceRect.y + sourceRect.height / 2 },
5893
5949
  { x: outerCross, y: targetRect.y + targetRect.height / 2 },
5894
5950
  ]
5895
5951
  : []),
5896
- { x: outerCross, y: end.y - sign * flowSpacing },
5897
- { x: end.x, y: end.y - sign * flowSpacing },
5952
+ { x: outerCross, y: end.y - targetFace * sign * flowSpacing },
5953
+ { x: end.x, y: end.y - targetFace * sign * flowSpacing },
5898
5954
  end,
5899
5955
  ]);
5900
5956
  }
@@ -6528,6 +6584,67 @@ export const routeEdgesOrthogonally = routeEdges("ORTHOGONAL");
6528
6584
  export const routeEdgesWithPolylines = routeEdges("POLYLINE");
6529
6585
  export const routeEdgesWithSplines = routeEdges("SPLINES");
6530
6586
 
6587
+ type PortSide = "NORTH" | "SOUTH" | "WEST" | "EAST";
6588
+ const sweptPortRanks = new WeakMap<
6589
+ readonly GraphPort<unknown>[],
6590
+ (port: GraphPort<unknown>, side: PortSide) => number | undefined
6591
+ >();
6592
+
6593
+ /**
6594
+ * Order movable ports along each physical side as crossing minimization left
6595
+ * them, as ELK places ports from its swept port lists. The lists are
6596
+ * clockwise per canonical side.
6597
+ */
6598
+ export function recordSweptPortOrder(
6599
+ graph: Pick<LayeredPhaseInput["graph"], "nodes" | "edges">,
6600
+ direction: LayeredPhaseInput["direction"],
6601
+ order: LayerOrder,
6602
+ ): void {
6603
+ // Unconnected ports carry no swept decision.
6604
+ const connected = new Set(
6605
+ graph.edges.flatMap((edge) => [
6606
+ JSON.stringify([edge.sourceId, edge.sourcePort]),
6607
+ JSON.stringify([edge.targetId, edge.targetPort]),
6608
+ ]),
6609
+ );
6610
+ const vertical = direction === "down" || direction === "up";
6611
+ const reverse = direction === "up" || direction === "left";
6612
+ const canonical: Record<PortSide, PortSide> =
6613
+ direction === "right"
6614
+ ? { NORTH: "NORTH", EAST: "EAST", SOUTH: "SOUTH", WEST: "WEST" }
6615
+ : direction === "left"
6616
+ ? { NORTH: "NORTH", EAST: "WEST", SOUTH: "SOUTH", WEST: "EAST" }
6617
+ : direction === "down"
6618
+ ? { NORTH: "WEST", EAST: "SOUTH", SOUTH: "EAST", WEST: "NORTH" }
6619
+ : { NORTH: "EAST", EAST: "SOUTH", SOUTH: "WEST", WEST: "NORTH" };
6620
+ for (const node of graph.nodes) {
6621
+ if (!node.ports) continue;
6622
+ const swept = order.physicalPortOrderByNodeId?.get(node.id);
6623
+ if (!swept) {
6624
+ sweptPortRanks.delete(node.ports);
6625
+ continue;
6626
+ }
6627
+ const index = new Map(swept.flatMap((id, i) => (connected.has(id) ? [[id, i] as const] : [])));
6628
+ const rankByPort = new Map(
6629
+ node.ports.map((port) => [port, index.get(JSON.stringify([node.id, port.name]))]),
6630
+ );
6631
+ sweptPortRanks.set(node.ports, (port, side) => {
6632
+ const rank = rankByPort.get(port);
6633
+ if (rank === undefined) return undefined;
6634
+ // Canonical clockwise ranks rise with the flow coordinate on NORTH,
6635
+ // with the cross coordinate on EAST, and fall on SOUTH and WEST.
6636
+ const alongFlow = (side === "NORTH" || side === "SOUTH") !== vertical;
6637
+ const c = canonical[side];
6638
+ const sign = alongFlow
6639
+ ? (c === "NORTH" ? 1 : -1) * (reverse ? -1 : 1)
6640
+ : c === "EAST"
6641
+ ? 1
6642
+ : -1;
6643
+ return sign * rank;
6644
+ });
6645
+ }
6646
+ }
6647
+
6531
6648
  export function placePorts<P>(
6532
6649
  ports: readonly GraphPort<P>[] | undefined,
6533
6650
  rect: EntityRect,
@@ -6573,13 +6690,24 @@ export function placePorts<P>(
6573
6690
  group.push(port);
6574
6691
  grouped.set(side, group);
6575
6692
  }
6693
+ const swept =
6694
+ constraints === "FIXED_ORDER" || constraints === "FIXED_POS" || constraints === "FIXED_RATIO"
6695
+ ? undefined
6696
+ : sweptPortRanks.get(ports as readonly GraphPort<unknown>[]);
6576
6697
  for (const group of grouped.values()) {
6577
- group.sort(
6578
- (left, right) =>
6579
- Number(portSettings?.(left)?.["port.index"] ?? ports.indexOf(left)) -
6580
- Number(portSettings?.(right)?.["port.index"] ?? ports.indexOf(right)),
6581
- );
6582
6698
  const side = sideByPort.get(group[0]!);
6699
+ const ranks = swept && group.map((port) => swept(port as GraphPort<unknown>, side!));
6700
+ const ranked =
6701
+ ranks?.every((rank) => rank !== undefined) &&
6702
+ group.every((port) => portSettings?.(port)?.["port.index"] === undefined)
6703
+ ? new Map(group.map((port, i) => [port, ranks[i]!]))
6704
+ : undefined;
6705
+ group.sort((left, right) =>
6706
+ ranked
6707
+ ? ranked.get(left)! - ranked.get(right)!
6708
+ : Number(portSettings?.(left)?.["port.index"] ?? ports.indexOf(left)) -
6709
+ Number(portSettings?.(right)?.["port.index"] ?? ports.indexOf(right)),
6710
+ );
6583
6711
  if (constraints === "FIXED_ORDER" && (side === "SOUTH" || side === "WEST")) {
6584
6712
  group.reverse();
6585
6713
  }