@chatpanel/events 0.33.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flowchart.js CHANGED
@@ -6,9 +6,9 @@
6
6
  // treats first-paint weight as a release gate — and for a renderer we want to unit-test.
7
7
  //
8
8
  // So this is a focused renderer for the shape models actually emit: `flowchart TB/LR` with
9
- // labelled nodes, edges and classDef styling. It is PURE (string in, string out), so it is
10
- // testable without a browser and runs identically in the extension, a desktop app or a
11
- // mobile client — which is why it lives in the shared package rather than in one client.
9
+ // labelled nodes, edges, subgraphs and classDef styling. It is PURE (string in, string out),
10
+ // so it is testable without a browser and runs identically in the extension, a desktop app or
11
+ // a mobile client — which is why it lives in the shared package rather than in one client.
12
12
  //
13
13
  // SAFETY: the output is only ever shown through an <img src="data:image/svg+xml,…">, which
14
14
  // loads SVG in restricted mode (no scripts, no external fetches). Every piece of model text
@@ -16,12 +16,23 @@
16
16
  //
17
17
  // Anything it can't parse returns null, and the caller keeps showing the code block.
18
18
 
19
- const NODE_SHAPES = {
20
- '[': ']', // rect
21
- '(': ')', // rounded
22
- '{': '}', // diamond → drawn as a rounded rect with a tint; shape fidelity is not the point
23
- '>': ']', // asymmetric
24
- };
19
+ // Node shapes, LONGEST OPENER FIRST — `[(` has to be tried before `[`, or a database node
20
+ // keeps its own bracket in the label and renders as `"etcd…")`. The value is the closer and
21
+ // the shape name; shape drives the corner radius, not a different silhouette (a hexagon that
22
+ // is really a rounded rect still reads correctly; a label with a stray `)` does not).
23
+ const NODE_SHAPES = [
24
+ ['[[', ']]', 'rect'], // subroutine
25
+ ['[(', ')]', 'round'], // database / cylinder
26
+ ['((', '))', 'pill'], // circle
27
+ ['([', '])', 'pill'], // stadium
28
+ ['{{', '}}', 'rect'], // hexagon
29
+ ['[/', '/]', 'rect'], // parallelogram
30
+ ['[\\', '\\]', 'rect'],
31
+ ['[', ']', 'rect'],
32
+ ['(', ')', 'round'],
33
+ ['{', '}', 'rect'], // diamond → a rounded rect with a tint; shape fidelity isn't the point
34
+ ['>', ']', 'rect'], // asymmetric
35
+ ];
25
36
 
26
37
  const DEFAULT_PALETTE = {
27
38
  fill: '#f8fafc', stroke: '#cbd5e1', color: '#1e293b',
@@ -40,22 +51,57 @@ function esc(s) {
40
51
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
41
52
  }
42
53
 
43
- // Split a label into display lines: explicit <br/> first, then greedy wrap on words.
44
- function wrapLabel(raw, maxChars = 22) {
54
+ // Labels carry inline HTML — models write `Name<br/><i>what it does</i>` constantly, and a
55
+ // renderer that only knows <br/> prints the tags themselves, which a reader reads as a bug.
56
+ // A whole line wrapped in one emphasis tag becomes a styled line; a formatting tag anywhere
57
+ // else is dropped, because half-styled runs would need per-run text layout for very little.
58
+ //
59
+ // ONLY the formatting tags below are dropped. Anything else a label contains is left exactly
60
+ // as the model wrote it and escaped at render time, so `<script>` still shows up as the text
61
+ // `<script>` rather than quietly vanishing — a renderer that eats unknown markup hides content
62
+ // as readily as it hides an attack.
63
+ const FORMAT_TAGS = /<\/?(?:i|em|b|strong|u|code|span|small|sup|sub)\s*\/?>/gi;
64
+ const EMPH = [
65
+ [/^<(i|em)>([\s\S]*)<\/\1>$/i, 'italic'],
66
+ [/^<(b|strong)>([\s\S]*)<\/\1>$/i, 'bold'],
67
+ ];
68
+ function styleOfPart(raw) {
69
+ let text = String(raw || '').trim();
70
+ let italic = false;
71
+ let bold = false;
72
+ // Peel repeatedly so `<b><i>x</i></b>` picks up both.
73
+ for (let n = 0; n < 3; n++) {
74
+ let hit = false;
75
+ for (const [re, kind] of EMPH) {
76
+ const m = re.exec(text);
77
+ if (!m) continue;
78
+ text = m[2].trim();
79
+ if (kind === 'italic') italic = true; else bold = true;
80
+ hit = true;
81
+ }
82
+ if (!hit) break;
83
+ }
84
+ return { text: text.replace(FORMAT_TAGS, ' ').trim(), italic, bold };
85
+ }
86
+
87
+ // Split a label into display lines: explicit <br/> first, then greedy wrap on words. Each line
88
+ // carries its own emphasis so the renderer can set font-style/weight per <text> element.
89
+ export function wrapLabel(raw, maxChars = 22) {
45
90
  const parts = String(raw || '').split(/<br\s*\/?>/i);
46
91
  const lines = [];
47
92
  for (const part of parts) {
48
- const words = part.trim().split(/\s+/).filter(Boolean);
93
+ const { text, italic, bold } = styleOfPart(part);
94
+ const words = text.split(/\s+/).filter(Boolean);
49
95
  if (!words.length) continue;
50
96
  let line = '';
51
97
  for (const w of words) {
52
98
  if (!line) line = w;
53
99
  else if ((line + ' ' + w).length <= maxChars) line += ' ' + w;
54
- else { lines.push(line); line = w; }
100
+ else { lines.push({ text: line, italic, bold }); line = w; }
55
101
  }
56
- if (line) lines.push(line);
102
+ if (line) lines.push({ text: line, italic, bold });
57
103
  }
58
- return lines.length ? lines : [''];
104
+ return lines.length ? lines : [{ text: '', italic: false, bold: false }];
59
105
  }
60
106
 
61
107
  // Strip mermaid's quoting around a label.
@@ -65,14 +111,24 @@ function cleanLabel(s) {
65
111
  return t.trim();
66
112
  }
67
113
 
68
- // Split one line into node tokens + the edge labels between them, honouring brackets and
69
- // quotes so an arrow inside a label can't be read as a connector. Returns null when the line
70
- // holds no top-level edge operator. Handles chains: A --> B -->|yes| C.
71
- const EDGE_OPS = ['-.->', '==>', '===>', '-->', '--->', '---', '-.-'];
114
+ // ONE connector, matched at the current scan position:
115
+ // an optional opening head `<`, a run of -/=/. , an optional mid-label (`A -- yes --> B`),
116
+ // and an optional closing head `>` / `o` / `x`.
117
+ // The head characters are excluded from the body and from the mid-label, so `-->` can never be
118
+ // read as a body and `A --> B` can never swallow `B` as a label.
119
+ const CONNECTOR = /^(<?)([-=.]{2,})(?:[ \t]*([^-=<>|\n]{1,80}?)[ \t]*([-=.]{2,}))?([>ox]?)/;
120
+
121
+ /**
122
+ * Split one line into node tokens + the edge labels between them, honouring brackets and
123
+ * quotes so an arrow inside a label can't be read as a connector. Returns null when the line
124
+ * holds no top-level edge operator. Handles chains (`A --> B -->|yes| C`), bidirectional
125
+ * links (`A <--> B`) and mid-line labels (`A -- yes --> B`).
126
+ */
72
127
  export function splitEdgeChain(line) {
73
128
  const s = String(line || '');
74
129
  const parts = [];
75
130
  const labels = [];
131
+ const bidir = [];
76
132
  let buf = '';
77
133
  let depth = 0;
78
134
  let quote = '';
@@ -82,16 +138,17 @@ export function splitEdgeChain(line) {
82
138
  if (ch === '"' || ch === "'") { quote = ch; buf += ch; continue; }
83
139
  if (ch === '[' || ch === '(' || ch === '{') { depth++; buf += ch; continue; }
84
140
  if (ch === ']' || ch === ')' || ch === '}') { depth = Math.max(0, depth - 1); buf += ch; continue; }
85
- if (depth === 0 && (ch === '-' || ch === '=')) {
86
- const op = EDGE_OPS.find((o) => s.startsWith(o, i));
87
- if (op) {
88
- i += op.length - 1;
89
- // An optional |label| directly after the arrow.
90
- let label = '';
141
+ if (depth === 0 && (ch === '-' || ch === '=' || ch === '<')) {
142
+ const m = CONNECTOR.exec(s.slice(i));
143
+ // A `<` only opens a connector when a real arrow body follows it.
144
+ if (m && (ch !== '<' || m[1])) {
145
+ i += m[0].length - 1;
146
+ let label = (m[3] || '').trim();
147
+ // An explicit |label| directly after the arrow wins over a mid-line one.
91
148
  const rest = s.slice(i + 1);
92
149
  const lm = rest.match(/^\s*\|([^|]*)\|/);
93
150
  if (lm) { label = lm[1]; i += lm[0].length; }
94
- parts.push(buf); labels.push(label); buf = '';
151
+ parts.push(buf); labels.push(label); bidir.push(m[1] === '<'); buf = '';
95
152
  continue;
96
153
  }
97
154
  }
@@ -99,11 +156,11 @@ export function splitEdgeChain(line) {
99
156
  }
100
157
  parts.push(buf);
101
158
  if (parts.length < 2) return null;
102
- return { parts: parts.map((p) => p.trim().replace(/;$/, '')).filter(Boolean), labels };
159
+ return { parts: parts.map((p) => p.trim().replace(/;$/, '')).filter(Boolean), labels, bidir };
103
160
  }
104
161
 
105
162
  /**
106
- * Parse a mermaid flowchart into { dir, nodes: Map, edges: [] , classes }.
163
+ * Parse a mermaid flowchart into { dir, nodes, edges, classDefs, nodeClass, groups }.
107
164
  * Returns null when the text isn't a flowchart we handle.
108
165
  */
109
166
  export function parseFlowchart(text) {
@@ -112,38 +169,78 @@ export function parseFlowchart(text) {
112
169
  if (!header) return null;
113
170
  const dir = header[1].toUpperCase();
114
171
 
115
- const nodes = new Map(); // id -> { id, label }
116
- const edges = []; // { from, to, label }
172
+ const nodes = new Map(); // id -> { id, label, shape }
173
+ const edges = []; // { from, to, label, both }
117
174
  const classDefs = new Map();
118
175
  const nodeClass = new Map();
119
-
120
- const ensure = (id, label) => {
176
+ const groups = []; // [{ id, title, members: [] }] — subgraphs, in source order
177
+ const stack = []; // open subgraphs; the innermost one owns a node
178
+ const memberOf = new Map();
179
+
180
+ const claim = (id) => {
181
+ const g = stack[stack.length - 1];
182
+ if (!g || memberOf.has(id)) return;
183
+ memberOf.set(id, g);
184
+ g.members.push(id);
185
+ };
186
+ const ensure = (id, label, shape) => {
121
187
  const key = String(id).trim();
122
188
  if (!key) return null;
123
- if (!nodes.has(key)) nodes.set(key, { id: key, label: label != null ? label : key });
124
- else if (label != null) nodes.get(key).label = label;
189
+ if (!nodes.has(key)) nodes.set(key, { id: key, label: label != null ? label : key, shape: shape || 'rect' });
190
+ else {
191
+ const n = nodes.get(key);
192
+ if (label != null) n.label = label;
193
+ if (shape) n.shape = shape;
194
+ }
195
+ claim(key);
125
196
  return nodes.get(key);
126
197
  };
127
198
 
128
- // `ID["label"]` / `ID(label)` / `ID{label}` → id + label, else a bare id.
199
+ // `ID["label"]` / `ID(label)` / `ID[("label")]` / `ID{label}` → id + label + shape, else a
200
+ // bare id. The label runs to the LAST closer, so brackets inside a label survive.
129
201
  function readNodeToken(token) {
130
202
  const t = token.trim();
131
203
  if (!t) return null;
132
204
  const m = t.match(/^([A-Za-z0-9_.-]+)\s*([[({>])([\s\S]*)$/);
133
205
  if (!m) return ensure(t.replace(/^[[(]|[\])]$/g, ''), null);
134
- const [, id, open, rest] = m;
135
- const close = NODE_SHAPES[open] || ']';
136
- // Take everything up to the LAST closing bracket, so labels may contain brackets.
137
- const end = rest.lastIndexOf(close);
138
- const label = end >= 0 ? rest.slice(0, end) : rest;
139
- return ensure(id, cleanLabel(label.replace(/^[([{>]+/, '')));
206
+ const id = m[1];
207
+ const after = t.slice(id.length).trim();
208
+ for (const [open, close, shape] of NODE_SHAPES) {
209
+ if (!after.startsWith(open)) continue;
210
+ const rest = after.slice(open.length);
211
+ const end = rest.lastIndexOf(close);
212
+ return ensure(id, cleanLabel(end >= 0 ? rest.slice(0, end) : rest), shape);
213
+ }
214
+ return ensure(id, null);
140
215
  }
141
216
 
142
- for (let raw of src.split('\n')) {
217
+ for (const raw of src.split('\n')) {
143
218
  const line = raw.trim();
144
219
  if (!line || /^%%/.test(line)) continue; // comment
145
220
  if (/^(?:flowchart|graph)\b/i.test(line)) continue; // header
146
- if (/^(?:subgraph|end)\b/i.test(line)) continue; // subgraphs: flattened, not drawn
221
+ if (/^direction\b/i.test(line)) continue; // per-subgraph direction: not modelled
222
+
223
+ // subgraph CP["Control Plane"] / subgraph CP [Control Plane] / subgraph Control Plane
224
+ const sg = line.match(/^subgraph\b\s*(.*)$/i);
225
+ if (sg) {
226
+ const rest = sg[1].trim();
227
+ const withLabel = rest.match(/^([A-Za-z0-9_.-]+)\s*([[("])([\s\S]*)$/);
228
+ let id = rest || `sub${groups.length + 1}`;
229
+ let title = rest;
230
+ if (withLabel) {
231
+ id = withLabel[1];
232
+ const open = withLabel[2];
233
+ const close = open === '[' ? ']' : open === '(' ? ')' : '"';
234
+ const body = withLabel[3];
235
+ const end = body.lastIndexOf(close);
236
+ title = cleanLabel(end >= 0 ? body.slice(0, end) : body);
237
+ }
238
+ const g = { id, title: cleanLabel(title), members: [] };
239
+ groups.push(g);
240
+ stack.push(g);
241
+ continue;
242
+ }
243
+ if (/^end\b/i.test(line)) { stack.pop(); continue; }
147
244
 
148
245
  // classDef name fill:#fff,color:#000,stroke:#ccc
149
246
  const cd = line.match(/^classDef\s+([A-Za-z0-9_-]+)\s+(.+?);?$/i);
@@ -172,35 +269,46 @@ export function parseFlowchart(text) {
172
269
  let prev = readNodeToken(seg.parts[0]);
173
270
  for (let k = 1; k < seg.parts.length; k++) {
174
271
  const next = readNodeToken(seg.parts[k]);
175
- if (prev && next) edges.push({ from: prev.id, to: next.id, label: cleanLabel(seg.labels[k - 1] || '') });
272
+ if (prev && next) {
273
+ edges.push({
274
+ from: prev.id, to: next.id,
275
+ label: cleanLabel(seg.labels[k - 1] || ''),
276
+ both: !!seg.bidir[k - 1],
277
+ });
278
+ }
176
279
  prev = next;
177
280
  }
178
281
  continue;
179
282
  }
180
- // A standalone node definition.
283
+ // A standalone node definition, or a bare id inside a subgraph — which is how mermaid
284
+ // says "this existing node belongs to this group".
181
285
  if (/^[A-Za-z0-9_.-]+\s*[[({>]/.test(line)) { readNodeToken(line); continue; }
286
+ if (stack.length && /^[A-Za-z0-9_.-]+;?$/.test(line)) { ensure(line.replace(/;$/, ''), null); continue; }
182
287
  }
183
288
 
184
289
  if (!nodes.size) return null;
185
- return { dir, nodes, edges, classDefs, nodeClass };
290
+ return { dir, nodes, edges, classDefs, nodeClass, groups: groups.filter((g) => g.members.length) };
186
291
  }
187
292
 
188
- // Longest-path ranking: a node sits one level below its deepest parent. Cycles are broken by
189
- // the visited guard, so a malformed graph still lays out instead of hanging.
190
- function rankNodes(nodes, edges) {
293
+ // Longest-path ranking over a SUBSET of the graph: a node sits one level below its deepest
294
+ // parent. Cycles are broken by the visited guard, so a malformed graph still lays out instead
295
+ // of hanging. Taking ids rather than the whole node map is what lets one subgraph be laid out
296
+ // on its own, by the same code that lays out the chart as a whole.
297
+ function rankNodes(ids, edges) {
298
+ const set = new Set(ids);
191
299
  const parents = new Map();
192
300
  const children = new Map();
193
- for (const id of nodes.keys()) { parents.set(id, []); children.set(id, []); }
301
+ for (const id of set) { parents.set(id, []); children.set(id, []); }
194
302
  for (const e of edges) {
195
- if (!nodes.has(e.from) || !nodes.has(e.to)) continue;
303
+ if (!set.has(e.from) || !set.has(e.to)) continue;
196
304
  parents.get(e.to).push(e.from);
197
305
  children.get(e.from).push(e.to);
198
306
  }
199
307
  const rank = new Map();
200
- const roots = [...nodes.keys()].filter((id) => parents.get(id).length === 0);
201
- const queue = roots.length ? [...roots] : [nodes.keys().next().value];
308
+ const roots = [...set].filter((id) => parents.get(id).length === 0);
309
+ const queue = roots.length ? [...roots] : [set.values().next().value];
202
310
  for (const r of queue) rank.set(r, 0);
203
- let guard = nodes.size * 4;
311
+ let guard = set.size * 4;
204
312
  while (queue.length && guard-- > 0) {
205
313
  const id = queue.shift();
206
314
  const r = rank.get(id) || 0;
@@ -209,7 +317,7 @@ function rankNodes(nodes, edges) {
209
317
  if ((rank.get(c) ?? -1) < want) { rank.set(c, want); queue.push(c); }
210
318
  }
211
319
  }
212
- for (const id of nodes.keys()) if (!rank.has(id)) rank.set(id, 0);
320
+ for (const id of set) if (!rank.has(id)) rank.set(id, 0);
213
321
  return { rank, parents, children };
214
322
  }
215
323
 
@@ -217,20 +325,25 @@ const CHAR_W = 7.1; // ~13px system-ui average advance; good enough for box si
217
325
  const LINE_H = 18;
218
326
  const PAD_X = 14;
219
327
  const PAD_Y = 12;
328
+ const GROUP_PAD = 16; // breathing room between a subgraph's frame and its nodes
329
+ const GROUP_TITLE_H = 28; // the band the subgraph's own name sits in
220
330
 
221
331
  function measure(labelLines) {
222
- const w = Math.max(...labelLines.map((l) => l.length)) * CHAR_W + PAD_X * 2;
332
+ const w = Math.max(...labelLines.map((l) => l.text.length * (l.bold ? 1.07 : 1))) * CHAR_W + PAD_X * 2;
223
333
  const h = labelLines.length * LINE_H + PAD_Y * 2;
224
334
  return { w: Math.max(72, Math.round(w)), h: Math.round(h) };
225
335
  }
226
336
 
227
- /** Lay the graph out on a grid: rank → row (TB) or column (LR). Pure geometry. */
228
- export function layoutFlowchart(graph, { dir = graph.dir, gapMain = 56, gapCross = 22 } = {}) {
229
- const { nodes, edges } = graph;
230
- const { rank, parents, children } = rankNodes(nodes, edges);
231
-
337
+ /**
338
+ * Place a set of ids on a grid: rank → row (TB) or column (LR). PURE geometry over sizes, so
339
+ * it serves both levels of a clustered chart — the nodes inside one subgraph, and the
340
+ * subgraphs themselves as blocks.
341
+ */
342
+ function placeGrid(ids, edges, sizeOf, { horizontal, gapMain = 56, gapCross = 22 } = {}) {
343
+ const { rank, parents } = rankNodes(ids, edges);
232
344
  const byRank = new Map();
233
- for (const [id, r] of rank) {
345
+ for (const id of ids) {
346
+ const r = rank.get(id) || 0;
234
347
  if (!byRank.has(r)) byRank.set(r, []);
235
348
  byRank.get(r).push(id);
236
349
  }
@@ -239,32 +352,24 @@ export function layoutFlowchart(graph, { dir = graph.dir, gapMain = 56, gapCross
239
352
  const order = new Map();
240
353
  const ranks = [...byRank.keys()].sort((a, b) => a - b);
241
354
  for (const r of ranks) {
242
- const ids = byRank.get(r);
243
- if (r === ranks[0]) { ids.forEach((id, i) => order.set(id, i)); continue; }
244
- ids.sort((a, b) => {
355
+ const list = byRank.get(r);
356
+ if (r === ranks[0]) { list.forEach((id, i) => order.set(id, i)); continue; }
357
+ list.sort((a, b) => {
245
358
  const pa = parents.get(a).map((p) => order.get(p) ?? 0);
246
359
  const pb = parents.get(b).map((p) => order.get(p) ?? 0);
247
360
  const ma = pa.length ? pa.reduce((x, y) => x + y, 0) / pa.length : 0;
248
361
  const mb = pb.length ? pb.reduce((x, y) => x + y, 0) / pb.length : 0;
249
362
  return ma - mb;
250
363
  });
251
- ids.forEach((id, i) => order.set(id, i));
252
- }
253
-
254
- const horizontal = dir === 'LR' || dir === 'RL';
255
- const box = new Map();
256
- for (const [id, n] of nodes) {
257
- const lines = wrapLabel(n.label);
258
- const { w, h } = measure(lines);
259
- box.set(id, { id, lines, w, h, rank: rank.get(id) || 0 });
364
+ list.forEach((id, i) => order.set(id, i));
260
365
  }
261
366
 
262
367
  // Cross-axis extent of each rank, then centre every rank in the widest one.
263
368
  const rankExtent = new Map();
264
369
  for (const r of ranks) {
265
- const ids = byRank.get(r);
266
- const total = ids.reduce((sum, id) => sum + (horizontal ? box.get(id).h : box.get(id).w), 0)
267
- + gapCross * Math.max(0, ids.length - 1);
370
+ const list = byRank.get(r);
371
+ const total = list.reduce((sum, id) => sum + (horizontal ? sizeOf(id).h : sizeOf(id).w), 0)
372
+ + gapCross * Math.max(0, list.length - 1);
268
373
  rankExtent.set(r, total);
269
374
  }
270
375
  const maxExtent = Math.max(...rankExtent.values(), 1);
@@ -274,24 +379,139 @@ export function layoutFlowchart(graph, { dir = graph.dir, gapMain = 56, gapCross
274
379
  let main = 0;
275
380
  for (const r of ranks) {
276
381
  mainOffset.set(r, main);
277
- const size = Math.max(...byRank.get(r).map((id) => (horizontal ? box.get(id).w : box.get(id).h)));
382
+ const size = Math.max(...byRank.get(r).map((id) => (horizontal ? sizeOf(id).w : sizeOf(id).h)));
278
383
  main += size + gapMain;
279
384
  }
280
385
  const mainTotal = Math.max(0, main - gapMain);
281
386
 
387
+ const pos = new Map();
282
388
  for (const r of ranks) {
283
- const ids = byRank.get(r);
284
389
  let cross = (maxExtent - rankExtent.get(r)) / 2;
285
- for (const id of ids) {
286
- const b = box.get(id);
287
- if (horizontal) { b.x = mainOffset.get(r); b.y = cross; cross += b.h + gapCross; }
288
- else { b.x = cross; b.y = mainOffset.get(r); cross += b.w + gapCross; }
390
+ for (const id of byRank.get(r)) {
391
+ const s = sizeOf(id);
392
+ if (horizontal) { pos.set(id, { x: mainOffset.get(r), y: cross }); cross += s.h + gapCross; }
393
+ else { pos.set(id, { x: cross, y: mainOffset.get(r) }); cross += s.w + gapCross; }
289
394
  }
290
395
  }
396
+ return {
397
+ pos,
398
+ rank,
399
+ width: horizontal ? mainTotal : maxExtent,
400
+ height: horizontal ? maxExtent : mainTotal,
401
+ };
402
+ }
291
403
 
292
- const width = horizontal ? mainTotal : maxExtent;
293
- const height = horizontal ? maxExtent : mainTotal;
294
- return { boxes: box, edges, dir, width, height, horizontal, children };
404
+ function boxesFor(graph, ids) {
405
+ const boxes = new Map();
406
+ for (const id of ids) {
407
+ const n = graph.nodes.get(id);
408
+ const lines = wrapLabel(n.label);
409
+ const { w, h } = measure(lines);
410
+ boxes.set(id, { id, lines, w, h, shape: n.shape || 'rect', rank: 0 });
411
+ }
412
+ return boxes;
413
+ }
414
+
415
+ /** One grid, no clusters: rank → row (TB) or column (LR). Pure geometry. */
416
+ function layoutFlat(graph, { dir = graph.dir, gapMain = 56, gapCross = 22 } = {}) {
417
+ const horizontal = dir === 'LR' || dir === 'RL';
418
+ const ids = [...graph.nodes.keys()];
419
+ const boxes = boxesFor(graph, ids);
420
+ const placed = placeGrid(ids, graph.edges, (id) => boxes.get(id), { horizontal, gapMain, gapCross });
421
+ for (const [id, b] of boxes) {
422
+ const p = placed.pos.get(id);
423
+ b.x = p.x; b.y = p.y; b.rank = placed.rank.get(id) || 0;
424
+ }
425
+ return {
426
+ boxes, edges: graph.edges, dir, horizontal, clusters: [],
427
+ width: placed.width, height: placed.height,
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Lay a chart out CLUSTER-FIRST: every subgraph is laid out on its own, then placed as a
433
+ * single block in the chart around it.
434
+ *
435
+ * Flattening subgraphs (what this used to do) does not just lose the frame, it loses the
436
+ * meaning. A "control plane / worker node" diagram flattened is nine boxes whose grouping was
437
+ * the entire point — and a member with no edges of its own, like a kube-proxy, drifts up to
438
+ * rank 0 among strangers. Two levels of the SAME grid fix both: members only rank against
439
+ * each other, so a group holds together, and groups rank against each other by the edges that
440
+ * cross between them. A chart with no subgraphs never reaches this function.
441
+ */
442
+ function layoutClustered(graph, { dir = graph.dir, gapMain = 56, gapCross = 22 } = {}) {
443
+ const horizontal = dir === 'LR' || dir === 'RL';
444
+ const boxes = boxesFor(graph, [...graph.nodes.keys()]);
445
+
446
+ // Every node belongs to exactly one cluster: its subgraph, or a cluster of its own.
447
+ const clusterOf = new Map();
448
+ const clusters = new Map();
449
+ graph.groups.forEach((g, i) => {
450
+ const key = `g${i}`;
451
+ clusters.set(key, { key, title: g.title || g.id, group: true, ids: [] });
452
+ for (const m of g.members) if (graph.nodes.has(m) && !clusterOf.has(m)) clusterOf.set(m, key);
453
+ });
454
+ for (const id of graph.nodes.keys()) {
455
+ const key = clusterOf.get(id) || `n:${id}`;
456
+ if (!clusters.has(key)) clusters.set(key, { key, title: '', group: false, ids: [] });
457
+ clusterOf.set(id, key);
458
+ clusters.get(key).ids.push(id);
459
+ }
460
+
461
+ // Inner layout, one cluster at a time, using only the edges that stay inside it.
462
+ for (const c of clusters.values()) {
463
+ const inner = placeGrid(c.ids, graph.edges, (id) => boxes.get(id), { horizontal, gapMain, gapCross });
464
+ c.inner = inner;
465
+ c.padL = c.group ? GROUP_PAD : 0;
466
+ c.padT = c.group ? GROUP_TITLE_H : 0;
467
+ c.w = inner.width + c.padL * 2;
468
+ c.h = inner.height + c.padT + (c.group ? GROUP_PAD : 0);
469
+ }
470
+
471
+ // Cluster-level edges: one per crossing PAIR, so the ranking sees structure, not volume.
472
+ const seen = new Set();
473
+ const clusterEdges = [];
474
+ for (const e of graph.edges) {
475
+ const a = clusterOf.get(e.from);
476
+ const b = clusterOf.get(e.to);
477
+ if (!a || !b || a === b) continue;
478
+ const key = `${a} ${b}`;
479
+ if (seen.has(key)) continue;
480
+ seen.add(key);
481
+ clusterEdges.push({ from: a, to: b });
482
+ }
483
+
484
+ const keys = [...clusters.keys()];
485
+ const outer = placeGrid(keys, clusterEdges, (k) => clusters.get(k), {
486
+ horizontal, gapMain: gapMain + 14, gapCross: gapCross + 16,
487
+ });
488
+ for (const k of keys) {
489
+ const c = clusters.get(k);
490
+ const p = outer.pos.get(k);
491
+ c.x = p.x; c.y = p.y;
492
+ const clusterRank = outer.rank.get(k) || 0;
493
+ for (const id of c.ids) {
494
+ const b = boxes.get(id);
495
+ const ip = c.inner.pos.get(id);
496
+ b.x = c.x + c.padL + ip.x;
497
+ b.y = c.y + c.padT + ip.y;
498
+ b.rank = clusterRank + (c.inner.rank.get(id) || 0);
499
+ }
500
+ }
501
+
502
+ return {
503
+ boxes, edges: graph.edges, dir, horizontal,
504
+ clusters: [...clusters.values()].filter((c) => c.group),
505
+ width: outer.width, height: outer.height,
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Lay a parsed chart out. A chart with subgraphs is laid out cluster-first; one without takes
511
+ * the single grid, byte for byte as before. Callers never choose — the chart does.
512
+ */
513
+ export function layoutFlowchart(graph, opts = {}) {
514
+ return graph.groups?.length ? layoutClustered(graph, opts) : layoutFlat(graph, opts);
295
515
  }
296
516
 
297
517
  function styleFor(id, graph, rankIdx) {
@@ -308,6 +528,12 @@ function styleFor(id, graph, rankIdx) {
308
528
  return RANK_TINTS[Math.min(rankIdx, RANK_TINTS.length - 1)];
309
529
  }
310
530
 
531
+ function radiusFor(b) {
532
+ if (b.shape === 'pill') return Math.round(Math.min(b.h, b.w) / 2);
533
+ if (b.shape === 'round') return 16;
534
+ return 9;
535
+ }
536
+
311
537
  /**
312
538
  * Mermaid flowchart text → a complete SVG document string, or null if it isn't a flowchart
313
539
  * this renderer handles (the caller then keeps the code block).
@@ -315,7 +541,8 @@ function styleFor(id, graph, rankIdx) {
315
541
  export function renderFlowchartSvg(text, { padding = 18, maxWidth = 1400, autoFlip = true } = {}) {
316
542
  const graph = parseFlowchart(text);
317
543
  if (!graph) return null;
318
- let L = layoutFlowchart(graph);
544
+ const lay = (opts) => layoutFlowchart(graph, opts);
545
+ let L = lay({});
319
546
  if (!L.boxes.size) return null;
320
547
 
321
548
  // A broad tree laid out top-down (one root, six categories, ~28 leaves) becomes a 3000px
@@ -325,8 +552,8 @@ export function renderFlowchartSvg(text, { padding = 18, maxWidth = 1400, autoFl
325
552
  // the Code view always shows what the model actually wrote.
326
553
  if (autoFlip) {
327
554
  const aspect = L.width / Math.max(1, L.height);
328
- if (aspect > 2.2 && !L.horizontal) L = layoutFlowchart(graph, { dir: 'LR' });
329
- else if (aspect < 0.25 && L.horizontal) L = layoutFlowchart(graph, { dir: 'TB' });
555
+ if (aspect > 2.2 && !L.horizontal) L = lay({ dir: 'LR' });
556
+ else if (aspect < 0.25 && L.horizontal) L = lay({ dir: 'TB' });
330
557
  }
331
558
 
332
559
  const W = Math.min(maxWidth, Math.ceil(L.width + padding * 2));
@@ -343,24 +570,49 @@ export function renderFlowchartSvg(text, { padding = 18, maxWidth = 1400, autoFl
343
570
 
344
571
  const px = (v) => Math.round(v * 10) / 10;
345
572
 
346
- // Edges first, so boxes paint over the joins.
573
+ // Subgraph frames first — they sit UNDER everything, as the surface a group is drawn on.
574
+ for (const c of L.clusters) {
575
+ out.push(
576
+ `<rect x="${px(c.x + padding)}" y="${px(c.y + padding)}" width="${px(c.w)}" height="${px(c.h)}" rx="14" `
577
+ + `fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" stroke-dasharray="5 4"/>`,
578
+ );
579
+ if (c.title) {
580
+ out.push(
581
+ `<text x="${px(c.x + padding + 14)}" y="${px(c.y + padding + 19)}" font-size="12" font-weight="700" `
582
+ + `fill="#475569">${esc(c.title.slice(0, 48))}</text>`,
583
+ );
584
+ }
585
+ }
586
+
587
+ // Edges next, so boxes paint over the joins.
347
588
  for (const e of L.edges) {
348
589
  const a = L.boxes.get(e.from);
349
590
  const b = L.boxes.get(e.to);
350
591
  if (!a || !b) continue;
351
592
  let x1, y1, x2, y2, d;
352
593
  if (L.horizontal) {
353
- x1 = a.x + a.w + padding; y1 = a.y + a.h / 2 + padding;
354
- x2 = b.x + padding; y2 = b.y + b.h / 2 + padding;
594
+ // Leave from whichever face actually points at the target: with subgraphs an edge can
595
+ // run backwards, and a curve out of the wrong face reads as a different connection.
596
+ const back = b.x + b.w < a.x;
597
+ x1 = back ? a.x + padding : a.x + a.w + padding;
598
+ y1 = a.y + a.h / 2 + padding;
599
+ x2 = back ? b.x + b.w + padding : b.x + padding;
600
+ y2 = b.y + b.h / 2 + padding;
355
601
  const mx = (x1 + x2) / 2;
356
602
  d = `M${px(x1)},${px(y1)} C${px(mx)},${px(y1)} ${px(mx)},${px(y2)} ${px(x2)},${px(y2)}`;
357
603
  } else {
358
- x1 = a.x + a.w / 2 + padding; y1 = a.y + a.h + padding;
359
- x2 = b.x + b.w / 2 + padding; y2 = b.y + padding;
604
+ const back = b.y + b.h < a.y;
605
+ x1 = a.x + a.w / 2 + padding;
606
+ y1 = back ? a.y + padding : a.y + a.h + padding;
607
+ x2 = b.x + b.w / 2 + padding;
608
+ y2 = back ? b.y + b.h + padding : b.y + padding;
360
609
  const my = (y1 + y2) / 2;
361
610
  d = `M${px(x1)},${px(y1)} C${px(x1)},${px(my)} ${px(x2)},${px(my)} ${px(x2)},${px(y2)}`;
362
611
  }
363
- out.push(`<path d="${d}" fill="none" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#a)"/>`);
612
+ // A bidirectional link (`A <--> B`) gets a head at BOTH ends — the marker is declared
613
+ // orient="auto-start-reverse", so the same one points the right way at the start.
614
+ const startHead = e.both ? ' marker-start="url(#a)"' : '';
615
+ out.push(`<path d="${d}" fill="none" stroke="#94a3b8" stroke-width="1.5"${startHead} marker-end="url(#a)"/>`);
364
616
  if (e.label) {
365
617
  const lx = (x1 + x2) / 2;
366
618
  const ly = (y1 + y2) / 2;
@@ -374,14 +626,15 @@ export function renderFlowchartSvg(text, { padding = 18, maxWidth = 1400, autoFl
374
626
  for (const [id, b] of L.boxes) {
375
627
  const s = styleFor(id, graph, b.rank);
376
628
  out.push(
377
- `<rect x="${px(b.x + padding)}" y="${px(b.y + padding)}" width="${px(b.w)}" height="${px(b.h)}" rx="9" `
629
+ `<rect x="${px(b.x + padding)}" y="${px(b.y + padding)}" width="${px(b.w)}" height="${px(b.h)}" rx="${radiusFor(b)}" `
378
630
  + `fill="${esc(s.fill)}" stroke="${esc(s.stroke)}" stroke-width="1.5"/>`,
379
631
  );
380
632
  const startY = b.y + padding + PAD_Y + LINE_H - 5;
381
633
  b.lines.forEach((line, i) => {
634
+ const style = (line.italic ? ' font-style="italic"' : '') + (line.bold ? ' font-weight="700"' : '');
382
635
  out.push(
383
636
  `<text x="${px(b.x + b.w / 2 + padding)}" y="${px(startY + i * LINE_H)}" font-size="13" `
384
- + `fill="${esc(s.color)}" text-anchor="middle">${esc(line)}</text>`,
637
+ + `fill="${esc(s.color)}" text-anchor="middle"${style}>${esc(line.text)}</text>`,
385
638
  );
386
639
  });
387
640
  }