@statelyai/layout 0.4.3 → 0.5.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.
Files changed (57) hide show
  1. package/README.md +32 -3
  2. package/dist/elkjs/index.cjs +2 -2
  3. package/dist/elkjs/index.d.cts +1 -1
  4. package/dist/elkjs/index.d.mts +1 -1
  5. package/dist/elkjs/index.mjs +2 -2
  6. package/dist/elkjs/main.cjs +1 -1
  7. package/dist/elkjs/main.mjs +1 -1
  8. package/dist/elkjs/worker.cjs +2 -2
  9. package/dist/elkjs/worker.mjs +2 -2
  10. package/dist/{elkjs-Cj-lWiK1.cjs → elkjs-Bh_E59N_.cjs} +3 -3
  11. package/dist/{elkjs-B5kN_zS6.mjs → elkjs-Dk9yOk9U.mjs} +3 -3
  12. package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
  13. package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
  14. package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
  15. package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
  16. package/dist/index.cjs +7 -4
  17. package/dist/index.d.cts +4 -4
  18. package/dist/index.d.mts +4 -4
  19. package/dist/index.mjs +4 -4
  20. package/dist/layered/index.cjs +1 -1
  21. package/dist/layered/index.d.cts +2 -2
  22. package/dist/layered/index.d.mts +2 -2
  23. package/dist/layered/index.mjs +1 -1
  24. package/dist/{layered-BsAdm_u8.mjs → layered-CUwft7on.mjs} +488 -91
  25. package/dist/{layered-BJu8A21s.cjs → layered-QzYtokgN.cjs} +488 -91
  26. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CQ5FcBPC.cjs} +1 -1
  27. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-DvDkMQM3.mjs} +1 -1
  28. package/dist/routing/index.cjs +5 -2
  29. package/dist/routing/index.d.cts +3 -3
  30. package/dist/routing/index.d.mts +3 -3
  31. package/dist/routing/index.mjs +2 -2
  32. package/dist/routing-D-LfMXOF.mjs +5189 -0
  33. package/dist/routing-Phxu1R0x.cjs +5423 -0
  34. package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
  35. package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
  36. package/package.json +4 -1
  37. package/src/authoring/partial.ts +1 -7
  38. package/src/authoring/routing.ts +64 -19
  39. package/src/layered/feedback-chains.ts +22 -4
  40. package/src/layered/index.ts +186 -42
  41. package/src/layered/loop-envelopes.ts +96 -26
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/self-loop-ports.ts +158 -0
  44. package/src/layered/strategies.ts +183 -32
  45. package/src/routing/algorithms.ts +1436 -148
  46. package/src/routing/bezier-fit.ts +605 -0
  47. package/src/routing/bezier-visibility.ts +107 -0
  48. package/src/routing/bezier.ts +596 -0
  49. package/src/routing/engine.ts +65 -13
  50. package/src/routing/index.ts +14 -0
  51. package/src/routing/model.ts +185 -1
  52. package/src/routing/path.ts +1 -1
  53. package/src/routing/policy-geometry.ts +536 -0
  54. package/src/routing/search.ts +10 -2
  55. package/src/routing/types.ts +77 -1
  56. package/dist/routing-CR8flv8b.mjs +0 -2780
  57. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -1,5 +1,25 @@
1
1
  import { EntityRect, Graph, GraphDiff, Point } from "@statelyai/graph";
2
2
 
3
+ //#region src/routing/bezier-fit.d.ts
4
+ interface BezierGuide {
5
+ readonly points: readonly RoutePoint[];
6
+ readonly sourceDirection: RouteSide;
7
+ readonly targetDirection: RouteSide;
8
+ readonly path?: RoutePath;
9
+ }
10
+ type BezierEndpoint = {
11
+ kind: "floating";
12
+ bounds: RouteBounds;
13
+ sides: readonly RouteSide[];
14
+ } | {
15
+ kind: "port";
16
+ point: RoutePoint;
17
+ direction: RouteSide;
18
+ escape: RoutePoint;
19
+ owner: RouteBounds;
20
+ };
21
+ declare function trimBezierPathStart(path: string, maximum?: number): string | undefined;
22
+ //#endregion
3
23
  //#region src/routing/types.d.ts
4
24
  type RoutePoint = Readonly<Point>;
5
25
  type RouteSegment = {
@@ -43,16 +63,26 @@ interface RouteSection {
43
63
  readonly from: RouteEndpoint;
44
64
  readonly to: RouteEndpoint;
45
65
  readonly path: RoutePath;
66
+ readonly fromSide?: RouteSide;
67
+ readonly toSide?: RouteSide;
46
68
  /** Same ID denotes a shared trunk; render it once. */
47
69
  readonly sharedId?: string;
48
70
  }
49
- type RoutingDiagnosticCode = "MISSING_GEOMETRY" | "MISSING_PORT" | "ROUTE_CONFLICT" | "ROUTE_BLOCKED" | "SEARCH_BUDGET" | "CONSTRAINT_VIOLATION";
71
+ type RoutingDiagnosticCode = "MISSING_GEOMETRY" | "MISSING_PORT" | "ROUTE_CONFLICT" | "ROUTE_BLOCKED" | "SEARCH_BUDGET" | "CONSTRAINT_VIOLATION" | "LABEL_OVERLAP";
50
72
  interface RoutingDiagnostic {
51
73
  readonly code: RoutingDiagnosticCode;
52
74
  readonly edgeId: string;
53
75
  readonly message: string;
76
+ readonly constraint?: "source-tangent" | "target-tangent" | "label-tangent" | "waypoints" | "node-clearance" | "label-clearance" | "monotonicity" | "bundle";
77
+ readonly relatedIds?: readonly string[];
78
+ }
79
+ interface RouteLabelPlacement {
80
+ readonly bounds: RouteBounds;
81
+ readonly anchor: RoutePoint;
82
+ readonly tangent: RoutePoint;
54
83
  }
55
84
  interface Route {
85
+ readonly label?: RouteLabelPlacement;
56
86
  readonly edgeId: string;
57
87
  readonly sections: readonly RouteSection[];
58
88
  readonly status: "routed" | "fallback";
@@ -73,7 +103,84 @@ interface RouteAttachment {
73
103
  readonly bounds: RouteBounds;
74
104
  readonly facing?: "inward" | "outward";
75
105
  }
106
+ interface BezierRoutingOptions {
107
+ readonly avoidObstacles?: boolean;
108
+ readonly attachmentMode?: "floating" | "orthogonal";
109
+ readonly targetClearance?: number;
110
+ }
111
+ interface BezierEdgeSettings extends BezierRoutingOptions {
112
+ readonly source?: BezierEndpoint;
113
+ readonly target?: Extract<BezierEndpoint, {
114
+ kind: "floating";
115
+ }>;
116
+ readonly guides?: {
117
+ readonly source: BezierGuide;
118
+ readonly target: BezierGuide;
119
+ };
120
+ readonly obstacles?: readonly RouteBounds[];
121
+ readonly terminalBounds?: readonly RouteBounds[];
122
+ readonly labelTangent?: RoutePoint;
123
+ readonly disabled?: boolean;
124
+ }
125
+ type RoutingTangent = {
126
+ readonly kind: "automatic";
127
+ } | {
128
+ readonly kind: "border-normal";
129
+ } | {
130
+ readonly kind: "vector";
131
+ readonly direction: RoutePoint;
132
+ };
133
+ type RoutingCurve = {
134
+ readonly kind: "polyline";
135
+ } | {
136
+ readonly kind: "bezier";
137
+ readonly curvature?: number;
138
+ } | {
139
+ readonly kind: "catmull-rom";
140
+ } | {
141
+ readonly kind: "rounded-orthogonal";
142
+ readonly radius?: number;
143
+ };
144
+ type RoutingLabel = {
145
+ readonly kind: "follow";
146
+ readonly fraction?: number;
147
+ readonly offset?: number;
148
+ } | {
149
+ readonly kind: "fixed";
150
+ } | {
151
+ readonly kind: "through";
152
+ readonly tangent?: RoutingTangent;
153
+ } | {
154
+ readonly kind: "gap";
155
+ readonly tangent?: RoutingTangent;
156
+ };
157
+ interface RoutingPolicy {
158
+ readonly curve?: RoutingCurve;
159
+ readonly sourceTangent?: RoutingTangent;
160
+ readonly targetTangent?: RoutingTangent;
161
+ readonly label?: RoutingLabel;
162
+ readonly collisions?: {
163
+ readonly nodes?: "avoid" | "allow";
164
+ readonly labels?: "avoid" | "allow";
165
+ readonly crossingPenalty?: number;
166
+ readonly overlapPenalty?: number;
167
+ };
168
+ readonly monotonic?: {
169
+ readonly axis: "x" | "y" | "both";
170
+ readonly strength: "prefer" | "require";
171
+ };
172
+ readonly stability?: {
173
+ readonly kind: "recompute" | "preserve-valid";
174
+ };
175
+ }
76
176
  interface EdgeRoutingSettings {
177
+ readonly policy?: RoutingPolicy;
178
+ /** Resolve the source from this edge label in the current graph geometry. */
179
+ readonly sourceLabel?: string;
180
+ readonly sourceSides?: readonly RouteSide[];
181
+ readonly targetSides?: readonly RouteSide[];
182
+ readonly labelSides?: readonly RouteSide[];
183
+ readonly bezier?: BezierEdgeSettings;
77
184
  readonly sourceAttachment?: RouteAttachment;
78
185
  readonly targetAttachment?: RouteAttachment;
79
186
  readonly sourceSide?: RouteSide;
@@ -83,6 +190,9 @@ interface EdgeRoutingSettings {
83
190
  readonly group?: string;
84
191
  }
85
192
  interface RoutingSettings {
193
+ readonly policy?: RoutingPolicy;
194
+ /** Opt into center-tangent Bézier routing and floating attachments. */
195
+ readonly bezier?: BezierRoutingOptions;
86
196
  /** Native compound geometry is parent-relative; adapters may supply resolved world geometry. */
87
197
  readonly coordinateSpace?: "parent" | "world";
88
198
  readonly clearance?: number;
@@ -130,4 +240,4 @@ interface NativeRoutingStrategy extends RoutingStrategy {
130
240
  }
131
241
  type RouteBounds = Readonly<EntityRect>;
132
242
  //#endregion
133
- export { RoutingMetrics as _, RouteBounds as a, RoutingStrategy as b, RoutePath as c, RouteSegment as d, RouteSide as f, RoutingGraph as g, RoutingDiagnosticCode as h, RouteAttachment as i, RoutePoint as l, RoutingDiagnostic as m, NativeRoutingStrategy as n, RouteEndpoint as o, RouteStyle as p, Route as r, RoutePatch as s, EdgeRoutingSettings as t, RouteSection as u, RoutingSettings as v, RoutingUpdate as x, RoutingSnapshot as y };
243
+ export { BezierGuide as A, RoutingPolicy as C, RoutingTangent as D, RoutingStrategy as E, RoutingUpdate as O, RoutingMetrics as S, RoutingSnapshot as T, RoutingCurve as _, Route as a, RoutingGraph as b, RouteEndpoint as c, RoutePath as d, RoutePoint as f, RouteStyle as g, RouteSide as h, NativeRoutingStrategy as i, trimBezierPathStart as j, BezierEndpoint as k, RouteLabelPlacement as l, RouteSegment as m, BezierRoutingOptions as n, RouteAttachment as o, RouteSection as p, EdgeRoutingSettings as r, RouteBounds as s, BezierEdgeSettings as t, RoutePatch as u, RoutingDiagnostic as v, RoutingSettings as w, RoutingLabel as x, RoutingDiagnosticCode as y };
@@ -1,5 +1,25 @@
1
1
  import { EntityRect, Graph, GraphDiff, Point } from "@statelyai/graph";
2
2
 
3
+ //#region src/routing/bezier-fit.d.ts
4
+ interface BezierGuide {
5
+ readonly points: readonly RoutePoint[];
6
+ readonly sourceDirection: RouteSide;
7
+ readonly targetDirection: RouteSide;
8
+ readonly path?: RoutePath;
9
+ }
10
+ type BezierEndpoint = {
11
+ kind: "floating";
12
+ bounds: RouteBounds;
13
+ sides: readonly RouteSide[];
14
+ } | {
15
+ kind: "port";
16
+ point: RoutePoint;
17
+ direction: RouteSide;
18
+ escape: RoutePoint;
19
+ owner: RouteBounds;
20
+ };
21
+ declare function trimBezierPathStart(path: string, maximum?: number): string | undefined;
22
+ //#endregion
3
23
  //#region src/routing/types.d.ts
4
24
  type RoutePoint = Readonly<Point>;
5
25
  type RouteSegment = {
@@ -43,16 +63,26 @@ interface RouteSection {
43
63
  readonly from: RouteEndpoint;
44
64
  readonly to: RouteEndpoint;
45
65
  readonly path: RoutePath;
66
+ readonly fromSide?: RouteSide;
67
+ readonly toSide?: RouteSide;
46
68
  /** Same ID denotes a shared trunk; render it once. */
47
69
  readonly sharedId?: string;
48
70
  }
49
- type RoutingDiagnosticCode = "MISSING_GEOMETRY" | "MISSING_PORT" | "ROUTE_CONFLICT" | "ROUTE_BLOCKED" | "SEARCH_BUDGET" | "CONSTRAINT_VIOLATION";
71
+ type RoutingDiagnosticCode = "MISSING_GEOMETRY" | "MISSING_PORT" | "ROUTE_CONFLICT" | "ROUTE_BLOCKED" | "SEARCH_BUDGET" | "CONSTRAINT_VIOLATION" | "LABEL_OVERLAP";
50
72
  interface RoutingDiagnostic {
51
73
  readonly code: RoutingDiagnosticCode;
52
74
  readonly edgeId: string;
53
75
  readonly message: string;
76
+ readonly constraint?: "source-tangent" | "target-tangent" | "label-tangent" | "waypoints" | "node-clearance" | "label-clearance" | "monotonicity" | "bundle";
77
+ readonly relatedIds?: readonly string[];
78
+ }
79
+ interface RouteLabelPlacement {
80
+ readonly bounds: RouteBounds;
81
+ readonly anchor: RoutePoint;
82
+ readonly tangent: RoutePoint;
54
83
  }
55
84
  interface Route {
85
+ readonly label?: RouteLabelPlacement;
56
86
  readonly edgeId: string;
57
87
  readonly sections: readonly RouteSection[];
58
88
  readonly status: "routed" | "fallback";
@@ -73,7 +103,84 @@ interface RouteAttachment {
73
103
  readonly bounds: RouteBounds;
74
104
  readonly facing?: "inward" | "outward";
75
105
  }
106
+ interface BezierRoutingOptions {
107
+ readonly avoidObstacles?: boolean;
108
+ readonly attachmentMode?: "floating" | "orthogonal";
109
+ readonly targetClearance?: number;
110
+ }
111
+ interface BezierEdgeSettings extends BezierRoutingOptions {
112
+ readonly source?: BezierEndpoint;
113
+ readonly target?: Extract<BezierEndpoint, {
114
+ kind: "floating";
115
+ }>;
116
+ readonly guides?: {
117
+ readonly source: BezierGuide;
118
+ readonly target: BezierGuide;
119
+ };
120
+ readonly obstacles?: readonly RouteBounds[];
121
+ readonly terminalBounds?: readonly RouteBounds[];
122
+ readonly labelTangent?: RoutePoint;
123
+ readonly disabled?: boolean;
124
+ }
125
+ type RoutingTangent = {
126
+ readonly kind: "automatic";
127
+ } | {
128
+ readonly kind: "border-normal";
129
+ } | {
130
+ readonly kind: "vector";
131
+ readonly direction: RoutePoint;
132
+ };
133
+ type RoutingCurve = {
134
+ readonly kind: "polyline";
135
+ } | {
136
+ readonly kind: "bezier";
137
+ readonly curvature?: number;
138
+ } | {
139
+ readonly kind: "catmull-rom";
140
+ } | {
141
+ readonly kind: "rounded-orthogonal";
142
+ readonly radius?: number;
143
+ };
144
+ type RoutingLabel = {
145
+ readonly kind: "follow";
146
+ readonly fraction?: number;
147
+ readonly offset?: number;
148
+ } | {
149
+ readonly kind: "fixed";
150
+ } | {
151
+ readonly kind: "through";
152
+ readonly tangent?: RoutingTangent;
153
+ } | {
154
+ readonly kind: "gap";
155
+ readonly tangent?: RoutingTangent;
156
+ };
157
+ interface RoutingPolicy {
158
+ readonly curve?: RoutingCurve;
159
+ readonly sourceTangent?: RoutingTangent;
160
+ readonly targetTangent?: RoutingTangent;
161
+ readonly label?: RoutingLabel;
162
+ readonly collisions?: {
163
+ readonly nodes?: "avoid" | "allow";
164
+ readonly labels?: "avoid" | "allow";
165
+ readonly crossingPenalty?: number;
166
+ readonly overlapPenalty?: number;
167
+ };
168
+ readonly monotonic?: {
169
+ readonly axis: "x" | "y" | "both";
170
+ readonly strength: "prefer" | "require";
171
+ };
172
+ readonly stability?: {
173
+ readonly kind: "recompute" | "preserve-valid";
174
+ };
175
+ }
76
176
  interface EdgeRoutingSettings {
177
+ readonly policy?: RoutingPolicy;
178
+ /** Resolve the source from this edge label in the current graph geometry. */
179
+ readonly sourceLabel?: string;
180
+ readonly sourceSides?: readonly RouteSide[];
181
+ readonly targetSides?: readonly RouteSide[];
182
+ readonly labelSides?: readonly RouteSide[];
183
+ readonly bezier?: BezierEdgeSettings;
77
184
  readonly sourceAttachment?: RouteAttachment;
78
185
  readonly targetAttachment?: RouteAttachment;
79
186
  readonly sourceSide?: RouteSide;
@@ -83,6 +190,9 @@ interface EdgeRoutingSettings {
83
190
  readonly group?: string;
84
191
  }
85
192
  interface RoutingSettings {
193
+ readonly policy?: RoutingPolicy;
194
+ /** Opt into center-tangent Bézier routing and floating attachments. */
195
+ readonly bezier?: BezierRoutingOptions;
86
196
  /** Native compound geometry is parent-relative; adapters may supply resolved world geometry. */
87
197
  readonly coordinateSpace?: "parent" | "world";
88
198
  readonly clearance?: number;
@@ -130,4 +240,4 @@ interface NativeRoutingStrategy extends RoutingStrategy {
130
240
  }
131
241
  type RouteBounds = Readonly<EntityRect>;
132
242
  //#endregion
133
- export { RoutingMetrics as _, RouteBounds as a, RoutingStrategy as b, RoutePath as c, RouteSegment as d, RouteSide as f, RoutingGraph as g, RoutingDiagnosticCode as h, RouteAttachment as i, RoutePoint as l, RoutingDiagnostic as m, NativeRoutingStrategy as n, RouteEndpoint as o, RouteStyle as p, Route as r, RoutePatch as s, EdgeRoutingSettings as t, RouteSection as u, RoutingSettings as v, RoutingUpdate as x, RoutingSnapshot as y };
243
+ export { BezierGuide as A, RoutingPolicy as C, RoutingTangent as D, RoutingStrategy as E, RoutingUpdate as O, RoutingMetrics as S, RoutingSnapshot as T, RoutingCurve as _, Route as a, RoutingGraph as b, RouteEndpoint as c, RoutePath as d, RoutePoint as f, RouteStyle as g, RouteSide as h, NativeRoutingStrategy as i, trimBezierPathStart as j, BezierEndpoint as k, RouteLabelPlacement as l, RouteSegment as m, BezierRoutingOptions as n, RouteAttachment as o, RouteSection as p, EdgeRoutingSettings as r, RouteBounds as s, BezierEdgeSettings as t, RoutePatch as u, RoutingDiagnostic as v, RoutingSettings as w, RoutingLabel as x, RoutingDiagnosticCode as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statelyai/layout",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Extensible, graph-native layout algorithms for @statelyai/graph",
5
5
  "license": "EPL-2.0 OR GPL-3.0-or-later",
6
6
  "repository": {
@@ -128,6 +128,9 @@
128
128
  "typecheck:repo": "tsc --project tsconfig.repo.json",
129
129
  "verify": "oxfmt --check . && oxlint && tsc --noEmit && tsc --project tsconfig.repo.json && tsc --project demo/tsconfig.json && tsc --project stories/tsconfig.json && tsx scripts/generate-elk-layered-options.ts --check && tsx scripts/generate-demo-corpus.ts --check && vitest --run && tsdown && vite build --config demo/vite.config.ts && storybook build -o dist-storybook --quiet && publint --pack npm && tsx scripts/smoke-package.ts",
130
130
  "validate:package": "publint --pack npm && tsx scripts/smoke-package.ts",
131
+ "verify:routing-policies": "tsx scripts/check-routing-policies.ts",
132
+ "routing:gallery": "tsx scripts/render-routing-policies.ts",
133
+ "routing:quality": "tsx scripts/render-routing-quality.ts",
131
134
  "storybook": "storybook dev -p 6018 --host 127.0.0.1 --no-open",
132
135
  "storybook:build": "storybook build -o dist-storybook",
133
136
  "storybook:typecheck": "tsc --project stories/tsconfig.json",
@@ -363,13 +363,7 @@ export function runPartialLayout<N, E, G, P>(
363
363
  "Partial layout supports its built-in orthogonal router only",
364
364
  );
365
365
  const ancestorIds = new Set([...ancestors(source.id), ...ancestors(target.id)]);
366
- const nodeObstacles = graph.nodes
367
- .filter((n) => !ancestorIds.has(n.id))
368
- .map((n) =>
369
- n.id === source.id || n.id === target.id
370
- ? n
371
- : { ...n, x: n.x - 1, y: n.y - 1, width: n.width + 2, height: n.height + 2 },
372
- );
366
+ const nodeObstacles = graph.nodes.filter((n) => !ancestorIds.has(n.id));
373
367
  const labelObstacles = [...edgeById.values()].filter(
374
368
  (e) => e.id !== edge.id && e.width > 0 && e.height > 0,
375
369
  );
@@ -191,24 +191,69 @@ export function routeOrthogonal(
191
191
  waypoints: readonly Point[],
192
192
  check: () => void,
193
193
  ): Point[] | undefined {
194
- const anchors = [
195
- endpoint(source, edge.sourcePort, direction, true),
196
- ...waypoints,
197
- endpoint(target, edge.targetPort, direction, false),
198
- ];
199
- const points: Point[] = [];
200
- for (let i = 1; i < anchors.length; i++) {
201
- const segment = connect(anchors[i - 1]!, anchors[i]!, obstacles, check);
202
- if (!segment) return undefined;
203
- points.push(...(points.length ? segment.slice(1) : segment));
204
- }
205
- return points.filter((p, i) => {
206
- if (i === 0 || i === points.length - 1) return true;
207
- const before = points[i - 1]!,
208
- after = points[i + 1]!;
209
- return !(
210
- (before.x === p.x && p.x === after.x && (p.y - before.y) * (after.y - p.y) >= 0) ||
211
- (before.y === p.y && p.y === after.y && (p.x - before.x) * (after.x - p.x) >= 0)
194
+ const start = endpoint(source, edge.sourcePort, direction, true);
195
+ const end = endpoint(target, edge.targetPort, direction, false);
196
+ const normal = (node: VisualNode, point: Point): Point => {
197
+ const sides = [
198
+ { distance: Math.abs(point.x - node.x), x: -1, y: 0 },
199
+ { distance: Math.abs(point.x - node.x - node.width), x: 1, y: 0 },
200
+ { distance: Math.abs(point.y - node.y), x: 0, y: -1 },
201
+ { distance: Math.abs(point.y - node.y - node.height), x: 0, y: 1 },
202
+ ];
203
+ return sides.sort((a, b) => a.distance - b.distance)[0]!;
204
+ };
205
+ const startNormal = normal(source, start);
206
+ const endNormal = normal(target, end);
207
+ for (const clearance of [8, 4, 1, 0]) {
208
+ check();
209
+ const padded = obstacles.map((rect) => ({
210
+ ...rect,
211
+ x: rect.x - clearance,
212
+ y: rect.y - clearance,
213
+ width: rect.width + clearance * 2,
214
+ height: rect.height + clearance * 2,
215
+ }));
216
+ const startLead = {
217
+ x: start.x + startNormal.x * clearance,
218
+ y: start.y + startNormal.y * clearance,
219
+ };
220
+ const endLead = {
221
+ x: end.x + endNormal.x * clearance,
222
+ y: end.y + endNormal.y * clearance,
223
+ };
224
+ if (
225
+ obstacles.some(
226
+ (rect, i) =>
227
+ (rect !== source && crossesRect(start, startLead, padded[i]!)) ||
228
+ (rect !== target && crossesRect(end, endLead, padded[i]!)),
229
+ )
230
+ )
231
+ continue;
232
+ const anchors = [startLead, ...waypoints, endLead];
233
+ const points: Point[] = [start];
234
+ let routed = true;
235
+ for (let i = 1; i < anchors.length; i++) {
236
+ const segment = connect(anchors[i - 1]!, anchors[i]!, padded, check);
237
+ if (!segment) {
238
+ routed = false;
239
+ break;
240
+ }
241
+ points.push(...(i === 1 ? segment : segment.slice(1)));
242
+ }
243
+ if (!routed) continue;
244
+ points.push(end);
245
+ const unique = points.filter(
246
+ (p, i) => i === 0 || p.x !== points[i - 1]!.x || p.y !== points[i - 1]!.y,
212
247
  );
213
- });
248
+ return unique.filter((p, i) => {
249
+ if (i === 0 || i === unique.length - 1) return true;
250
+ const before = unique[i - 1]!,
251
+ after = unique[i + 1]!;
252
+ return !(
253
+ (before.x === p.x && p.x === after.x && (p.y - before.y) * (after.y - p.y) >= 0) ||
254
+ (before.y === p.y && p.y === after.y && (p.x - before.x) * (after.x - p.x) >= 0)
255
+ );
256
+ });
257
+ }
258
+ return undefined;
214
259
  }
@@ -31,13 +31,15 @@ function opposed(a: readonly Point[], b: readonly Point[]): boolean {
31
31
  * A reversed feedback edge split by label or inverted-port dummies is routed
32
32
  * one segment at a time, so each segment detours to the outer feedback track
33
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.
34
+ * track, or the outer track with the label moved onto it, whichever is clear;
35
+ * failing both, a fresh track past everything else with the label on it.
35
36
  */
36
37
  export function straightenFeedbackChains(
37
38
  chains: readonly FeedbackChain[],
38
39
  pointsByEdgeId: Map<string, readonly Point[]>,
39
40
  rectByNodeId: Map<string, EntityRect>,
40
41
  horizontal: boolean,
42
+ spacing: number,
41
43
  ): void {
42
44
  const cross = (p: Point) => (horizontal ? p.y : p.x);
43
45
  const flow = (p: Point) => (horizontal ? p.x : p.y);
@@ -60,7 +62,19 @@ export function straightenFeedbackChains(
60
62
  end = points.at(-1)!;
61
63
  const outer = Math.max(...points.map(cross));
62
64
  const labelRect = label && rectByNodeId.get(label.id);
63
- for (const track of label ? [label.cross, outer] : [outer]) {
65
+ const tracks = label ? [label.cross, outer] : [outer];
66
+ if (labelRect) {
67
+ // Other feedback routes may share the outer track; past everything, the
68
+ // moved label is clear of them.
69
+ const farthest = Math.max(
70
+ ...[...pointsByEdgeId.values()].flatMap((other) => other.map(cross)),
71
+ ...[...rectByNodeId.values()].map((rect) =>
72
+ horizontal ? rect.y + rect.height : rect.x + rect.width,
73
+ ),
74
+ );
75
+ tracks.push(farthest + spacing + label!.cross - (horizontal ? labelRect.y : labelRect.x));
76
+ }
77
+ for (const track of tracks) {
64
78
  const route = [start, lead, at(flow(lead), track), at(flow(tail), track), tail, end];
65
79
  const moved = labelRect && {
66
80
  ...labelRect,
@@ -103,8 +117,12 @@ export function straightenFeedbackChains(
103
117
  }
104
118
  if (simple.at(-1) !== end) continue;
105
119
  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)))
120
+ // Never newly share a track with another route heading the opposite way.
121
+ if (
122
+ [...pointsByEdgeId].some(
123
+ ([id, other]) => id !== edgeId && opposed(simple, other) && !opposed(points, other),
124
+ )
125
+ )
108
126
  continue;
109
127
  pointsByEdgeId.set(edgeId, simple);
110
128
  if (moved) rectByNodeId.set(label!.id, moved);