@yassimba/pi-loom-mermaid 0.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/LICENSE +21 -0
- package/README.md +28 -0
- package/index.ts +1 -0
- package/package.json +57 -0
- package/src/index.ts +103 -0
- package/src/loom-mermaid/ansi.ts +65 -0
- package/src/loom-mermaid/canvas.ts +466 -0
- package/src/loom-mermaid/class-style.ts +65 -0
- package/src/loom-mermaid/css-colors.ts +153 -0
- package/src/loom-mermaid/diagrams/class.ts +297 -0
- package/src/loom-mermaid/diagrams/er.ts +189 -0
- package/src/loom-mermaid/diagrams/flowchart.ts +519 -0
- package/src/loom-mermaid/diagrams/gitgraph.ts +230 -0
- package/src/loom-mermaid/diagrams/mindmap.ts +107 -0
- package/src/loom-mermaid/diagrams/pie.ts +121 -0
- package/src/loom-mermaid/diagrams/sequence.ts +305 -0
- package/src/loom-mermaid/diagrams/state.ts +279 -0
- package/src/loom-mermaid/diagrams/timeline.ts +117 -0
- package/src/loom-mermaid/graph-render.ts +195 -0
- package/src/loom-mermaid/graph.ts +205 -0
- package/src/loom-mermaid/index.ts +78 -0
- package/src/loom-mermaid/labels.ts +353 -0
- package/src/loom-mermaid/layout-seq.ts +234 -0
- package/src/loom-mermaid/layout.ts +1749 -0
- package/src/loom-mermaid/paint.ts +325 -0
- package/src/loom-mermaid/placement.ts +255 -0
- package/src/loom-mermaid/registry.ts +88 -0
- package/src/loom-mermaid/source-box.ts +111 -0
- package/src/loom-mermaid/statements.ts +228 -0
- package/src/loom-mermaid/types.ts +64 -0
- package/src/loom-mermaid/width-data.ts +993 -0
- package/src/loom-mermaid/width.ts +69 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sequenceDiagram`: participants, messages, notes and block dividers.
|
|
3
|
+
* Lenient: an unreadable statement is dropped and recorded in `warnings`.
|
|
4
|
+
*
|
|
5
|
+
* Sequence diagrams have their own model — participants in declaration order
|
|
6
|
+
* plus a flat list of items — and their own geometry in `layout-seq.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MAX_EDGES, MAX_NODES } from '../graph.ts'
|
|
10
|
+
import { asciiLower, cleanLabel, decodeHtmlEntities } from '../labels.ts'
|
|
11
|
+
import { layoutSequence } from '../layout-seq.ts'
|
|
12
|
+
import type { Diagram } from '../registry.ts'
|
|
13
|
+
import { firstWord, headerKind, nonEmpty, splitOnce, statementsOf } from '../statements.ts'
|
|
14
|
+
|
|
15
|
+
export const sequence: Diagram = {
|
|
16
|
+
kind: 'sequence',
|
|
17
|
+
headers: ['sequencediagram'],
|
|
18
|
+
render(src, limits) {
|
|
19
|
+
const seq = parseSequence(src)
|
|
20
|
+
if (seq === null) return null
|
|
21
|
+
const canvas = layoutSequence(seq, limits)
|
|
22
|
+
if (canvas === null) return null
|
|
23
|
+
return { canvas, warnings: seq.warnings, classDefs: {} }
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SeqHead = 'arrow' | 'cross'
|
|
28
|
+
|
|
29
|
+
/** Message operators, longest-first so `-->>` wins over `-->`. */
|
|
30
|
+
const SEQ_OPS: [string, boolean, SeqHead][] = [
|
|
31
|
+
['-->>', true, 'arrow'],
|
|
32
|
+
['->>', false, 'arrow'],
|
|
33
|
+
['--x', true, 'cross'],
|
|
34
|
+
['-x', false, 'cross'],
|
|
35
|
+
['--)', true, 'arrow'],
|
|
36
|
+
['-)', false, 'arrow'],
|
|
37
|
+
['-->', true, 'arrow'],
|
|
38
|
+
['->', false, 'arrow'],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const MAX_SEQ_OP = 4
|
|
42
|
+
|
|
43
|
+
export type NoteAnchor =
|
|
44
|
+
| { kind: 'over'; from: number; to: number }
|
|
45
|
+
| { kind: 'left'; at: number }
|
|
46
|
+
| { kind: 'right'; at: number }
|
|
47
|
+
|
|
48
|
+
export type SeqItem =
|
|
49
|
+
| {
|
|
50
|
+
kind: 'message'
|
|
51
|
+
from: number
|
|
52
|
+
to: number
|
|
53
|
+
text: string | null
|
|
54
|
+
dashed: boolean
|
|
55
|
+
head: SeqHead
|
|
56
|
+
}
|
|
57
|
+
| { kind: 'note'; anchor: NoteAnchor; text: string }
|
|
58
|
+
| { kind: 'divider'; text: string }
|
|
59
|
+
|
|
60
|
+
/** A hot span of one lifeline: item indices, `to === null` while still open. */
|
|
61
|
+
interface Activation {
|
|
62
|
+
at: number
|
|
63
|
+
from: number
|
|
64
|
+
to: number | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class Sequence {
|
|
68
|
+
labels: string[] = []
|
|
69
|
+
index = new Map<string, number>()
|
|
70
|
+
items: SeqItem[] = []
|
|
71
|
+
activations: Activation[] = []
|
|
72
|
+
/** Set when a size cap was hit; the parser stops and renders the prefix. */
|
|
73
|
+
truncated: string | null = null
|
|
74
|
+
/** Statements the grammar could not read and dropped. */
|
|
75
|
+
warnings: string[] = []
|
|
76
|
+
|
|
77
|
+
participant(id: string, label: string | null): number | null {
|
|
78
|
+
const existing = this.index.get(id)
|
|
79
|
+
if (existing !== undefined) {
|
|
80
|
+
if (label !== null) this.labels[existing] = label
|
|
81
|
+
return existing
|
|
82
|
+
}
|
|
83
|
+
if (this.labels.length >= MAX_NODES) {
|
|
84
|
+
this.truncated ??= `participant cap (${MAX_NODES}) reached`
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
this.index.set(id, this.labels.length)
|
|
88
|
+
this.labels.push(label ?? id)
|
|
89
|
+
return this.labels.length - 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Append an item, or flag `truncated` when the item cap is reached. */
|
|
93
|
+
pushItem(item: SeqItem): void {
|
|
94
|
+
if (this.items.length >= MAX_EDGES) this.truncated ??= `item cap (${MAX_EDGES}) reached`
|
|
95
|
+
else this.items.push(item)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Record an unreadable statement; skipped once truncated. */
|
|
99
|
+
drop(st: string): void {
|
|
100
|
+
if (this.truncated === null) this.warnings.push(`dropped, unreadable statement: "${st}"`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Open an activation at item `from`. */
|
|
104
|
+
activate(at: number, from: number): void {
|
|
105
|
+
this.activations.push({ at, from, to: null })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Close the innermost open activation of `at` at item `to`. */
|
|
109
|
+
deactivate(at: number, to: number): void {
|
|
110
|
+
for (let i = this.activations.length - 1; i >= 0; i--) {
|
|
111
|
+
const a = this.activations[i]
|
|
112
|
+
if (a.at === at && a.to === null) {
|
|
113
|
+
a.to = to
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseSequence(src: string): Sequence | null {
|
|
121
|
+
const statements = statementsOf(src)
|
|
122
|
+
const kind = headerKind(statements)
|
|
123
|
+
if (kind === null || !sequence.headers.includes(kind)) return null
|
|
124
|
+
|
|
125
|
+
const seq = new Sequence()
|
|
126
|
+
let autonumber = false
|
|
127
|
+
let msgCount = 0
|
|
128
|
+
/** One entry per open block; `true` when it draws a divider on `end`. */
|
|
129
|
+
const blocks: boolean[] = []
|
|
130
|
+
|
|
131
|
+
for (const st of statements.slice(1)) {
|
|
132
|
+
const first = firstWord(st)
|
|
133
|
+
const lower = asciiLower(first)
|
|
134
|
+
|
|
135
|
+
if (lower === 'participant' || lower === 'actor') {
|
|
136
|
+
const rest = st.slice(first.length).trim()
|
|
137
|
+
if (rest === '') {
|
|
138
|
+
seq.drop(st)
|
|
139
|
+
} else {
|
|
140
|
+
const as = splitOnce(rest, ' as ')
|
|
141
|
+
seq.participant(as ? as[0].trim() : rest, as ? cleanLabel(as[1]) : null)
|
|
142
|
+
}
|
|
143
|
+
} else if (lower === 'autonumber') {
|
|
144
|
+
autonumber = true
|
|
145
|
+
} else if (lower === 'activate' || lower === 'deactivate') {
|
|
146
|
+
// Applies to the preceding message's row.
|
|
147
|
+
const who = seq.index.get(st.slice(first.length).trim())
|
|
148
|
+
if (who !== undefined && seq.items.length > 0) {
|
|
149
|
+
if (lower === 'activate') seq.activate(who, seq.items.length - 1)
|
|
150
|
+
else seq.deactivate(who, seq.items.length - 1)
|
|
151
|
+
}
|
|
152
|
+
} else if (
|
|
153
|
+
[
|
|
154
|
+
'create',
|
|
155
|
+
'destroy',
|
|
156
|
+
'title',
|
|
157
|
+
'acctitle',
|
|
158
|
+
'accdescr',
|
|
159
|
+
'links',
|
|
160
|
+
'link',
|
|
161
|
+
'properties',
|
|
162
|
+
].includes(lower)
|
|
163
|
+
) {
|
|
164
|
+
// No layout meaning.
|
|
165
|
+
} else if (lower === 'note') {
|
|
166
|
+
const note = parseNoteAnchor(st.slice(first.length).trim(), seq)
|
|
167
|
+
if (!note) seq.drop(st)
|
|
168
|
+
else seq.pushItem({ kind: 'note', anchor: note.anchor, text: note.text })
|
|
169
|
+
} else if (
|
|
170
|
+
['loop', 'alt', 'opt', 'par', 'critical', 'break', 'else', 'and', 'option'].includes(lower)
|
|
171
|
+
) {
|
|
172
|
+
// A continuation only divides a block that opened one.
|
|
173
|
+
const continues = ['else', 'and', 'option'].includes(lower)
|
|
174
|
+
if (!continues) blocks.push(true)
|
|
175
|
+
if (!continues || blocks.at(-1) === true) {
|
|
176
|
+
seq.pushItem({ kind: 'divider', text: decodeHtmlEntities(st) })
|
|
177
|
+
}
|
|
178
|
+
} else if (lower === 'rect' || lower === 'box') {
|
|
179
|
+
blocks.push(false)
|
|
180
|
+
} else if (lower === 'end') {
|
|
181
|
+
if (blocks.pop() === true) seq.pushItem({ kind: 'divider', text: 'end' })
|
|
182
|
+
} else {
|
|
183
|
+
const msg = parseSeqMessage(st, seq)
|
|
184
|
+
if (!msg) {
|
|
185
|
+
seq.drop(st)
|
|
186
|
+
} else {
|
|
187
|
+
let text = msg.text
|
|
188
|
+
if (autonumber) {
|
|
189
|
+
msgCount++
|
|
190
|
+
text = text === null ? `${msgCount}.` : `${msgCount}. ${text}`
|
|
191
|
+
}
|
|
192
|
+
const item = seq.items.length
|
|
193
|
+
seq.pushItem({
|
|
194
|
+
kind: 'message',
|
|
195
|
+
from: msg.from,
|
|
196
|
+
to: msg.to,
|
|
197
|
+
text,
|
|
198
|
+
dashed: msg.dashed,
|
|
199
|
+
head: msg.head,
|
|
200
|
+
})
|
|
201
|
+
// `+` activates the receiver on this row; `-` deactivates the sender.
|
|
202
|
+
// Only once the item was accepted — an activation pointing past the
|
|
203
|
+
// item cap would dereference a message that does not exist.
|
|
204
|
+
if (seq.items.length === item + 1) {
|
|
205
|
+
if (msg.marks.includes('+')) seq.activate(msg.to, item)
|
|
206
|
+
if (msg.marks.includes('-')) seq.deactivate(msg.from, item)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (seq.truncated !== null) {
|
|
211
|
+
seq.warnings.push(`diagram truncated: ${seq.truncated}`)
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return seq.labels.length === 0 ? null : seq
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseNoteAnchor(rest: string, seq: Sequence): { text: string; anchor: NoteAnchor } | null {
|
|
220
|
+
const lower = asciiLower(rest)
|
|
221
|
+
let kind: NoteAnchor['kind']
|
|
222
|
+
let idsAndText: string
|
|
223
|
+
if (lower.startsWith('over ')) {
|
|
224
|
+
kind = 'over'
|
|
225
|
+
idsAndText = rest.slice('over '.length)
|
|
226
|
+
} else if (lower.startsWith('left of ')) {
|
|
227
|
+
kind = 'left'
|
|
228
|
+
idsAndText = rest.slice('left of '.length)
|
|
229
|
+
} else if (lower.startsWith('right of ')) {
|
|
230
|
+
kind = 'right'
|
|
231
|
+
idsAndText = rest.slice('right of '.length)
|
|
232
|
+
} else {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const split = splitOnce(idsAndText, ':')
|
|
237
|
+
if (!split) return null
|
|
238
|
+
const text = decodeHtmlEntities(split[1].trim())
|
|
239
|
+
const parts = split[0]
|
|
240
|
+
.split(',')
|
|
241
|
+
.map((s) => s.trim())
|
|
242
|
+
.filter((s) => s !== '')
|
|
243
|
+
if (parts.length === 0) return null
|
|
244
|
+
const a = seq.participant(parts[0], null)
|
|
245
|
+
if (a === null) return null
|
|
246
|
+
|
|
247
|
+
if (kind !== 'over') return { text, anchor: { kind, at: a } }
|
|
248
|
+
let b = a
|
|
249
|
+
if (parts[1] !== undefined) {
|
|
250
|
+
const second = seq.participant(parts[1], null)
|
|
251
|
+
if (second === null) return null
|
|
252
|
+
b = second
|
|
253
|
+
}
|
|
254
|
+
return { text, anchor: { kind: 'over', from: Math.min(a, b), to: Math.max(a, b) } }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseSeqMessage(
|
|
258
|
+
st: string,
|
|
259
|
+
seq: Sequence,
|
|
260
|
+
): {
|
|
261
|
+
from: number
|
|
262
|
+
to: number
|
|
263
|
+
text: string | null
|
|
264
|
+
dashed: boolean
|
|
265
|
+
head: SeqHead
|
|
266
|
+
marks: string
|
|
267
|
+
} | null {
|
|
268
|
+
const chars = [...st]
|
|
269
|
+
let found: { pos: number; op: string; dashed: boolean; head: SeqHead } | null = null
|
|
270
|
+
outer: for (let pos = 0; pos < chars.length; pos++) {
|
|
271
|
+
const tail = chars.slice(pos, pos + MAX_SEQ_OP).join('')
|
|
272
|
+
for (const [op, dashed, head] of SEQ_OPS) {
|
|
273
|
+
if (tail.startsWith(op)) {
|
|
274
|
+
// `-x` / `-)` embedded in a hyphenated token (`pre-x->>B`) is not an
|
|
275
|
+
// operator — the real one follows. Require a non-link char after.
|
|
276
|
+
const after = chars[pos + op.length]
|
|
277
|
+
if ((op.endsWith('x') || op.endsWith(')')) && (after === '-' || after === '>')) continue
|
|
278
|
+
found = { pos, op, dashed, head }
|
|
279
|
+
break outer
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!found) return null
|
|
284
|
+
|
|
285
|
+
const fromId = chars.slice(0, found.pos).join('').trim()
|
|
286
|
+
if (fromId === '') return null
|
|
287
|
+
// `+` activates the receiver, `-` deactivates the sender.
|
|
288
|
+
const afterOp = chars
|
|
289
|
+
.slice(found.pos + [...found.op].length)
|
|
290
|
+
.join('')
|
|
291
|
+
.trimStart()
|
|
292
|
+
const marks = afterOp.match(/^[+-]+/)?.[0] ?? ''
|
|
293
|
+
const rest = afterOp.slice(marks.length)
|
|
294
|
+
|
|
295
|
+
const split = splitOnce(rest, ':')
|
|
296
|
+
const toId = (split ? split[0] : rest).trim()
|
|
297
|
+
const text = split ? nonEmpty(decodeHtmlEntities(split[1].trim())) : null
|
|
298
|
+
if (toId === '') return null
|
|
299
|
+
|
|
300
|
+
const from = seq.participant(fromId, null)
|
|
301
|
+
if (from === null) return null
|
|
302
|
+
const to = seq.participant(toId, null)
|
|
303
|
+
if (to === null) return null
|
|
304
|
+
return { from, to, text, dashed: found.dashed, head: found.head, marks }
|
|
305
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `stateDiagram` / `stateDiagram-v2`: states, transitions, descriptions and
|
|
3
|
+
* composite states. Lenient: an unreadable statement is dropped and recorded
|
|
4
|
+
* in `warnings`.
|
|
5
|
+
*
|
|
6
|
+
* A composite (`state X { ... }`) becomes a `Group`, drawn as a titled frame
|
|
7
|
+
* by the same machinery flowchart subgraphs use; `--` splits a composite into
|
|
8
|
+
* unlabelled sibling region groups. `[*]` is scoped per group, so an inner
|
|
9
|
+
* start dot is a different node from the outer one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Graph, MAX_GROUP_DEPTH, MAX_GROUPS, parseDir, type Shape } from '../graph.ts'
|
|
13
|
+
import { asciiLower, decodeHtmlEntities } from '../labels.ts'
|
|
14
|
+
import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
|
|
15
|
+
import type { Diagram } from '../registry.ts'
|
|
16
|
+
import {
|
|
17
|
+
firstWord,
|
|
18
|
+
headerKind,
|
|
19
|
+
nonEmpty,
|
|
20
|
+
parseClassAssign,
|
|
21
|
+
parseClassDef,
|
|
22
|
+
splitColon,
|
|
23
|
+
splitOnce,
|
|
24
|
+
statementsOf,
|
|
25
|
+
takeTags,
|
|
26
|
+
words,
|
|
27
|
+
} from '../statements.ts'
|
|
28
|
+
|
|
29
|
+
export const state: Diagram = {
|
|
30
|
+
kind: 'state',
|
|
31
|
+
headers: ['statediagram', 'statediagram-v2'],
|
|
32
|
+
render(src, limits) {
|
|
33
|
+
const graph = parseState(src)
|
|
34
|
+
if (graph === null) return null
|
|
35
|
+
const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
|
|
36
|
+
if (canvas === null) return null
|
|
37
|
+
return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseState(src: string): Graph | null {
|
|
42
|
+
const statements = statementsOf(src)
|
|
43
|
+
const kind = headerKind(statements)
|
|
44
|
+
if (kind === null || !state.headers.includes(kind)) return null
|
|
45
|
+
|
|
46
|
+
const graph = new Graph()
|
|
47
|
+
let inNote = false
|
|
48
|
+
/** `class A,B name` assignments, applied after the walk. */
|
|
49
|
+
const classAssignments: [string[], string[]][] = []
|
|
50
|
+
/** Open composites: the group itself and its current `--` region, if any. */
|
|
51
|
+
const stack: { base: number; region: number | null }[] = []
|
|
52
|
+
|
|
53
|
+
/** New group under the current scope, or `null` once a cap is hit. */
|
|
54
|
+
const newGroup = (id: string, label: string): number | null => {
|
|
55
|
+
if (graph.groups.length >= MAX_GROUPS || stack.length >= MAX_GROUP_DEPTH) {
|
|
56
|
+
graph.truncated ??= `subgraph cap (${MAX_GROUPS} groups, depth ${MAX_GROUP_DEPTH}) reached`
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
graph.groups.push({ id, label, parent: graph.curGroup })
|
|
60
|
+
return graph.groups.length - 1
|
|
61
|
+
}
|
|
62
|
+
const scopeOf = (): number | null => {
|
|
63
|
+
const top = stack.at(-1)
|
|
64
|
+
return top === undefined ? null : (top.region ?? top.base)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const st of statements.slice(1)) {
|
|
68
|
+
if (inNote) {
|
|
69
|
+
if (asciiLower(st) === 'end note') inNote = false
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const first = asciiLower(firstWord(st))
|
|
73
|
+
if (first === 'direction') {
|
|
74
|
+
graph.dir = parseDir(words(st)[1] ?? '')
|
|
75
|
+
} else if (first === 'note') {
|
|
76
|
+
// A single-line `note ... : text` needs no terminator.
|
|
77
|
+
if (!st.includes(':')) inNote = true
|
|
78
|
+
} else if (first === 'state') {
|
|
79
|
+
const rest = st.slice(firstWord(st).length).trim()
|
|
80
|
+
const open = rest.endsWith('{')
|
|
81
|
+
const body = open ? rest.slice(0, -1).trim() : rest
|
|
82
|
+
if (!open) {
|
|
83
|
+
if (parseStateDecl(body, graph) === null) graph.drop(st)
|
|
84
|
+
} else {
|
|
85
|
+
// A composite. An unreadable declaration still opens an anonymous
|
|
86
|
+
// frame: the `{` was consumed, so the `}` balance must hold.
|
|
87
|
+
const named = compositeName(body)
|
|
88
|
+
if (named === null) graph.drop(st)
|
|
89
|
+
const gi = newGroup(named?.id ?? `anon ${graph.groups.length}`, named?.label ?? '')
|
|
90
|
+
if (gi !== null) {
|
|
91
|
+
stack.push({ base: gi, region: null })
|
|
92
|
+
graph.curGroup = gi
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else if (first === '}') {
|
|
96
|
+
stack.pop()
|
|
97
|
+
graph.curGroup = scopeOf()
|
|
98
|
+
} else if (first === '--') {
|
|
99
|
+
// Region divider: members so far move into region 1 on the first `--`;
|
|
100
|
+
// each divider opens the next unlabelled sibling region.
|
|
101
|
+
const top = stack.at(-1)
|
|
102
|
+
if (top !== undefined) {
|
|
103
|
+
if (top.region === null) {
|
|
104
|
+
const r1 = newGroup(`region ${graph.groups.length}`, '')
|
|
105
|
+
if (r1 !== null) {
|
|
106
|
+
graph.nodeGroup.forEach((g, i) => {
|
|
107
|
+
if (g === top.base) graph.nodeGroup[i] = r1
|
|
108
|
+
})
|
|
109
|
+
graph.groups.forEach((g, i) => {
|
|
110
|
+
if (i !== r1 && g.parent === top.base) g.parent = r1
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
graph.curGroup = top.base
|
|
115
|
+
const next = newGroup(`region ${graph.groups.length}`, '')
|
|
116
|
+
top.region = next
|
|
117
|
+
graph.curGroup = next ?? top.base
|
|
118
|
+
}
|
|
119
|
+
} else if (first === 'classdef') {
|
|
120
|
+
const def = parseClassDef(st.slice(firstWord(st).length))
|
|
121
|
+
if (def) for (const name of def.names) graph.classDefs[name] = def.props
|
|
122
|
+
} else if (first === 'class') {
|
|
123
|
+
const assign = parseClassAssign(st.slice(firstWord(st).length))
|
|
124
|
+
if (assign) classAssignments.push(assign)
|
|
125
|
+
} else if (['hide', 'scale'].includes(first)) {
|
|
126
|
+
// Styling directives carry no layout meaning.
|
|
127
|
+
} else if (st.includes('-->')) {
|
|
128
|
+
if (parseTransition(st, graph) === null) graph.drop(st)
|
|
129
|
+
} else if (parseStateDesc(st, graph) === null) {
|
|
130
|
+
graph.drop(st)
|
|
131
|
+
}
|
|
132
|
+
if (graph.truncated !== null) {
|
|
133
|
+
graph.warnings.push(`diagram truncated: ${graph.truncated}`)
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
graph.applyClasses(classAssignments)
|
|
139
|
+
|
|
140
|
+
return graph.nodes.length === 0 ? null : graph
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The id and label of a composite declaration body, or `null`. */
|
|
144
|
+
function compositeName(body: string): { id: string; label: string } | null {
|
|
145
|
+
if (body.startsWith('"')) {
|
|
146
|
+
const close = body.indexOf('"', 1)
|
|
147
|
+
if (close === -1) return null
|
|
148
|
+
const label = decodeHtmlEntities(body.slice(1, close))
|
|
149
|
+
const after = body.slice(close + 1).trim()
|
|
150
|
+
// A `:::` tag on a composite is dropped: groups paint no classed cells.
|
|
151
|
+
const id = takeTags(after.startsWith('as') ? after.slice(2).trim() : label).id
|
|
152
|
+
return id === '' ? null : { id, label }
|
|
153
|
+
}
|
|
154
|
+
// A stereotype on a composite carries no drawing of its own; keep the name.
|
|
155
|
+
const id = takeTags(body.split('<<')[0].trim()).id
|
|
156
|
+
return id === '' || /\s/.test(id) ? null : { id, label: id }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `state "Label" as id` or `state id <<choice>>` — the non-composite forms. */
|
|
160
|
+
function parseStateDecl(rest: string, graph: Graph): true | null {
|
|
161
|
+
if (rest === '') return true
|
|
162
|
+
|
|
163
|
+
if (rest.startsWith('"')) {
|
|
164
|
+
const close = rest.indexOf('"', 1)
|
|
165
|
+
if (close === -1) return null
|
|
166
|
+
const label = rest.slice(1, close)
|
|
167
|
+
const after = rest.slice(close + 1).trim()
|
|
168
|
+
const { id, classes } = takeTags(after.startsWith('as') ? after.slice(2).trim() : label)
|
|
169
|
+
const idx = graph.nodeLabel(id, decodeHtmlEntities(label))
|
|
170
|
+
if (idx === null) return null
|
|
171
|
+
for (const cls of classes) graph.addClass(idx, cls)
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let shape: Shape = 'round'
|
|
176
|
+
let token = rest
|
|
177
|
+
let stereotyped = false
|
|
178
|
+
const pos = rest.indexOf('<<')
|
|
179
|
+
if (pos !== -1) {
|
|
180
|
+
const stereo = rest
|
|
181
|
+
.slice(pos + 2)
|
|
182
|
+
.replace(/>>$/, '')
|
|
183
|
+
.trim()
|
|
184
|
+
if (stereo === 'choice') shape = 'diamond'
|
|
185
|
+
token = rest.slice(0, pos).trim()
|
|
186
|
+
stereotyped = true
|
|
187
|
+
}
|
|
188
|
+
const { id, classes } = takeTags(token)
|
|
189
|
+
if (id === '' || /\s/.test(id)) return null
|
|
190
|
+
const idx = graph.nodeIndex(id, stereotyped ? id : null, shape)
|
|
191
|
+
if (idx === null) return null
|
|
192
|
+
for (const cls of classes) graph.addClass(idx, cls)
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `A --> B: label`, including chains `A --> B --> C`. */
|
|
197
|
+
function parseTransition(st: string, graph: Graph): true | null {
|
|
198
|
+
let rest = st
|
|
199
|
+
let prev: number | null = null
|
|
200
|
+
|
|
201
|
+
for (;;) {
|
|
202
|
+
const split = splitOnce(rest, '-->')
|
|
203
|
+
if (!split) break
|
|
204
|
+
const [lhs, rhs] = split
|
|
205
|
+
|
|
206
|
+
const fromTok = takeTags(lhs.trimEnd().replace(/-+$/, '').trim())
|
|
207
|
+
let from: number
|
|
208
|
+
if (prev !== null) {
|
|
209
|
+
// Mid-chain: the source is the previous target, so nothing may precede.
|
|
210
|
+
if (fromTok.id !== '') return null
|
|
211
|
+
from = prev
|
|
212
|
+
} else {
|
|
213
|
+
if (fromTok.id === '') return null
|
|
214
|
+
const f = stateEndpoint(graph, fromTok.id, true)
|
|
215
|
+
if (f === null) return null
|
|
216
|
+
for (const cls of fromTok.classes) graph.addClass(f, cls)
|
|
217
|
+
from = f
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// After the label colon it is all label — mermaid never chains past a
|
|
221
|
+
// label, so an arrow inside one (`: go "x --> y"`) is text, not a link.
|
|
222
|
+
const nextArrow = rhs.indexOf('-->')
|
|
223
|
+
const colon = splitColon(rhs)
|
|
224
|
+
const labelFirst = colon !== null && (nextArrow === -1 || colon[0].length < nextArrow)
|
|
225
|
+
const toPart = labelFirst ? colon[0] : nextArrow === -1 ? rhs : rhs.slice(0, nextArrow)
|
|
226
|
+
const label = labelFirst ? nonEmpty(decodeHtmlEntities(colon[1].trim())) : null
|
|
227
|
+
const tail = labelFirst || nextArrow === -1 ? '' : rhs.slice(nextArrow)
|
|
228
|
+
|
|
229
|
+
const toTok = takeTags(
|
|
230
|
+
toPart.trimStart().replace(/^>+/, '').trimEnd().replace(/-+$/, '').trim(),
|
|
231
|
+
)
|
|
232
|
+
if (toTok.id === '') return null
|
|
233
|
+
const to = stateEndpoint(graph, toTok.id, false)
|
|
234
|
+
if (to === null) return null
|
|
235
|
+
for (const cls of toTok.classes) graph.addClass(to, cls)
|
|
236
|
+
|
|
237
|
+
if (!graph.pushEdge({ from, to, label, headTo: 'arrow', headFrom: 'none', line: 'solid' })) {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
prev = to
|
|
241
|
+
rest = tail
|
|
242
|
+
}
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `[*]` is start or end depending on which side of the arrow it sits, and is
|
|
248
|
+
* scoped to the enclosing composite — each frame has its own start and end.
|
|
249
|
+
*/
|
|
250
|
+
function stateEndpoint(graph: Graph, id: string, isSource: boolean): number | null {
|
|
251
|
+
if (id === '[*]') {
|
|
252
|
+
const scope = graph.curGroup === null ? '' : ` g${graph.curGroup}`
|
|
253
|
+
return graph.nodeIndex(`[*]${isSource ? 'start' : 'end'}${scope}`, '●', 'round')
|
|
254
|
+
}
|
|
255
|
+
return graph.nodeIndex(id, null, 'round')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `id: description`, or a bare state name; either may carry `:::` tags. */
|
|
259
|
+
function parseStateDesc(st: string, graph: Graph): true | null {
|
|
260
|
+
const split = splitColon(st)
|
|
261
|
+
const { id, classes } = takeTags((split ? split[0] : st).trim())
|
|
262
|
+
let idx: number | null
|
|
263
|
+
if (split) {
|
|
264
|
+
const desc = split[1].trim()
|
|
265
|
+
if (id === '' || /\s/.test(id) || desc === '') return null
|
|
266
|
+
// Repeated descriptions accumulate (mermaid stacks them as lines; here
|
|
267
|
+
// they join and wrap). A bare node's label is its id — that one replaces.
|
|
268
|
+
const prev = graph.index.get(id)
|
|
269
|
+
const before = prev === undefined ? null : graph.nodes[prev].label
|
|
270
|
+
const text = decodeHtmlEntities(desc)
|
|
271
|
+
idx = graph.nodeLabel(id, before !== null && before !== id ? `${before} ${text}` : text)
|
|
272
|
+
} else {
|
|
273
|
+
if (id === '' || /\s/.test(id)) return null
|
|
274
|
+
idx = graph.nodeIndex(id, null, 'round')
|
|
275
|
+
}
|
|
276
|
+
if (idx === null) return null
|
|
277
|
+
for (const cls of classes) graph.addClass(idx, cls)
|
|
278
|
+
return true
|
|
279
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `timeline`: periods and their events as a vertical list — one row per
|
|
3
|
+
* event, the period named on its first row. Lenient: an unreadable statement
|
|
4
|
+
* is dropped and recorded in `warnings`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Canvas, drawText } from '../canvas.ts'
|
|
8
|
+
import { MAX_EDGES } from '../graph.ts'
|
|
9
|
+
import { decodeHtmlEntities, fitLabel, type Limits } from '../labels.ts'
|
|
10
|
+
import type { Diagram } from '../registry.ts'
|
|
11
|
+
import { headerKind, nonEmpty, statementsOf, words } from '../statements.ts'
|
|
12
|
+
import { stringWidth } from '../width.ts'
|
|
13
|
+
|
|
14
|
+
/** One output row: a period cell (blank on continuations) and an event. */
|
|
15
|
+
interface Row {
|
|
16
|
+
period: string
|
|
17
|
+
event: string
|
|
18
|
+
/** Section headers occupy a row of their own. */
|
|
19
|
+
section?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const timeline: Diagram = {
|
|
23
|
+
kind: 'timeline',
|
|
24
|
+
headers: ['timeline'],
|
|
25
|
+
render(src, limits) {
|
|
26
|
+
const parsed = parseTimeline(src, limits)
|
|
27
|
+
if (parsed === null) return null
|
|
28
|
+
const { title, rows, warnings } = parsed
|
|
29
|
+
|
|
30
|
+
const periodW = Math.max(...rows.map((r) => (r.section ? 0 : stringWidth(r.period))))
|
|
31
|
+
const top = title === null ? 0 : 1
|
|
32
|
+
const width = Math.max(
|
|
33
|
+
title === null ? 0 : stringWidth(title) + 6,
|
|
34
|
+
...rows.map((r) => (r.section ? stringWidth(r.event) : periodW + 3 + stringWidth(r.event))),
|
|
35
|
+
)
|
|
36
|
+
const canvas = new Canvas(width, top + rows.length)
|
|
37
|
+
|
|
38
|
+
if (title !== null) {
|
|
39
|
+
const t = ` ${title} `
|
|
40
|
+
const x = Math.max(0, Math.floor((width - stringWidth(t) - 4) / 2))
|
|
41
|
+
drawText(canvas, '──', x, 0, 'edge')
|
|
42
|
+
drawText(canvas, t, x + 2, 0, 'title')
|
|
43
|
+
drawText(canvas, '──', x + 2 + stringWidth(t), 0, 'edge')
|
|
44
|
+
}
|
|
45
|
+
rows.forEach((row, i) => {
|
|
46
|
+
const y = top + i
|
|
47
|
+
if (row.section) {
|
|
48
|
+
drawText(canvas, row.event, 0, y, 'title')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
drawText(canvas, row.period, 0, y, 'text')
|
|
52
|
+
if (row.event === '') return
|
|
53
|
+
drawText(canvas, '─', periodW + 1, y, 'edge')
|
|
54
|
+
drawText(canvas, row.event, periodW + 3, y, 'edgeLabel')
|
|
55
|
+
})
|
|
56
|
+
return { canvas, warnings, classDefs: {} }
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseTimeline(
|
|
61
|
+
src: string,
|
|
62
|
+
limits: Limits,
|
|
63
|
+
): { title: string | null; rows: Row[]; warnings: string[] } | null {
|
|
64
|
+
const statements = statementsOf(src)
|
|
65
|
+
if (headerKind(statements) !== 'timeline') return null
|
|
66
|
+
|
|
67
|
+
let title: string | null = null
|
|
68
|
+
const rows: Row[] = []
|
|
69
|
+
const warnings: string[] = []
|
|
70
|
+
let truncated = false
|
|
71
|
+
let lastPeriod = false
|
|
72
|
+
|
|
73
|
+
for (const st of statements.slice(1)) {
|
|
74
|
+
if (rows.length >= MAX_EDGES) {
|
|
75
|
+
truncated = true
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
const first = words(st)[0]?.toLowerCase()
|
|
79
|
+
if (first === 'title') {
|
|
80
|
+
title = nonEmpty(st.slice(st.toLowerCase().indexOf('title') + 5).trim())
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (first === 'section') {
|
|
84
|
+
const name = st.slice(st.toLowerCase().indexOf('section') + 7).trim()
|
|
85
|
+
rows.push({ period: '', event: clean(name, limits.label), section: true })
|
|
86
|
+
lastPeriod = false
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
// `period : event : event`; a statement of only `: event`s continues the
|
|
90
|
+
// previous period (the `;`-split form of mermaid's multi-line events).
|
|
91
|
+
const parts = st.split(':').map((p) => clean(p, limits.label))
|
|
92
|
+
const period = parts.shift() ?? ''
|
|
93
|
+
if (period === '' && parts.length > 0 && lastPeriod) {
|
|
94
|
+
for (const event of parts) rows.push({ period: '', event })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
// A bare period renders event-less, as mermaid draws it.
|
|
98
|
+
if (period !== '' && parts.length === 0) {
|
|
99
|
+
rows.push({ period, event: '' })
|
|
100
|
+
lastPeriod = true
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (period === '' || parts.some((p) => p === '')) {
|
|
104
|
+
warnings.push(`dropped, unreadable statement: "${st}"`)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
parts.forEach((event, i) => {
|
|
108
|
+
rows.push({ period: i === 0 ? period : '', event })
|
|
109
|
+
})
|
|
110
|
+
lastPeriod = true
|
|
111
|
+
}
|
|
112
|
+
if (truncated) warnings.push(`diagram truncated: event cap (${MAX_EDGES}) reached`)
|
|
113
|
+
|
|
114
|
+
return rows.length === 0 ? null : { title, rows, warnings }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const clean = (s: string, max: number): string => fitLabel(decodeHtmlEntities(s.trim()), max)
|