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