@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.
- package/README.md +32 -3
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +1 -1
- package/dist/elkjs/index.d.mts +1 -1
- package/dist/elkjs/index.mjs +2 -2
- package/dist/elkjs/main.cjs +1 -1
- package/dist/elkjs/main.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/{elkjs-Cj-lWiK1.cjs → elkjs-Bh_E59N_.cjs} +3 -3
- package/dist/{elkjs-B5kN_zS6.mjs → elkjs-Dk9yOk9U.mjs} +3 -3
- package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
- package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
- package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
- package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
- package/dist/index.cjs +7 -4
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +1 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-BsAdm_u8.mjs → layered-CUwft7on.mjs} +488 -91
- package/dist/{layered-BJu8A21s.cjs → layered-QzYtokgN.cjs} +488 -91
- package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CQ5FcBPC.cjs} +1 -1
- package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-DvDkMQM3.mjs} +1 -1
- package/dist/routing/index.cjs +5 -2
- package/dist/routing/index.d.cts +3 -3
- package/dist/routing/index.d.mts +3 -3
- package/dist/routing/index.mjs +2 -2
- package/dist/routing-D-LfMXOF.mjs +5189 -0
- package/dist/routing-Phxu1R0x.cjs +5423 -0
- package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
- package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
- package/package.json +4 -1
- package/src/authoring/partial.ts +1 -7
- package/src/authoring/routing.ts +64 -19
- package/src/layered/feedback-chains.ts +22 -4
- package/src/layered/index.ts +186 -42
- package/src/layered/loop-envelopes.ts +96 -26
- package/src/layered/north-south-ports.ts +17 -4
- package/src/layered/self-loop-ports.ts +158 -0
- package/src/layered/strategies.ts +183 -32
- package/src/routing/algorithms.ts +1436 -148
- package/src/routing/bezier-fit.ts +605 -0
- package/src/routing/bezier-visibility.ts +107 -0
- package/src/routing/bezier.ts +596 -0
- package/src/routing/engine.ts +65 -13
- package/src/routing/index.ts +14 -0
- package/src/routing/model.ts +185 -1
- package/src/routing/path.ts +1 -1
- package/src/routing/policy-geometry.ts +536 -0
- package/src/routing/search.ts +10 -2
- package/src/routing/types.ts +77 -1
- package/dist/routing-CR8flv8b.mjs +0 -2780
- package/dist/routing-QIdGGFBe.cjs +0 -2996
package/src/layered/index.ts
CHANGED
|
@@ -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
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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 (
|
|
161
|
-
|
|
162
|
-
|
|
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]) =>
|