@yassimba/pi-loom-mermaid 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yassimba/pi-loom-mermaid",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Loom's advanced Mermaid renderer for Pi, with better routing and color.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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,
@@ -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.
@@ -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,
@@ -1690,7 +1690,9 @@ function placeLr(
1690
1690
  })
1691
1691
  }
1692
1692
  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))
1693
+ const requestedSides = (e: Edge): [Side, Side] | null =>
1694
+ e.fromSide !== undefined && e.toSide !== undefined ? [e.fromSide, e.toSide] : null
1695
+ let ends = resolve((i) => requestedSides(graph.edges[i]) ?? (forward(graph.edges[i]) ? ['right', 'left'] : null))
1694
1696
  // A node whose incoming edges all leave their frames at one row off the
1695
1697
  // frame's centre sits that far off its own aligned position, so the
1696
1698
  // edges run straight rather than jog to it (`[*]` after a composite
@@ -1753,6 +1755,8 @@ function placeLr(
1753
1755
  })
1754
1756
  ends = resolve((i) => {
1755
1757
  const e = graph.edges[i]
1758
+ const requested = requestedSides(e)
1759
+ if (requested !== null) return requested
1756
1760
  if (e.from === e.to) return null
1757
1761
  if (ranks[e.to] === ranks[e.from] + 1 || edgeStraight[i]) return ['right', 'left']
1758
1762
  return ranks[e.to] < ranks[e.from] ? ['top', 'top'] : ['bottom', 'bottom']
@@ -1871,14 +1875,40 @@ function placeLr(
1871
1875
  const [from, to] = endsOf(i)
1872
1876
  const through = ends[i].flatMap((p, k) => (p === null ? [] : portAt(p, placed[k === 0 ? edge.from : edge.to]).through))
1873
1877
  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))
1878
+ edge.fromSide !== undefined && edge.toSide !== undefined
1879
+ ? portRoute(from, to, edge.fromSide, edge.toSide)
1880
+ : to.rank === from.rank + 1
1881
+ ? forwardRouteLr(from, to, edge, bandEnd[from.rank] + 1 + edgeBus[i], max, bundleOf(i) !== undefined)
1882
+ : to.rank > from.rank && edgeStraight[i]
1883
+ ? skipRouteLr(from, to, edge, skipRoute[i], max)
1884
+ : laneRoute(from, to, edge, onTop(i) ? edgeLane[i] : laneBase + edgeLane[i], max, onTop(i), laneEntry(i, from, to))
1879
1885
  return through.length === 0 ? route : { ...route, through: [...(route.through ?? []), ...through] }
1880
1886
  })
1881
- return { canvasW, canvasH, routes }
1887
+
1888
+ if (!graph.edges.some((e) => e.fromSide !== undefined)) return { canvasW, canvasH, routes }
1889
+ // Exact side ports may face out of the outermost box. Two cells of margin
1890
+ // keep their first/last segments on-canvas at every nested group level.
1891
+ for (const p of placed) {
1892
+ p.x += 2
1893
+ p.y += 2
1894
+ p.cx += 2
1895
+ p.cy += 2
1896
+ }
1897
+ for (const route of routes) {
1898
+ route.points = route.points.map(([x, y]) => [x + 2, y + 2])
1899
+ route.labels = route.labels.map((label) => ({ ...label, row: label.row + 2, x: label.x + 2 }))
1900
+ if (route.laneLabel !== undefined) {
1901
+ route.laneLabel = {
1902
+ ...route.laneLabel,
1903
+ y: route.laneLabel.y + 2,
1904
+ lo: route.laneLabel.lo + 2,
1905
+ hi: route.laneLabel.hi + 2,
1906
+ }
1907
+ }
1908
+ if (route.through !== undefined)
1909
+ route.through = route.through.map(([x, y, kind]) => [x + 2, y + 2, kind])
1910
+ }
1911
+ return { canvasW: canvasW + 4, canvasH: canvasH + 4, routes }
1882
1912
  }
1883
1913
 
1884
1914
  // -------------------------------------------------------------------- canvas
@@ -2213,6 +2243,50 @@ function chainRoute(
2213
2243
  return { points, labels: chainLabel(edge, headRow, entryX, labelLeft, labelAt, max) }
2214
2244
  }
2215
2245
 
2246
+ /** An orthogonal route that leaves and enters the exact requested box sides. */
2247
+ function portRoute(from: Placed, to: Placed, fromSide: PortSide, toSide: PortSide): Route {
2248
+ const normal = (side: PortSide): [number, number] =>
2249
+ side === 'left' ? [-1, 0] : side === 'right' ? [1, 0] : side === 'top' ? [0, -1] : [0, 1]
2250
+ const border = (p: Placed, side: PortSide): [number, number] =>
2251
+ side === 'left'
2252
+ ? [p.x, p.cy]
2253
+ : side === 'right'
2254
+ ? [p.x + p.w - 1, p.cy]
2255
+ : side === 'top'
2256
+ ? [p.cx, p.y]
2257
+ : [p.cx, p.y + p.h - 1]
2258
+ const move = ([x, y]: [number, number], [dx, dy]: [number, number]): [number, number] => [x + dx, y + dy]
2259
+
2260
+ const fromNormal = normal(fromSide)
2261
+ const toNormal = normal(toSide)
2262
+ const start = border(from, fromSide)
2263
+ const startOut = move(start, fromNormal)
2264
+ const head = move(border(to, toSide), toNormal)
2265
+ const targetOut = move(head, toNormal)
2266
+ const fromHorizontal = fromNormal[0] !== 0
2267
+ const toHorizontal = toNormal[0] !== 0
2268
+ let middle: [number, number][]
2269
+
2270
+ if (fromHorizontal !== toHorizontal) {
2271
+ middle = [fromHorizontal ? [targetOut[0], startOut[1]] : [startOut[0], targetOut[1]]]
2272
+ } else if (fromHorizontal) {
2273
+ const direction = Math.sign(targetOut[0] - startOut[0])
2274
+ const direct = direction === fromNormal[0] && direction === -toNormal[0]
2275
+ const lane = Math.max(from.y + from.h, to.y + to.h) + 1
2276
+ middle = direct ? [] : [[startOut[0], lane], [targetOut[0], lane]]
2277
+ } else {
2278
+ const direction = Math.sign(targetOut[1] - startOut[1])
2279
+ const direct = direction === fromNormal[1] && direction === -toNormal[1]
2280
+ const lane = Math.max(from.x + from.w, to.x + to.w) + 1
2281
+ middle = direct ? [] : [[lane, startOut[1]], [lane, targetOut[1]]]
2282
+ }
2283
+
2284
+ const points = [start, startOut, ...middle, targetOut, head].filter(
2285
+ ([x, y], i, all) => i === 0 || x !== all[i - 1][0] || y !== all[i - 1][1],
2286
+ ) as [number, number][]
2287
+ return { points, labels: [] }
2288
+ }
2289
+
2216
2290
  /**
2217
2291
  * Adjacent ranks, left-to-right: out the right side, jog on the bus
2218
2292
  * column. The verb keeps its usual spot above the line; cardinalities hug