@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/backup-envelope.js +221 -0
- package/curate.js +509 -0
- package/distance.js +124 -0
- package/entitlement.js +298 -0
- package/entity.js +354 -0
- package/flowchart.js +350 -97
- package/index.js +78 -0
- package/knowledge-derive.js +267 -0
- package/knowledge.js +221 -0
- package/library.js +265 -0
- package/omni.js +125 -0
- package/package.json +43 -11
- package/promotion.js +171 -0
- package/redaction-tokens.js +61 -0
- package/ref.js +4 -1
- package/subject-kinds.js +5 -0
- package/subject-name.js +96 -0
- package/sync-plan.js +170 -0
- package/synthesis.js +123 -0
- package/theme.js +155 -0
- package/voice-intents.js +5 -23
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),
|
|
10
|
-
// testable without a browser and runs identically in the extension, a desktop app or
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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, '"').replace(/'/g, ''');
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
|
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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 (
|
|
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 (/^
|
|
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)
|
|
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
|
|
189
|
-
// the visited guard, so a malformed graph still lays out instead
|
|
190
|
-
|
|
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
|
|
301
|
+
for (const id of set) { parents.set(id, []); children.set(id, []); }
|
|
194
302
|
for (const e of edges) {
|
|
195
|
-
if (!
|
|
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 = [...
|
|
201
|
-
const queue = roots.length ? [...roots] : [
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
243
|
-
if (r === ranks[0]) {
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
266
|
-
const total =
|
|
267
|
-
+ gapCross * Math.max(0,
|
|
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 ?
|
|
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
|
|
286
|
-
const
|
|
287
|
-
if (horizontal) {
|
|
288
|
-
else {
|
|
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
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
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
|
-
|
|
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 =
|
|
329
|
-
else if (aspect < 0.25 && L.horizontal) L =
|
|
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
|
-
//
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
}
|