@yassimba/pi-loom-mermaid 0.2.0 → 0.4.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/README.md +138 -5
- package/package.json +1 -1
- package/src/index.ts +22 -10
- package/src/loom-mermaid/ansi.ts +5 -3
- package/src/loom-mermaid/canvas.ts +9 -1
- package/src/loom-mermaid/diagrams/architecture.ts +116 -0
- package/src/loom-mermaid/graph-render.ts +122 -11
- package/src/loom-mermaid/graph.ts +16 -0
- package/src/loom-mermaid/index.ts +8 -3
- package/src/loom-mermaid/labels.ts +7 -0
- package/src/loom-mermaid/layout.ts +719 -182
- package/src/loom-mermaid/paint.ts +47 -19
- package/src/loom-mermaid/registry.ts +3 -0
- package/src/streaming.ts +23 -0
package/README.md
CHANGED
|
@@ -1,14 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-loom-mermaid
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Draws Mermaid blocks in Pi as Unicode terminal diagrams. Colored borders, hops where edges cross, and fewer stacked rails than Pi's built-in renderer or pi-lovely-mermaid. It also adds a short system prompt so the agent can answer in diagrams when that is clearer than prose.
|
|
4
|
+
|
|
5
|
+
Same diagram:
|
|
6
|
+
|
|
7
|
+
Pi built-in. One gray box style. Edges share long outer rails.
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="../../assets/mermaid-pi-builtin.png" alt="Pi built-in Mermaid: gray boxes and stacked outer edges">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
pi-lovely-mermaid. Color, still long parallel paths around the right side.
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="../../assets/mermaid-lovely.png" alt="pi-lovely-mermaid: colored borders with long parallel edges">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
This package. The same colors, plus crossing hops and shorter routes.
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="../../assets/mermaid-pi-loom.png" alt="pi-loom-mermaid: colored borders, crossing hops, and shorter routes">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
GitHub draws the next blocks with its own Mermaid. Paste the same source into Pi (built-in vs this package) to compare routing and color.
|
|
26
|
+
|
|
27
|
+
```mermaid
|
|
28
|
+
flowchart TD
|
|
29
|
+
CLI["turbine CLI<br/>shell.main"]:::orange
|
|
30
|
+
LSP["Editor<br/>turbine-lsp"]:::orange
|
|
31
|
+
HTTP["HTTP client"]:::red
|
|
32
|
+
|
|
33
|
+
CLI --> SELECT["Select ProjectLayout"]:::orange
|
|
34
|
+
LSP --> WORKSPACE["Discover Projects<br/>EditorWorkspace.open"]:::orange
|
|
35
|
+
|
|
36
|
+
SELECT --> RUNTIME["ProjectRuntime.create"]:::orange
|
|
37
|
+
WORKSPACE --> RUNTIME
|
|
38
|
+
|
|
39
|
+
ENTRY["Installed turbine.extension<br/>entry points"]:::red --> EXT["Discover, order, and admit<br/>Extensions"]:::orange
|
|
40
|
+
RUNTIME --> EXT
|
|
41
|
+
EXT --> CATALOG["ExtensionCatalog"]:::green
|
|
42
|
+
CATALOG --> FORMATS["InstalledFormats"]:::green
|
|
43
|
+
CATALOG --> LINT["CachedProjectLint"]:::green
|
|
44
|
+
|
|
45
|
+
RUNTIME --> SNAPSHOT["ProjectSnapshotCache"]:::green
|
|
46
|
+
RUNTIME --> RUN["CheckRun"]:::green
|
|
47
|
+
RUNTIME --> HISTORY["RunHistoryReader"]:::green
|
|
48
|
+
|
|
49
|
+
SNAPSHOT --> COMMANDS["CLI commands"]:::orange
|
|
50
|
+
SNAPSHOT --> SESSION["EditorSession"]:::orange
|
|
51
|
+
SNAPSHOT --> API["Management API"]:::orange
|
|
52
|
+
|
|
53
|
+
CLI --> COMMANDS
|
|
54
|
+
LSP --> SESSION
|
|
55
|
+
HTTP --> API
|
|
56
|
+
|
|
57
|
+
classDef red stroke:#9f5555
|
|
58
|
+
classDef orange stroke:#9a7438
|
|
59
|
+
classDef green stroke:#4f8560
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Stress cases: complex diagrams rendered by both engines.
|
|
63
|
+
|
|
64
|
+
**State — Pi built-in**
|
|
65
|
+
|
|
66
|
+
<p align="center">
|
|
67
|
+
<img src="../../assets/mermaid-state-pi.png" alt="Pi built-in state diagram">
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
**State — pi-loom-mermaid**
|
|
71
|
+
|
|
72
|
+
<p align="center">
|
|
73
|
+
<img src="../../assets/mermaid-state-loom.png" alt="pi-loom-mermaid state diagram">
|
|
74
|
+
</p>
|
|
75
|
+
|
|
76
|
+
**ER — Pi built-in**
|
|
77
|
+
|
|
78
|
+
<p align="center">
|
|
79
|
+
<img src="../../assets/mermaid-er-pi.png" alt="Pi built-in ER diagram">
|
|
80
|
+
</p>
|
|
81
|
+
|
|
82
|
+
**ER — pi-loom-mermaid**
|
|
83
|
+
|
|
84
|
+
<p align="center">
|
|
85
|
+
<img src="../../assets/mermaid-er-loom.png" alt="pi-loom-mermaid ER diagram">
|
|
86
|
+
</p>
|
|
87
|
+
|
|
88
|
+
**Sequence — Pi built-in**
|
|
89
|
+
|
|
90
|
+
<p align="center">
|
|
91
|
+
<img src="../../assets/mermaid-sequence-pi.png" alt="Pi built-in sequence diagram">
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
**Sequence — pi-loom-mermaid**
|
|
95
|
+
|
|
96
|
+
<p align="center">
|
|
97
|
+
<img src="../../assets/mermaid-sequence-loom.png" alt="pi-loom-mermaid sequence diagram">
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
**Dense class graph — Pi built-in**
|
|
101
|
+
|
|
102
|
+
<p align="center">
|
|
103
|
+
<img src="../../assets/mermaid-class-pi.png" alt="Pi built-in dense class diagram">
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
**Dense class graph — pi-loom-mermaid**
|
|
107
|
+
|
|
108
|
+
<p align="center">
|
|
109
|
+
<img src="../../assets/mermaid-class-loom.png" alt="pi-loom-mermaid dense class diagram">
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
**Dense flowchart — Pi built-in**
|
|
113
|
+
|
|
114
|
+
<p align="center">
|
|
115
|
+
<img src="../../assets/mermaid-dense-pi.png" alt="Pi built-in dense flowchart">
|
|
116
|
+
</p>
|
|
117
|
+
|
|
118
|
+
**Dense flowchart — pi-loom-mermaid**
|
|
119
|
+
|
|
120
|
+
<p align="center">
|
|
121
|
+
<img src="../../assets/mermaid-dense-loom.png" alt="pi-loom-mermaid dense flowchart">
|
|
122
|
+
</p>
|
|
4
123
|
|
|
5
124
|
## Install
|
|
6
125
|
|
|
126
|
+
From Loom setup, or:
|
|
127
|
+
|
|
7
128
|
```bash
|
|
8
129
|
pi install npm:@yassimba/pi-loom-mermaid
|
|
9
130
|
```
|
|
10
131
|
|
|
11
|
-
|
|
132
|
+
Turn off Pi's built-in Mermaid transformer so this extension gets the original source. In `~/.pi/agent/settings.json`:
|
|
12
133
|
|
|
13
134
|
```json
|
|
14
135
|
{
|
|
@@ -18,11 +139,23 @@ Disable Pi's built-in Mermaid transformer in `~/.pi/agent/settings.json` so this
|
|
|
18
139
|
}
|
|
19
140
|
```
|
|
20
141
|
|
|
21
|
-
|
|
142
|
+
Then run `/reload` in Pi.
|
|
143
|
+
|
|
144
|
+
## Usage
|
|
145
|
+
|
|
146
|
+
Mark diff nodes with the built-in `red`, `orange`, and `green` classes:
|
|
22
147
|
|
|
23
148
|
```mermaid
|
|
24
149
|
flowchart LR
|
|
25
150
|
A[Removed]:::red --> B[Changed]:::orange --> C[Added]:::green
|
|
26
151
|
```
|
|
27
152
|
|
|
28
|
-
|
|
153
|
+
Those classes color and dim only the box border. Fill and text stay on Pi's theme. A `classDef` in the diagram overrides a built-in class or adds other colors.
|
|
154
|
+
|
|
155
|
+
Diagrams wider than the terminal stay as Mermaid source.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
[MIT](LICENSE)
|
|
160
|
+
|
|
161
|
+
pi-loom-mermaid is adapted from pi-lovely-mermaid.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Marked, type Token } from "@earendil-works/pi-tui";
|
|
3
|
-
import { render, toAnsi } from "./loom-mermaid/index.ts";
|
|
3
|
+
import { diagramKind, render, toAnsi } from "./loom-mermaid/index.ts";
|
|
4
|
+
import { isClosedFence, streamingPrefixes } from "./streaming.ts";
|
|
4
5
|
|
|
5
6
|
const markdownParser = new Marked();
|
|
6
7
|
const diffClasses = {
|
|
@@ -12,7 +13,7 @@ const diffClasses = {
|
|
|
12
13
|
type TransformContext = {
|
|
13
14
|
messageType: "user" | "assistant" | "assistant-thinking";
|
|
14
15
|
availableWidth: number;
|
|
15
|
-
/**
|
|
16
|
+
/** Draw completed statements while the message is still arriving. */
|
|
16
17
|
isStreaming?: boolean;
|
|
17
18
|
};
|
|
18
19
|
|
|
@@ -22,11 +23,11 @@ type TransformContext = {
|
|
|
22
23
|
* laid out again for each token after it. Layout is deterministic, so the
|
|
23
24
|
* first render is the only one needed.
|
|
24
25
|
*/
|
|
25
|
-
const rendered = new Map<string, string>();
|
|
26
|
+
const rendered = new Map<string, string | null>();
|
|
26
27
|
const CACHE_SIZE = 64;
|
|
27
28
|
|
|
28
29
|
function renderBlock(text: string, availableWidth: number): string | null {
|
|
29
|
-
const key = `${availableWidth}\0${text}`;
|
|
30
|
+
const key = `${availableWidth}\0${text.trimEnd()}`;
|
|
30
31
|
const hit = rendered.get(key);
|
|
31
32
|
if (hit !== undefined) {
|
|
32
33
|
rendered.delete(key);
|
|
@@ -35,16 +36,15 @@ function renderBlock(text: string, availableWidth: number): string | null {
|
|
|
35
36
|
}
|
|
36
37
|
const styledSource = withDiffClasses(text);
|
|
37
38
|
const art = render(styledSource.source, { maxWidth: availableWidth });
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const out =
|
|
40
|
+
!art || art.width > availableWidth
|
|
41
|
+
? null
|
|
42
|
+
: `${dimDefaultBorders(toAnsi(art), styledSource.dimSgr).map(codeSpan).join(" \n")}\n`;
|
|
40
43
|
rendered.set(key, out);
|
|
41
44
|
if (rendered.size > CACHE_SIZE) rendered.delete(rendered.keys().next().value as string);
|
|
42
45
|
return out;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
/** A fenced block whose closing fence has arrived. */
|
|
46
|
-
const isClosed = (token: { raw: string }): boolean => /\n\s*(`{3,}|~{3,})\s*$/.test(token.raw);
|
|
47
|
-
|
|
48
48
|
function isMermaid(token: Token): token is Token & { type: "code"; text: string; lang?: string } {
|
|
49
49
|
return (
|
|
50
50
|
token.type === "code" && token.lang?.trim().split(/\s+/, 1)[0]?.toLowerCase() === "mermaid"
|
|
@@ -92,7 +92,16 @@ export function transformMermaidMarkdown(markdown: string, context: TransformCon
|
|
|
92
92
|
.lexer(markdown)
|
|
93
93
|
.map((token) => {
|
|
94
94
|
if (!isMermaid(token)) return token.raw;
|
|
95
|
-
if (context.isStreaming === true && !
|
|
95
|
+
if (context.isStreaming === true && !isClosedFence(token.raw)) {
|
|
96
|
+
if (diagramKind(token.text) === null) return token.raw;
|
|
97
|
+
// Marked removes the last newline from unclosed code tokens.
|
|
98
|
+
const source = token.text + (token.raw.endsWith("\n") ? "\n" : "");
|
|
99
|
+
for (const prefix of streamingPrefixes(source)) {
|
|
100
|
+
const out = renderBlock(prefix, context.availableWidth);
|
|
101
|
+
if (out !== null) return out;
|
|
102
|
+
}
|
|
103
|
+
return "_Drawing Mermaid…_\n";
|
|
104
|
+
}
|
|
96
105
|
return renderBlock(token.text, context.availableWidth) ?? token.raw;
|
|
97
106
|
})
|
|
98
107
|
.join("");
|
|
@@ -100,4 +109,7 @@ export function transformMermaidMarkdown(markdown: string, context: TransformCon
|
|
|
100
109
|
|
|
101
110
|
export default function piLovelyMermaid(pi: ExtensionAPI): void {
|
|
102
111
|
pi.registerMarkdownTransformer(transformMermaidMarkdown);
|
|
112
|
+
pi.on("before_agent_start", (event) => ({
|
|
113
|
+
systemPrompt: `${event.systemPrompt}\n\nUse fenced \`mermaid\` blocks; they render automatically in the user’s session. Use Mermaid proactively to explain relationships, flows, and changes. Choose by subject: architecture for deployed services, flowchart for dependencies/decisions, sequence for interactions, state for lifecycles, ER/class for models, mindmap for hierarchies, timeline/git graph for history, pie for proportions. Use complementary diagrams when explaining multiple aspects. Keep diagram labels short. Mark changes (like diff but also other changes) with: :::red removed, :::green added, :::orange changed. In general prefer colored outlines to logically group things (if there are no changes involved).`,
|
|
114
|
+
}));
|
|
103
115
|
}
|
package/src/loom-mermaid/ansi.ts
CHANGED
|
@@ -25,7 +25,7 @@ const rgb = (hex: string, sgr: 38 | 48): string =>
|
|
|
25
25
|
/**
|
|
26
26
|
* The truecolor SGR a class style gives a span of the given role, or
|
|
27
27
|
* undefined when the style says nothing about it (fall back to the theme).
|
|
28
|
-
* `stroke` colors borders, `color` text; `fill` backs
|
|
28
|
+
* `stroke` colors borders, `color` text; `fill` backs interior cells only,
|
|
29
29
|
* with a black/white foreground picked for contrast when none was declared.
|
|
30
30
|
* A style that colors nothing for this role keeps `fallback` (the theme's
|
|
31
31
|
* SGR), so a bold-only class bolds the themed look instead of replacing it.
|
|
@@ -33,9 +33,11 @@ const rgb = (hex: string, sgr: 38 | 48): string =>
|
|
|
33
33
|
export function classSgr(st: ClassStyle, role: Role, fallback?: string): string | undefined {
|
|
34
34
|
const p: string[] = []
|
|
35
35
|
const fg = role === 'border' ? (st.stroke ?? st.color) : role === 'edge' ? undefined : st.color
|
|
36
|
-
|
|
36
|
+
// Terminal backgrounds cover whole cells, including the outside half of an outline glyph.
|
|
37
|
+
const fill = role === 'border' ? undefined : st.fill
|
|
38
|
+
const backed = fg ?? (fill === undefined ? undefined : contrastOn(fill))
|
|
37
39
|
if (backed !== undefined) p.push(rgb(backed, 38))
|
|
38
|
-
if (
|
|
40
|
+
if (fill !== undefined) p.push(rgb(fill, 48))
|
|
39
41
|
if (p.length === 0 && fallback !== undefined) p.push(fallback)
|
|
40
42
|
if (st.bold === true) p.unshift('1')
|
|
41
43
|
return p.length > 0 ? p.join(';') : undefined
|
|
@@ -120,7 +120,9 @@ export class Canvas {
|
|
|
120
120
|
this.href[di] = sub.href[si]
|
|
121
121
|
this.style[di] = sub.style[si]
|
|
122
122
|
this.pass[di] = sub.pass[si]
|
|
123
|
-
|
|
123
|
+
// Blank padding inside the frame stays free: a cross-frame route
|
|
124
|
+
// may run a stub through it to the inner node it joins.
|
|
125
|
+
this.occupied[di] = sub.occupied[si] || sub.ch[si] !== ' ' ? 1 : 0
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
}
|
|
@@ -129,6 +131,12 @@ export class Canvas {
|
|
|
129
131
|
junction(x: number, y: number, bits: number): void {
|
|
130
132
|
if (x >= this.w || y >= this.h) return
|
|
131
133
|
const i = this.idx(x, y)
|
|
134
|
+
// A plain border glyph stamped from a sub-canvas goes back to bits so
|
|
135
|
+
// the tee resolves with the rest.
|
|
136
|
+
if (this.ch[i] === '│' || this.ch[i] === '─') {
|
|
137
|
+
this.mask[i] |= this.ch[i] === '│' ? U | D : L | R
|
|
138
|
+
this.ch[i] = ' '
|
|
139
|
+
}
|
|
132
140
|
this.mask[i] |= bits
|
|
133
141
|
this.pass[i] |= JOINED
|
|
134
142
|
if (this.role[i] !== 'border') this.role[i] = 'edge'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `architecture-beta`: services and junctions inside nested groups.
|
|
3
|
+
*
|
|
4
|
+
* Architecture's cardinal ports choose exact SVG attachment points. The
|
|
5
|
+
* terminal graph router already chooses reachable box sides, so this parser
|
|
6
|
+
* preserves endpoints and arrowheads while leaving port placement to it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Graph, MAX_GROUP_DEPTH, MAX_GROUPS } from '../graph.ts'
|
|
10
|
+
import { cleanLabel } from '../labels.ts'
|
|
11
|
+
import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
|
|
12
|
+
import type { Diagram } from '../registry.ts'
|
|
13
|
+
import { firstWord, headerKind, statementsOf } from '../statements.ts'
|
|
14
|
+
|
|
15
|
+
export const architecture: Diagram = {
|
|
16
|
+
kind: 'architecture',
|
|
17
|
+
headers: ['architecture-beta'],
|
|
18
|
+
render(src, limits) {
|
|
19
|
+
const graph = parseArchitecture(src)
|
|
20
|
+
if (graph === null) return null
|
|
21
|
+
const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
|
|
22
|
+
if (canvas === null) return null
|
|
23
|
+
return { canvas, warnings: graph.warnings, classDefs: {} }
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DECLARATION = /^(group|service)\s+([^\s()[\]:{}]+)\(([^)]*)\)\[([^\]]*)\](?:\s+in\s+([^\s]+))?$/i
|
|
28
|
+
const JUNCTION = /^junction\s+([^\s:{}]+)(?:\s+in\s+([^\s]+))?$/i
|
|
29
|
+
const EDGE = /^([^\s:{}]+)(?:\{group\})?:([TBLR])\s*(<)?--(>)?\s*([TBLR]):([^\s:{}]+)(?:\{group\})?$/i
|
|
30
|
+
|
|
31
|
+
function parseArchitecture(src: string): Graph | null {
|
|
32
|
+
const statements = statementsOf(src)
|
|
33
|
+
if (headerKind(statements) !== 'architecture-beta') return null
|
|
34
|
+
|
|
35
|
+
// Architecture has no global direction. LR gives grouped boundary edges the
|
|
36
|
+
// existing router's more precise inner-node anchors.
|
|
37
|
+
const graph = new Graph('right')
|
|
38
|
+
const groupIndex = new Map<string, number>()
|
|
39
|
+
|
|
40
|
+
for (const st of statements.slice(1)) {
|
|
41
|
+
const declaration = st.match(DECLARATION)
|
|
42
|
+
const junction = st.match(JUNCTION)
|
|
43
|
+
const edge = st.match(EDGE)
|
|
44
|
+
|
|
45
|
+
if (declaration) {
|
|
46
|
+
const [, kind, id, , rawLabel, parentId] = declaration
|
|
47
|
+
const parent = parentId === undefined ? null : groupIndex.get(parentId)
|
|
48
|
+
if (parentId !== undefined && parent === undefined) {
|
|
49
|
+
graph.drop(st)
|
|
50
|
+
} else if (kind.toLowerCase() === 'group') {
|
|
51
|
+
if (groupIndex.has(id)) {
|
|
52
|
+
graph.drop(st)
|
|
53
|
+
} else if (
|
|
54
|
+
graph.groups.length >= MAX_GROUPS ||
|
|
55
|
+
groupDepth(graph, parent ?? null) >= MAX_GROUP_DEPTH
|
|
56
|
+
) {
|
|
57
|
+
graph.truncated ??= `subgraph cap (${MAX_GROUPS} groups, depth ${MAX_GROUP_DEPTH}) reached`
|
|
58
|
+
} else {
|
|
59
|
+
groupIndex.set(id, graph.groups.length)
|
|
60
|
+
graph.groups.push({ id, label: cleanLabel(rawLabel) || id, parent: parent ?? null })
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
addNode(graph, id, cleanLabel(rawLabel) || id, parent ?? null, 'rect')
|
|
64
|
+
}
|
|
65
|
+
} else if (junction) {
|
|
66
|
+
const [, id, parentId] = junction
|
|
67
|
+
const parent = parentId === undefined ? null : groupIndex.get(parentId)
|
|
68
|
+
if (parentId !== undefined && parent === undefined) graph.drop(st)
|
|
69
|
+
else addNode(graph, id, '•', parent ?? null, 'round')
|
|
70
|
+
} else if (edge) {
|
|
71
|
+
const [, fromId, , leftArrow, rightArrow, , toId] = edge
|
|
72
|
+
const from = graph.index.get(fromId)
|
|
73
|
+
const to = graph.index.get(toId)
|
|
74
|
+
if (from === undefined || to === undefined) {
|
|
75
|
+
graph.drop(st)
|
|
76
|
+
} else {
|
|
77
|
+
graph.pushEdge({
|
|
78
|
+
from,
|
|
79
|
+
to,
|
|
80
|
+
label: null,
|
|
81
|
+
headFrom: leftArrow ? 'arrow' : 'none',
|
|
82
|
+
headTo: rightArrow ? 'arrow' : 'none',
|
|
83
|
+
line: 'solid',
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
} else if (firstWord(st).toLowerCase() !== 'title') {
|
|
87
|
+
graph.drop(st)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (graph.truncated !== null) {
|
|
91
|
+
graph.warnings.push(`diagram truncated: ${graph.truncated}`)
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return graph.nodes.length === 0 ? null : graph
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function addNode(
|
|
100
|
+
graph: Graph,
|
|
101
|
+
id: string,
|
|
102
|
+
label: string,
|
|
103
|
+
group: number | null,
|
|
104
|
+
shape: 'rect' | 'round',
|
|
105
|
+
): void {
|
|
106
|
+
const previous = graph.curGroup
|
|
107
|
+
graph.curGroup = group
|
|
108
|
+
graph.nodeIndex(id, label, shape)
|
|
109
|
+
graph.curGroup = previous
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function groupDepth(graph: Graph, parent: number | null): number {
|
|
113
|
+
let depth = 0
|
|
114
|
+
for (let at = parent; at !== null; at = graph.groups[at].parent) depth++
|
|
115
|
+
return depth
|
|
116
|
+
}
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Canvas } from './canvas.ts'
|
|
8
|
-
import type { Edge, Node } from './graph.ts'
|
|
8
|
+
import type { Anchor, Edge, Node } from './graph.ts'
|
|
9
9
|
import { Graph } from './graph.ts'
|
|
10
10
|
import type { Limits } from './labels.ts'
|
|
11
11
|
import { layout, type NodeExtra } from './layout.ts'
|
|
12
|
-
import { orient, paint } from './paint.ts'
|
|
12
|
+
import { frameOrigin, orient, paint } from './paint.ts'
|
|
13
13
|
|
|
14
14
|
/** A laid-out canvas, or `null` when the diagram is empty or over the cell cap. */
|
|
15
15
|
export type CanvasResult = Canvas | null
|
|
@@ -33,6 +33,66 @@ export function layoutClass(graph: Graph, limits: Limits): CanvasResult {
|
|
|
33
33
|
|
|
34
34
|
// -------------------------------------------------------------------- groups
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The overview of a grouped diagram: every top-level subgraph becomes one
|
|
38
|
+
* node labelled with its title and member count in parentheses, edges between two
|
|
39
|
+
* subgraphs (or a subgraph and a loose node) merge into one, and edges
|
|
40
|
+
* inside a subgraph disappear. Multilevel drawing's coarsest level
|
|
41
|
+
* (Walshaw), used when the full diagram is too wide for the space.
|
|
42
|
+
*/
|
|
43
|
+
function collapseGroups(graph: Graph): Graph {
|
|
44
|
+
const out = new Graph(graph.dir)
|
|
45
|
+
out.classDefs = graph.classDefs
|
|
46
|
+
const topOf = (g: number | null): number | null => {
|
|
47
|
+
let cur = g
|
|
48
|
+
while (cur !== null && graph.groups[cur].parent !== null) cur = graph.groups[cur].parent
|
|
49
|
+
return cur
|
|
50
|
+
}
|
|
51
|
+
const groupNode = new Map<number, number>()
|
|
52
|
+
const members = new Map<number, number>()
|
|
53
|
+
graph.nodeGroup.forEach((g) => {
|
|
54
|
+
const t = topOf(g)
|
|
55
|
+
if (t !== null) members.set(t, (members.get(t) ?? 0) + 1)
|
|
56
|
+
})
|
|
57
|
+
const nodeAt = new Map<number, number>()
|
|
58
|
+
graph.nodes.forEach((node, v) => {
|
|
59
|
+
const t = topOf(graph.nodeGroup[v])
|
|
60
|
+
if (t === null) {
|
|
61
|
+
if (graph.groups.some((g) => graph.index.get(g.id) === v)) return
|
|
62
|
+
nodeAt.set(v, out.nodes.length)
|
|
63
|
+
out.nodes.push(node)
|
|
64
|
+
out.nodeGroup.push(null)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
let gn = groupNode.get(t)
|
|
68
|
+
if (gn === undefined) {
|
|
69
|
+
gn = out.nodes.length
|
|
70
|
+
const count = members.get(t) ?? 0
|
|
71
|
+
out.nodes.push({ label: `${graph.groups[t].label || graph.groups[t].id} (${count})`, shape: 'rect' })
|
|
72
|
+
out.nodeGroup.push(null)
|
|
73
|
+
groupNode.set(t, gn)
|
|
74
|
+
}
|
|
75
|
+
nodeAt.set(v, gn)
|
|
76
|
+
})
|
|
77
|
+
// A node whose id names a subgraph stands for it.
|
|
78
|
+
graph.groups.forEach((g, gi) => {
|
|
79
|
+
const v = graph.index.get(g.id)
|
|
80
|
+
const t = topOf(gi)
|
|
81
|
+
if (v !== undefined && t !== null && groupNode.has(t)) nodeAt.set(v, groupNode.get(t) as number)
|
|
82
|
+
})
|
|
83
|
+
const seen = new Set<string>()
|
|
84
|
+
for (const e of graph.edges) {
|
|
85
|
+
const a = nodeAt.get(e.from)
|
|
86
|
+
const b = nodeAt.get(e.to)
|
|
87
|
+
if (a === undefined || b === undefined || a === b) continue
|
|
88
|
+
const key = `${a}>${b}`
|
|
89
|
+
if (seen.has(key)) continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
out.edges.push({ ...e, from: a, to: b, label: null, cardFrom: undefined, cardTo: undefined })
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
36
96
|
/** An endpoint inside a scope: a plain node or a (proxied) subgraph. */
|
|
37
97
|
interface ScopeItem {
|
|
38
98
|
group: boolean
|
|
@@ -47,6 +107,7 @@ interface ScopeItem {
|
|
|
47
107
|
* one crossing a subgraph boundary attaches to the frame instead of the node.
|
|
48
108
|
*/
|
|
49
109
|
export function layoutGrouped(graph: Graph, limits: Limits): CanvasResult {
|
|
110
|
+
if (limits.collapse) return layoutFlowchart(collapseGroups(graph), limits)
|
|
50
111
|
// A node whose id matches a subgraph id stands in for that subgraph.
|
|
51
112
|
const proxy = new Map<number, number>()
|
|
52
113
|
graph.groups.forEach((g, gi) => {
|
|
@@ -115,8 +176,14 @@ export function layoutGrouped(graph: Graph, limits: Limits): CanvasResult {
|
|
|
115
176
|
if (g.parent === null) visit(gi)
|
|
116
177
|
})
|
|
117
178
|
|
|
118
|
-
const
|
|
119
|
-
return
|
|
179
|
+
const scope = buildScope(graph, null, scopeEdges, directNodes, keep, limits)
|
|
180
|
+
return scope && orient(scope.canvas, graph)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A laid-out scope: its canvas and where every node inside it (at any depth) landed. */
|
|
184
|
+
interface Scope {
|
|
185
|
+
canvas: Canvas
|
|
186
|
+
anchors: Map<number, Anchor>
|
|
120
187
|
}
|
|
121
188
|
|
|
122
189
|
function buildScope(
|
|
@@ -126,19 +193,33 @@ function buildScope(
|
|
|
126
193
|
directNodes: Map<number | null, number[]>,
|
|
127
194
|
keep: boolean[],
|
|
128
195
|
limits: Limits,
|
|
129
|
-
):
|
|
196
|
+
): Scope | null {
|
|
130
197
|
const items: ScopeItem[] = (directNodes.get(scope) ?? []).map((i) => ({ group: false, i }))
|
|
131
198
|
const childGroups = graph.groups
|
|
132
199
|
.map((_, gi) => gi)
|
|
133
200
|
.filter((gi) => graph.groups[gi].parent === scope && keep[gi])
|
|
134
201
|
items.push(...childGroups.map((i) => ({ group: true, i })))
|
|
202
|
+
// In declaration order, a frame standing where its first member was
|
|
203
|
+
// named: ranking breaks cycles from the first item, the author's entry.
|
|
204
|
+
const firstIn = (gi: number): number => {
|
|
205
|
+
let first = graph.nodes.length
|
|
206
|
+
graph.nodeGroup.forEach((g, ni) => {
|
|
207
|
+
for (let at: number | null = g; at !== null; at = graph.groups[at].parent) {
|
|
208
|
+
if (at === gi) first = Math.min(first, ni)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
return first
|
|
212
|
+
}
|
|
213
|
+
const order = (item: ScopeItem): number => (item.group ? firstIn(item.i) : item.i)
|
|
214
|
+
items.sort((a, b) => order(a) - order(b))
|
|
135
215
|
|
|
136
|
-
if (items.length === 0) return new Canvas(1, 1)
|
|
216
|
+
if (items.length === 0) return { canvas: new Canvas(1, 1), anchors: new Map() }
|
|
137
217
|
|
|
138
218
|
const nodeAt = new Map<number, number>()
|
|
139
219
|
const groupAt = new Map<number, number>()
|
|
140
220
|
const nodes: Node[] = []
|
|
141
221
|
const extras: NodeExtra[] = []
|
|
222
|
+
const subScopes = new Map<number, Scope>()
|
|
142
223
|
for (const item of items) {
|
|
143
224
|
;(item.group ? groupAt : nodeAt).set(item.i, nodes.length)
|
|
144
225
|
if (!item.group) {
|
|
@@ -152,11 +233,15 @@ function buildScope(
|
|
|
152
233
|
} else {
|
|
153
234
|
const sub = buildScope(graph, item.i, scopeEdges, directNodes, keep, limits)
|
|
154
235
|
if (sub === null) return null
|
|
236
|
+
subScopes.set(nodes.length, sub)
|
|
155
237
|
nodes.push({ label: graph.groups[item.i].label, shape: 'rect' })
|
|
156
|
-
extras.push({ kind: 'frame', sub })
|
|
238
|
+
extras.push({ kind: 'frame', sub: sub.canvas })
|
|
157
239
|
}
|
|
158
240
|
}
|
|
159
241
|
|
|
242
|
+
// An end standing for a frame remembers the inner node it really joins.
|
|
243
|
+
const anchorOf = (item: ScopeItem, node: number): Anchor | undefined =>
|
|
244
|
+
item.group ? subScopes.get(groupAt.get(item.i) as number)?.anchors.get(node) : undefined
|
|
160
245
|
const edges: Edge[] = []
|
|
161
246
|
for (const [f, t, ei] of scopeEdges.get(scope) ?? []) {
|
|
162
247
|
const fi = (f.group ? groupAt : nodeAt).get(f.i)
|
|
@@ -170,11 +255,21 @@ function buildScope(
|
|
|
170
255
|
headTo: e.headTo,
|
|
171
256
|
headFrom: e.headFrom,
|
|
172
257
|
line: e.line,
|
|
258
|
+
fromAnchor: anchorOf(f, e.from),
|
|
259
|
+
toAnchor: anchorOf(t, e.to),
|
|
173
260
|
}
|
|
174
|
-
//
|
|
175
|
-
// the frame
|
|
261
|
+
// Repeats of one inner-node pair ride the same cells; draw them once.
|
|
262
|
+
// Top-down layout still ports at the frame (ponytail: anchors are wired
|
|
263
|
+
// into placeLr only), so there every pair of frames is one edge.
|
|
264
|
+
const ported = graph.dir === 'right' || graph.dir === 'left'
|
|
176
265
|
const twin = (a: Edge, b: Edge): boolean =>
|
|
177
|
-
a.from === b.from &&
|
|
266
|
+
a.from === b.from &&
|
|
267
|
+
a.to === b.to &&
|
|
268
|
+
(!ported || (a.fromAnchor?.node === b.fromAnchor?.node && a.toAnchor?.node === b.toAnchor?.node)) &&
|
|
269
|
+
a.label === b.label &&
|
|
270
|
+
a.headTo === b.headTo &&
|
|
271
|
+
a.headFrom === b.headFrom &&
|
|
272
|
+
a.line === b.line
|
|
178
273
|
if ((f.group || t.group) && edges.some((x) => twin(x, collapsed))) continue
|
|
179
274
|
edges.push(collapsed)
|
|
180
275
|
}
|
|
@@ -183,7 +278,23 @@ function buildScope(
|
|
|
183
278
|
const synth = new Graph(graph.dir)
|
|
184
279
|
synth.nodes = nodes
|
|
185
280
|
synth.edges = edges
|
|
186
|
-
|
|
281
|
+
const lay = layout(synth, extras, limits)
|
|
282
|
+
if (lay === null) return null
|
|
283
|
+
const canvas = paint(synth, extras, lay)
|
|
284
|
+
|
|
285
|
+
const anchors = new Map<number, Anchor>()
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
const li = (item.group ? groupAt : nodeAt).get(item.i) as number
|
|
288
|
+
const p = lay.placed[li]
|
|
289
|
+
if (!item.group) {
|
|
290
|
+
anchors.set(item.i, { node: item.i, x: p.x, y: p.y, w: p.w, h: p.h })
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const sub = subScopes.get(li) as Scope
|
|
294
|
+
const [ox, oy] = frameOrigin(p, sub.canvas)
|
|
295
|
+
for (const a of sub.anchors.values()) anchors.set(a.node, { ...a, x: a.x + ox, y: a.y + oy })
|
|
296
|
+
}
|
|
297
|
+
return { canvas, anchors }
|
|
187
298
|
}
|
|
188
299
|
|
|
189
300
|
/** Lay out and paint one scope. */
|
|
@@ -66,6 +66,22 @@ export interface Edge {
|
|
|
66
66
|
headTo: Head
|
|
67
67
|
headFrom: Head
|
|
68
68
|
line: LineKind
|
|
69
|
+
/**
|
|
70
|
+
* Set on an end that stands for a subgraph frame: the inner node the
|
|
71
|
+
* author actually named, as a box in the frame's sub-canvas coordinates.
|
|
72
|
+
* Layout ports the edge at that node instead of the frame's centre.
|
|
73
|
+
*/
|
|
74
|
+
fromAnchor?: Anchor
|
|
75
|
+
toAnchor?: Anchor
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An inner node's box inside a frame's sub-canvas. */
|
|
79
|
+
export interface Anchor {
|
|
80
|
+
node: number
|
|
81
|
+
x: number
|
|
82
|
+
y: number
|
|
83
|
+
w: number
|
|
84
|
+
h: number
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
export interface Group {
|
|
@@ -14,9 +14,9 @@ export type { MermaidArt, Role, Span } from './types.ts'
|
|
|
14
14
|
/**
|
|
15
15
|
* Render a Mermaid source block as Unicode box-drawing art.
|
|
16
16
|
*
|
|
17
|
-
* Supported: `graph`/`flowchart` (including `subgraph`),
|
|
18
|
-
* `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`,
|
|
19
|
-
* `timeline` and `gitGraph`.
|
|
17
|
+
* Supported: `architecture-beta`, `graph`/`flowchart` (including `subgraph`),
|
|
18
|
+
* `stateDiagram`, `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`,
|
|
19
|
+
* `mindmap`, `timeline` and `gitGraph`.
|
|
20
20
|
*
|
|
21
21
|
* The diagram is laid out at whatever size it needs; `art.width` reports the
|
|
22
22
|
* columns that turned out to be. Given `maxWidth`, a diagram wider than that
|
|
@@ -49,13 +49,18 @@ export function render(src: string, options: { maxWidth?: number } = {}): Mermai
|
|
|
49
49
|
// the tightest, for the caller to judge against `art.width`).
|
|
50
50
|
let drawn: ReturnType<Diagram['render']> = null
|
|
51
51
|
let art: ReturnType<Canvas['toLines']> = { plain: [], styled: [], width: 0 }
|
|
52
|
+
let collapsed = false
|
|
52
53
|
for (const limits of LIMITS) {
|
|
53
54
|
drawn = diagram.render(src, limits)
|
|
54
55
|
if (drawn === null) return null
|
|
56
|
+
collapsed = limits.collapse === true
|
|
55
57
|
art = drawn.canvas.toLines()
|
|
56
58
|
if (options.maxWidth === undefined || art.width <= options.maxWidth) break
|
|
57
59
|
}
|
|
58
60
|
if (drawn === null) return null
|
|
61
|
+
if (collapsed && /^\s*subgraph\b|^\s*state\s+\S+\s*\{/m.test(src)) {
|
|
62
|
+
drawn.warnings.push('too wide for the space: subgraphs drawn collapsed, one box each')
|
|
63
|
+
}
|
|
59
64
|
|
|
60
65
|
// A frontmatter `title:` is centred above the art, in the `title` role.
|
|
61
66
|
const title = frontmatterTitle(src)
|