@jarenjs/mermaid 0.34.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 +282 -0
- package/dist/types/ast.d.ts +210 -0
- package/dist/types/component/index.d.ts +81 -0
- package/dist/types/errors.d.ts +27 -0
- package/dist/types/index.d.ts +97 -0
- package/dist/types/interactive.d.ts +36 -0
- package/dist/types/layout/flowchart.d.ts +12 -0
- package/dist/types/layout/sequence.d.ts +12 -0
- package/dist/types/layout/state.d.ts +19 -0
- package/dist/types/parser/class.d.ts +11 -0
- package/dist/types/parser/config.d.ts +61 -0
- package/dist/types/parser/er.d.ts +11 -0
- package/dist/types/parser/flowchart.d.ts +26 -0
- package/dist/types/parser/gantt.d.ts +11 -0
- package/dist/types/parser/index.d.ts +22 -0
- package/dist/types/parser/pie.d.ts +11 -0
- package/dist/types/parser/sequence.d.ts +16 -0
- package/dist/types/parser/state.d.ts +22 -0
- package/dist/types/plugin.d.ts +50 -0
- package/dist/types/render/error.d.ts +23 -0
- package/dist/types/render/flowchart.d.ts +18 -0
- package/dist/types/render/index.d.ts +23 -0
- package/dist/types/render/misc.d.ts +54 -0
- package/dist/types/render/sequence.d.ts +16 -0
- package/dist/types/styles.d.ts +81 -0
- package/dist/types/theme.d.ts +47 -0
- package/dist/types/to-mermaid.d.ts +20 -0
- package/dist/types/utils.d.ts +47 -0
- package/docs/MERMAID-FORMAT.md +242 -0
- package/package.json +84 -0
- package/schemas/jaren-mermaid-ast.schema.json +78 -0
- package/schemas/jaren-workflow.schema.json +28 -0
- package/src/ast.js +252 -0
- package/src/component/index.js +109 -0
- package/src/errors.js +35 -0
- package/src/index.js +155 -0
- package/src/interactive.js +244 -0
- package/src/layout/flowchart.js +352 -0
- package/src/layout/sequence.js +178 -0
- package/src/layout/state.js +65 -0
- package/src/parser/class.js +90 -0
- package/src/parser/config.js +215 -0
- package/src/parser/er.js +86 -0
- package/src/parser/flowchart.js +413 -0
- package/src/parser/gantt.js +49 -0
- package/src/parser/index.js +122 -0
- package/src/parser/pie.js +32 -0
- package/src/parser/sequence.js +156 -0
- package/src/parser/state.js +137 -0
- package/src/plugin.js +76 -0
- package/src/render/error.js +55 -0
- package/src/render/flowchart.js +249 -0
- package/src/render/index.js +93 -0
- package/src/render/misc.js +135 -0
- package/src/render/sequence.js +152 -0
- package/src/styles.js +181 -0
- package/src/theme.js +180 -0
- package/src/to-mermaid.js +317 -0
- package/src/utils.js +64 -0
- package/styles/mermaid.css +115 -0
- package/stylesheets/dag-to-flowchart.jslt.json +62 -0
- package/stylesheets/flowchart-to-dag.jslt.json +29 -0
- package/stylesheets/state-to-workflow.jslt.json +26 -0
- package/stylesheets/workflow-to-state.jslt.json +41 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Optional pan/zoom/touch for a rendered diagram.
|
|
4
|
+
*
|
|
5
|
+
* The render stays pure: this module is never imported by it, runs only in a
|
|
6
|
+
* browser, and only when a consumer opts in with `mermaidPlugin({ interactive:
|
|
7
|
+
* true })`. Server-rendered output is byte-identical with and without it, and
|
|
8
|
+
* a page that never enables it tree-shakes the whole file away.
|
|
9
|
+
*
|
|
10
|
+
* Everything happens on the SVG's `viewBox`. Nothing re-renders, nothing is
|
|
11
|
+
* re-parsed, no `eval`, no `innerHTML` — panning is four numbers changing.
|
|
12
|
+
*
|
|
13
|
+
* The interaction rules are chosen so the diagram never fights the page,
|
|
14
|
+
* which is the usual failure of embedded zoomable content:
|
|
15
|
+
*
|
|
16
|
+
* - **A plain wheel scrolls the page.** Zoom needs ctrl/⌘ (the browser's own
|
|
17
|
+
* zoom gesture) or the on-diagram buttons. Hijacking the wheel is the
|
|
18
|
+
* fastest way to make a document unreadable.
|
|
19
|
+
* - **A one-finger drag pans only once zoomed in.** At rest the whole
|
|
20
|
+
* diagram is visible, so there is nothing to pan to, and a swipe should
|
|
21
|
+
* scroll the page like every other element. `touch-action` is switched to
|
|
22
|
+
* match, so the browser never has to guess.
|
|
23
|
+
* - **Two fingers always pinch-zoom**, because that gesture means nothing
|
|
24
|
+
* else inside a figure.
|
|
25
|
+
* - **Keyboard works**: the figure is focusable, `+`/`-`/`0` zoom and reset,
|
|
26
|
+
* arrows pan. A pointer-only zoom control is not usable by everyone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const MIN_SCALE = 1;
|
|
30
|
+
const MAX_SCALE = 8;
|
|
31
|
+
const ZOOM_STEP = 1.35;
|
|
32
|
+
const PAN_STEP = 40;
|
|
33
|
+
|
|
34
|
+
/** Parse `viewBox` into a mutable box, or null when the SVG lacks one. */
|
|
35
|
+
function readViewBox(svg) {
|
|
36
|
+
const raw = svg.getAttribute('viewBox');
|
|
37
|
+
if (typeof raw !== 'string') return null;
|
|
38
|
+
const parts = raw.trim().split(/[\s,]+/).map(Number);
|
|
39
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
|
|
40
|
+
return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Attach pan/zoom to one rendered diagram element.
|
|
45
|
+
* @param {any} el the block element wrapping the `<svg>`
|
|
46
|
+
* @param {{ document?: any }} [env] injection seam for tests
|
|
47
|
+
* @returns {(() => void)|undefined} a teardown function, or undefined when
|
|
48
|
+
* there is nothing to attach to
|
|
49
|
+
*/
|
|
50
|
+
export function attachInteractiveDiagram(el, env = {}) {
|
|
51
|
+
const svg = el.querySelector === undefined ? null : el.querySelector('svg');
|
|
52
|
+
if (svg === null) return undefined;
|
|
53
|
+
const home = readViewBox(svg);
|
|
54
|
+
if (home === null) return undefined;
|
|
55
|
+
|
|
56
|
+
const doc = env.document ?? el.ownerDocument ?? globalThis.document;
|
|
57
|
+
const view = { ...home };
|
|
58
|
+
let scale = 1;
|
|
59
|
+
|
|
60
|
+
const apply = () => {
|
|
61
|
+
svg.setAttribute('viewBox', `${view.x} ${view.y} ${view.w} ${view.h}`);
|
|
62
|
+
// Once zoomed the drag belongs to the diagram; until then it belongs to
|
|
63
|
+
// the page. Telling the browser directly avoids a preventDefault race.
|
|
64
|
+
el.style.touchAction = scale > 1 ? 'none' : 'pan-y';
|
|
65
|
+
el.setAttribute('data-mm-zoom', scale.toFixed(2));
|
|
66
|
+
// At rest the figure is an ordinary scrolling frame showing the diagram at
|
|
67
|
+
// its natural, legible size; zooming switches it to a viewBox viewport.
|
|
68
|
+
if (typeof el.classList?.toggle === 'function')
|
|
69
|
+
el.classList.toggle('mm-zoomed', scale > 1);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Clamp the view so the diagram can never be panned off its own canvas. */
|
|
73
|
+
const clamp = () => {
|
|
74
|
+
view.w = home.w / scale;
|
|
75
|
+
view.h = home.h / scale;
|
|
76
|
+
view.x = Math.min(Math.max(view.x, home.x), home.x + home.w - view.w);
|
|
77
|
+
view.y = Math.min(Math.max(view.y, home.y), home.y + home.h - view.h);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Zoom about a point given in viewBox units. */
|
|
81
|
+
const zoomAt = (factor, px, py) => {
|
|
82
|
+
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * factor));
|
|
83
|
+
if (next === scale) return;
|
|
84
|
+
const ratio = scale / next;
|
|
85
|
+
view.x = px - (px - view.x) * ratio;
|
|
86
|
+
view.y = py - (py - view.y) * ratio;
|
|
87
|
+
scale = next;
|
|
88
|
+
clamp();
|
|
89
|
+
apply();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const centre = () => [view.x + view.w / 2, view.y + view.h / 2];
|
|
93
|
+
|
|
94
|
+
const reset = () => {
|
|
95
|
+
scale = 1;
|
|
96
|
+
view.x = home.x; view.y = home.y; view.w = home.w; view.h = home.h;
|
|
97
|
+
apply();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Client coordinates to viewBox units. */
|
|
101
|
+
const toView = (clientX, clientY) => {
|
|
102
|
+
const rect = svg.getBoundingClientRect();
|
|
103
|
+
if (rect.width === 0 || rect.height === 0) return centre();
|
|
104
|
+
return [
|
|
105
|
+
view.x + ((clientX - rect.left) / rect.width) * view.w,
|
|
106
|
+
view.y + ((clientY - rect.top) / rect.height) * view.h,
|
|
107
|
+
];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** @type {Map<number, {x: number, y: number}>} live pointers */
|
|
111
|
+
const pointers = new Map();
|
|
112
|
+
let pinchDistance = 0;
|
|
113
|
+
|
|
114
|
+
const onWheel = (event) => {
|
|
115
|
+
// A plain wheel is the page's. Only the browser's own zoom modifier
|
|
116
|
+
// means "zoom this thing".
|
|
117
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
const [px, py] = toView(event.clientX, event.clientY);
|
|
120
|
+
zoomAt(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, px, py);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onPointerDown = (event) => {
|
|
124
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
125
|
+
if (pointers.size === 2) {
|
|
126
|
+
const [a, b] = [...pointers.values()];
|
|
127
|
+
pinchDistance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
128
|
+
}
|
|
129
|
+
if (typeof svg.setPointerCapture === 'function' && pointers.size === 1) {
|
|
130
|
+
try { svg.setPointerCapture(event.pointerId); }
|
|
131
|
+
catch { /* a capture the browser declines is not fatal */ }
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onPointerMove = (event) => {
|
|
136
|
+
const previous = pointers.get(event.pointerId);
|
|
137
|
+
if (previous === undefined) return;
|
|
138
|
+
const current = { x: event.clientX, y: event.clientY };
|
|
139
|
+
pointers.set(event.pointerId, current);
|
|
140
|
+
|
|
141
|
+
if (pointers.size === 2) {
|
|
142
|
+
const [a, b] = [...pointers.values()];
|
|
143
|
+
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
144
|
+
if (pinchDistance > 0 && distance > 0) {
|
|
145
|
+
const [px, py] = toView((a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
146
|
+
zoomAt(distance / pinchDistance, px, py);
|
|
147
|
+
}
|
|
148
|
+
pinchDistance = distance;
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// At rest there is nothing to pan to, so the gesture is the page's.
|
|
154
|
+
if (scale <= 1) return;
|
|
155
|
+
const rect = svg.getBoundingClientRect();
|
|
156
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
157
|
+
view.x -= ((current.x - previous.x) / rect.width) * view.w;
|
|
158
|
+
view.y -= ((current.y - previous.y) / rect.height) * view.h;
|
|
159
|
+
clamp();
|
|
160
|
+
apply();
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const onPointerUp = (event) => {
|
|
165
|
+
pointers.delete(event.pointerId);
|
|
166
|
+
if (pointers.size < 2) pinchDistance = 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const onDoubleClick = (event) => {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
if (scale > 1) { reset(); return; }
|
|
172
|
+
const [px, py] = toView(event.clientX, event.clientY);
|
|
173
|
+
zoomAt(ZOOM_STEP * ZOOM_STEP, px, py);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const onKeyDown = (event) => {
|
|
177
|
+
const [cx, cy] = centre();
|
|
178
|
+
switch (event.key) {
|
|
179
|
+
case '+': case '=': zoomAt(ZOOM_STEP, cx, cy); break;
|
|
180
|
+
case '-': case '_': zoomAt(1 / ZOOM_STEP, cx, cy); break;
|
|
181
|
+
case '0': reset(); break;
|
|
182
|
+
case 'ArrowLeft': view.x -= PAN_STEP / scale; clamp(); apply(); break;
|
|
183
|
+
case 'ArrowRight': view.x += PAN_STEP / scale; clamp(); apply(); break;
|
|
184
|
+
case 'ArrowUp': view.y -= PAN_STEP / scale; clamp(); apply(); break;
|
|
185
|
+
case 'ArrowDown': view.y += PAN_STEP / scale; clamp(); apply(); break;
|
|
186
|
+
default: return;
|
|
187
|
+
}
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// The controls are built here, not in the render, so server output stays
|
|
192
|
+
// free of buttons that would do nothing without this module. They are an
|
|
193
|
+
// enhancement on top of an enhancement: without a document to build them,
|
|
194
|
+
// pointer, pinch and keyboard still work, so their absence must not take
|
|
195
|
+
// the viewer down with it.
|
|
196
|
+
let controls = null;
|
|
197
|
+
if (doc !== undefined && doc !== null && typeof doc.createElement === 'function') {
|
|
198
|
+
controls = doc.createElement('div');
|
|
199
|
+
controls.className = 'mm-controls';
|
|
200
|
+
const buttons = [
|
|
201
|
+
['+', 'Zoom in', () => { const [x, y] = centre(); zoomAt(ZOOM_STEP, x, y); }],
|
|
202
|
+
['−', 'Zoom out', () => { const [x, y] = centre(); zoomAt(1 / ZOOM_STEP, x, y); }],
|
|
203
|
+
['↺', 'Reset view', reset],
|
|
204
|
+
];
|
|
205
|
+
for (const [glyph, label, action] of buttons) {
|
|
206
|
+
const button = doc.createElement('button');
|
|
207
|
+
button.type = 'button';
|
|
208
|
+
button.className = 'mm-control';
|
|
209
|
+
button.textContent = glyph;
|
|
210
|
+
button.setAttribute('aria-label', label);
|
|
211
|
+
button.addEventListener('click', action);
|
|
212
|
+
controls.appendChild(button);
|
|
213
|
+
}
|
|
214
|
+
el.appendChild(controls);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
el.setAttribute('tabindex', '0');
|
|
218
|
+
el.setAttribute('role', 'group');
|
|
219
|
+
el.setAttribute('aria-label',
|
|
220
|
+
'Diagram. Use plus and minus to zoom, arrow keys to pan, zero to reset.');
|
|
221
|
+
el.classList.add('mm-interactive');
|
|
222
|
+
|
|
223
|
+
svg.addEventListener('wheel', onWheel, { passive: false });
|
|
224
|
+
svg.addEventListener('pointerdown', onPointerDown);
|
|
225
|
+
svg.addEventListener('pointermove', onPointerMove);
|
|
226
|
+
svg.addEventListener('pointerup', onPointerUp);
|
|
227
|
+
svg.addEventListener('pointercancel', onPointerUp);
|
|
228
|
+
svg.addEventListener('dblclick', onDoubleClick);
|
|
229
|
+
el.addEventListener('keydown', onKeyDown);
|
|
230
|
+
apply();
|
|
231
|
+
|
|
232
|
+
return () => {
|
|
233
|
+
svg.removeEventListener('wheel', onWheel);
|
|
234
|
+
svg.removeEventListener('pointerdown', onPointerDown);
|
|
235
|
+
svg.removeEventListener('pointermove', onPointerMove);
|
|
236
|
+
svg.removeEventListener('pointerup', onPointerUp);
|
|
237
|
+
svg.removeEventListener('pointercancel', onPointerUp);
|
|
238
|
+
svg.removeEventListener('dblclick', onDoubleClick);
|
|
239
|
+
el.removeEventListener('keydown', onKeyDown);
|
|
240
|
+
if (controls !== null && controls.parentNode !== null)
|
|
241
|
+
controls.parentNode.removeChild(controls);
|
|
242
|
+
el.classList.remove('mm-interactive');
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Flowchart layout: a compact "dagre-lite" — longest-path rank
|
|
4
|
+
* assignment, stable within-rank ordering, banded coordinate
|
|
5
|
+
* assignment, and straight border-clipped edge routing. Pure and
|
|
6
|
+
* deterministic (same AST → identical geometry), so the golden-JSON
|
|
7
|
+
* tests catch any drift. Output is a host-free `PositionedDiagram`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { measureText } from '@jarenjs/view/helpers';
|
|
11
|
+
import { coord as round } from '../utils.js';
|
|
12
|
+
|
|
13
|
+
import { resolveNodeStyles } from '../styles.js';
|
|
14
|
+
|
|
15
|
+
const FONT_SIZE = 14;
|
|
16
|
+
const PAD_X = 14;
|
|
17
|
+
const PAD_Y = 9;
|
|
18
|
+
const MIN_W = 48;
|
|
19
|
+
const MIN_H = 34;
|
|
20
|
+
const RANK_SEP = 54;
|
|
21
|
+
const NODE_SEP = 40;
|
|
22
|
+
const MARGIN = 12;
|
|
23
|
+
const SUBGRAPH_PAD = 18;
|
|
24
|
+
const SUBGRAPH_LABEL_H = 22;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {any} ast flowchart AST
|
|
28
|
+
* @returns {any} PositionedDiagram
|
|
29
|
+
*/
|
|
30
|
+
export function layoutFlowchart(ast) {
|
|
31
|
+
const nodeStyles = resolveNodeStyles(ast);
|
|
32
|
+
const dir = ast.direction || 'TB';
|
|
33
|
+
const horizontal = dir === 'LR' || dir === 'RL';
|
|
34
|
+
|
|
35
|
+
// 1. Node boxes from label metrics.
|
|
36
|
+
/** @type {Map<string, any>} */
|
|
37
|
+
const boxes = new Map();
|
|
38
|
+
for (const node of ast.nodes) {
|
|
39
|
+
boxes.set(node.id, sizeNode(node));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Rank assignment (longest path over predecessors).
|
|
43
|
+
const preds = new Map();
|
|
44
|
+
for (const node of ast.nodes) preds.set(node.id, []);
|
|
45
|
+
for (const e of ast.edges) {
|
|
46
|
+
if (preds.has(e.to)) preds.get(e.to).push(e.from);
|
|
47
|
+
}
|
|
48
|
+
const rank = new Map();
|
|
49
|
+
const computing = new Set();
|
|
50
|
+
const rankOf = (id) => {
|
|
51
|
+
if (rank.has(id)) return rank.get(id);
|
|
52
|
+
if (computing.has(id)) return 0;
|
|
53
|
+
computing.add(id);
|
|
54
|
+
let r = 0;
|
|
55
|
+
for (const p of preds.get(id) ?? []) {
|
|
56
|
+
if (p !== id) r = Math.max(r, rankOf(p) + 1);
|
|
57
|
+
}
|
|
58
|
+
computing.delete(id);
|
|
59
|
+
rank.set(id, r);
|
|
60
|
+
return r;
|
|
61
|
+
};
|
|
62
|
+
for (const node of ast.nodes) rankOf(node.id);
|
|
63
|
+
|
|
64
|
+
// 3. Group by rank, preserve AST order within a rank.
|
|
65
|
+
/** @type {Map<number, string[]>} */
|
|
66
|
+
const ranks = new Map();
|
|
67
|
+
let maxRank = 0;
|
|
68
|
+
for (const node of ast.nodes) {
|
|
69
|
+
const r = rank.get(node.id);
|
|
70
|
+
if (!ranks.has(r)) ranks.set(r, []);
|
|
71
|
+
ranks.get(r).push(node.id);
|
|
72
|
+
if (r > maxRank) maxRank = r;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. Cross-axis extent per rank and the along-axis band offsets.
|
|
76
|
+
const bandStart = []; // cumulative main-axis position per rank
|
|
77
|
+
const bandSize = []; // main-axis size (height for TB, width for LR) per rank
|
|
78
|
+
let mainCursor = MARGIN;
|
|
79
|
+
let maxCross = 0;
|
|
80
|
+
for (let r = 0; r <= maxRank; r++) {
|
|
81
|
+
const ids = ranks.get(r) ?? [];
|
|
82
|
+
let mainMax = 0;
|
|
83
|
+
let crossTotal = 0;
|
|
84
|
+
for (let i = 0; i < ids.length; i++) {
|
|
85
|
+
const b = boxes.get(ids[i]);
|
|
86
|
+
const main = horizontal ? b.w : b.h;
|
|
87
|
+
const cross = horizontal ? b.h : b.w;
|
|
88
|
+
if (main > mainMax) mainMax = main;
|
|
89
|
+
crossTotal += cross + (i > 0 ? NODE_SEP : 0);
|
|
90
|
+
}
|
|
91
|
+
bandStart[r] = mainCursor;
|
|
92
|
+
bandSize[r] = mainMax;
|
|
93
|
+
mainCursor += mainMax + RANK_SEP;
|
|
94
|
+
if (crossTotal > maxCross) maxCross = crossTotal;
|
|
95
|
+
}
|
|
96
|
+
const mainTotal = mainCursor - RANK_SEP + MARGIN;
|
|
97
|
+
|
|
98
|
+
// 5. Place nodes: center each rank on the cross axis.
|
|
99
|
+
for (let r = 0; r <= maxRank; r++) {
|
|
100
|
+
const ids = ranks.get(r) ?? [];
|
|
101
|
+
let crossTotal = 0;
|
|
102
|
+
for (let i = 0; i < ids.length; i++) {
|
|
103
|
+
const b = boxes.get(ids[i]);
|
|
104
|
+
crossTotal += (horizontal ? b.h : b.w) + (i > 0 ? NODE_SEP : 0);
|
|
105
|
+
}
|
|
106
|
+
let cross = MARGIN + (maxCross - crossTotal) / 2;
|
|
107
|
+
for (let i = 0; i < ids.length; i++) {
|
|
108
|
+
const b = boxes.get(ids[i]);
|
|
109
|
+
const main = horizontal ? b.w : b.h;
|
|
110
|
+
const crossExtent = horizontal ? b.h : b.w;
|
|
111
|
+
const mainPos = bandStart[r] + (bandSize[r] - main) / 2;
|
|
112
|
+
if (horizontal) {
|
|
113
|
+
b.x = mainPos;
|
|
114
|
+
b.y = cross;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
b.x = cross;
|
|
118
|
+
b.y = mainPos;
|
|
119
|
+
}
|
|
120
|
+
cross += crossExtent + NODE_SEP;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let width = horizontal ? mainTotal : maxCross + 2 * MARGIN;
|
|
125
|
+
let height = horizontal ? maxCross + 2 * MARGIN : mainTotal;
|
|
126
|
+
|
|
127
|
+
// 6. Flip for BT / RL.
|
|
128
|
+
if (dir === 'BT') {
|
|
129
|
+
for (const b of boxes.values()) b.y = height - b.y - b.h;
|
|
130
|
+
}
|
|
131
|
+
else if (dir === 'RL') {
|
|
132
|
+
for (const b of boxes.values()) b.x = width - b.x - b.w;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 7. Positioned nodes.
|
|
136
|
+
const nodes = ast.nodes.map((node) => {
|
|
137
|
+
const b = boxes.get(node.id);
|
|
138
|
+
const styles = nodeStyles.get(node.id);
|
|
139
|
+
const box = { id: node.id, x: b.x, y: b.y, w: b.w, h: b.h, shape: node.shape, label: node.label };
|
|
140
|
+
// Carried on the positioned node so the scene stays self-describing: a
|
|
141
|
+
// renderer never has to reach back into the AST to know how to paint.
|
|
142
|
+
if (styles !== undefined) box.styles = styles;
|
|
143
|
+
return box;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 8. Edges: straight, clipped to node borders.
|
|
147
|
+
const edges = ast.edges.map((e) => {
|
|
148
|
+
const a = boxes.get(e.from);
|
|
149
|
+
const b = boxes.get(e.to);
|
|
150
|
+
if (a === undefined || b === undefined) {
|
|
151
|
+
return { from: e.from, to: e.to, points: [], label: e.label, labelPos: null, stroke: e.stroke, head: e.head, tail: e.tail };
|
|
152
|
+
}
|
|
153
|
+
const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 };
|
|
154
|
+
const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 };
|
|
155
|
+
const p1 = clipToBox(bc, ac, a);
|
|
156
|
+
const p2 = clipToBox(ac, bc, b);
|
|
157
|
+
const edge = {
|
|
158
|
+
from: e.from, to: e.to, points: [p1, p2], label: e.label,
|
|
159
|
+
labelPos: null, stroke: e.stroke, head: e.head, tail: e.tail,
|
|
160
|
+
};
|
|
161
|
+
if (e.label != null) {
|
|
162
|
+
// Measured, not estimated from the character count: the renderer used to
|
|
163
|
+
// guess the background width and a wide label overflowed its own box.
|
|
164
|
+
const m = measureText(e.label, FONT_SIZE);
|
|
165
|
+
edge.labelW = m.width + LABEL_PAD;
|
|
166
|
+
edge.labelH = m.height + LABEL_PAD / 2;
|
|
167
|
+
edge.labelPos = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
168
|
+
}
|
|
169
|
+
return edge;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
placeEdgeLabels(edges, nodes);
|
|
173
|
+
|
|
174
|
+
// 9. Subgraph bounding boxes around their members.
|
|
175
|
+
const subgraphs = ast.subgraphs.map((sg) => {
|
|
176
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
177
|
+
for (const id of sg.nodes) {
|
|
178
|
+
const b = boxes.get(id);
|
|
179
|
+
if (b === undefined) continue;
|
|
180
|
+
minX = Math.min(minX, b.x); minY = Math.min(minY, b.y);
|
|
181
|
+
maxX = Math.max(maxX, b.x + b.w); maxY = Math.max(maxY, b.y + b.h);
|
|
182
|
+
}
|
|
183
|
+
if (!isFinite(minX)) return { id: sg.id, label: sg.label, x: 0, y: 0, w: 0, h: 0 };
|
|
184
|
+
return {
|
|
185
|
+
id: sg.id,
|
|
186
|
+
label: sg.label,
|
|
187
|
+
x: minX - SUBGRAPH_PAD,
|
|
188
|
+
y: minY - SUBGRAPH_PAD - SUBGRAPH_LABEL_H,
|
|
189
|
+
w: maxX - minX + 2 * SUBGRAPH_PAD,
|
|
190
|
+
h: maxY - minY + 2 * SUBGRAPH_PAD + SUBGRAPH_LABEL_H,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Expand canvas for subgraph frames that spill past the content box.
|
|
195
|
+
for (const s of subgraphs) {
|
|
196
|
+
width = Math.max(width, s.x + s.w + MARGIN);
|
|
197
|
+
height = Math.max(height, s.y + s.h + MARGIN);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
type: 'flowchart',
|
|
202
|
+
direction: dir,
|
|
203
|
+
width: round(width),
|
|
204
|
+
height: round(height),
|
|
205
|
+
nodes: nodes.map(roundBox),
|
|
206
|
+
edges: edges.map(roundEdge),
|
|
207
|
+
subgraphs: subgraphs.map(roundBox),
|
|
208
|
+
fontSize: FONT_SIZE,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {any} node
|
|
214
|
+
* @returns {any}
|
|
215
|
+
*/
|
|
216
|
+
function sizeNode(node) {
|
|
217
|
+
const shape = node.shape;
|
|
218
|
+
// The state-diagram pseudo-states are fixed-size markers, never
|
|
219
|
+
// label-sized boxes. An empty-labeled doublecircle cannot come from
|
|
220
|
+
// flowchart source (the parser defaults a missing label to the id),
|
|
221
|
+
// so the branch only fires for the state layout's synthetic end node.
|
|
222
|
+
if (shape === 'statedot') {
|
|
223
|
+
return { x: 0, y: 0, w: 14, h: 14, lines: [] };
|
|
224
|
+
}
|
|
225
|
+
if (shape === 'doublecircle' && node.label === '') {
|
|
226
|
+
return { x: 0, y: 0, w: 22, h: 22, lines: [] };
|
|
227
|
+
}
|
|
228
|
+
const m = measureText(node.label, FONT_SIZE);
|
|
229
|
+
let w = m.width + 2 * PAD_X;
|
|
230
|
+
let h = m.height + 2 * PAD_Y;
|
|
231
|
+
if (shape === 'circle' || shape === 'doublecircle') {
|
|
232
|
+
const d = Math.max(w, h, MIN_H + 8);
|
|
233
|
+
w = d; h = d;
|
|
234
|
+
}
|
|
235
|
+
else if (shape === 'diamond') {
|
|
236
|
+
w = Math.max(w * 1.4, MIN_W + 20);
|
|
237
|
+
h = Math.max(h * 1.4, MIN_H + 8);
|
|
238
|
+
}
|
|
239
|
+
else if (shape === 'hexagon') {
|
|
240
|
+
w += 20;
|
|
241
|
+
}
|
|
242
|
+
else if (shape === 'stadium' || shape === 'round') {
|
|
243
|
+
w += 10;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
x: 0, y: 0,
|
|
247
|
+
w: Math.max(w, MIN_W),
|
|
248
|
+
h: Math.max(h, MIN_H),
|
|
249
|
+
lines: m.lines,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Intersect the segment from an external point `from` to the box center
|
|
255
|
+
* with the box border.
|
|
256
|
+
* @param {{x:number,y:number}} from
|
|
257
|
+
* @param {{x:number,y:number}} to box center
|
|
258
|
+
* @param {any} box
|
|
259
|
+
* @returns {{x:number,y:number}}
|
|
260
|
+
*/
|
|
261
|
+
function clipToBox(from, to, box) {
|
|
262
|
+
const cx = box.x + box.w / 2;
|
|
263
|
+
const cy = box.y + box.h / 2;
|
|
264
|
+
const dx = from.x - cx;
|
|
265
|
+
const dy = from.y - cy;
|
|
266
|
+
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
|
267
|
+
const hw = box.w / 2;
|
|
268
|
+
const hh = box.h / 2;
|
|
269
|
+
const scaleX = dx === 0 ? Infinity : hw / Math.abs(dx);
|
|
270
|
+
const scaleY = dy === 0 ? Infinity : hh / Math.abs(dy);
|
|
271
|
+
const scale = Math.min(scaleX, scaleY);
|
|
272
|
+
return { x: cx + dx * scale, y: cy + dy * scale };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const roundBox = (b) => ({ ...b, x: round(b.x), y: round(b.y), w: round(b.w), h: round(b.h) });
|
|
276
|
+
|
|
277
|
+
/** Horizontal breathing room around an edge label's background. */
|
|
278
|
+
const LABEL_PAD = 10;
|
|
279
|
+
|
|
280
|
+
/** How far along an edge a label may sit, nearest the middle first. */
|
|
281
|
+
const LABEL_SLOTS = [0.5, 0.42, 0.58, 0.34, 0.66];
|
|
282
|
+
|
|
283
|
+
/** Sideways steps, in label-heights, tried when sliding alone cannot free a
|
|
284
|
+
* label. Perpendicular to a near-vertical edge is horizontal, which is where
|
|
285
|
+
* the space actually is when two edges leave one node. */
|
|
286
|
+
const LABEL_OFFSETS = [0, 1, -1, 2, -2];
|
|
287
|
+
|
|
288
|
+
/** Whether two label boxes overlap, with a small gap required between them. */
|
|
289
|
+
function labelsOverlap(a, b) {
|
|
290
|
+
const gap = 2;
|
|
291
|
+
return Math.abs(a.x - b.x) * 2 < a.w + b.w + gap * 2
|
|
292
|
+
&& Math.abs(a.y - b.y) * 2 < a.h + b.h + gap * 2;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Place each edge label so it does not cover one already placed.
|
|
297
|
+
*
|
|
298
|
+
* Two edges leaving the same node land their midpoints at the same height, so
|
|
299
|
+
* with any pair of long labels the second one's opaque background covered the
|
|
300
|
+
* first — the reason the bundled diagrams had to keep edge labels short.
|
|
301
|
+
*
|
|
302
|
+
* Candidates are tried in order of how far they stray from the natural
|
|
303
|
+
* midpoint: first slide ALONG the edge (which keeps the label on its line),
|
|
304
|
+
* then step sideways. A label with nowhere free keeps the midpoint rather than
|
|
305
|
+
* drifting somewhere arbitrary — an honest overlap beats a confusing one.
|
|
306
|
+
*
|
|
307
|
+
* Deterministic by construction: edges are visited in document order and the
|
|
308
|
+
* candidate list is fixed, so a diagram always lays out the same way.
|
|
309
|
+
* Node boxes are seeded as obstacles too: a label that lands on top of a box
|
|
310
|
+
* it has nothing to do with reads as that box's text.
|
|
311
|
+
* @param {any[]} edges positioned edges, mutated in place
|
|
312
|
+
* @param {any[]} nodes positioned nodes, treated as obstacles
|
|
313
|
+
*/
|
|
314
|
+
function placeEdgeLabels(edges, nodes) {
|
|
315
|
+
const placed = nodes.map((n) => ({
|
|
316
|
+
x: n.x + n.w / 2, y: n.y + n.h / 2, w: n.w, h: n.h,
|
|
317
|
+
}));
|
|
318
|
+
for (const e of edges) {
|
|
319
|
+
if (e.labelPos === null || e.points.length < 2) continue;
|
|
320
|
+
const [p1, p2] = e.points;
|
|
321
|
+
const dx = p2.x - p1.x;
|
|
322
|
+
const dy = p2.y - p1.y;
|
|
323
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
324
|
+
// The unit normal: sideways relative to this edge, whichever way it runs.
|
|
325
|
+
const nx = -dy / len;
|
|
326
|
+
const ny = dx / len;
|
|
327
|
+
|
|
328
|
+
let chosen = null;
|
|
329
|
+
for (const off of LABEL_OFFSETS) {
|
|
330
|
+
for (const t of LABEL_SLOTS) {
|
|
331
|
+
const step = off * (e.labelH + 4);
|
|
332
|
+
const candidate = {
|
|
333
|
+
x: p1.x + dx * t + nx * step,
|
|
334
|
+
y: p1.y + dy * t + ny * step,
|
|
335
|
+
w: e.labelW, h: e.labelH,
|
|
336
|
+
};
|
|
337
|
+
if (placed.some((other) => labelsOverlap(candidate, other))) continue;
|
|
338
|
+
chosen = candidate;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
if (chosen !== null) break;
|
|
342
|
+
}
|
|
343
|
+
if (chosen === null) continue; // keep the midpoint; nothing is free
|
|
344
|
+
e.labelPos = { x: chosen.x, y: chosen.y };
|
|
345
|
+
placed.push(chosen);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const roundEdge = (e) => ({
|
|
349
|
+
...e,
|
|
350
|
+
points: e.points.map((p) => ({ x: round(p.x), y: round(p.y) })),
|
|
351
|
+
labelPos: e.labelPos ? { x: round(e.labelPos.x), y: round(e.labelPos.y) } : null,
|
|
352
|
+
});
|