umlimator 0.18.0 → 0.20.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
@@ -108,14 +108,26 @@ function registerDiagramType(name, def = {}) {
108
108
  }
109
109
 
110
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. */
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). */
112
124
 
113
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 */
114
126
  const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram : 'map') || null;
115
127
 
116
128
  /* What only a registered type's geometry uses, installed at load time by the files under src/typed/, which only
117
- lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf. Empty in
118
- umlimator.js, where no view is laid out but as a map (risks.md C5) */
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) */
119
131
  const TYPED = {};
120
132
 
121
133
  const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
@@ -619,12 +631,16 @@ function svgOf(geo, opts = {}) {
619
631
  : nodes.map(n => [n, n.shape != null && cardLook(n, 'box', false, null)]).filter(x => x[1]));
620
632
  const drawn = edges.map(e => map && looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
621
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)
622
- let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
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">
623
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>`;
624
636
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
625
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; }
626
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"' : ''}/>
627
- <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 ? `
628
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>` : `
629
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>`;
630
646
  });
@@ -788,12 +804,12 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
788
804
  ${parts.join('\n')}
789
805
  </g>`;
790
806
  });
791
- 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
792
- s += '</g><g class="keys">';
793
- edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
794
- const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`;
795
- s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(drawn[i])}" class="key-ring"/></g>`; });
796
- }
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('');
797
813
  s += '</g>' + (opts.over || '') + '<g class="labels depth-read">';
798
814
  edges.forEach((e, i) => {
799
815
  if (T) s += T.labels(i);
@@ -982,7 +998,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
982
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)); });
983
999
  // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side (a
984
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)
985
- 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);
986
1002
  // labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
987
1003
  // so the pills sit next to each other: the gap fits them all, with 6 px between pills
988
1004
  const sum = {};
@@ -1015,18 +1031,23 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1015
1031
  else place();
1016
1032
  // Family frames enclose ordinary cards, so clicks, ports and focus keep their existing owners.
1017
1033
  // Children contribute their entire frame to their parent's bounds, rather than just their cards.
1018
- const familyDefs = dg.containers || [], tagged = n => map && (n.author || n.k === 'm-ext'); // tagged: a name tag hangs under the card
1019
- 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'
1020
1039
  const frameOf = (f, trail = new Set()) => {
1021
1040
  if (cache[f.id]) return cache[f.id];
1022
1041
  if (trail.has(f.id)) return null;
1023
1042
  const next = new Set([...trail, f.id]);
1024
1043
  const children = familyDefs.filter(c => c.parent === f.id).map(c => frameOf(c, next)).filter(Boolean);
1025
1044
  const members = (f.nodeIds || []).map(id => byId[id]).filter(Boolean);
1026
- 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];
1027
1046
  if (!rects.length) return null;
1028
- const p = 12, x = Math.min(...rects.map(r => r.x)) - p, y = Math.min(...rects.map(r => r.y)) - p - 24;
1029
- 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;
1030
1051
  };
1031
1052
  const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
1032
1053
  if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
@@ -1037,17 +1058,19 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1037
1058
  };
1038
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
1039
1060
  // it, so their borders never touch and a line can run between them. Frames are measured again until nothing is too close.
1040
- 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++) {
1041
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;
1042
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;
1043
1064
  const fr = framesNow().map(f => ({ ...f, s: span(nodes.filter(n => within(n, f))) })).filter(f => f.s);
1044
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]) }));
1045
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
1046
1069
  fr.forEach((A, i) => [...fr.slice(i + 1), ...cards].forEach(B => {
1047
1070
  if (within(A, B) || within(B, A) || !hit(A, B, BOXGAP)) return;
1048
1071
  const a = A.s, b = B.s;
1049
- 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); }
1050
- 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); }
1051
1074
  }));
1052
1075
  if (!Object.keys(wantX).length && !Object.keys(wantY).length) break;
1053
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; });
@@ -1092,9 +1115,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1092
1115
  route exists; earlier labels are obstacles, so later routes keep clear of them. */
1093
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
1094
1117
  // something inside it: that has no line)
1095
- 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,
1096
- 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: [] }])) : {};
1097
- 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;
1098
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;
1099
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; }
1100
1124
  return true; }).map(e => ({ ...e, s: end(e.from), t: end(e.to) }));
@@ -1162,8 +1186,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1162
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
1163
1187
  // a side, off the title, tabs and corners on top or bottom; in that order, 16 (26) px apart
1164
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; };
1165
- const sp = vertical ? [n.y + 40, n.y + n.h - 16] : sideSpan(n, side) || [n.x, n.x + n.w];
1166
- 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;
1167
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);
1168
1194
  const v = order.map(i => Math.min(hi, Math.max(lo, ws[i])));
1169
1195
  for (let k = 1; k < v.length; k++) v[k] = Math.max(v[k], v[k - 1] + gap);
@@ -1237,9 +1263,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1237
1263
  const keep = new Set([st[0], en[0], st[1], en[1]]);
1238
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
1239
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);
1240
- 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); });
1241
1267
  let X = co([...xs]), Y = co([...ys]);
1242
- 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);
1243
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]))); }
1244
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)); }
1245
1271
  const nx = X.length, ny = Y.length, N = nx * ny;
@@ -1423,7 +1449,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1423
1449
  const runRouting = order => {
1424
1450
  routed.length = 0; reserved.length = 0; placed.length = 0; stats = {};
1425
1451
  (dg.blocks || []).forEach(b => { reserved.push(b); placed.push(b); });
1426
- 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);
1427
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
1428
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; });
1429
1455
  order.forEach(e => {
@@ -1452,8 +1478,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1452
1478
  let bad = runRouting(order);
1453
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
1454
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
1455
- // a label it tries, as the layout it may replace did)
1456
- 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++) {
1457
1483
  const stuck = order.filter(e => e.how !== 'grid');
1458
1484
  const next = [...stuck, ...order.filter(e => e.how === 'grid')];
1459
1485
  const snap = order.map(e => [e, e.pts, e.lbl, e.how]), snapStats = stats, snapR = [routed.slice(), reserved.slice(), placed.slice()];
@@ -1674,7 +1700,9 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
1674
1700
  const box = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
1675
1701
  function overlapIssues(geo, where, refs) {
1676
1702
  const issues = [], nodes = geo.nodes, say = teller(issues, refs);
1677
- for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(box(nodes[i]), box(nodes[j]), -1)) say(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`, nodes[i], nodes[j]);
1703
+ const inside = (a, b) => a.x >= b.x - 1 && a.y >= b.y - 1 && a.x + a.w <= b.x + b.w + 1 && a.y + a.h <= b.y + b.h + 1;
1704
+ const holds = (a, b) => (a.holder && inside(b, a)) || (b.holder && inside(a, b)); // a holder (a sequence's frame) may hold what it meets, not cross it
1705
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(box(nodes[i]), box(nodes[j]), -1) && !holds(nodes[i], nodes[j])) say(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`, nodes[i], nodes[j]);
1678
1706
  return issues;
1679
1707
  }
1680
1708
  function leaveIssues(geo, where, refs) {
@@ -1697,7 +1725,7 @@ function leaveIssues(geo, where, refs) {
1697
1725
  function auditSvg(svg, geo, where) {
1698
1726
  const issues = overlapIssues(geo, where), R = box;
1699
1727
  const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
1700
- const nodes = geo.nodes, faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
1728
+ const nodes = geo.nodes.filter(n => !n.holder), faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
1701
1729
  T.forEach(a => {
1702
1730
  if (a.t === 'lt' || a.t === 'ft') {
1703
1731
  nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
@@ -2022,7 +2050,7 @@ function mapContext(model, opts = {}) {
2022
2050
  /* a layout may place only some cards: they keep their slots, the rest take their auto-layout slot or the next free
2023
2051
  row below it (a complete layout is used as it is) */
2024
2052
  function fillLayout(vid, L) {
2025
- const ids = (V[vid].nodeIds || []).filter(i => N[i]);
2053
+ 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
2026
2054
  if (ids.every(i => Array.isArray(L[i]))) return L;
2027
2055
  const auto = autoPos(vid), out = {};
2028
2056
  ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
@@ -2037,8 +2065,9 @@ function mapContext(model, opts = {}) {
2037
2065
  lines come from and go to outside it too, where the pass before put them; then ordered by those lines as well. Its far
2038
2066
  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
2039
2067
  placed by its own lines alone, so a box fed from outside, and the lines that pass it, ran into the cards beside it. */
2040
- function blockPos(vid) {
2041
- const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
2068
+ // A typed view whose type gives boxes (notes/stage-2.md §3.2) passes them, T: { boxes, flip }; their ids are not cards
2069
+ function blockPos(vid, T) {
2070
+ 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);
2042
2071
  const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to), byBox = Object.fromEntries(boxes.map(b => [b.id, b]));
2043
2072
  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
2044
2073
  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
@@ -2094,7 +2123,10 @@ function mapContext(model, opts = {}) {
2094
2123
  }
2095
2124
  // a typed view whose type lays it out (1.9): the plain flow's ranking, with the lines the type turns round for it
2096
2125
  const ownLayout = vid => { if (D.schemaVersion !== 2) return null; const T = diagramOf(V[vid]); return T && T.layout && (T.layout.run || typeof T.layout === 'function') ? T : null; }; // version 1: a map
2097
- const autoPos = vid => ownLayout(vid) ? flowPos(vid, { flip: ownLayout(vid).rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
2126
+ // its boxes (registry.js): the type's, from the model; a map's are its containers
2127
+ const boxesOf = vid => { const T = ownLayout(vid); return T && T.boxes ? arrOf(T.boxes(D, V[vid])).filter(b => b && b.id != null) : []; };
2128
+ const autoPos = vid => { const T = ownLayout(vid), bx = boxesOf(vid);
2129
+ return T ? bx.length ? blockPos(vid, { boxes: bx, flip: T.rankFlip }) : flowPos(vid, { flip: T.rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid); };
2098
2130
  const memos = opts.memos || Object.create(null); // per view, layoutTyped's memo, kept across update()s: an edit that moves nothing lays out nothing
2099
2131
  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
2100
2132
  /* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
@@ -2114,8 +2146,9 @@ function mapContext(model, opts = {}) {
2114
2146
  let scouting = false;
2115
2147
  const fanIn = vid => { const k = {}; return viewEdges(V[vid] || {}).some(e => e.from !== e.to && (k[e.to] = (k[e.to] || 0) + 1) >= 6); };
2116
2148
  const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
2149
+ 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];
2117
2150
  function flowPos(vid, o = {}) {
2118
- const v = o.flip ? { ...V[vid], edges: viewEdges(V[vid]).map(e => o.flip(e) ? { ...e, from: e.to, to: e.from } : e) } : V[vid];
2151
+ const v = flipView(vid, o.flip);
2119
2152
  const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
2120
2153
  // cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
2121
2154
  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]));
@@ -2192,7 +2225,7 @@ function mapContext(model, opts = {}) {
2192
2225
  if (T) { // the type's layout (registry.js), in the host's measure (R2)
2193
2226
  const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
2194
2227
  if (k != null && memo.runKey === k) return memo.runGeo;
2195
- const pos = override || (v.layout && L.pins !== false ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
2228
+ 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)
2196
2229
  const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
2197
2230
  if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
2198
2231
  return g;
@@ -2317,7 +2350,7 @@ function mapContext(model, opts = {}) {
2317
2350
  const viewKey = vid => {
2318
2351
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
2319
2352
  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]);
2320
- 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
2353
+ 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
2321
2354
  };
2322
2355
  /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
2323
2356
  The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
@@ -2751,7 +2784,8 @@ export function diff(before, after) {
2751
2784
  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));
2752
2785
  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)}` : ''))); }
2753
2786
  if (h === 'removed') said.push(`removes ${name(n.id)}`);
2754
- if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label');
2787
+ 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;
2788
+ 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)
2755
2789
  said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
2756
2790
  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;
2757
2791
  const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
@@ -3464,7 +3498,10 @@ class Camera {
3464
3498
  core.Node and core.Line: a typed view's lines are typed, or validate() refuses them.
3465
3499
 
3466
3500
  Keys on a typed view: F2 renames, N adds the palette's first element, L connects (arrows or type a name, Enter),
3467
- Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model. */
3501
+ Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model. A box the type
3502
+ gives (registry.js boxes) is selected as a container and takes F2, Delete and the panel as a card does; a card dropped in
3503
+ another box, or the Owner field, gives it a new owner (reparent). L on a line starts at its far end, with edits.connect;
3504
+ layout.pins false: no pin. src/typed/edit.js takes gestures over (TYPED.edit, registry.js). */
3468
3505
 
3469
3506
  const GENERIC_NODE = { type: 'core.Node', label: 'Node' }, GENERIC_LINE = { type: 'core.Line', label: 'Line' };
3470
3507
  const isRec = x => x !== null && typeof x === 'object' && !Array.isArray(x);
@@ -3474,7 +3511,7 @@ function elementsOf(m) {
3474
3511
  const at = new Map(), put = (x, kind, owner) => { if (!isRec(x) || typeof x.id !== 'string' || at.has(x.id)) return;
3475
3512
  at.set(x.id, { x, kind, owner }); arrOf(x.members).forEach(y => put(y, 'member', x)); };
3476
3513
  arrOf(m && m.nodes).forEach(n => put(n, 'node')); arrOf(m && m.edges).forEach(e => put(e, 'edge'));
3477
- 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')); } });
3514
+ 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')); } });
3478
3515
  return at;
3479
3516
  }
3480
3517
 
@@ -3506,18 +3543,26 @@ function typedEditor(api) {
3506
3543
  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; };
3507
3544
  const typeOf = x => typeof x.type === 'string' && x.type ? x.type : null; // a model may hold anything in type
3508
3545
  const typed = (t, base) => { const ty = typeOf(t); return { id: newId(ty ? ty.split('.').pop() : base), ...(ty ? { type: ty } : {}) }; };
3509
- 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
3546
+ 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
3510
3547
  const selId = (at = elementsOf(model())) => { const id = idsOf(api.sel())[0]; return id && at.has(id) ? id : null; };
3511
3548
  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
3512
3549
 
3513
3550
  /* the gestures */
3514
3551
  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 }]); };
3515
- 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] }]);
3516
- const drop = (n, hint) => (hint.col !== n.col || hint.row !== n.row) && pin(n.id, hint.col, hint.row);
3517
- function remove(fromModel) { // Delete: out of this view; Shift + Delete: out of the model (risks.md E5). One transaction for the selection (E2)
3518
- const ids = [...new Set(api.picked().flatMap(idsOf))]; if (!can('delete') || !ids.length) return false;
3552
+ 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] }]);
3553
+ // 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
3554
+ // 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
3555
+ // picture before would land elsewhere and stretch a box over cards it does not hold: no pin, and a pin it had is removed.
3556
+ // into, from: the box under the drop, the box it was in
3557
+ // a re-own, by a drop or the Owner field: reparent, and the removal of a pin it had
3558
+ 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 }] : [])]; };
3559
+ 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)
3560
+ : can('move') && emit(reown(n.id, into ?? view().subject ?? null));
3561
+ const picks = () => [...new Set(api.picked().flatMap(idsOf))];
3562
+ 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)
3563
+ if (!can('delete') || !ids.length) return false;
3519
3564
  if (fromModel) return emit(ids.map(id => ({ op: 'delete', id })));
3520
- const v = view(), own = new Set([...arrOf(v.nodes), ...arrOf(v.edges)].filter(isRec).map(x => x.id)), at = elementsOf(model()), out = [];
3565
+ 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 = [];
3521
3566
  ids.forEach(id => { const e = at.get(id);
3522
3567
  if (own.has(id)) out.push({ op: 'delete', id }); // lives in this view only: hiding it is deleting it
3523
3568
  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');
@@ -3529,12 +3574,12 @@ function typedEditor(api) {
3529
3574
  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() }]));
3530
3575
  }
3531
3576
  const hook = k => { const E = def().edits; return isRec(E) && typeof E[k] === 'function' ? E[k] : null; }; // registry.js edits
3532
- const addLine = (from, to, t) => { const c = hook('connect'); // at (a slot): stage 3's
3533
- emit(c ? arrOf(c(model(), view(), from, to, null, t)) : [{ op: 'add', element: { ...typed(t, 'line'), from, to, ...(isRec(t.props) ? { props: structuredClone(t.props) } : {}) }, view: api.view() }]); };
3534
- function connect(from, to) { // the relationship picker, when the type has more than one kind of line
3535
- if (!can('add') || !from || !to || from === to) return;
3577
+ 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)
3578
+ 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 }
3579
+ function connect(from, to, ev, at = null) { // the relationship picker, when the type has more than one kind of line
3580
+ if (!can('add') || !from || !to || from === to && !I.self) return;
3536
3581
  const types = lineTypes();
3537
- if (types.length === 1) addLine(from, to, types[0]); else picker(to, types, t => addLine(from, to, t));
3582
+ if (types.length === 1) addLine(from, to, types[0], at); else picker(to, types, t => addLine(from, to, t, at));
3538
3583
  }
3539
3584
  function closeMenu(back) { const m = menu; if (!m) return false; menu = null; m.remove(); if (back) api.focus(); api.touch(); return true; }
3540
3585
  function picker(at, types, done) {
@@ -3570,10 +3615,10 @@ function typedEditor(api) {
3570
3615
  function linkKey(e) {
3571
3616
  const K = e.key;
3572
3617
  if (K === 'Escape') { endLink(); say('No line added'); return true; }
3573
- if (K === 'Enter') { const { from, to } = link; if (!to || to === from) { say('Pick another card first'); return true; } endLink(); connect(from, to); return true; }
3618
+ 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; }
3574
3619
  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; }
3575
3620
  if (K.length === 1 && !e.altKey) { const now = performance.now(); link.find = (now - link.t < 900 ? link.find : '') + K.toLowerCase(); link.t = now;
3576
- const hit = api.geo().nodes.find(n => n.id !== link.from && String(api.nameOf(n.id)).toLowerCase().startsWith(link.find));
3621
+ 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];
3577
3622
  if (hit) { link.to = hit.id; mark(); } return true; }
3578
3623
  return false;
3579
3624
  }
@@ -3583,11 +3628,12 @@ function typedEditor(api) {
3583
3628
  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];
3584
3629
  if (!d || !n) return false; if (Number.isFinite(n.col)) pin(n.id, n.col + d[0], n.row + d[1]); return true; }
3585
3630
  if (K === 'F2' && s && can('rename')) { api.rename(); return true; }
3586
- if ((K === 'n' || K === 'N') && can('add')) { addNode(0); return true; }
3587
- if ((K === 'l' || K === 'L') && can('add') && s && s.type === 'node') {
3588
- link = { from: s.id, to: null, find: '', t: 0 }; api.touch();
3589
- say(`Line from ${api.nameOf(s.id)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
3590
- if (K === 'Delete' && s) { remove(e.shiftKey); return true; }
3631
+ if ((K === 'n' || K === 'N') && can('add')) { I.addNode(0); return true; }
3632
+ const from = s && (s.type === 'node' ? s.id : s.type === 'edge' && hook('connect') && (api.geo().edges.find(x => idsOf(s).includes(x._id)) || {}).to);
3633
+ if ((K === 'l' || K === 'L') && can('add') && from) {
3634
+ link = { from, to: null, find: '', t: 0 }; api.touch();
3635
+ say(`Line from ${api.nameOf(from)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
3636
+ if (K === 'Delete' && s) { I.remove(e.shiftKey); return true; }
3591
3637
  return false;
3592
3638
  }
3593
3639
 
@@ -3618,6 +3664,12 @@ function typedEditor(api) {
3618
3664
  if (f.kind === 'bool') h += `<label class="um-check"><input type="checkbox" data-f="p:${esc(f.key)}"${key}${v ? ' checked' : ''}${off}>${esc(name)}</label>`;
3619
3665
  else if (f.kind === 'enum') h += `<label>${esc(name)}<select data-f="p:${esc(f.key)}"${key}${off}>${['', ...arrOf(f.options)].map(o => `<option value="${esc(o)}"${o === v ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select></label>`;
3620
3666
  else h += text('p:' + f.key, f.kind === 'list' ? arrOf(v).join(', ') : v ?? '', name, key + (f.kind === 'list' ? ' data-list' : '')); });
3667
+ // the Owner field (notes/stage-2.md §8): the owners the type offers, less the element and what it holds, and none
3668
+ 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; };
3669
+ // 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
3670
+ const offer = Array.isArray(os) && [{ id: '', label: 'none' }, ...os.filter(o => isRec(o) && o.id != null && !under(o.id))];
3671
+ 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 });
3672
+ 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>`;
3621
3673
  const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
3622
3674
  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>';
3623
3675
  const M = def().member;
@@ -3656,6 +3708,7 @@ function typedEditor(api) {
3656
3708
  const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
3657
3709
  const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
3658
3710
  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 }]); }
3711
+ if (d.owner != null) { const p = t.value || null; return id && can('move') && p !== (at.get(id).x.parent ?? null) && emit(reown(id, p)); }
3659
3712
  if (!can('rename') || (!id && d.m == null)) return;
3660
3713
  let value = t.type === 'checkbox' ? t.checked : t.value.trim();
3661
3714
  if (t.type !== 'checkbox') { if (t.value === d.was) return;
@@ -3679,12 +3732,12 @@ function typedEditor(api) {
3679
3732
  panel.className = 'um-editor'; panel.setAttribute('aria-label', 'Edit this diagram');
3680
3733
  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>';
3681
3734
  panel.addEventListener('click', e => { const b = e.target.closest('button, [data-orphans]'); if (!b) return; const d = b.dataset;
3682
- if (d.add != null) addNode(+d.add);
3735
+ if (d.add != null) I.addNode(+d.add);
3683
3736
  else if (d.go != null) { if (!d.go || !api.selectId(top(d.go))) api.status('Not drawn in this view'); }
3684
3737
  else if (d.show != null) show(d.show);
3685
- else if (d.del != null) { if (can('delete')) emit([{ op: 'delete', id: d.del }]); }
3738
+ else if (d.del != null) I.remove(true, [d.del]);
3686
3739
  else if (d.up != null || d.down != null) moveMember(d.up ?? d.down, d.up != null ? -1 : 1);
3687
- else if (d.hide != null || d.kill != null) remove(d.kill != null);
3740
+ else if (d.hide != null || d.kill != null) I.remove(d.kill != null);
3688
3741
  else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
3689
3742
  panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
3690
3743
  panel.addEventListener('keydown', e => { e.stopPropagation(); const t = e.target, K = e.key; // the panel's keys stay in it
@@ -3701,12 +3754,14 @@ function typedEditor(api) {
3701
3754
  refresh();
3702
3755
  }
3703
3756
 
3704
- return { on, key, label, drop, connect, mount, refresh,
3757
+ const I = { on, key, label, drop, connect, mount, refresh, addNode, remove,
3705
3758
  paint: () => { if (panel && on()) put('.um-props', props()); },
3706
- add: () => addNode(0),
3759
+ add: () => I.addNode(0),
3707
3760
  cancel: () => { const a = endLink(), b = closeMenu(false); return a || b; },
3708
3761
  linking: () => !!link || !!menu,
3709
3762
  width: () => panel ? panel.offsetWidth + 8 : 0 };
3763
+ if (TYPED.edit) TYPED.edit(I, { api, model, view, def, hook, emit, typed, nodeTypes, picker, picks });
3764
+ return I;
3710
3765
  }
3711
3766
  const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
3712
3767
 
@@ -3801,14 +3856,15 @@ export function render(container, model, options = {}) {
3801
3856
  const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
3802
3857
  const nodeRef = id => ({ type: 'node', id, view: cur });
3803
3858
  const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
3804
- const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
3859
+ const picked = () => sel ? [...more, sel].map(copyRef) : []; // every selected thing, the latest last (more: the others' refs)
3805
3860
  const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
3861
+ 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
3806
3862
  const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
3807
3863
  let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
3808
3864
  const emitSel = (again = false) => {
3809
3865
  const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
3810
- say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
3811
- : 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`);
3866
+ 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)])
3867
+ : `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'));
3812
3868
  emit('select', { target: copyRef(sel), targets: picked() });
3813
3869
  };
3814
3870
  const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
@@ -3947,25 +4003,25 @@ export function render(container, model, options = {}) {
3947
4003
  if (can('add') && sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id];
3948
4004
  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>`); }
3949
4005
  svg.querySelectorAll('.sel,.nb,.hot').forEach(g => g.classList.remove('sel', 'nb', 'hot')); stage.classList.remove('focusing');
3950
- svg.querySelectorAll('.node').forEach(g => g.setAttribute('aria-pressed', !!sel && sel.type === 'node' && (g.dataset.id === sel.id || more.includes(g.dataset.id))));
3951
- const li = sel && sel.type === 'edge' ? edgeIndex(sel) : -1; svg.querySelectorAll('.line-key').forEach(k => k.setAttribute('aria-pressed', +k.dataset.i === li));
4006
+ const pk = picked(), ns = pk.filter(r => r.type === 'node').map(r => r.id), li = pk.filter(r => r.type === 'edge').map(edgeIndex);
4007
+ 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)));
3952
4008
  if (!sel) return;
3953
4009
  if (sel.type === 'node') {
3954
4010
  const nid = sel.id, ups = new Set(), downs = new Set();
3955
4011
  geo.edges.forEach(e => { if (e.to === nid) ups.add(e.from); if (e.from === nid) downs.add(e.to); });
3956
- 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)); });
4012
+ 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)); });
3957
4013
  svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
3958
4014
  svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
3959
4015
  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)
3960
4016
  stage.classList.add('focusing');
3961
4017
  } else if (sel.type === 'edge') {
3962
- const i = edgeIndex(sel), e = geo.edges[i];
3963
- svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'));
3964
- svg.querySelectorAll('.node').forEach(g => g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to));
4018
+ const e = geo.edges[edgeIndex(sel)];
4019
+ 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
3965
4020
  stage.classList.add('focusing');
3966
4021
  } else if (sel.type === 'container') {
3967
4022
  const f = svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"]`); if (f) f.classList.add('sel');
3968
4023
  }
4024
+ li.forEach(i => svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'))); // the lines selected
3969
4025
  }
3970
4026
  function select(ref, o = {}) {
3971
4027
  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)
@@ -3989,11 +4045,14 @@ export function render(container, model, options = {}) {
3989
4045
  return best;
3990
4046
  }
3991
4047
 
3992
- /* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out */
3993
- function toggle(id) {
3994
- if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
3995
- else if (more.includes(id)) more = more.filter(x => x !== id);
3996
- else { more.push(sel.id); sel = nodeRef(id); }
4048
+ /* 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
4049
+ Shift + Enter on its ring (notes/stage-3.md §5.1). ref: a nodeRef or edgeRef */
4050
+ const kinds = () => typed() ? ['node', 'edge'] : ['node'], refKey = r => JSON.stringify([r.type, r.id, r.ids]);
4051
+ function toggle(ref) {
4052
+ const k = refKey(ref);
4053
+ if (k === refKey(sel)) { if (!more.length) return select(null); sel = more.pop(); }
4054
+ else if (more.some(r => refKey(r) === k)) more = more.filter(r => refKey(r) !== k);
4055
+ else { more.push(sel.type === 'node' ? nodeRef(sel.id) : sel); sel = ref; }
3997
4056
  sub = null; selectNew = false; paint(); emitSel();
3998
4057
  }
3999
4058
 
@@ -4120,7 +4179,7 @@ export function render(container, model, options = {}) {
4120
4179
  if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
4121
4180
  const keep = sel, keepMore = more, touched = cam.touched;
4122
4181
  mount(cur); cam.set({}); cam.touched = touched;
4123
- if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
4182
+ if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4124
4183
  emitSel(); }
4125
4184
  touch(); return true;
4126
4185
  }
@@ -4136,7 +4195,7 @@ export function render(container, model, options = {}) {
4136
4195
 
4137
4196
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
4138
4197
  function key(e) {
4139
- const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
4198
+ 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
4140
4199
  if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
4141
4200
  if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
4142
4201
  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
@@ -4144,7 +4203,8 @@ export function render(container, model, options = {}) {
4144
4203
  if (e.altKey) return false;
4145
4204
  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]');
4146
4205
  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);
4147
- 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)
4206
+ const add = k === 'Enter' && e.shiftKey && sel && kinds().includes(sel.type) && typed(); // Shift + Enter: into the selection, or out of it
4207
+ 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)
4148
4208
  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); }
4149
4209
  else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
4150
4210
  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)
@@ -4154,11 +4214,11 @@ export function render(container, model, options = {}) {
4154
4214
  else if (k === '1') cam.actual();
4155
4215
  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
4156
4216
  const id = g ? g.dataset.id : sel.id;
4157
- if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
4217
+ 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 }); }
4158
4218
  else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
4159
4219
  else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
4160
4220
  else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
4161
- 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 }); }
4221
+ (ringOf(to.id) || nodeG(to.id)).focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
4162
4222
  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); }
4163
4223
  else return false;
4164
4224
  readerMoved(); // a key the map acted on: a story playing waits
@@ -4220,14 +4280,16 @@ export function render(container, model, options = {}) {
4220
4280
  function hintAt(x, y) { // a point in the view, and on a column view its column and row
4221
4281
  const h = { x: Math.round(x), y: Math.round(y) };
4222
4282
  if (!gridView()) return h;
4283
+ 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
4284
+ if (T) [x, y] = [y, x];
4223
4285
  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
4224
- 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; };
4286
+ 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; };
4225
4287
  const first = cols[0], last = cols[cols.length - 1], step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
4226
4288
  let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
4227
4289
  if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step);
4228
4290
  else if (x < mid(first) - step / 2) col = Math.max(0, first - Math.round((mid(first) - x) / step));
4229
- 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;
4230
- h.col = col; h.row = Math.max(0, Math.round((y - (a.y - a.row * pitch) - 32) / pitch));
4291
+ 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;
4292
+ h.col = col; h.row = Math.max(0, Math.round((y - (Y(a) - a.row * pitch) - 32) / pitch));
4231
4293
  return h;
4232
4294
  }
4233
4295
  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); };
@@ -4301,7 +4363,7 @@ export function render(container, model, options = {}) {
4301
4363
  ['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
4302
4364
  function focusMap(ref = sel, reveal = true) {
4303
4365
  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)
4304
- 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
4366
+ const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && (ringOf(id) || nodeG(id));
4305
4367
  if (!g) return stage.focus({ preventScroll: true });
4306
4368
  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 });
4307
4369
  }
@@ -4337,7 +4399,7 @@ export function render(container, model, options = {}) {
4337
4399
  if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
4338
4400
  if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
4339
4401
  const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
4340
- 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 });
4402
+ 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)
4341
4403
  return; }
4342
4404
  if (!drag) return;
4343
4405
  const d = drag; drag = null;
@@ -4348,7 +4410,7 @@ export function render(container, model, options = {}) {
4348
4410
  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);
4349
4411
  const fromId = from ? from.id : null, toId = into ? into.id : null;
4350
4412
  let ev = null;
4351
- if (typed()) ev = tx.drop(n, hint); // a typed view: the card's pin
4413
+ 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)
4352
4414
  else if (toId !== fromId && can('move')) ev = change({ op: 'move', target: nodeRef(n.id), to: toId, hint });
4353
4415
  else if (gridView() && hint.col === n.col && can('reorder')) {
4354
4416
  const sib = siblings(n), at = sib.indexOf(n), i = sib.filter(m => m !== n && m.y + m.h / 2 < y).length;
@@ -4361,14 +4423,20 @@ export function render(container, model, options = {}) {
4361
4423
  stage.addEventListener('pointerup', endGesture);
4362
4424
  stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
4363
4425
  /* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
4364
- takes its hint; kept in this handle only, never written to the host's model */
4365
- function withPlaces(m, use = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }
4426
+ 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
4427
+ that changed the box an element shown before and after is drawn in lays the view out again, so every box holds exactly its
4428
+ contents (notes/stage-2.md §3.2); hiding or deleting a card keeps the places */
4429
+ 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) : []; };
4430
+ 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
4431
+ 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
4366
4432
  if (options.stableLayout === false) return m;
4367
4433
  const out = structuredClone(m);
4368
4434
  (out.views || []).forEach(v => {
4369
- const was = placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id ? use : {};
4435
+ const bx = boxesIn(out, v), pv = viewIn(prev, v.id), A = ownersOn(boxesIn(prev, pv)), B = ownersOn(bx);
4436
+ 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));
4437
+ const was = again ? null : placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id && !again ? use : {};
4370
4438
  if (!was && !Object.keys(hv).length && !Object.keys(ex).length) return;
4371
- const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
4439
+ 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];
4372
4440
  const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
4373
4441
  ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
4374
4442
  ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
@@ -4398,7 +4466,7 @@ export function render(container, model, options = {}) {
4398
4466
  sub = null;
4399
4467
  if (ref) {
4400
4468
  if (!activate(ref, e)) return; // the host took the click (a chip as a link, say): the selection stays as it was
4401
- if (ref.type === 'node' && (e.shiftKey || e.metaKey || e.ctrlKey) && sel && sel.type === 'node') return toggle(ref.id); // several cards
4469
+ if ((e.shiftKey || e.metaKey || e.ctrlKey) && sel && kinds().includes(ref.type) && kinds().includes(sel.type)) return toggle(ref); // several cards, and lines
4402
4470
  return select(ref, { again: ref.type === 'node' }); }
4403
4471
  select(null);
4404
4472
  if (P.onBackground) P.onBackground();
@@ -4506,10 +4574,10 @@ export function render(container, model, options = {}) {
4506
4574
  // a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
4507
4575
  const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
4508
4576
  m.delete(nid); const l = expanded[vid] ||= []; if (!l.includes(nid)) { l.push(nid); if (vid === cur) brought.push(nid); } }));
4509
- const old = ctx, keys = {}, wantNew = selectNew && !own;
4577
+ const old = ctx, keys = {}, wantNew = selectNew && !own, before = current;
4510
4578
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
4511
4579
  current = model2;
4512
- ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
4580
+ ctx = mapContext(withPlaces(model2, use, before), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
4513
4581
  Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
4514
4582
  if (!own) selectNew = false;
4515
4583
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
@@ -4534,7 +4602,7 @@ export function render(container, model, options = {}) {
4534
4602
  if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
4535
4603
  else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
4536
4604
  else { // the selection that is still drawn stays, and a select event comes only if it changed (unfolding: the caller says)
4537
- if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
4605
+ if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4538
4606
  if (!unfolding) emitSel(); }
4539
4607
  if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
4540
4608
  touch();
@@ -5668,9 +5736,10 @@ export function mountPage(container, model, options = {}) {
5668
5736
  - The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
5669
5737
  - Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
5670
5738
  as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
5671
- lines and boxes. They fill two columns below the picture, or one column beside a picture too tall to leave room
5672
- below. A table that runs out of room goes on in the next column, under its heading and header row again; a row is
5673
- never cut, and a heading never ends a column.
5739
+ lines and boxes (on a typed view: each element's type and members, as its type prints them, and each line's kind).
5740
+ They fill two columns below the picture, or one column beside a picture too tall to leave room below. A table that
5741
+ runs out of room goes on in the next column, under its heading and header row again; a row is never cut, and a
5742
+ heading never ends a column.
5674
5743
  - The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
5675
5744
  function printDoc(X, model, Q) {
5676
5745
  const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
@@ -5760,6 +5829,8 @@ function printDoc(X, model, Q) {
5760
5829
  shown.forEach(([v, svg], vi) => {
5761
5830
  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]));
5762
5831
  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);
5832
+ 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"
5833
+ const asText = (diagramOf(v)?.member || {}).print || (m => m.label ?? m.id); // a member as its type writes it, else its name
5763
5834
  const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
5764
5835
  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>` : ''}`;
5765
5836
  const body = sheet(head(false), vi);
@@ -5774,11 +5845,12 @@ function printDoc(X, model, Q) {
5774
5845
  const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
5775
5846
  const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
5776
5847
  const tables = [
5777
- { 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;
5848
+ { 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;
5778
5849
  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>`
5779
- + `<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>`; }) },
5780
- { 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';
5781
- 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>`; }) },
5850
+ + (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>`
5851
+ : `<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>`); }) },
5852
+ { 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';
5853
+ 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>`; }) },
5782
5854
  { 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>`) },
5783
5855
  ].filter(x => x.rows.length);
5784
5856
  [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);