@herbertgao/pi-extensions 2026.9.7 → 2026.9.9

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.
Files changed (158) hide show
  1. package/README.md +8 -4
  2. package/THIRD_PARTY_NOTICES.md +52 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  7. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  8. package/node_modules/@narumitw/pi-btw/README.md +6 -1
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts +209 -23
  10. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +1 -12
  13. package/node_modules/@narumitw/pi-btw/src/menu.ts +244 -15
  14. package/node_modules/@narumitw/pi-btw/src/settings.ts +5 -0
  15. package/node_modules/grok-mermaid/CHANGELOG.md +46 -0
  16. package/node_modules/grok-mermaid/LICENSE +205 -0
  17. package/node_modules/grok-mermaid/README.md +191 -0
  18. package/node_modules/grok-mermaid/dist/ansi.d.ts +16 -0
  19. package/node_modules/grok-mermaid/dist/ansi.d.ts.map +1 -0
  20. package/node_modules/grok-mermaid/dist/ansi.js +23 -0
  21. package/node_modules/grok-mermaid/dist/ansi.js.map +1 -0
  22. package/node_modules/grok-mermaid/dist/canvas.d.ts +87 -0
  23. package/node_modules/grok-mermaid/dist/canvas.d.ts.map +1 -0
  24. package/node_modules/grok-mermaid/dist/canvas.js +366 -0
  25. package/node_modules/grok-mermaid/dist/canvas.js.map +1 -0
  26. package/node_modules/grok-mermaid/dist/graph.d.ts +74 -0
  27. package/node_modules/grok-mermaid/dist/graph.d.ts.map +1 -0
  28. package/node_modules/grok-mermaid/dist/graph.js +91 -0
  29. package/node_modules/grok-mermaid/dist/graph.js.map +1 -0
  30. package/node_modules/grok-mermaid/dist/index.d.ts +32 -0
  31. package/node_modules/grok-mermaid/dist/index.d.ts.map +1 -0
  32. package/node_modules/grok-mermaid/dist/index.js +100 -0
  33. package/node_modules/grok-mermaid/dist/index.js.map +1 -0
  34. package/node_modules/grok-mermaid/dist/labels.d.ts +62 -0
  35. package/node_modules/grok-mermaid/dist/labels.d.ts.map +1 -0
  36. package/node_modules/grok-mermaid/dist/labels.js +324 -0
  37. package/node_modules/grok-mermaid/dist/labels.js.map +1 -0
  38. package/node_modules/grok-mermaid/dist/layout-seq.d.ts +12 -0
  39. package/node_modules/grok-mermaid/dist/layout-seq.d.ts.map +1 -0
  40. package/node_modules/grok-mermaid/dist/layout-seq.js +194 -0
  41. package/node_modules/grok-mermaid/dist/layout-seq.js.map +1 -0
  42. package/node_modules/grok-mermaid/dist/layout.d.ts +87 -0
  43. package/node_modules/grok-mermaid/dist/layout.d.ts.map +1 -0
  44. package/node_modules/grok-mermaid/dist/layout.js +881 -0
  45. package/node_modules/grok-mermaid/dist/layout.js.map +1 -0
  46. package/node_modules/grok-mermaid/dist/parse.d.ts +83 -0
  47. package/node_modules/grok-mermaid/dist/parse.d.ts.map +1 -0
  48. package/node_modules/grok-mermaid/dist/parse.js +1151 -0
  49. package/node_modules/grok-mermaid/dist/parse.js.map +1 -0
  50. package/node_modules/grok-mermaid/dist/source-box.d.ts +18 -0
  51. package/node_modules/grok-mermaid/dist/source-box.d.ts.map +1 -0
  52. package/node_modules/grok-mermaid/dist/source-box.js +78 -0
  53. package/node_modules/grok-mermaid/dist/source-box.js.map +1 -0
  54. package/node_modules/grok-mermaid/dist/types.d.ts +42 -0
  55. package/node_modules/grok-mermaid/dist/types.d.ts.map +1 -0
  56. package/node_modules/grok-mermaid/dist/types.js +1 -0
  57. package/node_modules/grok-mermaid/dist/types.js.map +1 -0
  58. package/node_modules/grok-mermaid/dist/width-data.d.ts +2 -0
  59. package/node_modules/grok-mermaid/dist/width-data.d.ts.map +1 -0
  60. package/node_modules/grok-mermaid/dist/width-data.js +994 -0
  61. package/node_modules/grok-mermaid/dist/width-data.js.map +1 -0
  62. package/node_modules/grok-mermaid/dist/width.d.ts +18 -0
  63. package/node_modules/grok-mermaid/dist/width.d.ts.map +1 -0
  64. package/node_modules/grok-mermaid/dist/width.js +76 -0
  65. package/node_modules/grok-mermaid/dist/width.js.map +1 -0
  66. package/node_modules/grok-mermaid/package.json +49 -0
  67. package/node_modules/grok-mermaid/src/ansi.ts +34 -0
  68. package/node_modules/grok-mermaid/src/canvas.ts +373 -0
  69. package/node_modules/grok-mermaid/src/graph.ts +142 -0
  70. package/node_modules/grok-mermaid/src/index.ts +104 -0
  71. package/node_modules/grok-mermaid/src/labels.ts +326 -0
  72. package/node_modules/grok-mermaid/src/layout-seq.ts +203 -0
  73. package/node_modules/grok-mermaid/src/layout.ts +1015 -0
  74. package/node_modules/grok-mermaid/src/parse.ts +1189 -0
  75. package/node_modules/grok-mermaid/src/source-box.ts +89 -0
  76. package/node_modules/grok-mermaid/src/types.ts +43 -0
  77. package/node_modules/grok-mermaid/src/width-data.ts +993 -0
  78. package/node_modules/grok-mermaid/src/width.ts +74 -0
  79. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +110 -0
  80. package/node_modules/pi-jev-auto-mode/LICENSE +21 -0
  81. package/node_modules/pi-jev-auto-mode/README.md +285 -0
  82. package/node_modules/pi-jev-auto-mode/SECURITY.md +32 -0
  83. package/node_modules/pi-jev-auto-mode/docs/calibration.md +154 -0
  84. package/node_modules/pi-jev-auto-mode/docs/design.md +164 -0
  85. package/node_modules/pi-jev-auto-mode/docs/security.md +124 -0
  86. package/node_modules/pi-jev-auto-mode/index.ts +1 -0
  87. package/node_modules/pi-jev-auto-mode/package.json +72 -0
  88. package/node_modules/pi-jev-auto-mode/src/call.ts +180 -0
  89. package/node_modules/pi-jev-auto-mode/src/decide.ts +86 -0
  90. package/node_modules/pi-jev-auto-mode/src/extension.ts +862 -0
  91. package/node_modules/pi-jev-auto-mode/src/intent.ts +71 -0
  92. package/node_modules/pi-jev-auto-mode/src/jev/availability.ts +53 -0
  93. package/node_modules/pi-jev-auto-mode/src/jev/criteria.ts +19 -0
  94. package/node_modules/pi-jev-auto-mode/src/jev/decide.ts +187 -0
  95. package/node_modules/pi-jev-auto-mode/src/jev/engine.ts +165 -0
  96. package/node_modules/pi-jev-auto-mode/src/jev/index.ts +20 -0
  97. package/node_modules/pi-jev-auto-mode/src/jev/questions.ts +273 -0
  98. package/node_modules/pi-jev-auto-mode/src/jev/response.ts +64 -0
  99. package/node_modules/pi-jev-auto-mode/src/jev/state.ts +20 -0
  100. package/node_modules/pi-jev-auto-mode/src/jev/transport.ts +117 -0
  101. package/node_modules/pi-jev-auto-mode/src/jev/types.ts +46 -0
  102. package/node_modules/pi-jev-auto-mode/src/policy.ts +558 -0
  103. package/node_modules/pi-jev-auto-mode/src/records.ts +118 -0
  104. package/node_modules/pi-jev-auto-mode/src/settings.ts +327 -0
  105. package/node_modules/pi-jev-auto-mode/src/ui.ts +233 -0
  106. package/node_modules/pi-lens/CHANGELOG.md +45 -0
  107. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +21 -9
  108. package/node_modules/pi-lens/dist/clients/cascade-format.js +24 -0
  109. package/node_modules/pi-lens/dist/clients/extension-log.js +48 -1
  110. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +11 -12
  111. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +3 -0
  112. package/node_modules/pi-lens/dist/clients/lsp/index.js +41 -7
  113. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +9 -2
  114. package/node_modules/pi-lens/dist/clients/path-utils.js +51 -9
  115. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +271 -0
  116. package/node_modules/pi-lens/dist/clients/pipeline.js +15 -1
  117. package/node_modules/pi-lens/dist/clients/runtime-turn.js +124 -7
  118. package/node_modules/pi-lens/dist/clients/skills-resolver.js +38 -2
  119. package/node_modules/pi-lens/dist/clients/widget-state.js +152 -6
  120. package/node_modules/pi-lens/dist/index.js +729 -344
  121. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +14 -2
  122. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +84 -17
  123. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +32 -5
  124. package/node_modules/pi-lens/package.json +1 -1
  125. package/node_modules/pi-typesafe/LICENSE +21 -0
  126. package/node_modules/pi-typesafe/README.md +153 -0
  127. package/node_modules/pi-typesafe/dist/ask.d.ts +32 -0
  128. package/node_modules/pi-typesafe/dist/ask.js +25 -0
  129. package/node_modules/pi-typesafe/dist/auth.d.ts +56 -0
  130. package/node_modules/pi-typesafe/dist/auth.js +114 -0
  131. package/node_modules/pi-typesafe/dist/batch.d.ts +74 -0
  132. package/node_modules/pi-typesafe/dist/batch.js +116 -0
  133. package/node_modules/pi-typesafe/dist/calibrate.d.ts +106 -0
  134. package/node_modules/pi-typesafe/dist/calibrate.js +157 -0
  135. package/node_modules/pi-typesafe/dist/client.d.ts +69 -0
  136. package/node_modules/pi-typesafe/dist/client.js +188 -0
  137. package/node_modules/pi-typesafe/dist/credentials.d.ts +46 -0
  138. package/node_modules/pi-typesafe/dist/credentials.js +112 -0
  139. package/node_modules/pi-typesafe/dist/errors.d.ts +9 -0
  140. package/node_modules/pi-typesafe/dist/errors.js +31 -0
  141. package/node_modules/pi-typesafe/dist/extension.d.ts +3 -0
  142. package/node_modules/pi-typesafe/dist/extension.js +222 -0
  143. package/node_modules/pi-typesafe/dist/index.d.ts +18 -0
  144. package/node_modules/pi-typesafe/dist/index.js +9 -0
  145. package/node_modules/pi-typesafe/dist/key-prompt.d.ts +3 -0
  146. package/node_modules/pi-typesafe/dist/key-prompt.js +43 -0
  147. package/node_modules/pi-typesafe/dist/login.d.ts +27 -0
  148. package/node_modules/pi-typesafe/dist/login.js +38 -0
  149. package/node_modules/pi-typesafe/dist/schema.d.ts +43 -0
  150. package/node_modules/pi-typesafe/dist/schema.js +152 -0
  151. package/node_modules/pi-typesafe/dist/ui.d.ts +3 -0
  152. package/node_modules/pi-typesafe/dist/ui.js +3 -0
  153. package/node_modules/pi-typesafe/dist/usage.d.ts +73 -0
  154. package/node_modules/pi-typesafe/dist/usage.js +182 -0
  155. package/node_modules/pi-typesafe/examples/decision-extension.ts +41 -0
  156. package/node_modules/pi-typesafe/extensions/index.js +2 -0
  157. package/node_modules/pi-typesafe/package.json +89 -0
  158. package/package.json +16 -8
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Graph layout: rank, order, place, route, draw.
3
+ *
4
+ * Follows the Sugiyama outline — assign ranks along the flow axis, reorder
5
+ * within ranks to cut crossings, then relax positions on the cross axis so
6
+ * chains stay straight. Edges between adjacent ranks share horizontal "bus"
7
+ * rows; everything else is routed around the diagram through vertical "lanes".
8
+ *
9
+ * `BT` and `RL` reuse the `TD`/`LR` layouts and flip the finished canvas, so
10
+ * text never ends up mirrored.
11
+ */
12
+
13
+ import {
14
+ Canvas,
15
+ CONT,
16
+ D,
17
+ drawText,
18
+ drawTextOverEdges,
19
+ L,
20
+ R,
21
+ STY_DOT,
22
+ STY_SOLID,
23
+ STY_THICK,
24
+ U,
25
+ } from './canvas.ts'
26
+ import type { ClassInfo, Edge, Head, Node, Shape } from './graph.ts'
27
+ import { Graph } from './graph.ts'
28
+ import { fitLabel, MAX_LABEL, MAX_LINES, WRAP_WIDTH, wrapLabel } from './labels.ts'
29
+ import { measured, stringWidth } from './width.ts'
30
+
31
+ /** Cells of padding between a box border and its text. */
32
+ const PAD = 1
33
+ /** Minimum horizontal / vertical space between boxes. */
34
+ const GAP_X = 3
35
+ const GAP_Y = 2
36
+ /** Refuse to allocate a canvas larger than this many cells. */
37
+ const MAX_CANVAS_CELLS = 1 << 21
38
+
39
+ /** A laid-out canvas, or `null` when the diagram is empty or over the cell cap. */
40
+ export type CanvasResult = Canvas | null
41
+
42
+ /** Saturating subtraction; Rust's `usize` arithmetic never goes negative. */
43
+ const sat = (a: number, b: number): number => Math.max(0, a - b)
44
+ const half = (n: number): number => Math.floor(n / 2)
45
+
46
+ export interface Placed {
47
+ x: number
48
+ y: number
49
+ w: number
50
+ h: number
51
+ cx: number
52
+ cy: number
53
+ rank: number
54
+ }
55
+
56
+ /** Per-node dimensions. `lay*` include room for self-edge loops and labels. */
57
+ interface NodeSizes {
58
+ boxW: number[]
59
+ boxH: number[]
60
+ layW: number[]
61
+ layH: number[]
62
+ extraH: number[]
63
+ selfLabelW: number[]
64
+ }
65
+
66
+ /** What to draw inside a node box. */
67
+ export type NodeExtra =
68
+ | { kind: 'plain' }
69
+ | { kind: 'frame'; sub: Canvas }
70
+ | { kind: 'compartments'; sections: string[][] }
71
+
72
+ interface RoutePlan {
73
+ canvasW: number
74
+ canvasH: number
75
+ /** Coordinate just past each rank's boxes, where its bus rows begin. */
76
+ bandEnd: number[]
77
+ /** Bus track offset per edge. */
78
+ edgeBus: number[]
79
+ /** Coordinate of the first lane track. */
80
+ laneBase: number
81
+ /** Lane track offset per edge. */
82
+ edgeLane: number[]
83
+ }
84
+
85
+ // ------------------------------------------------------------------ ranking
86
+
87
+ /**
88
+ * Longest-path ranking over the graph's DAG.
89
+ *
90
+ * Back edges (those closing a cycle) are excluded by a DFS colouring pass, so
91
+ * `A --> B --> C --> A` still ranks 0, 1, 2 rather than diverging.
92
+ */
93
+ export function computeRanks(graph: Graph): number[] {
94
+ const n = graph.nodes.length
95
+ const children: number[][] = Array.from({ length: n }, () => [])
96
+ const indeg = new Array<number>(n).fill(0)
97
+ for (const e of graph.edges) {
98
+ if (e.from !== e.to) {
99
+ children[e.from].push(e.to)
100
+ indeg[e.to]++
101
+ }
102
+ }
103
+
104
+ const color = new Uint8Array(n)
105
+ const dag: number[][] = Array.from({ length: n }, () => [])
106
+ const order: number[] = []
107
+
108
+ // Roots first so ranks grow from natural entry points, then any leftovers.
109
+ const roots = [...Array(n).keys()].filter((i) => indeg[i] === 0)
110
+ for (const start of [...roots, ...Array(n).keys()]) {
111
+ if (color[start] === 0) dfsDag(start, children, color, dag, order)
112
+ }
113
+
114
+ const rank = new Array<number>(n).fill(0)
115
+ for (let i = order.length - 1; i >= 0; i--) {
116
+ const u = order[i]
117
+ for (const v of dag[u]) rank[v] = Math.max(rank[v], rank[u] + 1)
118
+ }
119
+ return rank
120
+ }
121
+
122
+ /** Iterative DFS recording postorder and skipping edges back into the stack. */
123
+ function dfsDag(
124
+ start: number,
125
+ children: number[][],
126
+ color: Uint8Array,
127
+ dag: number[][],
128
+ order: number[],
129
+ ): void {
130
+ const stack: { u: number; i: number }[] = [{ u: start, i: 0 }]
131
+ color[start] = 1
132
+ while (stack.length > 0) {
133
+ const frame = stack[stack.length - 1]
134
+ const u = frame.u
135
+ if (frame.i < children[u].length) {
136
+ const v = children[u][frame.i]
137
+ frame.i++
138
+ if (color[v] === 1) continue // grey: a back edge, ignore it
139
+ dag[u].push(v)
140
+ if (color[v] === 0) {
141
+ color[v] = 1
142
+ stack.push({ u: v, i: 0 })
143
+ }
144
+ } else {
145
+ color[u] = 2
146
+ order.push(u)
147
+ stack.pop()
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Reorder nodes within each rank to minimise edge crossings (barycenter
154
+ * sweeps): alternate down/up passes sort each rank by the mean position of its
155
+ * neighbours, keeping whichever ordering crossed least.
156
+ */
157
+ export function orderRanks(byRank: number[][], edges: Edge[], ranks: number[]): void {
158
+ const n = ranks.length
159
+ if (byRank.length < 2 || n < 3) return
160
+
161
+ const parents: number[][] = Array.from({ length: n }, () => [])
162
+ const children: number[][] = Array.from({ length: n }, () => [])
163
+ for (const e of edges) {
164
+ if (e.from !== e.to && ranks[e.to] > ranks[e.from]) {
165
+ parents[e.to].push(e.from)
166
+ children[e.from].push(e.to)
167
+ }
168
+ }
169
+
170
+ const pos = new Array<number>(n).fill(0)
171
+ const reindex = (row: number[]): void => {
172
+ for (let i = 0; i < row.length; i++) pos[row[i]] = i
173
+ }
174
+ for (const row of byRank) reindex(row)
175
+
176
+ let best = byRank.map((row) => [...row])
177
+ let bestCrossings = countCrossings(edges, ranks, pos)
178
+ if (bestCrossings === 0) return
179
+
180
+ for (let it = 0; it < 8; it++) {
181
+ // Alternate sweeping down (sort by parents) and up (sort by children).
182
+ const rows = it % 2 === 0 ? byRank.slice(1) : byRank.slice(0, -1).reverse()
183
+ const neigh = it % 2 === 0 ? parents : children
184
+ for (const row of rows) {
185
+ sortByBarycenter(row, neigh, pos)
186
+ reindex(row)
187
+ }
188
+ const crossings = countCrossings(edges, ranks, pos)
189
+ if (crossings < bestCrossings) {
190
+ bestCrossings = crossings
191
+ best = byRank.map((row) => [...row])
192
+ }
193
+ if (bestCrossings === 0) break
194
+ }
195
+
196
+ for (let i = 0; i < byRank.length; i++) byRank[i].splice(0, byRank[i].length, ...best[i])
197
+ }
198
+
199
+ function sortByBarycenter(row: number[], neigh: number[][], pos: number[]): void {
200
+ const keyed = row.map((v) => ({
201
+ key:
202
+ neigh[v].length === 0 ? pos[v] : neigh[v].reduce((s, u) => s + pos[u], 0) / neigh[v].length,
203
+ v,
204
+ }))
205
+ keyed.sort((a, b) => a.key - b.key)
206
+ for (let i = 0; i < keyed.length; i++) row[i] = keyed[i].v
207
+ }
208
+
209
+ export function countCrossings(edges: Edge[], ranks: number[], pos: number[]): number {
210
+ const adjacent = edges
211
+ .filter((e) => e.from !== e.to && ranks[e.to] === ranks[e.from] + 1)
212
+ .map((e) => [ranks[e.from], pos[e.from], pos[e.to]] as const)
213
+ let crossings = 0
214
+ for (let i = 0; i < adjacent.length; i++) {
215
+ const a = adjacent[i]
216
+ for (let j = i + 1; j < adjacent.length; j++) {
217
+ const b = adjacent[j]
218
+ if (a[0] === b[0] && ((a[1] < b[1] && a[2] > b[2]) || (a[1] > b[1] && a[2] < b[2]))) {
219
+ crossings++
220
+ }
221
+ }
222
+ }
223
+ return crossings
224
+ }
225
+
226
+ /**
227
+ * Assign a cross-axis centre to every node so nodes line up under their
228
+ * neighbours: each node drifts toward the average of its neighbours while
229
+ * ranks keep their order and boxes keep `sep` between them.
230
+ */
231
+ export function assignPositions(
232
+ byRank: number[][],
233
+ size: number[],
234
+ sep: number,
235
+ edges: Edge[],
236
+ ranks: number[],
237
+ ): number[] {
238
+ const n = size.length
239
+ const parents: number[][] = Array.from({ length: n }, () => [])
240
+ const children: number[][] = Array.from({ length: n }, () => [])
241
+ for (const e of edges) {
242
+ if (e.from !== e.to && ranks[e.to] > ranks[e.from]) {
243
+ parents[e.to].push(e.from)
244
+ children[e.from].push(e.to)
245
+ }
246
+ }
247
+
248
+ const pos = new Array<number>(n).fill(0)
249
+ for (const row of byRank) {
250
+ let x = 0
251
+ for (const v of row) {
252
+ const h = size[v] / 2
253
+ x += h
254
+ pos[v] = x
255
+ x += h + sep
256
+ }
257
+ }
258
+
259
+ for (let it = 0; it < 10; it++) {
260
+ const rows = it % 2 === 0 ? byRank : [...byRank].reverse()
261
+ const neigh = it % 2 === 0 ? parents : children
262
+ for (const row of rows) relaxRank(row, neigh, pos, size, sep)
263
+ }
264
+
265
+ let minLeft = Number.POSITIVE_INFINITY
266
+ for (let v = 0; v < n; v++) minLeft = Math.min(minLeft, pos[v] - size[v] / 2)
267
+ if (!Number.isFinite(minLeft)) minLeft = 0
268
+ return Array.from({ length: n }, (_, v) => Math.max(0, Math.round(pos[v] - minLeft)))
269
+ }
270
+
271
+ function relaxRank(
272
+ nodes: number[],
273
+ neigh: number[][],
274
+ pos: number[],
275
+ size: number[],
276
+ sep: number,
277
+ ): void {
278
+ const n = nodes.length
279
+ if (n === 0) return
280
+
281
+ const desired = nodes.map((v) =>
282
+ neigh[v].length === 0 ? pos[v] : neigh[v].reduce((s, u) => s + pos[u], 0) / neigh[v].length,
283
+ )
284
+ const halfOf = (i: number): number => size[nodes[i]] / 2
285
+
286
+ // Sweep right then left, then take the midpoint: this centres a node between
287
+ // the tightest packing that respects order from either side.
288
+ const left = new Array<number>(n)
289
+ for (let i = 0; i < n; i++) {
290
+ left[i] =
291
+ i === 0 ? desired[i] : Math.max(desired[i], left[i - 1] + halfOf(i - 1) + sep + halfOf(i))
292
+ }
293
+ const right = new Array<number>(n)
294
+ for (let i = n - 1; i >= 0; i--) {
295
+ right[i] =
296
+ i === n - 1
297
+ ? desired[i]
298
+ : Math.min(desired[i], right[i + 1] - halfOf(i + 1) - sep - halfOf(i))
299
+ }
300
+ for (let i = 0; i < n; i++) pos[nodes[i]] = (left[i] + right[i]) / 2
301
+ for (let i = 1; i < n; i++) {
302
+ const minP = pos[nodes[i - 1]] + halfOf(i - 1) + sep + halfOf(i)
303
+ if (pos[nodes[i]] < minP) pos[nodes[i]] = minP
304
+ }
305
+ }
306
+
307
+ // ------------------------------------------------------------------- tracks
308
+
309
+ /** A span competing for a track: `[start, end, from, to, edgeIndex]`. */
310
+ type Span5 = [number, number, number, number, number]
311
+
312
+ /**
313
+ * Pack spans into as few parallel tracks as possible.
314
+ *
315
+ * Two spans share a track when they are two cells apart, or when they share an
316
+ * endpoint — edges fanning out of one node deliberately reuse a single row so
317
+ * a merge draws one arrowhead rather than a stack of them.
318
+ */
319
+ export function assignTracks(spans: Span5[]): { assigned: [number, number][]; count: number } {
320
+ const sorted = [...spans].sort((a, b) => {
321
+ for (let i = 0; i < 5; i++) if (a[i] !== b[i]) return a[i] - b[i]
322
+ return 0
323
+ })
324
+ const tracks: [number, number, number, number][][] = []
325
+ const assigned: [number, number][] = []
326
+ for (const [s, e, f, t, idx] of sorted) {
327
+ let slot = tracks.findIndex((members) =>
328
+ members.every(([s2, e2, f2, t2]) => e2 + 2 <= s || e + 2 <= s2 || f2 === f || t2 === t),
329
+ )
330
+ if (slot === -1) {
331
+ tracks.push([])
332
+ slot = tracks.length - 1
333
+ }
334
+ tracks[slot].push([s, e, f, t])
335
+ assigned.push([idx, slot])
336
+ }
337
+ return { assigned, count: tracks.length }
338
+ }
339
+
340
+ /** Edges from rank `r` to `r + 1` that must jog sideways, so need a bus row. */
341
+ function busSpans(
342
+ graph: Graph,
343
+ ranks: number[],
344
+ centers: number[],
345
+ r: number,
346
+ exact: boolean,
347
+ ): Span5[] {
348
+ const out: Span5[] = []
349
+ graph.edges.forEach((e, i) => {
350
+ const jogs = exact
351
+ ? centers[e.from] !== centers[e.to]
352
+ : Math.abs(centers[e.from] - centers[e.to]) > 1
353
+ if (e.from !== e.to && ranks[e.from] === r && ranks[e.to] === r + 1 && jogs) {
354
+ out.push([
355
+ Math.min(centers[e.from], centers[e.to]),
356
+ Math.max(centers[e.from], centers[e.to]),
357
+ e.from,
358
+ e.to,
359
+ i,
360
+ ])
361
+ }
362
+ })
363
+ return out
364
+ }
365
+
366
+ /** Edges skipping a rank or running backwards; these go around in a lane. */
367
+ function laneSpans(graph: Graph, ranks: number[], placed: Placed[], vertical: boolean): Span5[] {
368
+ const out: Span5[] = []
369
+ graph.edges.forEach((e, i) => {
370
+ if (e.from === e.to || ranks[e.to] === ranks[e.from] + 1) return
371
+ const pf = placed[e.from]
372
+ const pt = placed[e.to]
373
+ const a = vertical ? Math.min(pf.cy, pt.cy) : Math.min(pf.cx, pt.cx)
374
+ const b = vertical ? Math.max(pf.cy, pt.cy) : Math.max(pf.cx, pt.cx)
375
+ out.push([a, b, e.from, e.to, i])
376
+ })
377
+ return out
378
+ }
379
+
380
+ // ----------------------------------------------------------------- placement
381
+
382
+ function placeTd(
383
+ ranks: number[],
384
+ maxRank: number,
385
+ byRank: number[][],
386
+ sizes: NodeSizes,
387
+ graph: Graph,
388
+ placed: Placed[],
389
+ ): RoutePlan {
390
+ const centers = assignPositions(byRank, sizes.layW, GAP_X, graph.edges, ranks)
391
+
392
+ const edgeBus = new Array<number>(graph.edges.length).fill(0)
393
+ const busTracks = new Array<number>(maxRank + 1).fill(0)
394
+ for (let r = 0; r < maxRank; r++) {
395
+ const spans = busSpans(graph, ranks, centers, r, false)
396
+ if (spans.length === 0) continue
397
+ const { assigned, count } = assignTracks(spans)
398
+ for (const [idx, slot] of assigned) edgeBus[idx] = slot
399
+ busTracks[r] = count
400
+ }
401
+
402
+ const rankH = byRank.map((row) =>
403
+ row.length === 0 ? 3 : Math.max(...row.map((i) => sizes.boxH[i] + sizes.extraH[i])),
404
+ )
405
+ const rankY = new Array<number>(maxRank + 1).fill(0)
406
+ for (let r = 1; r <= maxRank; r++) {
407
+ rankY[r] = rankY[r - 1] + rankH[r - 1] + Math.max(GAP_Y, busTracks[r - 1] + 1)
408
+ }
409
+ const canvasH = rankY[maxRank] + rankH[maxRank]
410
+ const bandEnd = Array.from({ length: maxRank + 1 }, (_, r) => rankY[r] + rankH[r])
411
+
412
+ let diagramW = 1
413
+ byRank.forEach((row, r) => {
414
+ for (const idx of row) {
415
+ const w = sizes.boxW[idx]
416
+ const h = sizes.boxH[idx]
417
+ const cx = centers[idx]
418
+ const x = sat(cx, half(w))
419
+ const y = rankY[r] + half(rankH[r] - h - sizes.extraH[idx])
420
+ placed[idx] = { x, y, w, h, cx, cy: y + half(h), rank: r }
421
+ diagramW = Math.max(diagramW, x + w)
422
+ if (sizes.extraH[idx] > 0 && sizes.selfLabelW[idx] > 0) {
423
+ diagramW = Math.max(diagramW, x + w + 2 + sizes.selfLabelW[idx])
424
+ }
425
+ }
426
+ })
427
+
428
+ let contentW = diagramW
429
+ for (const e of graph.edges) {
430
+ if (e.from === e.to || e.label === null) continue
431
+ const lw = Math.min(stringWidth(e.label), MAX_LABEL)
432
+ contentW =
433
+ ranks[e.to] === ranks[e.from] + 1
434
+ ? Math.max(contentW, placed[e.to].cx + 2 + lw)
435
+ : Math.max(contentW, diagramW + lw + 1)
436
+ }
437
+
438
+ const edgeLane = new Array<number>(graph.edges.length).fill(0)
439
+ const lanes = laneSpans(graph, ranks, placed, true)
440
+ let canvasW = contentW
441
+ let laneBase = 0
442
+ if (lanes.length > 0) {
443
+ const { assigned, count } = assignTracks(lanes)
444
+ for (const [idx, slot] of assigned) edgeLane[idx] = slot
445
+ canvasW = contentW + 1 + count
446
+ laneBase = contentW + 1
447
+ }
448
+
449
+ return { canvasW, canvasH, bandEnd, edgeBus, laneBase, edgeLane }
450
+ }
451
+
452
+ function placeLr(
453
+ ranks: number[],
454
+ maxRank: number,
455
+ byRank: number[][],
456
+ sizes: NodeSizes,
457
+ graph: Graph,
458
+ placed: Placed[],
459
+ ): RoutePlan {
460
+ const colW = byRank.map((row) =>
461
+ row.length === 0 ? 0 : Math.max(...row.map((i) => sizes.boxW[i])),
462
+ )
463
+
464
+ // Left-to-right edge labels sit in the gap between columns, so the gap has
465
+ // to be wide enough for the widest of them.
466
+ const labelWidths = graph.edges
467
+ .filter((e) => e.from === e.to || ranks[e.to] === ranks[e.from] + 1)
468
+ .filter((e) => e.label !== null)
469
+ .map((e) => Math.min(stringWidth(e.label as string), MAX_LABEL))
470
+ const maxLabel = labelWidths.length === 0 ? 0 : Math.max(...labelWidths)
471
+ const baseGap = Math.max(GAP_X + 1, maxLabel + 3)
472
+
473
+ const centers = assignPositions(byRank, sizes.layH, 1, graph.edges, ranks)
474
+
475
+ const edgeBus = new Array<number>(graph.edges.length).fill(0)
476
+ const busTracks = new Array<number>(maxRank + 1).fill(0)
477
+ for (let r = 0; r < maxRank; r++) {
478
+ const spans = busSpans(graph, ranks, centers, r, true)
479
+ if (spans.length === 0) continue
480
+ const { assigned, count } = assignTracks(spans)
481
+ for (const [idx, slot] of assigned) edgeBus[idx] = slot
482
+ busTracks[r] = count
483
+ }
484
+
485
+ const rankX = new Array<number>(maxRank + 1).fill(0)
486
+ for (let r = 1; r <= maxRank; r++) {
487
+ rankX[r] = rankX[r - 1] + colW[r - 1] + Math.max(baseGap, busTracks[r - 1] + 1)
488
+ }
489
+ const selfTails = byRank[maxRank]
490
+ .filter((i) => sizes.extraH[i] > 0 && sizes.selfLabelW[i] > 0)
491
+ .map((i) => 2 + sizes.selfLabelW[i])
492
+ const canvasW =
493
+ rankX[maxRank] + colW[maxRank] + (selfTails.length === 0 ? 0 : Math.max(...selfTails))
494
+ const bandEnd = Array.from({ length: maxRank + 1 }, (_, r) => rankX[r] + colW[r])
495
+
496
+ let diagramH = 1
497
+ byRank.forEach((row, r) => {
498
+ const x = rankX[r]
499
+ for (const idx of row) {
500
+ const w = sizes.boxW[idx]
501
+ const h = sizes.boxH[idx]
502
+ const cy = centers[idx]
503
+ const y = sat(cy, half(h + sizes.extraH[idx]))
504
+ placed[idx] = { x, y, w, h, cx: x + half(w), cy: y + half(h), rank: r }
505
+ diagramH = Math.max(diagramH, y + h + sizes.extraH[idx])
506
+ }
507
+ })
508
+
509
+ const edgeLane = new Array<number>(graph.edges.length).fill(0)
510
+ const lanes = laneSpans(graph, ranks, placed, false)
511
+ let canvasH = diagramH
512
+ let laneBase = 0
513
+ if (lanes.length > 0) {
514
+ const { assigned, count } = assignTracks(lanes)
515
+ for (const [idx, slot] of assigned) edgeLane[idx] = slot
516
+ canvasH = diagramH + 1 + count
517
+ laneBase = diagramH + 1
518
+ }
519
+
520
+ return { canvasW, canvasH, bandEnd, edgeBus, laneBase, edgeLane }
521
+ }
522
+
523
+ // -------------------------------------------------------------------- canvas
524
+
525
+ /** Rank, place, draw and route a graph onto a fresh canvas. */
526
+ export function layoutCanvas(graph: Graph, extras: NodeExtra[]): CanvasResult {
527
+ const n = graph.nodes.length
528
+ if (n === 0) return null
529
+
530
+ const ranks = computeRanks(graph)
531
+ const maxRank = Math.max(...ranks, 0)
532
+
533
+ const byRank: number[][] = Array.from({ length: maxRank + 1 }, () => [])
534
+ for (let idx = 0; idx < ranks.length; idx++) byRank[ranks[idx]].push(idx)
535
+ orderRanks(byRank, graph.edges, ranks)
536
+
537
+ const wrapped = graph.nodes.map((node) => wrapLabel(node.label, WRAP_WIDTH, MAX_LINES))
538
+ const widest = (lines: string[]): number =>
539
+ Math.max(1, lines.length === 0 ? 1 : Math.max(...lines.map(stringWidth)))
540
+
541
+ const boxW = extras.map((extra, i) => {
542
+ if (extra.kind === 'frame') {
543
+ return Math.max(extra.sub.w + 2, stringWidth(fitLabel(graph.nodes[i].label, WRAP_WIDTH)) + 4)
544
+ }
545
+ if (extra.kind === 'compartments') return widest(extra.sections.flat()) + 2 * PAD + 2
546
+ return widest(wrapped[i]) + 2 * PAD + 2
547
+ })
548
+ const boxH = extras.map((extra, i) => {
549
+ if (extra.kind === 'frame') return extra.sub.h + 2
550
+ if (extra.kind === 'compartments') {
551
+ const filled = extra.sections.filter((s) => s.length > 0).length
552
+ return extra.sections.reduce((s, sec) => s + sec.length, 0) + sat(filled, 1) + 2
553
+ }
554
+ return wrapped[i].length + 2
555
+ })
556
+
557
+ // A self-edge needs two rows below its box, and room beside it for a label.
558
+ const extraH = new Array<number>(n).fill(0)
559
+ const selfLabelW = new Array<number>(n).fill(0)
560
+ for (const e of graph.edges) {
561
+ if (e.from !== e.to) continue
562
+ extraH[e.from] = 2
563
+ if (e.label !== null) {
564
+ selfLabelW[e.from] = Math.max(selfLabelW[e.from], Math.min(stringWidth(e.label), MAX_LABEL))
565
+ }
566
+ }
567
+ for (let i = 0; i < n; i++) if (extraH[i] > 0) boxW[i] = Math.max(boxW[i], 7)
568
+
569
+ const sizes: NodeSizes = {
570
+ boxW,
571
+ boxH,
572
+ layW: boxW.map((w, i) => w + (selfLabelW[i] > 0 ? 2 * (selfLabelW[i] + 3) : 0)),
573
+ layH: boxH.map((h, i) => h + extraH[i]),
574
+ extraH,
575
+ selfLabelW,
576
+ }
577
+
578
+ const placed: Placed[] = Array.from({ length: n }, () => ({
579
+ x: 0,
580
+ y: 0,
581
+ w: 0,
582
+ h: 0,
583
+ cx: 0,
584
+ cy: 0,
585
+ rank: 0,
586
+ }))
587
+
588
+ const vertical = graph.dir === 'down' || graph.dir === 'up'
589
+ const plan = vertical
590
+ ? placeTd(ranks, maxRank, byRank, sizes, graph, placed)
591
+ : placeLr(ranks, maxRank, byRank, sizes, graph, placed)
592
+
593
+ if (plan.canvasW * plan.canvasH > MAX_CANVAS_CELLS) return null
594
+
595
+ const canvas = new Canvas(plan.canvasW, plan.canvasH)
596
+ for (let idx = 0; idx < n; idx++) {
597
+ const extra = extras[idx]
598
+ if (extra.kind === 'frame') drawFrame(canvas, placed[idx], graph.nodes[idx].label, extra.sub)
599
+ else if (extra.kind === 'compartments') drawClassBox(canvas, placed[idx], extra.sections)
600
+ else drawBox(canvas, placed[idx], wrapped[idx], graph.nodes[idx].shape)
601
+ }
602
+
603
+ graph.edges.forEach((edge, i) => {
604
+ canvas.curStyle =
605
+ edge.line === 'dotted' ? STY_DOT : edge.line === 'thick' ? STY_THICK : STY_SOLID
606
+ if (edge.from === edge.to) {
607
+ routeSelf(canvas, placed[edge.from], edge)
608
+ return
609
+ }
610
+ const from = placed[edge.from]
611
+ const to = placed[edge.to]
612
+ const adjacent = to.rank === from.rank + 1
613
+ const bus = plan.bandEnd[from.rank] + plan.edgeBus[i]
614
+ const lane = plan.laneBase + plan.edgeLane[i]
615
+ if (vertical) {
616
+ if (adjacent) routeForward(canvas, from, to, edge, bus)
617
+ else routeBack(canvas, from, to, edge, lane)
618
+ } else if (adjacent) {
619
+ routeForwardLr(canvas, from, to, edge, bus)
620
+ } else {
621
+ routeBackLr(canvas, from, to, edge, lane)
622
+ }
623
+ })
624
+
625
+ canvas.finalizeMask()
626
+ return canvas
627
+ }
628
+
629
+ /** Apply the direction flip a finished canvas needs for `BT` / `RL`. */
630
+ export function orient(canvas: Canvas, graph: Graph): Canvas {
631
+ if (graph.dir === 'up') canvas.flipVertical()
632
+ else if (graph.dir === 'left') canvas.flipHorizontal()
633
+ return canvas
634
+ }
635
+
636
+ /** Flowchart and state diagrams: plain boxes, no extra content. */
637
+ export function layoutFlowchart(graph: Graph): CanvasResult {
638
+ const extras: NodeExtra[] = graph.nodes.map(() => ({ kind: 'plain' }))
639
+ const canvas = layoutCanvas(graph, extras)
640
+ return canvas && orient(canvas, graph)
641
+ }
642
+
643
+ /** Class and ER diagrams: boxes divided into title / attribute / method rows. */
644
+ export function layoutClass(graph: Graph, infos: ClassInfo[]): CanvasResult {
645
+ const extras: NodeExtra[] = graph.nodes.map((node, i) => {
646
+ const title: string[] = []
647
+ if (infos[i].annotation !== null) title.push(`«${infos[i].annotation}»`)
648
+ title.push(displayGenerics(node.label))
649
+ return { kind: 'compartments', sections: [title, infos[i].attrs, infos[i].methods] }
650
+ })
651
+ const canvas = layoutCanvas(graph, extras)
652
+ return canvas && orient(canvas, graph)
653
+ }
654
+
655
+ function displayGenerics(s: string): string {
656
+ let out = ''
657
+ let open = false
658
+ for (const c of s) {
659
+ if (c === '~') {
660
+ out += open ? '>' : '<'
661
+ open = !open
662
+ } else {
663
+ out += c
664
+ }
665
+ }
666
+ return out
667
+ }
668
+
669
+ // -------------------------------------------------------------------- groups
670
+
671
+ type ItemKey = string
672
+ const nodeKey = (i: number): ItemKey => `n${i}`
673
+ const groupKey = (i: number): ItemKey => `g${i}`
674
+
675
+ /**
676
+ * Lay out a flowchart that uses `subgraph`.
677
+ *
678
+ * Each subgraph becomes a framed box holding its own independently laid-out
679
+ * canvas. An edge is drawn in the innermost scope containing both endpoints;
680
+ * one crossing a subgraph boundary attaches to the frame instead of the node.
681
+ */
682
+ export function layoutGrouped(graph: Graph): CanvasResult {
683
+ // A node whose id matches a subgraph id stands in for that subgraph.
684
+ const proxy = new Map<number, number>()
685
+ graph.groups.forEach((g, gi) => {
686
+ const ni = graph.index.get(g.id)
687
+ if (ni !== undefined) proxy.set(ni, gi)
688
+ })
689
+
690
+ const groupChain = (g: number | null): number[] => {
691
+ const chain: number[] = []
692
+ let cur = g
693
+ while (cur !== null) {
694
+ chain.push(cur)
695
+ cur = graph.groups[cur].parent
696
+ }
697
+ return chain.reverse()
698
+ }
699
+ const endpoint = (n: number): { key: ItemKey; chain: number[] } => {
700
+ const gi = proxy.get(n)
701
+ return gi === undefined
702
+ ? { key: nodeKey(n), chain: groupChain(graph.nodeGroup[n]) }
703
+ : { key: groupKey(gi), chain: groupChain(graph.groups[gi].parent) }
704
+ }
705
+
706
+ /** Edges bucketed by the scope that draws them; `null` is the top level. */
707
+ const scopeEdges = new Map<number | null, [ItemKey, ItemKey, number][]>()
708
+ const referenced = new Array<boolean>(graph.groups.length).fill(false)
709
+ graph.edges.forEach((e, ei) => {
710
+ const f = endpoint(e.from)
711
+ const t = endpoint(e.to)
712
+ let k = 0
713
+ while (k < f.chain.length && k < t.chain.length && f.chain[k] === t.chain[k]) k++
714
+ const scope = k === 0 ? null : f.chain[k - 1]
715
+ const fKey = f.chain.length > k ? groupKey(f.chain[k]) : f.key
716
+ const tKey = t.chain.length > k ? groupKey(t.chain[k]) : t.key
717
+ for (const key of [fKey, tKey]) {
718
+ if (key.startsWith('g')) referenced[Number(key.slice(1))] = true
719
+ }
720
+ const list = scopeEdges.get(scope)
721
+ if (list) list.push([fKey, tKey, ei])
722
+ else scopeEdges.set(scope, [[fKey, tKey, ei]])
723
+ })
724
+
725
+ const directNodes = new Map<number | null, number[]>()
726
+ graph.nodeGroup.forEach((g, ni) => {
727
+ if (proxy.has(ni)) return
728
+ const list = directNodes.get(g)
729
+ if (list) list.push(ni)
730
+ else directNodes.set(g, [ni])
731
+ })
732
+
733
+ // Drop empty subgraphs, but keep any that an edge attaches to.
734
+ const keep = new Array<boolean>(graph.groups.length).fill(false)
735
+ for (let gi = graph.groups.length - 1; gi >= 0; gi--) {
736
+ const hasNodes = (directNodes.get(gi) ?? []).length > 0
737
+ const hasChildren = graph.groups.some((g, c) => g.parent === gi && keep[c])
738
+ keep[gi] = hasNodes || hasChildren || referenced[gi]
739
+ }
740
+
741
+ const canvas = buildScope(graph, null, scopeEdges, directNodes, keep)
742
+ return canvas && orient(canvas, graph)
743
+ }
744
+
745
+ function buildScope(
746
+ graph: Graph,
747
+ scope: number | null,
748
+ scopeEdges: Map<number | null, [ItemKey, ItemKey, number][]>,
749
+ directNodes: Map<number | null, number[]>,
750
+ keep: boolean[],
751
+ ): CanvasResult {
752
+ const items: ItemKey[] = (directNodes.get(scope) ?? []).map(nodeKey)
753
+ const childGroups = graph.groups
754
+ .map((_, gi) => gi)
755
+ .filter((gi) => graph.groups[gi].parent === scope && keep[gi])
756
+ items.push(...childGroups.map(groupKey))
757
+
758
+ if (items.length === 0) return new Canvas(1, 1)
759
+
760
+ const indexOf = new Map<ItemKey, number>()
761
+ const nodes: Node[] = []
762
+ const extras: NodeExtra[] = []
763
+ for (const item of items) {
764
+ indexOf.set(item, nodes.length)
765
+ const i = Number(item.slice(1))
766
+ if (item.startsWith('n')) {
767
+ nodes.push({ label: graph.nodes[i].label, shape: graph.nodes[i].shape })
768
+ extras.push({ kind: 'plain' })
769
+ } else {
770
+ const sub = buildScope(graph, i, scopeEdges, directNodes, keep)
771
+ if (sub === null) return null
772
+ nodes.push({ label: graph.groups[i].label, shape: 'rect' })
773
+ extras.push({ kind: 'frame', sub })
774
+ }
775
+ }
776
+
777
+ const edges: Edge[] = []
778
+ for (const [f, t, ei] of scopeEdges.get(scope) ?? []) {
779
+ const fi = indexOf.get(f)
780
+ const ti = indexOf.get(t)
781
+ if (fi === undefined || ti === undefined) continue
782
+ const e = graph.edges[ei]
783
+ edges.push({
784
+ from: fi,
785
+ to: ti,
786
+ label: e.label,
787
+ headTo: e.headTo,
788
+ headFrom: e.headFrom,
789
+ line: e.line,
790
+ })
791
+ }
792
+
793
+ // Layout only reads nodes/edges/dir, so a bare Graph carrying those is enough.
794
+ const synth = new Graph(graph.dir)
795
+ synth.nodes = nodes
796
+ synth.edges = edges
797
+ return layoutCanvas(synth, extras)
798
+ }
799
+
800
+ // ------------------------------------------------------------------- drawing
801
+
802
+ export function drawBox(canvas: Canvas, p: Placed, lines: string[], shape: Shape): void {
803
+ const { x, y, w, h } = p
804
+ const right = x + w - 1
805
+ const bottom = y + h - 1
806
+
807
+ const rounded = shape === 'round' || shape === 'diamond'
808
+ canvas.set(x, y, rounded ? '╭' : '┌', 'border')
809
+ canvas.set(right, y, rounded ? '╮' : '┐', 'border')
810
+ canvas.set(x, bottom, rounded ? '╰' : '└', 'border')
811
+ canvas.set(right, bottom, rounded ? '╯' : '┘', 'border')
812
+
813
+ // The perimeter is drawn as bits so edges can tee into it, but it is the box
814
+ // outline, so it claims `border` rather than `edge`.
815
+ for (let cx = x + 1; cx < right; cx++) {
816
+ canvas.addBits(cx, y, L | R, 'border')
817
+ canvas.addBits(cx, bottom, L | R, 'border')
818
+ }
819
+ for (let cy = y + 1; cy < bottom; cy++) {
820
+ canvas.addBits(x, cy, U | D, 'border')
821
+ canvas.addBits(right, cy, U | D, 'border')
822
+ }
823
+
824
+ for (let cy = y; cy <= bottom; cy++) {
825
+ for (let cx = x; cx <= right; cx++) canvas.occupied[canvas.idx(cx, cy)] = 1
826
+ }
827
+
828
+ const inner = Math.max(1, sat(w, 2 * PAD + 2))
829
+ lines.forEach((line, li) => {
830
+ const text = fitLabel(line, inner)
831
+ const textX = x + 1 + PAD + half(sat(inner, stringWidth(text)))
832
+ drawText(canvas, text, textX, y + 1 + li, 'text')
833
+ })
834
+ }
835
+
836
+ /** A class or ER box: sections separated by horizontal rules, title centred. */
837
+ function drawClassBox(canvas: Canvas, p: Placed, sections: string[][]): void {
838
+ drawBox(canvas, p, [], 'rect')
839
+ const inner = Math.max(1, sat(p.w, 2 * PAD + 2))
840
+ let row = p.y + 1
841
+ let first = true
842
+ sections.forEach((section, si) => {
843
+ if (section.length === 0) return
844
+ if (!first) {
845
+ canvas.set(p.x, row, '├', 'border')
846
+ for (let x = p.x + 1; x < p.x + p.w - 1; x++) canvas.set(x, row, '─', 'border')
847
+ canvas.set(p.x + p.w - 1, row, '┤', 'border')
848
+ row++
849
+ }
850
+ first = false
851
+ for (const line of section) {
852
+ const text = fitLabel(line, inner)
853
+ const tx = si === 0 ? p.x + 1 + PAD + half(sat(inner, stringWidth(text))) : p.x + 1 + PAD
854
+ drawTextOverEdges(canvas, text, tx, row, 'text')
855
+ row++
856
+ }
857
+ })
858
+ }
859
+
860
+ /** A subgraph frame: a titled box with a finished sub-canvas centred inside. */
861
+ function drawFrame(canvas: Canvas, p: Placed, title: string, sub: Canvas): void {
862
+ drawBox(canvas, p, [], 'rect')
863
+ const t = fitLabel(title, sat(p.w, 4))
864
+ drawTextOverEdges(canvas, ` ${t} `, p.x + 1, p.y, 'text')
865
+ canvas.blit(sub, p.x + 1 + half(p.w - 2 - sub.w), p.y + 1 + half(p.h - 2 - sub.h))
866
+ }
867
+
868
+ // ------------------------------------------------------------------- routing
869
+
870
+ function headGlyph(head: Head, arrow: string): string {
871
+ switch (head) {
872
+ case 'circle':
873
+ return 'o'
874
+ case 'cross':
875
+ return '×'
876
+ case 'diamondFill':
877
+ return '◆'
878
+ case 'diamondOpen':
879
+ return '◇'
880
+ case 'triangle':
881
+ return { '▼': '▽', '▲': '△', '◄': '◁', '▶': '▷' }[arrow] ?? arrow
882
+ default:
883
+ return arrow
884
+ }
885
+ }
886
+
887
+ /** Adjacent ranks, top-down: drop, jog along the bus row, drop into the head. */
888
+ function routeForward(canvas: Canvas, from: Placed, to: Placed, edge: Edge, bus: number): void {
889
+ const tx = to.cx
890
+ // A jog of one column reads as a kink; snap straight instead.
891
+ const bx = Math.abs(from.cx - tx) <= 1 ? tx : from.cx
892
+ const by = from.y + from.h - 1
893
+ const headRow = to.y - 1
894
+
895
+ canvas.junction(bx, by, D)
896
+ canvas.segV(bx, by, bus)
897
+ if (bx === tx) {
898
+ canvas.segV(bx, bus, headRow)
899
+ } else {
900
+ canvas.segH(bus, bx, tx)
901
+ canvas.segV(tx, bus, headRow)
902
+ }
903
+
904
+ if (edge.headTo === 'none') canvas.addBits(tx, headRow, U)
905
+ else canvas.set(tx, headRow, headGlyph(edge.headTo, '▼'), 'edge')
906
+ if (edge.headFrom !== 'none') canvas.set(bx, by, headGlyph(edge.headFrom, '▲'), 'edge')
907
+
908
+ if (edge.label !== null) placeLabel(canvas, edge.label, headRow, tx + 1)
909
+ }
910
+
911
+ /** A self-edge: a stub loop hanging below the box. */
912
+ function routeSelf(canvas: Canvas, p: Placed, edge: Edge): void {
913
+ const bottom = p.y + p.h - 1
914
+ const exitX = p.cx + 1
915
+ const retX = p.x + p.w - 2
916
+ if (retX <= exitX || bottom + 2 >= canvas.h) return
917
+
918
+ const [v, h, bl, br] =
919
+ edge.line === 'dotted'
920
+ ? ['╎', '╌', '╰', '╯']
921
+ : edge.line === 'thick'
922
+ ? ['┃', '━', '┗', '┛']
923
+ : ['│', '─', '╰', '╯']
924
+
925
+ canvas.junction(exitX, bottom, D)
926
+ canvas.set(exitX, bottom + 1, v, 'edge')
927
+ canvas.set(exitX, bottom + 2, bl, 'edge')
928
+ for (let x = exitX + 1; x < retX; x++) canvas.set(x, bottom + 2, h, 'edge')
929
+ canvas.set(retX, bottom + 2, br, 'edge')
930
+ canvas.set(retX, bottom + 1, headGlyph(edge.headTo, '▲'), 'edge')
931
+ if (edge.label !== null) placeLabel(canvas, edge.label, bottom + 1, p.x + p.w + 1)
932
+ }
933
+
934
+ /** Skip or back edge, top-down: out the right side, up a lane, back in. */
935
+ function routeBack(canvas: Canvas, from: Placed, to: Placed, edge: Edge, laneX: number): void {
936
+ const sx = from.x + from.w - 1
937
+ const sy = from.cy
938
+ const tx = to.x + to.w - 1
939
+ const tyc = to.cy
940
+
941
+ canvas.junction(sx, sy, R)
942
+ canvas.segH(sy, sx, laneX)
943
+ canvas.segV(laneX, sy, tyc)
944
+ canvas.segH(tyc, tx + 1, laneX)
945
+
946
+ if (edge.headTo === 'none') canvas.addBits(tx + 1, tyc, R)
947
+ else canvas.set(tx + 1, tyc, headGlyph(edge.headTo, '◄'), 'edge')
948
+ if (edge.headFrom !== 'none') canvas.set(sx, sy, headGlyph(edge.headFrom, '◄'), 'edge')
949
+
950
+ if (edge.label !== null) {
951
+ placeLabel(canvas, edge.label, sat(tyc, 1), sat(laneX, stringWidth(edge.label) + 1))
952
+ }
953
+ }
954
+
955
+ /** Adjacent ranks, left-to-right: out the right side, jog on the bus column. */
956
+ function routeForwardLr(canvas: Canvas, from: Placed, to: Placed, edge: Edge, bus: number): void {
957
+ const rx = from.x + from.w - 1
958
+ const ry = from.cy
959
+ const ly = to.cy
960
+ const headCol = to.x - 1
961
+
962
+ canvas.junction(rx, ry, R)
963
+ canvas.segH(ry, rx, bus)
964
+ if (ry === ly) {
965
+ canvas.segH(ry, bus, headCol)
966
+ } else {
967
+ canvas.segV(bus, ry, ly)
968
+ canvas.segH(ly, bus, headCol)
969
+ }
970
+
971
+ if (edge.headTo === 'none') canvas.addBits(headCol, ly, R)
972
+ else canvas.set(headCol, ly, headGlyph(edge.headTo, '▶'), 'edge')
973
+ if (edge.headFrom !== 'none') canvas.set(rx, ry, headGlyph(edge.headFrom, '◄'), 'edge')
974
+
975
+ if (edge.label !== null) placeLabel(canvas, edge.label, sat(ly, 1), bus + 1)
976
+ }
977
+
978
+ /** Skip or back edge, left-to-right: down out the bottom, along a lane, back up. */
979
+ function routeBackLr(canvas: Canvas, from: Placed, to: Placed, edge: Edge, laneY: number): void {
980
+ const sx = from.cx
981
+ const sy = from.y + from.h - 1
982
+ const tx = to.cx
983
+ const ty = to.y + to.h - 1
984
+
985
+ canvas.junction(sx, sy, D)
986
+ canvas.segV(sx, sy, laneY)
987
+ canvas.segH(laneY, sx, tx)
988
+ canvas.segV(tx, laneY, ty + 1)
989
+
990
+ if (edge.headTo === 'none') canvas.addBits(tx, ty + 1, D)
991
+ else canvas.set(tx, ty + 1, headGlyph(edge.headTo, '▲'), 'edge')
992
+ if (edge.headFrom !== 'none') canvas.set(sx, sy, headGlyph(edge.headFrom, '▲'), 'edge')
993
+
994
+ if (edge.label !== null) placeLabel(canvas, edge.label, sat(laneY, 1), half(sx + tx))
995
+ }
996
+
997
+ /** Write an edge label, stopping at the first cell already occupied. */
998
+ function placeLabel(canvas: Canvas, label: string, row: number, startX: number): void {
999
+ if (row >= canvas.h) return
1000
+ const text = fitLabel(label, MAX_LABEL)
1001
+ let x = startX
1002
+ for (const [c, cw] of measured(text)) {
1003
+ if (cw === 0) continue
1004
+ if (x + cw > canvas.w) break
1005
+ let blocked = false
1006
+ for (let k = 0; k < cw; k++) {
1007
+ const i = canvas.idx(x + k, row)
1008
+ if (canvas.ch[i] !== ' ' || canvas.mask[i] !== 0 || canvas.occupied[i]) blocked = true
1009
+ }
1010
+ if (blocked) break
1011
+ canvas.set(x, row, c, 'edgeLabel')
1012
+ for (let k = 1; k < cw; k++) canvas.set(x + k, row, CONT, 'edgeLabel')
1013
+ x += cw
1014
+ }
1015
+ }