umlimator 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/umlimator.js CHANGED
@@ -61,6 +61,7 @@ function typedProblems(model) {
61
61
  arr(m.edges).forEach((e, i) => { if (typedEdge(e)) { element(e, `edges[${i}]`, 'edge'); typeOf(e, `edges[${i}]`, true); } });
62
62
  const typedView = v => v.diagram != null && v.diagram !== 'map'; // a view with diagram 'map' is a map: the map's checks
63
63
  views.forEach((v, i) => { if (!typedView(v)) return;
64
+ arr(v.nodes).forEach((n, j) => element(n, `views[${i}].nodes[${j}]`, 'node'));
64
65
  arr(v.edges).forEach((e, j) => { if (isObj(e)) { element(e, `views[${i}].edges[${j}]`, 'edge'); typeOf(e, `views[${i}].edges[${j}]`, true); } });
65
66
  arr(v.fragments).forEach((f, j) => element(f, `views[${i}].fragments[${j}]`, 'fragment'));
66
67
  ['changes', 'constraints'].forEach(k => arr((v.timeline || {})[k]).forEach((c, j) => element(c, `views[${i}].timeline.${k}[${j}]`, 'time'))); });
@@ -84,17 +85,18 @@ function typedProblems(model) {
84
85
  views.forEach((v, i) => { if (!typedView(v)) return; const at = `views[${i}]`;
85
86
  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)`);
86
87
  if (v.direction != null && !['right', 'down'].includes(v.direction)) warn(at + '.direction', `direction ${name(v.direction)} is not right or down: drawn right`);
87
- arr(v.nodeIds).forEach((id, j) => { if (!nodeIds.has(id)) err(`${at}.nodeIds[${j}]`, `${name(id)} is not a node`); });
88
- const mine = new Set(arr(v.containers).map(c => isObj(c) && c.id));
89
- 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); });
88
+ 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`); });
89
+ 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
90
+ const item = e => (T ? arr(T.items).includes(e.type) : e.from == null && e.to == null);
91
+ 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); });
90
92
  if (v.subject != null && !has(v.subject)) err(at + '.subject', `subject ${name(v.subject)} is not an element`);
91
93
  ['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`); })); });
92
94
  return out;
93
95
  }
94
96
  /* ---------- diagram types (work package 1.3) ----------
95
97
  A typed view's `diagram` names its type; a view with none is a map, and the map registers itself as the default
96
- (src/map/typed.js). A type this release does not know is drawn as plain cards, with a warning. Provisional: what a
97
- definition holds is settled after the sequence spike (1.12), so the registry is not exported from the package yet. */
98
+ (src/map/typed.js). A type this release does not know is drawn as plain cards, with a warning. Internal until the
99
+ API is documented. */
98
100
  const TYPES = new Map();
99
101
 
100
102
  function registerDiagramType(name, def = {}) {
@@ -105,14 +107,18 @@ function registerDiagramType(name, def = {}) {
105
107
  return type;
106
108
  }
107
109
 
108
- /* A definition may hold, all optional and provisional (1.7's panels read them):
109
- elements [{ type, label }] the palette: what a user can add to the view
110
- lines [{ type, label }] the relationship picker: the kinds of line between two elements
111
- props { [type]: [{ key, label, kind: 'text' | 'bool' | 'enum', options? }] } the properties form, per element type
112
- Without them the canvas offers one generic node and one generic line (core.Node, core.Line), and shows an element's props to read. */
110
+ /* A definition's fields are optional and provisional; absent, things go as before. notes/spike-sequence.md §1 says what
111
+ each does (validate(model) with no view runs once a type, not once a view); 1.9's: rankFlip, member, rules, describe. */
113
112
 
114
113
  /* A view's type: the map for a view with no diagram, the registered type, or null for one this release does not draw */
115
114
  const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram : 'map') || null;
115
+
116
+ /* What only a registered type's geometry uses, installed at load time by the files under src/typed/, which only
117
+ lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf. Empty in
118
+ umlimator.js, where no view is laid out but as a map (risks.md C5) */
119
+ const TYPED = {};
120
+
121
+ const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
116
122
  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
117
123
  let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its warnings here: only the one kept says them
118
124
  /* f(), with the warnings it gives held back: [its result, what it would have said] */
@@ -173,7 +179,8 @@ export function history(model, limit = 100) {
173
179
  value undefined removes it. Through objects only; id, members, parent,
174
180
  from and to change by their own commands
175
181
  add { element, owner?, index?, view? } a node (no from/to), a line, or with `owner` a member, at `index`
176
- among the owner's members, else among the view's; `view` shows it there
182
+ among the owner's members, else among the view's; `view` shows it there;
183
+ in 'view' (not 'model'): in its edges, nodes or fragments (registry.js)
177
184
  move { id, index, owner? } a member to another place among its owner's members, or to a new owner
178
185
  { id, view, index } an element or line to another place in a view's order (its nodeIds or edges)
179
186
  hide { id, view } out of one view, with all the view says of it: its place, pin, present,
@@ -181,14 +188,16 @@ export function history(model, limit = 100) {
181
188
  keeps it (risks.md E5). A line that lives only in that view goes with it
182
189
  delete { id } out of the model, with its members, what it owns, the lines to them, and
183
190
  every place that names them (references()). One already gone in the same
184
- transaction is passed over, so a selection deletes in one transaction (E2)
191
+ transaction is passed over, so a selection deletes in one transaction (E2).
192
+ What it held and stays goes to its nearest owner left
185
193
  reparent { id, parent } a new owner by `parent`, or none with parent null
186
- 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
187
- space). What a type's own properties name (props.replyTo, a timeline) is the type's to keep, from its stage. */
194
+ 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
195
+ id space). */
188
196
 
189
197
  const isObj = x => x !== null && typeof x === 'object' && !Array.isArray(x), arr = x => (Array.isArray(x) ? x : []);
190
198
  const UNSAFE = new Set(['__proto__', 'constructor', 'prototype']); // never a key a command writes (risks.md M3)
191
199
  const isLine = x => isObj(x) && (x.from != null || x.to != null);
200
+ const dig = (x, path) => path.split('.').reduce((o, k) => (isObj(o) ? o[k] : undefined), x);
192
201
 
193
202
  /* Every element by id: { x, list, kind, owner, view } (owner: the element whose member it is; view: the view a line or
194
203
  fragment of its own lives in) */
@@ -205,7 +214,7 @@ function index(m) {
205
214
  }
206
215
 
207
216
  /* Where a model names any of `ids` (a Set): [{ path, kind }], path a list of keys from the model down. Kinds: end, parent,
208
- part, view, pin, present, override, subject, story, lens, rule. Neither the element itself nor its members are listed.
217
+ part, prop, view, pin, present, override, subject, story, lens, rule. Neither the element itself nor its members are listed.
209
218
  only(viewIndex): look in those views and their story steps only (hide), else in all of the model. */
210
219
  function refs(m, ids, only) {
211
220
  const out = [], say = (path, kind) => out.push({ path, kind }), has = id => typeof id === 'string' && ids.has(id);
@@ -213,11 +222,14 @@ function refs(m, ids, only) {
213
222
  if (has(x.parent)) say([...at, 'parent'], 'parent');
214
223
  if (line) ['from', 'to'].forEach(k => { if (has(x[k])) say([...at, k], 'end');
215
224
  if (has((((x.props || {}).ends || {})[k] || {}).part)) say([...at, 'props', 'ends', k, 'part'], 'part'); });
225
+ Object.keys(propRefs(x.type)).forEach(p => { const v = dig(x, p), ks = p.split('.');
226
+ if (has(v)) say([...at, ...ks], 'prop'); else arr(v).forEach((id, j) => { if (has(id)) say([...at, ...ks, j], 'prop'); }); });
216
227
  arr(x.members).forEach((y, j) => element(y, [...at, 'members', j], false)); };
217
228
  if (!only) { arr(m.nodes).forEach((n, i) => element(n, ['nodes', i], false)); arr(m.edges).forEach((e, i) => element(e, ['edges', i], true)); }
218
229
  const vids = new Set();
219
230
  arr(m.views).forEach((v, i) => { if (!isObj(v) || (only && !only(i))) return; const at = ['views', i]; vids.add(v.id);
220
231
  ['nodeIds', 'tour', 'stepIds'].forEach(k => arr(v[k]).forEach((id, j) => { if (has(id)) say([...at, k, j], 'view'); }));
232
+ arr(v.nodes).forEach((n, j) => element(n, [...at, 'nodes', j], false));
221
233
  arr(v.edges).forEach((e, j) => { if (has(e)) say([...at, 'edges', j], 'view'); else element(e, [...at, 'edges', j], true); });
222
234
  arr(v.fragments).forEach((f, j) => element(f, [...at, 'fragments', j], false));
223
235
  [['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); }));
@@ -295,8 +307,18 @@ function run(m, c, gone) {
295
307
  }
296
308
  case 'add': {
297
309
  if ('to' in c) throw new Error('add: the owner of a member is `owner`');
310
+ if (c.in != null && c.in !== 'view' && c.in !== 'model') throw new Error(`add: in is 'view' or 'model', not ${JSON.stringify(c.in)}`);
298
311
  checkNew(c.element, at);
299
312
  const x = structuredClone(c.element), owner = c.owner != null ? need(c.owner) : null;
313
+ if (c.in === 'view') {
314
+ if (c.view == null || owner) throw new Error('add: in \'view\' needs a view, and no owner');
315
+ const v = viewOf(c.view), T = diagramOf(v) || {}, pal = arr(T.elements);
316
+ const k = isLine(x) || arr(T.items).includes(x.type) ? 'edges' : !pal.length || pal.some(t => isObj(t) && t.type === x.type) ? 'nodes' : 'fragments';
317
+ 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`);
318
+ const list = (v[k] ||= []);
319
+ 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);
320
+ return;
321
+ }
300
322
  if (owner && owner.kind !== 'node' && owner.kind !== 'member' && owner.kind !== 'fragment') throw new Error(`add: "${c.owner}" holds no members`);
301
323
  const v = c.view != null ? viewOf(c.view) : null, list = owner ? (owner.x.members ||= []) : isLine(x) ? (m.edges ||= []) : (m.nodes ||= []);
302
324
  list.splice(owner ? place(list, c.index) : list.length, 0, x);
@@ -326,19 +348,24 @@ function run(m, c, gone) {
326
348
  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
327
349
  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);
328
350
  if (out && isObj(l) && typeof l.id === 'string') ids.add(l.id); return !out; }); // what names those lines in this view goes too
329
- clear(m, refs(m, ids, i => i === vi).filter(r => r.kind !== 'end' && r.kind !== 'parent' && r.kind !== 'part'));
351
+ clear(m, refs(m, ids, i => i === vi).filter(r => !['end', 'parent', 'part', 'prop'].includes(r.kind)));
330
352
  return;
331
353
  }
332
354
  case 'delete': {
333
355
  if (!at.has(c.id) && gone.has(c.id)) return; // gone with an earlier command of this transaction
334
- need(c.id); const now = new Set();
356
+ need(c.id); const now = new Set(), kids = new Map();
357
+ 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); }
335
358
  const take = id => { const r = at.get(id); if (!r || now.has(id)) return; now.add(id);
336
- arr(r.x.members).forEach(y => isObj(y) && take(y.id));
337
- 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
359
+ arr(r.x.members).forEach(y => isObj(y) && take(y.id)); arr(kids.get(id)).forEach(take); }; // what it owns goes with it
338
360
  take(c.id);
339
- // lines to what is gone go too, and lines to those lines; a line whose parent goes (a message in a deleted operand)
340
- // stays, outside it
341
- 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; } }
361
+ // lines to what is gone go too, lines to those lines, and what a type's refs cascade (a reply with its call)
362
+ const cascade = x => Object.entries(propRefs(x.type)).some(([p, how]) => how === 'cascade' && [].concat(dig(x, p)).some(id => now.has(id)));
363
+ 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; } }
364
+ // what stays in something gone goes to its nearest owner left; an operand's messages to its fragment's parent
365
+ const above = id => { const r = at.get(id); return r && (r.x.parent ?? (r.owner && above(r.owner.id))); };
366
+ for (const [k, o] of at) if (!now.has(k) && now.has(o.x.parent)) { let p = o.x.parent;
367
+ for (let n = 0; p != null && now.has(p) && n++ < at.size;) p = above(p);
368
+ if (p != null && !now.has(p)) o.x.parent = p; else delete o.x.parent; }
342
369
  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); }
343
370
  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
344
371
  now.forEach(id => gone.add(id));
@@ -479,60 +506,10 @@ const SHAPE = {
479
506
  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)}"/>`
480
507
  + `<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}"/>`; },
481
508
  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); } },
482
- classifier: { // UML's class box (1.5): a rectangle, its name compartment, then one compartment per member kind (classParts)
483
- size: n => classParts(n).size, out: boxOut(0), face: n => [n.y, n.y + n.h], inset: () => 0, draw: classSvg },
484
509
  };
485
- // what every shape reports (1.5): size(n), the box layout gives it (a map's card: mapNodeW wide, a row high; the classifier:
486
- // from its text), and ports(n, side, k), k line ends spread evenly along a side ('L', 'R', 'T', 'B'), each on the outline
510
+ // what every shape reports (1.5): size(n), the box layout gives it (a map's card: mapNodeW wide, a row high; the classifier,
511
+ // 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
487
512
  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); }
488
- // ---- 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
489
- // view's present; a map never draws it): shape 'classifier'; label; type ('uml.Class', named in its accessible name);
490
- // stereotypes [text], shown «…» over the name; abstract (the name in italics); compartments [{ name, members }] in order
491
- // ('attributes', 'operations', 'literals'), each member in the model's envelope ({ type, label, props }); collapsed
492
- // [compartment names], hidden with no divider; rows: at most that many rows in a compartment, then "+k more" (stage 1 note §5)
493
- const CL = { pad: 8, name: 13, row: 11, st: 14, nl: 18, rl: 15, gap: 5, empty: 10, minW: 96, maxW: 320 };
494
- const VIS = { public: '+', private: '-', protected: '#', package: '~' };
495
- // a member's row in UML's form: "+ total: Money [1]", "+ /count: Integer = 0", "+ place(at: Date): Order"; a literal's label
496
- const obj = x => !!x && typeof x === 'object'; // an entry the classifier reads; null, text or a number is passed over
497
- 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 ?? '');
498
- 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 : '');
499
- return name + (has(p.valueType) ? ': ' + p.valueType : '') + (has(p.multiplicity) ? ` [${p.multiplicity}]` : '') + (has(p.default) ? ' = ' + p.default : ''); }
500
- // the classifier as drawn: its stereotypes, its compartments (all: every one, each with every member; comps: those shown,
501
- // 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,
502
- // within minW and maxW (a longer row is cut: classSvg). An abstract name is in italics, which the measure (upright) does
503
- // not see: it is measured a tenth wider (slant)
504
- function classParts(n) {
505
- 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;
506
- const stereo = (Array.isArray(n.stereotypes) ? n.stereotypes : []).filter(t => t != null && t !== '').map(String), st = stereo.map(t => `«${t}»`);
507
- 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) : [] }));
508
- 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) }));
509
- 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]] : [])])];
510
- const w = Math.ceil(Math.min(CL.maxW, Math.max(CL.minW, ...texts.map(([t, px, k]) => textW(t, px) * k + 2 * CL.pad))));
511
- 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; };
512
- return { stereo, st, all, comps, head, tall, slant, size: { w, h: comps.reduce((a, c) => a + tall(c), head) } };
513
- }
514
- const words = t => String(t).split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
515
- function classSvg(n, c, f) { // its outline, its head, then each compartment under a divider, every text cut to the box (fit)
516
- 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);
517
- const font = (px, wt, it) => `style="font:${it ? 'italic ' : ''}${wt} ${px}px var(--um-sans)"`;
518
- 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>`;
519
- 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}"/>`];
520
- let ty = y + CL.gap; const hd = [];
521
- 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; });
522
- 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>`);
523
- out.push(`<g class="c-head" data-band="${r2(y)} ${r2(y + head)}">${hd.join('')}</g>`);
524
- let top = y + head;
525
- 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;
526
- const rows = C.rows.map((r, j) => { const p = obj(r.m.props) ? r.m.props : {};
527
- 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"' : ''}`); });
528
- if (C.more) rows.push(row('c-more', 'var(--um-muted)', at(C.rows.length), `+${C.more} more`, ` ${font(CL.row, 400)}`));
529
- 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>`);
530
- top += bh; });
531
- // its accessible name (A1): "Class Order (entity, abstract), 2 attributes, 1 operation", every member counted, shown or not
532
- const counts = all.map(C => `${C.members.length} ${C.members.length === 1 ? C.name.replace(/s$/, '') : C.name}`), why = [...stereo, ...(n.abstract ? ['abstract'] : [])];
533
- const name = [`${words(n.type || 'uml.Class')} ${n.label ?? ''}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
534
- return `<g class="node classifier" data-id="${esc(n.id)}" data-shape="classifier" tabindex="-1" role="img" aria-label="${esc(name)}">${out.join('')}</g>`;
535
- }
536
513
  const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
537
514
  // a card's shape in a notation: its own `shape` first; box draws every card as before (a hook stays a hexagon there too)
538
515
  const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
@@ -610,51 +587,21 @@ function roundPath(pts, r = 9) {
610
587
  }
611
588
  const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
612
589
  }
613
-
614
- // a typed line's end labels (1.5): each end's role and multiplicity (e.end.labels: { from: { role, mult }, to: { role, mult } }),
615
- // 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
616
- // the end's marker; the role above a line that leaves sideways and left of one that leaves up or down, the multiplicity on
617
- // 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
618
- // 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
619
- // 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
620
- // as obstacles; it moves them apart where many lines meet (L5)
621
- function endLabels(e, pts = e.pts) {
622
- const T = e.end && e.end.labels, out = []; if (!T || !pts || pts.length < 2) return out;
623
- 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;
624
- 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 }; }),
625
- ...[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 }))];
626
- [['from', pts[0], pts[1]], ['to', pts[pts.length - 1], pts[pts.length - 2]]].forEach(([end, p, q]) => {
627
- 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;
628
- [['role', at.role, -1], ['mult', at.mult, 1]].forEach(([kind, t, side]) => { if (t == null || t === '') return;
629
- 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;
630
- 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 });
631
- let b = null;
632
- 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; }
633
- out.push(b || box(8, side, false)); }); });
634
- return out;
635
- }
636
590
  let svgSeq = 0;
637
- // 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
638
- // 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);
639
- // dashed: a dependency's or realization's line; labels: each end's role and multiplicity (endLabels). Each end: [its
640
- // length, half its height, its path pointing right with the tip at its length, its fill]. Its tip is where the line ends,
641
- // on the node's outline. Hollow is the stage's background, filled the line's own colour (T2), in either theme
642
- 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'],
643
- diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'] };
644
- const endOf = (E, w) => hasKey(ENDS, E[w]) && ENDS[E[w]] ? E[w] : null;
645
- /* SVG markup. opts: { childCount(id) -> n, subject: id, title } */
591
+ /* SVG markup. opts: { childCount(id) -> n, subject: id, title, under, over } */
646
592
  function svgOf(geo, opts = {}) {
647
593
  const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
594
+ const LINE = map ? 'var(--um-arrow)' : 'var(--um-muted)'; // a typed view's lines: 3:1 or more in both themes
648
595
  const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
649
596
  const estyle = e => {
650
- 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)
597
+ 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)
651
598
  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' : '') };
652
599
  const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
653
600
  if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
654
601
  if (st === 'removed') return { c: 'var(--um-rose)', m: 'r', dash: '5 5', op: .7 };
655
602
  if (st === 'security') return { c: 'var(--um-rose)', m: 'r', dash: '5 5' };
656
603
  if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
657
- return { c: 'var(--um-arrow)', m: 'd', dash: '' };
604
+ return { c: LINE, m: 'd', dash: '' };
658
605
  };
659
606
  // 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)
660
607
  const broke = e => opts.broken ? [...new Set((e.all || [e]).flatMap(x => opts.broken[x._id] || []))] : [];
@@ -662,11 +609,8 @@ function svgOf(geo, opts = {}) {
662
609
  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;
663
610
  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>`; };
664
611
  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>`;
665
- 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]})`])) };
612
+ 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]})`])) };
666
613
  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
667
- 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) : []))];
668
- const endMark = km => { const k = km.slice(0, km.lastIndexOf('-')), m = km.slice(k.length + 1), [L, H, p, fl] = ENDS[k];
669
- 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>`; };
670
614
  // 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)
671
615
  const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
672
616
  // 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
@@ -674,8 +618,9 @@ function svgOf(geo, opts = {}) {
674
618
  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))])
675
619
  : nodes.map(n => [n, n.shape != null && cardLook(n, 'box', false, null)]).filter(x => x[1]));
676
620
  const drawn = edges.map(e => map && looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
621
+ 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)
677
622
  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">
678
- <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>`;
623
+ <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>`;
679
624
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
680
625
  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)
681
626
  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"' : ''}/>
@@ -711,7 +656,7 @@ function svgOf(geo, opts = {}) {
711
656
  return { d: roundPath(P), legs, at };
712
657
  })() : null;
713
658
  const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
714
- s += '<g class="edges">';
659
+ s += (opts.under || '') + '<g class="edges">'; // a type's layers (registry.js draw)
715
660
  (geo.intake || []).forEach(b => {
716
661
  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
717
662
  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)"/>
@@ -720,10 +665,9 @@ function svgOf(geo, opts = {}) {
720
665
  ${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>`;
721
666
  });
722
667
  edges.forEach((e, i) => {
723
- 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 : '');
724
- const cut = w => endOf(E, w) ? ENDS[endOf(E, w)][0] : 0, at = w => endOf(E, w) ? `url(#${id}-${endOf(E, w)}-${st.m})` : '';
725
- 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)
726
- 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})"` : ''}`;
668
+ 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 : '');
669
+ 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)
670
+ const marks = E ? E.marks : `marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}`;
727
671
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
728
672
  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>`; }
729
673
  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"/>
@@ -731,13 +675,13 @@ function svgOf(geo, opts = {}) {
731
675
  <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]];
732
676
  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]; });
733
677
  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) : ''}
734
- <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>`;
678
+ <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>`;
735
679
  });
736
680
  // 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
737
681
  // line, rather than across the card's name
738
682
  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">';
739
683
  nodes.forEach(n => {
740
- 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)
684
+ 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)
741
685
  const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
742
686
  const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
743
687
  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';
@@ -850,9 +794,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
850
794
  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('; ')}` : ''}`;
851
795
  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>`; });
852
796
  }
853
- s += '</g><g class="labels depth-read">';
797
+ s += '</g>' + (opts.over || '') + '<g class="labels depth-read">';
854
798
  edges.forEach((e, i) => {
855
- 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('');
799
+ if (T) s += T.labels(i);
856
800
  if (!e.lbl) return;
857
801
  const L = e.lbl;
858
802
  if (map) {
@@ -892,10 +836,10 @@ const labelW = e => textW(fit((e.step != null ? e.step + ' · ' : '') + (e.label
892
836
  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
893
837
  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.
894
838
  fixed: items at a set row in columns of their own, which stay as they are (a box's far ends, in blockPos). */
895
- function untangle(cols, links, h = () => 1, fixed = {}) {
896
- const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
897
- const segs = [], extra = new Set(), want = {}, hh = id => extra.has(id) ? 1 : h(id), mid = (id, o) => o ?? (hh(id) - 1) / 2;
898
- 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
839
+ function untangle(cols, links, h = () => 1, fixed = Object.create(null)) { // maps by id: no prototype (M3)
840
+ const colOf = Object.create(null); cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
841
+ 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;
842
+ 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
899
843
  const at0 = tops();
900
844
  links.forEach(([a, b, oa, ob], n) => {
901
845
  const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
@@ -907,7 +851,7 @@ function untangle(cols, links, h = () => 1, fixed = {}) {
907
851
  });
908
852
  if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
909
853
  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]); });
910
- const ins = {}, outs = {}; segs.forEach(([a, b, oa, ob]) => { (outs[a] ||= []).push([b, ob, oa]); (ins[b] ||= []).push([a, oa, ob]); });
854
+ 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]); });
911
855
  const crossings = () => { const at = tops(); let n = 0;
912
856
  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];
913
857
  if (colOf[a] === colOf[c] && (at[a] + oa - at[c] - oc) * (at[b] + ob - at[d] - od) < 0) n++; }
@@ -937,9 +881,10 @@ function pullSources(ids, links, rank) {
937
881
  adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
938
882
  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
939
883
  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). */
940
- function rowsFor(cols, links, h = () => 1, fixed = {}) {
941
- const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = fixed[id] ?? r; r += h(id); colOf[id] = k; }); });
942
- const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
884
+ function rowsFor(cols, links, h = () => 1, fixed = Object.create(null)) {
885
+ const row = Object.create(null); if (!cols.length) return row; // an empty view
886
+ 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; }); });
887
+ 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
943
888
  links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
944
889
  (outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
945
890
  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; };
@@ -948,7 +893,7 @@ function rowsFor(cols, links, h = () => 1, fixed = {}) {
948
893
  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; } });
949
894
  off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
950
895
  for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
951
- for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = {};
896
+ for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = Object.create(null);
952
897
  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]; });
953
898
  fit(cols[k], want); }
954
899
  const low = Math.min(...Object.keys(row).filter(id => !(id in fixed)).map(id => row[id]));
@@ -1054,29 +999,12 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1054
999
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
1055
1000
  [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); }); }); }
1056
1001
  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)
1057
- // A typed view's lines from a node to itself (risks.md F1) are loops round its top right corner, nested, the first innermost:
1058
- // 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
1059
- // 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
1060
- // 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)
1061
- const loopDims = (list, out) => { let D = 0, R = 0, half = 0;
1062
- 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];
1063
- D = k ? D + half + bh / 2 + 10 : STUB + 6 + bh / 2; half = bh / 2;
1064
- R = Math.max(k ? R + 2 * SEP : STUB + 4, bw + 12 - out[k]); return { D, R, top: D + half }; }); };
1065
- 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); });
1066
- Object.entries(loops).forEach(([id, list]) => { const n = byId[id], d = loopDims(list, list.map(() => 0)).at(-1);
1067
- extraY[n.row - .5] = Math.max(extraY[n.row - .5] || 0, d.top + STUB + 7 - RG);
1068
- extraX[n.col] = Math.max(extraX[n.col] || 0, d.R + STUB + 6 - Math.max(CG, gapAfter[n.col] || 0)); });
1069
- // 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
1070
- // NH with none. With every node NH tall, the pitch is a map's
1071
- const rowH = {}, rowTop = {}; if (dg.typed && !dg.abs && nodes.length) {
1072
- nodes.forEach(n => [Math.floor(n.row), Math.ceil(n.row)].forEach(k => { rowH[k] = Math.max(rowH[k] ?? 0, n.h); }));
1073
- const r0 = Math.min(...Object.keys(rowH).map(Number)), r1 = Math.max(...Object.keys(rowH).map(Number));
1074
- for (let k = r0, y = r0 * pitch; k <= r1 + 1; k++) { rowTop[k] = y; y += (rowH[k] ?? NH) + RG; } }
1075
- 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]); };
1076
- 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
1002
+ // 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)
1003
+ const loops = Object.create(null), LP = dg.typed && !dg.abs ? TYPED.loops(loops, dg, { byId, extraX, extraY, STUB, RG, CG, SEP, gapAfter }) : null;
1004
+ const rows = dg.typed && TYPED.rows(dg, nodes, pitch, NH, RG); // a typed view's rows (src/typed/layout.js)
1077
1005
  const gap = c => Math.max(CG, gapAfter[c] || 0) + (extraX[c] || 0);
1078
1006
  const cx = c => Number.isInteger(c) ? colX[c] : colX[Math.floor(c)] + (c % 1) * ((colW[Math.floor(c)] || NW) + gap(Math.floor(c)));
1079
- const rowY = r => Object.keys(extraY).reduce((y, k) => +k < r ? y + extraY[k] : y, dg.typed ? rowAt(r) : r * pitch);
1007
+ const rowY = r => Object.keys(extraY).reduce((y, k) => +k < r ? y + extraY[k] : y, rows ? rows.at(r) : r * pitch);
1080
1008
  const place = () => {
1081
1009
  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); }
1082
1010
  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; } });
@@ -1191,7 +1119,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1191
1119
  if (near && !through([...pts, ...up], e)) { e.fixed = [...pts, ...up]; e.under = k + 1; }
1192
1120
  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
1193
1121
  });
1194
- 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
1122
+ if (LP) LP.mark(edges);
1195
1123
  const free = edges.filter(e => !e.fixed);
1196
1124
  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'; });
1197
1125
  const sides = {};
@@ -1226,10 +1154,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1226
1154
  });
1227
1155
  free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
1228
1156
  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
1229
- // 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
1230
- const loopsOf = n => edges.filter(e => e[OWN_ROUTE] === 'loop' && e.s === n);
1231
- edges.filter(e => e[OWN_ROUTE] === 'loop').forEach(e => { const n = e.s, k = loopsOf(n).indexOf(e);
1232
- 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 }); });
1157
+ if (LP) LP.ports(pushSide);
1233
1158
  const portAt = (it, side, p) => { if (it.n === it.e.s && it.e.fs === side) it.e.p1 = p; else it.e.p2 = p; };
1234
1159
  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
1235
1160
  const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
@@ -1267,31 +1192,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1267
1192
  });
1268
1193
  });
1269
1194
  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]; });
1270
- // 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
1271
- // (corners: obstacles to routes only), so none touches it or turns where it does
1272
- const corners = []; [...new Set(edges.filter(e => e[OWN_ROUTE] === 'loop').map(e => e.s))].forEach(n => { const list = loopsOf(n);
1273
- 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;
1274
- e.pts = e.fixed = [e.p1, [x, e.p1[1]], [x, y], [e.p2[0], y], e.p2];
1275
- e.pts.slice(1, 4).forEach(([px, py]) => corners.push({ x: px - SEP, y: py - SEP, w: 2 * SEP, h: 2 * SEP })); }); });
1276
- // A view laid out down (turned): the labels of lines between one pair are wider than the 16 px between the lines, so the
1277
- // 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
1278
- // 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
1279
- if (dg.typed && dg.turned) { const pairs = new Map(), lr = e => e.fs === 'R' ? [e.p1, e.p2] : [e.p2, e.p1];
1280
- free.forEach(e => { const [l, r] = e.fs === 'R' ? [e.s, e.t] : [e.t, e.s], k = l.id + '\u0001' + r.id;
1281
- if (/^(RL|LR)$/.test(e.fs + e.ts) && l.x + l.w < r.x) pairs.set(k, [...(pairs.get(k) || []), e]); });
1282
- pairs.forEach(list => { if (list.length < 2 || !list.some(e => e.label || e.step != null)) return;
1283
- 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;
1284
- 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);
1285
- const mid = list.reduce((a, e) => a + lr(e)[0][1] + lr(e)[1][1], 0) / (2 * list.length), lanes = [];
1286
- gaps.reduce((y, g, i) => (lanes[i] = y + g), mid - gaps.reduce((a, g) => a + g, 0) / 2);
1287
- // 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
1288
- 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();
1289
- [up, down].forEach(g => g.forEach((i, k) => { at[i] = lr(list[i])[side][0] + (side ? -1 : 1) * (STUB + k * SEP); })); return at; };
1290
- const xl = turns(0), xr = turns(1);
1291
- if (Math.min(...xr) - Math.max(...xl) < 18 + 16) return;
1292
- 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(); });
1293
- if (list.some((e, i) => through(pts[i], e))) return;
1294
- 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); }); }); }
1195
+ const corners = LP ? LP.route() : []; // a loop's corners: obstacles to the other routes
1196
+ 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)
1295
1197
  const BEND = map ? 60 : 26, CROSS = 900; // maps: few bends
1296
1198
  const OUT = { R: [1, 0], L: [-1, 0], T: [0, -1], B: [0, 1] };
1297
1199
  const nodeRects = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
@@ -1301,7 +1203,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1301
1203
  nodes.forEach(n => gapLinesY.push(n.y - RG / 2, n.y + n.h + RG / 2));
1302
1204
  } else {
1303
1205
  Object.keys(colX).map(Number).forEach(c => gapLinesX.push(colX[c] - CG / 2, colX[c] + (colW[c] || NW) + CG / 2));
1304
- [...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));
1206
+ [...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));
1305
1207
  }
1306
1208
  const routed = [], reserved = [];
1307
1209
  const inRect = (p, r) => p[0] > r.x && p[0] < r.x + r.w && p[1] > r.y && p[1] < r.y + r.h;
@@ -1675,6 +1577,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1675
1577
  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])];
1676
1578
  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])];
1677
1579
  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); });
1580
+ if (!xs.length) xs.push(0), ys.push(0); // a view with nothing in it: an empty stage
1678
1581
  const M = 36, minX = Math.min(...xs) - M, minY = Math.min(...ys) - M;
1679
1582
  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 } };
1680
1583
  }
@@ -1817,49 +1720,6 @@ function auditSvg(svg, geo, where) {
1817
1720
  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`);
1818
1721
  return issues;
1819
1722
  }
1820
- /* 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.
1821
- 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
1822
- between one pair each with its own ports and label (F2); `direction: "down"` (note §6); and a memo that lays the view out
1823
- again only when its geometry changed (F7). Nodes come with their sizes, `w` and `h`, which their shapes give (1.5). */
1824
-
1825
- // what moves a typed view's geometry: every field of the view layoutGraph reads but VIEW_PAINT (a test holds the two lists to
1826
- // layered.js), and these of a node and a line. Anything else (a colour, a `props` field) only repaints
1827
- const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'rgap', 'turned', 'typed'];
1828
- 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'];
1829
- 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]))]);
1830
-
1831
- // 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
1832
- const SIDE_T = { L: 'T', T: 'L', R: 'B', B: 'R' }, swapXY = p => [p[1], p[0]];
1833
- const flipBox = r => { if (r) [r.x, r.y, r.w, r.h] = [r.y, r.x, r.h, r.w]; };
1834
- function transpose(g) {
1835
- [...g.nodes, ...(g.families || []), g.bounds].forEach(flipBox);
1836
- (g.groups || []).forEach(r => { [r.x, r.y, r.x2, r.y2] = [r.y, r.x, r.y2, r.x2]; });
1837
- 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);
1838
- e.fs = SIDE_T[e.fs]; e.ts = SIDE_T[e.ts]; e.stubs = (e.stubs || []).map(s => s.map(swapXY)); flipBox(e.lbl); });
1839
- return g;
1840
- }
1841
-
1842
- // 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
1843
- // 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
1844
- function repaint(g, was, dg) {
1845
- const by = (list, key) => new Map((list || []).filter(x => x && x[key] != null).map(x => [x[key], x]));
1846
- [[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');
1847
- 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); }); });
1848
- return g;
1849
- }
1850
-
1851
- /* dg: layoutGraph's input, with `direction` ("right", the default, or "down"). memo: an object kept for the view between
1852
- calls; while the geometry key is the same, the geometry it holds is repainted and returned, and nothing is laid out or
1853
- routed. memo.runs counts the layouts. */
1854
- function layoutTyped(dg, memo) {
1855
- const key = memo && geometryKey(dg);
1856
- if (memo && memo.key === key) { const g = repaint(memo.g, memo.dg, dg); memo.dg = dg; return g; }
1857
- const t = { ...dg, typed: true }, g = dg.direction === 'down'
1858
- ? transpose(layoutGraph({ ...t, turned: true, nodes: (dg.nodes || []).map(n => ({ ...n, w: n.h || dg.nodeH || 64, h: n.w || NW })) }, false))
1859
- : layoutGraph(t, false);
1860
- if (memo) Object.assign(memo, { key, g, dg, runs: (memo.runs || 0) + 1 });
1861
- return g;
1862
- }
1863
1723
  /* ================= diagram engine (shared by document diagrams and the map) =================
1864
1724
  Style adapted from tt-a1i/archify (MIT): semantic palette, opaque mask under a translucent
1865
1725
  fill, per-kind sigils, grid canvas, orthogonal rounded routes, arrow markers. */
@@ -2209,7 +2069,7 @@ function mapContext(model, opts = {}) {
2209
2069
  // 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.
2210
2070
  // 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
2211
2071
  // own column, or a line to a box, places nothing; passes until nothing moves, if a map needs them
2212
- 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;
2072
+ 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;
2213
2073
  if (top != null) fwd.forEach(e => { const fin = holds(g, e.from); if (fin === holds(g, e.to)) return;
2214
2074
  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;
2215
2075
  const id = '\u0002' + far.length, left = q[0] < p[0]; fixed[id] = q[1] - top;
@@ -2232,7 +2092,10 @@ function mapContext(model, opts = {}) {
2232
2092
  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
2233
2093
  return P;
2234
2094
  }
2235
- const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
2095
+ // a typed view whose type lays it out (1.9): the plain flow's ranking, with the lines the type turns round for it
2096
+ 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
2097
+ const autoPos = vid => ownLayout(vid) ? flowPos(vid, { flip: ownLayout(vid).rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
2098
+ const memos = opts.memos || Object.create(null); // per view, layoutTyped's memo, kept across update()s: an edit that moves nothing lays out nothing
2236
2099
  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
2237
2100
  /* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
2238
2101
  neighbours, or packed from the top as before, with cards no line comes into beside their callee or not. A cheap
@@ -2252,15 +2115,16 @@ function mapContext(model, opts = {}) {
2252
2115
  const fanIn = vid => { const k = {}; return viewEdges(V[vid] || {}).some(e => e.from !== e.to && (k[e.to] = (k[e.to] || 0) + 1) >= 6); };
2253
2116
  const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
2254
2117
  function flowPos(vid, o = {}) {
2255
- const v = V[vid], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
2118
+ 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];
2119
+ const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
2256
2120
  // cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
2257
2121
  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]));
2258
2122
  const loose = lined.size ? all.filter(i => !lined.has(i)) : [], ids = all.filter(i => !loose.includes(i));
2259
- const rank = {}; ids.forEach(i => { rank[i] = 0; });
2123
+ const rank = Object.create(null); ids.forEach(i => { rank[i] = 0; });
2260
2124
  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; });
2261
2125
  const seen = id => { const k = lp.edges.findIndex(e => e.to === id || e.from === id); return k < 0 ? 999 : k; };
2262
2126
  const sink = i => isState(i) && !fwd.some(e => e.from === i); // state that leads nowhere sits along the bottom
2263
- const pos = {}, links = fwd.map(e => [e.from, e.to]);
2127
+ const pos = Object.create(null), links = fwd.map(e => [e.from, e.to]);
2264
2128
  const order = rk => { const cols = {}; ids.forEach(i => (cols[rk[i]] ||= []).push(i));
2265
2129
  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)));
2266
2130
  untangle(mains, links); return { cols, ranks, mains }; }; // in reading order, unless another order crosses less (#53)
@@ -2285,8 +2149,8 @@ function mapContext(model, opts = {}) {
2285
2149
  // with no layout (a change of notation draws the cards again and nothing else)
2286
2150
  const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
2287
2151
  function drawView(vid, g, opts = {}) {
2288
- const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid);
2289
- return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes }), shapes };
2152
+ 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
2153
+ 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 };
2290
2154
  }
2291
2155
  // the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
2292
2156
  let ruled = null;
@@ -2296,7 +2160,7 @@ function mapContext(model, opts = {}) {
2296
2160
  (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)); }); }); });
2297
2161
  return Object.keys(by).length ? by : null;
2298
2162
  };
2299
- const layoutCache = {};
2163
+ const layoutCache = Object.create(null); // M3
2300
2164
  // the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
2301
2165
  const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
2302
2166
  return k || (opensOf(nid, vid) && n > 0 ? Math.floor(n) : 0); };
@@ -2324,7 +2188,15 @@ function mapContext(model, opts = {}) {
2324
2188
  // 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
2325
2189
  // with no route, no way round the cards for them, no warning)
2326
2190
  function buildGeo(vid, override, repair = (V[vid] || {}).repair !== false, quick = false) { // a view's repair: false turns port repair off
2327
- const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
2191
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), T = ownLayout(vid);
2192
+ if (T) { // the type's layout (registry.js), in the host's measure (R2)
2193
+ const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
2194
+ if (k != null && memo.runKey === k) return memo.runGeo;
2195
+ const pos = override || (v.layout && L.pins !== false ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
2196
+ const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
2197
+ if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
2198
+ return g;
2199
+ }
2328
2200
  const edges = viewEdges(v).map(e => ({ ...e }));
2329
2201
  if (v.render === 'sequence') edges.forEach((e, i) => { e.step = i + 1; });
2330
2202
  const P = planCache[vid] || { flow: true, backKeys: loopsOf(v).keys, steps: {} };
@@ -2567,7 +2439,7 @@ function mapContext(model, opts = {}) {
2567
2439
  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); }); } }))();
2568
2440
  const geoCache = {};
2569
2441
  return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
2570
- layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2442
+ layoutCache, geoCache, memos, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2571
2443
  }
2572
2444
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
2573
2445
 
@@ -2722,6 +2594,9 @@ export function validate(model) {
2722
2594
  out.push(...typedProblems(m).filter(p => !(p.level === 'error' && said.has(p.path))));
2723
2595
  views.forEach((v, i) => { const at = `views[${i}].diagram`; // a well-formed name of a type this release does not draw (M13)
2724
2596
  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`); });
2597
+ const once = new Set(); // each view's type's rules, a problem once; a validate(model) that takes no view, once a type
2598
+ 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);
2599
+ 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); } }); });
2725
2600
  }
2726
2601
  return out;
2727
2602
  }
@@ -2965,6 +2840,7 @@ export function reach(model, viewId, from, options = {}) {
2965
2840
  /** A view in plain sentences: its cards and their kinds, its boxes, what each card's lines do (in run order on a sequence
2966
2841
  * view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
2967
2842
  export function describe(model, viewId) {
2843
+ 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)
2968
2844
  const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
2969
2845
  const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
2970
2846
  const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
@@ -3652,7 +3528,9 @@ function typedEditor(api) {
3652
3528
  const t = nodeTypes()[i], cam = api.cam(); if (!t || !can('add') || !cam) return;
3653
3529
  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() }]));
3654
3530
  }
3655
- const addLine = (from, to, t) => emit([{ op: 'add', element: { ...typed(t, 'line'), from, to }, view: api.view() }]);
3531
+ const hook = k => { const E = def().edits; return isRec(E) && typeof E[k] === 'function' ? E[k] : null; }; // registry.js edits
3532
+ const addLine = (from, to, t) => { const c = hook('connect'); // at (a slot): stage 3's
3533
+ 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() }]); };
3656
3534
  function connect(from, to) { // the relationship picker, when the type has more than one kind of line
3657
3535
  if (!can('add') || !from || !to || from === to) return;
3658
3536
  const types = lineTypes();
@@ -3735,15 +3613,17 @@ function typedEditor(api) {
3735
3613
  const off = can('rename') ? '' : ' disabled'; // the editable flags: a field the host does not offer is shown, to read
3736
3614
  const text = (f, v, name, more = '') => `<label>${esc(name)}<input data-f="${esc(f)}" data-was="${esc(v)}" value="${esc(v)}"${more}${off}></label>`;
3737
3615
  let h = `<h3>Properties${x.type != null ? ': ' + esc(x.type) : ''}</h3>` + text('label', x.label ?? '', 'Name');
3738
- schema.forEach(f => { const v = P[f.key], name = f.label || f.key;
3739
- 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>`;
3740
- 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>`;
3741
- else h += text('p:' + f.key, v ?? '', name, ` data-key="${esc(f.key)}"`); });
3616
+ 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)
3617
+ const key = ` data-key="${esc(f.key)}"${Array.isArray(f.path) ? ` data-path="${esc(JSON.stringify(f.path))}"` : ''}`;
3618
+ 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>`;
3619
+ 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>`;
3620
+ else h += text('p:' + f.key, f.kind === 'list' ? arrOf(v).join(', ') : v ?? '', name, key + (f.kind === 'list' ? ' data-list' : '')); });
3742
3621
  const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
3743
3622
  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>';
3744
- if (e.kind === 'node') {
3745
- const ms = arrOf(x.members).filter(m => isRec(m) && typeof m.id === 'string');
3746
- 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}>`
3623
+ const M = def().member;
3624
+ if (e.kind === 'node' && !(M && M.holds && !M.holds(x))) { // a comment holds none
3625
+ 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)
3626
+ 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}>`
3747
3627
  + (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('')
3748
3628
  + '</ul>' + (can('add') ? '<input data-f="new" data-new placeholder="New member, Enter adds it" aria-label="New member">' : '');
3749
3629
  }
@@ -3771,15 +3651,19 @@ function typedEditor(api) {
3771
3651
 
3772
3652
  function commit(t) { // a field of the properties panel: one transaction
3773
3653
  const d = t.dataset, at = elementsOf(model()), id = selId(at);
3654
+ 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)
3774
3655
  if (d.new != null) { const value = t.value.trim(), x = id && at.get(id); if (!value || !x || !can('add')) return;
3775
- const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = '';
3776
- return emit([{ op: 'add', element: { ...typed(prev || {}, 'member'), label: value }, owner: id }]); }
3656
+ const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
3657
+ const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
3658
+ 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 }]); }
3777
3659
  if (!can('rename') || (!id && d.m == null)) return;
3778
3660
  let value = t.type === 'checkbox' ? t.checked : t.value.trim();
3779
3661
  if (t.type !== 'checkbox') { if (t.value === d.was) return;
3780
3662
  if (value === '' && d.key == null) { t.value = d.was; return; } // an empty name does nothing, as on the map; an empty property is removed
3781
- d.was = t.value; if (value === '') value = undefined; }
3782
- const path = d.key == null ? 'label' : d.key.includes('.') ? ['props', d.key] : 'props.' + d.key;
3663
+ 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');
3664
+ t.removeAttribute('aria-invalid'); d.was = t.value; return emit(r.commands); }
3665
+ d.was = t.value; if (value === '') value = undefined; else if (d.list != null) value = value.split(',').map(x => x.trim()).filter(Boolean); }
3666
+ const path = d.path ? ['props', ...JSON.parse(d.path)] : d.key == null ? 'label' : d.key.includes('.') ? ['props', d.key] : 'props.' + d.key;
3783
3667
  emit([{ op: 'set', id: d.m ?? id, path, value }]);
3784
3668
  }
3785
3669
  function moveMember(id, by) {
@@ -3804,7 +3688,7 @@ function typedEditor(api) {
3804
3688
  else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
3805
3689
  panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
3806
3690
  panel.addEventListener('keydown', e => { e.stopPropagation(); const t = e.target, K = e.key; // the panel's keys stay in it
3807
- if (K === 'Escape') { if (t.tagName === 'INPUT' && t.type !== 'checkbox') t.value = t.dataset.was ?? ''; api.focus(); } // what was typed is not kept
3691
+ 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
3808
3692
  else if (K === 'Enter' && t.tagName === 'BUTTON') { e.preventDefault(); t.click(); }
3809
3693
  else if (K === 'Enter' && t.tagName === 'INPUT' && t.type !== 'checkbox') { e.preventDefault(); commit(t); }
3810
3694
  else if (e.altKey && t.dataset.m != null && (K === 'ArrowUp' || K === 'ArrowDown')) { e.preventDefault(); moveMember(t.dataset.m, K === 'ArrowUp' ? -1 : 1); } });
@@ -4625,7 +4509,7 @@ export function render(container, model, options = {}) {
4625
4509
  const old = ctx, keys = {}, wantNew = selectNew && !own;
4626
4510
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
4627
4511
  current = model2;
4628
- ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
4512
+ ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
4629
4513
  Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
4630
4514
  if (!own) selectNew = false;
4631
4515
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
@@ -5129,9 +5013,10 @@ const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Brows
5129
5013
  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
5130
5014
  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>`; };
5131
5015
  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)
5132
- const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
5016
+ 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
5017
+ 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))];
5133
5018
  // each card as its picture's notation draws it: its shape, its dash and its mark (the legend draws what the map draws)
5134
- const cards = gs.flatMap((g, i) => { const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
5019
+ const cards = gs.flatMap((g, i) => { if (g.legend) return []; const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
5135
5020
  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
5136
5021
  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-') }; }); });
5137
5022
  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; };
@@ -5149,7 +5034,7 @@ function legendItems(gs, opens = 'double-click to open', notes = [], icons = {})
5149
5034
  .concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
5150
5035
  .concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
5151
5036
  .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>`))
5152
- .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>'] : []);
5037
+ .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);
5153
5038
  }
5154
5039
 
5155
5040
  function pieces(X, el, o) {