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.
- package/LICENSE +21 -0
- package/README.md +52 -2
- package/bin/lowkey-doc.mjs +171 -0
- package/commands/docs.md +38 -0
- package/licenses/elkjs.txt +263 -0
- package/licenses/inter.txt +93 -0
- package/licenses/mermaid.txt +21 -0
- package/package.json +44 -4
- package/skills/audit-docs/SKILL.md +71 -0
- package/skills/write-doc/SKILL.md +97 -0
- package/src/build.mjs +131 -0
- package/src/client/api.mjs +102 -0
- package/src/client/app.mjs +1184 -0
- package/src/client/css.mjs +853 -0
- package/src/client/diagrams.mjs +2477 -0
- package/src/client/live.mjs +156 -0
- package/src/collect.mjs +154 -0
- package/src/config.mjs +166 -0
- package/src/init.mjs +103 -0
- package/src/markdown.mjs +525 -0
- package/src/page.mjs +134 -0
- package/src/sections.mjs +63 -0
- package/src/vendor.mjs +151 -0
- package/src/watch.mjs +374 -0
|
@@ -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, '&').replace(/</g, '<')
|
|
2393
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
`;
|