@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
package/src/objective.ts CHANGED
@@ -112,6 +112,22 @@ export const crossings: CostTerm = {
112
112
  measure: ({ report }) => report.metrics.crossings,
113
113
  };
114
114
 
115
+ /** Labels overlapping other labels or boxes; as costly as a crossing, since either hides what the reader needs. */
116
+ export const textOverlaps: CostTerm = {
117
+ id: 'text-overlaps',
118
+ description: 'Labels overlapping another label or a box.',
119
+ weight: 3,
120
+ measure: ({ report }) => report.metrics.textOverlaps,
121
+ };
122
+
123
+ /** Connector pairs sharing a stretch of line; as costly as a crossing, since the reader cannot pair their ends. */
124
+ export const edgeOverlaps: CostTerm = {
125
+ id: 'edge-overlaps',
126
+ description: 'Connector pairs that run along the same line.',
127
+ weight: 3,
128
+ measure: ({ report }) => report.metrics.edgeOverlaps,
129
+ };
130
+
115
131
  export const bends: CostTerm = {
116
132
  id: 'bends',
117
133
  description: 'Turns taken by connectors.',
@@ -153,5 +169,5 @@ export const compactness: CostTerm = {
153
169
 
154
170
  export const DEFAULT: Objective = {
155
171
  constraints: [noHardDefects, framesApart()],
156
- costs: [crossings, bends, connectorLength, unevenFrameGaps, compactness],
172
+ costs: [crossings, edgeOverlaps, textOverlaps, bends, connectorLength, unevenFrameGaps, compactness],
157
173
  };
@@ -27,11 +27,98 @@ type Point = Scene.Point;
27
27
  const DX = [1, 0, -1, 0];
28
28
  const DY = [0, 1, 0, -1];
29
29
 
30
+ /** Offsets past this distance turn the same way as at it, so the table clamps to it. */
31
+ const TURN_RADIUS = 4;
32
+ const TURN_SPAN = TURN_RADIUS * 2 + 1;
33
+
34
+ /**
35
+ * Fewest turns from a cell heading `dir` to a target `(dx, dy)` away, entered heading `endDir` or
36
+ * paying the search's one-turn penalty for entering off-axis — on an empty grid, so an admissible
37
+ * and consistent lower bound for the search, indexed `[endDir][dir][dx][dy]`. It is exact where the
38
+ * old estimate ignored the arrival heading, which sent every search sweeping the cells behind its
39
+ * target. Computed once, by one backward search per arrival heading over the moves the search makes.
40
+ */
41
+ const TURN_TABLE = (() => {
42
+ const table = new Uint8Array(4 * 4 * TURN_SPAN * TURN_SPAN);
43
+ // The board leaves room around the radius for the detours an off-axis approach takes.
44
+ const board = TURN_RADIUS + 4;
45
+ const side = board * 2 + 1;
46
+ const stateOf = (x: number, y: number, dir: number) => ((y + board) * side + (x + board)) * 4 + dir;
47
+ const turns = new Uint8Array(side * side * 4);
48
+ // Label-correcting relaxation: steps cost 0, 1 or 2 turns, and a state re-enters the queue
49
+ // whenever it improves, so the queue holds at most a few passes over a small board.
50
+ const queue = new Int32Array(side * side * 4 * 8);
51
+ for (let endDir = 0; endDir < 4; endDir++) {
52
+ turns.fill(255);
53
+ let head = 0;
54
+ let tail = 0;
55
+ // Arriving at the target ends the search whatever the heading.
56
+ for (let dir = 0; dir < 4; dir++) {
57
+ turns[stateOf(0, 0, dir)] = 0;
58
+ queue[tail++] = stateOf(0, 0, dir);
59
+ }
60
+ while (head < tail) {
61
+ const state = queue[head++ % queue.length];
62
+ const arrivedDir = state % 4;
63
+ const cell = (state - arrivedDir) / 4;
64
+ const x = (cell % side) - board;
65
+ const y = Math.floor(cell / side) - board;
66
+ // Every state that steps into (x, y) heading `arrivedDir`, and what that step costs.
67
+ const px = x - DX[arrivedDir];
68
+ const py = y - DY[arrivedDir];
69
+ if (Math.abs(px) > board || Math.abs(py) > board || (px === 0 && py === 0)) {
70
+ continue;
71
+ }
72
+ const arrival = x === 0 && y === 0 && arrivedDir !== endDir ? 1 : 0;
73
+ for (let dir = 0; dir < 4; dir++) {
74
+ if ((arrivedDir + 2) % 4 === dir) {
75
+ continue;
76
+ }
77
+ const previous = stateOf(px, py, dir);
78
+ const total = turns[state] + (arrivedDir === dir ? 0 : 1) + arrival;
79
+ if (total < turns[previous]) {
80
+ turns[previous] = total;
81
+ queue[tail++ % queue.length] = previous;
82
+ }
83
+ }
84
+ }
85
+ for (let dir = 0; dir < 4; dir++) {
86
+ for (let dx = -TURN_RADIUS; dx <= TURN_RADIUS; dx++) {
87
+ for (let dy = -TURN_RADIUS; dy <= TURN_RADIUS; dy++) {
88
+ const at = ((endDir * 4 + dir) * TURN_SPAN + dx + TURN_RADIUS) * TURN_SPAN + dy + TURN_RADIUS;
89
+ table[at] = dx === 0 && dy === 0 ? 0 : turns[stateOf(-dx, -dy, dir)];
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return table;
95
+ })();
96
+
97
+ const clampTurnOffset = (offset: number) => Math.max(-TURN_RADIUS, Math.min(TURN_RADIUS, offset));
98
+
99
+ const turnsBetween = (dx: number, dy: number, dir: number, endDir: number): number =>
100
+ TURN_TABLE[
101
+ ((endDir * 4 + dir) * TURN_SPAN + clampTurnOffset(dx) + TURN_RADIUS) * TURN_SPAN + clampTurnOffset(dy) + TURN_RADIUS
102
+ ];
103
+
30
104
  export type AvoidingRouterOptions = {
31
105
  /** Search-cell size (scene px): a fraction of the layout grid, so routes can hug node borders. */
32
106
  step: number;
33
107
  };
34
108
 
109
+ /** Paths an avoiding router marked as used, so a later router can replay them; weak, so it holds nothing alive. */
110
+ const marked = new WeakSet<readonly Point[]>();
111
+
112
+ /** The avoiding router plus a way to replay an earlier route's footprint without searching again. */
113
+ export type AvoidingRouter = Router & {
114
+ /**
115
+ * Marks the cells of a path an avoiding router over the same obstacles returned before as used, as
116
+ * routing it did; a path that marked nothing (a fallback route) marks nothing, so a replayed prefix
117
+ * leaves the same state.
118
+ */
119
+ reserve: (points: readonly Point[]) => void;
120
+ };
121
+
35
122
  /**
36
123
  * Creates a router that avoids the given node rects. Stateful across edges: earlier routes
37
124
  * penalize (not block) the cells they occupy, spreading parallel runs apart. `fallback` handles
@@ -42,7 +129,7 @@ export const makeAvoidingRouter = (
42
129
  obstacles: Rect[],
43
130
  fallback: Router,
44
131
  { step: STEP }: AvoidingRouterOptions,
45
- ): Router => {
132
+ ): AvoidingRouter => {
46
133
  const xs = obstacles.flatMap((rect) => [rect.x, rect.x + rect.w]);
47
134
  const ys = obstacles.flatMap((rect) => [rect.y, rect.y + rect.h]);
48
135
  const bounds = {
@@ -51,14 +138,25 @@ export const makeAvoidingRouter = (
51
138
  x1: Math.ceil(Math.max(...xs) / STEP) + MARGIN,
52
139
  y1: Math.ceil(Math.max(...ys) / STEP) + MARGIN,
53
140
  };
54
- const width = bounds.x1 - bounds.x0 + 1;
55
- const height = bounds.y1 - bounds.y0 + 1;
141
+ // The grids carry a one-cell blocked border, so the search's blocked test also keeps it in bounds.
142
+ const width = bounds.x1 - bounds.x0 + 3;
143
+ const height = bounds.y1 - bounds.y0 + 3;
56
144
  const inBounds = (x: number, y: number) => x >= bounds.x0 && x <= bounds.x1 && y >= bounds.y0 && y <= bounds.y1;
57
- const cellIndex = (x: number, y: number) => (y - bounds.y0) * width + (x - bounds.x0);
145
+ const cellIndex = (x: number, y: number) => (y - bounds.y0 + 1) * width + (x - bounds.x0 + 1);
146
+ /** Cell offsets per direction, matching `DX` and `DY`. */
147
+ const STEPS = [1, width, -1, -width];
58
148
 
59
149
  // Flat grids in place of per-cell string keys: the search touches every cell many times over and
60
150
  // the string building was half its running time.
61
151
  const blockedGrid = new Uint8Array(width * height);
152
+ for (let x = 0; x < width; x++) {
153
+ blockedGrid[x] = 1;
154
+ blockedGrid[(height - 1) * width + x] = 1;
155
+ }
156
+ for (let y = 0; y < height; y++) {
157
+ blockedGrid[y * width] = 1;
158
+ blockedGrid[y * width + width - 1] = 1;
159
+ }
62
160
  for (const rect of obstacles) {
63
161
  const x0 = Math.floor(rect.x / STEP) - CLEARANCE;
64
162
  const y0 = Math.floor(rect.y / STEP) - CLEARANCE;
@@ -84,140 +182,204 @@ export const makeAvoidingRouter = (
84
182
  const settledGen = new Int32Array(width * height * 4);
85
183
  let generation = 0;
86
184
 
87
- /** `f` is cost + estimate, the heap's key. */
88
- type State = { x: number; y: number; dir: number; cost: number; f: number; prev?: State };
89
185
  type Terminal = { point: Point; dir: number };
90
186
  type Found = { cost: number; cells: Point[] };
91
187
 
188
+ // Search states live in a pool of parallel typed arrays, addressed by slot, and the heap holds
189
+ // slots: a search pushes millions of states, and one object per state made GC a third of the run.
190
+ let capacity = 1 << 16;
191
+ let stateKey = new Int32Array(capacity);
192
+ let stateCost = new Float64Array(capacity);
193
+ let statePrev = new Int32Array(capacity);
194
+ let heap = new Int32Array(capacity);
195
+ // Each heap entry's key beside its slot, so sifting compares without chasing the slot into the pool.
196
+ let heapF = new Float64Array(capacity);
197
+ let poolSize = 0;
198
+ let heapSize = 0;
199
+
200
+ const grow = () => {
201
+ capacity *= 2;
202
+ const widen = <T extends Int32Array | Float64Array>(array: T, make: (size: number) => T): T => {
203
+ const next = make(capacity);
204
+ next.set(array);
205
+ return next;
206
+ };
207
+ stateKey = widen(stateKey, (size) => new Int32Array(size));
208
+ stateCost = widen(stateCost, (size) => new Float64Array(size));
209
+ statePrev = widen(statePrev, (size) => new Int32Array(size));
210
+ heap = widen(heap, (size) => new Int32Array(size));
211
+ heapF = widen(heapF, (size) => new Float64Array(size));
212
+ };
213
+
92
214
  /** Binary min-heap on cost + estimate, so each pop is O(log n) instead of a frontier scan. */
93
- const heapPush = (heap: State[], state: State) => {
94
- heap.push(state);
95
- let index = heap.length - 1;
215
+ const heapPush = (key: number, cost: number, f: number, prev: number) => {
216
+ if (poolSize === capacity) {
217
+ grow();
218
+ }
219
+ const slot = poolSize++;
220
+ stateKey[slot] = key;
221
+ stateCost[slot] = cost;
222
+ statePrev[slot] = prev;
223
+ let index = heapSize++;
96
224
  while (index > 0) {
97
225
  const parent = (index - 1) >> 1;
98
- if (heap[parent].f <= heap[index].f) {
226
+ if (heapF[parent] <= f) {
99
227
  break;
100
228
  }
101
- [heap[parent], heap[index]] = [heap[index], heap[parent]];
229
+ heap[index] = heap[parent];
230
+ heapF[index] = heapF[parent];
102
231
  index = parent;
103
232
  }
233
+ heap[index] = slot;
234
+ heapF[index] = f;
104
235
  };
105
- const heapPop = (heap: State[]): State => {
236
+ const heapPop = (): number => {
106
237
  const top = heap[0];
107
- const last = heap.pop()!;
108
- if (heap.length > 0) {
109
- heap[0] = last;
238
+ const last = heap[--heapSize];
239
+ if (heapSize > 0) {
240
+ const lastF = heapF[heapSize];
110
241
  let index = 0;
111
242
  for (;;) {
112
243
  const left = index * 2 + 1;
113
244
  const right = left + 1;
114
245
  let smallest = index;
115
- if (left < heap.length && heap[left].f < heap[smallest].f) {
246
+ let smallestF = lastF;
247
+ if (left < heapSize && heapF[left] < smallestF) {
116
248
  smallest = left;
249
+ smallestF = heapF[left];
117
250
  }
118
- if (right < heap.length && heap[right].f < heap[smallest].f) {
251
+ if (right < heapSize && heapF[right] < smallestF) {
119
252
  smallest = right;
253
+ smallestF = heapF[right];
120
254
  }
121
255
  if (smallest === index) {
122
256
  break;
123
257
  }
124
- [heap[smallest], heap[index]] = [heap[index], heap[smallest]];
258
+ heap[index] = heap[smallest];
259
+ heapF[index] = smallestF;
125
260
  index = smallest;
126
261
  }
262
+ heap[index] = last;
263
+ heapF[index] = lastF;
127
264
  }
128
265
  return top;
129
266
  };
130
267
 
131
- /**
132
- * Fewest turns any path from (x, y) heading `dir` needs to reach the target: none while aligned
133
- * and heading at it, two when heading away along its axis, otherwise one. Admissible, and since
134
- * a turn costs as much as a thousand steps it is what makes the search converge on a diagram
135
- * hundreds of cells across — distance alone leaves it exploring almost uniformly.
136
- */
137
- const turnsNeeded = (x: number, y: number, dir: number, target: Point): number => {
268
+ const estimateFrom = (x: number, y: number, dir: number, target: Point, endDir: number): number => {
138
269
  const dx = target.x - x;
139
270
  const dy = target.y - y;
140
- if (dx !== 0 && dy !== 0) {
141
- return 1;
142
- }
143
- if (dx === 0 && dy === 0) {
144
- return 0;
145
- }
146
- const towards = dx !== 0 ? (dx > 0 ? 0 : 2) : dy > 0 ? 1 : 3;
147
- return dir === towards ? 0 : (dir + 2) % 4 === towards ? 2 : 1;
271
+ return Math.abs(dx) + Math.abs(dy) + turnsBetween(dx, dy, dir, endDir) * TURN_COST;
148
272
  };
149
273
 
150
- const estimateFrom = (x: number, y: number, dir: number, target: Point): number =>
151
- Math.abs(target.x - x) + Math.abs(target.y - y) + turnsNeeded(x, y, dir, target) * TURN_COST;
152
-
153
274
  // Every cell in every heading, so a reachable target is never abandoned on a large diagram.
154
275
  const budget = Math.max(50_000, (bounds.x1 - bounds.x0) * (bounds.y1 - bounds.y0) * 4);
155
276
 
156
277
  /**
157
278
  * Bounded A* between two stub ends (grid coordinates). Successors already dominated by a
158
- * settled state are pruned before pushing; undefined when the target is unreachable.
279
+ * settled state are pruned before pushing; undefined when the target is unreachable or no path
280
+ * costs less than `bound`.
159
281
  */
160
- const search = (source: Point, startDir: number, target: Point, endDir: number): Found | undefined => {
161
- const open: State[] = [
162
- { x: source.x, y: source.y, dir: startDir, cost: 0, f: estimateFrom(source.x, source.y, startDir, target) },
163
- ];
282
+ const search = (
283
+ source: Point,
284
+ startDir: number,
285
+ target: Point,
286
+ endDir: number,
287
+ bound = Infinity,
288
+ ): Found | undefined => {
289
+ poolSize = 0;
290
+ heapSize = 0;
291
+ heapPush(
292
+ cellIndex(source.x, source.y) * 4 + startDir,
293
+ 0,
294
+ estimateFrom(source.x, source.y, startDir, target, endDir),
295
+ -1,
296
+ );
164
297
  generation++;
165
- const settledAt = (index: number) => (settledGen[index] === generation ? settledCost[index] : undefined);
166
- let found: State | undefined;
298
+ const targetCell = inBounds(target.x, target.y) ? cellIndex(target.x, target.y) : -1;
299
+ // Coordinates relative to the padded grid, so the estimate needs no conversion per state.
300
+ const targetX = target.x - bounds.x0 + 1;
301
+ const targetY = target.y - bounds.y0 + 1;
302
+ let found = -1;
167
303
 
168
- for (let iterations = 0; open.length > 0 && iterations < budget; iterations++) {
169
- const current = heapPop(open);
170
- if (current.x === target.x && current.y === target.y) {
304
+ for (let iterations = 0; heapSize > 0 && iterations < budget; iterations++) {
305
+ // The estimate is admissible, so once the cheapest frontier key reaches the bound no path
306
+ // can beat it; without this a losing configuration explores every state up to its own optimum.
307
+ if (heapF[0] >= bound) {
308
+ break;
309
+ }
310
+ const current = heapPop();
311
+ const stateIndex = stateKey[current];
312
+ const currentDir = stateIndex & 3;
313
+ const currentCell = stateIndex >> 2;
314
+ const currentCost = stateCost[current];
315
+ if (currentCell === targetCell) {
171
316
  found = current;
172
317
  break;
173
318
  }
174
- const stateIndex = cellIndex(current.x, current.y) * 4 + current.dir;
175
- const seen = settledAt(stateIndex);
176
- if (seen !== undefined && seen <= current.cost) {
319
+ if (settledGen[stateIndex] === generation && settledCost[stateIndex] <= currentCost) {
177
320
  continue;
178
321
  }
179
322
  settledGen[stateIndex] = generation;
180
- settledCost[stateIndex] = current.cost;
323
+ settledCost[stateIndex] = currentCost;
324
+ const currentY = Math.floor(currentCell / width);
325
+ const currentX = currentCell - currentY * width;
181
326
 
182
327
  for (let dir = 0; dir < 4; dir++) {
183
- if ((dir + 2) % 4 === current.dir) {
184
- continue;
185
- }
186
- const x = current.x + DX[dir];
187
- const y = current.y + DY[dir];
188
- if (!inBounds(x, y)) {
328
+ if ((dir + 2) % 4 === currentDir) {
189
329
  continue;
190
330
  }
191
- const cell = cellIndex(x, y);
331
+ const cell = currentCell + STEPS[dir];
192
332
  if (blockedGrid[cell] !== 0) {
193
333
  continue;
194
334
  }
195
335
  const cost =
196
- current.cost +
336
+ currentCost +
197
337
  1 +
198
- (dir === current.dir ? 0 : TURN_COST) +
338
+ (dir === currentDir ? 0 : TURN_COST) +
199
339
  (usedGrid[cell] !== 0 ? USED_COST : 0) +
200
340
  // Entering the target off-axis forces one more bend at arrival; fold it in.
201
- (x === target.x && y === target.y && dir !== endDir ? TURN_COST : 0);
202
- const dominated = settledAt(cell * 4 + dir);
203
- if (dominated !== undefined && dominated <= cost) {
341
+ (cell === targetCell && dir !== endDir ? TURN_COST : 0);
342
+ const next = cell * 4 + dir;
343
+ if (settledGen[next] === generation && settledCost[next] <= cost) {
204
344
  continue;
205
345
  }
206
- heapPush(open, { x, y, dir, cost, f: cost + estimateFrom(x, y, dir, target), prev: current });
346
+ const dx = targetX - (currentX + DX[dir]);
347
+ const dy = targetY - (currentY + DY[dir]);
348
+ heapPush(
349
+ next,
350
+ cost,
351
+ cost + (Math.abs(dx) + Math.abs(dy) + turnsBetween(dx, dy, dir, endDir) * TURN_COST),
352
+ current,
353
+ );
207
354
  }
208
355
  }
209
356
 
210
- if (!found) {
357
+ if (found < 0) {
211
358
  return undefined;
212
359
  }
213
360
  const cells: Point[] = [];
214
- for (let state: State | undefined = found; state; state = state.prev) {
215
- cells.unshift({ x: state.x, y: state.y });
361
+ for (let slot = found; slot >= 0; slot = statePrev[slot]) {
362
+ const cell = stateKey[slot] >> 2;
363
+ cells.unshift({ x: (cell % width) + bounds.x0 - 1, y: Math.floor(cell / width) + bounds.y0 - 1 });
216
364
  }
217
- return { cost: found.cost, cells };
365
+ return { cost: stateCost[found], cells };
218
366
  };
219
367
 
220
- return (edge: RoutedRelation): Point[] => {
368
+ const markPath = (path: readonly Point[]) => {
369
+ for (let index = 0; index < path.length - 1; index++) {
370
+ const a = path[index];
371
+ const b = path[index + 1];
372
+ const ax = Math.round(a.x / STEP);
373
+ const ay = Math.round(a.y / STEP);
374
+ const steps = Math.max(Math.abs(Math.round(b.x / STEP) - ax), Math.abs(Math.round(b.y / STEP) - ay));
375
+ const dx = Math.sign(b.x - a.x);
376
+ const dy = Math.sign(b.y - a.y);
377
+ for (let step = 0; step <= steps; step++) {
378
+ markUsed(ax + dx * step, ay + dy * step);
379
+ }
380
+ }
381
+ };
382
+ const route = (edge: RoutedRelation): Point[] => {
221
383
  const { from, to, horizontal, ports } = edge;
222
384
  // Flow-axis faces mirror the Z-router (and the port assignment in `emit`).
223
385
  const sameLane = horizontal ? from.x === to.x : from.y === to.y;
@@ -270,6 +432,7 @@ export const makeAvoidingRouter = (
270
432
  startTerminal.dir,
271
433
  stub(endTerminal.point, endTerminal.dir, false),
272
434
  endTerminal.dir,
435
+ best?.cost,
273
436
  );
274
437
  if (result && (!best || result.cost < best.cost)) {
275
438
  best = result;
@@ -355,18 +518,15 @@ export const makeAvoidingRouter = (
355
518
  }
356
519
 
357
520
  // Mark the final path's cells so later edges route (and center) around it.
358
- for (let index = 0; index < simplified.length - 1; index++) {
359
- const a = simplified[index];
360
- const b = simplified[index + 1];
361
- const ax = Math.round(a.x / STEP);
362
- const ay = Math.round(a.y / STEP);
363
- const steps = Math.max(Math.abs(Math.round(b.x / STEP) - ax), Math.abs(Math.round(b.y / STEP) - ay));
364
- const dx = Math.sign(b.x - a.x);
365
- const dy = Math.sign(b.y - a.y);
366
- for (let step = 0; step <= steps; step++) {
367
- markUsed(ax + dx * step, ay + dy * step);
368
- }
369
- }
521
+ markPath(simplified);
522
+ marked.add(simplified);
370
523
  return simplified;
371
524
  };
525
+
526
+ const reserve = (points: readonly Point[]) => {
527
+ if (marked.has(points)) {
528
+ markPath(points);
529
+ }
530
+ };
531
+ return Object.assign(route, { reserve });
372
532
  };
@@ -0,0 +1,157 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { type PortPath, assign, keyOf } from './ports.ts';
8
+ import type * as Scene from './scene.ts';
9
+ import { GRID, type Rect, zRouter } from './uml-grid.ts';
10
+
11
+ const STEP = GRID / 2;
12
+
13
+ type Segment = readonly [Scene.Point, Scene.Point];
14
+
15
+ const segmentsOf = (points: readonly Scene.Point[]): Segment[] =>
16
+ points.slice(1).map((end, index) => [points[index], end] as const);
17
+
18
+ /** Whether two orthogonal polylines cross at a point interior to a segment of each. */
19
+ const crosses = (first: readonly Scene.Point[], second: readonly Scene.Point[]) =>
20
+ segmentsOf(first).some(([a0, a1]) =>
21
+ segmentsOf(second).some(([b0, b1]) => {
22
+ const [vertical, horizontal] =
23
+ a0.x === a1.x
24
+ ? [
25
+ [a0, a1],
26
+ [b0, b1],
27
+ ]
28
+ : [
29
+ [b0, b1],
30
+ [a0, a1],
31
+ ];
32
+ if (vertical[0].x !== vertical[1].x || horizontal[0].y !== horizontal[1].y) {
33
+ return false;
34
+ }
35
+ const x = vertical[0].x;
36
+ const y = horizontal[0].y;
37
+ return (
38
+ x > Math.min(horizontal[0].x, horizontal[1].x) &&
39
+ x < Math.max(horizontal[0].x, horizontal[1].x) &&
40
+ y > Math.min(vertical[0].y, vertical[1].y) &&
41
+ y < Math.max(vertical[0].y, vertical[1].y)
42
+ );
43
+ }),
44
+ );
45
+
46
+ describe('ports', () => {
47
+ // One box above two targets that sit far apart below it, on its left and its right.
48
+ const source: Rect = { x: 384, y: 0, w: 192, h: 96 };
49
+ const left: Rect = { x: 0, y: 384, w: 192, h: 96 };
50
+ const right: Rect = { x: 768, y: 384, w: 192, h: 96 };
51
+
52
+ /**
53
+ * Routes both edges off the bottom face as Zs whose cross runs sit at different heights, as two
54
+ * routes that share a side do once the second steers off the first.
55
+ */
56
+ const routeBoth = (toLeft: number, toRight: number) => {
57
+ const route = (target: Rect, start: number, offset: number) =>
58
+ zRouter({
59
+ relation: { from: 'source', to: target === left ? 'left' : 'right' },
60
+ from: source,
61
+ to: target,
62
+ horizontal: false,
63
+ offset,
64
+ ports: { start, end: target.x + target.w / 2 },
65
+ });
66
+ return [route(left, toLeft, 0), route(right, toRight, 1)];
67
+ };
68
+
69
+ test('two edges leaving one side in crossed order are reordered so they no longer cross', ({ expect }) => {
70
+ // Crossed: the edge to the left target leaves from the right of the side and vice versa.
71
+ const crossed = routeBoth(source.x + source.w - STEP * 2, source.x + STEP * 2);
72
+ expect(crossed.every((points) => points[0].y === source.y + source.h)).toBe(true);
73
+ expect(crosses(crossed[0], crossed[1])).toBe(true);
74
+
75
+ const paths: PortPath[] = [
76
+ { points: crossed[0], source, target: left },
77
+ { points: crossed[1], source, target: right },
78
+ ];
79
+ const [toLeft, toRight] = assign(paths, { step: STEP });
80
+ expect(toLeft.start).toBeDefined();
81
+ expect(toRight.start).toBeDefined();
82
+ const [leftStart, rightStart] = [toLeft.start ?? 0, toRight.start ?? 0];
83
+ expect(leftStart).toBeLessThan(rightStart);
84
+ for (const coord of [leftStart, rightStart]) {
85
+ expect(coord % STEP).toBe(0);
86
+ expect(coord).toBeGreaterThanOrEqual(source.x + STEP);
87
+ expect(coord).toBeLessThanOrEqual(source.x + source.w - STEP);
88
+ }
89
+ // The far ends were not asked to move.
90
+ expect(toLeft.end).toBeUndefined();
91
+ expect(toRight.end).toBeUndefined();
92
+
93
+ const ordered = routeBoth(leftStart, rightStart);
94
+ expect(crosses(ordered[0], ordered[1])).toBe(false);
95
+ });
96
+
97
+ test('a side already in order is left alone', ({ expect }) => {
98
+ const ordered = routeBoth(source.x + STEP * 2, source.x + source.w - STEP * 2);
99
+ expect(crosses(ordered[0], ordered[1])).toBe(false);
100
+ const assigned = assign(
101
+ [
102
+ { points: ordered[0], source, target: left },
103
+ { points: ordered[1], source, target: right },
104
+ ],
105
+ { step: STEP },
106
+ );
107
+ expect(assigned).toEqual([{}, {}]);
108
+ });
109
+
110
+ test('free ports are placed around a fixed one in key order', ({ expect }) => {
111
+ const below: Rect = { x: 384, y: 384, w: 192, h: 96 };
112
+ const center = source.x + source.w / 2;
113
+ const straight = [
114
+ { x: center, y: source.y + source.h },
115
+ { x: center, y: below.y },
116
+ ];
117
+ // Both turning edges start left of the straight one, though one of them heads right.
118
+ const turningRight = [
119
+ { x: center - STEP, y: 96 },
120
+ { x: center - STEP, y: 160 },
121
+ { x: right.x + right.w / 2, y: 160 },
122
+ { x: right.x + right.w / 2, y: right.y },
123
+ ];
124
+ const turningLeft = [
125
+ { x: center - STEP * 2, y: 96 },
126
+ { x: center - STEP * 2, y: 192 },
127
+ { x: left.x + left.w / 2, y: 192 },
128
+ { x: left.x + left.w / 2, y: left.y },
129
+ ];
130
+ const [first, second, third] = assign(
131
+ [
132
+ { points: straight, source, target: below, fixed: { start: true, end: true } },
133
+ { points: turningRight, source, target: right },
134
+ { points: turningLeft, source, target: left },
135
+ ],
136
+ { step: STEP },
137
+ );
138
+ expect(first).toEqual({});
139
+ expect(second.start).toBeGreaterThanOrEqual(center + STEP);
140
+ expect(third.start).toBeLessThanOrEqual(center - STEP);
141
+ });
142
+
143
+ test('keys order turning routes so neither turn-off cuts the other', ({ expect }) => {
144
+ const out = (run: number, towards: number) => [
145
+ { x: 0, y: 0 },
146
+ { x: 0, y: run },
147
+ { x: towards, y: run },
148
+ ];
149
+ // Of two routes turning to the high end, the one running out farther belongs nearer the low end.
150
+ expect(keyOf(out(64, 100), 'bottom')).toBeLessThan(keyOf(out(32, 100), 'bottom'));
151
+ // Of two turning to the low end, the one turning sooner belongs nearer the low end.
152
+ expect(keyOf(out(32, -100), 'bottom')).toBeLessThan(keyOf(out(64, -100), 'bottom'));
153
+ // A route that does not turn sits between the two fans.
154
+ expect(keyOf(out(64, -100), 'bottom')).toBeLessThan(keyOf(out(64, 0).slice(0, 2), 'bottom'));
155
+ expect(keyOf(out(64, 0).slice(0, 2), 'bottom')).toBeLessThan(keyOf(out(64, 100), 'bottom'));
156
+ });
157
+ });