@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 CHANGED
@@ -1,28 +1,66 @@
1
1
  # pi-loom-mermaid
2
2
 
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.
3
+ Show diagrams directly in Pi, with colored boxes, a clearer layout, and connecting lines that are easier to follow.
4
4
 
5
- Same diagram:
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
- Pi built-in. One gray box style. Edges share long outer rails.
7
+ ## Install
8
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>
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
- pi-lovely-mermaid. Color, still long parallel paths around the right side.
47
+ Gray boxes. Connecting lines take long paths around the outside.
14
48
 
15
49
  <p align="center">
16
- <img src="../../assets/mermaid-lovely.png" alt="pi-lovely-mermaid: colored borders with long parallel edges">
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
- This package. The same colors, plus crossing hops and shorter routes.
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="../../assets/mermaid-pi-loom.png" alt="pi-loom-mermaid: colored borders, crossing hops, and shorter routes">
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 draws the next blocks with its own Mermaid. Paste the same source into Pi (built-in vs this package) to compare routing and color.
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
- 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**
100
+ ## Update or remove
101
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>
123
-
124
- ## Install
125
-
126
- From Loom setup, or:
102
+ Update the package, then run `/reload`:
127
103
 
128
104
  ```bash
129
- pi install npm:@yassimba/pi-loom-mermaid
105
+ pi update npm:@yassimba/pi-loom-mermaid
130
106
  ```
131
107
 
132
- Turn off Pi's built-in Mermaid transformer so this extension gets the original source. In `~/.pi/agent/settings.json`:
108
+ To uninstall:
133
109
 
134
- ```json
135
- {
136
- "markdown": {
137
- "mermaid": "off"
138
- }
139
- }
110
+ ```bash
111
+ pi remove npm:@yassimba/pi-loom-mermaid
140
112
  ```
141
113
 
142
- Then run `/reload` in Pi.
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
- ```mermaid
149
- flowchart LR
150
- A[Removed]:::red --> B[Changed]:::orange --> C[Added]:::green
151
- ```
116
+ ## Contributing
152
117
 
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.
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.4.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
- * 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.
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: cleanLabel(rawLabel) || id, parent: parent ?? null })
67
+ graph.groups.push({ id, label: iconLabel(icon, rawLabel || id), parent: parent ?? null })
61
68
  }
62
69
  } else {
63
- addNode(graph, id, cleanLabel(rawLabel) || id, parent ?? null, 'rect')
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(src, limits) {
39
- const graph = parseGraph(src)
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 parseGraph(src: string): Graph | null {
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
@@ -255,6 +255,8 @@ function buildScope(
255
255
  headTo: e.headTo,
256
256
  headFrom: e.headFrom,
257
257
  line: e.line,
258
+ fromSide: e.fromSide,
259
+ toSide: e.toSide,
258
260
  fromAnchor: anchorOf(f, e.from),
259
261
  toAnchor: anchorOf(t, e.to),
260
262
  }
@@ -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 and the first
24
- * fit is returned. Deciding what to do when even the tightest exceeds the
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
- // Too wide for the space given: lay out again with shorter labels and
48
- // tighter wrapping, tightest last, and keep the first that fits (else
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 limits of LIMITS) {
54
- drawn = diagram.render(src, limits)
55
- if (drawn === null) return null
56
- collapsed = limits.collapse === true
57
- art = drawn.canvas.toLines()
58
- if (options.maxWidth === undefined || art.width <= options.maxWidth) break
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 = 'top' | 'bottom' | 'left' | 'right'
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
- let ends = resolve((i) => (forward(graph.edges[i]) ? ['right', 'left'] : null))
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
- to.rank === from.rank + 1
1875
- ? forwardRouteLr(from, to, edge, bandEnd[from.rank] + 1 + edgeBus[i], max, bundleOf(i) !== undefined)
1876
- : to.rank > from.rank && edgeStraight[i]
1877
- ? skipRouteLr(from, to, edge, skipRoute[i], max)
1878
- : laneRoute(from, to, edge, onTop(i) ? edgeLane[i] : laneBase + edgeLane[i], max, onTop(i), laneEntry(i, from, to))
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
- return { canvasW, canvasH, routes }
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[] = [