umlimator 0.21.0 → 0.23.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 +73 -14
- package/lib/umlimator-all.js +1222 -71
- package/lib/umlimator.d.ts +77 -1
- package/lib/umlimator.js +26 -18
- package/lib/umlimator.schema.json +190 -18
- package/package.json +1 -1
package/lib/umlimator-all.js
CHANGED
|
@@ -124,7 +124,7 @@ function registerDiagramType(name, def = {}) {
|
|
|
124
124
|
reroute is no registry field: reroute: false goes in the dg the type's own layout passes to layoutTyped (src/typed/layout.js).
|
|
125
125
|
Stage 4's (notes/stage-4.md §12.1, §15): edits.rename(model, view, id, text); a hook's { pick, then }; name: false and
|
|
126
126
|
opens on a palette entry; a line's cut, its label's width (150); rows(model, view, id) and word(model, id), the PDF's.
|
|
127
|
-
Stage 5's: a line's fromPort, toPort, inner, portRank, a box's pad, round, a view's labelGaps (src/typed/layout.js);
|
|
127
|
+
Stage 5's: a line's fromPort, toPort, inner, portRank, a box's pad, round, head, a view's labelGaps (src/typed/layout.js);
|
|
128
128
|
edits.member, edits.key (src/typed/edit.js). */
|
|
129
129
|
|
|
130
130
|
/* A view's type: the map for a view with no diagram, the registered type, or null for one this release does not draw */
|
|
@@ -132,7 +132,9 @@ const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram
|
|
|
132
132
|
|
|
133
133
|
/* What only a registered type's geometry uses, installed at load time by the files under src/typed/, which only
|
|
134
134
|
lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf, the
|
|
135
|
-
gestures of edit.js it takes over (edit)
|
|
135
|
+
gestures of edit.js it takes over (edit), diff()'s words for typed models (diff, order: src/uml/diff.js). Empty in
|
|
136
|
+
umlimator.js, where no view is laid out but as a map (risks.md C5).
|
|
137
|
+
Stage 6's: across(model), validate()'s rules on the whole model; fields(model, x), more panel fields (notes/stage-6.md §5) */
|
|
136
138
|
const TYPED = {};
|
|
137
139
|
|
|
138
140
|
const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
|
|
@@ -249,6 +251,7 @@ function refs(m, ids, only) {
|
|
|
249
251
|
arr(v.nodes).forEach((n, j) => element(n, [...at, 'nodes', j], false));
|
|
250
252
|
arr(v.edges).forEach((e, j) => { if (has(e)) say([...at, 'edges', j], 'view'); else element(e, [...at, 'edges', j], true); });
|
|
251
253
|
arr(v.fragments).forEach((f, j) => element(f, [...at, 'fragments', j], false));
|
|
254
|
+
['changes', 'constraints'].forEach(k => arr(isObj(v.timeline) && v.timeline[k]).forEach((c, j) => isObj(c) && ['lifeline', 'from', 'to'].forEach(f => { if (has(c[f])) say([...at, 'timeline', k, j, f], 'end'); })));
|
|
252
255
|
[['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); }));
|
|
253
256
|
if (has(v.subject)) say([...at, 'subject'], 'subject');
|
|
254
257
|
if (!only) ['parent', 'node'].forEach(k => { if (has(v[k])) say([...at, k], 'parent'); }); // a view opened from a card
|
|
@@ -375,9 +378,9 @@ function run(m, c, gone) {
|
|
|
375
378
|
const take = id => { const r = at.get(id); if (!r || now.has(id)) return; now.add(id);
|
|
376
379
|
arr(r.x.members).forEach(y => isObj(y) && take(y.id)); arr(kids.get(id)).forEach(take); }; // what it owns goes with it
|
|
377
380
|
take(c.id);
|
|
378
|
-
// lines to what is gone go too, lines to those lines, and what a type's refs cascade (a reply with its call)
|
|
381
|
+
// lines to what is gone go too, lines to those lines, a timeline's items, and what a type's refs cascade (a reply with its call)
|
|
379
382
|
const cascade = x => Object.entries(propRefs(x.type)).some(([p, how]) => how === 'cascade' && [].concat(dig(x, p)).some(id => now.has(id)));
|
|
380
|
-
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; } }
|
|
383
|
+
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)) || o.kind === 'time' && [o.x.lifeline, o.x.from, o.x.to].some(i => now.has(i)) || cascade(o.x))) { take(k); more = true; } }
|
|
381
384
|
// what stays in something gone goes to its nearest owner left; an operand's messages to its fragment's parent
|
|
382
385
|
const above = id => { const r = at.get(id); return r && (r.x.parent ?? (r.owner && above(r.owner.id))); };
|
|
383
386
|
for (const [k, o] of at) if (!now.has(k) && now.has(o.x.parent)) { let p = o.x.parent;
|
|
@@ -1036,7 +1039,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1036
1039
|
// 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
|
|
1037
1040
|
// grows to hold it; laid out turned (direction down), the 24 px header room goes left, the side that becomes the top
|
|
1038
1041
|
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
|
|
1039
|
-
const headW = f => textW(!map && f.title || f.label || f.id, 11) + 16; // a box's title, as the audit measures it
|
|
1042
|
+
const headW = f => textW(!map && f.title || f.label || f.id, !map && f.head ? 12 : 11) + 16; // a box's title, as the audit measures it (two lines: at 12 px)
|
|
1040
1043
|
const framesNow = () => { const cache = Object.create(null); // a box may be named 'constructor'
|
|
1041
1044
|
const frameOf = (f, trail = new Set()) => {
|
|
1042
1045
|
if (cache[f.id]) return cache[f.id];
|
|
@@ -1049,7 +1052,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1049
1052
|
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);
|
|
1050
1053
|
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};
|
|
1051
1054
|
if (!map) b[TB ? 'h' : 'w'] = Math.max(b[TB ? 'h' : 'w'], headW(f) + 16);
|
|
1052
|
-
return cache[f.id] = dg.typed ? TYPED.frame(b) : b;
|
|
1055
|
+
return cache[f.id] = dg.typed ? TYPED.frame(b, TB) : b;
|
|
1053
1056
|
};
|
|
1054
1057
|
const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
|
|
1055
1058
|
if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
|
|
@@ -1451,7 +1454,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1451
1454
|
const runRouting = order => {
|
|
1452
1455
|
routed.length = 0; reserved.length = 0; placed.length = 0; stats = {};
|
|
1453
1456
|
(dg.blocks || []).forEach(b => { reserved.push(b); placed.push(b); });
|
|
1454
|
-
families.forEach(f => { const h = TB ? { x: f.x + 3, y: f.y + 8, w:
|
|
1457
|
+
families.forEach(f => { const t = 22 + (!map && f.head || 0), h = TB ? { x: f.x + 3, y: f.y + 8, w: t, h: headOf(f) } : { x: f.x + 8, y: f.y + 3, w: headOf(f), h: t }; reserved.push(h); placed.push(h);
|
|
1455
1458
|
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
|
|
1456
1459
|
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; });
|
|
1457
1460
|
order.forEach(e => {
|
|
@@ -1688,7 +1691,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
|
|
|
1688
1691
|
if (f.parent && (!parent || !inside(parent,f))) say(`${where}: family "${f.label}" sticks out of its parent`, f);
|
|
1689
1692
|
(f.nodeIds || []).forEach(id => { const n = geo.byId[id]; if (n && !inside(f,n)) say(`${where}: family "${f.label}" does not contain "${n.label}"`, f, n); });
|
|
1690
1693
|
families.filter(g => g !== f && g.parent === f.parent && g.id > f.id).forEach(g => { if (hit(f,g,-1)) say(`${where}: families "${f.label}" and "${g.label}" overlap`, f, g); });
|
|
1691
|
-
geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22},i)) say(`${where}: family "${f.label}": header crossed by edge ${name(e)}`, f, e); });
|
|
1694
|
+
geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22+(!geo.map&&f.head||0)},i)) say(`${where}: family "${f.label}": header crossed by edge ${name(e)}`, f, e); });
|
|
1692
1695
|
geo.edges.forEach((e,i) => { if (segs[i].some(([a,b]) => { const v = a[0] === b[0], c = v ? a[0] : a[1], p = v ? [a[1], b[1]] : [a[0], b[0]];
|
|
1693
1696
|
const lo = Math.max(Math.min(...p), v ? f.y : f.x), hi = Math.min(Math.max(...p), v ? f.y + f.h : f.x + f.w);
|
|
1694
1697
|
return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); })) say(`${where}: edge ${name(e)} runs along the border of family "${f.label}"`, e, f); });
|
|
@@ -1703,7 +1706,7 @@ const box = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
|
|
|
1703
1706
|
function overlapIssues(geo, where, refs) {
|
|
1704
1707
|
const issues = [], nodes = geo.nodes, say = teller(issues, refs);
|
|
1705
1708
|
const inside = (a, b) => a.x >= b.x - 1 && a.y >= b.y - 1 && a.x + a.w <= b.x + b.w + 1 && a.y + a.h <= b.y + b.h + 1;
|
|
1706
|
-
const holds = (a, b) => (a.holder && inside(b, a)) || (b.holder && inside(a, b)); // a holder (a sequence's frame) may hold what it meets, not cross it
|
|
1709
|
+
const holds = (a, b) => (a.holder && inside(b, a)) || (b.holder && inside(a, b)) || !geo.map && ((a.owner || 0) === b.id || (b.owner || 0) === a.id); // a holder (a sequence's frame) may hold what it meets, not cross it; a port its owner
|
|
1707
1710
|
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(box(nodes[i]), box(nodes[j]), -1) && !holds(nodes[i], nodes[j])) say(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`, nodes[i], nodes[j]);
|
|
1708
1711
|
return issues;
|
|
1709
1712
|
}
|
|
@@ -2615,9 +2618,10 @@ export function validate(model) {
|
|
|
2615
2618
|
out.push(...typedProblems(m).filter(p => !(p.level === 'error' && said.has(p.path))));
|
|
2616
2619
|
views.forEach((v, i) => { const at = `views[${i}].diagram`; // a well-formed name of a type this release does not draw (M13)
|
|
2617
2620
|
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`); });
|
|
2618
|
-
const once = new Set()
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
+
const once = new Set(), say = p => { const k = JSON.stringify([p.level, p.path, p.message]); if (!once.has(k)) { once.add(k); out.push(p); } };
|
|
2622
|
+
// each view's type's rules, a problem once; a validate(model) that takes no view, once a type
|
|
2623
|
+
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); T.validate(m, v).forEach(say); });
|
|
2624
|
+
if (TYPED.across) arrOf(TYPED.across(m)).forEach(say);
|
|
2621
2625
|
}
|
|
2622
2626
|
return out;
|
|
2623
2627
|
}
|
|
@@ -2774,14 +2778,16 @@ export function diff(before, after) {
|
|
|
2774
2778
|
if (h === 'removed') said.push(`removes ${name(n.id)}`);
|
|
2775
2779
|
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;
|
|
2776
2780
|
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)
|
|
2781
|
+
const t = TYPED.diff && TYPED.diff(o, x, name, [A, out]); if (t) return said.push(...t); // a typed element's own words (src/uml/diff.js)
|
|
2777
2782
|
said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
|
|
2778
2783
|
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;
|
|
2779
2784
|
const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
|
|
2780
2785
|
if (h === 'added') said.push(`adds a line from ${ends}${lab}`);
|
|
2781
2786
|
if (h === 'removed') said.push(`removes the line from ${ends}${lab}`);
|
|
2782
|
-
if (h === 'changed') { const [o, x] = was.get(e);
|
|
2787
|
+
if (h === 'changed') { const [o, x] = was.get(e), t = TYPED.diff && TYPED.diff(o, x, name, [A, out]); if (t) return said.push(...t);
|
|
2783
2788
|
said.push(`changes the line from ${ends} (${diffKeys([o, x]).map(k => k === 'label' ? `label ${o.label ?? '-'} → ${x.label ?? '-'}` : k).join(', ')})`); } });
|
|
2784
2789
|
moved.forEach(([verb, id, v]) => said.push(`${verb} ${name(id)} ${verb === 'puts' ? 'in' : 'out of'} the view ${v.title || v.id}`));
|
|
2790
|
+
if (TYPED.order) TYPED.order(A, out, views, V0, said, touched, name);
|
|
2785
2791
|
|
|
2786
2792
|
// the views that show a change: a card or line the diff marks, or a card it put in or took out
|
|
2787
2793
|
const changed = views.filter(v => v && (touched.has(v.id) || arr(v.nodeIds).some(id => cardHow.has(id)) || linesOf(out, v).some(e => how.has(e)))).map(v => v.id);
|
|
@@ -2835,9 +2841,11 @@ const walkFrom = (edges, start, back) => { const seen = new Set([start]); // bre
|
|
|
2835
2841
|
for (let q = [start]; q.length;) { const u = q.shift(); edges.forEach(e => { const [a, b] = back ? [e.to, e.from] : [e.from, e.to]; if (a === u && !seen.has(b)) { seen.add(b); q.push(b); } }); }
|
|
2836
2842
|
return seen; };
|
|
2837
2843
|
|
|
2844
|
+
const viewType = (model, viewId) => model && model.schemaVersion === 2 && diagramOf(arrOf(model.views).find(v => v && v.id === viewId));
|
|
2838
2845
|
/** Every card and line on a path from one card to another, following lines forward: the cards both reachable from `from`
|
|
2839
2846
|
* and leading to `to`, and the lines between them. [] when no path joins them. */
|
|
2840
2847
|
export function paths(model, viewId, from, to) {
|
|
2848
|
+
const T = viewType(model, viewId); if (T && T.paths) return T.paths(model, viewId, from, to); // a type's own walk (a sequence's, in time)
|
|
2841
2849
|
const { ids, edges } = lineGraph(model, viewId);
|
|
2842
2850
|
if (!ids.includes(from) || !ids.includes(to)) return [];
|
|
2843
2851
|
const F = walkFrom(edges, from), B = walkFrom(edges, to, true), on = id => F.has(id) && B.has(id);
|
|
@@ -2862,7 +2870,7 @@ export function reach(model, viewId, from, options = {}) {
|
|
|
2862
2870
|
/** A view in plain sentences: its cards and their kinds, its boxes, what each card's lines do (in run order on a sequence
|
|
2863
2871
|
* view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
|
|
2864
2872
|
export function describe(model, viewId) {
|
|
2865
|
-
const T =
|
|
2873
|
+
const T = viewType(model, viewId); if (T && T.describe) return T.describe(model, viewId); // a type's own words (1.9)
|
|
2866
2874
|
const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
|
|
2867
2875
|
const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
|
|
2868
2876
|
const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
|
|
@@ -3643,7 +3651,7 @@ function typedEditor(api) {
|
|
|
3643
3651
|
function props() {
|
|
3644
3652
|
const at = elementsOf(model()), id = selId(at), e = id && at.get(id);
|
|
3645
3653
|
if (!e) return '<h3>Properties</h3><p class="um-note">Select a card or a line</p>';
|
|
3646
|
-
const x = e.x, P = isRec(x.props) ? x.props : {},
|
|
3654
|
+
const x = e.x, P = isRec(x.props) ? x.props : {}, F = a => arrOf(a).filter(f => isRec(f) && typeof f.key === 'string'), own = F(isRec(def().props) ? def().props[x.type] : null), schema = [...own, ...F(TYPED.fields && TYPED.fields(model(), x))];
|
|
3647
3655
|
const off = can('rename') ? '' : ' disabled'; // the editable flags: a field the host does not offer is shown, to read
|
|
3648
3656
|
const text = (f, v, name, more = '') => `<label>${esc(name)}<input data-f="${esc(f)}" data-was="${esc(v)}" value="${esc(v)}"${more}${off}></label>`;
|
|
3649
3657
|
let h = `<h3>Properties${x.type != null ? ': ' + esc(x.type) : ''}</h3>` + text('label', x.label ?? '', 'Name');
|
|
@@ -3658,7 +3666,7 @@ function typedEditor(api) {
|
|
|
3658
3666
|
const offer = Array.isArray(os) && [{ id: '', label: 'none' }, ...os.filter(o => isRec(o) && o.id != null && !under(o.id))];
|
|
3659
3667
|
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 });
|
|
3660
3668
|
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>`;
|
|
3661
|
-
const rest =
|
|
3669
|
+
const rest = own.length ? [] : Object.entries(P); // no schema: what it holds, to read
|
|
3662
3670
|
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>';
|
|
3663
3671
|
const M = def().member;
|
|
3664
3672
|
if (e.kind === 'node' && !(M && M.holds && !M.holds(x))) { // a comment holds none
|
|
@@ -3844,7 +3852,7 @@ export function render(container, model, options = {}) {
|
|
|
3844
3852
|
const emitSel = (again = false) => {
|
|
3845
3853
|
const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
|
|
3846
3854
|
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)])
|
|
3847
|
-
: `Box ${
|
|
3855
|
+
: `Box ${[...svg.querySelectorAll(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`)].map(t => t.textContent).join(' ') || sel.id}`) + (more.length ? `, ${more.length + 1} ${picked().every(r => r.type === 'node') ? 'cards ' : ''}selected` : ', selected'));
|
|
3848
3856
|
emit('select', { target: copyRef(sel), targets: picked() });
|
|
3849
3857
|
};
|
|
3850
3858
|
const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
|
|
@@ -4289,7 +4297,7 @@ export function render(container, model, options = {}) {
|
|
|
4289
4297
|
if (ref.type === 'node') { const n = geo.byId[ref.id]; return n ? { x: n.x + 50, y: n.y + n.h / 2 - 13, w: n.w - 58, h: 26 } : null; }
|
|
4290
4298
|
if (ref.type === 'edge') { const e = geo.edges[edgeIndex(ref)]; if (!e) return null; const L = e.lbl, m = e.pts[Math.floor(e.pts.length / 2)];
|
|
4291
4299
|
return L ? { x: L.x - 8, y: L.y - 2, w: L.w + 16, h: L.h + 4 } : { x: m[0] - 60, y: m[1] - 12, w: 120, h: 24 }; }
|
|
4292
|
-
const f = ref.type === 'container' && (geo.families || []).find(x => x.id === ref.id); return f ? { x: f.x + 8, y: f.y + 2, w: Math.min(f.w - 16, 220), h: 24 } : null; };
|
|
4300
|
+
const f = ref.type === 'container' && (geo.families || []).find(x => x.id === ref.id); return f ? { x: f.x + 8, y: f.y + 2 + (f.head || 0), w: Math.min(f.w - 16, 220), h: 24 } : null; };
|
|
4293
4301
|
function editor(where, text, done, ref = null) { // where: a box in map units (a new card's), or ref names what the field sits over
|
|
4294
4302
|
if (naming) naming.close(false);
|
|
4295
4303
|
const inp = document.createElement('input');
|
|
@@ -5883,6 +5891,9 @@ function layoutTyped(dg, memo) {
|
|
|
5883
5891
|
or [from, to] for a line with a port at each end
|
|
5884
5892
|
- a box's pad: its padding in px where it is more than stage 2's 12 (48 where it or a box it holds has ports with lines, §7);
|
|
5885
5893
|
a box's round: true grows its frame by √2 about its centre, so the ellipse drawn in it holds its contents (§9.3)
|
|
5894
|
+
- a box's head: px of header room over stage 2's 24, on the side its title is on (the top; laid out down, the left, which
|
|
5895
|
+
becomes the top): a second line of title, a keyword above the name (the deployment type's, decision of 13 Nov 2026);
|
|
5896
|
+
its two lines go in the box's kw and name, and its title (both lines) is measured at their 12 px (layered.js headW)
|
|
5886
5897
|
- a view's labelGaps: true lays the view out once more where a line's label found no spot, was cut short or sits on a box's
|
|
5887
5898
|
border, with the column gap it needs widened by the label's width (a map's labelRoom, layered.js; column gaps only, as a
|
|
5888
5899
|
typed view's rows are of mixed height; laid out down, the picture's row gaps, by the label's height, so fewer labels
|
|
@@ -5927,9 +5938,12 @@ function spreadPorts({ n, side, ends }, turned) {
|
|
|
5927
5938
|
x.e[x.from ? 'p1' : 'p2'] = v ? [B, c] : [c, B]; if (g.port != null) x.e[x.from ? 'fpb' : 'tpb'] = { ...box }; }));
|
|
5928
5939
|
s += g.L + gap; });
|
|
5929
5940
|
}
|
|
5930
|
-
// A typed view's box as frameOf measured it with stage 2's 12 px of padding
|
|
5931
|
-
//
|
|
5932
|
-
|
|
5941
|
+
// A typed view's box as frameOf measured it with stage 2's 12 px of padding and 24 px of header room: its head grows the
|
|
5942
|
+
// header room (turned: laid out down), its pad widens the padding on every side, and round grows it by √2 about its centre
|
|
5943
|
+
// (above). None of them: the box as it was
|
|
5944
|
+
TYPED.frame = (b, turned) => { const d = b.pad > 12 ? b.pad - 12 : 0, k = b.round ? Math.SQRT2 : 1, t = b.head > 0 ? b.head : 0;
|
|
5945
|
+
if (t) Object.assign(b, turned ? { x: b.x - t, w: b.w + t } : { y: b.y - t, h: b.h + t });
|
|
5946
|
+
if (!d && k === 1) return b;
|
|
5933
5947
|
const w = (b.w + 2 * d) * k, h = (b.h + 2 * d) * k; return Object.assign(b, { x: b.x + (b.w - w) / 2, y: b.y + (b.h - h) / 2, w, h }); };
|
|
5934
5948
|
|
|
5935
5949
|
// A typed view's lines from a node to itself (risks.md F1) are loops round its top right corner, nested, the first innermost:
|
|
@@ -6069,7 +6083,8 @@ SHAPE.classifier = CLASSIFIER;
|
|
|
6069
6083
|
// (notes/stage-5.md §9.2) is a usage's end on an interface drawn as a 16 px ball: a half circle of radius 11 open towards the
|
|
6070
6084
|
// ball, its middle on the line 3 px before the end (the ball's outline), its centre 8 px past it (the ball's)
|
|
6071
6085
|
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'],
|
|
6072
|
-
diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'], socket: [3, 11, 'M11 0A11 11 0 0 0 11 22', 'none']
|
|
6086
|
+
diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'], socket: [3, 11, 'M11 0A11 11 0 0 0 11 22', 'none'],
|
|
6087
|
+
'filled-triangle': [13, 7, 'M0 0L13 7L0 14Z', 'line'] }; // an extension's, at the metaclass (notes/stage-6.md §4.1)
|
|
6073
6088
|
const endOf = (E, w) => hasKey(ENDS, E[w]) && ENDS[E[w]] ? E[w] : null;
|
|
6074
6089
|
|
|
6075
6090
|
// a typed line's end labels (1.5): each end's role and multiplicity (e.end.labels: { from: { role, mult }, to: { role, mult } }),
|
|
@@ -6301,7 +6316,7 @@ TYPED.edit = (I, c) => {
|
|
|
6301
6316
|
boxes (§7, §10.3). Only lib/umlimator-all.js holds this file. */
|
|
6302
6317
|
|
|
6303
6318
|
const OWN_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), OWN_ARR = x => (Array.isArray(x) ? x : []);
|
|
6304
|
-
const OWN_BOXES = ['uml.Package']; // the node types that may be a box (§4.1, 1)
|
|
6319
|
+
const OWN_BOXES = ['uml.Package', 'uml.Profile']; // the node types that may be a box (§4.1, 1); a profile is a package (notes/stage-6.md §4.1)
|
|
6305
6320
|
// the types of rows 1 to 3 whose owner is a package (rule owner-kinds); comments are not checked
|
|
6306
6321
|
const OWNED = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType', 'uml.Package', 'uml.InstanceSpecification'];
|
|
6307
6322
|
const ownName = x => String(x.label ?? x.id);
|
|
@@ -6331,7 +6346,7 @@ function ownIndex(model, members = []) {
|
|
|
6331
6346
|
its node is and it holds something shown, and is a box when its node has two or more in the model (with one, what it
|
|
6332
6347
|
holds sits in the node's box); shape(x) and word(x), a box's shape and the word its accessible name starts with ('package', 'Package') */
|
|
6333
6348
|
function ownersOn(model, view, look = {}) {
|
|
6334
|
-
const { boxTypes = OWN_BOXES, qualify = true, members = [], shape = () => 'package', word =
|
|
6349
|
+
const { boxTypes = OWN_BOXES, qualify = true, members = [], shape = () => 'package', word = x => (x.type === 'uml.Profile' ? 'Profile' : 'Package') } = look;
|
|
6335
6350
|
const { at, chain, up, mems } = ownIndex(model, members), ids = OWN_ARR(OWN_OBJ(view) ? view.nodeIds : null).filter(id => at.has(id) && !mems.has(id)), seen = new Set(ids);
|
|
6336
6351
|
const live = new Set(ids.flatMap(id => chain(id).filter(p => mems.has(p) && seen.has(up.get(p))))), place = m => OWN_ARR(at.get(up.get(m)).members).indexOf(at.get(m)); // members holding something shown
|
|
6337
6352
|
const many = m => OWN_ARR(at.get(up.get(m)).members).filter(x => OWN_OBJ(x) && members.includes(x.type)).length > 1; // §6.1, 2: two or more in the model
|
|
@@ -6343,7 +6358,7 @@ function ownersOn(model, view, look = {}) {
|
|
|
6343
6358
|
const by = new Map(); all.forEach(id => { const o = owner(id); if (!by.has(o)) by.set(o, []); by.get(o).push(id); }); // grouped once: a view of 10,000 elements
|
|
6344
6359
|
const holds = o => by.get(o) || [], open = new Set([...by.keys()].filter(x => x != null));
|
|
6345
6360
|
const boxes = all.filter(id => open.has(id)).map(id => { const p = at.get(id), st = stereoOf(p), held = holds(id), o = owner(id), nm = name(id);
|
|
6346
|
-
return { id, label: ownName(p), title: [...st.map(t => `«${t}»`), nm].join(' '), nodeIds: held.filter(x => !open.has(x)), ...(o != null ? { parent: o } : {}), shape: shape(p),
|
|
6361
|
+
return { id, label: ownName(p), title: [...(p.type === 'uml.Profile' ? ['«profile»'] : []), ...st.map(t => `«${t}»`), nm].join(' '), nodeIds: held.filter(x => !open.has(x)), ...(o != null ? { parent: o } : {}), shape: shape(p),
|
|
6347
6362
|
aria: `${word(p)} ${nm}${st.length ? ` (${st.join(', ')})` : ''}, holding ${held.length} element${held.length === 1 ? '' : 's'}` }; });
|
|
6348
6363
|
return { ids, owner, holds, boxes, name, full, open, chain, at };
|
|
6349
6364
|
}
|
|
@@ -6356,13 +6371,15 @@ function readingOrder(pic) {
|
|
|
6356
6371
|
}
|
|
6357
6372
|
|
|
6358
6373
|
// a line between a package and something it holds, at any depth, or from a box to itself (§7): a, b node ids
|
|
6359
|
-
const holdsLine = (pic, a, b) => (a === b && pic.open.has(a)) || [[a, b], [b, a]].some(([p, x]) => (pic.at.get(p) || {}).type
|
|
6374
|
+
const holdsLine = (pic, a, b) => (a === b && pic.open.has(a)) || [[a, b], [b, a]].some(([p, x]) => OWN_BOXES.includes((pic.at.get(p) || {}).type) && pic.chain(x).includes(p));
|
|
6360
6375
|
|
|
6361
|
-
// the Owner field (registry.js owners): every package of the model by its name from the top, for an element a
|
|
6376
|
+
// the Owner field (registry.js owners): every package and profile of the model by its name from the top, for an element a
|
|
6377
|
+
// package may own; a stereotype's and a metaclass's: every profile (notes/stage-6.md §4.2)
|
|
6362
6378
|
function ownerChoices(model, x) {
|
|
6363
|
-
|
|
6379
|
+
const def = OWN_OBJ(x) && (x.type === 'uml.Stereotype' || x.type === 'uml.MetaclassReference');
|
|
6380
|
+
if (!OWN_OBJ(x) || !(OWNED.includes(x.type) || x.type === 'uml.Comment' || def) || x.from != null) return null;
|
|
6364
6381
|
const { at, chain } = ownIndex(model), full = id => [...chain(id).reverse(), id].map(k => ownName(at.get(k))).join('::');
|
|
6365
|
-
return [...at.values()].filter(n => n.type === 'uml.
|
|
6382
|
+
return [...at.values()].filter(n => (def ? n.type === 'uml.Profile' : OWN_BOXES.includes(n.type))).map(n => ({ id: n.id, label: full(n.id) }));
|
|
6366
6383
|
}
|
|
6367
6384
|
|
|
6368
6385
|
/* ---- the rules about owners and boxes (§9.3, §10.3). Each problem: { level, rule, path, message, ids } ---- */
|
|
@@ -6378,7 +6395,7 @@ function ownerProblems(model, view, lines) {
|
|
|
6378
6395
|
const all = new Map(), put = (x, kind) => { if (OWN_OBJ(x) && typeof x.id === 'string' && !all.has(x.id)) { all.set(x.id, { x, kind }); OWN_ARR(x.members).forEach(m => put(m, 'member')); } };
|
|
6379
6396
|
OWN_ARR(model.nodes).forEach(n => put(n, 'node')); const q = id => JSON.stringify(ownName((all.get(id) || { x: { id } }).x));
|
|
6380
6397
|
OWN_ARR(model.nodes).forEach((n, i) => { if (!OWN_OBJ(n) || !OWNED.includes(n.type) || n.parent == null || !all.has(n.parent)) return;
|
|
6381
|
-
const p = all.get(n.parent).x; if (p.type
|
|
6398
|
+
const p = all.get(n.parent).x; if (!OWN_BOXES.includes(p.type)) say('owner-kinds', `nodes[${i}].parent`, [n.id, p.id], `${q(n.id)} is owned by ${q(p.id)}, a ${String(p.type).split('.').pop()}, not a package`); });
|
|
6382
6399
|
if (!view) return out;
|
|
6383
6400
|
const vi = OWN_ARR(model.views).indexOf(view), pic = ownersOn(model, view);
|
|
6384
6401
|
lines.forEach(({ e, from, to }) => { if (holdsLine(pic, from, to)) say('line-inside', `views[${vi}]`, from === to ? [e.id, from] : [e.id, from, to],
|
|
@@ -6431,6 +6448,59 @@ function placeTabs(g) {
|
|
|
6431
6448
|
});
|
|
6432
6449
|
return g;
|
|
6433
6450
|
}
|
|
6451
|
+
/* ---------- diff()'s words for typed models (C10, risks.md M11) ----------
|
|
6452
|
+
Only lib/umlimator-all.js holds this file: it fills TYPED.diff and TYPED.order (registry.js), which diff() (src/map/diff.js)
|
|
6453
|
+
asks. Where order means something, a reorder reads "moves": a member of an element (an attribute, a slot, a port), a
|
|
6454
|
+
message or an interaction use among a sequence view's lines, a message among a communication view's `edges` (its ids, which
|
|
6455
|
+
number the messages; the model's `edges` order means nothing). A line that
|
|
6456
|
+
only changes its operand reads "moves … into …" (or "out of …"), as a re-own does. A sequence view's combined fragments
|
|
6457
|
+
read added, removed and changed. Maps have none of these, and are diffed as before. */
|
|
6458
|
+
const UD_ARR = x => (Array.isArray(x) ? x : []), UD_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
6459
|
+
const UD_ORDERED = new Set(['uml.Message', 'uml.InteractionUse']);
|
|
6460
|
+
const UD_KEY = (x, i) => x.id != null ? String(x.id) : '#' + i;
|
|
6461
|
+
// what moved from `old` to `now`, both keyed lists: the kept items not on a longest run in their old order, each with the
|
|
6462
|
+
// kept item now before it (null: first). O(n²), fine for a view's messages or an element's members
|
|
6463
|
+
function udMoved(old, now) {
|
|
6464
|
+
const at = new Map(old.map((x, i) => [UD_KEY(x, i), i])), kept = now.map((x, i) => [x, at.get(UD_KEY(x, i))]).filter(k => k[1] != null);
|
|
6465
|
+
const len = kept.map(() => 1), prev = kept.map(() => -1);
|
|
6466
|
+
kept.forEach((k, i) => { for (let j = 0; j < i; j++) if (kept[j][1] < k[1] && len[j] + 1 > len[i]) { len[i] = len[j] + 1; prev[i] = j; } });
|
|
6467
|
+
const run = new Set(); for (let i = len.indexOf(Math.max(0, ...len)); i >= 0; i = prev[i]) run.add(i);
|
|
6468
|
+
return kept.flatMap((k, i) => run.has(i) ? [] : [[k[0], i ? kept[i - 1][0] : null]]);
|
|
6469
|
+
}
|
|
6470
|
+
const UD_WHAT = (x, name) => String(x.label ?? (x.props && (x.props.trigger ?? x.props.name)) ?? (x.from != null ? `the message from ${name(x.from)} to ${name(x.to)}` : x.id));
|
|
6471
|
+
|
|
6472
|
+
// a changed card or line (o before, x after): its own words, or nothing for diff()'s
|
|
6473
|
+
TYPED.diff = (o, x, name, models) => {
|
|
6474
|
+
if (!x.type) return;
|
|
6475
|
+
const keys = [...new Set([...Object.keys(o), ...Object.keys(x)])].filter(k => canon(o[k]) !== canon(x[k]));
|
|
6476
|
+
if (keys.join() === 'members') { const a = UD_ARR(o.members).filter(UD_OBJ), b = UD_ARR(x.members).filter(UD_OBJ);
|
|
6477
|
+
const same = a.length === b.length && canon([...a].map(canon).sort()) === canon([...b].map(canon).sort());
|
|
6478
|
+
const r = same ? udMoved(a, b).map(([m, p]) => `moves ${UD_WHAT(m, name)} ${p ? 'after ' + UD_WHAT(p, name) : 'to the top'} in ${name(x.id)}`) : [];
|
|
6479
|
+
if (r.length) return r; } // no move found (members with no ids, or the same id twice): "changes X (members)" as before
|
|
6480
|
+
if (keys.join() === 'parent' && UD_ORDERED.has(x.type) && x.from != null) { // a message into or out of an operand
|
|
6481
|
+
const ops = new Map(models.flatMap(m => UD_ARR(m && m.views)).flatMap(v => UD_ARR(v && v.fragments)).flatMap(f => UD_ARR(f && f.members)).filter(UD_OBJ).map(op => [op.id, op]));
|
|
6482
|
+
const op = id => String((ops.get(id) || {}).label ?? id);
|
|
6483
|
+
return [`moves ${UD_WHAT(x, name)} ${x.parent != null ? 'into ' + op(x.parent) : 'out of ' + op(o.parent)}`]; }
|
|
6484
|
+
};
|
|
6485
|
+
|
|
6486
|
+
// the order of lines, and a sequence view's fragments: each move or fragment said, a moved line marked changed (its
|
|
6487
|
+
// edited dot), and the views that show it touched
|
|
6488
|
+
TYPED.order = (A, out, views, V0, said, touched, name) => {
|
|
6489
|
+
const lines = (old, now, v) => { const f = l => UD_ARR(l).filter(e => UD_OBJ(e) && UD_ORDERED.has(e.type) && e.change !== 'removed');
|
|
6490
|
+
udMoved(f(old), f(now)).forEach(([e, p]) => { said.push(`moves ${UD_WHAT(e, name)} ${p ? 'after ' + UD_WHAT(p, name) : 'to the start'}`);
|
|
6491
|
+
Object.assign(e, { change: 'changed', edited: true });
|
|
6492
|
+
touched.add(v.id); }); };
|
|
6493
|
+
const E0 = new Map(UD_ARR(A.edges).filter(UD_OBJ).map(e => [e.id, e])), E1 = new Map(UD_ARR(out.edges).filter(UD_OBJ).map(e => [e.id, e]));
|
|
6494
|
+
const own = (l, E) => UD_ARR(l).map(e => (typeof e === 'string' ? E.get(e) : e)); // a view's model lines by id (a communication view's messages)
|
|
6495
|
+
views.forEach(v => { const o = v && V0.get(v.id); if (!o) return;
|
|
6496
|
+
if (Array.isArray(v.edges)) lines(own(o.edges, E0), own(v.edges, E1), v);
|
|
6497
|
+
const F0 = new Map(UD_ARR(o.fragments).filter(UD_OBJ).map(f => [f.id, f])), F1 = new Map(UD_ARR(v.fragments).filter(UD_OBJ).map(f => [f.id, f]));
|
|
6498
|
+
const op = f => `the ${(f.props && f.props.operator) || 'combined'} fragment`;
|
|
6499
|
+
F1.forEach((f, id) => { const g = F0.get(id); if (g && canon(g) === canon(f)) return;
|
|
6500
|
+
said.push(g ? `changes ${op(g)} (${[...new Set([...Object.keys(g), ...Object.keys(f)])].filter(k => canon(g[k]) !== canon(f[k])).join(', ')})` : `adds ${op(f)}`); touched.add(v.id); });
|
|
6501
|
+
F0.forEach((f, id) => { if (!F1.has(id)) { said.push(`removes ${op(f)}`); touched.add(v.id); } });
|
|
6502
|
+
});
|
|
6503
|
+
};
|
|
6434
6504
|
/* ---------- the class diagram, uml.class (work package 1.9) ----------
|
|
6435
6505
|
The subset of proposal.md §8.2: class, interface, enumeration with literals, data type, primitive type; attributes and
|
|
6436
6506
|
operations in UML's text form; stereotypes as text; packages as owners, a package holding what it owns that the view
|
|
@@ -6547,8 +6617,8 @@ function memberAdd(text, owner) {
|
|
|
6547
6617
|
}
|
|
6548
6618
|
|
|
6549
6619
|
/* ---- the vocabulary (model.md, uml.class) ---- */
|
|
6550
|
-
const CLASSIFIERS = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType'];
|
|
6551
|
-
const IMPLIED = { 'uml.Interface': 'interface', 'uml.Enumeration': 'enumeration', 'uml.DataType': 'dataType', 'uml.PrimitiveType': 'primitive' }; // shown «…» over the name
|
|
6620
|
+
const CLASSIFIERS = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType', 'uml.Stereotype']; // a stereotype specializes a stereotype (notes/stage-6.md §4.2)
|
|
6621
|
+
const IMPLIED = { 'uml.Interface': 'interface', 'uml.Enumeration': 'enumeration', 'uml.DataType': 'dataType', 'uml.PrimitiveType': 'primitive', 'uml.Stereotype': 'stereotype', 'uml.MetaclassReference': 'metaclass', 'uml.Profile': 'profile' }; // shown «…» over the name
|
|
6552
6622
|
const STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
6553
6623
|
const endFields = w => [['role', 'text'], ['multiplicity', 'text'], ['aggregation', 'enum', ['none', 'shared', 'composite']], ['navigable', 'bool']]
|
|
6554
6624
|
.map(([k, kind, options]) => ({ key: `ends.${w}.${k}`, path: ['ends', w, k], label: `${w === 'from' ? 'From' : 'To'} ${k}`, kind, ...(options ? { options } : {}) }));
|
|
@@ -6570,7 +6640,7 @@ const CLASS_DEF = {
|
|
|
6570
6640
|
const COMPARTMENTS = { attributes: 'uml.Property', operations: 'uml.Operation', literals: 'uml.EnumerationLiteral' };
|
|
6571
6641
|
function compartmentsOf(n) { // a class's attributes and operations; an enumeration's literals, then the others it has
|
|
6572
6642
|
const c = name => ({ name, members: UML_ARR(n.members).filter(m => UML_OBJ(m) && m.type === COMPARTMENTS[name]) });
|
|
6573
|
-
if (n.type === 'uml.
|
|
6643
|
+
if (OWN_BOXES.includes(n.type) || n.type === 'uml.Comment' || n.type === 'uml.MetaclassReference') return []; // a package or a profile; a metaclass has no compartment (notes/stage-6.md §4.1)
|
|
6574
6644
|
return n.type === 'uml.Enumeration' ? [c('literals'), ...[c('attributes'), c('operations')].filter(x => x.members.length)] : [c('attributes'), c('operations')];
|
|
6575
6645
|
}
|
|
6576
6646
|
|
|
@@ -6605,7 +6675,8 @@ function lineLook(e) {
|
|
|
6605
6675
|
case 'uml.InterfaceRealization': return { from: 'none', to: 'triangle', dashed: true };
|
|
6606
6676
|
case 'uml.Dependency': return { from: 'none', to: 'open', dashed: true };
|
|
6607
6677
|
case 'uml.CommentLink': return { from: 'none', to: 'none', dashed: true };
|
|
6608
|
-
case 'uml.PackageImport': case 'uml.PackageMerge': return { from: 'none', to: 'open', dashed: true };
|
|
6678
|
+
case 'uml.PackageImport': case 'uml.PackageMerge': case 'uml.ProfileApplication': return { from: 'none', to: 'open', dashed: true };
|
|
6679
|
+
case 'uml.Extension': return { from: 'none', to: 'filled-triangle' }; // to the metaclass (notes/stage-6.md §4.1)
|
|
6609
6680
|
case 'uml.Association': { const E = endsOf(e), lab = w => ({ role: (E[w] || {}).role, mult: (E[w] || {}).multiplicity });
|
|
6610
6681
|
return { from: endOfLine(e, 'from'), to: endOfLine(e, 'to'), labels: { from: lab('from'), to: lab('to') } }; }
|
|
6611
6682
|
default: return { from: 'none', to: 'arrow' };
|
|
@@ -6613,15 +6684,19 @@ function lineLook(e) {
|
|
|
6613
6684
|
}
|
|
6614
6685
|
const stereoText = x => UML_ARR(UML_OBJ(x.props) ? x.props.stereotypes : null).filter(UML_HAS).map(t => `«${t}»`).join(' ');
|
|
6615
6686
|
// a package import says «import», or «access» when private; a merge «merge» (notes/stage-2.md §10.2)
|
|
6616
|
-
|
|
6687
|
+
// a profile application «apply», a required extension {required} (notes/stage-6.md §4.1)
|
|
6688
|
+
const keyword = e => e.type === 'uml.PackageImport' ? ((UML_OBJ(e.props) ? e.props.visibility : null) === 'private' ? '«access»' : '«import»') : e.type === 'uml.PackageMerge' ? '«merge»'
|
|
6689
|
+
: e.type === 'uml.ProfileApplication' ? '«apply»' : e.type === 'uml.Extension' && UML_OBJ(e.props) && e.props.isRequired === true ? '{required}' : '';
|
|
6617
6690
|
const lineLabel = e => [keyword(e), stereoText(e), e.label].filter(UML_HAS).join(' ');
|
|
6618
6691
|
const LINE_WORDS = { 'uml.Association': ['Association', 'a link between instances'], 'uml.Generalization': ['Generalization', 'is a kind of the one it points to'],
|
|
6619
6692
|
'uml.InterfaceRealization': ['Realization', 'implements the interface it points to'], 'uml.Dependency': ['Dependency', 'uses the one it points to'],
|
|
6620
6693
|
'uml.CommentLink': ['Comment link', 'what a comment is about'], 'uml.PackageImport': ['Package import', 'sees the names of the one it points to: «import», or «access» kept private'],
|
|
6621
|
-
'uml.PackageMerge': ['Package merge', 'takes in what the one it points to holds']
|
|
6694
|
+
'uml.PackageMerge': ['Package merge', 'takes in what the one it points to holds'], 'uml.Extension': ['Extension', 'the stereotype extends the metaclass it points to'],
|
|
6695
|
+
'uml.ProfileApplication': ['Profile application', '«apply»: the package may use the profile\'s stereotypes'] };
|
|
6622
6696
|
const LEGEND_ENDS = { // small copies of src/typed/draw.js's ends, pointing right with the tip at x 26
|
|
6623
6697
|
none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', triangle: '<path d="M16 3L26 8L16 13Z" fill="var(--um-panel)"/>',
|
|
6624
|
-
diamond: '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-panel)"/>', 'filled-diamond': '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-muted)"/>'
|
|
6698
|
+
diamond: '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-panel)"/>', 'filled-diamond': '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-muted)"/>',
|
|
6699
|
+
'filled-triangle': '<path d="M16 3L26 8L16 13Z" fill="var(--um-muted)"/>' };
|
|
6625
6700
|
const legendItem = (end, dashed, name, says) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8H${end === 'none' ? 26 : end.includes('diamond') ? 12 : 17}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${LEGEND_ENDS[end]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
6626
6701
|
// the legend of a view's lines (pieces.js shows it): each kind drawn, then the association's ends it shows
|
|
6627
6702
|
function legendOf(edges) {
|
|
@@ -6653,7 +6728,7 @@ const CLASS_LOOK = { members: true, own: null, word: 'class diagram' };
|
|
|
6653
6728
|
function nodeName(n, pic, look = CLASS_LOOK) {
|
|
6654
6729
|
if (n.type === 'uml.Comment') return 'Comment: ' + nameOfEl(n);
|
|
6655
6730
|
const p = UML_OBJ(n.props) ? n.props : {}, why = [...UML_ARR(p.stereotypes).filter(UML_HAS), ...(p.isAbstract ? ['abstract'] : [])], nm = pic ? pic.name(n.id) : nameOfEl(n);
|
|
6656
|
-
if (n.type
|
|
6731
|
+
if (OWN_BOXES.includes(n.type)) { const b = pic && pic.boxes.find(x => x.id === n.id); return b ? b.aria : `${typeWord(n.type)} ${nm} (${[...why, 'closed'].join(', ')})`; }
|
|
6657
6732
|
const counts = look.members ? compartmentsOf(n).map(c => plural(c.members.length, c.name.replace(/s$/, ''))) : [];
|
|
6658
6733
|
return [`${typeWord(n.type)} ${nm}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
|
|
6659
6734
|
}
|
|
@@ -6678,23 +6753,27 @@ function drawnLines(model, view, own) {
|
|
|
6678
6753
|
|
|
6679
6754
|
/* the view's geometry, for a type's look: { model, view, pos (col, row by id, the layered layout's ranking or the view's
|
|
6680
6755
|
pins), memo, direction }. A line that ends on a member is drawn to the member's owner; a line between two nodes the view
|
|
6681
|
-
does not both show is not drawn. An open package is a box (its id takes no slot), passed to layoutTyped as a container
|
|
6756
|
+
does not both show is not drawn. An open package is a box (its id takes no slot), passed to layoutTyped as a container.
|
|
6757
|
+
A look may also give node(d, n, model, pic), a node's drawing input before it is sized (the profile's tag compartments), and
|
|
6758
|
+
dg, more of layoutTyped's input (repair, reroute: notes/stage-6.md §4.1), and box(b, model, pic), a box as drawn (its title
|
|
6759
|
+
and accessible name) */
|
|
6682
6760
|
const typeGeo = look => ({ model, view, pos, memo }) => {
|
|
6683
|
-
const { at, pic, lines } = drawnLines(model, view, look.own), present = UML_OBJ(view.present) ? view.present : {};
|
|
6761
|
+
const { at, pic, lines } = drawnLines(model, view, look.own), present = UML_OBJ(view.present) ? view.present : {}, boxes = look.box ? pic.boxes.map(b => look.box(b, model, pic)) : pic.boxes;
|
|
6684
6762
|
const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const n = at.get(id).x, pr = UML_OBJ(UML_OWN(present, id)) ? present[id] : {}, p = UML_OBJ(n.props) ? n.props : {};
|
|
6685
6763
|
const st = UML_ARR(p.stereotypes).filter(UML_HAS);
|
|
6686
|
-
const shape = n.type === 'uml.Comment' ? { shape: 'note' } : n.type
|
|
6764
|
+
const shape = n.type === 'uml.Comment' ? { shape: 'note' } : OWN_BOXES.includes(n.type) ? { shape: 'package', stereotypes: [...(UML_OWN(IMPLIED, n.type) ? [IMPLIED[n.type]] : []), ...st] }
|
|
6687
6765
|
: { shape: 'classifier', stereotypes: [...(UML_OWN(IMPLIED, n.type) ? [IMPLIED[n.type]] : []), ...st], abstract: !!p.isAbstract, compartments: look.members ? compartmentsOf(n) : [],
|
|
6688
6766
|
collapsed: UML_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
|
|
6689
|
-
const
|
|
6767
|
+
const d0 = { id, label: n.type === 'uml.Comment' ? nameOfEl(n) : pic.name(id), type: n.type || 'uml.Class', kind: 'file', ...shape, aria: nodeName(n, pic, look), col: (UML_OWN(pos, id) || [0, 0])[0], row: (UML_OWN(pos, id) || [0, 0])[1] };
|
|
6768
|
+
const d = look.node ? look.node(d0, n, model, pic) : d0;
|
|
6690
6769
|
return { ...d, ...SHAPE[d.shape].size(d) }; });
|
|
6691
6770
|
const edges = lines.filter(({ from, to }) => !holdsLine(pic, from, to))
|
|
6692
6771
|
.map(({ e, from, to }) => ({ id: e.id, _id: e.id, type: e.type, from, to, label: lineLabel(e) || undefined, end: lineLook(e), aria: lineName(e, at, pic), ...(UML_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }));
|
|
6693
6772
|
// a box's border and padding take 48 px of each side of the gap between columns, so a line from box to box has none left for
|
|
6694
6773
|
// its label, which is then not drawn: laid out left to right, a view with boxes and labelled lines spreads its columns by its
|
|
6695
6774
|
// widest label and 40 px for its stubs (laid out down, a label sits across its line, in the gap between rows)
|
|
6696
|
-
const down = view.direction === 'down', wide = !down &&
|
|
6697
|
-
const g = layoutTyped({ nodes, edges, ...(
|
|
6775
|
+
const down = view.direction === 'down', wide = !down && boxes.length ? Math.max(0, ...edges.filter(e => e.label).map(labelW)) : 0;
|
|
6776
|
+
const g = layoutTyped({ nodes, edges, ...(boxes.length ? { containers: boxes } : {}), direction: down ? 'down' : 'right', gap: 96 + (wide ? Math.ceil(wide) + 40 : 0), rgap: 56, ...look.dg }, memo);
|
|
6698
6777
|
g.legend = legendOf(g.edges);
|
|
6699
6778
|
return placeTabs(g);
|
|
6700
6779
|
};
|
|
@@ -6779,6 +6858,7 @@ const describeAs = look => (model, viewId) => {
|
|
|
6779
6858
|
else if (e.type === 'uml.Dependency') said.push(`depends on ${nm(t)}${stereoText(e) ? ' ' + stereoText(e) : ''}`);
|
|
6780
6859
|
else if (e.type === 'uml.PackageImport') said.push(`${keyword(e) === '«access»' ? 'accesses' : 'imports'} ${nm(t)}`);
|
|
6781
6860
|
else if (e.type === 'uml.PackageMerge') said.push(`merges ${nm(t)}`);
|
|
6861
|
+
else if (e.type === 'uml.ProfileApplication') said.push(`applies ${nm(t)}`);
|
|
6782
6862
|
else if (e.type === 'uml.CommentLink') said.push(`about ${nm(t)}`); });
|
|
6783
6863
|
out.push(nodeName(at.get(id).x, pic, look) + (said.length ? '; ' + said.join('; ') : '') + '.'); });
|
|
6784
6864
|
return out.join('\n');
|
|
@@ -6806,7 +6886,8 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
|
|
|
6806
6886
|
merges («merge») and dependencies, dashed with an open arrow; comments. A view with no `edges` list draws these line
|
|
6807
6887
|
types only. Ships in lib/umlimator-all.js only. */
|
|
6808
6888
|
|
|
6809
|
-
|
|
6889
|
+
// a profile application too, drawn where a view shows a package and a profile (notes/stage-6.md §14); its palette is the profile view's
|
|
6890
|
+
const PACKAGE_LINES = ['uml.PackageImport', 'uml.PackageMerge', 'uml.Dependency', 'uml.CommentLink', 'uml.ProfileApplication'];
|
|
6810
6891
|
const PACKAGE_LOOK = { members: false, own: PACKAGE_LINES, word: 'package diagram' };
|
|
6811
6892
|
const PACKAGE_RULES = {
|
|
6812
6893
|
'import-ends': 'a package import or a package merge runs from a package to another package',
|
|
@@ -8165,6 +8246,21 @@ function sqDescribe(model, viewId) {
|
|
|
8165
8246
|
return out.join('\n');
|
|
8166
8247
|
}
|
|
8167
8248
|
|
|
8249
|
+
/* ---- paths(): in time order (C10). A path from one lifeline to another is a run of messages, each later than the one
|
|
8250
|
+
before and sent from the lifeline it reached; a line is on one when `from` reaches its sender before it and its receiver
|
|
8251
|
+
reaches `to` after it. Self-messages are left out, as paths() leaves them ---- */
|
|
8252
|
+
function sqPaths(model, viewId, from, to) {
|
|
8253
|
+
const V = SQ_ARR(model && model.views).find(x => SQ_OBJ(x) && x.id === viewId); if (!V) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
8254
|
+
const ids = sqHeads(model, V).map(h => h.id), ms = sqIndex(model, V).occ.filter(o => o.message && o.from !== o.to);
|
|
8255
|
+
if (!ids.includes(from) || !ids.includes(to)) return [];
|
|
8256
|
+
if (from === to) return [{ type: 'node', id: from, view: viewId }]; // the card alone, as paths() on a map gives it
|
|
8257
|
+
const got = new Map([[from, -1]]), left = new Map([[to, Infinity]]);
|
|
8258
|
+
ms.forEach((m, i) => { if (got.has(m.from) && !got.has(m.to)) got.set(m.to, i); }); // when each lifeline is first reached
|
|
8259
|
+
for (let i = ms.length - 1; i >= 0; i--) { const m = ms[i]; if (left.has(m.to) && !left.has(m.from)) left.set(m.from, i); } // its last chance to reach `to`
|
|
8260
|
+
const on = ms.filter((m, i) => got.get(m.from) < i && left.get(m.to) > i), at = new Set(on.flatMap(m => [m.from, m.to]));
|
|
8261
|
+
return on.length ? [...ids.filter(id => at.has(id)).map(id => ({ type: 'node', id, view: viewId })), ...on.map(m => ({ type: 'edge', ids: [m.id], view: viewId }))] : [];
|
|
8262
|
+
}
|
|
8263
|
+
|
|
8168
8264
|
/* ---- the gesture hooks (notes/stage-3.md §5.1, §5.2). Each takes the model and the view (or its id) and returns the
|
|
8169
8265
|
commands of one transaction, null to leave the gesture to the canvas's default, or { error } for the hint line. A
|
|
8170
8266
|
transaction that would add an error the model did not have is never returned: { error } names the rule (S5) ---- */
|
|
@@ -8362,6 +8458,7 @@ const SEQ_TYPE = registerDiagramType('uml.sequence', {
|
|
|
8362
8458
|
validate: sqProblems,
|
|
8363
8459
|
rules: SQ_RULES,
|
|
8364
8460
|
describe: sqDescribe,
|
|
8461
|
+
paths: sqPaths,
|
|
8365
8462
|
member: SQ_MEMBER,
|
|
8366
8463
|
edits: { connect: sqConnect, moveTo: sqMoveTo, wrap: sqWrap, add: sqAdd, remove: sqRemove },
|
|
8367
8464
|
});
|
|
@@ -8658,7 +8755,7 @@ function commAdd(model, view, entry, label, at) {
|
|
|
8658
8755
|
/* remove(model, view, ids, fromModel): Delete hides from this view, Shift + Delete deletes from the model. A connector takes
|
|
8659
8756
|
the messages it carries with it, deleted from every communication view that has no other connector for them; a hidden
|
|
8660
8757
|
call takes its replies in this view (a deleted one does by refs). A lifeline takes its connectors and messages (apply()
|
|
8661
|
-
does). { error } when what is left breaks a rule */
|
|
8758
|
+
does), said on the hint line ({ commands, say }). { error } when what is left breaks a rule */
|
|
8662
8759
|
function commRemove(model, view, ids, fromModel) {
|
|
8663
8760
|
if (!CM_OBJ(view)) return null;
|
|
8664
8761
|
const I = commIndex(model, view), out = [], seen = new Set(), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
|
|
@@ -8670,7 +8767,19 @@ function commRemove(model, view, ids, fromModel) {
|
|
|
8670
8767
|
on.forEach(m => { if (v === view && !fromModel || !left.carrierOf(m.from, m.to)) go(m.id); }); });
|
|
8671
8768
|
if (!fromModel) I.messages.filter(m => cmPropsOf(m).replyTo === id).forEach(m => go(m.id)); };
|
|
8672
8769
|
CM_ARR(ids).forEach(id => { if (I.shown.has(id) || I.E.has(id)) go(id); });
|
|
8673
|
-
return fromModel ? cmGuard(model, view, out, true) : out;
|
|
8770
|
+
return cmSaid(model, view, ids, fromModel, I, fromModel ? cmGuard(model, view, out, true) : out);
|
|
8771
|
+
}
|
|
8772
|
+
// the messages a connector or a lifeline took (its own, or by apply()'s cascade), said on the hint line: { commands, say }
|
|
8773
|
+
function cmSaid(model, view, ids, fromModel, I, r) {
|
|
8774
|
+
const what = CM_ARR(ids).filter(id => I.shown.has(id) || I.E.has(id) && I.E.get(id).type === 'uml.Connector');
|
|
8775
|
+
if (!Array.isArray(r) || !what.length) return r;
|
|
8776
|
+
let next; try { next = apply(model, r); } catch { return r; }
|
|
8777
|
+
const left = new Set(fromModel ? CM_ARR(next.edges).filter(CM_OBJ).map(e => e.id) : commIndex(next, CM_ARR(next.views).find(v => CM_OBJ(v) && v.id === view.id)).messages.map(m => m.id));
|
|
8778
|
+
const msgs = fromModel ? CM_ARR(model.edges).filter(e => CM_OBJ(e) && e.type === 'uml.Message') : I.messages;
|
|
8779
|
+
const took = msgs.filter(m => !left.has(m.id) && !CM_ARR(ids).includes(m.id)); if (!took.length) return r;
|
|
8780
|
+
const q = x => JSON.stringify(cmNameOf(x)), nm = id => cmNameOf(I.N.get(id) || { id });
|
|
8781
|
+
const who = what.map(id => (I.shown.has(id) ? 'lifeline ' + q(I.N.get(id)) : `connector ${JSON.stringify(nm(I.E.get(id).from) + '–' + nm(I.E.get(id).to))}`)).join(', ');
|
|
8782
|
+
return { commands: r, say: `${fromModel ? 'Deleted' : 'Hid'} ${who} and ${what.length === 1 ? 'its' : 'their'} ${cmPlural(took.length, 'message')}: ${took.map(q).join(', ')}` };
|
|
8674
8783
|
}
|
|
8675
8784
|
|
|
8676
8785
|
/* ---- the type ---- */
|
|
@@ -9819,6 +9928,18 @@ function ucConnect(model, view, from, to, at, line) {
|
|
|
9819
9928
|
return [{ op: 'add', element: { id: 'association' + k, type: 'uml.Association', from: to, to: from }, view: view.id }];
|
|
9820
9929
|
}
|
|
9821
9930
|
|
|
9931
|
+
/* remove: Shift + Delete on extension points (the member list's ✕) takes every extend left naming none of its use case's
|
|
9932
|
+
(extend-points), in the same transaction, said on the hint line; an extend naming another point keeps it. Else the default */
|
|
9933
|
+
function ucRemove(model, view, ids, fromModel) {
|
|
9934
|
+
const gone = new Set(UC_ARR(ids)), ext = fromModel ? UC_ARR(model && model.edges).filter(e => UC_OBJ(e) && e.type === 'uml.Extend' && ![e.id, e.from, e.to].some(x => gone.has(x))
|
|
9935
|
+
&& UC_ARR(UC_PROPS(e).extensionPoints).length && UC_ARR(UC_PROPS(e).extensionPoints).every(p => gone.has(p))) : [];
|
|
9936
|
+
if (!ext.length) return null;
|
|
9937
|
+
const N = UC_ARR(model.nodes).filter(UC_OBJ), pt = id => N.flatMap(n => UC_ARR(n.members)).find(p => UC_OBJ(p) && p.id === id);
|
|
9938
|
+
const nm = id => (pt(id) ? 'extension point ' : '') + JSON.stringify(ucName(pt(id) || N.find(n => n.id === id) || { id }));
|
|
9939
|
+
return { commands: [...gone, ...ext.map(e => e.id)].map(id => ({ op: 'delete', id })),
|
|
9940
|
+
say: `Deleted ${[...gone].map(nm).join(', ')} and ${ext.length > 1 ? 'the extends' : 'the extend'} from ${[...new Set(ext.map(e => nm(e.from)))].join(', ')}, which named no other extension point` };
|
|
9941
|
+
}
|
|
9942
|
+
|
|
9822
9943
|
const UC_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
9823
9944
|
const UC_TYPE = registerDiagramType('uml.use-case', {
|
|
9824
9945
|
title: 'use case diagram',
|
|
@@ -9838,7 +9959,7 @@ const UC_TYPE = registerDiagramType('uml.use-case', {
|
|
|
9838
9959
|
validate: ucProblems,
|
|
9839
9960
|
rules: UC_RULES,
|
|
9840
9961
|
describe: ucDescribe,
|
|
9841
|
-
edits: { connect: ucConnect },
|
|
9962
|
+
edits: { connect: ucConnect, remove: ucRemove },
|
|
9842
9963
|
});
|
|
9843
9964
|
/* ---------- the activity diagram, uml.activity (work package 4.5, notes/stage-4.md §9) ----------
|
|
9844
9965
|
Control nodes, actions and flows from src/uml/flow.js; the send and accept signals, the object node, the data store and
|
|
@@ -11506,6 +11627,9 @@ function portsDrawn(g, ports) {
|
|
|
11506
11627
|
const at = new Map(), onSide = new Map(), boxesOn = (o, s) => onSide.get(o + '|' + s) || onSide.set(o + '|' + s, []).get(o + '|' + s); // a port's box; each owner's side's
|
|
11507
11628
|
const seen = new Set(SN_OBJ(g.byId) ? Object.keys(g.byId) : []), list = SN_ARR(ports).filter(p => SN_OBJ(p) && owner(p.owner) && Object.hasOwn(SN_OUT, p.side) && !seen.has(p.id) && seen.add(p.id));
|
|
11508
11629
|
list.forEach(p => { const r = lined(p); if (r) { at.set(p.id, r); boxesOn(p.owner, p.side).push(r); } });
|
|
11630
|
+
// a line that names no port ends on its owner's side too: a port with no line keeps off its end (0.22.0, 5.8's fixtures)
|
|
11631
|
+
SN_ARR(g.edges).forEach(e => e.pts && [[e.fromPort, e.from, e.fs, e.pts[0]], [e.toPort, e.to, e.ts, e.pts[e.pts.length - 1]]].forEach(([q, id, s, p]) => {
|
|
11632
|
+
if (q == null && s && p) boxesOn(id, s).push({ x: p[0] - 1, y: p[1] - 1, w: 2, h: 2 }); }));
|
|
11509
11633
|
// the ports with no line, side by side: evenly over the span in members order, then pushed forwards and, at the span's end,
|
|
11510
11634
|
// back, 20 px centre to centre (12 + 8), each past a port with lines it would come within 8 px of; a side too short for them
|
|
11511
11635
|
// narrows the gaps down to 4 px (§4.4, step 3), and what still does not fit is held at the span's start, marked over
|
|
@@ -11567,9 +11691,8 @@ function structProblems(model, view) {
|
|
|
11567
11691
|
comments; realizations, component realizations, usages (a socket at a ball, else «use»), assembly and delegation
|
|
11568
11692
|
connectors (a delegation from a box's port inward is an inner line, §5), dependencies, manifestations and comment links.
|
|
11569
11693
|
Laid out by layoutTyped from the type's own ranking (cpPlace), a usage of a ball turned so the ball comes before its requirers: gap 48, labelGaps
|
|
11570
|
-
and cut 280 (§8), a box with ports 48 px of padding (§7), repair and reroute off (§14).
|
|
11571
|
-
picture
|
|
11572
|
-
node against every other, does not take a port for an overlap of its own owner (§4.5, as built). Ships in
|
|
11694
|
+
and cut 280 (§8), a box with ports 48 px of padding (§7), repair and reroute off (§14). Every port is a node of the
|
|
11695
|
+
picture, with its owner's id (owner), which the layout check passes half inside it (decision 22). Ships in
|
|
11573
11696
|
lib/umlimator-all.js only. */
|
|
11574
11697
|
|
|
11575
11698
|
const CP_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), CP_ARR = x => (Array.isArray(x) ? x : []);
|
|
@@ -11620,6 +11743,11 @@ const CP_BOX = { boxTypes: ['uml.Component', 'uml.Package'], shape: x => (x.type
|
|
|
11620
11743
|
const CP_WORD = { 'uml.Component': 'component', 'uml.Interface': 'interface', 'uml.Artifact': 'artifact', 'uml.Package': 'package', 'uml.Comment': 'comment', 'uml.Port': 'port' };
|
|
11621
11744
|
function cpPic(model, view) {
|
|
11622
11745
|
const I = cpIndex(model), pic = ownersOn(model, view, CP_BOX), shown = new Set(pic.ids);
|
|
11746
|
+
// a name is qualified by its components and packages only: an artifact nested in a node (deployed on it, as the
|
|
11747
|
+
// deployment view draws it) is not named by the node here (0.22.0, the Bookshop atlas)
|
|
11748
|
+
const qual = pic.name; pic.name = id => { const c = pic.chain(id), k = c.findIndex(p => !CP_BOX.boxTypes.includes((pic.at.get(p) || {}).type));
|
|
11749
|
+
if (k < 0) return qual(id); const up = c.slice(0, k), o = up.indexOf(pic.owner(id));
|
|
11750
|
+
return [...up.slice(0, o < 0 ? k : o).reverse(), id].map(x => String(pic.at.get(x).label ?? x)).join('::'); };
|
|
11623
11751
|
// each line the view lists (or with no list each of this type's kinds) whose ends, a port taken to its owner, it shows
|
|
11624
11752
|
const E = new Map(); CP_ARR(model && model.edges).forEach(e => { if (CP_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
|
|
11625
11753
|
const list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => CP_LINES.includes(e.type));
|
|
@@ -11661,10 +11789,6 @@ const CP_ENDS = { none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', tr
|
|
|
11661
11789
|
const cpLegend = kinds => Object.keys(CP_LOOK).filter(k => kinds.has(k)).map(k => { const [L, , name, says] = CP_LOOK[k], end = k === 'stem' ? 'ball' : L.to;
|
|
11662
11790
|
return `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8H${end === 'none' ? 26 : end === 'ball' ? 16 : end === 'socket' ? 23 : 17}"${L.dashed ? ' stroke-dasharray="3 2"' : ''}/>${CP_ENDS[end]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; });
|
|
11663
11791
|
|
|
11664
|
-
/* ---- a node with ports drawn in its own group: its shape's, with each port's node after it ---- */
|
|
11665
|
-
SHAPE.ported = { size: n => SHAPE[n.base].size(n), out: (n, d) => SHAPE[n.base].out(n, d), inset: (n, s, t) => SHAPE[n.base].inset(n, s, t), face: n => SHAPE[n.base].face(n),
|
|
11666
|
-
ports: (n, s, k) => SHAPE[n.base].ports(n, s, k), draw: (n, c, f) => SHAPE[n.base].draw({ ...n, shape: n.base }, c, f).replace(/<\/g>$/, CP_ARR(n.held).map(p => SHAPE.port.draw(p)).join('') + '</g>') };
|
|
11667
|
-
|
|
11668
11792
|
/* ---- the view laid out (§10.1) ----
|
|
11669
11793
|
Ranked here, not by the host, whose ranking reads only lines between the view's nodes and so none from a port (Q11): as
|
|
11670
11794
|
the host ranks a view with boxes (blockPos, src/map/context.js), in one pass. Each box's contents, then the top, are
|
|
@@ -11746,13 +11870,11 @@ function cpGeo({ model, view, pos: pins, memo }) {
|
|
|
11746
11870
|
...(l.f.port || l.t.port ? { portRank: l.f.port && l.t.port ? r : r[l.f.port ? 0 : 1] } : {}), label: label || undefined, ...(label ? { cut: 280 } : {}),
|
|
11747
11871
|
end: CP_LOOK[l.kind][0], aria: cpLineName(pic, l), ...(CP_OBJ(l.e.style) || typeof l.e.style === 'string' ? { style: l.e.style } : {}) }; });
|
|
11748
11872
|
const g = placeTabs(layoutTyped({ nodes, edges, ...(boxes.length ? { containers: boxes } : {}), direction: down ? 'down' : 'right', gap: 48, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo));
|
|
11749
|
-
// on a copy (stage 4 §2.4): every port
|
|
11873
|
+
// on a copy (stage 4 §2.4): every port as a node, and its name
|
|
11750
11874
|
const d = portsDrawn(g, shownPorts.map(({ p, owner }) => ({ id: p.id, owner, side: side[p.id], label: cpName(p), valueType: cpProps(p).valueType, aria: cpNodeName(model, view, pic, p.id) })));
|
|
11751
|
-
const held = new Map(); d.nodes.forEach(n => { if (n.shape === 'port' && Object.hasOwn(g.byId, n.owner)) held.set(n.owner, [...(held.get(n.owner) || []), n]); });
|
|
11752
|
-
const out = d.nodes.filter(n => !(n.shape === 'port' && held.has(n.owner))).map(n => (held.has(n.id) && n.shape !== 'port' ? { ...n, shape: 'ported', base: n.shape, held: held.get(n.id) } : n));
|
|
11753
11875
|
// a slot after each element (src/typed/edit.js): the palette's add hears the selection through it (cpAdd, at.sel)
|
|
11754
|
-
const
|
|
11755
|
-
return { ...d,
|
|
11876
|
+
const slots = Object.values(d.byId).map((n, index) => ({ index, x: n.x, y: n.y, after: n.id }));
|
|
11877
|
+
return { ...d, slots, legend: cpLegend(new Set(lines.map(l => l.kind))), order: cpOrder(pic) };
|
|
11756
11878
|
}
|
|
11757
11879
|
|
|
11758
11880
|
/* ---- validators (§10.1). Each problem: { level, rule, path, message, ids }. With a view, the lines and elements it shows ---- */
|
|
@@ -11888,7 +12010,9 @@ const CP_TYPE = registerDiagramType('uml.component', {
|
|
|
11888
12010
|
// the PDF's tables (§11): a component's ports, each with what it provides and requires; an interface's operations; what an artifact manifests
|
|
11889
12011
|
word: (model, id) => { const x = cpIndex(model).N.get(id); return x ? ({ 'uml.Component': 'Component', 'uml.Interface': 'Interface', 'uml.Artifact': 'Artifact', 'uml.Package': 'Package', 'uml.Comment': 'Comment' }[x.type] || null) : null; },
|
|
11890
12012
|
rows: (model, view, id) => { const pic = cpPic(model, view), x = pic.I.N.get(id); if (!x) return null;
|
|
11891
|
-
|
|
12013
|
+
// then the connectors from its ports (0.22.0), which the Lines table leaves out: a port is no card
|
|
12014
|
+
if (x.type === 'uml.Component') return [...cpPortsOf(x).map(p => cpNodeName(model, view, pic, p.id).replace(/^Port /, '').replace(` of ${pic.name(id)}`, '')),
|
|
12015
|
+
...pic.lines.filter(l => l.e.type === 'uml.Connector' && (pic.I.P.get(l.e.from) || {}).owner === id).map(l => cpLineName(pic, l))];
|
|
11892
12016
|
if (x.type === 'uml.Interface') return CP_ARR(x.members).filter(CP_OBJ).map(printMember);
|
|
11893
12017
|
if (x.type === 'uml.Artifact') return pic.lines.filter(l => l.e.type === 'uml.Manifestation' && l.e.from === id).map(l => `manifests ${pic.name(l.e.to)}`);
|
|
11894
12018
|
return null; },
|
|
@@ -11915,7 +12039,7 @@ const dpWord = x => (Object.hasOwn(DP_WORD, x.type) ? DP_WORD[x.type] : String(x
|
|
|
11915
12039
|
const DP_KW = { 'uml.Device': 'device', 'uml.ExecutionEnvironment': 'executionEnvironment' };
|
|
11916
12040
|
|
|
11917
12041
|
/* ---- the cube (§9.1): SHAPE.node's outline and inset, 10 px deep, in the typed look's paint; closed a node, open a frame ---- */
|
|
11918
|
-
const DP = { d: 10, pad: 10, name: 12, nlh: 16, st: 11, sth: 14, minW: 120, maxW: 320, minH: 60 };
|
|
12042
|
+
const DP = { d: 10, head: 15, pad: 10, name: 12, nlh: 16, st: 11, sth: 14, minW: 120, maxW: 320, minH: 60 };
|
|
11919
12043
|
const dpParts = n => { const st = [...(DP_KW[n.type] ? [DP_KW[n.type]] : []), ...DP_ARR(n.stereotypes)].map(t => `«${t}»`), name = String(n.label ?? '');
|
|
11920
12044
|
return { st, name, w: Math.ceil(Math.min(DP.maxW, Math.max(DP.minW, ...st.map(t => textW(t, DP.st) + 2 * DP.pad), textW(name, DP.name) + 2 * DP.pad)) + DP.d),
|
|
11921
12045
|
h: Math.max(DP.minH, DP.d + 16 + st.length * DP.sth + DP.nlh) }; };
|
|
@@ -11937,11 +12061,12 @@ SHAPE.cube = {
|
|
|
11937
12061
|
+ `<g class="c-head" data-band="${r2(y + p)} ${r2(y + h)}">${tx.join('')}</g></g>`;
|
|
11938
12062
|
},
|
|
11939
12063
|
// open: the frame holds the cube as a closed one does (its pad is 10 px more, dpPic), the front face its rect, the top and
|
|
11940
|
-
// right faces inside the frame; the
|
|
11941
|
-
|
|
12064
|
+
// right faces inside the frame; the box's title at the top left of its header room: the keyword and stereotypes (f.kw) on
|
|
12065
|
+
// a line above the name, as UML 2.5.1 draws them, in 24 px and the box's head more (dpPic); with neither, the name alone
|
|
12066
|
+
frame: f => { const p = DP.d, { x, y, w, h } = f, line = (t, dy) => `<text class="family-title" x="${r2(x + 12)}" y="${r2(y + p + dy)}" fill="var(--um-text)" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(t, 12, w - p - 24))}</text>`;
|
|
11942
12067
|
return `<rect class="family-box" x="${x}" y="${r2(y + p)}" width="${r2(w - p)}" height="${r2(h - p)}" stroke="var(--um-muted)"/>`
|
|
11943
12068
|
+ `<path d="M${r2(x)} ${r2(y + p)}L${r2(x + p)} ${r2(y)}H${r2(x + w)}V${r2(y + h - p)}L${r2(x + w - p)} ${r2(y + h)}M${r2(x + w - p)} ${r2(y + p)}L${r2(x + w)} ${r2(y)}" class="dp-face" fill="none" stroke="var(--um-muted)"/>`
|
|
11944
|
-
+
|
|
12069
|
+
+ (f.kw ? line(f.kw, 17) + line(f.name, 17 + DP.head) : line(f.title ?? f.label ?? f.id, 17)); },
|
|
11945
12070
|
};
|
|
11946
12071
|
|
|
11947
12072
|
/* ---- the view read: its boxes (§7), its lines, and every element's words ---- */
|
|
@@ -11959,7 +12084,9 @@ function dpIndex(model) {
|
|
|
11959
12084
|
// the view's boxes, each titled with its keyword and stereotypes and named as §10.3 says
|
|
11960
12085
|
function dpPic(model, view) {
|
|
11961
12086
|
const pic = ownersOn(model, view, DP_LOOK), I = dpIndex(model);
|
|
11962
|
-
|
|
12087
|
+
// a box with a keyword or stereotypes: they go on a line above its name (the frame's f.kw, f.name), in DP.head more header room
|
|
12088
|
+
const boxes = pic.boxes.map(b => { const x = pic.at.get(b.id), kw = [...(DP_KW[x.type] ? [DP_KW[x.type]] : []), ...dpStereo(x)].map(t => `«${t}»`).join(' ');
|
|
12089
|
+
return { ...b, title: kw ? kw + ' ' + dpName(x) : dpName(x), ...(kw ? { kw, name: dpName(x), head: DP.head } : {}), aria: dpNodeName(x, pic, I), pad: 12 + DP.d }; });
|
|
11963
12090
|
return { ...pic, I, boxes };
|
|
11964
12091
|
}
|
|
11965
12092
|
// the lines drawn: those the view lists, or with no list the model's of this type's kinds, between two elements it shows; a
|
|
@@ -12259,6 +12386,8 @@ const csEndName = (P, x) => (x.part != null ? `${csName(P.mem.get(x.id).m)} of $
|
|
|
12259
12386
|
const CS_LINE_WORD = { 'uml.Connector': 'Connector', 'uml.RoleBinding': 'Role binding', 'uml.CommentLink': 'Comment link' };
|
|
12260
12387
|
const csLineName = (P, { e, f, t }) => e.type === 'uml.RoleBinding' ? `Role binding from ${csEndName(P, f)} to ${csEndName(P, t)}${CS_HAS(e.label) ? ', as ' + e.label : ''}`
|
|
12261
12388
|
: `${CS_LINE_WORD[e.type] || 'Line'} from ${csEndName(P, f)} to ${csEndName(P, t)}${CS_HAS(e.label) ? ': ' + e.label : ''}`;
|
|
12389
|
+
// the structure a connector is drawn in: its from end's part's or role's, else its from end's owner (a port of the subject)
|
|
12390
|
+
const csStructOf = (P, e) => { const p = (CS_PROPS(e).ends || {}).from, k = CS_OBJ(p) && p.part != null ? p.part : e.from; return P.partOf.get(k) || (P.mem.get(k) || {}).owner; };
|
|
12262
12391
|
// the subject and its ports, each part and its ports, the collaboration uses, each open collaboration and its roles, the rest; then the lines
|
|
12263
12392
|
function csOrder(P) {
|
|
12264
12393
|
const out = [], part = m => { out.push(m.id); P.partPorts.get(m.id).forEach(q => out.push(csPortId(m.id, q.id))); };
|
|
@@ -12354,12 +12483,12 @@ function csGeo({ model, view, pos: pins, memo }) {
|
|
|
12354
12483
|
|
|
12355
12484
|
/* ---- validators (§10.2). Each problem: { level, rule, path, message, ids }; the shared port-owner and manifest-ends too ---- */
|
|
12356
12485
|
const CS_RULES = {
|
|
12357
|
-
'subject-shown': 'the view\'s subject is a class, a component or a collaboration listed in nodeIds',
|
|
12486
|
+
'subject-shown': 'the view\'s subject is a class, a component or a collaboration listed in nodeIds (a view with none: a warning)',
|
|
12358
12487
|
'part-type': 'a part\'s valueType names exactly one class or component of the model by label',
|
|
12359
12488
|
'connector-part': 'an end\'s part is a part of the subject, and the end is a port of that part\'s type',
|
|
12360
12489
|
'connector-scope': 'a connector\'s ends lie in one structure: the subject\'s ports and parts; or one collaboration\'s roles',
|
|
12361
12490
|
'connector-drawn': 'a connector between two ports of the subject is not drawn: the box has no line to itself',
|
|
12362
|
-
'binding-ends': 'a role binding runs from a part of the subject to a collaboration use of it',
|
|
12491
|
+
'binding-ends': 'a role binding runs from a part of the subject to a collaboration use of it (a view with no subject: a warning)',
|
|
12363
12492
|
'binding-role': 'a role binding\'s label names a role of the collaboration its use names',
|
|
12364
12493
|
'collaboration-named': 'a collaboration use\'s collaboration names a collaboration of the model',
|
|
12365
12494
|
...STRUCT_RULES,
|
|
@@ -12369,7 +12498,9 @@ function csProblems(model, view) {
|
|
|
12369
12498
|
const out = [], P = csPic(model, view || {}), vi = CS_ARR(model && model.views).indexOf(view), S = P.S, say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
12370
12499
|
const q = id => JSON.stringify(P.el(id) ? csName(P.el(id)) : String(id));
|
|
12371
12500
|
if (!view) return structProblems(model);
|
|
12372
|
-
|
|
12501
|
+
// a view with no subject at all (its subject deleted, or hidden from it, takes it: apply()) draws its other cards: a warning
|
|
12502
|
+
if (!S && view.subject == null) say('warning', 'subject-shown', `views[${vi}]`, [view.id], `view ${JSON.stringify(view.id)} has no subject (deleted, or hidden from it): it draws its other cards, and no structure until one is set`);
|
|
12503
|
+
else if (!S) say('error', 'subject-shown', `views[${vi}].subject`, [view.id, view.subject], `view ${JSON.stringify(view.id)}'s subject ${JSON.stringify(view.subject ?? null)} is not a class, a component or a collaboration it lists in nodeIds`);
|
|
12373
12504
|
const nodeAt = id => CS_ARR(model.nodes).indexOf(P.at.get(id)), memPath = id => { const o = P.mem.get(id).owner; return `nodes[${nodeAt(o.id)}].members[${o.members.indexOf(P.mem.get(id).m)}]`; };
|
|
12374
12505
|
if (S && S.type !== 'uml.Collaboration') P.parts.forEach(m => { if (!P.typeOf(m)) say('warning', 'part-type', memPath(m.id) + '.props.valueType', [m.id, S.id],
|
|
12375
12506
|
`part ${q(m.id)} of ${q(S.id)} is typed ${JSON.stringify(CS_PROPS(m).valueType ?? null)}, which names ${[...P.at.values()].some(n => CS_TYPED.includes(n.type) && CS_HAS(n.label) && n.label === CS_PROPS(m).valueType) ? 'more than one' : 'no'} class or component, so it shows only the ports its connectors reach through it`); });
|
|
@@ -12393,7 +12524,7 @@ function csProblems(model, view) {
|
|
|
12393
12524
|
if (e.type === 'uml.RoleBinding' && (P.at.get(e.to) || {}).type === 'uml.CollaborationUse' && P.ids.includes(e.to) || e.type === 'uml.RoleBinding' && S && owner(e.from) === S) {
|
|
12394
12525
|
const u = P.at.get(e.to);
|
|
12395
12526
|
if (!(S && owner(e.from) === S && P.mem.get(e.from).m.type === 'uml.Property' && u && u.type === 'uml.CollaborationUse' && u.parent === S.id))
|
|
12396
|
-
return say('error', 'binding-ends', path, [e.id, e.from, e.to], `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a role binding runs from a part of the subject to a collaboration use of it`);
|
|
12527
|
+
return say(S || view.subject != null ? 'error' : 'warning', 'binding-ends', path, [e.id, e.from, e.to], `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a role binding runs from a part of the subject to a collaboration use of it`);
|
|
12397
12528
|
const C = csCollab(P, u);
|
|
12398
12529
|
if (C && !P.kids(C, 'uml.Property').some(m => m.label === e.label)) say('warning', 'binding-role', `${path}.label`, [e.id, C.id], `${e.id} binds ${JSON.stringify(e.label ?? null)}, not a role of ${q(C.id)}`);
|
|
12399
12530
|
} });
|
|
@@ -12531,8 +12662,1028 @@ const CS_TYPE = registerDiagramType('uml.composite-structure', {
|
|
|
12531
12662
|
return x.type === 'uml.Property' ? (q.owner.type === 'uml.Collaboration' ? 'Role' : 'Part') : x.type === 'uml.Port' ? 'Port' : x.type === 'uml.CollaborationUse' ? 'Collaboration use' : null; },
|
|
12532
12663
|
rows: (model, view, id) => { const P = csPic(model, view), q = P.mem.get(id);
|
|
12533
12664
|
if (q && q.m.type === 'uml.Property') return [csPartText(q.m), CS_PROPS(q.m).aggregation === 'shared' ? 'shared' : 'composite'];
|
|
12534
|
-
const u = P.at.get(id); if (!u
|
|
12665
|
+
const u = P.at.get(id); if (!u) return null;
|
|
12666
|
+
// the subject or an open collaboration (0.22.0): its members, a part or role with its aggregation, then the connectors
|
|
12667
|
+
// among them, which the Lines table leaves out (their ends are members, not cards)
|
|
12668
|
+
if (id === P.sid && P.S || P.collabs.some(c => c.x.id === id)) return [...CS_ARR(u.members).filter(CS_OBJ).map(m => CS_MEMBER.print(m) + (m.type === 'uml.Property' && id === P.sid ? `, ${CS_PROPS(m).aggregation === 'shared' ? 'shared' : 'composite'}` : '')),
|
|
12669
|
+
...P.lines.filter(l => l.e.type === 'uml.Connector' && csStructOf(P, l.e) === u).map(l => csLineName(P, l))];
|
|
12670
|
+
if (u.type !== 'uml.CollaborationUse') return null;
|
|
12535
12671
|
return [...(CS_HAS(CS_PROPS(u).collaboration) ? [String(CS_PROPS(u).collaboration)] : []),
|
|
12536
12672
|
...CS_ARR(model.edges).filter(e => CS_OBJ(e) && e.type === 'uml.RoleBinding' && e.to === id).map(e => `${e.label ?? '?'} ← ${csName(P.el(e.from) || { id: e.from })}`)]; },
|
|
12537
12673
|
edits: { connect: csConnect, moveTo: csMoveTo, add: csAdd, remove: csRemove, rename: csRename, member: csMember },
|
|
12538
12674
|
});
|
|
12675
|
+
/* ---------- stereotypes applied across the model (work package 6.2, notes/stage-6.md §5) ----------
|
|
12676
|
+
A profile (uml.Profile) defines stereotypes (uml.Stereotype, their tag definitions uml.Property members) that extend
|
|
12677
|
+
metaclasses (uml.Extension to a uml.MetaclassReference); a uml.ProfileApplication from a package applies it to everything
|
|
12678
|
+
the package owns, at any depth (§5.2). Every element of the model that names a stereotype in props.stereotypes is checked
|
|
12679
|
+
here, whatever view draws it: the name (§5.3), the metaclass it fits (§5.4), required extensions (§5.5) and its tags,
|
|
12680
|
+
props.tags keyed by stereotype then tag (§5.6). Fills two slots of the core (src/core/registry.js's TYPED):
|
|
12681
|
+
- TYPED.across(model) -> problems, which validate() runs once on a version-2 model: [] at once when it holds no profile;
|
|
12682
|
+
- TYPED.fields(model, element) -> the panel's fields for the element's tags, after its type's own (§5.7).
|
|
12683
|
+
appliedOf(model, id) is what the profile type and the PDF read. Only lib/umlimator-all.js holds this file. */
|
|
12684
|
+
|
|
12685
|
+
const STY_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), STY_ARR = x => (Array.isArray(x) ? x : []);
|
|
12686
|
+
const styKey = s => s.charAt(0).toLowerCase() + s.slice(1); // names meet with the first letter in either case (§5.3): Entity is «entity»
|
|
12687
|
+
const styName = x => String(x.label ?? x.id), styQ = x => JSON.stringify(styName(x));
|
|
12688
|
+
|
|
12689
|
+
/* UML 2.5.1's metaclasses (§5.4), each with its generals as OMG's UML.xmi (20161101) lists them: a row "A B: C D" says A and B specialize C, then D.
|
|
12690
|
+
A row for every uml.* type a registered type uses (tests/stereotypes.test.mjs holds it), and the abstract ones between */
|
|
12691
|
+
const UML_META = {};
|
|
12692
|
+
`Element:
|
|
12693
|
+
NamedElement MultiplicityElement TemplateableElement ParameterableElement Comment Slot Relationship: Element
|
|
12694
|
+
Namespace RedefinableElement TypedElement DeploymentTarget DeployedArtifact ActivityGroup InteractionFragment Lifeline Message CollaborationUse: NamedElement
|
|
12695
|
+
PackageableElement: NamedElement ParameterableElement
|
|
12696
|
+
Vertex: NamedElement RedefinableElement
|
|
12697
|
+
Type Constraint: PackageableElement
|
|
12698
|
+
IntervalConstraint: Constraint
|
|
12699
|
+
DurationConstraint TimeConstraint: IntervalConstraint
|
|
12700
|
+
ConnectableElement: TypedElement ParameterableElement
|
|
12701
|
+
Classifier: Namespace Type RedefinableElement TemplateableElement
|
|
12702
|
+
StructuredClassifier BehavioredClassifier Interface DataType InformationItem Signal: Classifier
|
|
12703
|
+
Artifact: Classifier DeployedArtifact
|
|
12704
|
+
EncapsulatedClassifier: StructuredClassifier
|
|
12705
|
+
Class: EncapsulatedClassifier BehavioredClassifier
|
|
12706
|
+
Component Stereotype Behavior: Class
|
|
12707
|
+
Node: Class DeploymentTarget
|
|
12708
|
+
Activity StateMachine: Behavior
|
|
12709
|
+
Interaction: Behavior InteractionFragment
|
|
12710
|
+
Device ExecutionEnvironment: Node
|
|
12711
|
+
Actor UseCase: BehavioredClassifier
|
|
12712
|
+
Collaboration: StructuredClassifier BehavioredClassifier
|
|
12713
|
+
Association: Relationship Classifier
|
|
12714
|
+
Extension CommunicationPath: Association
|
|
12715
|
+
Enumeration PrimitiveType: DataType
|
|
12716
|
+
DeploymentSpecification: Artifact
|
|
12717
|
+
Package: Namespace PackageableElement TemplateableElement
|
|
12718
|
+
Profile Model: Package
|
|
12719
|
+
Feature: RedefinableElement
|
|
12720
|
+
StructuralFeature: TypedElement MultiplicityElement Feature
|
|
12721
|
+
Property: StructuralFeature ConnectableElement DeploymentTarget
|
|
12722
|
+
Port: Property
|
|
12723
|
+
BehavioralFeature: Feature Namespace
|
|
12724
|
+
Operation: BehavioralFeature TemplateableElement ParameterableElement
|
|
12725
|
+
Connector: Feature
|
|
12726
|
+
InstanceSpecification: PackageableElement DeploymentTarget DeployedArtifact
|
|
12727
|
+
EnumerationLiteral: InstanceSpecification
|
|
12728
|
+
DirectedRelationship: Relationship
|
|
12729
|
+
Dependency: DirectedRelationship PackageableElement
|
|
12730
|
+
Usage Abstraction Deployment: Dependency
|
|
12731
|
+
Realization Manifestation: Abstraction
|
|
12732
|
+
InterfaceRealization ComponentRealization: Realization
|
|
12733
|
+
Generalization PackageImport PackageMerge ProfileApplication ElementImport: DirectedRelationship
|
|
12734
|
+
InformationFlow: DirectedRelationship PackageableElement
|
|
12735
|
+
Include Extend: DirectedRelationship NamedElement
|
|
12736
|
+
MetaclassReference: ElementImport
|
|
12737
|
+
ActivityNode ActivityEdge Region Transition ExtensionPoint: RedefinableElement
|
|
12738
|
+
ExecutableNode ControlNode: ActivityNode
|
|
12739
|
+
ObjectNode: ActivityNode TypedElement
|
|
12740
|
+
Action: ExecutableNode
|
|
12741
|
+
InvocationAction AcceptEventAction: Action
|
|
12742
|
+
CallAction SendSignalAction: InvocationAction
|
|
12743
|
+
CallBehaviorAction: CallAction
|
|
12744
|
+
InitialNode DecisionNode MergeNode ForkNode JoinNode FinalNode: ControlNode
|
|
12745
|
+
ActivityFinalNode FlowFinalNode: FinalNode
|
|
12746
|
+
CentralBufferNode: ObjectNode
|
|
12747
|
+
DataStoreNode: CentralBufferNode
|
|
12748
|
+
ControlFlow ObjectFlow: ActivityEdge
|
|
12749
|
+
ActivityPartition: ActivityGroup
|
|
12750
|
+
State: Namespace Vertex
|
|
12751
|
+
Pseudostate: Vertex
|
|
12752
|
+
FinalState: State
|
|
12753
|
+
CombinedFragment InteractionUse StateInvariant: InteractionFragment
|
|
12754
|
+
InteractionOperand: InteractionFragment Namespace`.split('\n').forEach(row => { const [a, b] = row.split(':'), g = b.trim() ? b.trim().split(' ') : []; a.split(' ').forEach(n => { UML_META[n] = g; }); });
|
|
12755
|
+
Object.assign(UML_META, { Region: ['Namespace', 'RedefinableElement'], Transition: ['Namespace', 'RedefinableElement'] }); // through Namespace too
|
|
12756
|
+
// the registry's types that are no metaclass, and what they are (§5.4); null: no stereotype fits it
|
|
12757
|
+
const UML_AS = { InternalTransition: 'Transition', Subject: 'Classifier', Link: 'InstanceSpecification', RoleBinding: 'Dependency', CommentLink: null };
|
|
12758
|
+
// a uml.* element's metaclass, null for none, undefined for an element of no UML type (a sysml. one is not checked, §5.4)
|
|
12759
|
+
const metaOf = x => typeof x.type !== 'string' || !x.type.startsWith('uml.') ? undefined : Object.hasOwn(UML_AS, x.type.slice(4)) ? UML_AS[x.type.slice(4)] : x.type.slice(4);
|
|
12760
|
+
const META_UP = new Map();
|
|
12761
|
+
// a metaclass and every metaclass it specializes; a name in no row is only itself
|
|
12762
|
+
function metaUp(name) {
|
|
12763
|
+
if (!META_UP.has(name)) { const out = new Set([name]); META_UP.set(name, out); (Object.hasOwn(UML_META, name) ? UML_META[name] : []).forEach(g => metaUp(g).forEach(x => out.add(x))); }
|
|
12764
|
+
return META_UP.get(name);
|
|
12765
|
+
}
|
|
12766
|
+
const metaKnown = name => Object.hasOwn(UML_META, name);
|
|
12767
|
+
|
|
12768
|
+
// UML 2.5.1's standard profile (Table 22.1) and keywords (Table C.1, three of them two words), and earlier UML's that
|
|
12769
|
+
// models still store (after, all, at, dataStore, deploymentSpec, else, from, when): free keywords inside an applied profile (§5.3, 3)
|
|
12770
|
+
const STY_FREE = new Set(('auxiliary buildComponent call create derive destroy document entity executable file focus framework implement implementationClass instantiate library metaclass '
|
|
12771
|
+
+ 'metamodel modelLibrary process realization refine responsibility script send service source specification subsystem systemModel trace type utility '
|
|
12772
|
+
+ 'abstraction access activity actor apply artifact centralBuffer bind collaboration component datastore dataType decisionInput decisionInputFlow deploy device '
|
|
12773
|
+
+ 'enumeration executionEnvironment extend extended external final flow import include information interface iterative localPostcondition localPrecondition '
|
|
12774
|
+
+ 'manifest merge model multicast multireceive occurrence parallel postcondition protocol precondition primitive profile reference representation selection signal '
|
|
12775
|
+
+ 'singleExecution statemachine stream stereotype strict structured substitute transformation use '
|
|
12776
|
+
+ 'after all at dataStore deploymentSpec else from when').split(' ').concat('deployment spec', 'element access', 'element import').map(styKey));
|
|
12777
|
+
|
|
12778
|
+
const STY_RULES = {
|
|
12779
|
+
'stereotype-unknown': 'a stereotype applied inside an applied profile\'s scope is one of its stereotypes, a standard one or a UML keyword',
|
|
12780
|
+
'stereotype-applied': 'a stereotype of a profile is applied only where that profile is applied',
|
|
12781
|
+
'stereotype-ambiguous': 'a name two applied profiles define is written Profile::name',
|
|
12782
|
+
'stereotype-extends': 'an applied stereotype extends the element\'s metaclass or one it specializes',
|
|
12783
|
+
'stereotype-abstract': 'an abstract stereotype is not applied',
|
|
12784
|
+
'stereotype-required': 'an abstract required stereotype is met by a specialization the element applies',
|
|
12785
|
+
'tag-defined': 'each tag is under a stereotype the element has, and that stereotype or a general defines it; a redefined tag keeps its type',
|
|
12786
|
+
'tag-value': 'a tag\'s value fits its type',
|
|
12787
|
+
'tag-required': 'a tag of lower bound 1 with no default has a value',
|
|
12788
|
+
};
|
|
12789
|
+
|
|
12790
|
+
/* The model read once (§12): its elements with their paths and the id whose scope they take, profiles, stereotypes by name,
|
|
12791
|
+
applications, generals, extensions; scopes memoised per owner. Kept per model object for appliedOf and fields */
|
|
12792
|
+
// ponytail: kept per model object, so a model edited in place keeps its first reading until TYPED.across reads it again;
|
|
12793
|
+
// apply() returns a new model, so the canvas never does. A version stamp per model is the upgrade if a host edits in place
|
|
12794
|
+
const STY_SEEN = new WeakMap();
|
|
12795
|
+
function styIndex(model) {
|
|
12796
|
+
const at = new Map(), up = new Map(), rec = new Map(), els = []; // up: an element's owner (§5.2); a view's own elements have none
|
|
12797
|
+
const put = (x, path, own, scoped) => { if (!STY_OBJ(x) || typeof x.id !== 'string') return; const r = { x, path, own: scoped ? own : undefined, line: scoped && (x.from != null || x.to != null) };
|
|
12798
|
+
if (!at.has(x.id)) { at.set(x.id, x); rec.set(x.id, r); if (scoped) up.set(x.id, own); }
|
|
12799
|
+
els.push(r); STY_ARR(x.members).forEach((m, j) => put(m, `${path}.members[${j}]`, x.id, scoped)); };
|
|
12800
|
+
STY_ARR(model.nodes).forEach((n, i) => put(n, `nodes[${i}]`, n && n.parent, true));
|
|
12801
|
+
// a line's owner: its parent, else its from end's owner (§5.2), read once every node and member is in
|
|
12802
|
+
STY_ARR(model.edges).forEach((e, i) => put(e, `edges[${i}]`, undefined, true));
|
|
12803
|
+
els.forEach(r => { if (r.line) { r.own = r.x.parent != null ? r.x.parent : up.get(r.x.from); if (rec.get(r.x.id) === r) up.set(r.x.id, r.own); } });
|
|
12804
|
+
STY_ARR(model.views).forEach((v, k) => { if (STY_OBJ(v)) ['nodes', 'edges', 'fragments'].forEach(f => STY_ARR(v[f]).forEach((x, j) => put(x, `views[${k}].${f}[${j}]`, undefined, false))); }); // a view's own: no scope
|
|
12805
|
+
const isT = (id, t) => (at.get(id) || {}).type === t, profiles = [...at.values()].filter(x => x.type === 'uml.Profile');
|
|
12806
|
+
const I = { at, rec, els, profiles, byProfile: new Map(), byName: new Map(), byLabel: new Map(), enums: new Map(), applied: new Map(), gens: new Map(), ext: new Map(), req: new Map(), memo: new Map(), cache: new Map(), defsOf: new Map(), upOf: new Map(), probs: [] };
|
|
12807
|
+
if (!profiles.length) return I;
|
|
12808
|
+
profiles.forEach(p => { I.byProfile.set(p.id, new Map()); I.enums.set(p.id, new Map()); if (!I.byLabel.has(styName(p))) I.byLabel.set(styName(p), []); I.byLabel.get(styName(p)).push(p); });
|
|
12809
|
+
[...at.values()].forEach(x => { if (!I.byProfile.has(x.parent) || up.get(x.id) !== x.parent) return; const k = styKey(styName(x));
|
|
12810
|
+
if (x.type === 'uml.Stereotype' && !I.byProfile.get(x.parent).has(k)) { I.byProfile.get(x.parent).set(k, x); if (!I.byName.has(k)) I.byName.set(k, []); I.byName.get(k).push(at.get(x.parent)); }
|
|
12811
|
+
if (x.type === 'uml.Enumeration') I.enums.get(x.parent).set(styName(x), STY_ARR(x.members).filter(m => STY_OBJ(m) && m.type === 'uml.EnumerationLiteral').map(styName)); });
|
|
12812
|
+
const add = (map, k, v) => { if (!map.has(k)) map.set(k, []); map.get(k).push(v); };
|
|
12813
|
+
STY_ARR(model.edges).forEach(e => { if (!STY_OBJ(e)) return;
|
|
12814
|
+
if (e.type === 'uml.ProfileApplication' && isT(e.to, 'uml.Profile') && at.has(e.from)) add(I.applied, e.from, e.to);
|
|
12815
|
+
if (e.type === 'uml.Generalization' && isT(e.from, 'uml.Stereotype') && isT(e.to, 'uml.Stereotype')) add(I.gens, e.from, e.to);
|
|
12816
|
+
if (e.type === 'uml.Extension' && isT(e.from, 'uml.Stereotype') && isT(e.to, 'uml.MetaclassReference')) { const meta = styName(at.get(e.to)); add(I.ext, e.from, meta);
|
|
12817
|
+
const p = at.get(e.from).parent; if (STY_OBJ(e.props) && e.props.isRequired === true && I.byProfile.has(p)) add(I.req, p, { st: at.get(e.from), meta }); } });
|
|
12818
|
+
// the profiles applied to an owner and its owners: memoised per owner, so no element walks its chain on its own
|
|
12819
|
+
I.scope = id => { if (id == null || !at.has(id)) return []; if (I.memo.has(id)) return I.memo.get(id); I.memo.set(id, []); // a cycle (the core's error) ends here
|
|
12820
|
+
const above = I.scope(up.get(id)), mine = (I.applied.get(id) || []).filter(p => !above.includes(p)), s = mine.length ? [...above, ...mine] : above; I.memo.set(id, s); return s; };
|
|
12821
|
+
// an owner inside a profile, or the profile: its own definitions, which its required stereotypes do not reach (§5.5)
|
|
12822
|
+
const inP = new Map(); I.inProf = id => { if (id == null || !at.has(id)) return false; if (!inP.has(id)) { inP.set(id, false); inP.set(id, at.get(id).type === 'uml.Profile' || I.inProf(up.get(id))); } return inP.get(id); };
|
|
12823
|
+
return I;
|
|
12824
|
+
}
|
|
12825
|
+
const styOf = model => { if (!STY_SEEN.has(model)) STY_SEEN.set(model, styIndex(model)); return STY_SEEN.get(model); };
|
|
12826
|
+
|
|
12827
|
+
// a stereotype's generals, nearest first, each once
|
|
12828
|
+
function styGenerals(I, st) {
|
|
12829
|
+
if (I.upOf.has(st.id)) return I.upOf.get(st.id);
|
|
12830
|
+
const out = [], seen = new Set([st.id]);
|
|
12831
|
+
for (let i = 0, q = [st.id]; i < q.length; i++) STY_ARR(I.gens.get(q[i])).forEach(g => { if (!seen.has(g)) { seen.add(g); q.push(g); out.push(I.at.get(g)); } });
|
|
12832
|
+
I.upOf.set(st.id, out); return out;
|
|
12833
|
+
}
|
|
12834
|
+
/* its tag definitions, its generals' first, a specialization's redefining a general's of one name (§5.6): name -> { def, st }.
|
|
12835
|
+
Each stereotype says only its own: a redefinition of another type, or two unrelated generals' tags of one name and two types */
|
|
12836
|
+
function styDefs(I, st) {
|
|
12837
|
+
if (I.defsOf.has(st.id)) return I.defsOf.get(st.id);
|
|
12838
|
+
const out = new Map(), clash = new Map(), t = x => STY_OBJ(x.props) ? x.props.valueType ?? 'String' : 'String'; I.defsOf.set(st.id, out); // a cycle (6.3's error) ends here
|
|
12839
|
+
STY_ARR(I.gens.get(st.id)).forEach(g => styDefs(I, I.at.get(g)).forEach((e, n) => { const was = out.get(n);
|
|
12840
|
+
if (!was || styGenerals(I, e.st).includes(was.st)) return out.set(n, e); // e redefines it
|
|
12841
|
+
if (was.def !== e.def && !styGenerals(I, was.st).includes(e.st) && t(was.def) !== t(e.def)) clash.set(n, [was, e]); }));
|
|
12842
|
+
STY_ARR(st.members).forEach(d => { if (!STY_OBJ(d) || d.type !== 'uml.Property') return; const n = styName(d), was = out.get(n); clash.delete(n);
|
|
12843
|
+
if (was && t(was.def) !== t(d)) I.probs.push(stySay('tag-defined', 'warning', styPath(I, d), [d.id, was.def.id], `«${styName(st)}» redefines tag "${n}" of «${styName(was.st)}» as ${t(d)}, not ${t(was.def)}`));
|
|
12844
|
+
out.set(n, { def: d, st }); });
|
|
12845
|
+
clash.forEach(([a, b], n) => I.probs.push(stySay('tag-defined', 'warning', styPath(I, st), [st.id, a.def.id, b.def.id], `«${styName(st)}» inherits tag "${n}" from «${styName(a.st)}» as ${t(a.def)} and from «${styName(b.st)}» as ${t(b.def)}: redefine it`)));
|
|
12846
|
+
return out;
|
|
12847
|
+
}
|
|
12848
|
+
const styFits = (I, st, meta) => [st, ...styGenerals(I, st)].some(s => STY_ARR(I.ext.get(s.id)).some(m => metaUp(meta).has(m)));
|
|
12849
|
+
const stySay = (rule, level, path, ids, msg) => ({ level, rule, path, ids, message: `${rule}: ${msg}` });
|
|
12850
|
+
const styPath = (I, x) => I.rec.get(x.id).path;
|
|
12851
|
+
|
|
12852
|
+
// a tag's value against its type (§5.6): Boolean, Integer, Real, UnlimitedNatural, an enumeration of the profile, else any text
|
|
12853
|
+
function styTagFits(I, def, prof, v) {
|
|
12854
|
+
const t = STY_OBJ(def.props) ? def.props.valueType : null, s = typeof v === 'object' ? null : String(v), re = (x, say) => (s !== null && x.test(s)) || say; // a list or a map is no value
|
|
12855
|
+
if (t === 'Boolean') return re(/^(true|false)$/, 'true or false');
|
|
12856
|
+
if (t === 'Integer') return re(/^-?\d+$/, 'a whole number');
|
|
12857
|
+
if (t === 'Real') return re(/^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/, 'a number');
|
|
12858
|
+
if (t === 'UnlimitedNatural') return re(/^(\d+|\*)$/, 'a whole number or *');
|
|
12859
|
+
const lits = prof && I.enums.get(prof.id).get(t);
|
|
12860
|
+
return lits ? (s !== null && lits.includes(s)) || `one of ${lits.join(', ')}` : s !== null || 'text';
|
|
12861
|
+
}
|
|
12862
|
+
|
|
12863
|
+
/* One element's stereotypes, resolved by §5.3, with the required ones its scope supplies (§5.5) and their tags (§5.6):
|
|
12864
|
+
[{ stereotype, profile, key, required, tags: [{ name, def, value }] }], key the name props.tags keys it by. say: problems */
|
|
12865
|
+
function styResolve(I, r, say) {
|
|
12866
|
+
const { x, path } = r, P = STY_OBJ(x.props) ? x.props : {}, scope = I.scope(I.applied.has(x.id) && I.rec.get(x.id) === r ? x.id : r.own), meta = metaOf(x); // a package applying a profile is in its scope
|
|
12867
|
+
const names = p => p.map(styName).join(' and '), owner = I.at.get(r.own), out = [], again = new Map();
|
|
12868
|
+
STY_ARR(P.stereotypes).forEach((s, k) => { if (typeof s !== 'string' || !s) return; const at = `${path}.props.stereotypes[${k}]`, q = s.indexOf('::');
|
|
12869
|
+
let hit = null;
|
|
12870
|
+
if (q > 0) { const ps = I.byLabel.get(s.slice(0, q)) || [], k2 = styKey(s.slice(q + 2)), has = p => I.byProfile.get(p.id).has(k2); // two of one label: the one applied here
|
|
12871
|
+
const p = ps.find(p => has(p) && scope.includes(p.id)) || ps.find(has) || ps[0], st = p && I.byProfile.get(p.id).get(k2);
|
|
12872
|
+
if (!st) return say(stySay('stereotype-unknown', 'warning', at, [x.id], `«${s}» on ${styQ(x)}: ${p ? `profile ${styName(p)} defines no stereotype ${s.slice(q + 2)}` : `no profile is named ${s.slice(0, q)}`}`));
|
|
12873
|
+
hit = { st, p }; }
|
|
12874
|
+
else { const k2 = styKey(s), here = scope.map(id => I.at.get(id)).filter(p => I.byProfile.get(p.id).has(k2));
|
|
12875
|
+
if (here.length > 1) return say(stySay('stereotype-ambiguous', 'warning', at, [x.id, ...here.map(p => p.id)], `«${s}» on ${styQ(x)} is a stereotype of ${names(here)}, both applied: write ${here.map(p => `${styName(p)}::${s}`).join(' or ')}`));
|
|
12876
|
+
if (here.length) hit = { st: I.byProfile.get(here[0].id).get(k2), p: here[0] };
|
|
12877
|
+
else if (STY_FREE.has(k2)) return; // a standard stereotype or a keyword
|
|
12878
|
+
else if (scope.length) return say(stySay('stereotype-unknown', 'warning', at, [x.id], `«${s}» on ${styQ(x)} is no stereotype of ${names(scope.map(id => I.at.get(id)))}, applied here, and no standard one`));
|
|
12879
|
+
else if (I.byName.has(k2)) { const ps = I.byName.get(k2); return say(stySay('stereotype-applied', 'warning', at, [x.id, ...ps.map(p => p.id)], `«${s}» is a stereotype of ${names(ps)}, which is not applied to ${owner ? `${styName(owner)} or its owners` : `any owner of ${styQ(x)}`}`)); }
|
|
12880
|
+
else return; }
|
|
12881
|
+
const twice = out.find(e => e.stereotype === hit.st); if (twice) return void again.set(s, twice.key);
|
|
12882
|
+
if (!scope.includes(hit.p.id)) return say(stySay('stereotype-applied', 'warning', at, [x.id, hit.p.id], `«${s}» is a stereotype of ${styName(hit.p)}, which is not applied to ${owner ? `${styName(owner)} or its owners` : `any owner of ${styQ(x)}`}`));
|
|
12883
|
+
if (meta !== undefined && !(meta && styFits(I, hit.st, meta))) say(stySay('stereotype-extends', 'warning', at, [x.id, hit.st.id], `«${s}» on ${styQ(x)} extends ${[...new Set([hit.st, ...styGenerals(I, hit.st)].flatMap(t => STY_ARR(I.ext.get(t.id))))].join(', ') || 'no metaclass'}, which a ${meta || String(x.type).slice(4)} is not`));
|
|
12884
|
+
if (STY_OBJ(hit.st.props) && hit.st.props.isAbstract === true) say(stySay('stereotype-abstract', 'error', at, [x.id, hit.st.id], `«${s}» on ${styQ(x)} is abstract: apply one of its specializations`));
|
|
12885
|
+
out.push({ stereotype: hit.st, profile: hit.p, key: s, required: false }); });
|
|
12886
|
+
// required extensions of the profiles in scope, on an element of their metaclass: met by the stereotype or a specialization the
|
|
12887
|
+
// element applies; else supplied by the tool, or warned when abstract (decision 22)
|
|
12888
|
+
if (meta && !(x.type === 'uml.Profile' || I.inProf(r.own))) scope.forEach(pid => STY_ARR(I.req.get(pid)).forEach(({ st, meta: m }) => { if (!metaUp(meta).has(m)) return;
|
|
12889
|
+
const met = out.filter(e => e.stereotype === st || styGenerals(I, e.stereotype).includes(st)); if (met.length) return met.forEach(e => { e.required = true; });
|
|
12890
|
+
if (STY_OBJ(st.props) && st.props.isAbstract === true) return say(stySay('stereotype-required', 'warning', path, [x.id, st.id], `${styQ(x)} applies no specialization of «${styName(st)}», abstract and required on ${m} by ${styName(I.at.get(pid))}`));
|
|
12891
|
+
if (!out.some(e => e.stereotype === st)) { const two = scope.filter(id => I.byProfile.get(id).has(styKey(styName(st)))).length > 1; // the key as §5.3 would write it
|
|
12892
|
+
out.push({ stereotype: st, profile: I.at.get(pid), key: two ? `${styName(I.at.get(pid))}::${styName(st)}` : styName(st), required: true }); } }));
|
|
12893
|
+
// tags, keyed by stereotype then tag
|
|
12894
|
+
const T = P.tags, tp = `${path}.props.tags`;
|
|
12895
|
+
if (T != null && !STY_OBJ(T)) say(stySay('tag-defined', 'warning', tp, [x.id], `the tags of ${styQ(x)} are a map from each stereotype to its tags`));
|
|
12896
|
+
else if (T) Object.keys(T).forEach(k => { const e = out.find(o => o.key === k), inner = T[k];
|
|
12897
|
+
if (!e) return say(stySay('tag-defined', 'warning', tp, [x.id], `${styQ(x)} keeps tags under "${k}", which is no stereotype it has${again.has(k) ? `: it names «${again.get(k)}» again, whose tags are under "${again.get(k)}"` : ''}`));
|
|
12898
|
+
if (!STY_OBJ(inner)) return say(stySay('tag-defined', 'warning', tp, [x.id], `the tags of ${styQ(x)} under «${k}» are a map from each tag to its value`));
|
|
12899
|
+
const D = styDefs(I, e.stereotype);
|
|
12900
|
+
Object.keys(inner).forEach(n => { const d = D.get(n), v = inner[n];
|
|
12901
|
+
if (!d) return say(stySay('tag-defined', 'warning', tp, [x.id, e.stereotype.id], `«${k}» on ${styQ(x)} has no tag "${n}"`));
|
|
12902
|
+
const ok = v == null || styTagFits(I, d.def, I.at.get(d.st.parent), v);
|
|
12903
|
+
if (ok !== true) say(stySay('tag-value', 'warning', tp, [x.id, d.def.id], `«${k}» ${n} = ${JSON.stringify(v)} on ${styQ(x)} is not ${ok}`)); }); });
|
|
12904
|
+
out.forEach(e => { const inner = STY_OBJ(T) && Object.hasOwn(T, e.key) && STY_OBJ(T[e.key]) ? T[e.key] : {};
|
|
12905
|
+
e.tags = [...styDefs(I, e.stereotype)].map(([name, { def }]) => ({ name, def, value: Object.hasOwn(inner, name) ? inner[name] : undefined }));
|
|
12906
|
+
e.tags.forEach(({ name, def, value }) => { const dp = STY_OBJ(def.props) ? def.props : {};
|
|
12907
|
+
if ((value == null || value === '') && dp.default == null && (multBounds(dp.multiplicity ?? '1') || { lower: 1 }).lower >= 1) say(stySay('tag-required', 'warning', tp, [x.id, def.id], `«${e.key}» ${name} on ${styQ(x)} has no value and no default`)); }); });
|
|
12908
|
+
return out;
|
|
12909
|
+
}
|
|
12910
|
+
|
|
12911
|
+
// every element's problems (§5.3 to §5.6), and the profiles' redefined tags: [] at once for a model with no profile
|
|
12912
|
+
function stereoProblems(model) {
|
|
12913
|
+
const I = styIndex(STY_OBJ(model) ? model : {}); if (STY_OBJ(model)) STY_SEEN.set(model, I);
|
|
12914
|
+
if (!I.profiles.length) return [];
|
|
12915
|
+
const out = [];
|
|
12916
|
+
I.byProfile.forEach(sts => sts.forEach(st => styDefs(I, st)));
|
|
12917
|
+
I.els.forEach(r => { const e = styResolve(I, r, p => out.push(p)); if (!I.cache.has(r.x.id)) I.cache.set(r.x.id, e); });
|
|
12918
|
+
return [...I.probs, ...out];
|
|
12919
|
+
}
|
|
12920
|
+
|
|
12921
|
+
// the stereotypes an element has, stored and required, with their tags (§5.5, §5.6): what 6.3 draws and the PDF lists
|
|
12922
|
+
function appliedOf(model, id) {
|
|
12923
|
+
const I = styOf(model); if (!I.profiles.length) return [];
|
|
12924
|
+
if (!I.cache.has(id)) { const r = I.rec.get(id); I.cache.set(id, r ? styResolve(I, r, () => {}) : []); }
|
|
12925
|
+
return I.cache.get(id);
|
|
12926
|
+
}
|
|
12927
|
+
|
|
12928
|
+
/* The panel's fields for an element's tags (§5.7): one per tag of each stereotype it has, its key carrying the stereotype so two
|
|
12929
|
+
stereotypes' `name` keep their own focus; a Boolean or an enumeration of the profile a choice, else text */
|
|
12930
|
+
function tagFields(model, x) {
|
|
12931
|
+
if (!STY_OBJ(x) || typeof x.id !== 'string') return [];
|
|
12932
|
+
const I = styOf(model);
|
|
12933
|
+
return appliedOf(model, x.id).flatMap(e => e.tags.map(({ name, def }) => { const t = STY_OBJ(def.props) ? def.props.valueType : null, p = I.at.get(e.stereotype.parent);
|
|
12934
|
+
const lits = t === 'Boolean' ? ['true', 'false'] : p && I.enums.get(p.id).get(t);
|
|
12935
|
+
return { key: `p:${e.key}:${name}`, path: ['tags', e.key, name], label: `«${styName(e.stereotype)}» ${name}`, ...(lits ? { kind: 'enum', options: lits } : { kind: 'text' }) }; }));
|
|
12936
|
+
}
|
|
12937
|
+
|
|
12938
|
+
TYPED.across = stereoProblems;
|
|
12939
|
+
TYPED.fields = tagFields;
|
|
12940
|
+
/* ---------- the profile diagram, uml.profile (work package 6.3, notes/stage-6.md §4) ----------
|
|
12941
|
+
A profile (a package with «profile») holding its stereotypes («stereotype», their tag definitions as attribute rows),
|
|
12942
|
+
metaclass references («metaclass») and enumerations; extensions (a solid line with a filled triangle at the metaclass,
|
|
12943
|
+
{required} when required), profile applications (dashed, «apply»), generalizations between stereotypes, comments. Drawn by
|
|
12944
|
+
the class type's look (typeGeo, src/uml/class.js), down by default, with repair and reroute off (§12). Any other element
|
|
12945
|
+
shown is its class or package look with a compartment per stereotype it has (src/uml/stereotypes.js's appliedOf, the
|
|
12946
|
+
required ones too), headed «name», each row `tag = value`, and its members that apply a stereotype, `«column» - placedAt:
|
|
12947
|
+
Instant {name = placed_at}`. Only lib/umlimator-all.js holds this file. */
|
|
12948
|
+
|
|
12949
|
+
const PR_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), PR_ARR = x => (Array.isArray(x) ? x : []);
|
|
12950
|
+
const prName = x => String(x.label ?? x.id);
|
|
12951
|
+
const prWord = t => t === 'uml.MetaclassReference' ? 'Metaclass' : String(t || 'uml.Class').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
|
|
12952
|
+
const PR_DEFS = ['uml.Stereotype', 'uml.MetaclassReference', 'uml.Enumeration']; // what goes into a profile (§4.3)
|
|
12953
|
+
|
|
12954
|
+
// the model read once per call: nodes by id, every member by id, lines by kind
|
|
12955
|
+
function prIndex(model) {
|
|
12956
|
+
const at = new Map(), mem = new Map();
|
|
12957
|
+
PR_ARR(model && model.nodes).forEach(n => { if (PR_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) { at.set(n.id, n); PR_ARR(n.members).forEach(m => { if (PR_OBJ(m) && typeof m.id === 'string') mem.set(m.id, m); }); } });
|
|
12958
|
+
const E = PR_ARR(model && model.edges).filter(e => PR_OBJ(e) && typeof e.id === 'string'), of = t => E.filter(e => e.type === t);
|
|
12959
|
+
return { at, mem, E, of, ty: id => (at.get(id) || {}).type };
|
|
12960
|
+
}
|
|
12961
|
+
|
|
12962
|
+
// a stereotype as an element has it: «key», required, and its tags `name = value` (a default when unset; `?` for a value a
|
|
12963
|
+
// tag of lower bound 1 needs; an optional one unset is left out)
|
|
12964
|
+
function prTags(e) {
|
|
12965
|
+
return e.tags.flatMap(({ name, def, value }) => { const d = PR_OBJ(def.props) ? def.props : {}, need = (multBounds(d.multiplicity ?? '1') || { lower: 1 }).lower >= 1;
|
|
12966
|
+
const v = value != null && value !== '' ? value : d.default != null ? d.default : need ? '?' : null;
|
|
12967
|
+
return v == null ? [] : [`${name} = ${typeof v === 'object' ? JSON.stringify(v) : v}`]; });
|
|
12968
|
+
}
|
|
12969
|
+
const prSay = e => { const t = prTags(e); return `«${e.key}»${e.required ? ' required' : ''}${t.length ? ' ' + t.join(', ') : ''}`; };
|
|
12970
|
+
|
|
12971
|
+
/* ---- what each element says (§9): its PDF rows, and its accessible name after its kind and name ---- */
|
|
12972
|
+
function prRows(model, x, I = prIndex(model)) {
|
|
12973
|
+
const nm = id => prName(I.at.get(id)), from = (t, id) => I.of(t).filter(e => e.from === id && I.at.has(e.to)), to = (t, id) => I.of(t).filter(e => e.to === id && I.at.has(e.from));
|
|
12974
|
+
if (x.type === 'uml.Profile') return [...[...I.at.values()].filter(n => n.parent === x.id && (n.type === 'uml.Stereotype' || n.type === 'uml.MetaclassReference')).map(n => (n.type === 'uml.Stereotype' ? `«${prName(n)}»` : `metaclass ${prName(n)}`)),
|
|
12975
|
+
...to('uml.ProfileApplication', x.id).map(e => `applied to ${nm(e.from)}`)];
|
|
12976
|
+
if (x.type === 'uml.Stereotype') return [...from('uml.Extension', x.id).map(e => `extends ${nm(e.to)}${PR_OBJ(e.props) && e.props.isRequired === true ? ' {required}' : ''}`),
|
|
12977
|
+
...from('uml.Generalization', x.id).map(e => `a kind of «${nm(e.to)}»`), ...PR_ARR(x.members).filter(m => PR_OBJ(m) && m.type === 'uml.Property').map(printMember)];
|
|
12978
|
+
if (x.type === 'uml.MetaclassReference') return to('uml.Extension', x.id).map(e => `extended by «${nm(e.from)}»`);
|
|
12979
|
+
return [...appliedOf(model, x.id).map(prSay), ...PR_ARR(x.members).filter(m => PR_OBJ(m) && appliedOf(model, m.id).length).map(m => `${printMember(m)}: ${appliedOf(model, m.id).map(prSay).join('; ')}`)];
|
|
12980
|
+
}
|
|
12981
|
+
// "Stereotype owned, extends Class {required}, team: String"; "Class Order: «entity» table = orders; «owned» required"
|
|
12982
|
+
function prAria(model, x, drawn, I) {
|
|
12983
|
+
const why = [...(x.type === 'uml.Stereotype' && PR_OBJ(x.props) && x.props.isAbstract === true ? ['abstract'] : []), ...(OWN_BOXES.includes(x.type) ? ['closed'] : [])];
|
|
12984
|
+
const head = `${prWord(x.type)} ${drawn}${why.length ? ` (${why.join(', ')})` : ''}`, rows = prRows(model, x, I);
|
|
12985
|
+
return !rows.length ? head : PR_DEFS.slice(0, 2).includes(x.type) || x.type === 'uml.Profile' ? [head, ...rows].join(', ') : `${head}: ${rows.join('; ')}`;
|
|
12986
|
+
}
|
|
12987
|
+
|
|
12988
|
+
/* ---- the look: the class type's, the classifiers drawn by SHAPE.profiled, which is the classifier with the node's own
|
|
12989
|
+
accessible name (the classifier writes one from its compartments, which here are a stereotype's tags) ---- */
|
|
12990
|
+
SHAPE.profiled = { ...SHAPE.classifier, draw: (n, c, f) => SHAPE.classifier.draw(n, c, f).replace(/aria-label="[^"]*"/, () => `aria-label="${esc(n.aria)}"`) };
|
|
12991
|
+
const row = text => ({ type: 'uml.Property', label: text }); // a compartment row as the classifier prints it: its text alone
|
|
12992
|
+
function prNode(d, n, model, pic) {
|
|
12993
|
+
if (d.shape === 'note') return d;
|
|
12994
|
+
const I = prIndex(model), aria = prAria(model, n, pic.name(n.id), I);
|
|
12995
|
+
if (d.shape !== 'classifier') return { ...d, aria };
|
|
12996
|
+
if (n.type === 'uml.Stereotype') return { ...d, shape: 'profiled', aria, compartments: d.compartments.filter(c => c.name === 'attributes') };
|
|
12997
|
+
const tags = appliedOf(model, n.id).map(e => ({ name: `«${e.key}»`, members: [row(`«${e.key}»`), ...prTags(e).map(row)] })), has = m => PR_OBJ(m) && appliedOf(model, m.id).length > 0;
|
|
12998
|
+
const mrow = m => { const A = appliedOf(model, m.id), t = A.flatMap(prTags); return row(`${A.map(e => `«${e.key}»`).join(' ')} ${printMember(m)}${t.length ? ` {${t.join(', ')}}` : ''}`); };
|
|
12999
|
+
// a metaclass and an enumeration keep their own compartments (none; the literals), the tag compartments over them
|
|
13000
|
+
if (n.type === 'uml.MetaclassReference' || n.type === 'uml.Enumeration') return { ...d, shape: 'profiled', aria, compartments: [...tags, ...d.compartments.map(c => ({ ...c, members: c.members.map(m => (has(m) ? mrow(m) : m)) }))] };
|
|
13001
|
+
const ms = PR_ARR(n.members).filter(has).map(mrow);
|
|
13002
|
+
return { ...d, shape: 'profiled', aria, compartments: [...tags, ...(ms.length ? [{ name: 'members', members: ms }] : [])] };
|
|
13003
|
+
}
|
|
13004
|
+
// an open package's or profile's box: over its name the stereotypes it has, the required ones too; its tags in its accessible name
|
|
13005
|
+
function prBox(b, model, pic) {
|
|
13006
|
+
const x = pic.at.get(b.id), A = appliedOf(model, b.id); if (!A.length) return b;
|
|
13007
|
+
const st = PR_ARR(PR_OBJ(x.props) ? x.props.stereotypes : null).filter(t => t != null && t !== '').map(String), all = [...st, ...A.map(e => e.key).filter(k => !st.includes(k))];
|
|
13008
|
+
return { ...b, title: [...(x.type === 'uml.Profile' ? ['«profile»'] : []), ...all.map(t => `«${t}»`), pic.name(b.id)].join(' '), aria: `${b.aria}: ${A.map(prSay).join('; ')}` };
|
|
13009
|
+
}
|
|
13010
|
+
const PR_LOOK = { members: true, own: null, word: 'profile diagram', node: prNode, box: prBox, dg: { repair: false, reroute: false } };
|
|
13011
|
+
const prGeo0 = typeGeo(PR_LOOK);
|
|
13012
|
+
// down unless the view says right; slots at each node and box, so the palette's add hears what is selected (composite.js's)
|
|
13013
|
+
function prGeo(a) {
|
|
13014
|
+
const view = a.view.direction ? a.view : { ...a.view, direction: 'down' }, g = prGeo0({ ...a, view }), pic = ownersOn(a.model, a.view);
|
|
13015
|
+
const slots = [...g.nodes, ...PR_ARR(g.families)].map(n => ({ after: n.id, parent: null, x: n.x + n.w / 2, y: n.y + n.h / 2 }));
|
|
13016
|
+
return { ...g, slots, order: [...readingOrder(pic), ...g.edges.map(e => e.id)] }; // a new object: what typeGeo returned is not edited (§2.4)
|
|
13017
|
+
}
|
|
13018
|
+
|
|
13019
|
+
/* ---- validators (§4.2): definitions are errors, a metaclass name a warning. Each problem: { level, rule, path, message, ids } ---- */
|
|
13020
|
+
const PR_RULES = {
|
|
13021
|
+
'profile-owns': 'a stereotype\'s and a metaclass reference\'s owner is a profile',
|
|
13022
|
+
'stereotype-names': 'stereotypes in one profile have different names, the first letter in either case; profiles have different names',
|
|
13023
|
+
'extension-ends': 'an extension runs from a stereotype to a metaclass reference',
|
|
13024
|
+
'application-ends': 'a profile application runs from a package to a profile, not from a profile to itself',
|
|
13025
|
+
'metaclass-known': 'a metaclass reference names a UML metaclass',
|
|
13026
|
+
};
|
|
13027
|
+
function prProblems(model) {
|
|
13028
|
+
const out = [], I = prIndex(model), q = id => JSON.stringify(I.at.has(id) ? prName(I.at.get(id)) : String(id)), say = (rule, path, ids, msg, level = 'error') => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
13029
|
+
const seen = new Map(), names = (k, id, path, what) => { const was = seen.get(k); if (was) say('stereotype-names', path, [was, id], `${q(id)} and ${q(was)} are one name ${what} (${was}, ${id})`); else seen.set(k, id); };
|
|
13030
|
+
PR_ARR(model && model.nodes).forEach((n, i) => { if (!PR_OBJ(n) || I.at.get(n.id) !== n) return; const path = `nodes[${i}]`;
|
|
13031
|
+
if ((n.type === 'uml.Stereotype' || n.type === 'uml.MetaclassReference') && I.ty(n.parent) !== 'uml.Profile') say('profile-owns', path, [n.id, n.parent], `${prWord(n.type).toLowerCase()} ${q(n.id)} is owned by ${n.parent != null && I.at.has(n.parent) ? q(n.parent) + ', not a profile' : 'no profile'}`);
|
|
13032
|
+
if (n.type === 'uml.Stereotype' && I.ty(n.parent) === 'uml.Profile') names(`s\u0000${n.parent}\u0000${styKey(prName(n))}`, n.id, path, `in ${q(n.parent)}`);
|
|
13033
|
+
if (n.type === 'uml.Profile') names(`p\u0000${prName(n)}`, n.id, path, 'for two profiles');
|
|
13034
|
+
if (n.type === 'uml.MetaclassReference' && !metaKnown(prName(n))) say('metaclass-known', path, [n.id], `${q(n.id)} is no UML metaclass`, 'warning'); });
|
|
13035
|
+
PR_ARR(model && model.edges).forEach((e, i) => { if (!PR_OBJ(e)) return; const path = `edges[${i}]`, ends = [...new Set([e.id, e.from, e.to])], k = id => (I.at.has(id) ? prWord(I.ty(id)).toLowerCase() : 'nothing');
|
|
13036
|
+
if (e.type === 'uml.Extension' && (I.ty(e.from) !== 'uml.Stereotype' || I.ty(e.to) !== 'uml.MetaclassReference')) say('extension-ends', path, ends, `${e.id} runs from ${q(e.from)} (${k(e.from)}) to ${q(e.to)} (${k(e.to)}): from a stereotype to a metaclass`);
|
|
13037
|
+
if (e.type === 'uml.ProfileApplication' && (!OWN_BOXES.includes(I.ty(e.from)) || I.ty(e.to) !== 'uml.Profile' || e.from === e.to)) say('application-ends', path, ends, `${e.id} runs from ${q(e.from)} (${k(e.from)}) to ${q(e.to)} (${k(e.to)}): from a package to a profile${e.from === e.to ? ' other than itself' : ''}`); });
|
|
13038
|
+
return out;
|
|
13039
|
+
}
|
|
13040
|
+
|
|
13041
|
+
/* ---- describe(): the view in sentences, each element by its accessible name in reading order, then each line ---- */
|
|
13042
|
+
function prDescribe(model, viewId) {
|
|
13043
|
+
const v = PR_ARR(model && model.views).find(x => PR_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
13044
|
+
const { at, pic, lines: all } = drawnLines(model, v, null), lines = all.filter(({ from, to }) => !holdsLine(pic, from, to)), I = prIndex(model), ids = readingOrder(pic);
|
|
13045
|
+
const pl = (k, w) => `${k} ${k === 1 ? w : w + 's'}`, box = new Map(pic.boxes.map(b => [b.id, prBox(b, model, pic).aria]));
|
|
13046
|
+
const said = id => { const x = at.get(id).x; return box.get(id) ?? (x.type === 'uml.Comment' ? 'Comment: ' + prName(x) : prAria(model, x, pic.name(id), I)); };
|
|
13047
|
+
return [`${v.title || v.id}: a profile diagram of ${pl(ids.length, 'element')} and ${pl(lines.length, 'line')}.`, ...ids.map(id => said(id) + '.'), ...lines.map(({ e }) => lineName(e, at, pic) + '.')].join('\n');
|
|
13048
|
+
}
|
|
13049
|
+
|
|
13050
|
+
/* ---- editing (§4.3) ---- */
|
|
13051
|
+
const prNewId = (model, base) => { const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
13052
|
+
let k = 1; while (used.has(base + k)) k++; return base + k; };
|
|
13053
|
+
// edit distance, for the metaclass names nearest one typed. ponytail: quadratic in the names' lengths, over about 90 names
|
|
13054
|
+
function prDist(a, b) {
|
|
13055
|
+
let row = [...Array(b.length + 1).keys()];
|
|
13056
|
+
for (let i = 1; i <= a.length; i++) { const next = [i]; for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); row = next; }
|
|
13057
|
+
return row[b.length];
|
|
13058
|
+
}
|
|
13059
|
+
// add: a stereotype, a metaclass or an enumeration goes into the profile selected (or the one holding what is selected), else
|
|
13060
|
+
// the view's first; a metaclass not in the table offers the nearest names; anything else is the default
|
|
13061
|
+
function prAdd(model, view, entry, label, at) {
|
|
13062
|
+
if (!PR_DEFS.includes(entry.type)) return null;
|
|
13063
|
+
const I = prIndex(model), prof = id => (I.ty(id) === 'uml.Profile' ? id : I.ty((I.at.get(id) || {}).parent) === 'uml.Profile' ? I.at.get(id).parent : null);
|
|
13064
|
+
const p = PR_ARR(at && at.sel).map(prof).find(x => x) ?? PR_ARR(view.nodeIds).map(id => (I.ty(id) === 'uml.Profile' ? id : null)).find(x => x);
|
|
13065
|
+
if (!p) return { error: 'Add a profile first' };
|
|
13066
|
+
const add = name => [{ op: 'add', element: { id: prNewId(model, { 'uml.Stereotype': 'stereotype', 'uml.MetaclassReference': 'metaclass', 'uml.Enumeration': 'enumeration' }[entry.type]), type: entry.type, ...(name ? { label: name } : {}), parent: p }, view: view.id }];
|
|
13067
|
+
const nm = String(label ?? ''), lo = nm.toLowerCase(); // a metaclass with no name is added unnamed (metaclass-known then warns)
|
|
13068
|
+
if (entry.type !== 'uml.MetaclassReference' || !nm || metaKnown(nm)) return add(nm);
|
|
13069
|
+
const near = Object.keys(UML_META).sort((a, b) => prDist(lo, a.toLowerCase()) - prDist(lo, b.toLowerCase()) || a.localeCompare(b)).slice(0, 6);
|
|
13070
|
+
return { pick: near, then: i => add(near[i]) };
|
|
13071
|
+
}
|
|
13072
|
+
// connect: an extension from a stereotype to a metaclass, an application from a package to a profile, a generalization
|
|
13073
|
+
// between stereotypes; else said, the rule's words
|
|
13074
|
+
function prConnect(model, view, from, to, at, line) {
|
|
13075
|
+
const I = prIndex(model), t = PR_OBJ(line) ? line.type : null;
|
|
13076
|
+
if (t === 'uml.Extension' && (I.ty(from) !== 'uml.Stereotype' || I.ty(to) !== 'uml.MetaclassReference')) return { error: 'An extension runs from a stereotype to a metaclass' };
|
|
13077
|
+
if (t === 'uml.ProfileApplication' && (!OWN_BOXES.includes(I.ty(from)) || I.ty(to) !== 'uml.Profile' || from === to)) return { error: `A profile application runs from a package to a profile${from === to ? ' other than itself' : ''}` };
|
|
13078
|
+
if (t === 'uml.Generalization' && (I.ty(from) !== 'uml.Stereotype' || I.ty(to) !== 'uml.Stereotype')) return { error: 'A generalization here joins two stereotypes' };
|
|
13079
|
+
return null;
|
|
13080
|
+
}
|
|
13081
|
+
|
|
13082
|
+
/* ---- the type ---- */
|
|
13083
|
+
const PR_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
13084
|
+
const PR_TYPE = registerDiagramType('uml.profile', {
|
|
13085
|
+
title: 'profile diagram',
|
|
13086
|
+
elements: [['Profile', 'Profile'], ['Stereotype', 'Stereotype'], ['MetaclassReference', 'Metaclass'], ['Enumeration', 'Enumeration'], ['Package', 'Package'], ['Comment', 'Comment']].map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
13087
|
+
lines: [['Extension', 'Extension'], ['Generalization', 'Generalization'], ['ProfileApplication', 'Profile application'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
13088
|
+
props: { ...CLASS_DEF.props, 'uml.Profile': [PR_STEREO], 'uml.Stereotype': [{ key: 'isAbstract', label: 'Abstract', kind: 'bool' }], 'uml.MetaclassReference': [],
|
|
13089
|
+
'uml.Extension': [{ key: 'isRequired', label: 'Required', kind: 'bool' }], 'uml.ProfileApplication': [] },
|
|
13090
|
+
// the metaclass ranks first, above the stereotypes that extend it, as a general is above its specializations (§4.1)
|
|
13091
|
+
rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.InterfaceRealization' || e.type === 'uml.Extension',
|
|
13092
|
+
layout: prGeo,
|
|
13093
|
+
boxes: (model, view) => { const pic = ownersOn(model, view); return pic.boxes.map(b => prBox(b, model, pic)); },
|
|
13094
|
+
owners: ownerChoices,
|
|
13095
|
+
// a stereotype's rows are its tag definitions, in the class type's member syntax; an enumeration's its literals
|
|
13096
|
+
member: { ...CLASS_TYPE.member, holds: n => n.type === 'uml.Stereotype' || n.type === 'uml.Enumeration' },
|
|
13097
|
+
validate: (model, view) => [...classProblems(model), ...prProblems(model), ...ownerProblems(model, view, drawnLines(model, view, null).lines)],
|
|
13098
|
+
rules: { ...RULES, ...OWNER_RULES, ...PR_RULES, ...STY_RULES },
|
|
13099
|
+
describe: prDescribe,
|
|
13100
|
+
// the PDF's tables (§9): a profile's, a stereotype's and a metaclass's kind; each element's stereotypes and tags
|
|
13101
|
+
word: (model, id) => { const x = prIndex(model).at.get(id);
|
|
13102
|
+
return !x ? null : x.type === 'uml.Stereotype' ? (PR_OBJ(x.props) && x.props.isAbstract === true ? 'Stereotype (abstract)' : 'Stereotype') : x.type === 'uml.MetaclassReference' ? 'Metaclass' : x.type === 'uml.Profile' ? 'Profile' : null; },
|
|
13103
|
+
rows: (model, view, id) => { const I = prIndex(model), x = I.at.get(id); if (!x) return null; const r = prRows(model, x, I); return r.length || PR_DEFS.slice(0, 2).includes(x.type) ? r : null; },
|
|
13104
|
+
edits: { add: prAdd, connect: prConnect },
|
|
13105
|
+
});
|
|
13106
|
+
/* ---------- the time ruler (work package 6.4, notes/stage-6.md §6) ----------
|
|
13107
|
+
layoutTimeRuler(input) -> out, the geometry of a timing view: no DOM, no model, in layoutTimeAxis's contract (sizes come
|
|
13108
|
+
in measured by the type, problems go out, never warn(); slots for the gestures). Time here is a quantity, not an order:
|
|
13109
|
+
x is linear in it, one base for every lane (§6.3). Only lib/umlimator-all.js holds this file.
|
|
13110
|
+
|
|
13111
|
+
input = {
|
|
13112
|
+
lanes: [{ id, name: { w, h }, form: 'states' | 'values', rows: [{ state, w }], lifelines, top to bottom; rows: the
|
|
13113
|
+
changes: [{ id, at, state, w }] }], states, top first (values: []); a
|
|
13114
|
+
change's w: its text's width
|
|
13115
|
+
messages: [{ id, from, to, sentAt, receivedAt, w, h }], from, to: lane ids; w, h: the label
|
|
13116
|
+
constraints: [{ id, kind: 'duration' | 'time', from, to, w, h }], from, to: change ids (time: from only)
|
|
13117
|
+
unit, text or null
|
|
13118
|
+
}
|
|
13119
|
+
out = { lanes: [{ id, x, y, w, h, name: { x, y }, rows: [{ state, y }], strip: { y, h } }],
|
|
13120
|
+
steps: [{ lane, pts }], bands: [{ lane, segs: [{ x0, x1, text, cut }] }], changes: [{ id, x, y, w, h }],
|
|
13121
|
+
lines: [{ id, pts, lbl }], marks: [{ id, kind, pts, lbl }],
|
|
13122
|
+
ruler: { y, x0, x1, ticks: [{ t, x, major, label }], unit }, slots: [{ index, x, y, parent, after, t }],
|
|
13123
|
+
scale: { t0, t1, k, step }, bounds, problems }
|
|
13124
|
+
A lane's rows: its states', then one per state its changes name that the lane does not list (drawn at the bottom; the
|
|
13125
|
+
type's change-state says it). Changes are drawn by `at`; two on one lane at one instant: the one whose id sorts last
|
|
13126
|
+
(the type's change-instant says it); the other gets no hit box, and a constraint on it is on the one drawn. A step
|
|
13127
|
+
line starts at a lane's first change; a band segment's text is null under 16 px, and `cut` when the text (cut at
|
|
13128
|
+
160 px) and 12 px do not fit it: the type fits it to x1 - x0 - 12. A duration mark's pts run up the extension line at
|
|
13129
|
+
its earlier change, along the dimension line, up the other: [a, b, c, d], the arrows on b and c, its label on the
|
|
13130
|
+
line; a time mark's are its 6 px tick, its label to the right, bottoms level. A strip's level is 20 px, or its tallest
|
|
13131
|
+
label and 4 px. Ticks start at the first minor tick at or after t0. Slots: on each lane one at every minor tick (a
|
|
13132
|
+
fifth of the step); index: how many of the lane's drawn changes are at or before t, after: the last of them.
|
|
13133
|
+
Problems: { level, rule, message, ids }: timing-dense (the plot at its 4,800 px cap and changes still under 12 px
|
|
13134
|
+
apart, or values cut), timing-label (a message label that meets a step, a value or a label within its sender's lane's
|
|
13135
|
+
height), and an item not drawn (a time past ±1e300, an end that is not here, a constraint kind not known). */
|
|
13136
|
+
|
|
13137
|
+
const TR = {
|
|
13138
|
+
edge: 16, pad: 8, cut: 160, // the margin; a column's padding each side; the widest name and value text
|
|
13139
|
+
row: 24, above: 12, band: 24, // a state row's pitch; above the first row and below the last; a value band's height
|
|
13140
|
+
gap: 12, room: 12, bare: 16, // two changes' least distance; a value's room in its segment; a segment too short for text
|
|
13141
|
+
plot: 480, max: 4800, tick: 64, font: 10, // the plot's least and greatest width; majors' least distance; their labels' size
|
|
13142
|
+
lift: 4, level: 20, hit: 12, mark: 6, ruler: 28, // a label over its line, and each move; a strip's level; a change's hit width; a tick; the ruler's height
|
|
13143
|
+
};
|
|
13144
|
+
|
|
13145
|
+
const trArr = x => (Array.isArray(x) ? x : []);
|
|
13146
|
+
const trNum = x => (Number.isFinite(x) ? x : 0);
|
|
13147
|
+
const trTime = t => Number.isFinite(t) && Math.abs(t) <= 1e300; // past it the span, a step or t1 + a step is no number
|
|
13148
|
+
const trNice = x => Number(x.toPrecision(12));
|
|
13149
|
+
const trFmt = (t, s) => (Math.round(t / s) * s).toFixed(Math.max(0, -Math.floor(Math.log10(s) + 1e-9)));
|
|
13150
|
+
const trMeets = (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;
|
|
13151
|
+
|
|
13152
|
+
function layoutTimeRuler(input = {}) {
|
|
13153
|
+
const problems = [], say = (level, rule, message, ids) => problems.push({ level, rule, message, ids });
|
|
13154
|
+
const unit = input.unit == null || input.unit === '' ? null : String(input.unit);
|
|
13155
|
+
|
|
13156
|
+
// ---- each lane's changes in time order, one per instant (§7.2's change-instant): the id that sorts last
|
|
13157
|
+
const byId = new Map(), lanes = trArr(input.lanes).map((ln, j) => {
|
|
13158
|
+
const all = trArr(ln.changes).filter(c => trTime(c.at) || (say('error', 'change-time', `change ${c.id}: its time is not a number within ±1e300: not drawn`, [c.id]), false))
|
|
13159
|
+
.sort((a, b) => a.at - b.at || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
|
|
13160
|
+
const cs = all.filter((c, i) => !(all[i + 1] && all[i + 1].at === c.at)), L = { ln, j, form: ln.form === 'values' ? 'values' : 'states', cs };
|
|
13161
|
+
for (let i = all.length - 1, d; i >= 0; i--) { if (!all[i + 1] || all[i + 1].at !== all[i].at) d = all[i]; byId.set(all[i].id, { L, c: d }); } // a skipped id: the change drawn at its instant
|
|
13162
|
+
return L; });
|
|
13163
|
+
const laneOf = new Map(lanes.map(L => [L.ln.id, L]));
|
|
13164
|
+
const msgs = trArr(input.messages).filter(m => {
|
|
13165
|
+
if (!laneOf.has(m.from) || !laneOf.has(m.to)) say('error', 'message-ends', `message ${m.id}: ${laneOf.has(m.from) ? m.to : m.from} is not a lifeline here: not drawn`, [m.id]);
|
|
13166
|
+
else if (!trTime(m.sentAt) || !trTime(m.receivedAt)) say('error', 'change-time', `message ${m.id}: its times are not numbers within ±1e300: not drawn`, [m.id]);
|
|
13167
|
+
else return true;
|
|
13168
|
+
return false; });
|
|
13169
|
+
|
|
13170
|
+
// ---- the scale (§6.3): k the least px per unit that keeps changes 12 px apart, values in their segments and the plot
|
|
13171
|
+
// 480 px wide, capped at 4,800; t1 the latest time and half a step; the step the least 1, 2 or 5 × 10ⁿ 64 px apart
|
|
13172
|
+
let t0 = 0, tMax = 0;
|
|
13173
|
+
const time = t => { if (t < t0) t0 = t; if (t > tMax) tMax = t; };
|
|
13174
|
+
lanes.forEach(L => L.cs.forEach(c => time(c.at))); msgs.forEach(m => { time(m.sentAt); time(m.receivedAt); });
|
|
13175
|
+
const vw = c => Math.min(TR.cut, trNum(c.w)) + TR.room, top = tMax - t0 >= 1e-90 ? tMax : t0 + 1; // all at one instant (or within 1e-90, past toFixed's digits): one unit
|
|
13176
|
+
let need = 0;
|
|
13177
|
+
lanes.forEach(L => L.cs.forEach((c, i) => { const n = L.cs[i + 1]; if (n) need = Math.max(need, TR.gap / (n.at - c.at), L.form === 'values' ? vw(c) / (n.at - c.at) : 0); }));
|
|
13178
|
+
const stepOf = k => { const wide = s => Math.max(textW(trFmt(t0, s), TR.font), textW(trFmt(tMax + s, s), TR.font)) + TR.gap;
|
|
13179
|
+
for (let n = Math.floor(Math.log10(TR.tick / k)) - 1; ; n++) for (const m of [1, 2, 5]) { const s = trNice(m * 10 ** n); if (s * k >= TR.tick && s * k >= wide(s)) return s; } };
|
|
13180
|
+
const endOf = (k, s) => Math.max(top, tMax + s / 2, ...lanes.filter(L => L.form === 'values' && L.cs.length).map(L => { const c = L.cs[L.cs.length - 1]; return c.at + vw(c) / k; }));
|
|
13181
|
+
const kOf = t1 => Math.min(Math.max(need, TR.plot / (t1 - t0)), TR.max / (t1 - t0));
|
|
13182
|
+
let k = kOf(top), step, t1;
|
|
13183
|
+
for (let n = 0; n < 40; n++) { step = stepOf(k); t1 = endOf(k, step); const kk = kOf(t1); if (kk === k) break; k = kk; }
|
|
13184
|
+
step = stepOf(k); t1 = endOf(k, step);
|
|
13185
|
+
if ((t1 - t0) * k > TR.max) { k = TR.max / (t1 - t0); step = stepOf(k); } // not settled in 40 rounds: the cap holds
|
|
13186
|
+
const name = Math.min(TR.cut, Math.max(0, ...lanes.map(L => trNum((L.ln.name || {}).w)))) + 2 * TR.pad;
|
|
13187
|
+
|
|
13188
|
+
// ---- each lane's rows, height, and the strip its constraints need
|
|
13189
|
+
lanes.forEach(L => {
|
|
13190
|
+
L.rows = L.form === 'values' ? [] : trArr(L.ln.rows).map(r => ({ state: r.state, w: trNum(r.w) }));
|
|
13191
|
+
const listed = new Set(L.rows.map(r => r.state));
|
|
13192
|
+
L.cs.forEach(c => { if (L.form === 'states' && !listed.has(c.state)) { listed.add(c.state); L.rows.push({ state: c.state, w: trNum(c.w) }); } });
|
|
13193
|
+
const nh = trNum((L.ln.name || {}).h) + 2 * TR.pad;
|
|
13194
|
+
L.h = Math.max(nh, L.form === 'values' ? TR.band + 2 * TR.above : Math.max(1, L.rows.length) * TR.row);
|
|
13195
|
+
L.levels = []; L.lh = []; L.marks = []; });
|
|
13196
|
+
const states = Math.max(0, ...lanes.flatMap(L => L.rows.map(r => r.w)));
|
|
13197
|
+
const X0 = TR.edge + name + (states ? states + 2 * TR.pad : 0), xOf = t => X0 + (t - t0) * k, X1 = xOf(t1);
|
|
13198
|
+
|
|
13199
|
+
const marks = trArr(input.constraints).filter(c => {
|
|
13200
|
+
if (c.kind !== 'time' && c.kind !== 'duration') { say('error', 'constraint-kind', `constraint ${c.id}: its kind ${c.kind} is not duration or time: not drawn`, [c.id]); return false; }
|
|
13201
|
+
const a = byId.get(c.from), b = c.kind === 'time' ? a : byId.get(c.to);
|
|
13202
|
+
if (!a || !b) { say('error', 'constraint-ends', `constraint ${c.id}: ${a ? c.to : c.from} is not a change here: not drawn`, [c.id]); return false; }
|
|
13203
|
+
const lo = a.c.at <= b.c.at ? a : b, hi = lo === a ? b : a, L = a.L.j >= b.L.j ? a.L : b.L, w = trNum(c.w), h = trNum(c.h);
|
|
13204
|
+
const xa = xOf(lo.c.at), xb = xOf(hi.c.at), cx = (xa + xb) / 2;
|
|
13205
|
+
const it = c.kind === 'time' ? { c, a, x0: xa - TR.mark / 2, x1: xa + TR.mark / 2 + TR.lift + w, w, h } : { c, a: lo, b: hi, x0: Math.min(xa, cx - w / 2), x1: Math.max(xb, cx + w / 2), w, h };
|
|
13206
|
+
L.marks.push(it); return true; });
|
|
13207
|
+
lanes.forEach(L => L.marks.slice().sort((p, q) => p.x0 - q.x0 || (String(p.c.id) < String(q.c.id) ? -1 : 1)).forEach(it => { // greedy, by start
|
|
13208
|
+
let v = L.levels.findIndex(end => end + TR.mark <= it.x0); if (v < 0) { v = L.levels.length; L.levels.push(0); }
|
|
13209
|
+
L.levels[v] = it.x1; L.lh[v] = Math.max(L.lh[v] || TR.level, trNum(it.h) + TR.lift); it.v = v; })); // a level as tall as its labels, 20 px at least
|
|
13210
|
+
|
|
13211
|
+
// ---- down the page: lanes, their strips, the ruler
|
|
13212
|
+
let y = TR.edge;
|
|
13213
|
+
lanes.forEach(L => { L.y = y; L.mid = y + L.h / 2; y += L.h; L.sy = y;
|
|
13214
|
+
L.top = L.lh.map((h, v) => L.lh.slice(0, v).reduce((s, x) => s + x, 0)); L.sh = L.lh.length ? L.lh.reduce((s, x) => s + x, 0) + TR.lift : 0; y += L.sh; });
|
|
13215
|
+
const rulerY = y + TR.pad;
|
|
13216
|
+
const rowY = (L, s) => L.y + TR.above + L.rows.findIndex(r => r.state === s) * TR.row;
|
|
13217
|
+
const lvl = (L, c) => (L.form === 'values' ? L.mid : rowY(L, c.state));
|
|
13218
|
+
const levelAt = (L, t) => { let c = null; for (const d of L.cs) { if (d.at > t) break; c = d; } return c ? lvl(L, c) : L.mid; };
|
|
13219
|
+
|
|
13220
|
+
// ---- steps, bands, the changes' hit boxes
|
|
13221
|
+
const steps = [], bands = [], changes = [], obst = [], dense = new Set();
|
|
13222
|
+
lanes.forEach(L => {
|
|
13223
|
+
const pts = [], segs = [], cs = L.cs;
|
|
13224
|
+
cs.forEach((c, i) => { const x = xOf(c.at), yc = lvl(L, c), yp = i ? lvl(L, cs[i - 1]) : yc, n = cs[i + 1];
|
|
13225
|
+
if (n && (n.at - c.at) * k < TR.gap - 1e-6) dense.add(n.id);
|
|
13226
|
+
if (L.form === 'values') { const x1 = n ? xOf(n.at) : X1, cut = x1 - x < vw(c) - 1e-6;
|
|
13227
|
+
if (cut) dense.add(c.id);
|
|
13228
|
+
segs.push({ x0: x, x1, text: x1 - x < TR.bare ? null : c.state ?? '', cut: cut || trNum(c.w) > TR.cut });
|
|
13229
|
+
if (x1 - x >= TR.bare) { const tw = Math.min(TR.cut, trNum(c.w), x1 - x - TR.room); obst.push({ x: (x + x1 - tw) / 2, y: L.mid - 7, w: tw, h: 14 }); }
|
|
13230
|
+
changes.push({ id: c.id, x: x - TR.hit / 2, y: L.mid - TR.band / 2, w: TR.hit, h: TR.band }); return; }
|
|
13231
|
+
if (i) pts.push([x, yp]); pts.push([x, yc]);
|
|
13232
|
+
const h = Math.max(TR.hit, Math.abs(yc - yp));
|
|
13233
|
+
changes.push({ id: c.id, x: x - TR.hit / 2, y: (yc + yp) / 2 - h / 2, w: TR.hit, h }); });
|
|
13234
|
+
if (L.form === 'values') { if (segs.length) bands.push({ lane: L.ln.id, segs }); return; }
|
|
13235
|
+
if (!cs.length) return;
|
|
13236
|
+
pts.push([X1, lvl(L, cs[cs.length - 1])]);
|
|
13237
|
+
steps.push({ lane: L.ln.id, pts });
|
|
13238
|
+
pts.slice(1).forEach((q, i) => { const p = pts[i]; obst.push({ x: Math.min(p[0], q[0]), y: Math.min(p[1], q[1]), w: Math.abs(q[0] - p[0]), h: Math.abs(q[1] - p[1]) }); }); });
|
|
13239
|
+
// a duplicate at one instant is not dense: it is skipped, and the type's change-instant says so
|
|
13240
|
+
if (dense.size) say('warning', 'timing-dense', `the ruler is at its ${TR.max} px cap: ${dense.size} change${dense.size > 1 ? 's are' : ' is'} closer than ${TR.gap} px to the one before, or ${dense.size > 1 ? 'have their values' : 'has its value'} cut`, [...dense].sort());
|
|
13241
|
+
|
|
13242
|
+
// ---- constraints in their strips (§6.6), placed before the messages, whose labels then keep off theirs
|
|
13243
|
+
const outMarks = lanes.flatMap(L => L.marks.map(it => {
|
|
13244
|
+
const yl = L.sy + L.top[it.v] + L.lh[it.v] - TR.lift; // the dimension line, or the tick's middle; its label on it
|
|
13245
|
+
if (it.c.kind === 'time') { const x = xOf(it.a.c.at);
|
|
13246
|
+
return { id: it.c.id, kind: 'time', pts: [[x, yl - TR.mark / 2], [x, yl + TR.mark / 2]], lbl: { x: x + TR.mark / 2 + TR.lift, y: yl + TR.mark / 2 - it.h, w: it.w, h: it.h } }; }
|
|
13247
|
+
const xa = xOf(it.a.c.at), xb = xOf(it.b.c.at);
|
|
13248
|
+
return { id: it.c.id, kind: 'duration', pts: [[xa, lvl(it.a.L, it.a.c)], [xa, yl], [xb, yl], [xb, lvl(it.b.L, it.b.c)]], lbl: { x: (xa + xb) / 2 - it.w / 2, y: yl - it.h, w: it.w, h: it.h } }; }))
|
|
13249
|
+
.map(o => ({ ...o, pts: o.pts.map(p => p.map(r2)), lbl: { x: r2(o.lbl.x), y: r2(o.lbl.y), w: r2(o.lbl.w), h: r2(o.lbl.h) } }));
|
|
13250
|
+
outMarks.forEach(o => obst.push(o.lbl));
|
|
13251
|
+
|
|
13252
|
+
// ---- messages: straight from the sender's level at sentAt to the receiver's at receivedAt; each label 4 px over its
|
|
13253
|
+
// middle, moved up 4 px at a time, at most its lane's height, until it meets nothing placed before it (§6.5).
|
|
13254
|
+
// ponytail: quadratic in labels, with the obstacles filtered by x once per message; a grid of buckets if views grow
|
|
13255
|
+
const lines = msgs.map(m => {
|
|
13256
|
+
const A = laneOf.get(m.from), B = laneOf.get(m.to), p = [xOf(m.sentAt), levelAt(A, m.sentAt)], q = [xOf(m.receivedAt), levelAt(B, m.receivedAt)];
|
|
13257
|
+
const w = trNum(m.w), h = trNum(m.h), lbl = { x: (p[0] + q[0]) / 2 - w / 2, y: (p[1] + q[1]) / 2 - TR.lift - h, w, h }, y0 = lbl.y;
|
|
13258
|
+
const near = obst.filter(o => o.x < lbl.x + w && lbl.x < o.x + o.w), hits = () => near.some(o => trMeets(lbl, o));
|
|
13259
|
+
while (hits() && y0 - lbl.y + TR.lift <= A.h) lbl.y -= TR.lift;
|
|
13260
|
+
if (hits()) { lbl.y = y0; say('warning', 'timing-label', `message ${m.id}: its label meets a step, a value or a label within ${A.h} px above it`, [m.id]); }
|
|
13261
|
+
obst.push(lbl);
|
|
13262
|
+
return { id: m.id, pts: [p, q].map(pt => pt.map(r2)), lbl: { x: r2(lbl.x), y: r2(lbl.y), w: r2(w), h: r2(h) } }; });
|
|
13263
|
+
|
|
13264
|
+
// ---- the ruler and the slots: a tick at every fifth of the step from the first at or after t0
|
|
13265
|
+
const minor = step / 5, ticks = [], slots = [];
|
|
13266
|
+
for (let i = Math.ceil(t0 / minor - 1e-9), e = Math.floor(t1 / minor + 1e-9); i <= e; i++) { const t = trNice(i * minor), major = i % 5 === 0;
|
|
13267
|
+
ticks.push({ t, x: r2(xOf(t)), major, label: major ? trFmt(t, step) : null }); }
|
|
13268
|
+
lanes.forEach(L => { let n = 0;
|
|
13269
|
+
ticks.forEach(tk => { while (n < L.cs.length && L.cs[n].at <= tk.t) n++;
|
|
13270
|
+
slots.push({ index: n, x: tk.x, y: r2(L.mid), parent: L.ln.id, after: n ? L.cs[n - 1].id : null, t: tk.t }); }); });
|
|
13271
|
+
|
|
13272
|
+
// ---- out
|
|
13273
|
+
const outLanes = lanes.map(L => ({ id: L.ln.id, x: TR.edge, y: r2(L.y), w: r2(X1 - TR.edge), h: r2(L.h),
|
|
13274
|
+
name: { x: TR.edge + TR.pad, y: r2(L.y + (L.h - trNum((L.ln.name || {}).h)) / 2) }, rows: L.rows.map(r => ({ state: r.state, y: r2(rowY(L, r.state)) })),
|
|
13275
|
+
strip: { y: r2(L.sy), h: r2(L.sh) } }));
|
|
13276
|
+
const last = ticks.filter(t => t.major).pop(), right = X1 + (last ? textW(last.label, TR.font) / 2 : 0) + (unit ? TR.lift + textW(unit, TR.font) : 0);
|
|
13277
|
+
const boxes = [...lines.map(l => l.lbl), ...outMarks.map(o => o.lbl)];
|
|
13278
|
+
const xs = [right, X1, ...boxes.flatMap(b => [b.x, b.x + b.w])], ys = [rulerY + TR.ruler, ...boxes.flatMap(b => [b.y, b.y + b.h])];
|
|
13279
|
+
const bx0 = Math.min(0, ...xs.map(v => v - TR.edge)), by0 = Math.min(0, ...ys.map(v => v - TR.edge));
|
|
13280
|
+
return {
|
|
13281
|
+
lanes: outLanes, steps: steps.map(s => ({ lane: s.lane, pts: s.pts.map(p => p.map(r2)) })),
|
|
13282
|
+
bands: bands.map(b => ({ lane: b.lane, segs: b.segs.map(s => ({ ...s, x0: r2(s.x0), x1: r2(s.x1) })) })),
|
|
13283
|
+
changes: changes.map(c => ({ id: c.id, x: r2(c.x), y: r2(c.y), w: c.w, h: r2(c.h) })), lines, marks: outMarks,
|
|
13284
|
+
ruler: { y: r2(rulerY), x0: r2(X0), x1: r2(X1), ticks, unit }, slots, scale: { t0, t1: trNice(t1), k, step },
|
|
13285
|
+
bounds: { x: r2(bx0), y: r2(by0), w: r2(Math.max(...xs) + TR.edge - bx0), h: r2(Math.max(...ys) + TR.edge - by0) }, problems,
|
|
13286
|
+
};
|
|
13287
|
+
}
|
|
13288
|
+
/* ---------- the timing diagram, uml.timing (work package 6.5, notes/stage-6.md §7 and §8) ----------
|
|
13289
|
+
Lifelines (nodeIds) are lanes stacked down the page, each drawn in the states form (a step line over its `states`, top to
|
|
13290
|
+
bottom) or, with present.<id>.as "values", the values form (a band crossed at each change, its value inside). The view's
|
|
13291
|
+
`timeline` holds its changes ({ id, lifeline, at, state }, drawn by `at`) and its time and duration constraints; its
|
|
13292
|
+
inline uml.Message lines are drawn from the sender's level at sentAt to the receiver's at receivedAt. The geometry is
|
|
13293
|
+
src/layout/timeruler.js's; this file measures the view into its input and turns its output into svgOf's geometry: lanes,
|
|
13294
|
+
state rows, changes, constraints and the ruler as nodes (a change, a constraint: their own ids; a state row
|
|
13295
|
+
`<lifeline>-><state>` and the ruler `->ruler`: geometry ids no element has, notes/stage-5.md §6), messages as lines, steps
|
|
13296
|
+
and bands in the type's `under` layer. Then the validators, describe(), the legend, the PDF's rows and the gesture hooks
|
|
13297
|
+
of §8, which write a time item by `set` of the view's timeline path. Ships in lib/umlimator-all.js only. */
|
|
13298
|
+
|
|
13299
|
+
const TM_ARR = x => (Array.isArray(x) ? x : []), TM_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
13300
|
+
const TM_HAS = x => x != null && x !== '';
|
|
13301
|
+
const TM = { name: 12, text: 10.5, lbl: 16, mark: 14 }; // a lane's name, a state's or a value's text; a message's label's and a constraint's height
|
|
13302
|
+
const TM_SORTS = ['call', 'async', 'reply'];
|
|
13303
|
+
// the sequence type's ends for these sorts (sequence.js's SQ_END): a call filled, async open, a reply dashed and open
|
|
13304
|
+
const TM_END = { call: { from: 'none', to: 'arrow' }, async: { from: 'none', to: 'open' }, reply: { from: 'none', to: 'open', dashed: true } };
|
|
13305
|
+
const TM_KIND = { 'uml.DurationConstraint': 'duration', 'uml.TimeConstraint': 'time' };
|
|
13306
|
+
const TM_RULER = '->ruler', tmRowId = (lane, state) => `${lane}->${state}`; // geometry ids: no element id holds '->' (model.md rule 5)
|
|
13307
|
+
|
|
13308
|
+
const tmView = (model, view) => (TM_OBJ(view) ? view : TM_ARR(model && model.views).find(v => TM_OBJ(v) && v.id === view)) || null;
|
|
13309
|
+
const tmProps = x => (TM_OBJ(x && x.props) ? x.props : {});
|
|
13310
|
+
const tmName = n => String(n.label ?? n.id);
|
|
13311
|
+
const tmHead = n => tmName(n) + (TM_HAS(tmProps(n).represents) ? ' : ' + tmProps(n).represents : ''); // "worker : OrderWorker"
|
|
13312
|
+
const tmStates = n => [...new Set(TM_ARR(tmProps(n).states).filter(TM_HAS).map(String))]; // a state listed twice is one row
|
|
13313
|
+
const tmNameType = s => { const [name, ...rest] = s.split(/\s*:\s*/); return [name, rest.join(':').trim()]; }; // `a : B : C` is a, represents B:C
|
|
13314
|
+
const tmLabel = x => (TM_HAS(x.label) ? String(x.label) : '');
|
|
13315
|
+
const tmPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
13316
|
+
|
|
13317
|
+
/* The view read once: its lanes (nodeIds that are nodes), every node by id, the timeline's changes and constraints, its
|
|
13318
|
+
messages, the unit, and each lane's form; shown: the constraints drawn (one on a hidden lifeline's change is not, §7.2) */
|
|
13319
|
+
function tmIndex(model, V) {
|
|
13320
|
+
const N = new Map(); [...TM_ARR(V.nodes), ...TM_ARR(model && model.nodes)].forEach(n => { if (TM_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) N.set(n.id, n); });
|
|
13321
|
+
const TL = TM_OBJ(V.timeline) ? V.timeline : {}, item = x => TM_OBJ(x) && typeof x.id === 'string';
|
|
13322
|
+
const changes = TM_ARR(TL.changes).filter(item), cons = TM_ARR(TL.constraints).filter(item), msgs = TM_ARR(V.edges).filter(e => item(e) && e.type === 'uml.Message');
|
|
13323
|
+
const heads = TM_ARR(V.nodeIds).filter(id => N.has(id) && N.get(id).type === 'uml.Lifeline').map(id => N.get(id)), P = TM_OBJ(V.present) ? V.present : {};
|
|
13324
|
+
const unit = TM_HAS(TL.unit) ? String(TL.unit) : null, t = x => `${x}${unit ? ' ' + unit : ''}`;
|
|
13325
|
+
const name = id => (N.has(id) ? tmName(N.get(id)) : String(id)), C = new Map(changes.map(c => [c.id, c]));
|
|
13326
|
+
const H = new Map(heads.map(n => [n.id, n])), hid = id => C.has(id) && !H.has(C.get(id).lifeline); // a change of a hidden lifeline
|
|
13327
|
+
return { N, H, heads, changes, C, cons, shown: cons.filter(c => !hid(c.from) && !hid(c.to)), msgs, unit, t, name, values: id => TM_OBJ(P[id]) && P[id].as === 'values',
|
|
13328
|
+
mine: id => changes.filter(c => c.lifeline === id).sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : 1)) };
|
|
13329
|
+
}
|
|
13330
|
+
|
|
13331
|
+
/* ---- names (§9): a change's, a message's, a constraint's, said the same in the picture, describe() and the PDF ---- */
|
|
13332
|
+
const tmSort = m => (TM_SORTS.includes(tmProps(m).sort) ? tmProps(m).sort : 'call');
|
|
13333
|
+
const tmChangeSaid = (X, c) => `${X.name(c.lifeline)}: ${c.state ?? ''} from ${X.t(c.at)}`;
|
|
13334
|
+
const tmMsgSaid = (X, m) => { const p = tmProps(m), t = tmLabel(m), s = tmSort(m);
|
|
13335
|
+
return `${s[0].toUpperCase() + s.slice(1)}${t ? ' ' + t : ''} from ${X.name(m.from)} at ${X.t(p.sentAt)} to ${X.name(m.to)} at ${X.t(p.receivedAt)}`; };
|
|
13336
|
+
const tmConSaid = (X, c) => { const a = X.C.get(c.from), b = X.C.get(c.to), t = tmLabel(c);
|
|
13337
|
+
if (TM_KIND[c.type] === 'time') return `Time constraint${t ? ' ' + t : ''}${a ? ` on ${X.name(a.lifeline)} at ${X.t(a.at)}` : ''}`;
|
|
13338
|
+
const [p, q] = a && b && b.at < a.at ? [b, a] : [a, b];
|
|
13339
|
+
return `Duration constraint${t ? ' ' + t : ''}${p && q ? ` on ${X.name(p.lifeline)}${q.lifeline !== p.lifeline ? ` and ${X.name(q.lifeline)}` : ''} from ${X.t(p.at)} to ${X.t(q.at)}` : ''}`; };
|
|
13340
|
+
|
|
13341
|
+
/* ---- F2's micro-syntax (§8.1): `text`, `text @ t`, `text @ t1 → t2` (or ->); times are numbers in the view's unit. A
|
|
13342
|
+
text whose part after its last @ is not one or two numbers is all text (an address with @ is a name) ---- */
|
|
13343
|
+
function tmParse(text) {
|
|
13344
|
+
const s = String(text ?? '').trim(), m = /^(.*?)\s*@\s*([^@]*)$/.exec(s);
|
|
13345
|
+
const ts = m ? m[2].split(/\s*(?:→|->)\s*/).map(x => x.trim()) : [];
|
|
13346
|
+
if (!m || ts.length > 2 || ts.some(x => !/^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(x) || !Number.isFinite(Number(x)))) return { text: s, times: [] };
|
|
13347
|
+
return { text: m[1].trim(), times: ts.map(Number) };
|
|
13348
|
+
}
|
|
13349
|
+
const tmPrint = (text, ...times) => `${text ?? ''}${times.length ? ' @ ' + times.join(' → ') : ''}`;
|
|
13350
|
+
|
|
13351
|
+
/* ---- layout: the view as layoutTimeRuler's input (§6.2), measured with textW in the host's measure ---- */
|
|
13352
|
+
function tmInput(model, V, X = tmIndex(model, V)) {
|
|
13353
|
+
const w = (t, px = TM.text) => Math.ceil(textW(String(t ?? ''), px));
|
|
13354
|
+
return {
|
|
13355
|
+
lanes: X.heads.map(n => { const vals = X.values(n.id);
|
|
13356
|
+
return { id: n.id, name: { w: w(tmHead(n), TM.name), h: TM.lbl }, form: vals ? 'values' : 'states', rows: vals ? [] : tmStates(n).map(s => ({ state: s, w: w(s) })),
|
|
13357
|
+
changes: X.changes.filter(c => c.lifeline === n.id).map(c => ({ id: c.id, at: c.at, state: String(c.state ?? ''), w: w(c.state) })) }; }),
|
|
13358
|
+
messages: X.msgs.map(m => { const t = tmLabel(m), p = tmProps(m);
|
|
13359
|
+
return { id: m.id, from: m.from, to: m.to, sentAt: p.sentAt, receivedAt: p.receivedAt, ...(t ? { w: w(t) + 12, h: TM.lbl } : { w: 0, h: 0 }) }; }),
|
|
13360
|
+
constraints: X.shown.map(c => { const t = tmLabel(c); return { id: c.id, kind: TM_KIND[c.type] ?? c.type, from: c.from, to: c.to, ...(t ? { w: w(t) + 6, h: TM.mark } : { w: 0, h: 0 }) }; }),
|
|
13361
|
+
unit: X.unit,
|
|
13362
|
+
};
|
|
13363
|
+
}
|
|
13364
|
+
|
|
13365
|
+
/* run(model, view): svgOf's geometry. Nodes: each lane (the lifeline, its name in the left column), its state rows, its
|
|
13366
|
+
changes' hit boxes, the constraints (their marks and labels) and the ruler; edges: the messages; tr: the ruler's output,
|
|
13367
|
+
for draw(); slots and problems from it; order (§9): lanes top to bottom, each with its state rows and its changes in time
|
|
13368
|
+
order, then messages by sentAt, then constraints, then the ruler */
|
|
13369
|
+
function tmRun(model, view) {
|
|
13370
|
+
const V = tmView(model, view), X = tmIndex(model, V), o = layoutTimeRuler(tmInput(model, V, X)), x0 = o.ruler.x0;
|
|
13371
|
+
const nodes = [], order = [];
|
|
13372
|
+
o.lanes.forEach(l => { const n = X.N.get(l.id), vals = X.values(l.id), cs = X.mine(l.id);
|
|
13373
|
+
nodes.push({ id: l.id, shape: 'tm-lane', type: 'uml.Lifeline', label: tmHead(n), k: 'file', holder: true, x: l.x, y: l.y, w: l.w, h: l.h, nx: l.name.x, ny: l.name.y,
|
|
13374
|
+
aria: `${vals ? 'Value lifeline' : 'Lifeline'} ${tmHead(n)}${vals ? '' : `, ${tmPlural(l.rows.length, 'state')}`}, ${tmPlural(cs.length, 'change')}` });
|
|
13375
|
+
order.push(l.id);
|
|
13376
|
+
l.rows.forEach(r => { const w = Math.ceil(textW(r.state, TM.text)), id = tmRowId(l.id, r.state);
|
|
13377
|
+
nodes.push({ id, shape: 'tm-row', label: r.state, k: 'file', x: r2(x0 - TR.pad - w), y: r2(r.y - 8), w, h: 16, aria: `State ${r.state} of ${tmName(n)}` }); order.push(id); }); });
|
|
13378
|
+
o.changes.forEach(h => { const c = X.C.get(h.id); nodes.push({ ...h, shape: 'tm-change', label: tmPrint(c.state, c.at), k: 'file', aria: tmChangeSaid(X, c) }); });
|
|
13379
|
+
o.lanes.forEach(l => { const at = o.changes.filter(h => X.C.get(h.id).lifeline === l.id).sort((a, b) => a.x - b.x).map(h => h.id); order.splice(order.indexOf(l.id) + 1 + l.rows.length, 0, ...at); });
|
|
13380
|
+
const byCon = new Map(X.cons.map(c => [c.id, c]));
|
|
13381
|
+
o.marks.forEach(mk => { const c = byCon.get(mk.id), b = mk.lbl.w ? mk.lbl : { x: mk.pts[1][0] - 6, y: mk.pts[1][1] - 6, w: 12, h: 12 }; // a label-less one: round its line's middle
|
|
13382
|
+
nodes.push({ id: c.id, shape: 'tm-mark', kind: mk.kind, pts: mk.pts, lbl: mk.lbl, label: tmLabel(c), k: 'file', x: b.x, y: b.y, w: b.w, h: b.h, aria: tmConSaid(X, c) }); });
|
|
13383
|
+
const R = o.ruler, rw = Math.max(R.x1 - x0, 1), s = o.scale, M = new Map(X.msgs.map(m => [m.id, m]));
|
|
13384
|
+
const last = [...o.changes.map(h => X.C.get(h.id).at), ...o.lines.flatMap(l => [tmProps(M.get(l.id)).sentAt, tmProps(M.get(l.id)).receivedAt])].reduce((a, t) => (tmNum(t) && t > a ? t : a), s.t0); // the span of the times, not the room after
|
|
13385
|
+
nodes.push({ id: TM_RULER, shape: 'tm-ruler', label: X.unit ?? '', k: 'file', x: x0, y: R.y, w: rw, h: TR.ruler, ticks: R.ticks, unit: R.unit,
|
|
13386
|
+
aria: `Time ruler, ${s.t0} to ${X.t(last)}, a tick every ${X.t(s.step)}` });
|
|
13387
|
+
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
|
|
13388
|
+
const edges = o.lines.map(l => { const m = M.get(l.id), p = tmProps(m), t = tmLabel(m), sort = tmSort(m);
|
|
13389
|
+
return { id: m.id, _id: m.id, type: 'uml.Message', from: m.from, to: m.to, s: byId[m.from], t: byId[m.to], pts: l.pts, end: TM_END[sort], sort,
|
|
13390
|
+
label: tmPrint(t, p.sentAt, p.receivedAt), ...(t ? { lbl: { ...l.lbl, text: t } } : {}), aria: tmMsgSaid(X, m) }; });
|
|
13391
|
+
order.push(...[...edges].sort((a, b) => tmProps(M.get(a.id)).sentAt - tmProps(M.get(b.id)).sentAt).map(e => e.id), ...o.marks.map(mk => mk.id), TM_RULER);
|
|
13392
|
+
return { nodes, edges, groups: [], byId, bounds: o.bounds, map: false, order, slots: o.slots, problems: o.problems, tr: o, legend: tmLegend(X, o) };
|
|
13393
|
+
}
|
|
13394
|
+
|
|
13395
|
+
/* draw, under the lines (§7.1): the rules between lanes, then each step line and value band, --um-text at 1.5 px */
|
|
13396
|
+
function tmDraw(g) {
|
|
13397
|
+
const o = g.tr; if (!o) return { under: '', over: '' };
|
|
13398
|
+
const L = new Map(o.lanes.map(l => [l.id, l])), path = pts => 'M' + pts.map(p => `${p[0]} ${p[1]}`).join('L');
|
|
13399
|
+
const rules = o.lanes.slice(1).map(l => `<path d="M${l.x} ${l.y}H${r2(l.x + l.w)}" stroke="var(--um-muted)" stroke-width="1"/>`).join('');
|
|
13400
|
+
const steps = o.steps.map(s => `<path class="tm-step" d="${path(s.pts)}" fill="none" stroke="var(--um-text)" stroke-width="1.5" stroke-linejoin="round"/>`).join('');
|
|
13401
|
+
const bands = o.bands.map(b => { const l = L.get(b.lane), m = r2(l.y + l.h / 2), up = r2(m - TR.band / 2), dn = r2(m + TR.band / 2);
|
|
13402
|
+
return b.segs.map(s => { const c = Math.min(4, (s.x1 - s.x0) / 2), a = r2(s.x0 + c), z = r2(s.x1 - c); // the edges cross over 8 px at each change
|
|
13403
|
+
return `<path class="tm-band" d="M${s.x0} ${m}L${a} ${up}H${z}L${s.x1} ${m}L${z} ${dn}H${a}Z" fill="none" stroke="var(--um-text)" stroke-width="1.5" stroke-linejoin="round"/>`
|
|
13404
|
+
+ (s.text != null ? `<text x="${r2((s.x0 + s.x1) / 2)}" y="${r2(m + 4)}" text-anchor="middle" class="tm-value" fill="var(--um-text)" style="font:500 ${TM.text}px var(--um-sans)">${esc(fit(s.text, TM.text, Math.min(TR.cut, s.x1 - s.x0 - TR.room)))}</text>` : ''); }).join(''); }).join('');
|
|
13405
|
+
return { under: `<g class="tm-under">${rules}${steps}${bands}</g>`, over: '' };
|
|
13406
|
+
}
|
|
13407
|
+
|
|
13408
|
+
/* the shapes, each a node the canvas selects, names and focuses (§6.7) */
|
|
13409
|
+
const tmNode = (n, shape, inner) => `<g class="node ${shape}" data-id="${esc(n.id)}" data-shape="${shape}" tabindex="-1" role="img" aria-label="${esc(n.aria || n.label || n.id)}">${inner}</g>`;
|
|
13410
|
+
const tmText = (x, y, t, px, w = 500, a = 'start', cls = '') => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="${a}"${cls ? ` class="${cls}"` : ''} fill="var(--um-text)" style="font:${w} ${px}px var(--um-sans)">${esc(t)}</text>`;
|
|
13411
|
+
SHAPE['tm-lane'] = { draw: n => tmNode(n, 'tm-lane', `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" class="n-body" fill="transparent"/>${tmText(n.nx, n.ny + 12, fit(n.label, TM.name, TR.cut), TM.name, 600, 'start', 'c-name')}`) };
|
|
13412
|
+
SHAPE['tm-row'] = { draw: n => tmNode(n, 'tm-row', `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" fill="transparent"/>${tmText(n.x + n.w, n.y + 12, n.label, TM.text, 500, 'end')}`) };
|
|
13413
|
+
SHAPE['tm-change'] = { draw: n => tmNode(n, 'tm-change', `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" class="n-body" fill="transparent"/>`) };
|
|
13414
|
+
SHAPE['tm-mark'] = {
|
|
13415
|
+
draw(n) { const P = n.pts, d = n.kind === 'time' ? `M${P[0][0]} ${P[0][1]}V${P[1][1]}` : `M${P[0][0]} ${P[0][1]}V${P[1][1]}M${P[2][0]} ${P[2][1]}V${P[3][1]}`;
|
|
13416
|
+
const arrows = n.kind === 'time' ? '' : (() => { const [b, c] = [P[1], P[2]], k = Math.sign(c[0] - b[0]) || 1, y = b[1], tip = (x, s) => `M${r2(x + 7 * s)} ${r2(y - 3.5)}L${x} ${y}L${r2(x + 7 * s)} ${r2(y + 3.5)}`;
|
|
13417
|
+
return `<path d="M${b[0]} ${y}H${c[0]}${tip(b[0], k)}${tip(c[0], -k)}" fill="none" stroke="var(--um-muted)" stroke-width="1.2"/>`; })();
|
|
13418
|
+
return tmNode(n, 'tm-mark', `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" fill="transparent"/><path d="${d}" stroke="var(--um-muted)" stroke-width="${n.kind === 'time' ? 1.5 : 0.8}"/>${arrows}`
|
|
13419
|
+
+ (n.label ? tmText(n.lbl.x + (n.kind === 'time' ? 0 : n.lbl.w / 2), n.lbl.y + 11, n.label, TM.text, 500, n.kind === 'time' ? 'start' : 'middle') : '')); },
|
|
13420
|
+
};
|
|
13421
|
+
SHAPE['tm-ruler'] = {
|
|
13422
|
+
draw(n) { const y = n.y, ticks = TM_ARR(n.ticks), last = ticks.filter(t => t.major).pop();
|
|
13423
|
+
const d = `M${n.x} ${y}H${r2(n.x + n.w)}` + ticks.map(t => `M${t.x} ${y}v${t.major ? TR.mark : 3}`).join('');
|
|
13424
|
+
const labels = ticks.filter(t => t.major).map(t => tmText(t.x, y + 18, t.label, TR.font, 500, 'middle')).join('');
|
|
13425
|
+
const unit = n.unit && last ? tmText(last.x + textW(last.label, TR.font) / 2 + 4, y + 18, n.unit, TR.font, 500) : '';
|
|
13426
|
+
return tmNode(n, 'tm-ruler', `<rect x="${n.x}" y="${y}" width="${n.w}" height="${n.h}" fill="transparent"/><path d="${d}" stroke="var(--um-muted)" stroke-width="1"/>${labels}${unit}`); },
|
|
13427
|
+
};
|
|
13428
|
+
|
|
13429
|
+
/* the legend (pieces.js shows it): each sort of message, each kind of constraint and each lane form the view draws */
|
|
13430
|
+
const TM_WORDS = { call: ['Call', 'the sender waits for it'], async: ['Async', 'the sender goes on'], reply: ['Reply', 'the answer to a call'] };
|
|
13431
|
+
const tmKey = (name, says, svg) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><g stroke="var(--um-muted)" stroke-width="1.4" fill="none" stroke-linejoin="round">${svg}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
13432
|
+
function tmLegend(X, o) {
|
|
13433
|
+
const sorts = new Set(o.lines.map(l => tmSort(X.msgs.find(m => m.id === l.id)))), kinds = new Set(o.marks.map(m => m.kind)), forms = new Set(o.lanes.map(l => (X.values(l.id) ? 'values' : 'states')));
|
|
13434
|
+
return [...TM_SORTS.filter(s => sorts.has(s)).map(s => tmKey(...TM_WORDS[s], `<path d="M2 8H17"${TM_END[s].dashed ? ' stroke-dasharray="3 2"' : ''}/>${s === 'call' ? '<path d="M17 4L26 8L17 12Z" fill="var(--um-muted)"/>' : '<path d="M17 4L26 8L17 12"/>'}`)),
|
|
13435
|
+
...(kinds.has('duration') ? [tmKey('Duration constraint', 'how long may pass between two changes', '<path d="M2 4V12M26 4V12M2 8H26M8 5L2 8L8 11M20 5L26 8L20 11"/>')] : []),
|
|
13436
|
+
...(kinds.has('time') ? [tmKey('Time constraint', 'when a change may happen', '<path d="M8 4V12"/>')] : []),
|
|
13437
|
+
...(forms.has('states') ? [tmKey('States', 'the lifeline steps between its states', '<path d="M2 12H10V4H18V12H26" stroke="var(--um-text)"/>')] : []),
|
|
13438
|
+
...(forms.has('values') ? [tmKey('Values', 'the lifeline\'s value between changes', '<path d="M2 8L5 3H12L15 8L18 3H26M2 8L5 13H12L15 8L18 13H26" stroke="var(--um-text)"/>')] : [])];
|
|
13439
|
+
}
|
|
13440
|
+
|
|
13441
|
+
/* ---- validators (§7.2). Each problem: { level, rule, path, message, ids } ---- */
|
|
13442
|
+
const TM_RULES = {
|
|
13443
|
+
'change-lifeline': 'a change names a lifeline of the model',
|
|
13444
|
+
'change-time': 'a change\'s at, and a message\'s sentAt and receivedAt, are numbers',
|
|
13445
|
+
'change-state': 'in the states form, a change\'s state is one of its lifeline\'s states (else it is drawn on an extra row)',
|
|
13446
|
+
'change-instant': 'one lifeline has one change at an instant (of two, the one whose id sorts last is drawn)',
|
|
13447
|
+
'message-ends': 'a message joins one or two lifelines of the view',
|
|
13448
|
+
'message-times': 'a message is received no earlier than it is sent',
|
|
13449
|
+
sort: 'a timing message\'s sort is call, async or reply; any other is drawn as a call',
|
|
13450
|
+
'reply-call': 'a reply names in replyTo a call of the view going the other way, sent no later',
|
|
13451
|
+
'constraint-ends': 'a duration constraint\'s from and to are changes of the view; a time constraint has a from and no to',
|
|
13452
|
+
'constraint-order': 'a duration constraint\'s to is no earlier than its from',
|
|
13453
|
+
};
|
|
13454
|
+
const tmNum = x => typeof x === 'number' && Number.isFinite(x);
|
|
13455
|
+
function tmProblems(model, view) {
|
|
13456
|
+
const out = [], V = tmView(model, view); if (!TM_OBJ(model) || !V) return out;
|
|
13457
|
+
const vi = TM_ARR(model.views).indexOf(V), at = `views[${vi}]`, X = tmIndex(model, V), TL = TM_OBJ(V.timeline) ? V.timeline : {};
|
|
13458
|
+
const say = (rule, path, who, msg, level = 'error') => out.push({ level, rule, path, ids: who.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
13459
|
+
const cPath = c => `${at}.timeline.changes[${TM_ARR(TL.changes).indexOf(c)}]`, kPath = c => `${at}.timeline.constraints[${TM_ARR(TL.constraints).indexOf(c)}]`, ePath = m => `${at}.edges[${TM_ARR(V.edges).indexOf(m)}]`;
|
|
13460
|
+
const seen = new Map();
|
|
13461
|
+
X.changes.forEach(c => { const n = X.N.get(c.lifeline);
|
|
13462
|
+
if (!n || n.type !== 'uml.Lifeline') return say('change-lifeline', cPath(c) + '.lifeline', [c.id], `change ${c.id} names ${JSON.stringify(c.lifeline)}, which is not a lifeline of the model`);
|
|
13463
|
+
if (!tmNum(c.at)) return say('change-time', cPath(c) + '.at', [c.id], `change ${c.id}'s time ${JSON.stringify(c.at)} is not a number`);
|
|
13464
|
+
if (!X.H.has(c.lifeline)) return; // a hidden lifeline keeps its changes, not drawn and not warned (§7.2)
|
|
13465
|
+
if (!X.values(c.lifeline) && !tmStates(n).includes(String(c.state ?? ''))) say('change-state', cPath(c) + '.state', [c.id, c.lifeline], `change ${c.id}'s state ${JSON.stringify(c.state)} is not one of ${tmName(n)}'s states: drawn on an extra row`, 'warning');
|
|
13466
|
+
const k = `${c.lifeline} ${c.at}`; if (seen.has(k)) say('change-instant', cPath(c) + '.at', [seen.get(k), c.id], `${tmName(n)} changes twice at ${X.t(c.at)} (${seen.get(k)} and ${c.id}): only ${[seen.get(k), c.id].sort().pop()} is drawn`, 'warning'); else seen.set(k, c.id); });
|
|
13467
|
+
X.msgs.forEach(m => { const p = tmProps(m), off = [m.from, m.to].filter(id => !X.H.has(id));
|
|
13468
|
+
if (off.length) say('message-ends', ePath(m), [m.id, ...off], `${m.id} runs from ${m.from} to ${m.to}, and ${[...new Set(off)].join(' and ')} ${new Set(off).size > 1 ? 'are not lifelines' : 'is not a lifeline'} of view ${V.id}`);
|
|
13469
|
+
if (!tmNum(p.sentAt) || !tmNum(p.receivedAt)) say('change-time', ePath(m) + '.props', [m.id], `message ${m.id}'s sentAt and receivedAt are not both numbers`);
|
|
13470
|
+
else if (p.receivedAt < p.sentAt) say('message-times', ePath(m) + '.props.receivedAt', [m.id], `${m.id} is received at ${X.t(p.receivedAt)}, before it is sent at ${X.t(p.sentAt)}`);
|
|
13471
|
+
if (!TM_SORTS.includes(p.sort)) say('sort', ePath(m) + '.props.sort', [m.id], `${m.id} has ${p.sort === undefined ? 'no sort' : `sort ${JSON.stringify(p.sort)}`}, not one of ${TM_SORTS.join(', ')}: it is drawn as a call`, 'warning');
|
|
13472
|
+
else if (p.sort === 'reply') { const c = X.msgs.find(x => x.id === p.replyTo);
|
|
13473
|
+
if (!c || tmSort(c) !== 'call' || c.from !== m.to || c.to !== m.from || !(tmProps(c).sentAt <= p.sentAt)) say('reply-call', ePath(m) + '.props.replyTo', [m.id, c && c.id], `${m.id} answers ${JSON.stringify(p.replyTo)}, which is not a call of this view from ${m.to} to ${m.from} sent no later`); } });
|
|
13474
|
+
X.cons.forEach(c => { const kind = TM_KIND[c.type], a = X.C.get(c.from), b = X.C.get(c.to);
|
|
13475
|
+
if (kind === 'time' ? !a || c.to != null : !a || !b) return say('constraint-ends', kPath(c), [c.id], kind === 'time' ? `time constraint ${c.id} needs a change of the view in from, and no to` : kind ? `duration constraint ${c.id} needs two changes of the view in from and to` : `${c.id} is a ${JSON.stringify(c.type)}, not a duration or time constraint`);
|
|
13476
|
+
if (kind === 'duration' && tmNum(a.at) && tmNum(b.at) && b.at < a.at) say('constraint-order', kPath(c) + '.to', [c.id, c.from, c.to], `${c.id} runs from ${X.t(a.at)} back to ${X.t(b.at)}`, 'warning'); });
|
|
13477
|
+
return out;
|
|
13478
|
+
}
|
|
13479
|
+
|
|
13480
|
+
/* ---- describe(): the view in words, lanes top to bottom, then messages in time order, then constraints ---- */
|
|
13481
|
+
function tmDescribe(model, viewId) {
|
|
13482
|
+
const V = TM_ARR(model && model.views).find(x => TM_OBJ(x) && x.id === viewId); if (!V) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
13483
|
+
const X = tmIndex(model, V), shown = X.changes.filter(c => X.H.has(c.lifeline));
|
|
13484
|
+
const out = [`${V.title || V.id}: a timing diagram of ${tmPlural(X.heads.length, 'lifeline')}, ${tmPlural(shown.length, 'change')}, ${tmPlural(X.msgs.length, 'message')} and ${tmPlural(X.shown.length, 'constraint')}${X.unit ? `, in ${X.unit}` : ''}.`];
|
|
13485
|
+
X.heads.forEach(n => { const cs = X.mine(n.id); out.push(`${tmHead(n)}${X.values(n.id) ? ' (values)' : ''}: ${cs.length ? cs.map(c => `${c.state ?? ''} from ${X.t(c.at)}`).join('; ') : 'no change'}.`); });
|
|
13486
|
+
[...X.msgs].sort((a, b) => tmProps(a).sentAt - tmProps(b).sentAt).forEach(m => out.push(tmMsgSaid(X, m) + '.'));
|
|
13487
|
+
X.shown.forEach(c => out.push(tmConSaid(X, c) + '.'));
|
|
13488
|
+
return out.join('\n');
|
|
13489
|
+
}
|
|
13490
|
+
|
|
13491
|
+
/* the PDF's rows (4.9, §9): a lifeline's changes in time order, the messages it sends, the constraints on its changes */
|
|
13492
|
+
function tmRows(model, view, id) {
|
|
13493
|
+
const V = tmView(model, view); if (!V) return null; const X = tmIndex(model, V); if (!X.H.has(id)) return null;
|
|
13494
|
+
const mine = X.mine(id), ids = new Set(mine.map(c => c.id));
|
|
13495
|
+
return [...mine.map(c => `${X.t(c.at)}: ${c.state ?? ''}`),
|
|
13496
|
+
...X.msgs.filter(m => m.from === id).map(m => { const p = tmProps(m); return `${tmLabel(m) || tmSort(m)} to ${X.name(m.to)} at ${X.t(p.sentAt)} → ${X.t(p.receivedAt)}`; }),
|
|
13497
|
+
...X.shown.filter(c => ids.has(c.from) || ids.has(c.to)).map(c => tmConSaid(X, c))];
|
|
13498
|
+
}
|
|
13499
|
+
|
|
13500
|
+
/* ---- the gesture hooks (§8.1). Each takes the model and the view (or its id) and returns the commands of one transaction,
|
|
13501
|
+
{ commands, say }, null to leave the gesture to the canvas's default, or { error } for the hint line. A transaction that
|
|
13502
|
+
would add an error the model did not have, or a change on another's instant, is never returned: { error } names the rule ---- */
|
|
13503
|
+
function tmNewId(model, base) { // the base and the first number free in the model, time items and members included
|
|
13504
|
+
const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
13505
|
+
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;
|
|
13506
|
+
}
|
|
13507
|
+
const tmErr = error => ({ error });
|
|
13508
|
+
const tmKeyOf = p => `${p.rule}|${[...p.ids].sort().join()}`, tmBad = p => p.level === 'error' || p.rule === 'change-instant'; // a change put on another's instant would not be drawn
|
|
13509
|
+
function tmGuard(model, V, commands) {
|
|
13510
|
+
if (!Array.isArray(commands) || !commands.length) return commands;
|
|
13511
|
+
let next; try { next = apply(model, commands); } catch (x) { return tmErr(String(x && x.message || x)); }
|
|
13512
|
+
const was = new Set(tmProblems(model, V).filter(tmBad).map(tmKeyOf));
|
|
13513
|
+
const bad = tmProblems(next, tmView(next, V.id)).find(p => tmBad(p) && !was.has(tmKeyOf(p)));
|
|
13514
|
+
return bad ? tmErr(bad.message) : commands;
|
|
13515
|
+
}
|
|
13516
|
+
// a new time item, by set of the whole list (the core's add would put it in nodes or edges, §8.1), its id unused in the model
|
|
13517
|
+
function tmPut(model, V, list, item) {
|
|
13518
|
+
const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
13519
|
+
if (used.has(item.id)) return tmErr(`id ${item.id} is used already`);
|
|
13520
|
+
return { op: 'set', view: V.id, path: ['timeline', list], value: [...TM_ARR(TM_OBJ(V.timeline) && V.timeline[list]), item] };
|
|
13521
|
+
}
|
|
13522
|
+
// the minor tick (a fifth of the ruler's step) and the next one before or after t
|
|
13523
|
+
const tmMinor = (model, V) => layoutTimeRuler(tmInput(model, V)).scale.step / 5;
|
|
13524
|
+
const tmSnap = (t, minor, dir) => Number(((dir > 0 ? Math.floor(t / minor + 1e-9) + 1 : Math.ceil(t / minor - 1e-9) - 1) * minor).toPrecision(12));
|
|
13525
|
+
// a state to put a change in: in the states form, one not listed is added to the lifeline's states in the same transaction
|
|
13526
|
+
function tmState(X, lane, state) {
|
|
13527
|
+
const n = X.N.get(lane), have = tmStates(n);
|
|
13528
|
+
if (X.values(lane) || have.includes(state)) return { cmds: [], said: null };
|
|
13529
|
+
return { cmds: [{ op: 'set', id: lane, path: 'props.states', value: [...have, state] }], said: `added state ${state} to ${tmName(n)}` };
|
|
13530
|
+
}
|
|
13531
|
+
const tmSaid = (r, say) => (Array.isArray(r) && say ? { commands: r, say } : r);
|
|
13532
|
+
|
|
13533
|
+
/* add(model, view, entry, label, at): a lifeline (name, or `name : Type`) at the end of nodeIds; a change (its state or
|
|
13534
|
+
value typed) after the change selected, a minor tick on, else on the lifeline selected at the ruler's start, else at the
|
|
13535
|
+
slot `at` (its lane and t), moved on a minor tick at a time past its lifeline's changes; a time constraint on the change
|
|
13536
|
+
selected */
|
|
13537
|
+
function tmAdd(model, view, entry, label, at) {
|
|
13538
|
+
const V = tmView(model, view), type = TM_OBJ(entry) ? entry.type : null; if (!V) return null;
|
|
13539
|
+
const X = tmIndex(model, V), text = String(label ?? '').trim(), sel = TM_ARR(TM_OBJ(at) && at.sel);
|
|
13540
|
+
const change = [...sel].reverse().map(id => X.C.get(id)).find(Boolean), lane = [...sel].reverse().find(id => X.H.has(id));
|
|
13541
|
+
if (type === 'uml.Lifeline') { const [name, rep] = tmNameType(text);
|
|
13542
|
+
const element = { id: tmNewId(model, name || 'lifeline'), type, ...(name ? { label: name } : {}), ...(TM_HAS(rep) ? { props: { represents: rep } } : {}) };
|
|
13543
|
+
return tmGuard(model, V, [{ op: 'add', element, view: V.id }]); }
|
|
13544
|
+
if (type === 'uml.StateInvariant') {
|
|
13545
|
+
if (!text) return tmErr('a change needs its state: type it');
|
|
13546
|
+
const minor = tmMinor(model, V), where = change && X.H.has(change.lifeline) ? { lane: change.lifeline, t: tmSnap(change.at, minor, 1) }
|
|
13547
|
+
: lane ? { lane, t: layoutTimeRuler(tmInput(model, V, X)).scale.t0 } : TM_OBJ(at) && X.H.has(at.parent) && Number.isFinite(at.t) ? { lane: at.parent, t: at.t } : null;
|
|
13548
|
+
if (!where) return tmErr('select a lifeline or a change first');
|
|
13549
|
+
const taken = new Set(X.changes.filter(c => c.lifeline === where.lane).map(c => c.at)); while (taken.has(where.t)) where.t = tmSnap(where.t, minor, 1); // the first free minor tick
|
|
13550
|
+
const s = tmState(X, where.lane, text), put = tmPut(model, V, 'changes', { id: tmNewId(model, 'change'), lifeline: where.lane, at: where.t, state: text }); if (put.error) return put;
|
|
13551
|
+
return tmSaid(tmGuard(model, V, [...s.cmds, put]), s.said && `Added the change; ${s.said}`); }
|
|
13552
|
+
if (type === 'uml.TimeConstraint') {
|
|
13553
|
+
if (!change) return tmErr('select the change a time constraint is on first');
|
|
13554
|
+
const put = tmPut(model, V, 'constraints', { id: tmNewId(model, 'time'), type, from: change.id, ...(text ? { label: text } : {}) }); if (put.error) return put;
|
|
13555
|
+
return tmGuard(model, V, [put]); }
|
|
13556
|
+
return null;
|
|
13557
|
+
}
|
|
13558
|
+
|
|
13559
|
+
/* connect(model, view, from, to, at, line): a message between two lifelines at the slot's t (sent and received then), from
|
|
13560
|
+
one change's time to another's, or from a change to a lifeline at the change's time; a duration constraint between two
|
|
13561
|
+
changes, in time order. A reply answers the latest call back along it sent no later */
|
|
13562
|
+
function tmConnect(model, view, from, to, at, line) {
|
|
13563
|
+
const V = tmView(model, view); if (!V) return null;
|
|
13564
|
+
const X = tmIndex(model, V), type = (line && line.type) || 'uml.Message', t0 = TM_OBJ(at) && Number.isFinite(at.t) ? at.t : 0;
|
|
13565
|
+
const end = id => (X.C.has(id) ? { lane: X.C.get(id).lifeline, t: X.C.get(id).at, c: X.C.get(id) } : X.H.has(id) ? { lane: id, t: t0 } : null), a = end(from), b = end(to);
|
|
13566
|
+
if (type === 'uml.DurationConstraint') {
|
|
13567
|
+
if (!a || !b || !a.c || !b.c || a.c === b.c) return tmErr('a duration constraint joins two changes: draw it from one change to another');
|
|
13568
|
+
const [p, q] = b.c.at < a.c.at ? [b.c, a.c] : [a.c, b.c], put = tmPut(model, V, 'constraints', { id: tmNewId(model, 'duration'), type, from: p.id, to: q.id }); if (put.error) return put;
|
|
13569
|
+
return tmGuard(model, V, [put]); }
|
|
13570
|
+
if (!a || !b) return tmErr('a message joins two lifelines of the view, or two changes on them');
|
|
13571
|
+
const props = { ...(TM_OBJ(line && line.props) ? structuredClone(line.props) : {}) }, sort = TM_SORTS.includes(props.sort) ? props.sort : (props.sort = 'call');
|
|
13572
|
+
Object.assign(props, { sentAt: a.t, receivedAt: b.t });
|
|
13573
|
+
if (sort === 'reply') { const c = X.msgs.filter(m => tmSort(m) === 'call' && m.from === b.lane && m.to === a.lane && tmProps(m).sentAt <= a.t).sort((p, q) => tmProps(q).sentAt - tmProps(p).sentAt)[0];
|
|
13574
|
+
if (c) props.replyTo = c.id; }
|
|
13575
|
+
return tmGuard(model, V, [{ op: 'add', element: { id: tmNewId(model, 'm'), type: 'uml.Message', from: a.lane, to: b.lane, props }, view: V.id, in: 'view' }]);
|
|
13576
|
+
}
|
|
13577
|
+
|
|
13578
|
+
/* moveTo(model, view, id, at): a change dropped (at a slot: its t) or Alt + Left/Right (one minor tick), Alt + Up/Down on a
|
|
13579
|
+
states-form change (the state above or below); a message dropped (both times by the drop's t less its sentAt) or Alt +
|
|
13580
|
+
Left/Right (both by a minor tick); a lifeline Alt + Up/Down (its place in nodeIds). Anything else: null */
|
|
13581
|
+
function tmMoveTo(model, view, id, at) {
|
|
13582
|
+
const V = tmView(model, view); if (!V || !TM_OBJ(at)) return null;
|
|
13583
|
+
const X = tmIndex(model, V), by = Array.isArray(at.by) ? at.by : null, c = X.C.get(id), m = X.msgs.find(x => x.id === id);
|
|
13584
|
+
if (X.H.has(id)) { if (!by || by[0] || !by[1]) return null; const ids = TM_ARR(V.nodeIds), i = ids.indexOf(id), j = i + Math.sign(by[1]);
|
|
13585
|
+
return j < 0 || j >= ids.length ? [] : [{ op: 'move', id, view: V.id, index: j }]; }
|
|
13586
|
+
if (c) {
|
|
13587
|
+
if (by && by[1] && !by[0]) { const n = X.N.get(c.lifeline); if (!n || X.values(c.lifeline)) return [];
|
|
13588
|
+
const ss = tmStates(n), i = ss.indexOf(String(c.state)), k = (i < 0 ? ss.length : i) + Math.sign(by[1]); // an unlisted state: the extra row below
|
|
13589
|
+
return k < 0 || k >= ss.length ? [] : tmGuard(model, V, [{ op: 'set', id, path: 'state', value: ss[k] }]); }
|
|
13590
|
+
const t = by ? (by[0] ? tmSnap(c.at, tmMinor(model, V), by[0]) : null) : Number.isFinite(at.t) ? at.t : null;
|
|
13591
|
+
return t == null ? null : t === c.at ? [] : tmGuard(model, V, [{ op: 'set', id, path: 'at', value: t }]); }
|
|
13592
|
+
if (m) { const p = tmProps(m), d = by ? (by[0] && !by[1] ? Math.sign(by[0]) * tmMinor(model, V) : 0) : Number.isFinite(at.t) ? at.t - p.sentAt : null;
|
|
13593
|
+
if (d == null) return null; if (!d) return [];
|
|
13594
|
+
const nice = x => Number((x + d).toPrecision(12));
|
|
13595
|
+
return tmGuard(model, V, [{ op: 'set', id, path: 'props.sentAt', value: nice(p.sentAt) }, { op: 'set', id, path: 'props.receivedAt', value: nice(p.receivedAt) }]); }
|
|
13596
|
+
return null;
|
|
13597
|
+
}
|
|
13598
|
+
|
|
13599
|
+
/* rename(model, view, id, text): F2. A change: `state` or `state @ t`; a message: `label`, `label @ t` or `label @ t1 → t2`;
|
|
13600
|
+
a constraint: its label; a lifeline: `name` or `name : Type`; a state's row: the state's name, in states and in every
|
|
13601
|
+
change of that lifeline in it; the ruler: the timeline's unit (empty removes it) */
|
|
13602
|
+
function tmRename(model, view, id, text) {
|
|
13603
|
+
const V = tmView(model, view); if (!V) return null;
|
|
13604
|
+
const X = tmIndex(model, V), s = String(text ?? '').trim();
|
|
13605
|
+
if (id === TM_RULER) return [{ op: 'set', view: V.id, path: ['timeline', 'unit'], value: s || undefined }];
|
|
13606
|
+
const c = X.C.get(id);
|
|
13607
|
+
if (c) { const r = tmParse(s); if (r.times.length > 1) return tmErr('a change has one time: state @ t'); if (!r.text) return tmErr('a change needs its state');
|
|
13608
|
+
const st = X.H.has(c.lifeline) ? tmState(X, c.lifeline, r.text) : { cmds: [], said: null };
|
|
13609
|
+
return tmSaid(tmGuard(model, V, [...st.cmds, { op: 'set', id, path: 'state', value: r.text }, ...(r.times.length ? [{ op: 'set', id, path: 'at', value: r.times[0] }] : [])]), st.said && `Renamed the change; ${st.said}`); }
|
|
13610
|
+
const m = X.msgs.find(x => x.id === id);
|
|
13611
|
+
if (m) { const r = tmParse(s), [t1, t2] = r.times.length === 1 ? [r.times[0], r.times[0]] : r.times;
|
|
13612
|
+
return tmGuard(model, V, [{ op: 'set', id, path: 'label', value: r.text || undefined }, ...(r.times.length ? [{ op: 'set', id, path: 'props.sentAt', value: t1 }, { op: 'set', id, path: 'props.receivedAt', value: t2 }] : [])]); }
|
|
13613
|
+
if (X.cons.some(k => k.id === id)) return [{ op: 'set', id, path: 'label', value: s || undefined }];
|
|
13614
|
+
if (X.H.has(id)) { const [name, rep] = tmNameType(s);
|
|
13615
|
+
if (!name) return tmErr('a lifeline needs a name');
|
|
13616
|
+
return [{ op: 'set', id, path: 'label', value: name }, ...(s.includes(':') ? [{ op: 'set', id, path: 'props.represents', value: rep || undefined }] : [])]; }
|
|
13617
|
+
const k = String(id).indexOf('->'), lane = k > 0 ? String(id).slice(0, k) : null, was = k > 0 ? String(id).slice(k + 2) : null;
|
|
13618
|
+
if (lane && X.H.has(lane)) { const ss = tmStates(X.N.get(lane));
|
|
13619
|
+
if (!ss.includes(was)) return tmErr(`${was} is not one of ${X.name(lane)}'s listed states: add it to its states first`);
|
|
13620
|
+
if (!s) return tmErr('a state needs a name'); if (s !== was && ss.includes(s)) return tmErr(`${X.name(lane)} has a state ${s} already`);
|
|
13621
|
+
return [{ op: 'set', id: lane, path: 'props.states', value: ss.map(x => (x === was ? s : x)) }, ...X.changes.filter(x => x.lifeline === lane && String(x.state) === was).map(x => ({ op: 'set', id: x.id, path: 'state', value: s }))]; }
|
|
13622
|
+
return null;
|
|
13623
|
+
}
|
|
13624
|
+
|
|
13625
|
+
/* remove(model, view, ids, fromModel): a change, a message or a constraint cannot be hidden (S17): Delete deletes it, and says
|
|
13626
|
+
so. Delete on a lifeline hides it (its changes stay in the timeline, not drawn; its messages go with it, said with their
|
|
13627
|
+
count, decision 23); Shift + Delete deletes it, its changes and their constraints (the core's cascade). A state's row or
|
|
13628
|
+
the ruler is not deleted on its own */
|
|
13629
|
+
function tmRemove(model, view, ids, fromModel) {
|
|
13630
|
+
const V = tmView(model, view); if (!V) return null;
|
|
13631
|
+
const X = tmIndex(model, V), out = [], gone = { lane: [] };
|
|
13632
|
+
for (const id of new Set(TM_ARR(ids))) {
|
|
13633
|
+
if (id === TM_RULER || String(id).includes('->')) return tmErr('a state\'s row and the ruler go with their lifeline or view: rename a state with F2, or edit the lifeline\'s states');
|
|
13634
|
+
if (X.H.has(id)) { gone.lane.push(id); out.push(fromModel || TM_ARR(V.nodes).some(n => TM_OBJ(n) && n.id === id) ? { op: 'delete', id } : { op: 'hide', id, view: V.id }); continue; }
|
|
13635
|
+
if (X.C.has(id) || X.msgs.some(m => m.id === id) || X.cons.some(c => c.id === id)) { out.push({ op: 'delete', id }); continue; }
|
|
13636
|
+
return null;
|
|
13637
|
+
}
|
|
13638
|
+
const r = tmGuard(model, V, out); if (!Array.isArray(r) || !r.length) return r;
|
|
13639
|
+
// what goes, read off the model after: the core's cascade takes a call's reply and a change's constraints too
|
|
13640
|
+
const next = apply(model, r), Y = tmIndex(next, tmView(next, V.id)), left = new Set([...Y.changes, ...Y.msgs, ...Y.cons].map(x => x.id));
|
|
13641
|
+
const lanes = new Set(gone.lane), theirs = new Set(X.msgs.filter(m => lanes.has(m.from) || lanes.has(m.to)).map(m => m.id)), n = theirs.size;
|
|
13642
|
+
const k = X.changes.filter(c => lanes.has(c.lifeline) && left.has(c.id)).length;
|
|
13643
|
+
const list = (skip = new Set()) => [[X.changes, 'change'], [X.msgs, 'message'], [X.cons, 'constraint']].map(([xs, w]) => [xs.filter(x => !left.has(x.id) && !skip.has(x.id)).length, w])
|
|
13644
|
+
.filter(([c]) => c).map(([c, w]) => tmPlural(c, w)).join(', ').replace(/, ([^,]*)$/, ' and $1'); // "1 change, 2 messages and 1 constraint"
|
|
13645
|
+
const who = gone.lane.map(X.name).join(', '), one = gone.lane.length === 1, msgs = n ? tmPlural(n, 'message') : 'no message', more = list(theirs), all = list();
|
|
13646
|
+
const say = gone.lane.length ? (fromModel ? `Deleted ${who}${all ? ', ' + all : ''}` : `Hid ${who}; deleted ${one ? 'its' : 'their'} ${msgs}${more ? `, and ${more}` : ''}; ${one ? 'its' : 'their'} ${k === 1 ? 'change stays' : 'changes stay'} in the timeline`)
|
|
13647
|
+
: `Deleted ${all}: a change, a message or a constraint is deleted, not hidden`;
|
|
13648
|
+
return { commands: r, say };
|
|
13649
|
+
}
|
|
13650
|
+
|
|
13651
|
+
/* key(model, view, ids, key): S on a lifeline turns its values form on or off (present.<id>.as); any other key: null */
|
|
13652
|
+
function tmKeyHook(model, view, ids, key) {
|
|
13653
|
+
const V = tmView(model, view); if (!V || (key !== 's' && key !== 'S')) return null;
|
|
13654
|
+
const X = tmIndex(model, V), ls = TM_ARR(ids).filter(id => X.H.has(id)); if (!ls.length) return null;
|
|
13655
|
+
return ls.map(id => ({ op: 'set', view: V.id, path: ['present', id, 'as'], value: X.values(id) ? undefined : 'values' }));
|
|
13656
|
+
}
|
|
13657
|
+
|
|
13658
|
+
/* ---- the type ---- */
|
|
13659
|
+
const TM_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
13660
|
+
const TM_TYPE = registerDiagramType('uml.timing', {
|
|
13661
|
+
title: 'timing diagram',
|
|
13662
|
+
elements: [{ type: 'uml.Lifeline', label: 'Lifeline' }, { type: 'uml.StateInvariant', label: 'Change' }, { type: 'uml.TimeConstraint', label: 'Time constraint' }],
|
|
13663
|
+
lines: [...TM_SORTS.map(sort => ({ type: 'uml.Message', label: TM_WORDS[sort][0], props: { sort } })), { type: 'uml.DurationConstraint', label: 'Duration constraint' }],
|
|
13664
|
+
refs: { 'uml.Message': { 'props.replyTo': 'cascade' } },
|
|
13665
|
+
props: {
|
|
13666
|
+
'uml.Lifeline': [{ key: 'represents', label: 'Represents', kind: 'text' }, { key: 'states', label: 'States', kind: 'list' }, TM_STEREO],
|
|
13667
|
+
'uml.Message': [{ key: 'sort', label: 'Sort', kind: 'enum', options: TM_SORTS }, { key: 'replyTo', label: 'Reply to', kind: 'text' }],
|
|
13668
|
+
},
|
|
13669
|
+
layout: { run: tmRun, pins: false },
|
|
13670
|
+
draw: tmDraw,
|
|
13671
|
+
validate: tmProblems,
|
|
13672
|
+
rules: TM_RULES,
|
|
13673
|
+
describe: tmDescribe,
|
|
13674
|
+
rows: tmRows,
|
|
13675
|
+
edits: { connect: tmConnect, moveTo: tmMoveTo, add: tmAdd, remove: tmRemove, rename: tmRename, key: tmKeyHook },
|
|
13676
|
+
});
|
|
13677
|
+
|
|
13678
|
+
|
|
13679
|
+
|
|
13680
|
+
|
|
13681
|
+
|
|
13682
|
+
|
|
13683
|
+
|
|
13684
|
+
|
|
13685
|
+
|
|
13686
|
+
|
|
13687
|
+
|
|
13688
|
+
|
|
13689
|
+
|