SLOPSHOPPER

diagram

Draws the last mermaid diagram from Claude's answer as box art in a pane: run /diagram to open it. Flowcharts, sequence diagrams and pie charts; offline, no…

newpanecommandtoast
★ 1v0.1.1MITupdated 2026-10-12samuelbfernandes/moddeck/mods/diagram
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diagram
│ ┃ diagram ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to show yet │ ┃ [ Close ] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diagram │ ⎿ diagram: Diagram · nothing to show yet │ ⎿ diagram: No mermaid diagram in an answer so far. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · diagram
Nothing to show yet [ Close ]
README

diagram

Install (Claude Code 2.1.287 or later): /plugin install diagram --marketplace samuelbfernandes/moddeck Claude often answers with a `mermaid block, which a terminal shows as raw text. This mod keeps the last one and draws the simple cases as box art in a pane. Everything runs offline in the mod itself: no model tokens, no network, no files read or written, no programs started.

Use

  • After an answer that contains a mermaid block, one short line appears: Diagram · /diagram to see it (at most once per turn; not while the pane is open). Nothing else happens by itself.
  • /diagram opens the pane; /diagram close closes it. It runs locally and is never sent to the model. Esc or the Close button also close it. An open pane redraws when a newer answer brings another diagram.
  • Drawn: flowchart / graph (TD, TB, LR; boxes A[..], A(..), A{..}, circles drawn rounded; arrows -->, ---, -.->, ==>, <-->, labels -->|text| or -- text -->; A & B --> C), sequenceDiagram (participants, aliases, ->> -->> -> --> -x -), notes, autonumber, loop/alt blocks as a marker row) and pie (bars with percentages).
  • Wide drawings wrap their text to the pane. A left-to-right chart that is still too wide is drawn top-down.
  • Anything else (class, gantt, ER, state ...), a diagram over 25 nodes or 60 connections, one it cannot read, or one too wide for the pane shows Diagram · <reason> and the source in a box. It never shows an error.

Settings and turning off

hint (true): the one-line hint after an answer. /plugin disable diagram turns the mod off. Only the text of the last diagram (cut at 20,000 characters) is kept, in memory for the session; /clear forgets it.

Limits

  • Simplified: subgraphs are drawn flat, BT and RL as TD and LR, styling lines are ignored, and a loop that points back up is listed under the drawing (↺ B → A) instead of being drawn as a line. In busy charts lines may cross at ┼ and two lines that meet can read as one. Each character counts as one column, so wide (CJK, emoji) text may misalign.
  • Only a diagram in the final text of a turn is seen (not one in an earlier message of a long turn or in a subagent).
  • Not verified in a live session: how the pane docks and scrolls tall drawings, colours in the desktop app, /diagram in the real prompt box, the toast. Tests use a mocked engine. Not run on Windows.
Source 10 files
hooks/register.tsx 109 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { lastMermaid } from './extract'
5import { drawDiagram } from './render'
6
7const PANE = 'diagram'
8const HINT = 'Diagram · /diagram to see it'
9const FAILED = 'Diagram · could not open'
10
11// The last mermaid block of a final answer, as text. A drawing reads it, so a new answer redraws an open pane.
12const last = atom({ plugin: 'diagram', key: 'last' } as const, null)
13
14async function isPaneOpen($: EngineInterface): Promise<boolean> {
15  return (await $.ui.panes()).some(pane => pane.id === PANE)
16}
17
18/** What `/diagram` does. The text is a headline and at most one dim detail line. */
19async function openDiagram($: EngineInterface): Promise<string> {
20  if ((await read($, last)) === null) return 'Diagram · nothing to show yet\nNo mermaid diagram in an answer so far.'
21  const opened = await $.ui.open({ id: PANE, title: 'Diagram', focus: true, closeOnEscape: true })
22  if (!opened.isPlaced) return 'Diagram · no room for the pane\nTry a wider window.'
23
24  return 'Diagram · open'
25}
26
27async function closeDiagram($: EngineInterface): Promise<string> {
28  if (!(await isPaneOpen($))) return 'Diagram · not open'
29  await $.ui.close({ id: PANE })
30
31  return 'Diagram · closed'
32}
33
34export const register: Register = (on, options) => {
35  on('session.start', async ($, e, next) => {
36    await $.command.register({
37      name: 'diagram',
38      description: "Draw the last mermaid diagram from Claude's answer in a pane",
39      argumentHint: '[close]',
40    })
41
42    return next(e)
43  })
44
45  // A /clear ends the conversation the diagram belongs to.
46  on('session.end', async ($, e, next) => {
47    await update($, last, () => null)
48
49    return next(e)
50  }).catch(($, e, next) => next(e)) // observational: never get in the way of ending
51
52  on('command.run', { command: 'diagram' }, async ($, e) => {
53    const arg = e.args.trim().toLowerCase()
54    if (arg === '') return { text: await openDiagram($) }
55    if (arg === 'close') return { text: await closeDiagram($) }
56
57    return { text: 'Diagram · /diagram opens it, /diagram close hides it' }
58  }).catch(() => ({ text: FAILED }))
59
60  // Remembers the last mermaid block of the answer and, at most once per turn, hints that it can be drawn. Nothing else:
61  // no tokens, no pane. Fail-open and observational: an error here never touches the turn or its answer. Subagent
62  // turns and turns that did not end in an answer are ignored.
63  on('turn.complete', async ($, e, next) => {
64    if (e.agentId === undefined && e.reason === 'answer') await remember($, e.answer, options.hint !== false).catch(() => undefined)
65
66    return next(e)
67  }).catch(($, e, next) => next(e))
68
69  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
70    const { Box, Button, Text } = $.ui.resolve(e)
71    const close = <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
72    const source = await read($, last)
73    if (source === null) {
74      return (
75        <Box flexDirection="column">
76          <Text dimColor>Nothing to show yet</Text>
77          {close}
78        </Box>
79      )
80    }
81    const drawing = drawDiagram(source, e.props.bodyColumns)
82
83    // One Text per row, never wrapped: a wrapped row would break the drawing.
84    return (
85      <Box flexDirection="column">
86        {drawing.title !== '' && <Text bold>{`Diagram · ${drawing.title}`}</Text>}
87        {drawing.lines.map((line, i) => (
88          <Text key={`row${i}`} wrap="truncate-end">
89            {line === '' ? ' ' : line}
90          </Text>
91        ))}
92        <Box gap={1}>
93          {drawing.isDrawn && <Text dimColor>Simple built-in drawing</Text>}
94          {close}
95        </Box>
96      </Box>
97    )
98  }).catch(($, e, next) => next(e))
99}
100
101async function remember($: EngineInterface, answer: string, isHintOn: boolean): Promise<void> {
102  const source = lastMermaid(answer)
103  if (source === undefined) return
104  await update($, last, () => source)
105  // A pane that is open redraws by itself; a plain `-p` run has nobody looking.
106  if (!isHintOn || (await isPaneOpen($)) || (await $.session.surfaces()).length === 0) return
107  $.ui.toast(HINT)
108}
109
hooks/extract.ts 30 lines
1/** The most characters of a diagram kept; the rest is cut (the drawing then shows the source). */
2export const MAX_SOURCE = 20_000
3
4/** An opening fence: up to three spaces, three or more backticks or tildes, then the language word. */
5const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/
6
7/**
8 * The last ```mermaid block of an answer: its text without the fences, or undefined when there is none. A block
9 * the answer never closed (it was cut short) counts to the end of the answer, as Markdown reads it. Every fenced
10 * block is walked to its end, so a mermaid example shown inside another code block is not taken for a diagram.
11 */
12export function lastMermaid(answer: string): string | undefined {
13  const lines = answer.replace(/\r\n?/g, '\n').split('\n')
14  let found: string | undefined
15  for (let i = 0; i < lines.length; i++) {
16    const open = FENCE.exec(lines[i] as string)
17    if (open === null) continue
18    const fence = open[1] as string
19    // A closing fence is the same character, at least as long, and nothing else on its line.
20    const isClose = new RegExp(`^ {0,3}\\${fence.charAt(0)}{${fence.length},}\\s*$`)
21    const body: string[] = []
22    let j = i + 1
23    while (j < lines.length && !isClose.test(lines[j] as string)) body.push(lines[j++] as string)
24    if ((open[2] as string).toLowerCase() === 'mermaid') found = body.join('\n')
25    i = j
26  }
27
28  return found === undefined ? undefined : found.slice(0, MAX_SOURCE)
29}
30
hooks/render.ts 93 lines
1import { drawFlow } from './flow-draw'
2import { parseFlow } from './flow-parse'
3import { drawPie, parsePie } from './pie'
4import { drawSequence, parseSequence } from './sequence'
5import { TooBig, TooWide, clean, codeBox, truncate, wrapText } from './text'
6
7/**
8 * The whole drawing: Mermaid source in, rows of text out. It never throws. Whatever it cannot draw (an unknown kind,
9 * a parse it does not trust, too many nodes, too wide for the pane) comes back as a headline and the source in a box.
10 */
11
12export type Drawing = {
13  /** What was drawn ('Flowchart', ...); empty when the source is shown instead. */
14  title: string
15  lines: string[]
16  isDrawn: boolean
17}
18
19export type DrawOptions = { maxNodes?: number }
20
21/** The most nodes (or participants) drawn; a bigger diagram is shown as source. */
22export const MAX_NODES = 25
23/** The most edges drawn. */
24const MAX_EDGES = 60
25/** The most message, note and block lines of a sequence diagram. */
26const MAX_STEPS = 150
27
28function shown(headline: string, source: string, columns: number): Drawing {
29  return { title: '', isDrawn: false, lines: [...wrapText(headline, columns), '', ...codeBox(source, columns)] }
30}
31
32/** The source without a leading `---` front-matter block, as lines. */
33function bodyOf(source: string): string[] {
34  const lines = source.split('\n')
35  if (lines[0]?.trim() === '---') {
36    const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
37    if (end > 0) return lines.slice(end + 1)
38  }
39
40  return lines
41}
42
43const isNoise = (line: string): boolean => line.trim() === '' || line.trim().startsWith('%%')
44
45/** The first word of the header line ("flowchart", "classDiagram"), as typed, made printable. */
46function kindOf(header: string): string {
47  const word = /^[A-Za-z][\w-]*/.exec(header.trim())?.[0] ?? header.trim().split(/\s+/)[0] ?? ''
48
49  return truncate(word.replace(/[^\x20-\x7e -￿]/g, ''), 24)
50}
51
52function draw(source: string, columns: number, maxNodes: number): Drawing {
53  const lines = bodyOf(source)
54  const at = lines.findIndex(line => !isNoise(line))
55  if (at < 0) return { title: '', isDrawn: false, lines: ['Diagram · nothing to draw'] }
56  const header = (lines[at] as string).trim()
57  const body = lines.slice(at + 1)
58  const kind = kindOf(header)
59  const lower = kind.toLowerCase()
60
61  if (lower === 'flowchart' || lower === 'graph' || lower === 'flowchart-elk') {
62    const flow = parseFlow(header, body)
63    if (flow.nodes.length > maxNodes) return shown(`Diagram · ${flow.nodes.length} nodes is more than the ${maxNodes} drawn here`, source, columns)
64
65    if (flow.edges.length > MAX_EDGES) return shown(`Diagram · ${flow.edges.length} connections is more than the ${MAX_EDGES} drawn here`, source, columns)
66
67    return { title: 'Flowchart', isDrawn: true, lines: drawFlow(flow, columns) }
68  }
69  if (lower === 'sequencediagram') {
70    const seq = parseSequence(body)
71    if (seq.names.length > maxNodes) return shown(`Diagram · ${seq.names.length} participants is more than the ${maxNodes} drawn here`, source, columns)
72    if (seq.events.length > MAX_STEPS) return shown(`Diagram · ${seq.events.length} steps is more than the ${MAX_STEPS} drawn here`, source, columns)
73
74    return { title: 'Sequence diagram', isDrawn: true, lines: drawSequence(seq, columns) }
75  }
76  if (lower === 'pie') return { title: 'Pie chart', isDrawn: true, lines: drawPie(parsePie(header, body), columns) }
77
78  return shown(`Diagram · ${kind === '' ? 'this kind' : kind} is not drawn here`, source, columns)
79}
80
81/** Mermaid source to rows of at most `columns` characters. Never throws. */
82export function drawDiagram(raw: string, columns: number, options: DrawOptions = {}): Drawing {
83  const width = Number.isFinite(columns) ? Math.max(12, Math.floor(columns)) : 60
84  const source = clean(typeof raw === 'string' ? raw : '').replace(/\s+$/, '')
85  try {
86    return draw(source, width, options.maxNodes ?? MAX_NODES)
87  } catch (error) {
88    const why = error instanceof TooWide ? 'too wide for this pane' : error instanceof TooBig ? 'too many connections to draw here' : 'could not be drawn'
89
90    return shown(`Diagram · ${why}`, source, width)
91  }
92}
93
hooks/flow-draw.ts 499 lines
1import { Canvas } from './canvas'
2import type { LineStyle } from './canvas'
3import type { Flow, FlowEdge, FlowNode } from './flow-parse'
4import { TooBig, TooWide, truncate, widest, wrapText } from './text'
5
6/**
7 * Layered drawing of a flowchart. Nodes are ranked by longest path, ordered inside a rank to reduce crossings,
8 * placed across the flow, and joined by lines routed in the gaps between ranks. A line that skips a rank passes
9 * through a one-cell "dummy" in each rank it crosses. An edge that would point back up (a loop) is not drawn as a
10 * line: it is listed under the drawing. "Along" is the flow's direction (down for TD, right for LR), "across" the other.
11 */
12
13type Item = {
14  layer: number
15  real: boolean
16  /** Size along the flow and across it, in cells. */
17  p: number
18  c: number
19  /** Position across the flow. */
20  pos: number
21  node?: FlowNode
22  rows: string[]
23  /** The edge a dummy carries. */
24  edge?: FlowEdge
25  /** Extra space wanted to the right (top-down charts): the label of the edge that enters from above sits there. */
26  room: number
27}
28
29type Seg = {
30  from: Item
31  to: Item
32  edge: FlowEdge
33  first: boolean
34  last: boolean
35  /** The row of its gap in which it runs across, when it bends. */
36  row: number
37  /** Whose line the label sits beside (top-down charts): the source's stem or the target's last leg. */
38  anchor: 'from' | 'to'
39}
40
41/** Wrap widths tried in turn until the drawing fits the pane. */
42const TEXT_WIDTHS = [30, 24, 18, 14, 10, 8, 6]
43const LABEL_MAX = 16
44const TOP_ROWS = 40
45/** Line pieces (an edge that skips ranks counts once per rank it crosses) beyond which a chart is left as source: layout cost grows with the square. */
46const MAX_SEGMENTS = 300
47
48const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
49const labelOf = (edge: FlowEdge): string => oneLine(truncate(edge.label, LABEL_MAX))
50
51type Analysis = { rank: Map<string, number>; forward: FlowEdge[]; loops: FlowEdge[] }
52
53/** Splits the edges into those that go forward and those that close a loop (including self-loops), then ranks the nodes. */
54function analyse(flow: Flow): Analysis {
55  const outgoing = new Map<string, FlowEdge[]>()
56  for (const edge of flow.edges) outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge])
57  const state = new Map<string, 1 | 2>()
58  const forward = new Set<FlowEdge>()
59  const loops = new Set<FlowEdge>()
60  const visit = (id: string): void => {
61    state.set(id, 1)
62    for (const edge of outgoing.get(id) ?? []) {
63      const next = state.get(edge.to)
64      if (edge.from === edge.to || next === 1) loops.add(edge)
65      else {
66        forward.add(edge)
67        if (next === undefined) visit(edge.to)
68      }
69    }
70    state.set(id, 2)
71  }
72  for (const node of flow.nodes) if (!state.has(node.id)) visit(node.id)
73
74  const kept = flow.edges.filter(edge => forward.has(edge))
75  const rank = new Map<string, number>(flow.nodes.map(node => [node.id, 0]))
76  const indegree = new Map<string, number>(flow.nodes.map(node => [node.id, 0]))
77  for (const edge of kept) indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1)
78  const queue = flow.nodes.filter(node => indegree.get(node.id) === 0).map(node => node.id)
79  for (let head = 0; head < queue.length; head++) {
80    const id = queue[head] as string
81    for (const edge of outgoing.get(id) ?? []) {
82      if (!forward.has(edge)) continue
83      rank.set(edge.to, Math.max(rank.get(edge.to) ?? 0, (rank.get(id) ?? 0) + 1))
84      indegree.set(edge.to, (indegree.get(edge.to) ?? 0) - 1)
85      if (indegree.get(edge.to) === 0) queue.push(edge.to)
86    }
87  }
88
89  return { rank, forward: kept, loops: flow.edges.filter(edge => loops.has(edge)) }
90}
91
92type Graph = { layers: Item[][]; segs: Seg[]; items: Map<string, Item>; preds: Map<Item, Item[]>; succs: Map<Item, Item[]> }
93
94function boxItem(node: FlowNode, layer: number, textWidth: number, isDown: boolean): Item {
95  const rows = wrapText(node.text, textWidth)
96  const w = widest(rows) + 4
97  const h = rows.length + 2
98
99  return { layer, real: true, p: isDown ? h : w, c: isDown ? w : h, pos: 0, node, rows, room: 0 }
100}
101
102/**
103 * Decides which line each label of a top-down chart sits beside, and reserves the room for it. Several edges merging
104 * into one node each keep their label by their own source; a fan-out keeps its labels by the targets, where the
105 * branches part. The anchor node gets extra space to its right so neighbours do not run into the text.
106 */
107function placeLabelRooms(segs: Seg[]): void {
108  const labelled = segs.filter(s => s.first && labelOf(s.edge) !== '')
109  const outCount = new Map<Item, number>()
110  const inCount = new Map<Item, number>()
111  for (const s of segs) inCount.set(s.to, (inCount.get(s.to) ?? 0) + 1)
112  for (const s of labelled) outCount.set(s.from, (outCount.get(s.from) ?? 0) + 1)
113  for (const s of labelled) {
114    s.anchor = outCount.get(s.from) === 1 && (inCount.get(s.to) ?? 0) > 1 ? 'from' : 'to'
115    const item = s.anchor === 'from' ? s.from : s.to
116    item.room = Math.max(item.room, labelOf(s.edge).length + 2 - (item.c - Math.floor(item.c / 2) - 1))
117  }
118}
119
120/** Items per rank and the segments between neighbouring ranks, dummies included. */
121function buildGraph(flow: Flow, analysis: Analysis, textWidth: number, isDown: boolean): Graph {
122  const items = new Map<string, Item>()
123  const layers: Item[][] = []
124  const layerOf = (n: number): Item[] => (layers[n] ??= [])
125  for (const node of flow.nodes) {
126    const item = boxItem(node, analysis.rank.get(node.id) ?? 0, textWidth, isDown)
127    items.set(node.id, item)
128    layerOf(item.layer).push(item)
129  }
130  for (let n = 0; n < layers.length; n++) layerOf(n)
131  const segs: Seg[] = []
132  for (const edge of analysis.forward) {
133    const from = items.get(edge.from) as Item
134    const to = items.get(edge.to) as Item
135    let previous = from
136    for (let layer = from.layer + 1; layer <= to.layer; layer++) {
137      const isLast = layer === to.layer
138      const next: Item = isLast ? to : { layer, real: false, p: 1, c: 1, pos: 0, rows: [], edge, room: 0 }
139      if (!isLast) layerOf(layer).push(next)
140      segs.push({ from: previous, to: next, edge, first: previous === from, last: isLast, row: 0, anchor: 'to' })
141      previous = next
142    }
143  }
144  const preds = new Map<Item, Item[]>()
145  const succs = new Map<Item, Item[]>()
146  for (const s of segs) {
147    preds.set(s.to, [...(preds.get(s.to) ?? []), s.from])
148    succs.set(s.from, [...(succs.get(s.from) ?? []), s.to])
149  }
150  if (isDown) placeLabelRooms(segs)
151
152  return { layers, segs, items, preds, succs }
153}
154
155// ---- ordering inside a rank ------------------------------------------------------------------------------------
156
157function indexMap(layers: Item[][]): Map<Item, number> {
158  const map = new Map<Item, number>()
159  for (const layer of layers) layer.forEach((item, i) => map.set(item, i))
160
161  return map
162}
163
164function crossings(layers: Item[][], segs: Seg[]): number {
165  const at = indexMap(layers)
166  const byGap = new Map<number, Seg[]>()
167  for (const s of segs) byGap.set(s.from.layer, [...(byGap.get(s.from.layer) ?? []), s])
168  let count = 0
169  for (const gap of byGap.values()) {
170    for (let i = 0; i < gap.length; i++) {
171      for (let j = i + 1; j < gap.length; j++) {
172        const a = gap[i] as Seg
173        const b = gap[j] as Seg
174        const d1 = (at.get(a.from) as number) - (at.get(b.from) as number)
175        const d2 = (at.get(a.to) as number) - (at.get(b.to) as number)
176        if (d1 * d2 < 0) count++
177      }
178    }
179  }
180
181  return count
182}
183
184/** Reorders one rank by the average index of each item's neighbours in the rank next to it. */
185function reorder(layer: Item[], neighbours: (item: Item) => Item[], at: Map<Item, number>): Item[] {
186  const keyed = layer.map((item, i) => {
187    const near = neighbours(item).map(n => at.get(n) as number)
188
189    return { item, i, key: near.length === 0 ? i : near.reduce((a, b) => a + b, 0) / near.length }
190  })
191
192  return keyed.sort((a, b) => a.key - b.key || a.i - b.i).map(k => k.item)
193}
194
195function orderLayers(graph: Graph): Item[][] {
196  let best = graph.layers.map(layer => [...layer])
197  let bestCrossings = crossings(best, graph.segs)
198  let layers = best.map(layer => [...layer])
199  for (let pass = 0; pass < 8 && bestCrossings > 0; pass++) {
200    const down = pass % 2 === 0
201    const order = down ? layers.map((_, l) => l).slice(1) : layers.map((_, l) => l).slice(0, -1).reverse()
202    for (const l of order) {
203      const at = indexMap(layers)
204      const near = (item: Item): Item[] => (down ? graph.preds.get(item) : graph.succs.get(item)) ?? []
205      layers[l] = reorder(layers[l] as Item[], near, at)
206    }
207    const x = crossings(layers, graph.segs)
208    if (x < bestCrossings) {
209      bestCrossings = x
210      best = layers.map(layer => [...layer])
211    }
212    layers = layers.map(layer => [...layer])
213  }
214
215  return best
216}
217
218// ---- positions across the flow ---------------------------------------------------------------------------------
219
220const centre = (item: Item): number => item.pos + Math.floor(item.c / 2)
221
222function separation(a: Item, b: Item, isDown: boolean): number {
223  if (!isDown) return 1
224
225  return (a.real && b.real ? 3 : 2) + a.room
226}
227
228/** Moves each item of a rank towards its target without overlap: the average of a left-to-right and a right-to-left packing. */
229function settle(layer: Item[], target: Map<Item, number>, isDown: boolean): void {
230  const left: number[] = []
231  layer.forEach((item, i) => {
232    const t = target.get(item) as number
233    const prev = layer[i - 1]
234    left.push(prev === undefined ? t : Math.max(t, (left[i - 1] as number) + prev.c + separation(prev, item, isDown)))
235  })
236  const right: number[] = new Array<number>(layer.length)
237  for (let i = layer.length - 1; i >= 0; i--) {
238    const item = layer[i] as Item
239    const next = layer[i + 1]
240    const t = target.get(item) as number
241    right[i] = next === undefined ? t : Math.min(t, (right[i + 1] as number) - item.c - separation(item, next, isDown))
242  }
243  layer.forEach((item, i) => {
244    item.pos = Math.floor(((left[i] as number) + (right[i] as number)) / 2)
245  })
246}
247
248function position(layers: Item[][], graph: Graph, isDown: boolean): void {
249  for (const layer of layers) {
250    let at = 0
251    for (const item of layer) {
252      item.pos = at
253      at += item.c + separation(item, item, isDown)
254    }
255  }
256  for (let pass = 0; pass < 8; pass++) {
257    const down = pass % 2 === 0
258    const order = down ? layers.map((_, l) => l).slice(1) : layers.map((_, l) => l).slice(0, -1).reverse()
259    for (const l of order) {
260      const layer = layers[l] as Item[]
261      const target = new Map<Item, number>()
262      for (const item of layer) {
263        const near = ((down ? graph.preds.get(item) : graph.succs.get(item)) ?? []).map(centre)
264        const wanted = near.length === 0 ? item.pos : Math.round(near.reduce((a, b) => a + b, 0) / near.length) - Math.floor(item.c / 2)
265        target.set(item, wanted)
266      }
267      settle(layer, target, isDown)
268    }
269  }
270  const low = Math.min(...layers.flat().map(item => item.pos))
271  for (const item of layers.flat()) item.pos -= low
272}
273
274// ---- lines in the gaps between ranks -----------------------------------------------------------------------------
275
276/** Whether two segments of one gap may share a row: those that meet at a source or a target merge on purpose. */
277function clash(a: Seg, b: Seg): boolean {
278  if (a.from === b.from || a.to === b.to) return false
279  const aLo = Math.min(centre(a.from), centre(a.to))
280  const aHi = Math.max(centre(a.from), centre(a.to))
281  const bLo = Math.min(centre(b.from), centre(b.to))
282  const bHi = Math.max(centre(b.from), centre(b.to))
283
284  return aLo <= bHi + 1 && bLo <= aHi + 1
285}
286
287/** Gives each bent segment of a gap a row so unrelated lines never run into each other; returns the rows used. */
288function assignRows(gap: Seg[]): number {
289  const bent = gap
290    .filter(s => centre(s.from) !== centre(s.to))
291    .sort((a, b) => Math.min(centre(a.from), centre(a.to)) - Math.min(centre(b.from), centre(b.to)) || centre(a.from) - centre(b.from))
292  const placed: Seg[] = []
293  for (const s of bent) {
294    let row = 0
295    const fits = (r: number): boolean =>
296      placed.every(p => {
297        if (p.row === r && clash(p, s)) return false
298        // A stem must end above the row where another line's last leg starts in the same column, or they would read as one line.
299        if (centre(s.from) === centre(p.to) && !(r < p.row)) return false
300        if (centre(s.to) === centre(p.from) && !(p.row < r)) return false
301
302        return true
303      })
304    while (row < TOP_ROWS && !fits(row)) row++
305    s.row = row
306    placed.push(s)
307  }
308
309  return placed.reduce((max, s) => Math.max(max, s.row + 1), 0)
310}
311
312type Gap = { segs: Seg[]; channels: number; labelRows: number; size: number }
313
314const anchorOf = (s: Seg): Item => (s.anchor === 'from' ? s.from : s.to)
315
316function gapOf(segs: Seg[], isDown: boolean): Gap {
317  const channels = assignRows(segs)
318  const labelled = segs.filter(s => s.first && labelOf(s.edge) !== '')
319  // Top-down: as many rows as labels share one line; left-to-right: wide enough for the longest label.
320  const sharing = new Map<Item, number>()
321  for (const s of labelled) sharing.set(anchorOf(s), (sharing.get(anchorOf(s)) ?? 0) + 1)
322  const labelRows = labelled.length === 0 ? 0 : isDown ? Math.max(...sharing.values()) : Math.max(...labelled.map(s => labelOf(s.edge).length)) + 1
323
324  return { segs, channels, labelRows, size: 1 + channels + labelRows + 1 }
325}
326
327// ---- drawing ---------------------------------------------------------------------------------------------------
328
329const CORNERS = {
330  rect: ['┌', '┐', '┘', '└'],
331  round: ['╭', '╮', '╯', '╰'],
332  diamond: ['╱', '╲', '╱', '╲'],
333} as const
334
335function drawBox(canvas: Canvas, item: Item, x: number, y: number, isDown: boolean): void {
336  const node = item.node as FlowNode
337  const w = isDown ? item.c : item.p
338  const h = isDown ? item.p : item.c
339  canvas.box(x, y, w, h, CORNERS[node.shape])
340  const inner = w - 4
341  item.rows.forEach((row, j) => {
342    const left = Math.floor((inner - row.length) / 2)
343    canvas.text(x + 2 + left, y + 1 + j, row)
344  })
345  if (node.shape === 'diamond') {
346    const mid = y + Math.floor(h / 2)
347    canvas.put(x, mid, '<')
348    canvas.put(x + w - 1, mid, '>')
349  }
350}
351
352type Drawing = { lines: string[]; unplaced: FlowEdge[] }
353
354/** Where everything sits along the flow, and how to draw in the flow's own terms (down for TD, right for LR). */
355type Frame = {
356  isDown: boolean
357  canvas: Canvas
358  /** First coordinate along the flow of each rank, and the size of each rank. */
359  start: number[]
360  size: number[]
361}
362
363const gapStartOf = (frame: Frame, layer: number): number => (frame.start[layer] as number) + (frame.size[layer] as number)
364
365function alongLine(frame: Frame, c: number, p1: number, p2: number, style: LineStyle): void {
366  if (frame.isDown) frame.canvas.vline(c, p1, p2, style)
367  else frame.canvas.hline(p1, p2, c, style)
368}
369
370function acrossLine(frame: Frame, p: number, c1: number, c2: number, style: LineStyle): void {
371  if (frame.isDown) frame.canvas.hline(c1, c2, p, style)
372  else frame.canvas.vline(p, c1, c2, style)
373}
374
375function putAt(frame: Frame, p: number, c: number, ch: string): void {
376  if (frame.isDown) frame.canvas.put(c, p, ch)
377  else frame.canvas.put(p, c, ch)
378}
379
380/** One piece of an edge between two neighbouring ranks: out of the source, across in its row of the gap, into the target. */
381function drawSegment(frame: Frame, s: Seg, gap: Gap): void {
382  const l = s.from.layer
383  const gapStart = gapStartOf(frame, l)
384  const arrowAt = gapStart + gap.size - 1
385  const fromEnd = s.from.real ? (frame.start[l] as number) + s.from.p - 1 : gapStart - 1
386  const xs = centre(s.from)
387  const xt = centre(s.to)
388  const style = s.edge.style
389  // The last leg always reaches the arrow's cell (an arrow drawn there replaces the line), so a bend gets its downward stroke.
390  if (xs === xt) alongLine(frame, xs, fromEnd, arrowAt, style)
391  else {
392    const turn = gapStart + 1 + s.row
393    alongLine(frame, xs, fromEnd, turn, style)
394    acrossLine(frame, turn, xs, xt, style)
395    alongLine(frame, xt, turn, arrowAt, style)
396  }
397  if (s.from.real) putAt(frame, fromEnd, xs, frame.isDown ? '┬' : '├')
398  if (s.last && s.edge.head) putAt(frame, arrowAt, xt, frame.isDown ? '▼' : '►')
399  if (s.first && s.edge.both && s.from.real) putAt(frame, gapStart, xs, frame.isDown ? '▲' : '◄')
400}
401
402/** Writes the label at `x` on the first of `rows` where it and one free cell either side fit; false when none does. */
403function writeLabel(canvas: Canvas, label: string, x: number, rows: number[]): boolean {
404  for (const row of rows) {
405    if (canvas.isFree(x - 1, row, label.length + 2)) return (canvas.text(x, row, label), true)
406  }
407
408  return false
409}
410
411/** Writes an edge's label where it has room, one free cell around it; false when there is none. */
412function placeLabel(frame: Frame, s: Seg, gap: Gap): boolean {
413  const { canvas } = frame
414  const label = labelOf(s.edge)
415  const gapStart = gapStartOf(frame, s.from.layer)
416  const xs = centre(s.from)
417  const xt = centre(s.to)
418  const arrowAt = gapStart + gap.size - 1
419  if (!frame.isDown) {
420    // Left to right: just before the arrow, on the row above or below the line.
421    return writeLabel(canvas, label, arrowAt - label.length, [xt - 1, xt + 1])
422  }
423  const turn = gapStart + 1 + s.row
424  const isStraight = xs === xt
425  // By the target: beside the last leg, nearest the arrow first. By the source: beside its stem, nearest the box first.
426  const legRows = Array.from({ length: arrowAt - (isStraight ? gapStart : turn) }, (_, k) => arrowAt - 1 - k)
427  const stemRows = Array.from({ length: (isStraight ? arrowAt : turn) - gapStart }, (_, k) => gapStart + k)
428  const spots: [number, number[]][] = [[xt, [...legRows, arrowAt]], [xs, stemRows]]
429  if (s.anchor === 'from') spots.reverse()
430  // Right of the line first, then left of it.
431  return spots.some(([x, rows]) => writeLabel(canvas, label, x + 2, rows) || writeLabel(canvas, label, x - label.length - 1, rows))
432}
433
434function drawLayout(flow: Flow, graph: Graph, layers: Item[][]): Drawing {
435  const isDown = flow.direction === 'TD'
436  const size = layers.map(layer => Math.max(1, ...layer.filter(i => i.real).map(i => i.p)))
437  const gaps = layers.slice(0, -1).map((_, l) => gapOf(graph.segs.filter(s => s.from.layer === l), isDown))
438  const start: number[] = []
439  layers.forEach((_, l) => start.push(l === 0 ? 0 : (start[l - 1] as number) + (size[l - 1] as number) + (gaps[l - 1] as Gap).size))
440  const along = (start[start.length - 1] as number) + (size[size.length - 1] as number)
441  const across = Math.max(...layers.flat().map(i => i.pos + i.c + i.room))
442  const frame: Frame = { isDown, canvas: isDown ? new Canvas(across, along) : new Canvas(along, across), start, size }
443
444  layers.forEach((layer, l) => {
445    const p = start[l] as number
446    for (const item of layer) {
447      if (item.real) drawBox(frame.canvas, item, isDown ? item.pos : p, isDown ? p : item.pos, isDown)
448      else alongLine(frame, centre(item), p, p + (size[l] as number) - 1, (item.edge as FlowEdge).style)
449    }
450  })
451  for (const gap of gaps) for (const s of gap.segs) drawSegment(frame, s, gap)
452  // Labels last, so they only take cells no line needs.
453  const unplaced = gaps.flatMap(gap => gap.segs.filter(s => s.first && s.edge.label !== '' && !placeLabel(frame, s, gap)).map(s => s.edge))
454
455  return { lines: frame.canvas.toLines(), unplaced }
456}
457
458function notesOf(flow: Flow, analysis: Analysis, unplaced: FlowEdge[], columns: number): string[] {
459  const names = new Map(flow.nodes.map(n => [n.id, truncate(oneLine(n.text), 24)]))
460  const name = (id: string): string => names.get(id) ?? id
461  const lines = [
462    ...analysis.loops.map(e => `↺ ${name(e.from)} → ${name(e.to)}${e.label === '' ? '' : ` · ${oneLine(e.label)}`}`),
463    ...unplaced.map(e => `${name(e.from)} → ${name(e.to)} · ${oneLine(e.label)}`),
464    ...flow.notes,
465  ]
466
467  return lines.length === 0 ? [] : ['', ...lines.map(line => truncate(line, columns))]
468}
469
470/** One try at every wrap width for a direction; undefined when none fits. */
471function drawIn(flow: Flow, analysis: Analysis, columns: number): string[] | undefined {
472  const isDown = flow.direction === 'TD'
473  for (const textWidth of TEXT_WIDTHS) {
474    const graph = buildGraph(flow, analysis, textWidth, isDown)
475    if (graph.segs.length > MAX_SEGMENTS) throw new TooBig('too many connections')
476    const layers = orderLayers(graph)
477    position(layers, graph, isDown)
478    const drawn = drawLayout(flow, graph, layers)
479    if (widest(drawn.lines) <= columns) return [...drawn.lines, ...notesOf(flow, analysis, drawn.unplaced, columns)]
480  }
481
482  return undefined
483}
484
485/**
486 * Draws a flowchart in at most `columns` columns. A left-to-right chart that is too wide even with the smallest
487 * boxes is drawn top to bottom instead (and says so). Throws TooWide when nothing fits.
488 */
489export function drawFlow(flow: Flow, columns: number): string[] {
490  const analysis = analyse(flow)
491  const drawn = drawIn(flow, analysis, columns)
492  if (drawn !== undefined) return drawn
493  if (flow.direction === 'LR') {
494    const down = drawIn({ ...flow, direction: 'TD' }, analysis, columns)
495    if (down !== undefined) return [...down, ...(down.includes('') ? [] : ['']), truncate('Drawn top to bottom to fit', columns)]
496  }
497  throw new TooWide('no wrap width fits')
498}
499
hooks/flow-parse.ts 231 lines
1import type { LineStyle } from './canvas'
2
3/** Parsing the small, common part of Mermaid's flowchart syntax. Anything it does not understand throws, and the caller shows the source. */
4
5export type Shape = 'rect' | 'round' | 'diamond'
6export type FlowNode = { id: string; text: string; shape: Shape }
7export type FlowEdge = {
8  from: string
9  to: string
10  label: string
11  style: LineStyle
12  /** An arrowhead at the `to` end. */
13  head: boolean
14  /** An arrowhead at the `from` end too (`<-->`). */
15  both: boolean
16}
17export type Flow = {
18  direction: 'TD' | 'LR'
19  nodes: FlowNode[]
20  edges: FlowEdge[]
21  /** Things that were left out or simplified, one short line each. */
22  notes: string[]
23}
24
25/** Lines that only style or annotate; the drawing ignores them. */
26const IGNORED = /^(classDef|class|style|linkStyle|click|accTitle|accDescr|direction)\b/
27const ID = /^[A-Za-z0-9_\u00c0-\uffff]+/
28/** Openers with their closers and the shape they draw; the longer openers come first. */
29const SHAPES: readonly (readonly [string, string, Shape])[] = [
30  ['((', '))', 'round'],
31  ['([', '])', 'round'],
32  ['[[', ']]', 'rect'],
33  ['[(', ')]', 'rect'],
34  ['{{', '}}', 'round'],
35  ['[', ']', 'rect'],
36  ['(', ')', 'round'],
37  ['{', '}', 'diamond'],
38]
39const DOTTED = /^(<)?-\.+->?/
40const THICK = /^(<)?={2,}>?/
41const SOLID_HEAD = /^(<)?-{2,}>/
42const SOLID_END = /^(<)?-{2,}[xo](?=[\s|]|$)/
43const SOLID_OPEN = /^(<)?-{3,}/
44/** The older spelling with the text inside the arrow: `A -- text --> B`. */
45const INLINE_LABEL = /^(--|==|-\.)\s+(.+?)\s+(-{2,}>|-{3,}|={2,}>|={3,}|\.+->|\.+-)(?=\s|$)/
46
47/** The statements of a line: split at ";" that is outside quotes and brackets; a `%%` comment is cut off. */
48export function statementsOf(line: string): string[] {
49  const out: string[] = []
50  let current = ''
51  let depth = 0
52  let quoted = false
53  for (let i = 0; i < line.length; i++) {
54    const ch = line.charAt(i)
55    if (!quoted && ch === '%' && line.charAt(i + 1) === '%') break
56    if (ch === '"') quoted = !quoted
57    if (!quoted && (ch === '[' || ch === '(' || ch === '{')) depth++
58    if (!quoted && (ch === ']' || ch === ')' || ch === '}')) depth = Math.max(0, depth - 1)
59    if (ch === ';' && !quoted && depth === 0) {
60      out.push(current)
61      current = ''
62    } else current += ch
63  }
64  out.push(current)
65
66  return out.map(s => s.trim()).filter(s => s !== '')
67}
68
69/** A node's text without quotes, backticks or line-break tags. */
70function nodeText(raw: string): string {
71  let text = raw.trim()
72  if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) text = text.slice(1, -1)
73  if (text.length >= 2 && text.startsWith('`') && text.endsWith('`')) text = text.slice(1, -1)
74
75  return text
76    .replace(/<br\s*\/?>/gi, '\n')
77    .replace(/<[^>]*>/g, '')
78    .replace(/&quot;/g, '"')
79    .replace(/&amp;/g, '&')
80    .trim()
81}
82
83/** The index of `closer` in `s` from `from`, skipping quoted stretches; -1 when there is none. */
84function findCloser(s: string, closer: string, from: number): number {
85  let quoted = false
86  for (let i = from; i < s.length; i++) {
87    if (s.charAt(i) === '"') quoted = !quoted
88    else if (!quoted && s.startsWith(closer, i)) return i
89  }
90
91  return -1
92}
93
94type Parsed<T> = { value: T; next: number }
95
96function parseNodeRef(s: string, start: number): Parsed<{ id: string; text?: string; shape?: Shape }> {
97  const rest = s.slice(start)
98  const m = ID.exec(rest)
99  if (m === null) throw new Error(`no node at "${rest.slice(0, 12)}"`)
100  const id = m[0]
101  let i = start + id.length
102  for (const [open, close, shape] of SHAPES) {
103    if (!s.startsWith(open, i)) continue
104    const end = findCloser(s, close, i + open.length)
105    if (end < 0) throw new Error(`unclosed ${open}`)
106    const text = nodeText(s.slice(i + open.length, end))
107
108    return { value: { id, text: text === '' ? id : text, shape }, next: skipClass(s, end + close.length) }
109  }
110  i = skipClass(s, i)
111
112  return { value: { id }, next: i }
113}
114
115/** `A:::someClass` styles a node; the class is dropped. */
116function skipClass(s: string, i: number): number {
117  const m = /^:::[A-Za-z0-9_-]+/.exec(s.slice(i))
118
119  return m === null ? i : i + m[0].length
120}
121
122function skipSpaces(s: string, i: number): number {
123  let j = i
124  while (j < s.length && /\s/.test(s.charAt(j))) j++
125
126  return j
127}
128
129/** `A & B` : the nodes of one side of an arrow. */
130function parseGroup(s: string, start: number): Parsed<{ id: string; text?: string; shape?: Shape }[]> {
131  const group: { id: string; text?: string; shape?: Shape }[] = []
132  let i = start
133  for (;;) {
134    const ref = parseNodeRef(s, skipSpaces(s, i))
135    group.push(ref.value)
136    i = skipSpaces(s, ref.next)
137    if (s.charAt(i) !== '&') return { value: group, next: i }
138    i++
139  }
140}
141
142type Link = Omit<FlowEdge, 'from' | 'to'>
143
144function linkOf(op: string, label: string): Link {
145  const style: LineStyle = op.includes('=') ? 'thick' : op.includes('.') ? 'dotted' : 'solid'
146  const head = op.endsWith('>') || op.endsWith('x') || op.endsWith('o')
147
148  return { label, style, head, both: op.startsWith('<') }
149}
150
151function parseLink(s: string, start: number): Parsed<Link> {
152  const rest = s.slice(start)
153  const inline = INLINE_LABEL.exec(rest)
154  if (inline !== null) {
155    const op = inline[3] ?? ''
156
157    return { value: linkOf(op, (inline[2] ?? '').trim()), next: start + inline[0].length }
158  }
159  const m = DOTTED.exec(rest) ?? THICK.exec(rest) ?? SOLID_HEAD.exec(rest) ?? SOLID_END.exec(rest) ?? SOLID_OPEN.exec(rest)
160  if (m === null) throw new Error(`no arrow at "${rest.slice(0, 12)}"`)
161  let i = start + m[0].length
162  let label = ''
163  const piped = /^\s*\|([^|]*)\|/.exec(s.slice(i))
164  if (piped !== null) {
165    label = nodeText(piped[1] ?? '')
166    i += piped[0].length
167  }
168
169  return { value: linkOf(m[0], label), next: i }
170}
171
172class Builder {
173  readonly nodes = new Map<string, FlowNode>()
174  readonly edges: FlowEdge[] = []
175
176  node(ref: { id: string; text?: string; shape?: Shape }): void {
177    const known = this.nodes.get(ref.id)
178    if (known === undefined) this.nodes.set(ref.id, { id: ref.id, text: ref.text ?? ref.id, shape: ref.shape ?? 'rect' })
179    else if (ref.shape !== undefined) {
180      known.text = ref.text ?? known.text
181      known.shape = ref.shape
182    }
183  }
184
185  edge(from: string, to: string, link: Link): void {
186    const dup = this.edges.some(e => e.from === from && e.to === to && e.label === link.label && e.style === link.style)
187    if (!dup) this.edges.push({ from, to, ...link })
188  }
189}
190
191function parseStatement(stmt: string, out: Builder, notes: Set<string>): void {
192  if (IGNORED.test(stmt)) return
193  if (/^subgraph\b/.test(stmt)) {
194    notes.add('Subgraphs are drawn flat')
195
196    return
197  }
198  if (/^end$/.test(stmt)) return
199  let { value: previous, next: i } = parseGroup(stmt, 0)
200  previous.forEach(ref => out.node(ref))
201  for (;;) {
202    i = skipSpaces(stmt, i)
203    if (i >= stmt.length) return
204    const link = parseLink(stmt, i)
205    const group = parseGroup(stmt, link.next)
206    group.value.forEach(ref => out.node(ref))
207    for (const from of previous) for (const to of group.value) out.edge(from.id, to.id, link.value)
208    previous = group.value
209    i = group.next
210  }
211}
212
213/** Parses the lines after a `flowchart` / `graph` header line. Throws on anything it cannot read. */
214export function parseFlow(headerLine: string, body: readonly string[]): Flow {
215  const dir = /^(?:flowchart|graph)(?:-elk)?\s+(TB|TD|BT|LR|RL)\b/i.exec(headerLine.trim())?.[1]?.toUpperCase()
216  const out = new Builder()
217  const notes = new Set<string>()
218  // Statements may follow the header on its own line (`graph TD; A --> B`).
219  const afterHeader = headerLine.trim().replace(/^(?:flowchart|graph)(?:-elk)?(?:\s+(?:TB|TD|BT|LR|RL)\b)?/i, '')
220  for (const line of [afterHeader, ...body]) for (const stmt of statementsOf(line)) parseStatement(stmt, out, notes)
221  if (out.nodes.size === 0) throw new Error('no nodes')
222  if (dir === 'BT' || dir === 'RL') notes.add(`${dir} is drawn as ${dir === 'BT' ? 'TD' : 'LR'}`)
223
224  return {
225    direction: dir === 'LR' || dir === 'RL' ? 'LR' : 'TD',
226    nodes: [...out.nodes.values()],
227    edges: out.edges,
228    notes: [...notes],
229  }
230}
231
hooks/pie.ts 71 lines
1import { padEnd, truncate, widest } from './text'
2
3/** `pie` as horizontal bars: label, a bar, the share in percent (and the value when `showData` is given). */
4
5export type Pie = { title: string; showData: boolean; slices: { label: string; value: number }[] }
6
7const SLICE = /^"?([^"]*?)"?\s*:\s*(-?\d+(?:\.\d+)?)\s*$/
8
9/** Parses the header line and the lines after it. Throws on anything it cannot read. */
10export function parsePie(headerLine: string, body: readonly string[]): Pie {
11  const head = /^pie\b\s*(showData\b)?\s*(?:title\s+(.*))?$/i.exec(headerLine.trim())
12  const pie: Pie = { title: (head?.[2] ?? '').trim(), showData: head?.[1] !== undefined, slices: [] }
13  for (const raw of body) {
14    const line = raw.replace(/%%.*$/, '').trim()
15    if (line === '') continue
16    const title = /^title\s+(.*)$/i.exec(line)
17    if (title !== null) pie.title = (title[1] ?? '').trim()
18    else if (/^showData$/i.test(line)) pie.showData = true
19    else {
20      const m = SLICE.exec(line)
21      if (m === null) throw new Error(`cannot read "${line.slice(0, 20)}"`)
22      const value = Number(m[2])
23      if (!Number.isFinite(value) || value < 0) throw new Error('negative value')
24      pie.slices.push({ label: (m[1] ?? '').trim(), value })
25    }
26  }
27  if (pie.slices.length === 0) throw new Error('no slices')
28  if (pie.slices.every(slice => slice.value === 0)) throw new Error('all zero')
29
30  return pie
31}
32
33const PARTIAL = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
34const MIN_BAR = 6
35
36/** A bar `width` cells wide with `fraction` of it filled, to an eighth of a cell, on a light track. */
37export function bar(fraction: number, width: number): string {
38  const eighths = Math.round(Math.min(1, Math.max(0, fraction)) * width * 8)
39  const full = Math.floor(eighths / 8)
40  const part = PARTIAL[eighths % 8] ?? ''
41
42  return '█'.repeat(full) + part + '░'.repeat(Math.max(0, width - full - (part === '' ? 0 : 1)))
43}
44
45const number = (value: number): string => (Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100))
46
47export function drawPie(pie: Pie, columns: number): string[] {
48  const total = pie.slices.reduce((sum, slice) => sum + slice.value, 0)
49  const percents = pie.slices.map(slice => `${((slice.value / total) * 100).toFixed(1)}%`)
50  const percentWidth = widest(percents)
51  const allValues = pie.slices.map(slice => number(slice.value))
52  // The values go first when the pane is too narrow for them; the labels are cut before the share is.
53  const isValueShown = pie.showData && columns >= widest(allValues) + percentWidth + 14
54  const values = isValueShown ? allValues : []
55  const valueWidth = widest(values)
56  const extra = isValueShown ? valueWidth + 2 : 0
57  const labelCap = Math.max(1, Math.min(24, Math.floor(columns * 0.35), columns - percentWidth - extra - 2))
58  const labels = pie.slices.map(slice => truncate(slice.label.replace(/\s+/g, ' '), labelCap))
59  const labelWidth = widest(labels)
60  const barWidth = columns - labelWidth - percentWidth - extra - 4
61  const rows = pie.slices.map((slice, i) => {
62    const share = (percents[i] as string).padStart(percentWidth)
63    const value = isValueShown ? `  ${(values[i] as string).padStart(valueWidth)}` : ''
64    const drawing = barWidth >= MIN_BAR ? `  ${bar(slice.value / total, barWidth)}` : ''
65
66    return truncate(`${padEnd(labels[i] as string, labelWidth)}${drawing}  ${share}${value}`, columns)
67  })
68
69  return [...(pie.title === '' ? [] : [truncate(pie.title, columns), '']), ...rows]
70}
71
hooks/sequence.ts 246 lines
1import { Canvas } from './canvas'
2import type { LineStyle } from './canvas'
3import { statementsOf } from './flow-parse'
4import { TooWide, padEnd, truncate, widest, wrapText } from './text'
5
6/** The simple core of sequenceDiagram: participants, messages, notes, and block lines shown as a marker row. */
7
8type Head = 'arrow' | 'none' | 'cross' | 'async'
9type Event =
10  | { type: 'message'; from: number; to: number; text: string; style: LineStyle; head: Head }
11  | { type: 'note'; where: 'over' | 'left' | 'right'; a: number; b: number; text: string }
12  | { type: 'marker'; text: string }
13
14export type Sequence = { names: string[]; events: Event[] }
15
16const HEADS: Record<string, { style: LineStyle; head: Head }> = {
17  '->>': { style: 'solid', head: 'arrow' },
18  '-->>': { style: 'dotted', head: 'arrow' },
19  '->': { style: 'solid', head: 'none' },
20  '-->': { style: 'dotted', head: 'none' },
21  '-x': { style: 'solid', head: 'cross' },
22  '--x': { style: 'dotted', head: 'cross' },
23  '-)': { style: 'solid', head: 'async' },
24  '--)': { style: 'dotted', head: 'async' },
25  '<<->>': { style: 'solid', head: 'arrow' },
26  '<<-->>': { style: 'dotted', head: 'arrow' },
27}
28const MESSAGE = /^(\S+?)(<<-->>|<<->>|-->>|->>|--x|-x|--\)|-\)|-->|->)[+-]?([^\s:+<>)]+)\s*(?::\s*(.*))?$/
29const DECLARE = /^(?:create\s+)?(?:participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/i
30const NOTE = /^note\s+(left of|right of|over)\s+([^:,]+?)(?:\s*,\s*([^:]+?))?\s*:\s*(.*)$/i
31const BLOCK = /^(loop|alt|opt|par|critical|break|rect|else|and|option)\b\s*(.*)$/i
32const IGNORED = /^(activate|deactivate|destroy|title|box|accTitle|accDescr|link|links|end)\b/i
33const AUTONUMBER = /^(autonumber)\b/i
34
35const plain = (raw: string): string => raw.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]*>/g, '').trim()
36
37/** Parses the lines after a `sequenceDiagram` header. Throws on anything it cannot read. */
38export function parseSequence(body: readonly string[]): Sequence {
39  const ids: string[] = []
40  const names: string[] = []
41  const events: Event[] = []
42  let numbered = false
43  let count = 0
44  const index = (id: string, name?: string): number => {
45    let i = ids.indexOf(id)
46    if (i < 0) {
47      ids.push(id)
48      names.push(id)
49      i = ids.length - 1
50    }
51    if (name !== undefined && name !== '') names[i] = plain(name)
52
53    return i
54  }
55  for (const line of body) {
56    for (const stmt of statementsOf(line)) {
57      let m: RegExpExecArray | null
58      if (AUTONUMBER.test(stmt)) numbered = true
59      else if (IGNORED.test(stmt)) continue
60      else if ((m = DECLARE.exec(stmt)) !== null) index(m[1] as string, m[2])
61      else if ((m = NOTE.exec(stmt)) !== null) {
62        const where = (m[1] as string).toLowerCase().startsWith('over') ? 'over' : (m[1] as string).toLowerCase().startsWith('left') ? 'left' : 'right'
63        const a = index((m[2] as string).trim())
64        const b = m[3] === undefined ? a : index(m[3].trim())
65        events.push({ type: 'note', where, a: Math.min(a, b), b: Math.max(a, b), text: plain(m[4] ?? '') })
66      } else if ((m = MESSAGE.exec(stmt)) !== null) {
67        const how = HEADS[m[2] as string] as { style: LineStyle; head: Head }
68        count++
69        const label = plain(m[4] ?? '')
70        events.push({
71          type: 'message',
72          from: index(m[1] as string),
73          to: index(m[3] as string),
74          text: numbered ? `${count}. ${label}` : label,
75          ...how,
76        })
77      } else if ((m = BLOCK.exec(stmt)) !== null) {
78        events.push({ type: 'marker', text: `${(m[1] as string).toLowerCase()}${m[2] === '' ? '' : ` · ${plain(m[2] ?? '')}`}` })
79      } else throw new Error(`cannot read "${stmt.slice(0, 20)}"`)
80    }
81  }
82  if (names.length === 0) throw new Error('no participants')
83
84  return { names, events }
85}
86
87const LABEL_WIDTHS = [34, 26, 20, 14, 10, 7]
88const NOTE_MAX = 28
89/** The head of a message, by whether it points right or left. */
90function headOf(head: Head, isRight: boolean): string {
91  if (head === 'arrow') return isRight ? '►' : '◄'
92  if (head === 'async') return isRight ? '»' : '«'
93
94  return head === 'cross' ? '×' : ''
95}
96
97type Need = { lo: number; hi: number; width: number }
98
99/** Distances between neighbouring lifelines, grown until every message and note fits across the lifelines it spans. */
100function spacing(boxes: number[], needs: Need[]): number[] {
101  const gap = boxes.slice(0, -1).map((w, i) => Math.ceil((w + (boxes[i + 1] as number)) / 2) + 2)
102  for (const need of [...needs].sort((a, b) => a.hi - a.lo - (b.hi - b.lo))) {
103    let have = 0
104    for (let i = need.lo; i < need.hi; i++) have += gap[i] as number
105    if (have < need.width && need.hi > need.lo) gap[need.hi - 1] = (gap[need.hi - 1] as number) + need.width - have
106  }
107
108  return gap
109}
110
111type Row = { y: number; e: Event; label: string[]; box?: [number, number] }
112
113/** Rows of the body: where each event sits and how far left and right everything reaches (relative to the first lifeline). */
114function plan(seq: Sequence, xs: number[], boxes: number[], headHeight: number, labelWidth: number) {
115  let y = headHeight
116  let left = 0
117  let right = 0
118  const reach = (lo: number, hi: number): void => {
119    left = Math.min(left, lo)
120    right = Math.max(right, hi)
121  }
122  boxes.forEach((w, i) => reach((xs[i] as number) - Math.floor(w / 2), (xs[i] as number) - Math.floor(w / 2) + w - 1))
123  const rows: Row[] = []
124  for (const e of seq.events) {
125    y += 1
126    if (e.type === 'message') {
127      const label = wrapText(e.text, labelWidth)
128      rows.push({ y, e, label })
129      if (e.from === e.to) {
130        reach(xs[e.from] as number, (xs[e.from] as number) + 5 + widest(label))
131        y += Math.max(2, label.length)
132      } else y += label.length + 1
133    } else if (e.type === 'note') {
134      const label = noteRowsOf(e, labelWidth)
135      const bw = widest(label) + 4
136      const a = xs[e.a] as number
137      const isSpan = e.where === 'over' && e.b > e.a
138      const lo = e.where === 'over' ? (isSpan ? a - 2 : a - Math.floor(bw / 2)) : e.where === 'right' ? a + 2 : a - 2 - bw + 1
139      const hi = isSpan ? Math.max((xs[e.b] as number) + 2, lo + bw - 1) : lo + bw - 1
140      reach(lo, hi)
141      rows.push({ y, e, label, box: [lo, hi] })
142      y += label.length + 2
143    } else {
144      const label = truncate(e.text, labelWidth + 12)
145      rows.push({ y, e, label: [label] })
146      reach(0, label.length + 6)
147      y += 1
148    }
149  }
150
151  return { rows, left, right, height: y + 1 }
152}
153
154function noteRowsOf(e: Extract<Event, { type: 'note' }>, labelWidth: number): string[] {
155  return wrapText(e.text, Math.min(labelWidth, NOTE_MAX))
156}
157
158function layout(seq: Sequence, labelWidth: number): string[] {
159  const n = seq.names.length
160  const headRows = seq.names.map(name => wrapText(name, labelWidth))
161  const boxes = headRows.map(rows => widest(rows) + 4)
162  const headHeight = Math.max(...headRows.map(rows => rows.length)) + 2
163  const needs: Need[] = []
164  for (const e of seq.events) {
165    if (e.type === 'message') {
166      const w = widest(wrapText(e.text, labelWidth))
167      if (e.from !== e.to) needs.push({ lo: Math.min(e.from, e.to), hi: Math.max(e.from, e.to), width: w + 4 })
168      else if (e.from + 1 < n) needs.push({ lo: e.from, hi: e.from + 1, width: w + 8 })
169    } else if (e.type === 'note') {
170      const w = widest(noteRowsOf(e, labelWidth)) + 4
171      if (e.where === 'over' && e.b > e.a) needs.push({ lo: e.a, hi: e.b, width: w })
172      if (e.where === 'right' && e.a + 1 < n) needs.push({ lo: e.a, hi: e.a + 1, width: w + 6 })
173      if (e.where === 'left' && e.a > 0) needs.push({ lo: e.a - 1, hi: e.a, width: w + 6 })
174    }
175  }
176  const gap = spacing(boxes, needs)
177  const xs: number[] = []
178  boxes.forEach((_, i) => xs.push(i === 0 ? 0 : (xs[i - 1] as number) + (gap[i - 1] as number)))
179  const body = plan(seq, xs, boxes, headHeight, labelWidth)
180  const shift = -body.left
181  const canvas = new Canvas(body.right + shift + 1, body.height)
182  const cx = (i: number): number => (xs[i] as number) + shift
183
184  boxes.forEach((w, i) => {
185    const x = cx(i) - Math.floor(w / 2)
186    const rows = headRows[i] as string[]
187    canvas.vline(cx(i), headHeight - 1, body.height - 1, 'dotted')
188    canvas.box(x, 0, w, headHeight, ['┌', '┐', '┘', '└'])
189    rows.forEach((row, j) => canvas.text(x + 2 + Math.floor((w - 4 - row.length) / 2), 1 + j, row))
190    canvas.put(cx(i), headHeight - 1, '┬')
191  })
192  for (const row of body.rows) drawRow(canvas, row, cx, shift)
193
194  return canvas.toLines()
195}
196
197function drawRow(canvas: Canvas, r: Row, cx: (i: number) => number, shift: number): void {
198  const e = r.e
199  if (e.type === 'marker') canvas.text(0, r.y, `── ${r.label[0] ?? ''} ──`)
200  else if (e.type === 'note') drawNote(canvas, r, shift)
201  else if (e.from === e.to) drawSelf(canvas, r, e, cx(e.from))
202  else drawMessage(canvas, r, e, cx(e.from), cx(e.to))
203}
204
205function drawNote(canvas: Canvas, r: Row, shift: number): void {
206  const [lo, hi] = r.box as [number, number]
207  const x = lo + shift
208  const w = hi - lo + 1
209  canvas.box(x, r.y, w, r.label.length + 2, ['┌', '┐', '┘', '└'])
210  r.label.forEach((row, j) => {
211    // The box is cleared inside first, so a lifeline does not show through the note.
212    canvas.text(x + 1, r.y + 1 + j, padEnd('', w - 2))
213    canvas.text(x + 2 + Math.floor((w - 4 - row.length) / 2), r.y + 1 + j, row)
214  })
215}
216
217/** A message to another participant: the label above a line with its head next to the target. */
218function drawMessage(canvas: Canvas, r: Row, e: Extract<Event, { type: 'message' }>, from: number, to: number): void {
219  const lo = Math.min(from, to)
220  const hi = Math.max(from, to)
221  const arrowY = r.y + r.label.length
222  r.label.forEach((row, j) => canvas.text(lo + 2, r.y + j, row))
223  canvas.hline(lo, hi, arrowY, e.style)
224  const head = headOf(e.head, to > from)
225  if (head !== '') canvas.put(to > from ? hi - 1 : lo + 1, arrowY, head)
226}
227
228/** A message to itself: a small loop to the right of the lifeline, the label beside it. */
229function drawSelf(canvas: Canvas, r: Row, e: Extract<Event, { type: 'message' }>, x: number): void {
230  canvas.hline(x, x + 3, r.y, e.style)
231  canvas.vline(x + 3, r.y, r.y + 1, e.style)
232  canvas.hline(x, x + 3, r.y + 1, e.style)
233  const head = headOf(e.head, false)
234  if (head !== '') canvas.put(x + 1, r.y + 1, head)
235  r.label.forEach((row, j) => canvas.text(x + 5, r.y + j, row))
236}
237
238/** Draws a sequence diagram in at most `columns` columns. Throws TooWide when no label width fits. */
239export function drawSequence(seq: Sequence, columns: number): string[] {
240  for (const width of LABEL_WIDTHS) {
241    const lines = layout(seq, width)
242    if (widest(lines) <= columns) return lines
243  }
244  throw new TooWide('no label width fits')
245}
246
hooks/text.ts 89 lines
1/** Plain-text helpers shared by the drawings. Every character counts as one column (no East Asian or emoji widths). */
2
3/** Thrown when no wrap width makes a drawing fit the pane. */
4export class TooWide extends Error {}
5
6/** Thrown when a drawing would take too many line pieces to lay out in good time. */
7export class TooBig extends Error {}
8
9/** Tabs become two spaces and control characters (but not newlines) go, so a stray byte never breaks a row. */
10export function clean(source: string): string {
11  return source
12    .replace(/\r\n?/g, '\n')
13    .replace(/\t/g, '  ')
14    // eslint-disable-next-line no-control-regex
15    .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '')
16}
17
18/** Cuts a text to `width` characters, ending in an ellipsis when it was cut. */
19export function truncate(text: string, width: number): string {
20  if (width <= 0) return ''
21  if (text.length <= width) return text
22  if (width === 1) return '…'
23
24  return `${text.slice(0, width - 1)}…`
25}
26
27export function padEnd(text: string, width: number): string {
28  return text.length >= width ? text : text + ' '.repeat(width - text.length)
29}
30
31/** Splits a word longer than `width` into pieces of at most `width` characters. */
32function breakWord(word: string, width: number): string[] {
33  const pieces: string[] = []
34  for (let i = 0; i < word.length; i += width) pieces.push(word.slice(i, i + width))
35
36  return pieces
37}
38
39/** Wraps one paragraph (no newlines) greedily at word boundaries; a word too long for a row is broken. */
40function wrapParagraph(text: string, width: number): string[] {
41  const rows: string[] = []
42  let row = ''
43  for (const word of text.split(/\s+/).filter(w => w !== '')) {
44    for (const piece of breakWord(word, width)) {
45      if (row === '') row = piece
46      else if (row.length + 1 + piece.length <= width) row = `${row} ${piece}`
47      else {
48        rows.push(row)
49        row = piece
50      }
51    }
52  }
53  if (row !== '' || rows.length === 0) rows.push(row)
54
55  return rows
56}
57
58/** Wraps a text to rows of at most `width` characters; a "\n" in the text always starts a new row. Never returns an empty list. */
59export function wrapText(text: string, width: number): string[] {
60  const w = Math.max(1, Math.floor(width))
61
62  return text.split('\n').flatMap(paragraph => wrapParagraph(paragraph, w))
63}
64
65/** The longest row of a list. */
66export function widest(rows: readonly string[]): number {
67  return rows.reduce((max, row) => Math.max(max, row.length), 0)
68}
69
70const MAX_BOX_ROWS = 60
71
72/**
73 * The source in a box of its own, hard-wrapped to fit `columns`. Used wherever a diagram is not drawn,
74 * so the person still sees exactly what Claude wrote.
75 */
76export function codeBox(source: string, columns: number): string[] {
77  const inner = Math.max(4, columns - 4)
78  const rows = source
79    .replace(/\s+$/, '')
80    .split('\n')
81    .flatMap(line => (line.length <= inner ? [line] : breakWord(line, inner)))
82  const shown = rows.slice(0, MAX_BOX_ROWS)
83  if (rows.length > shown.length) shown.push(`… ${rows.length - shown.length} more lines`)
84  const width = widest(shown)
85  const bar = '─'.repeat(width + 2)
86
87  return [`┌${bar}┐`, ...shown.map(row => `│ ${padEnd(row, width)} │`), `└${bar}┘`]
88}
89
hooks/canvas.ts 120 lines
1/** A grid of characters with line segments that join into box-drawing corners and crossings. */
2
3export type LineStyle = 'solid' | 'dotted' | 'thick'
4
5const UP = 1
6const RIGHT = 2
7const DOWN = 4
8const LEFT = 8
9
10// Indexed by the OR of the four directions a cell's line leaves in.
11const SOLID = [' ', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼']
12const THICK = [' ', '┃', '━', '┗', '┃', '┃', '┏', '┣', '━', '┛', '━', '┻', '┓', '┫', '┳', '╋']
13const RANK: Record<LineStyle, number> = { dotted: 0, solid: 1, thick: 2 }
14
15function glyph(mask: number, style: LineStyle): string {
16  if (style === 'thick') return THICK[mask] ?? ' '
17  if (style === 'dotted') {
18    if (mask === UP || mask === DOWN || mask === (UP | DOWN)) return '┆'
19    if (mask === LEFT || mask === RIGHT || mask === (LEFT | RIGHT)) return '┄'
20  }
21
22  return SOLID[mask] ?? ' '
23}
24
25export class Canvas {
26  readonly width: number
27  readonly height: number
28  private readonly chars: (string | undefined)[][]
29  private readonly masks: number[][]
30  private readonly styles: LineStyle[][]
31
32  constructor(width: number, height: number) {
33    this.width = Math.max(0, Math.floor(width))
34    this.height = Math.max(0, Math.floor(height))
35    this.chars = Array.from({ length: this.height }, () => new Array<string | undefined>(this.width).fill(undefined))
36    this.masks = Array.from({ length: this.height }, () => new Array<number>(this.width).fill(0))
37    this.styles = Array.from({ length: this.height }, () => new Array<LineStyle>(this.width).fill('dotted'))
38  }
39
40  private inside(x: number, y: number): boolean {
41    return x >= 0 && y >= 0 && x < this.width && y < this.height
42  }
43
44  /** Writes one character that wins over any line passing through the cell. Out-of-range writes are ignored. */
45  put(x: number, y: number, ch: string): void {
46    if (this.inside(x, y)) (this.chars[y] as (string | undefined)[])[x] = ch
47  }
48
49  /** Writes a text left to right from (x, y). */
50  text(x: number, y: number, text: string): void {
51    for (let i = 0; i < text.length; i++) this.put(x + i, y, text.charAt(i))
52  }
53
54  private addLine(x: number, y: number, bits: number, style: LineStyle): void {
55    if (!this.inside(x, y)) return
56    const maskRow = this.masks[y] as number[]
57    const styleRow = this.styles[y] as LineStyle[]
58    if (maskRow[x] === 0 || RANK[style] >= RANK[styleRow[x] as LineStyle]) styleRow[x] = style
59    maskRow[x] = (maskRow[x] as number) | bits
60  }
61
62  /** A horizontal line from x1 to x2 (either order), ends included; it joins lines that meet it. */
63  hline(x1: number, x2: number, y: number, style: LineStyle = 'solid'): void {
64    const lo = Math.min(x1, x2)
65    const hi = Math.max(x1, x2)
66    for (let x = lo; x <= hi; x++) this.addLine(x, y, (x > lo ? LEFT : 0) | (x < hi ? RIGHT : 0), style)
67  }
68
69  /** A vertical line from y1 to y2 (either order), ends included. */
70  vline(x: number, y1: number, y2: number, style: LineStyle = 'solid'): void {
71    const lo = Math.min(y1, y2)
72    const hi = Math.max(y1, y2)
73    for (let y = lo; y <= hi; y++) this.addLine(x, y, (y > lo ? UP : 0) | (y < hi ? DOWN : 0), style)
74  }
75
76  /** True when nothing is drawn in the cells x .. x+length-1 of row y (and they are on the canvas). */
77  isFree(x: number, y: number, length: number): boolean {
78    for (let i = 0; i < length; i++) {
79      if (!this.inside(x + i, y)) return false
80      if ((this.chars[y] as (string | undefined)[])[x + i] !== undefined || (this.masks[y] as number[])[x + i] !== 0) return false
81    }
82
83    return true
84  }
85
86  /** A box with its own border characters; `corners` is top-left, top-right, bottom-right, bottom-left. */
87  box(x: number, y: number, w: number, h: number, corners: readonly [string, string, string, string]): void {
88    const [tl, tr, br, bl] = corners
89    for (let i = 1; i < w - 1; i++) {
90      this.put(x + i, y, '─')
91      this.put(x + i, y + h - 1, '─')
92    }
93    for (let j = 1; j < h - 1; j++) {
94      this.put(x, y + j, '│')
95      this.put(x + w - 1, y + j, '│')
96    }
97    this.put(x, y, tl)
98    this.put(x + w - 1, y, tr)
99    this.put(x + w - 1, y + h - 1, br)
100    this.put(x, y + h - 1, bl)
101  }
102
103  /** The rows as strings, trailing blanks and trailing empty rows removed. */
104  toLines(): string[] {
105    const rows: string[] = []
106    for (let y = 0; y < this.height; y++) {
107      let row = ''
108      for (let x = 0; x < this.width; x++) {
109        const ch = (this.chars[y] as (string | undefined)[])[x]
110        const mask = (this.masks[y] as number[])[x] as number
111        row += ch ?? (mask === 0 ? ' ' : glyph(mask, (this.styles[y] as LineStyle[])[x] as LineStyle))
112      }
113      rows.push(row.replace(/\s+$/, ''))
114    }
115    while (rows.length > 0 && rows[rows.length - 1] === '') rows.pop()
116
117    return rows
118  }
119}
120
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    diagram: {
4      /** The text of the last mermaid code block in a final answer (cut at 20,000 characters), or null. */
5      last: string | null
6    }
7  }
8}
9