@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.
- package/README.md +40 -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-CSi7oB5J.mjs → elkjs-DBkalvt1.mjs} +25 -3
- package/dist/{elkjs-CnBvdyBu.cjs → elkjs-DonMqGjf.cjs} +25 -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-CmL7oynw.mjs} +245 -69
- package/dist/{layered-BJu8A21s.cjs → layered-SB2mlAqt.cjs} +245 -69
- package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-BXoIpYL4.mjs} +1 -1
- package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CAyJ-I-u.cjs} +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/elkjs/index.ts +26 -0
- package/src/layered/feedback-chains.ts +22 -4
- package/src/layered/index.ts +173 -41
- package/src/layered/north-south-ports.ts +17 -4
- package/src/layered/strategies.ts +143 -15
- 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
|
@@ -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
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
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(
|
|
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]) =>
|
|
@@ -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
|
-
|
|
2444
|
-
|
|
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
|
}
|