@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/view.ts ADDED
@@ -0,0 +1,376 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Text views of a laid-out scene, for a judge that reads text but cannot see images: a coordinate
7
+ // listing and an ASCII rasterization. Both are derived from the emitted scene alone, like
8
+ // `Diagnostics`, so they show what the renderer draws rather than what the source declared.
9
+ //
10
+
11
+ import { analyze, arrowPoints, bindTargets } from './diagnostics.ts';
12
+ import type * as Scene from './scene.ts';
13
+
14
+ type Point = Scene.Point;
15
+ type Rect = { x: number; y: number; w: number; h: number };
16
+
17
+ export type Box = { ref: string; label?: string; rect: Rect; frame: boolean };
18
+ export type Path = {
19
+ ref: string;
20
+ points: Point[];
21
+ label?: string;
22
+ from?: Box;
23
+ to?: Box;
24
+ /** The arrow's end markers and line style, when not a plain solid arrow. */
25
+ style?: Pick<Scene.Arrow, 'head' | 'tail' | 'stroke'>;
26
+ };
27
+
28
+ export type Drawing = { boxes: Box[]; paths: Path[]; bounds: Rect };
29
+
30
+ const place = (object: Scene.WorldObject, point: Point): Point => {
31
+ const { x = 0, y = 0 } = object.origin ?? {};
32
+ const scale = object.scale ?? 1;
33
+ return { x: x + point.x * scale, y: y + point.y * scale };
34
+ };
35
+
36
+ const contains = (outer: Rect, inner: Rect) =>
37
+ outer.x <= inner.x &&
38
+ outer.y <= inner.y &&
39
+ outer.x + outer.w >= inner.x + inner.w &&
40
+ outer.y + outer.h >= inner.y + inner.h &&
41
+ (outer.w > inner.w || outer.h > inner.h);
42
+
43
+ const distance = ({ x, y }: Point, rect: Rect) =>
44
+ Math.hypot(Math.max(rect.x - x, 0, x - rect.x - rect.w), Math.max(rect.y - y, 0, y - rect.y - rect.h));
45
+
46
+ /** Boxes, frames and connector paths in absolute coordinates, each path resolved to the boxes at its ends. */
47
+ export const extract = (objects: readonly Scene.WorldObject[]): Drawing => {
48
+ const boxes: Box[] = [];
49
+ const lines = new Map<string, Path>();
50
+ const labels = new Map<string, string>();
51
+ const targets = bindTargets(objects);
52
+ for (const object of objects) {
53
+ const scale = object.scale ?? 1;
54
+ for (const element of object.elements) {
55
+ if (element.kind === 'rect' || element.kind === 'ellipse' || element.kind === 'diamond') {
56
+ const origin = place(object, element);
57
+ boxes.push({
58
+ ref: `${object.id}/${element.id}`,
59
+ label: element.text,
60
+ rect: { x: origin.x, y: origin.y, w: element.w * scale, h: element.h * scale },
61
+ frame: false,
62
+ });
63
+ } else if (element.kind === 'line') {
64
+ // Keyed by the full scene ref, as diagnostics report it: element ids are only unique within an object.
65
+ const ref = `${object.id}/${element.id.replace(/-path$/, '')}`;
66
+ lines.set(ref, { ref, points: element.points.map((point) => place(object, point)) });
67
+ } else if (element.kind === 'text') {
68
+ labels.set(`${object.id}/${element.id.replace(/-label$/, '')}`, element.text);
69
+ }
70
+ }
71
+ for (const element of object.elements) {
72
+ const head = element.kind === 'arrow' ? arrowPoints(object, element, targets) : undefined;
73
+ if (element.kind === 'arrow' && head) {
74
+ const ref = `${object.id}/${element.id}`;
75
+ const existing = lines.get(ref);
76
+ const { head: headMarker, tail, stroke } = element;
77
+ const style = {
78
+ ...(headMarker && headMarker !== 'arrow' ? { head: headMarker } : {}),
79
+ ...(tail && tail !== 'none' ? { tail } : {}),
80
+ ...(stroke && stroke !== 'solid' ? { stroke } : {}),
81
+ };
82
+ lines.set(ref, {
83
+ ref,
84
+ points: existing ? [...existing.points.slice(0, -1), ...head] : head,
85
+ ...(Object.keys(style).length ? { style } : {}),
86
+ });
87
+ }
88
+ }
89
+ }
90
+ // A group frame is a box that encloses another box; its label is the object's free text.
91
+ const frameLabels = new Map(
92
+ objects.flatMap((object) =>
93
+ object.elements.flatMap((element) => (element.kind === 'text' ? [[object.id, element.text] as const] : [])),
94
+ ),
95
+ );
96
+ for (const box of boxes) {
97
+ if (boxes.some((other) => other !== box && contains(box.rect, other.rect))) {
98
+ box.frame = true;
99
+ box.label ??= frameLabels.get(box.ref.slice(0, box.ref.indexOf('/')));
100
+ }
101
+ }
102
+ const nodes = boxes.filter(({ frame }) => !frame);
103
+ const nearest = (point: Point) =>
104
+ nodes.reduce<Box | undefined>(
105
+ (best, box) => (!best || distance(point, box.rect) < distance(point, best.rect) ? box : best),
106
+ undefined,
107
+ );
108
+ const paths = [...lines.values()].map((path) => ({
109
+ ...path,
110
+ label: labels.get(path.ref),
111
+ from: nearest(path.points[0]),
112
+ to: nearest(path.points[path.points.length - 1]),
113
+ }));
114
+ // Connectors can route outside every box (a back edge around the diagram), so they count towards the extent.
115
+ const points = paths.flatMap(({ points }) => points);
116
+ const xs = [...boxes.flatMap(({ rect }) => [rect.x, rect.x + rect.w]), ...points.map(({ x }) => x)];
117
+ const ys = [...boxes.flatMap(({ rect }) => [rect.y, rect.y + rect.h]), ...points.map(({ y }) => y)];
118
+ const bounds = xs.length
119
+ ? {
120
+ x: Math.min(...xs),
121
+ y: Math.min(...ys),
122
+ w: Math.max(...xs) - Math.min(...xs),
123
+ h: Math.max(...ys) - Math.min(...ys),
124
+ }
125
+ : { x: 0, y: 0, w: 0, h: 0 };
126
+ return { boxes, paths, bounds };
127
+ };
128
+
129
+ const name = (box?: Box) => (box ? `"${box.label ?? box.ref}"` : '(free end)');
130
+
131
+ const groupOf = (box: Box, boxes: readonly Box[]) =>
132
+ boxes.find((other) => other.frame && contains(other.rect, box.rect));
133
+
134
+ /**
135
+ * Every box, frame and connector with its position in grid cells from the top-left corner (x right,
136
+ * y down): what a person reads off the picture as "where", in numbers.
137
+ */
138
+ export const coordinates = (objects: readonly Scene.WorldObject[], cell = 32): string => {
139
+ const { boxes, paths, bounds } = extract(objects);
140
+ const unit = (value: number) => Math.round((value / cell) * 10) / 10;
141
+ const at = ({ x, y }: Point) => `(${unit(x - bounds.x)}, ${unit(y - bounds.y)})`;
142
+ const lines = [`canvas ${unit(bounds.w)} × ${unit(bounds.h)} cells; x grows right, y grows down`];
143
+ for (const box of boxes.filter(({ frame }) => frame)) {
144
+ lines.push(`group ${name(box)}: top-left ${at(box.rect)}, size ${unit(box.rect.w)} × ${unit(box.rect.h)}`);
145
+ }
146
+ for (const box of boxes.filter(({ frame }) => !frame)) {
147
+ const group = groupOf(box, boxes);
148
+ lines.push(
149
+ `box ${name(box)}${group ? ` in ${name(group)}` : ''}: top-left ${at(box.rect)}, size ${unit(box.rect.w)} × ${unit(box.rect.h)}`,
150
+ );
151
+ }
152
+ for (const path of paths) {
153
+ lines.push(
154
+ `arrow ${name(path.from)} → ${name(path.to)}${path.label ? ` labelled "${path.label}"` : ''}: ${path.points.map(at).join(' → ')}`,
155
+ );
156
+ }
157
+ return lines.join('\n');
158
+ };
159
+
160
+ const HEADS: Record<string, string> = {
161
+ triangle: 'hollow triangle head',
162
+ crowsfoot: "crow's foot head",
163
+ none: 'no head',
164
+ };
165
+ const TAILS: Record<string, string> = { circle: 'dot at the start' };
166
+
167
+ /** A path's markers in words, e.g. "dashed, hollow triangle head", or undefined for a plain arrow. */
168
+ const describe = (style?: Path['style']) => {
169
+ const parts = [
170
+ style?.stroke,
171
+ style?.head ? HEADS[style.head] : undefined,
172
+ style?.tail ? TAILS[style.tail] : undefined,
173
+ ].filter(Boolean);
174
+ return parts.length ? parts.join(', ') : undefined;
175
+ };
176
+
177
+ /**
178
+ * The drawing in words, as a reader scans it: boxes row by row from the top, left to right, then each
179
+ * arrow with the way it runs across the page and the rows it spans.
180
+ */
181
+ export const rows = (objects: readonly Scene.WorldObject[]): string => {
182
+ const { boxes, paths } = extract(objects);
183
+ const nodes = boxes.filter(({ frame }) => !frame);
184
+ const middle = ({ rect }: Box) => ({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
185
+ // Boxes whose centres are within half a box height share a row.
186
+ const bands: Box[][] = [];
187
+ for (const box of [...nodes].sort((a, b) => middle(a).y - middle(b).y)) {
188
+ const band = bands[bands.length - 1];
189
+ if (band && middle(box).y - middle(band[0]).y < box.rect.h / 2) {
190
+ band.push(box);
191
+ } else {
192
+ bands.push([box]);
193
+ }
194
+ }
195
+ const rowOf = new Map(bands.flatMap((band, index) => band.map((box) => [box, index + 1] as const)));
196
+ const lines = bands.map((band, index) => {
197
+ const cells = [...band]
198
+ .sort((a, b) => middle(a).x - middle(b).x)
199
+ .map((box) => {
200
+ const group = groupOf(box, boxes);
201
+ return `${name(box)}${group ? ` [${group.label ?? group.ref}]` : ''}`;
202
+ });
203
+ return `row ${index + 1}${index === 0 ? ' (top)' : index === bands.length - 1 ? ' (bottom)' : ''}, left to right: ${cells.join(', ')}`;
204
+ });
205
+ for (const path of paths) {
206
+ const [start, end] = [path.points[0], path.points[path.points.length - 1]];
207
+ const [dx, dy] = [end.x - start.x, end.y - start.y];
208
+ const vertical = Math.abs(dy) >= 16 ? (dy > 0 ? 'down' : 'up') : undefined;
209
+ const horizontal = Math.abs(dx) >= 16 ? (dx > 0 ? 'right' : 'left') : undefined;
210
+ const way = [vertical, horizontal].filter(Boolean).join(' and ') || 'in place';
211
+ const span = path.from && path.to ? `, row ${rowOf.get(path.from)} to row ${rowOf.get(path.to)}` : '';
212
+ const bends = path.points.length > 2 ? `, ${path.points.length - 2} bends` : '';
213
+ const drawn = describe(path.style);
214
+ lines.push(
215
+ `arrow ${name(path.from)} → ${name(path.to)}${drawn ? ` (${drawn})` : ''}${path.label ? ` labelled "${path.label}"` : ''} runs ${way}${span}${bends}`,
216
+ );
217
+ }
218
+ // A crossing is the one thing a reader sees at a glance that no row or direction implies, so it is stated.
219
+ const byRef = new Map(paths.map((path) => [path.ref, path]));
220
+ const arrow = (ref: string) => {
221
+ const path = byRef.get(ref);
222
+ return path ? `${name(path.from)} → ${name(path.to)}` : ref;
223
+ };
224
+ const crossings = analyze(objects).diagnostics.filter(({ code }) => code === 'edge-crossing');
225
+ lines.push(
226
+ ...(crossings.length
227
+ ? crossings.map(({ refs: [left, right] }) => `crossing: arrow ${arrow(left)} crosses arrow ${arrow(right)}`)
228
+ : ['no arrows cross']),
229
+ );
230
+ return lines.join('\n');
231
+ };
232
+
233
+ export type AsciiOptions = {
234
+ /** Scene units per column; small enough that a box's label fits inside it. */
235
+ column?: number;
236
+ /** Scene units per row. */
237
+ row?: number;
238
+ };
239
+
240
+ // Line-drawing directions as bits, so a cell crossed twice combines into the right junction glyph.
241
+ const UP = 1;
242
+ const DOWN = 2;
243
+ const LEFT = 4;
244
+ const RIGHT = 8;
245
+ const GLYPHS: Record<number, string> = {
246
+ [UP | DOWN]: '│',
247
+ [LEFT | RIGHT]: '─',
248
+ [DOWN | RIGHT]: '┌',
249
+ [DOWN | LEFT]: '┐',
250
+ [UP | RIGHT]: '└',
251
+ [UP | LEFT]: '┘',
252
+ [UP | DOWN | RIGHT]: '├',
253
+ [UP | DOWN | LEFT]: '┤',
254
+ [LEFT | RIGHT | DOWN]: '┬',
255
+ [LEFT | RIGHT | UP]: '┴',
256
+ [UP | DOWN | LEFT | RIGHT]: '┼',
257
+ [UP]: '│',
258
+ [DOWN]: '│',
259
+ [LEFT]: '─',
260
+ [RIGHT]: '─',
261
+ };
262
+
263
+ /**
264
+ * The drawing rasterized onto a character grid: boxes with their labels, dashed group frames, routed
265
+ * connectors with corners and junctions, and an arrowhead where each connector ends.
266
+ */
267
+ export const ascii = (objects: readonly Scene.WorldObject[], { column = 8, row = 16 }: AsciiOptions = {}): string => {
268
+ const { boxes, paths, bounds } = extract(objects);
269
+ const col = (x: number) => Math.round((x - bounds.x) / column);
270
+ const rowOf = (y: number) => Math.round((y - bounds.y) / row);
271
+ const width = col(bounds.x + bounds.w) + 1;
272
+ const height = rowOf(bounds.y + bounds.h) + 1;
273
+ const text = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
274
+ const bits = Array.from({ length: height }, () => new Array<number>(width).fill(0));
275
+ const put = (r: number, c: number, glyph: string) => {
276
+ if (r >= 0 && r < height && c >= 0 && c < width) {
277
+ text[r][c] = glyph;
278
+ }
279
+ };
280
+ const write = (r: number, c: number, value: string) => [...value].forEach((glyph, index) => put(r, c + index, glyph));
281
+
282
+ const drawBox = (box: Box, horizontal: string, vertical: string, corners: string) => {
283
+ const [left, top, right, bottom] = [
284
+ col(box.rect.x),
285
+ rowOf(box.rect.y),
286
+ col(box.rect.x + box.rect.w),
287
+ rowOf(box.rect.y + box.rect.h),
288
+ ];
289
+ for (let c = left; c <= right; c++) {
290
+ put(top, c, horizontal);
291
+ put(bottom, c, horizontal);
292
+ }
293
+ for (let r = top; r <= bottom; r++) {
294
+ put(r, left, vertical);
295
+ put(r, right, vertical);
296
+ }
297
+ put(top, left, corners[0]);
298
+ put(top, right, corners[1]);
299
+ put(bottom, left, corners[2]);
300
+ put(bottom, right, corners[3]);
301
+ return { left, top, right, bottom };
302
+ };
303
+
304
+ for (const frame of boxes.filter(({ frame }) => frame)) {
305
+ const { left, top } = drawBox(frame, '┄', '┆', '┌┐└┘');
306
+ if (frame.label) {
307
+ write(top, left + 2, ` ${frame.label} `);
308
+ }
309
+ }
310
+
311
+ // Connectors accumulate direction bits per cell, so crossings and shared runs read as junctions.
312
+ for (const path of paths) {
313
+ for (let index = 0; index < path.points.length - 1; index++) {
314
+ const [a, b] = [path.points[index], path.points[index + 1]];
315
+ const [r0, c0, r1, c1] = [rowOf(a.y), col(a.x), rowOf(b.y), col(b.x)];
316
+ if (r0 === r1) {
317
+ const [from, to] = c0 < c1 ? [c0, c1] : [c1, c0];
318
+ for (let c = from; c <= to; c++) {
319
+ bits[r0][c] |= (c > from ? LEFT : 0) | (c < to ? RIGHT : 0);
320
+ }
321
+ } else if (c0 === c1) {
322
+ const [from, to] = r0 < r1 ? [r0, r1] : [r1, r0];
323
+ for (let r = from; r <= to; r++) {
324
+ bits[r][c0] |= (r > from ? UP : 0) | (r < to ? DOWN : 0);
325
+ }
326
+ } else {
327
+ // Diagonal runs are rare (curves); a dotted trace keeps them visible without faking junctions.
328
+ const steps = Math.max(Math.abs(r1 - r0), Math.abs(c1 - c0));
329
+ for (let step = 0; step <= steps; step++) {
330
+ put(Math.round(r0 + ((r1 - r0) * step) / steps), Math.round(c0 + ((c1 - c0) * step) / steps), '·');
331
+ }
332
+ }
333
+ }
334
+ }
335
+ for (let r = 0; r < height; r++) {
336
+ for (let c = 0; c < width; c++) {
337
+ if (bits[r][c]) {
338
+ text[r][c] = GLYPHS[bits[r][c]] ?? '┼';
339
+ }
340
+ }
341
+ }
342
+
343
+ for (const box of boxes.filter(({ frame }) => !frame)) {
344
+ const { left, top, right, bottom } = drawBox(box, '─', '│', '╭╮╰╯');
345
+ for (let r = top + 1; r < bottom; r++) {
346
+ for (let c = left + 1; c < right; c++) {
347
+ put(r, c, ' ');
348
+ }
349
+ }
350
+ const inner = right - left - 1;
351
+ const label = (box.label ?? '').slice(0, inner);
352
+ write(Math.round((top + bottom) / 2), left + 1 + Math.floor((inner - label.length) / 2), label);
353
+ }
354
+
355
+ for (const path of paths) {
356
+ const [a, b] = path.points.slice(-2);
357
+ const glyph = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) ? (b.x > a.x ? '▶' : '◀') : b.y > a.y ? '▼' : '▲';
358
+ // The head sits on the last cell outside the target box, where the line meets its border.
359
+ const [dr, dc] = glyph === '▶' ? [0, -1] : glyph === '◀' ? [0, 1] : glyph === '▼' ? [-1, 0] : [1, 0];
360
+ put(rowOf(b.y) + dr, col(b.x) + dc, glyph);
361
+ if (path.label) {
362
+ const middle = path.points[Math.floor((path.points.length - 1) / 2)];
363
+ const next = path.points[Math.floor((path.points.length - 1) / 2) + 1];
364
+ const r = rowOf((middle.y + next.y) / 2);
365
+ const c = col((middle.x + next.x) / 2);
366
+ // Beside a vertical run, above a horizontal one, so the label does not erase the line.
367
+ if (middle.x === next.x) {
368
+ write(r, c + 2, path.label);
369
+ } else {
370
+ write(r - 1, c - Math.floor(path.label.length / 2), path.label);
371
+ }
372
+ }
373
+ }
374
+
375
+ return text.map((line) => line.join('').trimEnd()).join('\n');
376
+ };