umlimator 0.18.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/README.md +23 -9
- package/lib/umlimator-all.js +2482 -190
- package/lib/umlimator.d.ts +22 -1
- package/lib/umlimator.js +173 -103
- package/lib/umlimator.schema.json +32 -14
- package/package.json +1 -1
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
|
|
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="
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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 =
|
|
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
|
-
|
|
1019
|
-
|
|
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 =>
|
|
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
|
-
|
|
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 (
|
|
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]
|
|
1050
|
-
else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY,
|
|
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
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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()];
|
|
@@ -2022,7 +2048,7 @@ function mapContext(model, opts = {}) {
|
|
|
2022
2048
|
/* a layout may place only some cards: they keep their slots, the rest take their auto-layout slot or the next free
|
|
2023
2049
|
row below it (a complete layout is used as it is) */
|
|
2024
2050
|
function fillLayout(vid, L) {
|
|
2025
|
-
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
|
|
2026
2052
|
if (ids.every(i => Array.isArray(L[i]))) return L;
|
|
2027
2053
|
const auto = autoPos(vid), out = {};
|
|
2028
2054
|
ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
|
|
@@ -2037,8 +2063,9 @@ function mapContext(model, opts = {}) {
|
|
|
2037
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
|
|
2038
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
|
|
2039
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. */
|
|
2040
|
-
|
|
2041
|
-
|
|
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);
|
|
2042
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]));
|
|
2043
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
|
|
2044
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
|
|
@@ -2094,7 +2121,10 @@ function mapContext(model, opts = {}) {
|
|
|
2094
2121
|
}
|
|
2095
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
|
|
2096
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
|
|
2097
|
-
|
|
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); };
|
|
2098
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
|
|
2099
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
|
|
2100
2130
|
/* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
|
|
@@ -2114,8 +2144,9 @@ function mapContext(model, opts = {}) {
|
|
|
2114
2144
|
let scouting = false;
|
|
2115
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); };
|
|
2116
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];
|
|
2117
2148
|
function flowPos(vid, o = {}) {
|
|
2118
|
-
const v =
|
|
2149
|
+
const v = flipView(vid, o.flip);
|
|
2119
2150
|
const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
|
|
2120
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
|
|
2121
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]));
|
|
@@ -2192,7 +2223,7 @@ function mapContext(model, opts = {}) {
|
|
|
2192
2223
|
if (T) { // the type's layout (registry.js), in the host's measure (R2)
|
|
2193
2224
|
const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
|
|
2194
2225
|
if (k != null && memo.runKey === k) return memo.runGeo;
|
|
2195
|
-
const pos = override || (
|
|
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)
|
|
2196
2227
|
const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
|
|
2197
2228
|
if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
|
|
2198
2229
|
return g;
|
|
@@ -2317,7 +2348,7 @@ function mapContext(model, opts = {}) {
|
|
|
2317
2348
|
const viewKey = vid => {
|
|
2318
2349
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
|
|
2319
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]);
|
|
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
|
|
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
|
|
2321
2352
|
};
|
|
2322
2353
|
/* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
|
|
2323
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
|
|
@@ -2751,7 +2782,8 @@ export function diff(before, after) {
|
|
|
2751
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));
|
|
2752
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)}` : ''))); }
|
|
2753
2784
|
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');
|
|
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)
|
|
2755
2787
|
said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
|
|
2756
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;
|
|
2757
2789
|
const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
|
|
@@ -3464,7 +3496,10 @@ class Camera {
|
|
|
3464
3496
|
core.Node and core.Line: a typed view's lines are typed, or validate() refuses them.
|
|
3465
3497
|
|
|
3466
3498
|
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.
|
|
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). */
|
|
3468
3503
|
|
|
3469
3504
|
const GENERIC_NODE = { type: 'core.Node', label: 'Node' }, GENERIC_LINE = { type: 'core.Line', label: 'Line' };
|
|
3470
3505
|
const isRec = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
@@ -3474,7 +3509,7 @@ function elementsOf(m) {
|
|
|
3474
3509
|
const at = new Map(), put = (x, kind, owner) => { if (!isRec(x) || typeof x.id !== 'string' || at.has(x.id)) return;
|
|
3475
3510
|
at.set(x.id, { x, kind, owner }); arrOf(x.members).forEach(y => put(y, 'member', x)); };
|
|
3476
3511
|
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')); } });
|
|
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')); } });
|
|
3478
3513
|
return at;
|
|
3479
3514
|
}
|
|
3480
3515
|
|
|
@@ -3506,18 +3541,26 @@ function typedEditor(api) {
|
|
|
3506
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; };
|
|
3507
3542
|
const typeOf = x => typeof x.type === 'string' && x.type ? x.type : null; // a model may hold anything in type
|
|
3508
3543
|
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
|
|
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
|
|
3510
3545
|
const selId = (at = elementsOf(model())) => { const id = idsOf(api.sel())[0]; return id && at.has(id) ? id : null; };
|
|
3511
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
|
|
3512
3547
|
|
|
3513
3548
|
/* the gestures */
|
|
3514
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 }]); };
|
|
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
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
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;
|
|
3519
3562
|
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 = [];
|
|
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 = [];
|
|
3521
3564
|
ids.forEach(id => { const e = at.get(id);
|
|
3522
3565
|
if (own.has(id)) out.push({ op: 'delete', id }); // lives in this view only: hiding it is deleting it
|
|
3523
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');
|
|
@@ -3529,12 +3572,12 @@ function typedEditor(api) {
|
|
|
3529
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() }]));
|
|
3530
3573
|
}
|
|
3531
3574
|
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
|
|
3533
|
-
|
|
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;
|
|
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;
|
|
3536
3579
|
const types = lineTypes();
|
|
3537
|
-
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));
|
|
3538
3581
|
}
|
|
3539
3582
|
function closeMenu(back) { const m = menu; if (!m) return false; menu = null; m.remove(); if (back) api.focus(); api.touch(); return true; }
|
|
3540
3583
|
function picker(at, types, done) {
|
|
@@ -3570,10 +3613,10 @@ function typedEditor(api) {
|
|
|
3570
3613
|
function linkKey(e) {
|
|
3571
3614
|
const K = e.key;
|
|
3572
3615
|
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; }
|
|
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; }
|
|
3574
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; }
|
|
3575
3618
|
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
|
|
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];
|
|
3577
3620
|
if (hit) { link.to = hit.id; mark(); } return true; }
|
|
3578
3621
|
return false;
|
|
3579
3622
|
}
|
|
@@ -3583,11 +3626,12 @@ function typedEditor(api) {
|
|
|
3583
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];
|
|
3584
3627
|
if (!d || !n) return false; if (Number.isFinite(n.col)) pin(n.id, n.col + d[0], n.row + d[1]); return true; }
|
|
3585
3628
|
if (K === 'F2' && s && can('rename')) { api.rename(); return true; }
|
|
3586
|
-
if ((K === 'n' || K === 'N') && can('add')) { addNode(0); return true; }
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
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; }
|
|
3591
3635
|
return false;
|
|
3592
3636
|
}
|
|
3593
3637
|
|
|
@@ -3618,6 +3662,12 @@ function typedEditor(api) {
|
|
|
3618
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>`;
|
|
3619
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>`;
|
|
3620
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>`;
|
|
3621
3671
|
const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
|
|
3622
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>';
|
|
3623
3673
|
const M = def().member;
|
|
@@ -3656,6 +3706,7 @@ function typedEditor(api) {
|
|
|
3656
3706
|
const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
|
|
3657
3707
|
const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
|
|
3658
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)); }
|
|
3659
3710
|
if (!can('rename') || (!id && d.m == null)) return;
|
|
3660
3711
|
let value = t.type === 'checkbox' ? t.checked : t.value.trim();
|
|
3661
3712
|
if (t.type !== 'checkbox') { if (t.value === d.was) return;
|
|
@@ -3679,12 +3730,12 @@ function typedEditor(api) {
|
|
|
3679
3730
|
panel.className = 'um-editor'; panel.setAttribute('aria-label', 'Edit this diagram');
|
|
3680
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>';
|
|
3681
3732
|
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);
|
|
3733
|
+
if (d.add != null) I.addNode(+d.add);
|
|
3683
3734
|
else if (d.go != null) { if (!d.go || !api.selectId(top(d.go))) api.status('Not drawn in this view'); }
|
|
3684
3735
|
else if (d.show != null) show(d.show);
|
|
3685
|
-
else if (d.del != null)
|
|
3736
|
+
else if (d.del != null) I.remove(true, [d.del]);
|
|
3686
3737
|
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);
|
|
3738
|
+
else if (d.hide != null || d.kill != null) I.remove(d.kill != null);
|
|
3688
3739
|
else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
|
|
3689
3740
|
panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
|
|
3690
3741
|
panel.addEventListener('keydown', e => { e.stopPropagation(); const t = e.target, K = e.key; // the panel's keys stay in it
|
|
@@ -3701,12 +3752,14 @@ function typedEditor(api) {
|
|
|
3701
3752
|
refresh();
|
|
3702
3753
|
}
|
|
3703
3754
|
|
|
3704
|
-
|
|
3755
|
+
const I = { on, key, label, drop, connect, mount, refresh, addNode, remove,
|
|
3705
3756
|
paint: () => { if (panel && on()) put('.um-props', props()); },
|
|
3706
|
-
add: () => addNode(0),
|
|
3757
|
+
add: () => I.addNode(0),
|
|
3707
3758
|
cancel: () => { const a = endLink(), b = closeMenu(false); return a || b; },
|
|
3708
3759
|
linking: () => !!link || !!menu,
|
|
3709
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;
|
|
3710
3763
|
}
|
|
3711
3764
|
const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
|
|
3712
3765
|
|
|
@@ -3801,14 +3854,15 @@ export function render(container, model, options = {}) {
|
|
|
3801
3854
|
const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
|
|
3802
3855
|
const nodeRef = id => ({ type: 'node', id, view: cur });
|
|
3803
3856
|
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(
|
|
3857
|
+
const picked = () => sel ? [...more, sel].map(copyRef) : []; // every selected thing, the latest last (more: the others' refs)
|
|
3805
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
|
|
3806
3860
|
const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
|
|
3807
3861
|
let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
|
|
3808
3862
|
const emitSel = (again = false) => {
|
|
3809
3863
|
const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
|
|
3810
|
-
say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id)
|
|
3811
|
-
:
|
|
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'));
|
|
3812
3866
|
emit('select', { target: copyRef(sel), targets: picked() });
|
|
3813
3867
|
};
|
|
3814
3868
|
const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
|
|
@@ -3947,25 +4001,25 @@ export function render(container, model, options = {}) {
|
|
|
3947
4001
|
if (can('add') && sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id];
|
|
3948
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>`); }
|
|
3949
4003
|
svg.querySelectorAll('.sel,.nb,.hot').forEach(g => g.classList.remove('sel', 'nb', 'hot')); stage.classList.remove('focusing');
|
|
3950
|
-
|
|
3951
|
-
|
|
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)));
|
|
3952
4006
|
if (!sel) return;
|
|
3953
4007
|
if (sel.type === 'node') {
|
|
3954
4008
|
const nid = sel.id, ups = new Set(), downs = new Set();
|
|
3955
4009
|
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',
|
|
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)); });
|
|
3957
4011
|
svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
|
|
3958
4012
|
svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
|
|
3959
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)
|
|
3960
4014
|
stage.classList.add('focusing');
|
|
3961
4015
|
} else if (sel.type === 'edge') {
|
|
3962
|
-
const
|
|
3963
|
-
svg.querySelectorAll(
|
|
3964
|
-
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
|
|
3965
4018
|
stage.classList.add('focusing');
|
|
3966
4019
|
} else if (sel.type === 'container') {
|
|
3967
4020
|
const f = svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"]`); if (f) f.classList.add('sel');
|
|
3968
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
|
|
3969
4023
|
}
|
|
3970
4024
|
function select(ref, o = {}) {
|
|
3971
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)
|
|
@@ -3989,11 +4043,14 @@ export function render(container, model, options = {}) {
|
|
|
3989
4043
|
return best;
|
|
3990
4044
|
}
|
|
3991
4045
|
|
|
3992
|
-
/* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
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; }
|
|
3997
4054
|
sub = null; selectNew = false; paint(); emitSel();
|
|
3998
4055
|
}
|
|
3999
4056
|
|
|
@@ -4120,7 +4177,7 @@ export function render(container, model, options = {}) {
|
|
|
4120
4177
|
if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
|
|
4121
4178
|
const keep = sel, keepMore = more, touched = cam.touched;
|
|
4122
4179
|
mount(cur); cam.set({}); cam.touched = touched;
|
|
4123
|
-
if (keep && exists(keep)) { sel = keep; more =
|
|
4180
|
+
if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
|
|
4124
4181
|
emitSel(); }
|
|
4125
4182
|
touch(); return true;
|
|
4126
4183
|
}
|
|
@@ -4136,7 +4193,7 @@ export function render(container, model, options = {}) {
|
|
|
4136
4193
|
|
|
4137
4194
|
/* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
|
|
4138
4195
|
function key(e) {
|
|
4139
|
-
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
|
|
4140
4197
|
if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
|
|
4141
4198
|
if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
|
|
4142
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
|
|
@@ -4144,7 +4201,8 @@ export function render(container, model, options = {}) {
|
|
|
4144
4201
|
if (e.altKey) return false;
|
|
4145
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]');
|
|
4146
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);
|
|
4147
|
-
|
|
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)
|
|
4148
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); }
|
|
4149
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
|
|
4150
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)
|
|
@@ -4154,11 +4212,11 @@ export function render(container, model, options = {}) {
|
|
|
4154
4212
|
else if (k === '1') cam.actual();
|
|
4155
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
|
|
4156
4214
|
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 }); }
|
|
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 }); }
|
|
4158
4216
|
else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
|
|
4159
4217
|
else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
|
|
4160
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;
|
|
4161
|
-
|
|
4219
|
+
(ringOf(to.id) || nodeG(to.id)).focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
|
|
4162
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); }
|
|
4163
4221
|
else return false;
|
|
4164
4222
|
readerMoved(); // a key the map acted on: a story playing waits
|
|
@@ -4220,14 +4278,16 @@ export function render(container, model, options = {}) {
|
|
|
4220
4278
|
function hintAt(x, y) { // a point in the view, and on a column view its column and row
|
|
4221
4279
|
const h = { x: Math.round(x), y: Math.round(y) };
|
|
4222
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];
|
|
4223
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
|
|
4224
|
-
const mid = c => { const ns = geo.nodes.filter(n => n.col === c && !n.aside); return ns.reduce((a, n) => a + n
|
|
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; };
|
|
4225
4285
|
const first = cols[0], last = cols[cols.length - 1], step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
|
|
4226
4286
|
let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
|
|
4227
4287
|
if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step);
|
|
4228
4288
|
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
|
|
4230
|
-
h.col = col; h.row = Math.max(0, Math.round((y - (a
|
|
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));
|
|
4231
4291
|
return h;
|
|
4232
4292
|
}
|
|
4233
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); };
|
|
@@ -4301,7 +4361,7 @@ export function render(container, model, options = {}) {
|
|
|
4301
4361
|
['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
|
|
4302
4362
|
function focusMap(ref = sel, reveal = true) {
|
|
4303
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)
|
|
4304
|
-
const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg &&
|
|
4364
|
+
const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && (ringOf(id) || nodeG(id));
|
|
4305
4365
|
if (!g) return stage.focus({ preventScroll: true });
|
|
4306
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 });
|
|
4307
4367
|
}
|
|
@@ -4337,7 +4397,7 @@ export function render(container, model, options = {}) {
|
|
|
4337
4397
|
if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
|
|
4338
4398
|
if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
|
|
4339
4399
|
const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
|
|
4340
|
-
if (t &&
|
|
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)
|
|
4341
4401
|
return; }
|
|
4342
4402
|
if (!drag) return;
|
|
4343
4403
|
const d = drag; drag = null;
|
|
@@ -4348,7 +4408,7 @@ export function render(container, model, options = {}) {
|
|
|
4348
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);
|
|
4349
4409
|
const fromId = from ? from.id : null, toId = into ? into.id : null;
|
|
4350
4410
|
let ev = null;
|
|
4351
|
-
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)
|
|
4352
4412
|
else if (toId !== fromId && can('move')) ev = change({ op: 'move', target: nodeRef(n.id), to: toId, hint });
|
|
4353
4413
|
else if (gridView() && hint.col === n.col && can('reorder')) {
|
|
4354
4414
|
const sib = siblings(n), at = sib.indexOf(n), i = sib.filter(m => m !== n && m.y + m.h / 2 < y).length;
|
|
@@ -4361,14 +4421,20 @@ export function render(container, model, options = {}) {
|
|
|
4361
4421
|
stage.addEventListener('pointerup', endGesture);
|
|
4362
4422
|
stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
|
|
4363
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
|
|
4364
|
-
takes its hint; kept in this handle only, never written to the host's model
|
|
4365
|
-
|
|
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
|
|
4366
4430
|
if (options.stableLayout === false) return m;
|
|
4367
4431
|
const out = structuredClone(m);
|
|
4368
4432
|
(out.views || []).forEach(v => {
|
|
4369
|
-
const
|
|
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 : {};
|
|
4370
4436
|
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];
|
|
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];
|
|
4372
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])); };
|
|
4373
4439
|
ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
|
|
4374
4440
|
ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
|
|
@@ -4398,7 +4464,7 @@ export function render(container, model, options = {}) {
|
|
|
4398
4464
|
sub = null;
|
|
4399
4465
|
if (ref) {
|
|
4400
4466
|
if (!activate(ref, e)) return; // the host took the click (a chip as a link, say): the selection stays as it was
|
|
4401
|
-
if (
|
|
4467
|
+
if ((e.shiftKey || e.metaKey || e.ctrlKey) && sel && kinds().includes(ref.type) && kinds().includes(sel.type)) return toggle(ref); // several cards, and lines
|
|
4402
4468
|
return select(ref, { again: ref.type === 'node' }); }
|
|
4403
4469
|
select(null);
|
|
4404
4470
|
if (P.onBackground) P.onBackground();
|
|
@@ -4506,10 +4572,10 @@ export function render(container, model, options = {}) {
|
|
|
4506
4572
|
// a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
|
|
4507
4573
|
const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
|
|
4508
4574
|
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;
|
|
4575
|
+
const old = ctx, keys = {}, wantNew = selectNew && !own, before = current;
|
|
4510
4576
|
old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
|
|
4511
4577
|
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 });
|
|
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 });
|
|
4513
4579
|
Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
|
|
4514
4580
|
if (!own) selectNew = false;
|
|
4515
4581
|
ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
|
|
@@ -4534,7 +4600,7 @@ export function render(container, model, options = {}) {
|
|
|
4534
4600
|
if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
|
|
4535
4601
|
else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
|
|
4536
4602
|
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 =
|
|
4603
|
+
if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
|
|
4538
4604
|
if (!unfolding) emitSel(); }
|
|
4539
4605
|
if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
|
|
4540
4606
|
touch();
|
|
@@ -5668,9 +5734,10 @@ export function mountPage(container, model, options = {}) {
|
|
|
5668
5734
|
- The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
|
|
5669
5735
|
- Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
|
|
5670
5736
|
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
|
|
5672
|
-
|
|
5673
|
-
|
|
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.
|
|
5674
5741
|
- The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
|
|
5675
5742
|
function printDoc(X, model, Q) {
|
|
5676
5743
|
const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
|
|
@@ -5760,6 +5827,8 @@ function printDoc(X, model, Q) {
|
|
|
5760
5827
|
shown.forEach(([v, svg], vi) => {
|
|
5761
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]));
|
|
5762
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
|
|
5763
5832
|
const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
|
|
5764
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>` : ''}`;
|
|
5765
5834
|
const body = sheet(head(false), vi);
|
|
@@ -5774,11 +5843,12 @@ function printDoc(X, model, Q) {
|
|
|
5774
5843
|
const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
|
|
5775
5844
|
const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
|
|
5776
5845
|
const tables = [
|
|
5777
|
-
{ title: `Cards <span>${ids.length}</span>`, cls: 'cards', head:
|
|
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;
|
|
5778
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>`
|
|
5779
|
-
+ `<td
|
|
5780
|
-
|
|
5781
|
-
|
|
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>`; }) },
|
|
5782
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>`) },
|
|
5783
5853
|
].filter(x => x.rows.length);
|
|
5784
5854
|
[1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
|