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/README.md +50 -10
- package/lib/umlimator-all.js +2486 -192
- package/lib/umlimator.d.ts +51 -2
- package/lib/umlimator.js +177 -105
- package/lib/umlimator.schema.json +141 -18
- 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()];
|
|
@@ -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
|
-
|
|
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
|
-
|
|
2041
|
-
|
|
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
|
-
|
|
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 =
|
|
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 || (
|
|
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
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
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
|
|
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;
|
|
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
|
|
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
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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)
|
|
3811
|
-
:
|
|
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
|
-
|
|
3951
|
-
|
|
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',
|
|
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
|
|
3963
|
-
svg.querySelectorAll(
|
|
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
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4230
|
-
h.col = col; h.row = Math.max(0, Math.round((y - (a
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|
5672
|
-
|
|
5673
|
-
|
|
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:
|
|
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
|
|
5780
|
-
|
|
5781
|
-
|
|
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);
|