umlimator 0.23.0 → 0.24.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 +44 -6
- package/lib/umlimator-all.js +3436 -24
- package/lib/umlimator.d.ts +49 -2
- package/lib/umlimator.js +17 -5
- package/lib/umlimator.schema.json +107 -11
- package/package.json +1 -1
package/lib/umlimator-all.js
CHANGED
|
@@ -134,7 +134,9 @@ const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram
|
|
|
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
135
|
gestures of edit.js it takes over (edit), diff()'s words for typed models (diff, order: src/uml/diff.js). Empty in
|
|
136
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)
|
|
137
|
+
Stage 6's: across(model), validate()'s rules on the whole model; fields(model, x), more panel fields (notes/stage-6.md §5)
|
|
138
|
+
Milestone 2's (notes/milestone-2.md §6.3, §9): unfolds; text(model, view, id), the PDF's line text; ENDS open to
|
|
139
|
+
families */
|
|
138
140
|
const TYPED = {};
|
|
139
141
|
|
|
140
142
|
const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
|
|
@@ -210,6 +212,9 @@ export function history(model, limit = 100) {
|
|
|
210
212
|
transaction is passed over, so a selection deletes in one transaction (E2).
|
|
211
213
|
What it held and stays goes to its nearest owner left
|
|
212
214
|
reparent { id, parent } a new owner by `parent`, or none with parent null
|
|
215
|
+
addView { view, index? } a view, added to views at index (else at the end); refused when its id is
|
|
216
|
+
a view's already, or it is not an object with a string id. Nothing else is
|
|
217
|
+
checked: validate() says what it lacks (notes/milestone-2.md §6.2)
|
|
213
218
|
An element is a node, a member, a model line or a view's own line or item with an id, or a fragment (schema version 2's
|
|
214
219
|
id space). */
|
|
215
220
|
|
|
@@ -402,7 +407,14 @@ function run(m, c, gone) {
|
|
|
402
407
|
e.x.parent = c.parent;
|
|
403
408
|
return;
|
|
404
409
|
}
|
|
405
|
-
|
|
410
|
+
case 'addView': {
|
|
411
|
+
const v = c.view, list = (m.views ||= []);
|
|
412
|
+
if (!isObj(v) || typeof v.id !== 'string') throw new Error('addView: a view is an object with a string id');
|
|
413
|
+
if (list.some(x => isObj(x) && x.id === v.id)) throw new Error(`addView: view "${v.id}" is there already`);
|
|
414
|
+
list.splice(place(list, c.index), 0, structuredClone(v));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
default: throw new Error(`${JSON.stringify(c.op)} is not a command (set, add, move, hide, delete, reparent, addView)`);
|
|
406
418
|
}
|
|
407
419
|
}
|
|
408
420
|
|
|
@@ -2355,7 +2367,7 @@ function mapContext(model, opts = {}) {
|
|
|
2355
2367
|
// the inner view's own cards: those this view shows neither as cards nor as boxes (a card unfolded here already is one, #98)
|
|
2356
2368
|
const ownOf = (v, C, nid) => (C.nodeIds || []).filter(i => N[i] && i !== nid && !(v.nodeIds || []).includes(i) && !(v.containers || []).some(b => b && b.id === i));
|
|
2357
2369
|
const unfoldable = (nid, vid) => { const v = V[vid], ids = (v && v.nodeIds) || [], c = ids.includes(nid) && N[nid] && v.render !== 'sequence' ? childOf(nid, vid) : null;
|
|
2358
|
-
if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid) || V[c].diagram != v.diagram) return null;
|
|
2370
|
+
if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid) || V[c].diagram != v.diagram || v.diagram && v.diagram != 'map' && !(diagramOf(v) || {}).unfolds) return null;
|
|
2359
2371
|
return ownOf(v, V[c], nid).length ? c : null; };
|
|
2360
2372
|
function unfold(vid, nid) {
|
|
2361
2373
|
const v = V[vid], c = unfoldable(nid, vid); if (!c) return false;
|
|
@@ -5827,8 +5839,8 @@ function tablesOf({ D, N, nodeIn, viewEdges }, v, g) {
|
|
|
5827
5839
|
return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
|
|
5828
5840
|
+ (typed ? `<td>${esc(T.word?.(D, id) ?? word(c.type || 'uml.Class'))}</td><td>${(Array.isArray(rs) ? rs : arrOf(c.members).filter(m => m && typeof m === 'object').map(m => asText(m))).map(r => `<p>${esc(r)}</p>`).join('')}</td></tr>`
|
|
5829
5841
|
: `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`); }) },
|
|
5830
|
-
{ title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>${typed ? 'Kind' : 'Technology'}</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
|
|
5831
|
-
return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : typed ? '' : `<span class="up">${w}</span>`}</td><td>${esc(typed ? word(e.type) : e.sublabel || '')}</td></tr>`; }) },
|
|
5842
|
+
{ title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>${typed ? 'Kind' : 'Technology'}</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls', t = typed && T.text?.(D, v, e._id); // a type's text for the line (S17), else its label
|
|
5843
|
+
return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${t || e.label ? esc(t || e.label) : typed ? '' : `<span class="up">${w}</span>`}</td><td>${esc(typed ? word(e.type) : e.sublabel || '')}</td></tr>`; }) },
|
|
5832
5844
|
{ title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
|
|
5833
5845
|
];
|
|
5834
5846
|
}
|
|
@@ -5842,7 +5854,7 @@ function tablesOf({ D, N, nodeIn, viewEdges }, v, g) {
|
|
|
5842
5854
|
// what moves a typed view's geometry: every field of the view layoutGraph reads but VIEW_PAINT (a test holds the two lists to
|
|
5843
5855
|
// layered.js), and these of a node and a line. Anything else (a colour, a `props` field) only repaints
|
|
5844
5856
|
const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'labelGaps', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'reroute', 'rgap', 'turned', 'typed'];
|
|
5845
|
-
const VIEW_PAINT = ['tour', 'subAndChips'], NODE_GEO = ['id', 'col', 'row', 'w', 'h', 'ax', 'ay'], EDGE_GEO = ['id', 'from', 'to', 'label', 'step', 'fromSide', 'toSide', 'back', 'fixed', 'sublabel', 'cut', 'fromPort', 'toPort', 'inner', 'portRank'];
|
|
5857
|
+
const VIEW_PAINT = ['tour', 'subAndChips'], NODE_GEO = ['id', 'col', 'row', 'w', 'h', 'ax', 'ay'], EDGE_GEO = ['id', 'from', 'to', 'label', 'step', 'fromSide', 'toSide', 'back', 'fixed', 'sublabel', 'cut', 'fromPort', 'toPort', 'inner', 'portRank', 'portMin'];
|
|
5846
5858
|
const geometryKey = dg => JSON.stringify([VIEW_GEO.map(k => dg[k]), (dg.nodes || []).map(n => NODE_GEO.map(k => n[k])), (dg.edges || []).map(e => EDGE_GEO.map(k => e[k]))]);
|
|
5847
5859
|
|
|
5848
5860
|
// direction "down": laid out left to right with every box turned, then turned back, x for y and the sides L for T, R for B
|
|
@@ -5889,6 +5901,8 @@ function layoutTyped(dg, memo) {
|
|
|
5889
5901
|
leaves (a port on the left border leaves right) and its point is on the port's own border
|
|
5890
5902
|
- a line's portRank: the port's place in its owner's members, breaking ties in the order of ports along a side; a number,
|
|
5891
5903
|
or [from, to] for a line with a port at each end
|
|
5904
|
+
- a line's portMin: the least length in px of its port's box along the border (milestone 4b's boundary event, a 32 px
|
|
5905
|
+
circle: notes/milestone-4b.md §6.2), its ends centred in it; a number, or [from, to] as portRank. None: 2 + 10 k px
|
|
5892
5906
|
- 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);
|
|
5893
5907
|
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
5908
|
- 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
|
|
@@ -5920,10 +5934,12 @@ function placePorts(sides, turned) {
|
|
|
5920
5934
|
}
|
|
5921
5935
|
function spreadPorts({ n, side, ends }, turned) {
|
|
5922
5936
|
const v = side === 'L' || side === 'R', a0 = v ? n.y : n.x, len = v ? n.h : n.w, far = x => v ? x.other.y + x.other.h / 2 : x.other.x + x.other.w / 2;
|
|
5923
|
-
const
|
|
5937
|
+
const per = (x, r) => Array.isArray(r) ? r[x.from ? 0 : 1] : r, least = x => { const m = per(x, x.e.portMin); return Number.isFinite(m) ? m : 0; };
|
|
5938
|
+
const rank = x => per(x, x.e.portRank) ?? 1e9, byFar = (a, b) => far(a) - far(b), groups = [];
|
|
5924
5939
|
ends.forEach(x => { const g = x.port != null && groups.find(o => o.port === x.port); if (g) g.ends.push(x); else groups.push({ port: x.port, ends: [x] }); });
|
|
5925
5940
|
groups.forEach(g => { g.faces = [g.ends.filter(x => !x.inner).sort(byFar), g.ends.filter(x => x.inner).sort(byFar)];
|
|
5926
|
-
g.at = g.ends.reduce((a, x) => a + far(x), 0) / g.ends.length; g.
|
|
5941
|
+
g.at = g.ends.reduce((a, x) => a + far(x), 0) / g.ends.length; g.need = g.port == null ? 2 : 2 + 10 * Math.max(...g.faces.map(f => f.length));
|
|
5942
|
+
g.L = g.port == null ? 2 : Math.max(g.need, ...g.ends.map(least)); });
|
|
5927
5943
|
groups.sort((a, b) => a.at - b.at || rank(a.ends[0]) - rank(b.ends[0]));
|
|
5928
5944
|
let lo = a0 + 8, hi = a0 + len - 8;
|
|
5929
5945
|
if (n.box) { const across = turned ? !v : v; // as layoutPass spreads a box's ends: below the title on a side, off the title and corners elsewhere
|
|
@@ -5934,7 +5950,7 @@ function spreadPorts({ n, side, ends }, turned) {
|
|
|
5934
5950
|
const B = side === 'L' ? n.x : side === 'R' ? n.x + n.w : side === 'T' ? n.y : n.y + n.h;
|
|
5935
5951
|
let s = (lo + hi - sum - gap * k) / 2;
|
|
5936
5952
|
groups.forEach(g => { const box = v ? { x: B - 6, y: s, w: 12, h: g.L } : { x: s, y: B - 6, w: g.L, h: 12 };
|
|
5937
|
-
g.faces.forEach(face => face.forEach((x, j) => { const c = g.port == null ? s + 1 : s + 6 + 10 * j;
|
|
5953
|
+
g.faces.forEach(face => face.forEach((x, j) => { const c = g.port == null ? s + 1 : s + (g.L - g.need) / 2 + 6 + 10 * j;
|
|
5938
5954
|
x.e[x.from ? 'p1' : 'p2'] = v ? [B, c] : [c, B]; if (g.port != null) x.e[x.from ? 'fpb' : 'tpb'] = { ...box }; }));
|
|
5939
5955
|
s += g.L + gap; });
|
|
5940
5956
|
}
|
|
@@ -6077,14 +6093,16 @@ SHAPE.classifier = CLASSIFIER;
|
|
|
6077
6093
|
|
|
6078
6094
|
// A typed view's line (1.5; a map's lines never read it) says how it is drawn in e.end: { from, to, dashed, labels }. from
|
|
6079
6095
|
// and to name an end in this table, the line's ends as UML draws them (none, or a name it does not hold, draws no marker);
|
|
6080
|
-
// dashed: a dependency's or realization's line
|
|
6081
|
-
//
|
|
6082
|
-
//
|
|
6083
|
-
// (notes/stage-5.md §9.2) is a usage's end on
|
|
6084
|
-
//
|
|
6096
|
+
// dashed: a dependency's or realization's line, 'dot' a dotted one (BPMN's association, notes/milestone-4b.md §10);
|
|
6097
|
+
// labels: each end's role and multiplicity (endLabels). Each end: [its length, half its height, its path pointing right
|
|
6098
|
+
// with the tip at its length, its fill]. Its tip is where the line ends, on the node's outline. Hollow is the stage's
|
|
6099
|
+
// background, filled the line's own colour (T2), in either theme. The socket (notes/stage-5.md §9.2) is a usage's end on
|
|
6100
|
+
// an interface drawn as a 16 px ball: a half circle of radius 11 open towards the ball, its middle on the line 3 px before
|
|
6101
|
+
// the end (the ball's outline), its centre 8 px past it (the ball's)
|
|
6085
6102
|
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'],
|
|
6086
6103
|
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']
|
|
6104
|
+
'filled-triangle': [13, 7, 'M0 0L13 7L0 14Z', 'line'], // an extension's, at the metaclass (notes/stage-6.md §4.1)
|
|
6105
|
+
circle: [8, 4, 'M0 4A4 4 0 1 0 8 4A4 4 0 1 0 0 4Z', 'hollow'] }; // a message flow's start, its far side the line's end (notes/milestone-4b.md §10)
|
|
6088
6106
|
const endOf = (E, w) => hasKey(ENDS, E[w]) && ENDS[E[w]] ? E[w] : null;
|
|
6089
6107
|
|
|
6090
6108
|
// a typed line's end labels (1.5): each end's role and multiplicity (e.end.labels: { from: { role, mult }, to: { role, mult } }),
|
|
@@ -6128,7 +6146,7 @@ TYPED.lines = ({ id, edges, drawn, lstyle, MC }) => {
|
|
|
6128
6146
|
return {
|
|
6129
6147
|
defs: ends.map(endMark).join(''),
|
|
6130
6148
|
edge(e, i) { const E = e.end; if (!E) return null;
|
|
6131
|
-
const st = E.dashed ? { ...lstyle(e), dash: '6 4' } : lstyle(e), cut = w => endOf(E, w) ? ENDS[endOf(E, w)][0] : 0, at = w => endOf(E, w) ? `url(#${id}-${endOf(E, w)}-${st.m})` : '';
|
|
6149
|
+
const st = E.dashed ? { ...lstyle(e), dash: E.dashed === 'dot' ? '2 3' : '6 4' } : lstyle(e), cut = w => endOf(E, w) ? ENDS[endOf(E, w)][0] : 0, at = w => endOf(E, w) ? `url(#${id}-${endOf(E, w)}-${st.m})` : '';
|
|
6132
6150
|
return { st, pts: trimLine(drawn[i], cut('from'), cut('to')), marks: `${at('to') ? `marker-end="${at('to')}"` : ''} ${at('from') ? `marker-start="${at('from')}"` : ''}` }; },
|
|
6133
6151
|
labels: i => edges[i].end ? endLabs(i).map(b => `<text x="${r2(b.x + b.w / 2)}" y="${r2(b.y + 10.5)}" text-anchor="middle" class="end-lab" data-i="${i}" data-end="${b.end}" data-box="${b.x} ${b.y} ${b.w} ${b.h}" fill="var(--um-muted)" style="font:500 10px var(--um-sans)">${esc(b.text)}</text>`).join('') : '',
|
|
6134
6152
|
};
|
|
@@ -9595,9 +9613,13 @@ function parseFlow(text) {
|
|
|
9595
9613
|
laneRows(pos, laneOf, lanes) -> pos' pos: { id: [col, row] } as ranked (pins included); laneOf: id -> its innermost
|
|
9596
9614
|
lane's id, or null; lanes: [{ id, parent, order }] outermost first, siblings
|
|
9597
9615
|
by `order`, those with none after them in list order. pos' is new: each lane a run of rows
|
|
9598
|
-
laneBands(g, lanes, laneOf, turned, rgap) -> { bands: [{ id, parent, depth, x, y, w, h, head: { x, y, w, h } }], shift, bounds }
|
|
9616
|
+
laneBands(g, lanes, laneOf, turned, rgap, runs) -> { bands: [{ id, parent, depth, x, y, w, h, head: { x, y, w, h } }], shift, bounds }
|
|
9599
9617
|
g: layoutTyped's geometry, read, never written (§2.4); turned: laid out down;
|
|
9600
|
-
rgap: the row gap it was laid out with (else the narrowest gap between two rows)
|
|
9618
|
+
rgap: the row gap it was laid out with (else the narrowest gap between two rows);
|
|
9619
|
+
runs (optional, milestone 4b §5.2): { key: [first, last] }, each lane's rows as
|
|
9620
|
+
the type placed them, in place of counting g's nodes; a lane with no run takes its
|
|
9621
|
+
nested lanes' rows, and with none has no band; a key that is no lane (the empty
|
|
9622
|
+
row between two pools) only counts its rows.
|
|
9601
9623
|
bands are where they lie once the type has moved its copy of g by shift ({ x, y });
|
|
9602
9624
|
bounds: g's bounds so moved and grown to hold the bands
|
|
9603
9625
|
Rows: lanes in depth-first order, a lane's own nodes in the rows before its nested lanes'; in each column a lane's nodes
|
|
@@ -9606,7 +9628,8 @@ function parseFlow(text) {
|
|
|
9606
9628
|
Bands: along the flow, the whole diagram and a 22 px header strip per level of nesting at its start; across it, from the
|
|
9607
9629
|
divider before a lane's first row to the one after its last, each 12 px past the router's line rgap / 2 before the later
|
|
9608
9630
|
row (gapLinesY), toward it; the outer edges 12 px inside the router's line. An empty lane at an end takes a row that
|
|
9609
|
-
leaves its band 120 px across at least.
|
|
9631
|
+
leaves its band 120 px across at least. A box's frame (g.families) counts toward the extent of the first and last rows
|
|
9632
|
+
of the nodes it holds, so no divider crosses it (milestone 4b §5.2; the activity type has no boxes). */
|
|
9610
9633
|
|
|
9611
9634
|
const LN = { head: 22, off: 12, pad: 16, gap: 44, row: 64, min: 120 }; // a header level; a divider off the router's line; the bands' margin; the row gap when g has none to measure; an empty end row's least size; an empty end lane's least width
|
|
9612
9635
|
|
|
@@ -9645,16 +9668,23 @@ function laneRows(pos, laneOf, lanes) {
|
|
|
9645
9668
|
return out;
|
|
9646
9669
|
}
|
|
9647
9670
|
|
|
9648
|
-
function laneBands(g, lanes, laneOf, turned, rgap) {
|
|
9671
|
+
function laneBands(g, lanes, laneOf, turned, rgap, runs) {
|
|
9649
9672
|
const order = laneOrder(lanes), nodes = (g && g.nodes) || [], b = (g && g.bounds) || { x: 0, y: 0, w: 0, h: 0 };
|
|
9650
9673
|
const [A, S, L, T] = turned ? ['x', 'w', 'y', 'h'] : ['y', 'h', 'x', 'w']; // across the flow, its size; along it, its size
|
|
9651
|
-
|
|
9674
|
+
let first, last, rows;
|
|
9675
|
+
if (runs && typeof runs === 'object') { const ok = r => Array.isArray(r) && Number.isInteger(r[0]) && Number.isInteger(r[1]) && r[0] >= 0 && r[0] <= r[1], R = Object.values(runs).filter(ok);
|
|
9676
|
+
const run = id => (Object.hasOwn(runs, id) && ok(runs[id]) ? runs[id] : [Infinity, -Infinity]);
|
|
9677
|
+
first = order.map(l => run(l.id)[0]); last = order.map(l => run(l.id)[1]);
|
|
9678
|
+
rows = Math.max(0, ...R.map(r => r[1] + 1), ...nodes.map(n => (Number.isInteger(n.row) ? n.row + 1 : 0))); }
|
|
9679
|
+
else { const c = laneRuns(nodes.map(n => [n.id, n.col]), laneOf, order); rows = c.rows; first = c.start.slice(0, order.length); last = order.map((l, s) => c.start[s] + c.count[s] - 1); }
|
|
9652
9680
|
// each row's extent across the flow: from its nodes, else between its neighbours; a gap is the narrowest between two rows
|
|
9653
9681
|
const lo = [], hi = [];
|
|
9654
9682
|
nodes.forEach(n => { const r = n.row; if (!Number.isInteger(r)) return; lo[r] = Math.min(lo[r] ?? Infinity, n[A]); hi[r] = Math.max(hi[r] ?? -Infinity, n[A] + n[S]); });
|
|
9655
9683
|
let gap = Infinity; for (let r = 0; r + 1 < rows; r++) if (lo[r] != null && lo[r + 1] != null) gap = Math.min(gap, lo[r + 1] - hi[r]);
|
|
9656
9684
|
if (gap === Infinity) gap = LN.gap;
|
|
9657
9685
|
if (Number.isFinite(rgap) && rgap > 0) gap = rgap;
|
|
9686
|
+
((g && g.families) || []).forEach(f => { const rs = nodes.filter(n => Number.isInteger(n.row) && n.x >= f.x && n.y >= f.y && n.x + n.w <= f.x + f.w && n.y + n.h <= f.y + f.h).map(n => n.row);
|
|
9687
|
+
if (!rs.length) return; const r0 = Math.min(...rs), r1 = Math.max(...rs); lo[r0] = Math.min(lo[r0], f[A]); hi[r1] = Math.max(hi[r1], f[A] + f[S]); });
|
|
9658
9688
|
const end = Math.max(LN.row, LN.min + 2 * LN.off - gap); // an empty end row: its band, the row and a gap less two offsets, 120 px at least
|
|
9659
9689
|
for (let r = 0; r < rows;) { if (lo[r] != null) { r++; continue; }
|
|
9660
9690
|
let e = r; while (e < rows && lo[e] == null) e++; // rows r to e - 1 hold nothing
|
|
@@ -9664,13 +9694,13 @@ function laneBands(g, lanes, laneOf, turned, rgap) {
|
|
|
9664
9694
|
r = e; }
|
|
9665
9695
|
const before = r => lo[r] - gap / 2 + LN.off, after = r => (r + 1 < rows ? before(r + 1) : hi[r] + gap / 2 - LN.off); // off the router's lines, which lie rgap / 2 from each row
|
|
9666
9696
|
// each lane's rows: its own and its nested lanes'
|
|
9667
|
-
const
|
|
9697
|
+
const at = new Map(order.map((l, s) => [l.id, s]));
|
|
9668
9698
|
order.forEach((l, s) => { for (let p = order[at.get(l.parent)]; p; p = order[at.get(p.parent)]) { const q = at.get(p.id); first[q] = Math.min(first[q], first[s]); last[q] = Math.max(last[q], last[s]); } });
|
|
9669
9699
|
const levels = order.length ? 1 + Math.max(...order.map(l => l.depth)) : 0, shift = { x: 0, y: 0, [L]: levels * LN.head };
|
|
9670
9700
|
const a0 = b[L] + LN.pad, a1 = b[L] + b[T] + shift[L] - LN.pad;
|
|
9671
|
-
const bands = order.
|
|
9701
|
+
const bands = order.flatMap((l, s) => { if (!(first[s] <= last[s])) return []; const c0 = before(first[s]), c1 = after(last[s]), s0 = a0 + l.depth * LN.head;
|
|
9672
9702
|
const box = len => ({ [L]: r2(s0), [T]: r2(len), [A]: r2(c0), [S]: r2(c1 - c0) });
|
|
9673
|
-
return { id: l.id, parent: l.parent, depth: l.depth, ...box(a1 - s0), head: box(LN.head) }; });
|
|
9703
|
+
return [{ id: l.id, parent: l.parent, depth: l.depth, ...box(a1 - s0), head: box(LN.head) }]; });
|
|
9674
9704
|
const x0 = Math.min(b.x + shift.x, ...bands.map(d => d.x - LN.pad)), y0 = Math.min(b.y + shift.y, ...bands.map(d => d.y - LN.pad));
|
|
9675
9705
|
const x1 = Math.max(b.x + b.w + shift.x, ...bands.map(d => d.x + d.w + LN.pad)), y1 = Math.max(b.y + b.h + shift.y, ...bands.map(d => d.y + d.h + LN.pad));
|
|
9676
9706
|
return { bands, shift, bounds: { x: r2(x0), y: r2(y0), w: r2(x1 - x0), h: r2(y1 - y0) } };
|
|
@@ -10257,6 +10287,7 @@ const ACT_TYPE = registerDiagramType('uml.activity', {
|
|
|
10257
10287
|
validate: actProblems,
|
|
10258
10288
|
rules: ACT_RULES,
|
|
10259
10289
|
describe: actDescribe,
|
|
10290
|
+
unfolds: true, // a call behaviour action unfolds its activity in place (notes/milestone-2.md §6.3)
|
|
10260
10291
|
edits: { connect: actConnect, moveTo: actMoveTo, add: actAdd, remove: actRemove, rename: actRename },
|
|
10261
10292
|
// the PDF's tables (§15): a node's kind, and its partition
|
|
10262
10293
|
word: (model, id) => { const x = AC_ARR(model && model.nodes).find(n => AC_OBJ(n) && n.id === id); return x && Object.hasOwn(AC_LOOK, x.type) ? AC_LOOK[x.type][1] : null; },
|
|
@@ -13674,15 +13705,3396 @@ const TM_TYPE = registerDiagramType('uml.timing', {
|
|
|
13674
13705
|
rows: tmRows,
|
|
13675
13706
|
edits: { connect: tmConnect, moveTo: tmMoveTo, add: tmAdd, remove: tmRemove, rename: tmRename, key: tmKeyHook },
|
|
13676
13707
|
});
|
|
13708
|
+
/* ---------- the entity-relationship diagram, er (work package M2.2, notes/milestone-2.md §4, §7.1) ----------
|
|
13709
|
+
Entities (tables) with their columns as rows, the key columns first under a divider; relationships from the referenced
|
|
13710
|
+
entity to the referring one (model.md rule 7), each end chosen from its multiplicity and drawn in the view's notation:
|
|
13711
|
+
crow's foot (the default) or IDEF1X, a repaint on one geometry (§4.3). Identifying solid, non-identifying dashed, in both.
|
|
13712
|
+
A column's row reads and prints `name type [PK] [FK table.column] [UK] [NULL]` (S9). Laid out by layoutTyped with a 48 px
|
|
13713
|
+
gap, labelGaps and labels cut at 280 px; repair and reroute off (S19). Ships in lib/umlimator-all.js only. */
|
|
13714
|
+
|
|
13715
|
+
const ER_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), ER_ARR = x => (Array.isArray(x) ? x : []);
|
|
13716
|
+
const ER_HAS = x => x != null && x !== '';
|
|
13717
|
+
const erName = x => String(x.label ?? x.id);
|
|
13718
|
+
const erProps = x => (ER_OBJ(x.props) ? x.props : {});
|
|
13719
|
+
const erCols = n => ER_ARR(n.members).filter(c => ER_OBJ(c) && c.type === 'er.Column');
|
|
13720
|
+
const erPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
13721
|
+
const erAnd = a => (a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a.at(-1));
|
|
13722
|
+
|
|
13723
|
+
// the model read once: nodes by id, every entity's columns by id ({ c, e }), the lines. Each column remembers the model it
|
|
13724
|
+
// was last read in, which print (the panel's rows, given a column alone) needs to name what it references
|
|
13725
|
+
const ER_SEEN = new WeakMap();
|
|
13726
|
+
function erIndex(model) {
|
|
13727
|
+
const at = new Map(), col = new Map();
|
|
13728
|
+
ER_ARR(model && model.nodes).forEach(n => { if (!ER_OBJ(n) || typeof n.id !== 'string' || at.has(n.id)) return; at.set(n.id, n);
|
|
13729
|
+
if (n.type === 'er.Entity') erCols(n).forEach(c => { ER_SEEN.set(c, model); if (typeof c.id === 'string' && !col.has(c.id)) col.set(c.id, { c, e: n }); }); });
|
|
13730
|
+
return { at, col, R: ER_ARR(model && model.edges).filter(e => ER_OBJ(e) && typeof e.id === 'string') };
|
|
13731
|
+
}
|
|
13732
|
+
|
|
13733
|
+
/* ---- a column's row (S9): name type [PK] [FK table.column] [UK] [NULL], the flags in any order and either case. FK names
|
|
13734
|
+
the referenced column by its entity's and its own labels (or by its id), resolved to its id when the row is read ---- */
|
|
13735
|
+
const ER_FLAG = { pk: 'primaryKey', fk: 'foreignKey', uk: 'unique', null: 'nullable' };
|
|
13736
|
+
const ER_SYNTAX = ['dataType', 'primaryKey', 'foreignKey', 'unique', 'nullable', 'references'];
|
|
13737
|
+
const erFlag = t => Object.hasOwn(ER_FLAG, String(t).toLowerCase());
|
|
13738
|
+
function erTokens(s) { // split at spaces outside brackets: "amount numeric(10, 2) NULL"; null when a bracket is left open or closes none
|
|
13739
|
+
const out = []; let depth = 0, cur = '';
|
|
13740
|
+
for (const ch of s) { if ('([{<'.includes(ch)) depth++; else if (')]}>'.includes(ch) && --depth < 0) return null;
|
|
13741
|
+
if (/\s/.test(ch) && !depth) { if (cur) out.push(cur); cur = ''; } else cur += ch; }
|
|
13742
|
+
if (cur) out.push(cur);
|
|
13743
|
+
return depth ? null : out;
|
|
13744
|
+
}
|
|
13745
|
+
// a column named by table.column, its entity's label and its own, when exactly one column has them; else by its id. Labels
|
|
13746
|
+
// first: an id (`<entity id>.<label>`, erNewId) can read as another column's labels once a table is renamed
|
|
13747
|
+
function erRef(t, I) {
|
|
13748
|
+
const k = t.indexOf('.'), hit = k > 0 ? [...I.col].filter(([, { c, e }]) => erName(e) === t.slice(0, k) && erName(c) === t.slice(k + 1)) : [];
|
|
13749
|
+
return hit.length === 1 ? hit[0][0] : I.col.has(t) ? t : null;
|
|
13750
|
+
}
|
|
13751
|
+
// a row's name: its first word, or "in quotes" when it has a space (printColumn writes it so)
|
|
13752
|
+
function parseColumn(text, I) {
|
|
13753
|
+
const s = String(text ?? '').trim(), q = /^"([^"]*)"(?=\s|$)/.exec(s), t = erTokens(q ? s.slice(q[0].length) : s), fail = why => ({ error: `"${s}" is not a column: ${why}` });
|
|
13754
|
+
if (!t) return fail('its brackets do not pair');
|
|
13755
|
+
if (q ? !q[1].trim() : !t.length) return fail('it is empty');
|
|
13756
|
+
const label = q ? q[1] : t.shift(), props = {}, type = [];
|
|
13757
|
+
while (t.length && !erFlag(t[0])) type.push(t.shift()); // a type of several words: "double precision", "timestamp with time zone"
|
|
13758
|
+
if (type.length) props.dataType = type.join(' ');
|
|
13759
|
+
while (t.length) { const k = t.shift(), f = erFlag(k) && ER_FLAG[k.toLowerCase()];
|
|
13760
|
+
if (!f) return fail(`"${k}" is left over: write name type [PK] [FK table.column] [UK] [NULL]`);
|
|
13761
|
+
if (props[f]) return fail(`${k.toUpperCase()} is written twice`);
|
|
13762
|
+
props[f] = true;
|
|
13763
|
+
if (f === 'foreignKey' && t.length && !erFlag(t[0])) { const r = t.shift(), id = erRef(r, I); if (id == null) return fail(`FK ${r} names no column (table.column)`); props.references = id; } }
|
|
13764
|
+
return { label, props };
|
|
13765
|
+
}
|
|
13766
|
+
function printColumn(c, I = erIndex(ER_SEEN.get(c))) {
|
|
13767
|
+
const p = erProps(c), r = ER_HAS(p.references) && I.col.get(p.references), pair = r && `${erName(r.e)}.${erName(r.c)}`;
|
|
13768
|
+
const ref = r && !/[\s.]/.test(erName(r.e) + erName(r.c)) && erRef(pair, I) === p.references ? pair : p.references, name = String(c.label ?? '');
|
|
13769
|
+
return [/\s/.test(name) ? `"${name}"` : name, ER_HAS(p.dataType) && String(p.dataType), p.primaryKey === true && 'PK', (p.foreignKey === true || ER_HAS(ref)) && 'FK', ref, p.unique === true && 'UK', p.nullable === true && 'NULL'].filter(x => typeof x === 'string' && x !== '').join(' ');
|
|
13770
|
+
}
|
|
13771
|
+
|
|
13772
|
+
/* ---- the entity (§7.1): its name in a 24 px header, then its key columns, a divider, the rest; each row its marks (PK, FK,
|
|
13773
|
+
UK, ? for a nullable column) in a left column, its name, its type in --um-muted; 120 to 320 px wide, every text cut with
|
|
13774
|
+
fit. present.<id>.rows and collapsed (the sections 'keys', 'columns') as the classifier's. Drawing input: label;
|
|
13775
|
+
sections [{ name, rows: [{ marks, name, type }] }]; dep: IDEF1X's dependent entity, drawn with rounded corners ---- */
|
|
13776
|
+
const ER = { pad: 8, head: 24, name: 13, px: 11, fpx: 9, rl: 15, gap: 4, empty: 10, sep: 8, minW: 120, maxW: 320, round: 8 };
|
|
13777
|
+
const ER_MARK = [['primaryKey', 'PK'], ['foreignKey', 'FK'], ['unique', 'UK'], ['nullable', '?']];
|
|
13778
|
+
function erParts(n) {
|
|
13779
|
+
const cap = Number.isFinite(n.rows) ? Math.max(0, Math.floor(n.rows)) : Infinity, hidden = new Set(ER_ARR(n.collapsed));
|
|
13780
|
+
const secs = ER_ARR(n.sections).filter(S => !hidden.has(S.name)).map(S => ({ name: S.name, rows: S.rows.slice(0, cap), more: Math.max(0, S.rows.length - cap) }));
|
|
13781
|
+
const rows = secs.flatMap(S => S.rows), W = (k, px) => Math.max(0, ...rows.map(r => textW(r[k], px)));
|
|
13782
|
+
const fw = W('marks', ER.fpx), nw = W('name', ER.px), tw = W('type', ER.px), lead = fw ? fw + ER.sep : 0;
|
|
13783
|
+
const w = Math.ceil(Math.min(ER.maxW, Math.max(ER.minW, 2 * ER.pad + lead + nw + (tw ? ER.sep + tw : 0), textW(n.label ?? '', ER.name) + 2 * ER.pad,
|
|
13784
|
+
...secs.filter(S => S.more).map(S => 2 * ER.pad + lead + textW(`+${S.more} more`, ER.px)))));
|
|
13785
|
+
const tall = S => { const k = S.rows.length + (S.more ? 1 : 0); return k ? 2 * ER.gap + k * ER.rl : ER.empty; };
|
|
13786
|
+
return { secs, fw, nw, tw, lead, w, tall, h: ER.head + secs.reduce((a, S) => a + tall(S), 0) };
|
|
13787
|
+
}
|
|
13788
|
+
function erSvg(n, c, f) {
|
|
13789
|
+
const P = erParts(n), { x, y, w, h } = n, avail = w - 2 * ER.pad - P.lead, nx = x + ER.pad + P.lead, tx = nx + (P.tw ? Math.min(P.nw + ER.sep, Math.max(avail * 0.6, avail - P.tw)) : avail);
|
|
13790
|
+
const rx = n.dep ? ` rx="${ER.round}"` : '', box = `x="${x}" y="${y}" width="${w}" height="${h}"${rx}`;
|
|
13791
|
+
const txt = (X, Y, t, px, wt, fill, cls, max) => `<text x="${r2(X)}" y="${r2(Y)}" class="${cls}" fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(fit(t, px, max))}</text>`;
|
|
13792
|
+
const out = [`<rect ${box} class="n-mask"/><rect ${box} class="n-body" fill="${f}" stroke="${c}"/>`,
|
|
13793
|
+
`<g class="c-head" data-band="${r2(y)} ${r2(y + ER.head)}"><text x="${r2(x + w / 2)}" y="${r2(y + 16.5)}" text-anchor="middle" class="c-name" fill="var(--um-text)" style="font:600 ${ER.name}px var(--um-sans)" data-o="${esc(n.id)}">${esc(fit(n.label ?? '', ER.name, w - 2 * ER.pad))}</text></g>`];
|
|
13794
|
+
let top = y + ER.head;
|
|
13795
|
+
P.secs.forEach(S => { const at = j => top + ER.gap - 1 + j * ER.rl + 11.5;
|
|
13796
|
+
const rows = S.rows.map((r, j) => (r.marks ? txt(x + ER.pad, at(j), r.marks, ER.fpx, 600, 'var(--um-muted)', 'er-mark', P.fw) : '')
|
|
13797
|
+
+ txt(nx, at(j), r.name, ER.px, 400, 'var(--um-text)', 'c-row', P.tw ? tx - nx - ER.sep : avail) + (r.type ? txt(tx, at(j), r.type, ER.px, 400, 'var(--um-muted)', 'er-type', x + w - ER.pad - tx) : ''));
|
|
13798
|
+
if (S.more) rows.push(txt(nx, at(S.rows.length), `+${S.more} more`, ER.px, 400, 'var(--um-muted)', 'c-more', avail));
|
|
13799
|
+
out.push(`<g class="c-comp" data-comp="${S.name}" data-band="${r2(top)} ${r2(top + P.tall(S))}"><path d="M${x} ${r2(top)}H${x + w}" class="n-det" stroke="${c}"/>${rows.join('')}</g>`);
|
|
13800
|
+
top += P.tall(S); });
|
|
13801
|
+
return `<g class="node entity" data-id="${esc(n.id)}" data-shape="entity" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Table ' + (n.label ?? ''))}">${out.join('')}</g>`;
|
|
13802
|
+
}
|
|
13803
|
+
const ENTITY = { size: n => { const P = erParts(n); return { w: P.w, h: P.h }; }, out: boxOut(0), face: n => [n.y, n.y + n.h], inset: () => 0, draw: erSvg };
|
|
13804
|
+
ENTITY.ports = (n, side, k = 1) => portsOn(ENTITY, n, side, k);
|
|
13805
|
+
SHAPE.entity = ENTITY;
|
|
13806
|
+
|
|
13807
|
+
/* ---- the ends (§4.1, §4.2): six rows of src/typed/draw.js's table, pointing right with the tip on the entity's outline.
|
|
13808
|
+
Crow's foot's are 18 px long, a ring hollow (the stage's background); IDEF1X's dot is a filled 6 px circle, its letter an
|
|
13809
|
+
end label; its diamond, at an optional parent, hollow ---- */
|
|
13810
|
+
const ER_RING = 'M1 7A4 4 0 1 0 9 7A4 4 0 1 0 1 7Z';
|
|
13811
|
+
Object.assign(ENDS, { 'cf-one': [18, 7, 'M0 7H18M8 0V14M12 0V14', 'none'], 'cf-zero-one': [18, 7, `M0 7H1M9 7H18M13 0V14${ER_RING}`, 'hollow'],
|
|
13812
|
+
'cf-one-many': [18, 7, 'M0 7H18M4 0V14M8 7L18 0M8 7L18 14', 'none'], 'cf-zero-many': [18, 7, `M0 7H1M9 7H18M10 7L18 0M10 7L18 14${ER_RING}`, 'hollow'],
|
|
13813
|
+
'idef-dot': [6, 3, 'M0 3A3 3 0 1 0 6 3A3 3 0 1 0 0 3Z', 'line'], 'idef-diamond': [12, 5, 'M0 5L6 0L12 5L6 10Z', 'hollow'] });
|
|
13814
|
+
|
|
13815
|
+
// a multiplicity's bounds when a relationship's end can have them (an upper bound of 1 or more, the lower not above it)
|
|
13816
|
+
const erBounds = t => { const b = ER_HAS(t) ? multBounds(t) : null; return b && b.upper >= 1 && b.lower <= b.upper ? b : null; };
|
|
13817
|
+
const ER_FORMS = { '1 1': ['cf-one', 'exactly one', '1'], '0 1': ['cf-zero-one', 'zero or one', 'Z'], '1 Infinity': ['cf-one-many', 'one or more', 'P'], '0 Infinity': ['cf-zero-many', 'zero or more', undefined] };
|
|
13818
|
+
const erForm = b => ER_FORMS[`${b.lower} ${b.upper}`];
|
|
13819
|
+
const erMult = (e, w) => { const E = erProps(e).ends; return ER_OBJ(E) && ER_OBJ(E[w]) ? E[w].multiplicity : undefined; };
|
|
13820
|
+
const erSay = t => { const b = erBounds(t); return b && erForm(b) ? erForm(b)[1] : ER_HAS(t) ? String(t) : 'no multiplicity'; };
|
|
13821
|
+
// an end as drawn: [its name in ENDS, its end label]. Crow's foot: the nearest of the four by its bounds, the text written
|
|
13822
|
+
// when it is none of them; IDEF1X: at the parent (from) nothing for 1, a diamond for 0..1, else a dot; at the child a dot,
|
|
13823
|
+
// with 1, Z, P, nothing or the text. Unreadable: no end, the text as its label
|
|
13824
|
+
function erEnd(e, w, idef) {
|
|
13825
|
+
const t = erMult(e, w), b = erBounds(t), s = ER_HAS(t) ? String(t) : undefined;
|
|
13826
|
+
if (!b) return ['none', s];
|
|
13827
|
+
const F = erForm(b);
|
|
13828
|
+
if (!idef) return [ER_FORMS[`${b.lower ? 1 : 0} ${b.upper > 1 ? 'Infinity' : 1}`][0], F ? undefined : s];
|
|
13829
|
+
if (w === 'from' && b.upper === 1) return [b.lower ? 'none' : 'idef-diamond'];
|
|
13830
|
+
return ['idef-dot', F ? F[2] : s];
|
|
13831
|
+
}
|
|
13832
|
+
const erIdentifying = e => erProps(e).identifying === true;
|
|
13833
|
+
function erLook(e, idef) {
|
|
13834
|
+
if (e.type !== 'er.Relationship') return { from: 'none', to: 'none', dashed: true }; // a comment link
|
|
13835
|
+
const [f, fl] = erEnd(e, 'from', idef), [t, tl] = erEnd(e, 'to', idef);
|
|
13836
|
+
return { from: f, to: t, dashed: !erIdentifying(e), labels: { from: { mult: fl }, to: { mult: tl } } };
|
|
13837
|
+
}
|
|
13838
|
+
|
|
13839
|
+
/* ---- names (A1) and the view's words ---- */
|
|
13840
|
+
function erNodeName(n) {
|
|
13841
|
+
if (n.type === 'uml.Comment') return 'Comment: ' + erName(n);
|
|
13842
|
+
const cs = erCols(n), keys = cs.filter(c => erProps(c).primaryKey === true).map(erName);
|
|
13843
|
+
return `Table ${erName(n)}, ${keys.length ? 'key ' + erAnd(keys) : 'no key'}, ${erPlural(cs.length, 'column')}`;
|
|
13844
|
+
}
|
|
13845
|
+
// "Identifying relationship contains from order (exactly one) to order_line (one or more)"
|
|
13846
|
+
function erLineName(e, I) {
|
|
13847
|
+
const nm = id => (I.at.has(id) ? erName(I.at.get(id)) : String(id));
|
|
13848
|
+
if (e.type !== 'er.Relationship') return `Comment link from ${nm(e.from)} to ${nm(e.to)}`;
|
|
13849
|
+
return `${erIdentifying(e) ? 'Identifying' : 'Non-identifying'} relationship ${ER_HAS(e.label) ? e.label + ' ' : ''}from ${nm(e.from)} (${erSay(erMult(e, 'from'))}) to ${nm(e.to)} (${erSay(erMult(e, 'to'))})`;
|
|
13850
|
+
}
|
|
13851
|
+
const erShown = (model, view) => { const I = erIndex(model); return { I, ids: [...new Set(ER_ARR(view.nodeIds))].filter(id => I.at.has(id)) }; };
|
|
13852
|
+
// the lines drawn: those the view lists, or with no list the model's relationships and comment links, between two it shows
|
|
13853
|
+
function erLines(view, I, ids) {
|
|
13854
|
+
const shown = new Set(ids), E = new Map(I.R.map(e => [e.id, e]));
|
|
13855
|
+
const list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : I.R.filter(e => e.type === 'er.Relationship' || e.type === 'uml.CommentLink');
|
|
13856
|
+
return list.filter(e => ER_OBJ(e) && typeof e.id === 'string' && shown.has(e.from) && shown.has(e.to));
|
|
13857
|
+
}
|
|
13858
|
+
|
|
13859
|
+
/* ---- the legend: the two line looks drawn, then each end drawn, a small copy of its ENDS row ---- */
|
|
13860
|
+
const ER_SAYS = { 'cf-one': ['Exactly one', 'one and only one at that end'], 'cf-zero-one': ['Zero or one', 'at most one at that end'],
|
|
13861
|
+
'cf-one-many': ['One or more', 'at least one at that end'], 'cf-zero-many': ['Zero or more', 'any number at that end'],
|
|
13862
|
+
'idef-dot': ['Child end', 'zero or more; P one or more, Z zero or one, a number exactly that many'], 'idef-diamond': ['Optional parent', 'zero or one parent'] };
|
|
13863
|
+
const erGlyph = (end, dashed, name, says) => { const E = end !== 'none' && ENDS[end], k = 0.7, L = E ? E[0] * k : 0;
|
|
13864
|
+
const mark = E ? `<g transform="translate(${r2(26 - L)} ${r2(8 - E[1] * k)}) scale(${k})"><path d="${E[2]}" fill="${E[3] === 'line' ? 'var(--um-muted)' : E[3] === 'hollow' ? 'var(--um-panel)' : 'none'}"/></g>` : '';
|
|
13865
|
+
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${r2(26 - L)}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${mark}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; };
|
|
13866
|
+
function erLegend(edges, dep) {
|
|
13867
|
+
const rel = edges.filter(e => e.type === 'er.Relationship'), ends = new Set(rel.flatMap(e => [e.end.from, e.end.to]));
|
|
13868
|
+
return [...(rel.some(e => !e.end.dashed) ? [erGlyph('none', false, 'Identifying', 'the child\'s key holds the parent\'s key')] : []),
|
|
13869
|
+
...(rel.some(e => e.end.dashed) ? [erGlyph('none', true, 'Non-identifying', 'the child refers to the parent outside its key')] : []),
|
|
13870
|
+
...Object.keys(ER_SAYS).filter(k => ends.has(k)).map(k => erGlyph(k, false, ...ER_SAYS[k])),
|
|
13871
|
+
...(dep ? [`<span class="lgi" title="Dependent entity — its key holds its parent's"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><rect x="3" y="2" width="22" height="12" rx="4" fill="none" stroke="var(--um-muted)" stroke-width="1.4"/></svg><b>Dependent entity</b><span>rounded corners: its key holds its parent's</span></span>`] : []),
|
|
13872
|
+
...(edges.some(e => e.type === 'uml.CommentLink') ? [erGlyph('none', true, 'Comment link', 'what a comment is about')] : [])];
|
|
13873
|
+
}
|
|
13874
|
+
|
|
13875
|
+
/* ---- the view laid out: one geometry for both notations (§4.3): the notation picks the ends and IDEF1X's rounded corners,
|
|
13876
|
+
neither in the geometry key, so a switch repaints ---- */
|
|
13877
|
+
const erPick = (o, k) => (ER_OBJ(o) && Object.hasOwn(o, k) ? o[k] : undefined);
|
|
13878
|
+
function erSections(n) {
|
|
13879
|
+
const row = c => { const p = erProps(c); return { name: erName(c), type: ER_HAS(p.dataType) ? String(p.dataType) : '', marks: ER_MARK.filter(([k]) => p[k] === true).map(m => m[1]).join(' ') }; };
|
|
13880
|
+
const cs = erCols(n), keys = cs.filter(c => erProps(c).primaryKey === true), rest = cs.filter(c => erProps(c).primaryKey !== true);
|
|
13881
|
+
return [...(keys.length ? [{ name: 'keys', rows: keys.map(row) }] : []), ...(rest.length || !keys.length ? [{ name: 'columns', rows: rest.map(row) }] : [])];
|
|
13882
|
+
}
|
|
13883
|
+
// IDEF1X's dependent entity: the child of an identifying relationship (its key holds the parent's)
|
|
13884
|
+
const erDependent = (I, id) => I.R.some(e => e.type === 'er.Relationship' && e.to === id && erIdentifying(e));
|
|
13885
|
+
function erGeo({ model, view, pos, memo }) {
|
|
13886
|
+
const { I, ids } = erShown(model, view), idef = view.notation === 'idef1x', lines = erLines(view, I, ids), P = id => erPick(pos, id) || [0, 0];
|
|
13887
|
+
const nodes = ids.map(id => { const n = I.at.get(id), pr = ER_OBJ(erPick(view.present, id)) ? view.present[id] : {};
|
|
13888
|
+
const d = n.type === 'uml.Comment' ? { shape: 'note' } : { shape: 'entity', sections: erSections(n), collapsed: ER_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}), ...(idef && erDependent(I, id) ? { dep: true } : {}) };
|
|
13889
|
+
const x = { id, label: erName(n), type: n.type || 'er.Entity', kind: 'file', ...d, aria: erNodeName(n), col: P(id)[0], row: P(id)[1] };
|
|
13890
|
+
return { ...x, ...SHAPE[x.shape].size(x) }; });
|
|
13891
|
+
const edges = lines.map(e => ({ id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: ER_HAS(e.label) ? String(e.label) : undefined, end: erLook(e, idef), aria: erLineName(e, I), cut: 280,
|
|
13892
|
+
...(ER_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }));
|
|
13893
|
+
const g = layoutTyped({ nodes, edges, direction: view.direction === 'down' ? 'down' : 'right', gap: 48, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo);
|
|
13894
|
+
return { ...g, legend: erLegend(g.edges, nodes.some(n => n.dep)), order: [...ids, ...lines.map(e => e.id)] }; // a new object: what layoutTyped returned is not edited
|
|
13895
|
+
}
|
|
13896
|
+
|
|
13897
|
+
/* ---- validators (§7.1). Each problem: { level, rule, path, message, ids } ---- */
|
|
13898
|
+
const ER_RULES = {
|
|
13899
|
+
'pk-exists': 'an entity has a primary key column',
|
|
13900
|
+
'column-names': 'column names are unique within an entity',
|
|
13901
|
+
'fk-target': 'a column\'s references names a column of an entity (an error), one that is a key or unique (a warning)',
|
|
13902
|
+
'fk-flag': 'a column with references is a foreign key, and a foreign key has references',
|
|
13903
|
+
'relationship-fk': 'a relationship from A to B has a column of B that references a column of A',
|
|
13904
|
+
'identifying-key': 'an identifying relationship\'s foreign key columns are part of the child\'s primary key',
|
|
13905
|
+
'identifying-parent': 'an identifying relationship\'s parent end is 1',
|
|
13906
|
+
'relationship-ends': 'each end\'s multiplicity is readable (an error), and one of 1, 0..1, 1..* and 0..* (a warning)',
|
|
13907
|
+
'relationship-tables': 'a relationship joins two tables',
|
|
13908
|
+
'notation': 'an er view\'s notation is crowsfoot or idef1x',
|
|
13909
|
+
};
|
|
13910
|
+
// the columns of entity b that reference a column of entity a
|
|
13911
|
+
const erFks = (I, a, b) => erCols(I.at.get(b) || {}).filter(c => { const r = I.col.get(erProps(c).references); return r && r.e.id === a; });
|
|
13912
|
+
function erProblems(model) {
|
|
13913
|
+
const out = [], I = erIndex(model), say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
13914
|
+
const q = id => JSON.stringify(I.at.has(id) ? erName(I.at.get(id)) : String(id)), qc = r => JSON.stringify(`${erName(r.e)}.${erName(r.c)}`);
|
|
13915
|
+
ER_ARR(model && model.nodes).forEach((n, i) => { if (!ER_OBJ(n) || n.type !== 'er.Entity' || I.at.get(n.id) !== n) return;
|
|
13916
|
+
const seen = new Map();
|
|
13917
|
+
if (!erCols(n).some(c => erProps(c).primaryKey === true)) say('warning', 'pk-exists', `nodes[${i}]`, [n.id], `${q(n.id)} has no primary key column`);
|
|
13918
|
+
ER_ARR(n.members).forEach((c, j) => { if (!ER_OBJ(c) || c.type !== 'er.Column') return;
|
|
13919
|
+
const path = `nodes[${i}].members[${j}]`, p = erProps(c), me = qc({ e: n, c }), was = seen.get(erName(c));
|
|
13920
|
+
if (was) say('error', 'column-names', path, [n.id, was, c.id], `${q(n.id)} has two columns named ${JSON.stringify(erName(c))} (${was}, ${c.id})`); else seen.set(erName(c), c.id);
|
|
13921
|
+
if (ER_HAS(p.references)) { const r = I.col.get(p.references);
|
|
13922
|
+
if (!r) say('error', 'fk-target', `${path}.props.references`, [c.id], `${me} references ${JSON.stringify(String(p.references))}, which is no column of an entity`);
|
|
13923
|
+
else if (erProps(r.c).primaryKey !== true && erProps(r.c).unique !== true) say('warning', 'fk-target', `${path}.props.references`, [c.id, r.c.id], `${me} references ${qc(r)}, which is neither a key nor unique`); }
|
|
13924
|
+
if (ER_HAS(p.references) && p.foreignKey !== true) say('warning', 'fk-flag', path, [c.id], `${me} references a column but is not marked FK`);
|
|
13925
|
+
if (p.foreignKey === true && !ER_HAS(p.references)) say('warning', 'fk-flag', path, [c.id], `${me} is a foreign key that references no column`); }); });
|
|
13926
|
+
ER_ARR(model && model.edges).forEach((e, i) => { if (!ER_OBJ(e) || e.type !== 'er.Relationship') return;
|
|
13927
|
+
const path = `edges[${i}]`, A = e.from, B = e.to, ent = id => (I.at.get(id) || {}).type === 'er.Entity';
|
|
13928
|
+
['from', 'to'].forEach(w => { const t = erMult(e, w), b = erBounds(t);
|
|
13929
|
+
if (!b) say('error', 'relationship-ends', `${path}.props.ends.${w}.multiplicity`, [e.id], `the ${w} end of ${e.id} has ${ER_HAS(t) ? `multiplicity ${JSON.stringify(String(t))}, which is not readable` : 'no multiplicity'}`);
|
|
13930
|
+
else if (!erForm(b)) say('warning', 'relationship-ends', `${path}.props.ends.${w}.multiplicity`, [e.id], `the ${w} end of ${e.id} has multiplicity ${JSON.stringify(String(t))}, none of 1, 0..1, 1..* and 0..*: drawn as the nearest, with its text`); });
|
|
13931
|
+
if (!ent(A) || !ent(B)) { const not = [A, B].filter(id => I.at.has(id) && !ent(id)); // a missing end is the core's to say
|
|
13932
|
+
if (not.length) say('error', 'relationship-tables', path, [e.id, ...not], `${e.id} joins ${erAnd(not.map(q))}, which ${not.length === 1 ? 'is not a table' : 'are not tables'}`);
|
|
13933
|
+
return; }
|
|
13934
|
+
const fks = erFks(I, A, B);
|
|
13935
|
+
if (!fks.length) say('warning', 'relationship-fk', path, [e.id, A, B], `${e.id} runs from ${q(A)} to ${q(B)}, and no column of ${q(B)} references a column of ${q(A)}`);
|
|
13936
|
+
if (!erIdentifying(e)) return;
|
|
13937
|
+
const b = erBounds(erMult(e, 'from'));
|
|
13938
|
+
if (b && (b.lower !== 1 || b.upper !== 1)) say('error', 'identifying-parent', `${path}.props.ends.from.multiplicity`, [e.id, A], `${e.id} is identifying, so its parent end at ${q(A)} is 1, not ${JSON.stringify(String(erMult(e, 'from')))}`);
|
|
13939
|
+
// a column out of the key may be another relationship's of the pair, a non-identifying one: then one in the key will do.
|
|
13940
|
+
// ponytail: the model does not say which columns are which relationship's; a relationship naming its columns would
|
|
13941
|
+
const out2 = fks.filter(c => erProps(c).primaryKey !== true), shared = I.R.some(x => x !== e && x.type === 'er.Relationship' && x.from === A && x.to === B && !erIdentifying(x));
|
|
13942
|
+
if (out2.length && (!shared || out2.length === fks.length)) say('error', 'identifying-key', path, [e.id, B, ...out2.map(c => c.id)], `${e.id} is identifying, but ${erAnd(out2.map(erName))} of ${q(B)} ${out2.length === 1 ? 'is' : 'are'} not in its primary key`); });
|
|
13943
|
+
ER_ARR(model && model.views).forEach((v, i) => { if (ER_OBJ(v) && v.diagram === 'er' && v.notation != null && v.notation !== 'crowsfoot' && v.notation !== 'idef1x')
|
|
13944
|
+
say('warning', 'notation', `views[${i}].notation`, [v.id], `${JSON.stringify(String(v.id))} has notation ${JSON.stringify(String(v.notation))}, drawn as crowsfoot: write crowsfoot or idef1x`); });
|
|
13945
|
+
return out;
|
|
13946
|
+
}
|
|
13947
|
+
|
|
13948
|
+
/* ---- describe(): the view in sentences, each element by its accessible name and an entity's rows, in reading order, then
|
|
13949
|
+
each line ---- */
|
|
13950
|
+
function erDescribe(model, viewId) {
|
|
13951
|
+
const v = ER_ARR(model && model.views).find(x => ER_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
13952
|
+
const { I, ids } = erShown(model, v), lines = erLines(v, I, ids), say = n => { const cs = erCols(n); return erNodeName(n) + (cs.length ? ': ' + cs.map(c => printColumn(c, I)).join('; ') : ''); };
|
|
13953
|
+
return [`${v.title || v.id}: an entity-relationship diagram of ${erPlural(ids.length, 'element')} and ${erPlural(lines.length, 'line')}.`,
|
|
13954
|
+
...ids.map(id => say(I.at.get(id)) + '.'), ...lines.map(e => erLineName(e, I) + '.')].join('\n');
|
|
13955
|
+
}
|
|
13956
|
+
|
|
13957
|
+
/* ---- editing (§7.1) ---- */
|
|
13958
|
+
const erNewId = (model, base) => { const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
13959
|
+
let k = 2, id = base; while (used.has(id)) id = `${base}-${k++}`; return id; };
|
|
13960
|
+
// a row of the panel's Members, read as S9's: a new column, or a column's name and syntax props set (its other props kept)
|
|
13961
|
+
function erMember(model, view, owner, id, text) {
|
|
13962
|
+
const I = erIndex(model); if ((I.at.get(owner) || {}).type !== 'er.Entity') return null;
|
|
13963
|
+
const r = parseColumn(text, I); if (r.error) return r;
|
|
13964
|
+
if (id == null) return [{ op: 'add', element: { id: erNewId(model, `${owner}.${r.label}`), type: 'er.Column', label: r.label, ...(Object.keys(r.props).length ? { props: r.props } : {}) }, owner }];
|
|
13965
|
+
const m = (I.col.get(id) || {}).c; if (!m) return null;
|
|
13966
|
+
const keep = Object.fromEntries(Object.entries(erProps(m)).filter(([k]) => !ER_SYNTAX.includes(k)));
|
|
13967
|
+
return [{ op: 'set', id, path: 'label', value: r.label }, { op: 'set', id, path: 'props', value: { ...keep, ...r.props } }];
|
|
13968
|
+
}
|
|
13969
|
+
// connect: a relationship joins two tables (the palette's entry gives it 1 to 0..*, non-identifying); else said
|
|
13970
|
+
function erConnect(model, view, from, to, at, line) {
|
|
13971
|
+
if (!ER_OBJ(line) || line.type !== 'er.Relationship') return null;
|
|
13972
|
+
const I = erIndex(model), ent = id => (I.at.get(id) || {}).type === 'er.Entity';
|
|
13973
|
+
return ent(from) && ent(to) ? null : { error: 'A relationship joins two tables' };
|
|
13974
|
+
}
|
|
13975
|
+
|
|
13976
|
+
/* ---- the type ---- */
|
|
13977
|
+
const erMultField = w => ({ key: `ends.${w}.multiplicity`, path: ['ends', w, 'multiplicity'], label: `${w === 'from' ? 'Parent' : 'Child'} multiplicity`, kind: 'text' });
|
|
13978
|
+
const ER_TYPE = registerDiagramType('er', {
|
|
13979
|
+
title: 'entity-relationship diagram',
|
|
13980
|
+
elements: [{ type: 'er.Entity', label: 'Table' }, { type: 'uml.Comment', label: 'Comment' }],
|
|
13981
|
+
lines: [{ type: 'er.Relationship', label: 'Relationship', props: { identifying: false, ends: { from: { multiplicity: '1' }, to: { multiplicity: '0..*' } } } }, { type: 'uml.CommentLink', label: 'Comment link' }],
|
|
13982
|
+
props: { 'er.Entity': [], 'uml.Comment': [], 'uml.CommentLink': [],
|
|
13983
|
+
'er.Column': [['dataType', 'Type', 'text'], ['primaryKey', 'Primary key', 'bool'], ['foreignKey', 'Foreign key', 'bool'], ['references', 'References (column id)', 'text'], ['unique', 'Unique', 'bool'], ['nullable', 'Nullable', 'bool']].map(([key, label, kind]) => ({ key, label, kind })),
|
|
13984
|
+
'er.Relationship': [{ key: 'identifying', label: 'Identifying', kind: 'bool' }, erMultField('from'), erMultField('to')] },
|
|
13985
|
+
refs: { 'er.Column': { 'props.references': 'drop' } }, // a deleted column leaves the columns that referenced it
|
|
13986
|
+
layout: erGeo,
|
|
13987
|
+
// ponytail: the panel's member.print is given a column alone; the panel asks owners, with the live model, just before it,
|
|
13988
|
+
// so that model is read here and print names a referenced column by labels. No Owner field (null). A hook given the model ends this
|
|
13989
|
+
owners: model => (erIndex(model), null),
|
|
13990
|
+
member: { print: c => printColumn(c), holds: n => n.type === 'er.Entity' }, // rows are read by edits.member, which has the model
|
|
13991
|
+
validate: erProblems,
|
|
13992
|
+
rules: ER_RULES,
|
|
13993
|
+
describe: erDescribe,
|
|
13994
|
+
// the PDF's tables (§9): an entity's word and its columns as S9 prints them; a relationship's multiplicities
|
|
13995
|
+
word: (model, id) => ((erIndex(model).at.get(id) || {}).type === 'er.Entity' ? 'Table' : null),
|
|
13996
|
+
rows: (model, view, id) => { const I = erIndex(model), n = I.at.get(id); return n && n.type === 'er.Entity' ? erCols(n).map(c => printColumn(c, I)) : null; },
|
|
13997
|
+
text: (model, view, id) => { const e = erIndex(model).R.find(x => x.id === id);
|
|
13998
|
+
return e && e.type === 'er.Relationship' ? `${ER_HAS(e.label) ? e.label + ': ' : ''}${erMult(e, 'from') ?? '?'} to ${erMult(e, 'to') ?? '?'}, ${erIdentifying(e) ? 'identifying' : 'non-identifying'}` : null; },
|
|
13999
|
+
edits: { member: erMember, connect: erConnect },
|
|
14000
|
+
});
|
|
14001
|
+
/* ---------- the flowchart, flowchart (work package M2.3, notes/milestone-2.md §7.2) ----------
|
|
14002
|
+
Ten symbols: the decision is stage 4's diamond and the connector a circle over SHAPE.initial's outline, both read only;
|
|
14003
|
+
the database is drawn over the map's cylinder's outline with a lid line of its own (the cylinder's det is tinted by a map
|
|
14004
|
+
kind); the other seven are this file's, each with size, out, inset (a line's end slid onto it, stage 4 §4.2), face, ports
|
|
14005
|
+
and draw. A node's text is flowWrap's, three lines of 180 px, then cut, inside the shape's text box (its padding per
|
|
14006
|
+
side, FC_SHAPES). Flows are solid with a filled arrow, their labels cut at 280 px. Laid out down unless the view says
|
|
14007
|
+
right (model.md row 21), gap 48 with gapAfter's widening, labelGaps, repair and reroute off (§11). Ships in
|
|
14008
|
+
lib/umlimator-all.js only. */
|
|
14009
|
+
|
|
14010
|
+
const FC_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), FC_ARR = x => (Array.isArray(x) ? x : []);
|
|
14011
|
+
const FC_TXT = x => (x != null && String(x).trim() !== '' ? String(x).trim() : '');
|
|
14012
|
+
const FC = { px: 12, lh: 16, max: 180, padX: 12, padY: 8, minW: 96, minH: 40, slant: 14, wave: 5, slope: 12, side: 8, line: 'var(--um-muted)', ink: 'var(--um-text)', bg: 'var(--um-stage-bg)' };
|
|
14013
|
+
// each node type: its shape and its word (accessible names, the PDF, the legend)
|
|
14014
|
+
const FC_LOOK = {
|
|
14015
|
+
'flow.Terminator': ['terminator', 'Terminator'], 'flow.Process': ['process', 'Process'], 'flow.Decision': ['diamond', 'Decision'],
|
|
14016
|
+
'flow.InputOutput': ['io', 'Input/output'], 'flow.PredefinedProcess': ['predefined', 'Predefined process'], 'flow.Document': ['doc', 'Document'],
|
|
14017
|
+
'flow.Database': ['database', 'Database'], 'flow.ManualInput': ['manual', 'Manual input'], 'flow.Delay': ['delay', 'Delay'],
|
|
14018
|
+
'flow.Connector': ['connector', 'Connector'], 'uml.Comment': ['note', 'Comment'],
|
|
14019
|
+
};
|
|
14020
|
+
const FC_NODES = Object.keys(FC_LOOK).filter(t => t !== 'uml.Comment'); // what a flow may end on
|
|
14021
|
+
const fcLook = x => (Object.hasOwn(FC_LOOK, x.type) ? FC_LOOK[x.type] : ['process', String(x.type || 'Node').split('.').pop()]);
|
|
14022
|
+
|
|
14023
|
+
/* ---- the shapes ---- */
|
|
14024
|
+
const arcIn = (r, u) => r - Math.sqrt(Math.max(0, r * r - Math.min(r, Math.abs(u)) ** 2)); // a circle's depth at u from its axis
|
|
14025
|
+
const grow = (n, d) => ({ x: n.x - d, y: n.y - d, w: n.w + 2 * d, h: n.h + 2 * d });
|
|
14026
|
+
const WAVE = FC.wave / 0.2887; // a cubic's control offset that swings its curve by FC.wave each way
|
|
14027
|
+
// the document's wavy bottom at u (0 at its right, 1 at its left): how far above the box's bottom it is
|
|
14028
|
+
const waveIn = u => FC.wave - 3 * WAVE * u * (1 - u) * (2 * u - 1);
|
|
14029
|
+
// [text padding left, right, top, bottom] past FC.padX and FC.padY, out(n, d), inset(n, side, t), det(n): a shape's own
|
|
14030
|
+
const FC_SHAPES = {
|
|
14031
|
+
process: [() => [0, 0, 0, 0], boxOut(0), () => 0],
|
|
14032
|
+
terminator: [h => [h / 4, h / 4, 0, 0], (n, d = 0) => boxOut(n.h / 2)(n, d),
|
|
14033
|
+
(n, s, t) => { const r = n.h / 2; return s === 'L' || s === 'R' ? arcIn(r, t - n.y - r) : t < n.x + r ? arcIn(r, n.x + r - t) : t > n.x + n.w - r ? arcIn(r, t - n.x - n.w + r) : 0; }],
|
|
14034
|
+
io: [() => [FC.slant, FC.slant, 0, 0], (n, d = 0) => { const { x, y, w, h } = grow(n, d), k = FC.slant; return `M${r2(x + k)} ${r2(y)}H${r2(x + w)}L${r2(x + w - k)} ${r2(y + h)}H${r2(x)}Z`; },
|
|
14035
|
+
(n, s, t) => { const k = FC.slant, v = Math.min(1, Math.max(0, (t - n.y) / n.h)), u = Math.max(0, Math.min(1, (n.x + k - t) / k)), b = Math.max(0, Math.min(1, (t - n.x - n.w + k) / k));
|
|
14036
|
+
return s === 'L' ? k * (1 - v) : s === 'R' ? k * v : s === 'T' ? n.h * u : n.h * b; }],
|
|
14037
|
+
predefined: [() => [FC.side, FC.side, 0, 0], boxOut(0), () => 0,
|
|
14038
|
+
n => `<path d="M${r2(n.x + FC.side)} ${r2(n.y)}V${r2(n.y + n.h)}M${r2(n.x + n.w - FC.side)} ${r2(n.y)}V${r2(n.y + n.h)}" class="n-det" stroke="${FC.line}"/>`],
|
|
14039
|
+
doc: [() => [0, 0, 0, 2 * FC.wave], (n, d = 0) => { const { x, y, w, h } = grow(n, d), b = y + h - FC.wave;
|
|
14040
|
+
return `M${r2(x)} ${r2(y)}H${r2(x + w)}V${r2(b)}C${r2(x + 2 * w / 3)} ${r2(b - WAVE)} ${r2(x + w / 3)} ${r2(b + WAVE)} ${r2(x)} ${r2(b)}Z`; },
|
|
14041
|
+
(n, s, t) => (s === 'B' ? waveIn(Math.max(0, Math.min(1, (n.x + n.w - t) / n.w))) : 0), null, n => [n.y, n.y + n.h - FC.wave]],
|
|
14042
|
+
// the map's cylinder's outline; its lid's front edge drawn here, in the typed line colour
|
|
14043
|
+
database: [() => [0, 0, 12, 6], (n, d = 0) => SHAPE.cylinder.out(n, d), (n, s, t) => SHAPE.cylinder.inset(n, s, t),
|
|
14044
|
+
n => { const ry = SHAPE.cylinder.ry(n); return `<path d="M${r2(n.x)} ${r2(n.y + ry)}A${r2(n.w / 2)} ${ry} 0 0 0 ${r2(n.x + n.w)} ${r2(n.y + ry)}" class="n-det" fill="none" stroke="${FC.line}"/>`; },
|
|
14045
|
+
n => SHAPE.cylinder.face(n)],
|
|
14046
|
+
manual: [() => [0, 0, FC.slope, 0], (n, d = 0) => { const { x, y, w, h } = grow(n, d); return `M${r2(x)} ${r2(y + FC.slope)}L${r2(x + w)} ${r2(y)}V${r2(y + h)}H${r2(x)}Z`; },
|
|
14047
|
+
(n, s, t) => (s === 'T' ? FC.slope * Math.max(0, Math.min(1, 1 - (t - n.x) / n.w)) : s === 'L' && t < n.y + FC.slope ? n.w * (n.y + FC.slope - t) / FC.slope : 0), null, n => [n.y + FC.slope, n.y + n.h]],
|
|
14048
|
+
delay: [h => [0, h / 2, 0, 0], (n, d = 0) => { const { x, y, w, h } = grow(n, d), r = h / 2; return `M${r2(x)} ${r2(y)}H${r2(x + w - r)}A${r2(r)} ${r2(r)} 0 0 1 ${r2(x + w - r)} ${r2(y + h)}H${r2(x)}Z`; },
|
|
14049
|
+
(n, s, t) => { const r = n.h / 2; return s === 'R' ? arcIn(r, t - n.y - r) : (s === 'T' || s === 'B') && t > n.x + n.w - r ? arcIn(r, t - n.x - n.w + r) : 0; }],
|
|
14050
|
+
};
|
|
14051
|
+
const fcG = (n, shape, said, inner) => `<g class="node ${shape}" data-id="${esc(n.id)}" data-shape="${shape}" tabindex="-1" role="img" aria-label="${esc(n.aria || said)}">${inner}</g>`;
|
|
14052
|
+
const fcBody = d => `<path d="${d}" class="n-mask"/><path d="${d}" class="n-body" fill="${FC.bg}" stroke="${FC.line}"/>`;
|
|
14053
|
+
const fcText = (cx, y, t) => `<text x="${r2(cx)}" y="${r2(y)}" text-anchor="middle" class="c-name" fill="${FC.ink}" style="font:600 ${FC.px}px var(--um-sans)">${esc(t)}</text>`;
|
|
14054
|
+
const fcSaid = (n, word) => (FC_TXT(n.label) ? `${word} ${FC_TXT(n.label)}` : word);
|
|
14055
|
+
for (const [shape, [pad, out, inset, det, face]] of Object.entries(FC_SHAPES)) {
|
|
14056
|
+
const word = Object.values(FC_LOOK).find(l => l[0] === shape)[1];
|
|
14057
|
+
const S = SHAPE[shape] = { out, inset, face: face || (n => [n.y, n.y + n.h]),
|
|
14058
|
+
size(n) { const ls = flowWrap(n.label, FC.px, FC.max, 3), h = Math.max(FC.minH, 2 * FC.padY + Math.max(1, ls.length) * FC.lh), [l, r, t, b] = pad(h);
|
|
14059
|
+
return { w: Math.ceil(Math.max(FC.minW, ...ls.map(x => textW(x, FC.px) + 2 * FC.padX)) + l + r), h: Math.ceil(h + t + b) }; },
|
|
14060
|
+
draw(n) { const [l, r, t, b] = pad(n.h), w = n.w - l - r - 2 * FC.padX, ls = flowWrap(n.label, FC.px, FC.max, 3);
|
|
14061
|
+
const y0 = n.y + t + (n.h - t - b - ls.length * FC.lh) / 2, cx = n.x + l + (n.w - l - r) / 2;
|
|
14062
|
+
return fcG(n, shape, fcSaid(n, word), fcBody(out(n)) + (det ? det(n) : '') + ls.map((x, i) => fcText(cx, y0 + i * FC.lh + 12, fit(x, FC.px, w))).join('')); } };
|
|
14063
|
+
S.ports = (n, side, k = 1) => portsOn(S, n, side, k);
|
|
14064
|
+
}
|
|
14065
|
+
// the connector: a circle 28 px across, or as wide as its label (one or two characters) up to 40 px, a longer label cut
|
|
14066
|
+
SHAPE.connector = { out: SHAPE.initial.out, inset: SHAPE.initial.inset, face: n => [n.y, n.y + n.h],
|
|
14067
|
+
size: n => { const d = Math.min(40, Math.max(28, Math.ceil(textW(FC_TXT(n.label), FC.px) + 10))); return { w: d, h: d }; },
|
|
14068
|
+
draw: n => fcG(n, 'connector', fcSaid(n, 'Connector'), fcBody(SHAPE.initial.out(n)) + (FC_TXT(n.label) ? fcText(n.x + n.w / 2, n.y + n.h / 2 + 4, fit(FC_TXT(n.label), FC.px, n.w - 6)) : '')) };
|
|
14069
|
+
SHAPE.connector.ports = (n, side, k = 1) => portsOn(SHAPE.connector, n, side, k);
|
|
14070
|
+
|
|
14071
|
+
/* ---- the view read: its nodes, its lines, its flows in and out of each node, its starts and ends, its reading order ---- */
|
|
14072
|
+
function fcIndex(model, view) {
|
|
14073
|
+
const N = new Map(), E = new Map(), path = new Map();
|
|
14074
|
+
FC_ARR(model && model.nodes).forEach((n, k) => { if (FC_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) { N.set(n.id, n); path.set(n.id, `nodes[${k}]`); } });
|
|
14075
|
+
FC_ARR(model && model.edges).forEach((e, k) => { if (FC_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) { E.set(e.id, e); path.set(e.id, `edges[${k}]`); } });
|
|
14076
|
+
const ids = view ? [...new Set(FC_ARR(view.nodeIds).filter(id => N.has(id)))] : [...N.keys()].filter(id => Object.hasOwn(FC_LOOK, N.get(id).type)), on = new Set(ids);
|
|
14077
|
+
const list = view && Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => e.type === 'flow.Flow' || e.type === 'uml.CommentLink');
|
|
14078
|
+
const lines = list.filter(e => FC_OBJ(e) && typeof e.id === 'string' && on.has(e.from) && on.has(e.to)), flows = lines.filter(e => e.type === 'flow.Flow');
|
|
14079
|
+
const ins = id => flows.filter(e => e.to === id), outs = id => flows.filter(e => e.from === id);
|
|
14080
|
+
const term = id => N.get(id).type === 'flow.Terminator', starts = ids.filter(id => term(id) && !ins(id).length), ends = ids.filter(id => term(id) && !outs(id).length);
|
|
14081
|
+
// reading order: from each start along its flows, depth first, a decision's exits in the model's order (one branch read to its end before the next)
|
|
14082
|
+
const walk = [], seen = new Set(), visit = s => { const q = [s]; while (q.length) { const id = q.pop(); if (seen.has(id)) continue; seen.add(id); walk.push(id); outs(id).reverse().forEach(e => q.push(e.to)); } };
|
|
14083
|
+
starts.forEach(visit); const reached = new Set(seen); ids.forEach(visit);
|
|
14084
|
+
return { N, E, path, ids, lines, flows, ins, outs, starts, ends, walk, reached };
|
|
14085
|
+
}
|
|
14086
|
+
const fcName = x => String(x.label ?? x.id);
|
|
14087
|
+
const fcCalled = x => (x ? (FC_TXT(x.label) || fcLook(x)[1].toLowerCase()) : '?');
|
|
14088
|
+
const fcPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
14089
|
+
|
|
14090
|
+
// accessible names (A1): "Decision Fit for resale?, exits yes to Refund the payment, no to Rejection letter"; "Terminator Return closed, an end"
|
|
14091
|
+
function fcNodeName(I, x) {
|
|
14092
|
+
if (x.type === 'uml.Comment') return 'Comment: ' + fcName(x);
|
|
14093
|
+
const [, word] = fcLook(x), head = FC_TXT(x.label) ? `${word} ${FC_TXT(x.label)}` : word, o = I.outs(x.id);
|
|
14094
|
+
if (x.type === 'flow.Decision') return `${head}, ${o.length ? 'exits ' + o.map(e => `${FC_TXT(e.label) ? FC_TXT(e.label) + ' ' : ''}to ${fcCalled(I.N.get(e.to))}`).join(', ') : 'no exits'}`;
|
|
14095
|
+
if (x.type === 'flow.Terminator') return `${head}, ${I.starts.includes(x.id) ? 'a start' : I.ends.includes(x.id) ? 'an end' : 'flows in and out'}`;
|
|
14096
|
+
return head;
|
|
14097
|
+
}
|
|
14098
|
+
function fcLineName(I, e) {
|
|
14099
|
+
const t = FC_TXT(e.label), ends = `from ${fcCalled(I.N.get(e.from))} to ${fcCalled(I.N.get(e.to))}`;
|
|
14100
|
+
return e.type === 'uml.CommentLink' ? `Comment link ${ends}` : `Flow ${ends}${t ? ': ' + t : ''}`;
|
|
14101
|
+
}
|
|
14102
|
+
|
|
14103
|
+
/* ---- the legend: each line kind and each symbol the view draws (1.9's legendItem form) ---- */
|
|
14104
|
+
const fcKey = (svg, 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" fill="none">${svg}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
14105
|
+
const FC_KEYS = [
|
|
14106
|
+
['flow.Flow', '<path d="M2 8H18"/><path d="M17 4L26 8L17 12Z" fill="var(--um-muted)"/>', 'Flow', 'the next step'],
|
|
14107
|
+
['uml.CommentLink', '<path d="M2 8H26" stroke-dasharray="3 2"/>', 'Comment link', 'what a comment is about'],
|
|
14108
|
+
['flow.Terminator', '<rect x="3" y="3" width="22" height="10" rx="5"/>', 'Terminator', 'a start or an end'],
|
|
14109
|
+
['flow.Process', '<rect x="3" y="3" width="22" height="10"/>', 'Process', 'a step'],
|
|
14110
|
+
['flow.Decision', '<path d="M14 2L24 8L14 14L4 8Z"/>', 'Decision', 'a question, an exit per answer'],
|
|
14111
|
+
['flow.InputOutput', '<path d="M7 3H25L21 13H3Z"/>', 'Input/output', 'data read or written'],
|
|
14112
|
+
['flow.PredefinedProcess', '<rect x="3" y="3" width="22" height="10"/><path d="M6 3V13M22 3V13"/>', 'Predefined process', 'steps written out elsewhere'],
|
|
14113
|
+
['flow.Document', '<path d="M3 3H25V11C19 7 9 16 3 11Z"/>', 'Document', 'a document made or read'],
|
|
14114
|
+
['flow.Database', '<path d="M6 4V12A8 2 0 0 0 22 12V4"/><ellipse cx="14" cy="4" rx="8" ry="2"/>', 'Database', 'stored data'],
|
|
14115
|
+
['flow.ManualInput', '<path d="M3 7L25 3V13H3Z"/>', 'Manual input', 'data keyed in by hand'],
|
|
14116
|
+
['flow.Delay', '<path d="M4 3H18A5 5 0 0 1 18 13H4Z"/>', 'Delay', 'a wait'],
|
|
14117
|
+
['flow.Connector', '<circle cx="14" cy="8" r="5"/>', 'Connector', 'flows joined, or carried on elsewhere'],
|
|
14118
|
+
['uml.Comment', '<path d="M4 3H20L24 7V13H4Z"/>', 'Comment', 'a note on the chart'],
|
|
14119
|
+
];
|
|
14120
|
+
|
|
14121
|
+
/* ---- the view laid out: { model, view, pos, memo } ---- */
|
|
14122
|
+
function fcGeo({ model, view, pos, memo }) {
|
|
14123
|
+
const I = fcIndex(model, view), down = view.direction == null || view.direction === 'down', at = id => (FC_OBJ(pos) && Array.isArray(pos[id]) ? pos[id] : [0, 0]);
|
|
14124
|
+
const nodes = I.ids.map(id => { const x = I.N.get(id), [shape] = fcLook(x), d = { id, label: fcName(x), type: x.type, kind: 'file', shape, aria: fcNodeName(I, x), col: at(id)[0], row: at(id)[1] };
|
|
14125
|
+
return { ...d, ...SHAPE[shape].size(d) }; });
|
|
14126
|
+
const edges = I.lines.map(e => { const note = e.type === 'uml.CommentLink', label = note ? '' : FC_TXT(e.label);
|
|
14127
|
+
return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, ...(label ? { label, cut: 280 } : {}), end: note ? { from: 'none', to: 'none', dashed: true } : { from: 'none', to: 'arrow' },
|
|
14128
|
+
aria: fcLineName(I, e), ...(FC_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
|
|
14129
|
+
const g = layoutTyped({ nodes, edges, direction: down ? 'down' : 'right', gap: 48, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo);
|
|
14130
|
+
const types = new Set([...I.lines.map(e => e.type), ...I.ids.map(id => I.N.get(id).type)]);
|
|
14131
|
+
// a new object: what layoutTyped returned is never edited (stage 4 §2.4). The slots: one per node, so L and the palette hear the selection
|
|
14132
|
+
return { ...g, order: [...I.walk, ...I.lines.map(e => e.id)], slots: g.nodes.map(n => ({ after: n.id, x: n.x + n.w / 2, y: n.y + n.h / 2 })),
|
|
14133
|
+
legend: FC_KEYS.filter(([t]) => types.has(t)).map(([, svg, name, says]) => fcKey(svg, name, says)) };
|
|
14134
|
+
}
|
|
14135
|
+
|
|
14136
|
+
/* ---- validators (§7.2). Each problem: { level, rule, path, message, ids } ---- */
|
|
14137
|
+
const FC_RULES = {
|
|
14138
|
+
'flow-ends': 'a flow joins two flowchart nodes',
|
|
14139
|
+
'start-end': 'a view has exactly one start (a terminator with no flow in) and at least one end (a terminator with no flow out)',
|
|
14140
|
+
'terminator-flows': 'a start has one flow out; an end has none out',
|
|
14141
|
+
'decision-exits': 'a decision has two or more flows out, each labelled, no two with one label',
|
|
14142
|
+
reachable: 'every node is reached from a start',
|
|
14143
|
+
};
|
|
14144
|
+
function fcProblems(model, view) {
|
|
14145
|
+
const I = fcIndex(model, view), out = [], q = id => { const x = I.N.get(id); return x && FC_TXT(x.label) ? `${id} ("${FC_TXT(x.label)}")` : id; };
|
|
14146
|
+
const say = (rule, path, ids, msg, level = 'warning') => out.push({ level, rule, path, ids, message: `${rule}: ${msg}` });
|
|
14147
|
+
const shown = new Set(I.ids), mine = id => I.N.has(id) && FC_NODES.includes(I.N.get(id).type), list = es => es.map(e => e.id).join(', ');
|
|
14148
|
+
I.E.forEach((e, id) => { if (e.type !== 'flow.Flow' || !(shown.has(e.from) || shown.has(e.to))) return;
|
|
14149
|
+
[e.from, e.to].forEach(end => { if (I.N.has(end) && !mine(end)) say('flow-ends', I.path.get(id), [id, end], `flow ${id} ends on ${q(end)}, a ${String(I.N.get(end).type).split('.').pop()}: a flow joins two flowchart nodes`, 'error'); }); });
|
|
14150
|
+
if (!view) return out;
|
|
14151
|
+
const vp = `views[${FC_ARR(model.views).indexOf(view)}]`;
|
|
14152
|
+
if (I.starts.length !== 1) say('start-end', vp, [view.id, ...I.starts], `view ${view.id} has ${fcPlural(I.starts.length, 'start')}${I.starts.length ? ` (${I.starts.map(q).join(', ')})` : ''}; it wants one`);
|
|
14153
|
+
if (!I.ends.length) say('start-end', vp, [view.id], `view ${view.id} has no end: no terminator without a flow out`);
|
|
14154
|
+
I.ids.forEach(id => { const x = I.N.get(id), i = I.ins(id), o = I.outs(id), p = I.path.get(id);
|
|
14155
|
+
if (x.type === 'flow.Terminator' && !i.length && o.length !== 1) say('terminator-flows', p, [id, ...o.map(e => e.id)], `the start ${q(id)} has ${o.length ? `${o.length} flows out (${list(o)})` : 'no flow out'}; it wants one`);
|
|
14156
|
+
if (x.type === 'flow.Terminator' && i.length && o.length) say('terminator-flows', p, [id, ...o.map(e => e.id)], `the end ${q(id)} has flows out (${list(o)}); a terminator with flows in is an end`);
|
|
14157
|
+
if (x.type === 'flow.Decision') { const bare = o.filter(e => !FC_TXT(e.label)), by = new Map(); o.forEach(e => { const k = FC_TXT(e.label).toLowerCase(); if (k) by.set(k, [...(by.get(k) || []), e]); }); // as connect reads them
|
|
14158
|
+
const twice = [...by.values()].filter(es => es.length > 1).flat();
|
|
14159
|
+
if (o.length < 2) say('decision-exits', p, [id, ...o.map(e => e.id)], `${q(id)} has ${fcPlural(o.length, 'exit')}; a decision wants two or more`);
|
|
14160
|
+
if (bare.length) say('decision-exits', p, [id, ...bare.map(e => e.id)], `${fcPlural(bare.length, 'exit')} of ${q(id)} ${bare.length === 1 ? 'has' : 'have'} no label (${list(bare)})`);
|
|
14161
|
+
if (twice.length) say('decision-exits', p, [id, ...twice.map(e => e.id)], `exits of ${q(id)} share a label (${list(twice)})`); }
|
|
14162
|
+
if (I.starts.length && mine(id) && !I.reached.has(id)) say('reachable', p, [id], `${q(id)} is reached from no start`); });
|
|
14163
|
+
return out;
|
|
14164
|
+
}
|
|
14165
|
+
|
|
14166
|
+
/* ---- describe(): the view in sentences: each node in reading order (from the start, along its flows), then each line ---- */
|
|
14167
|
+
function fcDescribe(model, viewId) {
|
|
14168
|
+
const v = FC_ARR(model && model.views).find(x => FC_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
14169
|
+
const I = fcIndex(model, v);
|
|
14170
|
+
return [`${v.title || v.id}: a flowchart of ${fcPlural(I.ids.length, 'node')} and ${fcPlural(I.flows.length, 'flow')}.`,
|
|
14171
|
+
...I.walk.map(id => fcNodeName(I, I.N.get(id)) + '.'), ...I.lines.map(e => fcLineName(I, e) + '.')].join('\n');
|
|
14172
|
+
}
|
|
14173
|
+
|
|
14174
|
+
/* ---- editing: each hook returns commands, null to leave the gesture to the default, or { error } for the hint line ---- */
|
|
14175
|
+
const fcId = (model, base) => { const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
14176
|
+
let k = 1; while (used.has(base + k)) k++; return base + k; }; // as the canvas names a line: 'flow1'
|
|
14177
|
+
// connect: a flow joins two flowchart nodes (refused otherwise); a decision's second exit is labelled to answer its first,
|
|
14178
|
+
// yes then no, and an unlabelled first exit becomes yes
|
|
14179
|
+
function fcConnect(model, view, from, to, at, line) {
|
|
14180
|
+
if (!FC_OBJ(view) || !FC_OBJ(line) || line.type !== 'flow.Flow') return null;
|
|
14181
|
+
const I = fcIndex(model, null), bad = [from, to].find(id => !I.N.has(id) || !FC_NODES.includes(I.N.get(id).type));
|
|
14182
|
+
if (bad != null) return { error: `flow-ends: a flow joins two flowchart nodes, and ${fcCalled(I.N.get(bad))} is not one` };
|
|
14183
|
+
const o = [...I.E.values()].filter(e => e.type === 'flow.Flow' && e.from === from);
|
|
14184
|
+
if (I.N.get(from).type !== 'flow.Decision' || o.length !== 1) return null;
|
|
14185
|
+
const was = FC_TXT(o[0].label).toLowerCase(), label = was === 'yes' || !was ? 'no' : was === 'no' ? 'yes' : null;
|
|
14186
|
+
if (!label) return null;
|
|
14187
|
+
return [...(was ? [] : [{ op: 'set', id: o[0].id, path: 'label', value: 'yes' }]), { op: 'add', element: { id: fcId(model, 'flow'), type: 'flow.Flow', from, to, label }, view: view.id }];
|
|
14188
|
+
}
|
|
14189
|
+
// moveTo: a line dragged (or Alt + arrows on it) is refused, said on the hint line: a flow follows its ends. A view with no direction is laid out down, which the canvas's drop reads as laid out right (render.js hintAt reads
|
|
14190
|
+
// a view that says down): the pin is read here with the axes turned, as hintAt reads one. Anything else: null, the default
|
|
14191
|
+
function fcMoveTo(model, view, id, at) {
|
|
14192
|
+
if (FC_ARR(model && model.edges).some(e => FC_OBJ(e) && e.id === id)) return { error: 'A flow follows its nodes: move a node instead' };
|
|
14193
|
+
if (!FC_OBJ(view) || view.direction != null || !FC_OBJ(at) || !Array.isArray(at.pt) || !FC_OBJ(at.geo)) return null;
|
|
14194
|
+
const ns = FC_ARR(at.geo.nodes).filter(m => FC_OBJ(m) && Number.isFinite(m.col) && Number.isFinite(m.row) && !m.aside), n = ns.find(m => m.id === id);
|
|
14195
|
+
if (!n) return null;
|
|
14196
|
+
const [y, x] = at.pt, X = m => m.y + m.h / 2, Y = m => m.x, cols = [...new Set(ns.map(m => m.col))].sort((a, b) => a - b);
|
|
14197
|
+
const mid = c => { const k = ns.filter(m => m.col === c); return k.reduce((s, m) => s + X(m), 0) / k.length; };
|
|
14198
|
+
const first = cols[0], last = cols.at(-1), step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
|
|
14199
|
+
let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
|
|
14200
|
+
if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step); else if (x < mid(first) - step / 2) col = Math.max(0, first - Math.round((mid(first) - x) / step));
|
|
14201
|
+
const a = ns[0], b = ns.find(m => m.row !== a.row), pitch = b ? (Y(b) - Y(a)) / (b.row - a.row) : 108, row = Math.max(0, Math.round((y - (Y(a) - a.row * pitch) - 32) / pitch));
|
|
14202
|
+
return col === n.col && row === n.row ? [] : [{ op: 'set', view: view.id, path: ['layout', id], value: [col, row] }];
|
|
14203
|
+
}
|
|
14204
|
+
|
|
14205
|
+
/* ---- the type ---- */
|
|
14206
|
+
const FC_TYPE = registerDiagramType('flowchart', {
|
|
14207
|
+
title: 'flowchart',
|
|
14208
|
+
elements: [...FC_NODES, 'uml.Comment'].map(type => ({ type, label: FC_LOOK[type][1] })),
|
|
14209
|
+
lines: [{ type: 'flow.Flow', label: 'Flow' }, { type: 'uml.CommentLink', label: 'Comment link' }],
|
|
14210
|
+
layout: fcGeo,
|
|
14211
|
+
validate: fcProblems,
|
|
14212
|
+
rules: FC_RULES,
|
|
14213
|
+
describe: fcDescribe,
|
|
14214
|
+
// F2 on a node or a flow sets its label: the default
|
|
14215
|
+
edits: { connect: fcConnect, moveTo: fcMoveTo },
|
|
14216
|
+
// the PDF's tables: a node's symbol; a line's text, which flags a decision's exit with no label (milestone 2 §9)
|
|
14217
|
+
word: (model, id) => { const x = FC_ARR(model && model.nodes).find(n => FC_OBJ(n) && n.id === id); return x && Object.hasOwn(FC_LOOK, x.type) ? FC_LOOK[x.type][1] : null; },
|
|
14218
|
+
text: (model, view, id) => { const by = x => FC_ARR(x).find(y => FC_OBJ(y) && y.id === id), e = by(model && model.edges), d = e && e.type === 'flow.Flow' && FC_ARR(model.nodes).find(n => FC_OBJ(n) && n.id === e.from);
|
|
14219
|
+
return e && !FC_TXT(e.label) && d && d.type === 'flow.Decision' ? `an exit of ${fcCalled(d)} with no label` : null; },
|
|
14220
|
+
});
|
|
14221
|
+
/* ---------- the data flow diagram, dfd (work package M2.4, notes/milestone-2.md §4.3, §6.1, §6.4, §7.3) ----------
|
|
14222
|
+
Processes, data stores and external entities, joined by data flows; comments. Two skins on one geometry (§4.3): each
|
|
14223
|
+
node is sized for the larger of its Yourdon and its Gane and Sarson look, and its shape's name carries the skin
|
|
14224
|
+
(dfd-process-yourdon), so a change of view.notation repaints and lays nothing out (`shape` is no geometry, `notation`
|
|
14225
|
+
no view geometry). Numbers are stored (props.number) and checked under their parent process's; a process that opens a
|
|
14226
|
+
level (opensView, a dfd view whose parent is the view the process is on) balances against it (§6.4). D on a selected
|
|
14227
|
+
process that opens no view makes its level, balanced from the start (§6.1). Laid out by layoutTyped from the host's
|
|
14228
|
+
ranking, repair and reroute off (S19). Ships in lib/umlimator-all.js only. */
|
|
14229
|
+
|
|
14230
|
+
const DFD_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), DFD_ARR = x => (Array.isArray(x) ? x : []);
|
|
14231
|
+
const DFD_HAS = x => x != null && String(x).trim() !== '';
|
|
14232
|
+
const dfdNum = x => (DFD_OBJ(x && x.props) && DFD_HAS(x.props.number) ? String(x.props.number).trim() : '');
|
|
14233
|
+
const dfdPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
14234
|
+
const DFD_LINES = ['dfd.DataFlow', 'uml.CommentLink'];
|
|
14235
|
+
const DFD_KIND = { 'dfd.Process': 'process', 'dfd.DataStore': 'store', 'dfd.ExternalEntity': 'entity' };
|
|
14236
|
+
const DFD_WORD = { 'dfd.Process': 'Process', 'dfd.DataStore': 'Data store', 'dfd.ExternalEntity': 'External entity', 'uml.Comment': 'Comment' };
|
|
14237
|
+
const dfdSkin = view => (view && view.notation === 'gane-sarson' ? 'gane-sarson' : 'yourdon');
|
|
14238
|
+
|
|
14239
|
+
/* ---- the shapes (§4.3): one size for both skins; each draws its number and name inside its box ---- */
|
|
14240
|
+
const DF = { px: 12, lh: 16, nmax: 160, num: 11, numh: 14, pad: 10, k: 1.42, band: 22, shadow: 4, minW: 96 };
|
|
14241
|
+
const dfdName = (t, k) => flowWrap(t, DF.px, DF.nmax, k);
|
|
14242
|
+
const dfdTextW = ls => Math.max(0, ...ls.map(l => textW(l, DF.px)));
|
|
14243
|
+
const dfdText = (x, y, t, px, wt, fill, max, cls, anchor = 'middle', more = '') => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="${anchor}" class="${cls}" fill="${fill}"${more} style="font:${wt} ${px}px var(--um-sans)">${esc(fit(t, px, max))}</text>`;
|
|
14244
|
+
const dfdNode = (n, kind, inner) => `<g class="node dfd-${kind}" data-id="${esc(n.id)}" data-shape="${n.shape}" tabindex="-1" role="img" aria-label="${esc(n.aria || n.label || '')}">${inner}</g>`;
|
|
14245
|
+
const dfdBody = (d, c, f) => `<path d="${d}" class="n-mask"/><path d="${d}" class="n-body" fill="${f}" stroke="${c}"/>`;
|
|
14246
|
+
const dfdRect = (x, y, w, h) => `M${r2(x)} ${r2(y)}H${r2(x + w)}V${r2(y + h)}H${r2(x)}Z`;
|
|
14247
|
+
const dfdSquare = { out: (n, d = 0) => dfdRect(n.x - d, n.y - d, n.w + 2 * d, n.h + 2 * d), inset: () => 0 };
|
|
14248
|
+
// name lines centred in the band [y0, y1] of the box, the first with data-o (F2's field goes over it)
|
|
14249
|
+
const dfdNameSvg = (n, ls, cx, y0, y1, max) => { const y = (y0 + y1) / 2 - ls.length * DF.lh / 2;
|
|
14250
|
+
return ls.map((l, i) => dfdText(cx, y + i * DF.lh + 12, l, DF.px, 600, 'var(--um-text)', max, 'c-name', 'middle', i ? '' : ` data-o="${esc(n.id)}"`)).join(''); };
|
|
14251
|
+
|
|
14252
|
+
// a process: Yourdon's ellipse round its number over its name (the text block 1.42 times, so its corners fit inside it), or
|
|
14253
|
+
// Gane and Sarson's rounded rectangle with the number in a band across its top
|
|
14254
|
+
const dfdProcParts = n => { const num = String(n.number ?? ''), ls = dfdName(n.label, 3), tw = Math.max(dfdTextW(ls), num ? Math.min(DF.nmax, textW(num, DF.num)) : 0), th = (num ? DF.numh : 0) + ls.length * DF.lh;
|
|
14255
|
+
return { num, ls, w: Math.ceil(Math.max(DF.minW, tw * DF.k + 16, tw + 2 * DF.pad)), h: Math.ceil(Math.max(56, th * DF.k + 12, DF.band + ls.length * DF.lh + 2 * DF.pad)) }; };
|
|
14256
|
+
const dfdProcess = skin => ({
|
|
14257
|
+
size: n => { const p = dfdProcParts(n); return { w: p.w, h: p.h }; },
|
|
14258
|
+
...(skin === 'yourdon' ? { out: SHAPE.ellipse.out, inset: SHAPE.ellipse.inset } : { out: SHAPE.box.out, inset: SHAPE.box.inset }),
|
|
14259
|
+
draw(n, c, f) {
|
|
14260
|
+
const { num, ls } = dfdProcParts(n), cx = n.x + n.w / 2;
|
|
14261
|
+
if (skin === 'yourdon') { const max = (n.w - 16) / DF.k, y0 = n.y + n.h / 2 - ((num ? DF.numh : 0) + ls.length * DF.lh) / 2;
|
|
14262
|
+
return dfdNode(n, 'process', dfdBody(SHAPE.ellipse.out(n), c, f) + (num ? dfdText(cx, y0 + 10.5, num, DF.num, 500, 'var(--um-muted)', max, 'c-st') : '')
|
|
14263
|
+
+ dfdNameSvg(n, ls, cx, y0 + (num ? DF.numh : 0), n.y + n.h / 2 + ((num ? DF.numh : 0) + ls.length * DF.lh) / 2, max)); }
|
|
14264
|
+
const max = n.w - 2 * DF.pad;
|
|
14265
|
+
return dfdNode(n, 'process', dfdBody(SHAPE.box.out(n), c, f) + `<path d="M${r2(n.x)} ${r2(n.y + DF.band)}H${r2(n.x + n.w)}" class="n-det" stroke="${c}"/>`
|
|
14266
|
+
+ (num ? dfdText(cx, n.y + 15, num, DF.num, 500, 'var(--um-muted)', max, 'c-st') : '') + dfdNameSvg(n, ls, cx, n.y + DF.band, n.y + n.h, max));
|
|
14267
|
+
},
|
|
14268
|
+
});
|
|
14269
|
+
// a data store: Yourdon's two lines with the number before the name, or Gane and Sarson's rectangle open on the right,
|
|
14270
|
+
// its number in an id box on the left
|
|
14271
|
+
const dfdStoreParts = n => { const num = String(n.number ?? ''), ls = dfdName(n.label, 2), nw = num ? Math.min(DF.nmax, textW(num, DF.num)) : 0, ib = Math.max(28, Math.ceil(nw) + 12), tw = dfdTextW(ls);
|
|
14272
|
+
return { num, ls, nw, ib, w: Math.ceil(Math.max(DF.minW, ib + tw + 2 * DF.pad, nw + 8 + tw + 2 * DF.pad)), h: Math.max(36, ls.length * DF.lh + 16) }; };
|
|
14273
|
+
const dfdStore = skin => ({
|
|
14274
|
+
size: n => { const p = dfdStoreParts(n); return { w: p.w, h: p.h }; },
|
|
14275
|
+
...dfdSquare,
|
|
14276
|
+
draw(n, c, f) {
|
|
14277
|
+
const { num, ls, nw, ib } = dfdStoreParts(n), { x, y, w, h } = n, mask = `<path d="${dfdRect(x, y, w, h)}" class="n-mask"/>`;
|
|
14278
|
+
if (skin === 'yourdon') { const tx = x + DF.pad + (num ? nw + 8 : 0), max = x + w - DF.pad - tx;
|
|
14279
|
+
return dfdNode(n, 'store', mask + `<path d="${dfdRect(x, y, w, h)}" fill="${f}" stroke="none"/><path d="M${r2(x)} ${r2(y)}H${r2(x + w)}M${r2(x)} ${r2(y + h)}H${r2(x + w)}" class="n-body" fill="none" stroke="${c}"/>`
|
|
14280
|
+
+ (num ? dfdText(x + DF.pad, y + h / 2 + 4, num, DF.num, 500, 'var(--um-muted)', nw + 1, 'c-st', 'start') : '') + dfdNameSvg(n, ls, tx + max / 2, y, y + h, max)); }
|
|
14281
|
+
const max = w - ib - 2 * DF.pad;
|
|
14282
|
+
return dfdNode(n, 'store', mask + `<path d="${dfdRect(x, y, w, h)}" fill="${f}" stroke="none"/><path d="M${r2(x + w)} ${r2(y)}H${r2(x)}V${r2(y + h)}H${r2(x + w)}M${r2(x + ib)} ${r2(y)}V${r2(y + h)}" class="n-body" fill="none" stroke="${c}"/>`
|
|
14283
|
+
+ (num ? dfdText(x + ib / 2, y + h / 2 + 4, num, DF.num, 500, 'var(--um-muted)', ib - 6, 'c-st') : '') + dfdNameSvg(n, ls, x + ib + DF.pad + max / 2, y, y + h, max));
|
|
14284
|
+
},
|
|
14285
|
+
});
|
|
14286
|
+
// an external entity: a rectangle; in Gane and Sarson a 4 px shadow inside the box, at its right and bottom
|
|
14287
|
+
const dfdEntityParts = n => { const ls = dfdName(n.label, 3); return { ls, w: Math.ceil(Math.max(DF.minW, dfdTextW(ls) + 2 * DF.pad + DF.shadow)), h: ls.length * DF.lh + 16 + DF.shadow }; };
|
|
14288
|
+
const dfdEntity = skin => ({
|
|
14289
|
+
size: n => { const p = dfdEntityParts(n); return { w: p.w, h: p.h }; },
|
|
14290
|
+
...dfdSquare,
|
|
14291
|
+
draw(n, c, f) {
|
|
14292
|
+
const { ls } = dfdEntityParts(n), { x, y, w, h } = n, s = skin === 'yourdon' ? 0 : DF.shadow;
|
|
14293
|
+
const body = s ? `<path d="${dfdRect(x, y, w, h)}" class="n-mask"/><path d="${dfdRect(x + s, y + s, w - s, h - s)}" class="n-det" fill="${c}" stroke="${c}"/><path d="${dfdRect(x, y, w - s, h - s)}" class="n-body" fill="${f}" stroke="${c}"/>` : dfdBody(dfdRect(x, y, w, h), c, f);
|
|
14294
|
+
return dfdNode(n, 'entity', body + dfdNameSvg(n, ls, x + (w - s) / 2, y, y + h - s, w - s - 2 * DF.pad));
|
|
14295
|
+
},
|
|
14296
|
+
});
|
|
14297
|
+
for (const skin of ['yourdon', 'gane-sarson']) for (const [kind, make] of [['process', dfdProcess], ['store', dfdStore], ['entity', dfdEntity]]) {
|
|
14298
|
+
const S = SHAPE[`dfd-${kind}-${skin}`] = make(skin); S.face = n => [n.y, n.y + n.h]; S.ports = (n, side, k = 1) => portsOn(S, n, side, k); }
|
|
14299
|
+
|
|
14300
|
+
/* ---- the model read: its elements, the lines a view draws, its levels ---- */
|
|
14301
|
+
function dfdIndex(model) {
|
|
14302
|
+
const at = new Map(), path = new Map(), views = new Map();
|
|
14303
|
+
DFD_ARR(model && model.nodes).forEach((x, i) => { if (DFD_OBJ(x) && typeof x.id === 'string' && !at.has(x.id)) { at.set(x.id, x); path.set(x.id, `nodes[${i}]`); } });
|
|
14304
|
+
DFD_ARR(model && model.views).forEach(v => { if (DFD_OBJ(v) && typeof v.id === 'string' && !views.has(v.id)) views.set(v.id, v); });
|
|
14305
|
+
const flows = DFD_ARR(model && model.edges).map((e, i) => [e, `edges[${i}]`]).filter(([e]) => DFD_OBJ(e) && e.type === 'dfd.DataFlow');
|
|
14306
|
+
const ty = id => (at.get(id) || {}).type, proc = id => ty(id) === 'dfd.Process';
|
|
14307
|
+
const name = id => { const x = at.get(id); if (!x) return String(id); const k = dfdNum(x); return (k ? k + ' ' : '') + String(x.label ?? x.id); };
|
|
14308
|
+
const q = id => `${(DFD_WORD[ty(id)] || 'element').toLowerCase()} "${name(id)}"`;
|
|
14309
|
+
// a level: the view a process opens, its parent view, and its own processes, those it shows and its parent view does not
|
|
14310
|
+
const level = p => { const v = views.get((at.get(p) || {}).opensView), pv = v && views.get(v.parent), up = new Set(DFD_ARR(pv && pv.nodeIds));
|
|
14311
|
+
return v ? { v, pv, up, kids: DFD_ARR(v.nodeIds).filter(id => proc(id) && id !== p && !up.has(id)) } : null; };
|
|
14312
|
+
const parentOf = vid => [...at.values()].find(x => x.type === 'dfd.Process' && x.opensView === vid) || null; // the process a view is the level of
|
|
14313
|
+
return { at, path, views, flows, ty, proc, name, q, level, parentOf };
|
|
14314
|
+
}
|
|
14315
|
+
// the lines drawn: those the view lists, or with no list the model's of this type's kinds, between two nodes it shows; on a
|
|
14316
|
+
// level (a view a process opens) with no list, not those with both ends on its parent view: they are the parent level's,
|
|
14317
|
+
// and reach the level only through its own processes (Yourdon and DeMarco's levelling)
|
|
14318
|
+
function dfdLines(model, view) {
|
|
14319
|
+
const E = new Map(); DFD_ARR(model.edges).forEach(e => { if (DFD_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
|
|
14320
|
+
const shown = new Set(DFD_ARR(view.nodeIds)), listed = Array.isArray(view.edges), list = listed ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => DFD_LINES.includes(e.type));
|
|
14321
|
+
const lv = !listed && view.parent !== view.id && DFD_ARR(model.nodes).some(x => DFD_OBJ(x) && x.type === 'dfd.Process' && x.opensView === view.id);
|
|
14322
|
+
const up = new Set(lv ? DFD_ARR((DFD_ARR(model.views).find(v => DFD_OBJ(v) && v.id === view.parent) || {}).nodeIds) : []);
|
|
14323
|
+
return list.filter(e => DFD_OBJ(e) && typeof e.id === 'string' && shown.has(e.from) && shown.has(e.to) && !(up.has(e.from) && up.has(e.to)));
|
|
14324
|
+
}
|
|
14325
|
+
// accessible names (A1): "Process 1 Take order, opens level 1 Take order, 2 flows in, 2 out"; a flow: "Data flow basket from Customer to 1 Take order"
|
|
14326
|
+
function dfdNodeName(I, id, lines) {
|
|
14327
|
+
const x = I.at.get(id); if (x.type === 'uml.Comment') return 'Comment: ' + String(x.label ?? '');
|
|
14328
|
+
const ins = lines.filter(e => e.type === 'dfd.DataFlow' && e.to === id && e.from !== id).length, outs = lines.filter(e => e.type === 'dfd.DataFlow' && e.from === id && e.to !== id).length;
|
|
14329
|
+
const v = x.type === 'dfd.Process' && I.views.get(x.opensView);
|
|
14330
|
+
return `${DFD_WORD[x.type] || String(x.type).split('.').pop()} ${I.name(id)}${v ? `, opens level ${v.title || v.id}` : ''}, ${dfdPlural(ins, 'flow')} in, ${outs} out`;
|
|
14331
|
+
}
|
|
14332
|
+
const dfdLineName = (I, e) => `${e.type === 'dfd.DataFlow' ? 'Data flow' : 'Comment link'}${DFD_HAS(e.label) ? ' ' + e.label : ''} from ${I.name(e.from)} to ${I.name(e.to)}`;
|
|
14333
|
+
// reading order: the processes by number, then the stores, the entities and the rest as the view lists them, then the lines
|
|
14334
|
+
function dfdOrder(I, ids, lines) {
|
|
14335
|
+
const rank = id => ['dfd.Process', 'dfd.DataStore', 'dfd.ExternalEntity'].indexOf(I.ty(id)) + 1 || 4, byNum = (a, b) => dfdNum(I.at.get(a)).localeCompare(dfdNum(I.at.get(b)), undefined, { numeric: true });
|
|
14336
|
+
return [...[...ids].sort((a, b) => rank(a) - rank(b) || (rank(a) === 1 ? byNum(a, b) : 0)), ...lines.map(e => e.id)];
|
|
14337
|
+
}
|
|
14338
|
+
|
|
14339
|
+
/* ---- the legend: each symbol drawn, in the view's skin, and the line kinds ---- */
|
|
14340
|
+
const DFD_GLYPH = {
|
|
14341
|
+
'process-yourdon': '<ellipse cx="14" cy="8" rx="12" ry="6.5" fill="var(--um-panel)"/>', 'process-gane-sarson': '<rect x="3" y="1.5" width="22" height="13" rx="3" fill="var(--um-panel)"/><path d="M3 5.5H25"/>',
|
|
14342
|
+
'store-yourdon': '<path d="M2 3H26M2 13H26"/>', 'store-gane-sarson': '<path d="M26 3H2V13H26M8 3V13"/>',
|
|
14343
|
+
'entity-yourdon': '<rect x="3" y="2" width="22" height="12" fill="var(--um-panel)"/>', 'entity-gane-sarson': '<rect x="5" y="4" width="21" height="11" fill="var(--um-muted)"/><rect x="3" y="2" width="21" height="11" fill="var(--um-panel)"/>',
|
|
14344
|
+
'dfd.DataFlow': '<path d="M2 8H17"/><path d="M17 4L26 8L17 12Z" fill="var(--um-muted)"/>', 'uml.CommentLink': '<path d="M2 8H26" stroke-dasharray="3 2"/>',
|
|
14345
|
+
};
|
|
14346
|
+
const DFD_SAYS = { process: 'transforms the data flowing in into the data flowing out', store: 'holds data at rest', entity: 'outside the system: a source or a sink of data',
|
|
14347
|
+
'dfd.DataFlow': 'data moving, named by what it carries', 'uml.CommentLink': 'what a comment is about' };
|
|
14348
|
+
const dfdLegendItem = (glyph, 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" fill="none">${glyph}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
14349
|
+
const dfdLegend = (I, ids, lines, skin) => [
|
|
14350
|
+
...['process', 'store', 'entity'].filter(k => ids.some(id => DFD_KIND[I.ty(id)] === k)).map(k => dfdLegendItem(DFD_GLYPH[`${k}-${skin}`], DFD_WORD[Object.keys(DFD_KIND).find(t => DFD_KIND[t] === k)], DFD_SAYS[k])),
|
|
14351
|
+
...DFD_LINES.filter(t => lines.some(e => e.type === t)).map(t => dfdLegendItem(DFD_GLYPH[t], t === 'dfd.DataFlow' ? 'Data flow' : 'Comment link', DFD_SAYS[t]))];
|
|
14352
|
+
|
|
14353
|
+
/* ---- the view laid out (§7): labels cut at 280 with room made for them (labelGaps); a gap of 64 px, as 48 laid a label of
|
|
14354
|
+
22-data-flow.json on a line, down (the note's §7.3, amended) ---- */
|
|
14355
|
+
const DFD_END = { 'dfd.DataFlow': { from: 'none', to: 'arrow' }, 'uml.CommentLink': { from: 'none', to: 'none', dashed: true } };
|
|
14356
|
+
function dfdGeo({ model, view, pos, memo }) {
|
|
14357
|
+
const I = dfdIndex(model), ids = DFD_ARR(view.nodeIds).filter((id, k, a) => I.at.has(id) && a.indexOf(id) === k), lines = dfdLines(model, view), skin = dfdSkin(view), P = id => (pos && Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
|
|
14358
|
+
const nodes = ids.map(id => { const x = I.at.get(id), k = DFD_KIND[x.type];
|
|
14359
|
+
const d = { id, label: String(x.label ?? id), type: x.type || 'dfd.Process', kind: 'file', ...(k ? { shape: `dfd-${k}-${skin}`, number: dfdNum(x) } : x.type === 'uml.Comment' ? { shape: 'note' } : { shape: 'classifier', compartments: [] }),
|
|
14360
|
+
aria: dfdNodeName(I, id, lines), col: P(id)[0], row: P(id)[1] };
|
|
14361
|
+
return { ...d, ...SHAPE[d.shape].size(d) }; });
|
|
14362
|
+
const edges = lines.map(e => ({ id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, ...(DFD_HAS(e.label) ? { label: String(e.label), cut: 280 } : {}),
|
|
14363
|
+
end: DFD_END[e.type] || DFD_END['dfd.DataFlow'], aria: dfdLineName(I, e), ...(DFD_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }));
|
|
14364
|
+
const g = layoutTyped({ nodes, edges, direction: view.direction === 'down' ? 'down' : 'right', gap: 64, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo);
|
|
14365
|
+
return { ...g, legend: dfdLegend(I, ids, lines, skin), order: dfdOrder(I, ids, lines) }; // a new object: what layoutTyped returned is never edited (§2.6)
|
|
14366
|
+
}
|
|
14367
|
+
|
|
14368
|
+
/* ---- validators (§6.4, §7.3). Each problem: { level, rule, path, message, ids }; on the whole model, once a type ---- */
|
|
14369
|
+
const DFD_RULES = {
|
|
14370
|
+
'dfd-flow-process': 'a flow has a process at one end at least: no flow between two stores, two entities, or an entity and a store',
|
|
14371
|
+
'dfd-process-io': 'a process has a flow in and a flow out',
|
|
14372
|
+
'dfd-flow-label': 'a flow to or from a process has a label',
|
|
14373
|
+
'dfd-numbers': 'a level\'s processes are numbered under their parent process\'s (1.1, 1.2 under 1); no number is used twice',
|
|
14374
|
+
'dfd-balance': 'the flows between a process and the rest of its level match its level\'s flows across the level\'s edge, by label, far end and direction',
|
|
14375
|
+
'dfd-level': 'a process opens a dfd view whose parent is a view showing the process; the level is that process\'s alone and does not show it',
|
|
14376
|
+
};
|
|
14377
|
+
function dfdProblems(model) {
|
|
14378
|
+
const I = dfdIndex(model), out = [], say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
14379
|
+
const procs = [...I.at.values()].filter(x => x.type === 'dfd.Process');
|
|
14380
|
+
I.flows.forEach(([e, path]) => { if (!I.at.has(e.from) || !I.at.has(e.to)) return;
|
|
14381
|
+
if (!I.proc(e.from) && !I.proc(e.to)) say('warning', 'dfd-flow-process', path, [e.id, e.from, e.to], `${e.id} runs from ${I.q(e.from)} to ${I.q(e.to)}: a flow has a process at one end at least`);
|
|
14382
|
+
else if (!DFD_HAS(e.label)) say('warning', 'dfd-flow-label', path, [e.id], `${e.id}, from ${I.q(e.from)} to ${I.q(e.to)}, has no label: a flow is named by the data it carries`); });
|
|
14383
|
+
procs.forEach(p => { const has = w => I.flows.some(([e]) => e[w] === p.id && e[w === 'from' ? 'to' : 'from'] !== p.id), miss = ['in', 'out'].filter((k, i) => !has(i ? 'from' : 'to'));
|
|
14384
|
+
if (miss.length) say('warning', 'dfd-process-io', I.path.get(p.id), [p.id], `${I.q(p.id)} has no flow ${miss.join(' and no flow ')}`); });
|
|
14385
|
+
// numbers: none used twice among processes, nor among stores; a level's processes under their parent's
|
|
14386
|
+
for (const t of ['dfd.Process', 'dfd.DataStore']) { const by = new Map(); [...I.at.values()].filter(x => x.type === t && dfdNum(x)).forEach(x => by.set(dfdNum(x), [...(by.get(dfdNum(x)) || []), x.id]));
|
|
14387
|
+
by.forEach((list, k) => { if (list.length > 1) say('warning', 'dfd-numbers', I.path.get(list[1]), list, `${list.length} ${t === 'dfd.Process' ? 'processes' : 'stores'} are numbered ${k}: ${list.map(id => JSON.stringify(String(I.at.get(id).label ?? id))).join(', ')}`); }); }
|
|
14388
|
+
procs.forEach(p => { const L = I.level(p.id), n = dfdNum(p); if (!L || L.v.diagram !== 'dfd' || !n || I.parentOf(L.v.id) !== p) return;
|
|
14389
|
+
L.kids.filter(id => !new RegExp(`^${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.\\d+$`).test(dfdNum(I.at.get(id)))).forEach(id =>
|
|
14390
|
+
say('warning', 'dfd-numbers', I.path.get(id), [id, p.id], `${I.q(id)}, on the level of ${I.q(p.id)}, is numbered ${dfdNum(I.at.get(id)) || 'nothing'}: its number is ${n}.1, ${n}.2, …`)); });
|
|
14391
|
+
// a level: a dfd view whose parent shows the process; then balanced (§6.4), each pair by label, far end and direction
|
|
14392
|
+
procs.filter(p => p.opensView != null).forEach(p => { const L = I.level(p.id), path = I.path.get(p.id);
|
|
14393
|
+
const first = L && I.parentOf(L.v.id), bad = !L ? `opens "${p.opensView}", which is no view` : L.v.diagram !== 'dfd' ? `opens "${L.v.id}", a ${L.v.diagram || 'map'} view, not a level`
|
|
14394
|
+
: L.v === L.pv ? `opens "${L.v.id}", whose parent view is itself` : !L.up.has(p.id) ? `opens "${L.v.id}", whose parent view ${L.pv ? `"${L.pv.id}" does not show it` : `is ${L.v.parent == null ? 'not given' : `"${L.v.parent}", which is no view`}`}`
|
|
14395
|
+
: first !== p ? `opens "${L.v.id}", which ${I.q(first.id)} opens already: a level is one process's` : DFD_ARR(L.v.nodeIds).includes(p.id) ? `opens "${L.v.id}", which shows the process itself: its level shows its parts` : '';
|
|
14396
|
+
if (bad) return say('warning', 'dfd-level', path, [p.id, ...(first && first !== p ? [first.id] : [])], `${I.q(p.id)} ${bad}`);
|
|
14397
|
+
// the flows each view draws: the process's on its parent view; on the level, those across the edge of its own processes,
|
|
14398
|
+
// but to a store the parent view does not show (a local store)
|
|
14399
|
+
const kids = new Set(L.kids), key = (e, mine) => { const o = e.from === mine ? e.to : e.from; return [e.from === mine ? 'out' : 'in', o, String(e.label ?? '').trim()]; };
|
|
14400
|
+
const drawn = v => dfdLines(model, v).filter(e => e.type === 'dfd.DataFlow');
|
|
14401
|
+
const outer = drawn(L.pv).filter(e => (e.from === p.id) !== (e.to === p.id)).map(e => [e, key(e, p.id)]);
|
|
14402
|
+
const inner = drawn(L.v).filter(e => { const o = kids.has(e.from) ? e.to : e.from; return kids.has(e.from) !== kids.has(e.to) && o !== p.id && (L.up.has(o) || I.ty(o) !== 'dfd.DataStore'); }).map(e => [e, key(e, kids.has(e.from) ? e.from : e.to)]);
|
|
14403
|
+
const left = [...inner], miss = outer.filter(([, k]) => { const i = left.findIndex(([, j]) => j.join('\u0001') === k.join('\u0001')); if (i < 0) return true; left.splice(i, 1); return false; });
|
|
14404
|
+
if (!miss.length && !left.length) return;
|
|
14405
|
+
const said = ([e, [dir, o, l]]) => `"${l}" ${dir === 'in' ? 'in from' : 'out to'} ${I.q(o)} (${e.id})`, lv = `level "${L.v.title || L.v.id}"`;
|
|
14406
|
+
say('error', 'dfd-balance', path, [p.id, ...miss.map(([e]) => e.id), ...left.map(([e]) => e.id)], `${I.q(p.id)} does not balance with ${lv}: ${[
|
|
14407
|
+
miss.length ? `its flow${miss.length > 1 ? 's' : ''} ${miss.map(said).join(', ')} ${miss.length > 1 ? 'have' : 'has'} none at the level` : '',
|
|
14408
|
+
left.length ? `the level's flow${left.length > 1 ? 's' : ''} ${left.map(said).join(', ')} ${left.length > 1 ? 'are' : 'is'} not the process's` : ''].filter(Boolean).join('; ')}`); });
|
|
14409
|
+
return out;
|
|
14410
|
+
}
|
|
14411
|
+
|
|
14412
|
+
/* ---- describe(): the view in sentences, each element by its accessible name in reading order, then each line ---- */
|
|
14413
|
+
function dfdDescribe(model, viewId) {
|
|
14414
|
+
const v = DFD_ARR(model && model.views).find(x => DFD_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
14415
|
+
const I = dfdIndex(model), ids = DFD_ARR(v.nodeIds).filter((id, k, a) => I.at.has(id) && a.indexOf(id) === k), lines = dfdLines(model, v), by = new Map(lines.map(e => [e.id, e])), up = I.parentOf(v.id);
|
|
14416
|
+
return [`${v.title || v.id}: a data flow diagram${up ? `, the level of ${I.q(up.id)},` : ''} of ${dfdPlural(ids.length, 'element')} and ${dfdPlural(lines.length, 'line')}.`,
|
|
14417
|
+
...dfdOrder(I, ids, lines).map(id => (by.has(id) ? dfdLineName(I, by.get(id)) : dfdNodeName(I, id, lines)) + '.')].join('\n');
|
|
14418
|
+
}
|
|
14419
|
+
|
|
14420
|
+
/* ---- editing (§6.1, §7.3) ---- */
|
|
14421
|
+
const dfdUsed = model => { const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; }); return used; };
|
|
14422
|
+
// a free id: base, else base-2, base-3, …; numbered (the palette's): base1, base2, …. Taken once given
|
|
14423
|
+
const dfdFree = (used, base, numbered) => { let k = 1, id = numbered ? base + 1 : base; while (used.has(id)) id = base + (numbered ? '' : '-') + ++k; used.add(id); return id; };
|
|
14424
|
+
// the next free number of a type after pre: pre1, pre2, …
|
|
14425
|
+
const dfdFreeNum = (I, type, pre) => { const taken = new Set([...I.at.values()].filter(x => x.type === type).map(dfdNum)); let k = 1; while (taken.has(pre + k)) k++; return pre + k; };
|
|
14426
|
+
// the next free number: under the view's parent process (1.1, 1.2 under 1), else 1, 2, …; a store's D1, D2, …
|
|
14427
|
+
function dfdNextNumber(model, view, type) {
|
|
14428
|
+
const I = dfdIndex(model), up = type === 'dfd.Process' && I.parentOf(view.id);
|
|
14429
|
+
return dfdFreeNum(I, type, type === 'dfd.DataStore' ? 'D' : up && dfdNum(up) ? dfdNum(up) + '.' : '');
|
|
14430
|
+
}
|
|
14431
|
+
// the palette's add: a process or a store with the next free number
|
|
14432
|
+
function dfdAdd(model, view, entry, label) {
|
|
14433
|
+
if (!['dfd.Process', 'dfd.DataStore'].includes(entry.type)) return null;
|
|
14434
|
+
const id = dfdFree(dfdUsed(model), entry.type === 'dfd.Process' ? 'process' : 'store', true);
|
|
14435
|
+
return [{ op: 'add', element: { id, type: entry.type, ...(label ? { label } : {}), props: { number: dfdNextNumber(model, view, entry.type) } }, view: view.id }];
|
|
14436
|
+
}
|
|
14437
|
+
/* D on a selected process that opens no view (§6.1): its level, in one transaction. The new view (<id>-level, the next
|
|
14438
|
+
free) shows the far ends of the process's flows drawn on this view and one child process, numbered <n>.1 (the next
|
|
14439
|
+
free under <n>) and named as its parent; each of those flows is copied with the process's end moved to the child, so the level balances from the start */
|
|
14440
|
+
function dfdKey(model, view, ids, key) {
|
|
14441
|
+
if (key !== 'd' && key !== 'D') return null;
|
|
14442
|
+
const I = dfdIndex(model), p = ids.length === 1 && I.proc(ids[0]) ? I.at.get(ids[0]) : null; if (!p) return null;
|
|
14443
|
+
if (p.opensView != null) return { error: `${I.q(p.id)} opens "${p.opensView}" already: Enter opens it` };
|
|
14444
|
+
const used = dfdUsed(model), n = dfdNum(p), kn = dfdFreeNum(I, 'dfd.Process', n ? n + '.' : '');
|
|
14445
|
+
const flows = dfdLines(model, view).filter(e => e.type === 'dfd.DataFlow' && (e.from === p.id) !== (e.to === p.id));
|
|
14446
|
+
const vid = dfdFree(new Set(DFD_ARR(model.views).filter(DFD_OBJ).map(v => v.id)), p.id + '-level'), kid = dfdFree(used, `${p.id}-${kn}`), title = `${n ? n + ' ' : ''}${p.label ?? p.id}`;
|
|
14447
|
+
return { commands: [
|
|
14448
|
+
{ op: 'addView', view: { id: vid, diagram: 'dfd', title, parent: view.id, ...(view.notation != null ? { notation: view.notation } : {}), ...(view.direction != null ? { direction: view.direction } : {}),
|
|
14449
|
+
nodeIds: [...new Set(flows.map(e => (e.from === p.id ? e.to : e.from)))] } },
|
|
14450
|
+
{ op: 'add', element: { id: kid, type: 'dfd.Process', ...(p.label != null ? { label: p.label } : {}), props: { number: kn } }, view: vid },
|
|
14451
|
+
...flows.map(e => ({ op: 'add', element: { id: dfdFree(used, `${e.id}-${kn}`), type: 'dfd.DataFlow', from: e.from === p.id ? kid : e.from, to: e.to === p.id ? kid : e.to, ...(e.label != null ? { label: e.label } : {}) } })),
|
|
14452
|
+
{ op: 'set', id: p.id, path: 'opensView', value: vid }],
|
|
14453
|
+
say: `Made level ${title}, balanced with ${dfdPlural(flows.length, 'flow')}: Enter opens it` };
|
|
14454
|
+
}
|
|
14455
|
+
|
|
14456
|
+
const DFD_NUMBER = { key: 'number', label: 'Number', kind: 'text' };
|
|
14457
|
+
const DFD_TYPE = registerDiagramType('dfd', {
|
|
14458
|
+
title: 'data flow diagram',
|
|
14459
|
+
elements: [['dfd.Process', 'Process', 'dfd'], ['dfd.DataStore', 'Data store'], ['dfd.ExternalEntity', 'External entity'], ['uml.Comment', 'Comment']].map(([type, label, opens]) => ({ type, label, ...(opens ? { opens } : {}) })),
|
|
14460
|
+
lines: [{ type: 'dfd.DataFlow', label: 'Data flow' }, { type: 'uml.CommentLink', label: 'Comment link' }],
|
|
14461
|
+
props: { 'dfd.Process': [DFD_NUMBER], 'dfd.DataStore': [DFD_NUMBER], 'dfd.ExternalEntity': [], 'dfd.DataFlow': [], 'uml.CommentLink': [] },
|
|
14462
|
+
layout: dfdGeo,
|
|
14463
|
+
validate: dfdProblems,
|
|
14464
|
+
rules: DFD_RULES,
|
|
14465
|
+
describe: dfdDescribe,
|
|
14466
|
+
edits: { add: dfdAdd, key: dfdKey },
|
|
14467
|
+
// the PDF's tables (§9): an element's kind; a process's number and the level it opens, a store's number
|
|
14468
|
+
word: (model, id) => { const x = dfdIndex(model).at.get(id); return x && Object.hasOwn(DFD_WORD, x.type) ? DFD_WORD[x.type] : null; },
|
|
14469
|
+
rows: (model, view, id) => { const I = dfdIndex(model), x = I.at.get(id); if (!x || !['dfd.Process', 'dfd.DataStore'].includes(x.type)) return null;
|
|
14470
|
+
const v = x.type === 'dfd.Process' && I.views.get(x.opensView); return [...(dfdNum(x) ? [`number ${dfdNum(x)}`] : []), ...(v ? [`opens level ${v.title || v.id}`] : [])]; },
|
|
14471
|
+
});
|
|
14472
|
+
/* ---------- the information flow diagram, uml.information-flow (work package M2.5, notes/milestone-2.md §7.4) ----------
|
|
14473
|
+
Classes and interfaces (the class type's classifier, name only), components (src/uml/structure.js's look) and actors (the
|
|
14474
|
+
use case type's figure), each read only; information items, a classifier with a small filled triangle at its top right
|
|
14475
|
+
(UML 2.5.1 §20.2.4); comments. Information flows: a dashed line with an open arrow, its label «flow» then what it conveys
|
|
14476
|
+
by name (`Order, Card details`), after its own name when it has one (`place: Order`), cut at 280; comment links.
|
|
14477
|
+
props.conveyed is a declared reference (refs: drop), so deleting an element drops it from every flow. Laid out by
|
|
14478
|
+
layoutTyped from the host's ranking: gap 48 with gapAfter's widening, labelGaps, repair and reroute off (S19). Ships in
|
|
14479
|
+
lib/umlimator-all.js only. */
|
|
14480
|
+
|
|
14481
|
+
const IF_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), IF_ARR = x => (Array.isArray(x) ? x : []);
|
|
14482
|
+
const IF_HAS = x => x != null && x !== '';
|
|
14483
|
+
const ifName = x => String(x.label ?? x.id);
|
|
14484
|
+
const ifStereo = x => IF_ARR(IF_OBJ(x.props) ? x.props.stereotypes : null).filter(IF_HAS).map(String);
|
|
14485
|
+
const ifPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
14486
|
+
const ifAnd = a => (a.length < 2 ? a.join('') : `${a.slice(0, -1).join(', ')} and ${a.at(-1)}`);
|
|
14487
|
+
const IF_ENDS = ['uml.Class', 'uml.Interface', 'uml.Component', 'uml.Actor']; // what a flow joins (§3, §13)
|
|
14488
|
+
// what a flow may convey: UML 2.5.1 §20.2.4's convey_classifiers, a class, an interface, an information item, a signal or a component
|
|
14489
|
+
const IF_CONVEY = ['uml.InformationItem', 'uml.Class', 'uml.Interface', 'uml.Component', 'uml.Signal'];
|
|
14490
|
+
const IF_LINES = ['uml.InformationFlow', 'uml.CommentLink'];
|
|
14491
|
+
const IF_WORD = { 'uml.Class': 'Class', 'uml.Interface': 'Interface', 'uml.Component': 'Component', 'uml.Actor': 'Actor', 'uml.InformationItem': 'Information item', 'uml.Comment': 'Comment',
|
|
14492
|
+
'uml.Signal': 'Signal' };
|
|
14493
|
+
const ifWord = t => (Object.hasOwn(IF_WORD, t) ? IF_WORD[t] : String(t ?? 'untyped').split('.').pop());
|
|
14494
|
+
// the keyword over a classifier's name: an interface's, a signal's, and any other kind a view shows its own («useCase»), so none passes for a class
|
|
14495
|
+
const IF_KEY = { 'uml.Class': null, 'uml.InformationItem': null, 'uml.PrimitiveType': 'primitive' };
|
|
14496
|
+
const ifKey = t => (Object.hasOwn(IF_KEY, t) ? IF_KEY[t] : String(t ?? '').split('.').pop().replace(/^./, c => c.toLowerCase()) || null);
|
|
14497
|
+
|
|
14498
|
+
/* ---- the information item: the classifier, 32 px wider, its centred name kept 8 px clear of the triangle at its top right ---- */
|
|
14499
|
+
const IF_IT = { pad: 8, name: 13, more: 32 };
|
|
14500
|
+
// the classifier's own markup, named by n.aria (A1) and by its shape
|
|
14501
|
+
const ifClassifier = (n, c, f, shape = 'classifier') => SHAPE.classifier.draw(n, c, f).replace(/data-shape="classifier" tabindex="-1" role="img" aria-label="[^"]*"/,
|
|
14502
|
+
`data-shape="${shape}" tabindex="-1" role="img" aria-label="${esc(n.aria || n.label || '')}"`);
|
|
14503
|
+
// a class or an interface: the classifier, named by its accessible name rather than the classifier's own («interface» twice)
|
|
14504
|
+
SHAPE['if-classifier'] = { ...SHAPE.classifier, draw: (n, c, f) => ifClassifier(n, c, f) };
|
|
14505
|
+
SHAPE['information-item'] = { ...SHAPE.classifier,
|
|
14506
|
+
size: n => { const s = SHAPE.classifier.size(n); return { w: s.w + IF_IT.more, h: s.h }; },
|
|
14507
|
+
draw(n, c, f) { const x = n.x + n.w - 16, y = n.y + 6, tri = `<path d="M${r2(x)} ${r2(y)}L${r2(x + 10)} ${r2(y + 5)}L${r2(x)} ${r2(y + 10)}Z" class="if-icon" fill="${c}" stroke="${c}" stroke-linejoin="round"/>`;
|
|
14508
|
+
return ifClassifier({ ...n, label: fit(String(n.label ?? ''), IF_IT.name, n.w - 2 * IF_IT.pad - IF_IT.more / 2 - 16) }, c, f, 'information-item').replace(/<\/g>$/, tri + '</g>'); },
|
|
14509
|
+
};
|
|
14510
|
+
|
|
14511
|
+
/* ---- the model read ---- */
|
|
14512
|
+
function ifIndex(model) {
|
|
14513
|
+
const at = new Map(); IF_ARR(model && model.nodes).forEach(n => { if (IF_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
|
|
14514
|
+
const E = IF_ARR(model && model.edges).filter(e => IF_OBJ(e) && typeof e.id === 'string');
|
|
14515
|
+
const conveyed = e => IF_ARR(IF_OBJ(e.props) ? e.props.conveyed : null);
|
|
14516
|
+
const nm = id => (at.has(id) ? ifName(at.get(id)) : String(id)); // a conveyed id that is gone: said as itself (conveyed-kind)
|
|
14517
|
+
return { at, E, conveyed, nm, flowsOf: id => E.filter(e => e.type === 'uml.InformationFlow' && conveyed(e).includes(id)) };
|
|
14518
|
+
}
|
|
14519
|
+
// what a flow says: its name, then what it conveys by name ("place: Order, Card details"); the PDF's line text (S17)
|
|
14520
|
+
const ifSays = (I, e) => `${IF_HAS(e.label) ? e.label + ': ' : ''}${I.conveyed(e).map(I.nm).join(', ')}`;
|
|
14521
|
+
// the lines drawn: those the view lists, or with no list the model's of this type's kinds, between two elements it shows
|
|
14522
|
+
function ifLines(I, view, shown) {
|
|
14523
|
+
const by = new Map(I.E.map(e => [e.id, e])), list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? by.get(x) : x)) : I.E.filter(e => IF_LINES.includes(e.type));
|
|
14524
|
+
return list.filter(e => IF_OBJ(e) && typeof e.id === 'string' && shown.has(e.from) && shown.has(e.to));
|
|
14525
|
+
}
|
|
14526
|
+
const ifShown = (I, view) => [...new Set(IF_ARR(view && view.nodeIds))].filter(id => I.at.has(id));
|
|
14527
|
+
|
|
14528
|
+
/* ---- accessible names (§7.4): "Information item Order, conveyed by 4 flows"; "Information flow from Shopper to Storefront,
|
|
14529
|
+
conveys Order and Card details" ---- */
|
|
14530
|
+
function ifNodeName(I, n) {
|
|
14531
|
+
if (n.type === 'uml.Comment') return 'Comment: ' + ifName(n);
|
|
14532
|
+
const st = ifStereo(n), w = ifWord(n.type), k = I.flowsOf(n.id).length;
|
|
14533
|
+
return `${w} ${ifName(n)}${st.length ? ` (${st.join(', ')})` : ''}${k ? `, conveyed by ${ifPlural(k, 'flow')}` : ''}`;
|
|
14534
|
+
}
|
|
14535
|
+
function ifLineName(I, e) {
|
|
14536
|
+
const ends = `from ${I.nm(e.from)} to ${I.nm(e.to)}`;
|
|
14537
|
+
if (e.type === 'uml.CommentLink') return `Comment link ${ends}`;
|
|
14538
|
+
if (e.type !== 'uml.InformationFlow') return `Line ${ends}`;
|
|
14539
|
+
const c = I.conveyed(e).map(I.nm);
|
|
14540
|
+
return `Information flow${IF_HAS(e.label) ? ' ' + e.label : ''} ${ends}${c.length ? ', conveys ' + ifAnd(c) : ''}`;
|
|
14541
|
+
}
|
|
14542
|
+
|
|
14543
|
+
/* ---- the legend. ponytail: copies class.js's legendItem, which it does not export ---- */
|
|
14544
|
+
const IF_SAYS = { 'uml.InformationFlow': ['Information flow', '«flow»: what travels from one end to the other'], 'uml.CommentLink': ['Comment link', 'what a comment is about'] };
|
|
14545
|
+
const ifLegend = edges => IF_LINES.filter(t => edges.some(e => e.type === t)).map(t => { const [name, says] = IF_SAYS[t], flow = t === 'uml.InformationFlow';
|
|
14546
|
+
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${flow ? 17 : 26}" stroke-dasharray="3 2"/>${flow ? '<path d="M17 4L26 8L17 12" fill="none"/>' : ''}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; });
|
|
14547
|
+
|
|
14548
|
+
/* ---- the view laid out ----
|
|
14549
|
+
A flow back over two columns or more (a reply that closes a loop: an email to the shopper) leaves its source's bottom and
|
|
14550
|
+
comes into its target's bottom, in the layout's axes, under the rows it passes: through the middle it crosses the lines
|
|
14551
|
+
between them (15-information-flow.json, right and down). ponytail: one fixed side, not a search; a view whose long back
|
|
14552
|
+
flows still cross would take the router's side choice (reroute), which S19 leaves off for the edits' time */
|
|
14553
|
+
function ifGeo({ model, view, pos, memo }) {
|
|
14554
|
+
const I = ifIndex(model), ids = ifShown(I, view), lines = ifLines(I, view, new Set(ids)), P = id => (IF_OBJ(pos) && Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
|
|
14555
|
+
const nodes = ids.map(id => { const n = I.at.get(id), st = ifStereo(n), pr = IF_OBJ(n.props) ? n.props : {};
|
|
14556
|
+
const look = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Actor' ? { shape: 'actor', stereotypes: st } : n.type === 'uml.Component' ? { shape: 'component', stereotypes: st, keyword: !!pr.keyword }
|
|
14557
|
+
: n.type === 'uml.InformationItem' ? { shape: 'information-item', stereotypes: st, compartments: [] } : { shape: 'if-classifier', stereotypes: [...[ifKey(n.type || 'uml.Class')].filter(Boolean), ...st], compartments: [] };
|
|
14558
|
+
const d = { id, label: ifName(n), type: n.type || 'uml.Class', kind: 'file', ...look, aria: ifNodeName(I, n), col: P(id)[0], row: P(id)[1] };
|
|
14559
|
+
return { ...d, ...SHAPE[d.shape].size(d) }; });
|
|
14560
|
+
const edges = lines.map(e => { const flow = e.type === 'uml.InformationFlow', label = flow ? '«flow» ' + ifSays(I, e) : e.label;
|
|
14561
|
+
return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: IF_HAS(label) ? label : undefined, end: flow ? { from: 'none', to: 'open', dashed: true } : { from: 'none', to: 'none', dashed: true },
|
|
14562
|
+
aria: ifLineName(I, e), cut: 280, ...(P(e.from)[0] - P(e.to)[0] > 1 ? { fromSide: 'bottom', toSide: 'bottom' } : {}), ...(IF_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
|
|
14563
|
+
const g = layoutTyped({ nodes, edges, direction: view.direction === 'down' ? 'down' : 'right', gap: 48, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo);
|
|
14564
|
+
return { ...g, legend: ifLegend(g.edges), order: [...ids, ...lines.map(e => e.id)] }; // a new object: what layoutTyped returned is never edited (§2.6)
|
|
14565
|
+
}
|
|
14566
|
+
|
|
14567
|
+
/* ---- validators (§7.4). Each problem: { level, rule, path, message, ids } ---- */
|
|
14568
|
+
const IF_RULES = {
|
|
14569
|
+
'conveyed-some': 'an information flow conveys one element at least',
|
|
14570
|
+
'conveyed-kind': 'each element a flow conveys is an information item, a class, an interface, a component or a signal of the model',
|
|
14571
|
+
'conveyed-once': 'a flow conveys each element once (UML: conveyed is a set)',
|
|
14572
|
+
'flow-ends': 'an information flow joins two classes, interfaces, components or actors, never an information item or a comment',
|
|
14573
|
+
};
|
|
14574
|
+
function ifProblems(model) {
|
|
14575
|
+
const out = [], I = ifIndex(model), say = (rule, path, ids, msg) => out.push({ level: 'error', rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
14576
|
+
const q = id => JSON.stringify(I.nm(id)), kind = id => (I.at.has(id) ? ifWord(I.at.get(id).type).toLowerCase() : 'nothing');
|
|
14577
|
+
IF_ARR(model && model.edges).forEach((e, i) => { if (!IF_OBJ(e) || e.type !== 'uml.InformationFlow') return; const path = `edges[${i}]`, c = I.conveyed(e);
|
|
14578
|
+
if (!c.length) say('conveyed-some', path, [e.id], `${e.id} conveys nothing: a flow conveys one element at least`);
|
|
14579
|
+
c.forEach((id, j) => { if (c.indexOf(id) < j) say('conveyed-once', `${path}.props.conveyed[${j}]`, [e.id, typeof id === 'string' ? id : null], `${e.id} conveys ${q(id)} twice: a flow conveys each element once`);
|
|
14580
|
+
else if (!I.at.has(id) || !IF_CONVEY.includes(I.at.get(id).type))
|
|
14581
|
+
say('conveyed-kind', `${path}.props.conveyed[${j}]`, [e.id, typeof id === 'string' ? id : null], I.at.has(id) ? `${e.id} conveys ${q(id)}, a ${kind(id)}: it conveys information items, classes, interfaces, components and signals` : `${e.id} conveys ${JSON.stringify(id)}, which the model does not hold`); });
|
|
14582
|
+
if (!IF_ENDS.includes((I.at.get(e.from) || {}).type) || !IF_ENDS.includes((I.at.get(e.to) || {}).type))
|
|
14583
|
+
say('flow-ends', path, [e.id, e.from, e.to], `${e.id} runs from ${q(e.from)} (${kind(e.from)}) to ${q(e.to)} (${kind(e.to)}): a flow joins two classes, interfaces, components or actors`); });
|
|
14584
|
+
return out;
|
|
14585
|
+
}
|
|
14586
|
+
|
|
14587
|
+
/* ---- describe(): the view in sentences, each element by its accessible name in the view's order, then each line ---- */
|
|
14588
|
+
function ifDescribe(model, viewId) {
|
|
14589
|
+
const v = IF_ARR(model && model.views).find(x => IF_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
14590
|
+
const I = ifIndex(model), ids = ifShown(I, v), lines = ifLines(I, v, new Set(ids));
|
|
14591
|
+
return [`${v.title || v.id}: an information flow diagram of ${ifPlural(ids.length, 'element')} and ${ifPlural(lines.length, 'line')}.`,
|
|
14592
|
+
...ids.map(id => ifNodeName(I, I.at.get(id)) + '.'), ...lines.map(e => ifLineName(I, e) + '.')].join('\n');
|
|
14593
|
+
}
|
|
14594
|
+
|
|
14595
|
+
/* ---- editing (§7.4) ---- */
|
|
14596
|
+
// the elements a flow may convey, information items first, then classifiers, each in the model's order
|
|
14597
|
+
const ifConveyable = I => [...I.at.values()].filter(n => IF_CONVEY.includes(n.type)).sort((a, b) => (b.type === 'uml.InformationItem') - (a.type === 'uml.InformationItem'));
|
|
14598
|
+
// connect: a flow between two classifiers or actors asks which element it conveys; other ends are refused, said on the hint line
|
|
14599
|
+
function ifConnect(model, view, from, to, at, line) {
|
|
14600
|
+
if (!IF_OBJ(line) || line.type !== 'uml.InformationFlow') return null;
|
|
14601
|
+
const I = ifIndex(model);
|
|
14602
|
+
if (![from, to].every(id => IF_ENDS.includes((I.at.get(id) || {}).type))) return { error: 'An information flow joins two classes, interfaces, components or actors' };
|
|
14603
|
+
const can = ifConveyable(I); if (!can.length) return { error: 'Add an information item for the flow to convey first' };
|
|
14604
|
+
const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
14605
|
+
let k = 1; while (used.has('flow' + k)) k++;
|
|
14606
|
+
return { pick: can.map(ifName), then: i => [{ op: 'add', element: { id: 'flow' + k, type: 'uml.InformationFlow', from, to, props: { conveyed: [can[i].id] } }, view: view.id }] };
|
|
14607
|
+
}
|
|
14608
|
+
// rename (F2, a double-click on a selected flow): the flow's text as drawn, `[name:] element, element`, «flow» left out or not;
|
|
14609
|
+
// each element by its name (an information item first), else by its id; a name may hold `:` or `,` (`Billing::Receipt`). The
|
|
14610
|
+
// text is read with no name first, then named at each `:` in turn; the reading that keeps the flow as it is wins, else the first
|
|
14611
|
+
// that names elements throughout. Sets conveyed (each once) and the name, each when it changed
|
|
14612
|
+
function ifRename(model, view, id, text) {
|
|
14613
|
+
const I = ifIndex(model), e = I.E.find(x => x.id === id); if (!e || e.type !== 'uml.InformationFlow') return null;
|
|
14614
|
+
const t = String(text ?? '').replace(/^\s*«flow»\s*/, ''), can = ifConveyable(I), find = s => can.find(n => ifName(n) === s) || can.find(n => n.id === s);
|
|
14615
|
+
// a list read entry by entry, each the longest run of comma-separated parts that names an element
|
|
14616
|
+
const list = s => { const p = s.split(','), ids = []; let i = 0;
|
|
14617
|
+
while (i < p.length) { if (!p[i].trim()) { i++; continue; }
|
|
14618
|
+
let j = p.length, x; while (j > i && !(x = find(p.slice(i, j).join(',').trim()))) j--;
|
|
14619
|
+
if (!x) return { error: `No information item or classifier is named ${p[i].trim()}` }; ids.push(x.id); i = j; }
|
|
14620
|
+
return ids.length ? { ids: [...new Set(ids)] } : { error: 'A flow conveys one element at least' }; };
|
|
14621
|
+
const reads = [-1, ...[...t].flatMap((ch, k) => (ch === ':' ? [k] : []))].map(k => ({ name: k < 0 ? '' : t.slice(0, k).trim(), ...list(k < 0 ? t : t.slice(k + 1)) }));
|
|
14622
|
+
const same = r => r.ids && r.name === (e.label ?? '') && JSON.stringify(r.ids) === JSON.stringify(I.conveyed(e));
|
|
14623
|
+
const r = reads.find(same) || reads.find(x => x.ids); if (!r) return { error: reads[reads.length > 1 ? 1 : 0].error };
|
|
14624
|
+
return [...(JSON.stringify(r.ids) !== JSON.stringify(I.conveyed(e)) ? [{ op: 'set', id, path: 'props.conveyed', value: r.ids }] : []),
|
|
14625
|
+
...(r.name !== (e.label ?? '') ? [{ op: 'set', id, path: 'label', value: r.name || undefined }] : [])];
|
|
14626
|
+
}
|
|
14627
|
+
|
|
14628
|
+
// remove: Shift + Delete on what flows convey takes every flow left conveying nothing (conveyed-some), in the same
|
|
14629
|
+
// transaction, said on the hint line; a flow conveying something else keeps it (apply() drops the id: refs). Else the default
|
|
14630
|
+
function ifRemove(model, view, ids, fromModel) {
|
|
14631
|
+
const I = ifIndex(model), gone = new Set(IF_ARR(ids)), flows = fromModel ? I.E.filter(e => e.type === 'uml.InformationFlow' && ![e.id, e.from, e.to].some(x => gone.has(x))
|
|
14632
|
+
&& I.conveyed(e).length && I.conveyed(e).every(x => gone.has(x))) : [];
|
|
14633
|
+
if (!flows.length) return null;
|
|
14634
|
+
return { commands: [...gone, ...flows.map(e => e.id)].map(id => ({ op: 'delete', id })),
|
|
14635
|
+
say: `Deleted ${[...gone].map(id => { const l = I.E.find(e => e.id === id); return l ? `the line ${I.nm(l.from)} → ${I.nm(l.to)}` : JSON.stringify(I.nm(id)); }).join(', ')} and the ${ifPlural(flows.length, 'flow')} that conveyed nothing else: ${flows.map(e => `${I.nm(e.from)} → ${I.nm(e.to)}`).join(', ')}` };
|
|
14636
|
+
}
|
|
14637
|
+
|
|
14638
|
+
const IF_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
14639
|
+
const IF_TYPE = registerDiagramType('uml.information-flow', {
|
|
14640
|
+
title: 'information flow diagram',
|
|
14641
|
+
elements: [['Class', 'Class'], ['Interface', 'Interface'], ['Component', 'Component'], ['Actor', 'Actor'], ['InformationItem', 'Information item'], ['Comment', 'Comment']].map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
14642
|
+
lines: [['InformationFlow', 'Information flow'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
14643
|
+
props: { 'uml.Class': [IF_STEREO], 'uml.Interface': [IF_STEREO], 'uml.Component': [IF_STEREO], 'uml.Actor': [IF_STEREO], 'uml.InformationItem': [], 'uml.InformationFlow': [], 'uml.CommentLink': [] },
|
|
14644
|
+
refs: { 'uml.InformationFlow': { 'props.conveyed': 'drop' } }, // a deleted element leaves every flow that conveys it
|
|
14645
|
+
layout: ifGeo,
|
|
14646
|
+
validate: ifProblems,
|
|
14647
|
+
rules: IF_RULES,
|
|
14648
|
+
describe: ifDescribe,
|
|
14649
|
+
edits: { connect: ifConnect, rename: ifRename, remove: ifRemove },
|
|
14650
|
+
// the PDF (§9): an element's kind; an information item's rows, and any conveyed element's, the flows that convey it; a flow's text, what it conveys
|
|
14651
|
+
word: (model, id) => { const x = ifIndex(model).at.get(id); return x && Object.hasOwn(IF_WORD, x.type) ? IF_WORD[x.type] : null; },
|
|
14652
|
+
rows: (model, view, id) => { const I = ifIndex(model), x = I.at.get(id); return x && (x.type === 'uml.InformationItem' || I.flowsOf(id).length) ? I.flowsOf(id).map(e => `${I.nm(e.from)} → ${I.nm(e.to)}`) : null; },
|
|
14653
|
+
text: (model, view, id) => { const I = ifIndex(model), e = I.E.find(x => x.id === id); return e && e.type === 'uml.InformationFlow' ? ifSays(I, e) : null; },
|
|
14654
|
+
});
|
|
14655
|
+
/* ---------- C4's elements and static views, c4.landscape, c4.context, c4.container, c4.component (work package M2.6,
|
|
14656
|
+
notes/milestone-2.md §5.1, §5.2, §6.1, §6.5, §7.5, §7.5.1) ----------
|
|
14657
|
+
People, software systems, containers and components, each drawn in the map's card shape its props.shape names (a person
|
|
14658
|
+
`person`, the rest `box`, by default: S11) with C4's three lines over it: its name, [Container: technology], its
|
|
14659
|
+
description; an external element grey. A view's subject, when it lists it, is a dashed boundary holding what it owns
|
|
14660
|
+
that the view shows (ownersOn). A relationship whose end the view does not show is drawn between the nearest owners it
|
|
14661
|
+
shows, one implied line a pair, dashed, unless a relationship joins them already (§5.2): derived when drawn, never stored,
|
|
14662
|
+
its id `<from>-><to>`. Each view kind checks what it may show (§5.1). D on a software system or a container that opens
|
|
14663
|
+
no view makes its container or component view (§6.1). One definition, registered under the four view names; the
|
|
14664
|
+
dynamic and deployment views are src/c4/views.js's, on the helpers exported here. Laid out by layoutTyped from the
|
|
14665
|
+
host's ranking, repair and reroute off (§11). Ships in lib/umlimator-all.js only. */
|
|
14666
|
+
|
|
14667
|
+
const C4_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), C4_ARR = x => (Array.isArray(x) ? x : []);
|
|
14668
|
+
const C4_HAS = x => x != null && String(x).trim() !== '';
|
|
14669
|
+
const C4_P = x => (C4_OBJ(x.props) ? x.props : {});
|
|
14670
|
+
const c4Name = x => String(x.label ?? x.id);
|
|
14671
|
+
const c4Plural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
14672
|
+
const C4_WORD = { 'c4.Person': 'Person', 'c4.SoftwareSystem': 'Software system', 'c4.Container': 'Container', 'c4.Component': 'Component',
|
|
14673
|
+
'c4.DeploymentNode': 'Deployment node', 'c4.ContainerInstance': 'Container instance', 'uml.Comment': 'Comment' };
|
|
14674
|
+
const C4_TAG = { 'c4.Person': 'Person', 'c4.SoftwareSystem': 'Software System', 'c4.Container': 'Container', 'c4.Component': 'Component' };
|
|
14675
|
+
const c4Word = x => (Object.hasOwn(C4_WORD, x.type) ? C4_WORD[x.type] : String(x.type ?? '').split('.').pop());
|
|
14676
|
+
const C4_STATIC = ['c4.Person', 'c4.SoftwareSystem', 'c4.Container', 'c4.Component'], C4_DEPLOY = ['c4.DeploymentNode', 'c4.ContainerInstance'];
|
|
14677
|
+
// the map's card shapes an element may name (S11): all but UML's node, which is the deployment view's
|
|
14678
|
+
const C4_SHAPES = ['box', 'person', 'robot', 'browser', 'window', 'terminal', 'mobile', 'cylinder', 'pipe', 'folder', 'document', 'hexagon'];
|
|
14679
|
+
const c4Shape = x => { const s = String(C4_P(x).shape ?? '').toLowerCase(); return C4_SHAPES.includes(s) ? s : x.type === 'c4.Person' ? 'person' : 'box'; };
|
|
14680
|
+
const c4Ext = x => C4_P(x).external === true;
|
|
14681
|
+
// "[Container: Python, FastAPI]", "[Software System]", "[Person]"
|
|
14682
|
+
const c4TagOf = x => { const t = C4_TAG[x.type] || c4Word(x), tech = C4_P(x).technology; return `[${t}${C4_HAS(tech) && x.type !== 'c4.Person' && x.type !== 'c4.SoftwareSystem' ? ': ' + tech : ''}]`; };
|
|
14683
|
+
|
|
14684
|
+
/* ---- the element (§7.5): the map shape's outline, det and face, sized by its text ---- */
|
|
14685
|
+
const C4 = { name: 13, nlh: 17, tag: 10, tlh: 14, d: 11, dlh: 14, dw: 220, dk: 4, minW: 160, maxW: 280, px: 14, py: 10, gap: 4 };
|
|
14686
|
+
const C4_TOP = { person: 26, robot: 16, browser: 14, window: 14, terminal: 14, folder: 9 }, C4_SIDE = { mobile: 12, pipe: 12, hexagon: 12 };
|
|
14687
|
+
function c4Parts(n) {
|
|
14688
|
+
const pad = C4.px + (C4_SIDE[n.sh] || 0), max = C4.maxW - 2 * pad, name = flowWrap(n.label, C4.name, max, 2), tag = fit(n.tag || '', C4.tag, max);
|
|
14689
|
+
const desc = C4_HAS(n.desc) ? flowWrap(n.desc, C4.d, Math.min(C4.dw, max), C4.dk) : [];
|
|
14690
|
+
const w = Math.ceil(Math.min(C4.maxW, Math.max(C4.minW, Math.max(...name.map(t => textW(t, C4.name) * 1.05), textW(tag, C4.tag), ...desc.map(t => textW(t, C4.d))) + 2 * pad)));
|
|
14691
|
+
const text = Math.max(1, name.length) * C4.nlh + C4.tlh + (desc.length ? C4.gap + desc.length * C4.dlh : 0), body = text + 2 * C4.py;
|
|
14692
|
+
let h = (C4_TOP[n.sh] || 0) + body, top = C4_TOP[n.sh] || 0;
|
|
14693
|
+
if (n.sh === 'cylinder') { for (let k = 0; k < 4; k++) h = body + 3 * Math.max(5, Math.round(h * .11)); top = 2 * Math.max(5, Math.round(h * .11)); } // the lid above, its rim below
|
|
14694
|
+
return { name, tag, desc, w, h: Math.ceil(h), top, text, pad };
|
|
14695
|
+
}
|
|
14696
|
+
const c4Text = (x, y, t, px, wt, fill, cls, more = '') => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="middle" class="${cls}"${more} fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(t)}</text>`;
|
|
14697
|
+
const C4_EL = {
|
|
14698
|
+
size: n => { const p = c4Parts(n); return { w: p.w, h: p.h }; },
|
|
14699
|
+
out: (n, d = 0) => SHAPE[n.sh || 'box'].out(n, d), face: n => SHAPE[n.sh || 'box'].face(n),
|
|
14700
|
+
inset: (n, s, t) => SHAPE[n.sh || 'box'].inset(n, s, t),
|
|
14701
|
+
// its outline, the map shape's details (a person's head, a cylinder's lid) and C4's three lines, centred under its top
|
|
14702
|
+
draw(n, c, f) {
|
|
14703
|
+
const S = SHAPE[n.sh || 'box'], P = c4Parts(n), cx = n.x + n.w / 2, max = n.w - 2 * P.pad, bottom = n.sh === 'cylinder' ? P.top / 2 : 0;
|
|
14704
|
+
let y = n.y + P.top + (n.h - P.top - bottom - P.text) / 2;
|
|
14705
|
+
const tx = [];
|
|
14706
|
+
P.name.forEach((t, i) => { tx.push(c4Text(cx, y + 13, fit(t, C4.name, max), C4.name, 600, 'var(--um-text)', 'c-name', i ? '' : ` data-o="${esc(n.id)}"`)); y += C4.nlh; });
|
|
14707
|
+
tx.push(c4Text(cx, y + 10, fit(P.tag, C4.tag, max), C4.tag, 400, 'var(--um-muted)', 'c-st')); y += C4.tlh + C4.gap;
|
|
14708
|
+
P.desc.forEach(t => { tx.push(c4Text(cx, y + 11, fit(t, C4.d, max), C4.d, 400, 'var(--um-text)', 'c-desc')); y += C4.dlh; });
|
|
14709
|
+
return `<g class="node c4${n.ext ? ' c4-ext' : ''}" data-id="${esc(n.id)}" data-shape="c4" data-c4="${n.sh}" tabindex="-1" role="img" aria-label="${esc(n.aria || `${c4Word(n)} ${n.label ?? ''}`)}">`
|
|
14710
|
+
+ `<path d="${S.out(n)}" class="n-mask"/><path d="${S.out(n)}" class="n-body" fill="${f}" stroke="${c}"/>${S.det ? S.det(n, c, n.k) : ''}<g class="c-head">${tx.join('')}</g></g>`;
|
|
14711
|
+
},
|
|
14712
|
+
};
|
|
14713
|
+
C4_EL.ports = (n, side, k = 1) => portsOn(C4_EL, n, side, k);
|
|
14714
|
+
SHAPE.c4 = C4_EL;
|
|
14715
|
+
// a boundary (§7.5.1): a dashed box titled "<name> [Software System]", as C4 draws it
|
|
14716
|
+
SHAPE['c4-boundary'] = { frame: f => `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="6" fill="none" stroke="var(--um-muted)" stroke-dasharray="6 4"/>`
|
|
14717
|
+
+ `<text class="family-title" x="${r2(f.x + 12)}" y="${r2(f.y + 18)}" fill="var(--um-text)" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 12, f.w - 24))}</text>` };
|
|
14718
|
+
|
|
14719
|
+
/* ---- the model read: elements by id, their owners, relationships ---- */
|
|
14720
|
+
function c4Index(model) {
|
|
14721
|
+
const at = new Map(); C4_ARR(model && model.nodes).forEach(n => { if (C4_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
|
|
14722
|
+
const E = C4_ARR(model && model.edges).filter(e => C4_OBJ(e) && typeof e.id === 'string'), rels = E.filter(e => e.type === 'c4.Relationship');
|
|
14723
|
+
// the chain of owners, nearest first; a cycle (the core's error) stops it
|
|
14724
|
+
const chain = id => { const out = [], seen = new Set([id]); for (let p = (at.get(id) || {}).parent; p != null && at.has(p) && !seen.has(p); p = at.get(p).parent) { seen.add(p); out.push(p); } return out; };
|
|
14725
|
+
const kids = (id, type) => [...at.values()].filter(n => n.parent === id && n.type === type).map(n => n.id);
|
|
14726
|
+
return { at, E, rels, chain, kids };
|
|
14727
|
+
}
|
|
14728
|
+
const C4_LOOK = { boxTypes: ['c4.SoftwareSystem', 'c4.Container'], qualify: false, shape: () => 'c4-boundary', word: c4Word };
|
|
14729
|
+
// the view's picture: its boxes, titled as C4 titles a boundary, each named by what it holds
|
|
14730
|
+
function c4Pic(model, view) {
|
|
14731
|
+
const pic = ownersOn(model, view, C4_LOOK), I = c4Index(model);
|
|
14732
|
+
const boxes = pic.boxes.map(b => { const x = pic.at.get(b.id), held = pic.holds(b.id).map(id => pic.at.get(id)), k = new Map();
|
|
14733
|
+
held.forEach(y => { const w = c4Word(y).toLowerCase(); k.set(w, (k.get(w) || 0) + 1); });
|
|
14734
|
+
return { ...b, title: `${c4Name(x)} [${C4_TAG[x.type]}]`, aria: `${c4Word(x)} ${c4Name(x)}, a boundary holding ${[...k].map(([w, c]) => c4Plural(c, w)).join(', ') || 'nothing'}` }; });
|
|
14735
|
+
return { ...pic, I, boxes };
|
|
14736
|
+
}
|
|
14737
|
+
|
|
14738
|
+
/* ---- the lines drawn (§5.2): the view's own relationships between shown elements, then one implied line a pair ---- */
|
|
14739
|
+
// a relationship's end lifted to what the view shows of it: itself, else its nearest shown owner; null when the first shown
|
|
14740
|
+
// thing up its chain is a boundary (a boundary is no end) or nothing is shown
|
|
14741
|
+
function c4Lift(pic, id) {
|
|
14742
|
+
for (const x of [id, ...pic.I.chain(id)]) if (pic.ids.includes(x)) return pic.open.has(x) ? null : x;
|
|
14743
|
+
return null;
|
|
14744
|
+
}
|
|
14745
|
+
function c4Lines(model, view, pic) {
|
|
14746
|
+
const ends = new Set(pic.ids.filter(id => !pic.open.has(id))), byId = new Map(pic.I.E.map(e => [e.id, e]));
|
|
14747
|
+
const listed = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? byId.get(x) : x)).filter(C4_OBJ) : null;
|
|
14748
|
+
const own = (listed || pic.I.E.filter(e => e.type === 'c4.Relationship' || e.type === 'uml.CommentLink')).filter(e => typeof e.id === 'string' && ends.has(e.from) && ends.has(e.to));
|
|
14749
|
+
// a pair a relationship joins already, drawn or hidden by the view's list, takes no implied line
|
|
14750
|
+
const joined = new Set([...own, ...pic.I.rels].filter(e => ends.has(e.from) && ends.has(e.to)).map(e => e.from + '\n' + e.to)), implied = new Map();
|
|
14751
|
+
pic.I.rels.forEach(r => { if (ends.has(r.from) && ends.has(r.to)) return; // shown as itself, or hidden from the view: never lifted
|
|
14752
|
+
const a = c4Lift(pic, r.from), b = c4Lift(pic, r.to), k = a + '\n' + b;
|
|
14753
|
+
if (a == null || b == null || a === b || joined.has(k)) return;
|
|
14754
|
+
if (!implied.has(k)) implied.set(k, { id: `${a}->${b}`, from: a, to: b, of: [] });
|
|
14755
|
+
implied.get(k).of.push(r); });
|
|
14756
|
+
return { own, implied: [...implied.values()] };
|
|
14757
|
+
}
|
|
14758
|
+
const c4LineLabel = e => [e.label, C4_HAS(C4_P(e).technology) ? `[${C4_P(e).technology}]` : ''].filter(C4_HAS).join(' ');
|
|
14759
|
+
// accessible names (A1): "Container Shop API, Python, FastAPI: Catalogue, basket, checkout and accounts as a JSON API"
|
|
14760
|
+
function c4NodeName(x) {
|
|
14761
|
+
if (x.type === 'uml.Comment') return 'Comment: ' + c4Name(x);
|
|
14762
|
+
const P = C4_P(x), tech = x.type === 'c4.Container' || x.type === 'c4.Component' ? P.technology : null;
|
|
14763
|
+
return `${c4Ext(x) ? 'External ' + c4Word(x).toLowerCase() : c4Word(x)} ${c4Name(x)}${C4_HAS(tech) ? ', ' + tech : ''}${C4_HAS(P.description) ? ': ' + P.description : ''}`;
|
|
14764
|
+
}
|
|
14765
|
+
// "Relationship from Storefront to Shop API: Reads the catalogue and places orders through [JSON over HTTPS]"; an implied
|
|
14766
|
+
// line names what it stands for and where they are edited
|
|
14767
|
+
function c4LineName(e, pic) {
|
|
14768
|
+
const nm = id => c4Name(pic.at.get(id) || { id });
|
|
14769
|
+
if (e.of) { const said = e.of.map(r => `${c4Name(pic.at.get(r.from) || { id: r.from })} to ${c4Name(pic.at.get(r.to) || { id: r.to })}${C4_HAS(r.label) ? ', ' + r.label : ''}`);
|
|
14770
|
+
return `Implied relationship from ${nm(e.from)} to ${nm(e.to)}, standing for ${c4Plural(e.of.length, 'relationship')}: ${said.join('; ')}. Edit them where their own ends are shown`; }
|
|
14771
|
+
if (e.type === 'uml.CommentLink') return `Comment link from ${nm(e.from)} to ${nm(e.to)}`;
|
|
14772
|
+
const said = c4LineLabel(e); return `Relationship from ${nm(e.from)} to ${nm(e.to)}${said ? ': ' + said : ''}`;
|
|
14773
|
+
}
|
|
14774
|
+
|
|
14775
|
+
/* ---- the key (§7.5): each element type and shape drawn, external, the relationship and the implied line ---- */
|
|
14776
|
+
const C4_LINE_LOOK = { rel: { from: 'none', to: 'open' }, implied: { from: 'none', to: 'open', dashed: true }, note: { from: 'none', to: 'none', dashed: true } };
|
|
14777
|
+
function c4Legend(nodes, edges) {
|
|
14778
|
+
const els = [...new Map(nodes.filter(n => n.shape === 'c4').map(n => [`${n.type} ${n.sh} ${n.ext}`, n])).values()];
|
|
14779
|
+
const swatch = n => `<rect x="3" y="2" width="22" height="12" rx="3" fill="var(--um-${n.ext ? 'slate' : 'cyan'}-f)" stroke="var(--um-${n.ext ? 'slate' : 'cyan'})"/>`;
|
|
14780
|
+
const item = (name, says, svg) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true">${svg}</svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
14781
|
+
const line = dashed => `<g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round" fill="none"><path d="M2 8H25"${dashed ? ' stroke-dasharray="3 2"' : ''}/><path d="M17 4L26 8L17 12"/></g>`;
|
|
14782
|
+
return [...els.map(n => item(`${n.ext ? 'External ' + c4Word(n).toLowerCase() : c4Word(n)}${n.sh !== 'box' && !(n.sh === 'person' && n.type === 'c4.Person') ? ', ' + n.sh : ''}`,
|
|
14783
|
+
n.ext ? 'outside the system, drawn grey' : `a ${c4Word(n).toLowerCase()}`, swatch(n))),
|
|
14784
|
+
...(edges.some(e => e.type === 'c4.Relationship' && !e.implied) ? [item('Relationship', 'uses, with its technology in brackets', line(false))] : []),
|
|
14785
|
+
...(edges.some(e => e.implied) ? [item('Implied relationship', 'stands for relationships between what the two hold', line(true))] : [])];
|
|
14786
|
+
}
|
|
14787
|
+
|
|
14788
|
+
/* ---- the view laid out (§7.5, §11) ---- */
|
|
14789
|
+
function c4Geo({ model, view, pos, memo }) {
|
|
14790
|
+
const pic = c4Pic(model, view), { own, implied } = c4Lines(model, view, pic), P = id => (C4_OBJ(pos) && Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
|
|
14791
|
+
const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const x = pic.at.get(id);
|
|
14792
|
+
if (x.type === 'uml.Comment') { const d = { id, label: c4Name(x), type: x.type, kind: 'file', shape: 'note', aria: c4NodeName(x), col: P(id)[0], row: P(id)[1] }; return { ...d, ...SHAPE.note.size(d) }; }
|
|
14793
|
+
const ext = c4Ext(x), d = { id, label: c4Name(x), type: x.type || 'c4.SoftwareSystem', kind: ext ? 'external' : 'frontend', shape: 'c4', sh: c4Shape(x), ext, tag: c4TagOf(x), desc: C4_P(x).description,
|
|
14794
|
+
aria: c4NodeName(x), col: P(id)[0], row: P(id)[1] };
|
|
14795
|
+
return { ...d, ...C4_EL.size(d) }; });
|
|
14796
|
+
const edges = [...own.map(e => { const note = e.type === 'uml.CommentLink', label = note ? '' : c4LineLabel(e);
|
|
14797
|
+
return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: label || undefined, end: note ? C4_LINE_LOOK.note : C4_LINE_LOOK.rel, aria: c4LineName(e, pic), cut: 280 }; }),
|
|
14798
|
+
...implied.map(e => ({ id: e.id, _id: e.id, type: 'c4.Relationship', from: e.from, to: e.to, implied: e.of.map(r => r.id), end: C4_LINE_LOOK.implied, aria: c4LineName(e, pic), cut: 280,
|
|
14799
|
+
label: [e.of[0].label, e.of.length > 1 ? `[+${e.of.length - 1}]` : ''].filter(C4_HAS).join(' ') || undefined }))];
|
|
14800
|
+
const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: view.direction === 'down' ? 'down' : 'right',
|
|
14801
|
+
gap: 48, rgap: 64, labelGaps: true, repair: false, reroute: false }, memo);
|
|
14802
|
+
// on a copy (stage 4 §2.4): a person's or robot's body starts just above the highest line on its side, as a map card's
|
|
14803
|
+
// does (sidePorts), so each such end lands on its outline
|
|
14804
|
+
const sp = sidePorts(g), lift = new Map();
|
|
14805
|
+
g.nodes.forEach(n => { const p = (n.sh === 'person' || n.sh === 'robot') && sp.get(n), t = p ? Math.min(p.L, p.R) : Infinity; if (t < n.y + 14) lift.set(n, { ...n, portTop: t }); });
|
|
14806
|
+
const out = lift.size ? { nodes: g.nodes.map(n => lift.get(n) || n), byId: Object.fromEntries(g.nodes.map(n => [n.id, lift.get(n) || n])),
|
|
14807
|
+
edges: g.edges.map(e => (lift.has(e.s) || lift.has(e.t) ? { ...e, s: lift.get(e.s) || e.s, t: lift.get(e.t) || e.t } : e)) } : {};
|
|
14808
|
+
return { ...g, ...out, legend: c4Legend(nodes, edges), order: [...readingOrder(pic), ...edges.map(e => e.id)] }; // a new object: what layoutTyped returned is never edited
|
|
14809
|
+
}
|
|
14810
|
+
|
|
14811
|
+
/* ---- validators (§5.1, §6.5). Each problem: { level, rule, path, message, ids } ---- */
|
|
14812
|
+
const C4_RULES = {
|
|
14813
|
+
'c4-level': 'a view shows only what its level draws: a landscape or context view people and software systems; a container view containers too; a component view the components of its subject; an element is not listed with its owner unless that owner is the view\'s subject',
|
|
14814
|
+
'c4-subject': 'a context or container view zooms into a software system, a component view into a container; a context view shows its subject',
|
|
14815
|
+
'c4-owner': 'a container is owned by a software system, a component by a container, a container instance by a deployment node; a person or software system by nothing',
|
|
14816
|
+
'c4-instance': 'a container instance names a container',
|
|
14817
|
+
'c4-relationship': 'a relationship joins two static elements or two deployment elements, never an element and its own owner',
|
|
14818
|
+
'c4-zoom': 'a software system opens a view whose subject is that system, a container one whose subject is that container',
|
|
14819
|
+
'c4-description': 'every element has a description',
|
|
14820
|
+
'c4-technology': 'every container and component has a technology',
|
|
14821
|
+
'c4-label': 'every relationship has a description',
|
|
14822
|
+
'c4-title': 'every view has a title',
|
|
14823
|
+
};
|
|
14824
|
+
const C4_KIND = { 'c4.landscape': 'landscape', 'c4.context': 'context', 'c4.container': 'container', 'c4.component': 'component' };
|
|
14825
|
+
// what each static view kind may show (§5.1), and the type its subject is
|
|
14826
|
+
const C4_SHOWS = { landscape: ['c4.Person', 'c4.SoftwareSystem'], context: ['c4.Person', 'c4.SoftwareSystem'], container: ['c4.Person', 'c4.SoftwareSystem', 'c4.Container'],
|
|
14827
|
+
component: ['c4.Person', 'c4.SoftwareSystem', 'c4.Container', 'c4.Component'] };
|
|
14828
|
+
const C4_SUBJECT = { context: 'c4.SoftwareSystem', container: 'c4.SoftwareSystem', component: 'c4.Container' };
|
|
14829
|
+
const C4_OWNER = { 'c4.Container': 'c4.SoftwareSystem', 'c4.Component': 'c4.Container', 'c4.ContainerInstance': 'c4.DeploymentNode' };
|
|
14830
|
+
const c4A = w => (/^[aeiou]/i.test(w) ? 'an ' : 'a ') + w;
|
|
14831
|
+
function c4Problems(model, view) {
|
|
14832
|
+
const out = [], I = c4Index(model), say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
14833
|
+
const q = id => JSON.stringify(I.at.has(id) ? c4Name(I.at.get(id)) : String(id)), kind = id => (I.at.has(id) ? c4Word(I.at.get(id)).toLowerCase() : 'nothing');
|
|
14834
|
+
const views = C4_ARR(model && model.views), vi = views.indexOf(view), at = vi >= 0 ? `views[${vi}]` : 'view', K = C4_OBJ(view) ? C4_KIND[view.diagram] : null;
|
|
14835
|
+
// the model's rules: said once, whichever view asks (validate() keeps one of each path and message)
|
|
14836
|
+
C4_ARR(model && model.nodes).forEach((n, i) => { if (!C4_OBJ(n)) return;
|
|
14837
|
+
if (Object.hasOwn(C4_OWNER, n.type) && (I.at.get(n.parent) || {}).type !== C4_OWNER[n.type])
|
|
14838
|
+
say('error', 'c4-owner', `nodes[${i}].parent`, [n.id, n.parent], `${q(n.id)}, ${c4A(kind(n.id))}, is owned by ${n.parent != null && I.at.has(n.parent) ? `${q(n.parent)}, ${c4A(kind(n.parent))}` : 'nothing'}: its owner is ${c4A(C4_WORD[C4_OWNER[n.type]].toLowerCase())}`);
|
|
14839
|
+
if ((n.type === 'c4.Person' || n.type === 'c4.SoftwareSystem') && n.parent != null)
|
|
14840
|
+
say('error', 'c4-owner', `nodes[${i}].parent`, [n.id, n.parent], `${q(n.id)}, ${c4A(kind(n.id))}, is owned by ${q(n.parent)}${I.at.has(n.parent) ? `, ${c4A(kind(n.parent))}` : ''}: it is owned by nothing`);
|
|
14841
|
+
if (n.type === 'c4.DeploymentNode' && n.parent != null && (I.at.get(n.parent) || {}).type !== 'c4.DeploymentNode')
|
|
14842
|
+
say('error', 'c4-owner', `nodes[${i}].parent`, [n.id, n.parent], `${q(n.id)}, a deployment node, is owned by ${q(n.parent)}, ${c4A(kind(n.parent))}: its owner is a deployment node or none`);
|
|
14843
|
+
if (n.type === 'c4.ContainerInstance' && (I.at.get(C4_P(n).container) || {}).type !== 'c4.Container')
|
|
14844
|
+
say('error', 'c4-instance', `nodes[${i}].props.container`, [n.id, C4_P(n).container], `${q(n.id)} is an instance of ${!C4_HAS(C4_P(n).container) ? 'nothing' : I.at.has(C4_P(n).container) ? `${q(C4_P(n).container)}, ${c4A(kind(C4_P(n).container))}` : `${JSON.stringify(String(C4_P(n).container))}, which is no element`}: it names a container`);
|
|
14845
|
+
// §6.5: a system's or container's opensView, when it names a C4 view, has that element as its subject
|
|
14846
|
+
const ov = (n.type === 'c4.SoftwareSystem' || n.type === 'c4.Container') && n.opensView != null && views.find(v => C4_OBJ(v) && v.id === n.opensView);
|
|
14847
|
+
if (ov && /^c4\./.test(String(ov.diagram)) && ov.subject !== n.id)
|
|
14848
|
+
say('warning', 'c4-zoom', `nodes[${i}].opensView`, [n.id, ov.subject], `${q(n.id)} opens view ${JSON.stringify(ov.title || ov.id)}, whose subject is ${ov.subject != null ? q(ov.subject) : 'none'}: it zooms into ${q(n.id)}`); });
|
|
14849
|
+
// a relationship by its path: the model's, else written inline in the view's edges (model.md)
|
|
14850
|
+
const vEdges = C4_OBJ(view) ? C4_ARR(view.edges) : [], pathOf = e => { const i = C4_ARR(model && model.edges).indexOf(e); return i >= 0 ? `edges[${i}]` : `${at}.edges[${vEdges.indexOf(e)}]`; };
|
|
14851
|
+
[...C4_ARR(model && model.edges), ...vEdges.filter(e => C4_OBJ(e) && !C4_ARR(model && model.edges).includes(e))].forEach(e => { if (!C4_OBJ(e) || e.type !== 'c4.Relationship') return;
|
|
14852
|
+
const i = pathOf(e), a = I.at.get(e.from), b = I.at.get(e.to), st = x => x && C4_STATIC.includes(x.type), dp = x => x && C4_DEPLOY.includes(x.type);
|
|
14853
|
+
if ((st(a) && dp(b)) || (dp(a) && st(b))) say('error', 'c4-relationship', i, [e.id, e.from, e.to], `${e.id} joins ${q(e.from)}, ${c4A(kind(e.from))}, and ${q(e.to)}, ${c4A(kind(e.to))}: a static element and a deployment one`);
|
|
14854
|
+
else if (a && b && (I.chain(e.from).includes(e.to) || I.chain(e.to).includes(e.from))) say('error', 'c4-relationship', i, [e.id, e.from, e.to], `${e.id} joins ${q(e.from)} and its own owner or part ${q(e.to)}`); });
|
|
14855
|
+
if (!K) return out;
|
|
14856
|
+
// the view's rules (§5.1)
|
|
14857
|
+
const ids = C4_ARR(view.nodeIds), S = C4_SUBJECT[K], subj = view.subject != null ? I.at.get(view.subject) : null;
|
|
14858
|
+
if (K === 'context' && !(subj && subj.type === S && ids.includes(view.subject)))
|
|
14859
|
+
say('warning', 'c4-subject', `${at}.subject`, [view.subject], view.subject == null ? 'a context view names the software system it is about as its subject' : `${q(view.subject)} is ${!subj || subj.type !== S ? 'not a software system' : 'not shown'}: a context view shows its software system`);
|
|
14860
|
+
if ((K === 'container' || K === 'component') && !(subj && subj.type === S))
|
|
14861
|
+
say('error', 'c4-subject', `${at}.subject`, [view.subject], `${view.subject == null ? 'the view has no subject' : `its subject ${q(view.subject)} is ${c4A(kind(view.subject))}`}: a ${K} view zooms into ${c4A(C4_WORD[S].toLowerCase())}`);
|
|
14862
|
+
ids.forEach((id, k) => { const x = I.at.get(id); if (!x || x.type === 'uml.Comment') return;
|
|
14863
|
+
const ok = C4_SHOWS[K].includes(x.type) && !(K === 'component' && x.type === 'c4.Component' && x.parent !== view.subject);
|
|
14864
|
+
if (!ok) say('error', 'c4-level', `${at}.nodeIds[${k}]`, [id], `${q(id)}, ${c4A(kind(id))}${x.type === 'c4.Component' && K === 'component' ? ` of ${q(x.parent)}` : ''}, is not drawn in a ${K} view`);
|
|
14865
|
+
// §7.5.1: only the subject is a boundary; any other listed owner would box this element and drop the lines lifted onto it
|
|
14866
|
+
const o = I.chain(id).find(y => ids.includes(y));
|
|
14867
|
+
if (ok && o != null && o !== view.subject) say('error', 'c4-level', `${at}.nodeIds[${k}]`, [id, o], `${q(id)} is listed with its owner ${q(o)}, which is not the view's subject: only the subject is drawn as a boundary`);
|
|
14868
|
+
const P = C4_P(x), j = C4_ARR(model.nodes).indexOf(x);
|
|
14869
|
+
if (C4_STATIC.includes(x.type) && !C4_HAS(P.description)) say('warning', 'c4-description', `nodes[${j}].props.description`, [id], `${q(id)} has no description`);
|
|
14870
|
+
if ((x.type === 'c4.Container' || x.type === 'c4.Component') && !C4_HAS(P.technology)) say('warning', 'c4-technology', `nodes[${j}].props.technology`, [id], `${q(id)}, ${c4A(kind(id))}, has no technology`); });
|
|
14871
|
+
const pic = c4Pic(model, view);
|
|
14872
|
+
c4Lines(model, view, pic).own.forEach(e => {
|
|
14873
|
+
if (e.type === 'c4.Relationship' && !C4_HAS(e.label)) say('warning', 'c4-label', `${pathOf(e)}.label`, [e.id, e.from, e.to], `${e.id}, from ${q(e.from)} to ${q(e.to)}, has no description`); });
|
|
14874
|
+
if (!C4_HAS(view.title)) say('warning', 'c4-title', `${at}.title`, [], `view ${JSON.stringify(view.id)} has no title`);
|
|
14875
|
+
return out;
|
|
14876
|
+
}
|
|
14877
|
+
|
|
14878
|
+
/* ---- describe(): the view in sentences, each element in reading order, then each line by its accessible name ---- */
|
|
14879
|
+
function c4Describe(model, viewId) {
|
|
14880
|
+
const v = C4_ARR(model && model.views).find(x => C4_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
14881
|
+
const pic = c4Pic(model, v), { own, implied } = c4Lines(model, v, pic), K = C4_KIND[v.diagram] || 'C4', box = new Map(pic.boxes.map(b => [b.id, b]));
|
|
14882
|
+
return [`${v.title || v.id}: a C4 ${K === 'landscape' ? 'system landscape' : K === 'context' ? 'system context' : K} diagram of ${c4Plural(pic.ids.length, 'element')} and ${c4Plural(own.length + implied.length, 'relationship')}${implied.length ? `, ${implied.length} of them implied` : ''}.`,
|
|
14883
|
+
...readingOrder(pic).map(id => (box.has(id) ? box.get(id).aria : c4NodeName(pic.at.get(id)))), ...[...own, ...implied].map(e => c4LineName(e, pic))].map(t => t.replace(/\.?$/, '.')).join('\n');
|
|
14884
|
+
}
|
|
14885
|
+
|
|
14886
|
+
/* ---- editing (§6.1, §7.5) ---- */
|
|
14887
|
+
const c4Used = model => { const used = new Set(C4_ARR(model.views).filter(C4_OBJ).map(v => v.id)); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; }); return used; };
|
|
14888
|
+
const c4Free = (used, base) => { if (!used.has(base)) return base; let k = 2; while (used.has(`${base}-${k}`)) k++; return `${base}-${k}`; };
|
|
14889
|
+
// D (§6.1): a software system's container view, or a container's component view, made: the element (its boundary), what it
|
|
14890
|
+
// owns a level down, and what their relationships reach, each lifted to that level; the element then opens it. One transaction
|
|
14891
|
+
function c4Zoom(model, view, ids) {
|
|
14892
|
+
const I = c4Index(model), x = [...ids].reverse().map(id => I.at.get(id)).find(y => y && (y.type === 'c4.SoftwareSystem' || y.type === 'c4.Container') && y.opensView == null);
|
|
14893
|
+
if (!x) return null;
|
|
14894
|
+
const sys = x.type === 'c4.SoftwareSystem', kids = I.kids(x.id, sys ? 'c4.Container' : 'c4.Component'), inside = new Set(kids);
|
|
14895
|
+
// the far end of a relationship, at the new view's level: a person or a software system as it is, a container as it is (a
|
|
14896
|
+
// system's own are inside already), a component as its container, anything else not at all
|
|
14897
|
+
const level = id => { for (const y of [id, ...I.chain(id)]) { const t = (I.at.get(y) || {}).type;
|
|
14898
|
+
if (t === 'c4.Person' || t === 'c4.SoftwareSystem' || t === 'c4.Container') return y === x.id ? null : y; } return null; };
|
|
14899
|
+
const reach = []; I.rels.forEach(r => [[r.from, r.to], [r.to, r.from]].forEach(([a, b]) => { const up = [a, ...I.chain(a)].find(y => inside.has(y));
|
|
14900
|
+
const far = up && !inside.has(b) && !I.chain(b).some(y => inside.has(y)) ? level(b) : null; if (far && !reach.includes(far)) reach.push(far); }));
|
|
14901
|
+
const used = c4Used(model), id = c4Free(used, `${x.id}-${sys ? 'containers' : 'components'}`);
|
|
14902
|
+
const v = { id, diagram: sys ? 'c4.container' : 'c4.component', title: `${c4Name(x)}: ${sys ? 'containers' : 'components'}`, parent: view.id, subject: x.id, nodeIds: [x.id, ...kids, ...reach] };
|
|
14903
|
+
return { commands: [{ op: 'addView', view: v }, { op: 'set', id: x.id, path: 'opensView', value: id }], say: `Made the view ${v.title}: Enter opens it` };
|
|
14904
|
+
}
|
|
14905
|
+
// T: an element's C4 type, picked among the four (c4-owner and c4-level then say what no longer fits)
|
|
14906
|
+
function c4Retype(model, ids) {
|
|
14907
|
+
const I = c4Index(model), x = I.at.get(ids.at(-1)); if (!x || !C4_STATIC.includes(x.type)) return null;
|
|
14908
|
+
return { pick: C4_STATIC.map(t => C4_WORD[t]), then: i => (C4_STATIC[i] === x.type ? [] : [{ op: 'set', id: x.id, path: 'type', value: C4_STATIC[i] }]) };
|
|
14909
|
+
}
|
|
14910
|
+
const c4Key = (model, view, ids, key) => (key === 'd' || key === 'D' ? c4Zoom(model, view, ids) : key === 't' || key === 'T' ? c4Retype(model, ids) : null);
|
|
14911
|
+
// add: a container added to a container view, or a component to a component view, is owned by the view's subject
|
|
14912
|
+
function c4Add(model, view, entry, label) {
|
|
14913
|
+
const I = c4Index(model), s = I.at.get(view.subject), K = C4_KIND[view.diagram];
|
|
14914
|
+
if (!s || !((K === 'container' && entry.type === 'c4.Container') || (K === 'component' && entry.type === 'c4.Component')) || s.type !== C4_OWNER[entry.type]) return null;
|
|
14915
|
+
const base = entry.type.split('.').pop().toLowerCase(), used = c4Used(model); let k = 1; while (used.has(base + k)) k++;
|
|
14916
|
+
return [{ op: 'add', element: { id: base + k, type: entry.type, ...(label ? { label } : {}), parent: s.id }, view: view.id }];
|
|
14917
|
+
}
|
|
14918
|
+
// the Owner field: a container's software systems, a component's containers
|
|
14919
|
+
function c4Owners(model, x) {
|
|
14920
|
+
if (!C4_OBJ(x) || x.from != null || !Object.hasOwn(C4_OWNER, x.type) || x.type === 'c4.ContainerInstance') return null;
|
|
14921
|
+
return C4_ARR(model.nodes).filter(n => C4_OBJ(n) && n.type === C4_OWNER[x.type]).map(n => ({ id: n.id, label: c4Name(n) }));
|
|
14922
|
+
}
|
|
14923
|
+
|
|
14924
|
+
/* ---- the type, under its four static view names ---- */
|
|
14925
|
+
let c4People = new Set();
|
|
14926
|
+
const C4_ELEMENT_PROPS = [{ key: 'description', label: 'Description', kind: 'text' }, { key: 'technology', label: 'Technology', kind: 'text' },
|
|
14927
|
+
{ key: 'external', label: 'External', kind: 'bool' }, { key: 'shape', label: 'Shape', kind: 'enum', options: C4_SHAPES }];
|
|
14928
|
+
const C4_DEF = {
|
|
14929
|
+
title: 'C4 diagram',
|
|
14930
|
+
elements: [...C4_STATIC.map(t => ({ type: t, label: C4_WORD[t] })), { type: 'uml.Comment', label: 'Comment' }],
|
|
14931
|
+
lines: [{ type: 'c4.Relationship', label: 'Relationship' }, { type: 'uml.CommentLink', label: 'Comment link' }],
|
|
14932
|
+
props: { 'c4.Person': C4_ELEMENT_PROPS.filter(f => f.key !== 'technology'), 'c4.SoftwareSystem': C4_ELEMENT_PROPS.filter(f => f.key !== 'technology'),
|
|
14933
|
+
'c4.Container': C4_ELEMENT_PROPS, 'c4.Component': C4_ELEMENT_PROPS, 'c4.Relationship': [{ key: 'technology', label: 'Technology', kind: 'text' }], 'uml.CommentLink': [] },
|
|
14934
|
+
layout: c4Geo,
|
|
14935
|
+
boxes: (model, view) => { c4People = new Set(C4_ARR(model && model.nodes).filter(n => C4_OBJ(n) && n.type === 'c4.Person').map(n => n.id)); return c4Pic(model, view).boxes; },
|
|
14936
|
+
// a line into a person is ranked as if it came from them, so people come first (C4 draws them at the top or left). The host
|
|
14937
|
+
// asks boxes() before it ranks a view, which leaves the model's people here. ponytail: module state read by rankFlip,
|
|
14938
|
+
// which sees the line alone; a rankFlip(e, model) would lift it
|
|
14939
|
+
rankFlip: e => c4People.has(e.to) && !c4People.has(e.from),
|
|
14940
|
+
owners: c4Owners,
|
|
14941
|
+
validate: c4Problems,
|
|
14942
|
+
rules: C4_RULES,
|
|
14943
|
+
describe: c4Describe,
|
|
14944
|
+
// the PDF's tables (§9): an element's type; its technology and description; a relationship's text, with its technology
|
|
14945
|
+
word: (model, id) => { const x = c4Index(model).at.get(id); return x && Object.hasOwn(C4_WORD, x.type) ? C4_WORD[x.type] : null; },
|
|
14946
|
+
rows: (model, view, id) => { const x = c4Index(model).at.get(id); if (!x || !C4_STATIC.includes(x.type)) return null; const P = C4_P(x);
|
|
14947
|
+
return [...(C4_HAS(P.technology) ? [P.technology] : []), ...(C4_HAS(P.description) ? [P.description] : []), ...(c4Ext(x) ? ['external'] : [])]; },
|
|
14948
|
+
text: (model, view, id) => { const e = c4Index(model).rels.find(r => r.id === id) || (C4_OBJ(view) ? C4_ARR(view.edges) : []).find(r => C4_OBJ(r) && r.id === id && r.type === 'c4.Relationship'); return e ? c4LineLabel(e) || null : null; },
|
|
14949
|
+
edits: { add: c4Add, key: c4Key },
|
|
14950
|
+
};
|
|
14951
|
+
const C4_TYPES = Object.keys(C4_KIND).map(name => registerDiagramType(name, C4_DEF));
|
|
14952
|
+
/* ---------- C4's dynamic and deployment views, c4.dynamic and c4.deployment (work package M2.7, notes/milestone-2.md §7.5.2,
|
|
14953
|
+
§7.5.3, §5.1) ----------
|
|
14954
|
+
The dynamic view: its `edges` are model relationships by id, each once, in time order (S13); each is drawn numbered by its
|
|
14955
|
+
place, "k · description [technology]" (the layout's step: with no room for its text a label keeps its number), over the
|
|
14956
|
+
layered layout of the elements it shows, laid out as src/c4/c4.js lays a static view out (its boundary, people first); only
|
|
14957
|
+
steps are drawn, none implied. Alt + arrows on a
|
|
14958
|
+
selected step move it one place (moveTo); a relationship drawn by L or the handle between two shown elements is a step,
|
|
14959
|
+
the model's own when one joins them already. Its level is its subject's: a software system's as a container view, a
|
|
14960
|
+
container's as a component view, none as a landscape. The deployment view: deployment nodes nest as solid boxes
|
|
14961
|
+
(ownersOn) titled "<name> [Deployment Node: technology]", a node holding nothing shown drawn as the map's node shape;
|
|
14962
|
+
a container instance is drawn as its container (shape, name, technology, description), "Instance of" in its name. Its
|
|
14963
|
+
lines are the model's, lifted as §5.2 lifts them. Deleting a container deletes its instances (refs). Ships in
|
|
14964
|
+
lib/umlimator-all.js only. */
|
|
14965
|
+
|
|
14966
|
+
const V_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), V_ARR = x => (Array.isArray(x) ? x : []);
|
|
14967
|
+
const V_HAS = x => x != null && String(x).trim() !== '';
|
|
14968
|
+
const V_P = x => (V_OBJ(x.props) ? x.props : {});
|
|
14969
|
+
const vName = x => String(x.label ?? x.id);
|
|
14970
|
+
const vPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
14971
|
+
const V_RULES = { ...C4_RULES,
|
|
14972
|
+
'c4-level': C4_RULES['c4-level'] + '; a dynamic view what its subject\'s level draws; a deployment view deployment nodes and container instances, with a subject only instances of its containers',
|
|
14973
|
+
'c4-subject': C4_RULES['c4-subject'] + '; a dynamic view zooms into a software system, a container or nothing; a deployment view into a software system or nothing',
|
|
14974
|
+
'c4-steps': 'a dynamic view\'s steps are the model\'s relationships, each once, between elements it shows; it lists at least one' };
|
|
14975
|
+
const DEP_RULES = { ...V_RULES, 'c4-box-line': 'a deployment view draws no line onto a deployment node drawn as a box: a relationship with an end in or on one is not drawn' };
|
|
14976
|
+
// every id the model holds, views' too (src/c4/c4.js's c4Used, not exported; src/c4/c4.js is M2.6's)
|
|
14977
|
+
const vUsed = model => { const used = new Set(V_ARR(model.views).filter(V_OBJ).map(v => v.id)); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; }); return used; };
|
|
14978
|
+
|
|
14979
|
+
/* ---- the dynamic view (§7.5.2) ---- */
|
|
14980
|
+
// its steps drawn: [{ e, k, i }], each model relationship listed (the first time) between two elements the view shows (not a
|
|
14981
|
+
// boundary), numbered 1, 2, 3 among those drawn (S13), i its place in view.edges
|
|
14982
|
+
function dynSteps(model, view) {
|
|
14983
|
+
if (!V_OBJ(view)) return [];
|
|
14984
|
+
const pic = c4Pic(model, view), ends = new Set(pic.ids.filter(id => !pic.open.has(id))), rel = new Map(pic.I.rels.map(r => [r.id, r])), seen = new Set(), out = [];
|
|
14985
|
+
V_ARR(view.edges).forEach((x, i) => { const e = typeof x === 'string' ? rel.get(x) : null;
|
|
14986
|
+
if (e && !seen.has(e.id) && ends.has(e.from) && ends.has(e.to)) out.push({ e, k: out.length + 1, i }); if (e) seen.add(e.id); });
|
|
14987
|
+
return out;
|
|
14988
|
+
}
|
|
14989
|
+
// the level a dynamic view draws at: as the static view its subject's type zooms to
|
|
14990
|
+
const dynLevel = (model, view) => { const t = (c4Index(model).at.get(view.subject) || {}).type;
|
|
14991
|
+
return view.subject == null ? 'c4.landscape' : t === 'c4.SoftwareSystem' ? 'c4.container' : t === 'c4.Container' ? 'c4.component' : null; };
|
|
14992
|
+
// what a dynamic view draws: its steps between two elements it shows (not a boundary), and the comment links it lists
|
|
14993
|
+
function dynDrawn(model, view) {
|
|
14994
|
+
const pic = c4Pic(model, view), ends = new Set(pic.ids.filter(id => !pic.open.has(id))), steps = dynSteps(model, view);
|
|
14995
|
+
const notes = V_ARR(view.edges).map(x => (typeof x === 'string' ? pic.I.E.find(e => e.id === x) : null)).filter(e => e && e.type === 'uml.CommentLink' && ends.has(e.from) && ends.has(e.to));
|
|
14996
|
+
return { pic, steps, notes };
|
|
14997
|
+
}
|
|
14998
|
+
const dynName = (e, k, pic) => `Step ${k}: ${c4LineName(e, pic)}`;
|
|
14999
|
+
// the key's items: each element type, shape and tone drawn (as src/c4/c4.js keys them), the relationship and the implied line
|
|
15000
|
+
const vItem = (name, says, svg) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true">${svg}</svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
15001
|
+
const vSwatch = ext => `<rect x="3" y="2" width="22" height="12" rx="3" fill="var(--um-${ext ? 'slate' : 'cyan'}-f)" stroke="var(--um-${ext ? 'slate' : 'cyan'})"/>`;
|
|
15002
|
+
const vLine = dashed => `<g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round" fill="none"><path d="M2 8H25"${dashed ? ' stroke-dasharray="3 2"' : ''}/><path d="M17 4L26 8L17 12"/></g>`;
|
|
15003
|
+
const vLines = (edges, says = 'uses, with its technology in brackets') => [...(edges.some(e => e.type === 'c4.Relationship' && !e.implied) ? [vItem('Relationship', says, vLine(false))] : []),
|
|
15004
|
+
...(edges.some(e => e.implied) ? [vItem('Implied relationship', 'stands for relationships between what the two hold', vLine(true))] : [])];
|
|
15005
|
+
const V_LINE = { rel: { from: 'none', to: 'open' }, implied: { from: 'none', to: 'open', dashed: true }, note: { from: 'none', to: 'none', dashed: true } };
|
|
15006
|
+
// an element of a C4 view as src/c4/c4.js draws it (shape c4: its map shape and three lines), or a comment
|
|
15007
|
+
function vNode(id, x, P, more = {}) {
|
|
15008
|
+
if (x.type === 'uml.Comment') { const d = { id, label: vName(x), type: x.type, kind: 'file', shape: 'note', aria: c4NodeName(x), col: P(id)[0], row: P(id)[1] }; return { ...d, ...SHAPE.note.size(d) }; }
|
|
15009
|
+
const ext = c4Ext(x), d = { id, label: vName(x), type: x.type, kind: ext ? 'external' : 'frontend', shape: 'c4', sh: c4Shape(x), ext, tag: c4TagOf(x), desc: V_P(x).description, aria: c4NodeName(x), col: P(id)[0], row: P(id)[1], ...more };
|
|
15010
|
+
return { ...d, ...SHAPE.c4.size(d) };
|
|
15011
|
+
}
|
|
15012
|
+
// laid out as src/c4/c4.js lays a static view out (§11): its boxes, people first, the small gaps, labelGaps, repair and reroute off;
|
|
15013
|
+
// each step carries its number as the layout's step, so a label with no room keeps its number ("3" for "3 · Charges cards with")
|
|
15014
|
+
function dynGeo({ model, view, pos, memo }) {
|
|
15015
|
+
const { pic, steps, notes } = dynDrawn(model, view), P = id => (V_OBJ(pos) && Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
|
|
15016
|
+
const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => vNode(id, pic.at.get(id), P));
|
|
15017
|
+
const edges = [...steps.map(({ e, k }) => ({ id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, step: k, label: c4LineLabel(e) || undefined, end: V_LINE.rel, aria: dynName(e, k, pic), cut: 280 })),
|
|
15018
|
+
...notes.map(e => ({ id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, end: V_LINE.note, aria: c4LineName(e, pic), cut: 280 }))];
|
|
15019
|
+
const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: view.direction === 'down' ? 'down' : 'right',
|
|
15020
|
+
gap: 48, rgap: 64, labelGaps: true, repair: false, reroute: false }, memo);
|
|
15021
|
+
// on a copy (stage 4 §2.4): a person's or robot's body starts just above the highest line on its side, as src/c4/c4.js does
|
|
15022
|
+
const sp = sidePorts(g), lift = new Map();
|
|
15023
|
+
g.nodes.forEach(n => { const p = (n.sh === 'person' || n.sh === 'robot') && sp.get(n), t = p ? Math.min(p.L, p.R) : Infinity; if (t < n.y + 14) lift.set(n, { ...n, portTop: t }); });
|
|
15024
|
+
const out = lift.size ? { nodes: g.nodes.map(n => lift.get(n) || n), byId: Object.fromEntries(g.nodes.map(n => [n.id, lift.get(n) || n])),
|
|
15025
|
+
edges: g.edges.map(e => (lift.has(e.s) || lift.has(e.t) ? { ...e, s: lift.get(e.s) || e.s, t: lift.get(e.t) || e.t } : e)) } : {};
|
|
15026
|
+
const els = [...new Map(nodes.filter(n => n.shape === 'c4').map(n => [`${n.type} ${n.sh} ${n.ext}`, n])).values()];
|
|
15027
|
+
const legend = [...els.map(n => vItem(`${n.ext ? 'External ' + C4_WORD[n.type].toLowerCase() : C4_WORD[n.type]}${n.sh !== 'box' && !(n.sh === 'person' && n.type === 'c4.Person') ? ', ' + n.sh : ''}`,
|
|
15028
|
+
n.ext ? 'outside the system, drawn grey' : `a ${C4_WORD[n.type].toLowerCase()}`, vSwatch(n.ext))), ...vLines(edges, 'a step, numbered in time order, with its technology in brackets')];
|
|
15029
|
+
return { ...g, ...out, legend, order: [...readingOrder(pic), ...edges.map(e => e.id)] };
|
|
15030
|
+
}
|
|
15031
|
+
function dynProblems(model, view) {
|
|
15032
|
+
if (!V_OBJ(view)) return c4Problems(model, view);
|
|
15033
|
+
const views = V_ARR(model && model.views), vi = views.indexOf(view), at = vi >= 0 ? `views[${vi}]` : 'view', I = c4Index(model), lvl = dynLevel(model, view);
|
|
15034
|
+
const say = (level, rule, path, ids, msg) => ({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` }), q = id => JSON.stringify(I.at.has(id) ? vName(I.at.get(id)) : String(id));
|
|
15035
|
+
// the subject's level checked as that static view would be, on a copy of the model whose view says it
|
|
15036
|
+
const as = { ...view, diagram: lvl || 'c4.landscape' }, kind = { 'c4.landscape': 'landscape', 'c4.container': 'container', 'c4.component': 'component' }[as.diagram];
|
|
15037
|
+
const w = (C4_WORD[(I.at.get(view.subject) || {}).type] || 'element').toLowerCase(), out = lvl ? [] : [say('error', 'c4-subject', `${at}.subject`, [view.subject],
|
|
15038
|
+
`its subject ${q(view.subject)} is ${I.at.has(view.subject) ? (/^[aeiou]/.test(w) ? 'an ' : 'a ') + w : 'no element'}: a dynamic view zooms into a software system, a container or nothing`)];
|
|
15039
|
+
out.push(...c4Problems({ ...model, views: views.map(v => (v === view ? as : v)) }, as).map(p => (p.rule === 'c4-level' ? { ...p, message: p.message.replace(`in a ${kind} view`, `in a dynamic view${lvl && view.subject != null ? ' of ' + q(view.subject) : ''}`) } : p)));
|
|
15040
|
+
// its steps (§5.1): each a model relationship, each once, between elements the view shows
|
|
15041
|
+
const pic = c4Pic(model, view), ends = new Set(pic.ids.filter(id => !pic.open.has(id))), rel = new Set(I.rels.map(r => r.id)), seen = new Set();
|
|
15042
|
+
V_ARR(view.edges).forEach((x, k) => { const p = `${at}.edges[${k}]`;
|
|
15043
|
+
if (V_OBJ(x) && x.type === 'c4.Relationship') return out.push(say('error', 'c4-steps', p, [x.id], `step ${x.id} is written in the view: a step names a relationship of the model`));
|
|
15044
|
+
if (V_OBJ(x)) return out.push(say('error', 'c4-steps', p, [typeof x.id === 'string' ? x.id : null], `step ${JSON.stringify(typeof x.id === 'string' ? x.id : null)} is written in the view and is no relationship: a step names a relationship of the model`));
|
|
15045
|
+
if (typeof x !== 'string' || !(rel.has(x) || I.E.some(e => e.id === x && e.type === 'uml.CommentLink'))) return out.push(say('error', 'c4-steps', p, [String(x)], `step ${JSON.stringify(String(x))} is no relationship of the model`));
|
|
15046
|
+
if (!rel.has(x)) return null;
|
|
15047
|
+
if (seen.has(x)) return out.push(say('error', 'c4-steps', p, [x], `${x} is a step twice: each relationship is one step`));
|
|
15048
|
+
seen.add(x); const e = I.E.find(r => r.id === x), gone = [e.from, e.to].filter(id => !ends.has(id));
|
|
15049
|
+
if (gone.length) out.push(say('warning', 'c4-steps', p, [x, ...gone], `${x}, from ${q(e.from)} to ${q(e.to)}, is not drawn: the view ${gone.map(id => (pic.open.has(id) ? `draws ${q(id)} as a boundary` : `does not show ${q(id)}`)).join(' or ')}`)); return null; });
|
|
15050
|
+
if (!V_ARR(view.edges).some(x => typeof x === 'string' && rel.has(x))) out.push(say('warning', 'c4-steps', `${at}.edges`, [], `view ${JSON.stringify(String(view.id))} lists no step: a dynamic view draws its steps alone`));
|
|
15051
|
+
return out;
|
|
15052
|
+
}
|
|
15053
|
+
function dynDescribe(model, viewId) {
|
|
15054
|
+
const v = V_ARR(model && model.views).find(x => V_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
15055
|
+
const { steps, pic } = dynDrawn(model, v), box = new Map(c4Pic(model, v).boxes.map(b => [b.id, b]));
|
|
15056
|
+
return [`${v.title || v.id}: a C4 dynamic diagram of ${vPlural(pic.ids.length, 'element')} and ${vPlural(steps.length, 'step')}.`,
|
|
15057
|
+
...readingOrder(pic).map(id => (box.has(id) ? box.get(id).aria : c4NodeName(pic.at.get(id)))), ...steps.map(({ e, k }) => dynName(e, k, pic))].map(t => t.replace(/\.?$/, '.')).join('\n');
|
|
15058
|
+
}
|
|
15059
|
+
/* moveTo(model, view, id, at): a step one place earlier or later (Alt + arrows, { by }), or to { index } in view.edges. A
|
|
15060
|
+
card, or a line dropped where there is no slot: null, the canvas's own */
|
|
15061
|
+
function dynMoveTo(model, view, id, at) {
|
|
15062
|
+
if (!V_OBJ(view) || !V_OBJ(at)) return null;
|
|
15063
|
+
const steps = dynSteps(model, view), k = steps.findIndex(s => s.e.id === id);
|
|
15064
|
+
if (k < 0) return null;
|
|
15065
|
+
if (Number.isInteger(at.index)) return at.index === steps[k].i ? [] : [{ op: 'move', id, view: view.id, index: at.index }];
|
|
15066
|
+
if (!Array.isArray(at.by)) return null;
|
|
15067
|
+
const to = k + Math.sign((+at.by[0] || 0) + (+at.by[1] || 0));
|
|
15068
|
+
return to < 0 || to >= steps.length || to === k ? [] : [{ op: 'move', id, view: view.id, index: steps[to].i }];
|
|
15069
|
+
}
|
|
15070
|
+
// connect: a relationship between two shown elements is a step: the model's own when one joins them and is not a step yet,
|
|
15071
|
+
// else a new one (the canvas's add, which lists it last)
|
|
15072
|
+
function dynConnect(model, view, from, to, at, line) {
|
|
15073
|
+
if (!V_OBJ(view) || (V_OBJ(line) && line.type !== 'c4.Relationship')) return null;
|
|
15074
|
+
const listed = V_ARR(view.edges), r = c4Index(model).rels.find(e => e.from === from && e.to === to && !listed.includes(e.id));
|
|
15075
|
+
return r ? [{ op: 'set', view: view.id, path: 'edges', value: [...listed, r.id] }] : null;
|
|
15076
|
+
}
|
|
15077
|
+
// rename: F2 on a step holds its text, "description [technology]": the technology when unchanged, and a number typed before it
|
|
15078
|
+
// ("3." or "3 ·"), are not the description; what is left is set as its label. Anything else: null, the canvas's own
|
|
15079
|
+
function dynRename(model, view, id, text) {
|
|
15080
|
+
const s = dynSteps(model, view).find(x => x.e.id === id); if (!s) return null;
|
|
15081
|
+
const tech = V_P(s.e).technology, t = String(text).trim().replace(new RegExp(`^${s.k}\\s*[.·]\\s*`), '');
|
|
15082
|
+
const label = V_HAS(tech) && t.endsWith(`[${tech}]`) ? t.slice(0, -tech.length - 2).trim() : t;
|
|
15083
|
+
return label === (s.e.label ?? '') ? [] : [{ op: 'set', id, path: 'label', value: label || undefined }];
|
|
15084
|
+
}
|
|
15085
|
+
// add: a container or component added at the view's level (its subject's, §5.1) is owned by the subject, as on that static view
|
|
15086
|
+
const dynAdd = (model, view, entry, label) => (V_OBJ(view) ? C4_DEF.edits.add(model, { ...view, diagram: dynLevel(model, view) }, entry, label) : null);
|
|
15087
|
+
const DYN_DEF = { ...C4_DEF,
|
|
15088
|
+
title: 'C4 dynamic diagram',
|
|
15089
|
+
layout: dynGeo,
|
|
15090
|
+
validate: dynProblems,
|
|
15091
|
+
rules: V_RULES,
|
|
15092
|
+
describe: dynDescribe,
|
|
15093
|
+
// the PDF's lines table: a step's number before its text, as drawn
|
|
15094
|
+
text: (model, view, id) => { const t = C4_DEF.text(model, view, id), s = dynSteps(model, view).find(x => x.e.id === id); return s ? [String(s.k), t].filter(V_HAS).join(' · ') : t; },
|
|
15095
|
+
edits: { ...C4_DEF.edits, add: dynAdd, connect: dynConnect, moveTo: dynMoveTo, rename: dynRename },
|
|
15096
|
+
};
|
|
15097
|
+
|
|
15098
|
+
/* ---- the deployment view (§7.5.3) ---- */
|
|
15099
|
+
const depTag = x => `[Deployment Node${V_HAS(V_P(x).technology) ? ': ' + V_P(x).technology : ''}]`;
|
|
15100
|
+
const depOf = (I, x) => { const c = I.at.get(V_P(x).container); return c && c.type === 'c4.Container' ? c : null; };
|
|
15101
|
+
// "Deployment node Web server, Ubuntu 24.04 LTS: The web tier's server"; "Instance of container Shop API, Python, FastAPI: …"
|
|
15102
|
+
function depNodeName(I, x) {
|
|
15103
|
+
if (x.type === 'c4.DeploymentNode') { const P = V_P(x); return `Deployment node ${vName(x)}${V_HAS(P.technology) ? ', ' + P.technology : ''}${V_HAS(P.description) ? ': ' + P.description : ''}`; }
|
|
15104
|
+
if (x.type === 'c4.ContainerInstance') { const c = depOf(I, x), s = c ? c4NodeName(c) : `container ${vName(x)}, of no container`; return 'Instance of ' + s[0].toLowerCase() + s.slice(1); }
|
|
15105
|
+
return c4NodeName(x);
|
|
15106
|
+
}
|
|
15107
|
+
// a deployment node holding something: a solid box, its name over "[Deployment Node: technology]" in a header as tall as the
|
|
15108
|
+
// deployment type's (head 15 px more), so the layout measures the title at 12 px and the box is as wide as it
|
|
15109
|
+
const V_HEAD = 15;
|
|
15110
|
+
SHAPE['c4-node'] = { frame: f => { const line = (t, dy, wt, px, fill) => `<text class="family-title" x="${r2(f.x + 12)}" y="${r2(f.y + dy)}" fill="${fill}" style="font:${wt} ${px}px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(t, px, f.w - 24))}</text>`;
|
|
15111
|
+
return `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="6" fill="none" stroke="var(--um-muted)" stroke-width="1.4"/>`
|
|
15112
|
+
+ (f.kw ? line(f.name, 17, 600, 12, 'var(--um-text)') + line(f.kw, 17 + V_HEAD, 400, 11, 'var(--um-muted)') : line(f.title ?? f.label ?? f.id, 17, 600, 12, 'var(--um-text)')); } };
|
|
15113
|
+
const DEP_LOOK = { boxTypes: ['c4.DeploymentNode'], qualify: false, shape: () => 'c4-node', word: () => 'Deployment node' };
|
|
15114
|
+
function depPic(model, view) {
|
|
15115
|
+
const pic = ownersOn(model, view, DEP_LOOK), I = c4Index(model);
|
|
15116
|
+
const boxes = pic.boxes.map(b => { const x = pic.at.get(b.id), held = pic.holds(b.id).map(id => pic.at.get(id)), k = new Map();
|
|
15117
|
+
held.forEach(y => { const w = (C4_WORD[y.type] || 'element').toLowerCase(); k.set(w, (k.get(w) || 0) + 1); });
|
|
15118
|
+
return { ...b, title: `${vName(x)} ${depTag(x)}`, name: vName(x), kw: depTag(x), head: V_HEAD, aria: `Deployment node ${vName(x)}${V_HAS(V_P(x).technology) ? ', ' + V_P(x).technology : ''}, a box holding ${[...k].map(([w, c]) => vPlural(c, w)).join(', ')}` }; });
|
|
15119
|
+
return { ...pic, I, boxes };
|
|
15120
|
+
}
|
|
15121
|
+
// the key: a deployment node, each instance's look (as its container's), the relationship and the implied line
|
|
15122
|
+
function depLegend(nodes, edges, boxes) {
|
|
15123
|
+
const els = [...new Map(nodes.filter(n => n.type === 'c4.ContainerInstance').map(n => [`${n.sh} ${n.ext}`, n])).values()];
|
|
15124
|
+
return [...(boxes.length || nodes.some(n => n.type === 'c4.DeploymentNode') ? [vItem('Deployment node', 'a host, runtime or device; it holds what runs on it', '<rect x="3" y="2" width="22" height="12" rx="3" fill="none" stroke="var(--um-muted)" stroke-width="1.4"/>')] : []),
|
|
15125
|
+
...els.map(n => vItem(`Container instance${n.sh !== 'box' ? ', ' + n.sh : ''}${n.ext ? ', external' : ''}`, 'a container deployed here, drawn as its container', vSwatch(n.ext))), ...vLines(edges)];
|
|
15126
|
+
}
|
|
15127
|
+
function depGeo({ model, view, pos, memo }) {
|
|
15128
|
+
const pic = depPic(model, view), I = pic.I, { own, implied } = c4Lines(model, view, pic), P = id => (V_OBJ(pos) && Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
|
|
15129
|
+
const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const x = pic.at.get(id), base = { id, type: x.type, col: P(id)[0], row: P(id)[1], aria: depNodeName(I, x) };
|
|
15130
|
+
if (x.type === 'uml.Comment') { const d = { ...base, label: vName(x), kind: 'file', shape: 'note' }; return { ...d, ...SHAPE.note.size(d) }; }
|
|
15131
|
+
// an instance as its container; a deployment node holding nothing shown as the map's node; anything else (c4-level's error) as itself
|
|
15132
|
+
const c = x.type === 'c4.ContainerInstance' ? depOf(I, x) || x : x, dn = x.type === 'c4.DeploymentNode', ext = !dn && c4Ext(c);
|
|
15133
|
+
const d = { ...base, label: vName(c), kind: ext ? 'external' : 'frontend', shape: 'c4', sh: dn ? 'node' : c4Shape(c), ext, tag: dn ? depTag(x) : c === x && x.type === 'c4.ContainerInstance' ? '[Container]' : c4TagOf(c), desc: V_P(dn ? x : c).description };
|
|
15134
|
+
return { ...d, ...SHAPE.c4.size(d) }; });
|
|
15135
|
+
const edges = [...own.map(e => { const note = e.type === 'uml.CommentLink', label = note ? '' : c4LineLabel(e);
|
|
15136
|
+
return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: label || undefined, end: note ? V_LINE.note : V_LINE.rel, aria: c4LineName(e, pic), cut: 280 }; }),
|
|
15137
|
+
...implied.map(e => ({ id: e.id, _id: e.id, type: 'c4.Relationship', from: e.from, to: e.to, implied: e.of.map(r => r.id), end: V_LINE.implied, aria: c4LineName(e, pic), cut: 280,
|
|
15138
|
+
label: [e.of[0].label, e.of.length > 1 ? `[+${e.of.length - 1}]` : ''].filter(V_HAS).join(' ') || undefined }))];
|
|
15139
|
+
const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: view.direction === 'down' ? 'down' : 'right',
|
|
15140
|
+
gap: 48, rgap: 64, labelGaps: true, repair: false, reroute: false }, memo);
|
|
15141
|
+
return { ...g, legend: depLegend(nodes, edges, pic.boxes), order: [...readingOrder(pic), ...edges.map(e => e.id)] };
|
|
15142
|
+
}
|
|
15143
|
+
function depProblems(model, view) {
|
|
15144
|
+
const out = c4Problems(model, view); if (!V_OBJ(view)) return out;
|
|
15145
|
+
const views = V_ARR(model && model.views), vi = views.indexOf(view), at = vi >= 0 ? `views[${vi}]` : 'view', I = c4Index(model);
|
|
15146
|
+
const say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` }), q = id => JSON.stringify(I.at.has(id) ? vName(I.at.get(id)) : String(id));
|
|
15147
|
+
const kind = id => (C4_WORD[(I.at.get(id) || {}).type] || 'nothing').toLowerCase(), a = w => (/^[aeiou]/.test(w) ? 'an ' : 'a ') + w, subj = I.at.get(view.subject);
|
|
15148
|
+
if (view.subject != null && (!subj || subj.type !== 'c4.SoftwareSystem')) say('error', 'c4-subject', `${at}.subject`, [view.subject], `its subject ${q(view.subject)} is ${a(kind(view.subject))}: a deployment view is of a software system or of all`);
|
|
15149
|
+
V_ARR(view.nodeIds).forEach((id, k) => { const x = I.at.get(id); if (!x || x.type === 'uml.Comment') return;
|
|
15150
|
+
if (x.type !== 'c4.DeploymentNode' && x.type !== 'c4.ContainerInstance') return say('error', 'c4-level', `${at}.nodeIds[${k}]`, [id], `${q(id)}, ${a(kind(id))}, is not drawn in a deployment view`);
|
|
15151
|
+
const c = x.type === 'c4.ContainerInstance' && depOf(I, x);
|
|
15152
|
+
if (c && subj && subj.type === 'c4.SoftwareSystem' && !I.chain(c.id).includes(subj.id))
|
|
15153
|
+
say('error', 'c4-level', `${at}.nodeIds[${k}]`, [id, c.id], `${q(id)}, an instance of ${q(c.id)}, is not drawn in a deployment view of ${q(subj.id)}: ${q(c.id)} is not one of its containers`); });
|
|
15154
|
+
c4Lines(model, view, depPic(model, view)).own.forEach(e => { if (e.type !== 'c4.Relationship' || V_HAS(e.label)) return;
|
|
15155
|
+
const i = V_ARR(model.edges).indexOf(e); say('warning', 'c4-label', `${i >= 0 ? `edges[${i}]` : `${at}.edges[${V_ARR(view.edges).indexOf(e)}]`}.label`, [e.id, e.from, e.to], `${e.id}, from ${q(e.from)} to ${q(e.to)}, has no description`); });
|
|
15156
|
+
// a relationship whose end is, or lies in, a node drawn as a box: a line ends on an element, so it is not drawn (§5.2 lifts onto
|
|
15157
|
+
// a node holding nothing shown only)
|
|
15158
|
+
const dp = depPic(model, view), near = id => { for (const x of [id, ...I.chain(id)]) if (dp.ids.includes(x)) return x; return null; };
|
|
15159
|
+
I.rels.forEach(r => { const a = near(r.from), b = near(r.to); if (a == null || b == null || a === b || I.chain(a).includes(b) || I.chain(b).includes(a)) return;
|
|
15160
|
+
const box = [a, b].filter(x => dp.open.has(x)); if (!box.length) return;
|
|
15161
|
+
const i = V_ARR(model.edges).indexOf(r); say('warning', 'c4-box-line', i >= 0 ? `edges[${i}]` : `${at}.edges`, [r.id, ...box], `${r.id}, from ${q(r.from)} to ${q(r.to)}, is not drawn: the view draws ${box.map(q).join(' and ')} as a box, which no line ends on`); });
|
|
15162
|
+
if (!V_HAS(view.title)) say('warning', 'c4-title', `${at}.title`, [], `view ${JSON.stringify(view.id)} has no title`);
|
|
15163
|
+
return out;
|
|
15164
|
+
}
|
|
15165
|
+
function depDescribe(model, viewId) {
|
|
15166
|
+
const v = V_ARR(model && model.views).find(x => V_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
15167
|
+
const pic = depPic(model, v), { own, implied } = c4Lines(model, v, pic), box = new Map(pic.boxes.map(b => [b.id, b]));
|
|
15168
|
+
return [`${v.title || v.id}: a C4 deployment diagram of ${vPlural(pic.ids.length, 'element')} and ${vPlural(own.length + implied.length, 'relationship')}${implied.length ? `, ${implied.length} of them implied` : ''}.`,
|
|
15169
|
+
...readingOrder(pic).map(id => (box.has(id) ? box.get(id).aria : depNodeName(pic.I, pic.at.get(id)))), ...[...own, ...implied].map(e => c4LineName(e, pic))].map(t => t.replace(/\.?$/, '.')).join('\n');
|
|
15170
|
+
}
|
|
15171
|
+
// the palette's instance: its container picked, put in the view's first deployment node that holds no other (the Owner field
|
|
15172
|
+
// or a drop into a box moves it). ponytail: the add hook hears no selection on a layered view (no slots), so not "the node selected"
|
|
15173
|
+
function depAdd(model, view, entry, label) {
|
|
15174
|
+
if (!V_OBJ(entry) || entry.type !== 'c4.ContainerInstance') return null;
|
|
15175
|
+
const I = c4Index(model), cs = [...I.at.values()].filter(n => n.type === 'c4.Container'), ids = V_ARR(view.nodeIds).filter(id => (I.at.get(id) || {}).type === 'c4.DeploymentNode');
|
|
15176
|
+
const into = ids.find(id => !ids.some(o => (I.at.get(o) || {}).parent === id));
|
|
15177
|
+
if (into == null) return { error: 'Add a deployment node first: an instance runs on one' };
|
|
15178
|
+
if (!cs.length) return { error: 'The model has no container to deploy' };
|
|
15179
|
+
const used = vUsed(model);
|
|
15180
|
+
return { pick: cs.map(vName), then: i => { const c = cs[i], b = `${c.id}-instance`; let id = b, k = 2; while (used.has(id)) id = `${b}-${k++}`;
|
|
15181
|
+
return [{ op: 'add', element: { id, type: 'c4.ContainerInstance', label: V_HAS(label) ? label : vName(c), parent: into, props: { container: c.id } }, view: view.id }]; } };
|
|
15182
|
+
}
|
|
15183
|
+
// T on an instance: its container, picked among the model's; else as on a static view (T, D)
|
|
15184
|
+
function depKey(model, view, ids, key) {
|
|
15185
|
+
const I = c4Index(model), x = I.at.get(ids.at(-1));
|
|
15186
|
+
if (!(key === 't' || key === 'T') || !x || x.type !== 'c4.ContainerInstance') return c4Key(model, view, ids, key);
|
|
15187
|
+
const cs = [...I.at.values()].filter(n => n.type === 'c4.Container');
|
|
15188
|
+
return { pick: cs.map(vName), then: i => (cs[i].id === V_P(x).container ? [] : [{ op: 'set', id: x.id, path: 'props.container', value: cs[i].id }]) };
|
|
15189
|
+
}
|
|
15190
|
+
// the Owner field: an instance's and a deployment node's deployment nodes
|
|
15191
|
+
function depOwners(model, x) {
|
|
15192
|
+
if (!V_OBJ(x) || x.from != null || (x.type !== 'c4.ContainerInstance' && x.type !== 'c4.DeploymentNode')) return c4Owners(model, x);
|
|
15193
|
+
const I = c4Index(model); // not the node itself nor a node inside it (the core refuses a cycle)
|
|
15194
|
+
return V_ARR(model.nodes).filter(n => V_OBJ(n) && n.type === 'c4.DeploymentNode' && n.id !== x.id && !I.chain(n.id).includes(x.id)).map(n => ({ id: n.id, label: vName(n) }));
|
|
15195
|
+
}
|
|
15196
|
+
const DEP_DEF = {
|
|
15197
|
+
title: 'C4 deployment diagram',
|
|
15198
|
+
elements: [{ type: 'c4.DeploymentNode', label: 'Deployment node' }, { type: 'c4.ContainerInstance', label: 'Container instance', name: false }, { type: 'uml.Comment', label: 'Comment' }],
|
|
15199
|
+
lines: C4_DEF.lines,
|
|
15200
|
+
props: { 'c4.DeploymentNode': [{ key: 'description', label: 'Description', kind: 'text' }, { key: 'technology', label: 'Technology', kind: 'text' }],
|
|
15201
|
+
'c4.ContainerInstance': [{ key: 'container', label: 'Container (id; T picks it)', kind: 'text' }], 'c4.Relationship': C4_DEF.props['c4.Relationship'], 'uml.CommentLink': [] },
|
|
15202
|
+
layout: depGeo,
|
|
15203
|
+
boxes: (model, view) => depPic(model, view).boxes,
|
|
15204
|
+
owners: depOwners,
|
|
15205
|
+
validate: depProblems,
|
|
15206
|
+
rules: DEP_RULES,
|
|
15207
|
+
describe: depDescribe,
|
|
15208
|
+
// a deleted container takes its instances (§3, Q7); read for every view, whichever type declares it
|
|
15209
|
+
refs: { 'c4.ContainerInstance': { 'props.container': 'cascade' } },
|
|
15210
|
+
word: C4_DEF.word,
|
|
15211
|
+
rows: (model, view, id) => { const I = c4Index(model), x = I.at.get(id); if (!x) return null; const P = V_P(x);
|
|
15212
|
+
if (x.type === 'c4.DeploymentNode') return [...(V_HAS(P.technology) ? [P.technology] : []), ...(V_HAS(P.description) ? [P.description] : [])];
|
|
15213
|
+
if (x.type !== 'c4.ContainerInstance') return C4_DEF.rows(model, view, id);
|
|
15214
|
+
const c = depOf(I, x); return [`Instance of ${c ? vName(c) : 'no container'}`, ...(c ? C4_DEF.rows(model, view, c.id) : [])]; },
|
|
15215
|
+
text: C4_DEF.text,
|
|
15216
|
+
edits: { add: depAdd, key: depKey },
|
|
15217
|
+
};
|
|
15218
|
+
|
|
15219
|
+
const C4_VIEW_TYPES = [registerDiagramType('c4.dynamic', DYN_DEF), registerDiagramType('c4.deployment', DEP_DEF)];
|
|
15220
|
+
/* ---------- Mermaid erDiagram: a reader and a writer (notes/milestone-2.md §8.1, M2.8; risks.md R1 to R4, R7) ----------
|
|
15221
|
+
Written against Mermaid 12.3.0's entity relationship page (docs/syntax/entityRelationshipDiagram.md, read 14 Nov 2026)
|
|
15222
|
+
and its lexer (packages/mermaid/src/diagrams/er/parser/erDiagram.jison), as the other readers are against theirs.
|
|
15223
|
+
Internal: the CLI (`umlimator.py import`) imports this file through Node (M2.12); a host gets it once the registry is
|
|
15224
|
+
public. Its helpers are its own (me...), since the readers share one scope in lib/umlimator-all.js.
|
|
15225
|
+
|
|
15226
|
+
readMermaidEr(text) -> { model, report }: a version-2 model with one `er` view, in model.md row 20's vocabulary, and a
|
|
15227
|
+
report of what was not read as written, each item { line, status, message }, status `skipped` or `approximated` (R1).
|
|
15228
|
+
The reader is hand-written and linear, and refuses text over ME_LIMIT characters (R2). Ids come from the source's
|
|
15229
|
+
names (R3): an entity's name is its id, a column is <entity>.<name>, a relationship <from>-<to>, each the next free of
|
|
15230
|
+
base, base-2, base-3 ... (an id holds no "->": a name with one has it as "-"). `%% id: <id>` above a statement or a
|
|
15231
|
+
column gives that element its id instead, and above `erDiagram` the view's; `%% references: <column id>` above a
|
|
15232
|
+
column sets its `references`, which Mermaid's text has no place for. Every lookup table is keyed by Map or has no
|
|
15233
|
+
prototype, so `__proto__` and `constructor` are ordinary names. It never throws.
|
|
15234
|
+
|
|
15235
|
+
writeMermaidEr(model, viewId?) -> text: one er view as Mermaid. What Mermaid cannot carry is kept in its comments, as
|
|
15236
|
+
the other writers do: an id the reader would not derive, as `%% id:` (an entity whose id Mermaid cannot write as a
|
|
15237
|
+
name is written as `entity`, `entity-2` ... under its `%% id:`), a column's `references` as `%% references:`; an element
|
|
15238
|
+
the subset leaves out as `%% not written: <id> (<type>)`, and a property Mermaid has no place for as
|
|
15239
|
+
`%% not written: <id>: <what>` (a multiplicity other than the four, written as the nearest; the notation idef1x, as
|
|
15240
|
+
Mermaid draws crow's foot). A column is written only when the reader reads its row back as it is.
|
|
15241
|
+
|
|
15242
|
+
How ER maps onto Mermaid's text, both ways:
|
|
15243
|
+
- `NAME`, `"a name"`, `NAME[Alias]`, `NAME["An alias"]` an entity, its label the alias, else its name; `NAME { ... }`
|
|
15244
|
+
its columns, each `type name [PK, FK, UK] ["comment"]`: a type ending `?` is nullable (v11.16.0+), a name starting
|
|
15245
|
+
`*` a primary key, either in backticks taken as written; the comment kept under ext.mermaid.comment (reported once);
|
|
15246
|
+
- `A ||--o{ B : label` a relationship from A to B, each end's marker its multiplicity there, on either side: `||` 1,
|
|
15247
|
+
`|o` `o|` 0..1, `}|` `|{` 1..*, `}o` `o{` 0..*, and the word forms (`only one`, `one or more`, `many(0)`, `1+`, ...);
|
|
15248
|
+
`--` and `to` identifying, `..` `.-` `-.` and `optionally to` not; Mermaid's `u` (a parent marker its page does not
|
|
15249
|
+
show) approximated as 1. Mermaid says nothing of which column a foreign key references: `references` is left out,
|
|
15250
|
+
reported once (the ER type's fk-flag warns of each);
|
|
15251
|
+
- `direction TB` (Mermaid's default) is down, `LR` right (left unset: core's default, so a view's `right` reads back
|
|
15252
|
+
unset, drawn the same); BT and RL approximated; subgraphs are not read, the entities in them are, and a
|
|
15253
|
+
relationship with a subgraph is skipped; styles, classes and accessibility text are skipped. */
|
|
15254
|
+
const ME_LIMIT = 1 << 20; // characters; an ER diagram a person writes is a few kilobytes
|
|
15255
|
+
const ME_VIEW = 'er-diagram'; // the view's id when the source does not name it
|
|
15256
|
+
const meTable = o => Object.assign(Object.create(null), o); // a lookup table: "constructor" or "__proto__" finds nothing in it
|
|
15257
|
+
// each end's marker, both sides' forms, and the word forms (Mermaid's lexer reads any of them on either side)
|
|
15258
|
+
const ME_MULT = meTable({ '|o': '0..1', 'o|': '0..1', '||': '1', '}o': '0..*', 'o{': '0..*', '}|': '1..*', '|{': '1..*', 'zero or more': '0..*',
|
|
15259
|
+
'zero or many': '0..*', 'zero or one': '0..1', 'one or zero': '0..1', 'one or more': '1..*', 'one or many': '1..*', 'only one': '1',
|
|
15260
|
+
'many(0)': '0..*', 'many(1)': '1..*', many: '0..*', one: '1', '0+': '0..*', '1+': '1..*', 1: '1', u: '1' });
|
|
15261
|
+
const ME_CARD = /(\|o|o\||\|\||\}o|o\{|\}\||\|\{)|(zero or (?:more|many|one)|one or (?:zero|more|many)|only one|many\([01]\)|many|one)(?!\w)|([01]\+)|(1)(?![\w.])|(u)(?=[.|-])/iy;
|
|
15262
|
+
const ME_REL = /(--|\.\.|\.-|-\.)|(optionally to|to)(?!\w)/iy;
|
|
15263
|
+
const ME_SYM = meTable({ 1: ['||', '||'], '0..1': ['|o', 'o|'], '0..*': ['}o', 'o{'], '1..*': ['}|', '|{'] }); // the writer's marker, left and right
|
|
15264
|
+
const ME_NAME = /(?:[^\x00-\x7F]|[\w.*-])+/y; // a name not in quotes (the lexer's UNICODE_TEXT)
|
|
15265
|
+
const ME_WORD = /[*A-Za-z_\u00C0-\uFFFF][A-Za-z0-9\-_[\]().,\u00C0-\uFFFF*]*/y; // a column's type or name (ATTRIBUTE_WORD)
|
|
15266
|
+
const ME_KEY = /(?:PK|FK|UK)\b/iy;
|
|
15267
|
+
// a statement's first word that Mermaid's lexer takes as a keyword: the writer writes no name that starts with one
|
|
15268
|
+
const ME_KEYWORDS = new Set(['one', 'many', 'to', 'end', 'class', 'classdef', 'style', 'subgraph', 'erdiagram']);
|
|
15269
|
+
const ME_DIRECTION = /direction\s+(TB|BT|RL|LR)/gi; // the lexer takes any line holding this as a direction statement
|
|
15270
|
+
const meId = name => String(name).replace(/->+/g, '-'); // an id never holds "->" (model.md rule 5)
|
|
15271
|
+
const meSkip = (s, k) => { while (k < s.length && (s[k] === ' ' || s[k] === '\t')) k++; return k; };
|
|
15272
|
+
const meAt = (re, s, k) => { re.lastIndex = k; const m = re.exec(s); return m; };
|
|
15273
|
+
const meShort = s => (s.length > 60 ? s.slice(0, 57) + '...' : s);
|
|
15274
|
+
|
|
15275
|
+
// the first of base, base-2, base-3 ... not in used, resuming where the last ask for base stopped (R2)
|
|
15276
|
+
function meFree(used, next, base) {
|
|
15277
|
+
let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
|
|
15278
|
+
while (used.has(id)) id = `${base}-${++k}`;
|
|
15279
|
+
next.set(base, k);
|
|
15280
|
+
return id;
|
|
15281
|
+
}
|
|
15282
|
+
|
|
15283
|
+
// text: Mermaid's line breaks and entity codes (#quot; #lt; #35;)
|
|
15284
|
+
function meDecode(s) {
|
|
15285
|
+
return s.replace(/<br\s?\/?>/gi, '\n').replace(/#(quot|lt|gt|amp|\d{1,6});/g, (_, e) =>
|
|
15286
|
+
({ quot: '"', lt: '<', gt: '>', amp: '&' })[e] ?? String.fromCodePoint(Math.min(Number(e), 0x10ffff)));
|
|
15287
|
+
}
|
|
15288
|
+
// the writer's text in quotes: what would end the quotes, start a comment or an entity code, or make the line a direction
|
|
15289
|
+
// statement, as entity codes, which Mermaid draws as the characters and the reader decodes
|
|
15290
|
+
function meEncode(s) {
|
|
15291
|
+
return String(s).replace(/[#"%\\<>~]|[\x00-\x09\x0b-\x1f]/g, c => (c === '"' ? '#quot;' : c === '<' ? '#lt;' : c === '>' ? '#gt;' : `#${c.codePointAt(0)};`))
|
|
15292
|
+
.replace(/\n/g, '<br>').replace(/(direction)(\s+)(?=TB|BT|RL|LR)/gi, (_, d, w) => d + [...w].map(c => `#${c.codePointAt(0)};`).join(''));
|
|
15293
|
+
}
|
|
15294
|
+
// a name the writer writes as it is, or in quotes; else it needs an %% id:
|
|
15295
|
+
const meBare = id => /^[A-Za-z_\u0080-\uFFFF](?:[^\x00-\x7F]|[\w.*-])*$/.test(id) && !ME_KEYWORDS.has(/^\w*/.exec(id)[0].toLowerCase()) && !/^u[.-]/i.test(id);
|
|
15296
|
+
const meQuoted = id => /^[^"%\r\n\v\x08\\]+$/.test(id) && !/direction\s+(TB|BT|RL|LR)/i.test(id);
|
|
15297
|
+
|
|
15298
|
+
// a name at k: "in quotes" (not empty), or the lexer's UNICODE_TEXT: { text, end }
|
|
15299
|
+
function meName(s, k) {
|
|
15300
|
+
if (s[k] === '"') { const e = s.indexOf('"', k + 1); return e > k + 1 ? { text: s.slice(k + 1, e), end: e + 1 } : null; }
|
|
15301
|
+
const m = meAt(ME_NAME, s, k);
|
|
15302
|
+
return m ? { text: m[0], end: k + m[0].length } : null;
|
|
15303
|
+
}
|
|
15304
|
+
|
|
15305
|
+
// a column block's attributes from k, as Mermaid's lexer reads them in its block state: { cols, end, closed, bad }, each
|
|
15306
|
+
// col { type, nullable, name, keys, comment, at }; bad is where an attribute that is not one starts
|
|
15307
|
+
function meRow(s, k, tilde = s.lastIndexOf('~')) {
|
|
15308
|
+
const cols = [];
|
|
15309
|
+
let nt = -2, nw = -2; // the next ~ and the next whitespace at or after k, found once each as k moves on (linear)
|
|
15310
|
+
const nextTilde = () => { if (nt < k && nt !== -1) nt = s.indexOf('~', k); return nt; };
|
|
15311
|
+
const nextWs = () => { if (nw < k) { nw = k; while (nw < s.length && !/\s/.test(s[nw])) nw++; } return nw; };
|
|
15312
|
+
const ws = () => { while (k < s.length && /\s/.test(s[k])) k++; };
|
|
15313
|
+
const isKey = () => (k === 0 || !/\w/.test(s[k - 1])) && !!meAt(ME_KEY, s, k);
|
|
15314
|
+
const word = () => {
|
|
15315
|
+
if (isKey()) return null;
|
|
15316
|
+
const t = tilde > k ? nextTilde() : -1; // ([^\s]*)~.*~([^\s]*): a ~ in this run and another after it on the line
|
|
15317
|
+
if (t >= 0 && t < nextWs() && tilde > t) { let e = tilde + 1; while (e < s.length && !/\s/.test(s[e])) e++; const w = s.slice(k, e); k = e; return { text: w }; }
|
|
15318
|
+
if (s[k] === '`') { const e = s.indexOf('`', k + 1); if (e <= k + 1) return null; const w = s.slice(k + 1, e); k = e + 1; return { text: w, quoted: true }; }
|
|
15319
|
+
const m = meAt(ME_WORD, s, k); if (!m) return null;
|
|
15320
|
+
k += m[0].length;
|
|
15321
|
+
return { text: m[0] };
|
|
15322
|
+
};
|
|
15323
|
+
for (;;) {
|
|
15324
|
+
ws();
|
|
15325
|
+
if (k >= s.length) return { cols, end: k, closed: false };
|
|
15326
|
+
if (s[k] === '}') return { cols, end: k + 1, closed: true };
|
|
15327
|
+
const at = k, type = word();
|
|
15328
|
+
if (!type) return { cols, end: k, closed: false, bad: at };
|
|
15329
|
+
ws();
|
|
15330
|
+
let nullable = false;
|
|
15331
|
+
if (s[k] === '?') { nullable = true; k++; ws(); }
|
|
15332
|
+
const name = word();
|
|
15333
|
+
if (!name) return { cols, end: k, closed: false, bad: at };
|
|
15334
|
+
const keys = [];
|
|
15335
|
+
for (ws(); isKey(); ws()) {
|
|
15336
|
+
keys.push(s.slice(k, k + 2).toUpperCase()); k += 2; ws();
|
|
15337
|
+
if (s[k] !== ',') break;
|
|
15338
|
+
k++; ws();
|
|
15339
|
+
if (!isKey()) return { cols, end: k, closed: false, bad: at };
|
|
15340
|
+
}
|
|
15341
|
+
let comment;
|
|
15342
|
+
if (s[k] === '"') { const e = s.indexOf('"', k + 1); if (e < 0) return { cols, end: k, closed: false, bad: at }; comment = s.slice(k + 1, e); k = e + 1; }
|
|
15343
|
+
cols.push({ type, nullable, name, keys, comment, at });
|
|
15344
|
+
}
|
|
15345
|
+
}
|
|
15346
|
+
|
|
15347
|
+
// a row as the model has it: its label, its props, its comment
|
|
15348
|
+
function meColumn(c) {
|
|
15349
|
+
const star = !c.name.quoted && c.name.text.length > 1 && c.name.text[0] === '*', props = { dataType: c.type.text };
|
|
15350
|
+
if (star || c.keys.includes('PK')) props.primaryKey = true;
|
|
15351
|
+
if (c.keys.includes('FK')) props.foreignKey = true;
|
|
15352
|
+
if (c.keys.includes('UK')) props.unique = true;
|
|
15353
|
+
if (c.nullable) props.nullable = true;
|
|
15354
|
+
return { label: star ? c.name.text.slice(1) : c.name.text, props, ...(c.comment !== undefined ? { comment: meDecode(c.comment) } : {}) };
|
|
15355
|
+
}
|
|
15356
|
+
|
|
15357
|
+
function readMermaidEr(text) {
|
|
15358
|
+
const report = [], say = (line, status, message) => report.push({ line, status, message });
|
|
15359
|
+
const empty = () => ({ model: { schemaVersion: 2, root: ME_VIEW, nodes: [], edges: [], views: [{ id: ME_VIEW, diagram: 'er', nodeIds: [] }] }, report });
|
|
15360
|
+
if (typeof text !== 'string') { say(1, 'skipped', 'the input is not text: nothing read'); return empty(); }
|
|
15361
|
+
if (text.length > ME_LIMIT) { say(1, 'skipped', `the input is ${text.length} characters, over the limit of ${ME_LIMIT}: nothing read`); return empty(); }
|
|
15362
|
+
const lines = text.split('\n').map(l => l.replace(/\r$/, ''));
|
|
15363
|
+
let i = 0, title;
|
|
15364
|
+
|
|
15365
|
+
// front matter: its title; Mermaid's configuration is not read
|
|
15366
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
15367
|
+
if (lines[i] && lines[i].trim().replace(/^\uFEFF/, '') === '---') {
|
|
15368
|
+
let j = i + 1;
|
|
15369
|
+
for (; j < lines.length && lines[j].trim() !== '---'; j++) {
|
|
15370
|
+
const l = lines[j];
|
|
15371
|
+
if (!l.trim() || l.trim().startsWith('#') || /^\s/.test(l)) continue;
|
|
15372
|
+
const c = l.indexOf(':'), key = (c < 0 ? l : l.slice(0, c)).trim(), val = c < 0 ? '' : l.slice(c + 1).trim();
|
|
15373
|
+
if (key === 'title') {
|
|
15374
|
+
if (val.startsWith('"')) { try { title = JSON.parse(val); } catch { title = val.slice(1, -1); } }
|
|
15375
|
+
else title = val.startsWith("'") ? val.slice(1, -1).replaceAll("''", "'") : val;
|
|
15376
|
+
} else say(j + 1, 'skipped', `front matter ${key}: Mermaid's configuration is not read`);
|
|
15377
|
+
}
|
|
15378
|
+
if (j >= lines.length) { say(i + 1, 'skipped', 'the front matter is never closed with ---: nothing read'); return empty(); }
|
|
15379
|
+
i = j + 1;
|
|
15380
|
+
}
|
|
15381
|
+
|
|
15382
|
+
const ents = new Map(), seen = [], rels = [], groups = new Set(), once = new Set();
|
|
15383
|
+
let viewId, viewNo, direction = 'down', started = false, hint = {}, acc = false, depth = 0, block = null;
|
|
15384
|
+
const ent = (name, no) => { let r = ents.get(name); if (!r) { r = { name, no, cols: [], names: new Set() }; ents.set(name, r); seen.push(r); } return r; };
|
|
15385
|
+
const column = (rec, c, no) => {
|
|
15386
|
+
const col = meColumn(c), h = hint;
|
|
15387
|
+
hint = {};
|
|
15388
|
+
if (rec.names.has(col.label)) { say(no, 'skipped', `${rec.name}.${col.label}: ${rec.name} has a column ${col.label} already`); return; }
|
|
15389
|
+
rec.names.add(col.label);
|
|
15390
|
+
if (h.references !== undefined) col.props.references = h.references;
|
|
15391
|
+
rec.cols.push({ ...col, hint: h.id, no });
|
|
15392
|
+
if (col.comment !== undefined && !once.has('comment')) { once.add('comment'); say(no, 'approximated', `${rec.name}.${col.label}: a column's comment is kept under ext.mermaid.comment and not drawn, as every other column's`); }
|
|
15393
|
+
if (col.props.foreignKey && h.references === undefined && !once.has('fk')) { once.add('fk'); say(no, 'approximated', `${rec.name}.${col.label}: Mermaid does not say which column a foreign key references, so references is left out, as for every other (fk-flag warns of each)`); }
|
|
15394
|
+
};
|
|
15395
|
+
// :::a,b after a name: styling, skipped
|
|
15396
|
+
const styled = (s, k, name, no) => {
|
|
15397
|
+
if (!s.startsWith(':::', k)) return k;
|
|
15398
|
+
let e = k + 3; while (e < s.length && !/[\s{[:]/.test(s[e])) e++;
|
|
15399
|
+
say(no, 'skipped', `${s.slice(k, e)} on ${name}: styling is not read`);
|
|
15400
|
+
return meSkip(s, e);
|
|
15401
|
+
};
|
|
15402
|
+
|
|
15403
|
+
// one statement from k on line s (dir: where the line's last direction statement starts, or -1): where reading goes on
|
|
15404
|
+
const statement = (s, k, no, dir) => {
|
|
15405
|
+
const st = meSkip(s, k);
|
|
15406
|
+
if (st >= s.length) return s.length;
|
|
15407
|
+
const notRead = () => { say(no, 'skipped', `"${meShort(s.slice(st).trim())}" is not understood`); return s.length; };
|
|
15408
|
+
if (dir >= st) {
|
|
15409
|
+
const d = meAt(/direction\s+(TB|BT|RL|LR)/iy, s, dir)[1].toUpperCase();
|
|
15410
|
+
if (depth) say(no, 'skipped', `direction ${d} inside a subgraph is not kept: the view has one direction`);
|
|
15411
|
+
else if (d === 'TB') direction = 'down';
|
|
15412
|
+
else if (d === 'LR') direction = 'right';
|
|
15413
|
+
else { direction = d === 'BT' ? 'down' : 'right'; say(no, 'approximated', `direction ${d} is drawn ${direction === 'down' ? 'top to bottom' : 'left to right'}`); }
|
|
15414
|
+
return s.length;
|
|
15415
|
+
}
|
|
15416
|
+
const kw = meAt(/(classDef|class|style|subgraph|end)\b|(accTitle)\s*:|(accDescr)\s*([:{])/iy, s, st);
|
|
15417
|
+
if (kw) {
|
|
15418
|
+
const w = (kw[1] || kw[2] || kw[3]).toLowerCase();
|
|
15419
|
+
if (w === 'subgraph') {
|
|
15420
|
+
const n = meName(s, meSkip(s, st + 8));
|
|
15421
|
+
if (!n) return notRead();
|
|
15422
|
+
groups.add(n.text); depth++;
|
|
15423
|
+
say(no, 'skipped', `subgraph ${n.text}: a group is not read; the entities in it are`);
|
|
15424
|
+
} else if (w === 'end') { if (depth) depth--; else say(no, 'skipped', 'an end that closes no subgraph'); }
|
|
15425
|
+
else if (w === 'acctitle' || w === 'accdescr') {
|
|
15426
|
+
say(no, 'skipped', `${kw[2] || kw[3]}: accessibility text is not read`);
|
|
15427
|
+
if (kw[4] === '{' && !s.includes('}', st)) acc = true;
|
|
15428
|
+
} else say(no, 'skipped', `${kw[1]}: styling is not read`);
|
|
15429
|
+
return s.length;
|
|
15430
|
+
}
|
|
15431
|
+
const a = meName(s, st);
|
|
15432
|
+
if (!a) return notRead();
|
|
15433
|
+
let k2 = styled(s, meSkip(s, a.end), a.text, no);
|
|
15434
|
+
const card = meAt(ME_CARD, s, k2);
|
|
15435
|
+
if (!card) { // an entity: its alias, its classes, its block
|
|
15436
|
+
const rec = ent(a.text, no);
|
|
15437
|
+
rec.declared = true;
|
|
15438
|
+
if (hint.id !== undefined && rec.hint === undefined) { rec.hint = hint.id; rec.hintNo = no; }
|
|
15439
|
+
hint = {};
|
|
15440
|
+
if (s[k2] === '[') {
|
|
15441
|
+
const q = meSkip(s, k2 + 1), n = s[q] === '"' ? meName(s, q) : null, e = n ? meSkip(s, n.end) : s.indexOf(']', q);
|
|
15442
|
+
if (e < 0 || s[e] !== ']' || (!n && !s.slice(q, e).trim())) return notRead();
|
|
15443
|
+
const alias = meDecode(n ? n.text : s.slice(q, e).trim());
|
|
15444
|
+
if (rec.label === undefined) rec.label = alias;
|
|
15445
|
+
else if (rec.label !== alias) say(no, 'skipped', `${a.text}[${alias}]: ${a.text} has an alias already`);
|
|
15446
|
+
k2 = styled(s, meSkip(s, e + 1), a.text, no);
|
|
15447
|
+
}
|
|
15448
|
+
if (s[k2] === '{') { block = rec; return k2 + 1; }
|
|
15449
|
+
return k2 < s.length ? notRead() : s.length;
|
|
15450
|
+
}
|
|
15451
|
+
// a relationship: A card rel card B [: label]
|
|
15452
|
+
const end = m => k2 + m[0].length, mult = m => ME_MULT[m[0].toLowerCase()];
|
|
15453
|
+
k2 = meSkip(s, end(card));
|
|
15454
|
+
const rel = meAt(ME_REL, s, k2);
|
|
15455
|
+
if (!rel) return notRead();
|
|
15456
|
+
k2 = meSkip(s, end(rel));
|
|
15457
|
+
const card2 = meAt(ME_CARD, s, k2);
|
|
15458
|
+
if (!card2) return notRead();
|
|
15459
|
+
k2 = meSkip(s, end(card2));
|
|
15460
|
+
const b = meName(s, k2);
|
|
15461
|
+
if (!b) return notRead();
|
|
15462
|
+
k2 = styled(s, meSkip(s, b.end), b.text, no);
|
|
15463
|
+
let label;
|
|
15464
|
+
if (s[k2] === ':') { const t = s.slice(k2 + 1).trim(), q = /^"([^"]*)"$/.exec(t); label = meDecode(q ? q[1] : t); }
|
|
15465
|
+
else if (k2 < s.length) return notRead();
|
|
15466
|
+
else say(no, 'approximated', `${a.text} ${card[0]}${rel[0]}${card2[0]} ${b.text}: no : label, which Mermaid asks for, so it has none`);
|
|
15467
|
+
if (card[5] || card2[5]) say(no, 'approximated', `${a.text} ${card[0]}${rel[0]}${card2[0]} ${b.text}: u, a parent marker Mermaid's page does not show, is read as 1`);
|
|
15468
|
+
ent(a.text, no); ent(b.text, no);
|
|
15469
|
+
rels.push({ a: a.text, b: b.text, from: mult(card), to: mult(card2), identifying: rel[1] ? rel[1] === '--' : /^to$/i.test(rel[2]), label, no, hint: hint.id });
|
|
15470
|
+
hint = {};
|
|
15471
|
+
return s.length;
|
|
15472
|
+
};
|
|
15473
|
+
|
|
15474
|
+
for (; i < lines.length; i++) {
|
|
15475
|
+
const no = i + 1, s = lines[i], t = s.trim();
|
|
15476
|
+
if (!t) continue;
|
|
15477
|
+
if (acc) { if (t.includes('}')) acc = false; continue; }
|
|
15478
|
+
if (t.startsWith('%%')) {
|
|
15479
|
+
const c = t.slice(2).trim();
|
|
15480
|
+
if (c.startsWith('{')) {
|
|
15481
|
+
say(no, 'skipped', 'a %%{init}%% directive: Mermaid\'s configuration is not read');
|
|
15482
|
+
if (!t.includes('}%%')) while (i + 1 < lines.length && !lines[++i].includes('}%%')); // over several lines: to its }%%
|
|
15483
|
+
} else if (c.startsWith('id:')) { hint.id = c.slice(3).trim(); hint.no = no; }
|
|
15484
|
+
else if (c.startsWith('references:')) hint.references = c.slice(11).trim();
|
|
15485
|
+
continue;
|
|
15486
|
+
}
|
|
15487
|
+
if (!started) {
|
|
15488
|
+
if (!/^\uFEFF?erDiagram$/i.test(t)) { say(no, 'skipped', 'not a Mermaid erDiagram: nothing read'); return empty(); }
|
|
15489
|
+
started = true; viewId = hint.id; viewNo = hint.no; hint = {}; continue;
|
|
15490
|
+
}
|
|
15491
|
+
const tilde = s.lastIndexOf('~');
|
|
15492
|
+
let dir = -1;
|
|
15493
|
+
if (!block) for (const m of s.matchAll(ME_DIRECTION)) dir = m.index;
|
|
15494
|
+
for (let k = 0; k < s.length;) {
|
|
15495
|
+
if (!block) { k = statement(s, k, no, dir); continue; }
|
|
15496
|
+
const r = meRow(s, k, tilde);
|
|
15497
|
+
for (const c of r.cols) column(block, c, no);
|
|
15498
|
+
if (r.bad !== undefined) {
|
|
15499
|
+
const e = s.indexOf('}', r.end);
|
|
15500
|
+
say(no, 'skipped', `"${meShort(s.slice(r.bad, e < 0 ? s.length : e).trim())}" in ${block.name}'s block is not a column`);
|
|
15501
|
+
if (e >= 0) block = null;
|
|
15502
|
+
k = e < 0 ? s.length : e + 1;
|
|
15503
|
+
} else { k = r.end; if (r.closed) block = null; }
|
|
15504
|
+
}
|
|
15505
|
+
hint = {};
|
|
15506
|
+
}
|
|
15507
|
+
if (!started) { say(1, 'skipped', 'not a Mermaid erDiagram: nothing read'); return empty(); }
|
|
15508
|
+
if (block) say(lines.length, 'approximated', `${block.name}'s { is never closed: read to the end`);
|
|
15509
|
+
if (depth) say(lines.length, 'approximated', `${depth} subgraph${depth > 1 ? 's are' : ' is'} never closed with end: read to the end`);
|
|
15510
|
+
|
|
15511
|
+
// ids: an entity's hint or name, then the next free of it, each reported; then columns and relationships in the order
|
|
15512
|
+
// they come, made unique, a hint that is taken or holds -> reported
|
|
15513
|
+
const used = new Set(), next = new Map(), list = seen.filter(r => r.declared || !groups.has(r.name));
|
|
15514
|
+
for (const r of list) { const want = r.hint ?? r.name; if (meId(want) === want && !used.has(want)) { r.id = want; used.add(want); } }
|
|
15515
|
+
for (const r of list) if (r.id === undefined) {
|
|
15516
|
+
const want = r.hint ?? r.name;
|
|
15517
|
+
r.id = meFree(used, next, meId(want)); used.add(r.id);
|
|
15518
|
+
say(r.hintNo ?? r.no, 'approximated', r.hint !== undefined ? `%% id: ${want}${meId(want) !== want ? ': an id holds no "->"' : ' is taken'}, so entity ${r.name} is ${r.id}`
|
|
15519
|
+
: `entity ${r.name}${meId(want) !== want ? ': an id holds no "->"' : "'s name is another's id"}, so it is ${r.id}`);
|
|
15520
|
+
}
|
|
15521
|
+
const take = (h, base, no, what) => {
|
|
15522
|
+
const id = meFree(used, next, meId(h ?? base));
|
|
15523
|
+
used.add(id);
|
|
15524
|
+
if (h !== undefined && id !== h) say(no, 'approximated', `%% id: ${h}${meId(h) !== h ? ': an id holds no "->"' : ' is taken'}, so ${what} is ${id}`);
|
|
15525
|
+
return id;
|
|
15526
|
+
};
|
|
15527
|
+
const nodes = list.map(r => {
|
|
15528
|
+
const members = r.cols.map(c => ({ id: take(c.hint, `${r.id}.${c.label}`, c.no, `${r.name}.${c.label}`), type: 'er.Column', label: c.label, props: c.props,
|
|
15529
|
+
...(c.comment !== undefined ? { ext: { mermaid: { comment: c.comment } } } : {}) }));
|
|
15530
|
+
return { id: r.id, type: 'er.Entity', label: r.label ?? r.name, ...(members.length ? { members } : {}) };
|
|
15531
|
+
});
|
|
15532
|
+
const byName = new Map(list.map(r => [r.name, r.id])), edges = [];
|
|
15533
|
+
for (const r of rels) {
|
|
15534
|
+
const g = [r.a, r.b].find(x => !byName.has(x));
|
|
15535
|
+
if (g !== undefined) { say(r.no, 'skipped', `${r.a} -- ${r.b}: ${g} is a subgraph, and a relationship with a group is not read`); continue; }
|
|
15536
|
+
const from = byName.get(r.a), to = byName.get(r.b);
|
|
15537
|
+
edges.push({ id: take(r.hint, `${from}-${to}`, r.no, `${r.a} -- ${r.b}`), type: 'er.Relationship', from, to, ...(r.label ? { label: r.label } : {}),
|
|
15538
|
+
props: { identifying: r.identifying, ends: { from: { multiplicity: r.from }, to: { multiplicity: r.to } } } });
|
|
15539
|
+
}
|
|
15540
|
+
let vid = viewId || ME_VIEW;
|
|
15541
|
+
if (meId(vid) !== vid) { say(viewNo, 'approximated', `%% id: ${vid}: an id holds no "->", so the view is ${meId(vid)}`); vid = meId(vid); }
|
|
15542
|
+
const view = { id: vid, diagram: 'er', ...(title ? { title } : {}), ...(direction === 'down' ? { direction } : {}), nodeIds: nodes.map(n => n.id) };
|
|
15543
|
+
report.sort((a, b) => a.line - b.line); // stable: one line's items keep their order
|
|
15544
|
+
return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: view.id, nodes, edges, views: [view] }, report };
|
|
15545
|
+
}
|
|
15546
|
+
|
|
15547
|
+
function writeMermaidEr(model, viewId) {
|
|
15548
|
+
const m = model && typeof model === 'object' ? model : {}, arr = x => (Array.isArray(x) ? x : []), isObj = x => !!x && typeof x === 'object';
|
|
15549
|
+
const has = x => x != null && x !== '', flat = s => String(s).replace(/\r\n|\r|\n/g, '\\n'), nl = s => /[\r\n]/.test(String(s)); // a comment is one line
|
|
15550
|
+
const views = arr(m.views).filter(isObj);
|
|
15551
|
+
const view = viewId != null ? views.find(v => v.id === viewId) : views.find(v => v.diagram === 'er');
|
|
15552
|
+
// a model with views is written through its er view; one with none, whole
|
|
15553
|
+
if (viewId != null && !view) return `%% not written: view ${flat(viewId)}, which the model does not have\n`;
|
|
15554
|
+
if (views.length && !view) return '%% not written: the model, which has no er view\n';
|
|
15555
|
+
if (view && view.diagram !== 'er') return `%% not written: view ${flat(view.id)}, a ${flat(view.diagram)} view, not an er one\n`;
|
|
15556
|
+
const byId = new Map(arr(m.nodes).filter(isObj).map(n => [n.id, n]));
|
|
15557
|
+
const out = [], notWritten = [], skip = x => notWritten.push(`${flat(x.id)} (${flat(x.type)})`), lose = (id, what) => notWritten.push(`${flat(id)}: ${flat(what)}`);
|
|
15558
|
+
const once = (seen, id) => { if (!seen.has(id)) return seen.add(id); lose(id, 'listed twice in the view, written once'); return false; };
|
|
15559
|
+
const shownSet = new Set(), shown = (view ? arr(view.nodeIds) : [...byId.keys()]).filter(id => byId.has(id) && once(shownSet, id));
|
|
15560
|
+
// an id the reader keeps as it is: a string of one line, trimmed (as a hint is read), with no ->
|
|
15561
|
+
const keeps = id => typeof id === 'string' && id !== '' && !nl(id) && id.trim() === id && meId(id) === id;
|
|
15562
|
+
const derives = id => typeof id === 'string' && id !== '' && !nl(id) && meId(id) === id; // an id the reader may derive, needing no hint
|
|
15563
|
+
// the keys and props Mermaid has a place for; any other is named
|
|
15564
|
+
const extra = (x, keys, props = []) => {
|
|
15565
|
+
for (const k of Object.keys(x)) if (!keys.includes(k)) {
|
|
15566
|
+
if (k === 'props' && isObj(x.props)) { for (const p of Object.keys(x.props)) if (!props.includes(p)) lose(x.id, `property ${p}`); }
|
|
15567
|
+
else lose(x.id, k);
|
|
15568
|
+
}
|
|
15569
|
+
};
|
|
15570
|
+
const title = view && has(view.title) ? view.title : m.title;
|
|
15571
|
+
// as it is only when YAML reads it as text: a letter first, and not one of YAML's null or boolean words
|
|
15572
|
+
const plain = t => /^\p{L}[\p{L}\p{N} _.,()-]*$/u.test(t) && !t.endsWith(' ') && !/^(null|true|false|yes|no|on|off|y|n)$/i.test(t);
|
|
15573
|
+
if (has(title)) out.push('---', `title: ${plain(title) ? title : JSON.stringify(String(title))}`, '---');
|
|
15574
|
+
if (view && view.id !== ME_VIEW) { if (keeps(view.id)) out.push(`%% id: ${view.id}`); else lose(view.id, "the view's id, which the reader would not keep"); }
|
|
15575
|
+
out.push('erDiagram', view && view.direction === 'down' ? ' direction TB' : ' direction LR');
|
|
15576
|
+
if (view && has(view.notation) && view.notation !== 'crowsfoot') lose(view.id, `notation ${view.notation}: Mermaid draws crow's foot`);
|
|
15577
|
+
|
|
15578
|
+
// entities: a name Mermaid writes as it is or in quotes is the id; another id is an %% id: over a name the writer makes
|
|
15579
|
+
const ents = shown.map(id => byId.get(id)).filter(n => {
|
|
15580
|
+
const ok = n.type === 'er.Entity' && typeof n.id === 'string' && n.id !== '' && !nl(n.id) && meId(n.id) === n.id && (meBare(n.id) || meQuoted(n.id) || n.id.trim() === n.id);
|
|
15581
|
+
if (!ok) skip(n);
|
|
15582
|
+
return ok;
|
|
15583
|
+
});
|
|
15584
|
+
const names = new Map(), taken = new Set(ents.filter(n => meBare(n.id) || meQuoted(n.id)).map(n => n.id)), made = new Map();
|
|
15585
|
+
for (const n of ents) {
|
|
15586
|
+
if (meBare(n.id)) names.set(n.id, [n.id, false]);
|
|
15587
|
+
else if (meQuoted(n.id)) names.set(n.id, [`"${n.id}"`, false]);
|
|
15588
|
+
else { const w = meFree(taken, made, 'entity'); taken.add(w); names.set(n.id, [w, true]); }
|
|
15589
|
+
}
|
|
15590
|
+
const used = new Set(ents.map(n => n.id)), next = new Map();
|
|
15591
|
+
// the hint, if one is needed: null when the reader derives the id, undefined when it would need one it cannot keep
|
|
15592
|
+
const idOf = (id, base) => { const d = meFree(used, next, meId(base)); if (d !== id && !keeps(id)) return undefined; used.add(id); return d === id ? null : id; };
|
|
15593
|
+
for (const n of ents) {
|
|
15594
|
+
const [name, hinted] = names.get(n.id);
|
|
15595
|
+
extra(n, ['id', 'type', 'label', 'members']);
|
|
15596
|
+
let alias = '';
|
|
15597
|
+
if (typeof n.label === 'string' && n.label !== '') { if (hinted || n.label !== n.id) alias = `["${meEncode(n.label)}"]`; }
|
|
15598
|
+
else lose(n.id, n.label === '' ? 'an empty label, read as its name' : n.label == null ? 'no label, read as its name' : `label ${n.label}, which is not text`);
|
|
15599
|
+
const rows = [], labels = new Set();
|
|
15600
|
+
for (const x of arr(n.members).filter(isObj)) {
|
|
15601
|
+
const p = isObj(x.props) ? x.props : {}, ext = isObj(x.ext) ? x.ext : {}, me = isObj(ext.mermaid) ? ext.mermaid : {};
|
|
15602
|
+
if (x.type !== 'er.Column' || !derives(x.id) || typeof x.label !== 'string' || !x.label || typeof p.dataType !== 'string' || !p.dataType || labels.has(x.label)) { skip(x); continue; }
|
|
15603
|
+
const comment = typeof me.comment === 'string' ? me.comment : undefined;
|
|
15604
|
+
const keys = [p.primaryKey === true && 'PK', p.foreignKey === true && 'FK', p.unique === true && 'UK'].filter(Boolean);
|
|
15605
|
+
const want = JSON.stringify(Object.entries({ dataType: p.dataType, ...Object.fromEntries(['primaryKey', 'foreignKey', 'unique', 'nullable'].filter(f => p[f] === true).map(f => [f, true])) }).sort());
|
|
15606
|
+
// the type and the name as they are, else in backticks (and a ? apart, which a generic type's word would take in):
|
|
15607
|
+
// the first row the reader reads back as the column is written
|
|
15608
|
+
// (a row is one line: a type or name in backticks holding a line break would be read as two)
|
|
15609
|
+
const row = [[0, 0, ''], [1, 0, ''], [0, 1, ''], [1, 1, ''], [0, 0, ' '], [0, 1, ' ']].map(([q, r, sp]) => `${q ? '`' + p.dataType + '`' : p.dataType}${p.nullable === true ? sp + '?' : ''} ${r ? '`' + x.label + '`' : x.label}${keys.length ? ' ' + keys.join(', ') : ''}${comment !== undefined ? ` "${meEncode(comment)}"` : ''}`).find(row => {
|
|
15610
|
+
if (nl(row)) return false;
|
|
15611
|
+
const r = meRow(row, 0), c = r.cols.length === 1 && r.bad === undefined && r.end === row.length ? meColumn(r.cols[0]) : null;
|
|
15612
|
+
return c && c.label === x.label && c.comment === comment && JSON.stringify(Object.entries(c.props).sort()) === want;
|
|
15613
|
+
});
|
|
15614
|
+
const hint = row ? idOf(x.id, `${n.id}.${x.label}`) : undefined;
|
|
15615
|
+
if (hint === undefined) { skip(x); continue; }
|
|
15616
|
+
labels.add(x.label);
|
|
15617
|
+
extra(x, ['id', 'type', 'label', 'ext'], ['dataType', 'primaryKey', 'foreignKey', 'unique', 'nullable', 'references']);
|
|
15618
|
+
for (const f of ['primaryKey', 'foreignKey', 'unique', 'nullable']) if (p[f] !== undefined && p[f] !== true) lose(x.id, `${f} ${p[f]}, read as absent`);
|
|
15619
|
+
if (Object.keys(ext).some(k => k !== 'mermaid') || Object.keys(me).some(k => k !== 'comment') || (me.comment !== undefined && comment === undefined)) lose(x.id, 'ext, but for ext.mermaid.comment');
|
|
15620
|
+
let ref = null;
|
|
15621
|
+
if (p.references !== undefined) { if (keeps(p.references)) ref = p.references; else lose(x.id, `references ${p.references}, which the reader would not keep`); }
|
|
15622
|
+
rows.push([hint, ref, row]);
|
|
15623
|
+
}
|
|
15624
|
+
if (hinted) out.push(` %% id: ${n.id}`);
|
|
15625
|
+
if (!rows.length) { out.push(` ${name}${alias}`); continue; }
|
|
15626
|
+
out.push(` ${name}${alias} {`);
|
|
15627
|
+
for (const [hint, ref, row] of rows) out.push(...(hint ? [` %% id: ${hint}`] : []), ...(ref ? [` %% references: ${ref}`] : []), ` ${row}`);
|
|
15628
|
+
out.push(' }');
|
|
15629
|
+
}
|
|
15630
|
+
|
|
15631
|
+
const edgeOf = e => (typeof e === 'string' ? arr(m.edges).find(x => isObj(x) && x.id === e) : e);
|
|
15632
|
+
const lines = (view && Array.isArray(view.edges) ? view.edges.map(edgeOf) : arr(m.edges).filter(e => isObj(e) && shownSet.has(e.from) && shownSet.has(e.to))).filter(isObj);
|
|
15633
|
+
const seenEdges = new Set();
|
|
15634
|
+
for (const e of lines) {
|
|
15635
|
+
if (view && Array.isArray(view.edges) && !once(seenEdges, e.id)) continue;
|
|
15636
|
+
const hint = e.type === 'er.Relationship' && derives(e.id) && names.has(e.from) && names.has(e.to) ? idOf(e.id, `${e.from}-${e.to}`) : undefined;
|
|
15637
|
+
if (hint === undefined) { skip(e); continue; }
|
|
15638
|
+
const p = isObj(e.props) ? e.props : {}, ends = isObj(p.ends) ? p.ends : {};
|
|
15639
|
+
extra(e, ['id', 'type', 'from', 'to', 'label'], ['identifying', 'ends']);
|
|
15640
|
+
// each end's marker: one of the four, else the nearest by its bounds (0 or 1 below; 1 or many above), named
|
|
15641
|
+
const mark = (w, side) => {
|
|
15642
|
+
const end = isObj(ends[w]) ? ends[w] : {}, mult = end.multiplicity;
|
|
15643
|
+
for (const k of Object.keys(end)) if (k !== 'multiplicity') lose(e.id, `${k} at its ${w} end`);
|
|
15644
|
+
if (typeof mult === 'string' && ME_SYM[mult]) return ME_SYM[mult][side];
|
|
15645
|
+
const b = /^\s*(\d+|\*)\s*(?:\.\.\s*(\d+|\*)\s*)?$/.exec(typeof mult === 'string' ? mult : ''), lo = b && (b[1] === '*' ? 0 : +b[1]), hi = b && (b[2] ?? b[1]);
|
|
15646
|
+
const near = !b ? '1' : (lo === 0 ? '0' : '1') + (hi === '*' || +hi > 1 ? '..*' : lo === 0 ? '..1' : '');
|
|
15647
|
+
lose(e.id, `multiplicity ${mult ?? 'none'} at its ${w} end, written as ${near}`);
|
|
15648
|
+
return ME_SYM[near][side];
|
|
15649
|
+
};
|
|
15650
|
+
const left = mark('from', 0), right = mark('to', 1);
|
|
15651
|
+
if (typeof p.identifying !== 'boolean') lose(e.id, `identifying ${p.identifying ?? 'unset'}, written as non-identifying`);
|
|
15652
|
+
if (e.label === '' || (e.label != null && typeof e.label !== 'string')) lose(e.id, e.label === '' ? 'an empty label, read as none' : `label ${e.label}, which is not text`);
|
|
15653
|
+
const label = typeof e.label === 'string' ? meEncode(e.label) : '';
|
|
15654
|
+
out.push(...(hint ? [` %% id: ${hint}`] : []), ` ${names.get(e.from)[0]} ${left}${p.identifying === true ? '--' : '..'}${right} ${names.get(e.to)[0]} : "${label}"`);
|
|
15655
|
+
}
|
|
15656
|
+
for (const x of notWritten) out.push(` %% not written: ${x}`);
|
|
15657
|
+
return out.join('\n') + '\n';
|
|
15658
|
+
}
|
|
15659
|
+
/* ---------- Mermaid flowchart: a reader and a writer (notes/milestone-2.md §8, §8.2, M2.9; risks.md R1 to R4, R7) ----------
|
|
15660
|
+
Written against Mermaid 12.3.0's flowchart page (docs/syntax/flowchart.md) and its shapes' names and aliases
|
|
15661
|
+
(rendering-elements/shapes.ts), both read on Mermaid's repository 14 Nov 2026, and ported from the map
|
|
15662
|
+
importer's grammar (`flowchart()` in umlimator_import.py): node groups with &, links with their text in pipes or inline,
|
|
15663
|
+
subgraphs, statements split by ; outside quotes. On the file of every construct (tests/import/flowchart.mmd) it reads
|
|
15664
|
+
every node and link the importer reads, with the same ids; it reads more (Mermaid's @{ shape: ... } form and edge ids),
|
|
15665
|
+
and keeps each node's shape as its flowchart type. Internal: the CLI imports it through Node (M2.12); its helpers are
|
|
15666
|
+
its own (mf...), since the readers share one scope in lib/umlimator-all.js.
|
|
15667
|
+
|
|
15668
|
+
readMermaidFlowchart(text) -> { model, report }: a version-2 model with one `flowchart` view, in model.md row 21's
|
|
15669
|
+
vocabulary, and a report of what was not read as written, each item { line, status, message }, status `skipped` or
|
|
15670
|
+
`approximated` (R1). Hand-written and linear; text over MF_LIMIT characters is refused, and links past MF_FLOWS are not
|
|
15671
|
+
read (R2). Ids come from the source's names (R3): a node is its Mermaid id, a link its edge id (`A e1@--> B`), else
|
|
15672
|
+
<from>-<to>, the next free of base, base-2, base-3 ... `%% id: <id>` above a statement gives its first link that id, or,
|
|
15673
|
+
when it has no link, its first node; above the header, the view. It never throws.
|
|
15674
|
+
|
|
15675
|
+
writeMermaidFlowchart(model, viewId?) -> text: one flowchart view as Mermaid: each node declared on its own line, then
|
|
15676
|
+
each flow, its label quoted in pipes. An id Mermaid cannot carry as a name is written as a name made of it with
|
|
15677
|
+
`%% id:` above, as is a flow's id the reader would not derive; an element or property the subset leaves out is named in
|
|
15678
|
+
`%% not written:` lines (a comment: Mermaid's flowchart has no note).
|
|
15679
|
+
|
|
15680
|
+
How the flowchart's symbols map onto Mermaid's shapes, both ways (§8.2): ([ ]) a terminator, [ ] a process, { } a
|
|
15681
|
+
decision, [/ /] and [\ \] input/output, [[ ]] a predefined process, [( )] a database, (( )) a connector; doc,
|
|
15682
|
+
manual-input and delay by @{ shape: ... } only, with the aliases Mermaid's page lists. Mermaid's other shapes are read
|
|
15683
|
+
as processes, approximated. A link is a flow; one with no arrowhead, dotted, thick, or with a circle or cross end is
|
|
15684
|
+
approximated, <-->, o--o and x--x are two flows, ~~~ is not read. A subgraph's nodes are read, the group is not (row 21 has none). */
|
|
15685
|
+
const MF_LIMIT = 1 << 20; // characters; a flowchart a person writes is a few kilobytes
|
|
15686
|
+
const MF_FLOWS = 100000; // the most links read: a & b & ... --> c & d & ... makes the product of its groups (R2)
|
|
15687
|
+
const MF_VIEW = 'flowchart'; // the view's id when the source does not name it, as the importer's
|
|
15688
|
+
const mfTable = o => Object.assign(Object.create(null), o); // a lookup table: "constructor" or "__proto__" finds nothing in it
|
|
15689
|
+
// the bracket forms, longest first, as the importer's SHAPES: open, close, type (null: a process, approximated) and what Mermaid calls it
|
|
15690
|
+
const MF_BRACKETS = [['(((', ')))', null, 'a double circle'], ['([', '])', 'flow.Terminator'], ['[[', ']]', 'flow.PredefinedProcess'], ['[(', ')]', 'flow.Database'],
|
|
15691
|
+
['((', '))', 'flow.Connector'], ['{{', '}}', null, 'a hexagon'], ['[/', '/]', 'flow.InputOutput'], ['[/', '\\]', null, 'a trapezoid'], ['[\\', '\\]', 'flow.InputOutput'],
|
|
15692
|
+
['[\\', '/]', null, 'an upside-down trapezoid'], ['[', ']', 'flow.Process'], ['(', ')', null, 'a rounded box'], ['{', '}', 'flow.Decision'], ['>', ']', null, 'an asymmetric shape']];
|
|
15693
|
+
// @{ shape: ... }: the names and aliases in Mermaid's shapes.ts of the shapes a flowchart symbol has; any other is a process, approximated
|
|
15694
|
+
const MF_SHAPES = mfTable({
|
|
15695
|
+
stadium: 'flow.Terminator', terminal: 'flow.Terminator', pill: 'flow.Terminator',
|
|
15696
|
+
rect: 'flow.Process', rectangle: 'flow.Process', proc: 'flow.Process', process: 'flow.Process',
|
|
15697
|
+
diam: 'flow.Decision', diamond: 'flow.Decision', decision: 'flow.Decision', question: 'flow.Decision',
|
|
15698
|
+
'lean-r': 'flow.InputOutput', 'lean-right': 'flow.InputOutput', 'in-out': 'flow.InputOutput', 'lean-l': 'flow.InputOutput', 'lean-left': 'flow.InputOutput', 'out-in': 'flow.InputOutput',
|
|
15699
|
+
subproc: 'flow.PredefinedProcess', subprocess: 'flow.PredefinedProcess', subroutine: 'flow.PredefinedProcess', 'fr-rect': 'flow.PredefinedProcess', 'framed-rectangle': 'flow.PredefinedProcess',
|
|
15700
|
+
doc: 'flow.Document', document: 'flow.Document',
|
|
15701
|
+
cyl: 'flow.Database', cylinder: 'flow.Database', database: 'flow.Database', db: 'flow.Database',
|
|
15702
|
+
'manual-input': 'flow.ManualInput', 'sl-rect': 'flow.ManualInput', 'sloped-rectangle': 'flow.ManualInput',
|
|
15703
|
+
delay: 'flow.Delay', 'half-rounded-rectangle': 'flow.Delay',
|
|
15704
|
+
circle: 'flow.Connector', circ: 'flow.Connector' });
|
|
15705
|
+
// what the writer writes for each type: a bracket form, or a shape name for the three the brackets lack
|
|
15706
|
+
const MF_WRITE = mfTable({ 'flow.Terminator': ['([', '])'], 'flow.Process': ['[', ']'], 'flow.Decision': ['{', '}'], 'flow.InputOutput': ['[/', '/]'], 'flow.PredefinedProcess': ['[[', ']]'],
|
|
15707
|
+
'flow.Database': ['[(', ')]'], 'flow.Connector': ['((', '))'], 'flow.Document': 'doc', 'flow.ManualInput': 'manual-input', 'flow.Delay': 'delay' });
|
|
15708
|
+
const MF_NODE = /\s*([\p{L}\p{N}_$]+(?:[.-][\p{L}\p{N}_$]+)*)/uy; // a node's id, as the importer's NODE (Python's \w)
|
|
15709
|
+
const MF_NAME = /^[\p{L}\p{N}_$]+(?:[.-][\p{L}\p{N}_$]+)*$/u;
|
|
15710
|
+
const MF_PLAIN = /\s*([<ox]?(?:-{2,}[->ox]|={2,}[=>ox]|-?\.+-[>ox]?|~{3,}))/y; // a link with no text in it
|
|
15711
|
+
const MF_OPEN = /\s*([<ox]?(?:--|==|-\.))\s*/y; // a link's start, its text after it (-- text -->)
|
|
15712
|
+
const MF_PIPE = /\s*(?:\|("[^"]*"|[^|]*)\|)?/y; // a quoted text may hold |
|
|
15713
|
+
const MF_EDGE_ID = /\s*([^\s"@&|]+)@(?=[^{"])/y; // A e1@--> B
|
|
15714
|
+
// statements Mermaid reads and this reader does not; the importer passes over the same words (FLOW_QUIET)
|
|
15715
|
+
const MF_QUIET = /^(classDef|class|style|linkStyle|click|direction|accTitle|accDescr)(?![\p{L}\p{N}_])/u;
|
|
15716
|
+
const MF_WORDS = /^(end|subgraph|graph|flowchart|classDef|class|style|linkStyle|click|direction|accTitle|accDescr|default|call|href)(?![\p{L}\p{N}_])/u; // no name the writer gives starts so
|
|
15717
|
+
const mfId = s => String(s).replace(/->+/g, '-'); // an id never holds "->" (model.md rule 5)
|
|
15718
|
+
const mfClip = s => (s.length > 60 ? s.slice(0, 57) + '...' : s);
|
|
15719
|
+
|
|
15720
|
+
function mfFree(used, next, base) { // the first of base, base-2, ... not in used, resuming where the last ask stopped (R2)
|
|
15721
|
+
let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
|
|
15722
|
+
while (used.has(id)) id = `${base}-${++k}`;
|
|
15723
|
+
next.set(base, k);
|
|
15724
|
+
return id;
|
|
15725
|
+
}
|
|
15726
|
+
|
|
15727
|
+
// text: quotes and a Markdown string's backticks taken off (as the importer's text()), Mermaid's <br> and entity codes read
|
|
15728
|
+
function mfText(raw, md) {
|
|
15729
|
+
let s = raw.trim();
|
|
15730
|
+
if (s.length > 1 && s[0] === '"' && s.at(-1) === '"') {
|
|
15731
|
+
s = s.slice(1, -1);
|
|
15732
|
+
if (s.length > 1 && s[0] === '`' && s.at(-1) === '`') { s = s.slice(1, -1); md?.(); }
|
|
15733
|
+
}
|
|
15734
|
+
return s.replace(/<br\s?\/?>|\\n/gi, '\n').replace(/#(quot|lt|gt|amp|\d{1,6});/g, (_, e) =>
|
|
15735
|
+
({ quot: '"', lt: '<', gt: '>', amp: '&' })[e] ?? String.fromCodePoint(Math.min(Number(e), 0x10ffff)));
|
|
15736
|
+
}
|
|
15737
|
+
|
|
15738
|
+
// where a link's close (-->, ==>, .->, ---) ends when it starts at p, else -1: -{2,}[->ox], ={2,}[=>ox] or \.+-[>ox]?, greedy
|
|
15739
|
+
function mfCloseAt(s, p) {
|
|
15740
|
+
const c = s[p];
|
|
15741
|
+
let q = p;
|
|
15742
|
+
while (s[q] === c) q++;
|
|
15743
|
+
if (c === '.') return s[q] !== '-' ? -1 : q + 1 + (q + 1 < s.length && '>ox'.includes(s[q + 1]) ? 1 : 0);
|
|
15744
|
+
if (c !== '-' && c !== '=') return -1;
|
|
15745
|
+
if (q - p >= 2 && q < s.length && '>ox'.includes(s[q])) return q + 1;
|
|
15746
|
+
return q - p >= 3 ? q : -1;
|
|
15747
|
+
}
|
|
15748
|
+
// a link at i: { arrow, text, pipe, end }, or null. The importer's LINK, with its text's close found by one scan: the
|
|
15749
|
+
// first close after the text, and no | before it ([^|]+? then \s* then the close)
|
|
15750
|
+
function mfLink(s, i) {
|
|
15751
|
+
MF_PLAIN.lastIndex = i;
|
|
15752
|
+
let m = MF_PLAIN.exec(s), arrow, text, j;
|
|
15753
|
+
if (m) { arrow = m[1]; j = MF_PLAIN.lastIndex; } else {
|
|
15754
|
+
MF_OPEN.lastIndex = i;
|
|
15755
|
+
if (!(m = MF_OPEN.exec(s))) return null;
|
|
15756
|
+
const a = MF_OPEN.lastIndex, from = m.index + m[0].trimEnd().length; // the text starts at a; it may start in the spaces before
|
|
15757
|
+
let at = -1, end = -1;
|
|
15758
|
+
for (let p = a + 1; p < s.length && s[p] !== '|';) {
|
|
15759
|
+
end = mfCloseAt(s, p);
|
|
15760
|
+
if (end >= 0) { at = p; break; }
|
|
15761
|
+
const c = s[p];
|
|
15762
|
+
if (c === '-' || c === '=' || c === '.') while (s[p] === c) p++; else p++; // no close starts inside a run that fails
|
|
15763
|
+
}
|
|
15764
|
+
if (at < 0 && a > from && s[a] !== '|' && (end = mfCloseAt(s, a)) >= 0) at = a; // text of spaces only
|
|
15765
|
+
if (at < 0) return null;
|
|
15766
|
+
text = s.slice(a, at); arrow = m[1] + s.slice(at, end); j = end;
|
|
15767
|
+
}
|
|
15768
|
+
MF_PIPE.lastIndex = j;
|
|
15769
|
+
const p = MF_PIPE.exec(s);
|
|
15770
|
+
return { arrow, text, pipe: p[1], end: MF_PIPE.lastIndex, raw: s.slice(i, MF_PIPE.lastIndex).trim() };
|
|
15771
|
+
}
|
|
15772
|
+
|
|
15773
|
+
// @{ key: value, ... } at i (the @ itself): its fields and where it ends, or null when it is not closed
|
|
15774
|
+
function mfFields(s, i) {
|
|
15775
|
+
const out = new Map();
|
|
15776
|
+
let k = i + 2, key = '', val = '', inKey = true, q = null, quoted = false;
|
|
15777
|
+
const put = () => { if (key.trim()) out.set(key.trim(), quoted ? val : val.trim()); key = ''; val = ''; inKey = true; quoted = false; };
|
|
15778
|
+
for (; k < s.length; k++) {
|
|
15779
|
+
const c = s[k];
|
|
15780
|
+
if (q) { if (c === '\\' && k + 1 < s.length) val += s[++k]; else if (c === q) q = null; else val += c; continue; }
|
|
15781
|
+
if (c === '}') { put(); return { fields: out, end: k + 1 }; }
|
|
15782
|
+
if (inKey) { if (c === ':') inKey = false; else if (c === ',') put(); else key += c; continue; }
|
|
15783
|
+
if (c === ',') put();
|
|
15784
|
+
else if ((c === '"' || c === "'") && !val.trim()) { q = c; quoted = true; val = ''; } else val += c;
|
|
15785
|
+
}
|
|
15786
|
+
return null;
|
|
15787
|
+
}
|
|
15788
|
+
|
|
15789
|
+
// a node at i: { id, label, type, odd, fields, cls, end }, or null (the importer's fnode, with @{ } read too). find is
|
|
15790
|
+
// the statement's indexOf, which remembers where each string was not found, so unclosed brackets cost one scan (R2)
|
|
15791
|
+
function mfNode(s, i, find) {
|
|
15792
|
+
MF_NODE.lastIndex = i;
|
|
15793
|
+
const n = MF_NODE.exec(s);
|
|
15794
|
+
if (!n) return null;
|
|
15795
|
+
const out = { id: n[1], end: MF_NODE.lastIndex };
|
|
15796
|
+
i = out.end;
|
|
15797
|
+
for (const [o, c, type, odd] of MF_BRACKETS) {
|
|
15798
|
+
if (!s.startsWith(o, i)) continue;
|
|
15799
|
+
const q = s[i + o.length] === '"' ? find('"', i + o.length + 1) : -1;
|
|
15800
|
+
const k = find(c, q >= 0 ? q + 1 : i + o.length);
|
|
15801
|
+
if (k > 0) { out.label = s.slice(i + o.length, k); out.type = type || 'flow.Process'; out.odd = type ? null : odd; i = k + c.length; break; }
|
|
15802
|
+
}
|
|
15803
|
+
if (out.label === undefined && s.startsWith('@{', i)) {
|
|
15804
|
+
const f = mfFields(s, i);
|
|
15805
|
+
if (f) { out.fields = f.fields; i = f.end; }
|
|
15806
|
+
}
|
|
15807
|
+
if (s.startsWith(':::', i)) {
|
|
15808
|
+
let k = i + 3;
|
|
15809
|
+
while (k < s.length && /[\p{L}\p{N}_-]/u.test(s[k])) k++;
|
|
15810
|
+
if (k > i + 3) { out.cls = s.slice(i + 3, k); i = k; }
|
|
15811
|
+
}
|
|
15812
|
+
out.end = i;
|
|
15813
|
+
return out;
|
|
15814
|
+
}
|
|
15815
|
+
|
|
15816
|
+
// node groups (a & b) joined by links, or null: the importer's statement()
|
|
15817
|
+
function mfStatement(s) {
|
|
15818
|
+
const groups = [], links = [], miss = new Map();
|
|
15819
|
+
const find = (c, from) => {
|
|
15820
|
+
if (from >= (miss.get(c) ?? Infinity)) return -1;
|
|
15821
|
+
const k = s.indexOf(c, from);
|
|
15822
|
+
if (k < 0) miss.set(c, from);
|
|
15823
|
+
return k;
|
|
15824
|
+
};
|
|
15825
|
+
let i = 0;
|
|
15826
|
+
for (;;) {
|
|
15827
|
+
const grp = [];
|
|
15828
|
+
for (let n; (n = mfNode(s, i, find));) {
|
|
15829
|
+
grp.push(n); i = n.end;
|
|
15830
|
+
const amp = /\s*&/y; amp.lastIndex = i;
|
|
15831
|
+
if (!amp.exec(s)) break;
|
|
15832
|
+
i = amp.lastIndex;
|
|
15833
|
+
}
|
|
15834
|
+
if (!grp.length) return null;
|
|
15835
|
+
groups.push(grp);
|
|
15836
|
+
if (!s.slice(i).trim()) return { groups, links };
|
|
15837
|
+
MF_EDGE_ID.lastIndex = i;
|
|
15838
|
+
const eid = MF_EDGE_ID.exec(s), lk = mfLink(s, eid ? MF_EDGE_ID.lastIndex : i);
|
|
15839
|
+
if (!lk) return null;
|
|
15840
|
+
if (eid) lk.id = eid[1];
|
|
15841
|
+
links.push(lk); i = lk.end;
|
|
15842
|
+
}
|
|
15843
|
+
}
|
|
13677
15844
|
|
|
15845
|
+
// ; outside quotes ends a statement, as the importer splits a line
|
|
15846
|
+
function mfSplit(line) {
|
|
15847
|
+
const out = [];
|
|
15848
|
+
let from = 0, q = false;
|
|
15849
|
+
for (let k = 0; k < line.length; k++) {
|
|
15850
|
+
if (line[k] === '"') q = !q;
|
|
15851
|
+
else if (line[k] === ';' && !q) { out.push(line.slice(from, k)); from = k + 1; }
|
|
15852
|
+
}
|
|
15853
|
+
out.push(line.slice(from));
|
|
15854
|
+
return out.map(x => x.trim());
|
|
15855
|
+
}
|
|
13678
15856
|
|
|
15857
|
+
function readMermaidFlowchart(text) {
|
|
15858
|
+
const report = [], say = (line, status, message) => report.push({ line, status, message });
|
|
15859
|
+
const empty = () => ({ model: { schemaVersion: 2, root: MF_VIEW, nodes: [], edges: [], views: [{ id: MF_VIEW, diagram: 'flowchart', direction: 'down', nodeIds: [] }] }, report });
|
|
15860
|
+
if (typeof text !== 'string') { say(1, 'skipped', 'the input is not text: nothing read'); return empty(); }
|
|
15861
|
+
if (text.length > MF_LIMIT) { say(1, 'skipped', `the input is ${text.length} characters, over the limit of ${MF_LIMIT}: nothing read`); return empty(); }
|
|
15862
|
+
const lines = text.replace(/^/, '').split('\n').map(l => l.replace(/\r$/, ''));
|
|
15863
|
+
let i = 0, title;
|
|
13679
15864
|
|
|
15865
|
+
// front matter: its title; Mermaid's configuration is not read
|
|
15866
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
15867
|
+
if (lines[i] && lines[i].trim() === '---') {
|
|
15868
|
+
let j = i + 1;
|
|
15869
|
+
for (; j < lines.length && lines[j].trim() !== '---'; j++) {
|
|
15870
|
+
const l = lines[j];
|
|
15871
|
+
if (!l.trim() || l.trim().startsWith('#') || /^\s/.test(l)) continue;
|
|
15872
|
+
const c = l.indexOf(':'), key = (c < 0 ? l : l.slice(0, c)).trim(), val = c < 0 ? '' : l.slice(c + 1).trim();
|
|
15873
|
+
if (key === 'title') {
|
|
15874
|
+
if (val.startsWith('"')) { try { title = JSON.parse(val); } catch { title = val.slice(1, -1); } }
|
|
15875
|
+
else title = val.startsWith("'") ? val.slice(1, -1).replaceAll("''", "'") : val;
|
|
15876
|
+
} else say(j + 1, 'skipped', `front matter ${key}: Mermaid's configuration is not read`);
|
|
15877
|
+
}
|
|
15878
|
+
if (j >= lines.length) { say(i + 1, 'skipped', 'the front matter is never closed with ---: nothing read'); return empty(); }
|
|
15879
|
+
i = j + 1;
|
|
15880
|
+
}
|
|
13680
15881
|
|
|
15882
|
+
const nodes = new Map(), links = [], subs = [], closed = new Set(), edgeIds = new Set();
|
|
15883
|
+
let viewId, direction = 'down', started = false, hint = null, hintNo = 0, block = false, flows = 0, over = false;
|
|
15884
|
+
const statement = (s, no) => {
|
|
15885
|
+
if (!s) return;
|
|
15886
|
+
const sg = /^subgraph\s+(?:([^\s["]+)\s*\[([^\]]*)\]|"(.+)"|(\S+)$|(.+))/.exec(s);
|
|
15887
|
+
if (sg) { // its id as the importer's ident(): the name when it is plain, else a slug of its title
|
|
15888
|
+
const label = mfText(sg[2] ?? sg[3] ?? sg[4] ?? sg[5]), raw = (sg[1] ?? sg[4] ?? label).trim();
|
|
15889
|
+
subs.push({ id: /^[\p{L}\p{N}_.-]+$/u.test(raw) ? raw : raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'x', no });
|
|
15890
|
+
say(no, 'skipped', `subgraph ${subs.at(-1).id}: the group is not read, the nodes in it are`);
|
|
15891
|
+
return;
|
|
15892
|
+
}
|
|
15893
|
+
if (s === 'end') { if (subs.length) closed.add(subs.pop().id); else say(no, 'skipped', 'an end that closes no subgraph'); return; }
|
|
15894
|
+
const quiet = MF_QUIET.exec(s);
|
|
15895
|
+
if (quiet) {
|
|
15896
|
+
const w = quiet[1];
|
|
15897
|
+
if (w === 'direction') {
|
|
15898
|
+
const d = s.slice(9).trim().toUpperCase();
|
|
15899
|
+
if (subs.length) say(no, 'approximated', `direction ${d} inside a subgraph is not kept: the view has one direction`);
|
|
15900
|
+
else say(no, 'skipped', `direction ${d} outside a subgraph is not read: the header gives the view's direction`);
|
|
15901
|
+
} else if (w === 'accTitle' || w === 'accDescr') {
|
|
15902
|
+
say(no, 'skipped', `${w}: accessibility text is not read`);
|
|
15903
|
+
if (w === 'accDescr' && /^accDescr\s*\{/.test(s) && !s.includes('}')) block = true;
|
|
15904
|
+
} else say(no, 'skipped', `${mfClip(s)}: styles and interaction are not read`);
|
|
15905
|
+
return;
|
|
15906
|
+
}
|
|
15907
|
+
const p = mfStatement(s);
|
|
15908
|
+
if (!p) { say(no, 'skipped', `not read: ${mfClip(s)}`); return; }
|
|
15909
|
+
if (p.groups.length === 1 && p.groups[0].length === 1 && edgeIds.has(p.groups[0][0].id) && p.groups[0][0].fields) { // e1@{ animate: true }
|
|
15910
|
+
say(no, 'skipped', `${p.groups[0][0].id}@{ ... }: a link's animation is not read`);
|
|
15911
|
+
return;
|
|
15912
|
+
}
|
|
15913
|
+
for (const n of p.groups.flat()) {
|
|
15914
|
+
let rec = nodes.get(n.id);
|
|
15915
|
+
if (!rec) { rec = { name: n.id, no, label: n.id, type: 'flow.Process' }; nodes.set(n.id, rec); }
|
|
15916
|
+
if (hint != null && !p.links.length && rec.hint === undefined) { rec.hint = hint; rec.hintNo = no; hint = null; }
|
|
15917
|
+
if (n.cls) say(no, 'skipped', `${n.id}:::${n.cls}: a style class is not read`);
|
|
15918
|
+
if (n.label !== undefined) {
|
|
15919
|
+
rec.label = mfText(n.label, () => say(no, 'approximated', `${n.id}: a Markdown string is read as plain text`)); rec.type = n.type;
|
|
15920
|
+
if (n.odd) say(no, 'approximated', `${n.id}: ${n.odd} is read as a process`);
|
|
15921
|
+
} else if (n.fields) {
|
|
15922
|
+
for (const [k, v] of n.fields) {
|
|
15923
|
+
if (k === 'label') rec.label = mfText(v, () => say(no, 'approximated', `${n.id}: a Markdown string is read as plain text`));
|
|
15924
|
+
else if (k === 'shape') {
|
|
15925
|
+
const t = MF_SHAPES[v];
|
|
15926
|
+
rec.type = t || 'flow.Process';
|
|
15927
|
+
if (!t) say(no, 'approximated', `${n.id}: shape ${v} is read as a process`);
|
|
15928
|
+
} else say(no, 'skipped', `${n.id}: ${k} is not read`);
|
|
15929
|
+
}
|
|
15930
|
+
}
|
|
15931
|
+
}
|
|
15932
|
+
p.links.forEach((lk, k) => {
|
|
15933
|
+
const arrow = lk.arrow;
|
|
15934
|
+
if (arrow.startsWith('~')) { say(no, 'skipped', `${p.groups[k].map(n => n.id).join(' & ')} ~~~ ${p.groups[k + 1].map(n => n.id).join(' & ')}: an invisible link is not read`); return; }
|
|
15935
|
+
if (lk.id) edgeIds.add(lk.id);
|
|
15936
|
+
const label = lk.pipe !== undefined ? mfText(lk.pipe) : lk.text !== undefined ? mfText(lk.text) : undefined, head = arrow.at(-1), tail = arrow[0];
|
|
15937
|
+
const two = '<ox'.includes(tail), how = [...(arrow.includes('.') ? ['dotted'] : []), ...(arrow.includes('=') ? ['thick'] : []), ...('>ox'.includes(head) ? [] : ['no arrowhead']),
|
|
15938
|
+
...(head === 'o' || tail === 'o' ? ['a circle end'] : []), ...(head === 'x' || tail === 'x' ? ['a cross end'] : []), ...(two ? ['an arrowhead at each end'] : [])];
|
|
15939
|
+
for (const a of p.groups[k]) for (const b of p.groups[k + 1]) {
|
|
15940
|
+
if (flows >= MF_FLOWS) { if (!over) { over = true; say(no, 'skipped', `over ${MF_FLOWS} links: the rest are not read`); } return; }
|
|
15941
|
+
flows++;
|
|
15942
|
+
if (how.length) say(no, 'approximated', `${a.id} ${lk.raw} ${b.id}: ${how.join(', ')}: read as ${two ? 'two flows' : 'a flow'}`);
|
|
15943
|
+
links.push({ from: a.id, to: b.id, label, no, hint: hint ?? lk.id, eid: hint == null && lk.id != null }); hint = null;
|
|
15944
|
+
if (two) links.push({ from: b.id, to: a.id, label, no });
|
|
15945
|
+
}
|
|
15946
|
+
});
|
|
15947
|
+
};
|
|
15948
|
+
// a hint applies to the statement directly below it; one that statement does not take is reported, not carried on
|
|
15949
|
+
const unused = () => { if (hint != null) say(hintNo, 'skipped', `%% id: ${hint}: the statement below it takes no id from it`); hint = null; };
|
|
13681
15950
|
|
|
15951
|
+
for (; i < lines.length; i++) {
|
|
15952
|
+
const no = i + 1, s = lines[i].trim();
|
|
15953
|
+
if (!s) continue;
|
|
15954
|
+
if (block) { if (s.includes('}')) block = false; continue; } // inside accDescr { ... }
|
|
15955
|
+
if (s.startsWith('%%')) {
|
|
15956
|
+
const c = s.slice(2).trim();
|
|
15957
|
+
if (c.startsWith('{')) {
|
|
15958
|
+
say(no, 'skipped', 'a %%{init}%% directive: Mermaid\'s configuration is not read');
|
|
15959
|
+
if (!s.includes('}%%')) while (i + 1 < lines.length && !lines[++i].includes('}%%')); // over several lines: to its }%%
|
|
15960
|
+
} else if (c.startsWith('id:')) { unused(); hint = c.slice(3).trim(); hintNo = no; }
|
|
15961
|
+
continue;
|
|
15962
|
+
}
|
|
15963
|
+
if (!started) {
|
|
15964
|
+
const h = /^(flowchart-elk|flowchart|graph)(?![\w-])\s*(TB|TD|BT|RL|LR)?\s*;?/.exec(s);
|
|
15965
|
+
if (!h) { say(no, 'skipped', 'not a Mermaid flowchart: nothing read'); return empty(); }
|
|
15966
|
+
started = true; viewId = hint; hint = null;
|
|
15967
|
+
if (viewId != null && mfId(viewId) !== viewId) { say(no, 'approximated', `%% id: ${viewId}: an id holds no "->", so the view is ${mfId(viewId)}`); viewId = mfId(viewId); }
|
|
15968
|
+
const d = h[2];
|
|
15969
|
+
if (d === 'LR') direction = 'right';
|
|
15970
|
+
else if (d === 'BT' || d === 'RL') { direction = d === 'BT' ? 'down' : 'right'; say(no, 'approximated', `direction ${d} is drawn ${direction === 'down' ? 'top to bottom' : 'left to right'}`); }
|
|
15971
|
+
for (const x of mfSplit(s.slice(h[0].length))) { statement(x, no); unused(); }
|
|
15972
|
+
continue;
|
|
15973
|
+
}
|
|
15974
|
+
for (const x of mfSplit(s)) { statement(x, no); unused(); }
|
|
15975
|
+
}
|
|
15976
|
+
unused();
|
|
15977
|
+
if (!started) { say(1, 'skipped', 'not a Mermaid flowchart: nothing read'); return empty(); }
|
|
15978
|
+
for (const sg of subs) say(sg.no, 'approximated', `subgraph ${sg.id} is never closed: read to the end`);
|
|
13682
15979
|
|
|
15980
|
+
// ids: nodes by their names first, a subgraph's id naming none (the importer's box); then the links in order
|
|
15981
|
+
const used = new Set(), next = new Map();
|
|
15982
|
+
const take = (h, base, no, what, how = '%% id:') => {
|
|
15983
|
+
if (h != null && h !== '') {
|
|
15984
|
+
const t = mfId(h);
|
|
15985
|
+
if (t !== h) say(no, 'approximated', `${how} ${h}: an id holds no "->", so it is ${t}`);
|
|
15986
|
+
if (!used.has(t)) { used.add(t); return t; }
|
|
15987
|
+
const id = mfFree(used, next, base); used.add(id);
|
|
15988
|
+
say(no, 'approximated', `${how} ${t} is taken, so ${what} is ${id}`);
|
|
15989
|
+
return id;
|
|
15990
|
+
}
|
|
15991
|
+
const id = mfFree(used, next, base); used.add(id);
|
|
15992
|
+
return id;
|
|
15993
|
+
};
|
|
15994
|
+
const out = [], idOf = new Map();
|
|
15995
|
+
for (const rec of nodes.values()) {
|
|
15996
|
+
if (closed.has(rec.name)) continue;
|
|
15997
|
+
const id = take(rec.hint, rec.name, rec.hintNo ?? rec.no, `node ${rec.name}`);
|
|
15998
|
+
if (rec.hint == null && id !== rec.name) say(rec.no, 'approximated', `node ${rec.name}: its name is another's id, so it is ${id}`);
|
|
15999
|
+
idOf.set(rec.name, id);
|
|
16000
|
+
out.push({ id, type: rec.type, label: rec.label });
|
|
16001
|
+
}
|
|
16002
|
+
const edges = [];
|
|
16003
|
+
for (const l of links) {
|
|
16004
|
+
const bad = [l.from, l.to].find(x => !idOf.has(x));
|
|
16005
|
+
if (bad) { say(l.no, 'skipped', `${l.from} --> ${l.to}: ${bad} is a subgraph, which is not read`); continue; }
|
|
16006
|
+
const from = idOf.get(l.from), to = idOf.get(l.to);
|
|
16007
|
+
const e = { id: take(l.hint, mfId(`${from}-${to}`), l.no, `${l.from} --> ${l.to}`, l.eid ? 'the edge id' : undefined), type: 'flow.Flow', from, to };
|
|
16008
|
+
if (l.label !== undefined) e.label = l.label;
|
|
16009
|
+
edges.push(e);
|
|
16010
|
+
}
|
|
16011
|
+
const view = { id: viewId || MF_VIEW, diagram: 'flowchart', ...(title ? { title } : {}), direction, nodeIds: out.map(n => n.id) };
|
|
16012
|
+
report.sort((a, b) => a.line - b.line); // stable: one line's items keep their order
|
|
16013
|
+
return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: view.id, nodes: out, edges, views: [view] }, report };
|
|
16014
|
+
}
|
|
13683
16015
|
|
|
16016
|
+
function writeMermaidFlowchart(model, viewId) {
|
|
16017
|
+
const m = model && typeof model === 'object' ? model : {}, arr = x => (Array.isArray(x) ? x : []), isObj = x => !!x && typeof x === 'object';
|
|
16018
|
+
const flat = s => String(s).replace(/\r\n|\r|\n/g, '\\n'), has = x => x != null && x !== '', str = x => typeof x === 'string';
|
|
16019
|
+
const views = arr(m.views).filter(isObj);
|
|
16020
|
+
const view = viewId != null ? views.find(v => v.id === viewId) : views.find(v => v.diagram === 'flowchart');
|
|
16021
|
+
if (viewId != null && !view) return `%% not written: view ${flat(viewId)}, which the model does not have\n`;
|
|
16022
|
+
if (!view) return '%% not written: the model, which has no flowchart view\n';
|
|
16023
|
+
if (view.diagram !== 'flowchart') return `%% not written: view ${flat(view.id)}, a ${flat(view.diagram)} view, not a flowchart one\n`;
|
|
16024
|
+
// text, always quoted: Mermaid's own characters as entity codes, a line break as <br>, a space at either end as an
|
|
16025
|
+
// entity, which the reader's trim keeps; # first, so no other text reads as a code
|
|
16026
|
+
const ent = c => `#${c.codePointAt(0)};`;
|
|
16027
|
+
const enc = s => `"${String(s).replace(/[#"|;<>\\`]/g, ent).replace(/\r\n|\r|\n/g, '<br>').replace(/^\s|\s$/g, ent)}"`;
|
|
16028
|
+
const okId = id => str(id) && id !== '' && id.trim() === id && !/[\r\n]/.test(id); // a hint keeps it
|
|
16029
|
+
const out = [], notWritten = [], skip = x => notWritten.push(`${flat(x.id)} (${flat(x.type)})`), lose = (id, what) => notWritten.push(`${flat(id)}: ${flat(what)}`);
|
|
16030
|
+
const title = String(has(view.title) ? view.title : m.title ?? '');
|
|
16031
|
+
if (has(title)) out.push('---', `title: ${/^[\p{L}\p{N}][\p{L}\p{N} _.,()-]*$/u.test(title) && !title.endsWith(' ') ? title : JSON.stringify(title)}`, '---');
|
|
16032
|
+
if (has(view.id) && view.id !== MF_VIEW) { if (/[\r\n]/.test(view.id)) lose(view.id, "the view's id, which has a line break"); else out.push(`%% id: ${view.id}`); }
|
|
16033
|
+
out.push(`flowchart ${view.direction === 'right' ? 'LR' : 'TD'}`);
|
|
13684
16034
|
|
|
16035
|
+
const byId = new Map();
|
|
16036
|
+
for (const n of arr(m.nodes)) if (isObj(n) && !byId.has(n.id)) byId.set(n.id, n);
|
|
16037
|
+
const nodes = [], shown = new Set();
|
|
16038
|
+
for (const id of arr(view.nodeIds)) {
|
|
16039
|
+
const n = byId.get(id);
|
|
16040
|
+
if (!n || shown.has(id)) continue;
|
|
16041
|
+
shown.add(id);
|
|
16042
|
+
if (okId(n.id) && MF_WRITE[n.type]) nodes.push(n); else skip(n);
|
|
16043
|
+
}
|
|
16044
|
+
// names: an id Mermaid and the reader read as one name, else one made of it (and its id as a hint)
|
|
16045
|
+
const safe = id => MF_NAME.test(id) && !MF_WORDS.test(id);
|
|
16046
|
+
const names = new Map(), taken = new Set(nodes.map(n => n.id).filter(safe));
|
|
16047
|
+
for (const n of nodes) {
|
|
16048
|
+
if (safe(n.id)) { names.set(n.id, n.id); continue; }
|
|
16049
|
+
let base = n.id.replace(/[^\p{L}\p{N}_$]+/gu, '_').replace(/^_+|_+$/g, ''), name, k = 1;
|
|
16050
|
+
if (!safe(base)) base = 'N_' + base;
|
|
16051
|
+
for (name = base; taken.has(name); name = `${base}_${++k}`);
|
|
16052
|
+
taken.add(name); names.set(n.id, name);
|
|
16053
|
+
}
|
|
16054
|
+
for (const n of nodes) {
|
|
16055
|
+
const w = MF_WRITE[n.type], name = names.get(n.id);
|
|
16056
|
+
if (!str(n.label)) lose(n.id, has(n.label) ? 'its label, which is not text' : 'no label, read as an empty one');
|
|
16057
|
+
const label = enc(str(n.label) ? n.label : '');
|
|
16058
|
+
out.push(...(name !== n.id ? [` %% id: ${n.id}`] : []), ` ${name}${typeof w === 'string' ? `@{ shape: ${w}, label: ${label} }` : w[0] + label + w[1]}`);
|
|
16059
|
+
for (const k of Object.keys(n)) if (!['id', 'type', 'label'].includes(k)) lose(n.id, k === 'props' || k === 'ext' ? `its ${k}` : `property ${k}`);
|
|
16060
|
+
}
|
|
16061
|
+
// the flows, each with its id when the reader would derive another (it derives them in this order, after the nodes)
|
|
16062
|
+
const isNode = new Set(nodes.map(n => n.id)), used = new Set(isNode), next = new Map(), seen = new Set();
|
|
16063
|
+
for (const e of arr(m.edges)) {
|
|
16064
|
+
if (!isObj(e) || seen.has(e.id) || !(shown.has(e.from) || shown.has(e.to))) continue;
|
|
16065
|
+
seen.add(e.id);
|
|
16066
|
+
if (e.type !== 'flow.Flow' || !isNode.has(e.from) || !isNode.has(e.to) || !okId(e.id) || used.has(e.id)) { skip(e); continue; }
|
|
16067
|
+
const d = mfFree(used, next, mfId(`${e.from}-${e.to}`));
|
|
16068
|
+
used.add(e.id);
|
|
16069
|
+
if (has(e.label) && !str(e.label)) lose(e.id, 'its label, which is not text');
|
|
16070
|
+
out.push(...(d !== e.id ? [` %% id: ${e.id}`] : []), ` ${names.get(e.from)} -->${str(e.label) ? `|${enc(e.label)}|` : ''} ${names.get(e.to)}`);
|
|
16071
|
+
for (const k of Object.keys(e)) if (!['id', 'type', 'from', 'to', 'label'].includes(k)) lose(e.id, k === 'props' || k === 'ext' ? `its ${k}` : `property ${k}`);
|
|
16072
|
+
}
|
|
16073
|
+
for (const x of notWritten) out.push(` %% not written: ${x}`);
|
|
16074
|
+
return out.join('\n') + '\n';
|
|
16075
|
+
}
|
|
16076
|
+
/* ---------- Structurizr JSON: a reader (notes/milestone-2.md §8.3, M2.10; risks.md R1 to R3) ----------
|
|
16077
|
+
Written against Structurizr's workspace JSON (the workspace's published schema, structurizr/json), as the Structurizr
|
|
16078
|
+
CLI's `export -f json` writes it. Internal: the CLI (`umlimator.py import`) imports this file through Node; a host gets
|
|
16079
|
+
it once the registry is public. Its helpers are its own (sz...), since the readers share one scope in
|
|
16080
|
+
lib/umlimator-all.js. There is no writer (§13).
|
|
16081
|
+
|
|
16082
|
+
readStructurizr(workspace) -> { model, report }: a version-2 model in model.md row 23's vocabulary, typed C4, and a
|
|
16083
|
+
report of what was not read as written, each item { path, status, message }: `path` where in the workspace it is
|
|
16084
|
+
(`model.softwareSystems[0].containers[2]`), status `skipped` or `approximated` (R1). `workspace` is the parsed JSON, or
|
|
16085
|
+
its text (refused over SZ_LIMIT characters). It never throws, and reads at most SZ_MAX elements and SZ_MAX
|
|
16086
|
+
relationships, deployment nodes SZ_DEEP deep (deeper read flat from there), whatever the input (R2). A parsed object
|
|
16087
|
+
may hold one list or string in many places, so every list entry and every character of text read is also counted, each
|
|
16088
|
+
time it is reached, against SZ_WORK and SZ_CHARS: the counts alone would not bound the work. Every table keyed
|
|
16089
|
+
by a name or an id is a Map, so `__proto__` and `constructor` are ordinary names, and the input's fields are read as
|
|
16090
|
+
own properties only.
|
|
16091
|
+
|
|
16092
|
+
- People, software systems, their containers and those containers' components are c4.Person, c4.SoftwareSystem,
|
|
16093
|
+
c4.Container and c4.Component, owned by `parent`; deployment nodes c4.DeploymentNode, nested by `parent`; container
|
|
16094
|
+
instances c4.ContainerInstance, labelled as their container, `props.container` naming it. `description` and
|
|
16095
|
+
`technology` go to props; `location: "External"` or the tag External sets `external`.
|
|
16096
|
+
- Ids (R3): an element's `properties["structurizr.dsl.identifier"]` when the DSL wrote one (and it is not a UUID the DSL
|
|
16097
|
+
made up for an unnamed element), else its name slugged; an instance's `<container>-<instanceId>`; a relationship's
|
|
16098
|
+
identifier, else `<from>-<to>`; each the next free of base, base-2, base-3 ..., identifiers first. Never Structurizr's
|
|
16099
|
+
numeric ids, which change when the workspace is regenerated, so two exports of one DSL file read to one model.
|
|
16100
|
+
- Shapes: as Structurizr finds an element's style, its tags in order, each tag's styles in the order written, a later
|
|
16101
|
+
shape winning over an earlier (views.configuration.styles.elements); the map's shape it names goes to `props.shape`
|
|
16102
|
+
when it is not the type's own (a person's person, the rest's box). A shape the map lacks is a box, approximated.
|
|
16103
|
+
- Views: each kind as its c4 view, its key slugged as id, its `subject` the software system or container it is scoped
|
|
16104
|
+
to, `nodeIds` its elements; a container, component or dynamic view lists its subject, as Structurizr draws it as a
|
|
16105
|
+
boundary;
|
|
16106
|
+
a dynamic view's steps, ordered by `order`, are its `edges`, each relationship once (S13); a deployment view's
|
|
16107
|
+
environment is in its title; a view's automatic layout gives its direction.
|
|
16108
|
+
- Skipped, each reported: implied relationships (`linkedRelationshipId`: the C4 type works them out, §5.2), software
|
|
16109
|
+
system instances, infrastructure nodes and custom elements and their relationships, groups, filtered, custom and
|
|
16110
|
+
image views, perspectives, element properties but the identifier, urls, documentation and decisions, themes,
|
|
16111
|
+
animations, element positions, style fields but an element's shape, relationship styles, a deployment node's shape,
|
|
16112
|
+
an instance's own tags; an asynchronous relationship is drawn as any other (approximated). A static view's own list
|
|
16113
|
+
of relationships is not read: the type draws a view's lines from the model. */
|
|
16114
|
+
const SZ_LIMIT = 1 << 20; // characters of JSON text; a parsed object is held to counts instead
|
|
16115
|
+
const SZ_MAX = 10000; // the most elements, and the most relationships, read (R2)
|
|
16116
|
+
const SZ_DEEP = 64; // deployment nodes nested deeper are read flat from there (R2)
|
|
16117
|
+
const SZ_WORK = 500000; // the most list entries walked in all, a shared list counted each time it is reached (R2)
|
|
16118
|
+
const SZ_CHARS = 16 * SZ_LIMIT; // the most characters of text read in all, counted the same way (R2)
|
|
16119
|
+
const SZ_SHAPE = new Map(Object.entries({ person: 'person', robot: 'robot', webbrowser: 'browser', window: 'window', terminal: 'terminal', shell: 'terminal',
|
|
16120
|
+
mobiledevicelandscape: 'mobile', mobiledeviceportrait: 'mobile', cylinder: 'cylinder', pipe: 'pipe', folder: 'folder', hexagon: 'hexagon', box: 'box', roundedbox: 'box' }));
|
|
16121
|
+
// [the workspace's list, the view's diagram, its field naming the subject, the word in a title made for it]
|
|
16122
|
+
const SZ_VIEWS = [['systemLandscapeViews', 'c4.landscape', null, 'system landscape'], ['systemContextViews', 'c4.context', 'softwareSystemId', 'system context'],
|
|
16123
|
+
['containerViews', 'c4.container', 'softwareSystemId', 'containers'], ['componentViews', 'c4.component', 'containerId', 'components'],
|
|
16124
|
+
['dynamicViews', 'c4.dynamic', 'elementId', 'dynamic'], ['deploymentViews', 'c4.deployment', 'softwareSystemId', 'deployment']];
|
|
16125
|
+
const SZ_SUBJECT = { 'c4.context': ['c4.SoftwareSystem'], 'c4.container': ['c4.SoftwareSystem'], 'c4.component': ['c4.Container'], 'c4.dynamic': ['c4.SoftwareSystem', 'c4.Container'], 'c4.deployment': ['c4.SoftwareSystem'] };
|
|
16126
|
+
const SZ_ID = 'structurizr.dsl.identifier';
|
|
16127
|
+
const szOrder = new Intl.Collator('en', { numeric: true }).compare; // a dynamic view's steps: 2 before 10
|
|
16128
|
+
const SZ_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
16129
|
+
const szOwn = (o, k) => (o !== null && typeof o === 'object' && Object.hasOwn(o, k) ? o[k] : undefined);
|
|
16130
|
+
const szSlug = s => s.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}_.]+/gu, '-').replace(/^[-.]+|[-.]+$/g, '');
|
|
16131
|
+
const szDocumented = x => ['sections', 'decisions'].some(k => { const v = szOwn(szOwn(x, 'documentation'), k); return Array.isArray(v) && v.length > 0; });
|
|
16132
|
+
const szKey = x => (typeof x === 'string' || typeof x === 'number' ? String(x) : undefined); // Structurizr's ids are text; a number is read as its text
|
|
16133
|
+
|
|
16134
|
+
// the first of base, base-2, base-3 ... not in used, resuming where the last ask for base stopped (R2)
|
|
16135
|
+
function szFree(used, next, base) {
|
|
16136
|
+
let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
|
|
16137
|
+
while (used.has(id)) id = `${base}-${++k}`;
|
|
16138
|
+
next.set(base, k); used.add(id);
|
|
16139
|
+
return id;
|
|
16140
|
+
}
|
|
16141
|
+
|
|
16142
|
+
function readStructurizr(workspace) {
|
|
16143
|
+
const report = [], recs = [], rels = [], views = [];
|
|
16144
|
+
try { return szRead(workspace, report, recs, rels, views); } catch { // a getter that throws, a revoked proxy: what a host built
|
|
16145
|
+
report.length = recs.length = rels.length = views.length = 0;
|
|
16146
|
+
report.push({ path: '', status: 'skipped', message: 'the input threw when it was read: nothing read' });
|
|
16147
|
+
return { model: { schemaVersion: 2, root: 'landscape', nodes: [], edges: [], views: [{ id: 'landscape', diagram: 'c4.landscape', nodeIds: [] }] }, report };
|
|
16148
|
+
}
|
|
16149
|
+
}
|
|
16150
|
+
|
|
16151
|
+
function szRead(workspace, report, recs, rels, views) {
|
|
16152
|
+
const say = (path, status, message) => report.push({ path, status, message });
|
|
16153
|
+
const pending = [], bySid = new Map(), relBySid = new Map(), implied = new Map(), groups = new Set();
|
|
16154
|
+
let title;
|
|
16155
|
+
const done = () => {
|
|
16156
|
+
const nodes = recs.map(r => r.node), edges = rels.map(r => r.edge);
|
|
16157
|
+
if (!views.length) {
|
|
16158
|
+
if (nodes.length) say('views', 'approximated', 'the workspace has no view that is read: a landscape view shows its people and software systems');
|
|
16159
|
+
views.push({ id: 'landscape', diagram: 'c4.landscape', ...(title ? { title: `${title}: system landscape` } : {}),
|
|
16160
|
+
nodeIds: nodes.filter(n => n.type === 'c4.Person' || n.type === 'c4.SoftwareSystem').map(n => n.id) });
|
|
16161
|
+
}
|
|
16162
|
+
return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: views[0].id, nodes, edges, views }, report };
|
|
16163
|
+
};
|
|
16164
|
+
// what is walked, counted against SZ_WORK and SZ_CHARS; past either, nothing more is read (reported once)
|
|
16165
|
+
let work = SZ_WORK, chars = SZ_CHARS, spent = false;
|
|
16166
|
+
const over = path => { if (!spent) { spent = true; say(path, 'skipped', `over ${SZ_WORK} list entries or ${SZ_CHARS} characters read in all, a shared one counted each time: this and the rest are not read`); } };
|
|
16167
|
+
const str = (v, path) => { // text within the budget, else undefined
|
|
16168
|
+
if (typeof v !== 'string') return;
|
|
16169
|
+
if (v.length > chars) { chars = 0; over(path); return; }
|
|
16170
|
+
chars -= v.length;
|
|
16171
|
+
return v;
|
|
16172
|
+
};
|
|
16173
|
+
// a field of the wrong kind is reported and read as absent
|
|
16174
|
+
const list = (o, k, path) => {
|
|
16175
|
+
const v = szOwn(o, k), at = path ? `${path}.${k}` : k;
|
|
16176
|
+
if (Array.isArray(v)) {
|
|
16177
|
+
if (v.length <= work) { work -= v.length; return v; }
|
|
16178
|
+
const some = v.slice(0, work);
|
|
16179
|
+
work = 0; over(`${at}[${some.length}]`);
|
|
16180
|
+
return some;
|
|
16181
|
+
}
|
|
16182
|
+
if (v != null) say(at, 'skipped', `${k} is not a list: not read`);
|
|
16183
|
+
return [];
|
|
16184
|
+
};
|
|
16185
|
+
const text = (o, k, path) => {
|
|
16186
|
+
const v = szOwn(o, k);
|
|
16187
|
+
if (typeof v === 'string') return str(v, path ? `${path}.${k}` : k) || undefined;
|
|
16188
|
+
if (v != null) say(path ? `${path}.${k}` : k, 'skipped', `${k} is not text: not read`);
|
|
16189
|
+
};
|
|
13685
16190
|
|
|
16191
|
+
if (typeof workspace === 'string') {
|
|
16192
|
+
if (workspace.length > SZ_LIMIT) { say('', 'skipped', `the input is ${workspace.length} characters, over the limit of ${SZ_LIMIT}: nothing read`); return done(); }
|
|
16193
|
+
try { workspace = JSON.parse(workspace); } catch (e) { say('', 'skipped', `the input is not JSON (${e.message}): nothing read`); return done(); }
|
|
16194
|
+
}
|
|
16195
|
+
const model = szOwn(workspace, 'model');
|
|
16196
|
+
if (Array.isArray(workspace) || model === null || typeof model !== 'object' || Array.isArray(model)) { say('', 'skipped', 'not a Structurizr workspace (no model): nothing read'); return done(); }
|
|
16197
|
+
title = text(workspace, 'name', '');
|
|
16198
|
+
|
|
16199
|
+
// ---- elements ----
|
|
16200
|
+
let fullE = false;
|
|
16201
|
+
const add = (path, node, base, ident) => {
|
|
16202
|
+
if (recs.length >= SZ_MAX) { if (!fullE) { fullE = true; say(path, 'skipped', `over ${SZ_MAX} elements: this and the rest are not read`); } return null; }
|
|
16203
|
+
const rec = { node, base, ident };
|
|
16204
|
+
recs.push(rec);
|
|
16205
|
+
return rec;
|
|
16206
|
+
};
|
|
16207
|
+
const unread = (x, path, what) => { // what an element holds that the model has no place for
|
|
16208
|
+
const props = szOwn(x, 'properties'), other = props && typeof props === 'object' ? Object.keys(props).filter(k => k !== SZ_ID) : [];
|
|
16209
|
+
if (other.length) say(`${path}.properties`, 'skipped', `${what}: properties ${other.join(', ')} are not read`);
|
|
16210
|
+
if (list(x, 'perspectives', path).length) say(`${path}.perspectives`, 'skipped', `${what}: perspectives are not read`);
|
|
16211
|
+
const g = szOwn(x, 'group');
|
|
16212
|
+
if (typeof g === 'string' && !groups.has(g)) { groups.add(g); say(`${path}.group`, 'skipped', `group ${g}: groups are not read`); }
|
|
16213
|
+
if (szDocumented(x)) say(`${path}.documentation`, 'skipped', `${what}: documentation and decisions are not read`);
|
|
16214
|
+
if (str(szOwn(x, 'url'), `${path}.url`)) say(`${path}.url`, 'skipped', `${what}: its url is not read`);
|
|
16215
|
+
};
|
|
16216
|
+
const ident = x => { const v = str(szOwn(szOwn(x, 'properties'), SZ_ID), 'properties'); return v && !SZ_UUID.test(v) ? v : undefined; };
|
|
16217
|
+
const sid = (x, rec, path) => { // Structurizr's id, by which relationships and views name the element
|
|
16218
|
+
const k = szKey(szOwn(x, 'id'));
|
|
16219
|
+
if (k === undefined) return;
|
|
16220
|
+
if (bySid.has(k)) say(`${path}.id`, 'approximated', `id ${k} is used twice: relationships and views find the first`);
|
|
16221
|
+
else bySid.set(k, rec);
|
|
16222
|
+
};
|
|
16223
|
+
const relsOf = (x, path, from) => list(x, 'relationships', path).forEach((r, k) => pending.push({ x: r, path: `${path}.relationships[${k}]`, from }));
|
|
16224
|
+
const element = (x, path, type, parent) => {
|
|
16225
|
+
if (x === null || typeof x !== 'object' || Array.isArray(x)) { say(path, 'skipped', 'not an element: not read'); return null; }
|
|
16226
|
+
const name = text(x, 'name', path);
|
|
16227
|
+
if (name === undefined) { say(path, 'skipped', 'an element with no name: it and what it holds are not read'); return null; }
|
|
16228
|
+
const tags = (text(x, 'tags', path) || '').split(',').map(t => t.trim()).filter(Boolean), props = {};
|
|
16229
|
+
const description = text(x, 'description', path), technology = text(x, 'technology', path);
|
|
16230
|
+
if (description) props.description = description;
|
|
16231
|
+
if (technology) props.technology = technology;
|
|
16232
|
+
if (szOwn(x, 'location') === 'External' || tags.includes('External')) props.external = true;
|
|
16233
|
+
const rec = add(path, { type, label: name, props }, name, ident(x));
|
|
16234
|
+
if (!rec) return null;
|
|
16235
|
+
rec.parent = parent; rec.tags = tags; rec.path = path;
|
|
16236
|
+
sid(x, rec, path); unread(x, path, name); relsOf(x, path, rec);
|
|
16237
|
+
return rec;
|
|
16238
|
+
};
|
|
16239
|
+
|
|
16240
|
+
list(model, 'people', 'model').forEach((x, k) => element(x, `model.people[${k}]`, 'c4.Person'));
|
|
16241
|
+
list(model, 'softwareSystems', 'model').forEach((x, k) => {
|
|
16242
|
+
const p = `model.softwareSystems[${k}]`, s = element(x, p, 'c4.SoftwareSystem');
|
|
16243
|
+
if (s) list(x, 'containers', p).forEach((y, j) => {
|
|
16244
|
+
const q = `${p}.containers[${j}]`, c = element(y, q, 'c4.Container', s);
|
|
16245
|
+
if (c) list(y, 'components', q).forEach((z, i) => element(z, `${q}.components[${i}]`, 'c4.Component', c));
|
|
16246
|
+
});
|
|
16247
|
+
});
|
|
16248
|
+
// deployment nodes, by a stack (never recursion): `holder` owns a node; past SZ_DEEP a node's children take its holder
|
|
16249
|
+
const stack = list(model, 'deploymentNodes', 'model').map((x, k) => ({ x, path: `model.deploymentNodes[${k}]`, holder: null, depth: 1 })).reverse();
|
|
16250
|
+
let flat = false;
|
|
16251
|
+
while (stack.length && !fullE) {
|
|
16252
|
+
const { x, path, holder, depth } = stack.pop(), d = element(x, path, 'c4.DeploymentNode', holder);
|
|
16253
|
+
if (!d) continue;
|
|
16254
|
+
const deep = depth >= SZ_DEEP, kids = list(x, 'children', path);
|
|
16255
|
+
if (deep && !flat && kids.length) { flat = true; say(path, 'approximated', `deployment node ${d.node.label} is nested ${SZ_DEEP} deep: the nodes it holds are read flat from there`); }
|
|
16256
|
+
const instances = szKey(szOwn(x, 'instances'));
|
|
16257
|
+
if (instances !== undefined && instances !== '1') say(`${path}.instances`, 'approximated', `deployment node ${d.node.label} stands for ${instances} instances: it is drawn once`);
|
|
16258
|
+
for (const [k, what] of [['infrastructureNodes', 'an infrastructure node'], ['softwareSystemInstances', 'a software system instance']])
|
|
16259
|
+
list(x, k, path).forEach((y, j) => say(`${path}.${k}[${j}]`, 'skipped', `${what} ${typeof szOwn(y, 'name') === 'string' ? szOwn(y, 'name') + ' ' : ''}is not read, nor its relationships`));
|
|
16260
|
+
list(x, 'containerInstances', path).forEach((y, j) => {
|
|
16261
|
+
const q = `${path}.containerInstances[${j}]`, c = bySid.get(szKey(szOwn(y, 'containerId')));
|
|
16262
|
+
if (y === null || typeof y !== 'object' || !c || c.node.type !== 'c4.Container') { say(q, 'skipped', 'a container instance whose container is not read: not read'); return; }
|
|
16263
|
+
const n = szKey(szOwn(y, 'instanceId')) || '1', i = add(q, { type: 'c4.ContainerInstance', label: c.node.label, props: {} }, null, ident(y));
|
|
16264
|
+
if (!i) return;
|
|
16265
|
+
i.parent = d; i.container = c; i.instance = n;
|
|
16266
|
+
if (str(szOwn(y, 'tags'), q)?.split(',').some(t => t.trim() && t.trim() !== 'Container Instance')) say(`${q}.tags`, 'skipped', `instance ${n} of ${c.node.label}: its own tags are not read (its container's are)`);
|
|
16267
|
+
sid(y, i, q); unread(y, q, `instance ${n} of ${c.node.label}`); relsOf(y, q, i);
|
|
16268
|
+
});
|
|
16269
|
+
for (let k = kids.length - 1; k >= 0; k--) stack.push({ x: kids[k], path: `${path}.children[${k}]`, holder: deep ? holder : d, depth: deep ? depth : depth + 1 });
|
|
16270
|
+
}
|
|
16271
|
+
for (const [k, what] of [['softwareSystemInstances', 'a software system instance'], ['infrastructureNodes', 'an infrastructure node'], ['customElements', 'a custom element']])
|
|
16272
|
+
if (list(model, k, 'model').length) say(`model.${k}`, 'skipped', `${what} is not read`);
|
|
16273
|
+
|
|
16274
|
+
// ---- relationships ----
|
|
16275
|
+
let fullR = false, impliedCount = 0;
|
|
16276
|
+
for (const { x, path, from } of pending) {
|
|
16277
|
+
if (x === null || typeof x !== 'object' || Array.isArray(x)) { say(path, 'skipped', 'not a relationship: not read'); continue; }
|
|
16278
|
+
// a static element's linked relationship is implied; an instance's is replicated from its container's, and read (§7.5.3)
|
|
16279
|
+
const k = szKey(szOwn(x, 'id')), linked = szKey(szOwn(x, 'linkedRelationshipId'));
|
|
16280
|
+
if (linked !== undefined && from.node.type !== 'c4.ContainerInstance' && from.node.type !== 'c4.DeploymentNode') { if (k !== undefined) implied.set(k, linked); impliedCount++; continue; }
|
|
16281
|
+
if (rels.length === SZ_MAX) { if (!fullR) { fullR = true; say(path, 'skipped', `over ${SZ_MAX} relationships: this and the rest are not read`); } continue; }
|
|
16282
|
+
const to = bySid.get(szKey(szOwn(x, 'destinationId')));
|
|
16283
|
+
if (!to) { say(path, 'skipped', `a relationship from ${from.node.label} whose destination is not read: not read`); continue; }
|
|
16284
|
+
const description = text(x, 'description', path), technology = text(x, 'technology', path);
|
|
16285
|
+
const edge = { type: 'c4.Relationship', from, to, ...(description ? { label: description } : {}), ...(technology ? { props: { technology } } : {}) };
|
|
16286
|
+
const r = { edge, ident: ident(x) };
|
|
16287
|
+
if (k !== undefined) { if (relBySid.has(k)) say(`${path}.id`, 'approximated', `relationship id ${k} is used twice: views find the first`); else relBySid.set(k, r); }
|
|
16288
|
+
rels.push(r);
|
|
16289
|
+
unread(x, path, `relationship ${from.node.label} → ${to.node.label}`);
|
|
16290
|
+
if (szOwn(x, 'interactionStyle') === 'Asynchronous') say(`${path}.interactionStyle`, 'approximated', `relationship ${from.node.label} → ${to.node.label} is asynchronous: it is drawn as any other`);
|
|
16291
|
+
}
|
|
16292
|
+
if (impliedCount) say('model', 'skipped', `${impliedCount} implied relationship${impliedCount > 1 ? 's are' : ' is'} not read: the C4 type works them out when it draws`);
|
|
16293
|
+
|
|
16294
|
+
// ---- ids (R3): identifiers first, so a name never takes one; then the rest in order ----
|
|
16295
|
+
const used = new Set(), next = new Map(), plain = /^[A-Za-z0-9_.-]+$/;
|
|
16296
|
+
const fallback = { 'c4.Person': 'person', 'c4.SoftwareSystem': 'system', 'c4.Container': 'container', 'c4.Component': 'component', 'c4.DeploymentNode': 'node' };
|
|
16297
|
+
const baseOf = r => (r.node.type === 'c4.ContainerInstance' ? `${r.container.node.id}-${szSlug(r.instance) || '1'}` : szSlug(r.base) || fallback[r.node.type]);
|
|
16298
|
+
for (const r of recs) if (r.ident && plain.test(r.ident) && !used.has(r.ident)) { r.node.id = r.ident; used.add(r.ident); }
|
|
16299
|
+
for (const r of rels) if (r.ident && plain.test(r.ident) && !used.has(r.ident)) { r.edge.id = r.ident; used.add(r.ident); }
|
|
16300
|
+
for (const r of recs) if (r.node.id === undefined) r.node.id = szFree(used, next, baseOf(r));
|
|
16301
|
+
for (const r of rels) if (r.edge.id === undefined) r.edge.id = szFree(used, next, `${r.edge.from.node.id}-${r.edge.to.node.id}`);
|
|
16302
|
+
// ends, owners and references by id, now that every element has one; a shape from the styles
|
|
16303
|
+
const styles = new Map(), vs = szOwn(workspace, 'views'), conf = szOwn(vs, 'configuration'); // a tag's shape: its last style's, mapped once
|
|
16304
|
+
const looks = new Set();
|
|
16305
|
+
list(szOwn(conf, 'styles'), 'elements', 'views.configuration.styles').forEach(s => {
|
|
16306
|
+
const tag = str(szOwn(s, 'tag'), 'views.configuration.styles.elements'), shape = str(szOwn(s, 'shape'), 'views.configuration.styles.elements');
|
|
16307
|
+
if (tag !== undefined && shape !== undefined) styles.set(tag, { shape, mapped: SZ_SHAPE.get(shape.toLowerCase()) });
|
|
16308
|
+
if (s !== null && typeof s === 'object') for (const k of Object.keys(s)) if (k !== 'tag' && k !== 'shape') looks.add(k);
|
|
16309
|
+
});
|
|
16310
|
+
if (looks.size) say('views.configuration.styles.elements', 'skipped', `element styles: only shape is read, not ${[...looks].slice(0, 20).join(', ')}`);
|
|
16311
|
+
if (list(szOwn(conf, 'styles'), 'relationships', 'views.configuration.styles').length) say('views.configuration.styles.relationships', 'skipped', 'relationship styles are not read');
|
|
16312
|
+
if (list(conf, 'themes', 'views.configuration').length) say('views.configuration.themes', 'skipped', 'themes are not read: shapes come from the workspace\'s own styles');
|
|
16313
|
+
for (const r of recs) {
|
|
16314
|
+
const n = r.node;
|
|
16315
|
+
if (r.parent) n.parent = r.parent.node.id;
|
|
16316
|
+
if (r.container) n.props.container = r.container.node.id;
|
|
16317
|
+
if (r.tags) {
|
|
16318
|
+
let style;
|
|
16319
|
+
for (const t of r.tags) style = styles.get(t) ?? style;
|
|
16320
|
+
if (style && n.type === 'c4.DeploymentNode') { say(r.path, 'skipped', `${n.label}: a deployment node's shape ${style.shape} is not read`); style = undefined; }
|
|
16321
|
+
const own = n.type === 'c4.Person' ? 'person' : 'box', { shape, mapped } = style || {};
|
|
16322
|
+
if (shape && !mapped) say(r.path, 'approximated', `${n.label}: shape ${shape} is drawn as a box`);
|
|
16323
|
+
if (shape && (mapped || 'box') !== own) n.props.shape = mapped || 'box';
|
|
16324
|
+
}
|
|
16325
|
+
if (!Object.keys(n.props).length) delete n.props;
|
|
16326
|
+
}
|
|
16327
|
+
for (const r of rels) { r.edge.from = r.edge.from.node.id; r.edge.to = r.edge.to.node.id; }
|
|
16328
|
+
|
|
16329
|
+
// ---- views ----
|
|
16330
|
+
const vused = new Set(), vnext = new Map();
|
|
16331
|
+
for (const [key, diagram, field, word] of SZ_VIEWS) list(vs, key, 'views').forEach((x, k) => {
|
|
16332
|
+
const path = `views.${key}[${k}]`;
|
|
16333
|
+
if (x === null || typeof x !== 'object' || Array.isArray(x)) { say(path, 'skipped', 'not a view: not read'); return; }
|
|
16334
|
+
const vkey = text(x, 'key', path), id = szFree(vused, vnext, (vkey && szSlug(vkey)) || szSlug(word));
|
|
16335
|
+
const v = { id, diagram };
|
|
16336
|
+
let subject = field && bySid.get(szKey(szOwn(x, field)));
|
|
16337
|
+
if (subject && !SZ_SUBJECT[diagram].includes(subject.node.type)) { say(`${path}.${field}`, 'skipped', `view ${id}: ${subject.node.label} is not what a ${word} view is scoped to`); subject = null; }
|
|
16338
|
+
else if (!subject && field && szOwn(x, field) != null) say(`${path}.${field}`, 'skipped', `view ${id}: its scope is not read`);
|
|
16339
|
+
const env = text(x, 'environment', path), named = subject ? subject.node.label : title;
|
|
16340
|
+
v.title = text(x, 'title', path) || `${named ? named + ': ' : ''}${word}${env ? ', ' + env : ''}`;
|
|
16341
|
+
if (subject) v.subject = subject.node.id;
|
|
16342
|
+
const dir = szOwn(szOwn(x, 'automaticLayout'), 'rankDirection');
|
|
16343
|
+
if (dir != null && (typeof dir !== 'string' || !/^(TopBottom|BottomTop|LeftRight|RightLeft)$/.test(dir))) say(`${path}.automaticLayout.rankDirection`, 'skipped', `view ${id}: rank direction ${szKey(dir) ?? ''} is not one Structurizr has: not read`);
|
|
16344
|
+
else if (dir != null) {
|
|
16345
|
+
v.direction = /^(TopBottom|BottomTop)$/.test(dir) ? 'down' : 'right';
|
|
16346
|
+
if (dir === 'BottomTop' || dir === 'RightLeft') say(`${path}.automaticLayout`, 'approximated', `view ${id}: ${dir} is drawn ${v.direction === 'down' ? 'top to bottom' : 'left to right'}`);
|
|
16347
|
+
}
|
|
16348
|
+
const shown = new Set(subject && diagram !== 'c4.context' && diagram !== 'c4.deployment' ? [subject.node.id] : []);
|
|
16349
|
+
let lost = 0, placed = 0;
|
|
16350
|
+
for (const e of list(x, 'elements', path)) {
|
|
16351
|
+
const r = bySid.get(szKey(szOwn(e, 'id')));
|
|
16352
|
+
if (r) shown.add(r.node.id); else lost++;
|
|
16353
|
+
if (szOwn(e, 'x') != null || szOwn(e, 'y') != null) placed++;
|
|
16354
|
+
}
|
|
16355
|
+
if (lost) say(`${path}.elements`, 'skipped', `view ${id}: ${lost} element${lost > 1 ? 's' : ''} not read ${lost > 1 ? 'are' : 'is'} not shown`);
|
|
16356
|
+
if (placed) say(`${path}.elements`, 'skipped', `view ${id}: element positions are not read; the view is laid out`);
|
|
16357
|
+
v.nodeIds = [...shown];
|
|
16358
|
+
if (diagram === 'c4.dynamic') {
|
|
16359
|
+
const steps = list(x, 'relationships', path).map((s, j) => ({ s, j, order: szKey(szOwn(s, 'order')) ?? '' }));
|
|
16360
|
+
steps.sort((a, b) => szOrder(a.order, b.order) || a.j - b.j);
|
|
16361
|
+
const seen = new Set();
|
|
16362
|
+
v.edges = [];
|
|
16363
|
+
for (const { s, j, order } of steps) {
|
|
16364
|
+
const q = `${path}.relationships[${j}]`, k = szKey(szOwn(s, 'id'));
|
|
16365
|
+
let r = relBySid.get(k);
|
|
16366
|
+
if (!r && implied.has(k) && (r = relBySid.get(implied.get(k)))) say(q, 'approximated', `view ${id}: step ${order || j + 1} is an implied relationship: the relationship it is implied by, ${r.edge.id}, is the step`);
|
|
16367
|
+
if (!r) { say(q, 'skipped', `view ${id}: a step whose relationship is not read`); continue; }
|
|
16368
|
+
if (seen.has(r)) { say(q, 'approximated', `view ${id}: ${r.edge.id} is a step twice: the first is kept (each relationship is one step)`); continue; }
|
|
16369
|
+
seen.add(r); v.edges.push(r.edge.id);
|
|
16370
|
+
const d = szOwn(s, 'description');
|
|
16371
|
+
if (typeof d === 'string' && d && d !== (r.edge.label || '')) say(q, 'approximated', `view ${id}: step ${r.edge.id} says "${d}": the relationship's own description is drawn`);
|
|
16372
|
+
if (szOwn(s, 'response') === true) say(q, 'approximated', `view ${id}: step ${r.edge.id} is a response: it is drawn the relationship's way`);
|
|
16373
|
+
}
|
|
16374
|
+
}
|
|
16375
|
+
if (list(x, 'animations', path).length) say(`${path}.animations`, 'skipped', `view ${id}: animations are not read`);
|
|
16376
|
+
views.push(v);
|
|
16377
|
+
});
|
|
16378
|
+
for (const [key, word] of [['filteredViews', 'a filtered view'], ['customViews', 'a custom view'], ['imageViews', 'an image view']])
|
|
16379
|
+
list(vs, key, 'views').forEach((x, k) => say(`views.${key}[${k}]`, 'skipped', `${word}${typeof szOwn(x, 'key') === 'string' ? ' ' + szOwn(x, 'key') : ''} is not read`));
|
|
16380
|
+
if (szDocumented(workspace)) say('documentation', 'skipped', 'documentation and decisions are not read');
|
|
16381
|
+
return done();
|
|
16382
|
+
}
|
|
16383
|
+
/* ---------- DBML: an adapter over @dbml/core's parsed Database (notes/milestone-2.md §8.4, M2.11; risks.md R1 to R3) ---
|
|
16384
|
+
It imports nothing: a host parses DBML with @dbml/core, which it installs (`Parser.parse(text, 'dbmlv2')`), and hands
|
|
16385
|
+
this the Database or what `database.export()` gives (plain JSON, which drops the project, its note and database type,
|
|
16386
|
+
diagram views and a table's `checks {}`). Tested against a Database captured from @dbml/core 11.0.0
|
|
16387
|
+
(tests/import/dbml.json). Internal: the CLI (`umlimator.py import x.dbml`) runs it through Node; its helpers are its
|
|
16388
|
+
own (db...), since the readers share one scope in lib/umlimator-all.js.
|
|
16389
|
+
|
|
16390
|
+
readDbml(database) -> { model, report }: a version-2 model with one `er` view of every table, in model.md row 20's
|
|
16391
|
+
vocabulary, and a report of what was not read as written, each item { line, status, message }, status `skipped` or
|
|
16392
|
+
`approximated` (R1). A parsed object keeps no line numbers, so `line` is the item's path in the Database
|
|
16393
|
+
(`schemas[0].tables[2].fields[1]`), as the CLI names a JSON source's items. Ids come from the source's names (R3): a
|
|
16394
|
+
table's name, a column `<table>.<column>`, a relationship `<from>-<to>`, each the next free of base, base-2, ...
|
|
16395
|
+
|
|
16396
|
+
- a table is an `er.Entity`; every schema's tables are read, a table of a schema other than `public` under its own
|
|
16397
|
+
name, reported;
|
|
16398
|
+
- a field is an `er.Column`: `dataType` its type's name, `primaryKey` from `pk` or the table's `[pk]` index (a
|
|
16399
|
+
composite key), `unique`, and `nullable` when it is neither `not null` nor a key, as SQL has it; a partial's fields
|
|
16400
|
+
are the table's own (@dbml/core injects them), reported;
|
|
16401
|
+
- a ref is an `er.Relationship` from its `1` end (the referenced columns) to its `*` end (the referring ones), `1` to
|
|
16402
|
+
`0..*`. In a one-to-one (`-`, both ends `1`), `1` to `0..1`, the referring end is the first of a standalone ref
|
|
16403
|
+
(`Ref: b.a_id - a.id`) and the second of an inline one (`a_id int [ref: - a.id]`, which @dbml/core writes
|
|
16404
|
+
referenced end first and with no `name` key). The referring columns take `foreignKey` and `references`, pair by
|
|
16405
|
+
pair; it is identifying when they lie in the child's key, and its parent end is `0..1` when one of them is nullable
|
|
16406
|
+
(an optional parent). A many-to-many (`<>`) is one relationship `0..*` to `0..*` with no foreign key, approximated
|
|
16407
|
+
(DBML builds a junction table for it);
|
|
16408
|
+
- enums, notes, the project's note and database type, table groups, other indexes, defaults, increments, checks, a
|
|
16409
|
+
ref's name, note and actions, colours, records, sticky notes and diagram views are reported, not read.
|
|
16410
|
+
|
|
16411
|
+
Hostile input (§8, R2): at most DB_MAX tables, DB_MAX columns and DB_MAX refs are read, and DB_NAMES column names in
|
|
16412
|
+
ref ends and key indexes, the rest reported once each; the report lists DB_MAX items, then counts the rest in one; no
|
|
16413
|
+
nesting is followed (the walk is a fixed depth: schemas, tables, fields), so a deep object costs nothing; anything
|
|
16414
|
+
that is not an object, or a field of the wrong kind, is skipped and reported; names are looked up in Maps, so
|
|
16415
|
+
`__proto__` and `constructor` are ordinary names. It never throws. */
|
|
16416
|
+
const DB_MAX = 10000; // tables, columns, refs and report items each (§8)
|
|
16417
|
+
const DB_NAMES = 10 * DB_MAX; // column names in ref ends and [pk] indexes, all together
|
|
16418
|
+
const DB_VIEW = 'schema'; // the view's id (views keep their own ids, model.md rule 5)
|
|
16419
|
+
const DB_SCHEMA = 'public'; // @dbml/core's default schema
|
|
16420
|
+
const dbObj = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
16421
|
+
const dbName = x => (typeof x === 'string' && x ? x : null);
|
|
16422
|
+
|
|
16423
|
+
function readDbml(database) {
|
|
16424
|
+
const report = [], nodes = [], edges = [], used = new Set(), next = new Map();
|
|
16425
|
+
let title = null, more = 0;
|
|
16426
|
+
const say = (line, status, message) => {
|
|
16427
|
+
if (report.length < DB_MAX) report.push({ line, status, message }); else more++;
|
|
16428
|
+
};
|
|
16429
|
+
const model = () => ({ schemaVersion: 2, ...(title ? { title } : {}), root: DB_VIEW, nodes, edges,
|
|
16430
|
+
views: [{ id: DB_VIEW, diagram: 'er', nodeIds: nodes.map(n => n.id) }] });
|
|
16431
|
+
const free = base => { // the first of base, base-2, ... not taken; an id never holds "->" (model.md rule 5)
|
|
16432
|
+
base = base.replace(/->+/g, '-');
|
|
16433
|
+
let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
|
|
16434
|
+
while (used.has(id)) id = `${base}-${++k}`;
|
|
16435
|
+
next.set(base, k); used.add(id);
|
|
16436
|
+
return id;
|
|
16437
|
+
};
|
|
16438
|
+
const named = () => { // columns after every table (names first, R3)
|
|
16439
|
+
for (const n of nodes) for (const c of n.members) if (!c.id) c.id = free(`${n.id}.${c.label}`);
|
|
16440
|
+
};
|
|
16441
|
+
try {
|
|
16442
|
+
if (!dbObj(database) || !Array.isArray(database.schemas)) {
|
|
16443
|
+
say('', 'skipped', 'not a parsed DBML Database (parse the text with @dbml/core first): nothing read');
|
|
16444
|
+
return { model: model(), report };
|
|
16445
|
+
}
|
|
16446
|
+
title = dbName(database.name); // a project's name: a Database has it, its export() does not
|
|
16447
|
+
// `schema.table` and an alias -> { node, cols: Map name -> column }
|
|
16448
|
+
const tables = new Map(), aliases = new Map(), refs = [], notNull = new Set();
|
|
16449
|
+
const count = { tables: 0, columns: 0, refs: 0, names: 0 };
|
|
16450
|
+
const full = (what, at) => {
|
|
16451
|
+
const n = count[what]++;
|
|
16452
|
+
if (n === DB_MAX) say(at, 'skipped', `over ${DB_MAX} ${what}: the rest are not read`);
|
|
16453
|
+
return n >= DB_MAX;
|
|
16454
|
+
};
|
|
16455
|
+
const names = (n, at) => { // n more column names in a ref or a key index: over DB_NAMES, it and the rest not read
|
|
16456
|
+
if (count.names + n <= DB_NAMES) { count.names += n; return false; }
|
|
16457
|
+
if (count.names <= DB_NAMES) {
|
|
16458
|
+
say(at, 'skipped', `over ${DB_NAMES} column names in refs and keys: the rest are not read`);
|
|
16459
|
+
}
|
|
16460
|
+
count.names = DB_NAMES + 1;
|
|
16461
|
+
return true;
|
|
16462
|
+
};
|
|
16463
|
+
const extra = (x, at, what, keys) => {
|
|
16464
|
+
for (const [k, word] of keys) {
|
|
16465
|
+
const v = x[k];
|
|
16466
|
+
if (v == null || v === false || (Array.isArray(v) && !v.length)) continue;
|
|
16467
|
+
say(at, 'skipped', `${what}: ${word} not read`);
|
|
16468
|
+
}
|
|
16469
|
+
};
|
|
16470
|
+
extra(database, '', 'the database', [['notes', 'its sticky notes are'], ['records', 'its records are'],
|
|
16471
|
+
['note', 'its note is'], ['databaseType', 'its database type is'], ['diagramViews', 'its diagram views are']]);
|
|
16472
|
+
database.schemas.forEach((s, si) => {
|
|
16473
|
+
const sat = `schemas[${si}]`;
|
|
16474
|
+
if (!dbObj(s)) return say(sat, 'skipped', 'a schema that is not an object');
|
|
16475
|
+
const sname = dbName(s.name) || DB_SCHEMA;
|
|
16476
|
+
for (const [k, word] of [['enums', 'enum'], ['tableGroups', 'table group']]) {
|
|
16477
|
+
if (s[k] == null) continue;
|
|
16478
|
+
if (!Array.isArray(s[k])) { say(`${sat}.${k}`, 'skipped', `${k} is not a list`); continue; }
|
|
16479
|
+
s[k].forEach((x, i) =>
|
|
16480
|
+
say(`${sat}.${k}[${i}]`, 'skipped', `${word} ${dbName(x && x.name) || '(unnamed)'}: not read`));
|
|
16481
|
+
}
|
|
16482
|
+
if (Array.isArray(s.deps) && s.deps.length) {
|
|
16483
|
+
say(`${sat}.deps`, 'skipped', `schema ${sname}: its dependencies are not read`);
|
|
16484
|
+
}
|
|
16485
|
+
if (s.refs != null && !Array.isArray(s.refs)) say(`${sat}.refs`, 'skipped', 'refs is not a list');
|
|
16486
|
+
else (s.refs || []).forEach((r, i) => refs.push([r, `${sat}.refs[${i}]`]));
|
|
16487
|
+
if (s.tables == null) return;
|
|
16488
|
+
if (!Array.isArray(s.tables)) return say(`${sat}.tables`, 'skipped', 'tables is not a list');
|
|
16489
|
+
s.tables.forEach((t, ti) => {
|
|
16490
|
+
const at = `${sat}.tables[${ti}]`, name = dbObj(t) && dbName(t.name);
|
|
16491
|
+
if (!name) return say(at, 'skipped', 'a table with no name');
|
|
16492
|
+
if (full('tables', at)) return;
|
|
16493
|
+
const key = `${sname}.${name}`;
|
|
16494
|
+
if (tables.has(key)) return say(at, 'skipped', `table ${key} is named twice: the second is not read`);
|
|
16495
|
+
if (sname !== DB_SCHEMA) {
|
|
16496
|
+
say(at, 'approximated', `table ${key}: its schema is not kept, so it is read as ${name}`);
|
|
16497
|
+
}
|
|
16498
|
+
extra(t, at, `table ${name}`,
|
|
16499
|
+
[['note', 'its note is'], ['headerColor', 'its header colour is'], ['checks', 'its checks are']]);
|
|
16500
|
+
if (Array.isArray(t.partials) && t.partials.length) {
|
|
16501
|
+
say(at, 'approximated', `table ${name}: its partials' fields are read as its own`);
|
|
16502
|
+
}
|
|
16503
|
+
const node = { id: free(name), type: 'er.Entity', label: name, members: [] };
|
|
16504
|
+
const cols = new Map(), rec = { node, cols };
|
|
16505
|
+
tables.set(key, rec);
|
|
16506
|
+
if (dbName(t.alias) && !aliases.has(t.alias)) aliases.set(t.alias, rec);
|
|
16507
|
+
nodes.push(node);
|
|
16508
|
+
if (!Array.isArray(t.fields)) {
|
|
16509
|
+
if (t.fields != null) say(`${at}.fields`, 'skipped', `table ${name}: fields is not a list`);
|
|
16510
|
+
}
|
|
16511
|
+
else t.fields.forEach((f, fi) => {
|
|
16512
|
+
const fat = `${at}.fields[${fi}]`, fname = dbObj(f) && dbName(f.name);
|
|
16513
|
+
if (!fname) return say(fat, 'skipped', `table ${name}: a field with no name`);
|
|
16514
|
+
if (full('columns', fat)) return;
|
|
16515
|
+
if (cols.has(fname)) {
|
|
16516
|
+
return say(fat, 'skipped', `${name}.${fname} is named twice: the second is not read`);
|
|
16517
|
+
}
|
|
16518
|
+
const type = dbObj(f.type) ? dbName(f.type.type_name) : null, props = {};
|
|
16519
|
+
if (type) props.dataType = type;
|
|
16520
|
+
else say(fat, 'approximated', `${name}.${fname}: its type is not text, so it has none`);
|
|
16521
|
+
if (f.pk === true) props.primaryKey = true;
|
|
16522
|
+
if (f.unique === true) props.unique = true;
|
|
16523
|
+
extra(f, fat, `${name}.${fname}`, [['note', 'its note is'], ['dbdefault', 'its default is'],
|
|
16524
|
+
['increment', 'its increment is'], ['checks', 'its checks are'], ['checkIds', 'its checks are']]);
|
|
16525
|
+
const col = { id: '', type: 'er.Column', label: fname, props }; // its id by named()
|
|
16526
|
+
if (f.not_null === true) notNull.add(col);
|
|
16527
|
+
cols.set(fname, col); node.members.push(col);
|
|
16528
|
+
});
|
|
16529
|
+
if (t.indexes != null && !Array.isArray(t.indexes)) {
|
|
16530
|
+
say(`${at}.indexes`, 'skipped', `table ${name}: indexes is not a list`);
|
|
16531
|
+
}
|
|
16532
|
+
else (t.indexes || []).forEach((x, xi) => { // a [pk] index is the table's key; any other index is not read
|
|
16533
|
+
const xat = `${at}.indexes[${xi}]`, pk = dbObj(x) && x.pk === true;
|
|
16534
|
+
const list = pk && Array.isArray(x.columns) && !names(x.columns.length, xat) ? x.columns : null;
|
|
16535
|
+
const picked = list && list.map(c => (dbObj(c) && c.type === 'column' ? cols.get(c.value) : undefined));
|
|
16536
|
+
if (!picked || !picked.length || picked.includes(undefined)) {
|
|
16537
|
+
const what = pk ? 'a primary key index not of its columns' : 'an index';
|
|
16538
|
+
return say(xat, 'skipped', `table ${name}: ${what} is not read`);
|
|
16539
|
+
}
|
|
16540
|
+
picked.forEach(c => { c.props.primaryKey = true; });
|
|
16541
|
+
});
|
|
16542
|
+
for (const c of cols.values()) if (!notNull.has(c) && !c.props.primaryKey) c.props.nullable = true;
|
|
16543
|
+
});
|
|
16544
|
+
});
|
|
16545
|
+
named();
|
|
16546
|
+
const end = e => { // a ref's end: { rec, cols, one, name } or null
|
|
16547
|
+
if (!dbObj(e) || !dbName(e.tableName) || !Array.isArray(e.fieldNames) || !e.fieldNames.length) return null;
|
|
16548
|
+
if ((e.relation !== '1' && e.relation !== '*') || (e.schemaName != null && !dbName(e.schemaName))) return null;
|
|
16549
|
+
const rec = tables.get(`${dbName(e.schemaName) || DB_SCHEMA}.${e.tableName}`)
|
|
16550
|
+
|| (e.schemaName == null ? aliases.get(e.tableName) : undefined);
|
|
16551
|
+
const cols = rec && e.fieldNames.map(n => rec.cols.get(n));
|
|
16552
|
+
if (!cols || cols.includes(undefined)) return null;
|
|
16553
|
+
return { rec, cols, one: e.relation === '1', name: `${e.tableName}.(${e.fieldNames.join(', ')})` };
|
|
16554
|
+
};
|
|
16555
|
+
const width = e => (dbObj(e) && Array.isArray(e.fieldNames) ? e.fieldNames.length : 0);
|
|
16556
|
+
for (const [r, at] of refs) {
|
|
16557
|
+
if (full('refs', at)) continue;
|
|
16558
|
+
const eps = dbObj(r) && Array.isArray(r.endpoints) && r.endpoints.length === 2 ? r.endpoints : null;
|
|
16559
|
+
if (eps && names(width(eps[0]) + width(eps[1]), at)) continue;
|
|
16560
|
+
const ends = eps ? eps.map(end) : [null];
|
|
16561
|
+
if (ends.includes(null)) {
|
|
16562
|
+
say(at, 'skipped', 'a ref whose ends are not two columns of tables read: not read');
|
|
16563
|
+
continue;
|
|
16564
|
+
}
|
|
16565
|
+
if (r.inactive === true) {
|
|
16566
|
+
say(at, 'skipped', `ref ${ends[0].name} - ${ends[1].name}: inactive, so not read`);
|
|
16567
|
+
continue;
|
|
16568
|
+
}
|
|
16569
|
+
// a standalone ref writes its referring end first (`>`, `-`); an inline one, with no name key, its referenced end
|
|
16570
|
+
let [child, parent] = r.name === undefined ? [ends[1], ends[0]] : ends;
|
|
16571
|
+
if (child.one && !parent.one) [child, parent] = [parent, child];
|
|
16572
|
+
const what = `ref ${child.name} -> ${parent.name}`;
|
|
16573
|
+
extra(r, at, what, [['name', 'its name is'], ['note', 'its note is'], ['onDelete', 'its delete action is'],
|
|
16574
|
+
['onUpdate', 'its update action is'], ['color', 'its colour is']]);
|
|
16575
|
+
if (!child.one && !parent.one) { // many-to-many: no column refers; drawn first end to second
|
|
16576
|
+
say(at, 'approximated', `${what}: a many-to-many is one relationship with no foreign key`);
|
|
16577
|
+
edges.push({ id: free(`${child.rec.node.id}-${parent.rec.node.id}`), type: 'er.Relationship',
|
|
16578
|
+
from: child.rec.node.id, to: parent.rec.node.id,
|
|
16579
|
+
props: { identifying: false, ends: { from: { multiplicity: '0..*' }, to: { multiplicity: '0..*' } } } });
|
|
16580
|
+
continue;
|
|
16581
|
+
}
|
|
16582
|
+
if (child.cols.length !== parent.cols.length) {
|
|
16583
|
+
say(at, 'skipped', `${what}: its two ends name different numbers of columns: not read`);
|
|
16584
|
+
continue;
|
|
16585
|
+
}
|
|
16586
|
+
child.cols.forEach((c, i) => {
|
|
16587
|
+
const to = parent.cols[i].id;
|
|
16588
|
+
if (c.props.references && c.props.references !== to) {
|
|
16589
|
+
return say(at, 'approximated', `${what}: ${c.label} refers to ${c.props.references} already, which it keeps`);
|
|
16590
|
+
}
|
|
16591
|
+
c.props.foreignKey = true; c.props.references = to;
|
|
16592
|
+
});
|
|
16593
|
+
const optional = child.cols.some(c => c.props.nullable);
|
|
16594
|
+
edges.push({ id: free(`${parent.rec.node.id}-${child.rec.node.id}`), type: 'er.Relationship',
|
|
16595
|
+
from: parent.rec.node.id, to: child.rec.node.id,
|
|
16596
|
+
props: { identifying: child.cols.every(c => c.props.primaryKey === true),
|
|
16597
|
+
ends: { from: { multiplicity: optional ? '0..1' : '1' },
|
|
16598
|
+
to: { multiplicity: parent.one && child.one ? '0..1' : '0..*' } } } });
|
|
16599
|
+
}
|
|
16600
|
+
} catch (e) { // a host's object whose getter throws: what was read is kept
|
|
16601
|
+
let why = 'an error';
|
|
16602
|
+
try { if (e && typeof e.message === 'string') why = e.message; } catch { /* a getter that throws again */ }
|
|
16603
|
+
say('', 'skipped', `the Database could not be read to its end (${why}): the rest is not read`);
|
|
16604
|
+
}
|
|
16605
|
+
named();
|
|
16606
|
+
for (const n of nodes) if (!n.members.length) delete n.members;
|
|
16607
|
+
if (more) report.push({ line: '', status: 'skipped', message: `${more} more items like these: not listed` });
|
|
16608
|
+
return { model: model(), report };
|
|
16609
|
+
}
|
|
16610
|
+
|
|
16611
|
+
|
|
16612
|
+
|
|
16613
|
+
|
|
16614
|
+
|
|
16615
|
+
|
|
16616
|
+
|
|
16617
|
+
|
|
16618
|
+
/* ---------- BPMN XML: a reader and a writer through bpmn-moddle (notes/milestone-4b.md §12, 4b.4; risks.md R1 to R5) ---
|
|
16619
|
+
It imports nothing: the caller makes a `bpmn-moddle` instance (`BpmnModdle()` from bpmn-moddle 10.3.1, which it
|
|
16620
|
+
installs) and passes it in, as the markdown-it plugin takes the host's markdown-it. Internal: the CLI
|
|
16621
|
+
(`umlimator.py import x.bpmn`, `umlimator.py export`) imports this file through Node; its helpers are its own (bx...),
|
|
16622
|
+
since the readers share one scope in lib/umlimator-all.js. The vocabulary is model.md row 24's, as the note's §3.1
|
|
16623
|
+
maps it onto BPMN 2.0.2's Table 2.1, with the extras S1 and decision 21 name.
|
|
16624
|
+
|
|
16625
|
+
readBpmnXml(xml, moddle) -> promise of { model, report }: a version-2 model with one `bpmn` view per BPMNDiagram, its
|
|
16626
|
+
`nodeIds` the plane's shapes (a sub-process's own plane is a view with it as `subject`, which the sub-process
|
|
16627
|
+
`opensView`; an element with no shape goes to the first view; a file with no diagram interchange is one view), and a
|
|
16628
|
+
report of what was not read as written, each item { line, status, message }: `line` the element's id, status
|
|
16629
|
+
`skipped` or `weakened`. Ids are the file's, or its `umlimator:id` (R3). Read as written: participants (a lane of
|
|
16630
|
+
theirs a member, nested by `parent`), tasks (user, service), sub-processes, call activities, start, end, intermediate
|
|
16631
|
+
and boundary events with the definitions their kind may take, exclusive and parallel gateways, data object references
|
|
16632
|
+
(with their data object) and data store references, text annotations, groups (a category value names its group in
|
|
16633
|
+
`categoryValueRef`), documentation, sequence and message flows, associations and data associations. Weakened, kept
|
|
16634
|
+
under the element's `ext.bpmn`: a sequence flow's condition, a gateway's default, a send or receive task's kind, a
|
|
16635
|
+
timer's time. Anything else (an element, an event definition or a marker outside the subset, an extension) is
|
|
16636
|
+
reported `skipped` and kept under the model's `ext.bpmn.skipped` as moddle's JSON with its parent's id; a line to it
|
|
16637
|
+
is reported too. Positions and sizes are not kept (R5): the report says so once per file.
|
|
16638
|
+
|
|
16639
|
+
writeBpmnXml(model, viewId, geo, moddle) -> promise of { xml, report }: every `bpmn.*` element of the model in one
|
|
16640
|
+
definitions, a collaboration when it has a participant, a process per participant (its `processRef`, else
|
|
16641
|
+
<participant>-process) that holds anything and one for what is in no pool, and one BPMNDiagram per `bpmn` view. Its
|
|
16642
|
+
shapes and edges take their bounds and points from `geo` ({ nodes: [{ id, x, y, w, h }], edges: [{ id, pts }] }, the
|
|
16643
|
+
layout of `viewId`), any other on a plain grid. An id that is not an XML name is written escaped (`_` and its code
|
|
16644
|
+
points), the model's id in `umlimator:id`. Not written, and reported: what `ext.bpmn` holds, other diagrams' elements
|
|
16645
|
+
and views, a property or field outside row 24 or on a type it is not for, a data association against BPMN's direction
|
|
16646
|
+
(§4's data-ends), and whatever a reader would read back otherwise (note §12.5: a cycle of lanes, a lane as the parent
|
|
16647
|
+
of data or an artifact, a process id taken, a view's subject or parent, an opensView, a node in no view). The root's
|
|
16648
|
+
diagram is written first. The reader fills in the defaults: taskType none, definition none, cancelActivity true.
|
|
16649
|
+
|
|
16650
|
+
Hostile input (§12.2; a departure from R2, which asks for hand-written linear readers): text over BX_LIMIT characters
|
|
16651
|
+
is refused; moddle's parser and handlers and this file's walk run inside one `try`, so a throw (a RangeError at depth
|
|
16652
|
+
among them) is a report item, never a throw; the walk over moddle's tree is iterative, a skipped element's JSON is cut
|
|
16653
|
+
at BX_DEEP levels, lookups are by Set and Map (no scan per element), and either report lists BX_MAX items, then counts
|
|
16654
|
+
the rest in one. Neither function ever throws. A tab
|
|
16655
|
+
or carriage return in a name or id reads back as a space (XML's attribute rule). */
|
|
16656
|
+
const BX_LIMIT = 1 << 20; // characters of XML read (R2)
|
|
16657
|
+
const BX_MAX = 1000; // report items listed; the rest counted in one
|
|
16658
|
+
const BX_DEEP = 32; // levels of a skipped element kept under ext.bpmn
|
|
16659
|
+
const BX_NS = 'https://zintaen.github.io/umlimator/bpmn'; // the umlimator: attributes' namespace
|
|
16660
|
+
const BX_VIEW = 'process'; // the view of a file with no diagram interchange
|
|
16661
|
+
const BX_TASK = { 'bpmn:Task': 'none', 'bpmn:UserTask': 'user', 'bpmn:ServiceTask': 'service', 'bpmn:SendTask': 'send', 'bpmn:ReceiveTask': 'receive' };
|
|
16662
|
+
const BX_TASK_OUT = { none: 'bpmn:Task', user: 'bpmn:UserTask', service: 'bpmn:ServiceTask' };
|
|
16663
|
+
const BX_DEFS = { // the definitions each kind of event may take (§3.2)
|
|
16664
|
+
StartEvent: ['none', 'message', 'timer'], EndEvent: ['none', 'message', 'terminate'], IntermediateCatchEvent: ['message', 'timer'],
|
|
16665
|
+
IntermediateThrowEvent: ['none', 'message'], BoundaryEvent: ['message', 'timer'] };
|
|
16666
|
+
const BX_DEF = { 'bpmn:MessageEventDefinition': 'message', 'bpmn:TimerEventDefinition': 'timer', 'bpmn:TerminateEventDefinition': 'terminate' };
|
|
16667
|
+
const BX_DEF_OUT = { message: 'bpmn:MessageEventDefinition', timer: 'bpmn:TimerEventDefinition', terminate: 'bpmn:TerminateEventDefinition' };
|
|
16668
|
+
const BX_SAME = new Set(['Participant', 'SubProcess', 'CallActivity', 'ExclusiveGateway', 'ParallelGateway', 'DataStoreReference',
|
|
16669
|
+
'TextAnnotation', 'Group', ...Object.keys(BX_DEFS)]); // bpmn.X is bpmn:X
|
|
16670
|
+
const BX_ACTIVITY = new Set(['bpmn.Task', 'bpmn.SubProcess', 'bpmn.CallActivity']);
|
|
16671
|
+
const BX_PROPS = { 'bpmn.Participant': ['processRef'], 'bpmn.Task': ['taskType'], 'bpmn.CallActivity': ['calledElement'], 'bpmn.Association': ['direction'],
|
|
16672
|
+
'bpmn.BoundaryEvent': ['definition', 'attachedTo', 'cancelActivity'], 'bpmn.DataStoreReference': ['dataStoreRef'] }; // and documentation on any
|
|
16673
|
+
const bxProp = (t, k) => k === 'documentation' || (BX_PROPS[t] || (t.slice(5) in BX_DEFS ? ['definition'] : [])).includes(k)
|
|
16674
|
+
|| k === 'categoryValueRef' && !['bpmn.Group', 'bpmn.Lane'].includes(t) && !/(Flow|Association)$/.test(t); // a node's groups (row 24)
|
|
16675
|
+
const BX_LOOSE = new Set(['bpmn.DataObject', 'bpmn.DataStoreReference', 'bpmn.TextAnnotation', 'bpmn.Group']); // in a pool, never a lane
|
|
16676
|
+
const BX_FIELDS = new Set(['id', 'type', 'label', 'parent', 'props', 'members', 'opensView', 'ext', 'from', 'to']);
|
|
16677
|
+
const BX_SIZE = { 'bpmn.Participant': [600, 200], 'bpmn.Lane': [570, 100], 'bpmn.Group': [200, 120], 'bpmn.TextAnnotation': [120, 40],
|
|
16678
|
+
'bpmn.DataObject': [36, 50], 'bpmn.DataStoreReference': [50, 50], 'bpmn.ExclusiveGateway': [50, 50], 'bpmn.ParallelGateway': [50, 50] };
|
|
16679
|
+
const bxEvent = t => (t || '').endsWith('Event');
|
|
16680
|
+
const bxStr = x => (typeof x === 'string' && x ? x : null);
|
|
16681
|
+
const bxMsg = e => { try { return String(e && e.message || e).split('\n')[0]; } catch { return 'an error'; } };
|
|
16682
|
+
|
|
16683
|
+
// a moddle element as JSON: its own properties, a reference by its id, cut at BX_DEEP levels
|
|
16684
|
+
function bxJson(el, depth = 0) {
|
|
16685
|
+
if (!el || typeof el !== 'object') return el;
|
|
16686
|
+
if (Array.isArray(el)) return el.map(x => bxJson(x, depth));
|
|
16687
|
+
if (depth >= BX_DEEP) return { $type: el.$type, id: el.id, cut: true };
|
|
16688
|
+
const out = { $type: el.$type }, refs = new Set(((el.$descriptor && el.$descriptor.properties) || []).filter(p => p.isReference).map(p => p.name));
|
|
16689
|
+
for (const k of Object.keys(el)) {
|
|
16690
|
+
const v = el[k];
|
|
16691
|
+
if (k.startsWith('$') && k !== '$attrs' && k !== '$body') continue;
|
|
16692
|
+
if (v == null || k === '$attrs' && !Object.keys(v).length) continue;
|
|
16693
|
+
out[k] = refs.has(k) ? (Array.isArray(v) ? v.map(x => x && x.id) : v.id) : k === '$attrs' ? { ...v } : bxJson(v, depth + 1);
|
|
16694
|
+
}
|
|
16695
|
+
return out;
|
|
16696
|
+
}
|
|
16697
|
+
|
|
16698
|
+
async function readBpmnXml(xml, moddle) {
|
|
16699
|
+
const report = [], nodes = [], edges = [], views = [], skipped = [], byId = new Map();
|
|
16700
|
+
let title = null, more = 0;
|
|
16701
|
+
const say = (line, status, message) => { if (report.length < BX_MAX) report.push({ line, status, message }); else more++; };
|
|
16702
|
+
const result = () => {
|
|
16703
|
+
if (more) report.push({ line: '', status: 'skipped', message: `${more} more items like these: not listed` });
|
|
16704
|
+
return { model: { schemaVersion: 2, ...(title ? { title } : {}), ...(views.length ? { root: views[0].id } : {}), nodes, edges, views,
|
|
16705
|
+
...(skipped.length ? { ext: { bpmn: { skipped } } } : {}) }, report };
|
|
16706
|
+
};
|
|
16707
|
+
if (typeof xml !== 'string') { say('', 'skipped', 'not text: nothing read'); return result(); }
|
|
16708
|
+
if (xml.length > BX_LIMIT) { say('', 'skipped', `over ${BX_LIMIT} characters: nothing read (R2)`); return result(); }
|
|
16709
|
+
if (!moddle || typeof moddle.fromXML !== 'function') {
|
|
16710
|
+
say('', 'skipped', 'no bpmn-moddle instance given (npm install bpmn-moddle@10.3.1): nothing read');
|
|
16711
|
+
return result();
|
|
16712
|
+
}
|
|
16713
|
+
try {
|
|
16714
|
+
const { rootElement: defs, warnings, elementsById } = await moddle.fromXML(xml);
|
|
16715
|
+
for (const w of warnings || []) say((w.element && w.element.id) || '', 'skipped', `${bxMsg(w)}: not read`);
|
|
16716
|
+
if (!defs || defs.$type !== 'bpmn:Definitions') { say('', 'skipped', 'no BPMN definitions: nothing read'); return result(); }
|
|
16717
|
+
title = bxStr(defs.name);
|
|
16718
|
+
const ns = Object.keys(defs.$attrs || {}).find(k => k.startsWith('xmlns:') && defs.$attrs[k] === BX_NS), pre = ns ? ns.slice(6) + ':' : null;
|
|
16719
|
+
const attr = (el, k) => (pre && el.$attrs && typeof el.$attrs[pre + k] === 'string' ? el.$attrs[pre + k] : null);
|
|
16720
|
+
const idOf = el => attr(el, 'id') ?? el.id;
|
|
16721
|
+
const foreign = (el, id) => { for (const k of Object.keys(el.$attrs || {})) if (!k.startsWith('xmlns') && !(pre && k.startsWith(pre))) say(id, 'skipped', `the attribute ${k}: not read`); };
|
|
16722
|
+
foreign(defs, '');
|
|
16723
|
+
const elOf = new Map(); // a moddle element -> its node or lane
|
|
16724
|
+
const skip = (el, parent, why = 'outside the subset') => {
|
|
16725
|
+
const id = el.id || '';
|
|
16726
|
+
say(id || parent || '', 'skipped', `${(el.$type || 'an element').replace('bpmn:', '')} ${why}: kept under ext.bpmn, not read`);
|
|
16727
|
+
skipped.push({ ...(parent ? { parent } : {}), element: bxJson(el) });
|
|
16728
|
+
};
|
|
16729
|
+
const add = (el, type, parent, props = {}, label = el.name) => {
|
|
16730
|
+
const id = idOf(el);
|
|
16731
|
+
if (!bxStr(id) || byId.has(id)) { skip(el, parent, id ? `has the id ${id}, taken` : 'has no id'); return null; }
|
|
16732
|
+
const doc = (el.documentation || []).map(d => d.text).filter(bxStr).join('\n');
|
|
16733
|
+
if (doc) props.documentation = doc;
|
|
16734
|
+
const n = { id, type, ...(bxStr(label) ? { label } : {}), ...(parent ? { parent } : {}), ...(Object.keys(props).length ? { props } : {}) };
|
|
16735
|
+
nodes.push(n); byId.set(id, n); elOf.set(el, n); foreign(el, id);
|
|
16736
|
+
if (el.extensionElements) skip(el.extensionElements, id, '(an extension)');
|
|
16737
|
+
return n;
|
|
16738
|
+
};
|
|
16739
|
+
const weak = (n, what, key, value) => { (n.ext ??= { bpmn: {} }).bpmn[key] = value; say(n.id, 'weakened', `${what}: kept under ext.bpmn, read as plain`); };
|
|
16740
|
+
const pending = []; // [moddle element, model type, from element, to element, parent]
|
|
16741
|
+
const groups = [], tagged = [], hosts = []; // groups by their category value; elements naming category values; boundary events' hosts
|
|
16742
|
+
|
|
16743
|
+
// participants, their message flows and artifacts
|
|
16744
|
+
const owner = new Map(), queue = [];
|
|
16745
|
+
for (const r of defs.rootElements || []) {
|
|
16746
|
+
if (r.$type === 'bpmn:Collaboration') {
|
|
16747
|
+
for (const p of r.participants || []) {
|
|
16748
|
+
const n = add(p, 'bpmn.Participant', null); if (!n) continue;
|
|
16749
|
+
tagged.push([p, n]);
|
|
16750
|
+
if (p.processRef) {
|
|
16751
|
+
owner.set(p.processRef, n);
|
|
16752
|
+
const base = p.id + '-process', ref = p.processRef.id;
|
|
16753
|
+
if (ref !== base && !(ref.startsWith(base + '-') && /^\d+$/.test(ref.slice(base.length + 1)))) n.props = { ...n.props, processRef: ref };
|
|
16754
|
+
}
|
|
16755
|
+
}
|
|
16756
|
+
for (const f of r.messageFlows || []) pending.push([f, 'bpmn.MessageFlow', f.sourceRef, f.targetRef, null]);
|
|
16757
|
+
queue.push([{ artifacts: r.artifacts }, null]);
|
|
16758
|
+
for (const x of [...(r.conversations || []), ...(r.conversationLinks || [])]) skip(x, null);
|
|
16759
|
+
} else if (r.$type === 'bpmn:Process') queue.push([r, null]);
|
|
16760
|
+
else if (r.$type !== 'bpmn:DataStore' && r.$type !== 'bpmn:Category') skip(r, null);
|
|
16761
|
+
}
|
|
16762
|
+
// a process's lanes are its participant's members, nested by parent; a flow node is in the deepest lane naming it
|
|
16763
|
+
const laneOf = new Map(), depth = new Map();
|
|
16764
|
+
for (const [proc] of queue) {
|
|
16765
|
+
if (proc.$type !== 'bpmn:Process') continue;
|
|
16766
|
+
const pool = owner.get(proc), stack = (proc.laneSets || []).flatMap(s => (s.lanes || []).map(l => [l, null, 0])).reverse();
|
|
16767
|
+
while (stack.length) {
|
|
16768
|
+
const [l, up, d] = stack.pop();
|
|
16769
|
+
if (!pool) { skip(l, null, 'of a process with no pool'); continue; }
|
|
16770
|
+
const id = idOf(l);
|
|
16771
|
+
if (!bxStr(id) || byId.has(id)) { skip(l, pool.id, id ? `has the id ${id}, taken` : 'has no id'); continue; }
|
|
16772
|
+
const doc = (l.documentation || []).map(x => x.text).filter(bxStr).join('\n');
|
|
16773
|
+
const m = { id, type: 'bpmn.Lane', ...(bxStr(l.name) ? { label: l.name } : {}), ...(up ? { parent: up } : {}), ...(doc ? { props: { documentation: doc } } : {}) };
|
|
16774
|
+
(pool.members ??= []).push(m); byId.set(id, m); foreign(l, id);
|
|
16775
|
+
for (const f of l.flowNodeRef || []) if (!(depth.get(f) >= d)) { laneOf.set(f, id); depth.set(f, d); }
|
|
16776
|
+
if (l.childLaneSet) stack.push(...(l.childLaneSet.lanes || []).map(c => [c, id, d + 1]).reverse());
|
|
16777
|
+
}
|
|
16778
|
+
}
|
|
16779
|
+
// flow elements and artifacts, container by container (iterative: a sub-process queues its own)
|
|
16780
|
+
const objects = new Set(), refd = new Set();
|
|
16781
|
+
for (let q = 0; q < queue.length; q++) {
|
|
16782
|
+
const [c, inner] = queue[q], pool = c.$type === 'bpmn:Process' ? owner.get(c) : null, parent = inner || (pool && pool.id) || null;
|
|
16783
|
+
if (c.$type === 'bpmn:Process') foreign(c, c.id || '');
|
|
16784
|
+
else for (const s of c.laneSets || []) skip(s, inner, 'of a sub-process');
|
|
16785
|
+
for (const el of c.flowElements || []) {
|
|
16786
|
+
const t = el.$type, kind = t.replace('bpmn:', ''), at = (pool && laneOf.get(el)) || parent;
|
|
16787
|
+
let n = null;
|
|
16788
|
+
if (t in BX_TASK) {
|
|
16789
|
+
n = add(el, 'bpmn.Task', at, { taskType: BX_TASK[t] in BX_TASK_OUT ? BX_TASK[t] : 'none' });
|
|
16790
|
+
if (n && !(BX_TASK[t] in BX_TASK_OUT)) weak(n, `a ${BX_TASK[t]} task`, 'task', BX_TASK[t]);
|
|
16791
|
+
} else if (t === 'bpmn:SubProcess' && !el.triggeredByEvent) {
|
|
16792
|
+
n = add(el, 'bpmn.SubProcess', at);
|
|
16793
|
+
if (n) queue.push([el, n.id]);
|
|
16794
|
+
} else if (t === 'bpmn:CallActivity') n = add(el, 'bpmn.CallActivity', at, bxStr(el.calledElement) ? { calledElement: el.calledElement } : {});
|
|
16795
|
+
else if (kind in BX_DEFS) {
|
|
16796
|
+
const ds = el.eventDefinitions || [], def = ds.length ? BX_DEF[ds[0].$type] : 'none';
|
|
16797
|
+
if (ds.length > 1 || !BX_DEFS[kind].includes(def)) { skip(el, at, `with ${ds.map(d => d.$type.replace('bpmn:', '')).join(', ') || 'no definition'}`); continue; }
|
|
16798
|
+
const props = { definition: def };
|
|
16799
|
+
if (t === 'bpmn:BoundaryEvent') props.cancelActivity = el.cancelActivity !== false;
|
|
16800
|
+
n = add(el, `bpmn.${kind}`, at, props);
|
|
16801
|
+
if (n && el.attachedToRef) hosts.push([n, el.attachedToRef]);
|
|
16802
|
+
const time = ds[0] && ['timeDate', 'timeDuration', 'timeCycle'].filter(k => ds[0][k]).map(k => [k, ds[0][k].body ?? '']);
|
|
16803
|
+
if (n && time && time.length) weak(n, `a timer's ${time.map(x => x[0]).join(', ')}`, 'timer', Object.fromEntries(time));
|
|
16804
|
+
} else if (t === 'bpmn:ExclusiveGateway' || t === 'bpmn:ParallelGateway') {
|
|
16805
|
+
n = add(el, `bpmn.${kind}`, at);
|
|
16806
|
+
if (n && el.default) weak(n, 'a gateway\'s default flow', 'default', idOf(el.default));
|
|
16807
|
+
} else if (t === 'bpmn:DataObjectReference') {
|
|
16808
|
+
n = add(el, 'bpmn.DataObject', parent, {}, bxStr(el.name) || (el.dataObjectRef && el.dataObjectRef.name));
|
|
16809
|
+
if (el.dataObjectRef) refd.add(el.dataObjectRef);
|
|
16810
|
+
} else if (t === 'bpmn:DataObject') { objects.add([el, parent]); continue; }
|
|
16811
|
+
else if (t === 'bpmn:DataStoreReference') {
|
|
16812
|
+
const s = el.dataStoreRef, ref = s && (bxStr(s.name) || s.id);
|
|
16813
|
+
n = add(el, 'bpmn.DataStoreReference', parent, ref ? { dataStoreRef: ref } : {});
|
|
16814
|
+
} else if (t === 'bpmn:SequenceFlow') {
|
|
16815
|
+
pending.push([el, 'bpmn.SequenceFlow', el.sourceRef, el.targetRef, parent]);
|
|
16816
|
+
if (el.conditionExpression) pending.at(-1).push(['a condition', 'condition', el.conditionExpression.body ?? '']);
|
|
16817
|
+
continue;
|
|
16818
|
+
} else { skip(el, at); continue; }
|
|
16819
|
+
if (!n) continue;
|
|
16820
|
+
tagged.push([el, n]);
|
|
16821
|
+
if (el.loopCharacteristics) skip(el.loopCharacteristics, n.id, '(a marker outside the subset)');
|
|
16822
|
+
const ends = new Set((el.dataInputAssociations || []).map(a => a.targetRef)); // the writer's placeholder ends
|
|
16823
|
+
for (const x of [el.ioSpecification, el.inputSet, el.outputSet, ...(el.properties || []), ...(el.dataInputs || []), ...(el.dataOutputs || [])]) if (x && !ends.has(x)) skip(x, n.id);
|
|
16824
|
+
for (const a of el.dataInputAssociations || []) pending.push([a, 'bpmn.DataAssociation', (a.sourceRef || [])[0], el, null]);
|
|
16825
|
+
for (const a of el.dataOutputAssociations || []) pending.push([a, 'bpmn.DataAssociation', el, a.targetRef, null]);
|
|
16826
|
+
}
|
|
16827
|
+
for (const a of c.artifacts || []) {
|
|
16828
|
+
if (a.$type === 'bpmn:TextAnnotation') { const n = add(a, 'bpmn.TextAnnotation', parent, {}, a.text); if (n) tagged.push([a, n]); }
|
|
16829
|
+
else if (a.$type === 'bpmn:Group') {
|
|
16830
|
+
const n = add(a, 'bpmn.Group', parent, {}, a.categoryValueRef && a.categoryValueRef.value);
|
|
16831
|
+
if (n && a.categoryValueRef) groups.push([a.categoryValueRef, n.id]);
|
|
16832
|
+
} else if (a.$type === 'bpmn:Association') pending.push([a, 'bpmn.Association', a.sourceRef, a.targetRef, null]);
|
|
16833
|
+
else skip(a, parent);
|
|
16834
|
+
}
|
|
16835
|
+
}
|
|
16836
|
+
for (const [o, parent] of objects) if (!refd.has(o)) add(o, 'bpmn.DataObject', parent);
|
|
16837
|
+
for (const [n, h] of hosts) { // a boundary event's host is an activity read, else it is said
|
|
16838
|
+
const a = elOf.get(h);
|
|
16839
|
+
if (a && BX_ACTIVITY.has(a.type)) n.props.attachedTo = a.id;
|
|
16840
|
+
else say(n.id, 'skipped', `its host ${idOf(h) || '(no id)'} is not read: attachedTo not read`);
|
|
16841
|
+
}
|
|
16842
|
+
// the groups an element is in: its category values', or for an artifact its umlimator:groups
|
|
16843
|
+
const groupOf = new Map(groups);
|
|
16844
|
+
for (const [el, n] of tagged) {
|
|
16845
|
+
const vals = el.categoryValueRef || (attr(el, 'groups') || '').split(' ').map(v => elementsById && elementsById[v]).filter(Boolean);
|
|
16846
|
+
const ids = [...new Set(vals.map(v => groupOf.get(v)).filter(Boolean))];
|
|
16847
|
+
if (ids.length) n.props = { ...n.props, categoryValueRef: ids };
|
|
16848
|
+
}
|
|
16849
|
+
// lines, once every node is known: an end that is not read is said
|
|
16850
|
+
for (const [el, type, a, b, , w] of pending) {
|
|
16851
|
+
const id = idOf(el), from = a && elOf.get(a), to = b && elOf.get(b);
|
|
16852
|
+
if (!from || !to) { skip(el, null, `whose ${!from ? 'source' : 'target'} is not read`); continue; }
|
|
16853
|
+
if (!bxStr(id) || byId.has(id)) { skip(el, null, id ? `has the id ${id}, taken` : 'has no id'); continue; }
|
|
16854
|
+
const doc = (el.documentation || []).map(x => x.text).filter(bxStr).join('\n'), props = {};
|
|
16855
|
+
if (doc) props.documentation = doc;
|
|
16856
|
+
if (type === 'bpmn.Association' && el.associationDirection && el.associationDirection !== 'None') props.direction = el.associationDirection.toLowerCase();
|
|
16857
|
+
const e = { id, type, from: from.id, to: to.id, ...(bxStr(el.name) && type.endsWith('Flow') ? { label: el.name } : {}), ...(Object.keys(props).length ? { props } : {}) };
|
|
16858
|
+
edges.push(e); byId.set(id, e); foreign(el, id);
|
|
16859
|
+
if (w) weak(e, w[0], w[1], w[2]);
|
|
16860
|
+
if ((el.categoryValueRef || []).length) {
|
|
16861
|
+
(e.ext ??= { bpmn: {} }).bpmn.categoryValueRef = el.categoryValueRef.map(v => v.id);
|
|
16862
|
+
say(id, 'skipped', 'its groups (categoryValueRef, which row 24 gives no line): kept under ext.bpmn, not read');
|
|
16863
|
+
}
|
|
16864
|
+
}
|
|
16865
|
+
// views: one per diagram, its shapes in order; what no plane shows goes to the first
|
|
16866
|
+
const shown = new Set(), seen = new Set(), first = new Map(); // nodes in a plane; view ids; a node -> the first two views showing it
|
|
16867
|
+
let placed = false;
|
|
16868
|
+
for (const d of defs.diagrams || []) {
|
|
16869
|
+
const plane = d.plane || {}, id = idOf(d);
|
|
16870
|
+
if (!bxStr(id) || seen.has(id)) { skip(d, null, id ? `has the id ${id}, taken` : 'has no id'); continue; }
|
|
16871
|
+
const subject = plane.bpmnElement && elOf.get(plane.bpmnElement), v = { id, diagram: 'bpmn', ...(bxStr(d.name) ? { title: d.name } : {}), parent: null, nodeIds: [] };
|
|
16872
|
+
if (subject && subject.type === 'bpmn.SubProcess') { v.subject = subject.id; subject.opensView ??= id; }
|
|
16873
|
+
const ids = new Set();
|
|
16874
|
+
for (const s of plane.planeElement || []) {
|
|
16875
|
+
placed = true;
|
|
16876
|
+
const n = s.bpmnElement && elOf.get(s.bpmnElement);
|
|
16877
|
+
if (s.$type === 'bpmndi:BPMNShape' && n && !ids.has(n.id)) { ids.add(n.id); v.nodeIds.push(n.id); shown.add(n.id); const f = first.get(n.id) || []; if (f.length < 2) first.set(n.id, [...f, v]); }
|
|
16878
|
+
}
|
|
16879
|
+
views.push(v); seen.add(id);
|
|
16880
|
+
}
|
|
16881
|
+
for (const v of views) if (v.subject) { // the first other view that shows its subject
|
|
16882
|
+
const w = (first.get(v.subject) || []).find(x => x !== v);
|
|
16883
|
+
v.parent = w ? w.id : null;
|
|
16884
|
+
}
|
|
16885
|
+
if (placed) say('', 'skipped', 'positions and sizes in the diagram interchange are not kept: umlimator lays each view out (R5)');
|
|
16886
|
+
if (!views.length) {
|
|
16887
|
+
views.push({ id: BX_VIEW, diagram: 'bpmn', parent: null, nodeIds: [] });
|
|
16888
|
+
if (nodes.length) say('', 'skipped', `no diagram interchange: every element is in one view, ${BX_VIEW}`);
|
|
16889
|
+
}
|
|
16890
|
+
for (const n of nodes) if (!shown.has(n.id)) views[0].nodeIds.push(n.id);
|
|
16891
|
+
} catch (e) { // moddle's parser or handlers, or a getter, threw (a RangeError at depth among them): nothing is kept
|
|
16892
|
+
nodes.length = edges.length = views.length = skipped.length = 0;
|
|
16893
|
+
say('', 'skipped', `the file could not be read (${bxMsg(e)}): nothing read`);
|
|
16894
|
+
}
|
|
16895
|
+
return result();
|
|
16896
|
+
}
|
|
16897
|
+
|
|
16898
|
+
async function writeBpmnXml(model, viewId, geo, moddle) {
|
|
16899
|
+
const report = [];
|
|
16900
|
+
let more = 0;
|
|
16901
|
+
const say = (line, message) => { if (report.length < BX_MAX) report.push({ line, status: 'skipped', message: `${message}: not written` }); else more++; };
|
|
16902
|
+
const result = xml => {
|
|
16903
|
+
if (more) report.push({ line: '', status: 'skipped', message: `${more} more items like these: not listed` });
|
|
16904
|
+
return { xml, report };
|
|
16905
|
+
};
|
|
16906
|
+
try {
|
|
16907
|
+
if (!moddle || typeof moddle.create !== 'function') { say('', 'no bpmn-moddle instance given (npm install bpmn-moddle@10.3.1)'); return result(''); }
|
|
16908
|
+
const M = (type, props) => { const el = moddle.create(type); for (const [k, v] of Object.entries(props)) if (v != null) el[k] = v; return el; };
|
|
16909
|
+
const all = (model && model.nodes) || [], nodes = all.filter(n => n && typeof n.type === 'string' && n.type.startsWith('bpmn.'));
|
|
16910
|
+
const edges = ((model && model.edges) || []).filter(e => e && typeof e.type === 'string' && e.type.startsWith('bpmn.'));
|
|
16911
|
+
const views = ((model && model.views) || []).filter(v => v && v.diagram === 'bpmn'), root = views.findIndex(v => v.id === model.root);
|
|
16912
|
+
if (root > 0) views.unshift(...views.splice(root, 1)); // a reader takes the first diagram for the root
|
|
16913
|
+
if (all.length > nodes.length) say('', `elements of other diagrams (${all.length - nodes.length})`);
|
|
16914
|
+
if (model && model.views && model.views.length > views.length) say('', `views of other diagrams (${model.views.length - views.length})`);
|
|
16915
|
+
if (model && model.ext && model.ext.bpmn) say('', 'what the model keeps under ext.bpmn (elements a reader skipped)');
|
|
16916
|
+
const pools = nodes.filter(n => n.type === 'bpmn.Participant'), members = pools.flatMap(p => (p.members || []).filter(m => m && m.type === 'bpmn.Lane').map(m => [m, p]));
|
|
16917
|
+
// ids: a model id that is an XML name is the element's; the others escaped, after them, so none takes a model's id
|
|
16918
|
+
const used = new Set(), xid = new Map(), ok = id => typeof id === 'string' && /^[A-Za-z_][\w.-]*$/.test(id);
|
|
16919
|
+
const free = base => { let id = base, k = 1; while (used.has(id)) id = `${base}-${++k}`; used.add(id); return id; };
|
|
16920
|
+
const things = [...nodes, ...members.map(x => x[0]), ...edges, ...views];
|
|
16921
|
+
for (const x of things) if (ok(x.id) && !used.has(x.id)) { used.add(x.id); xid.set(x, x.id); }
|
|
16922
|
+
for (const x of things) if (!xid.has(x)) xid.set(x, free('_' + [...String(x.id)].map(c => (/[A-Za-z0-9.-]/.test(c) ? c : `_${c.codePointAt(0).toString(16)}_`)).join('')));
|
|
16923
|
+
const els = new Map(), byId = new Map(nodes.map(n => [n.id, n])), laneOf = new Map(members.map(([m, p]) => [m.id, [m, p]]));
|
|
16924
|
+
const mk = (type, x, props = {}) => {
|
|
16925
|
+
const el = M(type, { id: xid.get(x), ...props }), doc = x.props && x.props.documentation;
|
|
16926
|
+
if (el.id !== x.id) el.$attrs['umlimator:id'] = String(x.id);
|
|
16927
|
+
if (bxStr(doc)) el.documentation = [M('bpmn:Documentation', { text: doc })];
|
|
16928
|
+
for (const k of Object.keys(x)) if (!BX_FIELDS.has(k)) say(x.id, k);
|
|
16929
|
+
for (const k of Object.keys(x.props || {})) if (!bxProp(x.type, k)) say(x.id, `props.${k}`);
|
|
16930
|
+
if (x.ext && x.ext.bpmn) say(x.id, 'what it keeps under ext.bpmn');
|
|
16931
|
+
return el;
|
|
16932
|
+
};
|
|
16933
|
+
// containers: a participant's process, made when something sits in it; a sub-process; the process for no pool
|
|
16934
|
+
const roots = [], procs = new Map(), stores = new Map();
|
|
16935
|
+
const collab = pools.length ? M('bpmn:Collaboration', { id: free('collaboration'), participants: [], messageFlows: [], artifacts: [] }) : null;
|
|
16936
|
+
let loose = null;
|
|
16937
|
+
const proc = p => {
|
|
16938
|
+
if (!p) return loose ??= (roots.push(M('bpmn:Process', { id: free('process'), flowElements: [], artifacts: [] })), roots.at(-1));
|
|
16939
|
+
if (!procs.has(p)) {
|
|
16940
|
+
const r = p.props && p.props.processRef;
|
|
16941
|
+
const base = `${xid.get(p)}-process`, id = free(ok(r) ? r : base);
|
|
16942
|
+
if (r && !ok(r)) say(p.id, 'props.processRef, which is not an XML name');
|
|
16943
|
+
else if (r && id !== r) say(p.id, `props.processRef ${r}, an id already taken (written as ${id})`);
|
|
16944
|
+
else if (r && r.startsWith(base + '-') && /^\d+$/.test(r.slice(base.length + 1))) say(p.id, `props.processRef ${r}, which a reader takes for ${base} made unique`);
|
|
16945
|
+
procs.set(p, M('bpmn:Process', { id, flowElements: [], artifacts: [] })); roots.push(procs.get(p));
|
|
16946
|
+
els.get(p).processRef = procs.get(p);
|
|
16947
|
+
}
|
|
16948
|
+
return procs.get(p);
|
|
16949
|
+
};
|
|
16950
|
+
const holder = n => { // the process or sub-process n sits in (lanes are its pool's), or null at the top
|
|
16951
|
+
for (let up = n.parent, k = 0; up != null && k <= all.length; k++) {
|
|
16952
|
+
if (laneOf.has(up)) return proc(laneOf.get(up)[1]);
|
|
16953
|
+
const q = byId.get(up);
|
|
16954
|
+
if (!q) return null;
|
|
16955
|
+
if (q.type === 'bpmn.Participant') return proc(q);
|
|
16956
|
+
if (q.type === 'bpmn.SubProcess') return els.get(q);
|
|
16957
|
+
up = q.parent;
|
|
16958
|
+
}
|
|
16959
|
+
return null;
|
|
16960
|
+
};
|
|
16961
|
+
for (const n of nodes) {
|
|
16962
|
+
const t = n.type, k = t.slice(5), pr = n.props || {};
|
|
16963
|
+
if (t === 'bpmn.Task') els.set(n, mk(BX_TASK_OUT[pr.taskType] || 'bpmn:Task', n, { name: n.label }));
|
|
16964
|
+
else if (t === 'bpmn.DataObject') els.set(n, mk('bpmn:DataObjectReference', n, { name: n.label }));
|
|
16965
|
+
else if (t === 'bpmn.TextAnnotation') els.set(n, mk('bpmn:TextAnnotation', n, { text: n.label }));
|
|
16966
|
+
else if (t === 'bpmn.Group') {
|
|
16967
|
+
const el = mk('bpmn:Group', n), value = M('bpmn:CategoryValue', { id: free(`${el.id}-value`), value: n.label });
|
|
16968
|
+
roots.push(M('bpmn:Category', { id: free(`${el.id}-category`), categoryValue: [value] })); el.categoryValueRef = value; els.set(n, el);
|
|
16969
|
+
} else if (BX_SAME.has(k)) {
|
|
16970
|
+
const el = mk(`bpmn:${k}`, n, { name: n.label, ...(k === 'SubProcess' ? { flowElements: [], artifacts: [] } : {}) });
|
|
16971
|
+
if (k in BX_DEFS) {
|
|
16972
|
+
const def = pr.definition || 'none';
|
|
16973
|
+
if (!BX_DEFS[k].includes(def)) say(n.id, `the definition ${def}, which a ${k} may not take`);
|
|
16974
|
+
else if (def !== 'none') el.eventDefinitions = [M(BX_DEF_OUT[def], {})];
|
|
16975
|
+
if (k === 'BoundaryEvent' && pr.cancelActivity === false) el.cancelActivity = false;
|
|
16976
|
+
}
|
|
16977
|
+
if (k === 'CallActivity' && bxStr(pr.calledElement)) el.calledElement = pr.calledElement;
|
|
16978
|
+
if (k === 'DataStoreReference' && bxStr(pr.dataStoreRef)) {
|
|
16979
|
+
if (!stores.has(pr.dataStoreRef)) { stores.set(pr.dataStoreRef, M('bpmn:DataStore', { id: free(`${el.id}-store`), name: pr.dataStoreRef })); roots.push(stores.get(pr.dataStoreRef)); }
|
|
16980
|
+
el.dataStoreRef = stores.get(pr.dataStoreRef);
|
|
16981
|
+
}
|
|
16982
|
+
els.set(n, el);
|
|
16983
|
+
} else say(n.id, `${t}, outside the subset`);
|
|
16984
|
+
}
|
|
16985
|
+
for (const [m] of members) els.set(m, mk('bpmn:Lane', m, { name: m.label, flowNodeRef: [] }));
|
|
16986
|
+
const el = id => els.get(byId.get(id) || (laneOf.get(id) || [])[0]);
|
|
16987
|
+
const ring = new Set(); // lanes whose parents lead round to them: written at the top of their pool
|
|
16988
|
+
for (const [m, p] of members) {
|
|
16989
|
+
let up = m.parent;
|
|
16990
|
+
for (let k = 0; k <= members.length && up !== m.id && laneOf.has(up) && laneOf.get(up)[1] === p; k++) up = laneOf.get(up)[0].parent;
|
|
16991
|
+
if (up === m.id) { ring.add(m); say(m.id, `parent ${m.parent}, which leads round to this lane (written at the top of its pool)`); }
|
|
16992
|
+
}
|
|
16993
|
+
const upOf = m => (ring.has(m) ? null : m.parent);
|
|
16994
|
+
// place each node; a flow node in a lane is named by it and every lane around it
|
|
16995
|
+
for (const n of nodes) {
|
|
16996
|
+
const e = els.get(n); if (!e) continue;
|
|
16997
|
+
const groups = bxProp(n.type, 'categoryValueRef') ? ((n.props && n.props.categoryValueRef) || []).map(g => el(g) && el(g).categoryValueRef).filter(Boolean) : [];
|
|
16998
|
+
if (groups.length && e.$descriptor.properties.some(p => p.name === 'categoryValueRef')) e.categoryValueRef = groups;
|
|
16999
|
+
else if (groups.length) e.$attrs['umlimator:groups'] = groups.map(g => g.id).join(' ');
|
|
17000
|
+
if (n.type === 'bpmn.Participant') { collab.participants.push(e); if (n.props && n.props.processRef || (n.members || []).length) proc(n); continue; }
|
|
17001
|
+
if (BX_LOOSE.has(n.type) && laneOf.has(n.parent)) say(n.id, `parent ${n.parent}, a lane, which BPMN gives no ${n.type.slice(5)} (written in its pool)`);
|
|
17002
|
+
const c = holder(n), art = n.type === 'bpmn.TextAnnotation' || n.type === 'bpmn.Group';
|
|
17003
|
+
if (art) (c || collab || proc(null)).artifacts.push(e);
|
|
17004
|
+
else {
|
|
17005
|
+
(c || proc(null)).flowElements.push(e);
|
|
17006
|
+
if (n.type === 'bpmn.DataObject') { e.dataObjectRef = M('bpmn:DataObject', { id: free(`${e.id}-object`) }); (c || proc(null)).flowElements.push(e.dataObjectRef); }
|
|
17007
|
+
}
|
|
17008
|
+
if (n.type === 'bpmn.BoundaryEvent' && n.props && el(n.props.attachedTo)) e.attachedToRef = el(n.props.attachedTo);
|
|
17009
|
+
for (let l = laneOf.get(n.parent), k = 0; l && k <= members.length; l = laneOf.get(upOf(l[0])), k++) els.get(l[0]).flowNodeRef.push(e);
|
|
17010
|
+
}
|
|
17011
|
+
for (const p of pools) { // lane sets, nested by parent, in members order
|
|
17012
|
+
const ms = members.filter(x => x[1] === p).map(x => x[0]); if (!ms.length) continue;
|
|
17013
|
+
const own = new Set(ms.map(m => m.id)), set = (ls, base) => (ls.length ? M('bpmn:LaneSet', { id: free(`${base}-lanes`), lanes: ls.map(m => els.get(m)) }) : null);
|
|
17014
|
+
proc(p).laneSets = [set(ms.filter(m => !own.has(upOf(m))), xid.get(p))];
|
|
17015
|
+
for (const m of ms) { const s = set(ms.filter(c => upOf(c) === m.id), xid.get(m)); if (s) els.get(m).childLaneSet = s; }
|
|
17016
|
+
}
|
|
17017
|
+
// lines: each where BPMN keeps it
|
|
17018
|
+
const lines = new Map();
|
|
17019
|
+
for (const e of edges) {
|
|
17020
|
+
const a = byId.get(e.from), b = byId.get(e.to), A = a && els.get(a), B = b && els.get(b), pr = e.props || {};
|
|
17021
|
+
if (!A || !B) { say(e.id, `${e.type} whose ${A ? 'target' : 'source'} is not written`); continue; }
|
|
17022
|
+
let x = null;
|
|
17023
|
+
if (e.type === 'bpmn.SequenceFlow') { x = mk('bpmn:SequenceFlow', e, { name: e.label, sourceRef: A, targetRef: B }); (holder(a) || proc(null)).flowElements.push(x); }
|
|
17024
|
+
else if (e.type === 'bpmn.MessageFlow') {
|
|
17025
|
+
if (!collab) { say(e.id, 'a message flow in a model with no participant'); continue; }
|
|
17026
|
+
collab.messageFlows.push(x = mk('bpmn:MessageFlow', e, { name: e.label, sourceRef: A, targetRef: B }));
|
|
17027
|
+
} else if (e.type === 'bpmn.Association') {
|
|
17028
|
+
x = mk('bpmn:Association', e, { sourceRef: A, targetRef: B, associationDirection: { one: 'One', both: 'Both' }[pr.direction] });
|
|
17029
|
+
const at = [a, b].find(n => n.type === 'bpmn.TextAnnotation') || a;
|
|
17030
|
+
(holder(at) || collab || proc(null)).artifacts.push(x);
|
|
17031
|
+
} else if (e.type === 'bpmn.DataAssociation') {
|
|
17032
|
+
const data = n => n.type === 'bpmn.DataObject' || n.type === 'bpmn.DataStoreReference';
|
|
17033
|
+
const takes = (n, dir) => BX_ACTIVITY.has(n.type) || (dir === 'in' ? ['bpmn.EndEvent', 'bpmn.IntermediateThrowEvent'] : ['bpmn.StartEvent', 'bpmn.IntermediateCatchEvent', 'bpmn.BoundaryEvent']).includes(n.type);
|
|
17034
|
+
if (data(a) && takes(b, 'in')) {
|
|
17035
|
+
const target = BX_ACTIVITY.has(b.type) ? M('bpmn:Property', { id: free(`${xid.get(e)}-target`), name: '__targetRef_placeholder' }) : M('bpmn:DataInput', { id: free(`${xid.get(e)}-target`) });
|
|
17036
|
+
(BX_ACTIVITY.has(b.type) ? (B.properties ??= []) : (B.dataInputs ??= [])).push(target);
|
|
17037
|
+
(B.dataInputAssociations ??= []).push(x = mk('bpmn:DataInputAssociation', e, { sourceRef: [A], targetRef: target }));
|
|
17038
|
+
} else if (data(b) && takes(a, 'out')) (A.dataOutputAssociations ??= []).push(x = mk('bpmn:DataOutputAssociation', e, { targetRef: B }));
|
|
17039
|
+
else { say(e.id, 'a data association against BPMN\'s direction (data-ends)'); continue; }
|
|
17040
|
+
} else { say(e.id, `${e.type}, outside the subset`); continue; }
|
|
17041
|
+
if (e.label != null && (e.type === 'bpmn.Association' || e.type === 'bpmn.DataAssociation')) say(e.id, 'label, which BPMN gives no association');
|
|
17042
|
+
lines.set(e, x);
|
|
17043
|
+
}
|
|
17044
|
+
// what a reader derives from the diagrams, against the model: a view's subject (a sub-process), its parent (the first
|
|
17045
|
+
// other view showing its subject), a sub-process's opensView (the first view with it as subject), a node in no view
|
|
17046
|
+
const sub = v => { const s = byId.get(v.subject); return s && s.type === 'bpmn.SubProcess' && els.get(s) ? s : null; };
|
|
17047
|
+
const opens = new Map(), showing = new Map(); // a sub-process -> the view it opens; a node -> the first two views showing it
|
|
17048
|
+
for (const v of views) {
|
|
17049
|
+
if (sub(v) && !opens.has(v.subject)) opens.set(v.subject, v.id);
|
|
17050
|
+
for (const id of v.nodeIds || []) { const f = showing.get(id) || []; if (f.length < 2 && !f.includes(v)) showing.set(id, [...f, v]); }
|
|
17051
|
+
}
|
|
17052
|
+
for (const v of views) {
|
|
17053
|
+
if (v.subject != null && !sub(v)) say(v.id, `the view's subject ${v.subject}, which is no sub-process written`);
|
|
17054
|
+
const w = sub(v) && (showing.get(v.subject) || []).find(x => x !== v), parent = w ? w.id : null;
|
|
17055
|
+
if ((v.parent ?? null) !== parent) say(v.id, `the view's parent ${v.parent ?? null} (a reader takes ${parent} from its subject)`);
|
|
17056
|
+
for (const id of v.nodeIds || []) if (!els.get(byId.get(id))) say(v.id, `the view's node ${id}, which is no element written`);
|
|
17057
|
+
}
|
|
17058
|
+
for (const n of nodes) {
|
|
17059
|
+
if ((n.opensView ?? null) !== (opens.get(n.id) ?? null)) say(n.id, `opensView ${n.opensView ?? 'none'} (a reader takes ${opens.get(n.id) ?? null}, the first view with it as subject)`);
|
|
17060
|
+
if (els.get(n) && views.length && !showing.has(n.id)) say(n.id, `in no view (a reader puts it in the first, ${views[0].id})`);
|
|
17061
|
+
}
|
|
17062
|
+
// diagram interchange: a diagram per view, viewId's from geo, the others on a plain grid
|
|
17063
|
+
const G = new Map(((geo && geo.nodes) || []).map(g => [g.id, g])), GE = new Map(((geo && geo.edges) || []).map(g => [g.id, g]));
|
|
17064
|
+
const diagrams = views.map(v => {
|
|
17065
|
+
for (const k of Object.keys(v)) if (!['id', 'diagram', 'title', 'parent', 'subject', 'nodeIds'].includes(k)) say(v.id, `the view's ${k}`);
|
|
17066
|
+
const box = new Map(), ids = new Set(v.nodeIds || []), planeElement = [], mine = v.id === viewId;
|
|
17067
|
+
const shape = (x, more) => {
|
|
17068
|
+
const g = mine && G.get(x.id), [w, h] = BX_SIZE[x.type] || (bxEvent(x.type) ? [36, 36] : [100, 80]), k = box.size;
|
|
17069
|
+
const b = g ? { x: g.x, y: g.y, w: g.w, h: g.h } : { x: 40 + (k % 8) * 180, y: 40 + Math.floor(k / 8) * 140, w, h };
|
|
17070
|
+
box.set(x.id, b);
|
|
17071
|
+
planeElement.push(M('bpmndi:BPMNShape', { id: free(`${els.get(x).id}_di`), bpmnElement: els.get(x), ...more,
|
|
17072
|
+
bounds: M('dc:Bounds', { x: b.x, y: b.y, width: b.w, height: b.h }) }));
|
|
17073
|
+
};
|
|
17074
|
+
for (const id of v.nodeIds || []) {
|
|
17075
|
+
const n = byId.get(id); if (!n || !els.get(n) || box.has(id)) continue;
|
|
17076
|
+
shape(n, n.type === 'bpmn.Participant' ? { isHorizontal: true } : n.type === 'bpmn.SubProcess' ? { isExpanded: nodes.some(c => c.parent === id && ids.has(c.id)) } : {});
|
|
17077
|
+
if (n.type === 'bpmn.Participant') for (const [m] of members.filter(x => x[1] === n)) shape(m, { isHorizontal: true });
|
|
17078
|
+
}
|
|
17079
|
+
for (const [e, x] of lines) {
|
|
17080
|
+
const a = box.get(e.from), b = box.get(e.to), g = mine && GE.get(e.id); if (!a || !b) continue;
|
|
17081
|
+
const pts = g && Array.isArray(g.pts) && g.pts.length > 1 ? g.pts : [[a.x + a.w / 2, a.y + a.h / 2], [b.x + b.w / 2, b.y + b.h / 2]];
|
|
17082
|
+
planeElement.push(M('bpmndi:BPMNEdge', { id: free(`${x.id}_di`), bpmnElement: x, waypoint: pts.map(([px, py]) => M('dc:Point', { x: px, y: py })) }));
|
|
17083
|
+
}
|
|
17084
|
+
const subject = v.subject && byId.get(v.subject), d = M('bpmndi:BPMNDiagram', { id: xid.get(v), name: v.title });
|
|
17085
|
+
if (d.id !== v.id) d.$attrs['umlimator:id'] = String(v.id);
|
|
17086
|
+
d.plane = M('bpmndi:BPMNPlane', { id: free(`${d.id}-plane`), bpmnElement: (subject && els.get(subject)) || collab || proc(null), planeElement });
|
|
17087
|
+
return d;
|
|
17088
|
+
});
|
|
17089
|
+
if (collab) roots.unshift(collab);
|
|
17090
|
+
const defs = M('bpmn:Definitions', { id: free('definitions'), name: model && bxStr(model.title), targetNamespace: BX_NS, exporter: 'umlimator', rootElements: roots, diagrams });
|
|
17091
|
+
defs.$attrs['xmlns:umlimator'] = BX_NS;
|
|
17092
|
+
return result((await moddle.toXML(defs, { format: true })).xml);
|
|
17093
|
+
} catch (e) {
|
|
17094
|
+
say('', `the model (${bxMsg(e)})`);
|
|
17095
|
+
return result('');
|
|
17096
|
+
}
|
|
17097
|
+
}
|
|
13686
17098
|
|
|
13687
17099
|
|
|
13688
17100
|
|