@statelyai/layout 0.0.5 → 0.1.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 +14 -0
- package/dist/elkjs/index.d.mts +44 -1
- package/dist/elkjs/index.mjs +269 -7
- package/dist/index.mjs +2 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-BYhTymAk.mjs → layered-CZs9YWVx.mjs} +120 -28
- package/dist/{spore-B504wOFR.mjs → spore-9N0W9dZn.mjs} +1 -1
- package/package.json +1 -1
- package/src/elkjs/index.ts +15 -1
- package/src/elkjs/statechart.ts +338 -0
- package/src/layered/index.ts +82 -28
- package/src/layered/strategies.ts +188 -13
|
@@ -311,7 +311,8 @@ function addDepthFirstBackEdges(
|
|
|
311
311
|
if (sourceId === targetId) continue;
|
|
312
312
|
const targetState = state.get(targetId);
|
|
313
313
|
if (targetState === "active") {
|
|
314
|
-
reversedEdgeIds.
|
|
314
|
+
if (reversed) reversedEdgeIds.delete(edge.id);
|
|
315
|
+
else reversedEdgeIds.add(edge.id);
|
|
315
316
|
} else if (targetState === undefined) {
|
|
316
317
|
state.set(targetId, "active");
|
|
317
318
|
stack.push({ nodeId: targetId, edgeIndex: 0 });
|
|
@@ -740,7 +741,8 @@ export function applyLayerConstraintOrientation(
|
|
|
740
741
|
if (reversedEdgeIds.has(edge.id)) reversedEdgeIds.delete(edge.id);
|
|
741
742
|
else reversedEdgeIds.add(edge.id);
|
|
742
743
|
}
|
|
743
|
-
|
|
744
|
+
// Port-side preferences can reintroduce cycles after the cycle breaker.
|
|
745
|
+
return { reversedEdgeIds: addDepthFirstBackEdges(input, reversedEdgeIds, "model") };
|
|
744
746
|
}
|
|
745
747
|
|
|
746
748
|
/** Keep activated ELK partitions in ascending, contiguous layer blocks. */
|
|
@@ -2400,6 +2402,7 @@ export function applyPostCompaction(
|
|
|
2400
2402
|
input: LayeredPhaseInput,
|
|
2401
2403
|
placement: NodePlacement,
|
|
2402
2404
|
routes?: EdgeRoutes,
|
|
2405
|
+
labelEdges: readonly GraphEdge[] = input.graph.edges,
|
|
2403
2406
|
): NodePlacement {
|
|
2404
2407
|
const strategy = input.settings["compaction.postCompaction.strategy"] ?? "NONE";
|
|
2405
2408
|
// Both source strategies construct the same constraint relation; only their
|
|
@@ -2479,9 +2482,33 @@ export function applyPostCompaction(
|
|
|
2479
2482
|
: "spacing.edgeNode"
|
|
2480
2483
|
] ?? 10,
|
|
2481
2484
|
);
|
|
2485
|
+
// Compaction must preserve the space reserved for an inline center label,
|
|
2486
|
+
// even when its endpoint rectangles do not intersect on the cross axis.
|
|
2487
|
+
const inlineLabelSpaceByPair = new Map<string, number>();
|
|
2488
|
+
// EDGE_LENGTH retains its route-track label constraints below.
|
|
2489
|
+
if (strategy !== "EDGE_LENGTH" && (input.direction === "left" || input.direction === "right")) {
|
|
2490
|
+
for (const edge of labelEdges) {
|
|
2491
|
+
const settings = input.edgeSettings?.(edge);
|
|
2492
|
+
if (
|
|
2493
|
+
edge.sourceId === edge.targetId ||
|
|
2494
|
+
settings?.["edgeLabels.inline"] !== true ||
|
|
2495
|
+
(settings["edgeLabels.placement"] ?? "CENTER") !== "CENTER"
|
|
2496
|
+
)
|
|
2497
|
+
continue;
|
|
2498
|
+
const key = [edge.sourceId, edge.targetId].sort().join("\0");
|
|
2499
|
+
inlineLabelSpaceByPair.set(
|
|
2500
|
+
key,
|
|
2501
|
+
Math.max(inlineLabelSpaceByPair.get(key) ?? 0, (edge.width ?? 0) + 2 * input.spacing.layer),
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
const labelSpace = (left: ConstraintNode, right: ConstraintNode): number =>
|
|
2506
|
+
left.nodeId !== undefined && right.nodeId !== undefined
|
|
2507
|
+
? (inlineLabelSpaceByPair.get([left.nodeId, right.nodeId].sort().join("\0")) ?? 0)
|
|
2508
|
+
: 0;
|
|
2482
2509
|
const horizontalSpacing = (left: ConstraintNode, right: ConstraintNode): number =>
|
|
2483
2510
|
left.kind === "node" && right.kind === "node"
|
|
2484
|
-
? input.spacing.node
|
|
2511
|
+
? Math.max(input.spacing.node, labelSpace(left, right))
|
|
2485
2512
|
: left.kind === "segment" && right.kind === "segment" && left.edgeId === right.edgeId
|
|
2486
2513
|
? 0
|
|
2487
2514
|
: Number(
|
|
@@ -2526,7 +2553,7 @@ export function applyPostCompaction(
|
|
|
2526
2553
|
const verticalCollision =
|
|
2527
2554
|
right.y + right.height + verticalSpacing(left, right) > left.y + 1e-9 &&
|
|
2528
2555
|
right.y < left.y + left.height + verticalSpacing(left, right) - 1e-9;
|
|
2529
|
-
if (!ordered || !verticalCollision) continue;
|
|
2556
|
+
if (!ordered || (!verticalCollision && labelSpace(left, right) === 0)) continue;
|
|
2530
2557
|
constraints.get(left.id)!.push(right.id);
|
|
2531
2558
|
incoming.set(right.id, (incoming.get(right.id) ?? 0) + 1);
|
|
2532
2559
|
}
|
|
@@ -2684,7 +2711,13 @@ export function applyPostCompaction(
|
|
|
2684
2711
|
);
|
|
2685
2712
|
const edgeNodeSpacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
|
|
2686
2713
|
for (const edge of input.graph.edges) {
|
|
2687
|
-
|
|
2714
|
+
// Inline labels already constrain endpoint separation during compaction.
|
|
2715
|
+
// A late target nudge would invalidate its outgoing label constraints.
|
|
2716
|
+
if (
|
|
2717
|
+
!centerLabeledEdgeIds.has(edge.id) ||
|
|
2718
|
+
(strategy !== "EDGE_LENGTH" && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
|
|
2719
|
+
)
|
|
2720
|
+
continue;
|
|
2688
2721
|
const source = itemByNodeId.get(edge.sourceId);
|
|
2689
2722
|
const target = itemByNodeId.get(edge.targetId);
|
|
2690
2723
|
const originalEndpoints = originalEndpointsByEdgeId.get(edge.id);
|
|
@@ -2759,7 +2792,11 @@ export function applyPostCompaction(
|
|
|
2759
2792
|
...originalEndpoints.end,
|
|
2760
2793
|
x: originalEndpoints.end.x + (nodeDeltaById.get(edge.targetId) ?? 0),
|
|
2761
2794
|
};
|
|
2762
|
-
if (routes.outsideFeedbackEdgeIds?.has(edgeId)) {
|
|
2795
|
+
if (edge.sourceId === edge.targetId && routes.outsideFeedbackEdgeIds?.has(edgeId)) {
|
|
2796
|
+
const delta = nodeDeltaById.get(edge.sourceId) ?? 0;
|
|
2797
|
+
for (let index = 1; index + 1 < points.length; index++)
|
|
2798
|
+
points[index] = { ...points[index]!, x: points[index]!.x + delta };
|
|
2799
|
+
} else if (routes.outsideFeedbackEdgeIds?.has(edgeId)) {
|
|
2763
2800
|
const horizontal = input.direction === "left" || input.direction === "right";
|
|
2764
2801
|
const startDelta = points[0]!.x - originalEndpoints.start.x;
|
|
2765
2802
|
const endDelta = points.at(-1)!.x - originalEndpoints.end.x;
|
|
@@ -3413,6 +3450,57 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
3413
3450
|
mutableRects.set(candidateId, { ...candidateRect, x: candidateRect.x + shift });
|
|
3414
3451
|
}
|
|
3415
3452
|
}
|
|
3453
|
+
const loopSide = (
|
|
3454
|
+
distribution: string,
|
|
3455
|
+
index: number,
|
|
3456
|
+
edge?: GraphEdge,
|
|
3457
|
+
): "NORTH" | "SOUTH" | "EAST" | "WEST" => {
|
|
3458
|
+
const node = edge ? nodeById.get(edge.sourceId) : undefined;
|
|
3459
|
+
const sourcePort = node?.ports?.find((port) => port.name === edge?.sourcePort);
|
|
3460
|
+
const targetPort = node?.ports?.find((port) => port.name === edge?.targetPort);
|
|
3461
|
+
const sourceSide =
|
|
3462
|
+
node && sourcePort ? input.portSettings?.(sourcePort, node)?.["port.side"] : undefined;
|
|
3463
|
+
const targetSide =
|
|
3464
|
+
node && targetPort ? input.portSettings?.(targetPort, node)?.["port.side"] : undefined;
|
|
3465
|
+
if (
|
|
3466
|
+
sourceSide &&
|
|
3467
|
+
sourceSide === targetSide &&
|
|
3468
|
+
["NORTH", "SOUTH", "EAST", "WEST"].includes(sourceSide)
|
|
3469
|
+
)
|
|
3470
|
+
return sourceSide as "NORTH" | "SOUTH" | "EAST" | "WEST";
|
|
3471
|
+
return distribution === "EQUALLY"
|
|
3472
|
+
? (["NORTH", "SOUTH", "EAST", "WEST"][index % 4] as "NORTH" | "SOUTH" | "EAST" | "WEST")
|
|
3473
|
+
: distribution === "NORTH_SOUTH" && index % 2 === 1
|
|
3474
|
+
? "SOUTH"
|
|
3475
|
+
: "NORTH";
|
|
3476
|
+
};
|
|
3477
|
+
const loopDistance = (
|
|
3478
|
+
loops: readonly GraphEdge[],
|
|
3479
|
+
edge: GraphEdge,
|
|
3480
|
+
side: string,
|
|
3481
|
+
ordering: string,
|
|
3482
|
+
): number => {
|
|
3483
|
+
const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
|
|
3484
|
+
const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
|
|
3485
|
+
const index = ordered.indexOf(edge);
|
|
3486
|
+
const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
|
|
3487
|
+
const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
|
|
3488
|
+
const size = (candidate: GraphEdge) =>
|
|
3489
|
+
input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
|
|
3490
|
+
? (candidate[axis] ?? 0)
|
|
3491
|
+
: 0;
|
|
3492
|
+
return (
|
|
3493
|
+
spacing * (ordering === "SEQUENCED" ? 1 : index + 1) +
|
|
3494
|
+
ordered
|
|
3495
|
+
.slice(0, index)
|
|
3496
|
+
.reduce(
|
|
3497
|
+
(extent, candidate) =>
|
|
3498
|
+
extent + (size(candidate) > 0 ? size(candidate) + labelSpacing : 0),
|
|
3499
|
+
0,
|
|
3500
|
+
) +
|
|
3501
|
+
(size(edge) > 0 ? size(edge) / 2 + labelSpacing : 0)
|
|
3502
|
+
);
|
|
3503
|
+
};
|
|
3416
3504
|
const northReserveByLayer = new Map<number, number>();
|
|
3417
3505
|
for (const [id, loops] of selfLoopsByNodeId) {
|
|
3418
3506
|
const rect = mutableRects.get(id);
|
|
@@ -3449,6 +3537,62 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
3449
3537
|
mutableRects.set(candidateId, { ...candidateRect, y: candidateRect.y + reserve });
|
|
3450
3538
|
}
|
|
3451
3539
|
}
|
|
3540
|
+
// Reserve labeled loop envelopes before routing, on both sides of each
|
|
3541
|
+
// physical axis. This protects neighboring ranks and cross-axis rows alike.
|
|
3542
|
+
if (style === "ORTHOGONAL") {
|
|
3543
|
+
for (const axis of ["x", "y"] as const) {
|
|
3544
|
+
const size = axis === "x" ? "width" : "height";
|
|
3545
|
+
const reservations = new Map<number, number>();
|
|
3546
|
+
for (const [id, loops] of selfLoopsByNodeId) {
|
|
3547
|
+
const rect = mutableRects.get(id);
|
|
3548
|
+
const node = nodeById.get(id);
|
|
3549
|
+
if (!rect || !node) continue;
|
|
3550
|
+
const settings = input.nodeSettings?.(node);
|
|
3551
|
+
const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
|
|
3552
|
+
const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
|
|
3553
|
+
for (const [index, edge] of loops.entries()) {
|
|
3554
|
+
if (input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true) continue;
|
|
3555
|
+
const side = loopSide(distribution, index, edge);
|
|
3556
|
+
if ((axis === "y") !== (side === "NORTH" || side === "SOUTH")) continue;
|
|
3557
|
+
const sideLoops = loops.filter(
|
|
3558
|
+
(candidate, i) => loopSide(distribution, i, candidate) === side,
|
|
3559
|
+
);
|
|
3560
|
+
const extent = loopDistance(sideLoops, edge, side, ordering) + (edge[size] ?? 0) / 2;
|
|
3561
|
+
const negative = side === "NORTH" || side === "WEST";
|
|
3562
|
+
const boundary = rect[axis] + (negative ? 0 : rect[size]);
|
|
3563
|
+
const neighboringBounds = [...mutableRects.entries()].flatMap(([otherId, other]) => {
|
|
3564
|
+
if (otherId === id) return [];
|
|
3565
|
+
const bound = other[axis] + (negative ? other[size] : 0);
|
|
3566
|
+
return negative
|
|
3567
|
+
? bound <= boundary
|
|
3568
|
+
? [bound]
|
|
3569
|
+
: []
|
|
3570
|
+
: bound >= boundary
|
|
3571
|
+
? [bound]
|
|
3572
|
+
: [];
|
|
3573
|
+
});
|
|
3574
|
+
const existingGap =
|
|
3575
|
+
neighboringBounds.length === 0
|
|
3576
|
+
? 0
|
|
3577
|
+
: negative
|
|
3578
|
+
? boundary - Math.max(...neighboringBounds)
|
|
3579
|
+
: Math.min(...neighboringBounds) - boundary;
|
|
3580
|
+
const required = Math.max(
|
|
3581
|
+
0,
|
|
3582
|
+
extent + (neighboringBounds.length > 0 ? input.spacing.node : 0) - existingGap,
|
|
3583
|
+
);
|
|
3584
|
+
reservations.set(boundary, Math.max(reservations.get(boundary) ?? 0, required));
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
for (const [id, rect] of mutableRects) {
|
|
3588
|
+
const shift = [...reservations].reduce(
|
|
3589
|
+
(sum, [boundary, extent]) => sum + (rect[axis] >= boundary - 1e-9 ? extent : 0),
|
|
3590
|
+
0,
|
|
3591
|
+
);
|
|
3592
|
+
if (shift > 0) mutableRects.set(id, { ...rect, [axis]: rect[axis] + shift });
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3452
3596
|
const edgeLabelSideSelection = input.settings["edgeLabels.sideSelection"] ?? "SMART_DOWN";
|
|
3453
3597
|
const placeEdgeLabelsUp =
|
|
3454
3598
|
edgeLabelSideSelection === "ALWAYS_UP" ||
|
|
@@ -3903,7 +4047,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
3903
4047
|
slots === 0
|
|
3904
4048
|
? (existingGapByLayer[layerNo] ?? input.spacing.layer)
|
|
3905
4049
|
: Math.max(
|
|
3906
|
-
preservesNodeFlexibilityGap ||
|
|
4050
|
+
preservesNodeFlexibilityGap ||
|
|
4051
|
+
(labelExtraByGap[layerNo] ?? 0) > 0 ||
|
|
4052
|
+
[...selfLoopsByNodeId].some(
|
|
4053
|
+
([id, loops]) =>
|
|
4054
|
+
(flowLayerByNodeId.get(id) === layerNo ||
|
|
4055
|
+
flowLayerByNodeId.get(id) === layerNo + 1) &&
|
|
4056
|
+
loops.some(
|
|
4057
|
+
(edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true,
|
|
4058
|
+
),
|
|
4059
|
+
)
|
|
3907
4060
|
? preservedGap
|
|
3908
4061
|
: input.spacing.layer,
|
|
3909
4062
|
2 * edgeNodeSpacing + Math.max(0, slots - 1) * edgeEdgeSpacing,
|
|
@@ -4350,6 +4503,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
4350
4503
|
const fixedSideFeedback =
|
|
4351
4504
|
style === "ORTHOGONAL" &&
|
|
4352
4505
|
!sameSideSelfLoop &&
|
|
4506
|
+
(source.id !== target.id || input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true) &&
|
|
4353
4507
|
((hasFixedPortSide(source) && feedbackSourcePortSide === sourceAwaySide) ||
|
|
4354
4508
|
(hasFixedPortSide(target) && feedbackTargetPortSide === targetAwaySide));
|
|
4355
4509
|
if ((input.settings.feedbackEdges === true && reversedEdge) || fixedSideFeedback) {
|
|
@@ -4518,6 +4672,8 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
4518
4672
|
}
|
|
4519
4673
|
|
|
4520
4674
|
if (source.id === target.id) {
|
|
4675
|
+
if (style === "ORTHOGONAL" && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
|
|
4676
|
+
outsideFeedbackEdgeIds.add(edge.id);
|
|
4521
4677
|
const loops = selfLoopsByNodeId.get(source.id) ?? [edge];
|
|
4522
4678
|
const loopIndex = loops.indexOf(edge);
|
|
4523
4679
|
const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
|
|
@@ -4580,7 +4736,13 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
4580
4736
|
labelSpacing,
|
|
4581
4737
|
0,
|
|
4582
4738
|
);
|
|
4583
|
-
const trackDistance =
|
|
4739
|
+
const trackDistance =
|
|
4740
|
+
spacing +
|
|
4741
|
+
precedingLabelExtent +
|
|
4742
|
+
((side === "NORTH" || side === "SOUTH") &&
|
|
4743
|
+
input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
|
|
4744
|
+
? (edge.height ?? 0) / 2 + labelSpacing
|
|
4745
|
+
: 0);
|
|
4584
4746
|
if (side === "EAST" || side === "WEST") {
|
|
4585
4747
|
const nodeRects = [...placement.rectByNodeId.values()];
|
|
4586
4748
|
const minimumNodeX = Math.min(...nodeRects.map((rect) => rect.x));
|
|
@@ -4632,7 +4794,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
4632
4794
|
const sequenced = ordering === "SEQUENCED";
|
|
4633
4795
|
const nestingIndex =
|
|
4634
4796
|
ordering === "REVERSE_STACKED" ? countOnSide - indexOnSide - 1 : indexOnSide;
|
|
4635
|
-
|
|
4797
|
+
|
|
4636
4798
|
let startRatio: number;
|
|
4637
4799
|
let endRatio: number;
|
|
4638
4800
|
if (sequenced) {
|
|
@@ -4650,10 +4812,14 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
4650
4812
|
startRatio = (countOnSide + nestingIndex + 1) / denominator;
|
|
4651
4813
|
endRatio = (countOnSide - nestingIndex) / denominator;
|
|
4652
4814
|
}
|
|
4815
|
+
const sideLoops = loops.filter(
|
|
4816
|
+
(candidate, i) => loopSide(distribution, i, candidate) === side,
|
|
4817
|
+
);
|
|
4818
|
+
const distance = loopDistance(sideLoops, edge, side, ordering);
|
|
4653
4819
|
const y =
|
|
4654
4820
|
side === "NORTH"
|
|
4655
|
-
? sourceRect.y -
|
|
4656
|
-
: sourceRect.y + sourceRect.height +
|
|
4821
|
+
? sourceRect.y - distance
|
|
4822
|
+
: sourceRect.y + sourceRect.height + distance;
|
|
4657
4823
|
const start = {
|
|
4658
4824
|
x: sourceRect.x + sourceRect.width * startRatio,
|
|
4659
4825
|
y: side === "NORTH" ? sourceRect.y : sourceRect.y + sourceRect.height,
|
|
@@ -4675,10 +4841,19 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
|
|
|
4675
4841
|
const secondRatio = sequenced ? 1 / denominator : 2 / denominator;
|
|
4676
4842
|
const startRatio = side === "EAST" ? firstRatio : secondRatio;
|
|
4677
4843
|
const endRatio = side === "EAST" ? secondRatio : firstRatio;
|
|
4844
|
+
const distance =
|
|
4845
|
+
input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
|
|
4846
|
+
? loopDistance(
|
|
4847
|
+
loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side),
|
|
4848
|
+
edge,
|
|
4849
|
+
side,
|
|
4850
|
+
ordering,
|
|
4851
|
+
)
|
|
4852
|
+
: spacing * (indexOnSide + 1);
|
|
4678
4853
|
const x =
|
|
4679
4854
|
side === "EAST"
|
|
4680
|
-
? sourceRect.x + sourceRect.width +
|
|
4681
|
-
: sourceRect.x -
|
|
4855
|
+
? sourceRect.x + sourceRect.width + distance
|
|
4856
|
+
: sourceRect.x - distance;
|
|
4682
4857
|
const start = {
|
|
4683
4858
|
x: side === "EAST" ? sourceRect.x + sourceRect.width : sourceRect.x,
|
|
4684
4859
|
y: sourceRect.y + sourceRect.height * startRatio,
|