umlimator 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/lib/umlimator-all.js +1244 -253
- package/lib/umlimator.d.ts +33 -4
- package/lib/umlimator.js +138 -253
- package/lib/umlimator.schema.json +198 -7
- package/package.json +1 -1
package/lib/umlimator-all.js
CHANGED
|
@@ -62,6 +62,7 @@ function typedProblems(model) {
|
|
|
62
62
|
arr(m.edges).forEach((e, i) => { if (typedEdge(e)) { element(e, `edges[${i}]`, 'edge'); typeOf(e, `edges[${i}]`, true); } });
|
|
63
63
|
const typedView = v => v.diagram != null && v.diagram !== 'map'; // a view with diagram 'map' is a map: the map's checks
|
|
64
64
|
views.forEach((v, i) => { if (!typedView(v)) return;
|
|
65
|
+
arr(v.nodes).forEach((n, j) => element(n, `views[${i}].nodes[${j}]`, 'node'));
|
|
65
66
|
arr(v.edges).forEach((e, j) => { if (isObj(e)) { element(e, `views[${i}].edges[${j}]`, 'edge'); typeOf(e, `views[${i}].edges[${j}]`, true); } });
|
|
66
67
|
arr(v.fragments).forEach((f, j) => element(f, `views[${i}].fragments[${j}]`, 'fragment'));
|
|
67
68
|
['changes', 'constraints'].forEach(k => arr((v.timeline || {})[k]).forEach((c, j) => element(c, `views[${i}].timeline.${k}[${j}]`, 'time'))); });
|
|
@@ -85,17 +86,18 @@ function typedProblems(model) {
|
|
|
85
86
|
views.forEach((v, i) => { if (!typedView(v)) return; const at = `views[${i}]`;
|
|
86
87
|
if (typeof v.diagram !== 'string' || !/^[a-z0-9]+(\.[a-z0-9-]+)?$/.test(v.diagram)) warn(at + '.diagram', `diagram ${name(v.diagram)} is not <notation> or <notation>.<kind> (uml.class)`);
|
|
87
88
|
if (v.direction != null && !['right', 'down'].includes(v.direction)) warn(at + '.direction', `direction ${name(v.direction)} is not right or down: drawn right`);
|
|
88
|
-
arr(v.nodeIds).forEach((id, j) => { if (!nodeIds.has(id)) err(`${at}.nodeIds[${j}]`, `${name(id)} is not a node`); });
|
|
89
|
-
const mine = new Set(arr(v.containers).map(c => isObj(c) && c.id));
|
|
90
|
-
|
|
89
|
+
arr(v.nodeIds).forEach((id, j) => { if (!nodeIds.has(id) && !arr(v.nodes).some(n => isObj(n) && n.id === id)) err(`${at}.nodeIds[${j}]`, `${name(id)} is not a node`); });
|
|
90
|
+
const mine = new Set(arr(v.containers).map(c => isObj(c) && c.id)), T = diagramOf(v); // an item (the type's items; else no ends) is no line
|
|
91
|
+
const item = e => (T ? arr(T.items).includes(e.type) : e.from == null && e.to == null);
|
|
92
|
+
arr(v.edges).forEach((e, j) => { if (typeof e === 'string') { if (kindOf.get(e) !== 'edge') err(`${at}.edges[${j}]`, `${name(e)} is not a typed edge`); } else if (isObj(e) && !item(e)) ends(e, `${at}.edges[${j}]`, mine); });
|
|
91
93
|
if (v.subject != null && !has(v.subject)) err(at + '.subject', `subject ${name(v.subject)} is not an element`);
|
|
92
94
|
['layout', 'present'].forEach(k => Object.keys(isObj(v[k]) ? v[k] : {}).forEach(id => { if (!has(id)) warn(`${at}.${k}.${id}`, `${name(id)} is not an element: ignored`); })); });
|
|
93
95
|
return out;
|
|
94
96
|
}
|
|
95
97
|
/* ---------- diagram types (work package 1.3) ----------
|
|
96
98
|
A typed view's `diagram` names its type; a view with none is a map, and the map registers itself as the default
|
|
97
|
-
(src/map/typed.js). A type this release does not know is drawn as plain cards, with a warning.
|
|
98
|
-
|
|
99
|
+
(src/map/typed.js). A type this release does not know is drawn as plain cards, with a warning. Internal until the
|
|
100
|
+
API is documented. */
|
|
99
101
|
const TYPES = new Map();
|
|
100
102
|
|
|
101
103
|
function registerDiagramType(name, def = {}) {
|
|
@@ -106,14 +108,18 @@ function registerDiagramType(name, def = {}) {
|
|
|
106
108
|
return type;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/* A definition
|
|
110
|
-
|
|
111
|
-
lines [{ type, label }] the relationship picker: the kinds of line between two elements
|
|
112
|
-
props { [type]: [{ key, label, kind: 'text' | 'bool' | 'enum', options? }] } the properties form, per element type
|
|
113
|
-
Without them the canvas offers one generic node and one generic line (core.Node, core.Line), and shows an element's props to read. */
|
|
111
|
+
/* A definition's fields are optional and provisional; absent, things go as before. notes/spike-sequence.md §1 says what
|
|
112
|
+
each does (validate(model) with no view runs once a type, not once a view); 1.9's: rankFlip, member, rules, describe. */
|
|
114
113
|
|
|
115
114
|
/* A view's type: the map for a view with no diagram, the registered type, or null for one this release does not draw */
|
|
116
115
|
const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram : 'map') || null;
|
|
116
|
+
|
|
117
|
+
/* What only a registered type's geometry uses, installed at load time by the files under src/typed/, which only
|
|
118
|
+
lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf. Empty in
|
|
119
|
+
umlimator.js, where no view is laid out but as a map (risks.md C5) */
|
|
120
|
+
const TYPED = {};
|
|
121
|
+
|
|
122
|
+
const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
|
|
117
123
|
const warned = new Set(), warn = m => { if (hush) hush.push(m); else if (!warned.has(m)) { warned.add(m); console.warn('umlimator: ' + m); } }; // each warning once
|
|
118
124
|
let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its warnings here: only the one kept says them
|
|
119
125
|
/* f(), with the warnings it gives held back: [its result, what it would have said] */
|
|
@@ -174,7 +180,8 @@ export function history(model, limit = 100) {
|
|
|
174
180
|
value undefined removes it. Through objects only; id, members, parent,
|
|
175
181
|
from and to change by their own commands
|
|
176
182
|
add { element, owner?, index?, view? } a node (no from/to), a line, or with `owner` a member, at `index`
|
|
177
|
-
among the owner's members, else among the view's; `view` shows it there
|
|
183
|
+
among the owner's members, else among the view's; `view` shows it there;
|
|
184
|
+
in 'view' (not 'model'): in its edges, nodes or fragments (registry.js)
|
|
178
185
|
move { id, index, owner? } a member to another place among its owner's members, or to a new owner
|
|
179
186
|
{ id, view, index } an element or line to another place in a view's order (its nodeIds or edges)
|
|
180
187
|
hide { id, view } out of one view, with all the view says of it: its place, pin, present,
|
|
@@ -182,14 +189,16 @@ export function history(model, limit = 100) {
|
|
|
182
189
|
keeps it (risks.md E5). A line that lives only in that view goes with it
|
|
183
190
|
delete { id } out of the model, with its members, what it owns, the lines to them, and
|
|
184
191
|
every place that names them (references()). One already gone in the same
|
|
185
|
-
transaction is passed over, so a selection deletes in one transaction (E2)
|
|
192
|
+
transaction is passed over, so a selection deletes in one transaction (E2).
|
|
193
|
+
What it held and stays goes to its nearest owner left
|
|
186
194
|
reparent { id, parent } a new owner by `parent`, or none with parent null
|
|
187
|
-
An element is a node, a member, a model line or a view's own line with an id, or a fragment (schema version 2's
|
|
188
|
-
space).
|
|
195
|
+
An element is a node, a member, a model line or a view's own line or item with an id, or a fragment (schema version 2's
|
|
196
|
+
id space). */
|
|
189
197
|
|
|
190
198
|
const isObj = x => x !== null && typeof x === 'object' && !Array.isArray(x), arr = x => (Array.isArray(x) ? x : []);
|
|
191
199
|
const UNSAFE = new Set(['__proto__', 'constructor', 'prototype']); // never a key a command writes (risks.md M3)
|
|
192
200
|
const isLine = x => isObj(x) && (x.from != null || x.to != null);
|
|
201
|
+
const dig = (x, path) => path.split('.').reduce((o, k) => (isObj(o) ? o[k] : undefined), x);
|
|
193
202
|
|
|
194
203
|
/* Every element by id: { x, list, kind, owner, view } (owner: the element whose member it is; view: the view a line or
|
|
195
204
|
fragment of its own lives in) */
|
|
@@ -206,7 +215,7 @@ function index(m) {
|
|
|
206
215
|
}
|
|
207
216
|
|
|
208
217
|
/* Where a model names any of `ids` (a Set): [{ path, kind }], path a list of keys from the model down. Kinds: end, parent,
|
|
209
|
-
part, view, pin, present, override, subject, story, lens, rule. Neither the element itself nor its members are listed.
|
|
218
|
+
part, prop, view, pin, present, override, subject, story, lens, rule. Neither the element itself nor its members are listed.
|
|
210
219
|
only(viewIndex): look in those views and their story steps only (hide), else in all of the model. */
|
|
211
220
|
function refs(m, ids, only) {
|
|
212
221
|
const out = [], say = (path, kind) => out.push({ path, kind }), has = id => typeof id === 'string' && ids.has(id);
|
|
@@ -214,11 +223,14 @@ function refs(m, ids, only) {
|
|
|
214
223
|
if (has(x.parent)) say([...at, 'parent'], 'parent');
|
|
215
224
|
if (line) ['from', 'to'].forEach(k => { if (has(x[k])) say([...at, k], 'end');
|
|
216
225
|
if (has((((x.props || {}).ends || {})[k] || {}).part)) say([...at, 'props', 'ends', k, 'part'], 'part'); });
|
|
226
|
+
Object.keys(propRefs(x.type)).forEach(p => { const v = dig(x, p), ks = p.split('.');
|
|
227
|
+
if (has(v)) say([...at, ...ks], 'prop'); else arr(v).forEach((id, j) => { if (has(id)) say([...at, ...ks, j], 'prop'); }); });
|
|
217
228
|
arr(x.members).forEach((y, j) => element(y, [...at, 'members', j], false)); };
|
|
218
229
|
if (!only) { arr(m.nodes).forEach((n, i) => element(n, ['nodes', i], false)); arr(m.edges).forEach((e, i) => element(e, ['edges', i], true)); }
|
|
219
230
|
const vids = new Set();
|
|
220
231
|
arr(m.views).forEach((v, i) => { if (!isObj(v) || (only && !only(i))) return; const at = ['views', i]; vids.add(v.id);
|
|
221
232
|
['nodeIds', 'tour', 'stepIds'].forEach(k => arr(v[k]).forEach((id, j) => { if (has(id)) say([...at, k, j], 'view'); }));
|
|
233
|
+
arr(v.nodes).forEach((n, j) => element(n, [...at, 'nodes', j], false));
|
|
222
234
|
arr(v.edges).forEach((e, j) => { if (has(e)) say([...at, 'edges', j], 'view'); else element(e, [...at, 'edges', j], true); });
|
|
223
235
|
arr(v.fragments).forEach((f, j) => element(f, [...at, 'fragments', j], false));
|
|
224
236
|
[['layout', 'pin'], ['present', 'present'], ['nodeOverrides', 'override']].forEach(([k, kind]) => Object.keys(isObj(v[k]) ? v[k] : {}).forEach(id => { if (has(id)) say([...at, k, id], kind); }));
|
|
@@ -296,8 +308,18 @@ function run(m, c, gone) {
|
|
|
296
308
|
}
|
|
297
309
|
case 'add': {
|
|
298
310
|
if ('to' in c) throw new Error('add: the owner of a member is `owner`');
|
|
311
|
+
if (c.in != null && c.in !== 'view' && c.in !== 'model') throw new Error(`add: in is 'view' or 'model', not ${JSON.stringify(c.in)}`);
|
|
299
312
|
checkNew(c.element, at);
|
|
300
313
|
const x = structuredClone(c.element), owner = c.owner != null ? need(c.owner) : null;
|
|
314
|
+
if (c.in === 'view') {
|
|
315
|
+
if (c.view == null || owner) throw new Error('add: in \'view\' needs a view, and no owner');
|
|
316
|
+
const v = viewOf(c.view), T = diagramOf(v) || {}, pal = arr(T.elements);
|
|
317
|
+
const k = isLine(x) || arr(T.items).includes(x.type) ? 'edges' : !pal.length || pal.some(t => isObj(t) && t.type === x.type) ? 'nodes' : 'fragments';
|
|
318
|
+
if (k === 'edges' && !Array.isArray(v.edges) && arr(m.edges).length) throw new Error(`add: view "${c.view}" shows every line between its cards; list its lines first`);
|
|
319
|
+
const list = (v[k] ||= []);
|
|
320
|
+
if (k === 'nodes') { list.push(x); (v.nodeIds ||= []).splice(place(v.nodeIds, c.index), 0, x.id); } else list.splice(place(list, c.index), 0, x);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
301
323
|
if (owner && owner.kind !== 'node' && owner.kind !== 'member' && owner.kind !== 'fragment') throw new Error(`add: "${c.owner}" holds no members`);
|
|
302
324
|
const v = c.view != null ? viewOf(c.view) : null, list = owner ? (owner.x.members ||= []) : isLine(x) ? (m.edges ||= []) : (m.nodes ||= []);
|
|
303
325
|
list.splice(owner ? place(list, c.index) : list.length, 0, x);
|
|
@@ -327,19 +349,24 @@ function run(m, c, gone) {
|
|
|
327
349
|
const ends = l => isObj(l) && (ids.has(l.from) || ids.has(l.to)); // the view's lines to it, listed or its own, leave the view with it
|
|
328
350
|
if (Array.isArray(v.edges)) v.edges = v.edges.filter(y => { const l = typeof y === 'string' ? (at.get(y) || {}).x : y, out = y === c.id || ends(l);
|
|
329
351
|
if (out && isObj(l) && typeof l.id === 'string') ids.add(l.id); return !out; }); // what names those lines in this view goes too
|
|
330
|
-
clear(m, refs(m, ids, i => i === vi).filter(r =>
|
|
352
|
+
clear(m, refs(m, ids, i => i === vi).filter(r => !['end', 'parent', 'part', 'prop'].includes(r.kind)));
|
|
331
353
|
return;
|
|
332
354
|
}
|
|
333
355
|
case 'delete': {
|
|
334
356
|
if (!at.has(c.id) && gone.has(c.id)) return; // gone with an earlier command of this transaction
|
|
335
|
-
need(c.id); const now = new Set();
|
|
357
|
+
need(c.id); const now = new Set(), kids = new Map();
|
|
358
|
+
for (const [k, o] of at) if ((o.kind === 'node' || o.kind === 'member') && o.x.parent != null) { if (!kids.has(o.x.parent)) kids.set(o.x.parent, []); kids.get(o.x.parent).push(k); }
|
|
336
359
|
const take = id => { const r = at.get(id); if (!r || now.has(id)) return; now.add(id);
|
|
337
|
-
arr(r.x.members).forEach(y => isObj(y) && take(y.id));
|
|
338
|
-
for (const [k, o] of at) if ((o.kind === 'node' || o.kind === 'member') && o.x.parent === id) take(k); }; // what it owns goes with it
|
|
360
|
+
arr(r.x.members).forEach(y => isObj(y) && take(y.id)); arr(kids.get(id)).forEach(take); }; // what it owns goes with it
|
|
339
361
|
take(c.id);
|
|
340
|
-
// lines to what is gone go too,
|
|
341
|
-
|
|
342
|
-
for (let more = true; more;) { more = false; for (const [k, o] of at) if (!now.has(k) && o.kind === 'edge' && (now.has(o.x.from) || now.has(o.x.to))) {
|
|
362
|
+
// lines to what is gone go too, lines to those lines, and what a type's refs cascade (a reply with its call)
|
|
363
|
+
const cascade = x => Object.entries(propRefs(x.type)).some(([p, how]) => how === 'cascade' && [].concat(dig(x, p)).some(id => now.has(id)));
|
|
364
|
+
for (let more = true; more;) { more = false; for (const [k, o] of at) if (!now.has(k) && (o.kind === 'edge' && (now.has(o.x.from) || now.has(o.x.to)) || cascade(o.x))) { take(k); more = true; } }
|
|
365
|
+
// what stays in something gone goes to its nearest owner left; an operand's messages to its fragment's parent
|
|
366
|
+
const above = id => { const r = at.get(id); return r && (r.x.parent ?? (r.owner && above(r.owner.id))); };
|
|
367
|
+
for (const [k, o] of at) if (!now.has(k) && now.has(o.x.parent)) { let p = o.x.parent;
|
|
368
|
+
for (let n = 0; p != null && now.has(p) && n++ < at.size;) p = above(p);
|
|
369
|
+
if (p != null && !now.has(p)) o.x.parent = p; else delete o.x.parent; }
|
|
343
370
|
for (const [k, o] of at) if (now.has(k) && !(o.owner && now.has(o.owner.id))) { const i = o.list.indexOf(o.x); if (i >= 0) o.list.splice(i, 1); }
|
|
344
371
|
for (let found = refs(m, now); found.length; found = refs(m, now)) clear(m, found); // a line without an id to a gone line goes in a second round
|
|
345
372
|
now.forEach(id => gone.add(id));
|
|
@@ -480,60 +507,10 @@ const SHAPE = {
|
|
|
480
507
|
return `<path d="M${x + 1} ${y + p}L${x + p} ${y + 1}H${x + w - 1}L${x + w - p} ${y + p}Z" fill="${deeper(k, 12)}"/><path d="M${x + w - p} ${y + p}L${x + w - 1} ${y + 1}V${y + h - p}L${x + w - p} ${y + h - 1}Z" fill="${deeper(k, 22)}"/>`
|
|
481
508
|
+ `<path d="M${x} ${y + p}H${x + w - p}V${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + w - p} ${y + p}L${x + w} ${y}" class="n-det" stroke="${c}"/>`; },
|
|
482
509
|
inset(n, s, t) { const p = 7; return s === 'L' ? Math.max(0, n.y + p - t) : s === 'R' ? Math.max(0, t - n.y - n.h + p) : s === 'T' ? Math.max(0, n.x + p - t) : Math.max(0, t - n.x - n.w + p); } },
|
|
483
|
-
classifier: { // UML's class box (1.5): a rectangle, its name compartment, then one compartment per member kind (classParts)
|
|
484
|
-
size: n => classParts(n).size, out: boxOut(0), face: n => [n.y, n.y + n.h], inset: () => 0, draw: classSvg },
|
|
485
510
|
};
|
|
486
|
-
// what every shape reports (1.5): size(n), the box layout gives it (a map's card: mapNodeW wide, a row high; the classifier
|
|
487
|
-
// from its text), and ports(n, side, k), k line ends spread evenly along a side ('L', 'R', 'T', 'B'), each on the outline
|
|
511
|
+
// what every shape reports (1.5): size(n), the box layout gives it (a map's card: mapNodeW wide, a row high; the classifier,
|
|
512
|
+
// src/typed/draw.js: from its text), and ports(n, side, k), k line ends spread evenly along a side ('L', 'R', 'T', 'B'), each on the outline
|
|
488
513
|
for (const S of Object.values(SHAPE)) { S.size ||= n => ({ w: mapNodeW(n), h: 64 }); S.ports = (n, side, k = 1) => portsOn(S, n, side, k); }
|
|
489
|
-
// ---- the classifier (1.5). Its drawing input, on a typed view's node (not the model: 1.9 fills it from the model and the
|
|
490
|
-
// view's present; a map never draws it): shape 'classifier'; label; type ('uml.Class', named in its accessible name);
|
|
491
|
-
// stereotypes [text], shown «…» over the name; abstract (the name in italics); compartments [{ name, members }] in order
|
|
492
|
-
// ('attributes', 'operations', 'literals'), each member in the model's envelope ({ type, label, props }); collapsed
|
|
493
|
-
// [compartment names], hidden with no divider; rows: at most that many rows in a compartment, then "+k more" (stage 1 note §5)
|
|
494
|
-
const CL = { pad: 8, name: 13, row: 11, st: 14, nl: 18, rl: 15, gap: 5, empty: 10, minW: 96, maxW: 320 };
|
|
495
|
-
const VIS = { public: '+', private: '-', protected: '#', package: '~' };
|
|
496
|
-
// a member's row in UML's form: "+ total: Money [1]", "+ /count: Integer = 0", "+ place(at: Date): Order"; a literal's label
|
|
497
|
-
const obj = x => !!x && typeof x === 'object'; // an entry the classifier reads; null, text or a number is passed over
|
|
498
|
-
function memberText(m) { const p = obj(m.props) ? m.props : {}, has = x => x != null && x !== '', name = (hasKey(VIS, p.visibility) ? VIS[p.visibility] + ' ' : '') + (p.isDerived ? '/' : '') + (m.label ?? '');
|
|
499
|
-
if (/Operation$/.test(m.type || '')) return name + '(' + (Array.isArray(p.parameters) ? p.parameters.filter(obj) : []).map(a => (has(a.direction) && a.direction !== 'in' ? a.direction + ' ' : '') + (a.name ?? '') + (has(a.valueType) ? ': ' + a.valueType : '')).join(', ') + ')' + (has(p.returnType) ? ': ' + p.returnType : '');
|
|
500
|
-
return name + (has(p.valueType) ? ': ' + p.valueType : '') + (has(p.multiplicity) ? ` [${p.multiplicity}]` : '') + (has(p.default) ? ' = ' + p.default : ''); }
|
|
501
|
-
// the classifier as drawn: its stereotypes, its compartments (all: every one, each with every member; comps: those shown,
|
|
502
|
-
// with their rows and how many they leave out), the head's height, and the size its text needs: as wide as its widest text,
|
|
503
|
-
// within minW and maxW (a longer row is cut: classSvg). An abstract name is in italics, which the measure (upright) does
|
|
504
|
-
// not see: it is measured a tenth wider (slant)
|
|
505
|
-
function classParts(n) {
|
|
506
|
-
const hidden = new Set(Array.isArray(n.collapsed) ? n.collapsed : []), cap = Number.isFinite(n.rows) ? Math.max(0, Math.floor(n.rows)) : Infinity, slant = n.abstract ? 1.1 : 1;
|
|
507
|
-
const stereo = (Array.isArray(n.stereotypes) ? n.stereotypes : []).filter(t => t != null && t !== '').map(String), st = stereo.map(t => `«${t}»`);
|
|
508
|
-
const all = (Array.isArray(n.compartments) ? n.compartments : []).filter(obj).map(c => ({ name: String(c.name ?? ''), members: Array.isArray(c.members) ? c.members.filter(obj) : [] }));
|
|
509
|
-
const comps = all.filter(c => !hidden.has(c.name)).map(c => ({ name: c.name, rows: c.members.slice(0, cap).map(m => ({ m, text: memberText(m) })), more: Math.max(0, c.members.length - cap) }));
|
|
510
|
-
const texts = [[n.label ?? '', CL.name, slant], ...st.map(t => [t, CL.row, 1]), ...comps.flatMap(c => [...c.rows.map(r => [r.text, CL.row, 1]), ...(c.more ? [[`+${c.more} more`, CL.row, 1]] : [])])];
|
|
511
|
-
const w = Math.ceil(Math.min(CL.maxW, Math.max(CL.minW, ...texts.map(([t, px, k]) => textW(t, px) * k + 2 * CL.pad))));
|
|
512
|
-
const head = 2 * CL.gap + st.length * CL.st + CL.nl, tall = c => { const k = c.rows.length + (c.more ? 1 : 0); return k ? 2 * CL.gap - 2 + k * CL.rl : CL.empty; };
|
|
513
|
-
return { stereo, st, all, comps, head, tall, slant, size: { w, h: comps.reduce((a, c) => a + tall(c), head) } };
|
|
514
|
-
}
|
|
515
|
-
const words = t => String(t).split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
|
|
516
|
-
function classSvg(n, c, f) { // its outline, its head, then each compartment under a divider, every text cut to the box (fit)
|
|
517
|
-
const { stereo, st, all, comps, head, tall, slant } = classParts(n), { x, y, w, h } = n, max = w - 2 * CL.pad, cx = r2(x + w / 2);
|
|
518
|
-
const font = (px, wt, it) => `style="font:${it ? 'italic ' : ''}${wt} ${px}px var(--um-sans)"`;
|
|
519
|
-
const row = (cls, fill, ty, t, more = '') => `<text x="${r2(x + CL.pad)}" y="${r2(ty)}" class="${cls}" fill="${fill}"${more}>${esc(fit(t, CL.row, max))}</text>`;
|
|
520
|
-
const out = [`<rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-mask"/><rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-body" fill="${f}" stroke="${c}"/>`];
|
|
521
|
-
let ty = y + CL.gap; const hd = [];
|
|
522
|
-
st.forEach(t => { hd.push(`<text x="${cx}" y="${r2(ty + 11)}" text-anchor="middle" class="c-st" fill="var(--um-muted)" ${font(CL.row, 400)}>${esc(fit(t, CL.row, max))}</text>`); ty += CL.st; });
|
|
523
|
-
hd.push(`<text x="${cx}" y="${r2(ty + 14)}" text-anchor="middle" class="c-name" fill="var(--um-text)" ${font(CL.name, 600, n.abstract)} data-o="${esc(n.id)}">${esc(fit(n.label ?? '', CL.name, max / slant))}</text>`);
|
|
524
|
-
out.push(`<g class="c-head" data-band="${r2(y)} ${r2(y + head)}">${hd.join('')}</g>`);
|
|
525
|
-
let top = y + head;
|
|
526
|
-
comps.forEach((C, i) => { const bh = i === comps.length - 1 ? Math.max(y + h - top, 0) : tall(C), at = j => top + CL.gap - 1 + j * CL.rl + 11.5;
|
|
527
|
-
const rows = C.rows.map((r, j) => { const p = obj(r.m.props) ? r.m.props : {};
|
|
528
|
-
return row('c-row', 'var(--um-text)', at(j), r.text, ` ${font(CL.row, 400, /Operation$/.test(r.m.type || '') && p.isAbstract)}${p.isStatic ? ' text-decoration="underline"' : ''}`); });
|
|
529
|
-
if (C.more) rows.push(row('c-more', 'var(--um-muted)', at(C.rows.length), `+${C.more} more`, ` ${font(CL.row, 400)}`));
|
|
530
|
-
out.push(`<g class="c-comp" data-comp="${esc(C.name)}" data-band="${r2(top)} ${r2(top + bh)}"><path d="M${x} ${r2(top)}H${x + w}" class="n-det" stroke="${c}"/>${rows.join('')}</g>`);
|
|
531
|
-
top += bh; });
|
|
532
|
-
// its accessible name (A1): "Class Order (entity, abstract), 2 attributes, 1 operation", every member counted, shown or not
|
|
533
|
-
const counts = all.map(C => `${C.members.length} ${C.members.length === 1 ? C.name.replace(/s$/, '') : C.name}`), why = [...stereo, ...(n.abstract ? ['abstract'] : [])];
|
|
534
|
-
const name = [`${words(n.type || 'uml.Class')} ${n.label ?? ''}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
|
|
535
|
-
return `<g class="node classifier" data-id="${esc(n.id)}" data-shape="classifier" tabindex="-1" role="img" aria-label="${esc(name)}">${out.join('')}</g>`;
|
|
536
|
-
}
|
|
537
514
|
const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
|
|
538
515
|
// a card's shape in a notation: its own `shape` first; box draws every card as before (a hook stays a hexagon there too)
|
|
539
516
|
const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
|
|
@@ -611,51 +588,21 @@ function roundPath(pts, r = 9) {
|
|
|
611
588
|
}
|
|
612
589
|
const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
|
|
613
590
|
}
|
|
614
|
-
|
|
615
|
-
// a typed line's end labels (1.5): each end's role and multiplicity (e.end.labels: { from: { role, mult }, to: { role, mult } }),
|
|
616
|
-
// each in a box reserved by its end. First choice: 4 px out from the outline along the line, 8 px to one side of it, clear of
|
|
617
|
-
// the end's marker; the role above a line that leaves sideways and left of one that leaves up or down, the multiplicity on
|
|
618
|
-
// the other side. A box that would meet a segment of its own line (7.5 px round it, so markers too), either end's node or
|
|
619
|
-
// a box placed before it moves away from the line, 4 px at a time, on its side and then the other, at the end or centred
|
|
620
|
-
// across it (a short end segment, a loop). pts: the line as drawn; e.s and e.t: its nodes. Layout (1.4) may take the boxes
|
|
621
|
-
// as obstacles; it moves them apart where many lines meet (L5)
|
|
622
|
-
function endLabels(e, pts = e.pts) {
|
|
623
|
-
const T = e.end && e.end.labels, out = []; if (!T || !pts || pts.length < 2) return out;
|
|
624
|
-
const meets = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
625
|
-
const keep = [...pts.slice(1).map((q, i) => { const p = pts[i]; return { x: Math.min(p[0], q[0]) - 7.5, y: Math.min(p[1], q[1]) - 7.5, w: Math.abs(q[0] - p[0]) + 15, h: Math.abs(q[1] - p[1]) + 15 }; }),
|
|
626
|
-
...[e.s, e.t].filter(n => n && Number.isFinite(n.x)).map(n => ({ x: n.x - 2, y: n.y - 2, w: n.w + 4, h: n.h + 4 }))];
|
|
627
|
-
[['from', pts[0], pts[1]], ['to', pts[pts.length - 1], pts[pts.length - 2]]].forEach(([end, p, q]) => {
|
|
628
|
-
const at = T[end] || {}, flat = Math.abs(q[0] - p[0]) >= Math.abs(q[1] - p[1]), dir = Math.sign(flat ? q[0] - p[0] : q[1] - p[1]) || 1;
|
|
629
|
-
[['role', at.role, -1], ['mult', at.mult, 1]].forEach(([kind, t, side]) => { if (t == null || t === '') return;
|
|
630
|
-
const w = r2(textW(t, 10) + 6), h = 14, along = (v, d, mid) => mid ? v - d / 2 : dir > 0 ? v + 4 : v - 4 - d, aside = (v, d, s, o) => s < 0 ? v - o - d : v + o;
|
|
631
|
-
const box = (o, s, mid) => ({ end, kind, text: String(t), x: r2(flat ? along(p[0], w, mid) : aside(p[0], w, s, o)), y: r2(flat ? aside(p[1], h, s, o) : along(p[1], h, mid)), w, h });
|
|
632
|
-
let b = null;
|
|
633
|
-
for (let o = 8; !b && o <= 400; o += 4) for (const s of [side, -side]) for (const mid of [false, true]) { const c = box(o, s, mid); if (!b && ![...keep, ...out].some(k => meets(c, k))) b = c; }
|
|
634
|
-
out.push(b || box(8, side, false)); }); });
|
|
635
|
-
return out;
|
|
636
|
-
}
|
|
637
591
|
let svgSeq = 0;
|
|
638
|
-
|
|
639
|
-
// and to name an end in this table, the line's ends as UML draws them (none, or a name it does not hold, draws no marker);
|
|
640
|
-
// dashed: a dependency's or realization's line; labels: each end's role and multiplicity (endLabels). Each end: [its
|
|
641
|
-
// length, half its height, its path pointing right with the tip at its length, its fill]. Its tip is where the line ends,
|
|
642
|
-
// on the node's outline. Hollow is the stage's background, filled the line's own colour (T2), in either theme
|
|
643
|
-
const ENDS = { none: null, arrow: [10, 4, 'M0 0L10 4L0 8Z', 'line'], open: [10, 5, 'M0 0L10 5L0 10', 'none'], triangle: [13, 7, 'M0 0L13 7L0 14Z', 'hollow'],
|
|
644
|
-
diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'] };
|
|
645
|
-
const endOf = (E, w) => hasKey(ENDS, E[w]) && ENDS[E[w]] ? E[w] : null;
|
|
646
|
-
/* SVG markup. opts: { childCount(id) -> n, subject: id, title } */
|
|
592
|
+
/* SVG markup. opts: { childCount(id) -> n, subject: id, title, under, over } */
|
|
647
593
|
function svgOf(geo, opts = {}) {
|
|
648
594
|
const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
|
|
595
|
+
const LINE = map ? 'var(--um-arrow)' : 'var(--um-muted)'; // a typed view's lines: 3:1 or more in both themes
|
|
649
596
|
const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
|
|
650
597
|
const estyle = e => {
|
|
651
|
-
if (e.style === 'faded') return { ...(map && e.wk ? { c: `var(--um-w-${e.wk})`, m: e.wk, cls: ' w-' + e.wk + ' faded' } : { c:
|
|
598
|
+
if (e.style === 'faded') return { ...(map && e.wk ? { c: `var(--um-w-${e.wk})`, m: e.wk, cls: ' w-' + e.wk + ' faded' } : { c: LINE, m: 'd', cls: ' faded' }), dash: '', op: .35 }; // its own colour, faint, no flow (drift's idle lines)
|
|
652
599
|
if (map && e.wk && !e.style) return { c: `var(--um-w-${e.wk})`, m: e.wk, dash: e.back ? '6 5' : '', cls: ' w-' + e.wk + (e.back ? ' back' : '') };
|
|
653
600
|
const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
|
|
654
601
|
if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
|
|
655
602
|
if (st === 'removed') return { c: 'var(--um-rose)', m: 'r', dash: '5 5', op: .7 };
|
|
656
603
|
if (st === 'security') return { c: 'var(--um-rose)', m: 'r', dash: '5 5' };
|
|
657
604
|
if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
|
|
658
|
-
return { c:
|
|
605
|
+
return { c: LINE, m: 'd', dash: '' };
|
|
659
606
|
};
|
|
660
607
|
// a line that breaks a rule (#70): rose, with a "!" just after its start that names the rule (opts.broken: rule titles by edge id)
|
|
661
608
|
const broke = e => opts.broken ? [...new Set((e.all || [e]).flatMap(x => opts.broken[x._id] || []))] : [];
|
|
@@ -663,11 +610,8 @@ function svgOf(geo, opts = {}) {
|
|
|
663
610
|
const ruleMark = (e, why) => { const [a, b] = e.pts, L = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]) || 1, t = Math.min(18, L / 2) / L, x = a[0] + (b[0] - a[0]) * t, y = a[1] + (b[1] - a[1]) * t;
|
|
664
611
|
return `<g class="rule-mark"><title>Breaks rule: ${esc(why.join('; '))}</title><circle cx="${x}" cy="${y}" r="6.5"/><text x="${x}" y="${y + 3.4}" text-anchor="middle">!</text></g>`; };
|
|
665
612
|
const marker = (n, c, k = 1) => `<marker id="${id}-${n}" markerWidth="${10 * k}" markerHeight="${8 * k}" refX="${9 * k}" refY="${4 * k}" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0 0 L${10 * k} ${4 * k} L0 ${8 * k} Z" fill="${c}"/></marker>`;
|
|
666
|
-
const MC = { d:
|
|
613
|
+
const MC = { d: LINE, g: 'var(--um-green)', r: 'var(--um-rose)', v: 'var(--um-violet)', h: 'var(--um-cyan)', ...Object.fromEntries(WIRES.map(w => [w[0], `var(--um-w-${w[0]})`])) };
|
|
667
614
|
const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => lstyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
|
|
668
|
-
const typed = e => !map && e.end ? e.end : null, ends = [...new Set(edges.flatMap(e => typed(e) ? ['from', 'to'].map(w => endOf(e.end, w)).filter(Boolean).map(k => k + '-' + lstyle(e).m) : []))];
|
|
669
|
-
const endMark = km => { const k = km.slice(0, km.lastIndexOf('-')), m = km.slice(k.length + 1), [L, H, p, fl] = ENDS[k];
|
|
670
|
-
return `<marker id="${id}-${km}" markerWidth="${L}" markerHeight="${2 * H}" refX="${L}" refY="${H}" orient="auto-start-reverse" markerUnits="userSpaceOnUse" overflow="visible"><path d="${p}" fill="${fl === 'line' ? MC[m] : fl === 'hollow' ? 'var(--um-stage-bg)' : 'none'}" stroke="${MC[m]}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></marker>`; };
|
|
671
615
|
// the notation (0.15.0): each card's look, and each line as drawn, its ends on the cards' outlines (a map's cards only)
|
|
672
616
|
const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
|
|
673
617
|
// another view's node is drawn in its own shape, when it names one (1.5): its lines end where its ports() put them, on
|
|
@@ -675,8 +619,9 @@ function svgOf(geo, opts = {}) {
|
|
|
675
619
|
const ports = map ? sidePorts(geo) : null, looks = new Map(map ? nodes.map(n => [n, cardLook(n, notation, !!(kidsOf(n) || n.opens) && opts.openHint !== false, ports.get(n))])
|
|
676
620
|
: nodes.map(n => [n, n.shape != null && cardLook(n, 'box', false, null)]).filter(x => x[1]));
|
|
677
621
|
const drawn = edges.map(e => map && looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
|
|
622
|
+
const T = !map && TYPED.lines ? TYPED.lines({ id, edges, drawn, lstyle, MC }) : null; // a typed view's line ends and end labels (src/typed/draw.js)
|
|
678
623
|
let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
|
679
|
-
<defs>${marker('d',
|
|
624
|
+
<defs>${marker('d', LINE)}${marker('g', 'var(--um-green)')}${marker('r', 'var(--um-rose)')}${marker('v', 'var(--um-violet)')}${marker('h', 'var(--um-cyan)')}${map ? WIRES.map(w => marker(w[0], `var(--um-w-${w[0]})`)).join('') : ''}${heavy.map(m => { const [c, k] = m.split('-w'); return marker(m, MC[c], 1 + (k - 1) * .35); }).join('')}${T ? T.defs : ''}</defs>`;
|
|
680
625
|
const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
|
|
681
626
|
families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => { // in c4 every box is a boundary, dashed as Structurizr draws one (a toned box is dashed in any notation)
|
|
682
627
|
s += `<g class="family${f.tone ? ' toned' : ''}" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.tone ? ` style="--um-tone:${toneOf(f)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"${notation === 'c4' ? ' stroke-dasharray="7 5"' : ''}/>
|
|
@@ -712,7 +657,7 @@ function svgOf(geo, opts = {}) {
|
|
|
712
657
|
return { d: roundPath(P), legs, at };
|
|
713
658
|
})() : null;
|
|
714
659
|
const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
|
|
715
|
-
s += '<g class="edges">';
|
|
660
|
+
s += (opts.under || '') + '<g class="edges">'; // a type's layers (registry.js draw)
|
|
716
661
|
(geo.intake || []).forEach(b => {
|
|
717
662
|
const bp = roundPath(looks.size ? drawnPts({ pts: b.pts, t: geo.byId[b.to], ts: 'L' }, n => looks.get(n) || null) : b.pts); // its end on the card's outline, as a line's
|
|
718
663
|
s += `<g class="intake"><title>${esc(b.label)} feeds ${esc((geo.byId[b.to] || {}).label || b.to)}</title><path d="${bp}" class="edge" stroke="var(--um-w-feeds)" marker-end="url(#${id}-feeds)"/>
|
|
@@ -721,10 +666,9 @@ function svgOf(geo, opts = {}) {
|
|
|
721
666
|
${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${bp}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
|
|
722
667
|
});
|
|
723
668
|
edges.forEach((e, i) => {
|
|
724
|
-
const E =
|
|
725
|
-
const
|
|
726
|
-
const
|
|
727
|
-
const marks = E ? `${at('to') ? `marker-end="${at('to')}"` : ''} ${at('from') ? `marker-start="${at('from')}"` : ''}` : `marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}`;
|
|
669
|
+
const E = T && T.edge(e, i), st = E ? E.st : lstyle(e), why = broke(e), d = roundPath(drawn[i]), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
|
|
670
|
+
const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(E ? E.pts : trimLine(drawn[i], e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
|
|
671
|
+
const marks = E ? E.marks : `marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}`;
|
|
728
672
|
if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
|
|
729
673
|
s += `<path d="${d}" class="tour-glow" stroke="${st.c}" opacity="0">${tourAnim('0;0;.55;.55;0;0', [0, a, Math.min(a + .01, b), b, b + .03, 1])}</path>`; }
|
|
730
674
|
s += `<g class="edge-g${st.cls || ''}${e.weight > 1 ? ' wt-' + e.weight : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-i="${i}"><path d="${d}" class="halo"/><path d="${d}" class="hitline"/>
|
|
@@ -732,13 +676,13 @@ function svgOf(geo, opts = {}) {
|
|
|
732
676
|
<path d="${fd}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
|
|
733
677
|
e.pts.slice(1).forEach((p, k) => { const a = e.pts[k]; if (Math.abs(p[0] - a[0]) + Math.abs(p[1] - a[1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1])) m = [a, p]; });
|
|
734
678
|
return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}${why.length ? ruleMark(e, why) : ''}
|
|
735
|
-
<title>${e.step != null ? 'Step ' + esc(e.step) + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
|
|
679
|
+
<title>${!map && e.aria != null ? esc(e.aria) : `${e.step != null ? 'Step ' + esc(e.step) + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}`}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
|
|
736
680
|
});
|
|
737
681
|
// the tour's dot rides over the lines and boxes but under the cards: it goes into a card and comes out at its next
|
|
738
682
|
// line, rather than across the card's name
|
|
739
683
|
s += '</g>' + (tour ? `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>` : '') + '<g class="nodes">';
|
|
740
684
|
nodes.forEach(n => {
|
|
741
|
-
if (!map && n.shape
|
|
685
|
+
if (!map && hasKey(SHAPE, n.shape) && SHAPE[n.shape].draw) { s += SHAPE[n.shape].draw(n, col(n.k), fill(n.k)); return; } // a typed view's own shape: a class box (1.5), a note (1.9)
|
|
742
686
|
const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
|
|
743
687
|
const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
|
|
744
688
|
const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook' && String(n.shape ?? '').toLowerCase() !== 'box';
|
|
@@ -851,9 +795,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
851
795
|
const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`;
|
|
852
796
|
s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(drawn[i])}" class="key-ring"/></g>`; });
|
|
853
797
|
}
|
|
854
|
-
s += '</g
|
|
798
|
+
s += '</g>' + (opts.over || '') + '<g class="labels depth-read">';
|
|
855
799
|
edges.forEach((e, i) => {
|
|
856
|
-
if (
|
|
800
|
+
if (T) s += T.labels(i);
|
|
857
801
|
if (!e.lbl) return;
|
|
858
802
|
const L = e.lbl;
|
|
859
803
|
if (map) {
|
|
@@ -893,10 +837,10 @@ const labelW = e => textW(fit((e.step != null ? e.step + ' · ' : '') + (e.label
|
|
|
893
837
|
leaves a at row oa inside it and reaches b at row ob, as rowsFor's links do (the middle when not given). Each line into a
|
|
894
838
|
box has its own entry, so two that cross on the way in to its cards count as crossing; they all met it at one point.
|
|
895
839
|
fixed: items at a set row in columns of their own, which stay as they are (a box's far ends, in blockPos). */
|
|
896
|
-
function untangle(cols, links, h = () => 1, fixed =
|
|
897
|
-
const colOf =
|
|
898
|
-
const segs = [], extra = new Set(), want =
|
|
899
|
-
const tops = () => { const at =
|
|
840
|
+
function untangle(cols, links, h = () => 1, fixed = Object.create(null)) { // maps by id: no prototype (M3)
|
|
841
|
+
const colOf = Object.create(null); cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
|
|
842
|
+
const segs = [], extra = new Set(), want = Object.create(null), hh = id => extra.has(id) ? 1 : h(id), mid = (id, o) => o ?? (hh(id) - 1) / 2;
|
|
843
|
+
const tops = () => { const at = Object.create(null); cols.forEach(c => { let r = 0; c.forEach(id => { at[id] = fixed[id] ?? r; r += hh(id); }); }); return at; }; // each item's top row
|
|
900
844
|
const at0 = tops();
|
|
901
845
|
links.forEach(([a, b, oa, ob], n) => {
|
|
902
846
|
const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
|
|
@@ -908,7 +852,7 @@ function untangle(cols, links, h = () => 1, fixed = {}) {
|
|
|
908
852
|
});
|
|
909
853
|
if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
|
|
910
854
|
if (ps.length) cols[k] = [...c.map(id => [id, at0[id], 0]), ...ps.map(p => [p, want[p], 1])].sort((x, y) => x[1] - y[1] || x[2] - y[2]).map(x => x[0]); });
|
|
911
|
-
const ins =
|
|
855
|
+
const ins = Object.create(null), outs = Object.create(null); segs.forEach(([a, b, oa, ob]) => { (outs[a] ||= []).push([b, ob, oa]); (ins[b] ||= []).push([a, oa, ob]); });
|
|
912
856
|
const crossings = () => { const at = tops(); let n = 0;
|
|
913
857
|
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b, oa, ob] = segs[i], [c, d, oc, od] = segs[j];
|
|
914
858
|
if (colOf[a] === colOf[c] && (at[a] + oa - at[c] - oc) * (at[b] + ob - at[d] - od) < 0) n++; }
|
|
@@ -938,9 +882,10 @@ function pullSources(ids, links, rank) {
|
|
|
938
882
|
adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
|
|
939
883
|
height in rows (a box in a view with boxes); a link [a, b, oa, ob] leaves a at row oa inside it and reaches b at row
|
|
940
884
|
ob (a box's card), else at its middle. fixed: items at a set row, in columns of their own that stay (as untangle's). */
|
|
941
|
-
function rowsFor(cols, links, h = () => 1, fixed =
|
|
942
|
-
const row =
|
|
943
|
-
const
|
|
885
|
+
function rowsFor(cols, links, h = () => 1, fixed = Object.create(null)) {
|
|
886
|
+
const row = Object.create(null); if (!cols.length) return row; // an empty view
|
|
887
|
+
const colOf = Object.create(null); cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = fixed[id] ?? r; r += h(id); colOf[id] = k; }); });
|
|
888
|
+
const ins = Object.create(null), outs = Object.create(null), at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
|
|
944
889
|
links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
|
|
945
890
|
(outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
|
|
946
891
|
const median = xs => { const s = xs.slice().sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
@@ -949,7 +894,7 @@ function rowsFor(cols, links, h = () => 1, fixed = {}) {
|
|
|
949
894
|
while (blocks.length > 1 && blocks.at(-2).sum / blocks.at(-2).n > blocks.at(-1).sum / blocks.at(-1).n) { const b = blocks.pop(), a = blocks.at(-1); a.ids.push(...b.ids); a.sum += b.sum; a.n += b.n; } });
|
|
950
895
|
off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
|
|
951
896
|
for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
|
|
952
|
-
for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want =
|
|
897
|
+
for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = Object.create(null);
|
|
953
898
|
cols[k].forEach(id => { const nb = (step > 0 ? ins : outs)[id] || []; want[id] = nb.length ? median(nb.map(([x, ox, o]) => row[x] + ox - o)) : row[id]; });
|
|
954
899
|
fit(cols[k], want); }
|
|
955
900
|
const low = Math.min(...Object.keys(row).filter(id => !(id in fixed)).map(id => row[id]));
|
|
@@ -1055,29 +1000,12 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1055
1000
|
gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
|
|
1056
1001
|
[e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
|
|
1057
1002
|
const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
// node's top lies left of its right side (0 before ports are known, for the room: above the node's row, right of its column)
|
|
1062
|
-
const loopDims = (list, out) => { let D = 0, R = 0, half = 0;
|
|
1063
|
-
return list.map((e, k) => { const lw = e.label || e.step != null ? labelW(e) : 0, [bw, bh] = dg.turned ? [lw && 18, lw] : [lw, lw && 18];
|
|
1064
|
-
D = k ? D + half + bh / 2 + 10 : STUB + 6 + bh / 2; half = bh / 2;
|
|
1065
|
-
R = Math.max(k ? R + 2 * SEP : STUB + 4, bw + 12 - out[k]); return { D, R, top: D + half }; }); };
|
|
1066
|
-
const loops = Object.create(null); if (dg.typed && !dg.abs) (dg.edges || []).forEach(e => { if (e.from === e.to && byId[e.from]) (loops[e.from] ||= []).push(e); });
|
|
1067
|
-
Object.entries(loops).forEach(([id, list]) => { const n = byId[id], d = loopDims(list, list.map(() => 0)).at(-1);
|
|
1068
|
-
extraY[n.row - .5] = Math.max(extraY[n.row - .5] || 0, d.top + STUB + 7 - RG);
|
|
1069
|
-
extraX[n.col] = Math.max(extraX[n.col] || 0, d.R + STUB + 6 - Math.max(CG, gapAfter[n.col] || 0)); });
|
|
1070
|
-
// a typed view's rows (stage 1 note §4): each as tall as its tallest node, a node on a half row counting toward both rows, and
|
|
1071
|
-
// NH with none. With every node NH tall, the pitch is a map's
|
|
1072
|
-
const rowH = {}, rowTop = {}; if (dg.typed && !dg.abs && nodes.length) {
|
|
1073
|
-
nodes.forEach(n => [Math.floor(n.row), Math.ceil(n.row)].forEach(k => { rowH[k] = Math.max(rowH[k] ?? 0, n.h); }));
|
|
1074
|
-
const r0 = Math.min(...Object.keys(rowH).map(Number)), r1 = Math.max(...Object.keys(rowH).map(Number));
|
|
1075
|
-
for (let k = r0, y = r0 * pitch; k <= r1 + 1; k++) { rowTop[k] = y; y += (rowH[k] ?? NH) + RG; } }
|
|
1076
|
-
const rowAt = r => { const k = Math.floor(r), f = r - k; return rowTop[k] == null || rowTop[k + 1] == null ? r * pitch : rowTop[k] + f * (rowTop[k + 1] - rowTop[k]); };
|
|
1077
|
-
const rowHt = r => Math.max(rowH[Math.floor(r)] ?? NH, rowH[Math.ceil(r)] ?? NH); // a typed row's height, a half row's the taller of its two
|
|
1003
|
+
// a typed view's lines from a node to itself, drawn as loops (risks.md F1): the room they take, their ports and routes (src/typed/layout.js)
|
|
1004
|
+
const loops = Object.create(null), LP = dg.typed && !dg.abs ? TYPED.loops(loops, dg, { byId, extraX, extraY, STUB, RG, CG, SEP, gapAfter }) : null;
|
|
1005
|
+
const rows = dg.typed && TYPED.rows(dg, nodes, pitch, NH, RG); // a typed view's rows (src/typed/layout.js)
|
|
1078
1006
|
const gap = c => Math.max(CG, gapAfter[c] || 0) + (extraX[c] || 0);
|
|
1079
1007
|
const cx = c => Number.isInteger(c) ? colX[c] : colX[Math.floor(c)] + (c % 1) * ((colW[Math.floor(c)] || NW) + gap(Math.floor(c)));
|
|
1080
|
-
const rowY = r => Object.keys(extraY).reduce((y, k) => +k < r ? y + extraY[k] : y,
|
|
1008
|
+
const rowY = r => Object.keys(extraY).reduce((y, k) => +k < r ? y + extraY[k] : y, rows ? rows.at(r) : r * pitch);
|
|
1081
1009
|
const place = () => {
|
|
1082
1010
|
x = 0; for (let c = Math.floor(cols[0] ?? 0); c <= Math.ceil(cols[cols.length - 1] ?? 0); c++) { colX[c] = x; x += (colW[c] || NW) + gap(c); }
|
|
1083
1011
|
nodes.forEach(n => { n.x = cx(n.col) + ((colW[n.col] || n.w) - n.w) / 2; n.y = rowY(n.row); if (map && n.k === 'm-script') { n.h = NH - 22; n.y += 11; } });
|
|
@@ -1192,7 +1120,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1192
1120
|
if (near && !through([...pts, ...up], e)) { e.fixed = [...pts, ...up]; e.under = k + 1; }
|
|
1193
1121
|
else if (!through([...pts, ...bottom], e)) { e.fixed = [...pts, ...bottom]; e.under = true; } // one that would cut through a card is left to the router
|
|
1194
1122
|
});
|
|
1195
|
-
|
|
1123
|
+
if (LP) LP.mark(edges);
|
|
1196
1124
|
const free = edges.filter(e => !e.fixed);
|
|
1197
1125
|
edges.filter(e => e.fixed && !e[OWN_ROUTE]).forEach(e => { e.pts = e.fixed; e.p1 = e.pts[0]; e.p2 = e.pts[e.pts.length - 1]; e.fs = 'R'; e.ts = e.under > 0 ? 'R' : e.under ? 'B' : 'L'; e.stubs = []; e.how = e.under ? 'under' : 'comb'; });
|
|
1198
1126
|
const sides = {};
|
|
@@ -1227,10 +1155,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1227
1155
|
});
|
|
1228
1156
|
free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
|
|
1229
1157
|
edges.filter(e => e.under > 0).forEach(e => pushSide(e.t, 'R', e, e.s)); // a loop under the boxes takes its port with the side's other lines
|
|
1230
|
-
|
|
1231
|
-
const loopsOf = n => edges.filter(e => e[OWN_ROUTE] === 'loop' && e.s === n);
|
|
1232
|
-
edges.filter(e => e[OWN_ROUTE] === 'loop').forEach(e => { const n = e.s, k = loopsOf(n).indexOf(e);
|
|
1233
|
-
pushSide(n, 'R', e, { x: n.x + n.w, y: -1e6 + k, w: 0, h: 0 }); pushSide(n, 'T', e, { x: 1e6 - k, y: n.y, w: 0, h: 0 }); });
|
|
1158
|
+
if (LP) LP.ports(pushSide);
|
|
1234
1159
|
const portAt = (it, side, p) => { if (it.n === it.e.s && it.e.fs === side) it.e.p1 = p; else it.e.p2 = p; };
|
|
1235
1160
|
Object.entries(sides).sort(([, a], [, b]) => !!a[0].n.box - !!b[0].n.box).forEach(([key, list]) => { // cards first: a box's port lines up with them
|
|
1236
1161
|
const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
|
|
@@ -1268,31 +1193,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1268
1193
|
});
|
|
1269
1194
|
});
|
|
1270
1195
|
edges.filter(e => e.under > 0).forEach(e => { const P = e.fixed; e.pts = e.fixed = [...P.slice(0, 4), [P[3][0], e.p2[1]], e.p2]; });
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const corners = []; [...new Set(edges.filter(e => e[OWN_ROUTE] === 'loop').map(e => e.s))].forEach(n => { const list = loopsOf(n);
|
|
1274
|
-
loopDims(list, list.map(e => n.x + n.w - e.p2[0])).forEach(({ D, R }, k) => { const e = list[k], x = n.x + n.w + R, y = n.y - D;
|
|
1275
|
-
e.pts = e.fixed = [e.p1, [x, e.p1[1]], [x, y], [e.p2[0], y], e.p2];
|
|
1276
|
-
e.pts.slice(1, 4).forEach(([px, py]) => corners.push({ x: px - SEP, y: py - SEP, w: 2 * SEP, h: 2 * SEP })); }); });
|
|
1277
|
-
// A view laid out down (turned): the labels of lines between one pair are wider than the 16 px between the lines, so the
|
|
1278
|
-
// lines fan out in the gap between the pair's columns, each to a lane of its own that holds its label. Above the middle, the
|
|
1279
|
-
// outer line turns nearer each node, so none crosses another; a group the gap or another node leaves no room for goes to the router
|
|
1280
|
-
if (dg.typed && dg.turned) { const pairs = new Map(), lr = e => e.fs === 'R' ? [e.p1, e.p2] : [e.p2, e.p1];
|
|
1281
|
-
free.forEach(e => { const [l, r] = e.fs === 'R' ? [e.s, e.t] : [e.t, e.s], k = l.id + '\u0001' + r.id;
|
|
1282
|
-
if (/^(RL|LR)$/.test(e.fs + e.ts) && l.x + l.w < r.x) pairs.set(k, [...(pairs.get(k) || []), e]); });
|
|
1283
|
-
pairs.forEach(list => { if (list.length < 2 || !list.some(e => e.label || e.step != null)) return;
|
|
1284
|
-
list.sort((a, b) => lr(a)[0][1] - lr(b)[0][1]); if (list.some((e, i) => i && lr(e)[1][1] <= lr(list[i - 1])[1][1])) return;
|
|
1285
|
-
const bh = list.map(e => e.label || e.step != null ? labelW(e) : 0), gaps = bh.map((h, i) => i ? Math.max(SEP, (bh[i - 1] + h) / 2 + 6) : 0);
|
|
1286
|
-
const mid = list.reduce((a, e) => a + lr(e)[0][1] + lr(e)[1][1], 0) / (2 * list.length), lanes = [];
|
|
1287
|
-
gaps.reduce((y, g, i) => (lanes[i] = y + g), mid - gaps.reduce((a, g) => a + g, 0) / 2);
|
|
1288
|
-
// each line's turn on each side: the k-th line turning towards its lane from the outside turns k SEP px past the stub
|
|
1289
|
-
const turns = side => { const at = []; const up = list.map((e, i) => i).filter(i => lanes[i] < lr(list[i])[side][1]), down = list.map((e, i) => i).filter(i => lanes[i] >= lr(list[i])[side][1]).reverse();
|
|
1290
|
-
[up, down].forEach(g => g.forEach((i, k) => { at[i] = lr(list[i])[side][0] + (side ? -1 : 1) * (STUB + k * SEP); })); return at; };
|
|
1291
|
-
const xl = turns(0), xr = turns(1);
|
|
1292
|
-
if (Math.min(...xr) - Math.max(...xl) < 18 + 16) return;
|
|
1293
|
-
const pts = list.map((e, i) => { const [a, b] = lr(e), P = simplify([a, [xl[i], a[1]], [xl[i], lanes[i]], [xr[i], lanes[i]], [xr[i], b[1]], b]); return e.fs === 'R' ? P : P.reverse(); });
|
|
1294
|
-
if (list.some((e, i) => through(pts[i], e))) return;
|
|
1295
|
-
list.forEach((e, i) => { Object.assign(e, { [OWN_ROUTE]: 'fan', fixed: pts[i], pts: pts[i], stubs: [], how: 'fan' }); free.splice(free.indexOf(e), 1); }); }); }
|
|
1196
|
+
const corners = LP ? LP.route() : []; // a loop's corners: obstacles to the other routes
|
|
1197
|
+
if (dg.typed && dg.turned) TYPED.fan(free, { simplify, through, STUB, SEP }); // a view laid out down: lines between one pair fan out (src/typed/layout.js)
|
|
1296
1198
|
const BEND = map ? 60 : 26, CROSS = 900; // maps: few bends
|
|
1297
1199
|
const OUT = { R: [1, 0], L: [-1, 0], T: [0, -1], B: [0, 1] };
|
|
1298
1200
|
const nodeRects = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
|
|
@@ -1302,7 +1204,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1302
1204
|
nodes.forEach(n => gapLinesY.push(n.y - RG / 2, n.y + n.h + RG / 2));
|
|
1303
1205
|
} else {
|
|
1304
1206
|
Object.keys(colX).map(Number).forEach(c => gapLinesX.push(colX[c] - CG / 2, colX[c] + (colW[c] || NW) + CG / 2));
|
|
1305
|
-
[...new Set(nodes.map(n => n.y))].forEach(y => gapLinesY.push(y - RG / 2, y + (
|
|
1207
|
+
[...new Set(nodes.map(n => n.y))].forEach(y => gapLinesY.push(y - RG / 2, y + (rows ? rows.ht(nodes.find(n => n.y === y).row) : NH) + RG / 2));
|
|
1306
1208
|
}
|
|
1307
1209
|
const routed = [], reserved = [];
|
|
1308
1210
|
const inRect = (p, r) => p[0] > r.x && p[0] < r.x + r.w && p[1] > r.y && p[1] < r.y + r.h;
|
|
@@ -1676,6 +1578,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1676
1578
|
const xs = [...families.flatMap(f => [f.x,f.x+f.w]), ...(dg.lanes || []).flatMap(l => [l.x, l.x + l.w]), ...nodes.flatMap(n => [n.x - 14, n.x + n.w + 8]), ...groups.flatMap(g => [g.x, g.x2]), ...edges.flatMap(e => e.pts.map(p => p[0])), ...placed.flatMap(p => [p.x, p.x + p.w])];
|
|
1677
1579
|
const ys = [...families.flatMap(f => [f.y,f.y+f.h]), ...(dg.lanes || []).flatMap(l => [l.y, l.y + l.h]), ...nodes.flatMap(n => [n.y - 14, n.y + n.h + (n.tag ? 18 : 8)]), ...groups.flatMap(g => [g.y, g.y2]), ...edges.flatMap(e => e.pts.map(p => p[1])), ...placed.flatMap(p => [p.y, p.y + p.h])];
|
|
1678
1580
|
intake.forEach(b => { xs.push(b.cx - b.r - 8 - textW(b.label || '', 10.5), b.cx + b.r); ys.push(b.cy - b.r, b.cy + b.r); });
|
|
1581
|
+
if (!xs.length) xs.push(0), ys.push(0); // a view with nothing in it: an empty stage
|
|
1679
1582
|
const M = 36, minX = Math.min(...xs) - M, minY = Math.min(...ys) - M;
|
|
1680
1583
|
return { ms: __ms, ...(labelRoom ? { labelRoom } : {}), stats, nodes, edges, subAndChips: !!dg.subAndChips, groups, families, intake, tour: dg.tour || null, byId, map, hub: dg.hub || null, lanes: dg.lanes || [], blocks: dg.blocks || [], bounds: { x: minX, y: minY, w: Math.max(...xs) - minX + M, h: Math.max(...ys) - minY + M } };
|
|
1681
1584
|
}
|
|
@@ -1818,49 +1721,6 @@ function auditSvg(svg, geo, where) {
|
|
|
1818
1721
|
for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (hit(els[i].b, els[j].b, -1)) issues.push(`${where}: edge labels "${els[i].txt}" and "${els[j].txt}" overlap`);
|
|
1819
1722
|
return issues;
|
|
1820
1723
|
}
|
|
1821
|
-
/* A typed view's layout (work package 1.4): layoutGraph's plain layout with what a typed view needs, none of it on a map.
|
|
1822
|
-
Rows as tall as their tallest node (stage 1 note §4); a line from a node to itself drawn as a loop (risks.md F1); lines
|
|
1823
|
-
between one pair each with its own ports and label (F2); `direction: "down"` (note §6); and a memo that lays the view out
|
|
1824
|
-
again only when its geometry changed (F7). Nodes come with their sizes, `w` and `h`, which their shapes give (1.5). */
|
|
1825
|
-
|
|
1826
|
-
// what moves a typed view's geometry: every field of the view layoutGraph reads but VIEW_PAINT (a test holds the two lists to
|
|
1827
|
-
// layered.js), and these of a node and a line. Anything else (a colour, a `props` field) only repaints
|
|
1828
|
-
const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'rgap', 'turned', 'typed'];
|
|
1829
|
-
const VIEW_PAINT = ['tour', 'subAndChips'], NODE_GEO = ['id', 'col', 'row', 'w', 'h', 'ax', 'ay'], EDGE_GEO = ['id', 'from', 'to', 'label', 'step', 'fromSide', 'toSide', 'back', 'fixed', 'sublabel'];
|
|
1830
|
-
const geometryKey = dg => JSON.stringify([VIEW_GEO.map(k => dg[k]), (dg.nodes || []).map(n => NODE_GEO.map(k => n[k])), (dg.edges || []).map(e => EDGE_GEO.map(k => e[k]))]);
|
|
1831
|
-
|
|
1832
|
-
// direction "down": laid out left to right with every box turned, then turned back, x for y and the sides L for T, R for B
|
|
1833
|
-
const SIDE_T = { L: 'T', T: 'L', R: 'B', B: 'R' }, swapXY = p => [p[1], p[0]];
|
|
1834
|
-
const flipBox = r => { if (r) [r.x, r.y, r.w, r.h] = [r.y, r.x, r.h, r.w]; };
|
|
1835
|
-
function transpose(g) {
|
|
1836
|
-
[...g.nodes, ...(g.families || []), g.bounds].forEach(flipBox);
|
|
1837
|
-
(g.groups || []).forEach(r => { [r.x, r.y, r.x2, r.y2] = [r.y, r.x, r.y2, r.x2]; });
|
|
1838
|
-
g.edges.forEach(e => { e.pts = e.pts.map(swapXY); if (e.fixed) e.fixed = e.pts; e.p1 = swapXY(e.p1); e.p2 = swapXY(e.p2);
|
|
1839
|
-
e.fs = SIDE_T[e.fs]; e.ts = SIDE_T[e.ts]; e.stubs = (e.stubs || []).map(s => s.map(swapXY)); flipBox(e.lbl); });
|
|
1840
|
-
return g;
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
// the geometry kept, with the paint of the view given in place of the paint it was laid out with: each node's own fields, and
|
|
1844
|
-
// each line's, by its id (typed lines have ids); a field the view no longer gives goes. The geometric fields are the same in both
|
|
1845
|
-
function repaint(g, was, dg) {
|
|
1846
|
-
const by = (list, key) => new Map((list || []).filter(x => x && x[key] != null).map(x => [x[key], x]));
|
|
1847
|
-
[[g.nodes, was.nodes, dg.nodes], [g.edges, was.edges, dg.edges]].forEach(([out, a, b]) => { const A = by(a, 'id'), B = by(b, 'id');
|
|
1848
|
-
out.forEach(x => { const old = A.get(x.id), now = B.get(x.id); if (!now) return; for (const k in old) if (!(k in now)) delete x[k]; Object.assign(x, now); }); });
|
|
1849
|
-
return g;
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
/* dg: layoutGraph's input, with `direction` ("right", the default, or "down"). memo: an object kept for the view between
|
|
1853
|
-
calls; while the geometry key is the same, the geometry it holds is repainted and returned, and nothing is laid out or
|
|
1854
|
-
routed. memo.runs counts the layouts. */
|
|
1855
|
-
function layoutTyped(dg, memo) {
|
|
1856
|
-
const key = memo && geometryKey(dg);
|
|
1857
|
-
if (memo && memo.key === key) { const g = repaint(memo.g, memo.dg, dg); memo.dg = dg; return g; }
|
|
1858
|
-
const t = { ...dg, typed: true }, g = dg.direction === 'down'
|
|
1859
|
-
? transpose(layoutGraph({ ...t, turned: true, nodes: (dg.nodes || []).map(n => ({ ...n, w: n.h || dg.nodeH || 64, h: n.w || NW })) }, false))
|
|
1860
|
-
: layoutGraph(t, false);
|
|
1861
|
-
if (memo) Object.assign(memo, { key, g, dg, runs: (memo.runs || 0) + 1 });
|
|
1862
|
-
return g;
|
|
1863
|
-
}
|
|
1864
1724
|
/* ================= diagram engine (shared by document diagrams and the map) =================
|
|
1865
1725
|
Style adapted from tt-a1i/archify (MIT): semantic palette, opaque mask under a translucent
|
|
1866
1726
|
fill, per-kind sigils, grid canvas, orthogonal rounded routes, arrow markers. */
|
|
@@ -2210,7 +2070,7 @@ function mapContext(model, opts = {}) {
|
|
|
2210
2070
|
// the box's lines to cards outside it (#86): each far end at its row from the box's top, where the pass before put both.
|
|
2211
2071
|
// ponytail: three passes, so a far end that moves in the last one is followed where it was, and a far end in the card's
|
|
2212
2072
|
// own column, or a line to a box, places nothing; passes until nothing moves, if a map needs them
|
|
2213
|
-
const far = [], fixed =
|
|
2073
|
+
const far = [], fixed = Object.create(null), top = P && g != null && was[g] ? Object.keys(was[g].pos).map(c => P[c] && P[c][1] - was[g].pos[c][1]).find(r => r != null) : null;
|
|
2214
2074
|
if (top != null) fwd.forEach(e => { const fin = holds(g, e.from); if (fin === holds(g, e.to)) return;
|
|
2215
2075
|
const [mine, other] = fin ? [e.from, e.to] : [e.to, e.from], x = itemIn(mine, g), p = P[mine], q = P[other]; if (x === undefined || !p || !q || p[0] === q[0]) return;
|
|
2216
2076
|
const id = '\u0002' + far.length, left = q[0] < p[0]; fixed[id] = q[1] - top;
|
|
@@ -2233,7 +2093,10 @@ function mapContext(model, opts = {}) {
|
|
|
2233
2093
|
for (const order of [false, true]) { was = now; now = {}; P = block(null, P, order).pos; } // levelled by the lines from outside, then ordered by them too
|
|
2234
2094
|
return P;
|
|
2235
2095
|
}
|
|
2236
|
-
|
|
2096
|
+
// a typed view whose type lays it out (1.9): the plain flow's ranking, with the lines the type turns round for it
|
|
2097
|
+
const ownLayout = vid => { if (D.schemaVersion !== 2) return null; const T = diagramOf(V[vid]); return T && T.layout && (T.layout.run || typeof T.layout === 'function') ? T : null; }; // version 1: a map
|
|
2098
|
+
const autoPos = vid => ownLayout(vid) ? flowPos(vid, { flip: ownLayout(vid).rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
|
|
2099
|
+
const memos = opts.memos || Object.create(null); // per view, layoutTyped's memo, kept across update()s: an edit that moves nothing lays out nothing
|
|
2237
2100
|
const level = e => !/^(top|bottom)$/.test(e.fromSide) && !/^(top|bottom)$/.test(e.toSide); // a line into a card's top or bottom asks for no row
|
|
2238
2101
|
/* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
|
|
2239
2102
|
neighbours, or packed from the top as before, with cards no line comes into beside their callee or not. A cheap
|
|
@@ -2253,15 +2116,16 @@ function mapContext(model, opts = {}) {
|
|
|
2253
2116
|
const fanIn = vid => { const k = {}; return viewEdges(V[vid] || {}).some(e => e.from !== e.to && (k[e.to] = (k[e.to] || 0) + 1) >= 6); };
|
|
2254
2117
|
const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
|
|
2255
2118
|
function flowPos(vid, o = {}) {
|
|
2256
|
-
const v = V[vid],
|
|
2119
|
+
const v = o.flip ? { ...V[vid], edges: viewEdges(V[vid]).map(e => o.flip(e) ? { ...e, from: e.to, to: e.from } : e) } : V[vid];
|
|
2120
|
+
const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
|
|
2257
2121
|
// cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
|
|
2258
2122
|
const all = (v.nodeIds || []).filter(i => N[i]), lined = new Set(lp.edges.filter(e => e.from !== e.to).flatMap(e => [e.from, e.to]));
|
|
2259
2123
|
const loose = lined.size ? all.filter(i => !lined.has(i)) : [], ids = all.filter(i => !loose.includes(i));
|
|
2260
|
-
const rank =
|
|
2124
|
+
const rank = Object.create(null); ids.forEach(i => { rank[i] = 0; });
|
|
2261
2125
|
for (let it = 0; it <= ids.length; it++) fwd.forEach(e => { if (rank[e.to] < rank[e.from] + 1) rank[e.to] = rank[e.from] + 1; });
|
|
2262
2126
|
const seen = id => { const k = lp.edges.findIndex(e => e.to === id || e.from === id); return k < 0 ? 999 : k; };
|
|
2263
2127
|
const sink = i => isState(i) && !fwd.some(e => e.from === i); // state that leads nowhere sits along the bottom
|
|
2264
|
-
const pos =
|
|
2128
|
+
const pos = Object.create(null), links = fwd.map(e => [e.from, e.to]);
|
|
2265
2129
|
const order = rk => { const cols = {}; ids.forEach(i => (cols[rk[i]] ||= []).push(i));
|
|
2266
2130
|
const ranks = Object.keys(cols).map(Number).sort((a, b) => a - b), mains = ranks.map(c => cols[c].filter(i => !sink(i)).sort((a, b) => seen(a) - seen(b)));
|
|
2267
2131
|
untangle(mains, links); return { cols, ranks, mains }; }; // in reading order, unless another order crosses less (#53)
|
|
@@ -2286,8 +2150,8 @@ function mapContext(model, opts = {}) {
|
|
|
2286
2150
|
// with no layout (a change of notation draws the cards again and nothing else)
|
|
2287
2151
|
const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
|
|
2288
2152
|
function drawView(vid, g, opts = {}) {
|
|
2289
|
-
const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid);
|
|
2290
|
-
return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes }), shapes };
|
|
2153
|
+
const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid), T = ownLayout(vid), L = T && T.draw && T.draw(g, { esc, r2, textW }); // its two layers only
|
|
2154
|
+
return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes, ...(L && { under: L.under, over: L.over }) }), shapes };
|
|
2291
2155
|
}
|
|
2292
2156
|
// the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
|
|
2293
2157
|
let ruled = null;
|
|
@@ -2297,7 +2161,7 @@ function mapContext(model, opts = {}) {
|
|
|
2297
2161
|
(ruled ||= rules(D)).forEach(b => { if (b.view === vid) b.targets.forEach(t => { if (t.type === 'edge') t.ids.forEach(i => { const l = by[i] ||= []; if (!l.includes(title(b.rule))) l.push(title(b.rule)); }); }); });
|
|
2298
2162
|
return Object.keys(by).length ? by : null;
|
|
2299
2163
|
};
|
|
2300
|
-
const layoutCache =
|
|
2164
|
+
const layoutCache = Object.create(null); // M3
|
|
2301
2165
|
// the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
|
|
2302
2166
|
const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
|
|
2303
2167
|
return k || (opensOf(nid, vid) && n > 0 ? Math.floor(n) : 0); };
|
|
@@ -2325,7 +2189,15 @@ function mapContext(model, opts = {}) {
|
|
|
2325
2189
|
// quick: a candidate the layout searches below score and may drop, laid out once, as it was before 0.6.0 (no room for lines
|
|
2326
2190
|
// with no route, no way round the cards for them, no warning)
|
|
2327
2191
|
function buildGeo(vid, override, repair = (V[vid] || {}).repair !== false, quick = false) { // a view's repair: false turns port repair off
|
|
2328
|
-
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
|
|
2192
|
+
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), T = ownLayout(vid);
|
|
2193
|
+
if (T) { // the type's layout (registry.js), in the host's measure (R2)
|
|
2194
|
+
const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
|
|
2195
|
+
if (k != null && memo.runKey === k) return memo.runGeo;
|
|
2196
|
+
const pos = override || (v.layout && L.pins !== false ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
|
|
2197
|
+
const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
|
|
2198
|
+
if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
|
|
2199
|
+
return g;
|
|
2200
|
+
}
|
|
2329
2201
|
const edges = viewEdges(v).map(e => ({ ...e }));
|
|
2330
2202
|
if (v.render === 'sequence') edges.forEach((e, i) => { e.step = i + 1; });
|
|
2331
2203
|
const P = planCache[vid] || { flow: true, backKeys: loopsOf(v).keys, steps: {} };
|
|
@@ -2568,7 +2440,7 @@ function mapContext(model, opts = {}) {
|
|
|
2568
2440
|
for (let more = true; more && todo.length;) { more = false; todo = todo.filter(nid => { if (!unfold(vid, nid)) return true; (unfolded[vid] ||= []).push(nid); return !(more = true); }); } }))();
|
|
2569
2441
|
const geoCache = {};
|
|
2570
2442
|
return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
|
|
2571
|
-
layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
|
|
2443
|
+
layoutCache, geoCache, memos, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
|
|
2572
2444
|
}
|
|
2573
2445
|
const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
|
|
2574
2446
|
|
|
@@ -2723,6 +2595,9 @@ export function validate(model) {
|
|
|
2723
2595
|
out.push(...typedProblems(m).filter(p => !(p.level === 'error' && said.has(p.path))));
|
|
2724
2596
|
views.forEach((v, i) => { const at = `views[${i}].diagram`; // a well-formed name of a type this release does not draw (M13)
|
|
2725
2597
|
if (typedView(v) && !diagramOf(v) && !out.some(p => p.path === at)) warn(at, `diagram "${v.diagram}" is not drawn by this release: its nodes are drawn as plain cards`); });
|
|
2598
|
+
const once = new Set(); // each view's type's rules, a problem once; a validate(model) that takes no view, once a type
|
|
2599
|
+
views.filter(typedView).forEach(v => { const T = diagramOf(v); if (!T || !T.validate || once.has(T)) return; if (T.validate.length < 2) once.add(T);
|
|
2600
|
+
T.validate(m, v).forEach(p => { const k = JSON.stringify([p.level, p.path, p.message]); if (!once.has(k)) { once.add(k); out.push(p); } }); });
|
|
2726
2601
|
}
|
|
2727
2602
|
return out;
|
|
2728
2603
|
}
|
|
@@ -2966,6 +2841,7 @@ export function reach(model, viewId, from, options = {}) {
|
|
|
2966
2841
|
/** A view in plain sentences: its cards and their kinds, its boxes, what each card's lines do (in run order on a sequence
|
|
2967
2842
|
* view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
|
|
2968
2843
|
export function describe(model, viewId) {
|
|
2844
|
+
const T = model && model.schemaVersion === 2 && diagramOf(arrOf(model.views).find(v => v && v.id === viewId)); if (T && T.describe) return T.describe(model, viewId); // a type's own words (1.9)
|
|
2969
2845
|
const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
|
|
2970
2846
|
const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
|
|
2971
2847
|
const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
|
|
@@ -3653,7 +3529,9 @@ function typedEditor(api) {
|
|
|
3653
3529
|
const t = nodeTypes()[i], cam = api.cam(); if (!t || !can('add') || !cam) return;
|
|
3654
3530
|
api.editor({ x: cam.s.cx - 80, y: cam.s.cy - 13, w: 160, h: 26 }, '', value => emit([{ op: 'add', element: { ...typed(t, 'node'), label: value }, view: api.view() }]));
|
|
3655
3531
|
}
|
|
3656
|
-
const
|
|
3532
|
+
const hook = k => { const E = def().edits; return isRec(E) && typeof E[k] === 'function' ? E[k] : null; }; // registry.js edits
|
|
3533
|
+
const addLine = (from, to, t) => { const c = hook('connect'); // at (a slot): stage 3's
|
|
3534
|
+
emit(c ? arrOf(c(model(), view(), from, to, null, t)) : [{ op: 'add', element: { ...typed(t, 'line'), from, to, ...(isRec(t.props) ? { props: structuredClone(t.props) } : {}) }, view: api.view() }]); };
|
|
3657
3535
|
function connect(from, to) { // the relationship picker, when the type has more than one kind of line
|
|
3658
3536
|
if (!can('add') || !from || !to || from === to) return;
|
|
3659
3537
|
const types = lineTypes();
|
|
@@ -3736,15 +3614,17 @@ function typedEditor(api) {
|
|
|
3736
3614
|
const off = can('rename') ? '' : ' disabled'; // the editable flags: a field the host does not offer is shown, to read
|
|
3737
3615
|
const text = (f, v, name, more = '') => `<label>${esc(name)}<input data-f="${esc(f)}" data-was="${esc(v)}" value="${esc(v)}"${more}${off}></label>`;
|
|
3738
3616
|
let h = `<h3>Properties${x.type != null ? ': ' + esc(x.type) : ''}</h3>` + text('label', x.label ?? '', 'Name');
|
|
3739
|
-
schema.forEach(f => { const v = P[f.key], name = f.label || f.key;
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
else
|
|
3617
|
+
schema.forEach(f => { const v = Array.isArray(f.path) ? f.path.reduce((o, k) => isRec(o) ? o[k] : undefined, P) : P[f.key], name = f.label || f.key; // path: a field inside props (ends.from.role)
|
|
3618
|
+
const key = ` data-key="${esc(f.key)}"${Array.isArray(f.path) ? ` data-path="${esc(JSON.stringify(f.path))}"` : ''}`;
|
|
3619
|
+
if (f.kind === 'bool') h += `<label class="um-check"><input type="checkbox" data-f="p:${esc(f.key)}"${key}${v ? ' checked' : ''}${off}>${esc(name)}</label>`;
|
|
3620
|
+
else if (f.kind === 'enum') h += `<label>${esc(name)}<select data-f="p:${esc(f.key)}"${key}${off}>${['', ...arrOf(f.options)].map(o => `<option value="${esc(o)}"${o === v ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select></label>`;
|
|
3621
|
+
else h += text('p:' + f.key, f.kind === 'list' ? arrOf(v).join(', ') : v ?? '', name, key + (f.kind === 'list' ? ' data-list' : '')); });
|
|
3743
3622
|
const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
|
|
3744
3623
|
if (rest.length) h += '<dl>' + rest.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(typeof v === 'string' ? v : JSON.stringify(v))}</dd>`).join('') + '</dl>';
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3624
|
+
const M = def().member;
|
|
3625
|
+
if (e.kind === 'node' && !(M && M.holds && !M.holds(x))) { // a comment holds none
|
|
3626
|
+
const ms = arrOf(x.members).filter(m => isRec(m) && typeof m.id === 'string'), line = m => M ? M.print(m) : m.label ?? ''; // a type's member text: "+ total: Money [1]" (1.9)
|
|
3627
|
+
h += '<h3>Members</h3><ul>' + ms.map((m, i) => `<li><input data-f="m:${esc(m.id)}" data-m="${esc(m.id)}" data-was="${esc(line(m))}" value="${esc(line(m))}" aria-label="Member ${i + 1} of ${ms.length}${can('reorder') ? ', Alt + arrows to move it' : ''}"${off}>`
|
|
3748
3628
|
+ (can('reorder') ? `<button data-f="up:${esc(m.id)}" data-up="${esc(m.id)}" aria-label="Move ${esc(nameOf(m))} up">↑</button><button data-f="down:${esc(m.id)}" data-down="${esc(m.id)}" aria-label="Move ${esc(nameOf(m))} down">↓</button>` : '') + '</li>').join('')
|
|
3749
3629
|
+ '</ul>' + (can('add') ? '<input data-f="new" data-new placeholder="New member, Enter adds it" aria-label="New member">' : '');
|
|
3750
3630
|
}
|
|
@@ -3772,15 +3652,19 @@ function typedEditor(api) {
|
|
|
3772
3652
|
|
|
3773
3653
|
function commit(t) { // a field of the properties panel: one transaction
|
|
3774
3654
|
const d = t.dataset, at = elementsOf(model()), id = selId(at);
|
|
3655
|
+
const M = def().member, bad = why => { t.setAttribute('aria-invalid', 'true'); api.status(why); }; // a line the type cannot read: kept as written, nothing applied (E3)
|
|
3775
3656
|
if (d.new != null) { const value = t.value.trim(), x = id && at.get(id); if (!value || !x || !can('add')) return;
|
|
3776
|
-
const
|
|
3777
|
-
|
|
3657
|
+
const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
|
|
3658
|
+
const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
|
|
3659
|
+
return emit([{ op: 'add', element: r ? { ...typed(r, 'member'), label: r.label, ...(Object.keys(r.props).length ? { props: r.props } : {}) } : { ...typed(prev || {}, 'member'), label: value }, owner: id }]); }
|
|
3778
3660
|
if (!can('rename') || (!id && d.m == null)) return;
|
|
3779
3661
|
let value = t.type === 'checkbox' ? t.checked : t.value.trim();
|
|
3780
3662
|
if (t.type !== 'checkbox') { if (t.value === d.was) return;
|
|
3781
3663
|
if (value === '' && d.key == null) { t.value = d.was; return; } // an empty name does nothing, as on the map; an empty property is removed
|
|
3782
|
-
d.
|
|
3783
|
-
|
|
3664
|
+
if (d.m != null && M) { const m = (at.get(d.m) || {}).x, r = m && M.edit(m, value); if (!r || r.error) return bad(r ? r.error : 'not a member');
|
|
3665
|
+
t.removeAttribute('aria-invalid'); d.was = t.value; return emit(r.commands); }
|
|
3666
|
+
d.was = t.value; if (value === '') value = undefined; else if (d.list != null) value = value.split(',').map(x => x.trim()).filter(Boolean); }
|
|
3667
|
+
const path = d.path ? ['props', ...JSON.parse(d.path)] : d.key == null ? 'label' : d.key.includes('.') ? ['props', d.key] : 'props.' + d.key;
|
|
3784
3668
|
emit([{ op: 'set', id: d.m ?? id, path, value }]);
|
|
3785
3669
|
}
|
|
3786
3670
|
function moveMember(id, by) {
|
|
@@ -3805,7 +3689,7 @@ function typedEditor(api) {
|
|
|
3805
3689
|
else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
|
|
3806
3690
|
panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
|
|
3807
3691
|
panel.addEventListener('keydown', e => { e.stopPropagation(); const t = e.target, K = e.key; // the panel's keys stay in it
|
|
3808
|
-
if (K === 'Escape') { if (t.tagName === 'INPUT' && t.type !== 'checkbox') t.value = t.dataset.was ?? ''; api.focus(); } // what was typed is not kept
|
|
3692
|
+
if (K === 'Escape') { if (t.tagName === 'INPUT' && t.type !== 'checkbox') { t.value = t.dataset.was ?? ''; t.removeAttribute('aria-invalid'); } api.focus(); } // what was typed is not kept
|
|
3809
3693
|
else if (K === 'Enter' && t.tagName === 'BUTTON') { e.preventDefault(); t.click(); }
|
|
3810
3694
|
else if (K === 'Enter' && t.tagName === 'INPUT' && t.type !== 'checkbox') { e.preventDefault(); commit(t); }
|
|
3811
3695
|
else if (e.altKey && t.dataset.m != null && (K === 'ArrowUp' || K === 'ArrowDown')) { e.preventDefault(); moveMember(t.dataset.m, K === 'ArrowUp' ? -1 : 1); } });
|
|
@@ -4626,7 +4510,7 @@ export function render(container, model, options = {}) {
|
|
|
4626
4510
|
const old = ctx, keys = {}, wantNew = selectNew && !own;
|
|
4627
4511
|
old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
|
|
4628
4512
|
current = model2;
|
|
4629
|
-
ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
|
|
4513
|
+
ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
|
|
4630
4514
|
Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
|
|
4631
4515
|
if (!own) selectNew = false;
|
|
4632
4516
|
ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
|
|
@@ -5130,9 +5014,10 @@ const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Brows
|
|
|
5130
5014
|
const swatchOf = (sh, k, c, f, dashed) => { const n = { x: 0, y: 0, w: 100, h: 64 }, S = SHAPE[sh]; // a legend's key: the card's outline, small
|
|
5131
5015
|
return `<svg class="lgs" viewBox="-4 -4 108 72" aria-hidden="true"><path d="${S.out(n)}" fill="${f}" stroke="${c}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${S.det ? S.det(n, c, k) : ''}</svg>`; };
|
|
5132
5016
|
function legendItems(gs, opens = 'double-click to open', notes = [], icons = {}) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93). notes: each picture's notation (0.15.0)
|
|
5133
|
-
const
|
|
5017
|
+
const typedKeys = [...new Set(gs.flatMap(g => g.legend || []))]; // a typed view's type writes its own keys (1.9); its cards and lines are not a map's
|
|
5018
|
+
const ns = gs.flatMap(g => g.legend ? [] : g.nodes), es = gs.flatMap(g => g.legend ? [] : g.edges), used = [...new Set(ns.map(n => n.k))];
|
|
5134
5019
|
// each card as its picture's notation draws it: its shape, its dash and its mark (the legend draws what the map draws)
|
|
5135
|
-
const cards = gs.flatMap((g, i) => { const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
|
|
5020
|
+
const cards = gs.flatMap((g, i) => { if (g.legend) return []; const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
|
|
5136
5021
|
return g.nodes.map(n => { const sh = shapeOf(n, nt), I = (icons || {})[n.icon]; // an icon the table lacks leaves the card the component mark, as svgOf draws it
|
|
5137
5022
|
return { k: n.k, sh, dash: nt !== 'box' && n.k === 'external', comp: nt === 'uml' && sh === 'box' && !(I && (I.src || I.g)) && !String(n.k).startsWith('m-') }; }); });
|
|
5138
5023
|
const kindShape = k => { const s = [...new Set(cards.filter(d => d.k === k).map(d => d.sh))]; return s.length === 1 && s[0] !== 'box' && !(k === 'm-hook' && s[0] === 'hexagon') ? s[0] : null; };
|
|
@@ -5150,7 +5035,7 @@ function legendItems(gs, opens = 'double-click to open', notes = [], icons = {})
|
|
|
5150
5035
|
.concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
|
|
5151
5036
|
.concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
|
|
5152
5037
|
.concat(WIRES.filter(w => es.some(e => e.wk === w[0])).map(w => `<span class="lgi"><i class="w" style="border-color:var(--um-w-${w[0]})"></i><b>${w[1]}</b><span>${w[2]}</span></span>`))
|
|
5153
|
-
.concat(es.some(e => e.back) ? ['<span class="lgi"><i class="w dash" style="border-color:var(--um-w-calls)"></i><b>loops back</b><span>the one line that runs right to left</span></span>'] : []);
|
|
5038
|
+
.concat(es.some(e => e.back) ? ['<span class="lgi"><i class="w dash" style="border-color:var(--um-w-calls)"></i><b>loops back</b><span>the one line that runs right to left</span></span>'] : []).concat(typedKeys);
|
|
5154
5039
|
}
|
|
5155
5040
|
|
|
5156
5041
|
function pieces(X, el, o) {
|
|
@@ -5915,3 +5800,1109 @@ function printDoc(X, model, Q) {
|
|
|
5915
5800
|
all.forEach((p, i) => { p.querySelector('.pn').textContent = `page ${i + 1} of ${all.length}`; });
|
|
5916
5801
|
doc.querySelectorAll('[data-pg]').forEach(td => { td.textContent = all.findIndex(p => p.dataset.key === td.dataset.pg) + 1; });
|
|
5917
5802
|
}
|
|
5803
|
+
/* A typed view's layout (work package 1.4): layoutGraph's plain layout with what a typed view needs, none of it on a map.
|
|
5804
|
+
The parts of layoutPass only a typed view takes are here too (TYPED, registry.js); only lib/umlimator-all.js holds this
|
|
5805
|
+
file, as a map never reaches it (risks.md C5).
|
|
5806
|
+
Rows as tall as their tallest node (stage 1 note §4); a line from a node to itself drawn as a loop (risks.md F1); lines
|
|
5807
|
+
between one pair each with its own ports and label (F2); `direction: "down"` (note §6); and a memo that lays the view out
|
|
5808
|
+
again only when its geometry changed (F7). Nodes come with their sizes, `w` and `h`, which their shapes give (1.5). */
|
|
5809
|
+
|
|
5810
|
+
// what moves a typed view's geometry: every field of the view layoutGraph reads but VIEW_PAINT (a test holds the two lists to
|
|
5811
|
+
// layered.js), and these of a node and a line. Anything else (a colour, a `props` field) only repaints
|
|
5812
|
+
const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'rgap', 'turned', 'typed'];
|
|
5813
|
+
const VIEW_PAINT = ['tour', 'subAndChips'], NODE_GEO = ['id', 'col', 'row', 'w', 'h', 'ax', 'ay'], EDGE_GEO = ['id', 'from', 'to', 'label', 'step', 'fromSide', 'toSide', 'back', 'fixed', 'sublabel'];
|
|
5814
|
+
const geometryKey = dg => JSON.stringify([VIEW_GEO.map(k => dg[k]), (dg.nodes || []).map(n => NODE_GEO.map(k => n[k])), (dg.edges || []).map(e => EDGE_GEO.map(k => e[k]))]);
|
|
5815
|
+
|
|
5816
|
+
// direction "down": laid out left to right with every box turned, then turned back, x for y and the sides L for T, R for B
|
|
5817
|
+
const SIDE_T = { L: 'T', T: 'L', R: 'B', B: 'R' }, swapXY = p => [p[1], p[0]];
|
|
5818
|
+
const flipBox = r => { if (r) [r.x, r.y, r.w, r.h] = [r.y, r.x, r.h, r.w]; };
|
|
5819
|
+
function transpose(g) {
|
|
5820
|
+
[...g.nodes, ...(g.families || []), g.bounds].forEach(flipBox);
|
|
5821
|
+
(g.groups || []).forEach(r => { [r.x, r.y, r.x2, r.y2] = [r.y, r.x, r.y2, r.x2]; });
|
|
5822
|
+
g.edges.forEach(e => { e.pts = e.pts.map(swapXY); if (e.fixed) e.fixed = e.pts; e.p1 = swapXY(e.p1); e.p2 = swapXY(e.p2);
|
|
5823
|
+
e.fs = SIDE_T[e.fs]; e.ts = SIDE_T[e.ts]; e.stubs = (e.stubs || []).map(s => s.map(swapXY)); flipBox(e.lbl); });
|
|
5824
|
+
return g;
|
|
5825
|
+
}
|
|
5826
|
+
|
|
5827
|
+
// the geometry kept, with the paint of the view given in place of the paint it was laid out with: each node's own fields, and
|
|
5828
|
+
// each line's, by its id (typed lines have ids); a field the view no longer gives goes. The geometric fields are the same in both
|
|
5829
|
+
function repaint(g, was, dg) {
|
|
5830
|
+
const by = (list, key) => new Map((list || []).filter(x => x && x[key] != null).map(x => [x[key], x]));
|
|
5831
|
+
[[g.nodes, was.nodes, dg.nodes], [g.edges, was.edges, dg.edges]].forEach(([out, a, b]) => { const A = by(a, 'id'), B = by(b, 'id');
|
|
5832
|
+
out.forEach(x => { const old = A.get(x.id), now = B.get(x.id); if (!now) return; for (const k in old) if (!(k in now)) delete x[k]; Object.assign(x, now); }); });
|
|
5833
|
+
return g;
|
|
5834
|
+
}
|
|
5835
|
+
|
|
5836
|
+
/* dg: layoutGraph's input, with `direction` ("right", the default, or "down"). memo: an object kept for the view between
|
|
5837
|
+
calls; while the geometry key is the same, the geometry it holds is repainted and returned, and nothing is laid out or
|
|
5838
|
+
routed. memo.runs counts the layouts. */
|
|
5839
|
+
function layoutTyped(dg, memo) {
|
|
5840
|
+
const key = memo && geometryKey(dg);
|
|
5841
|
+
if (memo && memo.key === key) { const g = repaint(memo.g, memo.dg, dg); memo.dg = dg; return g; }
|
|
5842
|
+
const t = { ...dg, typed: true }, g = dg.direction === 'down'
|
|
5843
|
+
? transpose(layoutGraph({ ...t, turned: true, nodes: (dg.nodes || []).map(n => ({ ...n, w: n.h || dg.nodeH || 64, h: n.w || NW })) }, false))
|
|
5844
|
+
: layoutGraph(t, false);
|
|
5845
|
+
if (memo) Object.assign(memo, { key, g, dg, runs: (memo.runs || 0) + 1 });
|
|
5846
|
+
return g;
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
// A typed view's lines from a node to itself (risks.md F1) are loops round its top right corner, nested, the first innermost:
|
|
5850
|
+
// each one's top run D px above the node, its label on that run clear of the node's stub room and of the loop inside; its
|
|
5851
|
+
// right run R px right of the node, far enough for the top run to hold the label. out: how far each loop's port on the
|
|
5852
|
+
// node's top lies left of its right side (0 before ports are known, for the room: above the node's row, right of its column)
|
|
5853
|
+
const loopDims = (dg, STUB, SEP) => (list, out) => { let D = 0, R = 0, half = 0;
|
|
5854
|
+
return list.map((e, k) => { const lw = e.label || e.step != null ? labelW(e) : 0, [bw, bh] = dg.turned ? [lw && 18, lw] : [lw, lw && 18];
|
|
5855
|
+
D = k ? D + half + bh / 2 + 10 : STUB + 6 + bh / 2; half = bh / 2;
|
|
5856
|
+
R = Math.max(k ? R + 2 * SEP : STUB + 4, bw + 12 - out[k]); return { D, R, top: D + half }; }); };
|
|
5857
|
+
// layoutPass's loops (LP): fills loops (by node id, the lines from it to itself) and makes room for them; then mark(edges),
|
|
5858
|
+
// each such line routed here, not by the router; ports(pushSide): the top port of the node's right side and the right port
|
|
5859
|
+
// of its top, the inner loop the nearer the corner; route(): each loop out of the right side, up, left and down into the
|
|
5860
|
+
// top, and its corners, which the other routes keep SEP px off, so none touches it or turns where it does
|
|
5861
|
+
TYPED.loops = (loops, dg, { byId, extraX, extraY, STUB, RG, CG, SEP, gapAfter }) => {
|
|
5862
|
+
const dims = loopDims(dg, STUB, SEP); let edges = [];
|
|
5863
|
+
(dg.edges || []).forEach(e => { if (e.from === e.to && byId[e.from]) (loops[e.from] ||= []).push(e); });
|
|
5864
|
+
Object.entries(loops).forEach(([id, list]) => { const n = byId[id], d = dims(list, list.map(() => 0)).at(-1);
|
|
5865
|
+
extraY[n.row - .5] = Math.max(extraY[n.row - .5] || 0, d.top + STUB + 7 - RG);
|
|
5866
|
+
extraX[n.col] = Math.max(extraX[n.col] || 0, d.R + STUB + 6 - Math.max(CG, gapAfter[n.col] || 0)); });
|
|
5867
|
+
const loopsOf = n => edges.filter(e => e[OWN_ROUTE] === 'loop' && e.s === n);
|
|
5868
|
+
return {
|
|
5869
|
+
mark(all) { edges = all; edges.forEach(e => { if (e.s === e.t) Object.assign(e, { [OWN_ROUTE]: 'loop', fixed: true, fs: 'R', ts: 'T', stubs: [], how: 'self' }); }); }, // its loop is drawn once its ports are known
|
|
5870
|
+
ports: pushSide => edges.filter(e => e[OWN_ROUTE] === 'loop').forEach(e => { const n = e.s, k = loopsOf(n).indexOf(e);
|
|
5871
|
+
pushSide(n, 'R', e, { x: n.x + n.w, y: -1e6 + k, w: 0, h: 0 }); pushSide(n, 'T', e, { x: 1e6 - k, y: n.y, w: 0, h: 0 }); }),
|
|
5872
|
+
route() { const corners = []; [...new Set(edges.filter(e => e[OWN_ROUTE] === 'loop').map(e => e.s))].forEach(n => { const list = loopsOf(n);
|
|
5873
|
+
dims(list, list.map(e => n.x + n.w - e.p2[0])).forEach(({ D, R }, k) => { const e = list[k], x = n.x + n.w + R, y = n.y - D;
|
|
5874
|
+
e.pts = e.fixed = [e.p1, [x, e.p1[1]], [x, y], [e.p2[0], y], e.p2];
|
|
5875
|
+
e.pts.slice(1, 4).forEach(([px, py]) => corners.push({ x: px - SEP, y: py - SEP, w: 2 * SEP, h: 2 * SEP })); }); });
|
|
5876
|
+
return corners; },
|
|
5877
|
+
};
|
|
5878
|
+
};
|
|
5879
|
+
|
|
5880
|
+
// a typed view's rows (stage 1 note §4): each as tall as its tallest node, a node on a half row counting toward both rows, and
|
|
5881
|
+
// NH with none. With every node NH tall, the pitch is a map's. at(r): a row's top; ht(r): its height, a half row's the taller of its two
|
|
5882
|
+
TYPED.rows = (dg, nodes, pitch, NH, RG) => {
|
|
5883
|
+
const rowH = {}, rowTop = {}; if (!dg.abs && nodes.length) {
|
|
5884
|
+
nodes.forEach(n => [Math.floor(n.row), Math.ceil(n.row)].forEach(k => { rowH[k] = Math.max(rowH[k] ?? 0, n.h); }));
|
|
5885
|
+
const r0 = Math.min(...Object.keys(rowH).map(Number)), r1 = Math.max(...Object.keys(rowH).map(Number));
|
|
5886
|
+
for (let k = r0, y = r0 * pitch; k <= r1 + 1; k++) { rowTop[k] = y; y += (rowH[k] ?? NH) + RG; } }
|
|
5887
|
+
return { at: r => { const k = Math.floor(r), f = r - k; return rowTop[k] == null || rowTop[k + 1] == null ? r * pitch : rowTop[k] + f * (rowTop[k + 1] - rowTop[k]); },
|
|
5888
|
+
ht: r => Math.max(rowH[Math.floor(r)] ?? NH, rowH[Math.ceil(r)] ?? NH) };
|
|
5889
|
+
};
|
|
5890
|
+
|
|
5891
|
+
// A view laid out down (turned): the labels of lines between one pair are wider than the 16 px between the lines, so the
|
|
5892
|
+
// lines fan out in the gap between the pair's columns, each to a lane of its own that holds its label. Above the middle, the
|
|
5893
|
+
// outer line turns nearer each node, so none crosses another; a group the gap or another node leaves no room for goes to the router
|
|
5894
|
+
TYPED.fan = (free, { simplify, through, STUB, SEP }) => { const pairs = new Map(), lr = e => e.fs === 'R' ? [e.p1, e.p2] : [e.p2, e.p1];
|
|
5895
|
+
free.forEach(e => { const [l, r] = e.fs === 'R' ? [e.s, e.t] : [e.t, e.s], k = l.id + '\u0001' + r.id;
|
|
5896
|
+
if (/^(RL|LR)$/.test(e.fs + e.ts) && l.x + l.w < r.x) pairs.set(k, [...(pairs.get(k) || []), e]); });
|
|
5897
|
+
pairs.forEach(list => { if (list.length < 2 || !list.some(e => e.label || e.step != null)) return;
|
|
5898
|
+
list.sort((a, b) => lr(a)[0][1] - lr(b)[0][1]); if (list.some((e, i) => i && lr(e)[1][1] <= lr(list[i - 1])[1][1])) return;
|
|
5899
|
+
const bh = list.map(e => e.label || e.step != null ? labelW(e) : 0), gaps = bh.map((h, i) => i ? Math.max(SEP, (bh[i - 1] + h) / 2 + 6) : 0);
|
|
5900
|
+
const mid = list.reduce((a, e) => a + lr(e)[0][1] + lr(e)[1][1], 0) / (2 * list.length), lanes = [];
|
|
5901
|
+
gaps.reduce((y, g, i) => (lanes[i] = y + g), mid - gaps.reduce((a, g) => a + g, 0) / 2);
|
|
5902
|
+
// each line's turn on each side: the k-th line turning towards its lane from the outside turns k SEP px past the stub
|
|
5903
|
+
const turns = side => { const at = []; const up = list.map((e, i) => i).filter(i => lanes[i] < lr(list[i])[side][1]), down = list.map((e, i) => i).filter(i => lanes[i] >= lr(list[i])[side][1]).reverse();
|
|
5904
|
+
[up, down].forEach(g => g.forEach((i, k) => { at[i] = lr(list[i])[side][0] + (side ? -1 : 1) * (STUB + k * SEP); })); return at; };
|
|
5905
|
+
const xl = turns(0), xr = turns(1);
|
|
5906
|
+
if (Math.min(...xr) - Math.max(...xl) < 18 + 16) return;
|
|
5907
|
+
const pts = list.map((e, i) => { const [a, b] = lr(e), P = simplify([a, [xl[i], a[1]], [xl[i], lanes[i]], [xr[i], lanes[i]], [xr[i], b[1]], b]); return e.fs === 'R' ? P : P.reverse(); });
|
|
5908
|
+
if (list.some((e, i) => through(pts[i], e))) return;
|
|
5909
|
+
list.forEach((e, i) => { Object.assign(e, { [OWN_ROUTE]: 'fan', fixed: pts[i], pts: pts[i], stubs: [], how: 'fan' }); free.splice(free.indexOf(e), 1); }); }); };
|
|
5910
|
+
/* What a typed view draws and a map never does (risks.md C5): the classifier shape, a typed line's ends and its end labels.
|
|
5911
|
+
Only lib/umlimator-all.js holds this file; it installs SHAPE.classifier and TYPED.lines (registry.js) as it loads. */
|
|
5912
|
+
|
|
5913
|
+
// ---- the classifier (1.5). Its drawing input, on a typed view's node (not the model: 1.9 fills it from the model and the
|
|
5914
|
+
// view's present; a map never draws it): shape 'classifier'; label; type ('uml.Class', named in its accessible name);
|
|
5915
|
+
// stereotypes [text], shown «…» over the name; abstract (the name in italics); compartments [{ name, members }] in order
|
|
5916
|
+
// ('attributes', 'operations', 'literals'), each member in the model's envelope ({ type, label, props }); collapsed
|
|
5917
|
+
// [compartment names], hidden with no divider; rows: at most that many rows in a compartment, then "+k more" (stage 1 note §5)
|
|
5918
|
+
const CL = { pad: 8, name: 13, row: 11, st: 14, nl: 18, rl: 15, gap: 5, empty: 10, minW: 96, maxW: 320 };
|
|
5919
|
+
const VIS = { public: '+', private: '-', protected: '#', package: '~' };
|
|
5920
|
+
// a member's row in UML's form: "+ total: Money [1]", "+ /count: Integer = 0", "+ place(at: Date): Order"; a literal's label
|
|
5921
|
+
const obj = x => !!x && typeof x === 'object'; // an entry the classifier reads; null, text or a number is passed over
|
|
5922
|
+
function memberText(m) { const p = obj(m.props) ? m.props : {}, has = x => x != null && x !== '', name = (hasKey(VIS, p.visibility) ? VIS[p.visibility] + ' ' : '') + (p.isDerived ? '/' : '') + (m.label ?? '');
|
|
5923
|
+
if (/Operation$/.test(m.type || '')) return name + '(' + (Array.isArray(p.parameters) ? p.parameters.filter(obj) : []).map(a => (has(a.direction) && a.direction !== 'in' ? a.direction + ' ' : '') + (a.name ?? '') + (has(a.valueType) ? ': ' + a.valueType : '')).join(', ') + ')' + (has(p.returnType) ? ': ' + p.returnType : '');
|
|
5924
|
+
return name + (has(p.valueType) ? ': ' + p.valueType : '') + (has(p.multiplicity) ? ` [${p.multiplicity}]` : '') + (has(p.default) ? ' = ' + p.default : ''); }
|
|
5925
|
+
// the classifier as drawn: its stereotypes, its compartments (all: every one, each with every member; comps: those shown,
|
|
5926
|
+
// with their rows and how many they leave out), the head's height, and the size its text needs: as wide as its widest text,
|
|
5927
|
+
// within minW and maxW (a longer row is cut: classSvg). An abstract name is in italics, which the measure (upright) does
|
|
5928
|
+
// not see: it is measured a tenth wider (slant)
|
|
5929
|
+
function classParts(n) {
|
|
5930
|
+
const hidden = new Set(Array.isArray(n.collapsed) ? n.collapsed : []), cap = Number.isFinite(n.rows) ? Math.max(0, Math.floor(n.rows)) : Infinity, slant = n.abstract ? 1.1 : 1;
|
|
5931
|
+
const stereo = (Array.isArray(n.stereotypes) ? n.stereotypes : []).filter(t => t != null && t !== '').map(String), st = stereo.map(t => `«${t}»`);
|
|
5932
|
+
const all = (Array.isArray(n.compartments) ? n.compartments : []).filter(obj).map(c => ({ name: String(c.name ?? ''), members: Array.isArray(c.members) ? c.members.filter(obj) : [] }));
|
|
5933
|
+
const comps = all.filter(c => !hidden.has(c.name)).map(c => ({ name: c.name, rows: c.members.slice(0, cap).map(m => ({ m, text: memberText(m) })), more: Math.max(0, c.members.length - cap) }));
|
|
5934
|
+
const texts = [[n.label ?? '', CL.name, slant], ...st.map(t => [t, CL.row, 1]), ...comps.flatMap(c => [...c.rows.map(r => [r.text, CL.row, 1]), ...(c.more ? [[`+${c.more} more`, CL.row, 1]] : [])])];
|
|
5935
|
+
const w = Math.ceil(Math.min(CL.maxW, Math.max(CL.minW, ...texts.map(([t, px, k]) => textW(t, px) * k + 2 * CL.pad))));
|
|
5936
|
+
const head = 2 * CL.gap + st.length * CL.st + CL.nl, tall = c => { const k = c.rows.length + (c.more ? 1 : 0); return k ? 2 * CL.gap - 2 + k * CL.rl : CL.empty; };
|
|
5937
|
+
return { stereo, st, all, comps, head, tall, slant, size: { w, h: comps.reduce((a, c) => a + tall(c), head) } };
|
|
5938
|
+
}
|
|
5939
|
+
const words = t => String(t).split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
|
|
5940
|
+
function classSvg(n, c, f) { // its outline, its head, then each compartment under a divider, every text cut to the box (fit)
|
|
5941
|
+
const { stereo, st, all, comps, head, tall, slant } = classParts(n), { x, y, w, h } = n, max = w - 2 * CL.pad, cx = r2(x + w / 2);
|
|
5942
|
+
const font = (px, wt, it) => `style="font:${it ? 'italic ' : ''}${wt} ${px}px var(--um-sans)"`;
|
|
5943
|
+
const row = (cls, fill, ty, t, more = '') => `<text x="${r2(x + CL.pad)}" y="${r2(ty)}" class="${cls}" fill="${fill}"${more}>${esc(fit(t, CL.row, max))}</text>`;
|
|
5944
|
+
const out = [`<rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-mask"/><rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-body" fill="${f}" stroke="${c}"/>`];
|
|
5945
|
+
let ty = y + CL.gap; const hd = [];
|
|
5946
|
+
st.forEach(t => { hd.push(`<text x="${cx}" y="${r2(ty + 11)}" text-anchor="middle" class="c-st" fill="var(--um-muted)" ${font(CL.row, 400)}>${esc(fit(t, CL.row, max))}</text>`); ty += CL.st; });
|
|
5947
|
+
hd.push(`<text x="${cx}" y="${r2(ty + 14)}" text-anchor="middle" class="c-name" fill="var(--um-text)" ${font(CL.name, 600, n.abstract)} data-o="${esc(n.id)}">${esc(fit(n.label ?? '', CL.name, max / slant))}</text>`);
|
|
5948
|
+
out.push(`<g class="c-head" data-band="${r2(y)} ${r2(y + head)}">${hd.join('')}</g>`);
|
|
5949
|
+
let top = y + head;
|
|
5950
|
+
comps.forEach((C, i) => { const bh = i === comps.length - 1 ? Math.max(y + h - top, 0) : tall(C), at = j => top + CL.gap - 1 + j * CL.rl + 11.5;
|
|
5951
|
+
const rows = C.rows.map((r, j) => { const p = obj(r.m.props) ? r.m.props : {};
|
|
5952
|
+
return row('c-row', 'var(--um-text)', at(j), r.text, ` ${font(CL.row, 400, /Operation$/.test(r.m.type || '') && p.isAbstract)}${p.isStatic ? ' text-decoration="underline"' : ''}`); });
|
|
5953
|
+
if (C.more) rows.push(row('c-more', 'var(--um-muted)', at(C.rows.length), `+${C.more} more`, ` ${font(CL.row, 400)}`));
|
|
5954
|
+
out.push(`<g class="c-comp" data-comp="${esc(C.name)}" data-band="${r2(top)} ${r2(top + bh)}"><path d="M${x} ${r2(top)}H${x + w}" class="n-det" stroke="${c}"/>${rows.join('')}</g>`);
|
|
5955
|
+
top += bh; });
|
|
5956
|
+
// its accessible name (A1): "Class Order (entity, abstract), 2 attributes, 1 operation", every member counted, shown or not
|
|
5957
|
+
const counts = all.map(C => `${C.members.length} ${C.members.length === 1 ? C.name.replace(/s$/, '') : C.name}`), why = [...stereo, ...(n.abstract ? ['abstract'] : [])];
|
|
5958
|
+
const name = [`${words(n.type || 'uml.Class')} ${n.label ?? ''}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
|
|
5959
|
+
return `<g class="node classifier" data-id="${esc(n.id)}" data-shape="classifier" tabindex="-1" role="img" aria-label="${esc(name)}">${out.join('')}</g>`;
|
|
5960
|
+
}
|
|
5961
|
+
const CLASSIFIER = { // UML's class box (1.5): a rectangle, its name compartment, then one compartment per member kind (classParts)
|
|
5962
|
+
size: n => classParts(n).size, out: boxOut(0), face: n => [n.y, n.y + n.h], inset: () => 0, draw: classSvg };
|
|
5963
|
+
CLASSIFIER.ports = (n, side, k = 1) => portsOn(CLASSIFIER, n, side, k);
|
|
5964
|
+
SHAPE.classifier = CLASSIFIER;
|
|
5965
|
+
|
|
5966
|
+
// A typed view's line (1.5; a map's lines never read it) says how it is drawn in e.end: { from, to, dashed, labels }. from
|
|
5967
|
+
// and to name an end in this table, the line's ends as UML draws them (none, or a name it does not hold, draws no marker);
|
|
5968
|
+
// dashed: a dependency's or realization's line; labels: each end's role and multiplicity (endLabels). Each end: [its
|
|
5969
|
+
// length, half its height, its path pointing right with the tip at its length, its fill]. Its tip is where the line ends,
|
|
5970
|
+
// on the node's outline. Hollow is the stage's background, filled the line's own colour (T2), in either theme
|
|
5971
|
+
const ENDS = { none: null, arrow: [10, 4, 'M0 0L10 4L0 8Z', 'line'], open: [10, 5, 'M0 0L10 5L0 10', 'none'], triangle: [13, 7, 'M0 0L13 7L0 14Z', 'hollow'],
|
|
5972
|
+
diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'] };
|
|
5973
|
+
const endOf = (E, w) => hasKey(ENDS, E[w]) && ENDS[E[w]] ? E[w] : null;
|
|
5974
|
+
|
|
5975
|
+
// a typed line's end labels (1.5): each end's role and multiplicity (e.end.labels: { from: { role, mult }, to: { role, mult } }),
|
|
5976
|
+
// each in a box reserved by its end. First choice: 4 px out from the outline along the line, 8 px to one side of it, clear of
|
|
5977
|
+
// the end's marker; the role above a line that leaves sideways and left of one that leaves up or down, the multiplicity on
|
|
5978
|
+
// the other side. A box that would meet a segment of its own line (7.5 px round it, so markers too), either end's node or
|
|
5979
|
+
// a box placed before it moves away from the line, 4 px at a time, on its side and then the other, at the end or centred
|
|
5980
|
+
// across it (a short end segment, a loop). pts: the line as drawn; e.s and e.t: its nodes. Layout (1.4) may take the boxes
|
|
5981
|
+
// as obstacles; it moves them apart where many lines meet (L5)
|
|
5982
|
+
function endLabels(e, pts = e.pts, also = []) { // also: boxes to keep clear of too (other lines, their labels: svgOf)
|
|
5983
|
+
const T = e.end && e.end.labels, out = []; if (!T || !pts || pts.length < 2) return out;
|
|
5984
|
+
const meets = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
5985
|
+
const keep = [...pts.slice(1).map((q, i) => { const p = pts[i]; return { x: Math.min(p[0], q[0]) - 7.5, y: Math.min(p[1], q[1]) - 7.5, w: Math.abs(q[0] - p[0]) + 15, h: Math.abs(q[1] - p[1]) + 15 }; }),
|
|
5986
|
+
...[e.s, e.t].filter(n => n && Number.isFinite(n.x)).map(n => ({ x: n.x - 2, y: n.y - 2, w: n.w + 4, h: n.h + 4 })), ...also];
|
|
5987
|
+
[['from', pts[0], pts[1]], ['to', pts[pts.length - 1], pts[pts.length - 2]]].forEach(([end, p, q]) => {
|
|
5988
|
+
const at = T[end] || {}, flat = Math.abs(q[0] - p[0]) >= Math.abs(q[1] - p[1]), dir = Math.sign(flat ? q[0] - p[0] : q[1] - p[1]) || 1;
|
|
5989
|
+
[['role', at.role, -1], ['mult', at.mult, 1]].forEach(([kind, t, side]) => { if (t == null || t === '') return;
|
|
5990
|
+
const w = r2(textW(t, 10) + 6), h = 14, along = (v, d, mid) => mid ? v - d / 2 : dir > 0 ? v + 4 : v - 4 - d, aside = (v, d, s, o) => s < 0 ? v - o - d : v + o;
|
|
5991
|
+
const box = (o, s, mid) => ({ end, kind, text: String(t), x: r2(flat ? along(p[0], w, mid) : aside(p[0], w, s, o)), y: r2(flat ? aside(p[1], h, s, o) : along(p[1], h, mid)), w, h });
|
|
5992
|
+
let b = null;
|
|
5993
|
+
for (let o = 8; !b && o <= 400; o += 4) for (const s of [side, -side]) for (const mid of [false, true]) { const c = box(o, s, mid); if (!b && ![...keep, ...out].some(k => meets(c, k))) b = c; }
|
|
5994
|
+
out.push(b || box(8, side, false)); }); });
|
|
5995
|
+
return out;
|
|
5996
|
+
}
|
|
5997
|
+
|
|
5998
|
+
// svgOf's typed lines (TYPED.lines): { id, edges, drawn, lstyle, MC } -> defs, the markers of the ends drawn; edge(e, i), a
|
|
5999
|
+
// line with ends: its style, its points trimmed for its flow, its markers; labels(i): its end labels' text
|
|
6000
|
+
TYPED.lines = ({ id, edges, drawn, lstyle, MC }) => {
|
|
6001
|
+
const ends = [...new Set(edges.flatMap(e => e.end ? ['from', 'to'].map(w => endOf(e.end, w)).filter(Boolean).map(k => k + '-' + lstyle(e).m) : []))];
|
|
6002
|
+
const endMark = km => { const k = km.slice(0, km.lastIndexOf('-')), m = km.slice(k.length + 1), [L, H, p, fl] = ENDS[k];
|
|
6003
|
+
return `<marker id="${id}-${km}" markerWidth="${L}" markerHeight="${2 * H}" refX="${L}" refY="${H}" orient="auto-start-reverse" markerUnits="userSpaceOnUse" overflow="visible"><path d="${p}" fill="${fl === 'line' ? MC[m] : fl === 'hollow' ? 'var(--um-stage-bg)' : 'none'}" stroke="${MC[m]}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></marker>`; };
|
|
6004
|
+
// a typed line's end labels keep clear of the other lines, their labels and the end labels placed before them (1.9, L5)
|
|
6005
|
+
const segs = drawn.map(p => p.slice(1).map((q, k) => ({ x: Math.min(p[k][0], q[0]) - 3, y: Math.min(p[k][1], q[1]) - 3, w: Math.abs(q[0] - p[k][0]) + 6, h: Math.abs(q[1] - p[k][1]) + 6 }))), placed = [];
|
|
6006
|
+
const endLabs = i => { const bs = endLabels(edges[i], drawn[i], [...placed, ...segs.flatMap((S, j) => j === i ? [] : [...S, ...(edges[j].lbl ? [edges[j].lbl] : [])])]); placed.push(...bs); return bs; };
|
|
6007
|
+
return {
|
|
6008
|
+
defs: ends.map(endMark).join(''),
|
|
6009
|
+
edge(e, i) { const E = e.end; if (!E) return null;
|
|
6010
|
+
const st = E.dashed ? { ...lstyle(e), dash: '6 4' } : lstyle(e), cut = w => endOf(E, w) ? ENDS[endOf(E, w)][0] : 0, at = w => endOf(E, w) ? `url(#${id}-${endOf(E, w)}-${st.m})` : '';
|
|
6011
|
+
return { st, pts: trimLine(drawn[i], cut('from'), cut('to')), marks: `${at('to') ? `marker-end="${at('to')}"` : ''} ${at('from') ? `marker-start="${at('from')}"` : ''}` }; },
|
|
6012
|
+
labels: i => edges[i].end ? endLabs(i).map(b => `<text x="${r2(b.x + b.w / 2)}" y="${r2(b.y + 10.5)}" text-anchor="middle" class="end-lab" data-i="${i}" data-end="${b.end}" data-box="${b.x} ${b.y} ${b.w} ${b.h}" fill="var(--um-muted)" style="font:500 10px var(--um-sans)">${esc(b.text)}</text>`).join('') : '',
|
|
6013
|
+
};
|
|
6014
|
+
};
|
|
6015
|
+
/* ---------- the class diagram, uml.class (work package 1.9) ----------
|
|
6016
|
+
The subset of proposal.md §8.2: class, interface, enumeration with literals, data type, primitive type; attributes and
|
|
6017
|
+
operations in UML's text form; stereotypes as text; packages as owners (drawn as a node until stage 2 draws ownership);
|
|
6018
|
+
association with both ends, generalization, interface realization, dependency; comments. A view whose `diagram` is
|
|
6019
|
+
'uml.class' is laid out by layoutTyped (src/typed/layout.js) and drawn with the classifier shape and the line-end table
|
|
6020
|
+
(src/typed/draw.js). Ships in lib/umlimator-all.js only (stage 1 note §2.5). */
|
|
6021
|
+
|
|
6022
|
+
const UML_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), UML_ARR = x => (Array.isArray(x) ? x : []);
|
|
6023
|
+
const UML_HAS = x => x != null && x !== '';
|
|
6024
|
+
const UML_OWN = (o, k) => (o != null && Object.hasOwn(o, k) ? o[k] : undefined); // a lookup by a model's id or type: 'constructor' is not Object's (risks.md M3)
|
|
6025
|
+
|
|
6026
|
+
/* ---- the member micro-syntax (risks.md E3, E4) ----
|
|
6027
|
+
A property: [visibility] [{static}] [/]name [: Type] [[multiplicity]] [= default] + {static} /total: Money [1] = 0
|
|
6028
|
+
An operation: [visibility] [{static}] [{abstract}] name([direction] name: Type, …) [: Type] + place(at: Date, in qty: Int): Order
|
|
6029
|
+
A literal: name
|
|
6030
|
+
A name is letters of any script, digits, _ and $; a type may end in [] (String[]). Visibility is + - # ~. A multiplicity is n, *, n..m or n..*, its text kept as written. parseMember() returns
|
|
6031
|
+
{ type, label, props } or { error }; printMember() writes a member back, and print(parse(x)) is stable. */
|
|
6032
|
+
const MEMBER_SIGN = { public: '+', private: '-', protected: '#', package: '~' }, SIGN_VIS = { '+': 'public', '-': 'private', '#': 'protected', '~': 'package' };
|
|
6033
|
+
const MEMBER_NAME = /^[\p{L}_$][\p{L}\p{M}\p{N}_$]*/u; // a name in any script: Größe, 名前
|
|
6034
|
+
const SYNTAX_KEYS = ['visibility', 'valueType', 'multiplicity', 'default', 'isStatic', 'isDerived', 'isAbstract', 'parameters', 'returnType'];
|
|
6035
|
+
|
|
6036
|
+
/* a multiplicity's bounds: '1', '*', '0..1', '1..*', '2..5' → { lower, upper } (upper Infinity for *); null for anything else */
|
|
6037
|
+
function multBounds(t) {
|
|
6038
|
+
const m = /^\s*(\d+|\*)\s*(?:\.\.\s*(\d+|\*)\s*)?$/.exec(String(t ?? ''));
|
|
6039
|
+
if (!m || (m[2] != null && m[1] === '*')) return null;
|
|
6040
|
+
const n = x => (x === '*' ? Infinity : +x);
|
|
6041
|
+
return m[2] == null ? (m[1] === '*' ? { lower: 0, upper: Infinity } : { lower: +m[1], upper: +m[1] }) : { lower: +m[1], upper: n(m[2]) };
|
|
6042
|
+
}
|
|
6043
|
+
|
|
6044
|
+
// s split at each `sep` outside brackets of any kind: "a: Map<K, V>, b" → ["a: Map<K, V>", " b"]
|
|
6045
|
+
function splitTop(s, sep) {
|
|
6046
|
+
const out = []; let depth = 0, from = 0;
|
|
6047
|
+
for (let i = 0; i < s.length; i++) { const c = s[i];
|
|
6048
|
+
if ('<([{'.includes(c)) depth++; else if ('>)]}'.includes(c)) depth--;
|
|
6049
|
+
else if (c === sep && depth === 0) { out.push(s.slice(from, i)); from = i + 1; } }
|
|
6050
|
+
out.push(s.slice(from));
|
|
6051
|
+
return out;
|
|
6052
|
+
}
|
|
6053
|
+
|
|
6054
|
+
/* text: a member's line; kind: the member type wanted ('uml.Property', 'uml.Operation', 'uml.EnumerationLiteral'), or
|
|
6055
|
+
none to read it from the text (an operation has brackets). Hand-written and linear (risks.md R2) */
|
|
6056
|
+
function parseMember(text, kind) {
|
|
6057
|
+
let s = String(text ?? '').trim();
|
|
6058
|
+
const fail = why => ({ error: `"${String(text ?? '').trim()}" is not a member: ${why}` });
|
|
6059
|
+
if (!s) return fail('it is empty');
|
|
6060
|
+
if (kind === 'uml.EnumerationLiteral') return MEMBER_NAME.test(s) && MEMBER_NAME.exec(s)[0] === s ? { type: kind, label: s, props: {} } : fail('a literal is a name');
|
|
6061
|
+
const props = {};
|
|
6062
|
+
if (SIGN_VIS[s[0]]) { props.visibility = SIGN_VIS[s[0]]; s = s.slice(1).trim(); }
|
|
6063
|
+
for (let m; (m = /^\{\s*(static|abstract)\s*\}\s*/.exec(s));) { props[m[1] === 'static' ? 'isStatic' : 'isAbstract'] = true; s = s.slice(m[0].length); }
|
|
6064
|
+
if (s[0] === '{') return fail('a modifier is {static} or {abstract}');
|
|
6065
|
+
if (s[0] === '/') { props.isDerived = true; s = s.slice(1).trim(); }
|
|
6066
|
+
const nm = MEMBER_NAME.exec(s); if (!nm) return fail('a name comes first, after the visibility (+ - # ~)');
|
|
6067
|
+
const label = nm[0]; s = s.slice(label.length).trim();
|
|
6068
|
+
const op = kind === 'uml.Operation' || (kind == null && s[0] === '(');
|
|
6069
|
+
if (op) {
|
|
6070
|
+
if (s[0] !== '(') return fail('an operation\'s name is followed by its parameters in brackets');
|
|
6071
|
+
if (props.isDerived) return fail('an operation is not derived (/)');
|
|
6072
|
+
let depth = 0, end = -1;
|
|
6073
|
+
for (let i = 0; i < s.length && end < 0; i++) { if (s[i] === '(') depth++; else if (s[i] === ')' && --depth === 0) end = i; }
|
|
6074
|
+
if (end < 0) return fail('a bracket is not closed');
|
|
6075
|
+
const inner = s.slice(1, end).trim(), rest = s.slice(end + 1).trim(), params = [];
|
|
6076
|
+
for (const part of inner ? splitTop(inner, ',') : []) {
|
|
6077
|
+
let p = part.trim(); const a = {}, d = /^(in|out|inout|return)\s+/.exec(p);
|
|
6078
|
+
if (d) { a.direction = d[1]; p = p.slice(d[0].length); }
|
|
6079
|
+
const pn = MEMBER_NAME.exec(p); if (!pn) return fail(`parameter "${part.trim()}" has no name`);
|
|
6080
|
+
a.name = pn[0]; p = p.slice(pn[0].length).trim();
|
|
6081
|
+
if (p) { if (p[0] !== ':' || !p.slice(1).trim()) return fail(`parameter "${part.trim()}" is name: Type`); a.valueType = p.slice(1).trim(); }
|
|
6082
|
+
params.push({ name: a.name, ...(a.valueType != null ? { valueType: a.valueType } : {}), ...(a.direction ? { direction: a.direction } : {}) });
|
|
6083
|
+
}
|
|
6084
|
+
if (params.length) props.parameters = params;
|
|
6085
|
+
if (rest) { if (rest[0] !== ':' || !rest.slice(1).trim()) return fail('after the parameters comes : ReturnType'); props.returnType = rest.slice(1).trim(); }
|
|
6086
|
+
return { type: 'uml.Operation', label, props };
|
|
6087
|
+
}
|
|
6088
|
+
if (props.isAbstract) return fail('a property is not {abstract}');
|
|
6089
|
+
if (s[0] === ':') { // the type: up to a multiplicity or a default outside brackets
|
|
6090
|
+
let i = 1, depth = 0;
|
|
6091
|
+
for (; i < s.length; i++) { const c = s[i]; if (c === '<' || c === '(') depth++; else if (c === '>' || c === ')') depth--; else if (depth === 0 && c === '[' && s[i + 1] === ']') i++; else if (depth === 0 && (c === '[' || c === '=')) break; } // String[]: an array type, not a multiplicity
|
|
6092
|
+
const t = s.slice(1, i).trim(); if (!t) return fail('a type follows the colon');
|
|
6093
|
+
props.valueType = t; s = s.slice(i).trim();
|
|
6094
|
+
}
|
|
6095
|
+
if (s[0] === '[') {
|
|
6096
|
+
const end = s.indexOf(']'); if (end < 0) return fail('a multiplicity is closed by ]');
|
|
6097
|
+
const mult = s.slice(1, end).trim(); if (!multBounds(mult)) return fail(`[${mult}] is not a multiplicity (1, *, 0..1, 1..*)`);
|
|
6098
|
+
props.multiplicity = mult; s = s.slice(end + 1).trim();
|
|
6099
|
+
}
|
|
6100
|
+
if (s[0] === '=') { const d = s.slice(1).trim(); if (!d) return fail('a default follows ='); props.default = d; s = ''; }
|
|
6101
|
+
if (s) return fail(`"${s}" is left over: write name: Type [multiplicity] = default`);
|
|
6102
|
+
return { type: 'uml.Property', label, props };
|
|
6103
|
+
}
|
|
6104
|
+
|
|
6105
|
+
/* a member back in its text form: the inverse of parseMember */
|
|
6106
|
+
function printMember(m) {
|
|
6107
|
+
const p = UML_OBJ(m && m.props) ? m.props : {}, label = String((m && m.label) ?? '');
|
|
6108
|
+
if (m && m.type === 'uml.EnumerationLiteral') return label;
|
|
6109
|
+
const head = (Object.hasOwn(MEMBER_SIGN, p.visibility) ? MEMBER_SIGN[p.visibility] + ' ' : '') + (p.isStatic ? '{static} ' : '') + (p.isAbstract ? '{abstract} ' : '');
|
|
6110
|
+
if (m && m.type === 'uml.Operation') return head + label + '(' + UML_ARR(p.parameters).filter(UML_OBJ).map(a => (UML_HAS(a.direction) ? a.direction + ' ' : '') + (a.name ?? '') + (UML_HAS(a.valueType) ? ': ' + a.valueType : '')).join(', ') + ')' + (UML_HAS(p.returnType) ? ': ' + p.returnType : '');
|
|
6111
|
+
return head + (p.isDerived ? '/' : '') + label + (UML_HAS(p.valueType) ? ': ' + p.valueType : '') + (UML_HAS(p.multiplicity) ? ` [${p.multiplicity}]` : '') + (UML_HAS(p.default) ? ' = ' + p.default : '');
|
|
6112
|
+
}
|
|
6113
|
+
|
|
6114
|
+
/* a member edited as text: the commands that set it, one transaction; { error } when the text does not parse, and then
|
|
6115
|
+
nothing is applied (E3). Props the syntax does not write (stereotypes, a host's own) are kept */
|
|
6116
|
+
function memberEdit(m, text) {
|
|
6117
|
+
const r = parseMember(text, m.type === 'uml.Property' || m.type === 'uml.Operation' || m.type === 'uml.EnumerationLiteral' ? m.type : undefined);
|
|
6118
|
+
if (r.error) return r;
|
|
6119
|
+
const keep = Object.fromEntries(Object.entries(UML_OBJ(m.props) ? m.props : {}).filter(([k]) => !SYNTAX_KEYS.includes(k)));
|
|
6120
|
+
return { commands: [{ op: 'set', id: m.id, path: 'label', value: r.label }, { op: 'set', id: m.id, path: 'props', value: { ...keep, ...r.props } }] };
|
|
6121
|
+
}
|
|
6122
|
+
|
|
6123
|
+
/* a line typed into an owner's "New member": on an enumeration a bare name is a literal, anything else a property or an operation */
|
|
6124
|
+
function memberAdd(text, owner) {
|
|
6125
|
+
const t = String(text ?? '').trim(), nm = MEMBER_NAME.exec(t);
|
|
6126
|
+
return parseMember(t, owner && owner.type === 'uml.Enumeration' && nm && nm[0] === t ? 'uml.EnumerationLiteral' : undefined);
|
|
6127
|
+
}
|
|
6128
|
+
|
|
6129
|
+
/* ---- the vocabulary (model.md, uml.class) ---- */
|
|
6130
|
+
const CLASSIFIERS = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType'];
|
|
6131
|
+
const IMPLIED = { 'uml.Interface': 'interface', 'uml.Enumeration': 'enumeration', 'uml.DataType': 'dataType', 'uml.PrimitiveType': 'primitive' }; // shown «…» over the name
|
|
6132
|
+
const STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
6133
|
+
const endFields = w => [['role', 'text'], ['multiplicity', 'text'], ['aggregation', 'enum', ['none', 'shared', 'composite']], ['navigable', 'bool']]
|
|
6134
|
+
.map(([k, kind, options]) => ({ key: `ends.${w}.${k}`, path: ['ends', w, k], label: `${w === 'from' ? 'From' : 'To'} ${k}`, kind, ...(options ? { options } : {}) }));
|
|
6135
|
+
const CLASS_DEF = {
|
|
6136
|
+
title: 'class diagram',
|
|
6137
|
+
elements: [['Class', 'Class'], ['Interface', 'Interface'], ['Enumeration', 'Enumeration'], ['DataType', 'Data type'], ['PrimitiveType', 'Primitive type'], ['Package', 'Package'], ['Comment', 'Comment']]
|
|
6138
|
+
.map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
6139
|
+
lines: [['Association', 'Association'], ['Generalization', 'Generalization'], ['InterfaceRealization', 'Realization'], ['Dependency', 'Dependency'], ['CommentLink', 'Comment link']]
|
|
6140
|
+
.map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
6141
|
+
props: {
|
|
6142
|
+
'uml.Class': [{ key: 'isAbstract', label: 'Abstract', kind: 'bool' }, STEREO],
|
|
6143
|
+
'uml.Interface': [STEREO], 'uml.Enumeration': [STEREO], 'uml.DataType': [STEREO], 'uml.PrimitiveType': [STEREO], 'uml.Package': [STEREO],
|
|
6144
|
+
'uml.Association': [...endFields('from'), ...endFields('to'), STEREO],
|
|
6145
|
+
'uml.Dependency': [STEREO], 'uml.Generalization': [], 'uml.InterfaceRealization': [], 'uml.CommentLink': [],
|
|
6146
|
+
},
|
|
6147
|
+
};
|
|
6148
|
+
|
|
6149
|
+
/* ---- drawing input: the classifier's (src/typed/draw.js draws it) and the note's ---- */
|
|
6150
|
+
const COMPARTMENTS = { attributes: 'uml.Property', operations: 'uml.Operation', literals: 'uml.EnumerationLiteral' };
|
|
6151
|
+
function compartmentsOf(n) { // a class's attributes and operations; an enumeration's literals, then the others it has
|
|
6152
|
+
const c = name => ({ name, members: UML_ARR(n.members).filter(m => UML_OBJ(m) && m.type === COMPARTMENTS[name]) });
|
|
6153
|
+
if (n.type === 'uml.Package' || n.type === 'uml.Comment') return [];
|
|
6154
|
+
return n.type === 'uml.Enumeration' ? [c('literals'), ...[c('attributes'), c('operations')].filter(x => x.members.length)] : [c('attributes'), c('operations')];
|
|
6155
|
+
}
|
|
6156
|
+
|
|
6157
|
+
// a comment: a note with a folded corner, its text wrapped at 220 px
|
|
6158
|
+
const NOTE = { pad: 8, px: 11, lh: 15, max: 220, fold: 10 };
|
|
6159
|
+
function noteLines(t) {
|
|
6160
|
+
const out = []; let line = '';
|
|
6161
|
+
String(t ?? '').split(/\s+/).filter(Boolean).forEach(w => { const next = line ? line + ' ' + w : w;
|
|
6162
|
+
if (line && textW(next, NOTE.px) > NOTE.max) { out.push(line); line = w; } else line = next; });
|
|
6163
|
+
if (line) out.push(line);
|
|
6164
|
+
return out.length ? out : [''];
|
|
6165
|
+
}
|
|
6166
|
+
SHAPE.note = {
|
|
6167
|
+
size: n => { const ls = noteLines(n.label); return { w: Math.ceil(Math.max(60, ...ls.map(l => Math.min(NOTE.max, textW(l, NOTE.px)))) + 2 * NOTE.pad + NOTE.fold), h: ls.length * NOTE.lh + 2 * NOTE.pad }; },
|
|
6168
|
+
out: SHAPE.classifier.out, face: n => [n.y, n.y + n.h], inset: () => 0, ports: SHAPE.classifier.ports,
|
|
6169
|
+
draw(n, c, f) {
|
|
6170
|
+
const { x, y, w, h } = n, k = NOTE.fold, ls = noteLines(n.label), max = w - 2 * NOTE.pad - k;
|
|
6171
|
+
return `<g class="node note" data-id="${esc(n.id)}" data-shape="note" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Comment: ' + (n.label ?? ''))}">`
|
|
6172
|
+
+ `<path d="M${x} ${y}H${x + w - k}L${x + w} ${y + k}V${y + h}H${x}Z" class="n-mask"/><path d="M${x} ${y}H${x + w - k}L${x + w} ${y + k}V${y + h}H${x}Z" class="n-body" fill="${f}" stroke="${c}"/>`
|
|
6173
|
+
+ `<path d="M${x + w - k} ${y}V${y + k}H${x + w}" class="n-det" stroke="${c}"/>`
|
|
6174
|
+
+ `<g class="c-head" data-band="${r2(y)} ${r2(y + h)}">${ls.map((l, i) => `<text x="${r2(x + NOTE.pad)}" y="${r2(y + NOTE.pad + i * NOTE.lh + 11)}" class="c-row" fill="var(--um-text)" style="font:400 ${NOTE.px}px var(--um-sans)">${esc(fit(l, NOTE.px, max))}</text>`).join('')}</g></g>`;
|
|
6175
|
+
},
|
|
6176
|
+
};
|
|
6177
|
+
|
|
6178
|
+
/* ---- lines: how each kind is drawn (src/typed/draw.js's ENDS), and its words ---- */
|
|
6179
|
+
const endOfLine = (e, w) => { const at = ((UML_OBJ(e.props) && UML_OBJ(e.props.ends) ? e.props.ends : {})[w]) || {};
|
|
6180
|
+
return at.aggregation === 'composite' ? 'filled-diamond' : at.aggregation === 'shared' ? 'diamond' : at.navigable ? 'open' : 'none'; };
|
|
6181
|
+
const endsOf = e => (UML_OBJ(e.props) && UML_OBJ(e.props.ends) ? e.props.ends : {});
|
|
6182
|
+
function lineLook(e) {
|
|
6183
|
+
switch (e.type) {
|
|
6184
|
+
case 'uml.Generalization': return { from: 'none', to: 'triangle' };
|
|
6185
|
+
case 'uml.InterfaceRealization': return { from: 'none', to: 'triangle', dashed: true };
|
|
6186
|
+
case 'uml.Dependency': return { from: 'none', to: 'open', dashed: true };
|
|
6187
|
+
case 'uml.CommentLink': return { from: 'none', to: 'none', dashed: true };
|
|
6188
|
+
case 'uml.Association': { const E = endsOf(e), lab = w => ({ role: (E[w] || {}).role, mult: (E[w] || {}).multiplicity });
|
|
6189
|
+
return { from: endOfLine(e, 'from'), to: endOfLine(e, 'to'), labels: { from: lab('from'), to: lab('to') } }; }
|
|
6190
|
+
default: return { from: 'none', to: 'arrow' };
|
|
6191
|
+
}
|
|
6192
|
+
}
|
|
6193
|
+
const stereoText = x => UML_ARR(UML_OBJ(x.props) ? x.props.stereotypes : null).filter(UML_HAS).map(t => `«${t}»`).join(' ');
|
|
6194
|
+
const lineLabel = e => [stereoText(e), e.label].filter(UML_HAS).join(' ');
|
|
6195
|
+
const LINE_WORDS = { 'uml.Association': ['Association', 'a link between instances'], 'uml.Generalization': ['Generalization', 'is a kind of the one it points to'],
|
|
6196
|
+
'uml.InterfaceRealization': ['Realization', 'implements the interface it points to'], 'uml.Dependency': ['Dependency', 'uses the one it points to'],
|
|
6197
|
+
'uml.CommentLink': ['Comment link', 'what a comment is about'] };
|
|
6198
|
+
const LEGEND_ENDS = { // small copies of src/typed/draw.js's ends, pointing right with the tip at x 26
|
|
6199
|
+
none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', triangle: '<path d="M16 3L26 8L16 13Z" fill="var(--um-panel)"/>',
|
|
6200
|
+
diamond: '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-panel)"/>', 'filled-diamond': '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-muted)"/>' };
|
|
6201
|
+
const legendItem = (end, dashed, name, says) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8H${end === 'none' ? 26 : end.includes('diamond') ? 12 : 17}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${LEGEND_ENDS[end]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
6202
|
+
// the legend of a view's lines (pieces.js shows it): each kind drawn, then the association's ends it shows
|
|
6203
|
+
function legendOf(edges) {
|
|
6204
|
+
const types = new Set(edges.map(e => e.type)), ends = new Set(edges.filter(e => e.type === 'uml.Association').flatMap(e => [e.end.from, e.end.to]));
|
|
6205
|
+
return [...Object.keys(LINE_WORDS).filter(t => types.has(t)).map(t => { const L = lineLook({ type: t }), [name, says] = LINE_WORDS[t];
|
|
6206
|
+
return legendItem(t === 'uml.Association' ? 'none' : L.to, !!L.dashed, name, says); }),
|
|
6207
|
+
...[['filled-diamond', 'Composition', 'the whole owns its parts'], ['diamond', 'Aggregation', 'the whole holds its parts'], ['open', 'Navigable end', 'reachable from the other end']]
|
|
6208
|
+
.filter(([k]) => ends.has(k)).map(([k, name, says]) => legendItem(k, false, name, says))];
|
|
6209
|
+
}
|
|
6210
|
+
|
|
6211
|
+
/* ---- the view laid out: the model's elements as layoutTyped's input ---- */
|
|
6212
|
+
// every element by id, with its owner for a member
|
|
6213
|
+
function classIndex(model) {
|
|
6214
|
+
const at = new Map(), put = (x, kind, owner, path) => { if (!UML_OBJ(x) || typeof x.id !== 'string' || at.has(x.id)) return;
|
|
6215
|
+
at.set(x.id, { x, kind, owner, path }); UML_ARR(x.members).forEach((y, j) => put(y, 'member', x, `${path}.members[${j}]`)); };
|
|
6216
|
+
UML_ARR(model.nodes).forEach((n, i) => put(n, 'node', null, `nodes[${i}]`));
|
|
6217
|
+
UML_ARR(model.edges).forEach((e, i) => put(e, 'edge', null, `edges[${i}]`));
|
|
6218
|
+
return at;
|
|
6219
|
+
}
|
|
6220
|
+
const typeWord = t => String(t || 'uml.Class').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
|
|
6221
|
+
const nameOfEl = x => String(x.label ?? x.id);
|
|
6222
|
+
const plural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
6223
|
+
// an element's accessible name: "Class Order (entity, abstract), 5 attributes, 3 operations"
|
|
6224
|
+
function nodeName(n) {
|
|
6225
|
+
if (n.type === 'uml.Comment') return 'Comment: ' + nameOfEl(n);
|
|
6226
|
+
const p = UML_OBJ(n.props) ? n.props : {}, why = [...UML_ARR(p.stereotypes).filter(UML_HAS), ...(p.isAbstract ? ['abstract'] : [])];
|
|
6227
|
+
const counts = compartmentsOf(n).map(c => plural(c.members.length, c.name.replace(/s$/, '')));
|
|
6228
|
+
return [`${typeWord(n.type)} ${nameOfEl(n)}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
|
|
6229
|
+
}
|
|
6230
|
+
function lineName(e, at) {
|
|
6231
|
+
const nm = id => { const r = at.get(id); return r ? nameOfEl(r.owner || r.x) + (r.owner ? '.' + nameOfEl(r.x) : '') : id; };
|
|
6232
|
+
const [word] = UML_OWN(LINE_WORDS, e.type) || ['Line'], said = lineLabel(e), E = endsOf(e);
|
|
6233
|
+
const end = w => { const t = [(E[w] || {}).role, (E[w] || {}).multiplicity].filter(UML_HAS); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
6234
|
+
return `${word} from ${nm(e.from)}${end('from')} to ${nm(e.to)}${end('to')}${said ? ': ' + said : ''}`;
|
|
6235
|
+
}
|
|
6236
|
+
|
|
6237
|
+
/* the class view's geometry: { model, view, pos (col, row by id, the layered layout's ranking or the view's pins), memo,
|
|
6238
|
+
direction }. A line that ends on a member is drawn to the member's owner; a line between two nodes the view does not
|
|
6239
|
+
both show is not drawn */
|
|
6240
|
+
function classGeo({ model, view, pos, memo }) {
|
|
6241
|
+
const at = classIndex(model), present = UML_OBJ(view.present) ? view.present : {}, ids = UML_ARR(view.nodeIds).filter(id => (at.get(id) || {}).kind === 'node');
|
|
6242
|
+
const shown = new Set(ids), top = id => { const r = at.get(id); return r && r.owner ? top(r.owner.id) : id; };
|
|
6243
|
+
const nodes = ids.map(id => { const n = at.get(id).x, pr = UML_OBJ(UML_OWN(present, id)) ? present[id] : {}, p = UML_OBJ(n.props) ? n.props : {};
|
|
6244
|
+
const look = n.type === 'uml.Comment' ? { shape: 'note' } : { shape: 'classifier', stereotypes: [...(UML_OWN(IMPLIED, n.type) ? [IMPLIED[n.type]] : []), ...UML_ARR(p.stereotypes).filter(UML_HAS)],
|
|
6245
|
+
abstract: !!p.isAbstract, compartments: compartmentsOf(n), collapsed: UML_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
|
|
6246
|
+
const d = { id, label: nameOfEl(n), type: n.type || 'uml.Class', kind: 'file', ...look, aria: nodeName(n), col: (UML_OWN(pos, id) || [0, 0])[0], row: (UML_OWN(pos, id) || [0, 0])[1] };
|
|
6247
|
+
return { ...d, ...SHAPE[d.shape].size(d) }; });
|
|
6248
|
+
const list = Array.isArray(view.edges) ? view.edges : UML_ARR(model.edges);
|
|
6249
|
+
const edges = list.map(e => (typeof e === 'string' ? (at.get(e) || {}).x : e)).filter(e => UML_OBJ(e) && typeof e.id === 'string')
|
|
6250
|
+
.map(e => ({ e, from: top(e.from), to: top(e.to) })).filter(({ from, to }) => shown.has(from) && shown.has(to))
|
|
6251
|
+
.map(({ e, from, to }) => ({ id: e.id, _id: e.id, type: e.type, from, to, label: lineLabel(e) || undefined, end: lineLook(e), aria: lineName(e, at), ...(UML_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }));
|
|
6252
|
+
const g = layoutTyped({ nodes, edges, direction: view.direction === 'down' ? 'down' : 'right', gap: 96, rgap: 56 }, memo);
|
|
6253
|
+
g.legend = legendOf(g.edges);
|
|
6254
|
+
return g;
|
|
6255
|
+
}
|
|
6256
|
+
|
|
6257
|
+
/* ---- validators (proposal.md §8.3). Each problem: { level, rule, path, message, ids }; the message names the rule and
|
|
6258
|
+
the elements. Ownership cycles and references that do not resolve are the core's checks (src/core/model.js) ---- */
|
|
6259
|
+
const RULES = {
|
|
6260
|
+
'member-names': 'a classifier\'s members have different names',
|
|
6261
|
+
'classifier-names': 'classifiers in one owner have different names',
|
|
6262
|
+
multiplicity: 'a multiplicity is n, *, n..m or n..*, its lower bound not above its upper',
|
|
6263
|
+
'generalization-cycle': 'nothing is its own ancestor',
|
|
6264
|
+
'generalization-kinds': 'a generalization joins two classifiers of one kind (not a package, a comment or a member)',
|
|
6265
|
+
'realization-target': 'an interface realization runs from a classifier to an interface',
|
|
6266
|
+
'interface-composite': 'an interface owns no part by composition',
|
|
6267
|
+
'composite-multiplicity': 'a composite end\'s multiplicity is at most 1',
|
|
6268
|
+
'member-kinds': 'literals belong to enumerations; packages and comments have no members',
|
|
6269
|
+
'comment-link': 'a comment link runs from a comment',
|
|
6270
|
+
};
|
|
6271
|
+
function classProblems(model) {
|
|
6272
|
+
const out = [], at = classIndex(UML_OBJ(model) ? model : {}), q = id => JSON.stringify(nameOfEl((at.get(id) || { x: { id } }).x));
|
|
6273
|
+
const say = (rule, path, ids, msg, level = 'error') => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` }); // a line with no end names what it has
|
|
6274
|
+
const els = [...at.values()], nodes = els.filter(r => r.kind === 'node' && String(r.x.type || '').startsWith('uml.')), lines = els.filter(r => r.kind === 'edge' && String(r.x.type || '').startsWith('uml.'));
|
|
6275
|
+
const typeOfId = id => (at.get(id) || { x: {} }).x.type, isCls = id => CLASSIFIERS.includes(typeOfId(id));
|
|
6276
|
+
nodes.forEach(({ x, path }) => {
|
|
6277
|
+
const ms = UML_ARR(x.members).filter(UML_OBJ), seen = new Map();
|
|
6278
|
+
ms.forEach((m, j) => { // an operation is told apart by its parameters' types too (overloading)
|
|
6279
|
+
const k = m.type === 'uml.Operation' ? `${m.label}(${UML_ARR((m.props || {}).parameters).map(a => UML_OBJ(a) ? a.valueType ?? '' : '').join(',')})` : `${m.label}`;
|
|
6280
|
+
if (seen.has(k)) say('member-names', `${path}.members[${j}]`, [x.id, seen.get(k), m.id], `${q(x.id)} has two members named "${m.label}" (${seen.get(k)}, ${m.id})`); else seen.set(k, m.id);
|
|
6281
|
+
if (m.type === 'uml.EnumerationLiteral' && x.type !== 'uml.Enumeration') say('member-kinds', `${path}.members[${j}]`, [x.id, m.id], `literal "${m.label}" is in ${q(x.id)}, which is not an enumeration`);
|
|
6282
|
+
const mult = (m.props || {}).multiplicity; if (UML_HAS(mult)) multRule(String(mult), `${path}.members[${j}].props.multiplicity`, [m.id], `${q(x.id)}.${m.label}`); });
|
|
6283
|
+
if ((x.type === 'uml.Package' || x.type === 'uml.Comment') && ms.length) say('member-kinds', `${path}.members`, [x.id], `${q(x.id)} is a ${typeWord(x.type).toLowerCase()}, which has no members`);
|
|
6284
|
+
});
|
|
6285
|
+
function multRule(t, path, ids, what) { const b = multBounds(t);
|
|
6286
|
+
if (!b) say('multiplicity', path, ids, `${what}: [${t}] is not a multiplicity (1, *, 0..1, 1..*)`);
|
|
6287
|
+
else if (b.lower > b.upper) say('multiplicity', path, ids, `${what}: [${t}] has its lower bound above its upper`);
|
|
6288
|
+
return b; }
|
|
6289
|
+
const byOwner = new Map(); nodes.filter(r => CLASSIFIERS.includes(r.x.type)).forEach(({ x, path }) => { const k = (x.parent ?? '') + '\u0000' + nameOfEl(x), was = byOwner.get(k);
|
|
6290
|
+
if (was) say('classifier-names', path, [was, x.id], `${q(x.id)} is named twice in ${x.parent != null ? q(x.parent) : 'the model'} (${was}, ${x.id})`); else byOwner.set(k, x.id); });
|
|
6291
|
+
const parents = new Map();
|
|
6292
|
+
lines.forEach(({ x: e, path }) => {
|
|
6293
|
+
const E = endsOf(e), ends = [e.from, e.to];
|
|
6294
|
+
if (e.type === 'uml.Association') ['from', 'to'].forEach(w => { const at2 = UML_OBJ(E[w]) ? E[w] : {}, who = e[w];
|
|
6295
|
+
const b = UML_HAS(at2.multiplicity) ? multRule(String(at2.multiplicity), `${path}.props.ends.${w}.multiplicity`, [e.id], `the ${w} end of ${lineName(e, at)}`) : null;
|
|
6296
|
+
if (at2.aggregation === 'composite') {
|
|
6297
|
+
if (typeOfId(who) === 'uml.Interface') say('interface-composite', `${path}.props.ends.${w}.aggregation`, [e.id, who], `interface ${q(who)} owns a part by composition (${e.id})`);
|
|
6298
|
+
if (b && b.upper > 1) say('composite-multiplicity', `${path}.props.ends.${w}.multiplicity`, [e.id, who], `the composite end${who != null ? ' at ' + q(who) : ''} has multiplicity [${at2.multiplicity}], more than 1 (${e.id})`); } });
|
|
6299
|
+
if (e.type === 'uml.Generalization') {
|
|
6300
|
+
if (at.has(e.from) && at.has(e.to) && (!isCls(e.from) || !isCls(e.to) || typeOfId(e.from) !== typeOfId(e.to))) say('generalization-kinds', path, [e.id, ...ends], `${typeWord(typeOfId(e.from)).toLowerCase()} ${q(e.from)} specializes ${typeWord(typeOfId(e.to)).toLowerCase()} ${q(e.to)} (${e.id})`);
|
|
6301
|
+
if (!parents.has(e.from)) parents.set(e.from, []); parents.get(e.from).push([e.to, path, e.id]); }
|
|
6302
|
+
if (e.type === 'uml.InterfaceRealization' && (typeOfId(e.to) !== 'uml.Interface' || !isCls(e.from) || typeOfId(e.from) === 'uml.Interface'))
|
|
6303
|
+
say('realization-target', path, [e.id, ...ends], `${e.id} runs from ${q(e.from)} to ${q(e.to)}: from a class, enumeration or data type to an interface`);
|
|
6304
|
+
if (e.type === 'uml.CommentLink' && typeOfId(e.from) !== 'uml.Comment') say('comment-link', path, [e.id, e.from], `${e.id} runs from ${q(e.from)}, which is not a comment`);
|
|
6305
|
+
});
|
|
6306
|
+
// a generalization cycle: said once, at the line that closes it, naming every element on it
|
|
6307
|
+
const state = new Map(), stack = [];
|
|
6308
|
+
const walk = id => { state.set(id, 1); stack.push(id);
|
|
6309
|
+
for (const [p, path, eid] of parents.get(id) || []) {
|
|
6310
|
+
if (state.get(p) === 1) { const loop = stack.slice(stack.indexOf(p)); say('generalization-cycle', path, [...loop, eid], `${loop.map(q).join(' → ')} → ${q(p)} is its own ancestor (${eid})`); }
|
|
6311
|
+
else if (!state.has(p)) walk(p); }
|
|
6312
|
+
stack.pop(); state.set(id, 2); };
|
|
6313
|
+
[...parents.keys()].forEach(id => { if (!state.has(id)) walk(id); });
|
|
6314
|
+
return out;
|
|
6315
|
+
}
|
|
6316
|
+
|
|
6317
|
+
/* ---- describe(): the view in words, per element: "Class Order (entity), 5 attributes, 3 operations; associated with
|
|
6318
|
+
OrderLine (lines, 1..*)" ---- */
|
|
6319
|
+
function classDescribe(model, viewId) {
|
|
6320
|
+
const v = UML_ARR(model && model.views).find(x => UML_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
6321
|
+
const at = classIndex(model), top = id => { const r = at.get(id); return r && r.owner ? top(r.owner.id) : id; };
|
|
6322
|
+
const ids = UML_ARR(v.nodeIds).filter(id => (at.get(id) || {}).kind === 'node'), shown = new Set(ids), nm = id => nameOfEl(at.get(id).x);
|
|
6323
|
+
const lines = (Array.isArray(v.edges) ? v.edges : UML_ARR(model.edges)).map(e => (typeof e === 'string' ? (at.get(e) || {}).x : e))
|
|
6324
|
+
.filter(e => UML_OBJ(e) && shown.has(top(e.from)) && shown.has(top(e.to)));
|
|
6325
|
+
const end = (e, w) => { const E = endsOf(e)[w] || {}, t = [E.role, E.multiplicity].filter(UML_HAS); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
6326
|
+
const out = [`${v.title || v.id}: a class diagram of ${plural(ids.length, 'element')} and ${plural(lines.length, 'line')}.`];
|
|
6327
|
+
ids.forEach(id => { const said = [];
|
|
6328
|
+
lines.forEach(e => { const f = top(e.from), t = top(e.to), E = endsOf(e);
|
|
6329
|
+
if (e.type === 'uml.Association' && (f === id || t === id)) { const [me, far] = f === id ? ['from', 'to'] : ['to', 'from'], agg = (E[me] || {}).aggregation, whole = (E[far] || {}).aggregation;
|
|
6330
|
+
const verb = agg === 'composite' ? 'composed of' : agg === 'shared' ? 'aggregates' : whole === 'composite' ? 'part of' : whole === 'shared' ? 'held by' : 'associated with';
|
|
6331
|
+
said.push(`${verb} ${nm(far === 'to' ? t : f)}${end(e, far)}${UML_HAS(e.label) ? ` ("${e.label}")` : ''}`); }
|
|
6332
|
+
else if (f === id && e.type === 'uml.Generalization') said.push(`a kind of ${nm(t)}`);
|
|
6333
|
+
else if (f === id && e.type === 'uml.InterfaceRealization') said.push(`realizes ${nm(t)}`);
|
|
6334
|
+
else if (f === id && e.type === 'uml.Dependency') said.push(`depends on ${nm(t)}${stereoText(e) ? ' ' + stereoText(e) : ''}`);
|
|
6335
|
+
else if (f === id && e.type === 'uml.CommentLink') said.push(`about ${nm(t)}`); });
|
|
6336
|
+
out.push(nodeName(at.get(id).x) + (said.length ? '; ' + said.join('; ') : '') + '.'); });
|
|
6337
|
+
return out.join('\n');
|
|
6338
|
+
}
|
|
6339
|
+
|
|
6340
|
+
/* ---- the type ---- */
|
|
6341
|
+
const CLASS_TYPE = registerDiagramType('uml.class', {
|
|
6342
|
+
...CLASS_DEF,
|
|
6343
|
+
// the layered layout ranks the view's lines as maps are ranked, a generalization and a realization turned round so the
|
|
6344
|
+
// general one comes first: above its specializations in a view laid out down (risks.md L6)
|
|
6345
|
+
rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.InterfaceRealization',
|
|
6346
|
+
layout: classGeo,
|
|
6347
|
+
member: { print: printMember, edit: memberEdit, add: memberAdd, holds: n => compartmentsOf(n).length > 0 }, // holds: the panel offers members (not on a comment or a package)
|
|
6348
|
+
validate: classProblems,
|
|
6349
|
+
rules: RULES,
|
|
6350
|
+
describe: classDescribe,
|
|
6351
|
+
});
|
|
6352
|
+
/* ---------- Mermaid classDiagram: a reader and a writer (plan.md 1.10; risks.md R1 to R4, R7) ----------
|
|
6353
|
+
Written against Mermaid 12.3.0's class diagram page (docs/syntax/classDiagram.md, read 11 Nov 2026). Internal: the CLI
|
|
6354
|
+
(`umlimator.py import`) imports this file through Node; a host gets it once the registry is public.
|
|
6355
|
+
|
|
6356
|
+
readMermaidClass(text) -> { model, report }: a version-2 model with one `uml.class` view, in docs/diagrams/model.md's
|
|
6357
|
+
vocabulary, and a report of what was not read as written, each item { line, status, message }, status `skipped` or
|
|
6358
|
+
`approximated` (R1). The reader is hand-written and linear, and refuses text over MC_LIMIT characters (R2). Ids come
|
|
6359
|
+
from the source's names (a class's, a namespace's; a member is <class>.<name>, a relation <from>-<to>, a note
|
|
6360
|
+
note-<n>; a repeated one -2, -3 ...; a name with "->" has it as "-", reported), so the same source always gives the
|
|
6361
|
+
same ids (R3). It never throws.
|
|
6362
|
+
|
|
6363
|
+
writeMermaidClass(model, viewId?) -> text: one class view as Mermaid. What Mermaid cannot carry is kept in its comments:
|
|
6364
|
+
an id the reader would not derive, as `%% id: <id>` (and `%% link-id:` for a note's line) above its statement; an
|
|
6365
|
+
element the subset leaves out, as `%% not written: <id> (<type>)`, and a property of one Mermaid has no place for, as
|
|
6366
|
+
`%% not written: <id>: <what>` (navigable on an end with a diamond, a class's parent that is not a namespace, an
|
|
6367
|
+
unknown visibility, a stereotype the reader would read as something else). A member whose text has a line break is not
|
|
6368
|
+
written, nor a line that ends on a member. A model with views is written through its uml.class view; with none of them,
|
|
6369
|
+
the text is one `%% not written:` line. A view's layout and present are not written.
|
|
6370
|
+
|
|
6371
|
+
How UML maps onto Mermaid's text, both ways:
|
|
6372
|
+
- classes: `<<interface>>`, `<<enumeration>>`, `<<dataType>>`, `<<primitive>>` pick the type, `<<abstract>>` sets
|
|
6373
|
+
isAbstract; any other annotation is a stereotype;
|
|
6374
|
+
- attributes: `+name : Type[0..1] = default`, `/` for derived, a closing `$` for static; Mermaid's own `Type name`
|
|
6375
|
+
is read too. Operations: `+name(p : T, out q : U) Return`, a closing `*` abstract, `$` static. `List~int~` is
|
|
6376
|
+
List<int>. In an enumeration a bare name is a literal;
|
|
6377
|
+
- relations: `--` an association (`*`, `o` its aggregation, `<`, `>` its navigability, a quoted cardinality
|
|
6378
|
+
"role multiplicity" by each end), `--|>` a generalization, `..|>` an interface realization, `..>` a dependency,
|
|
6379
|
+
its label `«stereotype» text` after `:`. A relation runs left to right, so a child is on the left of `--|>`;
|
|
6380
|
+
- what `..|>` and a lollipop point to is an interface, reported when it is not declared one; Mermaid's `n` and `many`
|
|
6381
|
+
are the multiplicity *, reported;
|
|
6382
|
+
- a member named twice in one class (an operation with the same parameters' types) is read once, the other reported;
|
|
6383
|
+
an interface that realizes another specializes it, reported;
|
|
6384
|
+
- namespaces are packages, notes comments, `note for` a comment with its line; `direction TB` is down (the default),
|
|
6385
|
+
`LR` right; a %%{ ... }%% directive, on one line or several, is passed over. */
|
|
6386
|
+
const MC_LIMIT = 1 << 20; // characters; a class diagram a person writes is a few kilobytes
|
|
6387
|
+
const mcTable = o => Object.assign(Object.create(null), o); // a lookup table: "constructor" or "__proto__" finds nothing in it
|
|
6388
|
+
const MC_VIS = mcTable({ '+': 'public', '-': 'private', '#': 'protected', '~': 'package' });
|
|
6389
|
+
const MC_TYPES = mcTable({ interface: 'uml.Interface', enumeration: 'uml.Enumeration', enum: 'uml.Enumeration', datatype: 'uml.DataType', primitive: 'uml.PrimitiveType', primitivetype: 'uml.PrimitiveType' });
|
|
6390
|
+
const MC_VIEW = 'class-diagram'; // the view's id when the source does not name it
|
|
6391
|
+
const mcWord = c => c != null && /[\p{L}\p{N}_]/u.test(c);
|
|
6392
|
+
const mcId = name => String(name).replace(/->+/g, '-'); // an id never holds "->", which names a map's lines by position ("->>" too)
|
|
6393
|
+
|
|
6394
|
+
// the first of base, base-2, base-3 ... not in used; where it stopped is kept in next, so asking for one base again
|
|
6395
|
+
// resumes there and n repeats of a name cost n steps, not n * n (R2)
|
|
6396
|
+
function mcFree(used, next, base) {
|
|
6397
|
+
let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
|
|
6398
|
+
while (used.has(id)) id = `${base}-${++k}`;
|
|
6399
|
+
next.set(base, k);
|
|
6400
|
+
return id;
|
|
6401
|
+
}
|
|
6402
|
+
|
|
6403
|
+
// a multiplicity as UML writes it, Mermaid's n and many read as *; null when the text is not one, or its lower bound is
|
|
6404
|
+
// above its upper
|
|
6405
|
+
function mcBounds(t) {
|
|
6406
|
+
const m = /^(\d+|\*|n|many)(?:\.\.(\d+|\*|n|many))?$/.exec(t);
|
|
6407
|
+
if (!m) return null;
|
|
6408
|
+
const star = x => (x === 'n' || x === 'many' ? '*' : x), lo = star(m[1]), hi = m[2] == null ? null : star(m[2]);
|
|
6409
|
+
if (hi != null && (lo === '*' || (hi !== '*' && +lo > +hi))) return null;
|
|
6410
|
+
return hi == null ? lo : `${lo}..${hi}`;
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
// Mermaid's ~generic~ as <generic>: a ~ between two word characters opens, any other one closes an open one
|
|
6414
|
+
function mcGeneric(s) {
|
|
6415
|
+
let out = '', depth = 0;
|
|
6416
|
+
for (let k = 0; k < s.length; k++) {
|
|
6417
|
+
const c = s[k];
|
|
6418
|
+
if (c !== '~') out += c;
|
|
6419
|
+
else if (mcWord(s[k - 1]) && mcWord(s[k + 1])) { out += '<'; depth++; }
|
|
6420
|
+
else if (depth > 0) { out += '>'; depth--; }
|
|
6421
|
+
else out += c;
|
|
6422
|
+
}
|
|
6423
|
+
return out;
|
|
6424
|
+
}
|
|
6425
|
+
|
|
6426
|
+
// text in a label, a note or a cardinality: Mermaid's line breaks and entity codes (#quot; #lt; #35;)
|
|
6427
|
+
function mcText(s) {
|
|
6428
|
+
return s.replace(/<br\s?\/?>|\\n/gi, '\n').replace(/#(quot|lt|gt|amp|\d{1,6});/g, (_, e) =>
|
|
6429
|
+
({ quot: '"', lt: '<', gt: '>', amp: '&' })[e] ?? String.fromCodePoint(Math.min(Number(e), 0x10ffff)));
|
|
6430
|
+
}
|
|
6431
|
+
|
|
6432
|
+
// where whitespace ends
|
|
6433
|
+
function mcSkip(s, k) { while (k < s.length && (s[k] === ' ' || s[k] === '\t')) k++; return k; }
|
|
6434
|
+
|
|
6435
|
+
// a name at k: `quoted in backticks`, or word characters with single dashes (and dots, for a namespace) between them
|
|
6436
|
+
function mcName(s, k, dots) {
|
|
6437
|
+
if (s[k] === '`') { const e = s.indexOf('`', k + 1); return e > k + 1 ? [s.slice(k + 1, e), e + 1, true] : null; }
|
|
6438
|
+
let e = k;
|
|
6439
|
+
while (e < s.length && (mcWord(s[e]) || ((s[e] === '-' || (dots && s[e] === '.')) && mcWord(s[e - 1]) && mcWord(s[e + 1])))) e++;
|
|
6440
|
+
return e > k ? [s.slice(k, e), e, false] : null;
|
|
6441
|
+
}
|
|
6442
|
+
|
|
6443
|
+
// a "quoted" string at k: [text, after]
|
|
6444
|
+
function mcQuoted(s, k) { if (s[k] !== '"') return null; const e = s.indexOf('"', k + 1); return e < 0 ? null : [s.slice(k + 1, e), e + 1]; }
|
|
6445
|
+
|
|
6446
|
+
// the last whitespace outside <...>, or -1: where `Type name` splits
|
|
6447
|
+
function mcLastSpace(s) {
|
|
6448
|
+
let depth = 0, at = -1;
|
|
6449
|
+
for (let k = 0; k < s.length; k++) { const c = s[k]; if (c === '<') depth++; else if (c === '>') depth = Math.max(0, depth - 1); else if ((c === ' ' || c === '\t') && depth === 0) at = k; }
|
|
6450
|
+
return at;
|
|
6451
|
+
}
|
|
6452
|
+
|
|
6453
|
+
// a type with its multiplicity: "String[0..1]" -> ["String", "0..1"], "int[0..n]" -> ["int", "0..*", "0..n"] (as
|
|
6454
|
+
// written, when that differs); "int[]" and "int[5..2]" stay types
|
|
6455
|
+
function mcMult(type) {
|
|
6456
|
+
const t = type.trim(), lb = t.lastIndexOf('[');
|
|
6457
|
+
if (!t.endsWith(']') || lb < 0) return [t, undefined];
|
|
6458
|
+
const inner = t.slice(lb + 1, -1).replace(/\s/g, ''), mult = mcBounds(inner);
|
|
6459
|
+
return mult ? [t.slice(0, lb).trim(), mult, mult === inner ? undefined : inner] : [t, undefined];
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
// `name : Type` or Mermaid's `Type name` or `name`, generics already turned to <>: { name, valueType?, multiplicity? }
|
|
6463
|
+
function mcTyped(s, colonFirst) {
|
|
6464
|
+
let name, type = '';
|
|
6465
|
+
const colon = colonFirst ? s.indexOf(':') : -1;
|
|
6466
|
+
if (colon >= 0) { name = s.slice(0, colon).trim(); type = s.slice(colon + 1); }
|
|
6467
|
+
else { const sp = mcLastSpace(s.trim()); name = sp < 0 ? s.trim() : s.trim().slice(sp + 1); type = sp < 0 ? '' : s.trim().slice(0, sp); }
|
|
6468
|
+
const [valueType, multiplicity, written] = mcMult(type);
|
|
6469
|
+
return { name, ...(valueType ? { valueType } : {}), ...(multiplicity ? { multiplicity } : {}), ...(written ? { written } : {}) };
|
|
6470
|
+
}
|
|
6471
|
+
|
|
6472
|
+
// one member's text: an operation (it has parentheses), a literal (a bare name in an enumeration) or an attribute
|
|
6473
|
+
function mcMember(raw, inEnum) {
|
|
6474
|
+
let t = raw.trim();
|
|
6475
|
+
if (inEnum && /^[\p{L}\p{N}_]+$/u.test(t)) return { type: 'uml.EnumerationLiteral', label: t };
|
|
6476
|
+
let isStatic = false, isAbstract = false;
|
|
6477
|
+
for (;;) {
|
|
6478
|
+
if (t.endsWith('$')) isStatic = true; else if (t.endsWith('*')) isAbstract = true; else break;
|
|
6479
|
+
t = t.slice(0, -1).trimEnd();
|
|
6480
|
+
}
|
|
6481
|
+
const props = {};
|
|
6482
|
+
if (MC_VIS[t[0]]) { props.visibility = MC_VIS[t[0]]; t = t.slice(1).trimStart(); }
|
|
6483
|
+
t = mcGeneric(t);
|
|
6484
|
+
const open = t.indexOf('(');
|
|
6485
|
+
if (open >= 0) {
|
|
6486
|
+
const close = t.lastIndexOf(')'), inner = t.slice(open + 1, close > open ? close : t.length);
|
|
6487
|
+
let ret = close > open ? t.slice(close + 1).trim() : '';
|
|
6488
|
+
if (ret.startsWith(':')) ret = ret.slice(1).trim();
|
|
6489
|
+
const parameters = [];
|
|
6490
|
+
let depth = 0, from = 0;
|
|
6491
|
+
for (let k = 0; k <= inner.length; k++) {
|
|
6492
|
+
const c = inner[k];
|
|
6493
|
+
if (c === '<') depth++; else if (c === '>') depth = Math.max(0, depth - 1);
|
|
6494
|
+
else if (k === inner.length || (c === ',' && depth === 0)) {
|
|
6495
|
+
let p = inner.slice(from, k).trim(); from = k + 1;
|
|
6496
|
+
if (!p) continue;
|
|
6497
|
+
const sp = p.search(/\s/), first = sp < 0 ? '' : p.slice(0, sp), direction = ['in', 'out', 'inout', 'return'].includes(first) ? first : undefined;
|
|
6498
|
+
if (direction) p = p.slice(sp).trim();
|
|
6499
|
+
const { name, valueType } = mcTyped(p, true);
|
|
6500
|
+
parameters.push({ name, ...(valueType ? { valueType } : {}), ...(direction ? { direction } : {}) });
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
if (parameters.length) props.parameters = parameters;
|
|
6504
|
+
if (ret) props.returnType = ret;
|
|
6505
|
+
if (isStatic) props.isStatic = true;
|
|
6506
|
+
if (isAbstract) props.isAbstract = true;
|
|
6507
|
+
return { type: 'uml.Operation', label: t.slice(0, open).trim(), props };
|
|
6508
|
+
}
|
|
6509
|
+
if (t.startsWith('/')) { props.isDerived = true; t = t.slice(1).trimStart(); }
|
|
6510
|
+
const eq = t.indexOf('='), colon = t.indexOf(':');
|
|
6511
|
+
let head = t, def;
|
|
6512
|
+
if (colon >= 0 && (eq < 0 || colon < eq)) { const r = t.slice(colon + 1), e = r.indexOf('='); head = t.slice(0, colon + 1) + (e < 0 ? r : r.slice(0, e)); def = e < 0 ? undefined : r.slice(e + 1).trim(); }
|
|
6513
|
+
else if (eq >= 0) { head = t.slice(0, eq); def = t.slice(eq + 1).trim(); }
|
|
6514
|
+
const { name, valueType, multiplicity, written } = mcTyped(head, true);
|
|
6515
|
+
Object.assign(props, valueType ? { valueType } : {}, multiplicity ? { multiplicity } : {}, def !== undefined ? { default: def } : {}, isStatic ? { isStatic: true } : {});
|
|
6516
|
+
return { type: 'uml.Property', label: name, props, abstractField: isAbstract, multWritten: written };
|
|
6517
|
+
}
|
|
6518
|
+
|
|
6519
|
+
// a relation: A ["c1"] arrow ["c2"] B [: label], or null when the text is not one
|
|
6520
|
+
function mcRelation(s) {
|
|
6521
|
+
const a = mcName(s, 0); if (!a) return null;
|
|
6522
|
+
let k = mcSkip(s, a[1]), c1, c2, left = '', right = '';
|
|
6523
|
+
const q1 = mcQuoted(s, k); if (q1) { c1 = q1[0]; k = mcSkip(s, q1[1]); }
|
|
6524
|
+
for (const m of ['<|', '()', '*', 'o', '<']) if (s.startsWith(m, k)) { left = m; k += m.length; break; }
|
|
6525
|
+
const line = s.startsWith('--', k) ? 'solid' : s.startsWith('..', k) ? 'dashed' : null;
|
|
6526
|
+
if (!line) return null;
|
|
6527
|
+
k += 2;
|
|
6528
|
+
for (const m of ['|>', '()', '*', 'o', '>']) if (s.startsWith(m, k) && (!'*o'.includes(m) || k + 1 >= s.length || ' \t"'.includes(s[k + 1]))) { right = m; k += m.length; break; }
|
|
6529
|
+
k = mcSkip(s, k);
|
|
6530
|
+
const q2 = mcQuoted(s, k); if (q2) { c2 = q2[0]; k = mcSkip(s, q2[1]); }
|
|
6531
|
+
const b = mcName(s, k); if (!b) return null;
|
|
6532
|
+
k = mcSkip(s, b[1]);
|
|
6533
|
+
if (k < s.length && s[k] !== ':') return null;
|
|
6534
|
+
return { a: a[0], b: b[0], c1, c2, left, right, line, label: k < s.length ? s.slice(k + 1).trim() : '' };
|
|
6535
|
+
}
|
|
6536
|
+
|
|
6537
|
+
// a cardinality's text as an end: "1", "0..*", "lines 1..*", "lines"; Mermaid's "many" and "n" are *
|
|
6538
|
+
function mcEnd(text, say) {
|
|
6539
|
+
const t = mcText(text).trim(), sp = t.lastIndexOf(' '), last = sp < 0 ? t : t.slice(sp + 1), role = sp < 0 ? '' : t.slice(0, sp).trim();
|
|
6540
|
+
const mult = mcBounds(last);
|
|
6541
|
+
if (mult && mult !== last) say('approximated', `cardinality "${last}" is read as the multiplicity ${mult}`);
|
|
6542
|
+
return mult ? { ...(role ? { role } : {}), multiplicity: mult } : t ? { role: t } : {};
|
|
6543
|
+
}
|
|
6544
|
+
|
|
6545
|
+
function readMermaidClass(text) {
|
|
6546
|
+
const report = [], say = (line, status, message) => report.push({ line, status, message });
|
|
6547
|
+
const empty = () => ({ model: { schemaVersion: 2, root: MC_VIEW, nodes: [], edges: [], views: [{ id: MC_VIEW, diagram: 'uml.class', nodeIds: [] }] }, report });
|
|
6548
|
+
if (typeof text !== 'string') { say(1, 'skipped', 'the input is not text: nothing read'); return empty(); }
|
|
6549
|
+
if (text.length > MC_LIMIT) { say(1, 'skipped', `the input is ${text.length} characters, over the limit of ${MC_LIMIT}: nothing read`); return empty(); }
|
|
6550
|
+
const lines = text.split('\n').map(l => l.replace(/\r$/, ''));
|
|
6551
|
+
let i = 0, title;
|
|
6552
|
+
|
|
6553
|
+
// front matter: its title; Mermaid's configuration is not read
|
|
6554
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
6555
|
+
if (lines[i] && lines[i].trim() === '---') {
|
|
6556
|
+
let j = i + 1;
|
|
6557
|
+
for (; j < lines.length && lines[j].trim() !== '---'; j++) {
|
|
6558
|
+
const l = lines[j];
|
|
6559
|
+
if (!l.trim() || l.trim().startsWith('#') || /^\s/.test(l)) continue;
|
|
6560
|
+
const c = l.indexOf(':'), key = (c < 0 ? l : l.slice(0, c)).trim(), val = c < 0 ? '' : l.slice(c + 1).trim();
|
|
6561
|
+
if (key === 'title') {
|
|
6562
|
+
if (val.startsWith('"')) { try { title = JSON.parse(val); } catch { title = val.slice(1, -1); } }
|
|
6563
|
+
else title = val.startsWith("'") ? val.slice(1, -1).replaceAll("''", "'") : val;
|
|
6564
|
+
} else say(j + 1, 'skipped', `front matter ${key}: Mermaid's configuration is not read`);
|
|
6565
|
+
}
|
|
6566
|
+
if (j >= lines.length) { say(i + 1, 'skipped', 'the front matter is never closed with ---: nothing read'); return empty(); }
|
|
6567
|
+
i = j + 1;
|
|
6568
|
+
}
|
|
6569
|
+
|
|
6570
|
+
const classes = new Map(), packages = new Map(), order = [], relations = [], notes = [], stack = [];
|
|
6571
|
+
let viewId, direction = 'down', started = false, hint = {}, block = null;
|
|
6572
|
+
// classes and namespaces by their names, their ids given once all are read (a name that is not an id needs the others)
|
|
6573
|
+
const cls = (name, no) => {
|
|
6574
|
+
if (!classes.has(name)) { const rec = { kind: 'class', name, no, node: { id: undefined, type: 'uml.Class', label: name }, anns: [], raw: [] }; classes.set(name, rec); order.push(rec); }
|
|
6575
|
+
return classes.get(name);
|
|
6576
|
+
};
|
|
6577
|
+
const where = () => { for (let k = stack.length - 1; k >= 0; k--) if (stack[k].kind === 'package') return stack[k]; return null; };
|
|
6578
|
+
const annotate = (rec, s) => { const e = s.indexOf('>>', 2); rec.anns.push((e < 0 ? s.slice(2) : s.slice(2, e)).trim()); return e < 0 ? s.length : e + 2; };
|
|
6579
|
+
const member = (rec, s, no) => { if (s.trim()) rec.raw.push({ text: s, line: no, hint: hint.id }); };
|
|
6580
|
+
|
|
6581
|
+
for (; i < lines.length; i++) {
|
|
6582
|
+
const no = i + 1, s = lines[i].trim();
|
|
6583
|
+
if (!s) continue;
|
|
6584
|
+
if (block) { if (s.includes('}')) block = null; continue; } // inside accDescr { ... }
|
|
6585
|
+
if (s.startsWith('%%')) {
|
|
6586
|
+
const c = s.slice(2).trim();
|
|
6587
|
+
if (c.startsWith('{')) {
|
|
6588
|
+
say(no, 'skipped', 'a %%{init}%% directive: Mermaid\'s configuration is not read');
|
|
6589
|
+
if (!s.includes('}%%')) while (i + 1 < lines.length && !lines[++i].includes('}%%')); // over several lines: to its }%%
|
|
6590
|
+
}
|
|
6591
|
+
else if (c.startsWith('id:')) hint.id = c.slice(3).trim();
|
|
6592
|
+
else if (c.startsWith('link-id:')) hint.link = c.slice(8).trim();
|
|
6593
|
+
continue;
|
|
6594
|
+
}
|
|
6595
|
+
if (!started) {
|
|
6596
|
+
if (!/^classDiagram(-v2)?$/.test(s)) { say(no, 'skipped', 'not a Mermaid classDiagram: nothing read'); return empty(); }
|
|
6597
|
+
started = true; viewId = hint.id; hint = {}; continue;
|
|
6598
|
+
}
|
|
6599
|
+
const top = stack[stack.length - 1];
|
|
6600
|
+
const cut = s.search(/[\s{:("[<`]/), word = cut < 0 ? s : s.slice(0, cut);
|
|
6601
|
+
if (top && top.kind === 'class') { // a class body
|
|
6602
|
+
if (s === '}') stack.pop();
|
|
6603
|
+
else if (s.startsWith('<<')) annotate(top.rec, s);
|
|
6604
|
+
else member(top.rec, s, no);
|
|
6605
|
+
} else if (s === '}') {
|
|
6606
|
+
if (top) stack.pop(); else say(no, 'skipped', 'a } that closes nothing');
|
|
6607
|
+
} else if (word === 'direction') {
|
|
6608
|
+
const d = s.slice(9).trim();
|
|
6609
|
+
if (d === 'TB' || d === 'TD') direction = 'down';
|
|
6610
|
+
else if (d === 'LR') direction = 'right';
|
|
6611
|
+
else if (d === 'BT' || d === 'RL') { direction = d === 'BT' ? 'down' : 'right'; say(no, 'approximated', `direction ${d} is drawn ${direction === 'down' ? 'top to bottom' : 'left to right'}`); }
|
|
6612
|
+
else say(no, 'skipped', `direction ${d}: not a direction`);
|
|
6613
|
+
} else if (word === 'class') {
|
|
6614
|
+
const n = mcName(s, mcSkip(s, 5));
|
|
6615
|
+
if (!n) { say(no, 'skipped', 'a class with no name'); hint = {}; continue; }
|
|
6616
|
+
const rec = cls(n[0], no), pkg = where();
|
|
6617
|
+
if (pkg) rec.pkg = pkg;
|
|
6618
|
+
let k = n[1], rest = '';
|
|
6619
|
+
while (k < s.length) {
|
|
6620
|
+
k = mcSkip(s, k);
|
|
6621
|
+
if (k >= s.length) break;
|
|
6622
|
+
if (s[k] === '~') {
|
|
6623
|
+
let depth = 0, e = k;
|
|
6624
|
+
for (; e < s.length; e++) if (s[e] === '~') { if (mcWord(s[e - 1]) && mcWord(s[e + 1]) || e === k) depth++; else if (--depth === 0) break; }
|
|
6625
|
+
rec.node.label = mcGeneric(n[0] + s.slice(k, e + 1));
|
|
6626
|
+
say(no, 'approximated', `class ${n[0]}'s type parameter is kept in its name only, ${rec.node.label} (templates are not in the subset)`);
|
|
6627
|
+
k = e + 1;
|
|
6628
|
+
} else if (s.startsWith('["', k)) {
|
|
6629
|
+
const q = mcQuoted(s, k + 1); if (!q || s[q[1]] !== ']') { rest = s.slice(k); break; }
|
|
6630
|
+
rec.node.label = mcText(q[0]); k = q[1] + 1;
|
|
6631
|
+
} else if (s.startsWith('<<', k)) k = annotate(rec, s.slice(k)) + k;
|
|
6632
|
+
else if (s.startsWith(':::', k)) {
|
|
6633
|
+
const c = mcName(s, k + 3); say(no, 'skipped', `:::${c ? c[0] : ''} on class ${n[0]}: styling is not read`); k = c ? c[1] : k + 3;
|
|
6634
|
+
} else if (s[k] === '{') {
|
|
6635
|
+
const after = s.slice(k + 1).trim();
|
|
6636
|
+
if (after === '}') break;
|
|
6637
|
+
if (after.endsWith('}')) { member(rec, after.slice(0, -1), no); break; }
|
|
6638
|
+
stack.push({ kind: 'class', rec }); member(rec, after, no); break;
|
|
6639
|
+
} else { rest = s.slice(k); break; }
|
|
6640
|
+
}
|
|
6641
|
+
if (rest) say(no, 'skipped', `class ${n[0]}: "${rest}" is not read`);
|
|
6642
|
+
} else if (word === 'namespace') {
|
|
6643
|
+
const n = mcName(s, mcSkip(s, 9), true);
|
|
6644
|
+
if (!n) { say(no, 'skipped', 'a namespace with no name'); hint = {}; continue; }
|
|
6645
|
+
let k = mcSkip(s, n[1]), label;
|
|
6646
|
+
if (s.startsWith('["', k)) { const q = mcQuoted(s, k + 1); if (q && s[q[1]] === ']') { label = mcText(q[0]); k = mcSkip(s, q[1] + 1); } }
|
|
6647
|
+
const segs = n[2] ? [n[0]] : n[0].split('.');
|
|
6648
|
+
let parent = where();
|
|
6649
|
+
segs.forEach((seg, j) => {
|
|
6650
|
+
const path = segs.slice(0, j + 1).join('.');
|
|
6651
|
+
if (!packages.has(path)) {
|
|
6652
|
+
const rec = { kind: 'package', name: path, no, pkg: parent, node: { id: undefined, type: 'uml.Package', label: seg } };
|
|
6653
|
+
packages.set(path, rec); order.push(rec);
|
|
6654
|
+
}
|
|
6655
|
+
parent = packages.get(path);
|
|
6656
|
+
});
|
|
6657
|
+
if (label) parent.node.label = label;
|
|
6658
|
+
if (s[k] === '{') { if (s.slice(k + 1).trim() !== '}') stack.push(parent); }
|
|
6659
|
+
else say(no, 'skipped', `namespace ${n[0]} has no {: what follows is not inside it`);
|
|
6660
|
+
} else if (word === 'note') {
|
|
6661
|
+
let k = mcSkip(s, 4), target;
|
|
6662
|
+
if (s.startsWith('for ', k)) { const n = mcName(s, mcSkip(s, k + 4)); if (n) { target = cls(n[0], no); k = mcSkip(s, n[1]); } }
|
|
6663
|
+
const q = mcQuoted(s, k);
|
|
6664
|
+
if (!q) say(no, 'skipped', 'a note with no "text"');
|
|
6665
|
+
else { const rec = { kind: 'note', node: { type: 'uml.Comment', label: mcText(q[0]) }, target, hint: hint.id, linkHint: hint.link }; notes.push(rec); order.push(rec); }
|
|
6666
|
+
} else if (s.startsWith('<<')) {
|
|
6667
|
+
const e = s.indexOf('>>'), n = e < 0 ? null : mcName(s, mcSkip(s, e + 2));
|
|
6668
|
+
if (n) annotate(cls(n[0], no), s); else say(no, 'skipped', `${s}: an annotation names no class`);
|
|
6669
|
+
} else if (['click', 'link', 'callback'].includes(word)) say(no, 'skipped', `${word}: links and callbacks are not read`);
|
|
6670
|
+
else if (['style', 'classDef', 'cssClass'].includes(word)) say(no, 'skipped', `${word}: styling is not read`);
|
|
6671
|
+
else if (word.startsWith('accTitle') || word.startsWith('accDescr')) {
|
|
6672
|
+
say(no, 'skipped', `${word.startsWith('accTitle') ? 'accTitle' : 'accDescr'}: accessibility text is not read`);
|
|
6673
|
+
if (word.startsWith('accDescr') && s.includes('{') && !s.includes('}')) block = true;
|
|
6674
|
+
} else {
|
|
6675
|
+
const r = mcRelation(s);
|
|
6676
|
+
if (r) { relations.push({ ...r, no, hint: hint.id, from: cls(r.a, no), to: cls(r.b, no) }); hint = {}; continue; }
|
|
6677
|
+
const n = mcName(s, 0), k = n ? mcSkip(s, n[1]) : -1;
|
|
6678
|
+
if (n && s[k] === ':') member(cls(n[0], no), s.slice(k + 1), no);
|
|
6679
|
+
else if (n && k >= s.length) cls(n[0], no);
|
|
6680
|
+
else say(no, 'skipped', `"${s.length > 60 ? s.slice(0, 57) + '...' : s}" is not understood`);
|
|
6681
|
+
}
|
|
6682
|
+
hint = {};
|
|
6683
|
+
}
|
|
6684
|
+
if (!started) { say(1, 'skipped', 'not a Mermaid classDiagram: nothing read'); return empty(); }
|
|
6685
|
+
for (const open of stack) say(lines.length, 'approximated', `${open.kind === 'class' ? 'class ' + open.rec.name : 'namespace ' + open.name} is never closed: read to the end`);
|
|
6686
|
+
|
|
6687
|
+
// ids: a class's name, then a namespace's; a namespace with a class's name is <name>-namespace, and a name the id rule
|
|
6688
|
+
// changes (no "->") is made unique, both reported. Later members, relations and notes, in the order they come, made unique
|
|
6689
|
+
const used = new Set(), next = new Map(), uniq = name => { const id = mcFree(used, next, mcId(name)); used.add(id); return id; };
|
|
6690
|
+
const named = [...classes.values(), ...packages.values()];
|
|
6691
|
+
for (const rec of named) if (mcId(rec.name) === rec.name && !used.has(rec.name)) { rec.node.id = rec.name; used.add(rec.name); }
|
|
6692
|
+
for (const rec of named) if (rec.node.id === undefined) {
|
|
6693
|
+
const kept = mcId(rec.name) === rec.name, what = `${rec.kind === 'class' ? 'class' : 'namespace'} ${rec.name}`;
|
|
6694
|
+
rec.node.id = uniq(kept ? rec.name + '-namespace' : rec.name);
|
|
6695
|
+
say(rec.no, 'approximated', kept ? `${what} has a class's name: it is ${rec.node.id}` : `${what}: an id holds no "->", so it is ${rec.node.id}`);
|
|
6696
|
+
}
|
|
6697
|
+
for (const rec of named) if (rec.pkg) rec.node.parent = rec.pkg.node.id;
|
|
6698
|
+
|
|
6699
|
+
// the relations, as UML's
|
|
6700
|
+
const edges = [];
|
|
6701
|
+
for (const r of relations) {
|
|
6702
|
+
const at = (status, message) => say(r.no, status, message), tri = x => x === '<|' || x === '|>', what = `${r.a} ${r.left}${r.line === 'solid' ? '--' : '..'}${r.right} ${r.b}`;
|
|
6703
|
+
let e = null;
|
|
6704
|
+
if (r.left === '()' || r.right === '()') {
|
|
6705
|
+
const [iface, impl] = r.left === '()' ? [r.from, r.to] : [r.to, r.from];
|
|
6706
|
+
if ((r.left && r.right) || r.line === 'dashed') at('skipped', `${what}: a lollipop joins a class by a solid line`);
|
|
6707
|
+
else { iface.forced = 'uml.Interface'; e = { type: 'uml.InterfaceRealization', from: impl.node.id, to: iface.node.id }; at('approximated', `${what}: the lollipop ${iface.node.id} is an interface ${impl.node.id} realizes, drawn as a box`); }
|
|
6708
|
+
} else if (r.line === 'solid' && !tri(r.left) && !tri(r.right)) {
|
|
6709
|
+
const ends = {};
|
|
6710
|
+
[['from', r.left, r.c1], ['to', r.right, r.c2]].forEach(([k, mark, card]) => {
|
|
6711
|
+
const end = card != null ? mcEnd(card, at) : {};
|
|
6712
|
+
if (mark === '*') end.aggregation = 'composite'; else if (mark === 'o') end.aggregation = 'shared'; else if (mark) end.navigable = true;
|
|
6713
|
+
if (Object.keys(end).length) ends[k] = end;
|
|
6714
|
+
});
|
|
6715
|
+
e = { type: 'uml.Association', from: r.from.node.id, to: r.to.node.id, ...(Object.keys(ends).length ? { props: { ends } } : {}) };
|
|
6716
|
+
} else {
|
|
6717
|
+
const kind = r.line === 'solid' ? 'uml.Generalization' : tri(r.left) || tri(r.right) ? 'uml.InterfaceRealization' : 'uml.Dependency';
|
|
6718
|
+
const mark = kind === 'uml.Generalization' || kind === 'uml.InterfaceRealization' ? tri : x => x === '<' || x === '>';
|
|
6719
|
+
if (mark(r.left) && !r.right) e = { type: kind, from: r.to.node.id, to: r.from.node.id };
|
|
6720
|
+
else if (mark(r.right) && !r.left) e = { type: kind, from: r.from.node.id, to: r.to.node.id };
|
|
6721
|
+
else if (!r.left && !r.right) { e = { type: kind, from: r.from.node.id, to: r.to.node.id }; at('approximated', `${what}: a dashed link with no arrow is read as a dependency from ${r.a} to ${r.b}`); }
|
|
6722
|
+
else at('skipped', `${what}: ${r.left && r.right ? 'a two-way' : 'this'} ${kind === 'uml.Dependency' ? 'dashed relation' : kind === 'uml.Generalization' ? 'inheritance' : 'realization'} has no counterpart in UML's class diagram`);
|
|
6723
|
+
if (e && (r.c1 != null || r.c2 != null)) at('skipped', `${what}: a cardinality is read on an association only`);
|
|
6724
|
+
if (e && kind === 'uml.InterfaceRealization') { // what is realized is an interface, as a lollipop's is
|
|
6725
|
+
const sup = mark(r.left) ? r.from : r.to;
|
|
6726
|
+
if (!sup.forced && !sup.anns.some(a => a.toLowerCase() === 'interface')) at('approximated', `${what}: ${sup.node.id} is realized, so it is read as an interface`);
|
|
6727
|
+
sup.forced = 'uml.Interface';
|
|
6728
|
+
}
|
|
6729
|
+
}
|
|
6730
|
+
if (!e) continue;
|
|
6731
|
+
let label = mcText(r.label);
|
|
6732
|
+
const stereotypes = [];
|
|
6733
|
+
for (;;) {
|
|
6734
|
+
label = label.trim();
|
|
6735
|
+
const [o, c] = label.startsWith('«') ? ['«', '»'] : label.startsWith('<<') ? ['<<', '>>'] : [];
|
|
6736
|
+
const end = o ? label.indexOf(c, o.length) : -1;
|
|
6737
|
+
if (end < 0) break;
|
|
6738
|
+
stereotypes.push(label.slice(o.length, end).trim()); label = label.slice(end + c.length);
|
|
6739
|
+
}
|
|
6740
|
+
if (stereotypes.length) e.props = { ...e.props, stereotypes };
|
|
6741
|
+
if (label) e.label = label;
|
|
6742
|
+
edges.push({ e, hint: r.hint, at, what });
|
|
6743
|
+
}
|
|
6744
|
+
|
|
6745
|
+
// finish the classes: their type from the annotations, then their members
|
|
6746
|
+
for (const rec of classes.values()) {
|
|
6747
|
+
const n = rec.node, stereotypes = [];
|
|
6748
|
+
let type = rec.forced;
|
|
6749
|
+
for (const a of rec.anns) {
|
|
6750
|
+
const k = a.toLowerCase();
|
|
6751
|
+
if (k === 'abstract') n.props = { ...n.props, isAbstract: true };
|
|
6752
|
+
else if (MC_TYPES[k]) type = type || MC_TYPES[k];
|
|
6753
|
+
else stereotypes.push(a);
|
|
6754
|
+
}
|
|
6755
|
+
if (type) n.type = type;
|
|
6756
|
+
if (stereotypes.length) n.props = { ...n.props, stereotypes };
|
|
6757
|
+
}
|
|
6758
|
+
// an interface does not realize one: it specializes it (an interface by its annotation, or as what is realized elsewhere)
|
|
6759
|
+
const typeOf = new Map([...classes.values()].map(c => [c.node.id, c.node.type]));
|
|
6760
|
+
for (const { e, at, what } of edges) if (e.type === 'uml.InterfaceRealization' && typeOf.get(e.from) === 'uml.Interface') {
|
|
6761
|
+
e.type = 'uml.Generalization'; at('approximated', `${what}: ${e.from} is an interface, so it specializes ${e.to}`);
|
|
6762
|
+
}
|
|
6763
|
+
|
|
6764
|
+
for (const rec of classes.values()) {
|
|
6765
|
+
const members = [], seen = new Set(); // a member's name, an operation's with its parameters' types (overloading)
|
|
6766
|
+
for (const r of rec.raw) {
|
|
6767
|
+
const { abstractField, multWritten, ...x } = mcMember(r.text, rec.node.type === 'uml.Enumeration');
|
|
6768
|
+
if (!x.label) { say(r.line, 'skipped', `"${r.text.trim()}": a member with no name`); continue; }
|
|
6769
|
+
if (abstractField) say(r.line, 'approximated', `${rec.node.id}.${x.label}: an attribute is not abstract, so its * is not read`);
|
|
6770
|
+
if (multWritten) say(r.line, 'approximated', `${rec.node.id}.${x.label}: multiplicity [${multWritten}] is read as [${x.props.multiplicity}]`);
|
|
6771
|
+
const key = x.type === 'uml.Operation' ? `${x.label}(${(x.props.parameters || []).map(a => a.valueType ?? '').join(',')})` : x.label;
|
|
6772
|
+
if (seen.has(key)) { say(r.line, 'skipped', `${rec.node.id}.${x.label}: ${rec.node.id} has a member ${key} already`); continue; }
|
|
6773
|
+
seen.add(key);
|
|
6774
|
+
if (x.props && !Object.keys(x.props).length) delete x.props;
|
|
6775
|
+
members.push({ id: uniq(r.hint ?? `${rec.node.id}.${x.label}`), ...x });
|
|
6776
|
+
}
|
|
6777
|
+
if (members.length) rec.node.members = members;
|
|
6778
|
+
}
|
|
6779
|
+
const out = edges.map(({ e, hint }) => ({ id: uniq(hint ?? `${e.from}-${e.to}`), ...e }));
|
|
6780
|
+
notes.forEach((rec, k) => {
|
|
6781
|
+
rec.node = { id: uniq(rec.hint ?? `note-${k + 1}`), ...rec.node };
|
|
6782
|
+
if (rec.target) out.push({ id: uniq(rec.linkHint ?? `${rec.node.id}-link`), type: 'uml.CommentLink', from: rec.node.id, to: rec.target.node.id });
|
|
6783
|
+
});
|
|
6784
|
+
|
|
6785
|
+
const nodes = order.map(x => x.node), view = { id: viewId || MC_VIEW, diagram: 'uml.class', ...(title ? { title } : {}), ...(direction === 'down' ? { direction } : {}), nodeIds: nodes.map(n => n.id) };
|
|
6786
|
+
report.sort((a, b) => a.line - b.line); // stable: one line's items keep their order
|
|
6787
|
+
return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: view.id, nodes, edges: out, views: [view] }, report };
|
|
6788
|
+
}
|
|
6789
|
+
|
|
6790
|
+
function writeMermaidClass(model, viewId) {
|
|
6791
|
+
const m = model && typeof model === 'object' ? model : {}, arr = x => (Array.isArray(x) ? x : []), isObj = x => !!x && typeof x === 'object';
|
|
6792
|
+
const flat = s => String(s).replace(/\r\n|\r|\n/g, '\\n'), nl = s => /[\r\n]/.test(String(s)); // a comment is one line
|
|
6793
|
+
const views = arr(m.views).filter(isObj);
|
|
6794
|
+
const view = viewId != null ? views.find(v => v.id === viewId) : views.find(v => v.diagram === 'uml.class');
|
|
6795
|
+
// a model with views is written through its class view; one with none, whole
|
|
6796
|
+
if (viewId != null && !view) return `%% not written: view ${flat(viewId)}, which the model does not have\n`;
|
|
6797
|
+
if (views.length && !view) return '%% not written: the model, which has no uml.class view\n';
|
|
6798
|
+
if (view && view.diagram !== 'uml.class') return `%% not written: view ${flat(view.id)}, a ${flat(view.diagram)} view, not a uml.class one\n`;
|
|
6799
|
+
const byId = new Map(arr(m.nodes).filter(isObj).map(n => [n.id, n]));
|
|
6800
|
+
const shown = (view ? arr(view.nodeIds) : [...byId.keys()]).filter(id => byId.has(id));
|
|
6801
|
+
const KIND = mcTable({ 'uml.Class': '', 'uml.Interface': 'interface', 'uml.Enumeration': 'enumeration', 'uml.DataType': 'dataType', 'uml.PrimitiveType': 'primitive' });
|
|
6802
|
+
const VIS = mcTable({ public: '+', private: '-', protected: '#', package: '~' });
|
|
6803
|
+
const ARROW = mcTable({ 'uml.Generalization': '--|>', 'uml.InterfaceRealization': '..|>', 'uml.Dependency': '..>' });
|
|
6804
|
+
const has = x => x != null && x !== '', gen = s => String(s).replace(/[<>]/g, '~');
|
|
6805
|
+
const enc = s => String(s).replace(/"/g, '#quot;').replace(/</g, '#lt;').replace(/>/g, '#gt;').replace(/\n/g, '<br>');
|
|
6806
|
+
const name = id => (/^[\p{L}\p{N}_]+(-[\p{L}\p{N}_]+)*$/u.test(id) ? id : '`' + id + '`');
|
|
6807
|
+
// what Mermaid cannot carry: an element, as `<id> (<type>)`, or one property of one, as `<id>: <what>`
|
|
6808
|
+
const out = [], notWritten = [], skip = x => notWritten.push(`${flat(x.id)} (${flat(x.type)})`), lose = (id, what) => notWritten.push(`${flat(id)}: ${flat(what)}`);
|
|
6809
|
+
const title = view && has(view.title) ? view.title : m.title;
|
|
6810
|
+
if (has(title)) out.push('---', `title: ${/^[\p{L}\p{N}][\p{L}\p{N} _.,()-]*$/u.test(title) && !title.endsWith(' ') ? title : JSON.stringify(String(title))}`, '---');
|
|
6811
|
+
if (view && view.id !== MC_VIEW) { if (nl(view.id)) lose(view.id, "the view's id, which has a line break"); else out.push(`%% id: ${view.id}`); }
|
|
6812
|
+
out.push('classDiagram', view && view.direction === 'down' ? ' direction TB' : ' direction LR');
|
|
6813
|
+
|
|
6814
|
+
// what is written, in the reader's order: namespaces and classes as a tree, then relations, then notes
|
|
6815
|
+
const shownSet = new Set(shown), isPkg = n => n.type === 'uml.Package';
|
|
6816
|
+
const kids = new Map(), roots = [], notes = [], tree = [], boxes = [];
|
|
6817
|
+
for (const id of shown) {
|
|
6818
|
+
const n = byId.get(id), up = shownSet.has(n.parent) ? byId.get(n.parent) : null;
|
|
6819
|
+
if (n.type === 'uml.Comment') { if (nl(n.id)) skip(n); else notes.push(n); }
|
|
6820
|
+
else if ((!isPkg(n) && !(n.type in KIND)) || nl(n.id) || String(n.id).includes('`')) skip(n); // a name is one line, in `...` at most
|
|
6821
|
+
else if (up && isPkg(up)) { boxes.push(n); if (!kids.has(n.parent)) kids.set(n.parent, []); kids.get(n.parent).push(n); }
|
|
6822
|
+
else { if (up) lose(n.id, `its parent ${n.parent}, a ${up.type}, which only a namespace can be`); roots.push(n); }
|
|
6823
|
+
}
|
|
6824
|
+
const walk = n => { tree.push(n); for (const k of kids.get(n.id) || []) walk(k); };
|
|
6825
|
+
roots.forEach(walk);
|
|
6826
|
+
const inTree = new Set(tree);
|
|
6827
|
+
for (const n of boxes) if (!inTree.has(n)) skip(n); // under a namespace that is not written, or in a cycle of parents
|
|
6828
|
+
const written = new Set(tree.filter(n => !isPkg(n)).map(n => n.id));
|
|
6829
|
+
const used = new Set(tree.map(n => n.id)), next = new Map();
|
|
6830
|
+
const idOf = (id, base) => { const d = mcFree(used, next, mcId(base)); used.add(id); return d === id ? null : id; }; // the hint, if one is needed
|
|
6831
|
+
|
|
6832
|
+
const heads = new Map(); // a class's annotations and member rows
|
|
6833
|
+
for (const n of tree) if (!isPkg(n)) {
|
|
6834
|
+
const rows = [], np = isObj(n.props) ? n.props : {};
|
|
6835
|
+
const anns = [...(KIND[n.type] ? [KIND[n.type]] : []), ...(np.isAbstract ? ['abstract'] : []), ...arr(np.stereotypes).filter(s => {
|
|
6836
|
+
const k = String(s).toLowerCase(), ok = !nl(s) && !String(s).includes('>>') && k !== 'abstract' && !(k in MC_TYPES); // one the reader reads back as itself
|
|
6837
|
+
if (!ok) lose(n.id, `stereotype ${s}`);
|
|
6838
|
+
return ok;
|
|
6839
|
+
})];
|
|
6840
|
+
for (const x of arr(n.members).filter(isObj)) {
|
|
6841
|
+
const p = isObj(x.props) ? x.props : {};
|
|
6842
|
+
let text, vis = '';
|
|
6843
|
+
if (has(p.visibility)) { if (p.visibility in VIS) vis = VIS[p.visibility]; else lose(x.id, `visibility ${p.visibility}`); }
|
|
6844
|
+
if (x.type === 'uml.EnumerationLiteral') text = x.label;
|
|
6845
|
+
else if (x.type === 'uml.Operation') {
|
|
6846
|
+
const params = arr(p.parameters).filter(isObj).map(a => (has(a.direction) ? a.direction + ' ' : '') + (a.name ?? '') + (has(a.valueType) ? ' : ' + gen(a.valueType) : ''));
|
|
6847
|
+
text = `${vis}${x.label}(${params.join(', ')})${has(p.returnType) ? ' ' + gen(p.returnType) : ''}${p.isAbstract ? '*' : ''}${p.isStatic ? '$' : ''}`;
|
|
6848
|
+
} else if (x.type === 'uml.Property') {
|
|
6849
|
+
const colon = has(p.valueType) || has(p.multiplicity) || /\s/.test(x.label);
|
|
6850
|
+
text = `${vis}${p.isDerived ? '/' : ''}${x.label}${colon ? ' :' + (has(p.valueType) ? ' ' + gen(p.valueType) : '') : ''}${has(p.multiplicity) ? `[${p.multiplicity}]` : ''}${p.default != null ? ' = ' + p.default : ''}${p.isStatic ? '$' : ''}`;
|
|
6851
|
+
}
|
|
6852
|
+
// a member is one line of the class's body, which a line break, a lone }, a << or a %% would end or turn into another statement
|
|
6853
|
+
if (text == null || !has(x.label) || nl(x.id) || nl(text) || /^(\}$|<<|%%)/.test(text.trim())) { skip(x); continue; }
|
|
6854
|
+
rows.push([idOf(x.id, `${n.id}.${x.label}`), text]);
|
|
6855
|
+
}
|
|
6856
|
+
heads.set(n.id, [anns, rows]);
|
|
6857
|
+
}
|
|
6858
|
+
|
|
6859
|
+
const edgeOf = e => (typeof e === 'string' ? arr(m.edges).find(x => isObj(x) && x.id === e) : e);
|
|
6860
|
+
const owner = new Map(shown.flatMap(id => arr(byId.get(id).members).filter(isObj).map(x => [x.id, id])));
|
|
6861
|
+
const inView = id => shownSet.has(id) || owner.has(id); // a line may end on a member, which Mermaid cannot draw
|
|
6862
|
+
const lines = (view && Array.isArray(view.edges) ? view.edges.map(edgeOf) : arr(m.edges).filter(e => isObj(e) && inView(e.from) && inView(e.to))).filter(isObj);
|
|
6863
|
+
const relations = [], links = [];
|
|
6864
|
+
for (const e of lines) {
|
|
6865
|
+
if (e.type === 'uml.CommentLink') { if (nl(e.id)) skip(e); else links.push(e); continue; }
|
|
6866
|
+
if (!(written.has(e.from) && written.has(e.to)) || !(e.type === 'uml.Association' || e.type in ARROW) || nl(e.id)) { skip(e); continue; }
|
|
6867
|
+
const p = isObj(e.props) ? e.props : {}, ends = isObj(p.ends) ? p.ends : {};
|
|
6868
|
+
const label = [...arr(p.stereotypes).map(s => `«${s}»`), ...(has(e.label) ? [e.label] : [])].join(' ');
|
|
6869
|
+
let arrow = ARROW[e.type], c1 = '', c2 = '';
|
|
6870
|
+
if (!arrow) { // an end has one mark: a diamond, or else an arrowhead
|
|
6871
|
+
const mark = (end, nav, w) => {
|
|
6872
|
+
const dia = end.aggregation === 'composite' ? '*' : end.aggregation === 'shared' ? 'o' : '';
|
|
6873
|
+
if (dia && end.navigable) lose(e.id, `navigable at the ${w} end, which has a ${end.aggregation} diamond`);
|
|
6874
|
+
return dia || (end.navigable ? nav : '');
|
|
6875
|
+
};
|
|
6876
|
+
const card = end => { const t = [end.role, end.multiplicity].filter(has).join(' '); return t ? ` "${enc(t)}"` : ''; };
|
|
6877
|
+
const f = isObj(ends.from) ? ends.from : {}, t = isObj(ends.to) ? ends.to : {};
|
|
6878
|
+
arrow = mark(f, '<', 'from') + '--' + mark(t, '>', 'to'); c1 = card(f); c2 = card(t);
|
|
6879
|
+
}
|
|
6880
|
+
relations.push([idOf(e.id, `${e.from}-${e.to}`), `${name(e.from)}${c1} ${arrow}${c2} ${name(e.to)}${label ? ' : ' + enc(label) : ''}`]);
|
|
6881
|
+
}
|
|
6882
|
+
const noteLines = notes.map((n, k) => {
|
|
6883
|
+
const mine = links.filter(e => e.from === n.id && written.has(e.to));
|
|
6884
|
+
links.filter(e => e.from === n.id && e !== mine[0]).forEach(skip);
|
|
6885
|
+
const hint = idOf(n.id, `note-${k + 1}`), link = mine[0] ? idOf(mine[0].id, `${n.id}-link`) : null;
|
|
6886
|
+
return [hint, link, `note ${mine[0] ? `for ${name(mine[0].to)} ` : ''}"${enc(n.label ?? '')}"`];
|
|
6887
|
+
});
|
|
6888
|
+
links.filter(e => !notes.some(n => n.id === e.from)).forEach(skip);
|
|
6889
|
+
|
|
6890
|
+
const labelled = n => (has(n.label) && n.label !== n.id ? `["${enc(n.label)}"]` : '');
|
|
6891
|
+
const emit = (n, pad) => {
|
|
6892
|
+
if (isPkg(n)) {
|
|
6893
|
+
out.push(`${pad}namespace ${name(n.id)}${labelled(n)} {`);
|
|
6894
|
+
for (const k of kids.get(n.id) || []) emit(k, pad + ' ');
|
|
6895
|
+
return out.push(`${pad}}`);
|
|
6896
|
+
}
|
|
6897
|
+
const [anns, rows] = heads.get(n.id), head = `${pad}class ${name(n.id)}${labelled(n)}`;
|
|
6898
|
+
if (!anns.length && !rows.length) return out.push(head);
|
|
6899
|
+
out.push(head + ' {', ...anns.map(a => `${pad} <<${a}>>`));
|
|
6900
|
+
for (const [hint, text] of rows) out.push(...(hint ? [`${pad} %% id: ${hint}`] : []), `${pad} ${text}`);
|
|
6901
|
+
out.push(`${pad}}`);
|
|
6902
|
+
};
|
|
6903
|
+
roots.forEach(n => emit(n, ' '));
|
|
6904
|
+
for (const [hint, text] of relations) out.push(...(hint ? [` %% id: ${hint}`] : []), ` ${text}`);
|
|
6905
|
+
for (const [hint, link, text] of noteLines) out.push(...(hint ? [` %% id: ${hint}`] : []), ...(link ? [` %% link-id: ${link}`] : []), ` ${text}`);
|
|
6906
|
+
for (const x of notWritten) out.push(` %% not written: ${x}`);
|
|
6907
|
+
return out.join('\n') + '\n';
|
|
6908
|
+
}
|