dsh-model-everything 0.5.0 → 0.5.2

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 (65) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2829 -224
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +1227 -69
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +146 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/trunk.js +213 -0
  23. package/lib/render/wrap.js +87 -0
  24. package/lib/types/render/channels.d.ts +63 -0
  25. package/lib/types/render/components.d.ts +57 -0
  26. package/lib/types/render/coords.d.ts +64 -0
  27. package/lib/types/render/cycle-break.d.ts +45 -0
  28. package/lib/types/render/dummy.d.ts +32 -0
  29. package/lib/types/render/from-plantuml.d.ts +12 -6
  30. package/lib/types/render/layer.d.ts +28 -0
  31. package/lib/types/render/layout.d.ts +43 -11
  32. package/lib/types/render/model.d.ts +17 -0
  33. package/lib/types/render/network-simplex.d.ts +54 -0
  34. package/lib/types/render/order.d.ts +27 -0
  35. package/lib/types/render/paint.d.ts +157 -10
  36. package/lib/types/render/place.d.ts +40 -19
  37. package/lib/types/render/route.d.ts +77 -0
  38. package/lib/types/render/svg.d.ts +1 -1
  39. package/lib/types/render/symbol.d.ts +73 -0
  40. package/lib/types/render/text.d.ts +38 -0
  41. package/lib/types/render/trunk.d.ts +104 -0
  42. package/lib/types/render/wrap.d.ts +27 -0
  43. package/package.json +148 -148
  44. package/skills/model-everything-diagram/SKILL.md +85 -82
  45. package/src/render/channels.ts +309 -0
  46. package/src/render/collapse.ts +150 -155
  47. package/src/render/components.ts +126 -0
  48. package/src/render/coords.ts +328 -0
  49. package/src/render/cycle-break.ts +164 -0
  50. package/src/render/dummy.ts +65 -0
  51. package/src/render/from-mermaid.ts +261 -236
  52. package/src/render/from-plantuml.ts +469 -178
  53. package/src/render/layer.ts +98 -0
  54. package/src/render/layout.ts +101 -62
  55. package/src/render/model.ts +86 -67
  56. package/src/render/network-simplex.ts +290 -0
  57. package/src/render/order.ts +139 -0
  58. package/src/render/paint.ts +1570 -290
  59. package/src/render/place.ts +532 -234
  60. package/src/render/route.ts +269 -0
  61. package/src/render/svg.ts +274 -127
  62. package/src/render/symbol.ts +137 -0
  63. package/src/render/text.ts +86 -0
  64. package/src/render/trunk.ts +275 -0
  65. package/src/render/wrap.ts +103 -0
@@ -9,53 +9,180 @@
9
9
  * Supported:
10
10
  *
11
11
  * - `@startuml` / `@enduml` (optional) and `title ...`;
12
- * - `component X`, `component "text" as X`, `[text] as X`, `[text]`;
13
- * - `package "text" { ... }`, `node`, `folder`, `cloud`, `database` - a group, closed by `}`;
14
- * - `X --> Y`, `X ---> Y`, `X ..> Y`, `X --> Y : label`, arrow either direction;
15
- * - `state X`, `state "text" as X`, and `[*] --> X` for a start marker;
12
+ * - elements: `component`, `node`, `rectangle`, `artifact` and `state` (a rectangle), `database` and
13
+ * `entity` (a cylinder), `usecase` (an ellipse), `[text]`, each with a `"text"` label and `as` alias;
14
+ * - boundaries: `package`, `folder`, `node`, `component`, `rectangle` and `artifact` blocks, closed by `}`;
15
+ * - arrows, either direction: association `-->`/`<--`, dependency `..>`/`<..`, generalisation `--|>`/`<|--`,
16
+ * realisation `..|>`/`<|..`, composition `*--`/`--*`, aggregation `o--`/`--o`, labelled after `:`;
17
+ * - an edge end that names a declared block attaches to that block's rectangle, the way an external
18
+ * dependency on a package is drawn; a name that is not a declared block is an implicit element;
19
+ * - `[*] --> X` for a start marker;
16
20
  * - `'` comments and `skinparam`/`!theme` directives.
17
21
  *
18
- * Refused, by name: `sequence`, `actor`, `participant`, `usecase`, `class`, `object`, `note`, `legend`,
19
- * `alt`/`opt`/`loop`/`par` and any other fragment keyword.
22
+ * Refused, by line and by name: the element keywords this renderer has no symbol for (`actor`, `class`,
23
+ * `cloud`, ...), the diagram kinds it does not draw (`sequence`), and an edge it cannot draw as written (a
24
+ * line with no arrow end, a mark at both ends, a dotted diamond, an edge between two boundaries, or an edge
25
+ * into a boundary that is nested or empty).
20
26
  */
21
27
  import { emptyDiagram } from './model.js';
22
- const ARROW = /^(.+?)\s*(<?)(-{2,}|\.{2,})(>?)\s*(.+?)(?:\s*:\s*(.+))?$/;
23
- const DECLARED = /^(component|package|node|folder|cloud|database|state|artifact|frame|rectangle)\s+(.+)$/;
28
+ /** The element keywords this renderer draws, and the symbol each one becomes. */
29
+ const SHAPES = {
30
+ component: 'rect',
31
+ node: 'rect',
32
+ rectangle: 'rect',
33
+ artifact: 'rect',
34
+ state: 'rect',
35
+ database: 'cylinder',
36
+ // PlantUML draws an entity as a cylinder in a component diagram; `entity` in an ER diagram is a different
37
+ // symbol, and that diagram family is not one this renderer accepts.
38
+ entity: 'cylinder',
39
+ // A use case is an ellipse. The `circle` symbol is written as an ellipse whose radii follow the box, so it
40
+ // reads as the familiar use-case oval; `stadium` is the pill a terminal state is drawn as.
41
+ usecase: 'circle',
42
+ };
43
+ /** The keywords that open a boundary around what is written inside them. */
44
+ const BOUNDARY = new Set(['package', 'folder', 'node', 'component', 'rectangle', 'artifact']);
45
+ /** Element keywords this renderer deliberately does not draw, with the reason it gives for each. */
46
+ const NOT_DRAWN = {
47
+ actor: 'an actor is a stick figure, not one of the shapes this renderer draws',
48
+ participant: 'a participant is a lifeline, not one of the shapes this renderer draws',
49
+ class: 'a class box has compartments this renderer does not draw',
50
+ object: 'an object box has compartments this renderer does not draw',
51
+ interface: 'an interface is a circle or a boxed stereotype, neither of which this renderer draws',
52
+ enum: 'an enum box has compartments this renderer does not draw',
53
+ annotation: 'an annotation is not one of the shapes this renderer draws',
54
+ cloud: 'a cloud outline is not one of the shapes this renderer draws',
55
+ frame: 'a frame tab is not one of the shapes this renderer draws',
56
+ card: 'a card is not one of the shapes this renderer draws',
57
+ file: 'a file outline is not one of the shapes this renderer draws',
58
+ agent: 'an agent is not one of the shapes this renderer draws',
59
+ boundary: 'the boundary symbol is not one of the shapes this renderer draws',
60
+ control: 'the control symbol is not one of the shapes this renderer draws',
61
+ collections: 'a collections symbol is not one of the shapes this renderer draws',
62
+ queue: 'a queue is not one of the shapes this renderer draws',
63
+ stack: 'a stack is not one of the shapes this renderer draws',
64
+ storage: 'a storage symbol is not one of the shapes this renderer draws',
65
+ note: 'a note is not one of the shapes this renderer draws',
66
+ legend: 'a legend is not one of the shapes this renderer draws',
67
+ };
68
+ /** A sequence fragment or lifeline event: lines that only make sense in a diagram this renderer refuses. */
69
+ const FRAGMENT = new Set(['sequence', 'alt', 'opt', 'loop', 'par', 'group', 'ref', 'break', 'critical', 'activate', 'deactivate', 'destroy', 'create', 'newpage', 'autonumber']);
70
+ const ARROW = /^(.+?)\s*(<\|?|[*o])?(-{2,}|\.{2,})(\|?>|[*o])?\s*(.+?)(?:\s*:\s*(.+))?$/;
71
+ const SHAPE_KEYWORDS = Object.keys(SHAPES);
72
+ const DECLARED = new RegExp('^(' + SHAPE_KEYWORDS.join('|') + ')\\s+(.+)$');
24
73
  const QUOTED_ALIAS = /^["'](.+?)["']\s+as\s+([A-Za-z_][\w-]*)$/;
25
74
  const BRACKET_ALIAS = /^\[(.+?)\]\s+as\s+([A-Za-z_][\w-]*)$/;
75
+ const PLAIN_ALIAS = /^([A-Za-z_][\w.-]*)\s+as\s+([A-Za-z_][\w-]*)$/;
26
76
  const BRACKET = /^\[(.+?)\]$/;
27
- const UNSUPPORTED = /^(actor|participant|usecase|class|object|note|legend|alt|opt|loop|par|group\s|ref\s|activate|deactivate|destroy|create|newpage)\b/;
28
- const IGNORED = /^(skinparam|!theme|!define|!include|hide|show|scale|left to right direction|top to bottom direction|together)\b/;
29
- function ensure(builder, id, label) {
77
+ const IGNORED = /^(skinparam|!theme|!define|!include|!pragma|hide|show|scale|left to right direction|top to bottom direction|together|allowmixing)\b/;
78
+ function ensure(builder, id, label, shape, implicit = false) {
30
79
  const existing = builder.nodes.get(id);
31
80
  if (existing === undefined) {
32
- builder.nodes.set(id, { id, role: 'normal', ...(label === undefined ? {} : { label }) });
81
+ builder.nodes.set(id, {
82
+ id,
83
+ role: 'normal',
84
+ ...(label === undefined ? {} : { label }),
85
+ ...(shape === undefined ? {} : { shape }),
86
+ });
87
+ if (implicit)
88
+ builder.implicit.add(id);
33
89
  // Membership is decided where the node is first written: the innermost open boundary.
34
90
  const innermost = builder.open[builder.open.length - 1];
35
91
  if (innermost !== undefined && !innermost.members.includes(id))
36
92
  innermost.members.push(id);
37
93
  return;
38
94
  }
95
+ // An explicit declaration settles what a name is: it stays a box even when a block has the same name.
96
+ if (!implicit)
97
+ builder.implicit.delete(id);
39
98
  if (label !== undefined && (existing.label === undefined || existing.label === ''))
40
99
  existing.label = label;
100
+ // A bare reference may have been written first: the later declaration is what states the symbol.
101
+ if (shape !== undefined && existing.shape === undefined)
102
+ existing.shape = shape;
41
103
  }
42
- /** A component reference: `[text]`, `X`, or a quoted name. Returns the id and the label it carried. */
104
+ /** A component reference: `[text]`, `X`, or a quoted name. Returns the id, the label and any shape it stated. */
43
105
  function reference(token) {
44
106
  const text = token.trim();
45
107
  const bracket = BRACKET.exec(text);
46
108
  if (bracket !== null) {
47
109
  const label = bracket[1].trim();
48
- return { id: label, label };
110
+ return { id: label, label, shape: 'rect' };
49
111
  }
50
112
  const quoted = /^["'](.+?)["']$/.exec(text);
51
113
  if (quoted !== null)
52
114
  return { id: quoted[1], label: quoted[1] };
53
115
  return { id: text };
54
116
  }
117
+ function keywordOf(line) {
118
+ return line.split(/[\s{]/)[0].toLowerCase();
119
+ }
120
+ /** The id and label an opening boundary line states. An alias is the id, because edges will name it. */
121
+ function boundaryOf(declaration, keyword, ordinal) {
122
+ const rest = declaration.slice(keyword.length).trim();
123
+ const alias = QUOTED_ALIAS.exec(rest) ?? BRACKET_ALIAS.exec(rest) ?? PLAIN_ALIAS.exec(rest);
124
+ if (alias !== null)
125
+ return { id: alias[2], label: alias[1] };
126
+ const label = rest.replace(/^["']|["']$/g, '');
127
+ return { id: 'group-' + String(ordinal), label: label === '' ? keyword : label };
128
+ }
129
+ /**
130
+ * Read one arrow line.
131
+ *
132
+ * Returns the edge it states, `undefined` when the line is not an arrow at all, or the reason it is refused.
133
+ * The arrow the source writes decides which end carries the mark: `A <|-- B` is B generalising to A, so the
134
+ * edge runs from B to A with the triangle at A, and `A *-- B` puts its diamond at A, so A is the source.
135
+ */
136
+ function readArrow(line) {
137
+ const match = ARROW.exec(line);
138
+ if (match === null)
139
+ return undefined;
140
+ const left = reference(match[1]);
141
+ const right = reference(match[5]);
142
+ const leftMark = match[2];
143
+ const rightMark = match[4];
144
+ const dotted = match[3].startsWith('.');
145
+ const label = (match[6] ?? '').trim();
146
+ if (leftMark !== undefined && rightMark !== undefined) {
147
+ return 'an edge marked at both ends is not one this renderer draws';
148
+ }
149
+ if (leftMark === '*' || leftMark === 'o' || rightMark === '*' || rightMark === 'o') {
150
+ if (dotted)
151
+ return 'a dotted composition or aggregation is not one this renderer draws';
152
+ const atRight = rightMark === '*' || rightMark === 'o';
153
+ const mark = atRight ? rightMark : leftMark;
154
+ return {
155
+ from: atRight ? right : left,
156
+ to: atRight ? left : right,
157
+ label,
158
+ dashed: false,
159
+ arrow: mark === '*' ? 'composition' : 'aggregation',
160
+ };
161
+ }
162
+ if (leftMark !== undefined) {
163
+ return {
164
+ from: right,
165
+ to: left,
166
+ label,
167
+ dashed: dotted,
168
+ arrow: leftMark === '<|' ? (dotted ? 'realization' : 'generalization') : dotted ? 'dependency' : 'association',
169
+ };
170
+ }
171
+ if (rightMark !== undefined) {
172
+ return {
173
+ from: left,
174
+ to: right,
175
+ label,
176
+ dashed: dotted,
177
+ arrow: rightMark === '|>' ? (dotted ? 'realization' : 'generalization') : dotted ? 'dependency' : 'association',
178
+ };
179
+ }
180
+ return 'a line with no arrow end is not one this renderer draws; name the end it points at';
181
+ }
55
182
  export function fromPlantUml(source, title = 'plantuml') {
56
183
  const lines = source.split(/\r?\n/);
57
184
  const refused = [];
58
- const builder = { nodes: new Map(), edges: [], groups: [], open: [] };
185
+ const builder = { nodes: new Map(), edges: [], edgeLines: [], edgeScopes: [], groups: [], open: [], implicit: new Set() };
59
186
  let startId;
60
187
  let seenAny = false;
61
188
  let groupCount = 0;
@@ -71,8 +198,14 @@ export function fromPlantUml(source, title = 'plantuml') {
71
198
  return;
72
199
  if (IGNORED.test(line))
73
200
  return;
74
- if (UNSUPPORTED.test(line)) {
75
- refused.push(where + '`' + (line.split(/\s/)[0] ?? '') + '` is not a diagram this renderer draws');
201
+ const keyword = keywordOf(line);
202
+ const notDrawn = NOT_DRAWN[keyword];
203
+ if (notDrawn !== undefined) {
204
+ refused.push(where + '`' + keyword + '`: ' + notDrawn);
205
+ return;
206
+ }
207
+ if (FRAGMENT.has(keyword)) {
208
+ refused.push(where + '`' + keyword + '`: a sequence statement is not part of a drawing this renderer makes');
76
209
  return;
77
210
  }
78
211
  if (line === '}') {
@@ -87,59 +220,198 @@ export function fromPlantUml(source, title = 'plantuml') {
87
220
  }
88
221
  if (line.endsWith('{')) {
89
222
  const declaration = line.slice(0, -1).trim();
90
- const declared = DECLARED.exec(declaration);
91
- const label = declared === null ? declaration.replace(/^["']|["']$/g, '') : declared[2].replace(/^["']|["']$/g, '');
223
+ const opener = keywordOf(declaration);
224
+ if (!BOUNDARY.has(opener)) {
225
+ const reason = SHAPES[opener] === undefined
226
+ ? '`' + declaration + '` is not a boundary this renderer draws'
227
+ : 'a `' + opener + '` block is not a boundary this renderer draws';
228
+ refused.push(where + reason);
229
+ return;
230
+ }
92
231
  groupCount += 1;
93
- builder.open.push({ id: 'group-' + String(groupCount), label, members: [] });
232
+ const boundary = boundaryOf(declaration, opener, groupCount);
233
+ builder.open.push({ id: boundary.id, label: boundary.label, members: [] });
234
+ return;
235
+ }
236
+ if (line === '[*] --> [*]')
237
+ return;
238
+ const startMarker = /^\[\*\]\s*-->\s*(.+)$/.exec(line);
239
+ if (startMarker !== null) {
240
+ const target = reference(startMarker[1]);
241
+ ensure(builder, target.id, target.label, target.shape);
242
+ if (startId === undefined)
243
+ startId = target.id;
94
244
  return;
95
245
  }
96
246
  const declared = DECLARED.exec(line);
97
247
  if (declared !== null) {
248
+ const shape = SHAPES[declared[1]];
98
249
  const rest = declared[2].trim();
99
250
  const quoted = QUOTED_ALIAS.exec(rest);
100
251
  if (quoted !== null) {
101
- ensure(builder, quoted[2], quoted[1]);
252
+ ensure(builder, quoted[2], quoted[1], shape);
102
253
  return;
103
254
  }
104
255
  const bracketed = BRACKET_ALIAS.exec(rest);
105
256
  if (bracketed !== null) {
106
- ensure(builder, bracketed[2], bracketed[1]);
257
+ ensure(builder, bracketed[2], bracketed[1], shape);
107
258
  return;
108
259
  }
109
- if (/^["'](.+?)["']$/.test(rest))
110
- return; // a group heading with no alias: nothing to draw
111
- const reference_ = reference(rest);
112
- ensure(builder, reference_.id, reference_.label);
113
- return;
114
- }
115
- if (line === '[*] --> [*]')
116
- return;
117
- const startMarker = /^\[\*\]\s*-->\s*(.+)$/.exec(line);
118
- if (startMarker !== null) {
119
- const target = reference(startMarker[1]);
120
- ensure(builder, target.id, target.label);
121
- if (startId === undefined)
122
- startId = target.id;
260
+ const plain = PLAIN_ALIAS.exec(rest);
261
+ if (plain !== null) {
262
+ ensure(builder, plain[2], plain[1], shape);
263
+ return;
264
+ }
265
+ const written = reference(rest);
266
+ ensure(builder, written.id, written.label, shape);
123
267
  return;
124
268
  }
125
- const arrow = ARROW.exec(line);
126
- if (arrow !== null) {
127
- const left = arrow[2] === '<' ? arrow[5] : arrow[1];
128
- const right = arrow[2] === '<' ? arrow[1] : arrow[5];
129
- const from = reference(left);
130
- const to = reference(right);
131
- ensure(builder, from.id, from.label);
132
- ensure(builder, to.id, to.label);
133
- builder.edges.push({ from: from.id, to: to.id, label: (arrow[6] ?? '').trim(), ...(arrow[3].startsWith('.') ? { dashed: true } : {}) });
269
+ const arrow = readArrow(line);
270
+ if (arrow !== undefined) {
271
+ if (typeof arrow === 'string') {
272
+ refused.push(where + arrow);
273
+ return;
274
+ }
275
+ ensure(builder, arrow.from.id, arrow.from.label, arrow.from.shape, true);
276
+ ensure(builder, arrow.to.id, arrow.to.label, arrow.to.shape, true);
277
+ builder.edges.push({
278
+ from: arrow.from.id,
279
+ to: arrow.to.id,
280
+ label: arrow.label,
281
+ ...(arrow.dashed ? { dashed: true } : {}),
282
+ arrow: arrow.arrow,
283
+ });
284
+ builder.edgeLines.push(index + 1);
285
+ builder.edgeScopes.push(builder.open[builder.open.length - 1]?.id);
134
286
  return;
135
287
  }
136
288
  const single = BRACKET.exec(line);
137
289
  if (single !== null) {
138
- ensure(builder, single[1].trim(), single[1].trim());
290
+ ensure(builder, single[1].trim(), single[1].trim(), 'rect');
139
291
  return;
140
292
  }
141
293
  refused.push(where + '`' + line + '` is not something this renderer draws');
142
294
  });
295
+ // A block's name can be written as an edge endpoint: `Web <|-- Mobile` points at the `package Web`
296
+ // block. PlantUML allows it and so does UML; what it must not do is become a second box called `Web`.
297
+ // An endpoint an arrow created is re-read as the boundary it names, and the edge attaches to that
298
+ // boundary's rectangle. A name that is not a declared block stays an implicit element, which PlantUML
299
+ // also allows.
300
+ const boundaryNames = new Map();
301
+ const disputedNames = new Set();
302
+ for (const group of builder.groups) {
303
+ for (const name of [group.id, group.label]) {
304
+ const existing = boundaryNames.get(name);
305
+ if (existing !== undefined && existing !== group.id)
306
+ disputedNames.add(name);
307
+ else
308
+ boundaryNames.set(name, group.id);
309
+ }
310
+ }
311
+ for (const name of disputedNames)
312
+ boundaryNames.delete(name);
313
+ // Only a node an arrow wrote can turn out to be a boundary. An explicitly declared element of the same
314
+ // name stays a box: the source said what it is.
315
+ const boundaryAt = new Map();
316
+ const ambiguousAt = new Set();
317
+ for (const id of builder.implicit) {
318
+ if (disputedNames.has(id)) {
319
+ ambiguousAt.add(id);
320
+ builder.nodes.delete(id);
321
+ }
322
+ else {
323
+ const groupId = boundaryNames.get(id);
324
+ if (groupId === undefined)
325
+ continue;
326
+ boundaryAt.set(id, groupId);
327
+ builder.nodes.delete(id);
328
+ }
329
+ for (const group of builder.groups) {
330
+ const at = group.members.indexOf(id);
331
+ if (at >= 0)
332
+ group.members.splice(at, 1);
333
+ }
334
+ }
335
+ const groupById = new Map(builder.groups.map((group) => [group.id, group]));
336
+ const childrenOf = new Map();
337
+ for (const group of builder.groups) {
338
+ if (group.parent === undefined)
339
+ continue;
340
+ childrenOf.set(group.parent, [...(childrenOf.get(group.parent) ?? []), group.id]);
341
+ }
342
+ // An empty boundary is not drawn, so an edge cannot attach to it. Look through nested boundaries, which
343
+ // are drawn only when something inside them is.
344
+ const drawable = new Map();
345
+ const hasContents = (id) => {
346
+ const known = drawable.get(id);
347
+ if (known !== undefined)
348
+ return known;
349
+ const group = groupById.get(id);
350
+ let found = false;
351
+ for (const member of group.members) {
352
+ if (builder.nodes.has(member)) {
353
+ found = true;
354
+ break;
355
+ }
356
+ }
357
+ if (!found) {
358
+ for (const child of childrenOf.get(id) ?? []) {
359
+ if (hasContents(child)) {
360
+ found = true;
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ drawable.set(id, found);
366
+ return found;
367
+ };
368
+ const boundaryLabel = (id) => groupById.get(id).label;
369
+ const within = (scope, boundary) => {
370
+ let cursor = scope;
371
+ while (cursor !== undefined) {
372
+ if (cursor === boundary)
373
+ return true;
374
+ cursor = groupById.get(cursor).parent;
375
+ }
376
+ return false;
377
+ };
378
+ const keptEdges = [];
379
+ builder.edges.forEach((edge, index) => {
380
+ const where = 'line ' + String(builder.edgeLines[index]) + ': ';
381
+ const fromBoundary = boundaryAt.get(edge.from);
382
+ const toBoundary = boundaryAt.get(edge.to);
383
+ if (ambiguousAt.has(edge.from) || ambiguousAt.has(edge.to)) {
384
+ const name = ambiguousAt.has(edge.from) ? edge.from : edge.to;
385
+ refused.push(where + '`' + name + '` is the name of more than one block; an edge cannot say which boundary it means');
386
+ return;
387
+ }
388
+ if (fromBoundary !== undefined && toBoundary !== undefined) {
389
+ refused.push(where + '`' + boundaryLabel(fromBoundary) + '` and `' + boundaryLabel(toBoundary) + '` are boundaries, not elements; an edge between two boundaries is not one this renderer draws');
390
+ return;
391
+ }
392
+ const boundary = fromBoundary ?? toBoundary;
393
+ if (boundary !== undefined) {
394
+ const group = groupById.get(boundary);
395
+ if (group.parent !== undefined) {
396
+ refused.push(where + '`' + group.label + '` is a block inside `' + boundaryLabel(group.parent) + '`, not an element; an edge to a nested boundary would have to enter another boundary');
397
+ return;
398
+ }
399
+ if (within(builder.edgeScopes[index], boundary)) {
400
+ refused.push(where + '`' + group.label + '` is the block this edge is written inside, not an element; an edge cannot leave a boundary and arrive at the same boundary');
401
+ return;
402
+ }
403
+ if (!hasContents(boundary)) {
404
+ refused.push(where + '`' + group.label + '` is a block with nothing drawn inside it, so there is no rectangle to attach the edge to');
405
+ return;
406
+ }
407
+ }
408
+ keptEdges.push({
409
+ ...edge,
410
+ ...(fromBoundary === undefined ? {} : { from: fromBoundary }),
411
+ ...(toBoundary === undefined ? {} : { to: toBoundary }),
412
+ });
413
+ });
414
+ builder.edges = keptEdges;
143
415
  if (!seenAny || builder.nodes.size === 0) {
144
416
  return { diagram: emptyDiagram(title, 'no nodes found'), refused };
145
417
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Layering: which row each box is in.
3
+ *
4
+ * The graph is acyclic by the time it gets here. A layered drawing needs every edge to span at least one layer, so it
5
+ * always travels downwards, and it wants the total of the layers every edge spans beyond one to be as small as
6
+ * possible: those are the edges that have to be drawn through a row of placeholders instead of going straight down a
7
+ * gap.
8
+ *
9
+ * Longest paths are the first guess: every source on the top row, every other node one layer below its latest
10
+ * predecessor. It is feasible and it is not optimal, because it puts every node as high as it can go - a node whose
11
+ * edges leave it with slack above the boxes it points at is a node that could have moved down and shortened them.
12
+ * `network-simplex.ts` solves the program the mature layering implementations solve: the ranking that minimises the
13
+ * total length, found by exchanging edges in a tight tree until no exchange is left that shortens it.
14
+ *
15
+ * The initial state is layer zero. It is a node with no predecessor - cycle breaking has turned around every edge
16
+ * that ran into it - so a source, and every source is a candidate for the top row. The pin is passed to the solver as
17
+ * a constraint, so the search never trades it away: an initial state that moved down the page would be a machine
18
+ * diagram read from the wrong end.
19
+ *
20
+ * Pure: the graph in, one layer per node out.
21
+ */
22
+ import { minimumLength } from './network-simplex.js';
23
+ /**
24
+ * The longest-path ranking: the highest layer each node can possibly take.
25
+ *
26
+ * Every source starts on layer zero and every other node one below its latest predecessor, which is the shortest
27
+ * ranking that is feasible at all - and, because no node can then move up, the starting point the exchange search
28
+ * needs. A node the walk never reaches is part of a cycle and is put on layer zero: the stage is defined on a DAG,
29
+ * and a graph that arrived cyclic is laid out as far as it can be rather than losing the boxes of the cycle.
30
+ */
31
+ function longestPath(ids, edges, index) {
32
+ const rank = new Array(ids.length).fill(0);
33
+ const pending = new Array(ids.length).fill(0);
34
+ const outgoing = ids.map(() => []);
35
+ for (const edge of edges) {
36
+ outgoing[index.get(edge.from)].push(index.get(edge.to));
37
+ pending[index.get(edge.to)] += 1;
38
+ }
39
+ const queue = [];
40
+ for (let node = 0; node < ids.length; node += 1)
41
+ if (pending[node] === 0)
42
+ queue.push(node);
43
+ for (let head = 0; head < queue.length; head += 1) {
44
+ const node = queue[head];
45
+ for (const target of outgoing[node]) {
46
+ rank[target] = Math.max(rank[target], rank[node] + 1);
47
+ pending[target] -= 1;
48
+ if (pending[target] === 0)
49
+ queue.push(target);
50
+ }
51
+ }
52
+ return rank;
53
+ }
54
+ export function layer(graph, edges) {
55
+ // A model is data: the same box may be written twice, and an edge may name a box this level does not have.
56
+ const ids = [];
57
+ const seen = new Set();
58
+ for (const node of graph.nodes) {
59
+ if (seen.has(node.id))
60
+ continue;
61
+ seen.add(node.id);
62
+ ids.push(node.id);
63
+ }
64
+ const index = new Map(ids.map((id, offset) => [id, offset]));
65
+ const forward = edges.filter((edge) => index.has(edge.from) && index.has(edge.to) && edge.from !== edge.to);
66
+ const rankEdges = forward.map((edge) => ({
67
+ from: index.get(edge.from),
68
+ to: index.get(edge.to),
69
+ minLength: 1,
70
+ weight: 1,
71
+ }));
72
+ // The pin only means something for a node that could be on the top row. A node with a predecessor cannot be: it is
73
+ // forced below it, and no ranking puts it first while keeping the edge that points at it.
74
+ let pinned;
75
+ if (graph.init !== undefined && index.has(graph.init)) {
76
+ const start = index.get(graph.init);
77
+ const forced = forward.some((edge) => edge.to === graph.init);
78
+ if (!forced)
79
+ pinned = start;
80
+ }
81
+ const rank = minimumLength({ count: ids.length, edges: rankEdges, start: longestPath(ids, forward, index), ...(pinned === undefined ? {} : { pinned }) });
82
+ const layers = [];
83
+ for (const [offset, id] of ids.entries()) {
84
+ const depth = rank[offset];
85
+ while (layers.length <= depth)
86
+ layers.push([]);
87
+ layers[depth].push(id);
88
+ }
89
+ const layerOf = new Map(ids.map((id, offset) => [id, rank[offset]]));
90
+ return { layers, layerOf };
91
+ }
@@ -1,28 +1,26 @@
1
1
  /**
2
- * Ranks for a machine, and the order of states within each rank.
2
+ * The graph the layout stages agree on, and the order they start from.
3
3
  *
4
- * Breadth-first from the initial state decides the row: the layout a reader expects, and the one a layered
5
- * drawing asks for - edges mostly flow down, and a state's row is how far it is from the start. A state
6
- * nothing reaches goes in a block of its own rather than being dropped.
4
+ * A layout stage has no business knowing what a diagram is: it sees boxes with a size and edges
5
+ * between them. That is what `LayoutGraph` is, and it is the whole vocabulary the pipeline shares -
6
+ * cycle breaking reads it, layering writes layers, ordering and coordinate assignment read those.
7
7
  *
8
- * Within a row, states keep the order breadth-first found them in: a row is filled by walking the row above
9
- * left to right, so a state already sits under the parent that discovered it. A barycentre pass was written
10
- * and removed - with breadth-first insertion it could never move a state, so it was complexity without an
11
- * observable effect.
8
+ * `rankStates` stays: it is the breadth-first order a source's own reading suggests, and it seeds
9
+ * the ordering stage with the discovery order instead of whatever order the parser happened to
10
+ * collect. It no longer decides which row a state is in - `layer.ts` does that now - so the rows it
11
+ * returns are only a first guess at the left-to-right order.
12
12
  *
13
- * Pure, and asserted on the rows themselves.
13
+ * Pure, and asserted on the answer itself.
14
14
  */
15
15
  export function rankStates(nodes, edges, init) {
16
16
  const ids = nodes.map((node) => node.id);
17
17
  const known = new Set(ids);
18
18
  const start = init !== undefined && known.has(init) ? init : ids[0];
19
19
  const outgoing = new Map();
20
- const incoming = new Map();
21
20
  for (const edge of edges) {
22
21
  if (!known.has(edge.from) || !known.has(edge.to))
23
22
  continue;
24
23
  outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to]);
25
- incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge.from]);
26
24
  }
27
25
  const depth = new Map();
28
26
  if (start !== undefined) {
@@ -50,3 +48,21 @@ export function rankStates(nodes, edges, init) {
50
48
  }
51
49
  return { rows, unreachable: ids.filter((id) => !depth.has(id)) };
52
50
  }
51
+ /**
52
+ * The left-to-right seed the ordering stage starts from.
53
+ *
54
+ * Breadth-first discovery is what a reader expects a drawing to follow, so it is a better first
55
+ * guess than the order the parser collected. A state nothing reaches has no discovery order and
56
+ * keeps the order it was written in, after everything that was reached.
57
+ */
58
+ export function seedOrder(graph) {
59
+ const ranked = rankStates(graph.nodes, graph.edges, graph.init);
60
+ const order = new Map();
61
+ let index = 0;
62
+ for (const row of ranked.rows)
63
+ for (const id of row)
64
+ order.set(id, index++);
65
+ for (const id of ranked.unreachable)
66
+ order.set(id, index++);
67
+ return order;
68
+ }