@peter.prib/renderengine 0.12.0 → 0.13.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.
Files changed (31) hide show
  1. package/README.md +10 -4
  2. package/dist/renderengine-workspace.umd.js +1920 -1055
  3. package/dist/renderengine.umd.js +1901 -1052
  4. package/documentation/REFERENCE.md +66 -5
  5. package/package.json +1 -1
  6. package/src/charting/Axis.js +47 -5
  7. package/src/charting/DiagramEditor.js +239 -34
  8. package/src/charting/ModelFileFormat.js +47 -0
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  10. package/src/charting/archimate/deleteFromModel.js +57 -0
  11. package/src/charting/archimate/elementMenuItems.js +3 -1
  12. package/src/charting/archimate/heatMaps.js +169 -0
  13. package/src/charting/archimate/index.js +1 -0
  14. package/src/charting/charts/BarChart.js +5 -5
  15. package/src/charting/charts/BarChartBase.js +21 -3
  16. package/src/charting/charts/GroupedBarChart.js +7 -9
  17. package/src/charting/charts/TableChart.js +18 -2
  18. package/src/charting/diagrams/ArchimateModel.js +33 -0
  19. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  20. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  21. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  22. package/src/charting/pivot/PivotGridChart.js +1 -1
  23. package/src/charting/pivot/PivotView.js +11 -2
  24. package/src/charting/table/TableView.js +10 -1
  25. package/src/charting/table/TableViewChart.js +1 -1
  26. package/src/charting/templates/Template.js +10 -0
  27. package/src/exportImage.js +45 -18
  28. package/src/index.js +17 -4
  29. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  30. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  31. package/src/workspace/parts/ModelHosted.js +15 -4
@@ -3709,6 +3709,22 @@
3709
3709
  return denom > 0 ? 1 / denom : Math.max(hw, hh);
3710
3710
  }
3711
3711
 
3712
+ /** Pure. Where two boxes ({left, top, right, bottom}) face each other
3713
+ * across a gap with their x ranges (or y ranges) overlapping, the
3714
+ * shortest line between them runs straight across, at the middle of the
3715
+ * overlap: `{axis: 'v', at: x, forward}` (a vertical line, `forward` when
3716
+ * `a` is above `b`) or `{axis: 'h', at: y, forward}` (`a` left of `b`).
3717
+ * Null when they overlap neither way, or each other. */
3718
+ static acrossOverlap(a, b) {
3719
+ const left = Math.max(a.left, b.left), right = Math.min(a.right, b.right);
3720
+ const top = Math.max(a.top, b.top), bottom = Math.min(a.bottom, b.bottom);
3721
+ const apartV = a.bottom <= b.top || b.bottom <= a.top;
3722
+ const apartH = a.right <= b.left || b.right <= a.left;
3723
+ if (left < right && apartV) return { axis: 'v', at: (left + right) / 2, forward: a.bottom <= b.top };
3724
+ if (top < bottom && apartH) return { axis: 'h', at: (top + bottom) / 2, forward: a.right <= b.left };
3725
+ return null;
3726
+ }
3727
+
3712
3728
  /** Pure. Anchor for one end of an edge. A node that knows its own outline
3713
3729
  * supplies `info.boundaryPoint(dx, dy) → {x, y}` (DiagramEditor templates
3714
3730
  * do, rotation included), and the anchor is where the ray toward the other
@@ -4802,8 +4818,12 @@
4802
4818
  choices: [['straight', 'Straight'], ['orthogonal', 'Orthogonal'], ['curved', 'Curved'], ['polyline', 'Around shapes']],
4803
4819
  },
4804
4820
  {
4805
- key: 'attach', label: 'Attach to', type: 'select', default: 'floating',
4806
- choices: [['floating', 'Outline, aimed at the other end'], ['sides', 'Middle of the facing side']],
4821
+ key: 'attach', label: 'Attach to', type: 'select', default: 'shortest',
4822
+ choices: [
4823
+ ['shortest', 'Shortest: straight across where the shapes overlap, else aimed at the other end'],
4824
+ ['floating', 'Outline, aimed at the other end'],
4825
+ ['sides', 'Middle of the facing side'],
4826
+ ],
4807
4827
  },
4808
4828
  {
4809
4829
  key: 'lineStyle', label: 'Line', type: 'select', default: 'solid',
@@ -10182,6 +10202,30 @@
10182
10202
  return ticks;
10183
10203
  }
10184
10204
 
10205
+ /**
10206
+ * Ticks over a range that may go below zero: one common "nice" step, from at or below `minVal`
10207
+ * to at or above `maxVal`, always taking in 0 so bars have a baseline. Without a negative it is
10208
+ * `niceTicks(maxVal)`.
10209
+ * @param {number} minVal
10210
+ * @param {number} maxVal
10211
+ * @param {number} [count=4] desired number of intervals
10212
+ * @returns {number[]}
10213
+ */
10214
+ static niceRange(minVal, maxVal, count = 4) {
10215
+ const lo = Math.min(0, minVal || 0);
10216
+ const hi = Math.max(0, maxVal || 0);
10217
+ if (lo === 0) return Axis.niceTicks(hi, count);
10218
+ const raw = (hi - lo) / count;
10219
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
10220
+ const frac = raw / mag;
10221
+ const step = frac < 1.5 ? mag : frac < 3.5 ? 2 * mag : frac < 7.5 ? 5 * mag : 10 * mag;
10222
+ const first = Math.floor(lo / step + 1e-9);
10223
+ const last = Math.ceil(hi / step - 1e-9);
10224
+ const ticks = [];
10225
+ for (let i = first; i <= last; i++) ticks.push(parseFloat((i * step).toFixed(10)));
10226
+ return ticks;
10227
+ }
10228
+
10185
10229
  /**
10186
10230
  * How often a category label fits along an axis `width` wide: 1 (every
10187
10231
  * label), 2 (every other), … — so a long series (a minute of samples, a
@@ -10226,19 +10270,37 @@
10226
10270
  * @returns {number} the nice domain max the ticks actually span
10227
10271
  */
10228
10272
  drawYAxis(renderer, box, rawMax) {
10229
- const ticks = Axis.niceTicks(rawMax);
10230
- const niceMax = ticks[ticks.length - 1] || 1;
10273
+ return this.drawYRange(renderer, box, 0, rawMax).max;
10274
+ }
10275
+
10276
+ /**
10277
+ * Draw a y-axis over a range that may go below zero (`niceRange`): ticks, labels, gridlines,
10278
+ * and, when the range does go below zero, a zero line across the plot where it crosses.
10279
+ *
10280
+ * @param {*} renderer a renderer implementing .path() and .text()
10281
+ * @param {{x:number, y:number, width:number, height:number}} box plot-area box
10282
+ * @param {number} rawMin the raw data minimum (0 or more: the axis starts at 0)
10283
+ * @param {number} rawMax the raw data maximum
10284
+ * @returns {{min:number, max:number}} the nice domain the ticks actually span
10285
+ */
10286
+ drawYRange(renderer, box, rawMin, rawMax) {
10287
+ const ticks = Axis.niceRange(rawMin, rawMax);
10288
+ const min = ticks[0];
10289
+ const max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
10290
+ const yOf = (v) => box.y + box.height - ((v - min) / (max - min)) * box.height;
10231
10291
  for (const t of ticks) {
10232
- const ty = box.y + box.height - (t / niceMax) * box.height;
10292
+ const ty = yOf(t);
10233
10293
  renderer.path(`M${box.x - 5},${ty} L${box.x},${ty}`, 'none', { color: '#999', width: 1 });
10234
- if (t > 0) {
10294
+ if (t !== 0) {
10235
10295
  renderer.path(`M${box.x},${ty} L${box.x + box.width},${ty}`, 'none', { color: '#eee', width: 1 });
10296
+ } else if (min < 0) {
10297
+ renderer.path(`M${box.x},${ty} L${box.x + box.width},${ty}`, 'none', { color: '#666', width: 1, zeroLine: true });
10236
10298
  }
10237
10299
  renderer.text(Axis.formatValue(t), box.x - 10, ty, {
10238
10300
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
10239
10301
  });
10240
10302
  }
10241
- return niceMax;
10303
+ return { min, max };
10242
10304
  }
10243
10305
  }
10244
10306
 
@@ -10628,9 +10690,27 @@
10628
10690
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
10629
10691
  }
10630
10692
 
10631
- /** Draw a Y axis and return the nice-rounded max value. */
10632
- _drawYAxis(rawMax) {
10633
- return new Axis().drawYAxis(this.renderer, this._plotBox, rawMax);
10693
+ /**
10694
+ * Draw a Y axis and return the nice-rounded max value. With a negative `rawMin` the axis goes
10695
+ * below zero, with a zero line; `_barSpan` then draws each bar from zero, up or down.
10696
+ */
10697
+ _drawYAxis(rawMax, rawMin = 0) {
10698
+ this._domain = new Axis().drawYRange(this.renderer, this._plotBox, rawMin, rawMax);
10699
+ return this._domain.max;
10700
+ }
10701
+
10702
+ /**
10703
+ * Where a bar of `value` goes on the axis `_drawYAxis` drew: from the zero line up to it, or
10704
+ * down to it when negative.
10705
+ * @returns {{top: number, height: number, end: number}} `end` is the bar's far end from zero
10706
+ */
10707
+ _barSpan(value) {
10708
+ const { y, height } = this._plotBox;
10709
+ const { min, max } = this._domain;
10710
+ const yOf = (v) => y + height - ((v - min) / (max - min)) * height;
10711
+ const zero = yOf(0);
10712
+ const end = yOf(value);
10713
+ return { top: Math.min(zero, end), height: Math.abs(end - zero), end };
10634
10714
  }
10635
10715
 
10636
10716
  _render() {
@@ -10673,7 +10753,7 @@
10673
10753
  _computeMaxValue() {
10674
10754
  const data = this.data;
10675
10755
  const rawMax = Math.max(...data.map(d => d.value)) || 1;
10676
- return this._drawYAxis(rawMax);
10756
+ return this._drawYAxis(rawMax, Math.min(0, ...data.map(d => d.value)));
10677
10757
  }
10678
10758
 
10679
10759
  _beforeBars(maxValue) {
@@ -10695,12 +10775,12 @@
10695
10775
  }
10696
10776
 
10697
10777
  const color = colors ? this._palette.colorAt(i) : barColor;
10698
- const barHeight = (d.value / maxValue) * height;
10778
+ const bar = this._barSpan(d.value);
10699
10779
  const barX = x + i * (bandWidth + margin);
10700
- const barY = y + height - barHeight;
10701
10780
 
10702
- this._datum(this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
10703
- this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
10781
+ this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
10782
+ // The value just past the bar's end: above a bar going up, below one going down.
10783
+ this.renderer.text(String(d.value), barX + bandWidth / 2, d.value < 0 ? bar.end + 12 : bar.end - 5, { textAnchor, fontSize: '10px', fill: '#333' });
10704
10784
  if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
10705
10785
  });
10706
10786
  }
@@ -10782,14 +10862,30 @@
10782
10862
  return top + (1 + shown + more) * TableChart.ROW_H + bottom;
10783
10863
  }
10784
10864
 
10785
- /** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
10786
- static cellText(value, nullText = '—') {
10865
+ /**
10866
+ * A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. With a
10867
+ * `format` (`formatFor`'s), a number is written by it instead.
10868
+ */
10869
+ static cellText(value, nullText = '—', format = null) {
10787
10870
  if (value == null) return nullText;
10871
+ if (typeof value === 'number' && format) return String(typeof format === 'function' ? format(value) : format.format(value));
10788
10872
  if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
10789
10873
  if (value instanceof Date) return value.toISOString().slice(0, 10);
10790
10874
  return String(value);
10791
10875
  }
10792
10876
 
10877
+ /**
10878
+ * The number format for one column or measure, from a view's `format` option: a function
10879
+ * (number → text) or an `Intl.NumberFormat` for every number, or an object naming either per
10880
+ * column (`{ Amount: new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }) }`),
10881
+ * where a column it does not name keeps the plain text. Null when there is none.
10882
+ */
10883
+ static formatFor(format, name) {
10884
+ if (!format) return null;
10885
+ if (typeof format === 'function' || format instanceof Intl.NumberFormat) return format;
10886
+ return format[name] ?? null;
10887
+ }
10888
+
10793
10889
  _render() {
10794
10890
  const data = this.data ?? [];
10795
10891
  const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
@@ -10853,18 +10949,17 @@
10853
10949
  * Array<{name: string, values: Array<{label: string, value: number}>}>
10854
10950
  *
10855
10951
  * Each category (`label`) gets a cluster of bars side-by-side, one per series. A value a series does
10856
- * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero.
10952
+ * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero. A negative value
10953
+ * is drawn down from a zero line, the axis taking in the smallest value too.
10857
10954
  */
10858
10955
  class GroupedBarChart extends BarChartBase {
10859
10956
  static chartType = 'groupedBar';
10860
10957
 
10861
10958
  _computeMaxValue() {
10862
10959
  const series = this.data;
10863
- const rawMax = Math.max(
10864
- ...series.flatMap(s => s.values.map(v => v.value).filter(v => v != null)),
10865
- 1,
10866
- );
10867
- return this._drawYAxis(rawMax);
10960
+ const values = series.flatMap(s => s.values.map(v => v.value).filter(v => v != null));
10961
+ const rawMax = Math.max(...values, 1);
10962
+ return this._drawYAxis(rawMax, Math.min(0, ...values));
10868
10963
  }
10869
10964
 
10870
10965
  _renderBars(maxValue) {
@@ -10906,12 +11001,11 @@
10906
11001
  series.forEach((seriesData, seriesIndex) => {
10907
11002
  const value = valueBySeriesLabel[seriesIndex].get(label);
10908
11003
  if (value == null) return;
10909
- const barHeight = (value / maxValue) * height;
11004
+ const bar = this._barSpan(value);
10910
11005
  const barX = clusterX + seriesIndex * barWidth + barGap / 2;
10911
- const barY = y + height - barHeight;
10912
11006
  const color = this._palette.colorAt(seriesIndex);
10913
11007
 
10914
- this._datum(this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
11008
+ this._datum(this.renderer.rect(barX, bar.top, finalBarWidth, bar.height, color, null, {
10915
11009
  tooltip: `${seriesData.name} — ${label}: ${value}`,
10916
11010
  }), { label, series: seriesData.name });
10917
11011
  });
@@ -13019,8 +13113,10 @@
13019
13113
  * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
13020
13114
  * @param {ChartChooser} [options.chooser]
13021
13115
  * @param {function} [options.highlight] row → true for a row drawn highlighted (a host's selection)
13116
+ * @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every column or per
13117
+ * column (`TableChart.formatFor`). A copy keeps the raw numbers.
13022
13118
  */
13023
- constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null } = {}) {
13119
+ constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null, format = null } = {}) {
13024
13120
  this.rows = rows;
13025
13121
  this.columns = ChartChooser.describe(rows, columns);
13026
13122
  this.complete = complete;
@@ -13030,6 +13126,7 @@
13030
13126
  this.copy = copy ?? TableView.clipboard;
13031
13127
  this.chooser = chooser ?? new ChartChooser();
13032
13128
  this.highlight = highlight;
13129
+ this.format = format;
13033
13130
  /** The sort keys, most significant first: `{ column, direction: 'asc' | 'desc' }`. */
13034
13131
  this.sorts = [];
13035
13132
  this.filters = []; // { column, op, value }
@@ -13037,6 +13134,11 @@
13037
13134
  this.page = 0;
13038
13135
  }
13039
13136
 
13137
+ /** A column's value as the table shows it, in the column's `format` when there is one. */
13138
+ valueText(column, value, nullText = '—') {
13139
+ return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, column));
13140
+ }
13141
+
13040
13142
  static clipboard(text) {
13041
13143
  return globalThis.navigator?.clipboard?.writeText(text);
13042
13144
  }
@@ -13374,7 +13476,7 @@
13374
13476
  columns.forEach((c, i) => {
13375
13477
  const left = x0 + i * colW;
13376
13478
  const hit = this.renderer.rect(left, top, colW, ROW_H, 'transparent', null);
13377
- const text = this._cell(TableChart.cellText(row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
13479
+ const text = this._cell(view.valueText(c.key, row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
13378
13480
  this._on(interactive, [hit, text], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(row, c.key)), null);
13379
13481
  });
13380
13482
  top += ROW_H;
@@ -13474,6 +13576,8 @@
13474
13576
  * @param {Array<{measure?, below?, atLeast?, fill, label?}>} [options.trafficLights] the host's rules:
13475
13577
  * the first whose measure (any, when absent) and range match colours a detail cell
13476
13578
  * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
13579
+ * @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
13580
+ * measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
13477
13581
  */
13478
13582
  constructor(rows, options = {}) {
13479
13583
  this.onChange = options.onChange ?? null;
@@ -13484,6 +13588,7 @@
13484
13588
  this.members = options.members ?? null;
13485
13589
  this.menus = options.menus ?? {};
13486
13590
  this.copy = options.copy ?? PivotView.clipboard;
13591
+ this.format = options.format ?? null;
13487
13592
  this._listeners = new Set();
13488
13593
  this.lightRules = options.trafficLights ?? null;
13489
13594
  /** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
@@ -14029,18 +14134,24 @@
14029
14134
  return PivotView.LIGHTS[good];
14030
14135
  }
14031
14136
 
14137
+ /** A measure's value as the grid shows it, in the measure's `format` when there is one. */
14138
+ valueText(measure, value, nullText = '—') {
14139
+ return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, measure));
14140
+ }
14141
+
14032
14142
  /** The traffic lights in words, for a line under the grid; null when off. */
14033
14143
  lightsLegend() {
14034
14144
  if (!this.lights) return null;
14035
14145
  const tail = ' (detail cells; totals not coloured)';
14036
14146
  if (this.lights === 'rules') {
14037
14147
  const said = this.lightRules.map(r => r.label ?? [
14038
- r.measure, r.atLeast !== undefined ? '≥ ' + r.atLeast : null, r.below !== undefined ? '< ' + r.below : null,
14148
+ r.measure, r.atLeast !== undefined ? '≥ ' + this.valueText(r.measure, r.atLeast) : null,
14149
+ r.below !== undefined ? '< ' + this.valueText(r.measure, r.below) : null,
14039
14150
  ].filter(Boolean).join(' '));
14040
14151
  return 'Traffic lights: ' + said.join('; ') + tail;
14041
14152
  }
14042
14153
  const better = this.lights === 'thirds-high' ? 'higher' : 'lower';
14043
- const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + TableChart.cellText(a) + ' / ' + TableChart.cellText(b)).join('; ');
14154
+ const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + this.valueText(m, a) + ' / ' + this.valueText(m, b)).join('; ');
14044
14155
  return 'Traffic lights by thirds, ' + better + ' is better — cut at ' + (cuts || 'no values') + tail;
14045
14156
  }
14046
14157
 
@@ -14258,7 +14369,7 @@
14258
14369
  const lit = view.cellFill(node, c, cuts);
14259
14370
  if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
14260
14371
  const row = view.cell(node, c.node);
14261
- const text = row === null ? '' : TableChart.cellText(row[c.measure], nullText);
14372
+ const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
14262
14373
  const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
14263
14374
  // A cell with a value opens its menu (drill-through) where the host gives one.
14264
14375
  if (row !== null && view.cellHasMenu()) {
@@ -24717,6 +24828,86 @@
24717
24828
  }
24718
24829
  }
24719
24830
 
24831
+ /**
24832
+ * DeleteFromModelCommand — elements taken out of the model (Archi's "Delete
24833
+ * from Model"), not just off one view: their relationships go too (and any
24834
+ * relationship ending on one of those), and with them every box and line
24835
+ * drawing them on every view, and every view's own line (a connection) to
24836
+ * one of those boxes. Undo puts everything back as it was, ids and all.
24837
+ *
24838
+ * What goes is ArchimateModel#usage's: say it before asking.
24839
+ */
24840
+ class DeleteFromModelCommand extends Command {
24841
+ /**
24842
+ * @param {ArchimateModel} model
24843
+ * @param {Array<string>} elementIds
24844
+ */
24845
+ constructor(model, elementIds) {
24846
+ const ids = elementIds.filter(id => model.getElement(id));
24847
+ const first = model.getElement(ids[0]);
24848
+ super(ids.length === 1 ? `Delete "${first?.name || ids[0]}" from model` : `Delete ${ids.length} elements from model`);
24849
+ this.model = model;
24850
+ this.elementIds = ids;
24851
+ this._before = null;
24852
+ }
24853
+
24854
+ execute() {
24855
+ const { model } = this;
24856
+ if (!this.elementIds.length) return;
24857
+ this._before = this._snapshot();
24858
+ const { relationships } = model.usage(this.elementIds);
24859
+ const goneElements = new Set(this.elementIds);
24860
+ const goneRelationships = new Set(relationships.map(r => r.id));
24861
+ for (const view of model.views()) {
24862
+ if (Array.isArray(view.elementIds)) view.elementIds = view.elementIds.filter(id => !goneElements.has(id));
24863
+ const goneBoxes = new Set();
24864
+ for (const inst of [...(view.elementInstances?.values() ?? [])]) {
24865
+ if (goneElements.has(inst.elementId)) { goneBoxes.add(inst.id); view.elementInstances.delete(inst.id); }
24866
+ }
24867
+ for (const inst of [...(view.relationshipInstances?.values() ?? [])]) {
24868
+ if (goneRelationships.has(inst.relationshipId)) view.relationshipInstances.delete(inst.id);
24869
+ }
24870
+ for (const line of [...(view.viewConnections?.values() ?? [])]) {
24871
+ if (goneBoxes.has(line.sourceInstanceId) || goneBoxes.has(line.targetInstanceId)) view.viewConnections.delete(line.id);
24872
+ }
24873
+ }
24874
+ for (const id of goneRelationships) model.removeRelationship(id);
24875
+ for (const id of goneElements) model.removeElement(id);
24876
+ }
24877
+
24878
+ undo() {
24879
+ if (!this._before) return;
24880
+ const { model } = this;
24881
+ const b = this._before;
24882
+ model._elements = new Map(b.elements);
24883
+ model._elementFolder = new Map(b.folders);
24884
+ model._relationships = new Map(b.relationships);
24885
+ for (const v of b.views) {
24886
+ v.view.elementIds = v.elementIds;
24887
+ if (v.instances) v.view.elementInstances = new Map(v.instances);
24888
+ if (v.lines) v.view.relationshipInstances = new Map(v.lines);
24889
+ if (v.connections) v.view.viewConnections = new Map(v.connections);
24890
+ }
24891
+ }
24892
+
24893
+ /** Everything the delete changes, to put back (the objects themselves, so ids and state are kept). @private */
24894
+ _snapshot() {
24895
+ const { model } = this;
24896
+ return {
24897
+ elements: [...model._elements],
24898
+ folders: [...model._elementFolder],
24899
+ relationships: [...model._relationships],
24900
+ views: model.views().map(view => ({
24901
+ view,
24902
+ elementIds: Array.isArray(view.elementIds) ? [...view.elementIds] : view.elementIds,
24903
+ instances: view.elementInstances ? [...view.elementInstances] : null,
24904
+ lines: view.relationshipInstances ? [...view.relationshipInstances] : null,
24905
+ connections: view.viewConnections ? [...view.viewConnections] : null,
24906
+ })),
24907
+ };
24908
+ }
24909
+ }
24910
+
24720
24911
  /**
24721
24912
  * ConceptsCompared — an ArchimateModel's elements, or its relationships, as
24722
24913
  * two models are compared on them: one's content is all it holds (name, type,
@@ -25456,6 +25647,38 @@
25456
25647
  return new MergeElementsCommand(this, keepId, otherIds, choices);
25457
25648
  }
25458
25649
 
25650
+ /**
25651
+ * Where elements are used, so deleting them from the model can say what goes
25652
+ * with them: the views drawing them (with how many boxes on each), and the
25653
+ * relationships that go too — each one ending on one of them, and each one
25654
+ * ending on such a relationship, and so on.
25655
+ * @param {Array<string>} elementIds
25656
+ * @returns {{views: Array<{view: ArchimateView, boxes: number}>, relationships: Array<Object>}}
25657
+ */
25658
+ usage(elementIds) {
25659
+ const gone = new Set(elementIds);
25660
+ const relationships = [];
25661
+ for (let grew = true; grew;) {
25662
+ grew = false;
25663
+ for (const rel of this.relationships()) {
25664
+ if (!gone.has(rel.id) && (gone.has(rel.source) || gone.has(rel.target))) {
25665
+ gone.add(rel.id);
25666
+ relationships.push(rel);
25667
+ grew = true;
25668
+ }
25669
+ }
25670
+ }
25671
+ const views = this.views()
25672
+ .map(view => ({ view, boxes: [...(view.elementInstances?.values() ?? [])].filter(i => elementIds.includes(i.elementId)).length }))
25673
+ .filter(v => v.boxes > 0);
25674
+ return { views, relationships };
25675
+ }
25676
+
25677
+ /** Take elements out of the model, with their relationships and everything drawing them (not yet executed). */
25678
+ deleteFromModelCommand(elementIds) {
25679
+ return new DeleteFromModelCommand(this, elementIds);
25680
+ }
25681
+
25459
25682
  /** Two models are compared on, and merged by, their elements, relationships and views (in the order brought). */
25460
25683
  comparedKinds() {
25461
25684
  return [new ElementsCompared(), new RelationshipsCompared(), new ArchimateViewsCompared()];
@@ -27389,6 +27612,16 @@
27389
27612
  return { x: this.x + ux * t, y: this.y + uy * t };
27390
27613
  }
27391
27614
 
27615
+ /**
27616
+ * Where a line from (px, py), a point inside the shape, heading in
27617
+ * direction (dx, dy) leaves its outline (the shape unrotated).
27618
+ * @returns {{x: number, y: number}}
27619
+ */
27620
+ exitPoint(px, py, dx, dy) {
27621
+ const t = rayExitDistance(this.outline(), px, py, dx, dy);
27622
+ return { x: px + dx * t, y: py + dy * t };
27623
+ }
27624
+
27392
27625
  /** Centre of the name text; the z-index line sits just below it. */
27393
27626
  get labelAnchor() { return { x: this.x, y: this.y - 8 }; }
27394
27627
 
@@ -29388,6 +29621,229 @@
29388
29621
  }
29389
29622
  }
29390
29623
 
29624
+ /** At most this many views or relationships are named; the rest are counted. */
29625
+ const LISTED = 10;
29626
+
29627
+ /**
29628
+ * What deleting elements from the model takes with it, in words, for the
29629
+ * confirmation: the views they are on (and how many boxes), and the
29630
+ * relationships that go too (ArchimateModel#usage).
29631
+ * @param {ArchimateModel} model
29632
+ * @param {Array<string>} elementIds
29633
+ * @returns {string}
29634
+ */
29635
+ function deleteFromModelMessage(model, elementIds) {
29636
+ const elements = elementIds.map(id => model.getElement(id)).filter(Boolean);
29637
+ const name = (el) => `"${el.name || el.id}"`;
29638
+ const { views, relationships } = model.usage(elementIds);
29639
+ const list = (items) => [...items.slice(0, LISTED).map(s => ` • ${s}`), ...(items.length > LISTED ? [` • and ${items.length - LISTED} more`] : [])];
29640
+ const end = (id) => { const c = model.getElement(id) ?? model.getRelationship(id); return c ? `${c.name || typeTitle(c.type ?? c.kind ?? '') || c.id}` : id; };
29641
+ const lines = [
29642
+ elements.length === 1 ? `${name(elements[0])} (${typeTitle(elements[0].type ?? '')}) goes from the model.` : `These ${elements.length} elements go from the model: ${elements.map(name).join(', ')}.`,
29643
+ '',
29644
+ views.length
29645
+ ? `Used in ${views.length} view${views.length === 1 ? '' : 's'}, where ${elements.length === 1 ? 'its' : 'their'} boxes go:`
29646
+ : 'Used in no view.',
29647
+ ...list(views.map(({ view, boxes }) => `${view.name || view.id}${boxes > 1 ? ` (${boxes} boxes)` : ''}`)),
29648
+ '',
29649
+ relationships.length
29650
+ ? `${relationships.length} relationship${relationships.length === 1 ? '' : 's'} go${relationships.length === 1 ? 'es' : ''} too:`
29651
+ : 'No relationships.',
29652
+ ...list(relationships.map(r => `${end(r.source)} → ${end(r.target)} (${typeTitle(r.kind ?? r.type ?? '') || 'relationship'}${r.name ? `: ${r.name}` : ''})`)),
29653
+ '',
29654
+ 'Undo puts them all back.',
29655
+ ];
29656
+ return lines.join('\n');
29657
+ }
29658
+
29659
+ /**
29660
+ * Ask before deleting elements from the model: a dialog saying where they are
29661
+ * used and what goes with them; **Delete from model** runs `onConfirm()`.
29662
+ * @returns {FormDialog|null} null when none of the ids is an element
29663
+ */
29664
+ function askDeleteFromModel(model, elementIds, { onConfirm, document: doc = globalThis.document } = {}) {
29665
+ const ids = elementIds.filter(id => model.getElement(id));
29666
+ if (!ids.length) return null;
29667
+ const dialog = new FormDialog({
29668
+ title: ids.length === 1 ? 'Delete from model' : `Delete ${ids.length} elements from model`,
29669
+ description: deleteFromModelMessage(model, ids),
29670
+ fields: [],
29671
+ submitLabel: 'Delete from model',
29672
+ document: doc,
29673
+ onApply: () => onConfirm(ids),
29674
+ });
29675
+ dialog.open();
29676
+ return dialog;
29677
+ }
29678
+
29679
+ /**
29680
+ * Heat maps an ArchiMate view can be coloured by beyond its elements' own
29681
+ * fields (ArchimateCanvasEditor#colourKeys): what change a deliverable or work
29682
+ * package makes to each element, and a formula over the elements' numeric
29683
+ * properties. Pure: no editor, no DOM.
29684
+ */
29685
+
29686
+ /** The change heat map's colours. */
29687
+ const CHANGE_COLOURS = Object.freeze({ Create: '#FDD835', Update: '#1E88E5', Delete: '#D81B60' });
29688
+
29689
+ /** Element types that make changes: their relationships to an element say what they do to it. */
29690
+ const CHANGE_MAKERS = Object.freeze(['deliverable', 'work-package']);
29691
+
29692
+ /** The words for each change, synonyms included (verbs as base forms; their -s, -ed, -ing forms match too). */
29693
+ const CHANGE_WORDS = Object.freeze({
29694
+ Delete: ['delete', 'remove', 'retire', 'decommission', 'drop', 'sunset', 'terminate', 'eliminate', 'discontinue', 'deprecate', 'withdraw', 'phase out', 'shut down', 'switch off'],
29695
+ Create: ['create', 'add', 'new', 'introduce', 'build', 'built', 'establish', 'implement', 'insert', 'provision', 'set up', 'launch', 'develop', 'onboard'],
29696
+ Update: ['update', 'change', 'modify', 'amend', 'enhance', 'upgrade', 'improve', 'revise', 'extend', 'migrate', 'refactor', 'replace', 'rework', 'adapt', 'alter', 'patch'],
29697
+ });
29698
+
29699
+ /** Which change wins when an element is given more than one: the one with most consequence. */
29700
+ const PRECEDENCE = ['Delete', 'Create', 'Update'];
29701
+
29702
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29703
+
29704
+ /**
29705
+ * A word and its inflections: retire → retires, retired, retiring; drop →
29706
+ * dropped, dropping; modify → modifies, modified, modifying.
29707
+ */
29708
+ function forms(word) {
29709
+ const [verb, ...rest] = word.split(' ');
29710
+ const tail = rest.length ? `\\s+${rest.map(escape).join('\\s+')}` : '';
29711
+ const stem = verb.endsWith('e') ? verb.slice(0, -1) : verb;
29712
+ const doubled = /[^aeiou][aeiou][bdgmnpt]$/.test(verb) ? `|${escape(verb)}${verb.at(-1)}(?:ed|ing)` : '';
29713
+ const y = /[^aeiou]y$/.test(verb) ? `|${escape(verb.slice(0, -1))}i(?:es|ed)` : '';
29714
+ return `(?:${escape(verb)}(?:s|es|d|ed)?|${escape(stem)}(?:ed|ing|es)${doubled}${y})${tail}`;
29715
+ }
29716
+
29717
+ const PATTERNS = Object.fromEntries(Object.entries(CHANGE_WORDS)
29718
+ .map(([change, words]) => [change, new RegExp(`\\b(?:${words.map(forms).join('|')})\\b`, 'i')]));
29719
+
29720
+ /**
29721
+ * The change a piece of text names ('Create', 'Update' or 'Delete'), or null.
29722
+ * Where it names more than one, the one with most consequence (delete, then
29723
+ * create, then update).
29724
+ */
29725
+ function changeAction(text) {
29726
+ if (!text) return null;
29727
+ return PRECEDENCE.find(change => PATTERNS[change].test(String(text))) ?? null;
29728
+ }
29729
+
29730
+ /**
29731
+ * The change one relationship says a deliverable or work package makes: its
29732
+ * name, or, when it has none, the name of the deliverable or work package at
29733
+ * one of its ends (`toward`: the end that is not the element changed, when
29734
+ * known). Null when neither end makes changes, or nothing is said.
29735
+ * @returns {'Create'|'Update'|'Delete'|null}
29736
+ */
29737
+ function relationshipChange(model, rel, toward = null) {
29738
+ const ends = toward ? [toward] : [rel.source, rel.target];
29739
+ const maker = ends.map(id => model.getElement(id)).find(e => e && CHANGE_MAKERS.includes(e.type));
29740
+ if (!maker) return null;
29741
+ return changeAction((rel.name ?? '').trim() ? rel.name : maker.name);
29742
+ }
29743
+
29744
+ /**
29745
+ * What change the deliverables and work packages related to an element make
29746
+ * to it: for each relationship between them (either way), relationshipChange.
29747
+ * Where they differ, the one with most consequence. Null when none says.
29748
+ * @param {ArchimateModel} model
29749
+ * @param {string} elementId
29750
+ * @returns {'Create'|'Update'|'Delete'|null}
29751
+ */
29752
+ function elementChange(model, elementId) {
29753
+ const found = new Set();
29754
+ for (const rel of model.relationships()) {
29755
+ const other = rel.source === elementId ? rel.target : rel.target === elementId ? rel.source : null;
29756
+ const change = other && relationshipChange(model, rel, other);
29757
+ if (change) found.add(change);
29758
+ }
29759
+ return PRECEDENCE.find(change => found.has(change)) ?? null;
29760
+ }
29761
+
29762
+ /** Whether a model has anything that makes changes (a deliverable or work package). */
29763
+ function hasChangeMakers(model) {
29764
+ return model.elements().some(e => CHANGE_MAKERS.includes(e.type));
29765
+ }
29766
+
29767
+ /**
29768
+ * The names a formula can use for the properties: each property name as an
29769
+ * identifier (anything not a letter, digit or _ becomes _; a leading digit gets
29770
+ * a _ before it), mapped to the property it stands for.
29771
+ * @param {Array<Object>} elements
29772
+ * @returns {Map<string, string>} identifier -> property name
29773
+ */
29774
+ function propertyParams(elements) {
29775
+ const params = new Map();
29776
+ for (const e of elements) {
29777
+ for (const p of e.properties ?? []) {
29778
+ const name = p.name ?? p.key;
29779
+ if (!name) continue;
29780
+ let id = String(name).replace(/[^A-Za-z0-9_]/g, '_');
29781
+ if (/^\d/.test(id)) id = `_${id}`;
29782
+ if (!params.has(id)) params.set(id, name);
29783
+ }
29784
+ }
29785
+ return params;
29786
+ }
29787
+
29788
+ /**
29789
+ * A formula's result for each element (by id): the formula over the
29790
+ * element's numeric properties, by their propertyParams names. An element
29791
+ * missing a property the formula uses, or whose value is not a number, or
29792
+ * whose result is not a finite number, has none.
29793
+ * Throws the formula's own error when it does not parse (an unknown name, say).
29794
+ * @returns {Map<string, number>}
29795
+ */
29796
+ function expressionValues(elements, formula) {
29797
+ const params = propertyParams(elements);
29798
+ const names = [...params.keys()];
29799
+ if (!names.length) throw new Error('No element here has a property to use');
29800
+ const fn = compileFormula(formula, names);
29801
+ const used = names.filter(n => new RegExp(`\\b${n}\\b`).test(formula));
29802
+ const results = new Map();
29803
+ for (const e of elements) {
29804
+ const valueOf = (id) => {
29805
+ const p = (e.properties ?? []).find(q => (q.name ?? q.key) === params.get(id));
29806
+ const n = p === undefined || p.value === null || p.value === '' ? NaN : Number(p.value);
29807
+ return n;
29808
+ };
29809
+ if (used.some(id => !Number.isFinite(valueOf(id)))) continue;
29810
+ const result = fn(...names.map(id => { const v = valueOf(id); return Number.isFinite(v) ? v : 0; }));
29811
+ if (Number.isFinite(result)) results.set(e.id, result);
29812
+ }
29813
+ return results;
29814
+ }
29815
+
29816
+ /** A number as a label: integers as they are, others to at most 3 decimals. */
29817
+ const show = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toFixed(3))));
29818
+
29819
+ /**
29820
+ * Labels for a formula's results: each distinct result its own, in order,
29821
+ * when there are at most `distinct` of them; else `bands` equal-width bands
29822
+ * from the smallest to the largest ("10 – 20"), the last taking in the largest;
29823
+ * a band no result falls in is left out.
29824
+ * @param {Iterable<number>} numbers
29825
+ * @returns {{labels: string[], labelOf: (n: number) => string}}
29826
+ */
29827
+ function resultBands(numbers, { distinct = 8, bands = 5 } = {}) {
29828
+ const values = [...new Set(numbers)].sort((a, b) => a - b);
29829
+ if (values.length <= distinct) return { labels: values.map(show), labelOf: show };
29830
+ const lo = values[0], hi = values.at(-1), width = (hi - lo) / bands;
29831
+ const all = Array.from({ length: bands }, (_, i) => `${show(lo + i * width)} – ${show(i === bands - 1 ? hi : lo + (i + 1) * width)}`);
29832
+ const labelOf = (n) => all[Math.min(bands - 1, Math.floor((n - lo) / width))];
29833
+ const used = new Set(values.map(labelOf));
29834
+ return { labels: all.filter(l => used.has(l)), labelOf };
29835
+ }
29836
+
29837
+ /** `count` colours from light to dark (a sequential scale, light yellow to dark red), for ordered values. */
29838
+ function sequentialColours(count) {
29839
+ const from = [255, 247, 188], to = [189, 0, 38];
29840
+ const hex = (c) => Math.round(c).toString(16).padStart(2, '0');
29841
+ return Array.from({ length: count }, (_, i) => {
29842
+ const t = count === 1 ? 1 : i / (count - 1);
29843
+ return `#${from.map((f, k) => hex(f + (to[k] - f) * t)).join('')}`;
29844
+ });
29845
+ }
29846
+
29391
29847
  /**
29392
29848
  * ShowElementCommand — put an existing model element on a view (dropped from
29393
29849
  * the model tree), with its relationships to the elements already there, as
@@ -30870,10 +31326,10 @@
30870
31326
  /**
30871
31327
  * Work out the bitmap size to rasterize at.
30872
31328
  *
30873
- * `scale` is what the caller asked for (2 = retina-sharp). It is reduced, never
30874
- * the diagram's own dimensions, when the result would be too large: halving the
30875
- * scale of a huge diagram still shows all of it, whereas cropping or shrinking
30876
- * the diagram loses content or legibility.
31329
+ * `scale` is what the caller asked for (1 = 100%, 2 = retina-sharp, 0.5 = half
31330
+ * size). It is reduced, never the diagram's own dimensions, when the result
31331
+ * would be too large: halving the scale of a huge diagram still shows all of
31332
+ * it, whereas cropping or shrinking the diagram loses content or legibility.
30877
31333
  *
30878
31334
  * @returns {{width:number, height:number, scale:number, clamped:boolean}}
30879
31335
  */
@@ -30888,10 +31344,6 @@
30888
31344
  s = Math.sqrt(maxPixels / (w * h));
30889
31345
  clamped = true;
30890
31346
  }
30891
- // A scale below 1:1 would be a downscale the caller never asked for, but a
30892
- // diagram that is itself over the budget leaves no choice.
30893
- if (s < 1 && w * h <= maxPixels) s = 1;
30894
-
30895
31347
  return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)), scale: s, clamped };
30896
31348
  }
30897
31349
 
@@ -30906,8 +31358,10 @@
30906
31358
  * @param {number} [options.width] pixels; omitted leaves what is there
30907
31359
  * @param {number} [options.height]
30908
31360
  * @param {string|null} [options.background='#ffffff'] null for transparent
31361
+ * @param {string} [options.viewBox] the drawing's own units, when it has none
31362
+ * (so a picture sized other than as drawn scales rather than crops)
30909
31363
  */
30910
- function prepareSvgMarkup(markup, { width, height, background = '#ffffff' } = {}) {
31364
+ function prepareSvgMarkup(markup, { width, height, background = '#ffffff', viewBox } = {}) {
30911
31365
  let out = String(markup ?? '').trim();
30912
31366
  const open = out.match(/^<svg\b[^>]*>/i);
30913
31367
  if (!open) return out; // not an <svg> — hand it back untouched
@@ -30926,13 +31380,23 @@
30926
31380
  };
30927
31381
  if (Number.isFinite(width) && width > 0) setAttr('width', Math.round(width));
30928
31382
  if (Number.isFinite(height) && height > 0) setAttr('height', Math.round(height));
31383
+ if (viewBox && !/\sviewBox\s*=/i.test(tag)) setAttr('viewBox', viewBox);
30929
31384
 
30930
31385
  out = tag + out.slice(open[0].length);
30931
31386
 
30932
31387
  if (background) {
30933
- // First child, so it sits behind everything. Percentages rather than
30934
- // pixels so it still covers the drawing if a viewBox rescales it.
30935
- out = out.replace(tag, `${tag}<rect x="0" y="0" width="100%" height="100%" fill="${background}"/>`);
31388
+ // First child, so it sits behind everything. With a viewBox it reaches
31389
+ // a whole viewBox past each side (the picture clips it): a percentage
31390
+ // counts from (0,0), so a viewBox starting elsewhere (a cropped
31391
+ // drawing) would leave a strip bare, and the viewBox alone would leave
31392
+ // the bands a picture of another shape letterboxes it with.
31393
+ // Without one, percentages, so it covers whatever size it is shown at.
31394
+ const box = tag.match(/\sviewBox\s*=\s*["']\s*([-\d.e+]+)[\s,]+([-\d.e+]+)[\s,]+([-\d.e+]+)[\s,]+([-\d.e+]+)\s*["']/i);
31395
+ const [x, y, w, h] = box ? box.slice(1).map(Number) : [];
31396
+ const rect = box
31397
+ ? `<rect x="${x - w}" y="${y - h}" width="${3 * w}" height="${3 * h}" fill="${background}"/>`
31398
+ : `<rect x="0" y="0" width="100%" height="100%" fill="${background}"/>`;
31399
+ out = out.replace(tag, `${tag}${rect}`);
30936
31400
  }
30937
31401
  return out;
30938
31402
  }
@@ -30965,12 +31429,12 @@
30965
31429
  * Size is taken from the element's laid-out box rather than its attributes,
30966
31430
  * because that is what the user is actually looking at.
30967
31431
  */
30968
- function serializeSvg(svgEl, { background = '#ffffff', width, height } = {}) {
31432
+ function serializeSvg(svgEl, { background = '#ffffff', width, height, scale = 1 } = {}) {
30969
31433
  const box = svgEl.getBoundingClientRect?.() ?? {};
30970
31434
  const w = width ?? svgEl.width?.baseVal?.value ?? box.width;
30971
31435
  const h = height ?? svgEl.height?.baseVal?.value ?? box.height;
30972
31436
  const markup = new XMLSerializer().serializeToString(svgEl);
30973
- return prepareSvgMarkup(markup, { width: w, height: h, background });
31437
+ return prepareSvgMarkup(markup, { width: w * scale, height: h * scale, background, viewBox: `0 0 ${w} ${h}` });
30974
31438
  }
30975
31439
 
30976
31440
  /**
@@ -30989,9 +31453,16 @@
30989
31453
  }
30990
31454
 
30991
31455
  const box = drawable.getBoundingClientRect();
30992
- const size = imageSize({ width: box.width, height: box.height, scale, maxPixels });
30993
31456
  const markup = serializeSvg(drawable, { background, width: box.width, height: box.height });
31457
+ return svgMarkupToPngBlob(markup, { width: box.width, height: box.height, scale, background, maxPixels });
31458
+ }
30994
31459
 
31460
+ /**
31461
+ * Rasterize standalone SVG markup (prepareSvgMarkup's) drawn `width` × `height`
31462
+ * to a PNG Blob at `scale`. DOM only.
31463
+ */
31464
+ async function svgMarkupToPngBlob(markup, { width, height, scale = 1, background = '#ffffff', maxPixels = MAX_IMAGE_PIXELS } = {}) {
31465
+ const size = imageSize({ width, height, scale, maxPixels });
30995
31466
  // A data: URL rather than a blob: URL — a blob: URL taints nothing either,
30996
31467
  // but Safari has historically refused to draw SVG from one.
30997
31468
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
@@ -31039,7 +31510,15 @@
31039
31510
  * @returns {Promise<{via:'clipboard'|'download', filename?:string}>}
31040
31511
  */
31041
31512
  async function copyImage(host, { scale = 2, background = '#ffffff', name = 'diagram' } = {}) {
31042
- const blob = await toPngBlob(host, { scale, background });
31513
+ return copyPngBlob(await toPngBlob(host, { scale, background }), { name });
31514
+ }
31515
+
31516
+ /**
31517
+ * Put a PNG Blob on the clipboard, or download it where the browser cannot
31518
+ * (as copyImage). DOM only.
31519
+ * @returns {Promise<{via:'clipboard'|'download', filename?:string}>}
31520
+ */
31521
+ async function copyPngBlob(blob, { name = 'diagram' } = {}) {
31043
31522
  const canWrite = typeof ClipboardItem !== 'undefined'
31044
31523
  && typeof navigator !== 'undefined'
31045
31524
  && navigator.clipboard?.write;
@@ -31059,10 +31538,14 @@
31059
31538
  }
31060
31539
 
31061
31540
  /** Save a diagram as an SVG file. DOM only. */
31062
- function downloadSvg(host, { background = '#ffffff', name = 'diagram' } = {}) {
31541
+ function downloadSvg(host, { background = '#ffffff', name = 'diagram', scale = 1 } = {}) {
31063
31542
  const drawable = findDrawable(host);
31064
31543
  if (!drawable || drawable.tagName.toLowerCase() !== 'svg') throw new Error('No <svg> to export');
31065
- const markup = serializeSvg(drawable, { background });
31544
+ downloadSvgMarkup(serializeSvg(drawable, { background, scale }), { name });
31545
+ }
31546
+
31547
+ /** Save standalone SVG markup as a file. DOM only. */
31548
+ function downloadSvgMarkup(markup, { name = 'diagram' } = {}) {
31066
31549
  downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), imageFileName(name, 'svg'));
31067
31550
  }
31068
31551
 
@@ -31609,6 +32092,15 @@
31609
32092
  * toolbar container) to itself.
31610
32093
  */
31611
32094
  class DiagramEditor {
32095
+ /** An exported picture's defaults: the whole drawing, at 100%. */
32096
+ static EXPORT_DEFAULTS = Object.freeze({ area: 'drawing', scale: 1 });
32097
+
32098
+ /** Space kept clear round an exported drawing, so nothing touches the picture's edge. */
32099
+ static EXPORT_MARGIN = 20;
32100
+
32101
+ /** How far an arrowhead (or other line-end marker) reaches past its line's end. */
32102
+ static MARKER_REACH = 10;
32103
+
31612
32104
  /** How a selected shape (besides the one with handles) is marked: an outline `pad` outside it, tinted. */
31613
32105
  static SELECTION_OUTLINE = Object.freeze({ pad: 4, stroke: '#0066cc', strokeWidth: 2, dash: '5,3', fill: 'rgba(0, 102, 204, 0.08)' });
31614
32106
 
@@ -31672,6 +32164,10 @@
31672
32164
  /** Off unless asked for (the `showGrid` option, or ⚙ Settings → Grid), as the settings' default says. */
31673
32165
  this.showGrid = options.showGrid ?? false;
31674
32166
  this.gridSize = 20;
32167
+ /** A faint box where an exported picture's edges fall (⚙ Settings → Export area); never exported itself. */
32168
+ this.showExportArea = options.showExportArea ?? false;
32169
+ /** What an exported picture takes in, and at what size (Export image… remembers the last choice). */
32170
+ this.exportOptions = { ...DiagramEditor.EXPORT_DEFAULTS };
31675
32171
 
31676
32172
  // Zoom and pan
31677
32173
  this.zoomLevel = 1.0;
@@ -31979,6 +32475,17 @@
31979
32475
  return [];
31980
32476
  }
31981
32477
 
32478
+ /**
32479
+ * The whole menu a right-click on empty canvas at `at` opens: paste here,
32480
+ * canvasMenuItems, then Export image (imageMenuItems), each set apart.
32481
+ */
32482
+ canvasContextMenu(at) {
32483
+ const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
32484
+ const image = this.imageMenuItems();
32485
+ const sections = [paste, this.canvasMenuItems(at), image.length ? [{ label: 'Export image', items: image }] : []].filter(s => s.length);
32486
+ return sections.flatMap((s, i) => (i ? [{ separator: true }, ...s] : s));
32487
+ }
32488
+
31982
32489
  /**
31983
32490
  * The menu for empty canvas at `at` (drawing coordinates); none here, a
31984
32491
  * subclass offers its own (place a template, ...).
@@ -32220,6 +32727,7 @@
32220
32727
 
32221
32728
  // Render edges
32222
32729
  this._renderEdges(group);
32730
+ if (this.showExportArea && container.tagName === 'svg') this._renderExportArea(group);
32223
32731
 
32224
32732
  // The selection's groups and other selected shapes, then the primary shape's handles.
32225
32733
  this._renderArrangement(group);
@@ -32318,9 +32826,13 @@
32318
32826
  ];
32319
32827
  }
32320
32828
 
32321
- /** Export the current view as a picture: PNG, SVG, or a copy to the clipboard. */
32829
+ /**
32830
+ * Export the current view as a picture: PNG, SVG, or a copy to the clipboard,
32831
+ * as Export image… last chose (the whole drawing at 100% to begin with).
32832
+ */
32322
32833
  imageMenuItems() {
32323
32834
  return [
32835
+ { label: 'Export image…', action: () => this.askExportImage() },
32324
32836
  { label: 'Export image (PNG)', action: () => this.exportImage('png') },
32325
32837
  { label: 'Export image (SVG)', action: () => this.exportImage('svg') },
32326
32838
  { label: 'Copy image', action: () => this.exportImage('copy') },
@@ -32350,17 +32862,113 @@
32350
32862
  * The current view as a picture: 'png' or 'svg' downloads it, 'copy' puts a
32351
32863
  * PNG on the clipboard (or downloads it where the browser cannot). The grid,
32352
32864
  * the selection and its handles are left out.
32865
+ *
32866
+ * `area` 'drawing' (the default) takes in everything drawn, wherever the view
32867
+ * is scrolled to: every shape and line, labels and arrowheads included, with
32868
+ * a margin clear of the edge (drawingBounds). 'view' is what is in sight.
32869
+ * `scale` sizes the picture: 1 is 100%.
32353
32870
  * @param {'png'|'svg'|'copy'} format
32871
+ * @param {{area?: 'drawing'|'view', scale?: number}} [options] - Export image…'s last choice by default
32354
32872
  */
32355
- exportImage(format = 'png') {
32873
+ exportImage(format = 'png', { area = this.exportOptions.area, scale = this.exportOptions.scale } = {}) {
32356
32874
  const host = this.drawingHost();
32357
32875
  if (!host) return null;
32358
32876
  const name = this.imageName();
32359
- return this._withPlainDrawing(() => {
32360
- if (format === 'svg') return downloadSvg(host, { name });
32361
- if (format === 'copy') return copyImage(host, { name });
32362
- return downloadPng(host, { name });
32877
+ if (area === 'view' || host.tagName?.toLowerCase() !== 'svg') {
32878
+ return this._withPlainDrawing(() => {
32879
+ if (format === 'svg') return downloadSvg(host, { name, scale });
32880
+ if (format === 'copy') return copyImage(host, { name, scale });
32881
+ return downloadPng(host, { name, scale });
32882
+ });
32883
+ }
32884
+ const viewBox = this.drawingBounds();
32885
+ if (!viewBox) return null;
32886
+ const svg = this._withPlainDrawing(() => this._croppedSvg(host, viewBox, { scale }));
32887
+ if (format === 'svg') return downloadSvgMarkup(svg, { name });
32888
+ const png = svgMarkupToPngBlob(svg, { width: viewBox.width, height: viewBox.height, scale });
32889
+ if (format === 'copy') return png.then(blob => copyPngBlob(blob, { name }));
32890
+ return png.then(blob => downloadBlob(blob, imageFileName(name, 'png')));
32891
+ }
32892
+
32893
+ /** Ask what to export: the format, the whole drawing or what is in sight, and the scale (%). */
32894
+ askExportImage() {
32895
+ const { area, scale } = this.exportOptions;
32896
+ new FormDialog({
32897
+ title: 'Export image',
32898
+ description: 'The whole drawing takes in every shape and line, with a margin; the view, only what is in sight.',
32899
+ fields: [
32900
+ { key: 'format', label: 'Format', type: 'select', value: 'png', choices: [['png', 'PNG'], ['svg', 'SVG'], ['copy', 'Copy to the clipboard (PNG)']] },
32901
+ { key: 'area', label: 'Take in', type: 'select', value: area, choices: [['drawing', 'The whole drawing'], ['view', 'What is in sight']] },
32902
+ { key: 'scale', label: 'Scale (%)', type: 'number', value: Math.round(scale * 100), min: 10, max: 800, step: 10 },
32903
+ ],
32904
+ submitLabel: 'Export',
32905
+ document: this.document ?? globalThis.document,
32906
+ onApply: (values) => this.exportImage(values.format, this.setExportOptions(values)),
32907
+ }).open();
32908
+ }
32909
+
32910
+ /**
32911
+ * Remember an export's area and scale (the scale in %), for the next one.
32912
+ * A scale that is not a positive number keeps the last. Returns the options.
32913
+ */
32914
+ setExportOptions({ area, scale } = {}) {
32915
+ if (area === 'drawing' || area === 'view') this.exportOptions.area = area;
32916
+ const pct = Number(scale);
32917
+ if (Number.isFinite(pct) && pct > 0) this.exportOptions.scale = pct / 100;
32918
+ return { ...this.exportOptions };
32919
+ }
32920
+
32921
+ /**
32922
+ * Where an exported picture of the whole drawing ends (in drawing units):
32923
+ * every shape and line shown, with line labels and arrowheads, and
32924
+ * `margin` clear all round, so nothing touches the picture's edge. Null
32925
+ * when nothing is drawn.
32926
+ * @returns {{x, y, width, height}|null}
32927
+ */
32928
+ drawingBounds({ margin = DiagramEditor.EXPORT_MARGIN } = {}) {
32929
+ const boxes = [...this.templates.values()]
32930
+ .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
32931
+ .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom }));
32932
+ const cascade = this.edgeStyleCascade();
32933
+ for (const edge of this.getAllEdges()) {
32934
+ if (!edge.visible || this.edgeEmphasis(edge) === 'hidden') continue;
32935
+ const { style } = cascade.resolve(edge);
32936
+ const path = this._edgePath(edge, style);
32937
+ if (!path) continue;
32938
+ // Every point the path names (a curve's control points bound it); half the stroke and an arrowhead past them.
32939
+ const numbers = path.d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)?.map(Number) ?? [];
32940
+ const reach = (Number(style.strokeWidth) || 1) / 2 + DiagramEditor.MARKER_REACH;
32941
+ for (let i = 0; i + 1 < numbers.length; i += 2) {
32942
+ boxes.push({ left: numbers[i] - reach, top: numbers[i + 1] - reach, right: numbers[i] + reach, bottom: numbers[i + 1] + reach });
32943
+ }
32944
+ if (edge.label) {
32945
+ // As _renderEdges writes it: centred over the middle, 12px text (about 7px a character).
32946
+ const cx = (path.x1 + path.x2) / 2, base = (path.y1 + path.y2) / 2 - 8;
32947
+ const half = String(edge.label).length * 7 / 2;
32948
+ boxes.push({ left: cx - half, top: base - 12, right: cx + half, bottom: base + 4 });
32949
+ }
32950
+ }
32951
+ const bounds = ViewFit.union(boxes);
32952
+ if (!bounds) return null;
32953
+ return { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
32954
+ }
32955
+
32956
+ /** The faint box where an exported picture of the whole drawing ends (drawingBounds). @protected */
32957
+ _renderExportArea(group) {
32958
+ const area = this.drawingBounds();
32959
+ if (!area) return;
32960
+ const rect = this._svg('rect', {
32961
+ id: 'export-area', x: area.x, y: area.y, width: area.width, height: area.height,
32962
+ fill: 'rgba(43, 95, 217, 0.03)', stroke: 'rgba(43, 95, 217, 0.25)', 'stroke-dasharray': '6 4', 'stroke-width': 1,
32963
+ 'pointer-events': 'none',
32363
32964
  });
32965
+ group.insertBefore(rect, group.firstChild);
32966
+ }
32967
+
32968
+ /** Show or hide the export area. */
32969
+ setShowExportArea(shown) {
32970
+ this.showExportArea = !!shown;
32971
+ this.render();
32364
32972
  }
32365
32973
 
32366
32974
  /**
@@ -32369,8 +32977,8 @@
32369
32977
  * @protected
32370
32978
  */
32371
32979
  _withPlainDrawing(fn) {
32372
- const saved = { showGrid: this.showGrid, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
32373
- Object.assign(this, { showGrid: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
32980
+ const saved = { showGrid: this.showGrid, showExportArea: this.showExportArea, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
32981
+ Object.assign(this, { showGrid: false, showExportArea: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
32374
32982
  this.render();
32375
32983
  try {
32376
32984
  return fn();
@@ -32393,13 +33001,7 @@
32393
33001
  if (host?.tagName?.toLowerCase() !== 'svg' || !bounds) return null;
32394
33002
  const viewBox = { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
32395
33003
  return this._withPlainDrawing(() => {
32396
- const copy = host.cloneNode(true);
32397
- copy.querySelector('#grid-layer')?.remove();
32398
- copy.querySelector('#elements')?.removeAttribute('transform');
32399
- copy.removeAttribute('style');
32400
- copy.removeAttribute('id');
32401
- copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
32402
- const svg = prepareSvgMarkup(new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy), { width: viewBox.width, height: viewBox.height, background });
33004
+ const svg = this._croppedSvg(host, viewBox, { background });
32403
33005
  const areas = [...this.templates.values()]
32404
33006
  .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
32405
33007
  .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
@@ -32415,6 +33017,22 @@
32415
33017
  }
32416
33018
 
32417
33019
  /** The drawing's extent in drawing coordinates (ViewFit.union of the visible shapes), or null. */
33020
+ /**
33021
+ * The drawing as standalone SVG markup cut to `viewBox` (drawing units),
33022
+ * unpanned and unzoomed, sized at `scale`. Call it inside _withPlainDrawing.
33023
+ * @protected
33024
+ */
33025
+ _croppedSvg(host, viewBox, { background = '#ffffff', scale = 1 } = {}) {
33026
+ const copy = host.cloneNode(true);
33027
+ copy.querySelector('#grid-layer')?.remove();
33028
+ copy.querySelector('#elements')?.removeAttribute('transform');
33029
+ copy.removeAttribute('style');
33030
+ copy.removeAttribute('id');
33031
+ copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
33032
+ const markup = new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy);
33033
+ return prepareSvgMarkup(markup, { width: viewBox.width * scale, height: viewBox.height * scale, background });
33034
+ }
33035
+
32418
33036
  contentBounds() {
32419
33037
  return ViewFit.union([...this.templates.values()].filter(t => t.visible !== false)
32420
33038
  .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom })));
@@ -32518,6 +33136,7 @@
32518
33136
  return [
32519
33137
  { label: `Grid: ${this.showGrid ? 'on' : 'off'}`, action: () => { this.showGrid = !this.showGrid; this.render(); } },
32520
33138
  { label: `Grid size: ${this.gridSize}px…`, action: () => this.editGridSize() },
33139
+ { label: `Export area: ${this.showExportArea ? 'shown' : 'hidden'}`, action: () => this.setShowExportArea(!this.showExportArea) },
32521
33140
  ...(this.edgeStyleLevels.length ? [{ separator: true }] : []),
32522
33141
  ...this.edgeStyleLevels.map(level => ({
32523
33142
  label: `Line style: ${level.label} defaults…`,
@@ -32878,13 +33497,17 @@
32878
33497
  /**
32879
33498
  * Geometry of an edge between its templates' outlines, or null if an end is
32880
33499
  * missing. `style.attach === 'sides'` attaches each unpinned end at the middle
32881
- * of the side facing the other end (or the nearest waypoint); 'polyline'
33500
+ * of the side facing the other end (or the nearest waypoint); 'shortest' (the
33501
+ * default, as Archi draws) runs a straight line straight across where the
33502
+ * shapes overlap, else aims at the other end ('floating'); 'polyline'
32882
33503
  * routing bends around the other shapes.
32883
33504
  */
32884
33505
  _edgePath(edge, style = this.resolveEdgeStyle(edge)) {
32885
33506
  const source = this.getTemplate(edge.sourceId);
32886
33507
  const target = this.getTemplate(edge.targetId);
32887
33508
  if (!source || !target) return null;
33509
+ const across = style.attach === 'shortest' ? this._acrossPath(edge, source, target, style) : null;
33510
+ if (across) return across;
32888
33511
  const box = (t) => ({ w: t.width, h: t.height });
32889
33512
  let sourceSide = edge.sourceSide || 'auto';
32890
33513
  let targetSide = edge.targetSide || 'auto';
@@ -32904,6 +33527,25 @@
32904
33527
  );
32905
33528
  }
32906
33529
 
33530
+ /**
33531
+ * The shortest straight line between two shapes that face each other across
33532
+ * a gap (EdgeGeometry.acrossOverlap): straight across the middle of their
33533
+ * overlap, from outline to outline. Null where that does not apply: a line
33534
+ * with bends, a pinned end, other routing, a rotated shape, or no overlap.
33535
+ * @protected
33536
+ */
33537
+ _acrossPath(edge, source, target, style) {
33538
+ if (edge.waypoints.length || (edge.sourceSide || 'auto') !== 'auto' || (edge.targetSide || 'auto') !== 'auto') return null;
33539
+ if ((style.routing ?? 'straight') !== 'straight' || source.rotation || target.rotation) return null;
33540
+ const across = EdgeGeometry.acrossOverlap(source, target);
33541
+ if (!across) return null;
33542
+ const step = across.forward ? 1 : -1;
33543
+ const [a, b] = across.axis === 'v'
33544
+ ? [source.exitPoint(across.at, source.y, 0, step), target.exitPoint(across.at, target.y, 0, -step)]
33545
+ : [source.exitPoint(source.x, across.at, step, 0), target.exitPoint(target.x, across.at, -step, 0)];
33546
+ return { x1: a.x, y1: a.y, x2: b.x, y2: b.y, d: `M${a.x},${a.y} L${b.x},${b.y}` };
33547
+ }
33548
+
32907
33549
  /** Anchors and waypoints in order: the polyline a waypointed edge follows. */
32908
33550
  _edgeVertices(edge, edgePath = this._edgePath(edge)) {
32909
33551
  return [{ x: edgePath.x1, y: edgePath.y1 }, ...edge.waypoints, { x: edgePath.x2, y: edgePath.y2 }];
@@ -33396,12 +34038,9 @@
33396
34038
  e.preventDefault();
33397
34039
  this._startMarquee(e, container);
33398
34040
  });
33399
- // Empty canvas: what canvasMenuItems offers there (a shape's own menu stops the event first).
34041
+ // Empty canvas: canvasContextMenu there (a shape's own menu stops the event first).
33400
34042
  container.addEventListener('contextmenu', (e) => {
33401
- const at = this._clientToDiagram(e.clientX, e.clientY);
33402
- const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
33403
- const own = this.canvasMenuItems(at);
33404
- const items = [...paste, ...(paste.length && own.length ? [{ separator: true }] : []), ...own];
34043
+ const items = this.canvasContextMenu(this._clientToDiagram(e.clientX, e.clientY));
33405
34044
  if (!items.length) return;
33406
34045
  e.preventDefault();
33407
34046
  this._showMenu(e.clientX, e.clientY, items);
@@ -33856,11 +34495,27 @@
33856
34495
  return [['kind', 'Kind of shape']];
33857
34496
  }
33858
34497
 
34498
+ /** What a colour key is called (colourKeys' label; a key not listed there, itself). */
34499
+ colourKeyLabel(key) {
34500
+ return this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
34501
+ }
34502
+
34503
+ /** The colours offered for a key's values, in order: the palette's, round again when there are more. */
34504
+ colourDefaults(key, values) {
34505
+ const palette = this.constructor.COLOUR_PALETTE;
34506
+ return values.map((_, i) => palette[i % palette.length]);
34507
+ }
34508
+
33859
34509
  /** A shape's value for a colourKeys key, as text (undefined: it has none, and is left as it is). */
33860
34510
  colourValue(template, key) {
33861
34511
  return key === 'kind' ? this.layoutCategory(template) : undefined;
33862
34512
  }
33863
34513
 
34514
+ /** A line's value for a colourKeys key (undefined: lines are not coloured by it, as here; a subclass's may be). */
34515
+ colourEdgeValue(edge, key) {
34516
+ return undefined;
34517
+ }
34518
+
33864
34519
  /** The values of `key` among the shapes, in order of first appearance, each with its shapes. */
33865
34520
  colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
33866
34521
  const values = new Map();
@@ -33876,19 +34531,26 @@
33876
34531
 
33877
34532
  /**
33878
34533
  * Colour the shapes by what `key` says of them (a heat map): each value's
33879
- * shapes filled with its colour (`colours`, by value; a value left out or
34534
+ * shapes filled with its colour, and lines with a value of their own
34535
+ * (colourEdgeValue) drawn in it (`colours`, by value; a value left out or
33880
34536
  * empty is left as it is), and with `legend` a legend of the colours
33881
34537
  * under the drawing. One undo step; locked shapes are left as they are.
33882
34538
  * @returns {Command|null}
33883
34539
  */
33884
34540
  colourByCommand(key, colours, { legend = false } = {}) {
33885
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
34541
+ const label = this.colourKeyLabel(key);
33886
34542
  const steps = [];
33887
34543
  for (const [value, templates] of this.colourValues(key)) {
33888
34544
  const fillColor = colours[value];
33889
34545
  if (!fillColor) continue;
33890
34546
  for (const t of templates) if (!this.isFixed(t.id) && t.fillColor !== fillColor) steps.push(new SetPropertiesCommand(t, { fillColor }, 'Colour'));
33891
34547
  }
34548
+ // Lines with a value of their own take its colour too (colourEdgeValue).
34549
+ for (const edge of this.getAllEdges()) {
34550
+ if (edge.editable === false) continue;
34551
+ const strokeColor = colours[this.colourEdgeValue(edge, key)];
34552
+ if (strokeColor && edge.strokeColor !== strokeColor) steps.push(new SetStyleCommand(edge, { strokeColor }, 'Colour'));
34553
+ }
33892
34554
  if (legend) {
33893
34555
  const entries = [...this.colourValues(key).keys()].filter(v => colours[v]).map(v => ({ label: v, fillColor: colours[v] }));
33894
34556
  const cmd = entries.length && this.legendCommand(entries, label);
@@ -33914,13 +34576,13 @@
33914
34576
  /** The colour of each value of `key` (the palette's by default), then colour by it. @protected */
33915
34577
  askColours(key) {
33916
34578
  const values = [...this.colourValues(key).keys()];
33917
- const palette = this.constructor.COLOUR_PALETTE;
33918
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
34579
+ const defaults = this.colourDefaults(key, values);
34580
+ const label = this.colourKeyLabel(key);
33919
34581
  if (!values.length) { (globalThis.alert ?? (() => {}))(`No shape here has a value for ${label}.`); return; }
33920
34582
  new FormDialog({
33921
34583
  title: `Colour by ${label}`,
33922
34584
  fields: [
33923
- ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: palette[i % palette.length] })),
34585
+ ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: defaults[i] })),
33924
34586
  { key: 'legend', label: 'Add a legend', type: 'checkbox', value: true },
33925
34587
  ],
33926
34588
  submitLabel: 'Colour',
@@ -33960,19 +34622,45 @@
33960
34622
  }
33961
34623
 
33962
34624
  /**
33963
- * The command that adds a legend: a title and a box per entry, in a row
33964
- * under the drawing (legendPosition). Here, notes; a subclass makes its own
33965
- * kind of box. Null with no entries.
34625
+ * Where a legend's boxes go, its top-left at `{x, y}`: a frame titled at its
34626
+ * top-left that holds every entry, the entries in rows of up to
34627
+ * `perRow` inside it. Centres and sizes, as boxes are placed.
34628
+ * @returns {{frame: {x, y, width, height}, items: Array<{x, y, width, height}>}}
34629
+ */
34630
+ static legendLayout(count, { x, y }, { perRow = 4, itemWidth = 120, itemHeight = 40, gap = 10, pad = 10, titleHeight = 24 } = {}) {
34631
+ const columns = Math.max(1, Math.min(count, perRow));
34632
+ const rows = Math.max(1, Math.ceil(count / columns));
34633
+ const width = 2 * pad + columns * itemWidth + (columns - 1) * gap;
34634
+ const height = titleHeight + rows * itemHeight + (rows - 1) * gap + pad;
34635
+ const items = Array.from({ length: count }, (_, i) => ({
34636
+ x: x + pad + (i % columns) * (itemWidth + gap) + itemWidth / 2,
34637
+ y: y + titleHeight + Math.floor(i / columns) * (itemHeight + gap) + itemHeight / 2,
34638
+ width: itemWidth, height: itemHeight,
34639
+ }));
34640
+ return { frame: { x: x + width / 2, y: y + height / 2, width, height }, items };
34641
+ }
34642
+
34643
+ /** The legend frame's text: its title, bold at the top-left (as Archi's legends are). */
34644
+ static LEGEND_TITLE_STYLE = Object.freeze({ textAlign: 'left', textPosition: 'top', fontWeight: 'bold' });
34645
+
34646
+ /**
34647
+ * The command that adds a legend under the drawing (legendPosition): a
34648
+ * frame titled `title` with a box per entry inside it, grouped so they move
34649
+ * as one. Here, notes; a subclass makes its own kind of box. Null with no
34650
+ * entries.
33966
34651
  * @param {Array<{label: string, fillColor: string}>} entries
33967
34652
  * @param {string} title
33968
34653
  */
33969
34654
  legendCommand(entries, title) {
33970
34655
  if (!entries.length) return null;
33971
- const { x, y } = this.legendPosition();
34656
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
33972
34657
  const stamp = Date.now().toString(36);
33973
- const note = (i, name, fillColor, width = 120) => new NoteTemplate(`legend-${stamp}-${i}`, name, { x: x + width / 2 + i * 130, y: y + 25, width, height: 40, fillColor });
33974
- const boxes = [note(0, title, '#ffffff'), ...entries.map((e, i) => note(i + 1, e.label, e.fillColor))];
33975
- return new CompositeCommand(`Add legend: ${title}`, boxes.map(t => new CreateTemplateCommand(this, t)));
34658
+ const note = (i, name, box, more) => new NoteTemplate(`legend-${stamp}-${i}`, name, { ...box, ...more });
34659
+ const boxes = [note(0, title, frame, { fillColor: '#ffffff', ...DiagramEditor.LEGEND_TITLE_STYLE }), ...entries.map((e, i) => note(i + 1, e.label, items[i], { fillColor: e.fillColor }))];
34660
+ return new CompositeCommand(`Add legend: ${title}`, [
34661
+ ...boxes.map(t => new CreateTemplateCommand(this, t)),
34662
+ new GroupCommand(this.arrangement, boxes.map(t => t.id), { name: title }),
34663
+ ]);
33976
34664
  }
33977
34665
 
33978
34666
  // ===== Format painter =====
@@ -35816,974 +36504,1066 @@
35816
36504
  }).open();
35817
36505
  }
35818
36506
 
35819
- /**
35820
- * Which allowed relationship kind a new connection takes when nobody is asked:
35821
- * the dependency and dynamic kinds people usually mean first, the structural
35822
- * whole/part kinds last.
35823
- */
35824
- const KIND_PREFERENCE = Object.freeze([
35825
- 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
35826
- 'specialization', 'aggregation', 'composition', 'association',
35827
- ]);
35828
-
35829
- /**
35830
- * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
35831
- * view: its element instances as ArchimateElementTemplates, its relationship
35832
- * instances as ArchimateEdges, and its own undo history. Everything the base
35833
- * editor does (move, resize, restack, bend points, sides, styles, zoom,
35834
- * snapping) edits the view directly; the editor's maps are rebuilt from the
35835
- * view on every render, so undo of a view edit shows straight away.
35836
- *
35837
- * What stands for something else in ArchiMate is done through the view and
35838
- * model instead of the maps:
35839
- * - dropping a palette type adds a new model element and shows it;
35840
- * - connecting two elements adds a relationship, its kind the first the
35841
- * ArchiMate rules allow between them (association if none);
35842
- * - deleting removes from the view (with the element's lines); the model
35843
- * keeps its elements and relationships.
35844
- * Each is one step on the view's history.
35845
- */
35846
- class ArchimateCanvasEditor extends DiagramEditor {
35847
- /**
35848
- * @param {Object} options - DiagramEditor options, plus:
35849
- * @param {ArchimateView} options.view
35850
- * @param {ArchimateModel} options.model
35851
- * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
35852
- * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
35853
- * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
35854
- * Asks which relationship kind a new connection is, given the kinds allowed between the two
35855
- * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
35856
- * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
35857
- * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
35858
- * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
35859
- * live status for an element (a component's lifecycle state), drawn on its boxes
35860
- * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
35861
- * element (its transitions), at the end of a box's right-click menu
35862
- */
35863
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
35864
- super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
35865
- this.view = view;
35866
- this.model = model;
35867
- this.nodeOptions = nodeOptions;
35868
- this.chooseKind = chooseKind;
35869
- this.onNodeOptionsChange = onNodeOptionsChange;
35870
- /** Viewpoints of the user's own, offered with the standard ones. */
35871
- this.customViewpoints = customViewpoints;
35872
- /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
35873
- this.settingsManager = settingsManager;
35874
- /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
35875
- this.templateMenu = templateMenu;
35876
- /** A host's live status per element, drawn on its boxes, and its menu items per element. */
35877
- this.elementStatus = elementStatus;
35878
- this.elementMenuExtras = elementMenuExtras;
35879
- this.addDropHandler({
35880
- accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
35881
- drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
35882
- });
35883
- // An element from the model tree: shown here, with its relationships to what is already shown.
35884
- this.addDropHandler({
35885
- accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
35886
- drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
35887
- });
35888
- // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
35889
- if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
35890
- // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
35891
- this.addDropHandler({
35892
- accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
35893
- drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
35894
- });
35895
- }
35896
-
35897
- get version() {
35898
- return this.nodeOptions.archimateVersion ?? '4';
35899
- }
35900
-
35901
- /**
35902
- * An element's own items (a view of it and what it relates to; its
35903
- * relationships to what is shown, when some are not), a canvas box's hint,
35904
- * and the shape's selection (or the shape and its group) as a template.
35905
- */
35906
- shapeMenuExtras(elementId) {
35907
- const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
35908
- const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
35909
- const element = this._elementOf(elementId);
35910
- const missing = element ? this.missingRelationshipsOf(elementId) : [];
35911
- // Two or more elements chosen: merge them into one (choose the one kept).
35912
- const merging = this.elementIdsOf(ids);
35913
- return [
35914
- ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
35915
- ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
35916
- ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
35917
- ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
35918
- ...(items.length ? [{ label: 'Save as template', items }] : []),
35919
- ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
35920
- ];
35921
- }
35922
-
35923
- /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
35924
- missingRelationshipsOf(instanceId) {
35925
- const instance = this.view.getElementInstance(instanceId);
35926
- if (!instance) return [];
35927
- return this.view.missingRelationshipInstances(instance.elementId)
35928
- .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
35929
- }
35930
-
35931
- /** Draw them all, as one undo step. */
35932
- addMissingRelationships(instanceId) {
35933
- const missing = this.missingRelationshipsOf(instanceId);
35934
- if (!missing.length) return null;
35935
- const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
35936
- missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
35937
- this._executeCommand(cmd);
35938
- return cmd;
35939
- }
35940
-
35941
- /** The view's boxes (a canvas's palette; a note or picture elsewhere) at the point clicked, and on a canvas a template placed there. */
35942
- canvasMenuItems(at) {
35943
- const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
35944
- const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
35945
- // Elements from text: not on a canvas, whose boxes are not elements.
35946
- const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
35947
- return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
35948
- }
35949
-
35950
- /** The View menu, and Generate roadmap… (elements with dates on a time line). */
35951
- viewMenuItems() {
35952
- const repeated = this.view?.repeatedBoxes?.() ?? [];
35953
- return [
35954
- ...super.viewMenuItems(),
35955
- { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
35956
- { label: 'Generate roadmap…', action: () => this.askRoadmap() },
35957
- ];
35958
- }
35959
-
35960
- /** The model's own: Find likely duplicates…. */
35961
- modelMenuItems() {
35962
- return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
35963
- }
35964
-
35965
- /**
35966
- * Take off this view the boxes that only repeat another box of their
35967
- * element (ArchimateView#repeatedBoxes: same label, same lines), with
35968
- * their lines, as one undo step. Returns the command, or null with none.
35969
- */
35970
- removeRepeatedBoxes() {
35971
- const ids = this.view.repeatedBoxes();
35972
- if (!ids.length) return null;
35973
- const cmd = this.deleteTemplatesCommand(ids);
35974
- cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
35975
- this._executeCommand(cmd);
35976
- return cmd;
35977
- }
35978
-
35979
- /**
35980
- * Where Find likely duplicates… can look: the whole model, the elements on
35981
- * the view in front, the elements selected (two or more). [key, label, element ids or null].
35982
- */
35983
- duplicateScopes() {
35984
- const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
35985
- const selected = this.elementIdsOf(this.getSelectedIds());
35986
- return [
35987
- ['model', 'The whole model', null],
35988
- ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
35989
- ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
35990
- ];
35991
- }
35992
-
35993
- /**
35994
- * Find likely duplicates…: where to look (duplicateScopes), how to compare
35995
- * (names, documentation or a property's value; exactly or by their words)
35996
- * and what noise to leave out (duplicateSearchFields), then the groups
35997
- * found, each with Merge… (showDuplicates).
35998
- */
35999
- askDuplicates() {
36000
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
36001
- const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
36002
- const scopes = this.duplicateScopes();
36003
- return new FormDialog({
36004
- title: 'Find likely duplicates',
36005
- description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
36006
- fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
36007
- submitLabel: 'Find',
36008
- idPrefix: 'duplicates-',
36009
- document: this.document ?? globalThis.document,
36010
- validate: validateDuplicateSearch,
36011
- onApply: (values) => {
36012
- const { options, scope } = duplicateSearchOptions(values);
36013
- this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
36014
- },
36015
- }).open();
36016
- }
36017
-
36018
- /**
36019
- * The elements that look like one thing twice (the model check's likely
36020
- * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
36021
- * among these element ids), each group with Merge….
36022
- */
36023
- showDuplicates(options = {}, { within = null } = {}) {
36024
- const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
36025
- const groups = find();
36026
- new DuplicatesDialog({
36027
- groups,
36028
- refind: find,
36029
- merge: async (group) => !!(await this.askMerge(group.ids)),
36030
- onReveal: (group) => this.revealFinding(group),
36031
- empty: 'No elements look like the same thing twice.',
36032
- document: this.document ?? globalThis.document,
36033
- }).open();
36034
- return groups;
36035
- }
36036
-
36037
- /** The model elements of some boxes, each once (a box that is not an element is left out). */
36038
- elementIdsOf(instanceIds) {
36039
- return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
36040
- }
36041
-
36042
- /**
36043
- * Generate roadmap…: which elements (the selection's, or every element in
36044
- * the model with both dates), the properties their dates are in, and how
36045
- * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
36046
- */
36047
- askRoadmap() {
36048
- const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
36049
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
36050
- return new FormDialog({
36051
- title: 'Generate roadmap',
36052
- description: 'A view with each element a bar from its start date to its end date, under the months.',
36053
- fields: [
36054
- { key: 'from', label: 'Elements', type: 'select', value: selected.length ? 'selection' : 'model', choices: [...(selected.length ? [['selection', `The ${selected.length} selected`]] : []), ['model', 'Every element with both dates']] },
36055
- { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
36056
- { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
36057
- { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
36058
- { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
36059
- ],
36060
- submitLabel: 'Generate',
36061
- idPrefix: 'roadmap-',
36062
- document: this.document ?? globalThis.document,
36063
- validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
36064
- onApply: (v) => this.generateRoadmap(v),
36065
- }).open();
36066
- }
36067
-
36068
- /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
36069
- roadmapFor({ from, start, end }) {
36070
- const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
36071
- const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
36072
- const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
36073
- return { ids, view: any ? true : null };
36074
- }
36075
-
36076
- /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
36077
- generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
36078
- const { ids } = this.roadmapFor({ from, start, end });
36079
- return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
36080
- }
36081
-
36082
- /** An element's box keeps room for its type badge, and Archi's height. */
36083
- textFit(template) {
36084
- return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
36085
- }
36086
-
36087
- /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
36088
- shapeTypes() {
36089
- const viewpoint = this.nodeOptions.viewpoint;
36090
- const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
36091
- return ArchimateNotation.elementTypeNames(this.version)
36092
- .filter(t => !allowed || allowed.includes(t))
36093
- .map(t => [t, typeTitle(t)]);
36094
- }
36095
-
36096
- /**
36097
- * New model elements from text, each with its box here: one undo step.
36098
- * A documentation column becomes the element's documentation.
36099
- */
36100
- createShapesCommand(items) {
36101
- const ids = this.model.elements().map(e => e.id);
36102
- const steps = [], createdIds = [];
36103
- for (const item of items) {
36104
- const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
36105
- ids.push(id);
36106
- const instance = new ElementInstance(id, item.x, item.y);
36107
- createdIds.push(instance.id);
36108
- steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
36109
- }
36110
- const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
36111
- cmd.createdIds = createdIds;
36112
- return cmd;
36113
- }
36114
-
36115
- /** What elements can be coloured by: their layer, type, specialization, and each property any of them has. */
36116
- colourKeys(templates = [...this.templates.values()]) {
36117
- const properties = new Set();
36118
- for (const t of templates) for (const p of this._elementOf(t.id)?.properties ?? []) if (p.name ?? p.key) properties.add(p.name ?? p.key);
36119
- return [
36120
- ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
36121
- ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
36122
- ];
36123
- }
36124
-
36125
- /** An element's value for a colourKeys key (notes and pictures have none). */
36126
- colourValue(template, key) {
36127
- const element = this._elementOf(template.id);
36128
- if (!element) return undefined;
36129
- if (key === 'layer') return typeTitle(treeLayerOf(element));
36130
- if (key === 'type') return typeTitle(element.type ?? '');
36131
- if (key === 'specialization') return element.specialization;
36132
- if (key.startsWith('property:')) {
36133
- const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
36134
- return p?.value === undefined || p?.value === null ? undefined : String(p.value);
36135
- }
36136
- return super.colourValue(template, key);
36137
- }
36138
-
36139
- /** The kind legend: each element type on the view, in the colour of its first box. */
36140
- kindLegendEntries() {
36141
- const types = new Map();
36142
- for (const t of this.templates.values()) {
36143
- const element = this._elementOf(t.id);
36144
- if (!element || types.has(element.type)) continue;
36145
- types.set(element.type, t.fillColor);
36146
- }
36147
- return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
36148
- }
36149
-
36150
- /** A legend is notes (stickies on a canvas) on the view: one PlaceItemsCommand. */
36151
- legendCommand(entries, title) {
36152
- if (!entries.length) return null;
36153
- const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
36154
- if (!kind) return null;
36155
- const { x, y } = this.legendPosition();
36156
- const stamp = Date.now().toString(36);
36157
- const box = (i, name, fillColor) => {
36158
- const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x + 60 + i * 130, y + 20);
36159
- inst.size = { width: 120, height: 40 };
36160
- inst.appearance = { ...kind.appearance, fillColor };
36161
- return inst;
36162
- };
36163
- const boxes = [box(0, title, '#ffffff'), ...entries.map((e, i) => box(i + 1, e.label, e.fillColor))];
36164
- return new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` });
36165
- }
36166
-
36167
- /** Likely duplicates can be merged (Merge…: choose the one to keep). */
36168
- findingActions(finding) {
36169
- return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
36170
- }
36171
-
36172
- /**
36173
- * What differs among some elements, for the merge to choose: their
36174
- * documentation (when two or more have some, and it differs), each property
36175
- * with different values, and the specialization.
36176
- * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
36177
- * each choice [value, label]
36178
- */
36179
- mergeChoices(elements) {
36180
- const name = (e) => e.name || e.id;
36181
- const documented = elements.filter(e => (e.documentation ?? '').trim());
36182
- const texts = [...new Set(documented.map(e => e.documentation.trim()))];
36183
- const properties = new Map();
36184
- for (const e of elements) {
36185
- for (const p of e.properties ?? []) {
36186
- const key = p.name ?? p.key;
36187
- if (!properties.has(key)) properties.set(key, new Map());
36188
- const values = properties.get(key);
36189
- values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
36190
- }
36191
- }
36192
- const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
36193
- return {
36194
- documentation: texts.length > 1 ? texts.map(t => [t, `${documented.filter(e => e.documentation.trim() === t).map(name).join(', ')}: ${t.length > 50 ? `${t.slice(0, 50)}…` : t}`]) : [],
36195
- properties: new Map([...properties].filter(([, values]) => values.size > 1)
36196
- .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
36197
- specializations: specializations.length > 1 ? specializations : [],
36198
- };
36199
- }
36200
-
36201
- /**
36202
- * Some elements side by side, for a merge (MergeCompareDialog's rows): their
36203
- * names, types, specializations, documentation, each property, their
36204
- * relationships (kind, direction, the element at the other end) and the
36205
- * views they are on.
36206
- */
36207
- mergeComparison(elements) {
36208
- const nameOf = (id) => this.model.getElement(id)?.name || id;
36209
- const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
36210
- const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
36211
- const lines = (e) => {
36212
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
36213
- return [
36214
- ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
36215
- ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
36216
- ].sort().join('\n');
36217
- };
36218
- const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
36219
- return [
36220
- { label: 'Name', values: elements.map(e => e.name ?? '') },
36221
- { label: 'Type', values: elements.map(e => e.type ?? '') },
36222
- ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
36223
- { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
36224
- ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
36225
- { label: 'Relationships', values: elements.map(lines) },
36226
- { label: 'Views', values: elements.map(views) },
36227
- ];
36228
- }
36229
-
36230
- /**
36231
- * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
36232
- * Their relationships always come together. When they differ in anything
36233
- * but the views they are on, they are shown side by side
36234
- * (MergeCompareDialog) to choose which is kept and, where they differ, the
36235
- * documentation (one, or all joined), each property's value, the
36236
- * specialization; OK merges, Cancel leaves them. Alike, the first is kept
36237
- * without asking. Resolves to the merge's command, or null.
36238
- */
36239
- async askMerge(elementIds) {
36240
- const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
36241
- if (elements.length < 2) return null;
36242
- const used = (e) => {
36243
- const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
36244
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
36245
- return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
36246
- };
36247
- const ids = elements.map(e => e.id);
36248
- const rows = this.mergeComparison(elements);
36249
- const compare = new MergeCompareDialog({
36250
- title: `Merge ${elements.length} elements`,
36251
- description: 'The one kept takes the others\' relationships, their boxes on every view, and the properties it lacks; the others go. Where they differ (marked ≠), choose.',
36252
- columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
36253
- rows,
36254
- document: this.document ?? globalThis.document,
36255
- });
36256
- // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
36257
- if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
36258
- const { documentation, properties, specializations } = this.mergeChoices(elements);
36259
- const JOINED = '\u0000joined';
36260
- const propertyKeys = [...properties.keys()];
36261
- compare.fields = [
36262
- { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
36263
- ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
36264
- ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
36265
- ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
36266
- ];
36267
- const values = await compare.ask();
36268
- if (!values) return null;
36269
- const { keep } = values;
36270
- const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
36271
- return this.mergeElements(keep, ids.filter(id => id !== keep), {
36272
- ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
36273
- ...(specializations.length && { specialization: values.specialization }),
36274
- properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
36275
- });
36276
- }
36277
-
36278
- /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
36279
- mergeElements(keepId, otherIds, choices = {}) {
36280
- const cmd = this.model.mergeCommand(keepId, otherIds, choices);
36281
- this._executeCommand(cmd);
36282
- return cmd;
36283
- }
36284
-
36285
- /** The checks use this editor's ArchiMate version. */
36286
- checkerOptions() {
36287
- return { version: this.version };
36288
- }
36289
-
36290
- /** An element is shown by selecting its first occurrence on this view. */
36291
- revealFinding(finding) {
36292
- if (finding.kind !== 'element') return super.revealFinding(finding);
36293
- const instance = this.view.elementInstancesFor(finding.ids[0])[0];
36294
- if (instance && this.templates.has(instance.id)) {
36295
- this.selectTemplate(instance.id);
36296
- this.render();
36297
- }
36298
- }
36299
-
36300
- /**
36301
- * Bring the editor's templates and lines in line with the view, reusing
36302
- * the template or line of each instance still there.
36303
- */
36304
- syncFromView() {
36305
- const { view, model } = this;
36306
- const templates = new Map();
36307
- for (const inst of view.elementInstances.values()) {
36308
- if (!inst.shownIn(view) || !inst.resolve(model)) continue;
36309
- const existing = this.templates.get(inst.id);
36310
- templates.set(inst.id, existing?.instance === inst ? existing
36311
- : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
36312
- }
36313
- for (const t of templates.values()) {
36314
- t.nodeOptions = this.nodeOptions;
36315
- // Asked afresh at every sync: a host's status is live, not part of the model.
36316
- t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
36317
- }
36318
- // The view's groups (Archi groups: model folders with bounds on this view).
36319
- for (const folder of view.placedFolders) {
36320
- const existing = this.templates.get(folder.id);
36321
- templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
36322
- }
36323
- const edges = new Map();
36324
- // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
36325
- const nestedOut = (edge) => !this.nodeOptions.showNestedLines
36326
- && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
36327
- for (const inst of view.relationshipInstances.values()) {
36328
- const existing = this.edges.get(inst.id);
36329
- const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
36330
- if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
36331
- }
36332
- // Plain lines (a canvas's connections, a line from a note).
36333
- for (const line of view.viewConnections.values()) {
36334
- const existing = this.edges.get(line.id);
36335
- const edge = existing?.line === line ? existing : new ViewLineEdge(line);
36336
- if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
36337
- }
36338
- // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
36339
- if (this.nodeOptions.showDerived) {
36340
- const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
36341
- for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
36342
- const from = shown(derived.source), to = shown(derived.target);
36343
- if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
36344
- }
36345
- }
36346
- this.templates.clear();
36347
- for (const [id, t] of templates) this.templates.set(id, t);
36348
- this.edges.clear();
36349
- for (const [id, e] of edges) this.edges.set(id, e);
36350
- }
36351
-
36352
- render() {
36353
- if (this.disposed) return;
36354
- this.syncFromView();
36355
- super.render();
36356
- }
36357
-
36358
- /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
36359
- settingsMenuItems() {
36360
- const set = (patch) => {
36361
- this.nodeOptions = { ...this.nodeOptions, ...patch };
36362
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
36363
- else this.render();
36364
- };
36365
- const distinct = !!this.nodeOptions.useDistinctShapes;
36366
- const viewpoint = this.nodeOptions.viewpoint ?? 'All';
36367
- const mode = this.nodeOptions.viewpointMode ?? 'fade';
36368
- return [
36369
- {
36370
- label: `Version: ArchiMate ${this.version}`,
36371
- items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
36372
- },
36373
- { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
36374
- { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
36375
- { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
36376
- {
36377
- label: `Viewpoint: ${viewpoint}`,
36378
- items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
36379
- },
36380
- {
36381
- label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
36382
- items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
36383
- },
36384
- { separator: true },
36385
- { separator: true },
36386
- { label: 'Specializations…', action: () => this.editSpecializations() },
36387
- { label: 'Viewpoints…', action: () => this.editViewpoints() },
36388
- ...super.settingsMenuItems(),
36389
- ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
36390
- ];
36391
- }
36392
-
36393
- /**
36394
- * What the ArchiMate rules say against a relationship's line, or null
36395
- * (ArchimateValidator, for this ArchiMate version). Off when the view's
36396
- * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
36397
- */
36398
- edgeWarning(edge) {
36399
- const relationship = edge.relationship;
36400
- const mode = this.viewSettings().invalidRelationships ?? 'highlight';
36401
- if (!relationship || mode === 'off') return null;
36402
- const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
36403
- if (!from || !to) return null;
36404
- const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
36405
- return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
36406
- }
36407
-
36408
- /** Edit the specialization catalog; elements given one redraw with it. */
36409
- editSpecializations() {
36410
- return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
36411
- }
36412
-
36413
- /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
36414
- editViewpoints() {
36415
- return CustomViewpoints.dialog({
36416
- version: this.version, document: this.document ?? globalThis.document,
36417
- onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
36418
- }).open();
36419
- }
36420
-
36421
- /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
36422
- _notationChanged() {
36423
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
36424
- else this.render();
36425
- }
36426
-
36427
- /**
36428
- * The base editor's rules (a highlight), then the active filter's result
36429
- * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
36430
- */
36431
- emphasisRules() {
36432
- const { viewpoint, viewpointMode } = this.nodeOptions;
36433
- const key = `${viewpoint}|${viewpointMode}|${this.version}`;
36434
- if (this._viewpointRule?.key !== key) {
36435
- this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
36436
- }
36437
- return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
36438
- }
36439
-
36440
- /** Dim every model element but these (a filter's result); null clears it. Redraws. */
36441
- setElementFilter(elementIds) {
36442
- this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
36443
- this.render();
36444
- }
36445
-
36446
- /**
36447
- * The Element section of a shape's properties: the model element's own
36448
- * name, type, specialization, icon, documentation and typed properties (the same on every view;
36449
- * the dialog's Name above labels this occurrence only). None for a note,
36450
- * image or canvas box.
36451
- */
36452
- conceptFields(template) {
36453
- const element = this._elementOf(template.id);
36454
- if (!element) return [];
36455
- return [
36456
- { type: 'heading', label: 'Element (on every view)' },
36457
- ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
36458
- ];
36459
- }
36460
-
36461
- /** The element's changes as one EditViewItemCommand (null when nothing changed). */
36462
- conceptCommand(template, values) {
36463
- const element = this._elementOf(template.id);
36464
- return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
36465
- }
36466
-
36467
- /**
36468
- * With several shapes selected, the Elements section: their type, their
36469
- * specialization when they share a type, and one property to set on all.
36470
- * Only when every selected shape is an element.
36471
- */
36472
- manyConceptFields(templates) {
36473
- const elements = this._elementsOf(templates);
36474
- return elements ? manyElementsFields(elements, { version: this.version }) : [];
36475
- }
36476
-
36477
- /** The elements' changes as one step (null when nothing changed). */
36478
- manyConceptCommand(templates, values) {
36479
- const elements = this._elementsOf(templates);
36480
- const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
36481
- const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
36482
- return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
36483
- }
36484
-
36485
- /** The model elements the shapes show, each once; null unless every shape shows one. @private */
36486
- _elementsOf(templates) {
36487
- const elements = templates.map(t => this._elementOf(t.id));
36488
- return elements.every(Boolean) ? [...new Set(elements)] : null;
36489
- }
36490
-
36491
- /**
36492
- * The Relationship section of a line's properties: its kind (among those
36493
- * the ArchiMate rules allow between its ends, and its own), documentation
36494
- * and typed properties. None for a plain line or a derived one.
36495
- */
36496
- edgeConceptFields(edge) {
36497
- const relationship = edge.relationship;
36498
- if (!relationship || edge.editable === false) return [];
36499
- const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
36500
- return [
36501
- { type: 'heading', label: 'Relationship (on every view)' },
36502
- { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
36503
- { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
36504
- { type: 'heading', label: 'Properties' },
36505
- PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
36506
- ];
36507
- }
36508
-
36509
- /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
36510
- edgeConceptCommand(edge, values) {
36511
- return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
36512
- }
36513
-
36514
- /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
36515
- addElement(type, point) {
36516
- const name = typeTitle(type);
36517
- const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
36518
- const at = this._snapToGrid(point);
36519
- const instance = new ElementInstance(id, at.x, at.y);
36520
- this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
36521
- return instance;
36522
- }
36523
-
36524
- /**
36525
- * Show an element the model has at `point` (snapped), with its
36526
- * relationships to the elements already on the view (ShowElementCommand).
36527
- * One undo step. Null for an element the model lacks.
36528
- */
36529
- showElement(elementId, point) {
36530
- if (!this.model.getElement(elementId)) return null;
36531
- const at = this._snapToGrid(point);
36532
- const instance = new ElementInstance(elementId, at.x, at.y);
36533
- this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
36534
- this.selectTemplate(instance.id);
36535
- return instance;
36536
- }
36537
-
36538
- /**
36539
- * A picture as an image box on this view at `point` (snapped), one undo
36540
- * step, selected. Null when this kind of view has no image box.
36541
- * @param {string} dataURL - The picture
36542
- */
36543
- addImage(dataURL, point) {
36544
- if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
36545
- const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
36546
- this.render();
36547
- this.selectTemplate(box.id);
36548
- return box;
36549
- }
36550
-
36551
- /**
36552
- * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
36553
- * (snapped), as the canvas menu would. One undo step.
36554
- */
36555
- addCanvasBox(kindKey, point) {
36556
- const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
36557
- onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
36558
- }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
36559
- return item?.action();
36560
- }
36561
-
36562
- /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
36563
- allowedKinds(sourceId, targetId) {
36564
- const from = this._elementOf(sourceId), to = this._elementOf(targetId);
36565
- if (!from || !to) return [];
36566
- const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
36567
- return kinds.includes('association') ? kinds : [...kinds, 'association'];
36568
- }
36569
-
36570
- /**
36571
- * Connect two elements: the kind is asked for (`chooseKind`) among those
36572
- * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
36573
- * @returns {Command|null|Promise<Command|null>}
36574
- */
36575
- connect(sourceId, targetId) {
36576
- if (sourceId === targetId) return null;
36577
- // A box or note at either end: a plain line (the view's rule), one undo step.
36578
- if (!this.view.linesAreRelationships(sourceId, targetId)) {
36579
- if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
36580
- const line = this.view.connect(sourceId, targetId);
36581
- this.render();
36582
- return line;
36583
- }
36584
- const kinds = this.allowedKinds(sourceId, targetId);
36585
- if (!kinds.length) return null;
36586
- const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
36587
- if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
36588
- const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
36589
- return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
36590
- }
36591
-
36592
- /** @private */
36593
- _connectAs(sourceId, targetId, kind) {
36594
- const cmd = this.connectCommand(sourceId, targetId, kind);
36595
- if (cmd) this._executeCommand(cmd);
36596
- return cmd;
36597
- }
36598
-
36599
- /** The model element an instance on this canvas shows (notes and images have none). @private */
36600
- /** A box of a model element links to the element's page in a report. */
36601
- reportRef(template) {
36602
- const element = this._elementOf(template.id);
36603
- return element ? { kind: 'element', id: element.id } : null;
36604
- }
36605
-
36606
- _elementOf(instanceId) {
36607
- const inst = this.templates.get(instanceId)?.instance;
36608
- return inst ? this.model.getElement(inst.elementId) : undefined;
36609
- }
36610
-
36611
- /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
36612
- connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
36613
- const source = this.templates.get(sourceId)?.instance;
36614
- const target = this.templates.get(targetId)?.instance;
36615
- const from = this._elementOf(sourceId);
36616
- const to = this._elementOf(targetId);
36617
- // Notes and images are not model elements: they take no relationships.
36618
- if (!from || !to || !kind) return null;
36619
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
36620
- const instance = new RelationshipInstance(id, null, source.id, target.id);
36621
- return new AddRelationshipToViewCommand(this.view, instance, {
36622
- relationship: { id, source: from.id, target: to.id, kind },
36623
- label: `Add ${kind}`,
36624
- });
36625
- }
36626
-
36627
- /**
36628
- * Deleting takes a box off the view (its lines go with it, see the base's
36629
- * deleteTemplatesCommand); the model keeps its element. A group goes off the
36630
- * view and what it held stays; the model keeps its folder.
36631
- */
36632
- /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
36633
- static clipboardKind = 'archimate';
36634
-
36635
- /**
36636
- * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
36637
- * with the lines and relationships between them and the elements they show.
36638
- * Groups are not copied (they are model folders).
36639
- */
36640
- copyPayload(ids) {
36641
- const boxes = ids.filter(id => this.view.getElementInstance(id));
36642
- return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
36643
- }
36644
-
36645
- /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
36646
- pasteCommand(payload, options) {
36647
- return this.view.pasteCommand(payload, options);
36648
- }
36649
-
36650
- /** Deleting is off the view: the model keeps what it showed. */
36651
- deleteLabel(templateIds) {
36652
- return `${super.deleteLabel(templateIds)} from view`;
36653
- }
36654
-
36655
- removeTemplateCommand(templateId) {
36656
- return this.templates.get(templateId) instanceof ViewGroupTemplate
36657
- ? new RemoveGroupFromViewCommand(this.view, templateId)
36658
- : new DeleteElementCommand(this.view, templateId);
36659
- }
36660
-
36661
- /**
36662
- * What moves with a shape: for a group, the elements drawn in it (its folder's),
36663
- * the notes and pictures Archi drew in it, and the groups inside it; for a box (a canvas block), the boxes Archi drew inside it.
36664
- */
36665
- containedIn(templateId) {
36666
- const template = this.templates.get(templateId);
36667
- const view = this.view;
36668
- if (template instanceof ViewGroupTemplate) {
36669
- const inner = [];
36670
- for (const t of this.templates.values()) {
36671
- if (t instanceof ViewGroupTemplate) {
36672
- if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
36673
- } else if (t.instance?.archi?.parent === templateId
36674
- || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
36675
- inner.push(t.id);
36676
- }
36677
- }
36678
- return inner;
36679
- }
36680
- const visualId = template?.instance?.visualId;
36681
- if (!visualId) return [];
36682
- return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
36683
- }
36684
-
36685
- // ===== Nesting =====
36686
-
36687
- /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
36688
- parentOf(templateId) {
36689
- const visualId = this.templates.get(templateId)?.instance?.nestedIn;
36690
- if (!visualId) return null;
36691
- const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
36692
- return parent?.id ?? null;
36693
- }
36694
-
36695
- /**
36696
- * An element's parts on this view: the elements it is composed of or
36697
- * aggregates (its composition and aggregation relationships' targets),
36698
- * each by its first box here.
36699
- */
36700
- partsOf(templateId) {
36701
- const element = this._elementOf(templateId);
36702
- if (!element) return [];
36703
- const parts = this.model.relationships()
36704
- .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
36705
- .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
36706
- .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
36707
- return [...new Set(parts)];
36708
- }
36709
-
36710
- /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
36711
- layoutCategory(template) {
36712
- const element = this._elementOf(template.id);
36713
- return element ? treeLayerOf(element) : 'other';
36714
- }
36715
-
36716
- /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
36717
- canHold(parentId, childId) {
36718
- const parent = this.templates.get(parentId), child = this.templates.get(childId);
36719
- if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
36720
- return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
36721
- }
36722
-
36723
- /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
36724
- nestsOnDrop(parentId, childId) {
36725
- if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
36726
- return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
36727
- }
36728
-
36729
- nestCommand(childId, parentId) {
36730
- const child = this.templates.get(childId)?.instance;
36731
- if (!child) return null;
36732
- return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
36733
- }
36734
-
36735
- /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
36736
- nested(changes) {
36737
- let chain = Promise.resolve();
36738
- for (const { childId, parentId } of changes) {
36739
- chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
36740
- }
36741
- return chain;
36742
- }
36743
-
36744
- /**
36745
- * The kinds a nesting can stand for, from parent to child, as Archi offers
36746
- * them: those of NESTING_KINDS the rules allow. None when the two already
36747
- * have such a relationship.
36748
- */
36749
- nestingKinds(parentId, childId) {
36750
- const from = this._elementOf(parentId), to = this._elementOf(childId);
36751
- if (!from || !to || from === to) return [];
36752
- if (nestingRelationships(this.model, from.id, to.id).length) return [];
36753
- const allowed = this.allowedKinds(parentId, childId);
36754
- return NESTING_KINDS.filter(k => allowed.includes(k));
36755
- }
36756
-
36757
- /**
36758
- * Ask which relationship a nesting stands for (`chooseKind`, with the
36759
- * reason 'nesting'; none chosen adds none) and add it to the model, drawn
36760
- * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
36761
- * @returns {Promise<Command|null>}
36762
- */
36763
- async offerNestingRelationship(parentId, childId) {
36764
- const kinds = this.nestingKinds(parentId, childId);
36765
- if (!kinds.length || !this.chooseKind) return null;
36766
- const from = this._elementOf(parentId), to = this._elementOf(childId);
36767
- const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
36768
- if (!kind || !kinds.includes(kind)) return null;
36769
- const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
36770
- this._executeCommand(cmd);
36771
- return cmd;
36772
- }
36773
-
36774
- /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
36775
- nestingRelationshipCommand(parentId, childId, kind) {
36776
- const from = this._elementOf(parentId), to = this._elementOf(childId);
36777
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
36778
- return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
36779
- }
36780
-
36781
- /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
36782
- deleteEdgeCommand(edgeId) {
36783
- return this.view.viewConnections.has(edgeId)
36784
- ? new RemoveLineCommand(this.view, edgeId)
36785
- : new RemoveRelationshipFromViewCommand(this.view, edgeId);
36786
- }
36507
+ /**
36508
+ * Which allowed relationship kind a new connection takes when nobody is asked:
36509
+ * the dependency and dynamic kinds people usually mean first, the structural
36510
+ * whole/part kinds last.
36511
+ */
36512
+ const KIND_PREFERENCE = Object.freeze([
36513
+ 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
36514
+ 'specialization', 'aggregation', 'composition', 'association',
36515
+ ]);
36516
+
36517
+ /**
36518
+ * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
36519
+ * view: its element instances as ArchimateElementTemplates, its relationship
36520
+ * instances as ArchimateEdges, and its own undo history. Everything the base
36521
+ * editor does (move, resize, restack, bend points, sides, styles, zoom,
36522
+ * snapping) edits the view directly; the editor's maps are rebuilt from the
36523
+ * view on every render, so undo of a view edit shows straight away.
36524
+ *
36525
+ * What stands for something else in ArchiMate is done through the view and
36526
+ * model instead of the maps:
36527
+ * - dropping a palette type adds a new model element and shows it;
36528
+ * - connecting two elements adds a relationship, its kind the first the
36529
+ * ArchiMate rules allow between them (association if none);
36530
+ * - deleting removes from the view (with the element's lines); the model
36531
+ * keeps its elements and relationships.
36532
+ * Each is one step on the view's history.
36533
+ */
36534
+ class ArchimateCanvasEditor extends DiagramEditor {
36535
+ /**
36536
+ * @param {Object} options - DiagramEditor options, plus:
36537
+ * @param {ArchimateView} options.view
36538
+ * @param {ArchimateModel} options.model
36539
+ * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
36540
+ * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
36541
+ * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
36542
+ * Asks which relationship kind a new connection is, given the kinds allowed between the two
36543
+ * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
36544
+ * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
36545
+ * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
36546
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
36547
+ * live status for an element (a component's lifecycle state), drawn on its boxes
36548
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
36549
+ * element (its transitions), at the end of a box's right-click menu
36550
+ */
36551
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
36552
+ super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
36553
+ this.view = view;
36554
+ this.model = model;
36555
+ this.nodeOptions = nodeOptions;
36556
+ this.chooseKind = chooseKind;
36557
+ this.onNodeOptionsChange = onNodeOptionsChange;
36558
+ /** Viewpoints of the user's own, offered with the standard ones. */
36559
+ this.customViewpoints = customViewpoints;
36560
+ /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
36561
+ this.settingsManager = settingsManager;
36562
+ /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
36563
+ this.templateMenu = templateMenu;
36564
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
36565
+ this.elementStatus = elementStatus;
36566
+ this.elementMenuExtras = elementMenuExtras;
36567
+ this.addDropHandler({
36568
+ accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
36569
+ drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
36570
+ });
36571
+ // An element from the model tree: shown here, with its relationships to what is already shown.
36572
+ this.addDropHandler({
36573
+ accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
36574
+ drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
36575
+ });
36576
+ // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
36577
+ if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
36578
+ // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
36579
+ this.addDropHandler({
36580
+ accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
36581
+ drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
36582
+ });
36583
+ }
36584
+
36585
+ get version() {
36586
+ return this.nodeOptions.archimateVersion ?? '4';
36587
+ }
36588
+
36589
+ /**
36590
+ * An element's own items (a view of it and what it relates to; its
36591
+ * relationships to what is shown, when some are not), a canvas box's hint,
36592
+ * and the shape's selection (or the shape and its group) as a template.
36593
+ */
36594
+ shapeMenuExtras(elementId) {
36595
+ const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
36596
+ const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
36597
+ const element = this._elementOf(elementId);
36598
+ const missing = element ? this.missingRelationshipsOf(elementId) : [];
36599
+ // Two or more elements chosen: merge them into one (choose the one kept).
36600
+ const merging = this.elementIdsOf(ids);
36601
+ return [
36602
+ ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
36603
+ ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
36604
+ ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
36605
+ ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
36606
+ ...(items.length ? [{ label: 'Save as template', items }] : []),
36607
+ ...(merging.length ? [{ label: '🗑 Delete from model…', danger: true, action: () => this.askDeleteFromModel(merging) }] : []),
36608
+ // The host's own items end the menu.
36609
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
36610
+ ];
36611
+ }
36612
+
36613
+ /** Delete elements from the model, once the dialog saying where they are used is confirmed (askDeleteFromModel). */
36614
+ askDeleteFromModel(elementIds) {
36615
+ return askDeleteFromModel(this.model, elementIds, {
36616
+ document: this.document ?? globalThis.document,
36617
+ onConfirm: (ids) => this.deleteFromModel(ids),
36618
+ });
36619
+ }
36620
+
36621
+ /** Take elements out of the model, with their relationships and every box and line of them, as one undo step. */
36622
+ deleteFromModel(elementIds) {
36623
+ const cmd = this.model.deleteFromModelCommand(elementIds);
36624
+ if (!cmd.elementIds.length) return null;
36625
+ this.clearSelection();
36626
+ this._executeCommand(cmd);
36627
+ return cmd;
36628
+ }
36629
+
36630
+ /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
36631
+ missingRelationshipsOf(instanceId) {
36632
+ const instance = this.view.getElementInstance(instanceId);
36633
+ if (!instance) return [];
36634
+ return this.view.missingRelationshipInstances(instance.elementId)
36635
+ .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
36636
+ }
36637
+
36638
+ /** Draw them all, as one undo step. */
36639
+ addMissingRelationships(instanceId) {
36640
+ const missing = this.missingRelationshipsOf(instanceId);
36641
+ if (!missing.length) return null;
36642
+ const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
36643
+ missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
36644
+ this._executeCommand(cmd);
36645
+ return cmd;
36646
+ }
36647
+
36648
+ /** The view's boxes (a canvas's palette; a note or picture elsewhere) at the point clicked, and on a canvas a template placed there. */
36649
+ canvasMenuItems(at) {
36650
+ const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
36651
+ const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
36652
+ // Elements from text: not on a canvas, whose boxes are not elements.
36653
+ const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
36654
+ return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
36655
+ }
36656
+
36657
+ /** The View menu, and Generate roadmap… (elements with dates on a time line). */
36658
+ viewMenuItems() {
36659
+ const repeated = this.view?.repeatedBoxes?.() ?? [];
36660
+ return [
36661
+ ...super.viewMenuItems(),
36662
+ { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
36663
+ { label: 'Generate roadmap…', action: () => this.askRoadmap() },
36664
+ ];
36665
+ }
36666
+
36667
+ /** The model's own: Find likely duplicates…. */
36668
+ modelMenuItems() {
36669
+ return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
36670
+ }
36671
+
36672
+ /**
36673
+ * Take off this view the boxes that only repeat another box of their
36674
+ * element (ArchimateView#repeatedBoxes: same label, same lines), with
36675
+ * their lines, as one undo step. Returns the command, or null with none.
36676
+ */
36677
+ removeRepeatedBoxes() {
36678
+ const ids = this.view.repeatedBoxes();
36679
+ if (!ids.length) return null;
36680
+ const cmd = this.deleteTemplatesCommand(ids);
36681
+ cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
36682
+ this._executeCommand(cmd);
36683
+ return cmd;
36684
+ }
36685
+
36686
+ /**
36687
+ * Where Find likely duplicates… can look: the whole model, the elements on
36688
+ * the view in front, the elements selected (two or more). [key, label, element ids or null].
36689
+ */
36690
+ duplicateScopes() {
36691
+ const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
36692
+ const selected = this.elementIdsOf(this.getSelectedIds());
36693
+ return [
36694
+ ['model', 'The whole model', null],
36695
+ ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
36696
+ ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
36697
+ ];
36698
+ }
36699
+
36700
+ /**
36701
+ * Find likely duplicates…: where to look (duplicateScopes), how to compare
36702
+ * (names, documentation or a property's value; exactly or by their words)
36703
+ * and what noise to leave out (duplicateSearchFields), then the groups
36704
+ * found, each with Merge… (showDuplicates).
36705
+ */
36706
+ askDuplicates() {
36707
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
36708
+ const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
36709
+ const scopes = this.duplicateScopes();
36710
+ return new FormDialog({
36711
+ title: 'Find likely duplicates',
36712
+ description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
36713
+ fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
36714
+ submitLabel: 'Find',
36715
+ idPrefix: 'duplicates-',
36716
+ document: this.document ?? globalThis.document,
36717
+ validate: validateDuplicateSearch,
36718
+ onApply: (values) => {
36719
+ const { options, scope } = duplicateSearchOptions(values);
36720
+ this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
36721
+ },
36722
+ }).open();
36723
+ }
36724
+
36725
+ /**
36726
+ * The elements that look like one thing twice (the model check's likely
36727
+ * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
36728
+ * among these element ids), each group with Merge….
36729
+ */
36730
+ showDuplicates(options = {}, { within = null } = {}) {
36731
+ const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
36732
+ const groups = find();
36733
+ new DuplicatesDialog({
36734
+ groups,
36735
+ refind: find,
36736
+ merge: async (group) => !!(await this.askMerge(group.ids)),
36737
+ onReveal: (group) => this.revealFinding(group),
36738
+ empty: 'No elements look like the same thing twice.',
36739
+ document: this.document ?? globalThis.document,
36740
+ }).open();
36741
+ return groups;
36742
+ }
36743
+
36744
+ /** The model elements of some boxes, each once (a box that is not an element is left out). */
36745
+ elementIdsOf(instanceIds) {
36746
+ return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
36747
+ }
36748
+
36749
+ /**
36750
+ * Generate roadmap…: which elements (the selection's, or every element in
36751
+ * the model with both dates), the properties their dates are in, and how
36752
+ * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
36753
+ */
36754
+ askRoadmap() {
36755
+ const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
36756
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
36757
+ return new FormDialog({
36758
+ title: 'Generate roadmap',
36759
+ description: 'A view with each element a bar from its start date to its end date, under the months.',
36760
+ fields: [
36761
+ { key: 'from', label: 'Elements', type: 'select', value: selected.length ? 'selection' : 'model', choices: [...(selected.length ? [['selection', `The ${selected.length} selected`]] : []), ['model', 'Every element with both dates']] },
36762
+ { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
36763
+ { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
36764
+ { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
36765
+ { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
36766
+ ],
36767
+ submitLabel: 'Generate',
36768
+ idPrefix: 'roadmap-',
36769
+ document: this.document ?? globalThis.document,
36770
+ validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
36771
+ onApply: (v) => this.generateRoadmap(v),
36772
+ }).open();
36773
+ }
36774
+
36775
+ /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
36776
+ roadmapFor({ from, start, end }) {
36777
+ const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
36778
+ const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
36779
+ const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
36780
+ return { ids, view: any ? true : null };
36781
+ }
36782
+
36783
+ /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
36784
+ generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
36785
+ const { ids } = this.roadmapFor({ from, start, end });
36786
+ return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
36787
+ }
36788
+
36789
+ /** An element's box keeps room for its type badge, and Archi's height. */
36790
+ textFit(template) {
36791
+ return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
36792
+ }
36793
+
36794
+ /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
36795
+ shapeTypes() {
36796
+ const viewpoint = this.nodeOptions.viewpoint;
36797
+ const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
36798
+ return ArchimateNotation.elementTypeNames(this.version)
36799
+ .filter(t => !allowed || allowed.includes(t))
36800
+ .map(t => [t, typeTitle(t)]);
36801
+ }
36802
+
36803
+ /**
36804
+ * New model elements from text, each with its box here: one undo step.
36805
+ * A documentation column becomes the element's documentation.
36806
+ */
36807
+ createShapesCommand(items) {
36808
+ const ids = this.model.elements().map(e => e.id);
36809
+ const steps = [], createdIds = [];
36810
+ for (const item of items) {
36811
+ const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
36812
+ ids.push(id);
36813
+ const instance = new ElementInstance(id, item.x, item.y);
36814
+ createdIds.push(instance.id);
36815
+ steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
36816
+ }
36817
+ const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
36818
+ cmd.createdIds = createdIds;
36819
+ return cmd;
36820
+ }
36821
+
36822
+ /**
36823
+ * What elements can be coloured by: their layer, type, specialization, each
36824
+ * property any of them has, the change deliverables and work packages make
36825
+ * to them (when the model has any), and a formula over their properties.
36826
+ */
36827
+ colourKeys(templates = [...this.templates.values()]) {
36828
+ const properties = new Set();
36829
+ for (const t of templates) for (const p of this._elementOf(t.id)?.properties ?? []) if (p.name ?? p.key) properties.add(p.name ?? p.key);
36830
+ return [
36831
+ ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
36832
+ ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
36833
+ ...(this.model && hasChangeMakers(this.model) ? [['change', 'Change (create, update, delete)']] : []),
36834
+ ...(properties.size ? [['expression', 'A formula over properties…']] : []),
36835
+ ];
36836
+ }
36837
+
36838
+ /** By change, a relationship to a deliverable or work package is drawn in the colour of the change it says. */
36839
+ colourEdgeValue(edge, key) {
36840
+ if (key !== 'change' || !edge.relationship) return super.colourEdgeValue(edge, key);
36841
+ return relationshipChange(this.model, edge.relationship) ?? undefined;
36842
+ }
36843
+
36844
+ /** A formula's key (`expression:<formula>`) is called by its formula. */
36845
+ colourKeyLabel(key) {
36846
+ return key.startsWith('expression:') ? key.slice(11) : super.colourKeyLabel(key);
36847
+ }
36848
+
36849
+ /** The change heat map's own colours; a formula's results, light to dark in order; else the palette. */
36850
+ colourDefaults(key, values) {
36851
+ if (key === 'change') return values.map(v => CHANGE_COLOURS[v]);
36852
+ if (key.startsWith('expression:')) return sequentialColours(values.length);
36853
+ return super.colourDefaults(key, values);
36854
+ }
36855
+
36856
+ /**
36857
+ * A formula's values: each element's result (expressionValues), as its own
36858
+ * value when there are few, else in bands, smallest first (resultBands).
36859
+ */
36860
+ colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
36861
+ if (!key.startsWith('expression:')) return super.colourValues(key, templates);
36862
+ const boxes = templates.map(t => [t, this._elementOf(t.id)]).filter(([, e]) => e);
36863
+ const results = expressionValues([...new Set(boxes.map(([, e]) => e))], key.slice(11));
36864
+ const { labels, labelOf } = resultBands(results.values());
36865
+ const values = new Map(labels.map(l => [l, []]));
36866
+ for (const [t, e] of boxes) if (results.has(e.id)) values.get(labelOf(results.get(e.id))).push(t);
36867
+ return values;
36868
+ }
36869
+
36870
+ /** A formula over properties asks for the formula first. @protected */
36871
+ askColours(key) {
36872
+ if (key !== 'expression') return super.askColours(key);
36873
+ const elements = [...new Set([...this.templates.values()].map(t => this._elementOf(t.id)).filter(Boolean))];
36874
+ const names = [...propertyParams(elements)].map(([id, name]) => (id === name ? id : `${id} (${name})`));
36875
+ new FormDialog({
36876
+ title: 'Colour by a formula',
36877
+ description: `A number from each element's properties, e.g. Cost * Risk, or Cost > 10000 ? 1 : 0. Properties: ${names.join(', ')}. `
36878
+ + `Arithmetic, comparisons, a ? b : c, and ${Object.keys(KERNEL_FUNCTIONS).join(', ')}. `
36879
+ + 'An element without a number for a property the formula uses is left as it is.',
36880
+ fields: [{ key: 'formula', label: 'Formula', type: 'text', value: this._lastFormula ?? '' }],
36881
+ submitLabel: 'Next',
36882
+ idPrefix: 'colour-formula-',
36883
+ document: this.document ?? globalThis.document,
36884
+ validate: ({ formula }) => {
36885
+ try { expressionValues(elements, formula); return null; } catch (e) { return e.message; }
36886
+ },
36887
+ onApply: ({ formula }) => {
36888
+ this._lastFormula = formula;
36889
+ super.askColours(`expression:${formula}`);
36890
+ },
36891
+ }).open();
36892
+ }
36893
+
36894
+ /** An element's value for a colourKeys key (notes and pictures have none). */
36895
+ colourValue(template, key) {
36896
+ const element = this._elementOf(template.id);
36897
+ if (!element) return undefined;
36898
+ if (key === 'layer') return typeTitle(treeLayerOf(element));
36899
+ if (key === 'type') return typeTitle(element.type ?? '');
36900
+ if (key === 'specialization') return element.specialization;
36901
+ if (key === 'change') return elementChange(this.model, element.id) ?? undefined;
36902
+ if (key.startsWith('property:')) {
36903
+ const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
36904
+ return p?.value === undefined || p?.value === null ? undefined : String(p.value);
36905
+ }
36906
+ return super.colourValue(template, key);
36907
+ }
36908
+
36909
+ /** The kind legend: each element type on the view, in the colour of its first box. */
36910
+ kindLegendEntries() {
36911
+ const types = new Map();
36912
+ for (const t of this.templates.values()) {
36913
+ const element = this._elementOf(t.id);
36914
+ if (!element || types.has(element.type)) continue;
36915
+ types.set(element.type, t.fillColor);
36916
+ }
36917
+ return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
36918
+ }
36919
+
36920
+ /**
36921
+ * A legend is notes (stickies on a canvas) on the view: a frame holding a
36922
+ * note per entry (DiagramEditor.legendLayout), grouped, as one undo step.
36923
+ * Placed in order, so the frame stacks behind its entries.
36924
+ */
36925
+ legendCommand(entries, title) {
36926
+ if (!entries.length) return null;
36927
+ const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
36928
+ if (!kind) return null;
36929
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
36930
+ const stamp = Date.now().toString(36);
36931
+ const box = (i, name, { x, y, width, height }, appearance) => {
36932
+ const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x, y);
36933
+ inst.size = { width, height };
36934
+ inst.appearance = { ...kind.appearance, ...appearance };
36935
+ return inst;
36936
+ };
36937
+ const boxes = [
36938
+ box(0, title, frame, { fillColor: '#ffffff', ...DiagramEditor.LEGEND_TITLE_STYLE }),
36939
+ ...entries.map((e, i) => box(i + 1, e.label, items[i], { fillColor: e.fillColor })),
36940
+ ];
36941
+ return new CompositeCommand(`Add legend: ${title}`, [
36942
+ new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` }),
36943
+ new GroupCommand(this.arrangement, boxes.map(b => b.id), { name: title }),
36944
+ ]);
36945
+ }
36946
+
36947
+ /** Likely duplicates can be merged (Merge…: choose the one to keep). */
36948
+ findingActions(finding) {
36949
+ return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
36950
+ }
36951
+
36952
+ /**
36953
+ * What differs among some elements, for the merge to choose: their
36954
+ * documentation (when two or more have some, and it differs), each property
36955
+ * with different values, and the specialization.
36956
+ * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
36957
+ * each choice [value, label]
36958
+ */
36959
+ mergeChoices(elements) {
36960
+ const name = (e) => e.name || e.id;
36961
+ const documented = elements.filter(e => (e.documentation ?? '').trim());
36962
+ const texts = [...new Set(documented.map(e => e.documentation.trim()))];
36963
+ const properties = new Map();
36964
+ for (const e of elements) {
36965
+ for (const p of e.properties ?? []) {
36966
+ const key = p.name ?? p.key;
36967
+ if (!properties.has(key)) properties.set(key, new Map());
36968
+ const values = properties.get(key);
36969
+ values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
36970
+ }
36971
+ }
36972
+ const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
36973
+ return {
36974
+ documentation: texts.length > 1 ? texts.map(t => [t, `${documented.filter(e => e.documentation.trim() === t).map(name).join(', ')}: ${t.length > 50 ? `${t.slice(0, 50)}…` : t}`]) : [],
36975
+ properties: new Map([...properties].filter(([, values]) => values.size > 1)
36976
+ .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
36977
+ specializations: specializations.length > 1 ? specializations : [],
36978
+ };
36979
+ }
36980
+
36981
+ /**
36982
+ * Some elements side by side, for a merge (MergeCompareDialog's rows): their
36983
+ * names, types, specializations, documentation, each property, their
36984
+ * relationships (kind, direction, the element at the other end) and the
36985
+ * views they are on.
36986
+ */
36987
+ mergeComparison(elements) {
36988
+ const nameOf = (id) => this.model.getElement(id)?.name || id;
36989
+ const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
36990
+ const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
36991
+ const lines = (e) => {
36992
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
36993
+ return [
36994
+ ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
36995
+ ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
36996
+ ].sort().join('\n');
36997
+ };
36998
+ const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
36999
+ return [
37000
+ { label: 'Name', values: elements.map(e => e.name ?? '') },
37001
+ { label: 'Type', values: elements.map(e => e.type ?? '') },
37002
+ ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
37003
+ { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
37004
+ ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
37005
+ { label: 'Relationships', values: elements.map(lines) },
37006
+ { label: 'Views', values: elements.map(views) },
37007
+ ];
37008
+ }
37009
+
37010
+ /**
37011
+ * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
37012
+ * Their relationships always come together. When they differ in anything
37013
+ * but the views they are on, they are shown side by side
37014
+ * (MergeCompareDialog) to choose which is kept and, where they differ, the
37015
+ * documentation (one, or all joined), each property's value, the
37016
+ * specialization; OK merges, Cancel leaves them. Alike, the first is kept
37017
+ * without asking. Resolves to the merge's command, or null.
37018
+ */
37019
+ async askMerge(elementIds) {
37020
+ const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
37021
+ if (elements.length < 2) return null;
37022
+ const used = (e) => {
37023
+ const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
37024
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
37025
+ return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
37026
+ };
37027
+ const ids = elements.map(e => e.id);
37028
+ const rows = this.mergeComparison(elements);
37029
+ const compare = new MergeCompareDialog({
37030
+ title: `Merge ${elements.length} elements`,
37031
+ description: 'The one kept takes the others\' relationships, their boxes on every view, and the properties it lacks; the others go. Where they differ (marked ≠), choose.',
37032
+ columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
37033
+ rows,
37034
+ document: this.document ?? globalThis.document,
37035
+ });
37036
+ // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
37037
+ if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
37038
+ const { documentation, properties, specializations } = this.mergeChoices(elements);
37039
+ const JOINED = '\u0000joined';
37040
+ const propertyKeys = [...properties.keys()];
37041
+ compare.fields = [
37042
+ { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
37043
+ ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
37044
+ ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
37045
+ ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
37046
+ ];
37047
+ const values = await compare.ask();
37048
+ if (!values) return null;
37049
+ const { keep } = values;
37050
+ const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
37051
+ return this.mergeElements(keep, ids.filter(id => id !== keep), {
37052
+ ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
37053
+ ...(specializations.length && { specialization: values.specialization }),
37054
+ properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
37055
+ });
37056
+ }
37057
+
37058
+ /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
37059
+ mergeElements(keepId, otherIds, choices = {}) {
37060
+ const cmd = this.model.mergeCommand(keepId, otherIds, choices);
37061
+ this._executeCommand(cmd);
37062
+ return cmd;
37063
+ }
37064
+
37065
+ /** The checks use this editor's ArchiMate version. */
37066
+ checkerOptions() {
37067
+ return { version: this.version };
37068
+ }
37069
+
37070
+ /** An element is shown by selecting its first occurrence on this view. */
37071
+ revealFinding(finding) {
37072
+ if (finding.kind !== 'element') return super.revealFinding(finding);
37073
+ const instance = this.view.elementInstancesFor(finding.ids[0])[0];
37074
+ if (instance && this.templates.has(instance.id)) {
37075
+ this.selectTemplate(instance.id);
37076
+ this.render();
37077
+ }
37078
+ }
37079
+
37080
+ /**
37081
+ * Bring the editor's templates and lines in line with the view, reusing
37082
+ * the template or line of each instance still there.
37083
+ */
37084
+ syncFromView() {
37085
+ const { view, model } = this;
37086
+ const templates = new Map();
37087
+ for (const inst of view.elementInstances.values()) {
37088
+ if (!inst.shownIn(view) || !inst.resolve(model)) continue;
37089
+ const existing = this.templates.get(inst.id);
37090
+ templates.set(inst.id, existing?.instance === inst ? existing
37091
+ : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
37092
+ }
37093
+ for (const t of templates.values()) {
37094
+ t.nodeOptions = this.nodeOptions;
37095
+ // Asked afresh at every sync: a host's status is live, not part of the model.
37096
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
37097
+ }
37098
+ // The view's groups (Archi groups: model folders with bounds on this view).
37099
+ for (const folder of view.placedFolders) {
37100
+ const existing = this.templates.get(folder.id);
37101
+ templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
37102
+ }
37103
+ const edges = new Map();
37104
+ // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
37105
+ const nestedOut = (edge) => !this.nodeOptions.showNestedLines
37106
+ && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
37107
+ for (const inst of view.relationshipInstances.values()) {
37108
+ const existing = this.edges.get(inst.id);
37109
+ const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
37110
+ if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
37111
+ }
37112
+ // Plain lines (a canvas's connections, a line from a note).
37113
+ for (const line of view.viewConnections.values()) {
37114
+ const existing = this.edges.get(line.id);
37115
+ const edge = existing?.line === line ? existing : new ViewLineEdge(line);
37116
+ if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
37117
+ }
37118
+ // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
37119
+ if (this.nodeOptions.showDerived) {
37120
+ const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
37121
+ for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
37122
+ const from = shown(derived.source), to = shown(derived.target);
37123
+ if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
37124
+ }
37125
+ }
37126
+ this.templates.clear();
37127
+ for (const [id, t] of templates) this.templates.set(id, t);
37128
+ this.edges.clear();
37129
+ for (const [id, e] of edges) this.edges.set(id, e);
37130
+ }
37131
+
37132
+ render() {
37133
+ if (this.disposed) return;
37134
+ this.syncFromView();
37135
+ super.render();
37136
+ }
37137
+
37138
+ /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
37139
+ settingsMenuItems() {
37140
+ const set = (patch) => {
37141
+ this.nodeOptions = { ...this.nodeOptions, ...patch };
37142
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
37143
+ else this.render();
37144
+ };
37145
+ const distinct = !!this.nodeOptions.useDistinctShapes;
37146
+ const viewpoint = this.nodeOptions.viewpoint ?? 'All';
37147
+ const mode = this.nodeOptions.viewpointMode ?? 'fade';
37148
+ return [
37149
+ {
37150
+ label: `Version: ArchiMate ${this.version}`,
37151
+ items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
37152
+ },
37153
+ { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
37154
+ { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
37155
+ { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
37156
+ {
37157
+ label: `Viewpoint: ${viewpoint}`,
37158
+ items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
37159
+ },
37160
+ {
37161
+ label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
37162
+ items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
37163
+ },
37164
+ { separator: true },
37165
+ { separator: true },
37166
+ { label: 'Specializations…', action: () => this.editSpecializations() },
37167
+ { label: 'Viewpoints…', action: () => this.editViewpoints() },
37168
+ ...super.settingsMenuItems(),
37169
+ ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
37170
+ ];
37171
+ }
37172
+
37173
+ /**
37174
+ * What the ArchiMate rules say against a relationship's line, or null
37175
+ * (ArchimateValidator, for this ArchiMate version). Off when the view's
37176
+ * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
37177
+ */
37178
+ edgeWarning(edge) {
37179
+ const relationship = edge.relationship;
37180
+ const mode = this.viewSettings().invalidRelationships ?? 'highlight';
37181
+ if (!relationship || mode === 'off') return null;
37182
+ const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
37183
+ if (!from || !to) return null;
37184
+ const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
37185
+ return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
37186
+ }
37187
+
37188
+ /** Edit the specialization catalog; elements given one redraw with it. */
37189
+ editSpecializations() {
37190
+ return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
37191
+ }
37192
+
37193
+ /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
37194
+ editViewpoints() {
37195
+ return CustomViewpoints.dialog({
37196
+ version: this.version, document: this.document ?? globalThis.document,
37197
+ onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
37198
+ }).open();
37199
+ }
37200
+
37201
+ /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
37202
+ _notationChanged() {
37203
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
37204
+ else this.render();
37205
+ }
37206
+
37207
+ /**
37208
+ * The base editor's rules (a highlight), then the active filter's result
37209
+ * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
37210
+ */
37211
+ emphasisRules() {
37212
+ const { viewpoint, viewpointMode } = this.nodeOptions;
37213
+ const key = `${viewpoint}|${viewpointMode}|${this.version}`;
37214
+ if (this._viewpointRule?.key !== key) {
37215
+ this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
37216
+ }
37217
+ return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
37218
+ }
37219
+
37220
+ /** Dim every model element but these (a filter's result); null clears it. Redraws. */
37221
+ setElementFilter(elementIds) {
37222
+ this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
37223
+ this.render();
37224
+ }
37225
+
37226
+ /**
37227
+ * The Element section of a shape's properties: the model element's own
37228
+ * name, type, specialization, icon, documentation and typed properties (the same on every view;
37229
+ * the dialog's Name above labels this occurrence only). None for a note,
37230
+ * image or canvas box.
37231
+ */
37232
+ conceptFields(template) {
37233
+ const element = this._elementOf(template.id);
37234
+ if (!element) return [];
37235
+ return [
37236
+ { type: 'heading', label: 'Element (on every view)' },
37237
+ ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
37238
+ ];
37239
+ }
37240
+
37241
+ /** The element's changes as one EditViewItemCommand (null when nothing changed). */
37242
+ conceptCommand(template, values) {
37243
+ const element = this._elementOf(template.id);
37244
+ return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
37245
+ }
37246
+
37247
+ /**
37248
+ * With several shapes selected, the Elements section: their type, their
37249
+ * specialization when they share a type, and one property to set on all.
37250
+ * Only when every selected shape is an element.
37251
+ */
37252
+ manyConceptFields(templates) {
37253
+ const elements = this._elementsOf(templates);
37254
+ return elements ? manyElementsFields(elements, { version: this.version }) : [];
37255
+ }
37256
+
37257
+ /** The elements' changes as one step (null when nothing changed). */
37258
+ manyConceptCommand(templates, values) {
37259
+ const elements = this._elementsOf(templates);
37260
+ const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
37261
+ const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
37262
+ return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
37263
+ }
37264
+
37265
+ /** The model elements the shapes show, each once; null unless every shape shows one. @private */
37266
+ _elementsOf(templates) {
37267
+ const elements = templates.map(t => this._elementOf(t.id));
37268
+ return elements.every(Boolean) ? [...new Set(elements)] : null;
37269
+ }
37270
+
37271
+ /**
37272
+ * The Relationship section of a line's properties: its kind (among those
37273
+ * the ArchiMate rules allow between its ends, and its own), documentation
37274
+ * and typed properties. None for a plain line or a derived one.
37275
+ */
37276
+ edgeConceptFields(edge) {
37277
+ const relationship = edge.relationship;
37278
+ if (!relationship || edge.editable === false) return [];
37279
+ const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
37280
+ return [
37281
+ { type: 'heading', label: 'Relationship (on every view)' },
37282
+ { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
37283
+ { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
37284
+ { type: 'heading', label: 'Properties' },
37285
+ PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
37286
+ ];
37287
+ }
37288
+
37289
+ /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
37290
+ edgeConceptCommand(edge, values) {
37291
+ return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
37292
+ }
37293
+
37294
+ /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
37295
+ addElement(type, point) {
37296
+ const name = typeTitle(type);
37297
+ const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
37298
+ const at = this._snapToGrid(point);
37299
+ const instance = new ElementInstance(id, at.x, at.y);
37300
+ this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
37301
+ return instance;
37302
+ }
37303
+
37304
+ /**
37305
+ * Show an element the model has at `point` (snapped), with its
37306
+ * relationships to the elements already on the view (ShowElementCommand).
37307
+ * One undo step. Null for an element the model lacks.
37308
+ */
37309
+ showElement(elementId, point) {
37310
+ if (!this.model.getElement(elementId)) return null;
37311
+ const at = this._snapToGrid(point);
37312
+ const instance = new ElementInstance(elementId, at.x, at.y);
37313
+ this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
37314
+ this.selectTemplate(instance.id);
37315
+ return instance;
37316
+ }
37317
+
37318
+ /**
37319
+ * A picture as an image box on this view at `point` (snapped), one undo
37320
+ * step, selected. Null when this kind of view has no image box.
37321
+ * @param {string} dataURL - The picture
37322
+ */
37323
+ addImage(dataURL, point) {
37324
+ if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
37325
+ const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
37326
+ this.render();
37327
+ this.selectTemplate(box.id);
37328
+ return box;
37329
+ }
37330
+
37331
+ /**
37332
+ * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
37333
+ * (snapped), as the canvas menu would. One undo step.
37334
+ */
37335
+ addCanvasBox(kindKey, point) {
37336
+ const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
37337
+ onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
37338
+ }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
37339
+ return item?.action();
37340
+ }
37341
+
37342
+ /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
37343
+ allowedKinds(sourceId, targetId) {
37344
+ const from = this._elementOf(sourceId), to = this._elementOf(targetId);
37345
+ if (!from || !to) return [];
37346
+ const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
37347
+ return kinds.includes('association') ? kinds : [...kinds, 'association'];
37348
+ }
37349
+
37350
+ /**
37351
+ * Connect two elements: the kind is asked for (`chooseKind`) among those
37352
+ * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
37353
+ * @returns {Command|null|Promise<Command|null>}
37354
+ */
37355
+ connect(sourceId, targetId) {
37356
+ if (sourceId === targetId) return null;
37357
+ // A box or note at either end: a plain line (the view's rule), one undo step.
37358
+ if (!this.view.linesAreRelationships(sourceId, targetId)) {
37359
+ if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
37360
+ const line = this.view.connect(sourceId, targetId);
37361
+ this.render();
37362
+ return line;
37363
+ }
37364
+ const kinds = this.allowedKinds(sourceId, targetId);
37365
+ if (!kinds.length) return null;
37366
+ const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
37367
+ if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
37368
+ const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
37369
+ return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
37370
+ }
37371
+
37372
+ /** @private */
37373
+ _connectAs(sourceId, targetId, kind) {
37374
+ const cmd = this.connectCommand(sourceId, targetId, kind);
37375
+ if (cmd) this._executeCommand(cmd);
37376
+ return cmd;
37377
+ }
37378
+
37379
+ /** The model element an instance on this canvas shows (notes and images have none). @private */
37380
+ /** A box of a model element links to the element's page in a report. */
37381
+ reportRef(template) {
37382
+ const element = this._elementOf(template.id);
37383
+ return element ? { kind: 'element', id: element.id } : null;
37384
+ }
37385
+
37386
+ _elementOf(instanceId) {
37387
+ const inst = this.templates.get(instanceId)?.instance;
37388
+ return inst ? this.model.getElement(inst.elementId) : undefined;
37389
+ }
37390
+
37391
+ /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
37392
+ connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
37393
+ const source = this.templates.get(sourceId)?.instance;
37394
+ const target = this.templates.get(targetId)?.instance;
37395
+ const from = this._elementOf(sourceId);
37396
+ const to = this._elementOf(targetId);
37397
+ // Notes and images are not model elements: they take no relationships.
37398
+ if (!from || !to || !kind) return null;
37399
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
37400
+ const instance = new RelationshipInstance(id, null, source.id, target.id);
37401
+ return new AddRelationshipToViewCommand(this.view, instance, {
37402
+ relationship: { id, source: from.id, target: to.id, kind },
37403
+ label: `Add ${kind}`,
37404
+ });
37405
+ }
37406
+
37407
+ /**
37408
+ * Deleting takes a box off the view (its lines go with it, see the base's
37409
+ * deleteTemplatesCommand); the model keeps its element. A group goes off the
37410
+ * view and what it held stays; the model keeps its folder.
37411
+ */
37412
+ /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
37413
+ static clipboardKind = 'archimate';
37414
+
37415
+ /**
37416
+ * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
37417
+ * with the lines and relationships between them and the elements they show.
37418
+ * Groups are not copied (they are model folders).
37419
+ */
37420
+ copyPayload(ids) {
37421
+ const boxes = ids.filter(id => this.view.getElementInstance(id));
37422
+ return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
37423
+ }
37424
+
37425
+ /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
37426
+ pasteCommand(payload, options) {
37427
+ return this.view.pasteCommand(payload, options);
37428
+ }
37429
+
37430
+ /** Deleting is off the view: the model keeps what it showed. */
37431
+ deleteLabel(templateIds) {
37432
+ return `${super.deleteLabel(templateIds)} from view`;
37433
+ }
37434
+
37435
+ removeTemplateCommand(templateId) {
37436
+ return this.templates.get(templateId) instanceof ViewGroupTemplate
37437
+ ? new RemoveGroupFromViewCommand(this.view, templateId)
37438
+ : new DeleteElementCommand(this.view, templateId);
37439
+ }
37440
+
37441
+ /**
37442
+ * What moves with a shape: for a group, the elements drawn in it (its folder's),
37443
+ * the notes and pictures Archi drew in it, and the groups inside it; for a box (a canvas block), the boxes Archi drew inside it.
37444
+ */
37445
+ containedIn(templateId) {
37446
+ const template = this.templates.get(templateId);
37447
+ const view = this.view;
37448
+ if (template instanceof ViewGroupTemplate) {
37449
+ const inner = [];
37450
+ for (const t of this.templates.values()) {
37451
+ if (t instanceof ViewGroupTemplate) {
37452
+ if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
37453
+ } else if (t.instance?.archi?.parent === templateId
37454
+ || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
37455
+ inner.push(t.id);
37456
+ }
37457
+ }
37458
+ return inner;
37459
+ }
37460
+ const visualId = template?.instance?.visualId;
37461
+ if (!visualId) return [];
37462
+ return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
37463
+ }
37464
+
37465
+ // ===== Nesting =====
37466
+
37467
+ /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
37468
+ parentOf(templateId) {
37469
+ const visualId = this.templates.get(templateId)?.instance?.nestedIn;
37470
+ if (!visualId) return null;
37471
+ const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
37472
+ return parent?.id ?? null;
37473
+ }
37474
+
37475
+ /**
37476
+ * An element's parts on this view: the elements it is composed of or
37477
+ * aggregates (its composition and aggregation relationships' targets),
37478
+ * each by its first box here.
37479
+ */
37480
+ partsOf(templateId) {
37481
+ const element = this._elementOf(templateId);
37482
+ if (!element) return [];
37483
+ const parts = this.model.relationships()
37484
+ .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
37485
+ .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
37486
+ .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
37487
+ return [...new Set(parts)];
37488
+ }
37489
+
37490
+ /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
37491
+ layoutCategory(template) {
37492
+ const element = this._elementOf(template.id);
37493
+ return element ? treeLayerOf(element) : 'other';
37494
+ }
37495
+
37496
+ /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
37497
+ canHold(parentId, childId) {
37498
+ const parent = this.templates.get(parentId), child = this.templates.get(childId);
37499
+ if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
37500
+ return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
37501
+ }
37502
+
37503
+ /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
37504
+ nestsOnDrop(parentId, childId) {
37505
+ if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
37506
+ return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
37507
+ }
37508
+
37509
+ nestCommand(childId, parentId) {
37510
+ const child = this.templates.get(childId)?.instance;
37511
+ if (!child) return null;
37512
+ return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
37513
+ }
37514
+
37515
+ /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
37516
+ nested(changes) {
37517
+ let chain = Promise.resolve();
37518
+ for (const { childId, parentId } of changes) {
37519
+ chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
37520
+ }
37521
+ return chain;
37522
+ }
37523
+
37524
+ /**
37525
+ * The kinds a nesting can stand for, from parent to child, as Archi offers
37526
+ * them: those of NESTING_KINDS the rules allow. None when the two already
37527
+ * have such a relationship.
37528
+ */
37529
+ nestingKinds(parentId, childId) {
37530
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
37531
+ if (!from || !to || from === to) return [];
37532
+ if (nestingRelationships(this.model, from.id, to.id).length) return [];
37533
+ const allowed = this.allowedKinds(parentId, childId);
37534
+ return NESTING_KINDS.filter(k => allowed.includes(k));
37535
+ }
37536
+
37537
+ /**
37538
+ * Ask which relationship a nesting stands for (`chooseKind`, with the
37539
+ * reason 'nesting'; none chosen adds none) and add it to the model, drawn
37540
+ * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
37541
+ * @returns {Promise<Command|null>}
37542
+ */
37543
+ async offerNestingRelationship(parentId, childId) {
37544
+ const kinds = this.nestingKinds(parentId, childId);
37545
+ if (!kinds.length || !this.chooseKind) return null;
37546
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
37547
+ const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
37548
+ if (!kind || !kinds.includes(kind)) return null;
37549
+ const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
37550
+ this._executeCommand(cmd);
37551
+ return cmd;
37552
+ }
37553
+
37554
+ /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
37555
+ nestingRelationshipCommand(parentId, childId, kind) {
37556
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
37557
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
37558
+ return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
37559
+ }
37560
+
37561
+ /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
37562
+ deleteEdgeCommand(edgeId) {
37563
+ return this.view.viewConnections.has(edgeId)
37564
+ ? new RemoveLineCommand(this.view, edgeId)
37565
+ : new RemoveRelationshipFromViewCommand(this.view, edgeId);
37566
+ }
36787
37567
  }
36788
37568
 
36789
37569
  /**
@@ -36885,6 +37665,52 @@
36885
37665
  return groups.flatMap((group, i) => (i ? [{ separator: true }, ...group] : group));
36886
37666
  }
36887
37667
 
37668
+ /**
37669
+ * The part of pasted text that is a model in `format`: the text as it is when
37670
+ * the format recognizes it; else, as an assistant or a page would wrap it, a
37671
+ * fenced block (```json … ```), or the text from its first { (or [) to its
37672
+ * last } (or ]). Null when none is.
37673
+ * @param {ModelFileFormat} format - one that reads text
37674
+ * @param {string} text
37675
+ * @returns {string|null}
37676
+ */
37677
+ function findPayload(format, text) {
37678
+ const candidates = [String(text ?? '').trim()];
37679
+ for (const m of candidates[0].matchAll(/```[\w-]*[ \t]*\r?\n([\s\S]*?)```/g)) candidates.push(m[1].trim());
37680
+ for (const [open, close] of [['{', '}'], ['[', ']']]) {
37681
+ const from = candidates[0].indexOf(open), to = candidates[0].lastIndexOf(close);
37682
+ if (from >= 0 && to > from) candidates.push(candidates[0].slice(from, to + 1));
37683
+ }
37684
+ return candidates.find(c => c && format.recognizes(c)) ?? null;
37685
+ }
37686
+
37687
+ /**
37688
+ * Import a model from pasted text rather than a file: a dialog with a text
37689
+ * box; what is pasted may be wrapped in other text (findPayload). The new
37690
+ * model goes to `onImport`; text with no model in it is refused in the dialog.
37691
+ * @param {ModelFileFormat} format - one that reads text
37692
+ * @returns {FormDialog}
37693
+ */
37694
+ function askPasteImport(format, { onImport, document: doc = globalThis.document } = {}) {
37695
+ let model = null;
37696
+ const dialog = new FormDialog({
37697
+ title: `Paste ${format.label}`,
37698
+ description: `Paste the ${format.label} text: a file's contents, or an answer or page that has it in it (in a \`\`\` block, say).`,
37699
+ fields: [{ key: 'text', label: 'Text', type: 'textarea', rows: 14, value: '' }],
37700
+ submitLabel: 'Import',
37701
+ idPrefix: 'paste-import-',
37702
+ document: doc,
37703
+ validate: ({ text }) => {
37704
+ const payload = findPayload(format, text);
37705
+ if (!payload) return `No ${format.label} found in what is pasted`;
37706
+ try { model = format.import(payload); return null; } catch (err) { return `It could not be read: ${err.message}`; }
37707
+ },
37708
+ onApply: () => onImport(model),
37709
+ });
37710
+ dialog.open();
37711
+ return dialog;
37712
+ }
37713
+
36888
37714
  /**
36889
37715
  * Ask for a file and import it in `format`; resolves to the new model, or
36890
37716
  * null when no file was chosen or it could not be read (reported).
@@ -41628,9 +42454,10 @@
41628
42454
  * @param {(element) => void} host.editProperties
41629
42455
  * @param {(view, instanceId) => void} host.showIn - Open `view` with that box selected
41630
42456
  * @param {(element) => void} [host.addToView] - Show it on `view` (offered when it is not there)
42457
+ * @param {(element) => void} [host.deleteFromModel] - Delete it from the model, after saying where it is used
41631
42458
  * @returns {Array<Object>} menu items
41632
42459
  */
41633
- function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null }) {
42460
+ function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null, deleteFromModel = null }) {
41634
42461
  const placed = model.views()
41635
42462
  .map(v => ({ view: v, instance: v.elementInstancesFor?.(element.id)?.[0] }))
41636
42463
  .filter(({ instance }) => instance);
@@ -41645,6 +42472,7 @@
41645
42472
  : { label: 'Show in View (on none)', disabled: true, action: () => {} },
41646
42473
  ...(addToView && view && !onThisView ? [{ label: `Add to "${view.name || 'this view'}"`, action: () => addToView(element) }] : []),
41647
42474
  { label: 'Generate View For This', action: () => model.generateView([element.id]) },
42475
+ ...(deleteFromModel ? [{ separator: true }, { label: '🗑 Delete from model…', danger: true, action: () => deleteFromModel(element) }] : []),
41648
42476
  ];
41649
42477
  }
41650
42478
 
@@ -51323,6 +52151,8 @@ ${theirs}` : theirs;
51323
52151
  * @param {string} [options.title]
51324
52152
  * @param {number} [options.chartHeight] a chart's height (360)
51325
52153
  * @param {boolean} [options.fitHeight] size the container to what is drawn (true)
52154
+ * @param {function|Intl.NumberFormat|object} [options.format] how numbers show: for every column, or
52155
+ * per column ({ Amount: Intl.NumberFormat }); copies keep the raw numbers
51326
52156
  * @returns {TableView|null} the table's state, for the host to read or change
51327
52157
  */
51328
52158
  resultView(rows, options = {}) {
@@ -51362,7 +52192,8 @@ ${theirs}` : theirs;
51362
52192
  * the axes. The container's height follows the grid, as resultView's does.
51363
52193
  * @param {Array<object>} rows
51364
52194
  * @param {object} [options] rowAxis, columnAxis, measures, columns (hints, with the rollup column's),
51365
- * dimensions, onDrill, menus, title, nullText, fitHeight
52195
+ * dimensions, onDrill, menus, title, nullText, fitHeight, format (how numbers show, per measure or for
52196
+ * all: cells, totals and the traffic lights' cut-offs; the copy keeps the raw numbers)
51366
52197
  * @returns {PivotView|null} the grid's state, for the host to read or change
51367
52198
  */
51368
52199
  pivotView(rows, options = {}) {
@@ -52791,6 +53622,9 @@ ${theirs}` : theirs;
52791
53622
  exports.CAMERA_MODE = CAMERA_MODE;
52792
53623
  exports.CANVAS_BOX_KINDS = CANVAS_BOX_KINDS;
52793
53624
  exports.CANVAS_BOX_MIME = CANVAS_BOX_MIME;
53625
+ exports.CHANGE_COLOURS = CHANGE_COLOURS;
53626
+ exports.CHANGE_MAKERS = CHANGE_MAKERS;
53627
+ exports.CHANGE_WORDS = CHANGE_WORDS;
52794
53628
  exports.CHART_CATALOG = CHART_CATALOG;
52795
53629
  exports.COLOR_MODE = COLOR_MODE;
52796
53630
  exports.COORD_SYSTEM = COORD_SYSTEM;
@@ -52834,6 +53668,7 @@ ${theirs}` : theirs;
52834
53668
  exports.DataStoreTemplate = DataStoreTemplate;
52835
53669
  exports.DataType = DataType;
52836
53670
  exports.DeleteEdgeCommand = DeleteEdgeCommand;
53671
+ exports.DeleteFromModelCommand = DeleteFromModelCommand;
52837
53672
  exports.DeleteWaypointCommand = DeleteWaypointCommand;
52838
53673
  exports.DerivedRelationships = DerivedRelationships;
52839
53674
  exports.DiagramEditor = DiagramEditor;
@@ -53020,6 +53855,8 @@ ${theirs}` : theirs;
53020
53855
  exports.ZipArchive = ZipArchive;
53021
53856
  exports.archiElementType = archiElementType;
53022
53857
  exports.arrangeMenuItems = arrangeMenuItems;
53858
+ exports.askDeleteFromModel = askDeleteFromModel;
53859
+ exports.askPasteImport = askPasteImport;
53023
53860
  exports.buildAnalysisData = buildAnalysisData;
53024
53861
  exports.buildAnalysisPanel = buildAnalysisPanel;
53025
53862
  exports.buildModelTreePanel = buildModelTreePanel;
@@ -53028,24 +53865,31 @@ ${theirs}` : theirs;
53028
53865
  exports.canvasBoxKind = canvasBoxKind;
53029
53866
  exports.canvasBoxMenuItems = canvasBoxMenuItems;
53030
53867
  exports.canvasTemplate = canvasTemplate;
53868
+ exports.changeAction = changeAction;
53031
53869
  exports.chartRegistry = chartRegistry;
53032
53870
  exports.coerceFieldValue = coerceFieldValue;
53033
53871
  exports.copyImage = copyImage;
53872
+ exports.copyPngBlob = copyPngBlob;
53034
53873
  exports.createDataWindow = createDataWindow;
53874
+ exports.deleteFromModelMessage = deleteFromModelMessage;
53035
53875
  exports.derivedRefreshPlan = derivedRefreshPlan;
53036
53876
  exports.diagramRegistry = diagramRegistry;
53037
53877
  exports.downloadBlob = downloadBlob;
53038
53878
  exports.downloadPng = downloadPng;
53039
53879
  exports.downloadSvg = downloadSvg;
53880
+ exports.downloadSvgMarkup = downloadSvgMarkup;
53040
53881
  exports.drawGlyph = drawGlyph;
53041
53882
  exports.duplicateGroups = duplicateGroups;
53042
53883
  exports.duplicateKey = duplicateKey;
53043
53884
  exports.ebnfAstToDiagram = ebnfAstToDiagram;
53885
+ exports.elementChange = elementChange;
53044
53886
  exports.elementTypeChoices = elementTypeChoices;
53045
53887
  exports.exportElementsCSV = exportElementsCSV;
53046
53888
  exports.exportModelCSV = exportModelCSV;
53047
53889
  exports.exportRelationshipsCSV = exportRelationshipsCSV;
53890
+ exports.expressionValues = expressionValues;
53048
53891
  exports.findDrawable = findDrawable;
53892
+ exports.findPayload = findPayload;
53049
53893
  exports.fromArchimateView = fromArchimateView;
53050
53894
  exports.getAllDiagramTypes = getAllDiagramTypes;
53051
53895
  exports.getChartCatalogEntry = getChartCatalogEntry;
@@ -53074,9 +53918,13 @@ ${theirs}` : theirs;
53074
53918
  exports.parseExchangeModel = parseExchangeModel;
53075
53919
  exports.pickFile = pickFile;
53076
53920
  exports.prepareSvgMarkup = prepareSvgMarkup;
53921
+ exports.propertyParams = propertyParams;
53077
53922
  exports.propertyRowsFor = propertyRowsFor;
53078
53923
  exports.readInheritableFields = readInheritableFields;
53924
+ exports.relationshipChange = relationshipChange;
53079
53925
  exports.reorderedZ = reorderedZ;
53926
+ exports.resultBands = resultBands;
53927
+ exports.sequentialColours = sequentialColours;
53080
53928
  exports.serializeSvg = serializeSvg;
53081
53929
  exports.settingsManager = settingsManager;
53082
53930
  exports.showDiagramPropertiesDialog = showDiagramPropertiesDialog;
@@ -53085,6 +53933,7 @@ ${theirs}` : theirs;
53085
53933
  exports.specializationCatalogDialog = specializationCatalogDialog;
53086
53934
  exports.stackingOrder = stackingOrder;
53087
53935
  exports.strongerEmphasis = strongerEmphasis;
53936
+ exports.svgMarkupToPngBlob = svgMarkupToPngBlob;
53088
53937
  exports.toArchiExport = toArchiExport;
53089
53938
  exports.toArchiFile = toArchiFile;
53090
53939
  exports.toArchimateModel = toArchimateModel;