@statelyai/layout 0.5.2 → 0.5.3

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.
@@ -8,86 +8,6 @@ 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
- }
91
11
 
92
12
  export interface LoopEnvelope {
93
13
  before: number;
@@ -205,10 +125,18 @@ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, Loo
205
125
  }
206
126
  }
207
127
  const loops = allLoops.filter((edge) => hasMovableLoopPorts(input, edge));
208
- if (loops.length === 0) continue;
128
+ // Inline labels remain owned by the existing labeled-loop phase. Move
129
+ // the entire node together when that phase is ported, rather than mixing
130
+ // two different loop distributions and reservations on the same node.
131
+ if (
132
+ loops.length === 0 ||
133
+ loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
134
+ )
135
+ continue;
209
136
  const settings = input.nodeSettings?.(node);
210
137
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
211
138
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
139
+ const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
212
140
  const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
213
141
  const sides =
214
142
  distribution === "EQUALLY"
@@ -216,34 +144,25 @@ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, Loo
216
144
  : distribution === "NORTH_SOUTH"
217
145
  ? (["before", "after"] as const)
218
146
  : (["before"] as const);
219
- const canonicalSides = {
220
- before: "NORTH",
221
- after: "SOUTH",
222
- flowAfter: "EAST",
223
- flowBefore: "WEST",
224
- } as const;
225
- const horizontal = input.direction === "right" || input.direction === "left";
226
- const size = input.sizes.get(node.id) ?? { width: 0, height: 0 };
227
- const flowSize = horizontal ? size.width : size.height;
228
147
  for (const side of sides) {
229
148
  const selected = loops.filter((_, index) => sides[index % sides.length] === side);
230
- const canonical = canonicalSides[side];
231
- envelope[side] = Math.max(
232
- envelope[side],
233
- ...selected.map(
234
- (edge) =>
235
- movableLoopTrack(input, selected, edge, canonical, ordering) +
236
- movableLoopLabelExtent(input, edge, canonical),
237
- ),
238
- );
239
- if (!crossSide(canonical)) continue;
240
- // ELK centers cross-side loop labels on their node along the flow, and
241
- // a label longer than its node widens the node's layer by the overhang.
242
- for (const edge of selected) {
243
- const overhang = ((horizontal ? edge.width : edge.height) ?? 0) - flowSize;
244
- envelope.flowBefore = Math.max(envelope.flowBefore, overhang / 2);
245
- envelope.flowAfter = Math.max(envelope.flowAfter, overhang / 2);
246
- }
149
+ const horizontal = input.direction === "right" || input.direction === "left";
150
+ const crossSize =
151
+ side === "before" || side === "after"
152
+ ? horizontal
153
+ ? "height"
154
+ : "width"
155
+ : horizontal
156
+ ? "width"
157
+ : "height";
158
+ const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
159
+ envelope[side] =
160
+ spacing * (ordering === "SEQUENCED" ? Math.min(1, selected.length) : selected.length) +
161
+ selected.reduce(
162
+ (sum, edge) =>
163
+ sum + ((edge[crossSize] ?? 0) > 0 ? (edge[crossSize] ?? 0) + labelSpacing : 0),
164
+ 0,
165
+ );
247
166
  }
248
167
  envelopes.set(node.id, envelope);
249
168
  }
@@ -19,8 +19,6 @@ import { routingCoordinates } from "./routing-coordinates";
19
19
  import {
20
20
  hasMovableLoopPorts,
21
21
  hasPlacementLoopEnvelope,
22
- movableLoopPortOffset,
23
- movableLoopTrack,
24
22
  loopEnvelopes,
25
23
  preparedLoopEnvelopes,
26
24
  } from "./loop-envelopes";
@@ -4339,14 +4337,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4339
4337
  edge: GraphEdge,
4340
4338
  side: string,
4341
4339
  ordering: string,
4340
+ canonical = false,
4342
4341
  ): number => {
4343
- const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
4342
+ const crossSide = side === "NORTH" || side === "SOUTH";
4343
+ const axis = crossSide === (!canonical || horizontal) ? "height" : "width";
4344
4344
  const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
4345
4345
  const index = ordered.indexOf(edge);
4346
4346
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
4347
4347
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
4348
4348
  const size = (candidate: GraphEdge) =>
4349
- input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
4349
+ canonical || input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
4350
4350
  ? (candidate[axis] ?? 0)
4351
4351
  : 0;
4352
4352
  return (
@@ -4442,7 +4442,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4442
4442
  for (const [id, loops] of selfLoopsByNodeId) {
4443
4443
  const rect = mutableRects.get(id);
4444
4444
  const node = nodeById.get(id);
4445
- if (!rect || !node || hasPlacementLoopEnvelope(placement, id)) continue;
4445
+ if (!rect || !node) continue;
4446
4446
  const settings = input.nodeSettings?.(node);
4447
4447
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
4448
4448
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
@@ -6146,7 +6146,9 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
6146
6146
  continue;
6147
6147
  }
6148
6148
  const movable =
6149
- hasMovableLoopPorts(input, edge) && hasPlacementLoopEnvelope(placement, source.id);
6149
+ hasMovableLoopPorts(input, edge) &&
6150
+ hasPlacementLoopEnvelope(placement, source.id) &&
6151
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true;
6150
6152
  // Route unconstrained loops in the canonical rightward system, then
6151
6153
  // apply the same direction transform as the layer pipeline.
6152
6154
  const loopRect = movable
@@ -6198,21 +6200,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
6198
6200
  const sideLoops = loops.filter(
6199
6201
  (candidate, i) => loopSide(distribution, i, candidate) === side,
6200
6202
  );
6201
- if (movable) {
6202
- const track = movableLoopTrack(input, sideLoops, edge, side, ordering);
6203
- const sign = side === "NORTH" ? -1 : 1;
6204
- const base = side === "NORTH" ? 0 : loopRect.height;
6205
- const [first, last] = [startRatio, endRatio].map((ratio) =>
6206
- movableLoopPortOffset(input, sideLoops, side, loopRect.width, ratio),
6207
- );
6208
- return [
6209
- { x: first!.along, y: base + sign * first!.normal },
6210
- { x: first!.along, y: base + sign * track },
6211
- { x: last!.along, y: base + sign * track },
6212
- { x: last!.along, y: base + sign * last!.normal },
6213
- ];
6214
- }
6215
- const distance = loopDistance(sideLoops, edge, side, ordering);
6203
+ const distance = loopDistance(sideLoops, edge, side, ordering, movable);
6216
6204
  const y =
6217
6205
  side === "NORTH" ? loopRect.y - distance : loopRect.y + loopRect.height + distance;
6218
6206
  const start = {
@@ -6236,26 +6224,15 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
6236
6224
  const secondRatio = sequenced ? 1 / denominator : 2 / denominator;
6237
6225
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
6238
6226
  const endRatio = side === "EAST" ? secondRatio : firstRatio;
6239
- const sideLoops = loops.filter(
6240
- (candidate, i) => loopSide(distribution, i, candidate) === side,
6241
- );
6242
- if (movable) {
6243
- const track = movableLoopTrack(input, sideLoops, edge, side, ordering);
6244
- const sign = side === "WEST" ? -1 : 1;
6245
- const base = side === "WEST" ? 0 : loopRect.width;
6246
- const [first, last] = [startRatio, endRatio].map((ratio) =>
6247
- movableLoopPortOffset(input, sideLoops, side, loopRect.height, ratio),
6248
- );
6249
- return [
6250
- { x: base + sign * first!.normal, y: first!.along },
6251
- { x: base + sign * track, y: first!.along },
6252
- { x: base + sign * track, y: last!.along },
6253
- { x: base + sign * last!.normal, y: last!.along },
6254
- ];
6255
- }
6256
6227
  const distance =
6257
- input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
6258
- ? loopDistance(sideLoops, edge, side, ordering)
6228
+ movable || input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
6229
+ ? loopDistance(
6230
+ loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side),
6231
+ edge,
6232
+ side,
6233
+ ordering,
6234
+ movable,
6235
+ )
6259
6236
  : spacing * (indexOnSide + 1);
6260
6237
  const x =
6261
6238
  side === "EAST" ? loopRect.x + loopRect.width + distance : loopRect.x - distance;
@@ -1,158 +0,0 @@
1
- /* ELK self-loop port hiding and restoration. SPDX-License-Identifier: EPL-2.0 */
2
- import type { Graph, GraphEdge, GraphPort, Point, VisualGraph, VisualPort } from "@statelyai/graph";
3
- import type { LayeredLayoutOptions } from "./index";
4
-
5
- interface PortSize {
6
- width: number;
7
- height: number;
8
- }
9
- export interface HiddenLoopPorts {
10
- source: PortSize;
11
- target: PortSize;
12
- }
13
-
14
- const hiddenBySettings = new WeakMap<object, ReadonlyMap<string, HiddenLoopPorts>>();
15
-
16
- /** Sizes of the ports SelfLoopPreProcessor hid from a loop, if any. */
17
- export function hiddenLoopPorts(settings: object, edgeId: string): HiddenLoopPorts | undefined {
18
- return hiddenBySettings.get(settings)?.get(edgeId);
19
- }
20
-
21
- /**
22
- * ELK's SelfLoopPreProcessor removes ports that carry only self loops from
23
- * nodes whose port order is not fixed, and SelfLoopPortRestorer later puts
24
- * them on the loop's side. The layered phases then see implicit loop ports;
25
- * {@link restoreSelfLoopPorts} places the hidden ports at the loop ends.
26
- */
27
- export function hideSelfLoopPorts<N, E, G, P>(
28
- graph: Graph<N, E, G, P>,
29
- options: LayeredLayoutOptions,
30
- ):
31
- | {
32
- graph: Graph<N, E, G, P>;
33
- options: LayeredLayoutOptions;
34
- edges: ReadonlyMap<string, GraphEdge<E>>;
35
- }
36
- | undefined {
37
- const uses = new Map<string, number>();
38
- const nonLoop = new Set<string>();
39
- for (const edge of graph.edges) {
40
- for (const [node, port] of [
41
- [edge.sourceId, edge.sourcePort],
42
- [edge.targetId, edge.targetPort],
43
- ] as const) {
44
- if (port === undefined) continue;
45
- const key = `${node}\0${port}`;
46
- uses.set(key, (uses.get(key) ?? 0) + 1);
47
- if (edge.sourceId !== edge.targetId) nonLoop.add(key);
48
- }
49
- }
50
- const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
51
- const hidden = new Map<string, HiddenLoopPorts>();
52
- const edges = new Map<string, GraphEdge<E>>();
53
- const hiddenPorts = new Set<string>();
54
- for (const edge of graph.edges) {
55
- if (
56
- edge.sourceId !== edge.targetId ||
57
- edge.sourcePort === undefined ||
58
- edge.targetPort === undefined ||
59
- edge.sourcePort === edge.targetPort
60
- )
61
- continue;
62
- const node = nodeById.get(edge.sourceId);
63
- const constraints = node && options.nodeSettings?.(node)?.portConstraints;
64
- if (
65
- !node ||
66
- (constraints !== undefined && constraints !== "FREE" && constraints !== "UNDEFINED")
67
- )
68
- continue;
69
- // One loop per port: shared ports would make a self hyperloop.
70
- const ports = [edge.sourcePort, edge.targetPort].map((name) =>
71
- node.ports?.find((port) => port.name === name),
72
- );
73
- if (
74
- ports.some(
75
- (port) =>
76
- !port ||
77
- uses.get(`${node.id}\0${port.name}`) !== 1 ||
78
- nonLoop.has(`${node.id}\0${port.name}`),
79
- )
80
- )
81
- continue;
82
- const size = (port: GraphPort<P>) => ({ width: port.width ?? 0, height: port.height ?? 0 });
83
- hidden.set(edge.id, { source: size(ports[0]!), target: size(ports[1]!) });
84
- edges.set(edge.id, edge);
85
- for (const port of ports) hiddenPorts.add(`${node.id}\0${port!.name}`);
86
- }
87
- if (hidden.size === 0) return undefined;
88
- const settings = { ...options.settings };
89
- hiddenBySettings.set(settings, hidden);
90
- return {
91
- graph: {
92
- ...graph,
93
- nodes: graph.nodes.map((node) =>
94
- node.ports?.some((port) => hiddenPorts.has(`${node.id}\0${port.name}`))
95
- ? {
96
- ...node,
97
- ports: node.ports.filter((port) => !hiddenPorts.has(`${node.id}\0${port.name}`)),
98
- }
99
- : node,
100
- ),
101
- edges: graph.edges.map((edge) =>
102
- hidden.has(edge.id) ? { ...edge, sourcePort: undefined, targetPort: undefined } : edge,
103
- ),
104
- },
105
- options: { ...options, settings },
106
- edges,
107
- };
108
- }
109
-
110
- /** Put hidden loop ports back, anchored on the side their loop leaves from. */
111
- export function restoreSelfLoopPorts<N, E, G, P>(
112
- graph: Graph<N, E, G, P>,
113
- result: VisualGraph<N, E, G, P>,
114
- edges: ReadonlyMap<string, GraphEdge<E>>,
115
- ): VisualGraph<N, E, G, P> {
116
- const anchors = new Map<string, Point>();
117
- for (const edge of result.edges) {
118
- const original = edges.get(edge.id);
119
- const points = edge.points;
120
- if (!original || !points?.length) continue;
121
- anchors.set(`${edge.sourceId}\0${original.sourcePort}`, points[0]!);
122
- anchors.set(`${edge.targetId}\0${original.targetPort}`, points.at(-1)!);
123
- }
124
- const originalById = new Map(graph.nodes.map((node) => [node.id, node]));
125
- return {
126
- ...result,
127
- nodes: result.nodes.map((node) => {
128
- const original = originalById.get(node.id)?.ports;
129
- if (!original?.some((port) => anchors.has(`${node.id}\0${port.name}`))) return node;
130
- const placed = new Map(node.ports?.map((port) => [port.name, port]));
131
- return {
132
- ...node,
133
- ports: original.map((port): VisualPort<P> => {
134
- const anchor = anchors.get(`${node.id}\0${port.name}`);
135
- const width = port.width ?? 0,
136
- height = port.height ?? 0;
137
- if (!anchor) return placed.get(port.name) ?? { ...port, width, height, x: 0, y: 0 };
138
- const x = anchor.x - node.x,
139
- y = anchor.y - node.y;
140
- // The loop leaves from the port's outer border, centered on it.
141
- return {
142
- ...port,
143
- width,
144
- height,
145
- x: x <= 0 ? x : x >= node.width ? x - width : x - width / 2,
146
- y: x <= 0 || x >= node.width ? y - height / 2 : y <= 0 ? y : y - height,
147
- };
148
- }),
149
- };
150
- }),
151
- edges: result.edges.map((edge) => {
152
- const original = edges.get(edge.id);
153
- return original
154
- ? { ...edge, sourcePort: original.sourcePort, targetPort: original.targetPort }
155
- : edge;
156
- }),
157
- };
158
- }