@dxos/diagram 0.11.1 → 0.13.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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9396 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +99 -65
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +200 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +155 -0
  150. package/src/nudge.ts +385 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +64 -3
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +888 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, test } from 'vitest';
6
6
 
7
- import { compile, parse } from './mermaid.ts';
7
+ import { compile, markers, parse } from './mermaid.ts';
8
8
  import type * as Scene from './scene.ts';
9
9
 
10
10
  /** Exercises node declarations, a labelled-blank subgraph, fan-out, and a `C <-> Y` cycle. */
@@ -55,6 +55,22 @@ describe('mermaid', () => {
55
55
  ]);
56
56
  });
57
57
 
58
+ test('reads the relation tokens as kinds with their UML markers', ({ expect }) => {
59
+ const graph = parse(
60
+ ['flowchart TB', ' B ..|> A', ' C -.-> D', ' E o--> F', ' G --{ H', ' I --|> J', ' K --> L'].join('\n'),
61
+ );
62
+ expect(graph.edges.map(({ kind }) => kind)).toEqual([
63
+ 'implements',
64
+ 'creates',
65
+ 'contains',
66
+ 'hasMany',
67
+ 'inheritance',
68
+ 'reference',
69
+ ]);
70
+ expect(markers('implements')).toEqual({ relation: 'implementation', stroke: 'dashed' });
71
+ expect(markers('creates')).toEqual({ relation: 'dependency', stroke: 'dashed' });
72
+ });
73
+
58
74
  test('compiles to one object per node plus a frame and an edge object', ({ expect }) => {
59
75
  const objects = objectsOf(compile(FLOWCHART));
60
76
 
package/src/mermaid.ts CHANGED
@@ -29,7 +29,7 @@ export type MermaidGroup = {
29
29
  };
30
30
 
31
31
  /** Relationship kind, from the edge token; drawn with the UML end markers in `markers`. */
32
- export type RelationKind = 'reference' | 'inheritance' | 'hasMany' | 'contains';
32
+ export type RelationKind = 'reference' | 'inheritance' | 'implements' | 'hasMany' | 'contains' | 'creates';
33
33
 
34
34
  export type MermaidEdge = {
35
35
  from: string;
@@ -39,23 +39,30 @@ export type MermaidEdge = {
39
39
  };
40
40
 
41
41
  /**
42
- * Edge tokens: mermaid's own arrows read as references; the classDiagram-style `--|>` (hollow
43
- * triangle) and ER-style `--{` (crow's foot) and `o-->` (circle at the source) extend the flowchart
44
- * grammar with the UML kinds, at the cost of mermaid.js rejecting those lines.
42
+ * Edge tokens: mermaid's own arrows read as references, except the dotted `-.->`, which reads as
43
+ * creation (UML's dashed «create» dependency). The classDiagram-style `--|>` (hollow triangle) and
44
+ * `..|>` (dashed hollow triangle) and ER-style `--{` (crow's foot) and `o-->` (circle at the owner)
45
+ * extend the flowchart grammar with the UML kinds, at the cost of mermaid.js rejecting those lines.
45
46
  */
46
47
  const EDGE_KINDS: Record<string, RelationKind> = {
47
48
  '-->': 'reference',
48
49
  '---': 'reference',
49
- '-.->': 'reference',
50
50
  '==>': 'reference',
51
+ '-.->': 'creates',
51
52
  '--|>': 'inheritance',
53
+ '..|>': 'implements',
52
54
  '--{': 'hasMany',
53
55
  'o-->': 'contains',
54
56
  };
55
57
 
56
58
  /** Reference-render labels for the UML kinds, used when the edge carries none of its own. */
57
- const UML_LABELS: Record<string, string> = { '--|>': 'extends', '--{': 'has many', 'o-->': 'contains' };
58
- const UML_EDGE = /^(\s*\S+)\s*(o-->|--\|>|--\{)\s*(?:\|(.*?)\|\s*)?(\S+)\s*$/;
59
+ const UML_LABELS: Record<string, string> = {
60
+ '--|>': 'extends',
61
+ '..|>': 'implements',
62
+ '--{': 'has many',
63
+ 'o-->': 'contains',
64
+ };
65
+ const UML_EDGE = /^(\s*\S+)\s*(o-->|--\|>|\.\.\|>|--\{)\s*(?:\|(.*?)\|\s*)?(\S+)\s*$/;
59
66
 
60
67
  /**
61
68
  * Rewrite the UML edge tokens into mermaid-legal labelled arrows, for rendering the source with
@@ -72,15 +79,19 @@ export const toStandard = (source: string): string =>
72
79
  })
73
80
  .join('\n');
74
81
 
75
- /** Scene arrow markers for a relationship kind. */
76
- export const markers = (kind: RelationKind): Pick<Scene.Arrow, 'head' | 'tail'> => {
82
+ /** The scene relation a relationship kind draws as, with the dash its line needs; a plain reference stays a plain arrow. */
83
+ export const markers = (kind: RelationKind): Pick<Scene.Arrow, 'relation' | 'stroke'> => {
77
84
  switch (kind) {
78
85
  case 'inheritance':
79
- return { head: 'triangle' };
86
+ return { relation: 'inheritance' };
87
+ case 'implements':
88
+ return { relation: 'implementation', stroke: 'dashed' };
80
89
  case 'hasMany':
81
- return { head: 'crowsfoot' };
90
+ return { relation: 'one-to-many' };
82
91
  case 'contains':
83
- return { tail: 'circle' };
92
+ return { relation: 'aggregation' };
93
+ case 'creates':
94
+ return { relation: 'dependency', stroke: 'dashed' };
84
95
  default:
85
96
  return {};
86
97
  }
@@ -97,8 +108,9 @@ const DIRECTIONS: Direction[] = ['TB', 'BT', 'LR', 'RL'];
97
108
 
98
109
  // `A[Label]`, `A(Label)`, `A{Label}` or a bare `A`.
99
110
  const NODE = /^([A-Za-z0-9_-]+)(?:\[(.*?)\]|\((.*?)\)|\{(.*?)\})?$/;
100
- // `A --> B`, `A-->|label|B`, `A --- B`, plus the UML kinds `B --|> A`, `X --{ Y`, `A o--> B`.
101
- const EDGE = /^(.+?)\s*(o-->|--\|>|--\{|-->|---|-\.->|==>)\s*(?:\|(.*?)\|\s*)?(.+)$/;
111
+ // `A --> B`, `A-->|label|B`, `A --- B`, `A -.-> B`, plus the UML kinds `B --|> A`, `B ..|> A`, `X --{ Y`
112
+ // and `A o--> B`.
113
+ const EDGE = /^(.+?)\s*(o-->|--\|>|\.\.\|>|--\{|-->|---|-\.->|==>)\s*(?:\|(.*?)\|\s*)?(.+)$/;
102
114
  const SUBGRAPH = /^subgraph\s+([A-Za-z0-9_-]+)(?:\s*\[(.*?)\])?\s*$/;
103
115
 
104
116
  // `%% ref A packages/core/echo` — a comment to mermaid proper, so sources stay portable.
@@ -114,7 +126,7 @@ export const parse = (source: string): MermaidGraph => {
114
126
  let direction: Direction = 'TB';
115
127
  const stack: string[] = [];
116
128
 
117
- const declare = (token: string): string | undefined => {
129
+ const declareNode = (token: string): string | undefined => {
118
130
  const match = NODE.exec(token.trim());
119
131
  if (!match) {
120
132
  return undefined;
@@ -172,8 +184,8 @@ export const parse = (source: string): MermaidGraph => {
172
184
  const edge = EDGE.exec(line);
173
185
  if (edge) {
174
186
  const [, from, token, label, to] = edge;
175
- const fromId = declare(from);
176
- const toId = declare(to);
187
+ const fromId = declareNode(from);
188
+ const toId = declareNode(to);
177
189
  if (fromId && toId) {
178
190
  edges.push({
179
191
  from: fromId,
@@ -185,7 +197,7 @@ export const parse = (source: string): MermaidGraph => {
185
197
  continue;
186
198
  }
187
199
 
188
- declare(line);
200
+ declareNode(line);
189
201
  }
190
202
 
191
203
  // Directives may precede or follow the node they name.
@@ -0,0 +1,155 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { nudge } from './nudge.ts';
8
+ import type * as Scene from './scene.ts';
9
+ import { type Rect } from './uml-grid.ts';
10
+
11
+ const orthogonal = (points: readonly Scene.Point[]) =>
12
+ points.slice(1).every((point, index) => point.x === points[index].x || point.y === points[index].y);
13
+
14
+ /** Whether two orthogonal polylines share a collinear stretch of positive length. */
15
+ const overlapping = (first: readonly Scene.Point[], second: readonly Scene.Point[]) => {
16
+ const segments = (points: readonly Scene.Point[]) =>
17
+ points.slice(1).map((end, index) => [points[index], end] as const);
18
+ return segments(first).some(([a0, a1]) =>
19
+ segments(second).some(([b0, b1]) => {
20
+ if (a0.x === a1.x && b0.x === b1.x && a0.x === b0.x) {
21
+ const lo = Math.max(Math.min(a0.y, a1.y), Math.min(b0.y, b1.y));
22
+ const hi = Math.min(Math.max(a0.y, a1.y), Math.max(b0.y, b1.y));
23
+ return hi - lo > 0;
24
+ }
25
+ if (a0.y === a1.y && b0.y === b1.y && a0.y === b0.y) {
26
+ const lo = Math.max(Math.min(a0.x, a1.x), Math.min(b0.x, b1.x));
27
+ const hi = Math.min(Math.max(a0.x, a1.x), Math.max(b0.x, b1.x));
28
+ return hi - lo > 0;
29
+ }
30
+ return false;
31
+ }),
32
+ );
33
+ };
34
+
35
+ const onBorder = (point: Scene.Point, rect: Rect) =>
36
+ ((point.y === rect.y || point.y === rect.y + rect.h) && point.x >= rect.x && point.x <= rect.x + rect.w) ||
37
+ ((point.x === rect.x || point.x === rect.x + rect.w) && point.y >= rect.y && point.y <= rect.y + rect.h);
38
+
39
+ describe('nudge', () => {
40
+ const source: Rect = { x: 0, y: 0, w: 128, h: 64 };
41
+ const left: Rect = { x: 0, y: 200, w: 128, h: 64 };
42
+ const right: Rect = { x: 200, y: 200, w: 128, h: 64 };
43
+ // A fork: both leave the same port and run down x = 64 together before one turns right.
44
+ const straight = {
45
+ points: [
46
+ { x: 64, y: 64 },
47
+ { x: 64, y: 200 },
48
+ ],
49
+ source,
50
+ target: left,
51
+ };
52
+ const turning = {
53
+ points: [
54
+ { x: 64, y: 64 },
55
+ { x: 64, y: 132 },
56
+ { x: 264, y: 132 },
57
+ { x: 264, y: 200 },
58
+ ],
59
+ source,
60
+ target: right,
61
+ };
62
+
63
+ test('two edges sharing a vertical run are separated and stay orthogonal', ({ expect }) => {
64
+ const [first, second] = nudge([straight, turning], { obstacles: [source, left, right] });
65
+ expect(orthogonal(first)).toBe(true);
66
+ expect(orthogonal(second)).toBe(true);
67
+ // The edge that turns right takes the right-hand lane, so its turn-off does not cut the other.
68
+ expect(second[0].x - first[0].x).toBeGreaterThanOrEqual(8);
69
+ expect(second[1].x).toBe(second[0].x);
70
+ expect(onBorder(first[0], source) && onBorder(second[0], source)).toBe(true);
71
+ expect(first[first.length - 1]).toEqual({ x: first[0].x, y: 200 });
72
+ expect(second[second.length - 1]).toEqual({ x: 264, y: 200 });
73
+ });
74
+
75
+ test('a fork sharing a vertical run and then a horizontal run is fully separated', ({ expect }) => {
76
+ const far: Rect = { x: 400, y: 200, w: 128, h: 64 };
77
+ const near = {
78
+ points: [
79
+ { x: 64, y: 64 },
80
+ { x: 64, y: 132 },
81
+ { x: 264, y: 132 },
82
+ { x: 264, y: 200 },
83
+ ],
84
+ source,
85
+ target: right,
86
+ };
87
+ const away = {
88
+ points: [
89
+ { x: 64, y: 64 },
90
+ { x: 64, y: 132 },
91
+ { x: 464, y: 132 },
92
+ { x: 464, y: 200 },
93
+ ],
94
+ source,
95
+ target: far,
96
+ };
97
+ const [first, second] = nudge([near, away], { obstacles: [source, right, far] });
98
+ expect(orthogonal(first) && orthogonal(second)).toBe(true);
99
+ expect(overlapping(first, second)).toBe(false);
100
+ });
101
+
102
+ test('a fixed bus never moves; the edge lying on it is pushed off', ({ expect }) => {
103
+ const bus = [
104
+ { x: 100, y: 100 },
105
+ { x: 200, y: 100 },
106
+ ];
107
+ const edge = {
108
+ points: [
109
+ { x: 64, y: 64 },
110
+ { x: 64, y: 100 },
111
+ { x: 264, y: 100 },
112
+ { x: 264, y: 200 },
113
+ ],
114
+ source,
115
+ target: right,
116
+ };
117
+ const fixed = [bus];
118
+ const [moved] = nudge([edge], { obstacles: [source, right], fixed });
119
+ expect(fixed[0]).toEqual(bus);
120
+ expect(Math.abs(moved[1].y - 100)).toBeGreaterThanOrEqual(8);
121
+ expect(moved[0]).toEqual({ x: 64, y: 64 });
122
+ expect(orthogonal(moved)).toBe(true);
123
+ });
124
+
125
+ test('ports stay put when separation would leave the box side', ({ expect }) => {
126
+ const narrow: Rect = { x: 0, y: 0, w: 16, h: 64 };
127
+ const below: Rect = { x: 0, y: 200, w: 16, h: 64 };
128
+ const path = {
129
+ points: [
130
+ { x: 8, y: 64 },
131
+ { x: 8, y: 200 },
132
+ ],
133
+ source: narrow,
134
+ target: below,
135
+ };
136
+ const result = nudge([path, path], { obstacles: [narrow, below] });
137
+ expect(result).toEqual([path.points, path.points]);
138
+ });
139
+
140
+ test('a run is never pushed through a box', ({ expect }) => {
141
+ // Both lanes beside the shared run are blocked by boxes hugging it.
142
+ const wall = (x: number): Rect => ({ x, y: 80, w: 40, h: 100 });
143
+ const edge = (y: number) => ({
144
+ points: [
145
+ { x: 64, y: 64 },
146
+ { x: 64, y: 200 },
147
+ ],
148
+ source,
149
+ target: { x: 0, y, w: 128, h: 64 },
150
+ });
151
+ const obstacles = [source, wall(20), wall(68), { x: 0, y: 200, w: 128, h: 64 }];
152
+ const result = nudge([edge(200), edge(200)], { obstacles });
153
+ expect(result.map((points) => points[0].x)).toEqual([64, 64]);
154
+ });
155
+ });
package/src/nudge.ts ADDED
@@ -0,0 +1,385 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as Scene from './scene.ts';
6
+ import { type Rect } from './uml-grid.ts';
7
+
8
+ type Point = Scene.Point;
9
+ type Axis = 'h' | 'v';
10
+
11
+ /** One axis-aligned segment of a line: `points[start] → points[start + 1]`. */
12
+ type Run = { owner: number; start: number; axis: Axis; coord: number; lo: number; hi: number };
13
+
14
+ /** A routed connector: its orthogonal polyline and the boxes its ends are attached to. */
15
+ export type NudgePath = {
16
+ points: readonly Point[];
17
+ /** Box the first point sits on; without it the start port is never moved. */
18
+ source?: Rect;
19
+ /** Box the last point sits on; without it the end port is never moved. */
20
+ target?: Rect;
21
+ };
22
+
23
+ export type NudgeOptions = {
24
+ /** Gap between separated parallel runs (default 8). */
25
+ spacing?: number;
26
+ /** Boxes no run may be pushed through. */
27
+ obstacles?: readonly Rect[];
28
+ /** Lines that never move but that paths must not be pushed onto (e.g. an inheritance bus). */
29
+ fixed?: readonly (readonly Point[])[];
30
+ /** Minimum distance between a moved port and its box's corner (default `spacing`). */
31
+ margin?: number;
32
+ };
33
+
34
+ const EPSILON = 0.5;
35
+
36
+ const along = (axis: Axis, point: Point) => (axis === 'v' ? point.y : point.x);
37
+ const across = (axis: Axis, point: Point) => (axis === 'v' ? point.x : point.y);
38
+ const sign = (value: number) => (value > EPSILON ? 1 : value < -EPSILON ? -1 : 0);
39
+
40
+ /** Drops repeated waypoints and the midpoints of straight runs, so each run is one maximal segment. */
41
+ const simplify = (points: readonly Point[]): Point[] => {
42
+ const distinct = points.filter(
43
+ (point, index) =>
44
+ index === 0 ||
45
+ Math.abs(point.x - points[index - 1].x) >= EPSILON ||
46
+ Math.abs(point.y - points[index - 1].y) >= EPSILON,
47
+ );
48
+ return distinct.filter((point, index) => {
49
+ if (index === 0 || index === distinct.length - 1) {
50
+ return true;
51
+ }
52
+ const [previous, next] = [distinct[index - 1], distinct[index + 1]];
53
+ const vertical = Math.abs(previous.x - point.x) < EPSILON && Math.abs(point.x - next.x) < EPSILON;
54
+ const horizontal = Math.abs(previous.y - point.y) < EPSILON && Math.abs(point.y - next.y) < EPSILON;
55
+ return !vertical && !horizontal;
56
+ });
57
+ };
58
+
59
+ const runsOf = (points: readonly Point[], owner: number): Run[] =>
60
+ points.slice(0, -1).flatMap((from, start): Run[] => {
61
+ const to = points[start + 1];
62
+ const axis: Axis | undefined =
63
+ Math.abs(from.x - to.x) < EPSILON ? 'v' : Math.abs(from.y - to.y) < EPSILON ? 'h' : undefined;
64
+ if (!axis) {
65
+ return [];
66
+ }
67
+ const [a, b] = [along(axis, from), along(axis, to)];
68
+ return [{ owner, start, axis, coord: across(axis, from), lo: Math.min(a, b), hi: Math.max(a, b) }];
69
+ });
70
+
71
+ const shared = (left: Run, right: Run): boolean =>
72
+ left.axis === right.axis &&
73
+ Math.abs(left.coord - right.coord) < EPSILON &&
74
+ Math.min(left.hi, right.hi) - Math.max(left.lo, right.lo) > EPSILON;
75
+
76
+ const crosses = (left: Run, right: Run): boolean => {
77
+ if (left.axis === right.axis) {
78
+ return false;
79
+ }
80
+ return (
81
+ right.coord > left.lo + EPSILON &&
82
+ right.coord < left.hi - EPSILON &&
83
+ left.coord > right.lo + EPSILON &&
84
+ left.coord < right.hi - EPSILON
85
+ );
86
+ };
87
+
88
+ /** Length of the run inside the rect's interior; a run along a border or into a port is free. */
89
+ const interiorRun = (run: Run, rect: Rect): number => {
90
+ const [crossLo, crossHi, alongLo, alongHi] =
91
+ run.axis === 'v'
92
+ ? [rect.x, rect.x + rect.w, rect.y, rect.y + rect.h]
93
+ : [rect.y, rect.y + rect.h, rect.x, rect.x + rect.w];
94
+ if (run.coord <= crossLo + EPSILON || run.coord >= crossHi - EPSILON) {
95
+ return 0;
96
+ }
97
+ return Math.max(0, Math.min(run.hi, alongHi - EPSILON) - Math.max(run.lo, alongLo + EPSILON));
98
+ };
99
+
100
+ /**
101
+ * Pairs `{changed, other}` related by `related`: per line pair, or per run pair with `perRun`, which
102
+ * overlaps need so that separating one of two channels two lines share still lowers the count.
103
+ */
104
+ const pairCount = (
105
+ lines: readonly Point[][],
106
+ changed: ReadonlySet<number>,
107
+ related: (a: Run, b: Run) => boolean,
108
+ perRun = false,
109
+ ) => {
110
+ const runs = lines.map((points, owner) => runsOf(points, owner));
111
+ let count = 0;
112
+ for (const owner of changed) {
113
+ runs.forEach((others, other) => {
114
+ if (other === owner || (changed.has(other) && other < owner)) {
115
+ return;
116
+ }
117
+ if (perRun) {
118
+ for (const run of runs[owner]) {
119
+ count += others.filter((candidate) => related(run, candidate)).length;
120
+ }
121
+ } else if (runs[owner].some((run) => others.some((candidate) => related(run, candidate)))) {
122
+ count++;
123
+ }
124
+ });
125
+ }
126
+ return count;
127
+ };
128
+
129
+ /** Collinear runs of different lines that overlap, grouped transitively along each shared line. */
130
+ const channelsOf = (lines: readonly Point[][], movable: number): Run[][] => {
131
+ const runs = lines
132
+ .flatMap((points, owner) => runsOf(points, owner))
133
+ .sort((a, b) =>
134
+ a.axis === b.axis
135
+ ? Math.abs(a.coord - b.coord) < EPSILON
136
+ ? a.lo - b.lo
137
+ : a.coord - b.coord
138
+ : a.axis < b.axis
139
+ ? -1
140
+ : 1,
141
+ );
142
+ const channels: Run[][] = [];
143
+ let current: Run[] = [];
144
+ let reach = -Infinity;
145
+ const flush = () => {
146
+ const owners = new Set(current.map((run) => run.owner));
147
+ // A line that doubles back on itself, or a channel of fixed lines alone, is not ours to separate.
148
+ if (owners.size > 1 && owners.size === current.length && current.some((run) => run.owner < movable)) {
149
+ channels.push(current);
150
+ }
151
+ };
152
+ for (const run of runs) {
153
+ const head = current[0];
154
+ if (head && head.axis === run.axis && Math.abs(head.coord - run.coord) < EPSILON && run.lo < reach - EPSILON) {
155
+ current.push(run);
156
+ reach = Math.max(reach, run.hi);
157
+ } else {
158
+ flush();
159
+ current = [run];
160
+ reach = run.hi;
161
+ }
162
+ }
163
+ flush();
164
+ return channels;
165
+ };
166
+
167
+ /** Side (−1, 0, +1 across the run) the line turns toward at the run's low or high end; 0 at a terminal. */
168
+ const turn = (lines: readonly Point[][], run: Run, end: 'lo' | 'hi'): number => {
169
+ const points = lines[run.owner];
170
+ const startIsLow = along(run.axis, points[run.start]) < along(run.axis, points[run.start + 1]);
171
+ const neighbor = (end === 'lo') === startIsLow ? run.start - 1 : run.start + 2;
172
+ return neighbor < 0 || neighbor >= points.length ? 0 : sign(across(run.axis, points[neighbor]) - run.coord);
173
+ };
174
+
175
+ /**
176
+ * Order across the channel that keeps each run's turn-offs from cutting the others: a run that
177
+ * leaves the shared stretch early sits on the side it turns toward.
178
+ */
179
+ const order = (lines: readonly Point[][], channel: readonly Run[]): Run[] => {
180
+ const compare = (left: Run, right: Run): number => {
181
+ let vote = 0;
182
+ if (left.lo > right.lo + EPSILON) {
183
+ vote += turn(lines, left, 'lo');
184
+ } else if (right.lo > left.lo + EPSILON) {
185
+ vote -= turn(lines, right, 'lo');
186
+ } else {
187
+ vote += sign(turn(lines, left, 'lo') - turn(lines, right, 'lo'));
188
+ }
189
+ if (left.hi < right.hi - EPSILON) {
190
+ vote += turn(lines, left, 'hi');
191
+ } else if (right.hi < left.hi - EPSILON) {
192
+ vote -= turn(lines, right, 'hi');
193
+ } else {
194
+ vote += sign(turn(lines, left, 'hi') - turn(lines, right, 'hi'));
195
+ }
196
+ return vote !== 0 ? vote : left.owner - right.owner;
197
+ };
198
+ return [...channel].sort(compare);
199
+ };
200
+
201
+ /**
202
+ * Shift one run across its axis by `offset`, dragging the adjacent waypoints with it so the line
203
+ * stays orthogonal; undefined when that would detach a port, reverse a neighbour or crush an end stub.
204
+ */
205
+ const shift = (
206
+ points: readonly Point[],
207
+ run: Run,
208
+ offset: number,
209
+ path: NudgePath | undefined,
210
+ spacing: number,
211
+ margin: number,
212
+ ): Point[] | undefined => {
213
+ const move = (point: Point): Point =>
214
+ run.axis === 'v' ? { x: point.x + offset, y: point.y } : { x: point.x, y: point.y + offset };
215
+ const next = points.map((point, index) => (index === run.start || index === run.start + 1 ? move(point) : point));
216
+ const last = points.length - 1;
217
+ for (const [index, rect] of [
218
+ [0, path?.source],
219
+ [last, path?.target],
220
+ ] as const) {
221
+ if (index !== run.start && index !== run.start + 1) {
222
+ continue;
223
+ }
224
+ // A port slides along its own side only: the side must lie across the run, and the port must stay clear of the corners.
225
+ const port = points[index];
226
+ if (!rect) {
227
+ return undefined;
228
+ }
229
+ const [sideLo, sideHi, crossLo, crossHi] =
230
+ run.axis === 'v'
231
+ ? [rect.y, rect.y + rect.h, rect.x, rect.x + rect.w]
232
+ : [rect.x, rect.x + rect.w, rect.y, rect.y + rect.h];
233
+ const onSide =
234
+ Math.abs(along(run.axis, port) - sideLo) < EPSILON || Math.abs(along(run.axis, port) - sideHi) < EPSILON;
235
+ const moved = across(run.axis, next[index]);
236
+ if (!onSide || moved < crossLo + margin - EPSILON || moved > crossHi - margin + EPSILON) {
237
+ return undefined;
238
+ }
239
+ }
240
+ for (const segment of [run.start - 1, run.start + 1]) {
241
+ if (segment < 0 || segment >= last) {
242
+ continue;
243
+ }
244
+ const before = across(run.axis, points[segment + 1]) - across(run.axis, points[segment]);
245
+ const after = across(run.axis, next[segment + 1]) - across(run.axis, next[segment]);
246
+ if (sign(after) !== 0 && sign(after) !== sign(before)) {
247
+ return undefined;
248
+ }
249
+ // End stubs carry the arrowhead and the port's exit direction, so they may not shrink below one step.
250
+ const terminal = segment === 0 || segment === last - 1;
251
+ if (terminal && Math.abs(after) < Math.min(Math.abs(before), spacing) - EPSILON) {
252
+ return undefined;
253
+ }
254
+ }
255
+ return next;
256
+ };
257
+
258
+ type Candidate = { lines: Point[][]; overlaps: number; crossings: number; reversed: number; travel: number };
259
+
260
+ /**
261
+ * Separates the runs of one channel by `spacing`, preferring the channel's order; tries every window
262
+ * of offsets (and single moves as a fallback) and keeps the one that resolves the most overlaps
263
+ * without adding crossings or cutting through a box.
264
+ */
265
+ const separate = (
266
+ lines: readonly Point[][],
267
+ channel: readonly Run[],
268
+ paths: readonly NudgePath[],
269
+ obstacles: readonly Rect[],
270
+ spacing: number,
271
+ margin: number,
272
+ ): Point[][] | undefined => {
273
+ const movable = paths.length;
274
+ const fixed = channel.filter((run) => run.owner >= movable);
275
+ if (fixed.length > 1) {
276
+ return undefined;
277
+ }
278
+ const sorted = order(lines, channel);
279
+ const assignments: { offsets: Map<Run, number>; reversed: number }[] = [];
280
+ [sorted, [...sorted].reverse()].forEach((ordered, reversed) => {
281
+ const anchor = ordered.findIndex((run) => run.owner >= movable);
282
+ const shifts = anchor >= 0 ? [-anchor] : ordered.map((_, index) => -index);
283
+ for (const base of shifts) {
284
+ assignments.push({
285
+ offsets: new Map(ordered.map((run, index) => [run, (index + base) * spacing])),
286
+ reversed,
287
+ });
288
+ }
289
+ });
290
+ for (const run of channel) {
291
+ if (run.owner < movable) {
292
+ for (const steps of [1, -1, 2, -2]) {
293
+ assignments.push({ offsets: new Map([[run, steps * spacing]]), reversed: 1 });
294
+ }
295
+ }
296
+ }
297
+
298
+ const changedOf = (offsets: Map<Run, number>) =>
299
+ new Set([...offsets].filter(([, offset]) => offset !== 0).map(([run]) => run.owner));
300
+ const obstructed = (candidate: readonly Point[][], owners: ReadonlySet<number>) =>
301
+ [...owners].reduce(
302
+ (total, owner) =>
303
+ total +
304
+ runsOf(candidate[owner], owner).filter((run) => obstacles.some((rect) => interiorRun(run, rect) > EPSILON))
305
+ .length,
306
+ 0,
307
+ );
308
+
309
+ let best: Candidate | undefined;
310
+ for (const { offsets, reversed } of assignments) {
311
+ const changed = changedOf(offsets);
312
+ if (changed.size === 0 || [...changed].some((owner) => owner >= movable)) {
313
+ continue;
314
+ }
315
+ const candidate = [...lines];
316
+ let valid = true;
317
+ for (const [run, offset] of offsets) {
318
+ if (offset === 0) {
319
+ continue;
320
+ }
321
+ const moved = shift(lines[run.owner], run, offset, paths[run.owner], spacing, margin);
322
+ if (!moved) {
323
+ valid = false;
324
+ break;
325
+ }
326
+ candidate[run.owner] = moved;
327
+ }
328
+ if (!valid) {
329
+ continue;
330
+ }
331
+ const overlaps = pairCount(candidate, changed, shared, true);
332
+ const crossings = pairCount(candidate, changed, crosses);
333
+ if (
334
+ overlaps >= pairCount(lines, changed, shared, true) ||
335
+ crossings > pairCount(lines, changed, crosses) ||
336
+ obstructed(candidate, changed) > obstructed(lines, changed)
337
+ ) {
338
+ continue;
339
+ }
340
+ const travel = [...offsets.values()].reduce((total, offset) => total + Math.abs(offset), 0);
341
+ const better =
342
+ !best ||
343
+ overlaps < best.overlaps ||
344
+ (overlaps === best.overlaps &&
345
+ (crossings < best.crossings ||
346
+ (crossings === best.crossings &&
347
+ (reversed < best.reversed || (reversed === best.reversed && travel < best.travel)))));
348
+ if (better) {
349
+ best = { lines: candidate, overlaps, crossings, reversed, travel };
350
+ }
351
+ }
352
+ return best?.lines.map((points, owner) => (points === lines[owner] ? points : simplify(points)));
353
+ };
354
+
355
+ /**
356
+ * Nudging (Wybrow, Marriott & Stuckey 2010): distinct connectors that share a collinear run are
357
+ * pulled apart across it by `spacing`, ordered so their turn-offs do not cross, with ports sliding
358
+ * along their box side. Fixed lines (a deliberate bus) never move. Paths that need no change come
359
+ * back as given.
360
+ */
361
+ export const nudge = (paths: readonly NudgePath[], options: NudgeOptions = {}): Point[][] => {
362
+ const { spacing = 8, obstacles = [], fixed = [], margin = spacing } = options;
363
+ let lines: Point[][] = [...paths.map((path) => simplify(path.points)), ...fixed.map((points) => [...points])];
364
+ const touched = new Set<number>();
365
+ const attempted = new Set<string>();
366
+ const signature = (channel: readonly Run[]) =>
367
+ `${channel[0].axis}:${Math.round(channel[0].coord)}:${channel
368
+ .map((run) => `${run.owner}@${Math.round(run.lo)}-${Math.round(run.hi)}`)
369
+ .sort()
370
+ .join(',')}`;
371
+ // Each separation strictly lowers the overlap count and a failed channel is never retried, so this terminates.
372
+ for (;;) {
373
+ const channel = channelsOf(lines, paths.length).find((candidate) => !attempted.has(signature(candidate)));
374
+ if (!channel) {
375
+ break;
376
+ }
377
+ attempted.add(signature(channel));
378
+ const next = separate(lines, channel, paths, obstacles, spacing, margin);
379
+ if (next) {
380
+ next.forEach((points, owner) => points !== lines[owner] && touched.add(owner));
381
+ lines = next;
382
+ }
383
+ }
384
+ return paths.map((path, owner) => (touched.has(owner) ? lines[owner] : [...path.points]));
385
+ };
@@ -29,6 +29,8 @@ describe('objective', () => {
29
29
  expect(evaluation.violations).toEqual([]);
30
30
  expect(evaluation.terms.map(({ id }) => id)).toEqual([
31
31
  'crossings',
32
+ 'edge-overlaps',
33
+ 'text-overlaps',
32
34
  'bends',
33
35
  'connector-length',
34
36
  'uneven-frame-gaps',