@zombie-mermaid/ascii-renderer 3.1.0 → 3.2.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.
- package/dist/index.cjs +7 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2484 -1467
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ascii-bt-label-order.test.ts +97 -0
- package/src/__tests__/ascii-cluster-exit-anchoring-1148.test.ts +774 -0
- package/src/__tests__/ascii-cluster-exit-lanes-1182.test.ts +375 -0
- package/src/__tests__/ascii-role-colors.test.ts +328 -0
- package/src/__tests__/ascii-subgraph-back-edge-1197.test.ts +212 -0
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +4 -2
- package/src/__tests__/ascii-subgraph-title-edge-1222.test.ts +106 -0
- package/src/__tests__/c4-ascii.test.ts +311 -0
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +1 -1
- package/src/__tests__/xychart-ascii-ticks.test.ts +34 -0
- package/src/ansi.ts +16 -2
- package/src/architecture.ts +33 -0
- package/src/c4-diagram.ts +1087 -0
- package/src/canvas.ts +78 -8
- package/src/cluster-boundary.ts +429 -0
- package/src/converter.ts +18 -2
- package/src/draw-arrows.ts +120 -18
- package/src/draw-boxes.ts +1 -0
- package/src/draw-subgraphs.ts +102 -2
- package/src/draw.ts +57 -3
- package/src/edge-cell-styles.ts +20 -1
- package/src/edge-routing.ts +77 -1
- package/src/flowchart.ts +28 -3
- package/src/frame-avoidance.ts +235 -0
- package/src/grid.ts +78 -19
- package/src/pathfinder.ts +22 -2
- package/src/registry.ts +8 -0
- package/src/types.ts +69 -0
- package/src/xychart.ts +24 -5
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster exits mixed with parallel-lane siblings (#1182, follow-up to
|
|
3
|
+
* #1148). Two edges with the same stand-in and target (`S -->|first| T`,
|
|
4
|
+
* `S -->|second| T`) are lane siblings; beside other exits they used to
|
|
5
|
+
* either keep the whole cluster off the shared trunk (one other exit: it
|
|
6
|
+
* punched through the wall) or leave the second sibling through the
|
|
7
|
+
* cluster's side wall with both lane labels on the wall row (two others).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
11
|
+
import { parseMermaid } from '../../../../src/parser.ts'
|
|
12
|
+
import { convertToAsciiGraph } from '../converter.ts'
|
|
13
|
+
import { createMapping } from '../grid.ts'
|
|
14
|
+
|
|
15
|
+
type Dir = 'TD' | 'LR'
|
|
16
|
+
|
|
17
|
+
const head = (dir: Dir): string => `flowchart ${dir}
|
|
18
|
+
subgraph S [Cluster]
|
|
19
|
+
a
|
|
20
|
+
end
|
|
21
|
+
Start --> S
|
|
22
|
+
S -->|first| T
|
|
23
|
+
S -->|second| T
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
const CASES: Array<[string, Dir, string[]]> = [
|
|
27
|
+
['TD, one other exit', 'TD', ['other']],
|
|
28
|
+
['TD, two other exits', 'TD', ['other', 'more']],
|
|
29
|
+
['LR, one other exit', 'LR', ['other']],
|
|
30
|
+
['LR, two other exits', 'LR', ['other', 'more']],
|
|
31
|
+
]
|
|
32
|
+
const TD_CASES = CASES.filter(([, d]) => d === 'TD')
|
|
33
|
+
const LR_CASES = CASES.filter(([, d]) => d === 'LR')
|
|
34
|
+
|
|
35
|
+
const source = (dir: Dir, others: string[]): string =>
|
|
36
|
+
head(dir) + others.map((o, i) => ` S -->|${o}| ${'UVW'[i]}\n`).join('')
|
|
37
|
+
|
|
38
|
+
const render = (src: string, useAscii = false): string[] =>
|
|
39
|
+
renderMermaidASCII(src, { colorMode: 'none', useAscii })
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((l) => l.trimEnd())
|
|
42
|
+
|
|
43
|
+
const count = (lines: string[], word: string): number =>
|
|
44
|
+
lines.join('\n').split(word).length - 1
|
|
45
|
+
|
|
46
|
+
describe('cluster exits with parallel-lane siblings (#1182)', () => {
|
|
47
|
+
it.each(CASES)(
|
|
48
|
+
'%s: the cluster engages and owns every exit',
|
|
49
|
+
(_n, dir, others) => {
|
|
50
|
+
const graph = convertToAsciiGraph(parseMermaid(source(dir, others)), {
|
|
51
|
+
useAscii: false,
|
|
52
|
+
paddingX: 6,
|
|
53
|
+
paddingY: 5,
|
|
54
|
+
boxBorderPadding: 1,
|
|
55
|
+
graphDirection: dir,
|
|
56
|
+
})
|
|
57
|
+
createMapping(graph)
|
|
58
|
+
const plan = [...(graph.clusterExitPlans?.values() ?? [])][0]
|
|
59
|
+
expect(plan).toBeDefined()
|
|
60
|
+
expect(plan!.edges.size).toBe(2 + others.length)
|
|
61
|
+
expect(graph.edges.filter((e) => e.parallelLane)).toHaveLength(2)
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
it.each(CASES)(
|
|
66
|
+
'%s: every node and label appears exactly once',
|
|
67
|
+
(_n, dir, others) => {
|
|
68
|
+
for (const useAscii of [false, true]) {
|
|
69
|
+
const lines = render(source(dir, others), useAscii)
|
|
70
|
+
for (const word of ['Start', 'Cluster', 'first', 'second', ...others]) {
|
|
71
|
+
expect(count(lines, word), `${word} (ascii=${useAscii})`).toBe(1)
|
|
72
|
+
}
|
|
73
|
+
for (const node of ['T', 'U', 'V'].slice(0, 1 + others.length)) {
|
|
74
|
+
expect(
|
|
75
|
+
lines.filter((l) => l.split(/\W+/).includes(node)),
|
|
76
|
+
node,
|
|
77
|
+
).toHaveLength(1)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
it.each(TD_CASES)(
|
|
84
|
+
'%s: only the trunk crosses the bottom wall, and no label is on it',
|
|
85
|
+
(_n, dir, others) => {
|
|
86
|
+
for (const useAscii of [false, true]) {
|
|
87
|
+
const lines = render(source(dir, others), useAscii)
|
|
88
|
+
// The cluster's bottom wall is the last full-width border row
|
|
89
|
+
// (the top border, with the entry trunk, matches too).
|
|
90
|
+
const wall = lines.findLastIndex((l) =>
|
|
91
|
+
useAscii ? /^\+[-|]+\+$/.test(l) : /^└[─┼]+┘$/.test(l),
|
|
92
|
+
)
|
|
93
|
+
expect(wall).toBeGreaterThan(-1)
|
|
94
|
+
const text = lines[wall]!
|
|
95
|
+
expect(text).not.toMatch(/[a-z]/)
|
|
96
|
+
const crossings = useAscii
|
|
97
|
+
? (text.match(/\|/g) ?? []).length
|
|
98
|
+
: (text.match(/[┼┬┴├┤]/g) ?? []).length
|
|
99
|
+
expect(crossings).toBe(1)
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
it.each(LR_CASES)(
|
|
105
|
+
'%s: only the trunk crosses the right wall, and no label is on it',
|
|
106
|
+
(_n, dir, others) => {
|
|
107
|
+
const lines = render(source(dir, others))
|
|
108
|
+
const top = lines.find((l) => l.includes('┐'))!
|
|
109
|
+
const col = top.indexOf('┐')
|
|
110
|
+
const crossing = lines
|
|
111
|
+
.map((l) => l[col] ?? ' ')
|
|
112
|
+
.filter((ch) => ch !== ' ' && ch !== '│' && ch !== '┐' && ch !== '┘')
|
|
113
|
+
expect(crossing).toEqual(['┼'])
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
it.each(TD_CASES)(
|
|
118
|
+
'%s: no exit leaves through the stand-in node side',
|
|
119
|
+
(_n, dir, others) => {
|
|
120
|
+
const lines = render(source(dir, others))
|
|
121
|
+
const aRow = lines.findIndex((l) => /│\s+a\s+│/.test(l))
|
|
122
|
+
for (const l of lines.slice(aRow - 2, aRow + 4)) {
|
|
123
|
+
expect(l).not.toMatch(/a\s+├─|┴─┼/)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// A third sibling has no free side face left on the target, so the group
|
|
129
|
+
// keeps today's routing rather than running a lane along a node border.
|
|
130
|
+
it.each([
|
|
131
|
+
['TD', ''],
|
|
132
|
+
['LR', ''],
|
|
133
|
+
['TD', ' S -->|fourth| T\n'],
|
|
134
|
+
] as const)(
|
|
135
|
+
'%s: a three- or four-sibling lane group does not engage the cluster (%j)',
|
|
136
|
+
(dir, extra) => {
|
|
137
|
+
const src = `${head(dir)} S -->|third| T\n${extra} S -->|other| U\n S -->|more| V\n`
|
|
138
|
+
const graph = convertToAsciiGraph(parseMermaid(src), {
|
|
139
|
+
useAscii: false,
|
|
140
|
+
paddingX: 6,
|
|
141
|
+
paddingY: 5,
|
|
142
|
+
boxBorderPadding: 1,
|
|
143
|
+
graphDirection: dir,
|
|
144
|
+
})
|
|
145
|
+
createMapping(graph)
|
|
146
|
+
const plan = [...(graph.clusterExitPlans?.values() ?? [])][0]
|
|
147
|
+
expect(plan).toBeDefined()
|
|
148
|
+
expect([...plan!.edges].some((e) => e.parallelLane)).toBe(false)
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// A lane label short enough to sit clear of the junction unaided.
|
|
153
|
+
it('LR: a short second-lane label keeps its place beside the junction', () => {
|
|
154
|
+
const src = `flowchart LR
|
|
155
|
+
subgraph S [Cluster]
|
|
156
|
+
a
|
|
157
|
+
end
|
|
158
|
+
Start --> S
|
|
159
|
+
S -->|a1| T
|
|
160
|
+
S -->|b1| T
|
|
161
|
+
S -->|other| U
|
|
162
|
+
`
|
|
163
|
+
const lines = render(src)
|
|
164
|
+
const row = lines.find((l) => l.includes('b1'))!
|
|
165
|
+
expect(row[row.indexOf('b1') - 1]).toBe('─')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('node borders and label placement', () => {
|
|
169
|
+
const NODE_NAMES = ['Start', 'a', 'T', 'U', 'V']
|
|
170
|
+
|
|
171
|
+
// Where `name` sits as a whole token (not inside a longer word).
|
|
172
|
+
const findToken = (
|
|
173
|
+
lines: string[],
|
|
174
|
+
name: string,
|
|
175
|
+
): { row: number; col: number } | undefined => {
|
|
176
|
+
for (const [row, text] of lines.entries()) {
|
|
177
|
+
let from = 0
|
|
178
|
+
for (;;) {
|
|
179
|
+
const col = text.indexOf(name, from)
|
|
180
|
+
if (col < 0) break
|
|
181
|
+
const before = text[col - 1] ?? ' '
|
|
182
|
+
const after = text[col + name.length] ?? ' '
|
|
183
|
+
if (!/\w/.test(before) && !/\w/.test(after)) return { row, col }
|
|
184
|
+
from = col + 1
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const boxProblems = (
|
|
191
|
+
lines: string[],
|
|
192
|
+
useAscii: boolean,
|
|
193
|
+
names: string[],
|
|
194
|
+
): string[] => {
|
|
195
|
+
const problems: string[] = []
|
|
196
|
+
const at = (r: number, c: number): string => lines[r]?.[c] ?? ' '
|
|
197
|
+
const corners = useAscii ? ['+', '+', '+', '+'] : ['┌', '┐', '└', '┘']
|
|
198
|
+
// What an edge may legitimately merge into on each kind of border cell.
|
|
199
|
+
const horizontalOk = useAscii ? '-+' : '─┬┴┼'
|
|
200
|
+
const verticalOk = useAscii ? '|+' : '│├┤┼'
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
const tok = findToken(lines, name)
|
|
203
|
+
if (!tok) {
|
|
204
|
+
problems.push(`${name}: not found`)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
const { row, col } = tok
|
|
208
|
+
let left = col
|
|
209
|
+
while (left > 0 && !'│|├┤+'.includes(at(row, left))) left--
|
|
210
|
+
let right = col
|
|
211
|
+
while (
|
|
212
|
+
right < (lines[row]?.length ?? 0) &&
|
|
213
|
+
!'│|├┤+'.includes(at(row, right))
|
|
214
|
+
)
|
|
215
|
+
right++
|
|
216
|
+
let top = row - 1
|
|
217
|
+
while (top > 0 && at(top, col) === ' ') top--
|
|
218
|
+
let bottom = row + 1
|
|
219
|
+
while (bottom < lines.length && at(bottom, col) === ' ') bottom++
|
|
220
|
+
const where = `${name} (box rows ${top}-${bottom}, cols ${left}-${right})`
|
|
221
|
+
const cs = [
|
|
222
|
+
at(top, left),
|
|
223
|
+
at(top, right),
|
|
224
|
+
at(bottom, left),
|
|
225
|
+
at(bottom, right),
|
|
226
|
+
]
|
|
227
|
+
for (const [i, glyph] of cs.entries()) {
|
|
228
|
+
if (glyph !== corners[i]) {
|
|
229
|
+
problems.push(
|
|
230
|
+
`${where}: corner ${i} is '${glyph}', expected '${corners[i]}'`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (let c = left + 1; c < right; c++) {
|
|
235
|
+
for (const r of [top, bottom]) {
|
|
236
|
+
if (!horizontalOk.includes(at(r, c))) {
|
|
237
|
+
problems.push(
|
|
238
|
+
`${where}: border cell (${r},${c}) is '${at(r, c)}'`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (let r = top + 1; r < bottom; r++) {
|
|
244
|
+
for (const c of [left, right]) {
|
|
245
|
+
if (!verticalOk.includes(at(r, c))) {
|
|
246
|
+
problems.push(`${where}: wall cell (${r},${c}) is '${at(r, c)}'`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return problems
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Every node box keeps its four corners and its walls: an edge may
|
|
255
|
+
// merge a junction into a border but never replace a corner or put an
|
|
256
|
+
// arrowhead on it. The second lane used to run its last approach along
|
|
257
|
+
// T's own border (left wall in LR, top wall in TD).
|
|
258
|
+
it.each(CASES)('%s: every node box border is intact', (_n, dir, others) => {
|
|
259
|
+
for (const useAscii of [false, true]) {
|
|
260
|
+
expect(
|
|
261
|
+
boxProblems(
|
|
262
|
+
render(source(dir, others), useAscii),
|
|
263
|
+
useAscii,
|
|
264
|
+
NODE_NAMES.slice(0, 3 + others.length),
|
|
265
|
+
),
|
|
266
|
+
).toEqual([])
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// With the other exits declared first the target of the lane group sits
|
|
271
|
+
// past the trunk, so the second lane takes the target's near (low) side.
|
|
272
|
+
it.each(['TD', 'LR'] as const)(
|
|
273
|
+
'%s, other exits first: lane enters a side face, labels clear',
|
|
274
|
+
(dir) => {
|
|
275
|
+
const src = `flowchart ${dir}
|
|
276
|
+
subgraph S [Cluster]
|
|
277
|
+
a
|
|
278
|
+
end
|
|
279
|
+
Start --> S
|
|
280
|
+
S -->|other| U
|
|
281
|
+
S -->|first| T
|
|
282
|
+
S -->|second| T
|
|
283
|
+
S -->|more| V
|
|
284
|
+
`
|
|
285
|
+
for (const useAscii of [false, true]) {
|
|
286
|
+
const lines = render(src, useAscii)
|
|
287
|
+
expect(boxProblems(lines, useAscii, NODE_NAMES)).toEqual([])
|
|
288
|
+
for (const [r, text] of lines.entries()) {
|
|
289
|
+
const spans = ['other', 'first', 'second', 'more']
|
|
290
|
+
.map((w) => [text.indexOf(w), w.length] as const)
|
|
291
|
+
.filter(([i]) => i >= 0)
|
|
292
|
+
.sort((x, y) => x[0] - y[0])
|
|
293
|
+
for (let i = 1; i < spans.length; i++) {
|
|
294
|
+
const gap = spans[i]![0] - (spans[i - 1]![0] + spans[i - 1]![1])
|
|
295
|
+
expect(gap, `row ${r}`).toBeGreaterThanOrEqual(2)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// Two edge labels on one row need clear line between them, or they
|
|
303
|
+
// read as one run ("other-more").
|
|
304
|
+
it.each(CASES)('%s: no two labels abut on a row', (_n, dir, others) => {
|
|
305
|
+
for (const useAscii of [false, true]) {
|
|
306
|
+
const lines = render(source(dir, others), useAscii)
|
|
307
|
+
const words = ['first', 'second', ...others]
|
|
308
|
+
for (const [r, text] of lines.entries()) {
|
|
309
|
+
const spans = words
|
|
310
|
+
.map((w) => [text.indexOf(w), w.length] as const)
|
|
311
|
+
.filter(([i]) => i >= 0)
|
|
312
|
+
.sort((x, y) => x[0] - y[0])
|
|
313
|
+
for (let i = 1; i < spans.length; i++) {
|
|
314
|
+
const prev = spans[i - 1]!
|
|
315
|
+
const gap = spans[i]![0] - (prev[0] + prev[1])
|
|
316
|
+
expect(
|
|
317
|
+
gap,
|
|
318
|
+
`row ${r} (ascii=${useAscii}):\n${text}`,
|
|
319
|
+
).toBeGreaterThanOrEqual(2)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Each exit's label sits on that exit's own final leg, never on a run
|
|
326
|
+
// another edge shares or owns.
|
|
327
|
+
it.each(LR_CASES)('%s: each label is on its own leg', (_n, dir, others) => {
|
|
328
|
+
const lines = render(source(dir, others))
|
|
329
|
+
const rowOf = (w: string): string => lines.find((l) => l.includes(w))!
|
|
330
|
+
expect(rowOf('first')).toContain('T')
|
|
331
|
+
expect(rowOf('first')).not.toContain('second')
|
|
332
|
+
for (const [i, o] of others.entries()) {
|
|
333
|
+
expect(rowOf(o), o).toContain('UVW'[i]!)
|
|
334
|
+
}
|
|
335
|
+
// The second lane's label is on a row that carries no other label
|
|
336
|
+
// and no node, below T.
|
|
337
|
+
const secondRow = rowOf('second')
|
|
338
|
+
for (const w of ['first', ...others, 'T', 'U', 'V']) {
|
|
339
|
+
expect(secondRow, w).not.toContain(w)
|
|
340
|
+
}
|
|
341
|
+
// ...and it starts a line cell clear of the junction it turns off.
|
|
342
|
+
expect(secondRow[secondRow.indexOf('second') - 1]).toBe('─')
|
|
343
|
+
expect(lines.findIndex((l) => l.includes('second'))).toBeGreaterThan(
|
|
344
|
+
lines.findIndex((l) => l.includes('T')),
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// TD: every exit drops from the shared fan-out row, and nothing is
|
|
349
|
+
// written on that row between the trunk and the last drop - so the
|
|
350
|
+
// second lane visibly leaves the trunk's fan-out, not another edge's
|
|
351
|
+
// labelled run.
|
|
352
|
+
it.each(TD_CASES)(
|
|
353
|
+
'%s: the second lane leaves the unlabelled shared fan-out',
|
|
354
|
+
(_n, dir, others) => {
|
|
355
|
+
for (const useAscii of [false, true]) {
|
|
356
|
+
const lines = render(source(dir, others), useAscii)
|
|
357
|
+
const start = findToken(lines, 'Start')!
|
|
358
|
+
const trunk = start.col + 2
|
|
359
|
+
const second = findToken(lines, 'second')!
|
|
360
|
+
const secondCol = second.col + 3
|
|
361
|
+
const vertical = useAscii ? '|' : '│'
|
|
362
|
+
let r = second.row - 1
|
|
363
|
+
while (lines[r]?.[secondCol] === vertical) r--
|
|
364
|
+
const fanOut = lines[r]!
|
|
365
|
+
expect(fanOut, 'fan-out row').not.toMatch(/[a-z]/)
|
|
366
|
+
expect(fanOut[trunk], 'fan-out row meets the trunk').not.toBe(' ')
|
|
367
|
+
expect(
|
|
368
|
+
fanOut[secondCol],
|
|
369
|
+
'second lane starts on the fan-out row',
|
|
370
|
+
).not.toBe(' ')
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
})
|
|
375
|
+
})
|