ntk 3.10.2 → 4.0.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/README.md CHANGED
@@ -54,9 +54,8 @@ text costs about a byte per glyph on the wire. Font names resolve through
54
54
  fontconfig (`fc-match`). Very large and continuously animated sizes render
55
55
  as server-side trapezoids instead of cached bitmaps. A `TextLayout` engine
56
56
  wraps styled text to a target width, a `MarkdownView` widget renders
57
- markdown (with syntax-highlighted code fences, mermaid flowchart/sequence
58
- diagrams, and KaTeX math via `TexView`) on top of it — see
59
- [docs/text.md](docs/text.md), [docs/mermaid.md](docs/mermaid.md) and
57
+ markdown (with syntax-highlighted code fences and KaTeX math via
58
+ `TexView`) on top of it — see [docs/text.md](docs/text.md) and
60
59
  [docs/tex.md](docs/tex.md).
61
60
 
62
61
  PNG/JPEG images decode client-side (`loadImage`) and composite server-side
@@ -1,7 +1,6 @@
1
1
  import { TextLayout } from '../text/layout.js';
2
2
  import { tokenize } from './highlight.js';
3
3
  import { parseMarkdown } from './markdown.js';
4
- import { layoutMermaid, parseMermaid } from './mermaid.js';
5
4
  import { layoutTex } from './tex.js';
6
5
 
7
6
  const MATH_LANGS = new Set(['math', 'tex', 'latex', 'katex']);
@@ -51,10 +50,8 @@ const DEFAULT_THEME = {
51
50
  * wnd.map();
52
51
  *
53
52
  * Standalone use (no window attach): `view.layout(width)` then
54
- * `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Content that
55
- * arrives asynchronously (mermaid models) invalidates the layout — pass
56
- * `onInvalidate` to re-layout/redraw when that happens (window mode
57
- * repaints automatically).
53
+ * `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Layout is
54
+ * synchronous throughout — nothing here loads in the background.
58
55
  */
59
56
  export default class MarkdownView {
60
57
  constructor(window, opts = {}) {
@@ -73,10 +70,6 @@ export default class MarkdownView {
73
70
  /** called with (href, event) when a rendered link is clicked (window
74
71
  * mode wires mousedown automatically; standalone callers use linkAt) */
75
72
  this.onLink = opts.onLink ?? null;
76
- /** called when async content (a mermaid model) invalidates the layout;
77
- * standalone hosts re-layout/redraw here — window mode also repaints
78
- * on its own */
79
- this.onInvalidate = opts.onInvalidate ?? null;
80
73
 
81
74
  if (this.window) {
82
75
  this._ctx = this.window.getContext('2d');
@@ -105,41 +98,11 @@ export default class MarkdownView {
105
98
 
106
99
  setMarkdown(src) {
107
100
  this._blocks = parseMarkdown(src);
108
- this._mermaid = new Map();
109
101
  this._layoutWidth = -1;
110
102
  if (this.window && this.window._mapped) this.render();
111
103
  return this;
112
104
  }
113
105
 
114
- // ```mermaid fences parse asynchronously (the mermaid grammar loads
115
- // lazily); the fence shows as a code block until the model arrives, then
116
- // layout is invalidated — same pattern as image loading in HtmlView
117
- _mermaidEntry(text) {
118
- if (!this._mermaid) this._mermaid = new Map();
119
- let entry = this._mermaid.get(text);
120
- if (!entry) {
121
- entry = { model: null, failed: false };
122
- this._mermaid.set(text, entry);
123
- parseMermaid(text).then(
124
- (model) => {
125
- entry.model = model;
126
- this._invalidate();
127
- },
128
- () => {
129
- entry.failed = true; // unsupported/invalid: stays a code block
130
- }
131
- );
132
- }
133
- return entry;
134
- }
135
-
136
- /** async content arrived: drop the cached layout, repaint and/or notify */
137
- _invalidate() {
138
- this._layoutWidth = -1;
139
- if (this.window && this.window._mapped) this.render();
140
- if (this.onInvalidate) this.onInvalidate();
141
- }
142
-
143
106
  /** lay content out for a container width; returns total content height */
144
107
  layout(width) {
145
108
  const fonts = this._fonts();
@@ -208,31 +171,6 @@ export default class MarkdownView {
208
171
  }
209
172
 
210
173
  case 'code': {
211
- // ```mermaid fences render as diagrams (flowchart / sequence);
212
- // unsupported types or parse errors fall back to the code block
213
- if ((block.lang || '').toLowerCase() === 'mermaid') {
214
- let box = null;
215
- const entry = this._mermaidEntry(block.text);
216
- if (entry.model) {
217
- try {
218
- box = layoutMermaid(entry.model, {
219
- fonts,
220
- size: Math.round(t.size * 0.82),
221
- family: t.family,
222
- maxWidth: width,
223
- theme: { labelBg: t.background, text: t.color }
224
- });
225
- } catch {
226
- // fall through to the plain code block
227
- }
228
- }
229
- if (box) {
230
- const bx = x + Math.max(0, (width - box.width) / 2);
231
- this._items.push({ kind: 'tex', x: bx, y, box });
232
- y += box.height;
233
- break;
234
- }
235
- }
236
174
  // ```math / ```latex fences render as display-mode formulas
237
175
  if (MATH_LANGS.has((block.lang || '').toLowerCase())) {
238
176
  let box = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.10.2",
3
+ "version": "4.0.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",
@@ -33,7 +33,6 @@
33
33
  "node": ">=20.19.0"
34
34
  },
35
35
  "dependencies": {
36
- "@dagrejs/dagre": "^3.0.0",
37
36
  "bidi-js": "^1.0.3",
38
37
  "canvas-fontstyle": "^1.0.1",
39
38
  "css-select": "^7.0.0",
@@ -47,7 +46,6 @@
47
46
  "keysym": "0.0.6",
48
47
  "linebreak": "^1.1.0",
49
48
  "marked": "^18.0.7",
50
- "mermaid": "^11.16.0",
51
49
  "parse-color": "^1.0.0",
52
50
  "pngjs": "^7.0.0",
53
51
  "postcss": "^8.5.23",
@@ -1,720 +0,0 @@
1
- // Mermaid diagram rendering for the markdown widget (```mermaid fences).
2
- //
3
- // Parsing uses the mermaid package's own grammar (lazily imported, run
4
- // headless — see loadMermaid()); mermaid's *renderer* is the only part
5
- // that needs a browser, so the two most-used diagram types are drawn
6
- // natively instead: **flowcharts** laid out by @dagrejs/dagre and
7
- // **sequence diagrams** with a classic column/row layout. Text is measured
8
- // with the ntk font pipeline and everything draws through the 2d context —
9
- // no SVG round-trip, no browser. Diagram types without a native renderer
10
- // here throw from parseMermaid(), and MarkdownView falls back to a plain
11
- // code block (same contract as invalid math).
12
- //
13
- // const model = await parseMermaid('flowchart LR\n A[Start] --> B{OK?}');
14
- // const box = layoutMermaid(model, { fonts });
15
- // box.draw(ctx, x, y); // box.width / box.height
16
- import dagre from '@dagrejs/dagre';
17
-
18
- const DEFAULT_THEME = {
19
- nodeFill: '#ececff',
20
- nodeBorder: '#9370db',
21
- line: '#555555',
22
- text: '#333333',
23
- labelBg: '#ffffff', // behind edge labels; set to the page background
24
- actorFill: '#ececff',
25
- actorBorder: '#9370db',
26
- noteFill: '#fff5ad',
27
- noteBorder: '#b8b855',
28
- frameBorder: '#999999',
29
- frameLabel: '#666666'
30
- };
31
-
32
- // ---------------------------------------------------------------------------
33
- // parsing: the mermaid package's own grammar, run headless
34
- //
35
- // mermaid's renderer needs a browser (getBBox and friends), but its parsers
36
- // and diagram DBs work in Node once DOMPurify — which mermaid only uses to
37
- // sanitize label HTML for browser injection — is shimmed to the identity.
38
- // Labels here never reach a DOM: they are drawn as plain text.
39
-
40
- let mermaidLoad = null;
41
-
42
- function loadMermaid() {
43
- if (!mermaidLoad) {
44
- mermaidLoad = (async () => {
45
- const DOMPurify = (await import('dompurify')).default;
46
- if (typeof DOMPurify.sanitize !== 'function') {
47
- DOMPurify.sanitize = (s) => String(s);
48
- DOMPurify.addHook = () => {};
49
- }
50
- const mermaid = (await import('mermaid')).default;
51
- mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
52
- return mermaid;
53
- })();
54
- }
55
- return mermaidLoad;
56
- }
57
-
58
- function cleanLabel(text) {
59
- return String(text ?? '')
60
- .replace(/<br\s*\/?>/gi, '\n')
61
- .trim();
62
- }
63
-
64
- const SHAPE_MAP = {
65
- square: 'rect',
66
- rect: 'rect',
67
- round: 'round',
68
- rounded: 'round',
69
- stadium: 'stadium',
70
- subroutine: 'subroutine',
71
- cylinder: 'cylinder',
72
- cyl: 'cylinder',
73
- circle: 'circle',
74
- doublecircle: 'circle',
75
- dbl_circ: 'circle',
76
- diamond: 'diamond',
77
- question: 'diamond',
78
- decision: 'diamond',
79
- hexagon: 'hexagon',
80
- hex: 'hexagon'
81
- };
82
-
83
- function normalizeFlowchart(db) {
84
- const nodes = [];
85
- for (const [id, v] of db.getVertices()) {
86
- const kind = v.type ?? v.shape;
87
- nodes.push({ id, shape: SHAPE_MAP[kind] ?? 'rect', label: cleanLabel(v.text ?? id) || id });
88
- }
89
- const edges = db.getEdges().map((e) => ({
90
- from: e.start,
91
- to: e.end,
92
- label: cleanLabel(e.text) || null,
93
- style: e.stroke === 'dotted' ? 'dotted' : e.stroke === 'thick' ? 'thick' : 'solid',
94
- arrow: e.type !== 'arrow_open'
95
- }));
96
- if (nodes.length === 0) throw new Error('flowchart: no nodes');
97
- const dir = db.getDirection?.() ?? 'TB';
98
- const rankdir = { TD: 'TB' }[dir] ?? (['TB', 'BT', 'LR', 'RL'].includes(dir) ? dir : 'TB');
99
- return { type: 'flowchart', rankdir, nodes, edges, warnings: [] };
100
- }
101
-
102
- // sequenceDb LINETYPE values (stable across mermaid 8-11)
103
- const LT = {
104
- SOLID: 0, DOTTED: 1, NOTE: 2, SOLID_CROSS: 3, DOTTED_CROSS: 4,
105
- SOLID_OPEN: 5, DOTTED_OPEN: 6,
106
- LOOP_START: 10, LOOP_END: 11, ALT_START: 12, ALT_ELSE: 13, ALT_END: 14,
107
- OPT_START: 15, OPT_END: 16, ACTIVE_START: 17, ACTIVE_END: 18,
108
- PAR_START: 19, PAR_AND: 20, PAR_END: 21, RECT_START: 22, RECT_END: 23,
109
- SOLID_POINT: 24, DOTTED_POINT: 25, AUTONUMBER: 26,
110
- CRITICAL_START: 27, CRITICAL_OPTION: 28, CRITICAL_END: 29,
111
- BREAK_START: 30, BREAK_END: 31, PAR_OVER_START: 32
112
- };
113
-
114
- const SEQ_LINE = {
115
- [LT.SOLID]: { dotted: false, head: 'arrow' },
116
- [LT.DOTTED]: { dotted: true, head: 'arrow' },
117
- [LT.SOLID_CROSS]: { dotted: false, head: 'cross' },
118
- [LT.DOTTED_CROSS]: { dotted: true, head: 'cross' },
119
- [LT.SOLID_OPEN]: { dotted: false, head: 'none' },
120
- [LT.DOTTED_OPEN]: { dotted: true, head: 'none' },
121
- [LT.SOLID_POINT]: { dotted: false, head: 'open' },
122
- [LT.DOTTED_POINT]: { dotted: true, head: 'open' }
123
- };
124
-
125
- const FRAME_START = {
126
- [LT.LOOP_START]: 'loop', [LT.ALT_START]: 'alt', [LT.OPT_START]: 'opt',
127
- [LT.PAR_START]: 'par', [LT.PAR_OVER_START]: 'par',
128
- [LT.CRITICAL_START]: 'critical', [LT.BREAK_START]: 'break'
129
- };
130
- const FRAME_END = new Set([LT.LOOP_END, LT.ALT_END, LT.OPT_END, LT.PAR_END, LT.CRITICAL_END, LT.BREAK_END]);
131
- const FRAME_DIVIDER = new Set([LT.ALT_ELSE, LT.PAR_AND, LT.CRITICAL_OPTION]);
132
-
133
- function normalizeSequence(db) {
134
- const participants = [];
135
- for (const [id, a] of db.getActors()) {
136
- participants.push({ id, label: cleanLabel(a.description) || id, actor: a.type === 'actor' });
137
- }
138
- if (participants.length === 0) throw new Error('sequence: no participants');
139
-
140
- const items = [];
141
- const warnings = [];
142
- let numbering = db.showSequenceNumbers?.() ? 1 : 0;
143
- const actorId = (v) => (typeof v === 'object' && v !== null ? v.actor : v);
144
-
145
- for (const m of db.getMessages()) {
146
- const t = m.type;
147
- if (t === LT.AUTONUMBER) {
148
- numbering = m.message?.visible === false ? 0 : 1;
149
- continue;
150
- }
151
- if (t === LT.NOTE) {
152
- const pos = m.placement === 0 ? 'left of' : m.placement === 1 ? 'right of' : 'over';
153
- const ids = [actorId(m.from)];
154
- const to = actorId(m.to);
155
- if (to && to !== ids[0]) ids.push(to);
156
- items.push({ kind: 'note', pos, ids, text: cleanLabel(m.message) });
157
- continue;
158
- }
159
- if (t in FRAME_START) {
160
- items.push({ kind: 'frame', frame: FRAME_START[t], text: cleanLabel(m.message ?? '') });
161
- continue;
162
- }
163
- if (FRAME_END.has(t)) {
164
- items.push({ kind: 'frameEnd' });
165
- continue;
166
- }
167
- if (FRAME_DIVIDER.has(t)) {
168
- items.push({ kind: 'divider', text: cleanLabel(m.message ?? '') });
169
- continue;
170
- }
171
- const line = SEQ_LINE[t];
172
- if (line) {
173
- let text = cleanLabel(m.message);
174
- if (numbering) text = `${numbering++}. ${text}`;
175
- items.push({ kind: 'message', from: actorId(m.from), to: actorId(m.to), text, ...line });
176
- continue;
177
- }
178
- if (t !== LT.ACTIVE_START && t !== LT.ACTIVE_END && t !== LT.RECT_START && t !== LT.RECT_END) {
179
- warnings.push(`sequence: ignored message type ${t}`);
180
- }
181
- }
182
- return { type: 'sequence', participants, items, warnings };
183
- }
184
-
185
- /**
186
- * Parse mermaid source with the mermaid package's own parser (loaded
187
- * lazily, headless) and normalize the diagram DB into the model consumed
188
- * by layoutMermaid(). Throws for diagram types without a native renderer
189
- * here (anything but flowchart / sequence).
190
- */
191
- export async function parseMermaid(text) {
192
- const mermaid = await loadMermaid();
193
- const diagram = await mermaid.mermaidAPI.getDiagramFromText(String(text));
194
- const type = diagram.type;
195
- if (/^flowchart/.test(type) || type === 'graph') return normalizeFlowchart(diagram.db);
196
- if (/^sequence/.test(type)) return normalizeSequence(diagram.db);
197
- throw new Error(`mermaid: no native renderer for diagram type "${type}"`);
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // text measurement helpers
202
-
203
- function makeMeasurer(fonts, family, size) {
204
- const style = { family, size, weight: 'normal', style: 'normal' };
205
- style.font = fonts.match(family, style);
206
- const metrics = style.font.metrics(size);
207
- const lineH = Math.ceil((metrics.ascent + metrics.descent) * 1.25);
208
- return {
209
- style,
210
- lineH,
211
- ascent: metrics.ascent,
212
- width: (s) => (s ? fonts.shape(s, style).width : 0),
213
- linesOf: (s) => String(s ?? '').split('\n'),
214
- block(s) {
215
- const lines = this.linesOf(s);
216
- let w = 0;
217
- for (const l of lines) w = Math.max(w, this.width(l));
218
- return { lines, w, h: lines.length * lineH };
219
- }
220
- };
221
- }
222
-
223
- function drawTextBlock(ctx, meas, family, size, color, lines, cx, topY) {
224
- ctx.fillStyle = color;
225
- ctx.font = `${size}px ${family}`;
226
- ctx.textAlign = 'left';
227
- ctx.textBaseline = 'alphabetic';
228
- let y = topY + meas.ascent;
229
- for (const line of lines) {
230
- const w = meas.width(line);
231
- ctx.fillText(line, cx - w / 2, y);
232
- y += meas.lineH;
233
- }
234
- }
235
-
236
- // manual dashes (the 2d context has no setLineDash)
237
- function dashedLine(ctx, x0, y0, x1, y1, dash = 4, gap = 4) {
238
- const dx = x1 - x0;
239
- const dy = y1 - y0;
240
- const len = Math.hypot(dx, dy);
241
- if (!len) return;
242
- const ux = dx / len;
243
- const uy = dy / len;
244
- let t = 0;
245
- ctx.beginPath();
246
- while (t < len) {
247
- const e = Math.min(t + dash, len);
248
- ctx.moveTo(x0 + ux * t, y0 + uy * t);
249
- ctx.lineTo(x0 + ux * e, y0 + uy * e);
250
- t = e + gap;
251
- }
252
- ctx.stroke();
253
- }
254
-
255
- function dashedPolyline(ctx, pts, dash = 4, gap = 4) {
256
- for (let i = 0; i + 1 < pts.length; i++) dashedLine(ctx, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, dash, gap);
257
- }
258
-
259
- function arrowHead(ctx, x, y, fromX, fromY, size, color) {
260
- const a = Math.atan2(y - fromY, x - fromX);
261
- const s = size;
262
- ctx.fillStyle = color;
263
- ctx.beginPath();
264
- ctx.moveTo(x, y);
265
- ctx.lineTo(x - s * Math.cos(a - 0.45), y - s * Math.sin(a - 0.45));
266
- ctx.lineTo(x - s * Math.cos(a + 0.45), y - s * Math.sin(a + 0.45));
267
- ctx.closePath();
268
- ctx.fill();
269
- }
270
-
271
- // ---------------------------------------------------------------------------
272
- // flowchart layout + drawing
273
-
274
- function nodeSize(node, meas) {
275
- const { w, h } = meas.block(node.label);
276
- const padX = 14;
277
- const padY = 9;
278
- switch (node.shape) {
279
- case 'diamond':
280
- return { w: w * 1.5 + 28, h: h * 2 + 18 };
281
- case 'circle': {
282
- const d = Math.max(w, h) + 24;
283
- return { w: d, h: d };
284
- }
285
- case 'hexagon':
286
- return { w: w + padX * 2 + h + 12, h: h + padY * 2 };
287
- case 'cylinder':
288
- return { w: w + padX * 2, h: h + padY * 2 + 14 };
289
- case 'subroutine':
290
- return { w: w + padX * 2 + 12, h: h + padY * 2 };
291
- case 'stadium':
292
- return { w: w + padX * 2 + h, h: h + padY * 2 };
293
- default:
294
- return { w: w + padX * 2, h: h + padY * 2 };
295
- }
296
- }
297
-
298
- function nodePath(ctx, node, x, y, w, h) {
299
- // (x, y) is the node center
300
- ctx.beginPath();
301
- switch (node.shape) {
302
- case 'round':
303
- ctx.roundRect(x - w / 2, y - h / 2, w, h, 6);
304
- break;
305
- case 'stadium':
306
- ctx.roundRect(x - w / 2, y - h / 2, w, h, h / 2);
307
- break;
308
- case 'circle':
309
- ctx.arc(x, y, w / 2, 0, Math.PI * 2);
310
- break;
311
- case 'diamond':
312
- ctx.moveTo(x, y - h / 2);
313
- ctx.lineTo(x + w / 2, y);
314
- ctx.lineTo(x, y + h / 2);
315
- ctx.lineTo(x - w / 2, y);
316
- ctx.closePath();
317
- break;
318
- case 'hexagon': {
319
- const c = h / 2;
320
- ctx.moveTo(x - w / 2 + c, y - h / 2);
321
- ctx.lineTo(x + w / 2 - c, y - h / 2);
322
- ctx.lineTo(x + w / 2, y);
323
- ctx.lineTo(x + w / 2 - c, y + h / 2);
324
- ctx.lineTo(x - w / 2 + c, y + h / 2);
325
- ctx.lineTo(x - w / 2, y);
326
- ctx.closePath();
327
- break;
328
- }
329
- default:
330
- ctx.rect(x - w / 2, y - h / 2, w, h);
331
- }
332
- }
333
-
334
- function layoutFlowchart(model, meas, family, size, theme) {
335
- const g = new dagre.graphlib.Graph({ multigraph: true });
336
- // separations follow the font size so shrink-to-fit scales ~linearly
337
- g.setGraph({
338
- rankdir: model.rankdir,
339
- nodesep: Math.round(size * 2.8),
340
- ranksep: Math.round(size * 3),
341
- marginx: 6,
342
- marginy: 6
343
- });
344
- g.setDefaultEdgeLabel(() => ({}));
345
-
346
- for (const node of model.nodes) {
347
- const { w, h } = nodeSize(node, meas);
348
- g.setNode(node.id, { width: w, height: h, node });
349
- }
350
- model.edges.forEach((edge, i) => {
351
- const attrs = { edge };
352
- if (edge.label) {
353
- const { w, h } = meas.block(edge.label);
354
- attrs.width = w + 8;
355
- attrs.height = h + 4;
356
- attrs.labelpos = 'c';
357
- }
358
- g.setEdge(edge.from, edge.to, attrs, `e${i}`);
359
- });
360
-
361
- dagre.layout(g);
362
- const gw = Math.ceil(g.graph().width ?? 0);
363
- const gh = Math.ceil(g.graph().height ?? 0);
364
-
365
- return {
366
- type: 'flowchart',
367
- width: gw,
368
- height: gh,
369
- warnings: model.warnings,
370
- draw(ctx, ox = 0, oy = 0) {
371
- // edges under nodes
372
- for (const e of g.edges()) {
373
- const info = g.edge(e);
374
- const edge = info.edge;
375
- const pts = info.points.map((p) => ({ x: p.x + ox, y: p.y + oy }));
376
- ctx.strokeStyle = theme.line;
377
- ctx.lineWidth = edge.style === 'thick' ? 2.6 : 1.4;
378
- if (edge.style === 'dotted') {
379
- dashedPolyline(ctx, pts, 3, 3.5);
380
- } else {
381
- ctx.beginPath();
382
- ctx.moveTo(pts[0].x, pts[0].y);
383
- if (pts.length === 2) {
384
- ctx.lineTo(pts[1].x, pts[1].y);
385
- } else {
386
- for (let i = 1; i < pts.length - 1; i++) {
387
- const mx = (pts[i].x + pts[i + 1].x) / 2;
388
- const my = (pts[i].y + pts[i + 1].y) / 2;
389
- if (i === pts.length - 2) ctx.quadraticCurveTo(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
390
- else ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
391
- }
392
- }
393
- ctx.stroke();
394
- }
395
- if (edge.arrow) {
396
- const tip = pts[pts.length - 1];
397
- const before = pts[pts.length - 2] ?? pts[0];
398
- arrowHead(ctx, tip.x, tip.y, before.x, before.y, 7 + (edge.style === 'thick' ? 2 : 0), theme.line);
399
- }
400
- if (edge.label) {
401
- const { lines, w, h } = meas.block(edge.label);
402
- const lx = info.x + ox;
403
- const ly = info.y + oy;
404
- ctx.fillStyle = theme.labelBg;
405
- ctx.fillRect(Math.round(lx - w / 2 - 3), Math.round(ly - h / 2 - 1), Math.ceil(w + 6), Math.ceil(h + 2));
406
- drawTextBlock(ctx, meas, family, size, theme.text, lines, lx, ly - h / 2);
407
- }
408
- }
409
- // nodes
410
- for (const id of g.nodes()) {
411
- const info = g.node(id);
412
- const node = info.node;
413
- const x = info.x + ox;
414
- const y = info.y + oy;
415
- ctx.fillStyle = theme.nodeFill;
416
- ctx.strokeStyle = theme.nodeBorder;
417
- ctx.lineWidth = 1.3;
418
- if (node.shape === 'cylinder') {
419
- const capH = 10;
420
- const w = info.width;
421
- const h = info.height;
422
- const top = y - h / 2;
423
- ctx.beginPath();
424
- ctx.rect(x - w / 2, top + capH / 2, w, h - capH);
425
- ctx.fill();
426
- ctx.beginPath();
427
- ctx.moveTo(x - w / 2, top + capH / 2);
428
- ctx.lineTo(x - w / 2, top + h - capH / 2);
429
- ctx.moveTo(x + w / 2, top + capH / 2);
430
- ctx.lineTo(x + w / 2, top + h - capH / 2);
431
- ctx.stroke();
432
- ctx.beginPath();
433
- ctx.ellipse(x, top + h - capH / 2, w / 2, capH / 2, 0, 0, Math.PI * 2);
434
- ctx.fill();
435
- ctx.stroke();
436
- ctx.beginPath();
437
- ctx.ellipse(x, top + capH / 2, w / 2, capH / 2, 0, 0, Math.PI * 2);
438
- ctx.fill();
439
- ctx.stroke();
440
- } else {
441
- nodePath(ctx, node, x, y, info.width, info.height);
442
- ctx.fill();
443
- nodePath(ctx, node, x, y, info.width, info.height);
444
- ctx.stroke();
445
- if (node.shape === 'subroutine') {
446
- ctx.beginPath();
447
- ctx.moveTo(x - info.width / 2 + 5, y - info.height / 2);
448
- ctx.lineTo(x - info.width / 2 + 5, y + info.height / 2);
449
- ctx.moveTo(x + info.width / 2 - 5, y - info.height / 2);
450
- ctx.lineTo(x + info.width / 2 - 5, y + info.height / 2);
451
- ctx.stroke();
452
- }
453
- }
454
- const { lines, h } = meas.block(node.label);
455
- drawTextBlock(ctx, meas, family, size, theme.text, lines, x, y - h / 2);
456
- }
457
- }
458
- };
459
- }
460
-
461
- // ---------------------------------------------------------------------------
462
- // sequence layout + drawing
463
-
464
- function layoutSequence(model, meas, family, size, theme) {
465
- const P = model.participants;
466
- const idx = new Map(P.map((p, i) => [p.id, i]));
467
- const actorPadX = 12;
468
- const actorH = meas.lineH + 16;
469
- const boxW = P.map((p) => Math.max(60, meas.width(p.label) + actorPadX * 2));
470
-
471
- // gaps between adjacent lifelines, widened until every message/note fits
472
- const gaps = new Array(Math.max(0, P.length - 1)).fill(110);
473
- const need = (a, b, w) => {
474
- if (a === b) return;
475
- const [lo, hi] = a < b ? [a, b] : [b, a];
476
- let sum = 0;
477
- for (let i = lo; i < hi; i++) sum += gaps[i];
478
- if (sum < w) {
479
- const add = (w - sum) / (hi - lo);
480
- for (let i = lo; i < hi; i++) gaps[i] += add;
481
- }
482
- };
483
- for (const item of model.items) {
484
- if (item.kind === 'message' && item.from !== item.to) {
485
- need(idx.get(item.from), idx.get(item.to), meas.width(item.text) + 36);
486
- } else if (item.kind === 'note' && item.pos === 'over' && item.ids.length > 1) {
487
- need(idx.get(item.ids[0]), idx.get(item.ids[item.ids.length - 1]), meas.block(item.text).w + 30);
488
- }
489
- }
490
- // lifeline x positions
491
- const cx = [];
492
- let x = Math.max(20, boxW[0] / 2 + 8);
493
- for (let i = 0; i < P.length; i++) {
494
- if (i > 0) x += Math.max(gaps[i - 1], boxW[i - 1] / 2 + boxW[i] / 2 + 20);
495
- cx.push(x);
496
- }
497
-
498
- // vertical layout pass: assign a y to every item
499
- const rows = [];
500
- const frames = [];
501
- const openFrames = [];
502
- let y = actorH + 14;
503
- const frameInset = () => openFrames.length * 8;
504
- for (const item of model.items) {
505
- if (item.kind === 'frame') {
506
- openFrames.push({ item, top: y, dividers: [] });
507
- y += meas.lineH + 12;
508
- continue;
509
- }
510
- if (item.kind === 'divider') {
511
- const f = openFrames[openFrames.length - 1];
512
- y += 8;
513
- if (f) f.dividers.push({ y, text: item.text });
514
- y += meas.lineH + 6;
515
- continue;
516
- }
517
- if (item.kind === 'frameEnd') {
518
- const f = openFrames.pop();
519
- if (f) {
520
- y += 8;
521
- frames.push({ ...f, bottom: y });
522
- y += 10;
523
- }
524
- continue;
525
- }
526
- if (item.kind === 'note') {
527
- const { w, h } = { w: meas.block(item.text).w, h: meas.block(item.text).h };
528
- rows.push({ item, y, noteW: w + 20, noteH: h + 10 });
529
- y += h + 24;
530
- continue;
531
- }
532
- // message
533
- const self = item.from === item.to;
534
- rows.push({ item, y });
535
- y += meas.lineH + (self ? 26 : 16);
536
- }
537
- while (openFrames.length) {
538
- const f = openFrames.pop();
539
- frames.push({ ...f, bottom: y });
540
- y += 10;
541
- }
542
- const bodyBottom = y + 6;
543
- const height = Math.ceil(bodyBottom + actorH);
544
- const width = Math.ceil(cx[cx.length - 1] + Math.max(24, boxW[P.length - 1] / 2 + 10));
545
-
546
- const drawActor = (ctx, i, ox, top) => {
547
- const w = boxW[i];
548
- const h = actorH;
549
- const x0 = ox + cx[i] - w / 2;
550
- ctx.fillStyle = theme.actorFill;
551
- ctx.strokeStyle = theme.actorBorder;
552
- ctx.lineWidth = 1.3;
553
- ctx.beginPath();
554
- ctx.roundRect(x0, top, w, h, 4);
555
- ctx.fill();
556
- ctx.beginPath();
557
- ctx.roundRect(x0, top, w, h, 4);
558
- ctx.stroke();
559
- drawTextBlock(ctx, meas, family, size, theme.text, [P[i].label], ox + cx[i], top + (h - meas.lineH) / 2);
560
- };
561
-
562
- return {
563
- type: 'sequence',
564
- width,
565
- height,
566
- warnings: model.warnings,
567
- draw(ctx, ox = 0, oy = 0) {
568
- // lifelines
569
- ctx.strokeStyle = theme.frameBorder;
570
- ctx.lineWidth = 1;
571
- for (let i = 0; i < P.length; i++) {
572
- dashedLine(ctx, cx[i] + ox, oy + actorH, cx[i] + ox, oy + bodyBottom, 4, 4);
573
- }
574
- // frames (behind messages, above lifelines)
575
- for (const f of frames) {
576
- const x0 = ox + 8;
577
- const x1 = ox + width - 8;
578
- ctx.strokeStyle = theme.frameBorder;
579
- ctx.lineWidth = 1;
580
- ctx.beginPath();
581
- ctx.rect(x0, oy + f.top, x1 - x0, f.bottom - f.top);
582
- ctx.stroke();
583
- const tag = f.item.frame;
584
- const tagW = meas.width(tag) + 14;
585
- ctx.fillStyle = theme.actorFill;
586
- ctx.fillRect(x0, oy + f.top, tagW, meas.lineH + 6);
587
- ctx.strokeRect(x0, oy + f.top, tagW, meas.lineH + 6);
588
- drawTextBlock(ctx, meas, family, size, theme.text, [tag], x0 + tagW / 2, oy + f.top + 3);
589
- if (f.item.text) {
590
- drawTextBlock(ctx, meas, family, size, theme.frameLabel, [`[${f.item.text}]`], x0 + tagW + meas.width(`[${f.item.text}]`) / 2 + 10, oy + f.top + 3);
591
- }
592
- for (const d of f.dividers) {
593
- dashedLine(ctx, x0, oy + d.y, x1, oy + d.y, 5, 4);
594
- if (d.text) {
595
- drawTextBlock(ctx, meas, family, size, theme.frameLabel, [`[${d.text}]`], x0 + 30 + meas.width(`[${d.text}]`) / 2, oy + d.y + 3);
596
- }
597
- }
598
- }
599
- // messages and notes
600
- for (const row of rows) {
601
- const item = row.item;
602
- if (item.kind === 'note') {
603
- const ids = item.ids.map((id) => idx.get(id));
604
- let nx;
605
- if (item.pos === 'over') {
606
- const lo = Math.min(...ids);
607
- const hi = Math.max(...ids);
608
- nx = (cx[lo] + cx[hi]) / 2;
609
- } else if (item.pos === 'right of') {
610
- nx = cx[ids[0]] + row.noteW / 2 + 12;
611
- } else {
612
- nx = cx[ids[0]] - row.noteW / 2 - 12;
613
- }
614
- const { lines } = meas.block(item.text);
615
- ctx.fillStyle = theme.noteFill;
616
- ctx.strokeStyle = theme.noteBorder;
617
- ctx.lineWidth = 1;
618
- ctx.fillRect(ox + nx - row.noteW / 2, oy + row.y, row.noteW, row.noteH);
619
- ctx.strokeRect(ox + nx - row.noteW / 2, oy + row.y, row.noteW, row.noteH);
620
- drawTextBlock(ctx, meas, family, size, theme.text, lines, ox + nx, oy + row.y + 5);
621
- continue;
622
- }
623
- // message
624
- const a = idx.get(item.from);
625
- const b = idx.get(item.to);
626
- const lineY = oy + row.y + meas.lineH + 4;
627
- ctx.strokeStyle = theme.line;
628
- ctx.lineWidth = 1.3;
629
- if (a === b) {
630
- // self message: out, down, back
631
- const x0 = ox + cx[a];
632
- const loopW = 28;
633
- const pts = [
634
- { x: x0, y: lineY },
635
- { x: x0 + loopW, y: lineY },
636
- { x: x0 + loopW, y: lineY + 14 },
637
- { x: x0, y: lineY + 14 }
638
- ];
639
- if (item.dotted) dashedPolyline(ctx, pts, 4, 3);
640
- else {
641
- ctx.beginPath();
642
- ctx.moveTo(pts[0].x, pts[0].y);
643
- for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
644
- ctx.stroke();
645
- }
646
- arrowHead(ctx, x0 + 2, lineY + 14, x0 + loopW, lineY + 14, 7, theme.line);
647
- drawTextBlock(ctx, meas, family, size, theme.text, meas.linesOf(item.text), x0 + loopW + 8 + meas.width(item.text) / 2, oy + row.y + 6);
648
- continue;
649
- }
650
- const x0 = ox + cx[a];
651
- const x1 = ox + cx[b];
652
- if (item.dotted) dashedLine(ctx, x0, lineY, x1, lineY, 5, 4);
653
- else {
654
- ctx.beginPath();
655
- ctx.moveTo(x0, lineY);
656
- ctx.lineTo(x1, lineY);
657
- ctx.stroke();
658
- }
659
- const dir = x1 > x0 ? 1 : -1;
660
- if (item.head === 'cross') {
661
- const cxx = x1 - dir * 6;
662
- ctx.beginPath();
663
- ctx.moveTo(cxx - 5, lineY - 5);
664
- ctx.lineTo(cxx + 5, lineY + 5);
665
- ctx.moveTo(cxx - 5, lineY + 5);
666
- ctx.lineTo(cxx + 5, lineY - 5);
667
- ctx.stroke();
668
- } else if (item.head !== 'none') {
669
- arrowHead(ctx, x1, lineY, x0, lineY, 8, theme.line);
670
- }
671
- if (item.text) {
672
- drawTextBlock(ctx, meas, family, size, theme.text, [item.text], (x0 + x1) / 2, oy + row.y);
673
- }
674
- }
675
- // actors, top and bottom
676
- for (let i = 0; i < P.length; i++) {
677
- drawActor(ctx, i, ox, oy);
678
- drawActor(ctx, i, ox, oy + bodyBottom);
679
- }
680
- }
681
- };
682
- }
683
-
684
- // ---------------------------------------------------------------------------
685
-
686
- /**
687
- * Lay out a mermaid diagram without drawing it. Returns a box:
688
- * `{ width, height, type, warnings, draw(ctx, x, y) }`.
689
- *
690
- * Supported: `flowchart`/`graph` (TB/TD/BT/LR/RL, all common node shapes,
691
- * solid/dotted/thick edges with labels, chains and `&` fans) and
692
- * `sequenceDiagram` (participants/actors, all arrow kinds, notes,
693
- * loop/opt/alt/par frames with else/and dividers, autonumber).
694
- * Anything else throws — callers fall back to a code block.
695
- *
696
- * @param {object} model parsed diagram from `await parseMermaid(text)`
697
- * @param {object} options { fonts, size = 13, family = 'sans-serif', theme,
698
- * maxWidth } — when maxWidth is given and the diagram comes out wider,
699
- * it is laid out again at a proportionally smaller font size (floor 8px)
700
- */
701
- export function layoutMermaid(model, { fonts, size = 13, family = 'sans-serif', theme = {}, maxWidth } = {}) {
702
- if (!fonts) throw new Error('layoutMermaid: fonts (FontManager) is required');
703
- if (!model || (model.type !== 'flowchart' && model.type !== 'sequence')) {
704
- throw new Error('layoutMermaid: expected a model from parseMermaid()');
705
- }
706
- const th = { ...DEFAULT_THEME, ...theme };
707
- const attempt = (px) => {
708
- const meas = makeMeasurer(fonts, family, px);
709
- return model.type === 'flowchart'
710
- ? layoutFlowchart(model, meas, family, px, th)
711
- : layoutSequence(model, meas, family, px, th);
712
- };
713
- let box = attempt(size);
714
- let px = size;
715
- for (let i = 0; maxWidth && box.width > maxWidth && px > 8 && i < 4; i++) {
716
- px = Math.max(8, Math.floor((px * maxWidth) / box.width));
717
- box = attempt(px);
718
- }
719
- return box;
720
- }