@statelyai/layout 0.0.5 → 0.1.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.
@@ -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.add(edge.id);
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
- return { reversedEdgeIds };
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
- if (!centerLabeledEdgeIds.has(edge.id)) continue;
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 || (labelExtraByGap[layerNo] ?? 0) > 0
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 = spacing + precedingLabelExtent;
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
- const trackIndex = sequenced ? 1 : nestingIndex + 1;
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 - spacing * trackIndex
4656
- : sourceRect.y + sourceRect.height + spacing * trackIndex;
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 + spacing * (indexOnSide + 1)
4681
- : sourceRect.x - spacing * (indexOnSide + 1);
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,