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.
@@ -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
- 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)) ends(e, `${at}.edges[${j}]`, mine); });
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. Provisional: what a
98
- definition holds is settled after the sequence spike (1.12), so the registry is not exported from the package yet. */
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 may hold, all optional and provisional (1.7's panels read them):
110
- elements [{ type, label }] the palette: what a user can add to the view
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 id
188
- space). What a type's own properties name (props.replyTo, a timeline) is the type's to keep, from its stage. */
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 => r.kind !== 'end' && r.kind !== 'parent' && r.kind !== 'part'));
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, and lines to those lines; a line whose parent goes (a message in a deleted operand)
341
- // stays, outside it
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))) { now.add(k); more = true; } }
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
- // 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
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: 'var(--um-arrow)', m: 'd', cls: ' faded' }), dash: '', op: .35 }; // its own colour, faint, no flow (drift's idle lines)
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: 'var(--um-arrow)', m: 'd', dash: '' };
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: 'var(--um-arrow)', 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]})`])) };
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', 'var(--um-arrow)')}${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('')}${ends.map(endMark).join('')}</defs>`;
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 = typed(e), st = E && E.dashed ? { ...lstyle(e), dash: '6 4' } : lstyle(e), why = broke(e), d = roundPath(drawn[i]), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
725
- const cut = w => endOf(E, w) ? ENDS[endOf(E, w)][0] : 0, at = w => endOf(E, w) ? `url(#${id}-${endOf(E, w)}-${st.m})` : '';
726
- const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(E ? trimLine(drawn[i], cut('from'), cut('to')) : trimLine(drawn[i], e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
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 === 'classifier') { s += SHAPE.classifier.draw(n, col(n.k), fill(n.k)); return; } // a typed view's class box (1.5)
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><g class="labels depth-read">';
798
+ s += '</g>' + (opts.over || '') + '<g class="labels depth-read">';
855
799
  edges.forEach((e, i) => {
856
- if (typed(e)) s += endLabels(e, drawn[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('');
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 = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
898
- const segs = [], extra = new Set(), want = {}, hh = id => extra.has(id) ? 1 : h(id), mid = (id, o) => o ?? (hh(id) - 1) / 2;
899
- const tops = () => { const at = {}; cols.forEach(c => { let r = 0; c.forEach(id => { at[id] = fixed[id] ?? r; r += hh(id); }); }); return at; }; // each item's top row
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 = {}, outs = {}; segs.forEach(([a, b, oa, ob]) => { (outs[a] ||= []).push([b, ob, oa]); (ins[b] ||= []).push([a, oa, ob]); });
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 = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = fixed[id] ?? r; r += h(id); colOf[id] = k; }); });
943
- const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
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
- // A typed view's lines from a node to itself (risks.md F1) are loops round its top right corner, nested, the first innermost:
1059
- // 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
1060
- // 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
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, dg.typed ? rowAt(r) : r * pitch);
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
- 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
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
- // a loop from a node to itself takes the top port of its right side and the right port of its top, the inner loop the nearer the corner
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
- // a loop from a node to itself: out of the right side, up, left, down into the top. Other routes keep SEP px off its corners
1272
- // (corners: obstacles to routes only), so none touches it or turns where it does
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 + (dg.typed ? rowHt(nodes.find(n => n.y === y).row) : NH) + RG / 2));
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 = {}, 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;
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
- const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
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], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
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 = {}; ids.forEach(i => { rank[i] = 0; });
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 = {}, links = fwd.map(e => [e.from, e.to]);
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 addLine = (from, to, t) => emit([{ op: 'add', element: { ...typed(t, 'line'), from, to }, view: api.view() }]);
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
- if (f.kind === 'bool') h += `<label class="um-check"><input type="checkbox" data-f="p:${esc(f.key)}" data-key="${esc(f.key)}"${v ? ' checked' : ''}${off}>${esc(name)}</label>`;
3741
- else if (f.kind === 'enum') h += `<label>${esc(name)}<select data-f="p:${esc(f.key)}" data-key="${esc(f.key)}"${off}>${['', ...arrOf(f.options)].map(o => `<option value="${esc(o)}"${o === v ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select></label>`;
3742
- else h += text('p:' + f.key, v ?? '', name, ` data-key="${esc(f.key)}"`); });
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
- if (e.kind === 'node') {
3746
- const ms = arrOf(x.members).filter(m => isRec(m) && typeof m.id === 'string');
3747
- 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(m.label ?? '')}" value="${esc(m.label ?? '')}" aria-label="Member ${i + 1} of ${ms.length}${can('reorder') ? ', Alt + arrows to move it' : ''}"${off}>`
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 prev = arrOf(x.x.members).filter(isRec).pop(); t.value = '';
3777
- return emit([{ op: 'add', element: { ...typed(prev || {}, 'member'), label: value }, owner: id }]); }
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.was = t.value; if (value === '') value = undefined; }
3783
- const path = d.key == null ? 'label' : d.key.includes('.') ? ['props', d.key] : 'props.' + d.key;
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 ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
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
+ }