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
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Coordinate assignment: how far right each box sits. Brandes-Kopf.
3
+ *
4
+ * The layers are rows and the order inside them is settled; the only freedom left is the x of each box. Three
5
+ * properties make a drawing readable:
6
+ *
7
+ * - A box sits over what it is connected to, and a chain of boxes sits on one vertical line. Brandes and Kopf get
8
+ * this by *aligning*: every box is offered the median of its neighbours in the layer just above (or just below) and
9
+ * is joined to that neighbour's block when the join crosses no other edge. A block is then one node of a smaller
10
+ * graph, and a box that aligned downwards through five rows comes out as one straight segment.
11
+ * - Nothing overlaps, and the empty strip between two boxes in a row is the same everywhere. The blocks are compacted
12
+ * against each other by the least distance their rows allow, which is a longest path over the block graph: a block
13
+ * sits as far left as the block to its left permits, and no further.
14
+ * - The drawing is balanced rather than packed to one side. Aligning downwards alone would lean everything left, so
15
+ * the same two passes are run from the bottom up, and each pair is run once more mirrored left to right. The four
16
+ * layouts are then averaged. Averaging is a convex combination, and the separation constraints are linear, so the
17
+ * average of four layouts that keep the boxes apart also keeps them apart - the balance is free of overlaps by
18
+ * construction. A pass that starts at the top and one that starts at the bottom compact their blocks from opposite
19
+ * ends, so each side is first slid onto the origin of the first pass of that side, and only then averaged.
20
+ *
21
+ * Three things this module owes the rest of the pipeline rather than the paper:
22
+ *
23
+ * - A long edge's dummies share one x. They are one lane, not one point per row, so each lane is collapsed to the mean
24
+ * of the x its two ends want - the straight drop a reader follows. Collapsing can put a lane where a box already is,
25
+ * so the last pass spends the lane's space: boxes are pushed right past it and never the other way, and a row keeps
26
+ * the width its placeholders were given, which is the width a corridor needs when the lane has to go around a box.
27
+ * The lane is a proposal either way - the channel stage moves a lane into a free corridor when routing needs it, and
28
+ * a box never moves for a lane.
29
+ * - A lane is not a neighbour to be aligned with. A box that aligned itself to a lane passing through its row would be
30
+ * pulled off the boxes it does connect to, which is how a decision with one long edge comes out as a staircase. Lanes
31
+ * are placed by the compaction and then by the channel stage, which is where a lane belongs.
32
+ * - The empty strip between two rows is the caller's, not this module's: the y of a row is a running sum with one gap.
33
+ *
34
+ * Pure: layers and chains in, coordinates out.
35
+ */
36
+ /** Where each id sits inside its row, in the row's own order. */
37
+ function positions(rows) {
38
+ const at = new Map();
39
+ for (const row of rows)
40
+ row.forEach((id, index) => at.set(id, index));
41
+ return at;
42
+ }
43
+ /**
44
+ * Join each id to the median of its neighbours in the row above, unless that join would cross another edge.
45
+ *
46
+ * One block per chain of joins, reported as the block's root per id. The `previous` cursor is the whole of the
47
+ * crossing test: as ids are taken left to right, a neighbour has to be strictly right of the one the previous id was
48
+ * joined to, and since the ids are taken in order that is exactly "the joins do not cross".
49
+ */
50
+ function alignBlocks(frame, at) {
51
+ const root = new Map();
52
+ const joined = new Map();
53
+ for (const row of frame.rows) {
54
+ for (const id of row) {
55
+ root.set(id, id);
56
+ joined.set(id, id);
57
+ }
58
+ }
59
+ for (let depth = 1; depth < frame.rows.length; depth += 1) {
60
+ let previous = -1;
61
+ for (const id of frame.rows[depth]) {
62
+ const close = frame.near.get(id) ?? [];
63
+ if (close.length === 0)
64
+ continue;
65
+ const sorted = [...close].sort((left, right) => at.get(left) - at.get(right));
66
+ const middle = (sorted.length - 1) / 2;
67
+ // An even number of neighbours has two medians. Both are tried, nearest first, so a box with two parents is as
68
+ // happy under either as the paper's version.
69
+ for (let cursor = Math.floor(middle); cursor <= Math.ceil(middle); cursor += 1) {
70
+ if (joined.get(id) !== id)
71
+ break;
72
+ const target = sorted[cursor];
73
+ if (previous >= at.get(target))
74
+ continue;
75
+ joined.set(target, id);
76
+ root.set(id, root.get(target));
77
+ joined.set(id, root.get(id));
78
+ previous = at.get(target);
79
+ }
80
+ }
81
+ }
82
+ return root;
83
+ }
84
+ /**
85
+ * Place the blocks: each one as far left as the block to its left in any row allows.
86
+ *
87
+ * That is a longest path over the block graph, whose edges run from a block to the block it has to clear, weighted by
88
+ * the least distance between the two ids that forced the edge. Blocks cannot cross by construction, so the graph has
89
+ * no cycle; the guard is there because a cycle would otherwise be an infinite recursion rather than a wrong drawing.
90
+ */
91
+ function compactBlocks(rows, root, widthOf, gap) {
92
+ const clears = new Map();
93
+ for (const row of rows) {
94
+ for (let index = 1; index < row.length; index += 1) {
95
+ const left = row[index - 1];
96
+ const right = row[index];
97
+ const leftBlock = root.get(left);
98
+ const rightBlock = root.get(right);
99
+ const space = (widthOf(left) + widthOf(right)) / 2 + gap;
100
+ clears.set(rightBlock, [...(clears.get(rightBlock) ?? []), { block: leftBlock, space }]);
101
+ }
102
+ }
103
+ const placed = new Map();
104
+ const place = (block) => {
105
+ const known = placed.get(block);
106
+ if (known !== undefined)
107
+ return known;
108
+ let value = 0;
109
+ for (const step of clears.get(block) ?? [])
110
+ value = Math.max(value, place(step.block) + step.space);
111
+ placed.set(block, value);
112
+ return value;
113
+ };
114
+ const x = new Map();
115
+ for (const row of rows) {
116
+ for (const id of row) {
117
+ const block = root.get(id);
118
+ if (!placed.has(block))
119
+ place(block);
120
+ x.set(id, placed.get(block));
121
+ }
122
+ }
123
+ return x;
124
+ }
125
+ /** The left edge of the leftmost box and the right edge of the rightmost, in one pass's coordinates. */
126
+ function extentOf(x, rows, widthOf) {
127
+ let left = Infinity;
128
+ let right = -Infinity;
129
+ for (const row of rows) {
130
+ for (const id of row) {
131
+ const value = x.get(id);
132
+ left = Math.min(left, value - widthOf(id) / 2);
133
+ right = Math.max(right, value + widthOf(id) / 2);
134
+ }
135
+ }
136
+ return { left, right };
137
+ }
138
+ /** One pass: align the rows in this frame, compact the blocks, and flip the answer back if the frame is mirrored. */
139
+ function onePass(frame, widthOf, gap) {
140
+ const at = positions(frame.rows);
141
+ const root = alignBlocks(frame, at);
142
+ const x = compactBlocks(frame.rows, root, widthOf, gap);
143
+ if (!frame.mirrored)
144
+ return x;
145
+ const mirrored = new Map();
146
+ for (const [id, value] of x)
147
+ mirrored.set(id, -value);
148
+ return mirrored;
149
+ }
150
+ /** The four passes a balanced layout needs: down and up, each read left to right and right to left. */
151
+ function frames(layers, above, below) {
152
+ const fromTop = layers;
153
+ const fromBottom = [...layers].reverse();
154
+ const flip = (rows) => rows.map((row) => [...row].reverse());
155
+ return [
156
+ { rows: fromTop, near: above, mirrored: false },
157
+ { rows: flip(fromTop), near: above, mirrored: true },
158
+ { rows: fromBottom, near: below, mirrored: false },
159
+ { rows: flip(fromBottom), near: below, mirrored: true },
160
+ ];
161
+ }
162
+ export function assignCoordinates(input, options) {
163
+ const { layers, chains, sizeOf, chainOf } = input;
164
+ const gap = options.gap;
165
+ const laneIds = new Set(chainOf.values());
166
+ const isDummy = (id) => chainOf.has(id);
167
+ const isPoint = (id) => isDummy(id) || laneIds.has(id);
168
+ const widthOf = (id) => (isPoint(id) ? 0 : sizeOf(id).width);
169
+ const heightOf = (id) => (isPoint(id) ? 0 : sizeOf(id).height);
170
+ const laneOf = (id) => chainOf.get(id) ?? id;
171
+ const x = new Map();
172
+ const read = (id) => x.get(laneOf(id));
173
+ const above = new Map();
174
+ const below = new Map();
175
+ const laneEnds = new Map();
176
+ const link = (into, id, other) => {
177
+ into.set(id, [...(into.get(id) ?? []), other]);
178
+ };
179
+ for (const chain of chains) {
180
+ for (let cursor = 0; cursor + 1 < chain.path.length; cursor += 1) {
181
+ const top = chain.path[cursor];
182
+ const bottom = chain.path[cursor + 1];
183
+ // A lane is not a neighbour to align with: only two boxes are, which is what keeps a box over the boxes it
184
+ // connects to when a long edge crosses its row.
185
+ if (!isDummy(top) && !isDummy(bottom)) {
186
+ link(below, top, bottom);
187
+ link(above, bottom, top);
188
+ }
189
+ }
190
+ if (chain.path.length > 2) {
191
+ const lane = chainOf.get(chain.path[1]);
192
+ if (lane !== undefined)
193
+ laneEnds.set(lane, [chain.path[0], chain.path[chain.path.length - 1]]);
194
+ }
195
+ }
196
+ // The four layouts, averaged. A pass that starts at the top and a pass that starts at the bottom compact their
197
+ // blocks from opposite ends, so the four answers do not share an origin: each side is first slid onto the origin of
198
+ // the first pass of that side, and only then are they averaged. Averaging is what balances them - a box that aligned
199
+ // in one pass and not in another keeps the part the passes agreed on, which is the paper's last step, at the cost of
200
+ // one average.
201
+ const candidates = frames(layers, above, below).map((frame) => onePass(frame, widthOf, gap));
202
+ const leftOrigin = extentOf(candidates[0], layers, widthOf).left;
203
+ const rightOrigin = extentOf(candidates[1], layers, widthOf).right;
204
+ for (const [index, candidate] of candidates.entries()) {
205
+ const extent = extentOf(candidate, layers, widthOf);
206
+ const shift = index % 2 === 0 ? leftOrigin - extent.left : rightOrigin - extent.right;
207
+ for (const [id, value] of candidate)
208
+ candidate.set(id, value + shift);
209
+ }
210
+ for (const row of layers) {
211
+ for (const id of row) {
212
+ let total = 0;
213
+ for (const candidate of candidates)
214
+ total += candidate.get(id);
215
+ x.set(id, total / candidates.length);
216
+ }
217
+ }
218
+ // What each row has to make room for. The boxes are one part; the space the lane's placeholders were given by the
219
+ // compaction is the other, because that is where a corridor can run when the lane's own proposal lands on a box. A
220
+ // row that reserved nothing for its lanes is as wide as its boxes; a row whose lane had to go around a box keeps the
221
+ // width that detour needed, so the page is planned for the drawing that will be painted rather than the boxes alone.
222
+ const reserved = layers.map((layer) => {
223
+ let lowest = Infinity;
224
+ let highest = -Infinity;
225
+ for (const id of layer) {
226
+ const value = x.get(id);
227
+ lowest = Math.min(lowest, value - widthOf(id) / 2);
228
+ highest = Math.max(highest, value + widthOf(id) / 2);
229
+ }
230
+ return { lowest, highest };
231
+ });
232
+ // A lane is one variable across every row it crosses. The four passes give each of its dummies its own x; the lane
233
+ // takes the mean of what its two ends want, because the straight drop between them is what a reader follows. The rows
234
+ // it crosses are the channel stage's to keep it clear of. It stays a proposal either way: `channels.ts` moves a lane
235
+ // that lands on a box.
236
+ for (const [lane, ends] of laneEnds)
237
+ x.set(lane, (x.get(ends[0]) + x.get(ends[1])) / 2);
238
+ for (const id of chainOf.keys())
239
+ x.delete(id);
240
+ // The rows keep their order and their gap. A lane in a row does not move a box out of the way; a box does move for
241
+ // the box before it, because two boxes on top of each other is the one thing a drawing may not do.
242
+ for (const row of layers) {
243
+ let cursor = -Infinity;
244
+ for (const id of row) {
245
+ if (isDummy(id)) {
246
+ cursor = Math.max(cursor, read(id));
247
+ continue;
248
+ }
249
+ const width = widthOf(id);
250
+ const centre = Math.max(x.get(id), cursor + width / 2);
251
+ x.set(id, centre);
252
+ cursor = centre + width / 2 + gap;
253
+ }
254
+ }
255
+ let left = 0;
256
+ for (const [id, value] of x)
257
+ left = Math.min(left, value - widthOf(id) / 2);
258
+ for (const [id, value] of x)
259
+ x.set(id, value - left);
260
+ const layerHeight = layers.map((layer) => Math.max(0, ...layer.map((id) => heightOf(id))));
261
+ const layerWidth = layers.map((layer, depth) => {
262
+ let lowest = reserved[depth].lowest - left;
263
+ let highest = reserved[depth].highest - left;
264
+ for (const id of layer) {
265
+ const value = read(id);
266
+ lowest = Math.min(lowest, value - widthOf(id) / 2);
267
+ highest = Math.max(highest, value + widthOf(id) / 2);
268
+ }
269
+ return highest - lowest;
270
+ });
271
+ const layerY = [];
272
+ let top = 0;
273
+ for (const [depth, height] of layerHeight.entries()) {
274
+ layerY.push(top);
275
+ top += height + options.layerGap;
276
+ }
277
+ let width = 0;
278
+ for (const [id, value] of x)
279
+ width = Math.max(width, value + widthOf(id) / 2);
280
+ return {
281
+ x,
282
+ layerY,
283
+ layerHeight,
284
+ layerWidth,
285
+ width,
286
+ height: layerY.length === 0 ? 0 : layerY[layerY.length - 1] + layerHeight[layerHeight.length - 1],
287
+ };
288
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Cycle breaking, in the order a layered layout does it.
3
+ *
4
+ * A layered drawing is a drawing of a DAG: an edge that runs back up the page has no layer to go to. The fix is to
5
+ * lay such an edge out backwards and draw its arrow the way the source wrote it - but *which* edges to turn around
6
+ * is a decision, and deciding it edge by edge is what makes two back edges of one cycle end up pointing opposite
7
+ * ways.
8
+ *
9
+ * So the graph is decomposed first. Tarjan's algorithm finds the strongly connected components; contracting each to
10
+ * a node gives the condensation, which is a DAG, so every edge between components already points one way and nothing
11
+ * between them needs reversing. Only inside a component - where by definition every edge lies on a cycle - is there
12
+ * an order to choose: the nodes are ranked once, greedily, and every edge inside the component is oriented along that
13
+ * single ranking. One ranking per component is what "consistently" means: after the pass, every surviving edge of a
14
+ * component points from a lower rank to a higher one, so the component cannot contain a cycle any more.
15
+ *
16
+ * The initial state is a special case: it is layer zero by definition, so an edge that runs into it is a back edge
17
+ * before the decomposition even starts. Turning those around leaves the state with no predecessor, so the ranking
18
+ * of its component puts it first and nothing is ever oriented back into it.
19
+ *
20
+ * Pure: the same graph in, the same edge directions out.
21
+ */
22
+ /** Tarjan's strongly connected components, iterative enough to be read: the recursion is the algorithm. */
23
+ function tarjan(nodes, outgoing) {
24
+ let counter = 0;
25
+ const index = new Map();
26
+ const low = new Map();
27
+ const stack = [];
28
+ const onStack = new Set();
29
+ const found = [];
30
+ const visit = (node) => {
31
+ index.set(node, counter);
32
+ low.set(node, counter);
33
+ counter += 1;
34
+ stack.push(node);
35
+ onStack.add(node);
36
+ for (const next of outgoing.get(node) ?? []) {
37
+ if (!index.has(next)) {
38
+ visit(next);
39
+ low.set(node, Math.min(low.get(node), low.get(next)));
40
+ continue;
41
+ }
42
+ if (onStack.has(next))
43
+ low.set(node, Math.min(low.get(node), index.get(next)));
44
+ }
45
+ if (low.get(node) !== index.get(node))
46
+ return;
47
+ const component = [];
48
+ let member;
49
+ do {
50
+ member = stack.pop();
51
+ onStack.delete(member);
52
+ component.push(member);
53
+ } while (member !== node);
54
+ found.push(component);
55
+ };
56
+ for (const node of nodes)
57
+ if (!index.has(node))
58
+ visit(node);
59
+ return found;
60
+ }
61
+ export function breakCycles(graph) {
62
+ const known = new Set(graph.nodes.map((node) => node.id));
63
+ const position = new Map(graph.nodes.map((node, offset) => [node.id, offset]));
64
+ const edges = graph.edges.map((edge) => ({ ...edge }));
65
+ const reversed = new Set();
66
+ const flip = (index) => {
67
+ const edge = edges[index];
68
+ const next = edge.from;
69
+ edge.from = edge.to;
70
+ edge.to = next;
71
+ };
72
+ // The initial state is layer zero: every edge running into it is a back edge already.
73
+ if (graph.init !== undefined && known.has(graph.init)) {
74
+ for (const [index, edge] of edges.entries()) {
75
+ if (edge.to === graph.init && edge.from !== graph.init)
76
+ flip(index);
77
+ }
78
+ }
79
+ const outgoing = new Map();
80
+ for (const edge of edges) {
81
+ if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to)
82
+ continue;
83
+ outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to]);
84
+ }
85
+ const nodes = graph.nodes.map((node) => node.id);
86
+ const raw = tarjan(nodes, outgoing);
87
+ // Tarjan emits a component after everything it reaches, which is the reverse of a topological order.
88
+ raw.reverse();
89
+ const componentOf = new Map();
90
+ const components = raw.map((members, id) => {
91
+ for (const member of members)
92
+ componentOf.set(member, id);
93
+ return { id, members };
94
+ });
95
+ const condensation = [];
96
+ const seen = new Set();
97
+ for (const edge of edges) {
98
+ const from = componentOf.get(edge.from);
99
+ const to = componentOf.get(edge.to);
100
+ if (from === undefined || to === undefined || from === to)
101
+ continue;
102
+ const key = String(from) + '>' + String(to);
103
+ if (seen.has(key))
104
+ continue;
105
+ seen.add(key);
106
+ condensation.push({ from, to });
107
+ }
108
+ // Inside each component: one ranking, decided greedily by how much of the flow leaves the node rather than enters
109
+ // it. That is the order the edges are made to follow, so no two decisions can contradict each other.
110
+ const rank = new Map();
111
+ for (const component of components) {
112
+ if (component.members.length < 2) {
113
+ rank.set(component.members[0], 0);
114
+ continue;
115
+ }
116
+ const inside = new Set(component.members);
117
+ const score = new Map();
118
+ for (const member of component.members)
119
+ score.set(member, 0);
120
+ for (const edge of edges) {
121
+ if (!inside.has(edge.from) || !inside.has(edge.to) || edge.from === edge.to)
122
+ continue;
123
+ score.set(edge.from, score.get(edge.from) + 1);
124
+ score.set(edge.to, score.get(edge.to) - 1);
125
+ }
126
+ const ordered = [...component.members].sort((left, right) => {
127
+ const difference = score.get(right) - score.get(left);
128
+ return difference !== 0 ? difference : position.get(left) - position.get(right);
129
+ });
130
+ ordered.forEach((member, offset) => rank.set(member, offset));
131
+ }
132
+ for (const [index, edge] of edges.entries()) {
133
+ const from = rank.get(edge.from);
134
+ const to = rank.get(edge.to);
135
+ if (from === undefined || to === undefined || edge.from === edge.to)
136
+ continue;
137
+ if (componentOf.get(edge.from) !== componentOf.get(edge.to))
138
+ continue;
139
+ if (from > to)
140
+ flip(index);
141
+ }
142
+ edges.forEach((edge, index) => {
143
+ if (graph.edges[index].from !== edge.from)
144
+ reversed.add(edge.index);
145
+ });
146
+ return { edges, reversed, components, componentOf, rank, condensation };
147
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Dummy nodes: the placeholders a long edge stands on.
3
+ *
4
+ * An edge from layer 0 to layer 3 has to cross layers 1 and 2. The router can go around whatever is in the way, but a
5
+ * drawing reads better if the edge owns a lane through those layers: then ordering can keep other edges off it, and
6
+ * coordinate assignment can give the whole lane one x. A dummy node is that lane: one per layer the edge crosses.
7
+ *
8
+ * A lane is one variable, not one variable per layer. The instances keep their own id (`#d<edge>_<layer>`) so the
9
+ * ordering stage can sort them inside their layer, and they all point at one lane id (`#e<edge>`) so coordinate
10
+ * assignment cannot give the same edge three different x values.
11
+ *
12
+ * Pure: the layers, the chains, and the map between the two.
13
+ */
14
+ export function addDummies(layering, edges) {
15
+ const layers = layering.layers.map((layer) => [...layer]);
16
+ const chains = [];
17
+ const chainOf = new Map();
18
+ const laneOf = new Map();
19
+ for (const edge of edges) {
20
+ if (edge.from === edge.to) {
21
+ chains.push({ index: edge.index, path: [edge.from, edge.to] });
22
+ continue;
23
+ }
24
+ const from = layering.layerOf.get(edge.from);
25
+ const to = layering.layerOf.get(edge.to);
26
+ if (from === undefined || to === undefined || to <= from) {
27
+ chains.push({ index: edge.index, path: [edge.from, edge.to] });
28
+ continue;
29
+ }
30
+ const path = [edge.from];
31
+ for (let cursor = from + 1; cursor < to; cursor += 1) {
32
+ const instance = '#d' + String(edge.index) + '_' + String(cursor);
33
+ const lane = '#e' + String(edge.index);
34
+ chainOf.set(instance, lane);
35
+ laneOf.set(edge.index, lane);
36
+ layers[cursor].push(instance);
37
+ path.push(instance);
38
+ }
39
+ path.push(edge.to);
40
+ chains.push({ index: edge.index, path });
41
+ }
42
+ return { layers, chains, chainOf, laneOf };
43
+ }
@@ -40,10 +40,15 @@ const UNSUPPORTED = new Set([
40
40
  'requirementdiagram',
41
41
  'sankey-beta',
42
42
  ]);
43
- function ensure(builder, id, label) {
43
+ function ensure(builder, id, label, shape) {
44
44
  const existing = builder.nodes.get(id);
45
45
  if (existing === undefined) {
46
- builder.nodes.set(id, { id, role: 'normal', ...(label === undefined ? {} : { label }) });
46
+ builder.nodes.set(id, {
47
+ id,
48
+ role: 'normal',
49
+ ...(label === undefined ? {} : { label }),
50
+ ...(shape === undefined ? {} : { shape }),
51
+ });
47
52
  // Membership is decided where the node is first written: the innermost open boundary. Naming it again
48
53
  // later from an outer boundary does not move it - the outer boundary holds it through `parent`.
49
54
  const innermost = builder.open[builder.open.length - 1];
@@ -60,7 +65,26 @@ function nodeFrom(token) {
60
65
  return undefined;
61
66
  const id = match[1];
62
67
  const label = [match[2], match[3], match[4], match[5], match[6], match[7]].find((value) => value !== undefined && value.trim() !== '');
63
- return { id, ...(label === undefined ? {} : { label: label.trim().replace(/^["']|["']$/g, '') }) };
68
+ // Group 2 `[(x)]` is a datastore, 3 `[[x]]` a subroutine, 4 `[x]` a rectangle, 5 `(x)` a rounded box, 6 `{x}` a
69
+ // decision, 7 `<<x>>` a hexagon. Mermaid's own mapping; a shape the notation did not state stays undefined.
70
+ const shape = match[2] !== undefined
71
+ ? 'cylinder'
72
+ : match[3] !== undefined
73
+ ? 'subroutine'
74
+ : match[4] !== undefined
75
+ ? 'rect'
76
+ : match[5] !== undefined
77
+ ? 'stadium'
78
+ : match[6] !== undefined
79
+ ? 'diamond'
80
+ : match[7] !== undefined
81
+ ? 'hexagon'
82
+ : undefined;
83
+ return {
84
+ id,
85
+ ...(label === undefined ? {} : { label: label.trim().replace(/^["']|["']$/g, '') }),
86
+ ...(shape === undefined ? {} : { shape }),
87
+ };
64
88
  }
65
89
  export function fromMermaid(source, title = 'mermaid') {
66
90
  const lines = source.split(/\r?\n/);
@@ -78,13 +102,13 @@ export function fromMermaid(source, title = 'mermaid') {
78
102
  return;
79
103
  const where = 'line ' + String(index + 1) + ': ';
80
104
  const lower = line.toLowerCase();
81
- const keyword = lower.split(/[\s{}]/)[0] ?? '';
105
+ const keyword = lower.split(/[\s{}]/)[0];
82
106
  if (UNSUPPORTED.has(keyword)) {
83
107
  refused.push(where + '`' + keyword + '` is not a diagram this renderer draws');
84
108
  return;
85
109
  }
86
110
  if (DIRECTIVE.test(line)) {
87
- refused.push(where + '`' + (line.split(/\s/)[0] ?? '') + '` is a directive this renderer ignores');
111
+ refused.push(where + '`' + line.split(/\s/)[0] + '` is a directive this renderer ignores');
88
112
  return;
89
113
  }
90
114
  if (/^(graph|flowchart)\b/.test(lower)) {
@@ -160,8 +184,8 @@ export function fromMermaid(source, title = 'mermaid') {
160
184
  refused.push(where + '`' + line + '` does not name two nodes');
161
185
  return;
162
186
  }
163
- ensure(builder, left.id, left.label);
164
- ensure(builder, right.id, right.label);
187
+ ensure(builder, left.id, left.label, left.shape);
188
+ ensure(builder, right.id, right.label, right.shape);
165
189
  push({ from: left.id, to: right.id, label: labelled[2].trim() });
166
190
  return;
167
191
  }
@@ -173,8 +197,8 @@ export function fromMermaid(source, title = 'mermaid') {
173
197
  refused.push(where + '`' + line + '` does not name two nodes');
174
198
  return;
175
199
  }
176
- ensure(builder, left.id, left.label);
177
- ensure(builder, right.id, right.label);
200
+ ensure(builder, left.id, left.label, left.shape);
201
+ ensure(builder, right.id, right.label, right.shape);
178
202
  push({
179
203
  from: left.id,
180
204
  to: right.id,
@@ -185,7 +209,7 @@ export function fromMermaid(source, title = 'mermaid') {
185
209
  }
186
210
  const single = nodeFrom(line);
187
211
  if (single !== undefined) {
188
- ensure(builder, single.id, single.label);
212
+ ensure(builder, single.id, single.label, single.shape);
189
213
  return;
190
214
  }
191
215
  refused.push(where + '`' + line + '` is not something this renderer draws');