@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 CHANGED
@@ -1,14 +1,135 @@
1
- # Colored Mermaid diagrams for Pi
1
+ # pi-loom-mermaid
2
2
 
3
- Render Mermaid blocks as Unicode terminal diagrams with per-node colors.
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
- Disable Pi's built-in Mermaid transformer in `~/.pi/agent/settings.json` so this extension receives the original Mermaid source:
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
- Run `/reload`, then mark diff nodes with the built-in `red`, `orange`, and `green` classes:
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
- The built-in classes dim and color only the box border; backgrounds and text keep Pi's normal theme. Standard Mermaid `classDef` declarations can override these defaults or add other colors. Diagrams wider than the terminal remain Mermaid source blocks.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yassimba/pi-loom-mermaid",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Loom's advanced Mermaid renderer for Pi, with better routing and color.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- /** The message is still arriving; an unclosed fence shows as source until it closes. */
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
- if (!art || art.width > availableWidth) return null;
39
- const out = `${dimDefaultBorders(toAnsi(art), styledSource.dimSgr).map(codeSpan).join(" \n")}\n`;
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 && !isClosed(token)) return token.raw;
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
  }
@@ -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 every painted cell,
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
- const backed = fg ?? (st.fill === undefined ? undefined : contrastOn(st.fill))
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 (st.fill !== undefined) p.push(rgb(st.fill, 48))
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
- this.occupied[di] = 1
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 canvas = buildScope(graph, null, scopeEdges, directNodes, keep, limits)
119
- return canvas && orient(canvas, graph)
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
- ): CanvasResult {
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
- // Edges from (or to) different nodes inside one frame collapse onto
175
- // the frame and become indistinguishable; draw them once.
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 && a.to === b.to && a.label === b.label && a.headTo === b.headTo && a.headFrom === b.headFrom && a.line === b.line
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
- return layoutCanvas(synth, extras, limits)
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`), `stateDiagram`,
18
- * `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`, `mindmap`,
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)