@statelyai/layout 0.2.0 → 0.4.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.
Files changed (132) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +221 -13
  3. package/dist/elkjs/index.cjs +3 -2
  4. package/dist/elkjs/index.d.cts +13 -2
  5. package/dist/elkjs/index.d.mts +13 -2
  6. package/dist/elkjs/index.mjs +3 -3
  7. package/dist/elkjs/main.cjs +2 -2
  8. package/dist/elkjs/main.d.cts +2 -2
  9. package/dist/elkjs/main.d.mts +2 -2
  10. package/dist/elkjs/main.mjs +2 -2
  11. package/dist/elkjs/worker-api.cjs +1 -1
  12. package/dist/elkjs/worker-api.d.cts +2 -2
  13. package/dist/elkjs/worker-api.d.mts +2 -2
  14. package/dist/elkjs/worker-api.mjs +1 -1
  15. package/dist/elkjs/worker.cjs +2 -2
  16. package/dist/elkjs/worker.mjs +2 -2
  17. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  18. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  19. package/dist/index-B6cWNrMw.d.cts +80 -0
  20. package/dist/index-CJQLUKD9.d.mts +80 -0
  21. package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
  22. package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
  23. package/dist/index.cjs +39 -9
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.mts +4 -3
  26. package/dist/index.mjs +4 -3
  27. package/dist/layered/index.cjs +3 -1
  28. package/dist/layered/index.d.cts +3 -2
  29. package/dist/layered/index.d.mts +3 -2
  30. package/dist/layered/index.mjs +2 -2
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
  32. package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  34. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  35. package/dist/routing/index.cjs +29 -0
  36. package/dist/routing/index.d.cts +3 -0
  37. package/dist/routing/index.d.mts +3 -0
  38. package/dist/routing/index.mjs +3 -0
  39. package/dist/routing-CR8flv8b.mjs +2780 -0
  40. package/dist/routing-QIdGGFBe.cjs +2996 -0
  41. package/dist/types-CT2akdK0.d.mts +133 -0
  42. package/dist/types-uxPlGRof.d.cts +133 -0
  43. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  44. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  45. package/package.json +14 -3
  46. package/src/authoring/coordinates.ts +38 -10
  47. package/src/authoring/partial.ts +7 -2
  48. package/src/authoring/routing.ts +4 -1
  49. package/src/constraints.ts +174 -6
  50. package/src/elkjs/index.ts +1397 -452
  51. package/src/elkjs/layout-quality.ts +219 -0
  52. package/src/elkjs/opposing-tracks.ts +161 -0
  53. package/src/elkjs/orthogonal-junctions.ts +174 -0
  54. package/src/elkjs/routes.ts +147 -0
  55. package/src/elkjs/statechart.ts +9 -3
  56. package/src/elkjs/types.ts +2 -0
  57. package/src/index.ts +8 -0
  58. package/src/internal/elkjs-compatibility.ts +4 -1
  59. package/src/internal/layered-trace.ts +48 -0
  60. package/src/internal/layout-engine.ts +2 -1
  61. package/src/java-random.ts +6 -0
  62. package/src/layered/bk-node-placement.ts +403 -92
  63. package/src/layered/center-labels.ts +398 -0
  64. package/src/layered/compaction-bounds.ts +26 -0
  65. package/src/layered/compaction-errors.ts +2 -0
  66. package/src/layered/compaction-scanline.ts +63 -0
  67. package/src/layered/compound-boundaries.ts +99 -0
  68. package/src/layered/compound.ts +445 -0
  69. package/src/layered/constraint-orientation.ts +141 -0
  70. package/src/layered/crossing-constraints.ts +243 -0
  71. package/src/layered/crossing-counter.ts +221 -0
  72. package/src/layered/crossing-graph.ts +401 -0
  73. package/src/layered/cycle-random.ts +22 -0
  74. package/src/layered/elk-port-lists.ts +243 -0
  75. package/src/layered/end-labels.ts +124 -0
  76. package/src/layered/external-port-dummy.ts +137 -0
  77. package/src/layered/feedback-chains.ts +114 -0
  78. package/src/layered/fixed-self-loop.ts +150 -0
  79. package/src/layered/greedy-switch-decider.ts +356 -0
  80. package/src/layered/grouped-compaction.ts +787 -0
  81. package/src/layered/gwt-sort.ts +53 -0
  82. package/src/layered/hierarchical-port-phases.ts +242 -0
  83. package/src/layered/hierarchical-port-restoration.ts +233 -0
  84. package/src/layered/hierarchical-port-routing.ts +179 -0
  85. package/src/layered/hierarchy-crossing.ts +121 -0
  86. package/src/layered/hierarchy-sweepiness.ts +83 -0
  87. package/src/layered/hints.ts +371 -0
  88. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  89. package/src/layered/index.ts +911 -261
  90. package/src/layered/initial-model-order.ts +404 -0
  91. package/src/layered/inverted-ports.ts +207 -0
  92. package/src/layered/label-dummy-switcher.ts +200 -0
  93. package/src/layered/long-edges.ts +135 -44
  94. package/src/layered/loop-envelopes.ts +184 -0
  95. package/src/layered/native-routing.ts +275 -0
  96. package/src/layered/network-simplex.ts +11 -15
  97. package/src/layered/node-margins.ts +59 -0
  98. package/src/layered/north-south-ports.ts +271 -0
  99. package/src/layered/opposing-tracks.ts +944 -0
  100. package/src/layered/orthogonal-cycle-order.ts +106 -0
  101. package/src/layered/orthogonal-hypersegments.ts +92 -0
  102. package/src/layered/orthogonal-junctions.ts +202 -0
  103. package/src/layered/orthogonal-segments.ts +243 -0
  104. package/src/layered/placement-orientation.ts +22 -0
  105. package/src/layered/port-distributor.ts +192 -0
  106. package/src/layered/port-edge-order.ts +20 -0
  107. package/src/layered/replace-routing.ts +45 -0
  108. package/src/layered/routing-coordinates.ts +32 -0
  109. package/src/layered/separate-exterior-labels.ts +24 -6
  110. package/src/layered/spacing.ts +34 -12
  111. package/src/layered/splitter-order.ts +106 -0
  112. package/src/layered/strategies.ts +2024 -883
  113. package/src/layered/types.ts +34 -0
  114. package/src/layered/weighted-compaction.ts +84 -0
  115. package/src/routing/adapters.ts +143 -0
  116. package/src/routing/algorithms.ts +1088 -0
  117. package/src/routing/coordination.ts +91 -0
  118. package/src/routing/engine.ts +473 -0
  119. package/src/routing/index.ts +69 -0
  120. package/src/routing/layout-cache.ts +65 -0
  121. package/src/routing/model.ts +218 -0
  122. package/src/routing/path.ts +324 -0
  123. package/src/routing/persistent.ts +117 -0
  124. package/src/routing/search.ts +358 -0
  125. package/src/routing/spatial.ts +78 -0
  126. package/src/routing/types.ts +146 -0
  127. package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
  128. package/dist/elkjs-CFlfkEBz.mjs +0 -1528
  129. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  130. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  131. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  132. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -0,0 +1,124 @@
1
+ import type { EntityRect, Point } from "@statelyai/graph";
2
+ import type { ExteriorLabelEdge } from "./separate-exterior-labels";
3
+
4
+ interface PlaceEndLabelsInput<E extends ExteriorLabelEdge> {
5
+ edges: E[];
6
+ nodeRects: readonly EntityRect[];
7
+ spacing: number;
8
+ placement: (edge: E) => string;
9
+ inline: (edge: E) => boolean;
10
+ }
11
+
12
+ const overlaps = (left: EntityRect, right: EntityRect): boolean =>
13
+ left.x < right.x + right.width &&
14
+ left.x + left.width > right.x &&
15
+ left.y < right.y + right.height &&
16
+ left.y + left.height > right.y;
17
+
18
+ /** Whether segment a-b passes through the box's interior (Liang-Barsky clipping). */
19
+ const segmentCrosses = (a: Point, b: Point, box: EntityRect): boolean => {
20
+ let low = 0,
21
+ high = 1;
22
+ const dx = b.x - a.x,
23
+ dy = b.y - a.y;
24
+ for (const [p, q] of [
25
+ [-dx, a.x - box.x],
26
+ [dx, box.x + box.width - a.x],
27
+ [-dy, a.y - box.y],
28
+ [dy, box.y + box.height - a.y],
29
+ ] as const) {
30
+ if (p === 0) {
31
+ if (q <= 0) return false;
32
+ continue;
33
+ }
34
+ const t = q / p;
35
+ if (p < 0) low = Math.max(low, t);
36
+ else high = Math.min(high, t);
37
+ }
38
+ return low < high;
39
+ };
40
+
41
+ const crosses = (points: readonly Point[], box: EntityRect): boolean =>
42
+ points.some((a, index) => {
43
+ const b = points[index + 1];
44
+ return b !== undefined && segmentCrosses(a, b, box);
45
+ });
46
+
47
+ /**
48
+ * Moves a HEAD or TAIL label that covers a node, another label or a route
49
+ * beside its route instead: along the segment leaving its endpoint first,
50
+ * then along later segments, at the first spot clear of all three, or else
51
+ * the spot with the fewest collisions, nodes and labels weighing most.
52
+ */
53
+ export function placeEndLabels<E extends ExteriorLabelEdge>({
54
+ edges,
55
+ nodeRects,
56
+ spacing,
57
+ placement,
58
+ inline,
59
+ }: PlaceEndLabelsInput<E>): void {
60
+ const labelled = edges.filter((edge) => edge.width > 0 && edge.height > 0);
61
+ // Covering a node or another label hides content; a route through a label hides less.
62
+ const conflicts = (edge: E, box: EntityRect) =>
63
+ 2 * nodeRects.filter((rect) => overlaps(box, rect)).length +
64
+ 2 * labelled.filter((other) => other !== edge && overlaps(box, other)).length +
65
+ edges.filter((other) => crosses(other.points, box)).length;
66
+ for (const edge of labelled) {
67
+ const end = placement(edge);
68
+ // A CENTER label moves only off a node, another label or another route,
69
+ // to the clear spot nearest its own.
70
+ const center = end === "CENTER" && !inline(edge);
71
+ if (end !== "HEAD" && end !== "TAIL" && !center) continue;
72
+ const { width, height } = edge;
73
+ let best = { x: edge.x, y: edge.y, conflicts: conflicts(edge, edge), distance: 0 };
74
+ if (best.conflicts === 0) continue;
75
+ if (center && best.conflicts === Number(crosses(edge.points, edge))) continue;
76
+ const route = end === "HEAD" ? edge.points.toReversed() : edge.points;
77
+ // Beside each segment from the endpoint on, hugging the route before stepping away from it.
78
+ search: for (let lateral = spacing; lateral <= spacing + 64; lateral += 8)
79
+ for (let index = 1; index < route.length; index++) {
80
+ const a = route[index - 1]!,
81
+ b = route[index]!;
82
+ const dx = b.x - a.x,
83
+ dy = b.y - a.y;
84
+ if (dx === 0 && dy === 0) continue;
85
+ const along = Math.abs(dx) >= Math.abs(dy);
86
+ const length = along ? Math.abs(dx) : Math.abs(dy);
87
+ const size = along ? width : height;
88
+ const step = Math.max(4, (length - size) / 16);
89
+ // Slide from the segment's first point toward its last, below or right first, as ELK does.
90
+ for (
91
+ let offset = spacing;
92
+ offset <= Math.max(spacing, length - size - spacing);
93
+ offset += step
94
+ ) {
95
+ const boxes = along
96
+ ? [a.y + lateral, a.y - lateral - height].map((y) => ({
97
+ x: dx > 0 ? a.x + offset : a.x - offset - width,
98
+ y,
99
+ width,
100
+ height,
101
+ }))
102
+ : [a.x + lateral, a.x - lateral - width].map((x) => ({
103
+ x,
104
+ y: dy > 0 ? a.y + offset : a.y - offset - height,
105
+ width,
106
+ height,
107
+ }));
108
+ for (const box of boxes) {
109
+ const count = conflicts(edge, box);
110
+ const distance = Math.abs(box.x - edge.x) + Math.abs(box.y - edge.y);
111
+ if (
112
+ count < best.conflicts ||
113
+ (center && count === best.conflicts && distance < best.distance)
114
+ )
115
+ best = { x: box.x, y: box.y, conflicts: count, distance };
116
+ if (count === 0 && !center) break search;
117
+ }
118
+ }
119
+ }
120
+ // Without a clear spot, the spot with the fewest collisions.
121
+ edge.x = best.x;
122
+ edge.y = best.y;
123
+ }
124
+ }
@@ -0,0 +1,137 @@
1
+ /*
2
+ * Copyright (c) 2011, 2020 Kiel University and others.
3
+ * Native adaptation of ELK LGraphUtil.createExternalPortDummy, elkjs 0.11.1.
4
+ * SPDX-License-Identifier: EPL-2.0
5
+ */
6
+ import type { Point } from "@statelyai/graph";
7
+
8
+ export type ExternalPortSide = "WEST" | "EAST" | "NORTH" | "SOUTH";
9
+ export type ExternalPortConstraints =
10
+ | "UNDEFINED"
11
+ | "FREE"
12
+ | "FIXED_SIDE"
13
+ | "FIXED_ORDER"
14
+ | "FIXED_RATIO"
15
+ | "FIXED_POS";
16
+ export interface ExternalPortDummyInput {
17
+ constraints: ExternalPortConstraints;
18
+ side: ExternalPortSide;
19
+ direction: "RIGHT" | "LEFT" | "DOWN" | "UP";
20
+ netFlow: number;
21
+ borderOffset: number;
22
+ size: { width: number; height: number };
23
+ ownerSize?: { width: number; height: number };
24
+ position?: Point;
25
+ anchor?: Point;
26
+ index?: number;
27
+ }
28
+ export interface ExternalPortDummy {
29
+ type: "EXTERNAL_PORT";
30
+ /** Parent-scope port identity for a reused compound endpoint. */
31
+ parentPortId?: string;
32
+ constraints: "FIXED_POS";
33
+ side: ExternalPortSide;
34
+ borderOffset: number;
35
+ externalSize: { width: number; height: number };
36
+ width: number;
37
+ height: number;
38
+ port: { side: ExternalPortSide; x: number; y: number };
39
+ anchor: Point;
40
+ layerConstraint?: "FIRST_SEPARATE" | "LAST_SEPARATE";
41
+ edgeConstraint?: "OUTGOING_ONLY" | "INCOMING_ONLY";
42
+ inLayerConstraint?: "TOP" | "BOTTOM";
43
+ ratioOrPosition?: number;
44
+ }
45
+ const opposite: Record<ExternalPortSide, ExternalPortSide> = {
46
+ WEST: "EAST",
47
+ EAST: "WEST",
48
+ NORTH: "SOUTH",
49
+ SOUTH: "NORTH",
50
+ };
51
+
52
+ /** Preserve the upstream pre-direction-transform external boundary representation. */
53
+ export function createExternalPortDummy(input: ExternalPortDummyInput): ExternalPortDummy {
54
+ const forward = ({ RIGHT: "EAST", LEFT: "WEST", DOWN: "SOUTH", UP: "NORTH" } as const)[
55
+ input.direction
56
+ ];
57
+ const side =
58
+ input.constraints === "FREE" || input.constraints === "UNDEFINED"
59
+ ? input.netFlow >= 0
60
+ ? forward
61
+ : opposite[forward]
62
+ : input.side;
63
+ const anchor = input.anchor
64
+ ? { ...input.anchor }
65
+ : { x: input.size.width / 2, y: input.size.height / 2 };
66
+ const result: ExternalPortDummy = {
67
+ type: "EXTERNAL_PORT",
68
+ constraints: "FIXED_POS",
69
+ side,
70
+ borderOffset: input.borderOffset,
71
+ externalSize: { ...input.size },
72
+ width: 0,
73
+ height: 0,
74
+ port: { side: opposite[side], x: 0, y: 0 },
75
+ anchor,
76
+ };
77
+ const depth = input.borderOffset < 0 ? -input.borderOffset : 0;
78
+ switch (side) {
79
+ case "WEST":
80
+ result.layerConstraint = "FIRST_SEPARATE";
81
+ result.edgeConstraint = "OUTGOING_ONLY";
82
+ result.height = input.size.height;
83
+ result.width = depth;
84
+ if (!input.anchor) anchor.x = input.size.width;
85
+ anchor.x -= input.size.width;
86
+ break;
87
+ case "EAST":
88
+ result.layerConstraint = "LAST_SEPARATE";
89
+ result.edgeConstraint = "INCOMING_ONLY";
90
+ result.height = input.size.height;
91
+ result.width = depth;
92
+ if (!input.anchor) anchor.x = 0;
93
+ break;
94
+ case "NORTH":
95
+ result.inLayerConstraint = "TOP";
96
+ result.width = input.size.width;
97
+ result.height = depth;
98
+ if (!input.anchor) anchor.y = input.size.height;
99
+ anchor.y -= input.size.height;
100
+ break;
101
+ case "SOUTH":
102
+ result.inLayerConstraint = "BOTTOM";
103
+ result.width = input.size.width;
104
+ result.height = depth;
105
+ if (!input.anchor) anchor.y = 0;
106
+ break;
107
+ }
108
+ result.port.x = anchor.x;
109
+ result.port.y = anchor.y;
110
+ if (
111
+ input.constraints === "FIXED_ORDER" ||
112
+ input.constraints === "FIXED_RATIO" ||
113
+ input.constraints === "FIXED_POS"
114
+ ) {
115
+ let information = 0;
116
+ if (input.constraints === "FIXED_ORDER" && input.index !== undefined) {
117
+ information = side === "NORTH" || side === "EAST" ? input.index : -input.index;
118
+ } else {
119
+ const horizontalSide = side === "WEST" || side === "EAST";
120
+ information = horizontalSide ? input.position!.y : input.position!.x;
121
+ if (input.constraints === "FIXED_RATIO")
122
+ information /= horizontalSide ? input.ownerSize!.height : input.ownerSize!.width;
123
+ }
124
+ result.ratioOrPosition = information;
125
+ }
126
+ return result;
127
+ }
128
+
129
+ // Symbols survive native phase object spreads without becoming public JSON fields.
130
+ const externalPortDummyOrigin = Symbol("externalPortDummyOrigin");
131
+ export function attachExternalPortDummy<T extends object>(node: T, origin: ExternalPortDummy): T {
132
+ Object.defineProperty(node, externalPortDummyOrigin, { value: origin, enumerable: true });
133
+ return node;
134
+ }
135
+ export function externalPortDummyOf(node: object): ExternalPortDummy | undefined {
136
+ return (node as { [externalPortDummyOrigin]?: ExternalPortDummy })[externalPortDummyOrigin];
137
+ }
@@ -0,0 +1,114 @@
1
+ import type { EntityRect, Point } from "@statelyai/graph";
2
+
3
+ export interface FeedbackChain {
4
+ edgeId: string;
5
+ /** Center-label dummy on the chain and the cross coordinate where the route meets it. */
6
+ label?: { id: string; cross: number };
7
+ }
8
+
9
+ /** Whether two orthogonal polylines run along one line in opposite directions. */
10
+ function opposed(a: readonly Point[], b: readonly Point[]): boolean {
11
+ for (let i = 1; i < a.length; i++)
12
+ for (let j = 1; j < b.length; j++) {
13
+ const s = { a: a[i - 1]!, b: a[i]! },
14
+ t = { a: b[j - 1]!, b: b[j]! };
15
+ const dx = s.b.x - s.a.x,
16
+ dy = s.b.y - s.a.y;
17
+ if (dx * (t.b.x - t.a.x) + dy * (t.b.y - t.a.y) >= 0) continue;
18
+ const along = dx !== 0 ? "x" : "y",
19
+ across = along === "x" ? "y" : "x";
20
+ if (dx !== 0 && dy !== 0) continue;
21
+ if (s.a[across] !== s.b[across] || t.a[across] !== t.b[across]) continue;
22
+ if (Math.abs(s.a[across] - t.a[across]) > 1e-6) continue;
23
+ const lo = Math.max(Math.min(s.a[along], s.b[along]), Math.min(t.a[along], t.b[along]));
24
+ const hi = Math.min(Math.max(s.a[along], s.b[along]), Math.max(t.a[along], t.b[along]));
25
+ if (hi - lo > 1e-6) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * A reversed feedback edge split by label or inverted-port dummies is routed
32
+ * one segment at a time, so each segment detours to the outer feedback track
33
+ * and back to the next dummy. Rejoin the chain as one track: the label's own
34
+ * track, or the outer track with the label moved onto it, whichever is clear.
35
+ */
36
+ export function straightenFeedbackChains(
37
+ chains: readonly FeedbackChain[],
38
+ pointsByEdgeId: Map<string, readonly Point[]>,
39
+ rectByNodeId: Map<string, EntityRect>,
40
+ horizontal: boolean,
41
+ ): void {
42
+ const cross = (p: Point) => (horizontal ? p.y : p.x);
43
+ const flow = (p: Point) => (horizontal ? p.x : p.y);
44
+ const at = (f: number, c: number): Point => (horizontal ? { x: f, y: c } : { x: c, y: f });
45
+ const overlaps = (a: EntityRect, b: EntityRect) =>
46
+ a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
47
+ // Degenerate boxes still hit a rect whose interior they pass through.
48
+ const hits = (a: Point, b: Point, rect: EntityRect) =>
49
+ rect.width > 0 &&
50
+ rect.height > 0 &&
51
+ Math.min(a.x, b.x) < rect.x + rect.width &&
52
+ Math.max(a.x, b.x) > rect.x &&
53
+ Math.min(a.y, b.y) < rect.y + rect.height &&
54
+ Math.max(a.y, b.y) > rect.y;
55
+ for (const { edgeId, label } of chains) {
56
+ const points = pointsByEdgeId.get(edgeId);
57
+ if (!points || points.length < 6) continue;
58
+ const [start, lead] = points as [Point, Point];
59
+ const tail = points.at(-2)!,
60
+ end = points.at(-1)!;
61
+ const outer = Math.max(...points.map(cross));
62
+ const labelRect = label && rectByNodeId.get(label.id);
63
+ for (const track of label ? [label.cross, outer] : [outer]) {
64
+ const route = [start, lead, at(flow(lead), track), at(flow(tail), track), tail, end];
65
+ const moved = labelRect && {
66
+ ...labelRect,
67
+ ...(horizontal
68
+ ? { y: labelRect.y + track - label!.cross }
69
+ : { x: labelRect.x + track - label!.cross }),
70
+ };
71
+ const blocked = [...rectByNodeId].some(
72
+ ([id, rect]) =>
73
+ id !== label?.id &&
74
+ (route.slice(1, 4).some((point, index) => hits(point, route[index + 2]!, rect)) ||
75
+ (moved !== undefined && overlaps(moved, rect))),
76
+ );
77
+ const crossed =
78
+ moved !== undefined &&
79
+ track !== label!.cross &&
80
+ [...pointsByEdgeId].some(
81
+ ([id, other]) =>
82
+ id !== edgeId &&
83
+ other.some((point, index) => index > 0 && hits(other[index - 1]!, point, moved)),
84
+ );
85
+ if (blocked || crossed) continue;
86
+ const simple: Point[] = [];
87
+ for (const point of route) {
88
+ const previous = simple.at(-1),
89
+ before = simple.at(-2);
90
+ if (previous && previous.x === point.x && previous.y === point.y) continue;
91
+ if (
92
+ previous &&
93
+ before &&
94
+ ((before.x === previous.x && previous.x === point.x) ||
95
+ (before.y === previous.y && previous.y === point.y))
96
+ ) {
97
+ // A collinear turnaround folds the route back onto itself.
98
+ if ((previous.x - before.x) * (point.x - previous.x) < 0) break;
99
+ if ((previous.y - before.y) * (point.y - previous.y) < 0) break;
100
+ simple.pop();
101
+ }
102
+ simple.push(point);
103
+ }
104
+ if (simple.at(-1) !== end) continue;
105
+ if (simple.length >= points.length) break;
106
+ // Never share a track with another route heading the opposite way.
107
+ if ([...pointsByEdgeId].some(([id, other]) => id !== edgeId && opposed(simple, other)))
108
+ continue;
109
+ pointsByEdgeId.set(edgeId, simple);
110
+ if (moved) rectByNodeId.set(label!.id, moved);
111
+ break;
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,150 @@
1
+ /* Native fixed-port perimeter routing following ELK self-loop semantics. SPDX-License-Identifier: EPL-2.0 */
2
+ import type { EntityRect, GraphEdge, Point } from "@statelyai/graph";
3
+ import type { LayoutDirection } from "../types";
4
+ type Side = "NORTH" | "EAST" | "SOUTH" | "WEST";
5
+
6
+ export function fixedSelfLoopSide(value: string | undefined): Side | undefined {
7
+ return value === "NORTH" || value === "EAST" || value === "SOUTH" || value === "WEST"
8
+ ? value
9
+ : undefined;
10
+ }
11
+
12
+ /** Route fixed anchors around the perimeter; never replace a port with a default loop anchor. */
13
+ export function routeFixedSelfLoop(
14
+ rect: EntityRect,
15
+ start: Point,
16
+ end: Point,
17
+ sourceSide: Side,
18
+ targetSide: Side,
19
+ distance: number,
20
+ direction: LayoutDirection,
21
+ ): Point[] {
22
+ const horizontal = direction === "right" || direction === "left";
23
+ const width = horizontal ? rect.width : rect.height;
24
+ const height = horizontal ? rect.height : rect.width;
25
+ const reverse = direction === "left" || direction === "up";
26
+ const canonical = (p: Point): Point => {
27
+ const flow = horizontal ? p.x - rect.x : p.y - rect.y;
28
+ return { x: reverse ? width - flow : flow, y: horizontal ? p.y - rect.y : p.x - rect.x };
29
+ };
30
+ const physical = (p: Point): Point => {
31
+ const flow = reverse ? width - p.x : p.x;
32
+ return { x: rect.x + (horizontal ? flow : p.y), y: rect.y + (horizontal ? p.y : flow) };
33
+ };
34
+ const side = (s: Side): Side => {
35
+ const vectors = {
36
+ NORTH: { x: 0, y: -1 },
37
+ EAST: { x: 1, y: 0 },
38
+ SOUTH: { x: 0, y: 1 },
39
+ WEST: { x: -1, y: 0 },
40
+ };
41
+ const v = vectors[s];
42
+ const flow = (horizontal ? v.x : v.y) * (reverse ? -1 : 1);
43
+ const cross = horizontal ? v.y : v.x;
44
+ return flow > 0 ? "EAST" : flow < 0 ? "WEST" : cross > 0 ? "SOUTH" : "NORTH";
45
+ };
46
+ const a = canonical(start),
47
+ b = canonical(end);
48
+ const from = side(sourceSide),
49
+ to = side(targetSide);
50
+ const outward = (p: Point, s: Side): Point => ({
51
+ x:
52
+ s === "WEST"
53
+ ? Math.min(0, p.x) - distance
54
+ : s === "EAST"
55
+ ? Math.max(width, p.x) + distance
56
+ : p.x,
57
+ y:
58
+ s === "NORTH"
59
+ ? Math.min(0, p.y) - distance
60
+ : s === "SOUTH"
61
+ ? Math.max(height, p.y) + distance
62
+ : p.y,
63
+ });
64
+ const first = outward(a, from),
65
+ last = outward(b, to);
66
+ // A loop on a single port closes a small square beside the port instead of
67
+ // retracing its own outward segment.
68
+ if (from === to && a.x === b.x && a.y === b.y) {
69
+ const alongY = from === "WEST" || from === "EAST";
70
+ const step = (alongY ? a.y < height / 2 : a.x < width / 2) ? distance : -distance;
71
+ const shift = (p: Point): Point =>
72
+ alongY ? { x: p.x, y: p.y + step } : { x: p.x + step, y: p.y };
73
+ return [a, first, shift(first), shift(b), b].map(physical);
74
+ }
75
+ const points = [a, first];
76
+ if (from !== to) {
77
+ const verticalFrom = from === "WEST" || from === "EAST";
78
+ const verticalTo = to === "WEST" || to === "EAST";
79
+ if (verticalFrom !== verticalTo) {
80
+ points.push(verticalFrom ? { x: first.x, y: last.y } : { x: last.x, y: first.y });
81
+ } else if (verticalFrom) {
82
+ points.push({ x: first.x, y: height + distance }, { x: last.x, y: height + distance });
83
+ } else {
84
+ const track = from === "SOUTH" ? -distance : width + distance;
85
+ points.push({ x: track, y: first.y }, { x: track, y: last.y });
86
+ }
87
+ }
88
+ points.push(last, b);
89
+ return points.map(physical);
90
+ }
91
+
92
+ /**
93
+ * Spots for a same-side loop's label beyond its outer segment: centered on
94
+ * the loop, then flush with either end of it so the label clears edges that
95
+ * meet the loop's ports along their port lines.
96
+ */
97
+ export function fixedSelfLoopLabels(
98
+ points: readonly Point[],
99
+ side: Side,
100
+ width: number,
101
+ height: number,
102
+ spacing: number,
103
+ ): Point[] {
104
+ const xs = points.map((p) => p.x),
105
+ ys = points.map((p) => p.y);
106
+ const [left, right, top, bottom] = [
107
+ Math.min(...xs),
108
+ Math.max(...xs),
109
+ Math.min(...ys),
110
+ Math.max(...ys),
111
+ ];
112
+ if (side === "EAST" || side === "WEST") {
113
+ const x = side === "EAST" ? right + spacing : left - spacing - width;
114
+ return [(top + bottom - height) / 2, top + spacing, bottom - spacing - height].map((y) => ({
115
+ x,
116
+ y,
117
+ }));
118
+ }
119
+ const y = side === "SOUTH" ? bottom + spacing : top - spacing - height;
120
+ return [(left + right - width) / 2, left + spacing, right - spacing - width].map((x) => ({
121
+ x,
122
+ y,
123
+ }));
124
+ }
125
+
126
+ /**
127
+ * ELK combines self loops that share a port into one self hyperloop routed on
128
+ * a single track. Returns each loop's track index, in order of first loop.
129
+ */
130
+ export function selfLoopTracks(loops: readonly GraphEdge[]): Map<string, number> {
131
+ const parent = loops.map((_, index) => index);
132
+ const root = (index: number): number =>
133
+ parent[index] === index ? index : (parent[index] = root(parent[index]!));
134
+ const ownerByPort = new Map<string, number>();
135
+ for (const [index, loop] of loops.entries())
136
+ for (const port of [loop.sourcePort, loop.targetPort]) {
137
+ if (port === undefined) continue;
138
+ const other = ownerByPort.get(port);
139
+ if (other === undefined) ownerByPort.set(port, index);
140
+ else parent[root(index)] = root(other);
141
+ }
142
+ const trackByRoot = new Map<number, number>();
143
+ return new Map(
144
+ loops.map((loop, index) => {
145
+ const group = root(index);
146
+ if (!trackByRoot.has(group)) trackByRoot.set(group, trackByRoot.size);
147
+ return [loop.id, trackByRoot.get(group)!];
148
+ }),
149
+ );
150
+ }