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.
@@ -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
- default: throw new Error(`${JSON.stringify(c.op)} is not a command (set, add, move, hide, delete, reparent)`);
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 rank = x => { const r = x.e.portRank; return (Array.isArray(r) ? r[x.from ? 0 : 1] : r) ?? 1e9; }, byFar = (a, b) => far(a) - far(b), groups = [];
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.L = g.port == null ? 2 : 2 + 10 * Math.max(...g.faces.map(f => f.length)); });
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; labels: each end's role and multiplicity (endLabels). Each end: [its
6081
- // length, half its height, its path pointing right with the tip at its length, its fill]. Its tip is where the line ends,
6082
- // on the node's outline. Hollow is the stage's background, filled the line's own colour (T2), in either theme. The socket
6083
- // (notes/stage-5.md §9.2) is a usage's end on an interface drawn as a 16 px ball: a half circle of radius 11 open towards the
6084
- // ball, its middle on the line 3 px before the end (the ball's outline), its centre 8 px past it (the ball's)
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'] }; // an extension's, at the metaclass (notes/stage-6.md §4.1)
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
- const { start, count, rows } = laneRuns(nodes.map(n => [n.id, n.col]), laneOf, order);
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 first = start.slice(0, order.length), last = order.map((l, s) => start[s] + count[s] - 1), at = new Map(order.map((l, s) => [l.id, s]));
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.map((l, s) => { const c0 = before(first[s]), c1 = after(last[s]), s0 = a0 + l.depth * LN.head;
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