@statelyai/layout 0.4.3 → 0.5.1

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 (57) hide show
  1. package/README.md +32 -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-Cj-lWiK1.cjs → elkjs-Bh_E59N_.cjs} +3 -3
  11. package/dist/{elkjs-B5kN_zS6.mjs → elkjs-Dk9yOk9U.mjs} +3 -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-CUwft7on.mjs} +488 -91
  25. package/dist/{layered-BJu8A21s.cjs → layered-QzYtokgN.cjs} +488 -91
  26. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CQ5FcBPC.cjs} +1 -1
  27. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-DvDkMQM3.mjs} +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/layered/feedback-chains.ts +22 -4
  40. package/src/layered/index.ts +186 -42
  41. package/src/layered/loop-envelopes.ts +96 -26
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/self-loop-ports.ts +158 -0
  44. package/src/layered/strategies.ts +183 -32
  45. package/src/routing/algorithms.ts +1436 -148
  46. package/src/routing/bezier-fit.ts +605 -0
  47. package/src/routing/bezier-visibility.ts +107 -0
  48. package/src/routing/bezier.ts +596 -0
  49. package/src/routing/engine.ts +65 -13
  50. package/src/routing/index.ts +14 -0
  51. package/src/routing/model.ts +185 -1
  52. package/src/routing/path.ts +1 -1
  53. package/src/routing/policy-geometry.ts +536 -0
  54. package/src/routing/search.ts +10 -2
  55. package/src/routing/types.ts +77 -1
  56. package/dist/routing-CR8flv8b.mjs +0 -2780
  57. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -8,6 +8,7 @@ import { countAllCrossings } from "./crossing-counter";
8
8
  import { crossingGraph } from "./crossing-graph";
9
9
  import { sortInitialModelOrder, markInitialModelOrderPrepared } from "./initial-model-order";
10
10
  import { hasMovableLoopPorts } from "./loop-envelopes";
11
+ import { hideSelfLoopPorts, restoreSelfLoopPorts } from "./self-loop-ports";
11
12
  import { fixedSelfLoopLabels, fixedSelfLoopSide } from "./fixed-self-loop";
12
13
  import { compactionBounds, placementCrossBounds } from "./compaction-bounds";
13
14
  import { setElkjs0111ResultPolicy } from "../internal/elkjs-compatibility";
@@ -82,6 +83,8 @@ import {
82
83
  placeNodesInLayers,
83
84
  placeNodesInteractively,
84
85
  placePorts,
86
+ recordSweptPortOrder,
87
+ trackLabelPortSideFeedback,
85
88
  routeEdgesOrthogonally,
86
89
  routeEdgesWithPolylines,
87
90
  routeEdgesWithSplines,
@@ -1199,53 +1202,101 @@ function runSeparatedComponents<N, E, G, P>(
1199
1202
  bottom: options.padding?.bottom ?? 12,
1200
1203
  left: options.padding?.left ?? 12,
1201
1204
  };
1205
+ // Components are packed in reading order, row by row. For UP and LEFT layouts
1206
+ // the packing is mirrored along the flow so later components follow the first
1207
+ // one's flow instead of preceding it (e.g. an isolated state below an UP
1208
+ // statechart's start): LEFT mirrors each row, UP mirrors the stack of rows.
1209
+ // Compaction measures every shape in that mirrored frame.
1210
+ const direction = options.direction ?? graph.direction ?? "right";
1211
+ type Rect = { x: number; y: number; width: number; height: number };
1212
+ // Everything a component draws, so compaction keeps other components off its
1213
+ // routes and labels as well as its nodes.
1214
+ const drawnRects = (result: VisualGraph<N, E, G, P>): Rect[] => {
1215
+ const rects: Rect[] = [];
1216
+ for (const node of result.nodes) {
1217
+ const x = node.x ?? 0,
1218
+ y = node.y ?? 0;
1219
+ rects.push({ x, y, width: node.width ?? 0, height: node.height ?? 0 });
1220
+ for (const port of node.ports ?? [])
1221
+ rects.push({
1222
+ x: x + (port.x ?? 0),
1223
+ y: y + (port.y ?? 0),
1224
+ width: port.width ?? 0,
1225
+ height: port.height ?? 0,
1226
+ });
1227
+ }
1228
+ for (const edge of result.edges) {
1229
+ const points = edge.points ?? [];
1230
+ for (let index = 1; index < points.length; index++) {
1231
+ const from = points[index - 1]!,
1232
+ to = points[index]!;
1233
+ const x = Math.min(from.x, to.x),
1234
+ y = Math.min(from.y, to.y);
1235
+ rects.push({ x, y, width: Math.max(from.x, to.x) - x, height: Math.max(from.y, to.y) - y });
1236
+ }
1237
+ if ((edge.width ?? 0) > 0 || (edge.height ?? 0) > 0)
1238
+ rects.push({
1239
+ x: edge.x ?? 0,
1240
+ y: edge.y ?? 0,
1241
+ width: edge.width ?? 0,
1242
+ height: edge.height ?? 0,
1243
+ });
1244
+ }
1245
+ return rects;
1246
+ };
1247
+ const rows: Array<Array<{ component: (typeof components)[number]; x: number }>> = [];
1202
1248
  let x = 0;
1249
+ for (const component of components) {
1250
+ if (x > 0 && x + component.bounds.width > maxRowWidth) x = 0;
1251
+ if (x === 0) rows.push([]);
1252
+ rows.at(-1)!.push({ component, x });
1253
+ x += component.bounds.width + componentSpacing;
1254
+ }
1203
1255
  let y = 0;
1204
- let rowHeight = 0;
1205
1256
  const compactConnectedComponents = options.settings?.["compaction.connectedComponents"] === true;
1206
1257
  const placedShapes: Array<{ left: number; right: number; top: number; bottom: number }> = [];
1207
1258
  const nodeResults = new Map<string, VisualNode<N, P>>();
1208
1259
  const edgeResults = new Map<string, (typeof components)[number]["result"]["edges"][number]>();
1209
1260
  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
- }
1261
+ for (const row of rows) {
1262
+ const rowWidth = Math.max(...row.map((entry) => entry.x + entry.component.bounds.width));
1263
+ let rowHeight = 0;
1264
+ for (const { component, x: packedX } of row) {
1265
+ const { bounds } = component;
1266
+ const left = direction === "left" ? rowWidth - packedX - bounds.width : packedX;
1267
+ const shapes = drawnRects(component.result).map((rect) => ({
1268
+ left: left + rect.x - bounds.left,
1269
+ top:
1270
+ direction === "up"
1271
+ ? bounds.top + bounds.height - rect.y - rect.height
1272
+ : rect.y - bounds.top,
1273
+ width: rect.width,
1274
+ height: rect.height,
1275
+ }));
1276
+ let compactedY = y;
1277
+ if (compactConnectedComponents && y > 0) {
1278
+ let requiredY = 0;
1279
+ for (const shape of shapes)
1280
+ for (const placed of placedShapes) {
1281
+ if (shape.left + shape.width <= placed.left || shape.left >= placed.right) continue;
1282
+ requiredY = Math.max(requiredY, placed.bottom + componentSpacing - shape.top);
1283
+ }
1284
+ compactedY = Math.min(y, requiredY);
1227
1285
  }
1228
- compactedY = Math.min(y, requiredY);
1286
+ for (const shape of shapes)
1287
+ placedShapes.push({
1288
+ left: shape.left,
1289
+ right: shape.left + shape.width,
1290
+ top: compactedY + shape.top,
1291
+ bottom: compactedY + shape.top + shape.height,
1292
+ });
1293
+ placements.push({ component, x: left, y: compactedY });
1294
+ rowHeight = Math.max(rowHeight, bounds.height);
1229
1295
  }
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);
1296
+ y += rowHeight + componentSpacing;
1240
1297
  }
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
1298
  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;
1299
+ for (const { component, x: left, y: packedY } of placements) {
1249
1300
  const top = direction === "up" ? packedHeight - packedY - component.bounds.height : packedY;
1250
1301
  const dx = padding.left + left - component.bounds.left;
1251
1302
  const dy = padding.top + top - component.bounds.top;
@@ -1324,7 +1375,10 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1324
1375
  graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
1325
1376
  options: LayeredLayoutOptions,
1326
1377
  context?: LayoutExecutionContext,
1378
+ /** Pairs and segments that keep their former routing because the new one bent them diagonally. */
1379
+ fallbacks: OrthogonalFallbacks = { antiparallelPairs: new Set(), feedbackSegments: new Set() },
1327
1380
  ): Generator<LayeredCrossingPhase, VisualGraph<N, E, G, P>, LayerOrder> {
1381
+ const scopeOptions = options;
1328
1382
  if ((options.settings?.["spacing.edgeEdge"] ?? 10) < 2) {
1329
1383
  options = { ...options, settings: { ...options.settings, "spacing.edgeEdge": 2 } };
1330
1384
  }
@@ -1371,6 +1425,18 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1371
1425
  const separated = runSeparatedComponents(graph, options, context);
1372
1426
  if (separated) return separated;
1373
1427
  }
1428
+ const hiddenLoopPorts = hideSelfLoopPorts(graph as Graph<N, E, G, P>, options);
1429
+ if (hiddenLoopPorts)
1430
+ return restoreSelfLoopPorts(
1431
+ graph as Graph<N, E, G, P>,
1432
+ yield* createLayeredScopePipeline(
1433
+ hiddenLoopPorts.graph,
1434
+ hiddenLoopPorts.options,
1435
+ context,
1436
+ fallbacks,
1437
+ ),
1438
+ hiddenLoopPorts.edges,
1439
+ );
1374
1440
  const direction = options.direction ?? graph.direction ?? "right";
1375
1441
  const padding =
1376
1442
  typeof options.padding === "number"
@@ -1683,7 +1749,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1683
1749
  },
1684
1750
  };
1685
1751
  }
1686
- const northSouth = insertNorthSouthPortDummies(expanded);
1752
+ const northSouth = insertNorthSouthPortDummies(expanded, preparedPorts);
1687
1753
  expanded = northSouth.expansion;
1688
1754
  if (prepareModelOrder) markInitialModelOrderPrepared(expanded.input, preparedPorts!);
1689
1755
  const associates = new Map<string, string[]>(),
@@ -1789,6 +1855,8 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1789
1855
  minimize: minimizeScope,
1790
1856
  finishOrder: finishCrossingOrder,
1791
1857
  };
1858
+ const crossingOrder = order;
1859
+ recordSweptPortOrder(graph, direction, order);
1792
1860
  traceLayeredPhase(() => ({
1793
1861
  kind: "crossing-order",
1794
1862
  scope: graph.id,
@@ -2154,6 +2222,10 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2154
2222
  // rather than moving or orthogonalizing its routes afterwards.
2155
2223
  if (options.strategies?.routeEdges)
2156
2224
  measure("post-compaction", () => applyPostCompaction(expanded.input, placement));
2225
+ const labelPortSideSegments = trackLabelPortSideFeedback(
2226
+ expanded.input,
2227
+ fallbacks.feedbackSegments,
2228
+ );
2157
2229
  let expandedRoutes = measure("edge-routing", () =>
2158
2230
  edgeRouter(expanded.input, expanded.orientation, placement),
2159
2231
  );
@@ -2218,6 +2290,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2218
2290
  const outerAntiparallelLabelIds = new Set<string>();
2219
2291
  const parallelLabelPositions = new Map<string, Point>();
2220
2292
  const postCompactionNodeCrossDeltas = new Map<string, number>();
2293
+ const flowOrderedPairs = new Map<string, readonly [string, string]>();
2221
2294
  const horizontalAntiparallelFlow = direction === "left" || direction === "right";
2222
2295
  const usesVizInteractivePlacementProfile =
2223
2296
  options.strategies?.routeEdges === undefined &&
@@ -2313,6 +2386,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2313
2386
  const horizontal = horizontalAntiparallelFlow;
2314
2387
  const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
2315
2388
  const pairEdges = new Map<string, GraphEdge[]>();
2389
+ const flowOrder = direction === "left" || direction === "up";
2316
2390
  for (const edge of graph.edges) {
2317
2391
  if (edge.sourceId === edge.targetId || (edge.width ?? 0) <= 0 || (edge.height ?? 0) <= 0) {
2318
2392
  continue;
@@ -2322,7 +2396,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2322
2396
  pair.push(edge);
2323
2397
  pairEdges.set(key, pair);
2324
2398
  }
2325
- for (const pair of pairEdges.values()) {
2399
+ for (const [pairKey, pair] of pairEdges) {
2326
2400
  if (
2327
2401
  pair.length !== 2 ||
2328
2402
  pair[0]!.sourceId !== pair[1]!.targetId ||
@@ -2363,9 +2437,13 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2363
2437
  const hasFlexiblePairPorts = hasFlexiblePorts(firstNode) && hasFlexiblePorts(secondNode);
2364
2438
  if (isolatedAntiparallelPair && !hasFlexiblePairPorts) continue;
2365
2439
 
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;
2440
+ // "Before" is in flow order: UP and LEFT layouts flow toward smaller coordinates.
2441
+ const pairFlowOrder = flowOrder && !fallbacks.antiparallelPairs.has(pairKey);
2442
+ const firstBeforeSecond =
2443
+ (horizontal
2444
+ ? firstRect.x + firstRect.width / 2 < secondRect.x + secondRect.width / 2
2445
+ : firstRect.y + firstRect.height / 2 < secondRect.y + secondRect.height / 2) !==
2446
+ pairFlowOrder;
2369
2447
  const beforeRect = firstBeforeSecond ? firstRect : secondRect;
2370
2448
  const afterRect = firstBeforeSecond ? secondRect : firstRect;
2371
2449
  const forward = pair.find((edge) =>
@@ -2374,6 +2452,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2374
2452
  : edge.sourceId === pair[0]!.targetId,
2375
2453
  );
2376
2454
  if (!forward?.sourcePort || !forward.targetPort) continue;
2455
+ if (pairFlowOrder) flowOrderedPairs.set(pairKey, [firstNode.id, secondNode.id]);
2377
2456
 
2378
2457
  let cross = Math.min(
2379
2458
  horizontal ? firstRect.y : firstRect.x,
@@ -2554,6 +2633,7 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2554
2633
  routes.pointsByEdgeId as Map<string, readonly Point[]>,
2555
2634
  placement.rectByNodeId as Map<string, EntityRect>,
2556
2635
  horizontal,
2636
+ Number(options.settings?.["spacing.edgeEdge"] ?? 10),
2557
2637
  );
2558
2638
  }
2559
2639
  if (options.settings?.["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT") {
@@ -2667,8 +2747,21 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2667
2747
  };
2668
2748
  }
2669
2749
 
2750
+ // Stacking the pair's labels from the padding and aligning both endpoints
2751
+ // moves the nodes without regard for their layer neighbors, so it only
2752
+ // applies when the two nodes connect to nothing else.
2753
+ const connectsOnlyTo = (nodeId: string, otherId: string) =>
2754
+ graph.edges.every(
2755
+ (edge) =>
2756
+ edge.sourceId === edge.targetId ||
2757
+ (edge.sourceId !== nodeId && edge.targetId !== nodeId) ||
2758
+ edge.sourceId === otherId ||
2759
+ edge.targetId === otherId,
2760
+ );
2670
2761
  for (const pair of labeledEdgesByPair.values()) {
2671
2762
  if (pair.length < 2) continue;
2763
+ const { sourceId, targetId } = pair[0]!;
2764
+ if (!connectsOnlyTo(sourceId, targetId) || !connectsOnlyTo(targetId, sourceId)) continue;
2672
2765
  const sourceRect = mutableRects.get(pair[0]!.sourceId);
2673
2766
  const targetRect = mutableRects.get(pair[0]!.targetId);
2674
2767
  if (!sourceRect || !targetRect) continue;
@@ -3062,7 +3155,6 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3062
3155
  if (
3063
3156
  edgeRouting !== "ORTHOGONAL" ||
3064
3157
  labelPlacement !== "CENTER" ||
3065
- inlineLabel ||
3066
3158
  !hasMovableLoopPorts(input, edge) ||
3067
3159
  !sourceRect ||
3068
3160
  points.length !== 4
@@ -3249,6 +3341,41 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3249
3341
  });
3250
3342
  }
3251
3343
 
3344
+ // Diagonals are never valid orthogonal routes. A flow-ordered antiparallel
3345
+ // pair or a label-port-side feedback chain that came out diagonal falls back
3346
+ // to its former routing when that has fewer diagonals.
3347
+ if (edgeRouting === "ORTHOGONAL") {
3348
+ const diagonalEdges = edges.filter((edge) => countDiagonals(edge.points) > 0);
3349
+ const antiparallelPairs = [...flowOrderedPairs].flatMap(([key, nodeIds]) =>
3350
+ diagonalEdges.some(
3351
+ (edge) => nodeIds.includes(edge.sourceId) || nodeIds.includes(edge.targetId),
3352
+ )
3353
+ ? [key]
3354
+ : [],
3355
+ );
3356
+ const feedbackSegments = diagonalEdges.flatMap((edge) => {
3357
+ const segments = expanded.segmentIdsByEdgeId.get(edge.id) ?? [edge.id];
3358
+ return segments.some((id) => labelPortSideSegments.has(id)) ? segments : [];
3359
+ });
3360
+ if (antiparallelPairs.length > 0 || feedbackSegments.length > 0) {
3361
+ const retry = createLayeredScopePipeline(
3362
+ graph,
3363
+ scopeOptions,
3364
+ context && { ...context, diagnostics: [] },
3365
+ {
3366
+ antiparallelPairs: new Set([...fallbacks.antiparallelPairs, ...antiparallelPairs]),
3367
+ feedbackSegments: new Set([...fallbacks.feedbackSegments, ...feedbackSegments]),
3368
+ },
3369
+ );
3370
+ const prepared = retry.next();
3371
+ const resumed = prepared.done ? prepared : retry.next(crossingOrder);
3372
+ if (!resumed.done) throw new Error("Layered scope yielded more than one crossing phase");
3373
+ const diagonals = (routed: readonly { points?: readonly Point[] }[]) =>
3374
+ routed.reduce((sum, edge) => sum + countDiagonals(edge.points), 0);
3375
+ if (diagonals(resumed.value.edges) < diagonals(edges)) return resumed.value;
3376
+ }
3377
+ }
3378
+
3252
3379
  return setElkjs0111ResultPolicy(
3253
3380
  { ...graph, direction, nodes, edges },
3254
3381
  {
@@ -3261,6 +3388,23 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3261
3388
  );
3262
3389
  }
3263
3390
 
3391
+ interface OrthogonalFallbacks {
3392
+ /** Antiparallel pair keys that keep coordinate order instead of flow order. */
3393
+ antiparallelPairs: ReadonlySet<string>;
3394
+ /** Feedback segments that keep the plain detour instead of label-dummy port sides. */
3395
+ feedbackSegments: ReadonlySet<string>;
3396
+ }
3397
+
3398
+ function countDiagonals(points: readonly Point[] | undefined): number {
3399
+ let count = 0;
3400
+ for (let index = 1; index < (points?.length ?? 0); index++) {
3401
+ const a = points![index - 1]!;
3402
+ const b = points![index]!;
3403
+ if (Math.abs(a.x - b.x) > 1e-6 && Math.abs(a.y - b.y) > 1e-6) count++;
3404
+ }
3405
+ return count;
3406
+ }
3407
+
3264
3408
  /**
3265
3409
  * Deterministic native layered layout for an `@statelyai/graph` graph.
3266
3410
  *
@@ -8,6 +8,86 @@ import {
8
8
  selfLoopTracks,
9
9
  } from "./fixed-self-loop";
10
10
  import { findNode } from "./node-lookup";
11
+ import { hiddenLoopPorts } from "./self-loop-ports";
12
+
13
+ type CanonicalSide = "NORTH" | "SOUTH" | "EAST" | "WEST";
14
+ const crossSide = (side: CanonicalSide) => side === "NORTH" || side === "SOUTH";
15
+
16
+ /**
17
+ * Extents of a movable loop's hidden ports in the canonical rightward frame:
18
+ * along its side, and protruding from it.
19
+ */
20
+ function loopPortExtents(input: LayeredPhaseInput, edge: GraphEdge, side: CanonicalSide) {
21
+ const ports = hiddenLoopPorts(input.settings, edge.id);
22
+ const horizontal = input.direction === "right" || input.direction === "left";
23
+ const along = (crossSide(side) === horizontal ? "width" : "height") as "width" | "height";
24
+ const normal = along === "width" ? "height" : "width";
25
+ return {
26
+ along: ports ? ports.source[along] + ports.target[along] : 0,
27
+ normal: ports ? Math.max(ports.source[normal], ports.target[normal]) : 0,
28
+ };
29
+ }
30
+
31
+ /** A movable loop's label extent away from its side, in the canonical frame. */
32
+ export function movableLoopLabelExtent(
33
+ input: LayeredPhaseInput,
34
+ edge: GraphEdge,
35
+ side: CanonicalSide,
36
+ ): number {
37
+ const horizontal = input.direction === "right" || input.direction === "left";
38
+ const size = (crossSide(side) === horizontal ? edge.height : edge.width) ?? 0;
39
+ return size > 0 ? size + Number(input.settings["spacing.edgeLabel"] ?? 2) : 0;
40
+ }
41
+
42
+ /**
43
+ * ELK's OrthogonalSelfLoopRouter routing slot of a movable loop, measured
44
+ * from its node side in the canonical frame: past the protruding ports and
45
+ * the self-loop spacing, then one edge spacing plus any label per inner slot.
46
+ */
47
+ export function movableLoopTrack(
48
+ input: LayeredPhaseInput,
49
+ sideLoops: readonly GraphEdge[],
50
+ edge: GraphEdge,
51
+ side: CanonicalSide,
52
+ ordering: string,
53
+ ): number {
54
+ const ordered = ordering === "REVERSE_STACKED" ? [...sideLoops].reverse() : sideLoops;
55
+ const inner = ordering === "SEQUENCED" ? [] : ordered.slice(0, ordered.indexOf(edge));
56
+ const edgeSpacing = Number(input.settings["spacing.edgeEdge"] ?? 10);
57
+ return (
58
+ Math.max(0, ...sideLoops.map((loop) => loopPortExtents(input, loop, side).normal)) +
59
+ Number(input.settings["spacing.nodeSelfLoop"] ?? 10) +
60
+ inner.reduce(
61
+ (sum, loop) =>
62
+ sum + edgeSpacing + (crossSide(side) ? movableLoopLabelExtent(input, loop, side) : 0),
63
+ 0,
64
+ )
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Canonical position of a movable loop port along its side, given its
70
+ * DISTRIBUTED ratio among the side's implicit ports (ELK's port placement
71
+ * with equal gaps between hidden ports of their own size).
72
+ */
73
+ export function movableLoopPortOffset(
74
+ input: LayeredPhaseInput,
75
+ sideLoops: readonly GraphEdge[],
76
+ side: CanonicalSide,
77
+ length: number,
78
+ ratio: number,
79
+ ): { along: number; normal: number } {
80
+ const count = sideLoops.length * 2;
81
+ const extents = sideLoops.map((loop) => loopPortExtents(input, loop, side));
82
+ const total = extents.reduce((sum, extent) => sum + extent.along, 0);
83
+ const size = total / count;
84
+ const index = Math.round(ratio * (count + 1)) - 1;
85
+ const gap = (length - total) / (count + 1);
86
+ return {
87
+ along: gap * (index + 1) + size * index + size / 2,
88
+ normal: Math.max(0, ...extents.map((extent) => extent.normal)),
89
+ };
90
+ }
11
91
 
12
92
  export interface LoopEnvelope {
13
93
  before: number;
@@ -125,18 +205,10 @@ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, Loo
125
205
  }
126
206
  }
127
207
  const loops = allLoops.filter((edge) => hasMovableLoopPorts(input, edge));
128
- // Inline labels remain owned by the existing labeled-loop phase. Move
129
- // the entire node together when that phase is ported, rather than mixing
130
- // two different loop distributions and reservations on the same node.
131
- if (
132
- loops.length === 0 ||
133
- loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
134
- )
135
- continue;
208
+ if (loops.length === 0) continue;
136
209
  const settings = input.nodeSettings?.(node);
137
210
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
138
211
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
139
- const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
140
212
  const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
141
213
  const sides =
142
214
  distribution === "EQUALLY"
@@ -144,25 +216,23 @@ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, Loo
144
216
  : distribution === "NORTH_SOUTH"
145
217
  ? (["before", "after"] as const)
146
218
  : (["before"] as const);
219
+ const canonicalSides = {
220
+ before: "NORTH",
221
+ after: "SOUTH",
222
+ flowAfter: "EAST",
223
+ flowBefore: "WEST",
224
+ } as const;
147
225
  for (const side of sides) {
148
226
  const selected = loops.filter((_, index) => sides[index % sides.length] === side);
149
- const horizontal = input.direction === "right" || input.direction === "left";
150
- const crossSize =
151
- side === "before" || side === "after"
152
- ? horizontal
153
- ? "height"
154
- : "width"
155
- : horizontal
156
- ? "width"
157
- : "height";
158
- const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
159
- envelope[side] =
160
- spacing * (ordering === "SEQUENCED" ? Math.min(1, selected.length) : selected.length) +
161
- selected.reduce(
162
- (sum, edge) =>
163
- sum + ((edge[crossSize] ?? 0) > 0 ? (edge[crossSize] ?? 0) + labelSpacing : 0),
164
- 0,
165
- );
227
+ const canonical = canonicalSides[side];
228
+ envelope[side] = Math.max(
229
+ 0,
230
+ ...selected.map(
231
+ (edge) =>
232
+ movableLoopTrack(input, selected, edge, canonical, ordering) +
233
+ movableLoopLabelExtent(input, edge, canonical),
234
+ ),
235
+ );
166
236
  }
167
237
  envelopes.set(node.id, envelope);
168
238
  }
@@ -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]) =>