@yassimba/pi-loom-mermaid 0.4.0 → 0.5.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 +59 -98
- package/package.json +4 -1
- package/src/cli.ts +9 -0
- package/src/index.ts +18 -0
- package/src/loom-mermaid/diagrams/architecture.ts +23 -8
- package/src/loom-mermaid/diagrams/flowchart.ts +34 -10
- package/src/loom-mermaid/graph-render.ts +2 -0
- package/src/loom-mermaid/graph.ts +4 -0
- package/src/loom-mermaid/index.ts +25 -11
- package/src/loom-mermaid/layout.ts +109 -10
- package/src/loom-mermaid/registry.ts +2 -0
package/README.md
CHANGED
|
@@ -1,28 +1,66 @@
|
|
|
1
1
|
# pi-loom-mermaid
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Show diagrams directly in Pi, with colored boxes, a clearer layout, and connecting lines that are easier to follow.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This extension draws diagrams written in Mermaid, a text format for describing boxes, arrows, and other shapes. It also asks the agent to use diagrams when they explain something more clearly than text.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
Requires Pi and Node.js 22.6 or newer.
|
|
10
|
+
|
|
11
|
+
1. Install the package:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:@yassimba/pi-loom-mermaid
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
2. Set `markdown.mermaid` to `"off"` in `~/.pi/agent/settings.json`. Merge this into your existing settings; do not replace the file:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"markdown": {
|
|
22
|
+
"mermaid": "off"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This turns off Pi’s own diagram drawing so this extension can draw instead. If you have another Mermaid extension installed, disable it with `pi config`.
|
|
28
|
+
|
|
29
|
+
3. Run `/reload` in Pi.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Ask Pi: “Explain this code with a Mermaid diagram.” Pi draws the diagram in the conversation.
|
|
34
|
+
|
|
35
|
+
You can also paste the example below. Keep the opening line of three backticks followed by `mermaid`, and the closing line of three backticks.
|
|
36
|
+
|
|
37
|
+
To show changes, add `:::red` after a box for removed code, `:::orange` for changed code, or `:::green` for added code. These labels give boxes muted colored borders. Text and backgrounds keep your Pi theme’s colors. The `classDef` lines in the example set custom border colors.
|
|
38
|
+
|
|
39
|
+
If a diagram is too wide, Pi shows its code instead. Widen the terminal or ask Pi to split it into smaller diagrams.
|
|
40
|
+
|
|
41
|
+
## The same diagram in Pi and GitHub
|
|
42
|
+
|
|
43
|
+
All three views below use the same Mermaid code.
|
|
44
|
+
|
|
45
|
+
### Pi built-in
|
|
12
46
|
|
|
13
|
-
|
|
47
|
+
Gray boxes. Connecting lines take long paths around the outside.
|
|
14
48
|
|
|
15
49
|
<p align="center">
|
|
16
|
-
<img src="
|
|
50
|
+
<img src="https://raw.githubusercontent.com/Yassimba/loom/main/assets/mermaid-pi-builtin.png" alt="Pi built-in Mermaid: gray boxes with long connecting lines around the outside">
|
|
17
51
|
</p>
|
|
18
52
|
|
|
19
|
-
|
|
53
|
+
### pi-loom-mermaid
|
|
54
|
+
|
|
55
|
+
Colored boxes and shorter connecting lines. Small bends mark where lines cross.
|
|
20
56
|
|
|
21
57
|
<p align="center">
|
|
22
|
-
<img src="
|
|
58
|
+
<img src="https://raw.githubusercontent.com/Yassimba/loom/main/assets/mermaid-pi-loom.png" alt="pi-loom-mermaid: colored boxes, shorter connecting lines, and bends at crossings">
|
|
23
59
|
</p>
|
|
24
60
|
|
|
25
|
-
GitHub
|
|
61
|
+
### GitHub built-in
|
|
62
|
+
|
|
63
|
+
GitHub draws the code below as a diagram. Copy the code into Pi to compare how it looks.
|
|
26
64
|
|
|
27
65
|
```mermaid
|
|
28
66
|
flowchart TD
|
|
@@ -59,103 +97,26 @@ flowchart TD
|
|
|
59
97
|
classDef green stroke:#4f8560
|
|
60
98
|
```
|
|
61
99
|
|
|
62
|
-
|
|
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**
|
|
100
|
+
## Update or remove
|
|
101
101
|
|
|
102
|
-
|
|
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>
|
|
123
|
-
|
|
124
|
-
## Install
|
|
125
|
-
|
|
126
|
-
From Loom setup, or:
|
|
102
|
+
Update the package, then run `/reload`:
|
|
127
103
|
|
|
128
104
|
```bash
|
|
129
|
-
pi
|
|
105
|
+
pi update npm:@yassimba/pi-loom-mermaid
|
|
130
106
|
```
|
|
131
107
|
|
|
132
|
-
|
|
108
|
+
To uninstall:
|
|
133
109
|
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
"markdown": {
|
|
137
|
-
"mermaid": "off"
|
|
138
|
-
}
|
|
139
|
-
}
|
|
110
|
+
```bash
|
|
111
|
+
pi remove npm:@yassimba/pi-loom-mermaid
|
|
140
112
|
```
|
|
141
113
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
## Usage
|
|
145
|
-
|
|
146
|
-
Mark diff nodes with the built-in `red`, `orange`, and `green` classes:
|
|
114
|
+
Delete the `"mermaid": "off"` setting you added to restore Pi’s own diagram drawing, then run `/reload`.
|
|
147
115
|
|
|
148
|
-
|
|
149
|
-
flowchart LR
|
|
150
|
-
A[Removed]:::red --> B[Changed]:::orange --> C[Added]:::green
|
|
151
|
-
```
|
|
116
|
+
## Contributing
|
|
152
117
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
Diagrams wider than the terminal stay as Mermaid source.
|
|
118
|
+
From the [Loom repository](https://github.com/Yassimba/loom) root, run `npm ci`, `npm run check`, and `npm run audit` before opening a pull request.
|
|
156
119
|
|
|
157
120
|
## License
|
|
158
121
|
|
|
159
|
-
[MIT](LICENSE)
|
|
160
|
-
|
|
161
|
-
pi-loom-mermaid is adapted from pi-lovely-mermaid.
|
|
122
|
+
[MIT](LICENSE). Adapted from pi-lovely-mermaid.
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yassimba/pi-loom-mermaid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Loom's advanced Mermaid renderer for Pi, with better routing and color.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=22.6"
|
|
9
9
|
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"loom-mermaid-render": "./src/cli.ts"
|
|
12
|
+
},
|
|
10
13
|
"keywords": [
|
|
11
14
|
"pi-package",
|
|
12
15
|
"pi",
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --experimental-strip-types
|
|
2
|
+
|
|
3
|
+
import { transformMermaidForDocument } from "./index.ts";
|
|
4
|
+
|
|
5
|
+
let markdown = "";
|
|
6
|
+
for await (const chunk of process.stdin) markdown += chunk;
|
|
7
|
+
|
|
8
|
+
const width = Number.parseInt(process.env.LOOM_MERMAID_WIDTH ?? "100", 10);
|
|
9
|
+
process.stdout.write(transformMermaidForDocument(markdown, Number.isFinite(width) ? width : 100));
|
package/src/index.ts
CHANGED
|
@@ -85,6 +85,24 @@ function codeSpan(line: string): string {
|
|
|
85
85
|
return `${fence}${padding}${content}${padding}${fence}`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export function transformMermaidForDocument(markdown: string, availableWidth = 100): string {
|
|
89
|
+
return markdownParser
|
|
90
|
+
.lexer(markdown)
|
|
91
|
+
.map((token) => {
|
|
92
|
+
if (!isMermaid(token)) return token.raw;
|
|
93
|
+
const art = render(withDiffClasses(token.text).source, { maxWidth: availableWidth });
|
|
94
|
+
if (!art || art.width > availableWidth) return token.raw;
|
|
95
|
+
const text = art.plain.join("\n");
|
|
96
|
+
const longestRun = Math.max(
|
|
97
|
+
0,
|
|
98
|
+
...Array.from(text.matchAll(/`+/g), (match) => match[0].length),
|
|
99
|
+
);
|
|
100
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
101
|
+
return `${fence}\n${text}\n${fence}\n`;
|
|
102
|
+
})
|
|
103
|
+
.join("");
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
export function transformMermaidMarkdown(markdown: string, context: TransformContext): string {
|
|
89
107
|
if (context.messageType === "assistant-thinking") return markdown;
|
|
90
108
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `architecture-beta`: services and junctions inside nested groups.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* preserves endpoints and arrowheads while leaving port placement to it.
|
|
4
|
+
* Built-in icons use stable Unicode stand-ins; custom Iconify names remain
|
|
5
|
+
* visible as text because a terminal cannot draw their SVGs.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
import { Graph, MAX_GROUP_DEPTH, MAX_GROUPS } from '../graph.ts'
|
|
8
|
+
import { Graph, MAX_GROUP_DEPTH, MAX_GROUPS, type PortSide } from '../graph.ts'
|
|
10
9
|
import { cleanLabel } from '../labels.ts'
|
|
11
10
|
import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
|
|
12
11
|
import type { Diagram } from '../registry.ts'
|
|
@@ -27,6 +26,14 @@ export const architecture: Diagram = {
|
|
|
27
26
|
const DECLARATION = /^(group|service)\s+([^\s()[\]:{}]+)\(([^)]*)\)\[([^\]]*)\](?:\s+in\s+([^\s]+))?$/i
|
|
28
27
|
const JUNCTION = /^junction\s+([^\s:{}]+)(?:\s+in\s+([^\s]+))?$/i
|
|
29
28
|
const EDGE = /^([^\s:{}]+)(?:\{group\})?:([TBLR])\s*(<)?--(>)?\s*([TBLR]):([^\s:{}]+)(?:\{group\})?$/i
|
|
29
|
+
const SIDES: Record<string, PortSide> = { T: 'top', B: 'bottom', L: 'left', R: 'right' }
|
|
30
|
+
const ICONS: Record<string, string> = {
|
|
31
|
+
cloud: '☁',
|
|
32
|
+
database: '◉',
|
|
33
|
+
disk: '▰',
|
|
34
|
+
internet: '◎',
|
|
35
|
+
server: '▣',
|
|
36
|
+
}
|
|
30
37
|
|
|
31
38
|
function parseArchitecture(src: string): Graph | null {
|
|
32
39
|
const statements = statementsOf(src)
|
|
@@ -43,7 +50,7 @@ function parseArchitecture(src: string): Graph | null {
|
|
|
43
50
|
const edge = st.match(EDGE)
|
|
44
51
|
|
|
45
52
|
if (declaration) {
|
|
46
|
-
const [, kind, id, , rawLabel, parentId] = declaration
|
|
53
|
+
const [, kind, id, icon, rawLabel, parentId] = declaration
|
|
47
54
|
const parent = parentId === undefined ? null : groupIndex.get(parentId)
|
|
48
55
|
if (parentId !== undefined && parent === undefined) {
|
|
49
56
|
graph.drop(st)
|
|
@@ -57,10 +64,10 @@ function parseArchitecture(src: string): Graph | null {
|
|
|
57
64
|
graph.truncated ??= `subgraph cap (${MAX_GROUPS} groups, depth ${MAX_GROUP_DEPTH}) reached`
|
|
58
65
|
} else {
|
|
59
66
|
groupIndex.set(id, graph.groups.length)
|
|
60
|
-
graph.groups.push({ id, label:
|
|
67
|
+
graph.groups.push({ id, label: iconLabel(icon, rawLabel || id), parent: parent ?? null })
|
|
61
68
|
}
|
|
62
69
|
} else {
|
|
63
|
-
addNode(graph, id,
|
|
70
|
+
addNode(graph, id, iconLabel(icon, rawLabel || id), parent ?? null, 'rect')
|
|
64
71
|
}
|
|
65
72
|
} else if (junction) {
|
|
66
73
|
const [, id, parentId] = junction
|
|
@@ -68,7 +75,7 @@ function parseArchitecture(src: string): Graph | null {
|
|
|
68
75
|
if (parentId !== undefined && parent === undefined) graph.drop(st)
|
|
69
76
|
else addNode(graph, id, '•', parent ?? null, 'round')
|
|
70
77
|
} else if (edge) {
|
|
71
|
-
const [, fromId, , leftArrow, rightArrow, , toId] = edge
|
|
78
|
+
const [, fromId, fromPort, leftArrow, rightArrow, toPort, toId] = edge
|
|
72
79
|
const from = graph.index.get(fromId)
|
|
73
80
|
const to = graph.index.get(toId)
|
|
74
81
|
if (from === undefined || to === undefined) {
|
|
@@ -81,6 +88,8 @@ function parseArchitecture(src: string): Graph | null {
|
|
|
81
88
|
headFrom: leftArrow ? 'arrow' : 'none',
|
|
82
89
|
headTo: rightArrow ? 'arrow' : 'none',
|
|
83
90
|
line: 'solid',
|
|
91
|
+
fromSide: SIDES[fromPort.toUpperCase()],
|
|
92
|
+
toSide: SIDES[toPort.toUpperCase()],
|
|
84
93
|
})
|
|
85
94
|
}
|
|
86
95
|
} else if (firstWord(st).toLowerCase() !== 'title') {
|
|
@@ -96,6 +105,12 @@ function parseArchitecture(src: string): Graph | null {
|
|
|
96
105
|
return graph.nodes.length === 0 ? null : graph
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
function iconLabel(icon: string, rawLabel: string): string {
|
|
109
|
+
const name = cleanLabel(icon)
|
|
110
|
+
const mark = ICONS[name.toLowerCase()] ?? `[${name.split(':').at(-1)}]`
|
|
111
|
+
return `${mark} ${cleanLabel(rawLabel)}`
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
function addNode(
|
|
100
115
|
graph: Graph,
|
|
101
116
|
id: string,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
parseDir,
|
|
17
17
|
type Shape,
|
|
18
18
|
} from '../graph.ts'
|
|
19
|
-
import { asciiLower, cleanLabel, decodeHtmlEntities, isIdChar } from '../labels.ts'
|
|
19
|
+
import { asciiLower, cleanLabel, decodeHtmlEntities, isIdChar, type Limits } from '../labels.ts'
|
|
20
20
|
import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
|
|
21
21
|
import type { Diagram } from '../registry.ts'
|
|
22
22
|
import {
|
|
@@ -35,21 +35,42 @@ import {
|
|
|
35
35
|
export const flowchart: Diagram = {
|
|
36
36
|
kind: 'flowchart',
|
|
37
37
|
headers: ['graph', 'flowchart'],
|
|
38
|
-
render
|
|
39
|
-
|
|
40
|
-
if (graph === null) return null
|
|
41
|
-
const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
|
|
42
|
-
if (canvas === null) return null
|
|
43
|
-
return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
|
|
44
|
-
},
|
|
38
|
+
render: renderGraph,
|
|
39
|
+
renderDown: (src, limits) => renderGraph(src, limits, true),
|
|
45
40
|
}
|
|
46
41
|
|
|
47
|
-
function
|
|
42
|
+
function renderGraph(src: string, limits: Limits, fallbackDown = false) {
|
|
43
|
+
const graph = parseGraph(src, fallbackDown)
|
|
44
|
+
if (graph === null) return null
|
|
45
|
+
if (fallbackDown && graph.groups.length > 0) {
|
|
46
|
+
// TD attaches cross-scope edges to frames, losing member anchors and
|
|
47
|
+
// potentially merging distinct edges. Only retry when none need those anchors.
|
|
48
|
+
const scopes = [...graph.nodeGroup]
|
|
49
|
+
const groupNodes = new Set<number>()
|
|
50
|
+
for (const group of graph.groups) {
|
|
51
|
+
const node = graph.index.get(group.id)
|
|
52
|
+
if (node === undefined) continue
|
|
53
|
+
groupNodes.add(node)
|
|
54
|
+
scopes[node] = group.parent
|
|
55
|
+
}
|
|
56
|
+
if (graph.edges.some((e) => scopes[e.from] !== scopes[e.to] &&
|
|
57
|
+
(!groupNodes.has(e.from) || !groupNodes.has(e.to)))) return null
|
|
58
|
+
}
|
|
59
|
+
const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
|
|
60
|
+
if (canvas === null) return null
|
|
61
|
+
return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseGraph(src: string, fallbackDown: boolean): Graph | null {
|
|
48
65
|
const statements = statementsOf(src)
|
|
49
66
|
const kind = headerKind(statements)
|
|
50
67
|
if (kind === null || !flowchart.headers.includes(kind)) return null
|
|
51
68
|
|
|
52
69
|
const graph = new Graph(parseDir(words(statements[0])[1] ?? 'TB'))
|
|
70
|
+
if (fallbackDown) {
|
|
71
|
+
if (graph.dir !== 'right') return null
|
|
72
|
+
graph.dir = 'down'
|
|
73
|
+
}
|
|
53
74
|
const stack: number[] = []
|
|
54
75
|
/** `class A,B name` assignments, applied after the walk so a statement may
|
|
55
76
|
* precede the nodes it names. Unknown ids are ignored. */
|
|
@@ -91,9 +112,12 @@ function parseGraph(src: string): Graph | null {
|
|
|
91
112
|
if (target) hrefs.push(target)
|
|
92
113
|
continue
|
|
93
114
|
}
|
|
115
|
+
case 'direction':
|
|
116
|
+
// Nested directions are not modelled yet; do not reorient an explicit scope.
|
|
117
|
+
if (fallbackDown && stack.length > 0) return null
|
|
118
|
+
continue
|
|
94
119
|
case 'style':
|
|
95
120
|
case 'linkstyle':
|
|
96
|
-
case 'direction':
|
|
97
121
|
continue
|
|
98
122
|
default:
|
|
99
123
|
break
|
|
@@ -26,6 +26,7 @@ export type Head =
|
|
|
26
26
|
| 'diamondOpen'
|
|
27
27
|
|
|
28
28
|
export type LineKind = 'solid' | 'dotted' | 'thick'
|
|
29
|
+
export type PortSide = 'top' | 'bottom' | 'left' | 'right'
|
|
29
30
|
|
|
30
31
|
type Dir = 'down' | 'up' | 'right' | 'left'
|
|
31
32
|
|
|
@@ -66,6 +67,9 @@ export interface Edge {
|
|
|
66
67
|
headTo: Head
|
|
67
68
|
headFrom: Head
|
|
68
69
|
line: LineKind
|
|
70
|
+
/** Exact attachment sides requested by diagrams such as `architecture-beta`. */
|
|
71
|
+
fromSide?: PortSide
|
|
72
|
+
toSide?: PortSide
|
|
69
73
|
/**
|
|
70
74
|
* Set on an end that stands for a subgraph frame: the inner node the
|
|
71
75
|
* author actually named, as a box in the frame's sub-canvas coordinates.
|
|
@@ -20,8 +20,11 @@ export type { MermaidArt, Role, Span } from './types.ts'
|
|
|
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
|
|
23
|
-
* is laid out again with progressively tighter label limits
|
|
24
|
-
*
|
|
23
|
+
* is laid out again with progressively tighter label limits. LR flowcharts
|
|
24
|
+
* without explicit group directions or cross-scope member edges then retry
|
|
25
|
+
* top-down before collapsing subgraphs. The first fit is returned; the source
|
|
26
|
+
* is never rewritten.
|
|
27
|
+
* Deciding what to do when even the final fallback exceeds the
|
|
25
28
|
* space at hand is the caller's — `sourceBox` is the usual answer:
|
|
26
29
|
*
|
|
27
30
|
* ```ts
|
|
@@ -44,18 +47,29 @@ export function render(src: string, options: { maxWidth?: number } = {}): Mermai
|
|
|
44
47
|
if (src.trim() === '') return null
|
|
45
48
|
const diagram = diagramFor(src)
|
|
46
49
|
if (diagram === null) return null
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// the tightest, for the caller to judge against `art.width`).
|
|
50
|
+
// Preserve the requested direction while tightening labels, then try TD
|
|
51
|
+
// before the existing collapsed fallback. Each attempt parses fresh source.
|
|
50
52
|
let drawn: ReturnType<Diagram['render']> = null
|
|
51
53
|
let art: ReturnType<Canvas['toLines']> = { plain: [], styled: [], width: 0 }
|
|
52
54
|
let collapsed = false
|
|
53
|
-
for (const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
55
|
+
fitting: for (const [draw, collapse] of [
|
|
56
|
+
[diagram.render, false],
|
|
57
|
+
[diagram.renderDown, false],
|
|
58
|
+
[diagram.render, true],
|
|
59
|
+
] as const) {
|
|
60
|
+
if (draw === undefined) continue
|
|
61
|
+
for (const limits of LIMITS) {
|
|
62
|
+
if ((limits.collapse === true) !== collapse) continue
|
|
63
|
+
const candidate = draw(src, limits)
|
|
64
|
+
if (candidate === null) {
|
|
65
|
+
if (draw === diagram.renderDown) break
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
drawn = candidate
|
|
69
|
+
collapsed = collapse
|
|
70
|
+
art = drawn.canvas.toLines()
|
|
71
|
+
if (options.maxWidth === undefined || art.width <= options.maxWidth) break fitting
|
|
72
|
+
}
|
|
59
73
|
}
|
|
60
74
|
if (drawn === null) return null
|
|
61
75
|
if (collapsed && /^\s*subgraph\b|^\s*state\s+\S+\s*\{/m.test(src)) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { Canvas } from './canvas.ts'
|
|
14
|
-
import type { Anchor, Edge, LineKind } from './graph.ts'
|
|
14
|
+
import type { Anchor, Edge, LineKind, PortSide } from './graph.ts'
|
|
15
15
|
import type { Graph } from './graph.ts'
|
|
16
16
|
import { fitLabel, type Limits, wrapLabel } from './labels.ts'
|
|
17
17
|
import { brandesKoepf, type LayeredGraph } from './placement.ts'
|
|
@@ -117,7 +117,7 @@ interface Port {
|
|
|
117
117
|
wanted: number
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
type Side =
|
|
120
|
+
type Side = PortSide
|
|
121
121
|
|
|
122
122
|
function framePort(
|
|
123
123
|
sub: Canvas,
|
|
@@ -1332,9 +1332,34 @@ function placeTd(
|
|
|
1332
1332
|
const boxR = (j: number): number => boxL(j) + sizes.boxW[j] - 1
|
|
1333
1333
|
const port = (node: number, side: number): number =>
|
|
1334
1334
|
Math.max(boxL(node) + 1, Math.min(boxR(node) - 1, centers[node] + 2 * side))
|
|
1335
|
+
// An exterior return can clear every intermediate box yet still run
|
|
1336
|
+
// over its wider source. Move a straight, unshared outer chain just
|
|
1337
|
+
// beyond that source so the existing side-port checks can use it. Keep
|
|
1338
|
+
// straight top exits and interior/shared chains in their reserved slots.
|
|
1339
|
+
graph.edges.forEach((e, i) => {
|
|
1340
|
+
const chain = layered.chains[i]
|
|
1341
|
+
if (!isBack(e) || extras[e.from].kind !== 'plain' || chain.length === 0) return
|
|
1342
|
+
const col = centers[chain[0]]
|
|
1343
|
+
const side = Math.sign(col - centers[e.from])
|
|
1344
|
+
if (side === 0 || Math.abs(col - port(e.from, side)) <= 1) return
|
|
1345
|
+
const outer = side < 0 ? 0 : -1
|
|
1346
|
+
if (layered.layers[ranks[e.from]].at(outer) !== e.from) return
|
|
1347
|
+
if (chain.some((v) => centers[v] !== col || layered.shared.has(v) || layered.layers[layerOf[v]].at(outer) !== v)) return
|
|
1348
|
+
const left = centers[e.from] - half(sizes.boxW[e.from])
|
|
1349
|
+
const next = side < 0 ? left - 3 : left + sizes.boxW[e.from] + 2
|
|
1350
|
+
if ((next - col) * side <= 0) return
|
|
1351
|
+
const candidate = [...centers]
|
|
1352
|
+
for (const v of chain) candidate[v] = next
|
|
1353
|
+
const ports = sidePorts(candidate)
|
|
1354
|
+
if (ports.exit[i] !== side) return
|
|
1355
|
+
// Preserve an existing clear target-side entry.
|
|
1356
|
+
const entry = sidePorts(centers).entry[i]
|
|
1357
|
+
if (entry !== 0 && ports.entry[i] !== entry) return
|
|
1358
|
+
centers = candidate
|
|
1359
|
+
})
|
|
1335
1360
|
// A return entering on the left labels leftward; give the leftmost such
|
|
1336
1361
|
// label room before the first column.
|
|
1337
|
-
let margin = 0
|
|
1362
|
+
let margin = Math.max(0, -extentsOf(centers)[0])
|
|
1338
1363
|
graph.edges.forEach((e, i) => {
|
|
1339
1364
|
const text = edgeText(e)
|
|
1340
1365
|
if (!isBack(e) || entrySide[i] >= 0 || text === null || chainLabel[i] !== null) return
|
|
@@ -1690,7 +1715,9 @@ function placeLr(
|
|
|
1690
1715
|
})
|
|
1691
1716
|
}
|
|
1692
1717
|
const forward = (e: Edge): boolean => e.from !== e.to && ranks[e.to] > ranks[e.from]
|
|
1693
|
-
|
|
1718
|
+
const requestedSides = (e: Edge): [Side, Side] | null =>
|
|
1719
|
+
e.fromSide !== undefined && e.toSide !== undefined ? [e.fromSide, e.toSide] : null
|
|
1720
|
+
let ends = resolve((i) => requestedSides(graph.edges[i]) ?? (forward(graph.edges[i]) ? ['right', 'left'] : null))
|
|
1694
1721
|
// A node whose incoming edges all leave their frames at one row off the
|
|
1695
1722
|
// frame's centre sits that far off its own aligned position, so the
|
|
1696
1723
|
// edges run straight rather than jog to it (`[*]` after a composite
|
|
@@ -1753,6 +1780,8 @@ function placeLr(
|
|
|
1753
1780
|
})
|
|
1754
1781
|
ends = resolve((i) => {
|
|
1755
1782
|
const e = graph.edges[i]
|
|
1783
|
+
const requested = requestedSides(e)
|
|
1784
|
+
if (requested !== null) return requested
|
|
1756
1785
|
if (e.from === e.to) return null
|
|
1757
1786
|
if (ranks[e.to] === ranks[e.from] + 1 || edgeStraight[i]) return ['right', 'left']
|
|
1758
1787
|
return ranks[e.to] < ranks[e.from] ? ['top', 'top'] : ['bottom', 'bottom']
|
|
@@ -1871,14 +1900,40 @@ function placeLr(
|
|
|
1871
1900
|
const [from, to] = endsOf(i)
|
|
1872
1901
|
const through = ends[i].flatMap((p, k) => (p === null ? [] : portAt(p, placed[k === 0 ? edge.from : edge.to]).through))
|
|
1873
1902
|
const route =
|
|
1874
|
-
|
|
1875
|
-
?
|
|
1876
|
-
: to.rank
|
|
1877
|
-
?
|
|
1878
|
-
:
|
|
1903
|
+
edge.fromSide !== undefined && edge.toSide !== undefined
|
|
1904
|
+
? portRoute(from, to, edge.fromSide, edge.toSide)
|
|
1905
|
+
: to.rank === from.rank + 1
|
|
1906
|
+
? forwardRouteLr(from, to, edge, bandEnd[from.rank] + 1 + edgeBus[i], max, bundleOf(i) !== undefined)
|
|
1907
|
+
: to.rank > from.rank && edgeStraight[i]
|
|
1908
|
+
? skipRouteLr(from, to, edge, skipRoute[i], max)
|
|
1909
|
+
: laneRoute(from, to, edge, onTop(i) ? edgeLane[i] : laneBase + edgeLane[i], max, onTop(i), laneEntry(i, from, to))
|
|
1879
1910
|
return through.length === 0 ? route : { ...route, through: [...(route.through ?? []), ...through] }
|
|
1880
1911
|
})
|
|
1881
|
-
|
|
1912
|
+
|
|
1913
|
+
if (!graph.edges.some((e) => e.fromSide !== undefined)) return { canvasW, canvasH, routes }
|
|
1914
|
+
// Exact side ports may face out of the outermost box. Two cells of margin
|
|
1915
|
+
// keep their first/last segments on-canvas at every nested group level.
|
|
1916
|
+
for (const p of placed) {
|
|
1917
|
+
p.x += 2
|
|
1918
|
+
p.y += 2
|
|
1919
|
+
p.cx += 2
|
|
1920
|
+
p.cy += 2
|
|
1921
|
+
}
|
|
1922
|
+
for (const route of routes) {
|
|
1923
|
+
route.points = route.points.map(([x, y]) => [x + 2, y + 2])
|
|
1924
|
+
route.labels = route.labels.map((label) => ({ ...label, row: label.row + 2, x: label.x + 2 }))
|
|
1925
|
+
if (route.laneLabel !== undefined) {
|
|
1926
|
+
route.laneLabel = {
|
|
1927
|
+
...route.laneLabel,
|
|
1928
|
+
y: route.laneLabel.y + 2,
|
|
1929
|
+
lo: route.laneLabel.lo + 2,
|
|
1930
|
+
hi: route.laneLabel.hi + 2,
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
if (route.through !== undefined)
|
|
1934
|
+
route.through = route.through.map(([x, y, kind]) => [x + 2, y + 2, kind])
|
|
1935
|
+
}
|
|
1936
|
+
return { canvasW: canvasW + 4, canvasH: canvasH + 4, routes }
|
|
1882
1937
|
}
|
|
1883
1938
|
|
|
1884
1939
|
// -------------------------------------------------------------------- canvas
|
|
@@ -2213,6 +2268,50 @@ function chainRoute(
|
|
|
2213
2268
|
return { points, labels: chainLabel(edge, headRow, entryX, labelLeft, labelAt, max) }
|
|
2214
2269
|
}
|
|
2215
2270
|
|
|
2271
|
+
/** An orthogonal route that leaves and enters the exact requested box sides. */
|
|
2272
|
+
function portRoute(from: Placed, to: Placed, fromSide: PortSide, toSide: PortSide): Route {
|
|
2273
|
+
const normal = (side: PortSide): [number, number] =>
|
|
2274
|
+
side === 'left' ? [-1, 0] : side === 'right' ? [1, 0] : side === 'top' ? [0, -1] : [0, 1]
|
|
2275
|
+
const border = (p: Placed, side: PortSide): [number, number] =>
|
|
2276
|
+
side === 'left'
|
|
2277
|
+
? [p.x, p.cy]
|
|
2278
|
+
: side === 'right'
|
|
2279
|
+
? [p.x + p.w - 1, p.cy]
|
|
2280
|
+
: side === 'top'
|
|
2281
|
+
? [p.cx, p.y]
|
|
2282
|
+
: [p.cx, p.y + p.h - 1]
|
|
2283
|
+
const move = ([x, y]: [number, number], [dx, dy]: [number, number]): [number, number] => [x + dx, y + dy]
|
|
2284
|
+
|
|
2285
|
+
const fromNormal = normal(fromSide)
|
|
2286
|
+
const toNormal = normal(toSide)
|
|
2287
|
+
const start = border(from, fromSide)
|
|
2288
|
+
const startOut = move(start, fromNormal)
|
|
2289
|
+
const head = move(border(to, toSide), toNormal)
|
|
2290
|
+
const targetOut = move(head, toNormal)
|
|
2291
|
+
const fromHorizontal = fromNormal[0] !== 0
|
|
2292
|
+
const toHorizontal = toNormal[0] !== 0
|
|
2293
|
+
let middle: [number, number][]
|
|
2294
|
+
|
|
2295
|
+
if (fromHorizontal !== toHorizontal) {
|
|
2296
|
+
middle = [fromHorizontal ? [targetOut[0], startOut[1]] : [startOut[0], targetOut[1]]]
|
|
2297
|
+
} else if (fromHorizontal) {
|
|
2298
|
+
const direction = Math.sign(targetOut[0] - startOut[0])
|
|
2299
|
+
const direct = direction === fromNormal[0] && direction === -toNormal[0]
|
|
2300
|
+
const lane = Math.max(from.y + from.h, to.y + to.h) + 1
|
|
2301
|
+
middle = direct ? [] : [[startOut[0], lane], [targetOut[0], lane]]
|
|
2302
|
+
} else {
|
|
2303
|
+
const direction = Math.sign(targetOut[1] - startOut[1])
|
|
2304
|
+
const direct = direction === fromNormal[1] && direction === -toNormal[1]
|
|
2305
|
+
const lane = Math.max(from.x + from.w, to.x + to.w) + 1
|
|
2306
|
+
middle = direct ? [] : [[lane, startOut[1]], [lane, targetOut[1]]]
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
const points = [start, startOut, ...middle, targetOut, head].filter(
|
|
2310
|
+
([x, y], i, all) => i === 0 || x !== all[i - 1][0] || y !== all[i - 1][1],
|
|
2311
|
+
) as [number, number][]
|
|
2312
|
+
return { points, labels: [] }
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2216
2315
|
/**
|
|
2217
2316
|
* Adjacent ranks, left-to-right: out the right side, jog on the bus
|
|
2218
2317
|
* column. The verb keeps its usual spot above the line; cardinalities hug
|
|
@@ -51,6 +51,8 @@ export interface Diagram {
|
|
|
51
51
|
warnings: string[]
|
|
52
52
|
classDefs: Record<string, Record<string, string>>
|
|
53
53
|
} | null
|
|
54
|
+
/** Optional top-down retry; `null` means this source cannot change direction. */
|
|
55
|
+
renderDown?: Diagram['render']
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
const DIAGRAMS: Diagram[] = [
|