s-app-scoder-agent 0.9.10 → 0.9.15
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/.scoderrc.example.json +2 -0
- package/README.md +1 -1
- package/dist/agent/interaction.js +15 -8
- package/dist/agent/prompt-context.js +5 -0
- package/dist/agent.js +232 -3
- package/dist/cli/repl.js +6 -0
- package/dist/config/config.js +23 -6
- package/dist/context/prompt.js +10 -4
- package/dist/llm/llm.js +3 -1
- package/dist/llm/provider-anthropic.js +5 -3
- package/dist/llm/retry.js +4 -0
- package/dist/mcp/manager.js +2 -2
- package/dist/security/permissions.js +32 -12
- package/dist/tools/read-file.js +4 -2
- package/dist/tools/run-command.js +42 -24
- package/dist/tools/search.js +7 -0
- package/dist/tui/app.js +16 -3
- package/dist/tui/charset.js +26 -0
- package/dist/tui/chrome.js +9 -6
- package/dist/tui/hooks/use-keybinds.js +24 -1
- package/dist/tui/hooks/use-scroll.js +12 -3
- package/dist/tui/markdown/diagram.js +85 -6
- package/dist/tui/markdown/graph.js +1021 -0
- package/dist/tui/selectors.js +9 -3
- package/dist/tui/state.js +13 -0
- package/dist/workspace/file-index.js +6 -5
- package/package.json +1 -1
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flowchart layout engine — real graph rendering in the terminal (P9).
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: mermaid source → parse (nodes, shapes, arrows) → rank (longest
|
|
5
|
+
* path, cycle-safe) → order (barycenter sweeps) → size boxes → place →
|
|
6
|
+
* rasterize into a cell grid that carries a per-cell *kind*, so the Ink layer
|
|
7
|
+
* can color box borders, labels and edges separately.
|
|
8
|
+
*
|
|
9
|
+
* Two placement modes:
|
|
10
|
+
* - `layered` — Sugiyama-style bands, one layer per rank. Used whenever the
|
|
11
|
+
* graph fits the pane (the common case for branching flowcharts).
|
|
12
|
+
* - `strip` — topological order flowed into wrapped rows of boxes joined by
|
|
13
|
+
* `──▶`, with a `┌───┘` connector where the chain wraps. Used for long
|
|
14
|
+
* chains (version timelines) that would be taller than the viewport.
|
|
15
|
+
*
|
|
16
|
+
* Pure module: no Ink, no I/O, never throws. Every failure path returns null
|
|
17
|
+
* so diagram.tsx falls back to the structured summary — the TUI shows a
|
|
18
|
+
* complete graph or an honest summary, never a clipped drawing.
|
|
19
|
+
*
|
|
20
|
+
* Known approximations (documented in src/tui/README.md):
|
|
21
|
+
* - mermaid shapes collapse to three corner styles (rect / round / chamfer);
|
|
22
|
+
* - subgraphs are flattened into their nodes (a note names them);
|
|
23
|
+
* - edges that close a cycle are listed as notes instead of drawn;
|
|
24
|
+
* - an edge label with no free run wide enough is dropped and noted.
|
|
25
|
+
*/
|
|
26
|
+
import { charset } from "../charset.js";
|
|
27
|
+
import { clipCells, measure, wrapCells } from "../text-width.js";
|
|
28
|
+
// ── Geometry budget ─────────────────────────────────────────────────────────
|
|
29
|
+
const GAP = 2; // cross-axis gap between sibling boxes
|
|
30
|
+
const MARGIN = 2; // cross-axis gutters reserved for detour channels
|
|
31
|
+
const BAND = 3; // routing rows between layers (elbows + labels)
|
|
32
|
+
const STRIP_GAP = 3; // gap between strip boxes — wide enough for `──▶`
|
|
33
|
+
const STRIP_BAND = 2; // rows between strip rows — holds the wrap connector
|
|
34
|
+
const MAX_NODES = 80;
|
|
35
|
+
const MAX_EDGES = 200;
|
|
36
|
+
/** Label width caps, widest first: the first one that fits the pane wins. */
|
|
37
|
+
const LAYERED_CAPS = [22, 16, 12, 10];
|
|
38
|
+
const STRIP_CAPS = [26, 20, 14, 10];
|
|
39
|
+
/** Height budget for a drawn graph — anything taller degrades to the summary. */
|
|
40
|
+
export const MAX_GRAPH_ROWS = 40;
|
|
41
|
+
// ── 1. Label cleanup (shared with the summary renderer) ─────────────────────
|
|
42
|
+
const HTML_BREAK_RX = /<\s*br\s*\/?\s*>/gi;
|
|
43
|
+
const HTML_TAG_RX = /<\s*\/?\s*[a-z][^>]*>/gi;
|
|
44
|
+
const ENTITY_RX = /&(#x?[0-9a-f]+|[a-z]+);/gi;
|
|
45
|
+
const NAMED_ENTITIES = {
|
|
46
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Mermaid label → display text. `<br/>` is a LINE BREAK inside a mermaid node,
|
|
50
|
+
* so printing it literally is wrong: it becomes a space. Other tags are
|
|
51
|
+
* dropped, numeric/named entities decoded, quotes and repeated spaces
|
|
52
|
+
* collapsed.
|
|
53
|
+
*/
|
|
54
|
+
export function cleanMermaidLabel(raw) {
|
|
55
|
+
let out = raw.trim().replace(HTML_BREAK_RX, " ").replace(HTML_TAG_RX, "");
|
|
56
|
+
out = out.replace(ENTITY_RX, (whole, entity) => {
|
|
57
|
+
if (entity[0] !== "#")
|
|
58
|
+
return NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
|
|
59
|
+
const hex = entity[1] === "x" || entity[1] === "X";
|
|
60
|
+
const code = Number.parseInt(hex ? entity.slice(2) : entity.slice(1), hex ? 16 : 10);
|
|
61
|
+
return Number.isFinite(code) && code > 31 ? String.fromCharCode(code) : whole;
|
|
62
|
+
});
|
|
63
|
+
return out.replace(/^["']|["']$/g, "").replace(/\s+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
// ── 2. Parsing ──────────────────────────────────────────────────────────────
|
|
66
|
+
/** Shape wrappers, longest opener first so `[[x]]` never parses as `[x]`. */
|
|
67
|
+
const SHAPE_DEFS = [
|
|
68
|
+
["[[", "]]", "rect"],
|
|
69
|
+
["([", ")]", "round"],
|
|
70
|
+
["[(", ")]", "cylinder"],
|
|
71
|
+
["((", "))", "circle"],
|
|
72
|
+
["{{", "}}", "hex"],
|
|
73
|
+
["[", "]", "rect"],
|
|
74
|
+
["(", ")", "round"],
|
|
75
|
+
["{", "}", "diamond"],
|
|
76
|
+
[">", "]", "flag"],
|
|
77
|
+
];
|
|
78
|
+
const ARROW_SPECS = [
|
|
79
|
+
{ text: "<-.->", style: "dotted", head: "both" },
|
|
80
|
+
{ text: "<-->", style: "solid", head: "both" },
|
|
81
|
+
{ text: "<==>", style: "thick", head: "both" },
|
|
82
|
+
{ text: "x--x", style: "solid", head: "open" },
|
|
83
|
+
{ text: "o--o", style: "solid", head: "open" },
|
|
84
|
+
{ text: "<--x", style: "solid", head: "back" },
|
|
85
|
+
{ text: "--->", style: "solid", head: "fwd" },
|
|
86
|
+
{ text: "-.->", style: "dotted", head: "fwd" },
|
|
87
|
+
{ text: "==>", style: "thick", head: "fwd" },
|
|
88
|
+
{ text: "-->", style: "solid", head: "fwd" },
|
|
89
|
+
{ text: "<--", style: "solid", head: "back" },
|
|
90
|
+
{ text: "---", style: "solid", head: "open" },
|
|
91
|
+
{ text: "===", style: "thick", head: "open" },
|
|
92
|
+
{ text: "-.-", style: "dotted", head: "open" },
|
|
93
|
+
{ text: "->", style: "solid", head: "fwd" },
|
|
94
|
+
{ text: "=>", style: "thick", head: "fwd" },
|
|
95
|
+
{ text: "--", style: "solid", head: "open" },
|
|
96
|
+
{ text: "<-", style: "solid", head: "back" },
|
|
97
|
+
];
|
|
98
|
+
const ID_RX = /^[\p{L}\p{N}_][\p{L}\p{N}_\-.]*/u;
|
|
99
|
+
function skipSpace(line, i) {
|
|
100
|
+
while (i < line.length && (line[i] === " " || line[i] === "\t"))
|
|
101
|
+
i++;
|
|
102
|
+
return i;
|
|
103
|
+
}
|
|
104
|
+
function readShape(line, i) {
|
|
105
|
+
for (const [open, close, shape] of SHAPE_DEFS) {
|
|
106
|
+
if (!line.startsWith(open, i))
|
|
107
|
+
continue;
|
|
108
|
+
const end = line.indexOf(close, i + open.length);
|
|
109
|
+
if (end === -1)
|
|
110
|
+
continue;
|
|
111
|
+
return { text: line.slice(i + open.length, end), shape, end: end + close.length };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function matchArrow(line, i) {
|
|
116
|
+
for (const spec of ARROW_SPECS) {
|
|
117
|
+
if (line.startsWith(spec.text, i))
|
|
118
|
+
return { spec, start: i, end: i + spec.text.length };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Next arrow at/after `from`, skipping node shapes so `-->` inside a label is safe. */
|
|
123
|
+
function findNextArrow(line, from) {
|
|
124
|
+
for (let i = from; i < line.length; i++) {
|
|
125
|
+
const shape = readShape(line, i);
|
|
126
|
+
if (shape) {
|
|
127
|
+
i = shape.end - 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const hit = matchArrow(line, i);
|
|
131
|
+
if (hit)
|
|
132
|
+
return hit;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/** Scan one statement into alternating node/edge tokens (`A --> B --> C` works). */
|
|
137
|
+
function scanStatement(line) {
|
|
138
|
+
const toks = [];
|
|
139
|
+
let i = 0;
|
|
140
|
+
for (;;) {
|
|
141
|
+
i = skipSpace(line, i);
|
|
142
|
+
const id = ID_RX.exec(line.slice(i))?.[0];
|
|
143
|
+
if (!id)
|
|
144
|
+
return toks.length >= 2 ? toks : null;
|
|
145
|
+
i += id.length;
|
|
146
|
+
const shape = readShape(line, i);
|
|
147
|
+
if (shape) {
|
|
148
|
+
toks.push({ t: "node", id, label: shape.text, shape: shape.shape });
|
|
149
|
+
i = shape.end;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const cls = /^:::[\w-]+/.exec(line.slice(i)); // `A:::someClass`
|
|
153
|
+
if (cls)
|
|
154
|
+
i += cls[0].length;
|
|
155
|
+
toks.push({ t: "node", id });
|
|
156
|
+
}
|
|
157
|
+
i = skipSpace(line, i);
|
|
158
|
+
let arrow = matchArrow(line, i);
|
|
159
|
+
if (!arrow)
|
|
160
|
+
break;
|
|
161
|
+
i = arrow.end;
|
|
162
|
+
let label = "";
|
|
163
|
+
const bar = skipSpace(line, i);
|
|
164
|
+
if (line[bar] === "|") {
|
|
165
|
+
const close = line.indexOf("|", bar + 1);
|
|
166
|
+
if (close !== -1) {
|
|
167
|
+
label = line.slice(bar + 1, close);
|
|
168
|
+
i = close + 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (arrow.spec.head === "open") {
|
|
172
|
+
// `A -- yes --> B`: open arrow, label text, then the real arrow head.
|
|
173
|
+
const next = findNextArrow(line, i);
|
|
174
|
+
if (next && next.start > i) {
|
|
175
|
+
label = line.slice(i, next.start).trim();
|
|
176
|
+
arrow = next;
|
|
177
|
+
i = next.end;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
toks.push({ t: "edge", spec: arrow.spec, label: cleanMermaidLabel(label) });
|
|
181
|
+
}
|
|
182
|
+
return toks.length >= 2 ? toks : null;
|
|
183
|
+
}
|
|
184
|
+
function stripComment(line) {
|
|
185
|
+
const i = line.indexOf("%%");
|
|
186
|
+
return (i === -1 ? line : line.slice(0, i)).trim();
|
|
187
|
+
}
|
|
188
|
+
/** Parse a mermaid `graph`/`flowchart` block; null for anything else. */
|
|
189
|
+
export function parseFlowchart(lines) {
|
|
190
|
+
const rows = lines.map(stripComment).filter((l) => l !== "");
|
|
191
|
+
if (rows.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const header = /^(?:graph|flowchart)(?:\s+(TB|BT|TD|RL|LR))?\b/i.exec(rows[0]);
|
|
194
|
+
if (!header)
|
|
195
|
+
return null;
|
|
196
|
+
const direction = (header[1] ?? "TD").toUpperCase();
|
|
197
|
+
const nodes = new Map();
|
|
198
|
+
const edges = [];
|
|
199
|
+
const notes = [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const touch = (id, label, shape) => {
|
|
202
|
+
const node = nodes.get(id) ?? { id, label: id, shape: "rect" };
|
|
203
|
+
if (label !== undefined && label !== "") {
|
|
204
|
+
node.label = cleanMermaidLabel(label);
|
|
205
|
+
node.shape = shape ?? node.shape;
|
|
206
|
+
}
|
|
207
|
+
nodes.set(id, node);
|
|
208
|
+
};
|
|
209
|
+
for (let li = 1; li < rows.length; li++) {
|
|
210
|
+
const line = rows[li];
|
|
211
|
+
if (/^subgraph\b/i.test(line)) {
|
|
212
|
+
const title = cleanMermaidLabel(line.replace(/^subgraph\b/i, "").replace(/\[[^\]]*\]|\([^)]*\)/g, ""));
|
|
213
|
+
if (title)
|
|
214
|
+
notes.push(`subgraph flattened: ${title}`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (/^end\b/i.test(line))
|
|
218
|
+
continue;
|
|
219
|
+
// Styling / interaction statements do not change the graph shape.
|
|
220
|
+
if (/^(direction|style|classDef|class|linkStyle|click|callback|accTitle|accDescr)\b/i.test(line))
|
|
221
|
+
continue;
|
|
222
|
+
const toks = scanStatement(line);
|
|
223
|
+
if (!toks)
|
|
224
|
+
continue;
|
|
225
|
+
let prev = null;
|
|
226
|
+
let pending = null;
|
|
227
|
+
for (const tok of toks) {
|
|
228
|
+
if (tok.t === "edge") {
|
|
229
|
+
pending = tok;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
touch(tok.id, tok.label, tok.shape);
|
|
233
|
+
if (pending && prev) {
|
|
234
|
+
if (prev === tok.id) {
|
|
235
|
+
notes.push(`self-loop omitted: ${nodes.get(prev)?.label ?? prev}`);
|
|
236
|
+
}
|
|
237
|
+
else if (edges.length < MAX_EDGES) {
|
|
238
|
+
const reverse = pending.spec.head === "back";
|
|
239
|
+
const from = reverse ? tok.id : prev;
|
|
240
|
+
const to = reverse ? prev : tok.id;
|
|
241
|
+
const key = `${from}\u0000${to}\u0000${pending.label}\u0000${pending.spec.style}`;
|
|
242
|
+
if (!seen.has(key)) {
|
|
243
|
+
seen.add(key);
|
|
244
|
+
edges.push({ from, to, label: pending.label, style: pending.spec.style });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
prev = tok.id;
|
|
249
|
+
pending = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (nodes.size === 0 || nodes.size > MAX_NODES)
|
|
253
|
+
return null;
|
|
254
|
+
return { direction, nodes: [...nodes.values()], edges, notes: dedupe(notes).slice(0, 4) };
|
|
255
|
+
}
|
|
256
|
+
/** Longest-path ranking with DFS cycle breaking, then barycenter ordering. */
|
|
257
|
+
function rankLayers(g) {
|
|
258
|
+
const ids = g.nodes.map((n) => n.id);
|
|
259
|
+
const out = new Map(ids.map((id) => [id, []]));
|
|
260
|
+
for (const e of g.edges)
|
|
261
|
+
out.get(e.from)?.push(e);
|
|
262
|
+
// 1. mark back edges (target still on the DFS stack ⇒ cycle closer)
|
|
263
|
+
const state = new Map(); // 0 unseen · 1 on stack · 2 done
|
|
264
|
+
const back = new Set();
|
|
265
|
+
const stack = [];
|
|
266
|
+
for (const root of ids) {
|
|
267
|
+
if (state.get(root))
|
|
268
|
+
continue;
|
|
269
|
+
state.set(root, 1);
|
|
270
|
+
stack.push({ id: root, i: 0 });
|
|
271
|
+
while (stack.length > 0) {
|
|
272
|
+
const frame = stack[stack.length - 1];
|
|
273
|
+
const list = out.get(frame.id) ?? [];
|
|
274
|
+
if (frame.i >= list.length) {
|
|
275
|
+
state.set(frame.id, 2);
|
|
276
|
+
stack.pop();
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const edge = list[frame.i++];
|
|
280
|
+
const mark = state.get(edge.to) ?? 0;
|
|
281
|
+
if (mark === 1)
|
|
282
|
+
back.add(edge);
|
|
283
|
+
else if (mark === 0) {
|
|
284
|
+
state.set(edge.to, 1);
|
|
285
|
+
stack.push({ id: edge.to, i: 0 });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 2. Kahn topological order + longest-path rank over the acyclic part
|
|
290
|
+
const indeg = new Map(ids.map((id) => [id, 0]));
|
|
291
|
+
const fwd = new Map(ids.map((id) => [id, []]));
|
|
292
|
+
for (const e of g.edges) {
|
|
293
|
+
if (back.has(e))
|
|
294
|
+
continue;
|
|
295
|
+
fwd.get(e.from)?.push(e.to);
|
|
296
|
+
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
|
|
297
|
+
}
|
|
298
|
+
const rank = new Map(ids.map((id) => [id, 0]));
|
|
299
|
+
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0);
|
|
300
|
+
const topo = [];
|
|
301
|
+
while (queue.length > 0) {
|
|
302
|
+
const id = queue.shift();
|
|
303
|
+
topo.push(id);
|
|
304
|
+
for (const next of fwd.get(id) ?? []) {
|
|
305
|
+
rank.set(next, Math.max(rank.get(next) ?? 0, (rank.get(id) ?? 0) + 1));
|
|
306
|
+
const left = (indeg.get(next) ?? 1) - 1;
|
|
307
|
+
indeg.set(next, left);
|
|
308
|
+
if (left === 0)
|
|
309
|
+
queue.push(next);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const id of ids)
|
|
313
|
+
if (!topo.includes(id))
|
|
314
|
+
topo.push(id);
|
|
315
|
+
const depth = ids.reduce((max, id) => Math.max(max, (rank.get(id) ?? 0) + 1), 0);
|
|
316
|
+
const layers = Array.from({ length: depth }, () => []);
|
|
317
|
+
for (const id of topo)
|
|
318
|
+
layers[rank.get(id) ?? 0].push(id);
|
|
319
|
+
// 3. barycenter sweeps to reduce crossings
|
|
320
|
+
const neighbors = new Map(ids.map((id) => [id, []]));
|
|
321
|
+
for (const e of g.edges) {
|
|
322
|
+
if (back.has(e))
|
|
323
|
+
continue;
|
|
324
|
+
neighbors.get(e.from)?.push(e.to);
|
|
325
|
+
neighbors.get(e.to)?.push(e.from);
|
|
326
|
+
}
|
|
327
|
+
for (let sweep = 0; sweep < 4; sweep++) {
|
|
328
|
+
const down = sweep % 2 === 0;
|
|
329
|
+
const order = [];
|
|
330
|
+
for (let i = 1; i < depth; i++)
|
|
331
|
+
order.push(down ? i : depth - 1 - i);
|
|
332
|
+
for (const li of order) {
|
|
333
|
+
const ref = layers[down ? li - 1 : li + 1];
|
|
334
|
+
if (!ref || ref.length === 0)
|
|
335
|
+
continue;
|
|
336
|
+
const pos = new Map(ref.map((id, idx) => [id, idx]));
|
|
337
|
+
layers[li] = layers[li]
|
|
338
|
+
.map((id, idx) => {
|
|
339
|
+
const near = (neighbors.get(id) ?? [])
|
|
340
|
+
.map((n) => pos.get(n))
|
|
341
|
+
.filter((p) => p !== undefined);
|
|
342
|
+
const b = near.length > 0 ? near.reduce((s, p) => s + p, 0) / near.length : idx;
|
|
343
|
+
return { id, idx, b };
|
|
344
|
+
})
|
|
345
|
+
.sort((a, b) => a.b - b.b || a.idx - b.idx)
|
|
346
|
+
.map((x) => x.id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { layers: layers.filter((L) => L.length > 0), back };
|
|
350
|
+
}
|
|
351
|
+
// ── 4. Canvas ───────────────────────────────────────────────────────────────
|
|
352
|
+
const UP = 1, RIGHT = 2, DOWN = 4, LEFT = 8;
|
|
353
|
+
function lineGlyph(mask, style) {
|
|
354
|
+
const horizontal = (mask & (RIGHT | LEFT)) !== 0 && (mask & (UP | DOWN)) === 0;
|
|
355
|
+
const vertical = (mask & (UP | DOWN)) !== 0 && (mask & (RIGHT | LEFT)) === 0;
|
|
356
|
+
if (style === "thick") {
|
|
357
|
+
if (horizontal)
|
|
358
|
+
return charset.hHeavy;
|
|
359
|
+
if (vertical)
|
|
360
|
+
return charset.vHeavy;
|
|
361
|
+
}
|
|
362
|
+
else if (style === "dotted") {
|
|
363
|
+
if (horizontal)
|
|
364
|
+
return charset.hDotted;
|
|
365
|
+
if (vertical)
|
|
366
|
+
return charset.vDotted;
|
|
367
|
+
}
|
|
368
|
+
return charset.line[mask] ?? "+";
|
|
369
|
+
}
|
|
370
|
+
class Canvas {
|
|
371
|
+
w;
|
|
372
|
+
h;
|
|
373
|
+
chars;
|
|
374
|
+
kinds;
|
|
375
|
+
masks;
|
|
376
|
+
styles;
|
|
377
|
+
constructor(w, h) {
|
|
378
|
+
this.w = w;
|
|
379
|
+
this.h = h;
|
|
380
|
+
this.chars = Array.from({ length: h }, () => new Array(w).fill(" "));
|
|
381
|
+
this.kinds = Array.from({ length: h }, () => new Array(w).fill("empty"));
|
|
382
|
+
this.masks = Array.from({ length: h }, () => new Array(w).fill(0));
|
|
383
|
+
this.styles = Array.from({ length: h }, () => new Array(w).fill("solid"));
|
|
384
|
+
}
|
|
385
|
+
ok(x, y) {
|
|
386
|
+
return x >= 0 && y >= 0 && x < this.w && y < this.h;
|
|
387
|
+
}
|
|
388
|
+
busy(x, y) {
|
|
389
|
+
return this.ok(x, y) && this.kinds[y][x] !== "empty";
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Can a channel be drawn through this cell? `allowShared` lets a second
|
|
393
|
+
* edge merge into an existing line (junction) instead of giving up — used
|
|
394
|
+
* only after every clean route is exhausted, so the picture stays readable.
|
|
395
|
+
*/
|
|
396
|
+
passable(x, y, allowShared) {
|
|
397
|
+
if (!this.ok(x, y))
|
|
398
|
+
return false;
|
|
399
|
+
const kind = this.kinds[y][x];
|
|
400
|
+
return kind === "empty" || (allowShared && kind === "edge");
|
|
401
|
+
}
|
|
402
|
+
freeH(y, x1, x2, allowShared = false) {
|
|
403
|
+
const [a, b] = x1 <= x2 ? [x1, x2] : [x2, x1];
|
|
404
|
+
for (let x = a; x <= b; x++)
|
|
405
|
+
if (!this.passable(x, y, allowShared))
|
|
406
|
+
return false;
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
freeV(x, y1, y2, allowShared = false) {
|
|
410
|
+
const [a, b] = y1 <= y2 ? [y1, y2] : [y2, y1];
|
|
411
|
+
for (let y = a; y <= b; y++)
|
|
412
|
+
if (!this.passable(x, y, allowShared))
|
|
413
|
+
return false;
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Free run along the RANK axis at a fixed cross coordinate. TD: rank is
|
|
418
|
+
* vertical (a column); LR: rank is horizontal (a row).
|
|
419
|
+
*/
|
|
420
|
+
freeRank(atCross, r1, r2, rankVertical, allowShared = false) {
|
|
421
|
+
return rankVertical ? this.freeV(atCross, r1, r2, allowShared) : this.freeH(atCross, r1, r2, allowShared);
|
|
422
|
+
}
|
|
423
|
+
/** Free run along the CROSS axis at a fixed rank coordinate. */
|
|
424
|
+
freeCross(atRank, c1, c2, rankVertical, allowShared = false) {
|
|
425
|
+
return rankVertical ? this.freeH(atRank, c1, c2, allowShared) : this.freeV(atRank, c1, c2, allowShared);
|
|
426
|
+
}
|
|
427
|
+
put(x, y, ch, kind) {
|
|
428
|
+
if (!this.ok(x, y))
|
|
429
|
+
return;
|
|
430
|
+
this.chars[y][x] = ch;
|
|
431
|
+
this.kinds[y][x] = kind;
|
|
432
|
+
this.masks[y][x] = 0;
|
|
433
|
+
}
|
|
434
|
+
/** Add connection arms to a cell; crossing lines merge into one glyph. */
|
|
435
|
+
line(x, y, mask, style) {
|
|
436
|
+
if (!this.ok(x, y))
|
|
437
|
+
return;
|
|
438
|
+
const kind = this.kinds[y][x];
|
|
439
|
+
if (kind === "box" || kind === "label" || kind === "arrow")
|
|
440
|
+
return;
|
|
441
|
+
this.kinds[y][x] = "edge";
|
|
442
|
+
this.masks[y][x] |= mask;
|
|
443
|
+
if (style !== "solid")
|
|
444
|
+
this.styles[y][x] = style;
|
|
445
|
+
}
|
|
446
|
+
hLine(y, x1, x2, style) {
|
|
447
|
+
const [a, b] = x1 <= x2 ? [x1, x2] : [x2, x1];
|
|
448
|
+
for (let x = a; x <= b; x++) {
|
|
449
|
+
const mask = a === b ? RIGHT | LEFT : x === a ? RIGHT : x === b ? LEFT : RIGHT | LEFT;
|
|
450
|
+
this.line(x, y, mask, style);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
vLine(x, y1, y2, style) {
|
|
454
|
+
const [a, b] = y1 <= y2 ? [y1, y2] : [y2, y1];
|
|
455
|
+
for (let y = a; y <= b; y++) {
|
|
456
|
+
const mask = a === b ? UP | DOWN : y === a ? DOWN : y === b ? UP : UP | DOWN;
|
|
457
|
+
this.line(x, y, mask, style);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
arrow(x, y, dir) {
|
|
461
|
+
if (!this.ok(x, y))
|
|
462
|
+
return;
|
|
463
|
+
if (this.kinds[y][x] === "box" || this.kinds[y][x] === "label")
|
|
464
|
+
return;
|
|
465
|
+
const ch = dir === "up" ? charset.arrowUp : dir === "right" ? charset.arrowRight
|
|
466
|
+
: dir === "down" ? charset.arrowDown : charset.arrowLeft;
|
|
467
|
+
this.put(x, y, ch, "arrow");
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Write text at a cell. Busy cells are skipped unless `overwriteLines`,
|
|
471
|
+
* which lets an edge label break a line (mermaid draws labels on the
|
|
472
|
+
* connector) while never touching box borders or other text.
|
|
473
|
+
*/
|
|
474
|
+
text(x, y, str, kind, overwriteLines = false) {
|
|
475
|
+
let cx = x;
|
|
476
|
+
for (const seg of new Intl.Segmenter("en", { granularity: "grapheme" }).segment(str)) {
|
|
477
|
+
const width = measure(seg.segment);
|
|
478
|
+
if (cx < 0) {
|
|
479
|
+
cx += width;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (cx + width > this.w)
|
|
483
|
+
break;
|
|
484
|
+
const existing = this.ok(cx, y) ? this.kinds[y][cx] : "empty";
|
|
485
|
+
const blocked = existing !== "empty" && !(overwriteLines && existing === "edge");
|
|
486
|
+
if (!blocked) {
|
|
487
|
+
this.put(cx, y, seg.segment, kind);
|
|
488
|
+
for (let k = 1; k < width; k++)
|
|
489
|
+
this.put(cx + k, y, " ", kind);
|
|
490
|
+
}
|
|
491
|
+
cx += width;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Node box: border from `cornersFor(shape)` + centered label lines. */
|
|
495
|
+
box(rect, lines, shape) {
|
|
496
|
+
const { x, y, w, h } = rect;
|
|
497
|
+
if (w < 5 || h < 3) {
|
|
498
|
+
this.text(x, y, clipCells(lines[0] ?? "", Math.max(1, w)), "label");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const [tl, tr, br, bl] = cornersFor(shape);
|
|
502
|
+
this.put(x, y, tl, "box");
|
|
503
|
+
this.put(x + w - 1, y, tr, "box");
|
|
504
|
+
this.put(x + w - 1, y + h - 1, br, "box");
|
|
505
|
+
this.put(x, y + h - 1, bl, "box");
|
|
506
|
+
for (let i = x + 1; i < x + w - 1; i++) {
|
|
507
|
+
this.put(i, y, charset.h, "box");
|
|
508
|
+
this.put(i, y + h - 1, charset.h, "box");
|
|
509
|
+
}
|
|
510
|
+
for (let j = y + 1; j < y + h - 1; j++) {
|
|
511
|
+
this.put(x, j, charset.v, "box");
|
|
512
|
+
this.put(x + w - 1, j, charset.v, "box");
|
|
513
|
+
}
|
|
514
|
+
const inner = w - 4;
|
|
515
|
+
lines.forEach((line, i) => {
|
|
516
|
+
const row = y + 1 + i;
|
|
517
|
+
if (row >= y + h - 1)
|
|
518
|
+
return;
|
|
519
|
+
const pad = Math.max(0, Math.floor((inner - measure(line)) / 2));
|
|
520
|
+
this.text(x + 2 + pad, row, line, "label");
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
/** Resolve glyphs from masks, trim trailing blanks, group runs into segments. */
|
|
524
|
+
toRows() {
|
|
525
|
+
const rows = [];
|
|
526
|
+
for (let y = 0; y < this.h; y++) {
|
|
527
|
+
const segs = [];
|
|
528
|
+
let text = "";
|
|
529
|
+
let kind = "empty";
|
|
530
|
+
let last = -1;
|
|
531
|
+
for (let x = 0; x < this.w; x++) {
|
|
532
|
+
const cellKind = this.kinds[y][x];
|
|
533
|
+
const ch = cellKind === "edge" ? lineGlyph(this.masks[y][x], this.styles[y][x]) : this.chars[y][x];
|
|
534
|
+
if (cellKind !== kind) {
|
|
535
|
+
if (text !== "")
|
|
536
|
+
segs.push({ text, kind });
|
|
537
|
+
kind = cellKind;
|
|
538
|
+
text = "";
|
|
539
|
+
}
|
|
540
|
+
text += ch;
|
|
541
|
+
if (cellKind !== "empty")
|
|
542
|
+
last = x;
|
|
543
|
+
}
|
|
544
|
+
if (text !== "")
|
|
545
|
+
segs.push({ text, kind });
|
|
546
|
+
rows.push(cutToLast(segs, last));
|
|
547
|
+
}
|
|
548
|
+
return rows;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Drop the trailing empty run so rows carry no padding spaces. */
|
|
552
|
+
function cutToLast(segs, last) {
|
|
553
|
+
if (last < 0)
|
|
554
|
+
return [];
|
|
555
|
+
const out = [];
|
|
556
|
+
let used = 0;
|
|
557
|
+
for (const seg of segs) {
|
|
558
|
+
if (used > last)
|
|
559
|
+
break;
|
|
560
|
+
const width = measure(seg.text);
|
|
561
|
+
if (used + width > last + 1)
|
|
562
|
+
out.push({ text: seg.text.slice(0, last + 1 - used), kind: seg.kind });
|
|
563
|
+
else
|
|
564
|
+
out.push(seg);
|
|
565
|
+
used += width;
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
function cornersFor(shape) {
|
|
570
|
+
if (shape === "diamond")
|
|
571
|
+
return charset.cornerDiamond;
|
|
572
|
+
if (shape === "round" || shape === "circle" || shape === "hex")
|
|
573
|
+
return charset.cornerRound;
|
|
574
|
+
return charset.cornerRect;
|
|
575
|
+
}
|
|
576
|
+
/** Wrap each label into ≤ maxLines lines of ≤ cap-4 cells, then size its box. */
|
|
577
|
+
function sizeBoxes(g, cap, maxLines) {
|
|
578
|
+
const sizes = new Map();
|
|
579
|
+
const inner = Math.max(1, cap - 4);
|
|
580
|
+
for (const node of g.nodes) {
|
|
581
|
+
let lines = wrapCells(node.label || node.id, inner);
|
|
582
|
+
if (lines.length > maxLines) {
|
|
583
|
+
lines = lines.slice(0, maxLines);
|
|
584
|
+
lines[maxLines - 1] = clipCells(lines[maxLines - 1], inner);
|
|
585
|
+
}
|
|
586
|
+
const widest = lines.reduce((max, l) => Math.max(max, measure(l)), 1);
|
|
587
|
+
sizes.set(node.id, { lines, w: widest + 4, h: lines.length + 2 });
|
|
588
|
+
}
|
|
589
|
+
return sizes;
|
|
590
|
+
}
|
|
591
|
+
function toXY(p, rankVertical) {
|
|
592
|
+
return rankVertical ? { x: p.c, y: p.r } : { x: p.r, y: p.c };
|
|
593
|
+
}
|
|
594
|
+
function finalize(cv, meta) {
|
|
595
|
+
const rows = crop(cv.toRows());
|
|
596
|
+
const width = rows.reduce((max, segs) => Math.max(max, segs.reduce((s, seg) => s + measure(seg.text), 0)), 0);
|
|
597
|
+
return { ...meta, rows, width, height: rows.length, notes: dedupe(meta.notes).slice(0, 4) };
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Drop the unused gutters: leading/trailing blank rows, and the blank column
|
|
601
|
+
* offset shared by every row (a detour channel that was never needed costs
|
|
602
|
+
* the reader nothing).
|
|
603
|
+
*/
|
|
604
|
+
function crop(rows) {
|
|
605
|
+
const empty = (segs) => segs.length === 0;
|
|
606
|
+
let top = 0, bottom = rows.length;
|
|
607
|
+
while (top < bottom && empty(rows[top]))
|
|
608
|
+
top++;
|
|
609
|
+
while (bottom > top && empty(rows[bottom - 1]))
|
|
610
|
+
bottom--;
|
|
611
|
+
const body = rows.slice(top, bottom);
|
|
612
|
+
let indent = Number.POSITIVE_INFINITY;
|
|
613
|
+
for (const segs of body) {
|
|
614
|
+
const first = segs[0];
|
|
615
|
+
const lead = first !== undefined && first.kind === "empty" ? measure(first.text) : 0;
|
|
616
|
+
indent = Math.min(indent, lead);
|
|
617
|
+
}
|
|
618
|
+
if (!Number.isFinite(indent) || indent <= 0)
|
|
619
|
+
return body;
|
|
620
|
+
return body.map((segs) => {
|
|
621
|
+
const [first, ...rest] = segs;
|
|
622
|
+
if (first === undefined || first.kind !== "empty")
|
|
623
|
+
return segs;
|
|
624
|
+
const left = measure(first.text) - indent;
|
|
625
|
+
return left > 0 ? [{ text: " ".repeat(left), kind: "empty" }, ...rest] : rest;
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
function dedupe(items) {
|
|
629
|
+
return [...new Set(items.filter((s) => s !== ""))];
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* One band per rank. `BT`/`RL` are laid out as `TD`/`LR` on reversed edges and
|
|
633
|
+
* the arrowheads are moved back to the upstream end, so the picture still
|
|
634
|
+
* reads in the direction the author asked for.
|
|
635
|
+
*/
|
|
636
|
+
function layeredLayout(g, opts) {
|
|
637
|
+
const rankVertical = g.direction !== "LR" && g.direction !== "RL";
|
|
638
|
+
const flip = g.direction === "BT" || g.direction === "RL";
|
|
639
|
+
const graph = flip
|
|
640
|
+
? { ...g, edges: g.edges.map((e) => ({ ...e, from: e.to, to: e.from })) }
|
|
641
|
+
: g;
|
|
642
|
+
const { layers, back } = rankLayers(graph);
|
|
643
|
+
if (layers.length === 0)
|
|
644
|
+
return null;
|
|
645
|
+
// A cap that would split a WORD is rejected: boxes get taller, not chopped
|
|
646
|
+
// up. Chains with long single words fall through to strip layout, which has
|
|
647
|
+
// a wider cap and wraps at box level.
|
|
648
|
+
const wordsFit = graph.nodes.every((n) => {
|
|
649
|
+
const longest = (n.label || n.id).split(/\s+/).reduce((m, w) => Math.max(m, measure(w)), 0);
|
|
650
|
+
return longest <= opts.cap - 4;
|
|
651
|
+
});
|
|
652
|
+
if (!wordsFit)
|
|
653
|
+
return null;
|
|
654
|
+
const sizes = sizeBoxes(graph, opts.cap, 2);
|
|
655
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
656
|
+
const crossOf = (id) => (rankVertical ? sizes.get(id).w : sizes.get(id).h);
|
|
657
|
+
const rankOf = (id) => (rankVertical ? sizes.get(id).h : sizes.get(id).w);
|
|
658
|
+
const layerCross = layers.map((L) => L.reduce((s, id) => s + crossOf(id), 0) + GAP * (L.length - 1));
|
|
659
|
+
const innerCross = layerCross.reduce((m, v) => Math.max(m, v), 0);
|
|
660
|
+
// Side gutters: a long edge that has to skip a layer detours through them
|
|
661
|
+
// instead of being dropped (see the Z-route candidates in `routeEdge`).
|
|
662
|
+
const totalCross = innerCross + MARGIN * 2;
|
|
663
|
+
const boxes = new Map();
|
|
664
|
+
let rank = 0;
|
|
665
|
+
layers.forEach((L, li) => {
|
|
666
|
+
let c = MARGIN + Math.floor((innerCross - layerCross[li]) / 2);
|
|
667
|
+
for (const id of L) {
|
|
668
|
+
const size = sizes.get(id);
|
|
669
|
+
const node = byId.get(id);
|
|
670
|
+
const cr = crossOf(id);
|
|
671
|
+
const rr = rankOf(id);
|
|
672
|
+
const p = toXY({ r: rank, c }, rankVertical);
|
|
673
|
+
boxes.set(id, {
|
|
674
|
+
node,
|
|
675
|
+
lines: size.lines,
|
|
676
|
+
rect: { x: p.x, y: p.y, w: size.w, h: size.h },
|
|
677
|
+
rank0: rank,
|
|
678
|
+
rank1: rank + rr - 1,
|
|
679
|
+
cross0: c,
|
|
680
|
+
cross1: c + cr,
|
|
681
|
+
crossMid: c + Math.floor(cr / 2),
|
|
682
|
+
});
|
|
683
|
+
c += cr + GAP;
|
|
684
|
+
}
|
|
685
|
+
rank += L.reduce((max, id) => Math.max(max, rankOf(id)), 0) + BAND;
|
|
686
|
+
});
|
|
687
|
+
const totalRank = rank - BAND;
|
|
688
|
+
const width = rankVertical ? totalCross : totalRank;
|
|
689
|
+
const height = rankVertical ? totalRank : totalCross;
|
|
690
|
+
if (width < 1 || height < 1 || width > opts.width || height > opts.maxRows)
|
|
691
|
+
return null;
|
|
692
|
+
const cv = new Canvas(width, height);
|
|
693
|
+
for (const b of boxes.values())
|
|
694
|
+
cv.box(b.rect, b.lines, b.node.shape);
|
|
695
|
+
const notes = [];
|
|
696
|
+
const dropped = { labels: 0 };
|
|
697
|
+
const ctx = { rankVertical, flip, crossMax: totalCross - 1, dropped };
|
|
698
|
+
let drawn = 0;
|
|
699
|
+
for (const e of graph.edges) {
|
|
700
|
+
if (back.has(e))
|
|
701
|
+
continue;
|
|
702
|
+
const a = boxes.get(e.from);
|
|
703
|
+
const b = boxes.get(e.to);
|
|
704
|
+
if (!a || !b)
|
|
705
|
+
continue;
|
|
706
|
+
if (routeEdge(cv, a, b, e, ctx))
|
|
707
|
+
drawn++;
|
|
708
|
+
else
|
|
709
|
+
notes.push(`edge not drawn: ${a.node.label} → ${b.node.label}`);
|
|
710
|
+
}
|
|
711
|
+
if (dropped.labels > 0)
|
|
712
|
+
notes.push(`${dropped.labels} edge label(s) omitted (no room)`);
|
|
713
|
+
for (const e of back) {
|
|
714
|
+
const fromId = flip ? e.to : e.from;
|
|
715
|
+
const toId = flip ? e.from : e.to;
|
|
716
|
+
notes.push(`${charset.loop} cycle edge: ${byId.get(fromId)?.label ?? fromId} → ${byId.get(toId)?.label ?? toId}`);
|
|
717
|
+
}
|
|
718
|
+
return finalize(cv, {
|
|
719
|
+
mode: "layered",
|
|
720
|
+
direction: g.direction,
|
|
721
|
+
nodes: boxes.size,
|
|
722
|
+
edges: drawn,
|
|
723
|
+
notes: [...g.notes, ...notes],
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Candidate paths for `a → b`, best-looking first: straight shared channel,
|
|
728
|
+
* elbows next to either box, then Z routes through any column (including the
|
|
729
|
+
* side gutters, so long edges detour instead of disappearing).
|
|
730
|
+
*/
|
|
731
|
+
function edgePaths(a, b, from, to, crossMax) {
|
|
732
|
+
const candidates = [];
|
|
733
|
+
// 1. Straight channel: a column (TD) / row (LR) that touches both boxes,
|
|
734
|
+
// so the edge never has to jog. Nearest the target center first — when
|
|
735
|
+
// several edges enter the same box they take neighbouring ports instead
|
|
736
|
+
// of fighting over one cell.
|
|
737
|
+
const lo = Math.max(a.cross0, b.cross0), hi = Math.min(a.cross1, b.cross1) - 1;
|
|
738
|
+
if (lo <= hi) {
|
|
739
|
+
const ports = [];
|
|
740
|
+
for (let c = lo; c <= hi; c++)
|
|
741
|
+
ports.push(c);
|
|
742
|
+
ports.sort((p, q) => Math.abs(p - to.c) - Math.abs(q - to.c));
|
|
743
|
+
for (const c of ports.slice(0, 6))
|
|
744
|
+
candidates.push([{ r: from.r, c }, { r: to.r, c }]);
|
|
745
|
+
}
|
|
746
|
+
if (from.c === to.c)
|
|
747
|
+
candidates.push([from, to]);
|
|
748
|
+
// 2. Elbow just above the target, then 3. just below the source.
|
|
749
|
+
candidates.push([from, { r: to.r, c: from.c }, to]);
|
|
750
|
+
candidates.push([from, { r: from.r, c: to.c }, to]);
|
|
751
|
+
// 4. Z route through a channel column, nearest the target first.
|
|
752
|
+
const columns = [];
|
|
753
|
+
for (let c = 0; c <= crossMax; c++)
|
|
754
|
+
if (c !== from.c && c !== to.c)
|
|
755
|
+
columns.push(c);
|
|
756
|
+
columns.sort((p, q) => Math.abs(p - to.c) - Math.abs(q - to.c));
|
|
757
|
+
for (const c of columns.slice(0, 40))
|
|
758
|
+
candidates.push([from, { r: from.r, c }, { r: to.r, c }, to]);
|
|
759
|
+
return candidates;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Route one edge. `a` is the upstream box in layout space; `flip` means the
|
|
763
|
+
* real arrow points upstream, so the arrowhead goes at `a` instead of `b`.
|
|
764
|
+
*
|
|
765
|
+
* Candidate paths are tried clean-first (nothing else drawn on them), then
|
|
766
|
+
* again allowing merges, so a busy diagram still shows every edge instead of
|
|
767
|
+
* silently dropping it.
|
|
768
|
+
*/
|
|
769
|
+
function routeEdge(cv, a, b, edge, ctx) {
|
|
770
|
+
const { rankVertical, flip, crossMax, dropped } = ctx;
|
|
771
|
+
const from = { r: a.rank1 + 1, c: a.crossMid };
|
|
772
|
+
const to = { r: b.rank0 - 1, c: b.crossMid };
|
|
773
|
+
if (to.r < from.r)
|
|
774
|
+
return false;
|
|
775
|
+
const headAtB = !flip;
|
|
776
|
+
const dir = rankVertical
|
|
777
|
+
? (headAtB ? "down" : "up")
|
|
778
|
+
: (headAtB ? "right" : "left");
|
|
779
|
+
const candidates = edgePaths(a, b, from, to, crossMax);
|
|
780
|
+
for (const allowShared of [false, true]) {
|
|
781
|
+
for (const path of candidates) {
|
|
782
|
+
if (!pathClear(cv, path, rankVertical, allowShared))
|
|
783
|
+
continue;
|
|
784
|
+
for (let i = 0; i + 1 < path.length; i++)
|
|
785
|
+
drawAlong(cv, path[i], path[i + 1], edge.style, rankVertical);
|
|
786
|
+
const head = toXY(headAtB ? path[path.length - 1] : path[0], rankVertical);
|
|
787
|
+
cv.arrow(head.x, head.y, dir);
|
|
788
|
+
placeLabel(cv, path, edge.label, rankVertical, dropped);
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
/** Every segment of a candidate path is drawable. */
|
|
795
|
+
function pathClear(cv, path, rankVertical, allowShared) {
|
|
796
|
+
for (let i = 0; i + 1 < path.length; i++) {
|
|
797
|
+
const p = path[i], q = path[i + 1];
|
|
798
|
+
if (p.c === q.c) {
|
|
799
|
+
if (!cv.freeRank(p.c, p.r, q.r, rankVertical, allowShared))
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
else if (p.r === q.r) {
|
|
803
|
+
if (!cv.freeCross(p.r, p.c, q.c, rankVertical, allowShared))
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
return false; // not axis-aligned
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
/** Draw one axis-aligned abstract segment (rank or cross direction). */
|
|
813
|
+
function drawAlong(cv, p, q, style, rankVertical) {
|
|
814
|
+
const A = toXY(p, rankVertical);
|
|
815
|
+
const B = toXY(q, rankVertical);
|
|
816
|
+
if (A.x === B.x)
|
|
817
|
+
cv.vLine(A.x, A.y, B.y, style);
|
|
818
|
+
else
|
|
819
|
+
cv.hLine(A.y, A.x, B.x, style);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Edge labels: prefer the longest horizontal run (the label sits on the line,
|
|
823
|
+
* like mermaid does); otherwise put it beside the longest vertical run, in the
|
|
824
|
+
* routing band that is free by construction. `dropped` counts labels that had
|
|
825
|
+
* no room, so the caller can say so instead of losing them silently.
|
|
826
|
+
*/
|
|
827
|
+
function placeLabel(cv, path, label, rankVertical, dropped) {
|
|
828
|
+
if (!label)
|
|
829
|
+
return;
|
|
830
|
+
const runs = [];
|
|
831
|
+
for (let i = 0; i + 1 < path.length; i++) {
|
|
832
|
+
const p = path[i], q = path[i + 1];
|
|
833
|
+
const A = toXY(p, rankVertical), B = toXY(q, rankVertical);
|
|
834
|
+
const horizontal = A.y === B.y;
|
|
835
|
+
const len = horizontal ? Math.abs(B.x - A.x) + 1 : Math.abs(B.y - A.y) + 1;
|
|
836
|
+
runs.push({ p, q, len, horizontal });
|
|
837
|
+
}
|
|
838
|
+
const byLen = (a, b) => b.len - a.len;
|
|
839
|
+
const labelW = measure(label);
|
|
840
|
+
const ordered = runs.slice().sort(byLen);
|
|
841
|
+
// Longest suitable run wins: the text stays away from the box the edge just
|
|
842
|
+
// left. Horizontal runs carry the label on the line, vertical runs carry it
|
|
843
|
+
// beside the line (mermaid's own convention).
|
|
844
|
+
const onLine = ordered.find((r) => r.horizontal && r.len >= labelW + 2);
|
|
845
|
+
if (onLine) {
|
|
846
|
+
const A = toXY(onLine.p, rankVertical), B = toXY(onLine.q, rankVertical);
|
|
847
|
+
const [x1, x2] = A.x <= B.x ? [A.x, B.x] : [B.x, A.x];
|
|
848
|
+
cv.text(x1 + Math.floor((x2 - x1 + 1 - labelW) / 2), A.y, label, "elabel", true);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const beside = ordered.find((r) => !r.horizontal);
|
|
852
|
+
if (beside) {
|
|
853
|
+
const A = toXY(beside.p, rankVertical), B = toXY(beside.q, rankVertical);
|
|
854
|
+
const midY = A.y + Math.floor((B.y - A.y) / 2);
|
|
855
|
+
if (cv.freeH(midY, A.x + 1, A.x + labelW))
|
|
856
|
+
cv.text(A.x + 1, midY, label, "elabel");
|
|
857
|
+
else if (cv.freeH(midY, A.x - labelW, A.x - 1))
|
|
858
|
+
cv.text(A.x - labelW, midY, label, "elabel");
|
|
859
|
+
else
|
|
860
|
+
dropped.labels++;
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
dropped.labels++;
|
|
864
|
+
}
|
|
865
|
+
// ── 7. Strip layout (long chains) ───────────────────────────────────────────
|
|
866
|
+
/**
|
|
867
|
+
* Flow the topological order into wrapped rows of boxes. Chosen when layered
|
|
868
|
+
* placement cannot fit: a 20-node version timeline becomes 3 rows of boxes
|
|
869
|
+
* instead of 20 rows of boxes.
|
|
870
|
+
*/
|
|
871
|
+
function stripLayout(g, opts) {
|
|
872
|
+
const { layers, back } = rankLayers(g);
|
|
873
|
+
if (layers.length === 0)
|
|
874
|
+
return null;
|
|
875
|
+
const sizes = sizeBoxes(g, opts.cap, 3);
|
|
876
|
+
const order = [];
|
|
877
|
+
for (const L of layers)
|
|
878
|
+
order.push(...L);
|
|
879
|
+
const byId = new Map(g.nodes.map((n) => [n.id, n]));
|
|
880
|
+
const next = new Map();
|
|
881
|
+
for (const e of g.edges) {
|
|
882
|
+
if (back.has(e))
|
|
883
|
+
continue;
|
|
884
|
+
if (!next.has(e.from))
|
|
885
|
+
next.set(e.from, { id: e.to, style: e.style, label: e.label });
|
|
886
|
+
}
|
|
887
|
+
const rows = [];
|
|
888
|
+
let current = [];
|
|
889
|
+
let x = 0;
|
|
890
|
+
for (const id of order) {
|
|
891
|
+
const size = sizes.get(id);
|
|
892
|
+
const prev = current[current.length - 1];
|
|
893
|
+
const linked = prev !== undefined && next.get(prev.id)?.id === id;
|
|
894
|
+
const gap = linked ? STRIP_GAP : GAP;
|
|
895
|
+
if (current.length > 0 && x + gap + size.w > opts.width) {
|
|
896
|
+
rows.push(current);
|
|
897
|
+
current = [];
|
|
898
|
+
x = 0;
|
|
899
|
+
}
|
|
900
|
+
current.push({
|
|
901
|
+
id, row: rows.length, lines: size.lines, shape: byId.get(id).shape,
|
|
902
|
+
rect: { x, y: 0, w: size.w, h: size.h },
|
|
903
|
+
});
|
|
904
|
+
x += gap + size.w;
|
|
905
|
+
}
|
|
906
|
+
if (current.length > 0)
|
|
907
|
+
rows.push(current);
|
|
908
|
+
// Stack rows, vertically centering each box inside its row band.
|
|
909
|
+
let y = 0;
|
|
910
|
+
for (const row of rows) {
|
|
911
|
+
const h = row.reduce((max, p) => Math.max(max, p.rect.h), 0);
|
|
912
|
+
for (const p of row)
|
|
913
|
+
p.rect.y = y + Math.floor((h - p.rect.h) / 2);
|
|
914
|
+
y += h + STRIP_BAND;
|
|
915
|
+
}
|
|
916
|
+
const height = y - STRIP_BAND;
|
|
917
|
+
const width = rows.reduce((max, row) => {
|
|
918
|
+
const last = row[row.length - 1];
|
|
919
|
+
return Math.max(max, last ? last.rect.x + last.rect.w : 0);
|
|
920
|
+
}, 0);
|
|
921
|
+
if (width < 1 || height < 1 || width > opts.width || height > opts.maxRows)
|
|
922
|
+
return null;
|
|
923
|
+
const cv = new Canvas(width, height);
|
|
924
|
+
const placed = new Map();
|
|
925
|
+
for (const row of rows)
|
|
926
|
+
for (const p of row) {
|
|
927
|
+
placed.set(p.id, p);
|
|
928
|
+
cv.box(p.rect, p.lines, p.shape);
|
|
929
|
+
}
|
|
930
|
+
const notes = [];
|
|
931
|
+
let drawn = 0;
|
|
932
|
+
let labelsDropped = 0;
|
|
933
|
+
for (let i = 0; i + 1 < order.length; i++) {
|
|
934
|
+
const a = placed.get(order[i]);
|
|
935
|
+
const b = placed.get(order[i + 1]);
|
|
936
|
+
const link = next.get(order[i]);
|
|
937
|
+
if (!a || !b || !link || link.id !== order[i + 1])
|
|
938
|
+
continue;
|
|
939
|
+
if (a.row === b.row) {
|
|
940
|
+
// Same row: `──▶` across the gap, on the line joining the two box middles.
|
|
941
|
+
const midY = Math.floor((a.rect.y + Math.floor(a.rect.h / 2) + b.rect.y + Math.floor(b.rect.h / 2)) / 2);
|
|
942
|
+
const from = a.rect.x + a.rect.w;
|
|
943
|
+
const to = b.rect.x - 1;
|
|
944
|
+
cv.hLine(midY, from, to - 1, link.style);
|
|
945
|
+
cv.arrow(to, midY, "right");
|
|
946
|
+
if (link.label === "" || to - from >= measure(link.label) + 2) {
|
|
947
|
+
const start = from + Math.floor((to - from - measure(link.label)) / 2);
|
|
948
|
+
cv.text(start, midY - 1, link.label, "elabel");
|
|
949
|
+
}
|
|
950
|
+
else
|
|
951
|
+
labelsDropped++;
|
|
952
|
+
drawn++;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
// Wrapped to the next row: down out of `a`, across the band, down into `b`.
|
|
956
|
+
const sx = a.rect.x + Math.floor(a.rect.w / 2);
|
|
957
|
+
const tx = b.rect.x + Math.floor(b.rect.w / 2);
|
|
958
|
+
const headY = Math.max(a.rect.y + a.rect.h, b.rect.y - 1);
|
|
959
|
+
const runY = Math.max(a.rect.y + a.rect.h, headY - 1);
|
|
960
|
+
cv.line(sx, runY, UP | LEFT, link.style);
|
|
961
|
+
cv.hLine(runY, tx + 1, sx - 1, link.style);
|
|
962
|
+
cv.line(tx, runY, DOWN | RIGHT, link.style);
|
|
963
|
+
cv.arrow(tx, headY, "down");
|
|
964
|
+
drawn++;
|
|
965
|
+
}
|
|
966
|
+
if (labelsDropped > 0)
|
|
967
|
+
notes.push(`${labelsDropped} edge label(s) omitted (no room)`);
|
|
968
|
+
for (const e of g.edges)
|
|
969
|
+
if (back.has(e)) {
|
|
970
|
+
notes.push(`${charset.loop} cycle edge: ${byId.get(e.from)?.label ?? e.from} → ${byId.get(e.to)?.label ?? e.to}`);
|
|
971
|
+
}
|
|
972
|
+
const skipped = g.edges.length - back.size - drawn;
|
|
973
|
+
if (skipped > 0)
|
|
974
|
+
notes.push(`${skipped} side edge(s) not drawn (chain order only)`);
|
|
975
|
+
return finalize(cv, {
|
|
976
|
+
mode: "strip",
|
|
977
|
+
direction: "chain",
|
|
978
|
+
nodes: placed.size,
|
|
979
|
+
edges: drawn,
|
|
980
|
+
notes: [...g.notes, ...notes],
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
// ── 8. Entry point ──────────────────────────────────────────────────────────
|
|
984
|
+
const cache = new Map();
|
|
985
|
+
const CACHE_MAX = 24;
|
|
986
|
+
/**
|
|
987
|
+
* Render a mermaid flowchart into a raster, or null when the source is not a
|
|
988
|
+
* flowchart, is too large, or cannot fit the pane. Cached per source+width so
|
|
989
|
+
* the row estimator and the renderer share one layout pass.
|
|
990
|
+
*/
|
|
991
|
+
export function renderFlowchart(lines, width, maxRows = 40) {
|
|
992
|
+
const key = `${width}x${maxRows}\u0000${lines.join("\n")}`;
|
|
993
|
+
const hit = cache.get(key);
|
|
994
|
+
if (hit !== undefined)
|
|
995
|
+
return hit;
|
|
996
|
+
let result = null;
|
|
997
|
+
try {
|
|
998
|
+
const graph = parseFlowchart(lines);
|
|
999
|
+
if (graph) {
|
|
1000
|
+
for (const cap of LAYERED_CAPS) {
|
|
1001
|
+
result = layeredLayout(graph, { width, maxRows, cap });
|
|
1002
|
+
if (result)
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
if (!result) {
|
|
1006
|
+
for (const cap of STRIP_CAPS) {
|
|
1007
|
+
result = stripLayout(graph, { width, maxRows, cap });
|
|
1008
|
+
if (result)
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
result = null; // caller falls back to the summary box
|
|
1016
|
+
}
|
|
1017
|
+
if (cache.size >= CACHE_MAX)
|
|
1018
|
+
cache.delete(cache.keys().next().value ?? "");
|
|
1019
|
+
cache.set(key, result);
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|