lowkey-doc 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,2477 @@
1
+ // diagrams.mjs - the client-side diagram engine, inlined into the page.
2
+ //
3
+ // Two renderers live here.
4
+ //
5
+ // mermaid - mermaid's own, used for every diagram type the other one does
6
+ // not cover (state, class, gantt, pie, ...) and as the fallback
7
+ // whenever the other one cannot do the job.
8
+ //
9
+ // clean - flowcharts, ER diagrams and sequence diagrams. Mermaid parses
10
+ // the source, ELK lays a graph out (a sequence is laid out here,
11
+ // lane by lane), and this file draws the result.
12
+ //
13
+ // Why not just use mermaid for flowcharts: mermaid lays them out with dagre and
14
+ // routes edges as curves, which on a dense chart produces crossing arcs and edge
15
+ // labels dropped wherever they happen to land - often on top of each other, or
16
+ // nearer a stranger's arc than their own. The reader then cannot tell which text
17
+ // belongs to which arrow, which is the one thing that makes a dense flowchart
18
+ // unreadable.
19
+ //
20
+ // ELK's layered algorithm fixes that at the source, in three ways:
21
+ // - orthogonal routing: arcs run in straight lines with right-angle bends
22
+ // and share lanes, instead of sweeping across the drawing as curves;
23
+ // - reserved label space: an edge label is a first-class object in the
24
+ // layout, so the algorithm sets room aside for it. Nothing is placed on
25
+ // top of a label, and a label is never dropped onto a stranger's arc;
26
+ // - crossing minimisation as a real objective rather than a side effect.
27
+ //
28
+ // Layout alone still leaves one ambiguity: six parallel arcs with six labels
29
+ // beside them. So each label is also tied to its own arc by a hairline ending
30
+ // in a dot ON that arc, and hovering either the label or the arc lights the
31
+ // pair while dimming everything else. Between the two, "which arrow is this
32
+ // text on" stops being a guess.
33
+ //
34
+ // ER diagrams have both of those problems - mermaid routes relationships as
35
+ // curves and drops the role name near the middle - plus one of their own: an
36
+ // entity is a table, and drawing it as a grid of equal cells means nothing
37
+ // lines up, so every attribute has to be read word by word. They come through
38
+ // the same pipeline because mermaid hands back the same shape of model for
39
+ // both, and the entity is drawn as a real table: type and name in columns, the
40
+ // keys pinned to the right edge so PK/FK/UK form a column of their own, and a
41
+ // crow's foot at each end of every relationship.
42
+ //
43
+ // None of this is required. With no ELK on the page every diagram renders
44
+ // through mermaid, and the reader can switch any single diagram back to
45
+ // mermaid's layout from the diagram itself.
46
+
47
+ export const DIAGRAMS_JS = String.raw`
48
+ (function(){
49
+ var CFG = window.__LOWKEY_DOC__ || {};
50
+ var NS = 'http://www.w3.org/2000/svg';
51
+ var seq = 0;
52
+ var elk = null;
53
+ var mermaidReady = false;
54
+
55
+ function sel(v){ return (window.CSS && CSS.escape) ? CSS.escape(v) : String(v).replace(/["\\]/g,'\\$&'); }
56
+
57
+ function mk(tag, attrs, cls){
58
+ var e = document.createElementNS(NS, tag);
59
+ if (cls) e.setAttribute('class', cls);
60
+ for (var k in attrs) if (attrs[k] != null) e.setAttribute(k, attrs[k]);
61
+ return e;
62
+ }
63
+ function el(tag, cls, text){
64
+ var n = document.createElement(tag);
65
+ if (cls) n.className = cls;
66
+ if (text != null) n.textContent = text;
67
+ return n;
68
+ }
69
+
70
+ /* ------------------------------------------------------------ mermaid */
71
+
72
+ function initMermaid(){
73
+ if (mermaidReady || typeof mermaid === 'undefined') return mermaidReady;
74
+ mermaid.initialize({
75
+ startOnLoad: false,
76
+ // no click callbacks or javascript: links: a diagram can only draw
77
+ securityLevel: 'strict',
78
+ theme: 'base',
79
+ fontFamily: getComputedStyle(document.body).fontFamily,
80
+ themeVariables: {
81
+ fontSize: '13px',
82
+ darkMode: true,
83
+ background: '#13161b',
84
+ primaryColor: '#1a1e24', primaryBorderColor: '#3a414c', primaryTextColor: '#f3f5f7',
85
+ secondaryColor: '#13161b', secondaryBorderColor: '#323842', secondaryTextColor: '#f3f5f7',
86
+ tertiaryColor: '#13161b', tertiaryBorderColor: '#323842', tertiaryTextColor: '#f3f5f7',
87
+ lineColor: '#6b7480', textColor: '#f3f5f7',
88
+ mainBkg: '#1a1e24', nodeBorder: '#3a414c', clusterBkg: '#0f1216', clusterBorder: '#262b33',
89
+ edgeLabelBackground: '#13161b', labelBoxBkgColor: '#13161b', labelBoxBorderColor: '#3a414c',
90
+ pie1: '#4da3ff', pie2: '#1e6fff', pie3: '#1446b0', pie4: '#9aa2ad', pie5: '#3a414c',
91
+ pieTitleTextSize: '14px', pieSectionTextSize: '12px', pieStrokeColor: '#13161b', pieOuterStrokeColor: '#323842'
92
+ },
93
+ flowchart: { curve: 'basis', padding: 14, nodeSpacing: 38, rankSpacing: 46, useMaxWidth: true, htmlLabels: true },
94
+ er: { fill: '#1a1e24', stroke: '#3a414c', useMaxWidth: true, entityPadding: 12 },
95
+ sequence: { useMaxWidth: true, actorMargin: 44, mirrorActors: false }
96
+ });
97
+ mermaidReady = true;
98
+ return true;
99
+ }
100
+
101
+ function renderMermaid(fig, code){
102
+ var out = fig.querySelector('.diagram-out');
103
+ if (!initMermaid()){
104
+ out.innerHTML = '<div class="fail">Diagram engine unavailable offline. Re-run the build with a network connection.</div>';
105
+ return Promise.resolve();
106
+ }
107
+ return mermaid.render('mmd-' + (++seq), code).then(function(res){
108
+ out.innerHTML = res.svg;
109
+ }).catch(function(err){
110
+ out.textContent = '';
111
+ out.appendChild(el('div','fail', String(err && err.message || err)));
112
+ fig.querySelector('.diagram-src').style.display = 'block';
113
+ });
114
+ }
115
+
116
+ /* ------------------------------------------------------------ measuring
117
+ Text is measured on a canvas using the page's own resolved font, so a box
118
+ is the size of the words in it. The set is measured again once the webfont
119
+ has actually loaded: Inter and the fallback are not the same width, and a
120
+ diagram laid out against the wrong one comes out loose. */
121
+
122
+ var cv = document.createElement('canvas').getContext('2d');
123
+ function fontString(px, weight){
124
+ return (weight ? weight + ' ' : '') + px + 'px ' +
125
+ (getComputedStyle(document.body).fontFamily || 'sans-serif');
126
+ }
127
+ // Mermaid parks its own entity codes (#35; #quot;) behind placeholders while
128
+ // it parses and only puts them back when it draws; the parsed model still
129
+ // carries the placeholders, so they are put back here first.
130
+ function decode(s){
131
+ var t = document.createElement('textarea');
132
+ t.innerHTML = String(s == null ? '' : s)
133
+ .replace(/fl°°/g, '&#').replace(/fl°/g, '&').replace(/¶ß/g, ';');
134
+ return t.value;
135
+ }
136
+ function splitLabel(label){
137
+ var out = decode(label).split(/<br\s*\/?>|\n/).map(function(s){
138
+ return s.replace(/<[^>]*>/g,'').trim();
139
+ });
140
+ return out.length ? out : [''];
141
+ }
142
+ // hard: a single word wider than the line - a path, a URL, a dotted table
143
+ // name - is broken after its own punctuation rather than left to run long,
144
+ // and a wrapped line is balanced, so it never ends on one stranded word
145
+ function measure(label, px, maxw, weight, hard){
146
+ cv.font = fontString(px, weight);
147
+ var raw = splitLabel(label), out = [];
148
+ var fill = function(pieces, width){
149
+ var lines = [], cur = '';
150
+ for (var j=0;j<pieces.length;j++){
151
+ var t = cur ? cur + (pieces[j].glue ? '' : ' ') + pieces[j].t : pieces[j].t;
152
+ if (cv.measureText(t).width > width && cur){ lines.push(cur); cur = pieces[j].t; }
153
+ else cur = t;
154
+ }
155
+ if (cur) lines.push(cur);
156
+ return lines;
157
+ };
158
+ for (var i=0;i<raw.length;i++){
159
+ var line = raw[i];
160
+ if (!maxw || cv.measureText(line).width <= maxw){ out.push(line); continue; }
161
+ var pieces = [];
162
+ line.split(' ').forEach(function(w){
163
+ if (hard && cv.measureText(w).width > maxw){
164
+ w.split(/(?<=[\/._,;:=&?-])/).forEach(function(p, k){ pieces.push({ t:p, glue:k > 0 }); });
165
+ } else pieces.push({ t:w, glue:false });
166
+ });
167
+ var lines = fill(pieces, maxw);
168
+ if (hard && lines.length > 1){
169
+ // the narrowest width that still needs no more lines than the greedy fill
170
+ var lo = cv.measureText(line).width / lines.length, hi = maxw;
171
+ for (var step=0; step<9 && hi - lo > 2; step++){
172
+ var mid = (lo + hi)/2;
173
+ if (fill(pieces, mid).length > lines.length) lo = mid; else hi = mid;
174
+ }
175
+ lines = fill(pieces, hi);
176
+ }
177
+ out.push.apply(out, lines);
178
+ }
179
+ if (!out.length) out.push('');
180
+ var w = 0;
181
+ for (var k=0;k<out.length;k++) w = Math.max(w, cv.measureText(out[k]).width);
182
+ var lh = px * 1.34;
183
+ return { lines: out, w: Math.ceil(w), h: Math.ceil(out.length * lh), lh: lh };
184
+ }
185
+
186
+ /* ------------------------------------------------------------ elk graph */
187
+
188
+ var NODE_FS = 12.5, EDGE_FS = 11.5;
189
+ var PAD_X = 16, PAD_Y = 11;
190
+ var DIRS = { TB:'DOWN', TD:'DOWN', BT:'UP', LR:'RIGHT', RL:'LEFT' };
191
+
192
+ // An entity box is several times the size of a flowchart node, so an ER chart
193
+ // is laid out to its own spacing profile: wider apart across a layer, and
194
+ // slightly tighter between layers, because the gap between two entities is
195
+ // dominated by the relationship label rather than by this number. (See the
196
+ // edge labels in buildGraph for why.)
197
+ var SPACING = {
198
+ flow: { node:'40', layer:'54', edgeNode:'22', edgeNodeLayer:'24',
199
+ edgeEdge:'16', edgeEdgeLayer:'14', pad:'14' },
200
+ er: { node:'58', layer:'50', edgeNode:'34', edgeNodeLayer:'36',
201
+ edgeEdge:'20', edgeEdgeLayer:'18', pad:'18' }
202
+ };
203
+
204
+ /* ------------------------------------------------------------ entity box
205
+ An ER entity is not a labelled box, it is a small table, and drawing it
206
+ as one run of text per attribute is what makes mermaid's ER diagrams hard
207
+ to read: nothing lines up, so every row has to be parsed word by word.
208
+ Here the attributes are measured as real columns - type, name, comment,
209
+ and the keys pinned to the right edge - so PK/FK/UK form a column of their
210
+ own and the eye can scan down one of them instead of reading all of it. */
211
+
212
+ var ER_TITLE_FS = 13, ER_FS = 11.6, ER_KEY_FS = 9.4;
213
+ var ER_PAD = 13, ER_ROW_H = 21, ER_COL_GAP = 15, ER_HEAD_PAD = 9;
214
+ var ER_BADGE_H = 14, ER_BADGE_PAD = 5, ER_BADGE_GAP = 4;
215
+ // How much clear line a cardinality glyph needs at the end of a relationship.
216
+ var ER_END_CLEAR = 22;
217
+ // A comment up to this wide is a note, read in the table; a longer one is a
218
+ // description, and the opened entity is where it is read.
219
+ var ER_NOTE_W = 170;
220
+ // the room the header gives the button that opens and closes the entity
221
+ var ER_MORE = 24;
222
+ // An opened entity: its description column wraps at ER_OPEN_DESC_W, its
223
+ // notes at no less than ER_OPEN_NOTES_W, code in them is set at ER_CODE_FS.
224
+ var ER_OPEN_DESC_W = 300, ER_OPEN_NOTES_W = 380, ER_OPEN_HEAD_H = 24, ER_OPEN_GAP = 16;
225
+ var ER_LABEL_FS = 9.2, ER_CODE_FS = 10.8, ER_REF_FS = 10.6;
226
+
227
+ /* ------------------------------------------------------- entity details
228
+ Mermaid gives a column a type, a name, its keys and one comment. A schema
229
+ document needs more - whether the column can be null, its default, what it
230
+ references, a description longer than a diagram can hold - and so does the
231
+ entity itself. Those are written in %% lines, which mermaid ignores, so the
232
+ same source still renders wherever mermaid does:
233
+
234
+ DOCK["post.dock"] {
235
+ %% What the table is for. <- under the brace: the entity
236
+ %% - a convention, or a constraint <- a list item
237
+ VARCHAR(12) dock_code PK "What the column holds"
238
+ %% not null · default 'x' <- under a column: that column
239
+ %% references post.other
240
+ }
241
+
242
+ A %% line under a column made only of null / not null / default ... /
243
+ references ..., separated by a middle dot or a semicolon, sets those; any
244
+ other text there is more description. */
245
+
246
+ var ER_WORD = /^(?:not\s+null|null|nullable|default(?:\s*:)?(?:\s+.*)?|references?(?:\s*:)?\s+.+|ref(?:\s*:)?\s+.+)$/i;
247
+
248
+ function erDetails(code){
249
+ var found = {}, cur = null, col = null;
250
+ String(code).split(/\r?\n/).forEach(function(raw){
251
+ var line = raw.trim();
252
+ if (!cur){
253
+ var head = line.match(/^(?:"([^"]+)"|([^\s\["{]+))\s*(?:\[[^\]]*\])?\s*\{\s*$/);
254
+ if (head){
255
+ var name = head[1] || head[2];
256
+ cur = found[name] = found[name] || { notes: [], cols: [] };
257
+ col = null;
258
+ }
259
+ return;
260
+ }
261
+ if (/^\}/.test(line)){ cur = null; return; }
262
+ var c = line.match(/^%%(?!\{)\s?(.*)$/);
263
+ if (c){ (col ? col.lines : cur.notes).push(c[1]); return; }
264
+ if (!line) return;
265
+ col = { lines: [] };
266
+ cur.cols.push(col);
267
+ });
268
+ return found;
269
+ }
270
+
271
+ function erColumn(lines){
272
+ var meta = { nullable: null, def: '', ref: '', more: [] };
273
+ lines.forEach(function(line){
274
+ var bits = line.split(/\s+·\s+|\s*;\s*/).map(function(b){ return b.trim(); }).filter(Boolean);
275
+ if (!bits.length || !bits.every(function(b){ return ER_WORD.test(b); })){
276
+ if (line.trim()) meta.more.push(line.trim());
277
+ return;
278
+ }
279
+ bits.forEach(function(b){
280
+ var m;
281
+ if (/^not\s+null$/i.test(b)) meta.nullable = false;
282
+ else if (/^(?:null|nullable)$/i.test(b)) meta.nullable = true;
283
+ else if ((m = b.match(/^default(?:\s*:)?\s*(.*)$/i))) meta.def = m[1].replace(/^\x60(.*)\x60$/, '$1');
284
+ else if ((m = b.match(/^ref(?:erences?)?(?:\s*:)?\s+(.+)$/i))) meta.ref = m[1];
285
+ });
286
+ });
287
+ return meta;
288
+ }
289
+
290
+ // Everything an opened entity shows, gathered before layout: the header
291
+ // needs to know whether it carries the button.
292
+ function attachDetails(data, code){
293
+ var found = erDetails(code), nullKnown = false;
294
+ var drafts = data.nodes.filter(function(n){ return !n.isGroup; }).map(function(n){
295
+ var d = found[n.label] || found[n.id] || { notes: [], cols: [] };
296
+ var rows = (n.attributes || []).map(function(a, i){
297
+ var meta = erColumn((d.cols[i] || { lines: [] }).lines);
298
+ var type = decode(a.type == null ? '' : a.type).trim();
299
+ var sized = type.match(/^(.*?)\s*\(\s*([^)]*?)\s*\)$/);
300
+ if (meta.nullable !== null) nullKnown = true;
301
+ return {
302
+ name: decode(a.name == null ? '' : a.name).trim(),
303
+ type: sized ? sized[1] : type,
304
+ size: sized ? sized[2] : '',
305
+ keys: (a.keys || []).filter(Boolean).map(function(k){ return String(k).toUpperCase(); }),
306
+ nullable: meta.nullable, def: meta.def, ref: meta.ref,
307
+ desc: [decode(a.comment == null ? '' : a.comment).trim()].concat(meta.more).filter(Boolean).join(' ')
308
+ };
309
+ });
310
+ // The lines under the brace are of two kinds. A list - its items, and the
311
+ // line that leads into it - belongs with the columns, in the opened
312
+ // entity. Every other line describes the entity as a whole, which is not
313
+ // a column: it is shown while the pointer is on the entity, open or not.
314
+ var lists = [], desc = [];
315
+ d.notes.forEach(function(line, i){
316
+ var t = line.trim(), item = t.match(/^[-*]\s+(.*)$/);
317
+ if (!t) return;
318
+ if (item) lists.push({ li: true, text: item[1] });
319
+ else if (d.notes[i + 1] && /^[-*]\s+/.test(d.notes[i + 1].trim())) lists.push({ li: false, text: t });
320
+ else desc.push(t);
321
+ });
322
+ n._desc = desc;
323
+ return { n: n, details: { notes: lists, rows: rows } };
324
+ });
325
+ // A diagram whose comments are all short notes keeps them in its tables.
326
+ // One that uses them as descriptions shows all of them only when an entity
327
+ // is opened - not only the long ones, or the tables would show some and
328
+ // not others.
329
+ cv.font = fontString(ER_FS);
330
+ var describes = drafts.some(function(dr){
331
+ return (dr.n.attributes || []).some(function(a){
332
+ return a.comment && cv.measureText(decode(a.comment).trim()).width > ER_NOTE_W;
333
+ });
334
+ });
335
+
336
+ // Once any column says whether it can be null, a column that says nothing
337
+ // is NOT NULL - which is how a schema document is written. A key never is.
338
+ drafts.forEach(function(dr){
339
+ dr.n._describes = describes;
340
+ var s = dr.details;
341
+ s.nullKnown = nullKnown;
342
+ s.rows.forEach(function(r){
343
+ if (nullKnown && (r.nullable === null || r.keys.indexOf('PK') >= 0)) r.nullable = false;
344
+ });
345
+ if (nullKnown || s.notes.length ||
346
+ s.rows.some(function(r){ return r.desc || r.def || r.ref; })) dr.n._details = s;
347
+ });
348
+ }
349
+
350
+ // Measured once and cached on the node, so the box drawn is the box the
351
+ // layout was given room for - the two must not be allowed to disagree.
352
+ function erModel(n){
353
+ if (n._er) return n._er;
354
+ if (n._open) return (n._er = erOpened(n));
355
+ var title = measure(n.alias || n.label, ER_TITLE_FS, 340, 600);
356
+ var rows = (n.attributes || []).map(function(a){
357
+ return {
358
+ type: decode(a.type == null ? '' : a.type).trim(),
359
+ name: decode(a.name == null ? '' : a.name).trim(),
360
+ comment: decode(a.comment == null ? '' : a.comment).trim(),
361
+ keys: (a.keys || []).filter(Boolean).map(function(k){ return String(k).toUpperCase(); })
362
+ };
363
+ });
364
+
365
+ if (n._describes && n._details) rows.forEach(function(r){ r.comment = ''; });
366
+ cv.font = fontString(ER_FS);
367
+ var wType = 0, wName = 0, wCom = 0;
368
+ rows.forEach(function(r){
369
+ if (r.type) wType = Math.max(wType, cv.measureText(r.type).width);
370
+ if (r.name) wName = Math.max(wName, cv.measureText(r.name).width);
371
+ if (r.comment) wCom = Math.max(wCom, cv.measureText(r.comment).width);
372
+ });
373
+ wType = Math.ceil(wType); wName = Math.ceil(wName); wCom = Math.ceil(wCom);
374
+
375
+ cv.font = fontString(ER_KEY_FS, 600);
376
+ var wKey = 0;
377
+ rows.forEach(function(r){
378
+ var w = 0;
379
+ r.badges = r.keys.map(function(k){
380
+ var bw = Math.ceil(cv.measureText(k).width) + ER_BADGE_PAD * 2;
381
+ w += bw + (w ? ER_BADGE_GAP : 0);
382
+ return { text: k, w: bw, pk: k === 'PK' };
383
+ });
384
+ r.keyW = w;
385
+ wKey = Math.max(wKey, w);
386
+ });
387
+
388
+ var xName = ER_PAD + (wType ? wType + ER_COL_GAP : 0);
389
+ var xCom = xName + wName + ER_COL_GAP;
390
+ var contentW = (wCom ? xCom + wCom : xName + wName) +
391
+ (wKey ? ER_COL_GAP + wKey : 0) + ER_PAD;
392
+ var headH = Math.ceil(title.h + ER_HEAD_PAD * 2) + (rows.length ? 0 : 4);
393
+
394
+ n._er = {
395
+ title: title, rows: rows, headH: headH, xType: ER_PAD, xName: xName, xCom: xCom,
396
+ w: Math.round(Math.max(contentW, title.w + ER_PAD * 2 + 14 + (n._details ? ER_MORE * 2 : 0), 104)),
397
+ h: Math.round(headH + (rows.length ? rows.length * ER_ROW_H + 5 : 0))
398
+ };
399
+ return n._er;
400
+ }
401
+
402
+ /* -------------------------------------------------------- opened entity
403
+ The same entity with everything written about it: a real table of every
404
+ column - name, type, length, keys and what they reference, null, default,
405
+ description - with the notes and constraints for the entity above it. It
406
+ opens in place, in the diagram, and the diagram is laid out again around
407
+ it. Descriptions and notes wrap, and keep their code spans and bold. */
408
+
409
+ var MONO = null;
410
+ function monoString(px){
411
+ if (MONO === null) MONO = getComputedStyle(document.documentElement).getPropertyValue('--mono').trim() || 'monospace';
412
+ return px + 'px ' + MONO;
413
+ }
414
+
415
+ // text with code spans and bold, as words made of styled pieces
416
+ function richWords(text){
417
+ var parts = [], re = /\x60([^\x60]+)\x60|\*\*([^*]+)\*\*|([^\x60*]+|[\x60*])/g, m;
418
+ while ((m = re.exec(text))){
419
+ if (m[1] != null) parts.push({ s: m[1], k: 'code' });
420
+ else if (m[2] != null) parts.push({ s: m[2], k: 'bold' });
421
+ else parts.push({ s: m[3], k: '' });
422
+ }
423
+ var words = [[]];
424
+ parts.forEach(function(p){
425
+ p.s.split(/(\s+)/).forEach(function(bit){
426
+ if (!bit) return;
427
+ if (/^\s+$/.test(bit)){ if (words[words.length - 1].length) words.push([]); return; }
428
+ words[words.length - 1].push({ s: bit, k: p.k });
429
+ });
430
+ });
431
+ if (!words[words.length - 1].length) words.pop();
432
+ return words;
433
+ }
434
+
435
+ // Each piece is measured in its own face, so a line of mixed prose and code
436
+ // breaks where it really runs out of room.
437
+ function richWrap(text, px, maxw){
438
+ var words = richWords(decode(text));
439
+ cv.font = fontString(px);
440
+ var space = cv.measureText(' ').width, lines = [], cur = [], curW = 0, widest = 0;
441
+ words.forEach(function(word){
442
+ var pieces = word.map(function(p){
443
+ cv.font = p.k === 'code' ? monoString(ER_CODE_FS) : fontString(px, p.k === 'bold' ? 620 : null);
444
+ return { s: p.s, k: p.k, w: cv.measureText(p.s).width };
445
+ });
446
+ var ww = pieces.reduce(function(sum, p){ return sum + p.w; }, 0);
447
+ if (cur.length && curW + space + ww > maxw){
448
+ lines.push(cur);
449
+ widest = Math.max(widest, curW);
450
+ cur = []; curW = 0;
451
+ }
452
+ curW += (cur.length ? space : 0) + ww;
453
+ cur.push({ pieces: pieces, w: ww });
454
+ });
455
+ if (cur.length){ lines.push(cur); widest = Math.max(widest, curW); }
456
+ var lh = px * 1.42;
457
+ return { lines: lines, w: Math.ceil(widest), h: Math.ceil(Math.max(1, lines.length) * lh), lh: lh, space: space };
458
+ }
459
+
460
+ // one text per line, each word placed where it was measured to start
461
+ function richText(parent, m, x, cy, cls){
462
+ m.lines.forEach(function(line, i){
463
+ var t = mk('text', { x: x, y: cy + i * m.lh, 'text-anchor': 'start' }, cls);
464
+ var at = x;
465
+ line.forEach(function(word, j){
466
+ if (j) at += m.space;
467
+ // a word after the first is preceded by its own space, set one space
468
+ // earlier and in the plain face: the text still reads, and is found by
469
+ // a search in the page, as words
470
+ if (j){
471
+ var gap = mk('tspan', { x: at - m.space });
472
+ gap.textContent = ' ';
473
+ t.appendChild(gap);
474
+ }
475
+ word.pieces.forEach(function(p, k){
476
+ var ts = mk('tspan', k || j ? null : { x: at }, p.k ? 'dg-er-x-' + p.k : null);
477
+ ts.textContent = p.s;
478
+ t.appendChild(ts);
479
+ at += p.w;
480
+ });
481
+ });
482
+ parent.appendChild(t);
483
+ });
484
+ }
485
+
486
+ function erOpened(n){
487
+ var s = n._details, P = ER_PAD, G = ER_OPEN_GAP;
488
+ var title = measure(n.alias || n.label, ER_TITLE_FS, 340, 600);
489
+ var headH = Math.ceil(title.h + ER_HEAD_PAD * 2);
490
+ var any = function(test){ return s.rows.some(test); };
491
+
492
+ // a column of the table appears only when some row has something to put in it
493
+ var cols = [{ k: 'name', label: 'Column' }, { k: 'type', label: 'Type' }];
494
+ if (any(function(r){ return r.size; })) cols.push({ k: 'size', label: 'Length' });
495
+ if (any(function(r){ return r.keys.length || r.ref; })) cols.push({ k: 'key', label: 'Key' });
496
+ if (s.nullKnown) cols.push({ k: 'null', label: 'Null' });
497
+ if (any(function(r){ return r.def; })) cols.push({ k: 'def', label: 'Default' });
498
+ var hasDesc = any(function(r){ return r.desc; });
499
+ if (hasDesc) cols.push({ k: 'desc', label: 'Description' });
500
+ cv.font = fontString(ER_LABEL_FS, 620);
501
+ cols.forEach(function(c){
502
+ c.label = c.label.toUpperCase();
503
+ c.w = Math.ceil(cv.measureText(c.label).width + c.label.length * .65);
504
+ });
505
+ var col = {};
506
+ cols.forEach(function(c){ col[c.k] = c; });
507
+ var widen = function(k, w){ if (col[k]) col[k].w = Math.max(col[k].w, Math.ceil(w)); };
508
+
509
+ var rows = s.rows.map(function(r){
510
+ var row = { r: r };
511
+ cv.font = fontString(ER_FS, 560); widen('name', cv.measureText(r.name).width);
512
+ cv.font = fontString(ER_FS); widen('type', cv.measureText(r.type).width);
513
+ if (r.size) widen('size', cv.measureText(r.size).width);
514
+ cv.font = fontString(ER_KEY_FS, 600);
515
+ var kw = 0;
516
+ row.badges = r.keys.map(function(k){
517
+ var bw = Math.ceil(cv.measureText(k).width) + ER_BADGE_PAD * 2;
518
+ kw += bw + (kw ? ER_BADGE_GAP : 0);
519
+ return { text: k, w: bw, pk: k === 'PK' };
520
+ });
521
+ widen('key', kw);
522
+ if (r.ref){ cv.font = fontString(ER_REF_FS); widen('key', cv.measureText('→ ' + r.ref).width); }
523
+ if (col.null){
524
+ cv.font = fontString(ER_KEY_FS, 600);
525
+ widen('null', r.nullable ? cv.measureText('NULL').width + ER_BADGE_PAD * 2 : cv.measureText('NOT NULL').width);
526
+ }
527
+ if (r.def){ cv.font = monoString(ER_CODE_FS); widen('def', cv.measureText(r.def).width); }
528
+ if (r.desc) widen('desc', Math.min(richWrap(r.desc, ER_FS, 1e6).w, ER_OPEN_DESC_W));
529
+ return row;
530
+ });
531
+
532
+ var tableW = function(){
533
+ return P * 2 + cols.reduce(function(sum, c, i){ return sum + c.w + (i ? G : 0); }, 0);
534
+ };
535
+ var notes = s.notes.map(function(nt){ return { li: nt.li, text: nt.text }; });
536
+
537
+ // The table sets the width; the notes wrap to it, but not narrower than
538
+ // reads well, and the title and its button always fit. Any width to spare
539
+ // goes to the descriptions, which then need fewer lines.
540
+ var w = Math.max(tableW(), notes.length ? ER_OPEN_NOTES_W + P * 2 : 0,
541
+ title.w + P * 2 + 14 + ER_MORE * 2, 104);
542
+ if (hasDesc) col.desc.w += w - tableW();
543
+ w = Math.max(w, tableW());
544
+ // Nor, when it can be helped, wider than the page gives the diagram: an
545
+ // opened entity wider than that would shrink the whole diagram to fit, so
546
+ // its descriptions wrap narrower first.
547
+ if (n._room && hasDesc && w > n._room){
548
+ col.desc.w = Math.max(160, col.desc.w - (w - n._room));
549
+ w = Math.max(tableW(), notes.length ? Math.min(ER_OPEN_NOTES_W + P * 2, n._room) : 0,
550
+ title.w + P * 2 + 14 + ER_MORE * 2, 104);
551
+ }
552
+
553
+ var notesH = 0;
554
+ notes.forEach(function(nt, i){
555
+ nt.m = richWrap(nt.text, ER_FS, w - P * 2 - (nt.li ? 14 : 0));
556
+ nt.gap = i ? (nt.li && notes[i - 1].li ? 3 : 7) : 0;
557
+ notesH += nt.gap + nt.m.h;
558
+ });
559
+ if (notes.length) notesH += 20;
560
+
561
+ var x = P;
562
+ cols.forEach(function(c){ c.x = x; x += c.w + G; });
563
+ var bodyH = 0;
564
+ rows.forEach(function(row){
565
+ var lines = 1;
566
+ if (col.desc && row.r.desc){
567
+ row.desc = richWrap(row.r.desc, ER_FS, col.desc.w);
568
+ lines = Math.max(lines, row.desc.lines.length);
569
+ }
570
+ row.h = Math.max(ER_ROW_H + (row.r.ref ? 15 : 0), Math.ceil((lines - 1) * ER_FS * 1.42) + ER_ROW_H + (lines > 1 ? 4 : 0));
571
+ bodyH += row.h;
572
+ });
573
+
574
+ return {
575
+ open: true, title: title, headH: headH, notes: notes, notesH: notesH, cols: cols, col: col,
576
+ rows: rows, w: Math.round(w),
577
+ h: Math.round(headH + notesH + (rows.length ? ER_OPEN_HEAD_H + bodyH + 5 : 0))
578
+ };
579
+ }
580
+
581
+ function nodeSize(n){
582
+ if (n.shape === 'erBox'){ var e = erModel(n); return { w: e.w, h: e.h }; }
583
+ var m = measure(n.label, NODE_FS, CFG.nodeTextWidth || 210);
584
+ n._m = m;
585
+ var w = m.w + PAD_X*2, h = m.h + PAD_Y*2;
586
+ switch (n.shape){
587
+ case 'diamond': case 'question': case 'rhombus':
588
+ w = m.w*1.7 + 26; h = m.h*2 + 18; break;
589
+ case 'circle': case 'doublecircle': case 'state':
590
+ w = h = Math.max(w, h, Math.round(Math.hypot(m.w, m.h)) + 14); break;
591
+ case 'cylinder': case 'database': h += 12; break;
592
+ case 'hexagon': w += 26; break;
593
+ case 'subroutine': w += 16; break;
594
+ case 'lean_right': case 'lean_r': case 'lean_left': case 'lean_l':
595
+ case 'trapezoid': case 'inv_trapezoid': w += 34; break;
596
+ }
597
+ return { w: Math.round(w), h: Math.round(h) };
598
+ }
599
+
600
+ function buildGraph(data, dir, extra){
601
+ var sp = data.__er ? SPACING.er : SPACING.flow;
602
+ var kids = {};
603
+ data.nodes.forEach(function(n){
604
+ var p = n.parentId || '__root';
605
+ (kids[p] = kids[p] || []).push(n);
606
+ });
607
+
608
+ function build(n){
609
+ if (n.isGroup){
610
+ var gm = measure(n.label, NODE_FS, 320);
611
+ n._m = gm;
612
+ return {
613
+ id: n.id, _n: n,
614
+ layoutOptions: {
615
+ 'elk.padding': '[top=' + Math.round(gm.h + 22) + ',left=18,bottom=18,right=18]',
616
+ 'elk.direction': DIRS[String(n.dir || '').toUpperCase()] || dir
617
+ },
618
+ children: (kids[n.id] || []).map(build)
619
+ };
620
+ }
621
+ var s = nodeSize(n);
622
+ return { id: n.id, width: s.w, height: s.h, _n: n };
623
+ }
624
+
625
+ var horiz = dir === 'RIGHT' || dir === 'LEFT';
626
+ var edges = data.edges.map(function(e){
627
+ var ed = { id: e.id, sources: [e.start], targets: [e.end], _e: e };
628
+ if (e.label != null && String(e.label).trim() !== ''){
629
+ var m = measure(e.label, EDGE_FS, CFG.edgeTextWidth || 200);
630
+ e._m = m;
631
+ e._lw = m.w + 12;
632
+ e._lh = m.h + 4;
633
+ // ELK decides the gap around a centred edge label from the label box and
634
+ // nothing else - not from the layer spacing - so the clear line the two
635
+ // crow's feet need is reserved here, as part of the box, and the plate is
636
+ // then drawn at its own size in the middle of it.
637
+ var clear = data.__er ? ER_END_CLEAR * 2 : 0;
638
+ ed.labels = [{ id: e.id + '::label', text: 'x',
639
+ width: e._lw + (horiz ? clear : 0), height: e._lh + (horiz ? 0 : clear) }];
640
+ }
641
+ return ed;
642
+ });
643
+
644
+ var opts = {
645
+ 'elk.algorithm': 'layered',
646
+ 'elk.direction': dir,
647
+ 'elk.edgeRouting': 'ORTHOGONAL',
648
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
649
+ 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
650
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
651
+ // Keep the drawing in the order the document wrote it wherever that
652
+ // costs nothing: a diagram should read the way its source reads.
653
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
654
+ 'elk.layered.thoroughness': '30',
655
+ 'elk.layered.mergeEdges': 'false',
656
+ 'elk.spacing.nodeNode': sp.node,
657
+ 'elk.layered.spacing.nodeNodeBetweenLayers': sp.layer,
658
+ 'elk.spacing.edgeNode': sp.edgeNode,
659
+ 'elk.layered.spacing.edgeNodeBetweenLayers': sp.edgeNodeLayer,
660
+ 'elk.spacing.edgeEdge': sp.edgeEdge,
661
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': sp.edgeEdgeLayer,
662
+ 'elk.spacing.edgeLabel': '7',
663
+ 'elk.spacing.labelNode': '10',
664
+ 'elk.edgeLabels.placement': 'CENTER',
665
+ 'elk.padding': '[top='+sp.pad+',left='+sp.pad+',bottom='+sp.pad+',right='+sp.pad+']'
666
+ };
667
+ for (var k in extra || {}) opts[k] = extra[k];
668
+ return { id: 'root', children: (kids.__root || []).map(build), edges: edges, layoutOptions: opts };
669
+ }
670
+
671
+ /* ---------------------------------------------------- the shape of an ER
672
+ A layered layout puts every step of a chain of relationships on a new
673
+ layer, so a schema written top to bottom comes out as a tower: a document
674
+ much longer than it needs to be, and a diagram that is never on screen in
675
+ one piece. The column is wide, and an ER diagram has no reading direction
676
+ to keep, so it is laid out more than one way - as written, turned on its
677
+ side, and on its side with a long chain folded into rows - and the one
678
+ that takes the least height at the width it is shown in, while staying
679
+ readable, is kept. A direction the source writes is kept: across can
680
+ still be folded, down is left as it is. */
681
+
682
+ // below this, fitted into the column, an entity's text is too small to read
683
+ var ER_MIN_SCALE = .8;
684
+
685
+ function erShapes(code, dir, avail){
686
+ var said = /^\s*direction\s+(TB|TD|BT|LR|RL)\s*$/im.test(String(code));
687
+ var across = dir === 'RIGHT' || dir === 'LEFT';
688
+ // the shape a page wants: as wide as the column, no taller than most of a screen
689
+ var ratio = String(Math.max(1, Math.min(4, avail / Math.max(360, innerHeight * .8))).toFixed(2));
690
+ var packed = { 'elk.aspectRatio': ratio };
691
+ // ELK folds a layout that runs across into rows, not one that runs down:
692
+ // a chain laid out sideways and folded is the tower turned into a block.
693
+ // Its 'improved' wrapped edges lose the far half of a relationship that
694
+ // crosses the fold and skips a row - the line stops short of its entity -
695
+ // so they are left as the fold first routes them.
696
+ var folded = { 'elk.aspectRatio': ratio, 'elk.layered.wrapping.strategy': 'MULTI_EDGE',
697
+ 'elk.layered.wrapping.multiEdge.improveWrappedEdges': 'false',
698
+ 'elk.layered.wrapping.additionalEdgeSpacing': '24' };
699
+ var shapes = [{ dir: dir, extra: packed }];
700
+ if (said && !across) return shapes;
701
+ if (!across) shapes.push({ dir: 'RIGHT', extra: packed });
702
+ shapes.push({ dir: across ? dir : 'RIGHT', extra: folded });
703
+ return shapes;
704
+ }
705
+
706
+ // the height a layout takes once fitted to the column, and whether it can still be read
707
+ function erCost(res, avail){
708
+ var k = avail ? Math.min(1, avail / Math.max(1, res.width)) : 1;
709
+ return { h: res.height * k, legible: k >= ER_MIN_SCALE, k: k };
710
+ }
711
+
712
+ // Every relationship starts on one entity and ends on the other. A layout
713
+ // where one does not - whatever ELK got wrong - is never the one drawn.
714
+ function connected(res){
715
+ var at = {};
716
+ (res.children || []).forEach(function(c){ at[c.id] = c; });
717
+ var on = function(p, n){
718
+ return p && n && p.x >= n.x - 3 && p.x <= n.x + n.width + 3 && p.y >= n.y - 3 && p.y <= n.y + n.height + 3;
719
+ };
720
+ return (res.edges || []).every(function(e){
721
+ var secs = e.sections || [];
722
+ if (!secs.length) return false;
723
+ return on(secs[0].startPoint, at[(e.sources || [])[0]]) &&
724
+ on(secs[secs.length - 1].endPoint, at[(e.targets || [])[0]]);
725
+ });
726
+ }
727
+
728
+ function pickShape(tries, avail){
729
+ var best = null;
730
+ var whole = tries.filter(function(t){ return connected(t.res); });
731
+ // as written stays the fallback, even broken, rather than nothing at all
732
+ tries = whole.length ? whole : tries.slice(0, 1);
733
+ tries.forEach(function(t, i){
734
+ var c = erCost(t.res, avail);
735
+ t.cost = c;
736
+ if (!best){ best = t; return; }
737
+ var b = best.cost;
738
+ if (c.legible !== b.legible){ if (c.legible) best = t; return; }
739
+ if (!c.legible){ if (c.k > b.k) best = t; return; }
740
+ // as written wins unless another shape is clearly shorter
741
+ if (c.h < b.h * (best === tries[0] ? .85 : 1)) best = t;
742
+ });
743
+ return best;
744
+ }
745
+
746
+ /* ------------------------------------------------------------ svg output */
747
+
748
+ function absolutise(res){
749
+ var abs = {};
750
+ (function walk(n, ox, oy){
751
+ var cs = n.children || [];
752
+ for (var i=0;i<cs.length;i++){
753
+ var c = cs[i], x = ox + (c.x||0), y = oy + (c.y||0);
754
+ abs[c.id] = { x:x, y:y, w:c.width, h:c.height, n:c._n };
755
+ walk(c, x, y);
756
+ }
757
+ })(res, 0, 0);
758
+ return abs;
759
+ }
760
+
761
+ // An edge's route and its label come back in the coordinate space of the
762
+ // lowest node that contains both of its ends - the root for most edges, but
763
+ // the subgraph itself for an edge drawn wholly inside one. Everything else
764
+ // here works in root coordinates, so each edge is shifted by its own origin.
765
+ function edgeOrigin(data, abs, src, dst){
766
+ var parent = {};
767
+ data.nodes.forEach(function(n){ parent[n.id] = n.parentId || null; });
768
+ var chain = function(id){
769
+ var out = [];
770
+ for (var p = parent[id]; p; p = parent[p]) out.unshift(p);
771
+ return out;
772
+ };
773
+ var a = chain(src), b = chain(dst), lca = null;
774
+ for (var i=0; i<a.length && i<b.length && a[i] === b[i]; i++) lca = a[i];
775
+ var box = lca && abs[lca];
776
+ return box ? { x: box.x, y: box.y } : { x: 0, y: 0 };
777
+ }
778
+
779
+ // whether a classDef colour is a light one - written, most likely, for a
780
+ // light page - so the text on it has to go dark to stay readable
781
+ function isLight(c){
782
+ var probe = document.createElement('i');
783
+ probe.style.color = c;
784
+ document.body.appendChild(probe);
785
+ var v = (getComputedStyle(probe).color.match(/[\d.]+/g) || []).map(Number);
786
+ probe.remove();
787
+ return v.length >= 3 && (v[3] == null || v[3] > .5) && .299*v[0] + .587*v[1] + .114*v[2] > 150;
788
+ }
789
+
790
+ // classDef / style directives, flattened into a plain object
791
+ function styleOf(src){
792
+ var out = {};
793
+ [].concat(src.cssCompiledStyles || [], src.cssStyles || [], src.styles || [], src.style || [])
794
+ .forEach(function(s){
795
+ String(s).split(';').forEach(function(bit){
796
+ var i = bit.indexOf(':');
797
+ if (i > 0) out[bit.slice(0,i).trim()] = bit.slice(i+1).trim();
798
+ });
799
+ });
800
+ return out;
801
+ }
802
+
803
+ function shapeEl(x, y, w, h, shape){
804
+ var s;
805
+ switch (shape){
806
+ case 'diamond': case 'question': case 'rhombus':
807
+ return mk('polygon', { points: (x+w/2)+','+y+' '+(x+w)+','+(y+h/2)+' '+(x+w/2)+','+(y+h)+' '+x+','+(y+h/2) });
808
+ case 'stadium': case 'pill':
809
+ return mk('rect', { x:x, y:y, width:w, height:h, rx:h/2, ry:h/2 });
810
+ case 'circle': case 'doublecircle': case 'state':
811
+ return mk('ellipse', { cx:x+w/2, cy:y+h/2, rx:w/2, ry:h/2 });
812
+ case 'cylinder': case 'database':
813
+ var e = Math.min(11, h/5);
814
+ return mk('path', { d:'M'+x+','+(y+e)+' A'+(w/2)+','+e+' 0 0 1 '+(x+w)+','+(y+e)+
815
+ ' L'+(x+w)+','+(y+h-e)+' A'+(w/2)+','+e+' 0 0 1 '+x+','+(y+h-e)+' Z'+
816
+ ' M'+x+','+(y+e)+' A'+(w/2)+','+e+' 0 0 0 '+(x+w)+','+(y+e) });
817
+ case 'hexagon':
818
+ s = Math.min(18, w/5);
819
+ return mk('polygon', { points:(x+s)+','+y+' '+(x+w-s)+','+y+' '+(x+w)+','+(y+h/2)+' '+
820
+ (x+w-s)+','+(y+h)+' '+(x+s)+','+(y+h)+' '+x+','+(y+h/2) });
821
+ case 'lean_right': case 'lean_r':
822
+ s = Math.min(22, w/6);
823
+ return mk('polygon', { points:(x+s)+','+y+' '+(x+w)+','+y+' '+(x+w-s)+','+(y+h)+' '+x+','+(y+h) });
824
+ case 'lean_left': case 'lean_l':
825
+ s = Math.min(22, w/6);
826
+ return mk('polygon', { points:x+','+y+' '+(x+w-s)+','+y+' '+(x+w)+','+(y+h)+' '+(x+s)+','+(y+h) });
827
+ case 'trapezoid':
828
+ s = Math.min(22, w/6);
829
+ return mk('polygon', { points:(x+s)+','+y+' '+(x+w-s)+','+y+' '+(x+w)+','+(y+h)+' '+x+','+(y+h) });
830
+ case 'inv_trapezoid':
831
+ s = Math.min(22, w/6);
832
+ return mk('polygon', { points:x+','+y+' '+(x+w)+','+y+' '+(x+w-s)+','+(y+h)+' '+(x+s)+','+(y+h) });
833
+ case 'squareRect': case 'rect': case 'square':
834
+ return mk('rect', { x:x, y:y, width:w, height:h, rx:3, ry:3 });
835
+ default:
836
+ return mk('rect', { x:x, y:y, width:w, height:h, rx:8, ry:8 });
837
+ }
838
+ }
839
+
840
+ function textBlock(m, cx, cy, cls){
841
+ var g = mk('text', { x:cx, y:Math.round(cy - (m.lines.length-1)*m.lh/2), 'text-anchor':'middle' }, cls);
842
+ for (var i=0;i<m.lines.length;i++){
843
+ var ts = mk('tspan', { x:cx, dy: i ? m.lh : 0 });
844
+ ts.textContent = m.lines[i];
845
+ g.appendChild(ts);
846
+ }
847
+ return g;
848
+ }
849
+
850
+ // The entity, drawn as a table. The header band is a path rather than a rect
851
+ // so that only its top corners are rounded, and it is a wash laid over the
852
+ // body rather than an opaque fill, so a classDef colour on the entity still
853
+ // reads through it - and so the hover tint reaches the header too.
854
+ function erBoxEl(g, a, n, st){
855
+ var m = erModel(n), x = a.x, y = a.y, w = a.w, h = a.h, r = 9;
856
+
857
+ var body = mk('rect', { x:x, y:y, width:w, height:h, rx:r, ry:r }, 'dg-node-shape');
858
+ if (st.fill) body.style.fill = st.fill;
859
+ if (st.stroke) body.style.stroke = st.stroke;
860
+ if (st['stroke-width']) body.style.strokeWidth = st['stroke-width'];
861
+ if (st['stroke-dasharray']) body.style.strokeDasharray = st['stroke-dasharray'];
862
+ g.appendChild(body);
863
+
864
+ if (m.rows.length){
865
+ g.appendChild(mk('path', { d:
866
+ 'M'+x+','+(y+m.headH)+' V'+(y+r)+' A'+r+','+r+' 0 0 1 '+(x+r)+','+y+
867
+ ' H'+(x+w-r)+' A'+r+','+r+' 0 0 1 '+(x+w)+','+(y+r)+' V'+(y+m.headH)+' Z'
868
+ }, 'dg-er-head'));
869
+ var rule = mk('line', { x1:x, y1:y+m.headH, x2:x+w, y2:y+m.headH }, 'dg-er-rule');
870
+ if (st.stroke) rule.style.stroke = st.stroke;
871
+ g.appendChild(rule);
872
+ }
873
+
874
+ var t = textBlock(m.title, x + w/2, y + m.headH/2, 'dg-er-title');
875
+ if (st.color) t.style.fill = st.color;
876
+ g.appendChild(t);
877
+
878
+ // The button that opens the entity, or closes it again, in the corner of
879
+ // the header. Which entities are open is remembered by name on the figure,
880
+ // so it survives the diagram being drawn again.
881
+ g.setAttribute('data-entity', n.label);
882
+ if (n._desc && n._desc.length) g.setAttribute('data-desc', JSON.stringify(n._desc));
883
+ if (n._details){
884
+ var bx = x + w - 8 - 17, by = y + m.headH/2 - 8.5, cx = bx + 8.5, cy = by + 8.5;
885
+ var more = mk('g', null, 'dg-er-more' + (m.open ? ' is-open' : ''));
886
+ var tip = mk('title');
887
+ tip.textContent = m.open ? 'Back to the compact table' : 'Every column, and the notes';
888
+ more.appendChild(tip);
889
+ more.appendChild(mk('rect', { x:bx, y:by, width:17, height:17, rx:5 }, 'dg-er-more-box'));
890
+ more.appendChild(mk('path', { d: m.open
891
+ ? 'M'+(cx-4)+','+(cy-.5)+' H'+(cx-.5)+' V'+(cy-4)+' M'+(cx+.5)+','+(cy+4)+' V'+(cy+.5)+' H'+(cx+4)
892
+ : 'M'+(cx-4)+','+(cy-.5)+' V'+(cy-4)+' H'+(cx-.5)+' M'+(cx+.5)+','+(cy+4)+' H'+(cx+4)+' V'+(cy+.5)
893
+ }, 'dg-er-more-icon'));
894
+ g.appendChild(more);
895
+ }
896
+
897
+ if (m.open) return erOpenedEl(g, x, y, w, h, r, m);
898
+
899
+ m.rows.forEach(function(row, i){
900
+ var top = y + m.headH + i * ER_ROW_H, cy = top + ER_ROW_H/2;
901
+ var rg = mk('g', null, 'dg-er-row');
902
+ // what the column holds, shown while the pointer is on its row: the
903
+ // compact table has no room for it (an opened one writes it out)
904
+ var desc = n._details && n._details.rows[i] && n._details.rows[i].desc;
905
+ if (desc) rg.setAttribute('data-desc', JSON.stringify([desc]));
906
+ // a band under the row, lit on hover: on a wide entity it is what keeps
907
+ // the eye on one line while it travels from the type across to the keys
908
+ var last = i === m.rows.length - 1, rr = r - 1, x1 = x + 1, x2 = x + w - 1, y2 = y + h - 1;
909
+ rg.appendChild(last
910
+ ? mk('path', { d: 'M'+x1+','+top+' H'+x2+' V'+(y2-rr)+' A'+rr+','+rr+' 0 0 1 '+(x2-rr)+','+y2+
911
+ ' H'+(x1+rr)+' A'+rr+','+rr+' 0 0 1 '+x1+','+(y2-rr)+' Z' }, 'dg-er-band')
912
+ : mk('rect', { x:x1, y:top, width:w-2, height:ER_ROW_H }, 'dg-er-band'));
913
+ if (i) rg.appendChild(mk('line', { x1:x+ER_PAD, y1:top, x2:x+w-ER_PAD, y2:top }, 'dg-er-sep'));
914
+
915
+ var put = function(text, tx, cls){
916
+ if (!text) return;
917
+ var e = mk('text', { x:tx, y:cy, 'text-anchor':'start' }, cls);
918
+ e.textContent = text;
919
+ rg.appendChild(e);
920
+ };
921
+ put(row.type, x + m.xType, 'dg-er-type');
922
+ put(row.name, x + m.xName, 'dg-er-name');
923
+ put(row.comment, x + m.xCom, 'dg-er-comment');
924
+
925
+ var bx = x + w - ER_PAD - row.keyW;
926
+ row.badges.forEach(function(b){
927
+ rg.appendChild(mk('rect', { x:bx, y:cy - ER_BADGE_H/2, width:b.w, height:ER_BADGE_H, rx:4 },
928
+ 'dg-er-badge' + (b.pk ? ' is-pk' : '')));
929
+ var bt = mk('text', { x:bx + b.w/2, y:cy, 'text-anchor':'middle' }, 'dg-er-key');
930
+ bt.textContent = b.text;
931
+ rg.appendChild(bt);
932
+ bx += b.w + ER_BADGE_GAP;
933
+ });
934
+ g.appendChild(rg);
935
+ });
936
+ }
937
+
938
+ function erOpenedEl(g, x, y, w, h, r, m){
939
+ var P = ER_PAD, top = y + m.headH;
940
+
941
+ // the notes and constraints, between the header and the table
942
+ if (m.notes.length){
943
+ var ng = mk('g', null, 'dg-er-notes');
944
+ var at = top + 10;
945
+ m.notes.forEach(function(nt){
946
+ at += nt.gap;
947
+ var tx = x + P + (nt.li ? 14 : 0), cy = at + nt.m.lh/2;
948
+ if (nt.li) ng.appendChild(mk('circle', { cx: x + P + 4, cy: cy, r: 1.8 }, 'dg-er-bullet'));
949
+ richText(ng, nt.m, tx, cy, 'dg-er-note');
950
+ at += nt.m.h;
951
+ });
952
+ g.appendChild(ng);
953
+ top += m.notesH;
954
+ if (m.rows.length) g.appendChild(mk('line', { x1: x, y1: top, x2: x + w, y2: top }, 'dg-er-rule'));
955
+ }
956
+ if (!m.rows.length) return;
957
+
958
+ // the column headings
959
+ m.cols.forEach(function(c){
960
+ var t = mk('text', { x: x + c.x, y: top + ER_OPEN_HEAD_H/2 + 1, 'text-anchor': 'start' }, 'dg-er-x-label');
961
+ t.textContent = c.label;
962
+ g.appendChild(t);
963
+ });
964
+ top += ER_OPEN_HEAD_H;
965
+ g.appendChild(mk('line', { x1: x + P, y1: top, x2: x + w - P, y2: top }, 'dg-er-sep'));
966
+
967
+ var col = m.col;
968
+ m.rows.forEach(function(row, i){
969
+ var r0 = row.r, cy = top + ER_ROW_H/2;
970
+ var rg = mk('g', null, 'dg-er-row');
971
+ var last = i === m.rows.length - 1, rr = r - 1, x1 = x + 1, x2 = x + w - 1, y2 = y + h - 1;
972
+ rg.appendChild(last
973
+ ? mk('path', { d: 'M'+x1+','+top+' H'+x2+' V'+(y2-rr)+' A'+rr+','+rr+' 0 0 1 '+(x2-rr)+','+y2+
974
+ ' H'+(x1+rr)+' A'+rr+','+rr+' 0 0 1 '+x1+','+(y2-rr)+' Z' }, 'dg-er-band')
975
+ : mk('rect', { x: x1, y: top, width: w - 2, height: row.h }, 'dg-er-band'));
976
+ if (i) rg.appendChild(mk('line', { x1: x + P, y1: top, x2: x + w - P, y2: top }, 'dg-er-sep'));
977
+
978
+ var put = function(c, text, cls, dy){
979
+ if (!c) return;
980
+ var e = mk('text', { x: x + c.x, y: cy + (dy || 0), 'text-anchor': 'start' }, cls);
981
+ e.textContent = text;
982
+ rg.appendChild(e);
983
+ };
984
+ var none = function(c){ put(c, '—', 'dg-er-none'); };
985
+ put(col.name, r0.name, 'dg-er-name dg-er-x-name');
986
+ put(col.type, r0.type, 'dg-er-type');
987
+ if (r0.size) put(col.size, r0.size, 'dg-er-type'); else none(col.size);
988
+
989
+ if (col.key){
990
+ var bx = x + col.key.x;
991
+ row.badges.forEach(function(b){
992
+ rg.appendChild(mk('rect', { x: bx, y: cy - ER_BADGE_H/2, width: b.w, height: ER_BADGE_H, rx: 4 },
993
+ 'dg-er-badge' + (b.pk ? ' is-pk' : '')));
994
+ var bt = mk('text', { x: bx + b.w/2, y: cy, 'text-anchor': 'middle' }, 'dg-er-key');
995
+ bt.textContent = b.text;
996
+ rg.appendChild(bt);
997
+ bx += b.w + ER_BADGE_GAP;
998
+ });
999
+ if (r0.ref) put(col.key, '→ ' + r0.ref, 'dg-er-ref', row.badges.length ? 15 : 0);
1000
+ }
1001
+
1002
+ if (col.null){
1003
+ if (r0.nullable){
1004
+ cv.font = fontString(ER_KEY_FS, 600);
1005
+ var nw = Math.ceil(cv.measureText('NULL').width) + ER_BADGE_PAD * 2;
1006
+ rg.appendChild(mk('rect', { x: x + col.null.x, y: cy - ER_BADGE_H/2, width: nw, height: ER_BADGE_H, rx: 4 }, 'dg-er-null'));
1007
+ var nt = mk('text', { x: x + col.null.x + nw/2, y: cy, 'text-anchor': 'middle' }, 'dg-er-null-text');
1008
+ nt.textContent = 'NULL';
1009
+ rg.appendChild(nt);
1010
+ } else put(col.null, 'NOT NULL', 'dg-er-notnull');
1011
+ }
1012
+ if (r0.def) put(col.def, r0.def, 'dg-er-def'); else none(col.def);
1013
+ if (row.desc) richText(rg, row.desc, x + col.desc.x, cy, 'dg-er-desc');
1014
+ else if (col.desc) none(col.desc);
1015
+
1016
+ g.appendChild(rg);
1017
+ top += row.h;
1018
+ });
1019
+ }
1020
+
1021
+ // Right-angle bends look mechanical when they are exactly square; a small
1022
+ // radius reads as a route rather than as a staircase.
1023
+ function roundedPath(pts, r){
1024
+ if (pts.length < 2) return '';
1025
+ if (pts.length === 2) return 'M'+pts[0].x+','+pts[0].y+' L'+pts[1].x+','+pts[1].y;
1026
+ var d = 'M'+pts[0].x+','+pts[0].y;
1027
+ for (var i=1;i<pts.length-1;i++){
1028
+ var a=pts[i-1], b=pts[i], c=pts[i+1];
1029
+ var l1=Math.hypot(b.x-a.x,b.y-a.y), l2=Math.hypot(c.x-b.x,c.y-b.y);
1030
+ if (!l1 || !l2){ d += ' L'+b.x+','+b.y; continue; }
1031
+ var rr = Math.min(r, l1/2, l2/2);
1032
+ d += ' L'+(b.x+(a.x-b.x)/l1*rr)+','+(b.y+(a.y-b.y)/l1*rr) +
1033
+ ' Q'+b.x+','+b.y+' '+(b.x+(c.x-b.x)/l2*rr)+','+(b.y+(c.y-b.y)/l2*rr);
1034
+ }
1035
+ var e = pts[pts.length-1];
1036
+ return d + ' L'+e.x+','+e.y;
1037
+ }
1038
+
1039
+ function nearestOnPath(pts, px, py){
1040
+ var best = null, bd = Infinity;
1041
+ for (var i=0;i<pts.length-1;i++){
1042
+ var a=pts[i], b=pts[i+1];
1043
+ var dx=b.x-a.x, dy=b.y-a.y, L=dx*dx+dy*dy;
1044
+ var t = L ? ((px-a.x)*dx + (py-a.y)*dy)/L : 0;
1045
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
1046
+ var x=a.x+dx*t, y=a.y+dy*t, d=(x-px)*(x-px)+(y-py)*(y-py);
1047
+ if (d < bd){ bd = d; best = { x:x, y:y }; }
1048
+ }
1049
+ return best;
1050
+ }
1051
+
1052
+ // Arrowheads are drawn into the edge's own group rather than referenced as
1053
+ // shared <marker>s: that way one CSS rule recolours the head along with its
1054
+ // line when the edge lights up, and a cloned SVG carries its own heads.
1055
+ function arrowHead(tip, from, kind){
1056
+ var dx = tip.x - from.x, dy = tip.y - from.y;
1057
+ var L = Math.hypot(dx, dy) || 1;
1058
+ var ux = dx/L, uy = dy/L, px = -uy, py = ux;
1059
+ if (kind === 'dot'){
1060
+ return mk('circle', { cx: tip.x - ux*3.4, cy: tip.y - uy*3.4, r: 3.4 }, 'dg-head dg-head-open');
1061
+ }
1062
+ if (kind === 'cross'){
1063
+ var c = { x: tip.x - ux*4, y: tip.y - uy*4 }, s = 4;
1064
+ return mk('path', { d:
1065
+ 'M'+(c.x-(ux+px)*s)+','+(c.y-(uy+py)*s)+' L'+(c.x+(ux+px)*s)+','+(c.y+(uy+py)*s)+
1066
+ ' M'+(c.x-(ux-px)*s)+','+(c.y-(uy-py)*s)+' L'+(c.x+(ux-px)*s)+','+(c.y+(uy-py)*s),
1067
+ fill:'none' }, 'dg-head dg-head-open');
1068
+ }
1069
+ var back = 8.5, half = 3.4;
1070
+ var bx = tip.x - ux*back, by = tip.y - uy*back;
1071
+ if (kind === 'open'){
1072
+ // two strokes and no fill: an asynchronous message in a sequence diagram
1073
+ return mk('path', { d:
1074
+ 'M'+(bx+px*(half+.8))+','+(by+py*(half+.8))+' L'+tip.x+','+tip.y+' L'+(bx-px*(half+.8))+','+(by-py*(half+.8)),
1075
+ fill:'none' }, 'dg-head-line');
1076
+ }
1077
+ return mk('polygon', { points:
1078
+ tip.x+','+tip.y+' '+(bx+px*half)+','+(by+py*half)+' '+(bx-px*half)+','+(by-py*half) }, 'dg-head');
1079
+ }
1080
+
1081
+ var ARROW = { arrow_point:'tri', double_arrow_point:'tri', arrow_circle:'dot', arrow_cross:'cross' };
1082
+
1083
+ // Crow's-foot cardinality, drawn at the entity end of a relationship. The
1084
+ // mermaid source reads outwards from the entity - in "A }o--|| B" the "}"
1085
+ // is the mark nearest A and the "o" the one beyond it - so the glyphs are
1086
+ // placed in that order, stepping back along the line from the box edge.
1087
+ // Like arrowheads these are drawn into the edge's own group rather than as
1088
+ // shared <marker>s, so one CSS rule lights a glyph along with its line.
1089
+ var CARDS = { only_one:1, zero_or_one:1, one_or_more:1, zero_or_more:1, md_parent:1 };
1090
+
1091
+ function erMarker(tip, from, card){
1092
+ if (!CARDS[card]) return null;
1093
+ var dx = tip.x - from.x, dy = tip.y - from.y, L = Math.hypot(dx, dy) || 1;
1094
+ var ux = dx/L, uy = dy/L, px = -uy, py = ux;
1095
+ var g = mk('g', null, 'dg-card');
1096
+ var at = function(d){ return { x: tip.x - ux*d, y: tip.y - uy*d }; };
1097
+ var bar = function(d){
1098
+ var c = at(d);
1099
+ g.appendChild(mk('line', { x1:c.x+px*5.6, y1:c.y+py*5.6, x2:c.x-px*5.6, y2:c.y-py*5.6 }, 'dg-card-line'));
1100
+ };
1101
+ var ring = function(d){
1102
+ var c = at(d);
1103
+ g.appendChild(mk('circle', { cx:c.x, cy:c.y, r:3.5 }, 'dg-card-ring'));
1104
+ };
1105
+ // the middle prong of the foot is the edge line itself; only the two
1106
+ // spread prongs have to be drawn
1107
+ var foot = function(){
1108
+ var apex = at(10.5);
1109
+ g.appendChild(mk('line', { x1:apex.x, y1:apex.y, x2:tip.x+px*7, y2:tip.y+py*7 }, 'dg-card-line'));
1110
+ g.appendChild(mk('line', { x1:apex.x, y1:apex.y, x2:tip.x-px*7, y2:tip.y-py*7 }, 'dg-card-line'));
1111
+ };
1112
+ if (card === 'only_one'){ bar(5.5); bar(10.5); }
1113
+ else if (card === 'zero_or_one'){ bar(5.5); ring(12.5); }
1114
+ else if (card === 'one_or_more'){ foot(); bar(15); }
1115
+ else if (card === 'zero_or_more'){ foot(); ring(17.5); }
1116
+ else g.appendChild(mk('circle', { cx: at(4).x, cy: at(4).y, r:3.4 }, 'dg-card-dot'));
1117
+ return g;
1118
+ }
1119
+
1120
+ function drawSvg(res, data){
1121
+ var isEr = !!data.__er;
1122
+ var abs = absolutise(res);
1123
+ var W = Math.max(1, Math.ceil(res.width)), H = Math.max(1, Math.ceil(res.height));
1124
+ var svg = mk('svg', { viewBox:'0 0 '+W+' '+H, width:W, height:H, role:'img' }, 'dg');
1125
+ svg.style.maxWidth = '100%';
1126
+ svg.style.height = 'auto';
1127
+
1128
+ var gGroup = mk('g', null, 'dg-groups');
1129
+ var gEdge = mk('g', null, 'dg-edges');
1130
+ var gNode = mk('g', null, 'dg-nodes');
1131
+ var gLabel = mk('g', null, 'dg-labels');
1132
+ svg.appendChild(gGroup); svg.appendChild(gEdge); svg.appendChild(gNode); svg.appendChild(gLabel);
1133
+
1134
+ // subgraphs first: they are the ground everything else sits on
1135
+ Object.keys(abs).forEach(function(id){
1136
+ var a = abs[id], n = a.n;
1137
+ if (!n || !n.isGroup) return;
1138
+ var g = mk('g', { 'data-group': id }, 'dg-group');
1139
+ g.appendChild(mk('rect', { x:a.x, y:a.y, width:a.w, height:a.h, rx:12 }, 'dg-group-shape'));
1140
+ var t = mk('text', { x:a.x+16, y:a.y+18, 'text-anchor':'start' }, 'dg-group-text');
1141
+ t.textContent = n._m.lines.join(' ');
1142
+ g.appendChild(t);
1143
+ gGroup.appendChild(g);
1144
+ });
1145
+
1146
+ // a flowchart's roots - the nodes no arrow points into - are where reading
1147
+ // starts, so they are set apart from the boxes that follow from them. A
1148
+ // chart that loops back to its start ("try again") has no such node; its
1149
+ // reading still starts at the node written first, so that one is the root.
1150
+ var entered = {};
1151
+ (data.edges || []).forEach(function(e){ entered[e.end] = true; });
1152
+ var boxes = data.nodes.filter(function(n){ return !n.isGroup; });
1153
+ if (!isEr && boxes.length && boxes.every(function(n){ return entered[n.id]; }))
1154
+ entered[boxes[0].id] = false;
1155
+
1156
+ Object.keys(abs).forEach(function(id){
1157
+ var a = abs[id], n = a.n;
1158
+ if (!n || n.isGroup) return;
1159
+ var st = styleOf(n);
1160
+ var g = mk('g', { 'data-node': id }, 'dg-el dg-node' + (n.shape === 'erBox' ? ' dg-er'
1161
+ : !isEr && !entered[id] ? ' is-root' : '') + (st.fill && isLight(st.fill) ? ' on-light' : ''));
1162
+ if (n.shape === 'erBox'){ erBoxEl(g, a, n, st); gNode.appendChild(g); return; }
1163
+ var s = shapeEl(a.x, a.y, a.w, a.h, n.shape);
1164
+ s.setAttribute('class', 'dg-node-shape');
1165
+ if (st.fill) s.style.fill = st.fill;
1166
+ if (st.stroke) s.style.stroke = st.stroke;
1167
+ if (st['stroke-width']) s.style.strokeWidth = st['stroke-width'];
1168
+ if (st['stroke-dasharray']) s.style.strokeDasharray = st['stroke-dasharray'];
1169
+ g.appendChild(s);
1170
+ var t = textBlock(n._m, a.x + a.w/2, a.y + a.h/2, 'dg-node-text');
1171
+ if (st.color) t.style.fill = st.color;
1172
+ g.appendChild(t);
1173
+ gNode.appendChild(g);
1174
+ });
1175
+
1176
+ (res.edges || []).forEach(function(e){
1177
+ var info = e._e || {};
1178
+ var src = info.start || (e.sources||[])[0];
1179
+ var dst = info.end || (e.targets||[])[0];
1180
+ var g = mk('g', { 'data-edge': e.id, 'data-src': src, 'data-dst': dst }, 'dg-el dg-edge');
1181
+ var runs = [];
1182
+ var st = styleOf(info);
1183
+ var o = edgeOrigin(data, abs, src, dst);
1184
+ var shift = function(p){ return { x: p.x + o.x, y: p.y + o.y }; };
1185
+
1186
+ (e.sections || []).forEach(function(sec){
1187
+ runs.push([sec.startPoint].concat(sec.bendPoints || [], [sec.endPoint]).map(shift));
1188
+ });
1189
+ if (!runs.length) return;
1190
+
1191
+ runs.forEach(function(pts, i){
1192
+ var d = roundedPath(pts, 7);
1193
+ g.appendChild(mk('path', { d:d, fill:'none' }, 'dg-edge-hit'));
1194
+ var line = mk('path', { d:d, fill:'none' }, 'dg-edge-line');
1195
+ if (info.pattern === 'dashed') line.style.strokeDasharray = '7 4';
1196
+ else if (info.pattern === 'dotted' || info.stroke === 'dotted') line.style.strokeDasharray = '4 3';
1197
+ if (info.thickness === 'thick' || info.stroke === 'thick') line.style.strokeWidth = '2.4';
1198
+ if (st.stroke) line.style.stroke = st.stroke;
1199
+ g.appendChild(line);
1200
+
1201
+ if (i === runs.length - 1){
1202
+ if (isEr){
1203
+ var me = erMarker(pts[pts.length-1], pts[pts.length-2], info.arrowTypeEnd);
1204
+ if (me) g.appendChild(me);
1205
+ } else {
1206
+ var head = info.arrowTypeEnd === 'none' || info.type === 'arrow_open'
1207
+ ? null : (ARROW[info.arrowTypeEnd] || 'tri');
1208
+ if (head){
1209
+ var a = arrowHead(pts[pts.length-1], pts[pts.length-2], head);
1210
+ if (st.stroke){ a.style.fill = head === 'tri' ? st.stroke : ''; a.style.stroke = st.stroke; }
1211
+ g.appendChild(a);
1212
+ }
1213
+ }
1214
+ }
1215
+ if (i === 0){
1216
+ if (isEr){
1217
+ var ms = erMarker(pts[0], pts[1], info.arrowTypeStart);
1218
+ if (ms) g.appendChild(ms);
1219
+ } else if (info.arrowTypeStart && info.arrowTypeStart !== 'none'){
1220
+ g.appendChild(arrowHead(pts[0], pts[1], ARROW[info.arrowTypeStart] || 'tri'));
1221
+ }
1222
+ }
1223
+ });
1224
+ gEdge.appendChild(g);
1225
+
1226
+ // the label, and the hairline that ties it to this arc and no other
1227
+ (e.labels || []).forEach(function(lb){
1228
+ var m = info._m;
1229
+ if (!m) return;
1230
+ var pts = runs[Math.floor(runs.length/2)] || runs[0];
1231
+ var lg = mk('g', { 'data-edge': e.id, 'data-src': src, 'data-dst': dst }, 'dg-el dg-label');
1232
+ var cx = lb.x + o.x + lb.width/2, cy = lb.y + o.y + lb.height/2;
1233
+ var pw = info._lw || lb.width, ph = info._lh || lb.height;
1234
+ var lx = cx - pw/2, ly = cy - ph/2;
1235
+ var anchor = pts.length > 1 ? nearestOnPath(pts, cx, cy) : null;
1236
+ if (anchor){
1237
+ var dx = anchor.x - cx, dy = anchor.y - cy;
1238
+ var hw = pw/2 + 3, hh = ph/2 + 3, ax = cx, ay = cy;
1239
+ if (dx || dy){
1240
+ var t = Math.min(Math.abs(dx) > 1e-6 ? hw/Math.abs(dx) : 1e9,
1241
+ Math.abs(dy) > 1e-6 ? hh/Math.abs(dy) : 1e9);
1242
+ ax = cx + dx*t; ay = cy + dy*t;
1243
+ }
1244
+ if (Math.hypot(anchor.x-ax, anchor.y-ay) > 1.5){
1245
+ lg.appendChild(mk('line', { x1:ax, y1:ay, x2:anchor.x, y2:anchor.y }, 'dg-tie'));
1246
+ }
1247
+ lg.appendChild(mk('circle', { cx:anchor.x, cy:anchor.y, r:2.3 }, 'dg-tie-dot'));
1248
+ }
1249
+ lg.appendChild(mk('rect', { x:lx, y:ly, width:pw, height:ph, rx:5 }, 'dg-label-plate'));
1250
+ lg.appendChild(textBlock(m, cx, cy, 'dg-edge-text'));
1251
+ gLabel.appendChild(lg);
1252
+ });
1253
+ });
1254
+
1255
+ return svg;
1256
+ }
1257
+
1258
+ /* ------------------------------------------------------------- sequence
1259
+ A sequence diagram fails differently. Nothing has to be routed - every
1260
+ message is a straight line across the lanes - so what breaks is width.
1261
+ Mermaid opens each gap between two lifelines until the widest label that
1262
+ crosses it fits, so a diagram whose messages carry real payloads comes out
1263
+ thousands of pixels wide and is then shrunk into the column until none of
1264
+ it can be read. Its frames add their own trouble: the conditions of an alt
1265
+ float loose inside it, and an autonumber is a dot too small for its number.
1266
+
1267
+ Here the lanes are only as far apart as the participants need. A label is
1268
+ wrapped - a path or a URL too, after its own punctuation - and may overhang
1269
+ the ends of its own arrow, but never as far as the next lifeline out, so it
1270
+ still sits over one message and no other; rows then take the height their
1271
+ label needs. Frames are measured from what they hold, each nested one inset
1272
+ in its parent, with the condition beside the keyword and every else, and,
1273
+ or option line named where it falls. Numbers are badges big enough to read,
1274
+ participants repeat at the foot of a long diagram and ride along the top of
1275
+ the page while it scrolls past, and hovering works as it does everywhere
1276
+ else: participants are the nodes and messages are the edges. */
1277
+
1278
+ var SEQ = {
1279
+ fs: 11.5, textW: 220, // message text, and how wide it runs before wrapping -
1280
+ wraps: [170, 190, 220, 250, 280, 310, 340], // - chosen from these to suit the room
1281
+ actorFs: 12.5, actorW: 170, // participant names
1282
+ // when the room is too narrow: [name width, message line width], tried in order
1283
+ squeeze: [[140, 220], [110, 220], [110, 190], [110, 170]],
1284
+ gap: 26, // the least clear space between two participant boxes
1285
+ arrow: 96, // the shortest arrow between neighbouring lanes
1286
+ maxGap: 300, // the widest a gap is opened to fill the room
1287
+ overhang: 62, // how far a label may run past either end of its arrow,
1288
+ // when the room is too narrow for it to fit its arrow
1289
+ clear: 14, // ... and how near it may come to the next lifeline out
1290
+ edge: 16, // how far past the first or last lane a label may start
1291
+ plateGap: 5, numGap: 11, rowGap: 15,
1292
+ selfW: 38, selfH: 20,
1293
+ noteExtra: 20, notePad: 10, // a note runs a little wider than a message label
1294
+ framePad: 12, tabH: 19,
1295
+ bar: 9, badge: 8.5,
1296
+ tall: 420, // below this height nothing repeats or floats
1297
+ margin: 14
1298
+ };
1299
+
1300
+ var FRAME_OPEN = { LOOP_START:'loop', ALT_START:'alt', OPT_START:'opt', PAR_START:'par',
1301
+ PAR_OVER_START:'par', CRITICAL_START:'critical', BREAK_START:'break', RECT_START:'rect' };
1302
+ var FRAME_SPLIT = { ALT_ELSE:'else', PAR_AND:'and', CRITICAL_OPTION:'option' };
1303
+ var FRAME_CLOSE = { LOOP_END:1, ALT_END:1, OPT_END:1, PAR_END:1, CRITICAL_END:1, BREAK_END:1, RECT_END:1 };
1304
+
1305
+ // mermaid hands back Maps in some versions and plain objects in others
1306
+ function asObject(m){
1307
+ var o = {};
1308
+ if (m && typeof m.forEach === 'function' && !Array.isArray(m)) m.forEach(function(v, k){ o[k] = v; });
1309
+ else if (m) Object.keys(m).forEach(function(k){ o[k] = m[k]; });
1310
+ return o;
1311
+ }
1312
+
1313
+ function headKind(type){
1314
+ if (/_OPEN$/.test(type)) return null; // -> and -->: a line with no head
1315
+ if (/CROSS/.test(type)) return 'cross'; // -x
1316
+ if (/POINT$|STICK/.test(type)) return 'open'; // -) : asynchronous
1317
+ return 'tri'; // ->> and the rest
1318
+ }
1319
+
1320
+ function kwWidth(word){
1321
+ cv.font = fontString(9.4, 620);
1322
+ return Math.ceil(cv.measureText(word).width + word.length * .5) + 14;
1323
+ }
1324
+
1325
+ function textLeft(m, x, cy, cls){
1326
+ var t = mk('text', { x:x, y:cy, 'text-anchor':'start' }, cls);
1327
+ for (var i=0;i<m.lines.length;i++){
1328
+ var ts = mk('tspan', { x:x, dy: i ? m.lh : 0 });
1329
+ ts.textContent = m.lines[i];
1330
+ t.appendChild(ts);
1331
+ }
1332
+ return t;
1333
+ }
1334
+
1335
+ // avail: the width it will be shown at, in pixels; 0 when not known yet
1336
+ function drawSequence(db, code, avail){
1337
+ var LT = db.LINETYPE, PL = db.PLACEMENT || { LEFTOF:0, RIGHTOF:1, OVER:2 };
1338
+ var typeName = {};
1339
+ Object.keys(LT).forEach(function(k){ typeName[LT[k]] = k; });
1340
+
1341
+ /* --- participants --- */
1342
+
1343
+ var actorMap = asObject(db.getActors());
1344
+ var keys = db.getActorKeys ? db.getActorKeys() : Object.keys(actorMap);
1345
+ var created = asObject(db.getCreatedActors && db.getCreatedActors());
1346
+ var destroyed = asObject(db.getDestroyedActors && db.getDestroyedActors());
1347
+ var actors = keys.map(function(k, i){
1348
+ var a = actorMap[k] || {};
1349
+ return { id:k, i:i, type:a.type || 'participant', label: a.description || k };
1350
+ });
1351
+ if (!actors.length) throw new Error('no participants');
1352
+ var byId = {};
1353
+ actors.forEach(function(a){ byId[a.id] = a; });
1354
+
1355
+ // Fitting the diagram to its room measures the same text at the same few
1356
+ // widths over and over; each answer is kept for the length of this drawing.
1357
+ // (Not longer: the measurements change once the webfont has loaded.)
1358
+ var memo = {};
1359
+ var fit = function(text, px, width, weight){
1360
+ var key = px + '|' + width + '|' + (weight || '') + '|' + text;
1361
+ return memo[key] || (memo[key] = measure(text, px, width, weight, true));
1362
+ };
1363
+
1364
+ // names are measured for a given line width too; every box in the row is as tall as the tallest
1365
+ var headH = 0;
1366
+ var remeasureActors = function(width){
1367
+ actors.forEach(function(a){
1368
+ a.m = fit(a.label, SEQ.actorFs, width, 560);
1369
+ a.w = Math.round(Math.max(a.m.w + PAD_X*2 + (a.type === 'actor' ? 20 : 0), 72));
1370
+ });
1371
+ headH = Math.round(Math.max.apply(null, actors.map(function(a){ return a.m.h; })) + PAD_Y*2 +
1372
+ (actors.some(function(a){ return a.type === 'database'; }) ? 12 : 0));
1373
+ };
1374
+ remeasureActors(SEQ.actorW);
1375
+
1376
+ /* --- the message list, flattened --- */
1377
+
1378
+ var items = [], num = 1, step = 1, numbered = false, count = 0;
1379
+ db.getMessages().forEach(function(m, mi){
1380
+ var t = typeName[m.type] || '';
1381
+ if (t === 'AUTONUMBER'){
1382
+ var o = m.message || {};
1383
+ num = o.start || num;
1384
+ step = o.step || step;
1385
+ numbered = o.visible !== false;
1386
+ return;
1387
+ }
1388
+ if (t === 'NOTE'){
1389
+ if (byId[m.from]) items.push({ kind:'note', from:m.from, to: byId[m.to] ? m.to : m.from,
1390
+ place:m.placement, text:m.message });
1391
+ return;
1392
+ }
1393
+ if (t === 'ACTIVE_START' || t === 'ACTIVE_END'){
1394
+ if (byId[m.from]) items.push({ kind: t === 'ACTIVE_START' ? 'on' : 'off', actor:m.from });
1395
+ return;
1396
+ }
1397
+ if (FRAME_OPEN[t]){ items.push({ kind:'open', frame:FRAME_OPEN[t], text:m.message }); return; }
1398
+ if (FRAME_SPLIT[t]){ items.push({ kind:'split', word:FRAME_SPLIT[t], text:m.message }); return; }
1399
+ if (FRAME_CLOSE[t]){ items.push({ kind:'close' }); return; }
1400
+ if (!byId[m.from] || !byId[m.to]) return;
1401
+ count++;
1402
+ items.push({ kind:'msg', id:'m' + mi, from:m.from, to:m.to, type:t, text:m.message,
1403
+ a: byId[m.from].i, b: byId[m.to].i, n: numbered ? num : null,
1404
+ born: created[m.to] === mi ? m.to : created[m.from] === mi ? m.from : null,
1405
+ dies: destroyed[m.to] === mi ? m.to : destroyed[m.from] === mi ? m.from : null });
1406
+ num += step;
1407
+ });
1408
+
1409
+ // labels are measured for a given line width, which the fitting below may change
1410
+ var remeasure = function(width){
1411
+ items.forEach(function(it){
1412
+ if (it.kind === 'msg'){
1413
+ it.m = String(decode(it.text)).trim() ? fit(it.text, SEQ.fs, width) : null;
1414
+ it.pw = it.m ? it.m.w + 14 : 0;
1415
+ it.ph = it.m ? it.m.h + 6 : 0;
1416
+ } else if (it.kind === 'note'){
1417
+ it.m = fit(it.text, SEQ.fs, width + SEQ.noteExtra);
1418
+ it.w = it.m.w + SEQ.notePad*2;
1419
+ it.h = Math.round(it.m.h + SEQ.notePad*1.4);
1420
+ }
1421
+ });
1422
+ };
1423
+ remeasure(SEQ.textW);
1424
+
1425
+ /* --- lanes --- */
1426
+
1427
+ var n = actors.length, X = [0];
1428
+
1429
+ // Where a label's plate starts, between the leftmost lane xl and the
1430
+ // rightmost xr of its message: centred, except on the first or last lane,
1431
+ // where hanging out past the edge of the diagram widens it for nothing -
1432
+ // there the label is moved inwards, over its own arrow.
1433
+ var plateLeft = function(xl, xr, a, b, pw){
1434
+ var left = (xl + xr)/2 - pw/2;
1435
+ if (a === 0 && b === n-1) return left;
1436
+ if (a === 0) left = Math.max(left, xl - SEQ.edge);
1437
+ if (b === n-1) left = Math.min(left, xr + (a === b ? SEQ.selfW + 6 : SEQ.edge) - pw);
1438
+ return left;
1439
+ };
1440
+
1441
+ // The gaps between lanes for one allowance of overhang: each starts as wide
1442
+ // as its two participants need and opens further only where a label asks.
1443
+ var solveGaps = function(overhang){
1444
+ var gap = [];
1445
+ for (var k=0;k<n-1;k++) gap.push(Math.max((actors[k].w + actors[k+1].w)/2 + SEQ.gap, SEQ.arrow));
1446
+ var span = function(a, b){ var s = 0; for (var j=a;j<b;j++) s += gap[j]; return s; };
1447
+ // a shortfall is shared out across every gap the message crosses
1448
+ var widen = function(a, b, need){
1449
+ var have = span(a, b);
1450
+ if (b <= a || have >= need - .5) return false;
1451
+ for (var j=a;j<b;j++) gap[j] += (need - have)/(b - a);
1452
+ return true;
1453
+ };
1454
+ for (var pass=0; pass<8; pass++){
1455
+ var moved = false;
1456
+ items.forEach(function(it){
1457
+ if (it.kind === 'note'){
1458
+ var ni = byId[it.from].i;
1459
+ if (it.place === PL.RIGHTOF && ni < n-1) moved = widen(ni, ni+1, it.w + 12 + SEQ.clear) || moved;
1460
+ if (it.place === PL.LEFTOF && ni > 0) moved = widen(ni-1, ni, it.w + 12 + SEQ.clear) || moved;
1461
+ return;
1462
+ }
1463
+ if (it.kind !== 'msg') return;
1464
+ var a = Math.min(it.a, it.b), b = Math.max(it.a, it.b);
1465
+ if (a !== b){
1466
+ if (it.born) moved = widen(a, b, actors[byId[it.born].i].w/2 + 48) || moved;
1467
+ moved = widen(a, b, it.pw - overhang*2) || moved;
1468
+ }
1469
+ // wherever the label ends up, it stops short of the next lifeline out
1470
+ // (and a message to itself leaves room for its loop, which runs right)
1471
+ var xl = span(0, a), xr = span(0, b), left = plateLeft(xl, xr, a, b, it.pw);
1472
+ if (a > 0) moved = widen(a-1, a, xl - left + SEQ.clear) || moved;
1473
+ if (b < n-1) moved = widen(b, b+1, Math.max(left + it.pw - xr, a === b ? SEQ.selfW + 10 : 0) + SEQ.clear) || moved;
1474
+ });
1475
+ if (!moved) break;
1476
+ }
1477
+ return gap;
1478
+ };
1479
+ var placeLanes = function(gap){
1480
+ X = [0];
1481
+ for (var k=0;k<gap.length;k++) X.push(X[k] + gap[k]);
1482
+ X = X.map(Math.round);
1483
+ };
1484
+ var lane = function(id){ return X[byId[id].i]; };
1485
+
1486
+ var noteSpan = function(it){
1487
+ var xa = lane(it.from), xb = lane(it.to), l, r;
1488
+ if (it.place === PL.LEFTOF){ r = xa - 12; l = r - it.w; }
1489
+ else if (it.place === PL.RIGHTOF){ l = xa + 12; r = l + it.w; }
1490
+ else {
1491
+ var reach = xa === xb ? 0 : 28;
1492
+ l = Math.min(xa, xb) - reach; r = Math.max(xa, xb) + reach;
1493
+ if (r - l < it.w){ var c = (l + r)/2; l = c - it.w/2; r = c + it.w/2; }
1494
+ }
1495
+ return [Math.round(l), Math.round(r)];
1496
+ };
1497
+
1498
+ var extent = function(it){
1499
+ if (it.kind === 'note') return noteSpan(it);
1500
+ if (it.kind === 'on' || it.kind === 'off') return [lane(it.actor) - SEQ.bar, lane(it.actor) + SEQ.bar];
1501
+ if (it.kind !== 'msg') return null;
1502
+ var xa = X[it.a], xb = X[it.b], xl = Math.min(xa, xb), xr = Math.max(xa, xb);
1503
+ var left = plateLeft(xl, xr, Math.min(it.a, it.b), Math.max(it.a, it.b), it.pw);
1504
+ var l = Math.min(xl, left), r = Math.max(xr, left + it.pw);
1505
+ if (it.a === it.b) r = Math.max(r, xa + SEQ.selfW + 6);
1506
+ if (it.n != null){ l = Math.min(l, xa - SEQ.badge - 4); r = Math.max(r, xa + SEQ.badge + 4); }
1507
+ if (it.born){
1508
+ var bx = lane(it.born), bw = byId[it.born].w/2;
1509
+ l = Math.min(l, bx - bw); r = Math.max(r, bx + bw);
1510
+ }
1511
+ return [l, r];
1512
+ };
1513
+
1514
+ /* --- frames: measured from what they hold, each one inside its parent --- */
1515
+
1516
+ // balanced once: an else or an end with no frame to belong to is dropped,
1517
+ // and a frame left open is closed at the end
1518
+ var unclosed = 0;
1519
+ items = items.filter(function(it){
1520
+ if (it.kind === 'open') unclosed++;
1521
+ if (it.kind === 'split' && !unclosed) return false;
1522
+ if (it.kind === 'close'){ if (!unclosed) return false; unclosed--; }
1523
+ return true;
1524
+ });
1525
+ while (unclosed-- > 0) items.push({ kind:'close' });
1526
+
1527
+ var frames = [];
1528
+ var finish = function(f){
1529
+ if (!isFinite(f.l)){ f.l = X[0] - 40; f.r = X[0] + 120; }
1530
+ f.l -= SEQ.framePad; f.r += SEQ.framePad;
1531
+ if (f.frame !== 'rect'){
1532
+ f.kw = f.frame.toUpperCase(); f.kwW = kwWidth(f.kw);
1533
+ var widest = function(text, kwW){
1534
+ return String(decode(text)).trim() ? kwW + Math.min(fit(text, SEQ.fs, 0).w, 300) + 30 : kwW + 16;
1535
+ };
1536
+ var need = widest(f.text, f.kwW);
1537
+ f.splits.forEach(function(s){ s.kw = s.word.toUpperCase(); s.kwW = kwWidth(s.kw); need = Math.max(need, widest(s.text, s.kwW)); });
1538
+ f.r = Math.max(f.r, f.l + need);
1539
+ }
1540
+ if (f.parent){ f.parent.l = Math.min(f.parent.l, f.l); f.parent.r = Math.max(f.parent.r, f.r); }
1541
+ };
1542
+ var buildFrames = function(){
1543
+ var stack = [];
1544
+ frames = [];
1545
+ items.forEach(function(it){
1546
+ if (it.kind === 'open'){
1547
+ it.f = { frame:it.frame, text:it.text, l:Infinity, r:-Infinity, splits:[], parent: stack[stack.length-1] || null };
1548
+ frames.push(it.f);
1549
+ stack.push(it.f);
1550
+ return;
1551
+ }
1552
+ if (it.kind === 'split'){ it.f = stack[stack.length-1]; it.f.splits.push(it); return; }
1553
+ if (it.kind === 'close'){ it.f = stack.pop(); finish(it.f); return; }
1554
+ var e = extent(it), top = stack[stack.length-1];
1555
+ if (e && top){ top.l = Math.min(top.l, e[0]); top.r = Math.max(top.r, e[1]); }
1556
+ });
1557
+ };
1558
+
1559
+ // read from the source: mermaid keeps the title in state shared by every
1560
+ // diagram, and the next one parsed on the page clears it
1561
+ var title = String(decode((String(code || '').match(/^\s*title(?:\s*:\s*|\s+)(.+?)\s*$/m) || [])[1] || '')).trim();
1562
+ var titleM = title ? measure(title, 14, 0, 620) : null;
1563
+ var boxes = (db.getBoxes && db.getBoxes()) || [];
1564
+ var boxTitled = boxes.some(function(b){ return b.name && String(b.name).trim(); });
1565
+
1566
+ // everything that takes horizontal room, for the lanes as they stand
1567
+ var bounds = function(){
1568
+ var minX = Infinity, maxX = -Infinity;
1569
+ var take = function(l, r){ minX = Math.min(minX, l); maxX = Math.max(maxX, r); };
1570
+ actors.forEach(function(a){ take(X[a.i] - a.w/2, X[a.i] + a.w/2); });
1571
+ items.forEach(function(it){ var e = extent(it); if (e) take(e[0], e[1]); });
1572
+ frames.forEach(function(f){ take(f.l, f.r); });
1573
+ var boxRects = boxes.map(function(b){
1574
+ var ids = (b.actorKeys || []).filter(function(id){ return byId[id]; });
1575
+ if (!ids.length) return null;
1576
+ var l = Math.min.apply(null, ids.map(function(id){ return lane(id) - byId[id].w/2; })) - 14;
1577
+ var r = Math.max.apply(null, ids.map(function(id){ return lane(id) + byId[id].w/2; })) + 14;
1578
+ take(l, r);
1579
+ return { b:b, l:l, r:r };
1580
+ }).filter(Boolean);
1581
+ if (titleM) take((minX + maxX)/2 - titleM.w/2, (minX + maxX)/2 + titleM.w/2);
1582
+ return { minX: Math.floor(minX - SEQ.margin), maxX: Math.ceil(maxX + SEQ.margin), boxRects: boxRects };
1583
+ };
1584
+ var widthFor = function(gap){
1585
+ placeLanes(gap);
1586
+ buildFrames();
1587
+ var b = bounds();
1588
+ return b.maxX - b.minX;
1589
+ };
1590
+
1591
+ /* --- using the room there is ---
1592
+ Compact is the fallback, not the aim. Given the width the diagram will be
1593
+ shown at, it is laid out to fill that width, in order of what helps the
1594
+ reader most: first the overhang is taken back, so every label sits within
1595
+ its own arrow; then labels get longer lines, so they take fewer of them;
1596
+ then the narrowest gaps are raised - never past SEQ.maxGap, so two
1597
+ participants do not end up a whole column apart. A diagram too wide for
1598
+ the room goes the other way: its labels wrap shorter before the whole
1599
+ drawing is shrunk to fit. */
1600
+
1601
+ var chosen = solveGaps(SEQ.overhang), wraps = SEQ.wraps, k;
1602
+ if (avail > 0 && n > 1 && widthFor(chosen) > avail){
1603
+ // Narrower participant names first - wide names set the least gap between
1604
+ // lanes, and wrapping one only makes the header row taller - then shorter
1605
+ // message lines, which make every row taller.
1606
+ var tryFit = function(names, lines){
1607
+ remeasureActors(names);
1608
+ remeasure(lines);
1609
+ chosen = solveGaps(SEQ.overhang);
1610
+ return widthFor(chosen) <= avail;
1611
+ };
1612
+ var fitted = SEQ.squeeze.some(function(s){ return tryFit(s[0], s[1]); });
1613
+ // shorter lines that still do not fit only make it taller: keep the usual ones
1614
+ if (!fitted) tryFit(SEQ.squeeze[SEQ.squeeze.length - 1][0], SEQ.textW);
1615
+ } else if (avail > 0 && n > 1){
1616
+ var loose = solveGaps(0);
1617
+ if (widthFor(loose) > avail){
1618
+ var lo = 0, hi = SEQ.overhang;
1619
+ for (k=0;k<7;k++){
1620
+ var mid = (lo + hi)/2, g = solveGaps(mid);
1621
+ if (widthFor(g) <= avail){ chosen = g; hi = mid; } else lo = mid;
1622
+ }
1623
+ } else {
1624
+ for (k = wraps.indexOf(SEQ.textW) + 1; k < wraps.length; k++){
1625
+ remeasure(wraps[k]);
1626
+ var longer = solveGaps(0);
1627
+ if (widthFor(longer) > avail){ remeasure(wraps[k-1]); break; }
1628
+ loose = longer;
1629
+ }
1630
+ var room = avail - widthFor(loose);
1631
+ var raise = function(level){
1632
+ return loose.map(function(gp){ return Math.max(gp, Math.min(level, SEQ.maxGap)); });
1633
+ };
1634
+ var added = function(level){
1635
+ return raise(level).reduce(function(s, gp, j){ return s + gp - loose[j]; }, 0);
1636
+ };
1637
+ var level = SEQ.maxGap;
1638
+ if (added(level) > room){
1639
+ var lo2 = Math.min.apply(null, loose), hi2 = SEQ.maxGap;
1640
+ for (k=0;k<20;k++){ var m2 = (lo2 + hi2)/2; if (added(m2) > room) hi2 = m2; else lo2 = m2; }
1641
+ level = lo2;
1642
+ }
1643
+ chosen = raise(level);
1644
+ // what grows with the lanes - a frame's reach, a centred note - is measured, not assumed
1645
+ for (k=0;k<4;k++){
1646
+ var over = widthFor(chosen) - avail;
1647
+ if (over <= 1) break;
1648
+ chosen = chosen.map(function(gp, j){ return Math.max(loose[j], gp - over/chosen.length); });
1649
+ }
1650
+ }
1651
+ }
1652
+ placeLanes(chosen);
1653
+ buildFrames();
1654
+
1655
+ /* --- rows, top to bottom --- */
1656
+
1657
+ var y = SEQ.margin;
1658
+ if (titleM) y += titleM.h + 14;
1659
+ if (boxes.length) y += boxTitled ? 28 : 12;
1660
+ var headTop = y, headBottom = y + headH;
1661
+ y = headBottom + 18;
1662
+
1663
+ var active = {}, bars = [], born = {}, died = {};
1664
+ var depth = function(id){ return (active[id] || []).length; };
1665
+ // where an arrow meets a lane: the edge of an activation bar, if one is open
1666
+ var meet = function(id, side, extra){
1667
+ var d = depth(id) + (extra || 0);
1668
+ return d ? lane(id) + (d - 1)*SEQ.bar/2 + side*SEQ.bar/2 : lane(id);
1669
+ };
1670
+
1671
+ items.forEach(function(it, k){
1672
+ var prev = items[k-1], next = items[k+1];
1673
+ if (it.kind === 'msg'){
1674
+ var gapBelow = it.n != null ? SEQ.numGap : SEQ.plateGap;
1675
+ var arrowY = y + (it.ph ? it.ph + gapBelow : gapBelow + 2);
1676
+ if (it.born) arrowY = Math.max(arrowY, y + headH/2 + 2);
1677
+ it.top = y;
1678
+ it.y = Math.round(arrowY);
1679
+ var dir = it.b >= it.a ? 1 : -1;
1680
+ var opens = next && next.kind === 'on' && next.actor === it.to ? 1 : 0;
1681
+ if (it.a === it.b){
1682
+ it.x1 = meet(it.from, 1);
1683
+ it.x2 = meet(it.from, 1, opens);
1684
+ } else {
1685
+ // a participant created by this message is met at its box, not its lane
1686
+ it.x1 = it.born === it.from ? lane(it.from) + dir*byId[it.from].w/2 : meet(it.from, dir);
1687
+ it.x2 = it.born === it.to ? lane(it.to) - dir*byId[it.to].w/2 : meet(it.to, -dir, opens);
1688
+ }
1689
+ if (it.born) born[it.born] = it.y;
1690
+ if (it.dies) died[it.dies] = it.y + 16;
1691
+ y = it.y + (it.a === it.b ? SEQ.selfH : 0) + (it.born ? headH/2 : 0) + SEQ.rowGap;
1692
+ return;
1693
+ }
1694
+ var anchor = prev && prev.kind === 'msg' ? prev.y + (prev.a === prev.b ? SEQ.selfH : 0) : y;
1695
+ if (it.kind === 'on'){
1696
+ (active[it.actor] = active[it.actor] || []).push({ y0: anchor, d: depth(it.actor) });
1697
+ return;
1698
+ }
1699
+ if (it.kind === 'off'){
1700
+ var open = active[it.actor] && active[it.actor].pop();
1701
+ if (open) bars.push({ actor:it.actor, y0:open.y0, y1:Math.max(anchor, open.y0 + 14), d:open.d });
1702
+ return;
1703
+ }
1704
+ if (it.kind === 'note'){
1705
+ var sp = noteSpan(it);
1706
+ it.l = sp[0]; it.r = sp[1]; it.y = y;
1707
+ y += it.h + SEQ.rowGap;
1708
+ return;
1709
+ }
1710
+ var f = it.f;
1711
+ if (!f) return;
1712
+ var labelFor = function(text, kwW){
1713
+ return String(decode(text)).trim()
1714
+ ? measure(text, SEQ.fs, Math.max(f.r - f.l - kwW - 30, 120), null, true) : null;
1715
+ };
1716
+ if (it.kind === 'open'){
1717
+ f.y0 = y - 4;
1718
+ if (f.frame === 'rect'){ y += 8; return; }
1719
+ f.cond = labelFor(f.text, f.kwW);
1720
+ y = f.y0 + Math.max(SEQ.tabH, f.cond ? f.cond.h + 6 : 0) + 14;
1721
+ return;
1722
+ }
1723
+ if (it.kind === 'split'){
1724
+ it.y = y - 4;
1725
+ it.cond = labelFor(it.text, it.kwW);
1726
+ y = it.y + Math.max(SEQ.tabH, it.cond ? it.cond.h + 6 : 0) + 14;
1727
+ return;
1728
+ }
1729
+ if (it.kind === 'close'){
1730
+ f.y1 = y - (f.frame === 'rect' ? 6 : 2);
1731
+ y = f.y1 + SEQ.rowGap + 2;
1732
+ }
1733
+ });
1734
+
1735
+ var lifeEnd = y + 4;
1736
+ Object.keys(active).forEach(function(id){
1737
+ active[id].forEach(function(b){ bars.push({ actor:id, y0:b.y0, y1:Math.max(lifeEnd - 10, b.y0 + 14), d:b.d }); });
1738
+ });
1739
+ var tall = lifeEnd - headBottom > SEQ.tall;
1740
+ var footTop = lifeEnd, footBottom = tall ? footTop + headH : lifeEnd;
1741
+ var boxBottom = footBottom + 12;
1742
+
1743
+ var bb = bounds(), minX = bb.minX, maxX = bb.maxX, boxRects = bb.boxRects;
1744
+ var W = maxX - minX, H = Math.ceil((boxRects.length ? boxBottom : footBottom) + SEQ.margin);
1745
+
1746
+ /* --- drawing --- */
1747
+
1748
+ var svg = mk('svg', { viewBox: minX + ' 0 ' + W + ' ' + H, width:W, height:H, role:'img' }, 'dg dg-seq');
1749
+ svg.style.maxWidth = '100%';
1750
+ svg.style.height = 'auto';
1751
+ var layer = function(cls){ var g = mk('g', null, cls); svg.appendChild(g); return g; };
1752
+ var gBox = layer('dg-seq-boxes'), gFrame = layer('dg-seq-frames'), gNode = layer('dg-nodes'),
1753
+ gEdge = layer('dg-edges'), gNote = layer('dg-seq-notes'), gLabel = layer('dg-labels'),
1754
+ gMark = layer('dg-seq-marks');
1755
+
1756
+ if (titleM) svg.insertBefore(textBlock(titleM, (minX + maxX)/2, SEQ.margin + titleM.h/2, 'dg-seq-title'), gBox);
1757
+
1758
+ boxRects.forEach(function(br){
1759
+ var top = headTop - (boxTitled ? 28 : 12);
1760
+ var rect = mk('rect', { x:br.l, y:top, width:br.r - br.l, height:boxBottom - top, rx:12 }, 'dg-seq-box');
1761
+ if (br.b.fill && !/^(transparent|none)$/i.test(br.b.fill)){ rect.style.fill = br.b.fill; rect.style.fillOpacity = '.13'; }
1762
+ gBox.appendChild(rect);
1763
+ if (br.b.name && String(br.b.name).trim()){
1764
+ gBox.appendChild(textBlock(measure(br.b.name, 12, br.r - br.l - 20, 560), (br.l + br.r)/2, top + 14, 'dg-seq-box-text'));
1765
+ }
1766
+ });
1767
+
1768
+ var actorEl = function(g, a, top){
1769
+ var x = X[a.i], w = a.w, h = headH, l = x - w/2, shape;
1770
+ if (a.type === 'collections') g.appendChild(mk('rect', { x:l + 4, y:top - 4, width:w, height:h, rx:8 }, 'dg-node-shape'));
1771
+ if (a.type === 'database') shape = shapeEl(l, top, w, h, 'cylinder');
1772
+ else shape = mk('rect', { x:l, y:top, width:w, height:h, rx: a.type === 'queue' ? h/2 : 8 });
1773
+ shape.setAttribute('class', 'dg-node-shape');
1774
+ g.appendChild(shape);
1775
+ var cx = x, cy = top + h/2 + (a.type === 'database' ? 5 : 0);
1776
+ if (a.type === 'actor'){
1777
+ var gx = l + PAD_X + 3;
1778
+ cx = x + 10;
1779
+ g.appendChild(mk('circle', { cx:gx, cy:cy - 4.5, r:3.4 }, 'dg-seq-glyph'));
1780
+ g.appendChild(mk('path', { d:'M'+(gx - 6)+','+(cy + 7)+' a6,6.5 0 0 1 12,0' }, 'dg-seq-glyph'));
1781
+ }
1782
+ g.appendChild(textBlock(a.m, cx, cy, 'dg-node-text dg-seq-actor'));
1783
+ };
1784
+
1785
+ actors.forEach(function(a){
1786
+ var x = X[a.i];
1787
+ var g = mk('g', { 'data-node':a.id }, 'dg-el dg-node');
1788
+ var y0 = born[a.id] != null ? born[a.id] + headH/2 : headBottom;
1789
+ var y1 = died[a.id] != null ? died[a.id] : footTop;
1790
+ g.appendChild(mk('line', { x1:x, y1:y0, x2:x, y2:y1 }, 'dg-seq-life-hit'));
1791
+ g.appendChild(mk('line', { x1:x, y1:y0, x2:x, y2:y1 }, 'dg-seq-life'));
1792
+ bars.forEach(function(b){
1793
+ if (b.actor !== a.id) return;
1794
+ g.appendChild(mk('rect', { x:x - SEQ.bar/2 + b.d*SEQ.bar/2, y:b.y0, width:SEQ.bar, height:b.y1 - b.y0, rx:2 }, 'dg-seq-bar'));
1795
+ });
1796
+ if (died[a.id] != null){
1797
+ g.appendChild(mk('path', { d:'M'+(x-6)+','+(y1-6)+' L'+(x+6)+','+(y1+6)+' M'+(x+6)+','+(y1-6)+' L'+(x-6)+','+(y1+6) }, 'dg-seq-x'));
1798
+ }
1799
+ actorEl(g, a, born[a.id] != null ? born[a.id] - headH/2 : headTop);
1800
+ if (tall && died[a.id] == null) actorEl(g, a, footTop);
1801
+ gNode.appendChild(g);
1802
+ });
1803
+
1804
+ items.forEach(function(it){
1805
+ if (it.kind !== 'msg') return;
1806
+ var g = mk('g', { 'data-edge':it.id, 'data-src':it.from, 'data-dst':it.to }, 'dg-el dg-edge');
1807
+ var y = it.y, x1 = it.x1, x2 = it.x2, d, tip, back, tail, tailBack;
1808
+ if (it.a === it.b){
1809
+ // the badge sits on the lane like any other; the loop leaves from its edge
1810
+ var r = 6, xr = Math.max(x1, x2) + SEQ.selfW, yb = y + SEQ.selfH;
1811
+ var xs = it.n != null ? x1 + SEQ.badge + 1 : x1;
1812
+ d = 'M'+xs+','+y+' H'+(xr - r)+' Q'+xr+','+y+' '+xr+','+(y + r)+' V'+(yb - r)+
1813
+ ' Q'+xr+','+yb+' '+(xr - r)+','+yb+' H'+x2;
1814
+ tip = { x:x2, y:yb }; back = { x:x2 + 10, y:yb };
1815
+ tail = { x:x1, y:y }; tailBack = { x:x1 + 10, y:y };
1816
+ } else {
1817
+ var s = x2 > x1 ? 1 : -1;
1818
+ d = 'M'+x1+','+y+' H'+x2;
1819
+ tip = { x:x2, y:y }; back = { x:x2 - s*10, y:y };
1820
+ tail = { x:x1, y:y }; tailBack = { x:x1 + s*10, y:y };
1821
+ }
1822
+ g.appendChild(mk('path', { d:d, fill:'none' }, 'dg-edge-hit'));
1823
+ var line = mk('path', { d:d, fill:'none' }, 'dg-edge-line');
1824
+ if (/DOTTED/.test(it.type)) line.style.strokeDasharray = '6 4';
1825
+ g.appendChild(line);
1826
+ var head = headKind(it.type);
1827
+ if (head) g.appendChild(arrowHead(tip, back, head));
1828
+ if (/BIDIRECTIONAL/.test(it.type)){
1829
+ if (it.n != null && it.a !== it.b){
1830
+ var nudge = (x2 > x1 ? 1 : -1) * (SEQ.badge + 1);
1831
+ tail = { x:x1 + nudge, y:y }; tailBack = { x:tailBack.x + nudge, y:y };
1832
+ }
1833
+ g.appendChild(arrowHead(tail, tailBack, 'tri'));
1834
+ }
1835
+ gEdge.appendChild(g);
1836
+
1837
+ var lg = mk('g', { 'data-edge':it.id, 'data-src':it.from, 'data-dst':it.to }, 'dg-el dg-label');
1838
+ if (it.m){
1839
+ var pl = plateLeft(Math.min(X[it.a], X[it.b]), Math.max(X[it.a], X[it.b]), Math.min(it.a, it.b), Math.max(it.a, it.b), it.pw);
1840
+ var cx = pl + it.pw/2;
1841
+ lg.appendChild(mk('rect', { x:pl, y:it.top, width:it.pw, height:it.ph, rx:5 }, 'dg-label-plate'));
1842
+ lg.appendChild(textBlock(it.m, cx, it.top + it.ph/2, 'dg-edge-text dg-seq-text'));
1843
+ }
1844
+ if (it.n != null){
1845
+ var label = String(it.n);
1846
+ cv.font = fontString(9.6, 620);
1847
+ var bw = Math.max(SEQ.badge*2, Math.ceil(cv.measureText(label).width) + 9);
1848
+ lg.appendChild(mk('rect', { x:x1 - bw/2, y:y - SEQ.badge, width:bw, height:SEQ.badge*2, rx:SEQ.badge }, 'dg-seq-num'));
1849
+ var nt = mk('text', { x:x1, y:y + .5, 'text-anchor':'middle' }, 'dg-seq-num-text');
1850
+ nt.textContent = label;
1851
+ lg.appendChild(nt);
1852
+ }
1853
+ gLabel.appendChild(lg);
1854
+ });
1855
+
1856
+ items.forEach(function(it){
1857
+ if (it.kind !== 'note') return;
1858
+ gNote.appendChild(mk('rect', { x:it.l, y:it.y, width:it.r - it.l, height:it.h, rx:6 }, 'dg-seq-note'));
1859
+ gNote.appendChild(textBlock(it.m, (it.l + it.r)/2, it.y + it.h/2, 'dg-seq-note-text'));
1860
+ });
1861
+
1862
+ // the keyword on a tab, the condition on a plate beside it
1863
+ var mark = function(f, top, kw, kwW, cond, first){
1864
+ var l = f.l, h = SEQ.tabH;
1865
+ gMark.appendChild(mk('path', { d: first
1866
+ ? 'M'+l+','+(top + h)+' V'+(top + 8)+' A8,8 0 0 1 '+(l + 8)+','+top+' H'+(l + kwW)+' V'+(top + h - 6)+
1867
+ ' A6,6 0 0 1 '+(l + kwW - 6)+','+(top + h)+' Z'
1868
+ : 'M'+l+','+top+' H'+(l + kwW)+' V'+(top + h - 6)+' A6,6 0 0 1 '+(l + kwW - 6)+','+(top + h)+' H'+l+' Z'
1869
+ }, 'dg-seq-tab'));
1870
+ var kt = mk('text', { x:l + kwW/2, y:top + h/2 + .5, 'text-anchor':'middle' }, 'dg-seq-kw');
1871
+ kt.textContent = kw;
1872
+ gMark.appendChild(kt);
1873
+ if (!cond) return;
1874
+ var tx = l + kwW + 9;
1875
+ gMark.appendChild(mk('rect', { x:tx - 4, y:top + 2, width:cond.w + 8, height:cond.h + 2, rx:4 }, 'dg-label-plate'));
1876
+ gMark.appendChild(textLeft(cond, tx, top + 3 + cond.lh/2, 'dg-seq-cond'));
1877
+ };
1878
+
1879
+ frames.forEach(function(f){
1880
+ if (f.y0 == null || f.y1 == null) return;
1881
+ if (f.frame === 'rect'){
1882
+ var band = mk('rect', { x:f.l, y:f.y0, width:f.r - f.l, height:f.y1 - f.y0, rx:8 }, 'dg-seq-rect');
1883
+ if (f.text) band.style.fill = decode(f.text);
1884
+ gFrame.appendChild(band);
1885
+ return;
1886
+ }
1887
+ gFrame.appendChild(mk('rect', { x:f.l, y:f.y0, width:f.r - f.l, height:f.y1 - f.y0, rx:8 }, 'dg-seq-frame'));
1888
+ mark(f, f.y0, f.kw, f.kwW, f.cond, true);
1889
+ f.splits.forEach(function(s){
1890
+ if (s.y == null) return;
1891
+ gFrame.appendChild(mk('line', { x1:f.l, y1:s.y, x2:f.r, y2:s.y }, 'dg-seq-split'));
1892
+ mark(f, s.y, s.kw, s.kwW, s.cond, false);
1893
+ });
1894
+ });
1895
+
1896
+ // the participant row alone, to ride along the top of the page
1897
+ var strip = null;
1898
+ if (tall){
1899
+ strip = mk('svg', { viewBox: minX + ' ' + (headTop - 7) + ' ' + W + ' ' + (headH + 14),
1900
+ preserveAspectRatio:'xMidYMin meet' }, 'dg dg-seq dg-float-svg');
1901
+ actors.forEach(function(a){
1902
+ if (born[a.id] != null) return;
1903
+ var g = mk('g', { 'data-node':a.id }, 'dg-el dg-node');
1904
+ actorEl(g, a, headTop);
1905
+ strip.appendChild(g);
1906
+ });
1907
+ strip._top = headTop - 7;
1908
+ strip._h = headH + 14;
1909
+ }
1910
+
1911
+ return { svg:svg, strip:strip, count:count };
1912
+ }
1913
+
1914
+ function renderSequence(fig, code){
1915
+ return Promise.resolve(mermaid.mermaidAPI.getDiagramFromText(code)).then(function(d){
1916
+ var db = d && d.db;
1917
+ if (!db || typeof db.getMessages !== 'function' || !db.LINETYPE) throw new Error('no sequence model');
1918
+ var out = fig.querySelector('.diagram-out');
1919
+ var avail = out.clientWidth;
1920
+ var res = drawSequence(db, code, avail);
1921
+ out.textContent = '';
1922
+ out.appendChild(res.svg);
1923
+ fig._seqWidth = avail;
1924
+ refitOnResize(fig, out);
1925
+ if (res.strip){
1926
+ var wrap = el('div', 'dg-float');
1927
+ wrap.appendChild(res.strip);
1928
+ wrap._main = res.svg;
1929
+ res.strip._main = res.svg;
1930
+ res.svg._float = res.strip;
1931
+ out.appendChild(wrap);
1932
+ }
1933
+ var note = fig.querySelector('.diagram-note');
1934
+ if (res.count >= 6){
1935
+ if (!note){ note = el('div','diagram-note'); fig.appendChild(note); }
1936
+ note.textContent = 'Hover a message or a participant to isolate it · click to pin · Esc to release';
1937
+ } else if (note) note.remove();
1938
+ scheduleFloats();
1939
+ });
1940
+ }
1941
+
1942
+ // The lanes of a sequence, and the shape of an ER diagram, are fitted to the
1943
+ // width the diagram had when it was drawn. When that changes - the window resized, the sidebar folded away, a folded
1944
+ // section opened for the first time - it is drawn again for the new width.
1945
+ function refitOnResize(fig, out){
1946
+ if (fig._seqRefit || typeof ResizeObserver === 'undefined') return;
1947
+ var timer = 0;
1948
+ fig._seqRefit = new ResizeObserver(function(){
1949
+ clearTimeout(timer);
1950
+ timer = setTimeout(function(){
1951
+ var w = out.isConnected ? out.clientWidth : 0;
1952
+ // a scrollbar coming and going is not a reason to redraw
1953
+ if (!w || fig.dataset.drawn !== 'clean' || Math.abs(w - (fig._seqWidth || 0)) < 16) return;
1954
+ fig.dataset.drawn = '';
1955
+ draw(fig, 'clean');
1956
+ }, 120);
1957
+ });
1958
+ fig._seqRefit.observe(out);
1959
+ }
1960
+
1961
+ // A long sequence outlives its own header: a few screens down, which lane
1962
+ // is which becomes a guess. While the diagram is on screen and its header is
1963
+ // not, the participant row is laid over the top of it, lane for lane.
1964
+ function placeFloats(){
1965
+ var bar = document.getElementById('topbar');
1966
+ var edge = bar ? Math.max(0, bar.getBoundingClientRect().bottom) : 0;
1967
+ [].slice.call(document.querySelectorAll('.dg-float')).forEach(function(f){
1968
+ var svg = f._main, strip = f.firstChild, fig = f.closest('.diagram');
1969
+ if (!svg || !strip || !fig || !svg.isConnected) return;
1970
+ var vb = svg.viewBox && svg.viewBox.baseVal, r = svg.getBoundingClientRect();
1971
+ if (!vb || !vb.width || !r.width){ f.classList.remove('on'); return; }
1972
+ var k = r.width / vb.width, h = strip._h * k;
1973
+ var on = r.top + (strip._top + strip._h - vb.y) * k < edge && r.bottom - h * 3 > edge;
1974
+ f.classList.toggle('on', on);
1975
+ if (!on) return;
1976
+ var fr = fig.getBoundingClientRect();
1977
+ f.style.top = Math.round(edge - fr.top - fig.clientTop + fig.scrollTop) + 'px';
1978
+ f.style.height = Math.round(h) + 'px';
1979
+ strip.style.left = Math.round(r.left - fr.left - fig.clientLeft + fig.scrollLeft) + 'px';
1980
+ strip.style.width = Math.round(r.width) + 'px';
1981
+ strip.style.height = Math.round(h) + 'px';
1982
+ });
1983
+ }
1984
+ var floatTick = false;
1985
+ function scheduleFloats(){
1986
+ if (floatTick) return;
1987
+ floatTick = true;
1988
+ requestAnimationFrame(function(){ floatTick = false; placeFloats(); });
1989
+ }
1990
+ addEventListener('scroll', scheduleFloats, { passive: true });
1991
+ addEventListener('resize', scheduleFloats);
1992
+ // the page's own top bar slides in and out; the row sits under it
1993
+ document.addEventListener('transitionend', function(e){
1994
+ if (e.target && e.target.id === 'topbar') scheduleFloats();
1995
+ });
1996
+
1997
+ /* --------------------------------------------------------------- render */
1998
+
1999
+ // The types the clean renderer draws. Everything else - state, class,
2000
+ // gantt, pie - goes to mermaid, which draws them well.
2001
+ var CLEAN_KINDS = { flowchart:'flowchart', graph:'flowchart', erdiagram:'er', sequencediagram:'sequence' };
2002
+
2003
+ function cleanKind(code){
2004
+ var head = String(code).replace(/^\s*(%%\{[\s\S]*?\}%%\s*)*/, '').trim().split(/[\s\n]/)[0] || '';
2005
+ return CLEAN_KINDS[head.toLowerCase()] || null;
2006
+ }
2007
+
2008
+ // a sequence is laid out here, not by ELK, so it only needs mermaid's parser
2009
+ function canClean(kind){
2010
+ return (kind === 'sequence' || typeof ELK !== 'undefined') && typeof mermaid !== 'undefined' &&
2011
+ mermaid.mermaidAPI && typeof mermaid.mermaidAPI.getDiagramFromText === 'function';
2012
+ }
2013
+
2014
+ function renderClean(fig, code, kind){
2015
+ if (!initMermaid()) return Promise.reject(new Error('mermaid unavailable'));
2016
+ if (kind === 'sequence') return renderSequence(fig, code);
2017
+ if (!elk) elk = new ELK();
2018
+ return Promise.resolve(mermaid.mermaidAPI.getDiagramFromText(code)).then(function(d){
2019
+ var db = d && d.db;
2020
+ if (!db || typeof db.getData !== 'function') throw new Error('no graph model');
2021
+ var data = db.getData();
2022
+ if (!data || !data.nodes || !data.nodes.length) throw new Error('nothing to draw');
2023
+ // Flowcharts and ER diagrams hand back the same shape of model, which is
2024
+ // why one layout and one drawing pass serve both; only the sizes, the
2025
+ // end marks and the box itself differ.
2026
+ data.__er = kind === 'er';
2027
+ if (data.__er){
2028
+ attachDetails(data, code);
2029
+ var open = fig._open || {};
2030
+ // the width an opened entity can take without widening the diagram past the page
2031
+ var room = fig.querySelector('.diagram-out').clientWidth;
2032
+ room = room ? room - Number(SPACING.er.pad) * 2 - 4 : 0;
2033
+ data.nodes.forEach(function(n){
2034
+ n._open = !!(n._details && open[n.label]);
2035
+ n._room = room;
2036
+ });
2037
+ }
2038
+ var raw = (db.getDirection && db.getDirection()) || 'TB';
2039
+ var dir = DIRS[String(raw).toUpperCase()] || 'DOWN';
2040
+ var avail = fig.querySelector('.diagram-out').clientWidth;
2041
+ var shape = { dir: dir, extra: null };
2042
+ var layout = function(){ return elk.layout(buildGraph(data, shape.dir, shape.extra)); };
2043
+ var first = !data.__er || !avail ? layout() : Promise.all(erShapes(code, dir, avail).map(function(sh){
2044
+ return elk.layout(buildGraph(data, sh.dir, sh.extra)).then(function(res){
2045
+ return { dir: sh.dir, extra: sh.extra, res: res };
2046
+ }, function(){ return null; });
2047
+ })).then(function(tries){
2048
+ tries = tries.filter(Boolean);
2049
+ if (!tries.length) return layout();
2050
+ var best = pickShape(tries, avail);
2051
+ shape = { dir: best.dir, extra: best.extra };
2052
+ return best.res;
2053
+ });
2054
+ return first.then(function(res){
2055
+ // Relationships routed around an opened entity add width the entity
2056
+ // alone did not have. If that takes the diagram past the page, the
2057
+ // entity's descriptions give that width back and it is laid out once more.
2058
+ var over = Math.ceil(res.width) - avail;
2059
+ if (!data.__er || !avail || over <= 2 || !data.nodes.some(function(n){ return n._open; })) return res;
2060
+ data.nodes.forEach(function(n){ n._room = Math.max(0, n._room - over); n._er = null; });
2061
+ return layout();
2062
+ }).then(function(res){
2063
+ var out = fig.querySelector('.diagram-out');
2064
+ out.textContent = '';
2065
+ out.appendChild(drawSvg(res, data));
2066
+ // the shape was chosen for this width: another width may want another
2067
+ if (data.__er){ fig._seqWidth = avail; refitOnResize(fig, out); }
2068
+ var note = fig.querySelector('.diagram-note');
2069
+ // An ER chart turns dense sooner: entities are big, so fewer of them
2070
+ // fill the frame and the relationships start crossing earlier.
2071
+ var openable = data.__er && data.nodes.some(function(n){ return n._details; });
2072
+ // descriptions only show on request, so the diagram says how to ask
2073
+ var described = data.__er && data.nodes.some(function(n){
2074
+ return (n._desc && n._desc.length) || (n._details && n._details.rows.some(function(r){ return r.desc; }));
2075
+ });
2076
+ if ((data.edges || []).length >= (data.__er ? 4 : 6) || openable || described){
2077
+ if (!note){ note = el('div','diagram-note'); fig.appendChild(note); }
2078
+ note.textContent = data.__er
2079
+ ? 'Hover a relationship or an entity to isolate it · click to pin · Esc to release' +
2080
+ (described ? ' · hold ' + TIP_KEY_NAME + ' on a table or a column to read its description' : '') +
2081
+ (openable ? ' · the button in an entity’s corner opens it to every column' : '')
2082
+ : 'Hover an arrow or a box to isolate it · click to pin · Esc to release';
2083
+ } else if (note) note.remove();
2084
+ });
2085
+ });
2086
+ }
2087
+
2088
+ // The button is for comparing one diagram, not for changing the project's
2089
+ // mind: a switch lasts as long as the page is open and no longer. The
2090
+ // project-wide default is a config setting, where it can be reviewed.
2091
+ function engineFor(fig, code){
2092
+ if (!cleanKind(code)) return 'mermaid';
2093
+ return fig.dataset.engine || CFG.engine || 'clean';
2094
+ }
2095
+
2096
+ function paintButton(fig, code, used){
2097
+ var btn = fig.querySelector('[data-act="engine"]');
2098
+ if (!btn) return;
2099
+ btn.textContent = (cleanKind(code) && canClean(cleanKind(code)))
2100
+ ? (used === 'clean' ? 'Mermaid layout' : 'Clean layout') : '';
2101
+ }
2102
+
2103
+ /* ------------------------------------------------------------- folding
2104
+ Any diagram can be collapsed to an empty frame, out of the reader's way.
2105
+ The page remembers it per document and per place in it, in the reader's
2106
+ browser, so it stays collapsed on the next visit and through an edit to
2107
+ its source. A collapsed diagram is not drawn: it is drawn when it opens,
2108
+ at the width it then has. */
2109
+
2110
+ function foldKey(fig){
2111
+ var main = document.getElementById('main');
2112
+ var figs = main ? [].slice.call(main.querySelectorAll('[data-diagram]')) : [];
2113
+ var i = figs.indexOf(fig);
2114
+ if (i < 0) return null;
2115
+ var docId = decodeURIComponent((location.hash.match(/^#\/([^\/]+)/) || [])[1] || '');
2116
+ return (CFG.ns || 'lowkey-doc') + ':fold:' + docId + ':' + i;
2117
+ }
2118
+ function folded(fig){
2119
+ var k = foldKey(fig);
2120
+ try { return !!(k && localStorage.getItem(k)); } catch (e) { return false; }
2121
+ }
2122
+ function setFold(fig, on){
2123
+ var k = foldKey(fig);
2124
+ try { if (k){ if (on) localStorage.setItem(k, '1'); else localStorage.removeItem(k); } } catch (e) {}
2125
+ paintFold(fig, on);
2126
+ if (!on) draw(fig);
2127
+ }
2128
+ function paintFold(fig, on){
2129
+ fig.classList.toggle('is-folded', on);
2130
+ var btn = fig.querySelector('[data-act="fold"]');
2131
+ if (btn){
2132
+ btn.textContent = on ? 'Show' : 'Collapse';
2133
+ btn.title = on ? 'Show the diagram' : 'Fold the diagram away';
2134
+ }
2135
+ }
2136
+
2137
+ function draw(fig, force){
2138
+ var code = fig.querySelector('.diagram-src').textContent;
2139
+ if (!fig._foldSeen){ fig._foldSeen = true; paintFold(fig, folded(fig)); }
2140
+ if (fig.classList.contains('is-folded')) return Promise.resolve();
2141
+ var want = force || engineFor(fig, code);
2142
+ if (fig.dataset.drawn === want) return Promise.resolve();
2143
+ fig.dataset.drawn = want;
2144
+ var kind = cleanKind(code);
2145
+ var done;
2146
+ if (want === 'clean' && kind && canClean(kind)){
2147
+ done = renderClean(fig, code, kind).catch(function(){
2148
+ // Anything the clean renderer cannot do is mermaid's job, quietly.
2149
+ fig.dataset.drawn = 'mermaid';
2150
+ var note = fig.querySelector('.diagram-note');
2151
+ if (note) note.remove();
2152
+ return renderMermaid(fig, code);
2153
+ });
2154
+ } else {
2155
+ var note = fig.querySelector('.diagram-note');
2156
+ if (note) note.remove();
2157
+ done = renderMermaid(fig, code);
2158
+ }
2159
+ return done.then(function(){ paintButton(fig, code, fig.dataset.drawn); });
2160
+ }
2161
+
2162
+ function drawAll(scope){
2163
+ [].slice.call((scope || document).querySelectorAll('[data-diagram]')).forEach(function(f){ draw(f); });
2164
+ }
2165
+
2166
+ // First pass so nothing is blank, then one more once the webfont has landed
2167
+ // and the measurements are the ones the reader will actually see.
2168
+ var refit = false;
2169
+ if (document.fonts && document.fonts.ready){
2170
+ document.fonts.ready.then(function(){
2171
+ if (refit) return;
2172
+ refit = true;
2173
+ [].slice.call(document.querySelectorAll('[data-diagram]')).forEach(function(f){
2174
+ if (f.dataset.drawn === 'clean'){ f.dataset.drawn = ''; draw(f, 'clean'); }
2175
+ });
2176
+ });
2177
+ }
2178
+
2179
+ /* ---------------------------------------------------------- interaction
2180
+ A dense chart asks one hard question - which arrow is this label on, and
2181
+ what touches this box. Hovering answers it by lighting one thing and
2182
+ dimming the rest; clicking pins the answer so it survives the mouse
2183
+ moving away, which is what you want while reading the prose beside it. */
2184
+
2185
+ var pinned = null;
2186
+
2187
+ function clear(svg){
2188
+ svg.classList.remove('has-hot');
2189
+ [].slice.call(svg.querySelectorAll('.is-hot,.is-near')).forEach(function(n){
2190
+ n.classList.remove('is-hot');
2191
+ n.classList.remove('is-near');
2192
+ });
2193
+ mirror(svg);
2194
+ }
2195
+
2196
+ // the floating participant row of a sequence shows what its diagram shows
2197
+ function mirror(svg){
2198
+ var strip = svg._float;
2199
+ if (!strip) return;
2200
+ strip.classList.toggle('has-hot', svg.classList.contains('has-hot'));
2201
+ [].slice.call(strip.querySelectorAll('.dg-node')).forEach(function(n){
2202
+ var twin = svg.querySelector('.dg-node[data-node="' + sel(n.dataset.node) + '"]');
2203
+ n.classList.toggle('is-hot', !!twin && twin.classList.contains('is-hot'));
2204
+ n.classList.toggle('is-near', !!twin && twin.classList.contains('is-near'));
2205
+ });
2206
+ }
2207
+
2208
+ function light(svg, kind, id){
2209
+ clear(svg);
2210
+ svg.classList.add('has-hot');
2211
+ if (kind === 'edge'){
2212
+ var ends = {};
2213
+ [].slice.call(svg.querySelectorAll('[data-edge="' + sel(id) + '"]')).forEach(function(n){
2214
+ n.classList.add('is-hot');
2215
+ ends[n.dataset.src] = 1; ends[n.dataset.dst] = 1;
2216
+ });
2217
+ Object.keys(ends).forEach(function(nid){
2218
+ var n = svg.querySelector('.dg-node[data-node="' + sel(nid) + '"]');
2219
+ if (n) n.classList.add('is-near');
2220
+ });
2221
+ return mirror(svg);
2222
+ }
2223
+ var me = svg.querySelector('.dg-node[data-node="' + sel(id) + '"]');
2224
+ if (me) me.classList.add('is-hot');
2225
+ var near = {};
2226
+ [].slice.call(svg.querySelectorAll('.dg-edge,.dg-label')).forEach(function(n){
2227
+ if (n.dataset.src !== id && n.dataset.dst !== id) return;
2228
+ n.classList.add('is-hot');
2229
+ near[n.dataset.src === id ? n.dataset.dst : n.dataset.src] = 1;
2230
+ });
2231
+ Object.keys(near).forEach(function(nid){
2232
+ var n = svg.querySelector('.dg-node[data-node="' + sel(nid) + '"]');
2233
+ if (n && n !== me) n.classList.add('is-near');
2234
+ });
2235
+ mirror(svg);
2236
+ }
2237
+
2238
+ function targetOf(e){
2239
+ var t = e.target;
2240
+ if (!t || !t.closest) return null;
2241
+ var svg = t.closest('svg.dg');
2242
+ if (!svg) return null;
2243
+ if (svg._main) svg = svg._main; // the floating row stands in for its diagram
2244
+ var edge = t.closest('.dg-edge,.dg-label');
2245
+ if (edge) return { svg: svg, kind: 'edge', id: edge.dataset.edge };
2246
+ var node = t.closest('.dg-node');
2247
+ if (node) return { svg: svg, kind: 'node', id: node.dataset.node };
2248
+ return { svg: svg, kind: null };
2249
+ }
2250
+
2251
+ document.addEventListener('mousemove', function(e){
2252
+ var hit = targetOf(e);
2253
+ lastPointer = { x: e.clientX, y: e.clientY };
2254
+ var said = hit && hit.kind === 'node' && tipKeyHeld(e) && tipTarget(e);
2255
+ if (said) showTip(said); else hideTip();
2256
+ if (!hit){
2257
+ if (!pinned) [].slice.call(document.querySelectorAll('svg.dg.has-hot')).forEach(clear);
2258
+ return;
2259
+ }
2260
+ if (pinned && pinned.svg === hit.svg) return;
2261
+ if (!hit.kind){ clear(hit.svg); return; }
2262
+ light(hit.svg, hit.kind, hit.id);
2263
+ }, { passive: true });
2264
+
2265
+ document.addEventListener('click', function(e){
2266
+ if (e.target.closest && e.target.closest('.diagram-btn')) return;
2267
+ var more = e.target.closest && e.target.closest('.dg-er-more');
2268
+ if (more){ toggleEntity(more); return; }
2269
+ var hit = targetOf(e);
2270
+ if (!hit || !hit.kind){
2271
+ if (pinned){ clear(pinned.svg); pinned = null; }
2272
+ return;
2273
+ }
2274
+ if (pinned && pinned.svg === hit.svg && pinned.id === hit.id){
2275
+ pinned = null;
2276
+ clear(hit.svg);
2277
+ return;
2278
+ }
2279
+ pinned = hit;
2280
+ light(hit.svg, hit.kind, hit.id);
2281
+ });
2282
+
2283
+ document.addEventListener('keydown', function(e){
2284
+ if (e.key === 'Escape' && pinned){ clear(pinned.svg); pinned = null; }
2285
+ });
2286
+
2287
+ /* ------------------------------------------------- opening an entity
2288
+ An entity opens where it stands. Its size changes, so the diagram is laid
2289
+ out again - and so nothing jumps under the reader: the entity keeps its
2290
+ place on the screen, every other entity glides from where it was to where
2291
+ it now is, and the relationships, whose routes have changed, fade in once
2292
+ they are drawn. In the full-size view the same happens to the copy there. */
2293
+
2294
+ function toggleEntity(more){
2295
+ var node = more.closest('.dg-node'), svg = more.closest('svg.dg');
2296
+ if (!node || !svg) return;
2297
+ var inBox = !!svg.closest('.lightbox');
2298
+ var source = inBox ? svg._source : svg;
2299
+ var fig = source && source.closest('.diagram');
2300
+ if (!fig) return;
2301
+
2302
+ var key = node.getAttribute('data-entity');
2303
+ var open = fig._open = fig._open || {};
2304
+ if (open[key]) delete open[key]; else open[key] = true;
2305
+
2306
+ var was = {};
2307
+ [].slice.call(svg.querySelectorAll('.dg-node[data-entity]')).forEach(function(n){
2308
+ was[n.getAttribute('data-entity')] = n.getBoundingClientRect();
2309
+ });
2310
+ if (pinned){ clear(pinned.svg); pinned = null; }
2311
+
2312
+ fig.dataset.drawn = '';
2313
+ draw(fig, 'clean').then(function(){
2314
+ var shown = fig.querySelector('.diagram-out > svg.dg');
2315
+ if (!shown) return;
2316
+ if (inBox && window.__lightbox && window.__lightbox.swap) shown = window.__lightbox.swap(shown) || shown;
2317
+ var anchor = shown.querySelector('.dg-node[data-entity="' + sel(key) + '"]');
2318
+ if (anchor && was[key]){
2319
+ var now = anchor.getBoundingClientRect();
2320
+ if (inBox) window.__lightbox.nudge(was[key].left - now.left, was[key].top - now.top);
2321
+ else window.scrollBy(0, now.top - was[key].top);
2322
+ }
2323
+ glide(shown, was, key);
2324
+ });
2325
+ }
2326
+
2327
+ function glide(svg, was, key){
2328
+ var vb = svg.viewBox && svg.viewBox.baseVal, box = svg.getBoundingClientRect();
2329
+ if (!vb || !vb.width || !box.width) return;
2330
+ var k = box.width / vb.width, moving = [];
2331
+ [].slice.call(svg.querySelectorAll('.dg-node[data-entity]')).forEach(function(n){
2332
+ var id = n.getAttribute('data-entity');
2333
+ if (id === key){ n.classList.add('dg-er-opening'); return; }
2334
+ if (!was[id]) return;
2335
+ var now = n.getBoundingClientRect();
2336
+ // screen pixels back into the diagram's own units, which a transform on an svg element is in
2337
+ var dx = (was[id].left - now.left) / k, dy = (was[id].top - now.top) / k;
2338
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
2339
+ n.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
2340
+ moving.push(n);
2341
+ });
2342
+ svg.classList.add('dg-reflow');
2343
+ requestAnimationFrame(function(){
2344
+ requestAnimationFrame(function(){
2345
+ moving.forEach(function(n){
2346
+ n.style.transition = 'transform .32s cubic-bezier(.2,.7,.2,1)';
2347
+ n.style.transform = '';
2348
+ });
2349
+ });
2350
+ });
2351
+ // and whatever happened to the frames in between, everything ends where the layout put it
2352
+ setTimeout(function(){
2353
+ svg.classList.remove('dg-reflow');
2354
+ moving.forEach(function(n){ n.style.transition = ''; n.style.transform = ''; });
2355
+ [].slice.call(svg.querySelectorAll('.dg-er-opening')).forEach(function(n){ n.classList.remove('dg-er-opening'); });
2356
+ }, 700);
2357
+ }
2358
+
2359
+ /* ------------------------------------------- what an entity, a column is for
2360
+ What is written about an entity as a whole is not one of its columns, so
2361
+ it is not in its table: it appears while the pointer is on the entity's
2362
+ header, open or not. What a column holds has no room in the compact table,
2363
+ so it appears while the pointer is on that column's row; an opened entity
2364
+ writes it out, and says nothing more on hover. Either shows above what it
2365
+ is about when there is room, below it when not.
2366
+
2367
+ A description is asked for, not sprung on the reader: it shows only while
2368
+ a key is held - Ctrl, or Option on a Mac, where Ctrl turns a click into a
2369
+ right-click - with the pointer on the header or the row. Holding the key
2370
+ first and moving over the entity works, and so does pressing it once the
2371
+ pointer is already there. It goes when the key is let go, the pointer
2372
+ leaves, the page scrolls or a click lands. */
2373
+
2374
+ var tip = null, tipFor = null, tipTimer = 0;
2375
+ var IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
2376
+ var TIP_KEY = IS_MAC ? 'Alt' : 'Control';
2377
+ var TIP_KEY_NAME = IS_MAC ? '⌥ Option' : 'Ctrl';
2378
+ function tipKeyHeld(e){ return IS_MAC ? e.altKey : e.ctrlKey; }
2379
+ // where the pointer last was, for a key pressed without the mouse moving
2380
+ var lastPointer = null;
2381
+
2382
+ document.addEventListener('keydown', function(e){
2383
+ if (e.key !== TIP_KEY || e.repeat || !lastPointer) return;
2384
+ var at = document.elementFromPoint(lastPointer.x, lastPointer.y);
2385
+ var said = at && tipTarget({ target: at, clientY: lastPointer.y });
2386
+ if (said) showTip(said);
2387
+ });
2388
+ document.addEventListener('keyup', function(e){ if (e.key === TIP_KEY) hideTip(); });
2389
+ addEventListener('blur', hideTip);
2390
+
2391
+ function escHtml(s){
2392
+ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
2393
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2394
+ }
2395
+ // code spans and bold, the two such a description actually uses
2396
+ function inlineMd(s){
2397
+ return escHtml(s).replace(/\x60([^\x60]+)\x60/g, '<code>$1</code>')
2398
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
2399
+ }
2400
+
2401
+ // The element whose description the pointer is on, if any: a column's row,
2402
+ // or the entity when the pointer is on its header - the band with the title,
2403
+ // or the whole box of an entity with no columns.
2404
+ function tipTarget(e){
2405
+ var row = e.target.closest('.dg-er-row[data-desc]');
2406
+ if (row) return row;
2407
+ var node = e.target.closest('.dg-node[data-desc]');
2408
+ if (!node || e.target.closest('.dg-er-row')) return null;
2409
+ var rule = node.querySelector('.dg-er-rule');
2410
+ return !rule || e.clientY <= rule.getBoundingClientRect().bottom + 1 ? node : null;
2411
+ }
2412
+
2413
+ function showTip(target){
2414
+ if (tipFor === target) return;
2415
+ hideTip();
2416
+ tipFor = target;
2417
+ // asked for with a key: shown at once, on the next frame
2418
+ tipTimer = setTimeout(function(){
2419
+ if (tipFor !== target || !target.isConnected) return;
2420
+ var lines;
2421
+ try { lines = JSON.parse(target.getAttribute('data-desc')); } catch (err) { return; }
2422
+ tip = el('div', 'dg-tip');
2423
+ tip.innerHTML = lines.map(function(l){ return '<p>' + inlineMd(l) + '</p>'; }).join('');
2424
+ document.body.appendChild(tip);
2425
+ var isRow = target.classList.contains('dg-er-row');
2426
+ var box = (target.querySelector(isRow ? '.dg-er-band' : '.dg-node-shape') || target).getBoundingClientRect();
2427
+ var head = !isRow && target.querySelector('.dg-er-rule');
2428
+ var below = isRow ? box.bottom : head ? head.getBoundingClientRect().bottom : box.top + 34;
2429
+ var vw = document.documentElement.clientWidth, w = tip.offsetWidth, h = tip.offsetHeight;
2430
+ var top = box.top - h - 8;
2431
+ if (top < 8) top = below + 8;
2432
+ tip.style.left = Math.round(Math.min(Math.max(8, box.left), vw - w - 8)) + 'px';
2433
+ tip.style.top = Math.round(top) + 'px';
2434
+ }, 0);
2435
+ }
2436
+
2437
+ function hideTip(){
2438
+ clearTimeout(tipTimer);
2439
+ tipFor = null;
2440
+ if (tip){ tip.remove(); tip = null; }
2441
+ }
2442
+ addEventListener('scroll', hideTip, { passive: true });
2443
+ document.addEventListener('wheel', hideTip, { passive: true });
2444
+ document.addEventListener('mousedown', hideTip, true);
2445
+
2446
+ /* ----------------------------------------------------------- the toolbar */
2447
+
2448
+ document.addEventListener('click', function(e){
2449
+ var btn = e.target.closest && e.target.closest('.diagram-btn');
2450
+ var shut = e.target.closest && e.target.closest('.diagram.is-folded');
2451
+ // anywhere on a collapsed diagram opens it
2452
+ if (shut && (!btn || btn.dataset.act === 'fold')){ e.stopPropagation(); setFold(shut, false); return; }
2453
+ if (!btn) return;
2454
+ var fig = btn.closest('.diagram');
2455
+ if (!fig) return;
2456
+ e.stopPropagation();
2457
+ if (btn.dataset.act === 'fold'){
2458
+ if (pinned){ clear(pinned.svg); pinned = null; }
2459
+ hideTip();
2460
+ setFold(fig, true);
2461
+ return;
2462
+ }
2463
+ if (btn.dataset.act === 'engine'){
2464
+ var next = fig.dataset.drawn === 'clean' ? 'mermaid' : 'clean';
2465
+ fig.dataset.engine = next;
2466
+ draw(fig, next);
2467
+ return;
2468
+ }
2469
+ if (btn.dataset.act === 'zoom'){
2470
+ var svg = fig.querySelector('svg');
2471
+ if (svg && window.__lightbox) window.__lightbox(svg);
2472
+ }
2473
+ }, true);
2474
+
2475
+ window.__diagrams = { drawAll: drawAll, draw: draw };
2476
+ })();
2477
+ `;