@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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `gitGraph`: commit lanes, drawn the way `git log --graph` draws them —
3
+ * newest commit on top, one column per branch, connector rows where history
4
+ * splits. Lenient: an unreadable statement is dropped and recorded in
5
+ * `warnings`.
6
+ *
7
+ * Connector rows go through the canvas direction bits, so a merge reaching
8
+ * across an active lane crosses it with a `┼` instead of erasing it.
9
+ */
10
+
11
+ import { Canvas, D, drawText, U } from '../canvas.ts'
12
+ import { MAX_EDGES } from '../graph.ts'
13
+ import { cleanLabel, fitLabel, type Limits } from '../labels.ts'
14
+ import type { Diagram } from '../registry.ts'
15
+ import { headerKind, statementsOf, words } from '../statements.ts'
16
+ import { stringWidth } from '../width.ts'
17
+
18
+ interface GitCommit {
19
+ lane: number
20
+ id: string
21
+ tag: string | null
22
+ /** Lane this commit merged in, if it is a merge commit. */
23
+ mergeFrom: number | null
24
+ }
25
+
26
+ export const gitgraph: Diagram = {
27
+ kind: 'gitgraph',
28
+ headers: ['gitgraph', 'gitgraph:'],
29
+ render(src, limits) {
30
+ const model = parseGitGraph(src, limits)
31
+ if (model === null) return null
32
+ const { branches, commits, forkAt, warnings } = model
33
+ const laneCount = branches.length
34
+
35
+ // The newest commit of each lane wears the branch name.
36
+ const headOf = new Map<number, number>()
37
+ commits.forEach((c, i) => {
38
+ headOf.set(c.lane, i)
39
+ })
40
+
41
+ /** Rows, top-down: commit rows interleaved with connector rows.
42
+ * `open` hangs a merged lane off its merge commit, `close` returns a
43
+ * forked lane to its parent at its fork point. Lanes never used (a
44
+ * branch with no commits that nothing merged) simply never open. */
45
+ type GitRow =
46
+ | { kind: 'commit'; at: number }
47
+ | { kind: 'open' | 'close'; parent: number; lane: number }
48
+ const rows: GitRow[] = []
49
+ const used = branches.map(
50
+ (_, lane) => commits.some((c) => c.lane === lane || c.mergeFrom === lane) || lane === 0,
51
+ )
52
+ for (let i = commits.length - 1; i >= 0; i--) {
53
+ const c = commits[i]
54
+ rows.push({ kind: 'commit', at: i })
55
+ if (c.mergeFrom !== null) {
56
+ rows.push({ kind: 'open', parent: c.lane, lane: c.mergeFrom })
57
+ }
58
+ // Close outer lanes first so an inner close still sees them as columns.
59
+ for (let lane = branches.length - 1; lane > 0; lane--) {
60
+ if (forkAt[lane] === i - 1 && used[lane]) {
61
+ rows.push({ kind: 'close', parent: commits[i - 1].lane, lane })
62
+ }
63
+ }
64
+ }
65
+
66
+ const laneX = (lane: number): number => lane * 2
67
+ const graphW = laneCount * 2
68
+ const labels = rows.map((row) => {
69
+ if (row.kind !== 'commit') return null
70
+ const c = commits[row.at]
71
+ const parts: [string, 'text' | 'edgeLabel'][] = c.id === '' ? [] : [[c.id, 'text']]
72
+ if (headOf.get(c.lane) === row.at) parts.push([`(${branches[c.lane]})`, 'edgeLabel'])
73
+ if (c.tag !== null) parts.push([`[${c.tag}]`, 'edgeLabel'])
74
+ if (c.mergeFrom !== null) parts.push([`⇐ ${branches[c.mergeFrom]}`, 'edgeLabel'])
75
+ return parts
76
+ })
77
+ const width =
78
+ graphW +
79
+ Math.max(
80
+ 1,
81
+ ...labels.map((parts) =>
82
+ parts === null ? 0 : parts.reduce((w, [t]) => w + stringWidth(t) + 1, 0) - 1,
83
+ ),
84
+ )
85
+ const canvas = new Canvas(width, rows.length)
86
+
87
+ // Walk top-down with the set of lanes currently drawn as columns: a lane
88
+ // joins at its newest own commit or its `open` connector, and leaves at
89
+ // its `close`. Everything in the set draws `│` through every other row.
90
+ const live = new Set<number>()
91
+ rows.forEach((row, y) => {
92
+ if (row.kind === 'commit') {
93
+ const c = commits[row.at]
94
+ live.add(c.lane)
95
+ for (const lane of live) {
96
+ if (lane !== c.lane) canvas.addBits(laneX(lane), y, U | D)
97
+ }
98
+ canvas.set(laneX(c.lane), y, '●', 'edge')
99
+ let x = graphW
100
+ for (const [text, role] of labels[y] ?? []) {
101
+ drawText(canvas, text, x, y, role)
102
+ x += stringWidth(text) + 1
103
+ }
104
+ return
105
+ }
106
+ // The parent keeps its column, the child lane hooks on toward it, and
107
+ // unrelated live lanes cross the horizontal run as `┼` via the bit merge.
108
+ const { parent, lane } = row
109
+ const rejoins = row.kind === 'open' && live.has(lane)
110
+ if (row.kind === 'open') live.add(lane)
111
+ for (const l of live) {
112
+ if (l !== parent && l !== lane) canvas.addBits(laneX(l), y, U | D)
113
+ }
114
+ // An open hangs off the merge commit directly above; a close bends down
115
+ // into the fork commit below, continuing up only if the parent already
116
+ // had a column here.
117
+ if (row.kind === 'open') canvas.addBits(laneX(parent), y, U | D)
118
+ else canvas.addBits(laneX(parent), y, D | (live.has(parent) ? U : 0))
119
+ live.add(parent)
120
+ canvas.segH(y, laneX(parent), laneX(lane))
121
+ canvas.addBits(laneX(lane), y, (row.kind === 'open' ? D : U) | (rejoins ? U : 0))
122
+ if (row.kind === 'close') live.delete(lane)
123
+ })
124
+
125
+ canvas.finalizeMask()
126
+ return { canvas, warnings, classDefs: {} }
127
+ },
128
+ }
129
+
130
+ function parseGitGraph(
131
+ src: string,
132
+ limits: Limits,
133
+ ): {
134
+ branches: string[]
135
+ commits: GitCommit[]
136
+ forkAt: (number | null)[]
137
+ warnings: string[]
138
+ } | null {
139
+ const statements = statementsOf(src)
140
+ const kind = headerKind(statements)
141
+ if (kind === null || !gitgraph.headers.includes(kind)) return null
142
+
143
+ const branches = ['main']
144
+ const forkAt: (number | null)[] = [null]
145
+ const commits: GitCommit[] = []
146
+ const warnings: string[] = []
147
+ /** Newest commit index per lane — the fork point for branches cut from it. */
148
+ const heads: (number | null)[] = [null]
149
+ let cur = 0
150
+ let auto = 0
151
+ let truncated = false
152
+
153
+ for (const st of statements.slice(1)) {
154
+ if (commits.length >= MAX_EDGES) {
155
+ truncated = true
156
+ break
157
+ }
158
+ const first = words(st)[0]?.toLowerCase() ?? ''
159
+ const rest = st.slice(words(st)[0]?.length ?? 0).trim()
160
+ if (first === 'commit') {
161
+ const attrs = commitAttrs(rest, limits.label)
162
+ heads[cur] = commits.length
163
+ commits.push({ lane: cur, id: attrs.id ?? `c${auto++}`, tag: attrs.tag, mergeFrom: null })
164
+ } else if (first === 'branch') {
165
+ const { name } = nameToken(rest)
166
+ // The fork point is the current branch's head, not the newest commit.
167
+ const fork = heads[cur] ?? forkAt[cur]
168
+ if (name === undefined || branches.includes(name) || fork === null) {
169
+ warnings.push(`dropped, unreadable statement: "${st}"`)
170
+ continue
171
+ }
172
+ branches.push(name)
173
+ forkAt.push(fork)
174
+ heads.push(null)
175
+ cur = branches.length - 1
176
+ } else if (first === 'checkout' || first === 'switch') {
177
+ const lane = branches.indexOf(nameToken(rest).name ?? '')
178
+ if (lane === -1) {
179
+ warnings.push(`dropped, unreadable statement: "${st}"`)
180
+ continue
181
+ }
182
+ cur = lane
183
+ } else if (first === 'merge') {
184
+ const { name, after } = nameToken(rest)
185
+ const lane = branches.indexOf(name ?? '')
186
+ if (lane === -1 || lane === cur) {
187
+ warnings.push(`dropped, unreadable statement: "${st}"`)
188
+ continue
189
+ }
190
+ // An unnamed merge shows no id — the `⇐ branch` marker already says
191
+ // what it is, and an invented id would collide with authored ones.
192
+ const attrs = commitAttrs(after, limits.label)
193
+ heads[cur] = commits.length
194
+ commits.push({ lane: cur, id: attrs.id ?? '', tag: attrs.tag, mergeFrom: lane })
195
+ } else if (first === 'cherry-pick') {
196
+ const attrs = commitAttrs(rest, limits.label)
197
+ heads[cur] = commits.length
198
+ commits.push({
199
+ lane: cur,
200
+ id: attrs.id === null ? `c${auto++}` : `⟲ ${attrs.id}`,
201
+ tag: attrs.tag,
202
+ mergeFrom: null,
203
+ })
204
+ } else {
205
+ warnings.push(`dropped, unreadable statement: "${st}"`)
206
+ }
207
+ }
208
+ if (truncated) warnings.push(`diagram truncated: commit cap (${MAX_EDGES}) reached`)
209
+
210
+ return commits.length === 0 ? null : { branches, commits, forkAt, warnings }
211
+ }
212
+
213
+ /** First branch-name token; quotes let a name carry spaces or keywords. */
214
+ function nameToken(rest: string): { name: string | undefined; after: string } {
215
+ if (rest.startsWith('"')) {
216
+ const close = rest.indexOf('"', 1)
217
+ if (close !== -1) return { name: rest.slice(1, close), after: rest.slice(close + 1) }
218
+ }
219
+ const w = words(rest)[0]
220
+ return { name: w, after: w === undefined ? rest : rest.slice(w.length) }
221
+ }
222
+
223
+ /** `id: "x" tag: "v1" …` key/value pairs trailing a commit or merge. */
224
+ function commitAttrs(rest: string, max: number): { id: string | null; tag: string | null } {
225
+ const out = { id: null as string | null, tag: null as string | null }
226
+ for (const m of rest.matchAll(/(id|tag)\s*:\s*"([^"]*)"/gi)) {
227
+ out[m[1].toLowerCase() as 'id' | 'tag'] = fitLabel(cleanLabel(m[2]), max)
228
+ }
229
+ return out
230
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `mindmap`: an indentation tree, drawn the way every TUI draws trees
3
+ * (`├──`/`└──` guides). Parses raw lines rather than statements — the
4
+ * indentation IS the grammar, and `statementsOf` trims it away.
5
+ */
6
+
7
+ import { Canvas, drawText } from '../canvas.ts'
8
+ import { MAX_NODES } from '../graph.ts'
9
+ import { cleanLabel, fitLabel, type Limits, srcLines } from '../labels.ts'
10
+ import type { Diagram } from '../registry.ts'
11
+ import { frontmatterEnd } from '../statements.ts'
12
+ import { stringWidth } from '../width.ts'
13
+
14
+ interface MindNode {
15
+ text: string
16
+ children: MindNode[]
17
+ }
18
+
19
+ export const mindmap: Diagram = {
20
+ kind: 'mindmap',
21
+ headers: ['mindmap'],
22
+ render(src, limits) {
23
+ const parsed = parseMindmap(src, limits)
24
+ if (parsed === null) return null
25
+ const { roots, warnings } = parsed
26
+
27
+ /** [prefix, text] per row; prefixes carry the `│ ├ └` guides. */
28
+ const rows: [string, string][] = []
29
+ const walk = (node: MindNode, prefix: string, childPrefix: string): void => {
30
+ rows.push([prefix, node.text])
31
+ node.children.forEach((child, i) => {
32
+ const last = i === node.children.length - 1
33
+ walk(child, childPrefix + (last ? '└── ' : '├── '), childPrefix + (last ? ' ' : '│ '))
34
+ })
35
+ }
36
+ for (const root of roots) walk(root, '', '')
37
+
38
+ const width = Math.max(...rows.map(([p, t]) => stringWidth(p) + stringWidth(t)))
39
+ const canvas = new Canvas(width, rows.length)
40
+ rows.forEach(([prefix, text], y) => {
41
+ drawText(canvas, prefix, 0, y, 'edge')
42
+ drawText(canvas, text, stringWidth(prefix), y, 'text')
43
+ })
44
+ return { canvas, warnings, classDefs: {} }
45
+ },
46
+ }
47
+
48
+ function parseMindmap(src: string, limits: Limits): { roots: MindNode[]; warnings: string[] } | null {
49
+ const lines = srcLines(src).slice(frontmatterEnd(srcLines(src)))
50
+ const headerAt = lines.findIndex((l) => l.trim() !== '')
51
+ if (headerAt === -1 || lines[headerAt].trim().toLowerCase() !== 'mindmap') return null
52
+
53
+ const roots: MindNode[] = []
54
+ const warnings: string[] = []
55
+ /** Ancestors of the next node: the node at each indent level seen so far. */
56
+ const stack: { indent: number; node: MindNode }[] = []
57
+ let count = 0
58
+ let truncated = false
59
+
60
+ for (const raw of lines.slice(headerAt + 1)) {
61
+ const noComment = raw.split('%%')[0]
62
+ if (noComment.trim() === '') continue
63
+ const indent = noComment.length - noComment.trimStart().length
64
+ const body = noComment.trim()
65
+ // Decoration lines attach to the previous node and draw nothing.
66
+ if (body.startsWith('::icon') || body.startsWith(':::')) continue
67
+ const text = nodeText(body)
68
+ if (text === '') {
69
+ warnings.push(`dropped, unreadable statement: "${body}"`)
70
+ continue
71
+ }
72
+ if (count >= MAX_NODES) {
73
+ truncated = true
74
+ break
75
+ }
76
+ count++
77
+ const node: MindNode = { text: fitLabel(text, limits.wrap), children: [] }
78
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
79
+ if (stack.length === 0) roots.push(node)
80
+ else stack[stack.length - 1].node.children.push(node)
81
+ stack.push({ indent, node })
82
+ }
83
+ if (truncated) warnings.push(`diagram truncated: node cap (${MAX_NODES}) reached`)
84
+
85
+ return roots.length === 0 ? null : { roots, warnings }
86
+ }
87
+
88
+ /** Shape brackets around a mindmap node all mean "text" in a terminal. */
89
+ const SHAPES: [string, string][] = [
90
+ ['((', '))'],
91
+ ['))', '(('],
92
+ ['(-', '-)'],
93
+ ['{{', '}}'],
94
+ ['[', ']'],
95
+ ['(', ')'],
96
+ ]
97
+
98
+ function nodeText(body: string): string {
99
+ // `id((text))` — an optional id may precede the bracket.
100
+ for (const [open, close] of SHAPES) {
101
+ const at = body.indexOf(open)
102
+ if (at !== -1 && body.endsWith(close) && body.length > at + open.length + close.length - 1) {
103
+ return cleanLabel(body.slice(at + open.length, body.length - close.length))
104
+ }
105
+ }
106
+ return cleanLabel(body)
107
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `pie`: proportions as a labelled bar list. A terminal has no circle worth
3
+ * drawing; bars carry the same information in less space and align with how
4
+ * every TUI shows usage. Lenient: an unreadable statement is dropped and
5
+ * recorded in `warnings`.
6
+ */
7
+
8
+ import { Canvas, drawText } from '../canvas.ts'
9
+ import { MAX_NODES } from '../graph.ts'
10
+ import { cleanLabel, fitLabel } from '../labels.ts'
11
+ import type { Diagram } from '../registry.ts'
12
+ import { headerKind, nonEmpty, quoteMask, statementsOf, words } from '../statements.ts'
13
+ import { stringWidth } from '../width.ts'
14
+
15
+ /** Columns of the full-scale bar; eighth blocks refine below one cell. */
16
+ const BAR_W = 20
17
+ const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
18
+
19
+ export const pie: Diagram = {
20
+ kind: 'pie',
21
+ headers: ['pie'],
22
+ render(src) {
23
+ const parsed = parsePie(src)
24
+ if (parsed === null) return null
25
+ const { title, slices, showData, warnings } = parsed
26
+
27
+ const labelW = Math.max(...slices.map((s) => stringWidth(s.label)))
28
+ const total = slices.reduce((sum, s) => sum + s.value, 0)
29
+ const rows = slices.map((s) => {
30
+ const share = total === 0 ? 0 : s.value / total
31
+ const pct = `${Math.round(share * 100)}%`.padStart(4)
32
+ const data = showData ? ` (${s.value})` : ''
33
+ return { ...s, share, suffix: pct + data }
34
+ })
35
+
36
+ const barX = labelW + 2
37
+ const suffixX = barX + BAR_W + 1
38
+ const width = suffixX + Math.max(...rows.map((r) => stringWidth(r.suffix)))
39
+ const top = title === null ? 0 : 1
40
+ const canvas = new Canvas(width, top + rows.length)
41
+
42
+ if (title !== null) {
43
+ drawText(canvas, title, Math.max(0, Math.floor((width - stringWidth(title)) / 2)), 0, 'title')
44
+ }
45
+ rows.forEach((r, i) => {
46
+ const y = top + i
47
+ drawText(canvas, r.label, 0, y, 'text')
48
+ const eighths = Math.round(r.share * BAR_W * 8)
49
+ let bar = '█'.repeat(Math.floor(eighths / 8)) + EIGHTHS[eighths % 8]
50
+ // A nonzero slice always shows at least a sliver.
51
+ if (bar === '' && r.value > 0) bar = '▏'
52
+ drawText(canvas, bar, barX, y, 'edge')
53
+ // The unfilled remainder is a track, so every bar shows its full scale.
54
+ drawText(canvas, '░'.repeat(BAR_W - stringWidth(bar)), barX + stringWidth(bar), y, 'border')
55
+ drawText(canvas, r.suffix, suffixX, y, 'edgeLabel')
56
+ })
57
+ return { canvas, warnings, classDefs: {} }
58
+ },
59
+ }
60
+
61
+ interface PieSlice {
62
+ label: string
63
+ value: number
64
+ }
65
+
66
+ function parsePie(src: string): {
67
+ title: string | null
68
+ slices: PieSlice[]
69
+ showData: boolean
70
+ warnings: string[]
71
+ } | null {
72
+ const statements = statementsOf(src)
73
+ if (headerKind(statements) !== 'pie') return null
74
+
75
+ // The header line may carry `showData` and an inline `title ...`.
76
+ const head = words(statements[0])
77
+ const showData = head.some((w) => w.toLowerCase() === 'showdata')
78
+ const inlineTitle = head.findIndex((w) => w.toLowerCase() === 'title')
79
+ let title = inlineTitle === -1 ? null : nonEmpty(head.slice(inlineTitle + 1).join(' '))
80
+
81
+ const slices: PieSlice[] = []
82
+ const warnings: string[] = []
83
+ let truncated = false
84
+ for (const st of statements.slice(1)) {
85
+ const first = words(st)[0]?.toLowerCase()
86
+ if (first === 'title') {
87
+ title = nonEmpty(st.slice(st.toLowerCase().indexOf('title') + 5).trim())
88
+ continue
89
+ }
90
+ const slice = parseSlice(st)
91
+ if (slice === null) {
92
+ warnings.push(`dropped, unreadable statement: "${st}"`)
93
+ continue
94
+ }
95
+ if (slices.length >= MAX_NODES) {
96
+ truncated = true
97
+ break
98
+ }
99
+ slices.push(slice)
100
+ }
101
+ if (truncated) warnings.push(`diagram truncated: slice cap (${MAX_NODES}) reached`)
102
+
103
+ return slices.length === 0 ? null : { title, slices, showData, warnings }
104
+ }
105
+
106
+ /** `"Label" : 42.5` — the label may be unquoted as long as it has no colon. */
107
+ function parseSlice(st: string): PieSlice | null {
108
+ const chars = [...st]
109
+ const quoted = quoteMask(chars)
110
+ const colon = chars.findIndex((c, i) => c === ':' && !quoted[i])
111
+ if (colon === -1) return null
112
+ const label = fitLabel(cleanLabel(chars.slice(0, colon).join('')), 24)
113
+ const value = Number(
114
+ chars
115
+ .slice(colon + 1)
116
+ .join('')
117
+ .trim(),
118
+ )
119
+ if (label === '' || !Number.isFinite(value) || value < 0) return null
120
+ return { label, value }
121
+ }