@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,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
|
+
}
|