umlimator 0.17.0 → 0.19.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,30 @@ 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.
112
+ 2.1's (notes/stage-2.md §3, §4.2, §8):
113
+ - boxes(model, view) -> [{ id, label, title, nodeIds, parent, shape, aria }]: the view's boxes, a map's container format plus
114
+ the drawn title, the shape whose frame(f) draws it (SHAPE, src/draw/shapes.js: its border with class family-box, its title
115
+ with class family-title, as svg.js says) and its accessible name. A box's id is in the view's nodeIds but takes no slot:
116
+ the layout context places its nodeIds as a block (blockPos), and the type's layout passes the same boxes to layoutTyped as
117
+ containers. A drop into, out of or between boxes emits reparent, and removes the node's pin; an edit that changes the box
118
+ a shown element is drawn in lays the view out again (render.js withPlaces). None: no boxes, as before.
119
+ - owners(model, element) -> [{ id, label }]: the properties panel's Owner field for that element, a select of these and
120
+ "none", which emits reparent (the element itself and what it holds are left out; its owner is added when not offered).
121
+ None, or not a list: no Owner field.
122
+ - layout.pins false: pos is {}, no ranking; layout.rank false: pos is the view's pins alone, no ranking (risks.md P1).
123
+ reroute is no registry field: reroute: false goes in the dg the type's own layout passes to layoutTyped (src/typed/layout.js). */
113
124
 
114
125
  /* 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
126
  const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram : 'map') || null;
127
+
128
+ /* What only a registered type's geometry uses, installed at load time by the files under src/typed/, which only
129
+ lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf, the
130
+ gestures of edit.js it takes over (edit). Empty in umlimator.js, where no view is laid out but as a map (risks.md C5) */
131
+ const TYPED = {};
132
+
133
+ const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
116
134
  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
135
  let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its warnings here: only the one kept says them
118
136
  /* f(), with the warnings it gives held back: [its result, what it would have said] */
@@ -173,7 +191,8 @@ export function history(model, limit = 100) {
173
191
  value undefined removes it. Through objects only; id, members, parent,
174
192
  from and to change by their own commands
175
193
  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
194
+ among the owner's members, else among the view's; `view` shows it there;
195
+ in 'view' (not 'model'): in its edges, nodes or fragments (registry.js)
177
196
  move { id, index, owner? } a member to another place among its owner's members, or to a new owner
178
197
  { id, view, index } an element or line to another place in a view's order (its nodeIds or edges)
179
198
  hide { id, view } out of one view, with all the view says of it: its place, pin, present,
@@ -181,14 +200,16 @@ export function history(model, limit = 100) {
181
200
  keeps it (risks.md E5). A line that lives only in that view goes with it
182
201
  delete { id } out of the model, with its members, what it owns, the lines to them, and
183
202
  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)
203
+ transaction is passed over, so a selection deletes in one transaction (E2).
204
+ What it held and stays goes to its nearest owner left
185
205
  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. */
206
+ 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
207
+ id space). */
188
208
 
189
209
  const isObj = x => x !== null && typeof x === 'object' && !Array.isArray(x), arr = x => (Array.isArray(x) ? x : []);
190
210
  const UNSAFE = new Set(['__proto__', 'constructor', 'prototype']); // never a key a command writes (risks.md M3)
191
211
  const isLine = x => isObj(x) && (x.from != null || x.to != null);
212
+ const dig = (x, path) => path.split('.').reduce((o, k) => (isObj(o) ? o[k] : undefined), x);
192
213
 
193
214
  /* Every element by id: { x, list, kind, owner, view } (owner: the element whose member it is; view: the view a line or
194
215
  fragment of its own lives in) */
@@ -205,7 +226,7 @@ function index(m) {
205
226
  }
206
227
 
207
228
  /* 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.
229
+ part, prop, view, pin, present, override, subject, story, lens, rule. Neither the element itself nor its members are listed.
209
230
  only(viewIndex): look in those views and their story steps only (hide), else in all of the model. */
210
231
  function refs(m, ids, only) {
211
232
  const out = [], say = (path, kind) => out.push({ path, kind }), has = id => typeof id === 'string' && ids.has(id);
@@ -213,11 +234,14 @@ function refs(m, ids, only) {
213
234
  if (has(x.parent)) say([...at, 'parent'], 'parent');
214
235
  if (line) ['from', 'to'].forEach(k => { if (has(x[k])) say([...at, k], 'end');
215
236
  if (has((((x.props || {}).ends || {})[k] || {}).part)) say([...at, 'props', 'ends', k, 'part'], 'part'); });
237
+ Object.keys(propRefs(x.type)).forEach(p => { const v = dig(x, p), ks = p.split('.');
238
+ if (has(v)) say([...at, ...ks], 'prop'); else arr(v).forEach((id, j) => { if (has(id)) say([...at, ...ks, j], 'prop'); }); });
216
239
  arr(x.members).forEach((y, j) => element(y, [...at, 'members', j], false)); };
217
240
  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
241
  const vids = new Set();
219
242
  arr(m.views).forEach((v, i) => { if (!isObj(v) || (only && !only(i))) return; const at = ['views', i]; vids.add(v.id);
220
243
  ['nodeIds', 'tour', 'stepIds'].forEach(k => arr(v[k]).forEach((id, j) => { if (has(id)) say([...at, k, j], 'view'); }));
244
+ arr(v.nodes).forEach((n, j) => element(n, [...at, 'nodes', j], false));
221
245
  arr(v.edges).forEach((e, j) => { if (has(e)) say([...at, 'edges', j], 'view'); else element(e, [...at, 'edges', j], true); });
222
246
  arr(v.fragments).forEach((f, j) => element(f, [...at, 'fragments', j], false));
223
247
  [['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 +319,18 @@ function run(m, c, gone) {
295
319
  }
296
320
  case 'add': {
297
321
  if ('to' in c) throw new Error('add: the owner of a member is `owner`');
322
+ 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
323
  checkNew(c.element, at);
299
324
  const x = structuredClone(c.element), owner = c.owner != null ? need(c.owner) : null;
325
+ if (c.in === 'view') {
326
+ if (c.view == null || owner) throw new Error('add: in \'view\' needs a view, and no owner');
327
+ const v = viewOf(c.view), T = diagramOf(v) || {}, pal = arr(T.elements);
328
+ const k = isLine(x) || arr(T.items).includes(x.type) ? 'edges' : !pal.length || pal.some(t => isObj(t) && t.type === x.type) ? 'nodes' : 'fragments';
329
+ 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`);
330
+ const list = (v[k] ||= []);
331
+ 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);
332
+ return;
333
+ }
300
334
  if (owner && owner.kind !== 'node' && owner.kind !== 'member' && owner.kind !== 'fragment') throw new Error(`add: "${c.owner}" holds no members`);
301
335
  const v = c.view != null ? viewOf(c.view) : null, list = owner ? (owner.x.members ||= []) : isLine(x) ? (m.edges ||= []) : (m.nodes ||= []);
302
336
  list.splice(owner ? place(list, c.index) : list.length, 0, x);
@@ -326,19 +360,24 @@ function run(m, c, gone) {
326
360
  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
361
  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
362
  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'));
363
+ clear(m, refs(m, ids, i => i === vi).filter(r => !['end', 'parent', 'part', 'prop'].includes(r.kind)));
330
364
  return;
331
365
  }
332
366
  case 'delete': {
333
367
  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();
368
+ need(c.id); const now = new Set(), kids = new Map();
369
+ 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
370
  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
371
+ arr(r.x.members).forEach(y => isObj(y) && take(y.id)); arr(kids.get(id)).forEach(take); }; // what it owns goes with it
338
372
  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; } }
373
+ // lines to what is gone go too, lines to those lines, and what a type's refs cascade (a reply with its call)
374
+ const cascade = x => Object.entries(propRefs(x.type)).some(([p, how]) => how === 'cascade' && [].concat(dig(x, p)).some(id => now.has(id)));
375
+ 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; } }
376
+ // what stays in something gone goes to its nearest owner left; an operand's messages to its fragment's parent
377
+ const above = id => { const r = at.get(id); return r && (r.x.parent ?? (r.owner && above(r.owner.id))); };
378
+ for (const [k, o] of at) if (!now.has(k) && now.has(o.x.parent)) { let p = o.x.parent;
379
+ for (let n = 0; p != null && now.has(p) && n++ < at.size;) p = above(p);
380
+ if (p != null && !now.has(p)) o.x.parent = p; else delete o.x.parent; }
342
381
  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
382
  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
383
  now.forEach(id => gone.add(id));
@@ -479,60 +518,10 @@ const SHAPE = {
479
518
  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
519
  + `<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
520
  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
521
  };
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
522
+ // what every shape reports (1.5): size(n), the box layout gives it (a map's card: mapNodeW wide, a row high; the classifier,
523
+ // 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
524
  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
525
  const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
537
526
  // 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
527
  const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
@@ -610,51 +599,21 @@ function roundPath(pts, r = 9) {
610
599
  }
611
600
  const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
612
601
  }
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
602
  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 } */
603
+ /* SVG markup. opts: { childCount(id) -> n, subject: id, title, under, over } */
646
604
  function svgOf(geo, opts = {}) {
647
605
  const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
606
+ const LINE = map ? 'var(--um-arrow)' : 'var(--um-muted)'; // a typed view's lines: 3:1 or more in both themes
648
607
  const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
649
608
  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)
609
+ 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
610
  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
611
  const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
653
612
  if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
654
613
  if (st === 'removed') return { c: 'var(--um-rose)', m: 'r', dash: '5 5', op: .7 };
655
614
  if (st === 'security') return { c: 'var(--um-rose)', m: 'r', dash: '5 5' };
656
615
  if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
657
- return { c: 'var(--um-arrow)', m: 'd', dash: '' };
616
+ return { c: LINE, m: 'd', dash: '' };
658
617
  };
659
618
  // 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
619
  const broke = e => opts.broken ? [...new Set((e.all || [e]).flatMap(x => opts.broken[x._id] || []))] : [];
@@ -662,11 +621,8 @@ function svgOf(geo, opts = {}) {
662
621
  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
622
  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
623
  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]})`])) };
624
+ 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
625
  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
626
  // 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
627
  const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
672
628
  // 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,12 +630,17 @@ function svgOf(geo, opts = {}) {
674
630
  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
631
  : nodes.map(n => [n, n.shape != null && cardLook(n, 'box', false, null)]).filter(x => x[1]));
676
632
  const drawn = edges.map(e => map && looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
677
- 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>`;
633
+ 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)
634
+ let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="group" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
635
+ <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
636
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
680
637
  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)
638
+ // a typed view's box whose shape draws a frame (notes/stage-2.md §5): the shape's markup, in the group the canvas selects. It keeps
639
+ // .family-title (the audit, the canvas's "Box …, selected") and draws its border with class family-box: the canvas outlines a
640
+ // selected box and the box under a drag by .family-box alone (src/canvas/styles.js)
641
+ if (!map && hasKey(SHAPE, f.shape) && SHAPE[f.shape].frame) { s += `<g class="family" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.aria ? ` role="img" aria-label="${esc(f.aria)}"` : ''}>${SHAPE[f.shape].frame(f)}</g>`; return; }
681
642
  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"' : ''}/>
682
- <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
643
+ <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(!map && f.title || f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
683
644
  <g class="fold-hint"><title>Fold back into one card (E, or Alt + click a card in it)</title><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="16" rx="8" class="n-mask" stroke="var(--um-accent)"/><text x="${t.x + t.w / 2}" y="${f.y + 4}" text-anchor="middle" class="badge-tx" fill="var(--um-accent)">⤡</text></g>` : `
684
645
  <g class="pub-tab" data-badge="${t.badge}"><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="15" rx="4" fill="${t.fill}"/><text x="${t.x + t.w / 2}" y="${f.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`).join('')}</g>`;
685
646
  });
@@ -711,7 +672,7 @@ function svgOf(geo, opts = {}) {
711
672
  return { d: roundPath(P), legs, at };
712
673
  })() : null;
713
674
  const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
714
- s += '<g class="edges">';
675
+ s += (opts.under || '') + '<g class="edges">'; // a type's layers (registry.js draw)
715
676
  (geo.intake || []).forEach(b => {
716
677
  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
678
  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 +681,9 @@ function svgOf(geo, opts = {}) {
720
681
  ${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
682
  });
722
683
  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})"` : ''}`;
684
+ 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 : '');
685
+ 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)
686
+ const marks = E ? E.marks : `marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}`;
727
687
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
728
688
  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
689
  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 +691,13 @@ function svgOf(geo, opts = {}) {
731
691
  <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
692
  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
693
  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>`;
694
+ <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
695
  });
736
696
  // 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
697
  // line, rather than across the card's name
738
698
  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
699
  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)
700
+ 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
701
  const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
742
702
  const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
743
703
  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';
@@ -844,15 +804,15 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
844
804
  ${parts.join('\n')}
845
805
  </g>`;
846
806
  });
847
- if (map) { // each line's focus target: after the cards, so lines follow them in the Tab order (#43), under the labels; the mouse still hits the line
848
- s += '</g><g class="keys">';
849
- edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
850
- 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
- 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
- }
853
- s += '</g><g class="labels depth-read">';
807
+ // focus targets after the cards (#43), under the labels: each line's (a typed one's named by e.aria), or one per id of geo.order (notes/stage-3.md S13)
808
+ const ring = (a, name, d) => `<g class="um-key line-key${a}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${d}" class="key-ring"/></g>`;
809
+ const line = (e, i, said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '') => ring(`${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}`, !map && e.aria != null ? e.aria
810
+ : `${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('; ')}` : ''}`, roundPath(drawn[i]));
811
+ s += '</g><g class="keys">' + (!map && Array.isArray(geo.order) ? geo.order.map(id => { const n = hasKey(geo.byId || {}, id) && geo.byId[id], i = edges.findIndex(e => e.id === id);
812
+ return n ? ring(` node-key" data-node="${esc(id)}`, n.aria || n.label || id, `M${n.x} ${n.y}h${n.w}v${n.h}h${-n.w}z`) : i < 0 ? '' : line(edges[i], i); }) : edges.map((e, i) => line(e, i))).join('');
813
+ s += '</g>' + (opts.over || '') + '<g class="labels depth-read">';
854
814
  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('');
815
+ if (T) s += T.labels(i);
856
816
  if (!e.lbl) return;
857
817
  const L = e.lbl;
858
818
  if (map) {
@@ -892,10 +852,10 @@ const labelW = e => textW(fit((e.step != null ? e.step + ' · ' : '') + (e.label
892
852
  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
853
  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
854
  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
855
+ function untangle(cols, links, h = () => 1, fixed = Object.create(null)) { // maps by id: no prototype (M3)
856
+ const colOf = Object.create(null); cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
857
+ 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;
858
+ 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
859
  const at0 = tops();
900
860
  links.forEach(([a, b, oa, ob], n) => {
901
861
  const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
@@ -907,7 +867,7 @@ function untangle(cols, links, h = () => 1, fixed = {}) {
907
867
  });
908
868
  if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
909
869
  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]); });
870
+ 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
871
  const crossings = () => { const at = tops(); let n = 0;
912
872
  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
873
  if (colOf[a] === colOf[c] && (at[a] + oa - at[c] - oc) * (at[b] + ob - at[d] - od) < 0) n++; }
@@ -937,9 +897,10 @@ function pullSources(ids, links, rank) {
937
897
  adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
938
898
  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
899
  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
900
+ function rowsFor(cols, links, h = () => 1, fixed = Object.create(null)) {
901
+ const row = Object.create(null); if (!cols.length) return row; // an empty view
902
+ 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; }); });
903
+ 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
904
  links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
944
905
  (outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
945
906
  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 +909,7 @@ function rowsFor(cols, links, h = () => 1, fixed = {}) {
948
909
  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
910
  off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
950
911
  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 = {};
912
+ for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = Object.create(null);
952
913
  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
914
  fit(cols[k], want); }
954
915
  const low = Math.min(...Object.keys(row).filter(id => !(id in fixed)).map(id => row[id]));
@@ -1037,7 +998,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1037
998
  cols.forEach(c => { const ns = nodes.filter(n => n.col === c && !n.aside); if (ns.length) colW[c] = Math.max(...ns.map(n => n.w)); });
1038
999
  // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side (a
1039
1000
  // numbered line with no label: for its step mark). A typed view's too, so lines between one pair keep their own labels (risks.md F2)
1040
- const gapAfter = {}, colOf = {}, boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if ((map || dg.typed) && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
1001
+ const gapAfter = {}, colOf = Object.create(null), boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if ((map || dg.typed) && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
1041
1002
  // labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
1042
1003
  // so the pills sit next to each other: the gap fits them all, with 6 px between pills
1043
1004
  const sum = {};
@@ -1054,29 +1015,12 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1054
1015
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
1055
1016
  [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
1017
  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
1018
+ // 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)
1019
+ const loops = Object.create(null), LP = dg.typed && !dg.abs ? TYPED.loops(loops, dg, { byId, extraX, extraY, STUB, RG, CG, SEP, gapAfter }) : null;
1020
+ const rows = dg.typed && TYPED.rows(dg, nodes, pitch, NH, RG); // a typed view's rows (src/typed/layout.js)
1077
1021
  const gap = c => Math.max(CG, gapAfter[c] || 0) + (extraX[c] || 0);
1078
1022
  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);
1023
+ const rowY = r => Object.keys(extraY).reduce((y, k) => +k < r ? y + extraY[k] : y, rows ? rows.at(r) : r * pitch);
1080
1024
  const place = () => {
1081
1025
  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
1026
  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; } });
@@ -1087,18 +1031,23 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1087
1031
  else place();
1088
1032
  // Family frames enclose ordinary cards, so clicks, ports and focus keep their existing owners.
1089
1033
  // Children contribute their entire frame to their parent's bounds, rather than just their cards.
1090
- const familyDefs = dg.containers || [], tagged = n => map && (n.author || n.k === 'm-ext'); // tagged: a name tag hangs under the card
1091
- const framesNow = () => { const cache = {};
1034
+ // A typed view's boxes (notes/stage-2.md §3.2) take the same code as a map's: their title is the type's (`title`), and a box
1035
+ // grows to hold it; laid out turned (direction down), the 24 px header room goes left, the side that becomes the top
1036
+ const familyDefs = dg.containers || [], tagged = n => map && (n.author || n.k === 'm-ext'), TB = !map && dg.turned; // tagged: a name tag hangs under the card
1037
+ const headW = f => textW(!map && f.title || f.label || f.id, 11) + 16; // a box's title, as the audit measures it
1038
+ const framesNow = () => { const cache = Object.create(null); // a box may be named 'constructor'
1092
1039
  const frameOf = (f, trail = new Set()) => {
1093
1040
  if (cache[f.id]) return cache[f.id];
1094
1041
  if (trail.has(f.id)) return null;
1095
1042
  const next = new Set([...trail, f.id]);
1096
1043
  const children = familyDefs.filter(c => c.parent === f.id).map(c => frameOf(c, next)).filter(Boolean);
1097
1044
  const members = (f.nodeIds || []).map(id => byId[id]).filter(Boolean);
1098
- const rects = [...members.map(n => ({x:n.x - 36,y:n.y - 14,w:n.w + 72,h:n.h + 44 + (tagged(n) ? 14 : 0)})), ...children];
1045
+ const rects = [...members.map(n => TB ? { x: n.x - 14, y: n.y - 36, w: n.w + 44, h: n.h + 72 } : {x:n.x - 36,y:n.y - 14,w:n.w + 72,h:n.h + 44 + (tagged(n) ? 14 : 0)}), ...children];
1099
1046
  if (!rects.length) return null;
1100
- const p = 12, x = Math.min(...rects.map(r => r.x)) - p, y = Math.min(...rects.map(r => r.y)) - p - 24;
1101
- return cache[f.id] = {...f, x, y, w:Math.max(...rects.map(r => r.x+r.w)) + p - x, h:Math.max(...rects.map(r => r.y+r.h)) + p - y};
1047
+ const p = 12, x = Math.min(...rects.map(r => r.x)) - p - (TB ? 24 : 0), y = Math.min(...rects.map(r => r.y)) - p - (TB ? 0 : 24);
1048
+ const b = {...f, x, y, w:Math.max(...rects.map(r => r.x+r.w)) + p - x, h:Math.max(...rects.map(r => r.y+r.h)) + p - y};
1049
+ if (!map) b[TB ? 'h' : 'w'] = Math.max(b[TB ? 'h' : 'w'], headW(f) + 16);
1050
+ return cache[f.id] = b;
1102
1051
  };
1103
1052
  const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
1104
1053
  if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
@@ -1109,17 +1058,19 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1109
1058
  };
1110
1059
  // Boxes keep apart: a column (row) gap widens wherever a box would come within 32 px of another box, or of a card outside
1111
1060
  // it, so their borders never touch and a line can run between them. Frames are measured again until nothing is too close.
1112
- if (map && !dg.abs && familyDefs.length) for (let pass = 0, BOXGAP = 32; pass < 4; pass++) {
1061
+ if (!dg.abs && familyDefs.length) for (let pass = 0, BOXGAP = 32; pass < 4; pass++) {
1113
1062
  const within = (a, b) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
1114
1063
  const span = ns => ns.length ? { c0: Math.min(...ns.map(n => n.col)), c1: Math.max(...ns.map(n => n.col)), r0: Math.min(...ns.map(n => n.row)), r1: Math.max(...ns.map(n => n.row)) } : null;
1115
1064
  const fr = framesNow().map(f => ({ ...f, s: span(nodes.filter(n => within(n, f))) })).filter(f => f.s);
1116
1065
  const cards = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h + (tagged(n) ? 14 : 0), s: span([n]) }));
1117
1066
  const wantX = {}, wantY = {}, want = (o, k, v) => { if (v > 0) o[k] = Math.max(o[k] || 0, Math.ceil(v)); };
1067
+ const at = v => map ? Math.floor(v) : v; // the room goes after that row; a typed view's: after a half row too, so a card on row .5 leaves a box on 1.5
1068
+ // a column's room goes after a whole column k (gap(k) reads no other), and moves a column c in (k, k + 1) by c - k of it
1118
1069
  fr.forEach((A, i) => [...fr.slice(i + 1), ...cards].forEach(B => {
1119
1070
  if (within(A, B) || within(B, A) || !hit(A, B, BOXGAP)) return;
1120
1071
  const a = A.s, b = B.s;
1121
- if (a.c1 < b.c0 || b.c1 < a.c0) { const [L, R] = a.c1 < b.c0 ? [A, B] : [B, A]; want(wantX, Math.floor(L.s.c1), L.x + L.w + BOXGAP - R.x); }
1122
- else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY, Math.floor(T.s.r1), T.y + T.h + BOXGAP - U.y); }
1072
+ if (a.c1 < b.c0 || b.c1 < a.c0) { const [L, R] = a.c1 < b.c0 ? [A, B] : [B, A], k = Math.floor(L.s.c1); want(wantX, k, (L.x + L.w + BOXGAP - R.x) / (Math.min(1, R.s.c0 - k) - L.s.c1 + k)); }
1073
+ else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY, at(T.s.r1), T.y + T.h + BOXGAP - U.y); }
1123
1074
  }));
1124
1075
  if (!Object.keys(wantX).length && !Object.keys(wantY).length) break;
1125
1076
  Object.entries(wantX).forEach(([k, v]) => { extraX[k] = (extraX[k] || 0) + v; }); Object.entries(wantY).forEach(([k, v]) => { extraY[k] = (extraY[k] || 0) + v; });
@@ -1164,9 +1115,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1164
1115
  route exists; earlier labels are obstacles, so later routes keep clear of them. */
1165
1116
  // an end may be a container: the line ends on its box's border, whose title the ports keep clear of (not a box and
1166
1117
  // something inside it: that has no line)
1167
- const boxEnd = map ? Object.fromEntries(families.map(f => [f.id, { id: f.id, label: f.label || f.id, box: true, x: f.x, y: f.y, w: f.w, h: f.h,
1168
- decoT: [[f.x + 8, Math.min(f.w - 16, textW(f.label || f.id, 11) + 16)], ...(f.tabs || []).map(t => [t.x - 6, t.w + 12])], decoB: [] }])) : {};
1169
- const end = id => byId[id] || boxEnd[id], holds = (b, n) => b.box && n !== b && n.x >= b.x && n.y >= b.y && n.x + n.w <= b.x + b.w && n.y + n.h <= b.y + b.h;
1118
+ const headOf = f => Math.min((TB ? f.h : f.w) - 16, headW(f)); // the length of a box's title along its header
1119
+ const boxEnd = Object.fromEntries(families.map(f => [f.id, { id: f.id, label: f.label || f.id, box: true, x: f.x, y: f.y, w: f.w, h: f.h, hd: TB ? headOf(f) : 0,
1120
+ decoT: TB ? [] : [[f.x + 8, headOf(f)], ...(f.tabs || []).map(t => [t.x - 6, t.w + 12])], decoB: [] }]));
1121
+ const endOf = Object.assign(Object.create(null), boxEnd, byId), end = id => endOf[id], holds = (b, n) => b.box && n !== b && n.x >= b.x && n.y >= b.y && n.x + n.w <= b.x + b.w && n.y + n.h <= b.y + b.h;
1170
1122
  const edges = (dg.edges || []).filter(e => { const s = end(e.from), t = end(e.to); if (!s || !t || (e.from === e.to && !loops[e.from])) return false;
1171
1123
  if (holds(s, t) || holds(t, s)) { warn(`no line for edge ${e.id ?? e.from + '->' + e.to}: it joins the box "${(holds(s, t) ? s : t).id}" and something inside it`); return false; }
1172
1124
  return true; }).map(e => ({ ...e, s: end(e.from), t: end(e.to) }));
@@ -1191,7 +1143,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1191
1143
  if (near && !through([...pts, ...up], e)) { e.fixed = [...pts, ...up]; e.under = k + 1; }
1192
1144
  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
1145
  });
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
1146
+ if (LP) LP.mark(edges);
1195
1147
  const free = edges.filter(e => !e.fixed);
1196
1148
  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
1149
  const sides = {};
@@ -1226,10 +1178,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1226
1178
  });
1227
1179
  free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
1228
1180
  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 }); });
1181
+ if (LP) LP.ports(pushSide);
1233
1182
  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
1183
  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
1184
  const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
@@ -1237,8 +1186,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1237
1186
  if (n.box) { // each port in line with its line's other end where the border allows (a straight line): below the title on
1238
1187
  // a side, off the title, tabs and corners on top or bottom; in that order, 16 (26) px apart
1239
1188
  const want = it => { const q = it.e.s === n ? it.e.p2 : it.e.p1, o = it.other; return q ? q[vertical ? 1 : 0] : vertical ? o.y + o.h / 2 : o.x + o.w / 2; };
1240
- const sp = vertical ? [n.y + 40, n.y + n.h - 16] : sideSpan(n, side) || [n.x, n.x + n.w];
1241
- let lo = vertical ? sp[0] : Math.max(sp[0], n.x + 20), hi = vertical ? sp[1] : Math.min(sp[1], n.x + n.w - 20); if (hi < lo) lo = hi = (lo + hi) / 2;
1189
+ // (turned: the title runs down the left side, and the sides across the header room are the top and bottom)
1190
+ const across = TB ? !vertical : vertical, a0 = vertical ? n.y : n.x, len = vertical ? n.h : n.w;
1191
+ const sp = across ? [a0 + 40, a0 + len - 16] : TB ? [side === 'L' ? n.y + n.hd + 16 : n.y, n.y + n.h] : sideSpan(n, side) || [n.x, n.x + n.w];
1192
+ let lo = across ? sp[0] : Math.max(sp[0], a0 + 20), hi = across ? sp[1] : Math.min(sp[1], a0 + len - 20); if (hi < lo) lo = hi = (lo + hi) / 2;
1242
1193
  const ws = list.map(want), order = list.map((_, i) => i).sort((a, b) => ws[a] - ws[b]), gap = Math.min(vertical ? 16 : 26, (hi - lo) * 0.84 / list.length);
1243
1194
  const v = order.map(i => Math.min(hi, Math.max(lo, ws[i])));
1244
1195
  for (let k = 1; k < v.length; k++) v[k] = Math.max(v[k], v[k - 1] + gap);
@@ -1267,31 +1218,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1267
1218
  });
1268
1219
  });
1269
1220
  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); }); }); }
1221
+ const corners = LP ? LP.route() : []; // a loop's corners: obstacles to the other routes
1222
+ 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
1223
  const BEND = map ? 60 : 26, CROSS = 900; // maps: few bends
1296
1224
  const OUT = { R: [1, 0], L: [-1, 0], T: [0, -1], B: [0, 1] };
1297
1225
  const nodeRects = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
@@ -1301,7 +1229,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1301
1229
  nodes.forEach(n => gapLinesY.push(n.y - RG / 2, n.y + n.h + RG / 2));
1302
1230
  } else {
1303
1231
  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));
1232
+ [...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
1233
  }
1306
1234
  const routed = [], reserved = [];
1307
1235
  const inRect = (p, r) => p[0] > r.x && p[0] < r.x + r.w && p[1] > r.y && p[1] < r.y + r.h;
@@ -1335,9 +1263,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1335
1263
  const keep = new Set([st[0], en[0], st[1], en[1]]);
1336
1264
  avoid.forEach(([a, b]) => { if (a[0] === b[0]) keep.add(a[0]); else keep.add(a[1]); }); // a route's own line is never merged away
1337
1265
  const co = vals => vals.filter(v => !W || keep.has(v) || true).sort((a, b) => a - b).filter((v, i, s) => i === 0 || keep.has(v) || v - s[i - 1] >= 5);
1338
- if (map) families.forEach(f => { xs.add(f.x - 16); xs.add(f.x + f.w + 16); ys.add(f.y - 16); ys.add(f.y + f.h + 16); });
1266
+ families.forEach(f => { xs.add(f.x - 16); xs.add(f.x + f.w + 16); ys.add(f.y - 16); ys.add(f.y + f.h + 16); });
1339
1267
  let X = co([...xs]), Y = co([...ys]);
1340
- if (map) { const far = (v, bs) => keep.has(v) || bs.every(u => Math.abs(v - u) >= 8);
1268
+ if (families.length) { const far = (v, bs) => keep.has(v) || bs.every(u => Math.abs(v - u) >= 8);
1341
1269
  X = X.filter(v => far(v, families.flatMap(f => [f.x, f.x + f.w]))); Y = Y.filter(v => far(v, families.flatMap(f => [f.y, f.y + f.h]))); }
1342
1270
  if (W) { X = X.filter(v => keep.has(v) || (v >= W.x && v <= W.x2)); Y = Y.filter(v => keep.has(v) || (v >= W.y && v <= W.y2)); }
1343
1271
  const nx = X.length, ny = Y.length, N = nx * ny;
@@ -1521,7 +1449,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1521
1449
  const runRouting = order => {
1522
1450
  routed.length = 0; reserved.length = 0; placed.length = 0; stats = {};
1523
1451
  (dg.blocks || []).forEach(b => { reserved.push(b); placed.push(b); });
1524
- if (map) families.forEach(f => { const h = { x: f.x + 8, y: f.y + 3, w: Math.min(f.w - 16, textW(f.label || f.id, 11) + 16), h: 22 }; reserved.push(h); placed.push(h);
1452
+ families.forEach(f => { const h = TB ? { x: f.x + 3, y: f.y + 8, w: 22, h: headOf(f) } : { x: f.x + 8, y: f.y + 3, w: headOf(f), h: 22 }; reserved.push(h); placed.push(h);
1525
1453
  placed.push(...[{ x: f.x, y: f.y - 3, w: f.w, h: 6 }, { x: f.x, y: f.y + f.h - 3, w: f.w, h: 6 }, { x: f.x - 3, y: f.y, w: 6, h: f.h }, { x: f.x + f.w - 3, y: f.y, w: 6, h: f.h }].map(r => ({ ...r, band: true }))); }); // lane headers and arrows stay clear
1526
1454
  edges.filter(e => e.fixed).forEach(e => { routed.push(e); if (e.under || e[OWN_ROUTE]) { placeLabel(e); stats[e.how] = (stats[e.how] || 0) + 1; return; } if (e.lbl) { placed.push(e.lbl); reserved.push({ x: e.lbl.x - 3, y: e.lbl.y - 3, w: e.lbl.w + 6, h: e.lbl.h + 6 }); } stats.comb = (stats.comb || 0) + 1; });
1527
1455
  order.forEach(e => {
@@ -1550,8 +1478,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1550
1478
  let bad = runRouting(order);
1551
1479
  // rip-up and reroute: an edge that had to cross goes first next time; keep the best attempt (not when the view is laid
1552
1480
  // out again with room for lines: that layout's tries seldom gained anything, and they cost a third of its time; with room for
1553
- // a label it tries, as the layout it may replace did)
1554
- for (let tries = 0; bad && (!last || room.lx) && tries < 4; tries++) {
1481
+ // a label it tries, as the layout it may replace did; none with reroute: false)
1482
+ for (let tries = 0; bad && dg.reroute !== false && (!last || room.lx) && tries < 4; tries++) {
1555
1483
  const stuck = order.filter(e => e.how !== 'grid');
1556
1484
  const next = [...stuck, ...order.filter(e => e.how === 'grid')];
1557
1485
  const snap = order.map(e => [e, e.pts, e.lbl, e.how]), snapStats = stats, snapR = [routed.slice(), reserved.slice(), placed.slice()];
@@ -1675,6 +1603,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1675
1603
  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
1604
  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
1605
  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); });
1606
+ if (!xs.length) xs.push(0), ys.push(0); // a view with nothing in it: an empty stage
1678
1607
  const M = 36, minX = Math.min(...xs) - M, minY = Math.min(...ys) - M;
1679
1608
  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
1609
  }
@@ -1817,49 +1746,6 @@ function auditSvg(svg, geo, where) {
1817
1746
  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
1747
  return issues;
1819
1748
  }
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
1749
  /* ================= diagram engine (shared by document diagrams and the map) =================
1864
1750
  Style adapted from tt-a1i/archify (MIT): semantic palette, opaque mask under a translucent
1865
1751
  fill, per-kind sigils, grid canvas, orthogonal rounded routes, arrow markers. */
@@ -2162,7 +2048,7 @@ function mapContext(model, opts = {}) {
2162
2048
  /* a layout may place only some cards: they keep their slots, the rest take their auto-layout slot or the next free
2163
2049
  row below it (a complete layout is used as it is) */
2164
2050
  function fillLayout(vid, L) {
2165
- const ids = (V[vid].nodeIds || []).filter(i => N[i]);
2051
+ const bx = boxesOf(vid), ids = (V[vid].nodeIds || []).filter(i => N[i] && !bx.some(b => b.id === i)); // a box takes its place from what it holds
2166
2052
  if (ids.every(i => Array.isArray(L[i]))) return L;
2167
2053
  const auto = autoPos(vid), out = {};
2168
2054
  ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
@@ -2177,8 +2063,9 @@ function mapContext(model, opts = {}) {
2177
2063
  lines come from and go to outside it too, where the pass before put them; then ordered by those lines as well. Its far
2178
2064
  ends are items at their rows, in a column before its first (a line from the left) or after its last. A box's cards were
2179
2065
  placed by its own lines alone, so a box fed from outside, and the lines that pass it, ran into the cards beside it. */
2180
- function blockPos(vid) {
2181
- const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
2066
+ // A typed view whose type gives boxes (notes/stage-2.md §3.2) passes them, T: { boxes, flip }; their ids are not cards
2067
+ function blockPos(vid, T) {
2068
+ const v = T ? flipView(vid, T.flip) : V[vid], boxes = T ? T.boxes : (v.containers || []).filter(c => c && c.id), ids = (v.nodeIds || []).filter(i => N[i] && !(T && boxes.some(b => b.id === i))), lp = loopsOf(v);
2182
2069
  const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to), byBox = Object.fromEntries(boxes.map(b => [b.id, b]));
2183
2070
  const home = Object.fromEntries(ids.map(i => [i, (boxes.filter(b => (b.nodeIds || []).includes(i)).pop() || {}).id ?? null])); // the box a card is drawn in
2184
2071
  const up = x => ids.includes(x) ? home[x] : byBox[x] && byBox[byBox[x].parent] ? byBox[x].parent : null; // the box a card or box sits in
@@ -2209,7 +2096,7 @@ function mapContext(model, opts = {}) {
2209
2096
  // 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
2097
  // 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
2098
  // 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;
2099
+ 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
2100
  if (top != null) fwd.forEach(e => { const fin = holds(g, e.from); if (fin === holds(g, e.to)) return;
2214
2101
  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
2102
  const id = '\u0002' + far.length, left = q[0] < p[0]; fixed[id] = q[1] - top;
@@ -2232,7 +2119,13 @@ function mapContext(model, opts = {}) {
2232
2119
  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
2120
  return P;
2234
2121
  }
2235
- const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
2122
+ // a typed view whose type lays it out (1.9): the plain flow's ranking, with the lines the type turns round for it
2123
+ 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
2124
+ // its boxes (registry.js): the type's, from the model; a map's are its containers
2125
+ const boxesOf = vid => { const T = ownLayout(vid); return T && T.boxes ? arrOf(T.boxes(D, V[vid])).filter(b => b && b.id != null) : []; };
2126
+ const autoPos = vid => { const T = ownLayout(vid), bx = boxesOf(vid);
2127
+ return T ? bx.length ? blockPos(vid, { boxes: bx, flip: T.rankFlip }) : flowPos(vid, { flip: T.rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid); };
2128
+ 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
2129
  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
2130
  /* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
2238
2131
  neighbours, or packed from the top as before, with cards no line comes into beside their callee or not. A cheap
@@ -2251,16 +2144,18 @@ function mapContext(model, opts = {}) {
2251
2144
  let scouting = false;
2252
2145
  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
2146
  const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
2147
+ const flipView = (vid, flip) => flip ? { ...V[vid], edges: viewEdges(V[vid]).map(e => flip(e) ? { ...e, from: e.to, to: e.from } : e) } : V[vid];
2254
2148
  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);
2149
+ const v = flipView(vid, o.flip);
2150
+ const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
2256
2151
  // 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
2152
  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
2153
  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; });
2154
+ const rank = Object.create(null); ids.forEach(i => { rank[i] = 0; });
2260
2155
  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
2156
  const seen = id => { const k = lp.edges.findIndex(e => e.to === id || e.from === id); return k < 0 ? 999 : k; };
2262
2157
  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]);
2158
+ const pos = Object.create(null), links = fwd.map(e => [e.from, e.to]);
2264
2159
  const order = rk => { const cols = {}; ids.forEach(i => (cols[rk[i]] ||= []).push(i));
2265
2160
  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
2161
  untangle(mains, links); return { cols, ranks, mains }; }; // in reading order, unless another order crosses less (#53)
@@ -2285,8 +2180,8 @@ function mapContext(model, opts = {}) {
2285
2180
  // with no layout (a change of notation draws the cards again and nothing else)
2286
2181
  const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
2287
2182
  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 };
2183
+ 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
2184
+ 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
2185
  }
2291
2186
  // 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
2187
  let ruled = null;
@@ -2296,7 +2191,7 @@ function mapContext(model, opts = {}) {
2296
2191
  (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
2192
  return Object.keys(by).length ? by : null;
2298
2193
  };
2299
- const layoutCache = {};
2194
+ const layoutCache = Object.create(null); // M3
2300
2195
  // the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
2301
2196
  const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
2302
2197
  return k || (opensOf(nid, vid) && n > 0 ? Math.floor(n) : 0); };
@@ -2324,7 +2219,15 @@ function mapContext(model, opts = {}) {
2324
2219
  // 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
2220
  // with no route, no way round the cards for them, no warning)
2326
2221
  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]);
2222
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), T = ownLayout(vid);
2223
+ if (T) { // the type's layout (registry.js), in the host's measure (R2)
2224
+ const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
2225
+ if (k != null && memo.runKey === k) return memo.runGeo;
2226
+ const pos = override || (L.pins === false ? {} : L.rank === false ? v.layout || {} : v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))); // pins: false, no ranking either (notes/stage-3.md Q9)
2227
+ const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
2228
+ if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
2229
+ return g;
2230
+ }
2328
2231
  const edges = viewEdges(v).map(e => ({ ...e }));
2329
2232
  if (v.render === 'sequence') edges.forEach((e, i) => { e.step = i + 1; });
2330
2233
  const P = planCache[vid] || { flow: true, backKeys: loopsOf(v).keys, steps: {} };
@@ -2445,7 +2348,7 @@ function mapContext(model, opts = {}) {
2445
2348
  const viewKey = vid => {
2446
2349
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
2447
2350
  const icons = [...new Set(ns.map(n => n.icon).filter(Boolean))].map(k => (D.icons || {})[k]), who = [...new Set(ns.map(n => n.author).filter(Boolean))].map(k => (D.authors || {})[k]);
2448
- return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]); // a view's notation lays nothing out
2351
+ return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who, boxesOf(vid)]); // a view's notation lays nothing out; a type's boxes do
2449
2352
  };
2450
2353
  /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
2451
2354
  The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
@@ -2567,7 +2470,7 @@ function mapContext(model, opts = {}) {
2567
2470
  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
2471
  const geoCache = {};
2569
2472
  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 };
2473
+ layoutCache, geoCache, memos, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2571
2474
  }
2572
2475
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
2573
2476
 
@@ -2722,6 +2625,9 @@ export function validate(model) {
2722
2625
  out.push(...typedProblems(m).filter(p => !(p.level === 'error' && said.has(p.path))));
2723
2626
  views.forEach((v, i) => { const at = `views[${i}].diagram`; // a well-formed name of a type this release does not draw (M13)
2724
2627
  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`); });
2628
+ const once = new Set(); // each view's type's rules, a problem once; a validate(model) that takes no view, once a type
2629
+ 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);
2630
+ 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
2631
  }
2726
2632
  return out;
2727
2633
  }
@@ -2876,7 +2782,8 @@ export function diff(before, after) {
2876
2782
  if (h === 'added') { const ins = lines.filter(e => e.to === n.id && how.get(e) === 'added').map(e => name(e.from)), outs = lines.filter(e => e.from === n.id && how.get(e) === 'added').map(e => name(e.to));
2877
2783
  said.push(`adds ${name(n.id)}` + (ins.length === 1 && outs.length === 1 ? ` between ${ins[0]} and ${outs[0]}` : (ins.length ? `, from ${andList(ins)}` : '') + (outs.length ? `, to ${andList(outs)}` : ''))); }
2878
2784
  if (h === 'removed') said.push(`removes ${name(n.id)}`);
2879
- if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label');
2785
+ if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label'), p0 = o.parent ?? null, p1 = x.parent ?? null;
2786
+ if (keys.join() === 'parent' && p0 !== p1 && A.schemaVersion === 2 && out.schemaVersion === 2) return said.push(`moves ${name(n.id)} ${p1 != null ? 'into ' + name(p1) : 'out of ' + name(p0)}`); // a re-own (schema version 2; absent or null is no owner)
2880
2787
  said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
2881
2788
  lines.forEach(e => { const h = how.get(e); if (!h || cardHow.get(e.from) === h && h !== 'changed' || cardHow.get(e.to) === h && h !== 'changed') return;
2882
2789
  const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
@@ -2965,6 +2872,7 @@ export function reach(model, viewId, from, options = {}) {
2965
2872
  /** 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
2873
  * view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
2967
2874
  export function describe(model, viewId) {
2875
+ 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
2876
  const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
2969
2877
  const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
2970
2878
  const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
@@ -3588,7 +3496,10 @@ class Camera {
3588
3496
  core.Node and core.Line: a typed view's lines are typed, or validate() refuses them.
3589
3497
 
3590
3498
  Keys on a typed view: F2 renames, N adds the palette's first element, L connects (arrows or type a name, Enter),
3591
- Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model. */
3499
+ Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model. A box the type
3500
+ gives (registry.js boxes) is selected as a container and takes F2, Delete and the panel as a card does; a card dropped in
3501
+ another box, or the Owner field, gives it a new owner (reparent). L on a line starts at its far end, with edits.connect;
3502
+ layout.pins false: no pin. src/typed/edit.js takes gestures over (TYPED.edit, registry.js). */
3592
3503
 
3593
3504
  const GENERIC_NODE = { type: 'core.Node', label: 'Node' }, GENERIC_LINE = { type: 'core.Line', label: 'Line' };
3594
3505
  const isRec = x => x !== null && typeof x === 'object' && !Array.isArray(x);
@@ -3598,7 +3509,7 @@ function elementsOf(m) {
3598
3509
  const at = new Map(), put = (x, kind, owner) => { if (!isRec(x) || typeof x.id !== 'string' || at.has(x.id)) return;
3599
3510
  at.set(x.id, { x, kind, owner }); arrOf(x.members).forEach(y => put(y, 'member', x)); };
3600
3511
  arrOf(m && m.nodes).forEach(n => put(n, 'node')); arrOf(m && m.edges).forEach(e => put(e, 'edge'));
3601
- arrOf(m && m.views).forEach(v => { if (isRec(v)) { arrOf(v.nodes).forEach(n => put(n, 'node')); arrOf(v.edges).forEach(e => put(e, 'edge')); } });
3512
+ arrOf(m && m.views).forEach(v => { if (isRec(v)) { [...arrOf(v.nodes), ...arrOf(TYPED.edit && v.fragments)].forEach(n => put(n, 'node')); arrOf(v.edges).forEach(e => put(e, 'edge')); } });
3602
3513
  return at;
3603
3514
  }
3604
3515
 
@@ -3630,18 +3541,26 @@ function typedEditor(api) {
3630
3541
  const b = String(base).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'el'; let k = 1; while (used.has(b + k)) k++; return b + k; };
3631
3542
  const typeOf = x => typeof x.type === 'string' && x.type ? x.type : null; // a model may hold anything in type
3632
3543
  const typed = (t, base) => { const ty = typeOf(t); return { id: newId(ty ? ty.split('.').pop() : base), ...(ty ? { type: ty } : {}) }; };
3633
- const idsOf = r => !r ? [] : r.type === 'node' ? [r.id] : r.type === 'edge' ? arrOf(r.ids).filter(x => !String(x).includes('->')) : []; // not a positional line id
3544
+ const idsOf = r => !r ? [] : r.type === 'node' || r.type === 'container' && elementsOf(model()).has(r.id) ? [r.id] : r.type === 'edge' ? arrOf(r.ids).filter(x => !String(x).includes('->')) : []; // not a positional line id
3634
3545
  const selId = (at = elementsOf(model())) => { const id = idsOf(api.sel())[0]; return id && at.has(id) ? id : null; };
3635
3546
  const top = (id, at = elementsOf(model())) => { let e = at.get(id); while (e && e.owner) e = at.get(e.owner.id); return e ? e.x.id : id; }; // a member: its node
3636
3547
 
3637
3548
  /* the gestures */
3638
3549
  const label = (ref, value) => { const at = elementsOf(model()), id = idsOf(ref).find(x => at.has(x)); if (id && can('rename')) emit([{ op: 'set', id, path: 'label', value }]); };
3639
- const pin = (id, c, r) => can('move') && Number.isFinite(c) && Number.isFinite(r) && c >= 0 && r >= 0 && emit([{ op: 'set', view: api.view(), path: ['layout', id], value: [c, r] }]);
3640
- const drop = (n, hint) => (hint.col !== n.col || hint.row !== n.row) && pin(n.id, hint.col, hint.row);
3641
- function remove(fromModel) { // Delete: out of this view; Shift + Delete: out of the model (risks.md E5). One transaction for the selection (E2)
3642
- const ids = [...new Set(api.picked().flatMap(idsOf))]; if (!can('delete') || !ids.length) return false;
3550
+ const pin = (id, c, r) => can('move') && (def().layout || {}).pins !== false && Number.isFinite(c) && Number.isFinite(r) && c >= 0 && r >= 0 && emit([{ op: 'set', view: api.view(), path: ['layout', id], value: [c, r] }]);
3551
+ // a drop (notes/stage-2.md §8): in the box it is drawn in, its pin; into another box, or out of every box, a new owner (the
3552
+ // box's, else the view's subject or none), in one transaction. The view is then laid out again (§3.2), so a slot read off the
3553
+ // picture before would land elsewhere and stretch a box over cards it does not hold: no pin, and a pin it had is removed.
3554
+ // into, from: the box under the drop, the box it was in
3555
+ // a re-own, by a drop or the Owner field: reparent, and the removal of a pin it had
3556
+ const reown = (id, parent) => { const L = view().layout; return [{ op: 'reparent', id, parent }, ...(isRec(L) && Object.hasOwn(L, id) ? [{ op: 'set', view: api.view(), path: ['layout', id], value: undefined }] : [])]; };
3557
+ const drop = (n, hint, into = null, from = null) => into === from ? (hint.col !== n.col || hint.row !== n.row) && pin(n.id, hint.col, hint.row)
3558
+ : can('move') && emit(reown(n.id, into ?? view().subject ?? null));
3559
+ const picks = () => [...new Set(api.picked().flatMap(idsOf))];
3560
+ function remove(fromModel, ids = picks()) { // Delete: out of this view; Shift + Delete: out of the model (risks.md E5). One transaction for the selection (E2)
3561
+ if (!can('delete') || !ids.length) return false;
3643
3562
  if (fromModel) return emit(ids.map(id => ({ op: 'delete', id })));
3644
- const v = view(), own = new Set([...arrOf(v.nodes), ...arrOf(v.edges)].filter(isRec).map(x => x.id)), at = elementsOf(model()), out = [];
3563
+ const v = view(), own = new Set([...arrOf(v.nodes), ...arrOf(v.edges), ...arrOf(TYPED.edit && v.fragments)].filter(isRec).map(x => x.id)), at = elementsOf(model()), out = [];
3645
3564
  ids.forEach(id => { const e = at.get(id);
3646
3565
  if (own.has(id)) out.push({ op: 'delete', id }); // lives in this view only: hiding it is deleting it
3647
3566
  else if (e && e.kind === 'edge' && !Array.isArray(v.edges)) api.status('This view shows every line between its cards: Shift + Delete deletes the line');
@@ -3652,11 +3571,13 @@ function typedEditor(api) {
3652
3571
  const t = nodeTypes()[i], cam = api.cam(); if (!t || !can('add') || !cam) return;
3653
3572
  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
3573
  }
3655
- const addLine = (from, to, t) => emit([{ op: 'add', element: { ...typed(t, 'line'), from, to }, view: api.view() }]);
3656
- function connect(from, to) { // the relationship picker, when the type has more than one kind of line
3657
- if (!can('add') || !from || !to || from === to) return;
3574
+ const hook = k => { const E = def().edits; return isRec(E) && typeof E[k] === 'function' ? E[k] : null; }; // registry.js edits
3575
+ const addLine = (from, to, t, at) => { const c = hook('connect'), dflt = () => emit([{ op: 'add', element: { ...typed(t, 'line'), from, to, ...(isRec(t.props) ? { props: structuredClone(t.props) } : {}) }, view: api.view() }]); // at: a slot (src/typed/edit.js)
3576
+ return c ? (I.ran || (r => emit(arrOf(r))))(c(model(), view(), from, to, at, t), dflt) : dflt(); }; // I.ran: src/typed/edit.js's null and { error }
3577
+ function connect(from, to, ev, at = null) { // the relationship picker, when the type has more than one kind of line
3578
+ if (!can('add') || !from || !to || from === to && !I.self) return;
3658
3579
  const types = lineTypes();
3659
- if (types.length === 1) addLine(from, to, types[0]); else picker(to, types, t => addLine(from, to, t));
3580
+ if (types.length === 1) addLine(from, to, types[0], at); else picker(to, types, t => addLine(from, to, t, at));
3660
3581
  }
3661
3582
  function closeMenu(back) { const m = menu; if (!m) return false; menu = null; m.remove(); if (back) api.focus(); api.touch(); return true; }
3662
3583
  function picker(at, types, done) {
@@ -3692,10 +3613,10 @@ function typedEditor(api) {
3692
3613
  function linkKey(e) {
3693
3614
  const K = e.key;
3694
3615
  if (K === 'Escape') { endLink(); say('No line added'); return true; }
3695
- if (K === 'Enter') { const { from, to } = link; if (!to || to === from) { say('Pick another card first'); return true; } endLink(); connect(from, to); return true; }
3616
+ if (K === 'Enter') { const { from, to } = link; if (!to || to === from && !I.self) { say('Pick another card first'); return true; } endLink(); I.connect(from, to); return true; }
3696
3617
  if (K.startsWith('Arrow')) { const g = api.geo(), n = api.nearest(g.byId[link.to || link.from], K); if (n) { link.to = n.id; mark(); } return true; }
3697
3618
  if (K.length === 1 && !e.altKey) { const now = performance.now(); link.find = (now - link.t < 900 ? link.find : '') + K.toLowerCase(); link.t = now;
3698
- const hit = api.geo().nodes.find(n => n.id !== link.from && String(api.nameOf(n.id)).toLowerCase().startsWith(link.find));
3619
+ const ns = api.geo().nodes.filter(n => String(api.nameOf(n.id)).toLowerCase().startsWith(link.find)), hit = ns.find(n => n.id !== link.from) || I.self && ns[0];
3699
3620
  if (hit) { link.to = hit.id; mark(); } return true; }
3700
3621
  return false;
3701
3622
  }
@@ -3705,11 +3626,12 @@ function typedEditor(api) {
3705
3626
  if (e.altKey) { const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[K], n = s && s.type === 'node' && api.geo().byId[s.id];
3706
3627
  if (!d || !n) return false; if (Number.isFinite(n.col)) pin(n.id, n.col + d[0], n.row + d[1]); return true; }
3707
3628
  if (K === 'F2' && s && can('rename')) { api.rename(); return true; }
3708
- if ((K === 'n' || K === 'N') && can('add')) { addNode(0); return true; }
3709
- if ((K === 'l' || K === 'L') && can('add') && s && s.type === 'node') {
3710
- link = { from: s.id, to: null, find: '', t: 0 }; api.touch();
3711
- say(`Line from ${api.nameOf(s.id)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
3712
- if (K === 'Delete' && s) { remove(e.shiftKey); return true; }
3629
+ if ((K === 'n' || K === 'N') && can('add')) { I.addNode(0); return true; }
3630
+ const from = s && (s.type === 'node' ? s.id : s.type === 'edge' && hook('connect') && (api.geo().edges.find(x => idsOf(s).includes(x._id)) || {}).to);
3631
+ if ((K === 'l' || K === 'L') && can('add') && from) {
3632
+ link = { from, to: null, find: '', t: 0 }; api.touch();
3633
+ say(`Line from ${api.nameOf(from)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
3634
+ if (K === 'Delete' && s) { I.remove(e.shiftKey); return true; }
3713
3635
  return false;
3714
3636
  }
3715
3637
 
@@ -3735,15 +3657,23 @@ function typedEditor(api) {
3735
3657
  const off = can('rename') ? '' : ' disabled'; // the editable flags: a field the host does not offer is shown, to read
3736
3658
  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
3659
  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)}"`); });
3660
+ 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)
3661
+ const key = ` data-key="${esc(f.key)}"${Array.isArray(f.path) ? ` data-path="${esc(JSON.stringify(f.path))}"` : ''}`;
3662
+ 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>`;
3663
+ 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>`;
3664
+ else h += text('p:' + f.key, f.kind === 'list' ? arrOf(v).join(', ') : v ?? '', name, key + (f.kind === 'list' ? ' data-list' : '')); });
3665
+ // the Owner field (notes/stage-2.md §8): the owners the type offers, less the element and what it holds, and none
3666
+ const O = def().owners, os = e.kind !== 'member' && typeof O === 'function' ? O(model(), x) : null, under = id => { for (let k = 0, p = id; p != null && k < 64; k++, p = (at.get(p) || { x: {} }).x.parent) if (p === x.id) return true; return false; };
3667
+ // its owner is offered too when the type does not offer it (a class in a class), so the field says it and "none" is a change
3668
+ const offer = Array.isArray(os) && [{ id: '', label: 'none' }, ...os.filter(o => isRec(o) && o.id != null && !under(o.id))];
3669
+ if (offer && x.parent != null && !offer.some(o => o.id === x.parent)) offer.push({ id: x.parent, label: (at.get(x.parent) || { x: {} }).x.label });
3670
+ if (offer) h += `<label>Owner<select data-f="owner" data-owner${can('move') ? '' : ' disabled'}>${offer.map(o => `<option value="${esc(o.id)}"${o.id === (x.parent ?? '') ? ' selected' : ''}>${esc(o.label ?? o.id)}</option>`).join('')}</select></label>`;
3742
3671
  const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
3743
3672
  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}>`
3673
+ const M = def().member;
3674
+ if (e.kind === 'node' && !(M && M.holds && !M.holds(x))) { // a comment holds none
3675
+ 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)
3676
+ 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
3677
  + (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
3678
  + '</ul>' + (can('add') ? '<input data-f="new" data-new placeholder="New member, Enter adds it" aria-label="New member">' : '');
3749
3679
  }
@@ -3771,15 +3701,20 @@ function typedEditor(api) {
3771
3701
 
3772
3702
  function commit(t) { // a field of the properties panel: one transaction
3773
3703
  const d = t.dataset, at = elementsOf(model()), id = selId(at);
3704
+ 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
3705
  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 }]); }
3706
+ const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
3707
+ const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
3708
+ 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 }]); }
3709
+ if (d.owner != null) { const p = t.value || null; return id && can('move') && p !== (at.get(id).x.parent ?? null) && emit(reown(id, p)); }
3777
3710
  if (!can('rename') || (!id && d.m == null)) return;
3778
3711
  let value = t.type === 'checkbox' ? t.checked : t.value.trim();
3779
3712
  if (t.type !== 'checkbox') { if (t.value === d.was) return;
3780
3713
  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;
3714
+ 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');
3715
+ t.removeAttribute('aria-invalid'); d.was = t.value; return emit(r.commands); }
3716
+ d.was = t.value; if (value === '') value = undefined; else if (d.list != null) value = value.split(',').map(x => x.trim()).filter(Boolean); }
3717
+ const path = d.path ? ['props', ...JSON.parse(d.path)] : d.key == null ? 'label' : d.key.includes('.') ? ['props', d.key] : 'props.' + d.key;
3783
3718
  emit([{ op: 'set', id: d.m ?? id, path, value }]);
3784
3719
  }
3785
3720
  function moveMember(id, by) {
@@ -3795,16 +3730,16 @@ function typedEditor(api) {
3795
3730
  panel.className = 'um-editor'; panel.setAttribute('aria-label', 'Edit this diagram');
3796
3731
  panel.innerHTML = '<section class="um-pal" aria-label="Palette"></section><section class="um-props" aria-label="Properties"></section><section class="um-probs" aria-label="Problems"></section><section class="um-els" aria-label="Elements"></section>';
3797
3732
  panel.addEventListener('click', e => { const b = e.target.closest('button, [data-orphans]'); if (!b) return; const d = b.dataset;
3798
- if (d.add != null) addNode(+d.add);
3733
+ if (d.add != null) I.addNode(+d.add);
3799
3734
  else if (d.go != null) { if (!d.go || !api.selectId(top(d.go))) api.status('Not drawn in this view'); }
3800
3735
  else if (d.show != null) show(d.show);
3801
- else if (d.del != null) { if (can('delete')) emit([{ op: 'delete', id: d.del }]); }
3736
+ else if (d.del != null) I.remove(true, [d.del]);
3802
3737
  else if (d.up != null || d.down != null) moveMember(d.up ?? d.down, d.up != null ? -1 : 1);
3803
- else if (d.hide != null || d.kill != null) remove(d.kill != null);
3738
+ else if (d.hide != null || d.kill != null) I.remove(d.kill != null);
3804
3739
  else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
3805
3740
  panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
3806
3741
  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
3742
+ 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
3743
  else if (K === 'Enter' && t.tagName === 'BUTTON') { e.preventDefault(); t.click(); }
3809
3744
  else if (K === 'Enter' && t.tagName === 'INPUT' && t.type !== 'checkbox') { e.preventDefault(); commit(t); }
3810
3745
  else if (e.altKey && t.dataset.m != null && (K === 'ArrowUp' || K === 'ArrowDown')) { e.preventDefault(); moveMember(t.dataset.m, K === 'ArrowUp' ? -1 : 1); } });
@@ -3817,12 +3752,14 @@ function typedEditor(api) {
3817
3752
  refresh();
3818
3753
  }
3819
3754
 
3820
- return { on, key, label, drop, connect, mount, refresh,
3755
+ const I = { on, key, label, drop, connect, mount, refresh, addNode, remove,
3821
3756
  paint: () => { if (panel && on()) put('.um-props', props()); },
3822
- add: () => addNode(0),
3757
+ add: () => I.addNode(0),
3823
3758
  cancel: () => { const a = endLink(), b = closeMenu(false); return a || b; },
3824
3759
  linking: () => !!link || !!menu,
3825
3760
  width: () => panel ? panel.offsetWidth + 8 : 0 };
3761
+ if (TYPED.edit) TYPED.edit(I, { api, model, view, def, hook, emit, typed, nodeTypes, picker, picks });
3762
+ return I;
3826
3763
  }
3827
3764
  const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
3828
3765
 
@@ -3917,14 +3854,15 @@ export function render(container, model, options = {}) {
3917
3854
  const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
3918
3855
  const nodeRef = id => ({ type: 'node', id, view: cur });
3919
3856
  const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
3920
- const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
3857
+ const picked = () => sel ? [...more, sel].map(copyRef) : []; // every selected thing, the latest last (more: the others' refs)
3921
3858
  const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
3859
+ const nodeG = id => [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id), ringOf = id => [...svg.querySelectorAll('.node-key')].find(x => x.dataset.node === id); // no CSS.escape: jsdom has none
3922
3860
  const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
3923
3861
  let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
3924
3862
  const emitSel = (again = false) => {
3925
3863
  const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
3926
- say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
3927
- : sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}, selected` : 'Line selected')(geo.edges[edgeIndex(sel)]) : `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}, selected`);
3864
+ say(!sel ? 'Nothing selected' : (sel.type === 'node' ? nameOf(sel.id) : sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}` : 'Line')(geo.edges[edgeIndex(sel)])
3865
+ : `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}`) + (more.length ? `, ${more.length + 1} ${picked().every(r => r.type === 'node') ? 'cards ' : ''}selected` : ', selected'));
3928
3866
  emit('select', { target: copyRef(sel), targets: picked() });
3929
3867
  };
3930
3868
  const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
@@ -4063,25 +4001,25 @@ export function render(container, model, options = {}) {
4063
4001
  if (can('add') && sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id];
4064
4002
  svg.insertAdjacentHTML('beforeend', `<g class="um-edit um-handle" data-from="${esc(n.id)}"><title>Drag to another card to add a line</title><circle cx="${n.x + n.w + 12}" cy="${n.y + n.h / 2}" r="6"/></g>`); }
4065
4003
  svg.querySelectorAll('.sel,.nb,.hot').forEach(g => g.classList.remove('sel', 'nb', 'hot')); stage.classList.remove('focusing');
4066
- svg.querySelectorAll('.node').forEach(g => g.setAttribute('aria-pressed', !!sel && sel.type === 'node' && (g.dataset.id === sel.id || more.includes(g.dataset.id))));
4067
- const li = sel && sel.type === 'edge' ? edgeIndex(sel) : -1; svg.querySelectorAll('.line-key').forEach(k => k.setAttribute('aria-pressed', +k.dataset.i === li));
4004
+ const pk = picked(), ns = pk.filter(r => r.type === 'node').map(r => r.id), li = pk.filter(r => r.type === 'edge').map(edgeIndex);
4005
+ svg.querySelectorAll('.line-key').forEach(k => k.setAttribute('aria-pressed', li.includes(+k.dataset.i))); svg.querySelectorAll('.node, .node-key').forEach(g => g.setAttribute('aria-pressed', ns.includes(g.dataset.id ?? g.dataset.node)));
4068
4006
  if (!sel) return;
4069
4007
  if (sel.type === 'node') {
4070
4008
  const nid = sel.id, ups = new Set(), downs = new Set();
4071
4009
  geo.edges.forEach(e => { if (e.to === nid) ups.add(e.from); if (e.from === nid) downs.add(e.to); });
4072
- svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel', id === nid || more.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
4010
+ svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel', ns.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
4073
4011
  svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
4074
4012
  svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
4075
4013
  svg.querySelectorAll('.n-port[data-edges]').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i] && (geo.edges[i].from === nid || geo.edges[i].to === nid)))); // not an ease's fading copy (#67)
4076
4014
  stage.classList.add('focusing');
4077
4015
  } else if (sel.type === 'edge') {
4078
- const i = edgeIndex(sel), e = geo.edges[i];
4079
- svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'));
4080
- svg.querySelectorAll('.node').forEach(g => g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to));
4016
+ const e = geo.edges[edgeIndex(sel)];
4017
+ svg.querySelectorAll('.node').forEach(g => { g.classList.toggle('sel', ns.includes(g.dataset.id)); g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to); }); // a card selected before the line stays selected
4081
4018
  stage.classList.add('focusing');
4082
4019
  } else if (sel.type === 'container') {
4083
4020
  const f = svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"]`); if (f) f.classList.add('sel');
4084
4021
  }
4022
+ li.forEach(i => svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'))); // the lines selected
4085
4023
  }
4086
4024
  function select(ref, o = {}) {
4087
4025
  if (ref && (ref.type === 'chip' || ref.type === 'badge')) ref = ref.container != null ? { type: 'container', id: ref.container } : nodeRef(ref.node); // a chip or badge: its card (box)
@@ -4105,11 +4043,14 @@ export function render(container, model, options = {}) {
4105
4043
  return best;
4106
4044
  }
4107
4045
 
4108
- /* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out */
4109
- function toggle(id) {
4110
- if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
4111
- else if (more.includes(id)) more = more.filter(x => x !== id);
4112
- else { more.push(sel.id); sel = nodeRef(id); }
4046
+ /* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out; on a typed view a line too, and
4047
+ Shift + Enter on its ring (notes/stage-3.md §5.1). ref: a nodeRef or edgeRef */
4048
+ const kinds = () => typed() ? ['node', 'edge'] : ['node'], refKey = r => JSON.stringify([r.type, r.id, r.ids]);
4049
+ function toggle(ref) {
4050
+ const k = refKey(ref);
4051
+ if (k === refKey(sel)) { if (!more.length) return select(null); sel = more.pop(); }
4052
+ else if (more.some(r => refKey(r) === k)) more = more.filter(r => refKey(r) !== k);
4053
+ else { more.push(sel.type === 'node' ? nodeRef(sel.id) : sel); sel = ref; }
4113
4054
  sub = null; selectNew = false; paint(); emitSel();
4114
4055
  }
4115
4056
 
@@ -4236,7 +4177,7 @@ export function render(container, model, options = {}) {
4236
4177
  if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
4237
4178
  const keep = sel, keepMore = more, touched = cam.touched;
4238
4179
  mount(cur); cam.set({}); cam.touched = touched;
4239
- if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
4180
+ if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4240
4181
  emitSel(); }
4241
4182
  touch(); return true;
4242
4183
  }
@@ -4252,7 +4193,7 @@ export function render(container, model, options = {}) {
4252
4193
 
4253
4194
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
4254
4195
  function key(e) {
4255
- const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
4196
+ const k = e.key, nk = e.target && e.target.closest && e.target.closest('.node-key'), g = nk ? nodeG(nk.dataset.node) : e.target && e.target.closest && e.target.closest('.node'); // a node's ring (svg.js): the node
4256
4197
  if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
4257
4198
  if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
4258
4199
  if (k === 'Escape' && trip && trip.kind) { const was = trip.kind; endTrip(); say(was === 'replay' ? 'Replay stopped' : 'Tour stopped'); return true; } // Esc ends a tour or a replay first
@@ -4260,7 +4201,8 @@ export function render(container, model, options = {}) {
4260
4201
  if (e.altKey) return false;
4261
4202
  const lk = e.target && e.target.closest && e.target.closest('.line-key'), tk = e.target && e.target.closest && e.target.closest('.tab-key, .family [data-badge]');
4262
4203
  const ck = e.target && e.target.closest && e.target.closest('.chip-key'), card = ck && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === ck.dataset.node);
4263
- if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
4204
+ const add = k === 'Enter' && e.shiftKey && sel && kinds().includes(sel.type) && typed(); // Shift + Enter: into the selection, or out of it
4205
+ if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) add ? toggle(edgeRef(geo.edges[+lk.dataset.i])) : select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
4264
4206
  else if (k === 'Enter' && tk) { const fam = tk.closest('.family'), ref = badgeRef(tk.dataset.node, fam ? fam.dataset.family : null, +tk.dataset.badge); if (activate(ref, e)) select(ref); }
4265
4207
  else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
4266
4208
  else if (k === 'e' || k === 'E') return gesture(g ? g.dataset.id : sel && (sel.type === 'node' || sel.type === 'container') ? sel.id : null, 'key'); // unfold here, or fold back (#71)
@@ -4270,11 +4212,11 @@ export function render(container, model, options = {}) {
4270
4212
  else if (k === '1') cam.actual();
4271
4213
  else if (k === 'Enter' && (g || (sel && sel.type === 'node'))) { // a selected card opens its inner view; without one it is selected again, as a click does
4272
4214
  const id = g ? g.dataset.id : sel.id;
4273
- if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
4215
+ if (add) toggle(nodeRef(id)); else if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
4274
4216
  else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
4275
4217
  else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
4276
4218
  else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
4277
- const tg = svg.querySelector(`.node[data-id="${CSS.escape(to.id)}"]`); tg.focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
4219
+ (ringOf(to.id) || nodeG(to.id)).focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
4278
4220
  else if (k.startsWith('Arrow')) { const d = 80, m = { ArrowLeft: [d, 0], ArrowRight: [-d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] }[k]; if (!m) return false; cam.pan(m[0], m[1], true); }
4279
4221
  else return false;
4280
4222
  readerMoved(); // a key the map acted on: a story playing waits
@@ -4336,14 +4278,16 @@ export function render(container, model, options = {}) {
4336
4278
  function hintAt(x, y) { // a point in the view, and on a column view its column and row
4337
4279
  const h = { x: Math.round(x), y: Math.round(y) };
4338
4280
  if (!gridView()) return h;
4281
+ const T = typed() && (ctx.V[cur] || {}).direction === 'down', X = n => T ? n.y + n.h / 2 : n.x + n.w / 2, Y = n => T ? n.x : n.y; // a typed view laid out down: its columns run down
4282
+ if (T) [x, y] = [y, x];
4339
4283
  const cols = [...new Set(geo.nodes.filter(n => !n.aside).map(n => n.col))].sort((a, b) => a - b); // the row of cards with no line keeps off the columns
4340
- const mid = c => { const ns = geo.nodes.filter(n => n.col === c && !n.aside); return ns.reduce((a, n) => a + n.x + n.w / 2, 0) / ns.length; };
4284
+ const mid = c => { const ns = geo.nodes.filter(n => n.col === c && !n.aside); return ns.reduce((a, n) => a + X(n), 0) / ns.length; };
4341
4285
  const first = cols[0], last = cols[cols.length - 1], step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
4342
4286
  let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
4343
4287
  if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step);
4344
4288
  else if (x < mid(first) - step / 2) col = Math.max(0, first - Math.round((mid(first) - x) / step));
4345
- const a = geo.nodes[0], b = geo.nodes.find(n => n.row !== a.row), pitch = b ? (b.y - a.y) / (b.row - a.row) : 108;
4346
- h.col = col; h.row = Math.max(0, Math.round((y - (a.y - a.row * pitch) - 32) / pitch));
4289
+ const a = geo.nodes[0], b = geo.nodes.find(n => n.row !== a.row), pitch = b ? (Y(b) - Y(a)) / (b.row - a.row) : 108;
4290
+ h.col = col; h.row = Math.max(0, Math.round((y - (Y(a) - a.row * pitch) - 32) / pitch));
4347
4291
  return h;
4348
4292
  }
4349
4293
  const siblings = n => { const b = boxOf(n.id); return geo.nodes.filter(m => m.col === n.col && !m.aside === !n.aside && boxOf(m.id) === b).sort((p, q) => p.y - q.y); };
@@ -4417,7 +4361,7 @@ export function render(container, model, options = {}) {
4417
4361
  ['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
4418
4362
  function focusMap(ref = sel, reveal = true) {
4419
4363
  const line = ref && ref.type === 'edge' && svg && svg.querySelector(`.line-key[data-i="${edgeIndex(ref)}"]`); if (line) return line.focus({ preventScroll: true }); // a line (#43)
4420
- const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id); // no CSS.escape: jsdom has none
4364
+ const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && (ringOf(id) || nodeG(id));
4421
4365
  if (!g) return stage.focus({ preventScroll: true });
4422
4366
  g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n && reveal) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
4423
4367
  }
@@ -4453,7 +4397,7 @@ export function render(container, model, options = {}) {
4453
4397
  if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
4454
4398
  if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
4455
4399
  const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
4456
- if (t && t.dataset.id !== c.from && geo.byId[t.dataset.id]) typed() ? tx.connect(c.from, t.dataset.id) : change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id });
4400
+ if (t && geo.byId[t.dataset.id]) typed() ? tx.connect(c.from, t.dataset.id, e) : t.dataset.id !== c.from && change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id }); // a typed view: maybe to itself (I.self, edit.js)
4457
4401
  return; }
4458
4402
  if (!drag) return;
4459
4403
  const d = drag; drag = null;
@@ -4464,7 +4408,7 @@ export function render(container, model, options = {}) {
4464
4408
  const x = n.x + n.w / 2 + dx, y = n.y + n.h / 2 + dy, hint = hintAt(x, y), from = boxOf(n.id), into = boxAt(x, y);
4465
4409
  const fromId = from ? from.id : null, toId = into ? into.id : null;
4466
4410
  let ev = null;
4467
- if (typed()) ev = tx.drop(n, hint); // a typed view: the card's pin
4411
+ if (typed()) ev = tx.drop(n, hint, toId, fromId); // a typed view: the card's pin, and its owner when it left its box (notes/stage-2.md §8)
4468
4412
  else if (toId !== fromId && can('move')) ev = change({ op: 'move', target: nodeRef(n.id), to: toId, hint });
4469
4413
  else if (gridView() && hint.col === n.col && can('reorder')) {
4470
4414
  const sib = siblings(n), at = sib.indexOf(n), i = sib.filter(m => m !== n && m.y + m.h / 2 < y).length;
@@ -4477,14 +4421,20 @@ export function render(container, model, options = {}) {
4477
4421
  stage.addEventListener('pointerup', endGesture);
4478
4422
  stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
4479
4423
  /* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
4480
- takes its hint; kept in this handle only, never written to the host's model */
4481
- function withPlaces(m, use = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }
4424
+ takes its hint; kept in this handle only, never written to the host's model. A typed view's boxes take no slot, and an edit
4425
+ that changed the box an element shown before and after is drawn in lays the view out again, so every box holds exactly its
4426
+ contents (notes/stage-2.md §3.2); hiding or deleting a card keeps the places */
4427
+ const boxesIn = (m, v) => { const T = m && m.schemaVersion === 2 && v && v.diagram != null && diagramOf(v); return T && T.boxes ? arrOf(T.boxes(m, v)).filter(b => b && b.id != null) : []; };
4428
+ const ownersOn = bs => new Map(bs.flatMap(b => [[b.id, b.parent ?? null], ...arrOf(b.nodeIds).map(id => [id, b.id])])); // id -> the box it is drawn in
4429
+ function withPlaces(m, use = null, prev = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }; prev: the model before
4482
4430
  if (options.stableLayout === false) return m;
4483
4431
  const out = structuredClone(m);
4484
4432
  (out.views || []).forEach(v => {
4485
- const was = placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id ? use : {};
4433
+ const bx = boxesIn(out, v), pv = viewIn(prev, v.id), A = ownersOn(boxesIn(prev, pv)), B = ownersOn(bx);
4434
+ const shown = new Set(arrOf(pv.nodeIds)), again = A.size + B.size > 0 && arrOf(v.nodeIds).some(id => shown.has(id) && (A.get(id) ?? null) !== (B.get(id) ?? null));
4435
+ const was = again ? null : placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id && !again ? use : {};
4486
4436
  if (!was && !Object.keys(hv).length && !Object.keys(ex).length) return;
4487
- const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
4437
+ const ids = (v.nodeIds || []).filter(id => typeof id === 'string' && !bx.some(b => b.id === id)), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
4488
4438
  const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
4489
4439
  ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
4490
4440
  ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
@@ -4514,7 +4464,7 @@ export function render(container, model, options = {}) {
4514
4464
  sub = null;
4515
4465
  if (ref) {
4516
4466
  if (!activate(ref, e)) return; // the host took the click (a chip as a link, say): the selection stays as it was
4517
- if (ref.type === 'node' && (e.shiftKey || e.metaKey || e.ctrlKey) && sel && sel.type === 'node') return toggle(ref.id); // several cards
4467
+ if ((e.shiftKey || e.metaKey || e.ctrlKey) && sel && kinds().includes(ref.type) && kinds().includes(sel.type)) return toggle(ref); // several cards, and lines
4518
4468
  return select(ref, { again: ref.type === 'node' }); }
4519
4469
  select(null);
4520
4470
  if (P.onBackground) P.onBackground();
@@ -4622,10 +4572,10 @@ export function render(container, model, options = {}) {
4622
4572
  // a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
4623
4573
  const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
4624
4574
  m.delete(nid); const l = expanded[vid] ||= []; if (!l.includes(nid)) { l.push(nid); if (vid === cur) brought.push(nid); } }));
4625
- const old = ctx, keys = {}, wantNew = selectNew && !own;
4575
+ const old = ctx, keys = {}, wantNew = selectNew && !own, before = current;
4626
4576
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
4627
4577
  current = model2;
4628
- ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
4578
+ ctx = mapContext(withPlaces(model2, use, before), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
4629
4579
  Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
4630
4580
  if (!own) selectNew = false;
4631
4581
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
@@ -4650,7 +4600,7 @@ export function render(container, model, options = {}) {
4650
4600
  if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
4651
4601
  else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
4652
4602
  else { // the selection that is still drawn stays, and a select event comes only if it changed (unfolding: the caller says)
4653
- if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
4603
+ if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4654
4604
  if (!unfolding) emitSel(); }
4655
4605
  if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
4656
4606
  touch();
@@ -5129,9 +5079,10 @@ const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Brows
5129
5079
  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
5080
  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
5081
  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))];
5082
+ 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
5083
+ 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
5084
  // 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';
5085
+ const cards = gs.flatMap((g, i) => { if (g.legend) return []; const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
5135
5086
  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
5087
  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
5088
  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 +5100,7 @@ function legendItems(gs, opens = 'double-click to open', notes = [], icons = {})
5149
5100
  .concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
5150
5101
  .concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
5151
5102
  .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>'] : []);
5103
+ .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
5104
  }
5154
5105
 
5155
5106
  function pieces(X, el, o) {
@@ -5783,9 +5734,10 @@ export function mountPage(container, model, options = {}) {
5783
5734
  - The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
5784
5735
  - Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
5785
5736
  as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
5786
- lines and boxes. They fill two columns below the picture, or one column beside a picture too tall to leave room
5787
- below. A table that runs out of room goes on in the next column, under its heading and header row again; a row is
5788
- never cut, and a heading never ends a column.
5737
+ lines and boxes (on a typed view: each element's type and members, as its type prints them, and each line's kind).
5738
+ They fill two columns below the picture, or one column beside a picture too tall to leave room below. A table that
5739
+ runs out of room goes on in the next column, under its heading and header row again; a row is never cut, and a
5740
+ heading never ends a column.
5789
5741
  - The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
5790
5742
  function printDoc(X, model, Q) {
5791
5743
  const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
@@ -5875,6 +5827,8 @@ function printDoc(X, model, Q) {
5875
5827
  shown.forEach(([v, svg], vi) => {
5876
5828
  const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
5877
5829
  const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
5830
+ const typed = (v.diagram ?? 'map') !== 'map', word = t => String(t || '').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
5831
+ const asText = (diagramOf(v)?.member || {}).print || (m => m.label ?? m.id); // a member as its type writes it, else its name
5878
5832
  const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
5879
5833
  const head = more => `<header>${more ? `<div class="h2">${up}${T(v)} <span class="up">(continued)</span></div>` : `<h2>${up}${T(v)}</h2>`}<span class="meta">${[count(ids.length, 'card'), count(es.length, 'line'), ...(held.length ? [count(held.length, 'box', 'boxes')] : [])].join(' · ')}</span></header>${!more && v.caption ? `<p class="cap">${esc(v.caption)}</p>` : ''}`;
5880
5834
  const body = sheet(head(false), vi);
@@ -5889,11 +5843,12 @@ function printDoc(X, model, Q) {
5889
5843
  const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
5890
5844
  const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
5891
5845
  const tables = [
5892
- { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: '<th>Card</th><th>Kind</th><th>What it is</th>', rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
5846
+ { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: `<th>Card</th><th>Kind</th><th>${typed ? 'Members' : 'What it is'}</th>`, rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
5893
5847
  return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
5894
- + `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`; }) },
5895
- { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
5896
- return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
5848
+ + (typed ? `<td>${esc(word(c.type || 'uml.Class'))}</td><td>${arrOf(c.members).filter(m => m && typeof m === 'object').map(m => `<p>${esc(asText(m))}</p>`).join('')}</td></tr>`
5849
+ : `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`); }) },
5850
+ { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>${typed ? 'Kind' : 'Technology'}</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
5851
+ return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : typed ? '' : `<span class="up">${w}</span>`}</td><td>${esc(typed ? word(e.type) : e.sublabel || '')}</td></tr>`; }) },
5897
5852
  { title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
5898
5853
  ].filter(x => x.rows.length);
5899
5854
  [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);