@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
@@ -12,7 +12,7 @@
12
12
  import * as Schema from 'effect/Schema';
13
13
 
14
14
  import * as Layout from './layout.ts';
15
- import type * as Scene from './scene.ts';
15
+ import * as Scene from './scene.ts';
16
16
 
17
17
  export const Severity = Schema.Literals(['error', 'warning']);
18
18
  export type Severity = Schema.Schema.Type<typeof Severity>;
@@ -28,6 +28,8 @@ export const Code = Schema.Literals([
28
28
  'label-overflow',
29
29
  'edge-crossing',
30
30
  'excessive-bends',
31
+ 'text-overlap',
32
+ 'edge-overlap',
31
33
  ]);
32
34
  export type Code = Schema.Schema.Type<typeof Code>;
33
35
 
@@ -37,6 +39,8 @@ export const SEVERITY: Record<Code, Severity> = {
37
39
  'label-overflow': 'error',
38
40
  'edge-crossing': 'warning',
39
41
  'excessive-bends': 'warning',
42
+ 'text-overlap': 'warning',
43
+ 'edge-overlap': 'warning',
40
44
  };
41
45
 
42
46
  /** A schema so operations can return diagnostics to the agent — the repair loop. */
@@ -53,10 +57,15 @@ export type Diagnostic = Schema.Schema.Type<typeof Diagnostic>;
53
57
  /** Aggregate counts; the soft ones are the score a layout change is reviewed against. */
54
58
  export type Metrics = {
55
59
  nodes: number;
60
+ /** Connectors as a reader traces them, source to target: a bus counts once per spoke. */
56
61
  connectors: number;
57
62
  overlaps: number;
58
63
  routesThroughNodes: number;
59
64
  labelOverflows: number;
65
+ /** Free text (edge and frame labels) overlapping another label or a box it does not belong to. */
66
+ textOverlaps: number;
67
+ /** Connector pairs that run along the same line for part of their length, so a reader cannot tell them apart. */
68
+ edgeOverlaps: number;
60
69
  crossings: number;
61
70
  bends: number;
62
71
  /** Total connector length in scene units; long edges are hard to follow. */
@@ -114,36 +123,157 @@ const nodes = (objects: readonly Scene.WorldObject[]): Node[] =>
114
123
  }),
115
124
  );
116
125
 
117
- type Connector = { ref: string; points: Point[] };
126
+ /** Length two axis-aligned segments share when they lie on the same line; 0 when they only cross or touch. */
127
+ const sharedRun = ([a, b]: Segment, [c, d]: Segment): number => {
128
+ const overlap = (lo1: number, hi1: number, lo2: number, hi2: number) =>
129
+ Math.max(0, Math.min(Math.max(lo1, hi1), Math.max(lo2, hi2)) - Math.max(Math.min(lo1, hi1), Math.min(lo2, hi2)));
130
+ const horizontal = (p: Point, q: Point) => Math.abs(p.y - q.y) < EPSILON;
131
+ const vertical = (p: Point, q: Point) => Math.abs(p.x - q.x) < EPSILON;
132
+ if (horizontal(a, b) && horizontal(c, d) && Math.abs(a.y - c.y) < EPSILON) {
133
+ return overlap(a.x, b.x, c.x, d.x);
134
+ }
135
+ if (vertical(a, b) && vertical(c, d) && Math.abs(a.x - c.x) < EPSILON) {
136
+ return overlap(a.y, b.y, c.y, d.y);
137
+ }
138
+ return 0;
139
+ };
140
+
141
+ export type Label = { ref: string; rect: Rect };
142
+
143
+ /**
144
+ * Absolute extents of free text elements, estimated from the font metrics as the renderers draw it:
145
+ * `x, y` is the top-left, one line per `\n`.
146
+ */
147
+ export const labels = (objects: readonly Scene.WorldObject[]): Label[] =>
148
+ objects.flatMap((object) =>
149
+ object.elements.flatMap((element) => {
150
+ if (element.kind !== 'text') {
151
+ return [];
152
+ }
153
+ const font = Layout.FONT_METRICS[element.weight ?? 's'];
154
+ const scale = object.scale ?? 1;
155
+ const lines = element.text.split('\n');
156
+ const origin = place(object, { x: element.x, y: element.y });
157
+ return [
158
+ {
159
+ ref: `${object.id}/${element.id}`,
160
+ rect: {
161
+ x: origin.x,
162
+ y: origin.y,
163
+ w: Math.max(...lines.map((line) => line.length)) * font.charW * scale,
164
+ h: lines.length * font.lineH * scale,
165
+ },
166
+ },
167
+ ];
168
+ }),
169
+ );
170
+
171
+ /** Absolute rects of every shape an arrow can bind to, keyed `objectId/elementId` as `Scene.resolveRef` spells them. */
172
+ export const bindTargets = (objects: readonly Scene.WorldObject[]): Map<string, Rect> => {
173
+ const targets = new Map<string, Rect>();
174
+ for (const object of objects) {
175
+ const scale = object.scale ?? 1;
176
+ for (const element of object.elements) {
177
+ if (isBox(element) || element.kind === 'portal') {
178
+ const origin = place(object, { x: element.x, y: element.y });
179
+ targets.set(`${object.id}/${element.id}`, {
180
+ x: origin.x,
181
+ y: origin.y,
182
+ w: element.w * scale,
183
+ h: element.h * scale,
184
+ });
185
+ }
186
+ }
187
+ }
188
+ return targets;
189
+ };
190
+
191
+ const centerOf = ({ x, y, w, h }: Rect): Point => ({ x: x + w / 2, y: y + h / 2 });
192
+
193
+ /** Point where the segment from a rect's centre toward `target` crosses its border. */
194
+ const clipToBorder = (rect: Rect, target: Point): Point => {
195
+ const source = centerOf(rect);
196
+ const dx = target.x - source.x;
197
+ const dy = target.y - source.y;
198
+ let along = 1;
199
+ if (dx !== 0) {
200
+ along = Math.min(along, ((dx > 0 ? rect.x + rect.w : rect.x) - source.x) / dx);
201
+ }
202
+ if (dy !== 0) {
203
+ along = Math.min(along, ((dy > 0 ? rect.y + rect.h : rect.y) - source.y) / dy);
204
+ }
205
+ return { x: source.x + dx * along, y: source.y + dy * along };
206
+ };
207
+
208
+ /** How far a self-loop stands off its shape. */
209
+ const LOOP = 24;
210
+
211
+ /**
212
+ * The polyline drawn for an arrow, in absolute coordinates: a bound end sits where the line between the
213
+ * two centres leaves its shape, and an arrow bound to one shape at both ends loops off its top-right
214
+ * corner. Undefined when an end resolves to nothing. Renderers draw through this so the picture and the
215
+ * measurement cannot disagree.
216
+ */
217
+ export const arrowPoints = (
218
+ object: Scene.WorldObject,
219
+ arrow: Scene.Arrow,
220
+ targets: ReadonlyMap<string, Rect>,
221
+ ): Point[] | undefined => {
222
+ const resolve = (ref?: string) => (ref === undefined ? undefined : targets.get(Scene.resolveRef(ref, object.id)));
223
+ const [from, to] = [resolve(arrow.from), resolve(arrow.to)];
224
+ if (from && from === to) {
225
+ const [right, top] = [from.x + from.w, from.y];
226
+ return [
227
+ { x: from.x + from.w * 0.75, y: top },
228
+ { x: from.x + from.w * 0.75, y: top - LOOP },
229
+ { x: right + LOOP, y: top - LOOP },
230
+ { x: right + LOOP, y: top + from.h * 0.25 },
231
+ { x: right, y: top + from.h * 0.25 },
232
+ ];
233
+ }
234
+ const explicitStart = arrow.start && place(object, arrow.start);
235
+ const explicitEnd = arrow.end && place(object, arrow.end);
236
+ const start = from
237
+ ? clipToBorder(from, to ? centerOf(to) : (explicitEnd ?? place(object, { x: 0, y: 0 })))
238
+ : explicitStart;
239
+ const end = to
240
+ ? clipToBorder(to, from ? centerOf(from) : (explicitStart ?? place(object, { x: 0, y: 0 })))
241
+ : explicitEnd;
242
+ return start && end ? [start, end] : undefined;
243
+ };
244
+
245
+ /** A drawn connector piece; `headed` when it ends in an arrow element rather than a bare line. */
246
+ type Connector = { ref: string; points: Point[]; headed: boolean };
118
247
 
119
248
  /**
120
249
  * Connector paths, rejoining the `line` prefix and `arrow` head the emitters split a routed edge
121
- * into (`<id>-path` + `<id>`); a plain arrow is a one-segment path.
250
+ * into (`<id>-path` + `<id>`); a plain arrow is a one-segment path, a bound one drawn as `arrowPoints`.
122
251
  */
123
- const connectors = (objects: readonly Scene.WorldObject[]): Connector[] => {
252
+ const connectors = (objects: readonly Scene.WorldObject[], targets: ReadonlyMap<string, Rect>): Connector[] => {
124
253
  const paths = new Map<string, Connector>();
125
254
  for (const object of objects) {
126
255
  for (const element of object.elements) {
127
256
  if (element.kind === 'line') {
128
257
  const id = element.id.replace(/-path$/, '');
129
258
  const ref = `${object.id}/${id}`;
130
- paths.set(ref, { ref, points: element.points.map((point) => place(object, point)) });
259
+ paths.set(ref, { ref, points: element.points.map((point) => place(object, point)), headed: false });
131
260
  }
132
261
  }
133
262
  for (const element of object.elements) {
134
- if (element.kind === 'arrow' && element.start && element.end) {
263
+ const head = element.kind === 'arrow' ? arrowPoints(object, element, targets) : undefined;
264
+ if (head) {
135
265
  const ref = `${object.id}/${element.id}`;
136
- const head = [place(object, element.start), place(object, element.end)];
137
266
  const existing = paths.get(ref);
138
267
  // The head's start repeats the polyline's last waypoint; keep one copy.
139
- paths.set(ref, { ref, points: existing ? [...existing.points.slice(0, -1), ...head] : head });
268
+ paths.set(ref, { ref, points: existing ? [...existing.points.slice(0, -1), ...head] : head, headed: true });
140
269
  }
141
270
  }
142
271
  }
143
- return [...paths.values()];
272
+ // An empty path has no ends to bus or trace, and every reader of a connector assumes it has some.
273
+ return [...paths.values()].filter(({ points }) => points.length > 0);
144
274
  };
145
275
 
146
- const segmentsOf = ({ points }: Connector): Segment[] =>
276
+ const segmentsOf = ({ points }: { points: readonly Point[] }): Segment[] =>
147
277
  points.slice(0, -1).map((point, index): Segment => [point, points[index + 1]]);
148
278
 
149
279
  const orientation = (origin: Point, a: Point, b: Point) =>
@@ -151,16 +281,188 @@ const orientation = (origin: Point, a: Point, b: Point) =>
151
281
 
152
282
  const coincident = (a: Point, b: Point) => Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON;
153
283
 
154
- /** Proper crossing: the segments intersect away from all four endpoints (shared ports do not count). */
284
+ /**
285
+ * Proper crossing: each segment has the other's ends strictly on opposite sides, so a shared port or a
286
+ * T where one connector ends on another is not a crossing.
287
+ */
155
288
  const properlyCrosses = ([a1, a2]: Segment, [b1, b2]: Segment): boolean => {
156
289
  if ([a1, a2].some((a) => [b1, b2].some((b) => coincident(a, b)))) {
157
290
  return false;
158
291
  }
159
- const d1 = orientation(a1, a2, b1);
160
- const d2 = orientation(a1, a2, b2);
161
- const d3 = orientation(b1, b2, a1);
162
- const d4 = orientation(b1, b2, a2);
163
- return d1 > 0 !== d2 > 0 && d3 > 0 !== d4 > 0;
292
+ return orientation(a1, a2, b1) * orientation(a1, a2, b2) < 0 && orientation(b1, b2, a1) * orientation(b1, b2, a2) < 0;
293
+ };
294
+
295
+ /** Whether a point lies on a segment, within `EPSILON`. */
296
+ const onSegment = (point: Point, [start, end]: Segment): boolean => {
297
+ const [dx, dy] = [end.x - start.x, end.y - start.y];
298
+ const lengthSquared = dx * dx + dy * dy;
299
+ const along =
300
+ lengthSquared === 0
301
+ ? 0
302
+ : Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared));
303
+ return Math.hypot(point.x - start.x - along * dx, point.y - start.y - along * dy) < EPSILON;
304
+ };
305
+
306
+ const onPath = (point: Point, connector: Connector) =>
307
+ segmentsOf(connector).some((segment) => onSegment(point, segment));
308
+
309
+ /** Whether a point lies on a rect's outline: where a connector meets a node at a port. */
310
+ const onBorder = (point: Point, rect: Rect): boolean =>
311
+ point.x > rect.x - EPSILON &&
312
+ point.x < rect.x + rect.w + EPSILON &&
313
+ point.y > rect.y - EPSILON &&
314
+ point.y < rect.y + rect.h + EPSILON &&
315
+ !(
316
+ point.x > rect.x + EPSILON &&
317
+ point.x < rect.x + rect.w - EPSILON &&
318
+ point.y > rect.y + EPSILON &&
319
+ point.y < rect.y + rect.h - EPSILON
320
+ );
321
+
322
+ /** Drops repeated points and the midpoints of straight runs. */
323
+ const simplify = (points: readonly Point[]): Point[] =>
324
+ points
325
+ .filter((point, index) => index === 0 || !coincident(point, points[index - 1]))
326
+ .filter((point, index, kept) => {
327
+ if (index === 0 || index === kept.length - 1) {
328
+ return true;
329
+ }
330
+ const [previous, next] = [kept[index - 1], kept[index + 1]];
331
+ return (
332
+ Math.abs(orientation(previous, point, next)) >= EPSILON * Math.hypot(next.x - previous.x, next.y - previous.y)
333
+ );
334
+ });
335
+
336
+ /**
337
+ * Connector pieces joined at a junction in free space — a bus trunk and its spokes — as one connector: a
338
+ * piece ending on another away from any node's border, without running along it, belongs to it.
339
+ */
340
+ const busesOf = (pieces: readonly Connector[], rects: readonly Rect[]): Connector[][] => {
341
+ const parent = pieces.map((_, index) => index);
342
+ const root = (index: number): number => (parent[index] === index ? index : (parent[index] = root(parent[index])));
343
+ const free = (point: Point) => !rects.some((rect) => onBorder(point, rect));
344
+ pieces.forEach((piece, index) => {
345
+ for (const end of [piece.points[0], piece.points[piece.points.length - 1]]) {
346
+ if (!free(end)) {
347
+ continue;
348
+ }
349
+ pieces.forEach((other, otherIndex) => {
350
+ const alongside = segmentsOf(piece).some((a) => segmentsOf(other).some((b) => sharedRun(a, b) > EPSILON));
351
+ if (otherIndex !== index && onPath(end, other) && !alongside) {
352
+ parent[root(index)] = root(otherIndex);
353
+ }
354
+ });
355
+ }
356
+ });
357
+ const groups = new Map<number, Connector[]>();
358
+ pieces.forEach((piece, index) => groups.set(root(index), [...(groups.get(root(index)) ?? []), piece]));
359
+ return [...groups.values()];
360
+ };
361
+
362
+ /** Shortest walk between two points along a bus's pieces. */
363
+ const walk = (pieces: readonly Connector[], from: Point, to: Point): Point[] | undefined => {
364
+ const vertices: Point[] = [];
365
+ const indexOf = (point: Point) => {
366
+ const found = vertices.findIndex((vertex) => coincident(vertex, point));
367
+ return found >= 0 ? found : vertices.push(point) - 1;
368
+ };
369
+ pieces.forEach(({ points }) => points.forEach(indexOf));
370
+ const links = vertices.map((): { to: number; length: number }[] => []);
371
+ for (const segment of pieces.flatMap(segmentsOf)) {
372
+ const [start] = segment;
373
+ const stops = vertices
374
+ .map((vertex, index) => ({ index, distance: Math.hypot(vertex.x - start.x, vertex.y - start.y) }))
375
+ .filter(({ index }) => onSegment(vertices[index], segment))
376
+ .sort((left, right) => left.distance - right.distance);
377
+ stops.slice(1).forEach((stop, index) => {
378
+ const length = stop.distance - stops[index].distance;
379
+ links[stop.index].push({ to: stops[index].index, length });
380
+ links[stops[index].index].push({ to: stop.index, length });
381
+ });
382
+ }
383
+ const [source, target] = [indexOf(from), indexOf(to)];
384
+ const distance = vertices.map((_, index) => (index === source ? 0 : Infinity));
385
+ const previous = vertices.map((): number | undefined => undefined);
386
+ const open = new Set(vertices.map((_, index) => index));
387
+ while (open.size > 0) {
388
+ const current = [...open].reduce((best, index) => (distance[index] < distance[best] ? index : best));
389
+ open.delete(current);
390
+ if (current === target || distance[current] === Infinity) {
391
+ break;
392
+ }
393
+ for (const link of links[current]) {
394
+ if (distance[current] + link.length < distance[link.to]) {
395
+ distance[link.to] = distance[current] + link.length;
396
+ previous[link.to] = current;
397
+ }
398
+ }
399
+ }
400
+ if (distance[target] === Infinity) {
401
+ return undefined;
402
+ }
403
+ const path = [target];
404
+ for (let step = previous[target]; step !== undefined; step = previous[step]) {
405
+ path.unshift(step);
406
+ }
407
+ return path.map((index) => vertices[index]);
408
+ };
409
+
410
+ /** A connector as a reader traces it, from one shape to another; `ref` names the piece at its far end. */
411
+ export type Route = {
412
+ ref: string;
413
+ points: Point[];
414
+ /** Turns a reader follows; a turn where pieces of a bus meet is the bus's shape, not a bend. */
415
+ bends: number;
416
+ };
417
+
418
+ /**
419
+ * The routes of one connector. A bus with a single arrowhead (spokes gathering into a headed trunk) or a
420
+ * single bare end (a trunk fanning out to headed spokes) runs hub to spoke; anything else is its pieces.
421
+ */
422
+ const routesOf = (bus: readonly Connector[]): Route[] => {
423
+ if (bus.length === 1) {
424
+ const points = simplify(bus[0].points);
425
+ return [{ ref: bus[0].ref, points, bends: bendCount({ points }) }];
426
+ }
427
+ const ends = bus.flatMap((piece) => [
428
+ { piece, point: piece.points[0], headed: false },
429
+ { piece, point: piece.points[piece.points.length - 1], headed: piece.headed },
430
+ ]);
431
+ const meets = (end: (typeof ends)[number]) => bus.some((other) => other !== end.piece && onPath(end.point, other));
432
+ const junctions = ends.filter(meets).map(({ point }) => point);
433
+ const traced = (ref: string, points: Point[]): Route => {
434
+ const corners = simplify(points);
435
+ const atJunction = corners
436
+ .slice(1, -1)
437
+ .filter((corner, index) => bendCount({ points: corners.slice(index, index + 3) }) > 0)
438
+ .filter((corner) => junctions.some((junction) => coincident(junction, corner))).length;
439
+ return { ref, points: corners, bends: bendCount({ points: corners }) - atJunction };
440
+ };
441
+ const terminals = ends.filter((end) => !meets(end));
442
+ const heads = terminals.filter(({ headed }) => headed);
443
+ const tails = terminals.filter(({ headed }) => !headed);
444
+ const [hub, leaves, inward] =
445
+ heads.length === 1
446
+ ? [heads[0], tails, true]
447
+ : tails.length === 1
448
+ ? [tails[0], heads, false]
449
+ : [undefined, [], false];
450
+ if (!hub) {
451
+ return bus.map(({ ref, points }) => traced(ref, points));
452
+ }
453
+ return leaves.map((leaf) => {
454
+ const points = inward ? walk(bus, leaf.point, hub.point) : walk(bus, hub.point, leaf.point);
455
+ return traced(leaf.piece.ref, points ?? leaf.piece.points);
456
+ });
457
+ };
458
+
459
+ /** Every connector's routes, as a reader traces them; a bus contributes one per spoke. */
460
+ export const routes = (objects: readonly Scene.WorldObject[]): Route[] => {
461
+ const targets = bindTargets(objects);
462
+ return busesOf(
463
+ connectors(objects, targets),
464
+ nodes(objects).map(({ rect }) => rect),
465
+ ).flatMap(routesOf);
164
466
  };
165
467
 
166
468
  /** Overlap area of two rects; 0 when they merely touch. */
@@ -219,7 +521,7 @@ const interiorRun = ([start, end]: Segment, rect: Rect): number => {
219
521
  };
220
522
 
221
523
  /** Bends in an orthogonal path: waypoints where the run changes axis. */
222
- const bendCount = ({ points }: Connector): number => {
524
+ const bendCount = ({ points }: { points: readonly Point[] }): number => {
223
525
  let bends = 0;
224
526
  for (let index = 1; index < points.length - 1; index++) {
225
527
  const [previous, point, next] = [points[index - 1], points[index], points[index + 1]];
@@ -233,21 +535,91 @@ const bendCount = ({ points }: Connector): number => {
233
535
  return bends;
234
536
  };
235
537
 
538
+ /** Box label type as the SVG renderer sets it, per weight: font size and line advance in scene units. */
539
+ export const LABEL_TYPE: Record<Scene.Weight, { size: number; lineH: number }> = {
540
+ s: { size: 13, lineH: 20 },
541
+ m: { size: 18, lineH: 26 },
542
+ l: { size: 27, lineH: 38 },
543
+ xl: { size: 34, lineH: 48 },
544
+ };
545
+
546
+ /** Average glyph advance over font size for a UI sans, for wrap estimates. */
547
+ export const CHAR_EM = 0.6;
548
+
549
+ /** Room kept between a box label and the box's sides, and its top and bottom. */
550
+ export const LABEL_INSET = { x: 12, y: 4 };
551
+
552
+ /** Font scales a label tries, largest first, before it is ellipsized at the last. */
553
+ const LABEL_SCALES = [1, 0.9, 0.8];
554
+
555
+ /** Breaks inside a word too long for a line: before a capital that follows a lower-case letter or digit, after punctuation. */
556
+ const WORD_BREAK = /(?<=[a-z0-9])(?=[A-Z])|(?<=[-_/.:])/;
557
+
558
+ /** Greedy wrap at spaces; a word longer than a line breaks at camel case or punctuation, then anywhere. */
559
+ const wrapLabel = (text: string, perLine: number): string[] =>
560
+ text.split('\n').flatMap((line) => {
561
+ const tokens = line
562
+ .split(' ')
563
+ .flatMap((word, wordIndex) =>
564
+ (word.length <= perLine ? [word] : word.split(WORD_BREAK))
565
+ .flatMap((part) =>
566
+ part.length <= perLine
567
+ ? [part]
568
+ : Array.from({ length: Math.ceil(part.length / perLine) }, (_, index) =>
569
+ part.slice(index * perLine, (index + 1) * perLine),
570
+ ),
571
+ )
572
+ .map((part, partIndex) => ({ text: part, glue: wordIndex > 0 && partIndex === 0 ? ' ' : '' })),
573
+ );
574
+ const lines: string[] = [];
575
+ let current = '';
576
+ for (const { text: part, glue } of tokens) {
577
+ const candidate = current ? `${current}${glue}${part}` : part;
578
+ if (candidate.length > perLine && current) {
579
+ lines.push(current);
580
+ current = part;
581
+ } else {
582
+ current = candidate;
583
+ }
584
+ }
585
+ return [...lines, current];
586
+ });
587
+
588
+ export type LabelLayout = {
589
+ lines: string[];
590
+ size: number;
591
+ lineH: number;
592
+ /** The label does not show whole: it was ellipsized, or even one line is taller than the box. */
593
+ overflow: boolean;
594
+ };
595
+
236
596
  /**
237
- * Estimated bounds of a wrapped label, using the shared font metrics the dialects size boxes by.
238
- * The last line carries no trailing leading: `lineH ≈ 1.35em` (see `FONT_METRICS`), so the glyph
239
- * block is `(lines − 1) · lineH + em`.
597
+ * How the SVG renderer sets a box label: wrapped to the box width, shrunk a little if it still does not
598
+ * fit the height, and ellipsized at the smallest size, so text never spills out of its box.
240
599
  */
241
- const labelFits = ({ rect, text, weight }: Node): boolean => {
242
- if (!text) {
243
- return true;
600
+ export const layoutLabel = (text: string, weight: Scene.Weight, { w, h }: { w: number; h: number }): LabelLayout => {
601
+ const room = { w: Math.max(0, w - LABEL_INSET.x * 2), h: Math.max(0, h - LABEL_INSET.y * 2) };
602
+ const attempts = LABEL_SCALES.map((scale) => {
603
+ const [size, lineH] = [LABEL_TYPE[weight].size * scale, LABEL_TYPE[weight].lineH * scale];
604
+ const perLine = Math.max(1, Math.floor(room.w / (size * CHAR_EM)));
605
+ const maxLines = Math.max(1, Math.floor((room.h - size) / lineH) + 1);
606
+ return { size, lineH, perLine, maxLines, lines: wrapLabel(text, perLine) };
607
+ });
608
+ const fitting = attempts.find(({ size, lines, maxLines }) => lines.length <= maxLines && size <= room.h + EPSILON);
609
+ if (fitting) {
610
+ return { lines: fitting.lines, size: fitting.size, lineH: fitting.lineH, overflow: false };
244
611
  }
245
- const font = Layout.FONT_METRICS[weight];
246
- const perLine = Math.max(1, Math.floor(rect.w / font.charW));
247
- const lines = text.split('\n').reduce((total, line) => total + Math.max(1, Math.ceil(line.length / perLine)), 0);
248
- return (lines - 1) * font.lineH + font.lineH / 1.35 <= rect.h + EPSILON;
612
+ const { size, lineH, perLine, maxLines, lines } = attempts[attempts.length - 1];
613
+ if (lines.length <= maxLines) {
614
+ return { lines, size, lineH, overflow: true };
615
+ }
616
+ const kept = lines.slice(0, maxLines);
617
+ kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, Math.max(0, perLine - 1)).trimEnd()}…`;
618
+ return { lines: kept, size, lineH, overflow: true };
249
619
  };
250
620
 
621
+ const labelFits = ({ rect, text, weight }: Node): boolean => !text || !layoutLabel(text, weight, rect).overflow;
622
+
251
623
  /**
252
624
  * Analyzes an emitted scene for layout defects and quality metrics. Pure: the same scene always
253
625
  * yields the same report, so the soft metrics can be golden-filed and the hard codes asserted.
@@ -255,7 +627,12 @@ const labelFits = ({ rect, text, weight }: Node): boolean => {
255
627
  export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }: Options = {}): Report => {
256
628
  const diagnostics: Diagnostic[] = [];
257
629
  const allNodes = nodes(objects);
258
- const allConnectors = connectors(objects);
630
+ const allConnectors = connectors(objects, bindTargets(objects));
631
+ const buses = busesOf(
632
+ allConnectors,
633
+ allNodes.map(({ rect }) => rect),
634
+ );
635
+ const allRoutes = buses.flatMap(routesOf);
259
636
  const ownerOf = (ref: string) => ref.slice(0, ref.indexOf('/'));
260
637
 
261
638
  // A box enclosing another object's box is a container (subgraph or group frame): connectors
@@ -319,12 +696,22 @@ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }:
319
696
  }
320
697
  }
321
698
 
699
+ // Pieces of one bus meet by design, so crossings and shared runs are counted between buses.
700
+ const firstPair = (
701
+ left: readonly Connector[],
702
+ right: readonly Connector[],
703
+ test: (a: Segment, b: Segment) => boolean,
704
+ ) =>
705
+ left
706
+ .flatMap((a) => right.map((b) => [a, b] as const))
707
+ .find(([a, b]) => segmentsOf(a).some((first) => segmentsOf(b).some((second) => test(first, second))));
708
+
322
709
  let crossings = 0;
323
- for (let i = 0; i < allConnectors.length; i++) {
324
- for (let j = i + 1; j < allConnectors.length; j++) {
325
- const [left, right] = [allConnectors[i], allConnectors[j]];
326
- const crossed = segmentsOf(left).some((a) => segmentsOf(right).some((b) => properlyCrosses(a, b)));
710
+ for (let i = 0; i < buses.length; i++) {
711
+ for (let j = i + 1; j < buses.length; j++) {
712
+ const crossed = firstPair(buses[i], buses[j], properlyCrosses);
327
713
  if (crossed) {
714
+ const [left, right] = crossed;
328
715
  crossings++;
329
716
  diagnostics.push({
330
717
  code: 'edge-crossing',
@@ -336,18 +723,73 @@ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }:
336
723
  }
337
724
  }
338
725
 
726
+ let edgeOverlaps = 0;
727
+ for (let i = 0; i < buses.length; i++) {
728
+ for (let j = i + 1; j < buses.length; j++) {
729
+ const pairs = buses[i].flatMap((left) => buses[j].map((right) => [left, right] as const));
730
+ const runs = pairs.map(([left, right]) =>
731
+ segmentsOf(left).reduce(
732
+ (total, a) => total + segmentsOf(right).reduce((sum, b) => sum + sharedRun(a, b), 0),
733
+ 0,
734
+ ),
735
+ );
736
+ const run = runs.reduce((total, value) => total + value, 0);
737
+ if (run > EPSILON) {
738
+ const [left, right] = pairs[runs.findIndex((value) => value > 0)];
739
+ edgeOverlaps++;
740
+ diagnostics.push({
741
+ code: 'edge-overlap',
742
+ severity: SEVERITY['edge-overlap'],
743
+ message: `Connectors "${left.ref}" and "${right.ref}" run along the same line for ${Math.round(run)} units.`,
744
+ refs: [left.ref, right.ref],
745
+ });
746
+ }
747
+ }
748
+ }
749
+
750
+ // Text is compared with other text and with boxes outside its own object; a frame's label sits in
751
+ // the frame's band by design, and a container is a backdrop, not an obstacle.
752
+ const allLabels = labels(objects);
753
+ let textOverlaps = 0;
754
+ const overlapText = (ref: string, other: string) => {
755
+ textOverlaps++;
756
+ diagnostics.push({
757
+ code: 'text-overlap',
758
+ severity: SEVERITY['text-overlap'],
759
+ message: `Label "${ref}" overlaps "${other}".`,
760
+ refs: [ref, other],
761
+ });
762
+ };
763
+ for (let i = 0; i < allLabels.length; i++) {
764
+ for (let j = i + 1; j < allLabels.length; j++) {
765
+ if (overlapArea(allLabels[i].rect, allLabels[j].rect) > EPSILON) {
766
+ overlapText(allLabels[i].ref, allLabels[j].ref);
767
+ }
768
+ }
769
+ for (const node of allNodes) {
770
+ if (ownerOf(node.ref) !== ownerOf(allLabels[i].ref) && !containers.has(node.ref)) {
771
+ if (overlapArea(allLabels[i].rect, node.rect) > EPSILON) {
772
+ overlapText(allLabels[i].ref, node.ref);
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ // Each drawn corner counts once; the bend limit applies to a route as a reader follows it, trunk and spoke together.
339
779
  let bends = 0;
340
780
  let length = 0;
341
781
  for (const connector of allConnectors) {
342
782
  length += segmentsOf(connector).reduce((total, [a, b]) => total + Math.hypot(b.x - a.x, b.y - a.y), 0);
343
- const count = bendCount(connector);
344
- bends += count;
783
+ bends += bendCount(connector);
784
+ }
785
+ for (const route of allRoutes) {
786
+ const count = route.bends;
345
787
  if (count > maxBends) {
346
788
  diagnostics.push({
347
789
  code: 'excessive-bends',
348
790
  severity: SEVERITY['excessive-bends'],
349
- message: `Connector "${connector.ref}" takes ${count} bends.`,
350
- refs: [connector.ref],
791
+ message: `Connector "${route.ref}" takes ${count} bends.`,
792
+ refs: [route.ref],
351
793
  });
352
794
  }
353
795
  }
@@ -372,10 +814,12 @@ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }:
372
814
  diagnostics,
373
815
  metrics: {
374
816
  nodes: allNodes.length,
375
- connectors: allConnectors.length,
817
+ connectors: allRoutes.length,
376
818
  overlaps: diagnostics.filter(({ code }) => code === 'node-overlap').length,
377
819
  routesThroughNodes: diagnostics.filter(({ code }) => code === 'route-through-node').length,
378
820
  labelOverflows: diagnostics.filter(({ code }) => code === 'label-overflow').length,
821
+ textOverlaps,
822
+ edgeOverlaps,
379
823
  crossings,
380
824
  bends,
381
825
  length,