@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
@@ -3852,10 +3852,10 @@
3852
3852
  /**
3853
3853
  * Work out the bitmap size to rasterize at.
3854
3854
  *
3855
- * `scale` is what the caller asked for (2 = retina-sharp). It is reduced, never
3856
- * the diagram's own dimensions, when the result would be too large: halving the
3857
- * scale of a huge diagram still shows all of it, whereas cropping or shrinking
3858
- * the diagram loses content or legibility.
3855
+ * `scale` is what the caller asked for (1 = 100%, 2 = retina-sharp, 0.5 = half
3856
+ * size). It is reduced, never the diagram's own dimensions, when the result
3857
+ * would be too large: halving the scale of a huge diagram still shows all of
3858
+ * it, whereas cropping or shrinking the diagram loses content or legibility.
3859
3859
  *
3860
3860
  * @returns {{width:number, height:number, scale:number, clamped:boolean}}
3861
3861
  */
@@ -3870,10 +3870,6 @@
3870
3870
  s = Math.sqrt(maxPixels / (w * h));
3871
3871
  clamped = true;
3872
3872
  }
3873
- // A scale below 1:1 would be a downscale the caller never asked for, but a
3874
- // diagram that is itself over the budget leaves no choice.
3875
- if (s < 1 && w * h <= maxPixels) s = 1;
3876
-
3877
3873
  return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)), scale: s, clamped };
3878
3874
  }
3879
3875
 
@@ -3888,8 +3884,10 @@
3888
3884
  * @param {number} [options.width] pixels; omitted leaves what is there
3889
3885
  * @param {number} [options.height]
3890
3886
  * @param {string|null} [options.background='#ffffff'] null for transparent
3887
+ * @param {string} [options.viewBox] the drawing's own units, when it has none
3888
+ * (so a picture sized other than as drawn scales rather than crops)
3891
3889
  */
3892
- function prepareSvgMarkup(markup, { width, height, background = '#ffffff' } = {}) {
3890
+ function prepareSvgMarkup(markup, { width, height, background = '#ffffff', viewBox } = {}) {
3893
3891
  let out = String(markup ?? '').trim();
3894
3892
  const open = out.match(/^<svg\b[^>]*>/i);
3895
3893
  if (!open) return out; // not an <svg> — hand it back untouched
@@ -3908,13 +3906,23 @@
3908
3906
  };
3909
3907
  if (Number.isFinite(width) && width > 0) setAttr('width', Math.round(width));
3910
3908
  if (Number.isFinite(height) && height > 0) setAttr('height', Math.round(height));
3909
+ if (viewBox && !/\sviewBox\s*=/i.test(tag)) setAttr('viewBox', viewBox);
3911
3910
 
3912
3911
  out = tag + out.slice(open[0].length);
3913
3912
 
3914
3913
  if (background) {
3915
- // First child, so it sits behind everything. Percentages rather than
3916
- // pixels so it still covers the drawing if a viewBox rescales it.
3917
- out = out.replace(tag, `${tag}<rect x="0" y="0" width="100%" height="100%" fill="${background}"/>`);
3914
+ // First child, so it sits behind everything. With a viewBox it reaches
3915
+ // a whole viewBox past each side (the picture clips it): a percentage
3916
+ // counts from (0,0), so a viewBox starting elsewhere (a cropped
3917
+ // drawing) would leave a strip bare, and the viewBox alone would leave
3918
+ // the bands a picture of another shape letterboxes it with.
3919
+ // Without one, percentages, so it covers whatever size it is shown at.
3920
+ const box = tag.match(/\sviewBox\s*=\s*["']\s*([-\d.e+]+)[\s,]+([-\d.e+]+)[\s,]+([-\d.e+]+)[\s,]+([-\d.e+]+)\s*["']/i);
3921
+ const [x, y, w, h] = box ? box.slice(1).map(Number) : [];
3922
+ const rect = box
3923
+ ? `<rect x="${x - w}" y="${y - h}" width="${3 * w}" height="${3 * h}" fill="${background}"/>`
3924
+ : `<rect x="0" y="0" width="100%" height="100%" fill="${background}"/>`;
3925
+ out = out.replace(tag, `${tag}${rect}`);
3918
3926
  }
3919
3927
  return out;
3920
3928
  }
@@ -3947,12 +3955,12 @@
3947
3955
  * Size is taken from the element's laid-out box rather than its attributes,
3948
3956
  * because that is what the user is actually looking at.
3949
3957
  */
3950
- function serializeSvg(svgEl, { background = '#ffffff', width, height } = {}) {
3958
+ function serializeSvg(svgEl, { background = '#ffffff', width, height, scale = 1 } = {}) {
3951
3959
  const box = svgEl.getBoundingClientRect?.() ?? {};
3952
3960
  const w = width ?? svgEl.width?.baseVal?.value ?? box.width;
3953
3961
  const h = height ?? svgEl.height?.baseVal?.value ?? box.height;
3954
3962
  const markup = new XMLSerializer().serializeToString(svgEl);
3955
- return prepareSvgMarkup(markup, { width: w, height: h, background });
3963
+ return prepareSvgMarkup(markup, { width: w * scale, height: h * scale, background, viewBox: `0 0 ${w} ${h}` });
3956
3964
  }
3957
3965
 
3958
3966
  /**
@@ -3971,9 +3979,16 @@
3971
3979
  }
3972
3980
 
3973
3981
  const box = drawable.getBoundingClientRect();
3974
- const size = imageSize({ width: box.width, height: box.height, scale, maxPixels });
3975
3982
  const markup = serializeSvg(drawable, { background, width: box.width, height: box.height });
3983
+ return svgMarkupToPngBlob(markup, { width: box.width, height: box.height, scale, background, maxPixels });
3984
+ }
3976
3985
 
3986
+ /**
3987
+ * Rasterize standalone SVG markup (prepareSvgMarkup's) drawn `width` × `height`
3988
+ * to a PNG Blob at `scale`. DOM only.
3989
+ */
3990
+ async function svgMarkupToPngBlob(markup, { width, height, scale = 1, background = '#ffffff', maxPixels = MAX_IMAGE_PIXELS } = {}) {
3991
+ const size = imageSize({ width, height, scale, maxPixels });
3977
3992
  // A data: URL rather than a blob: URL — a blob: URL taints nothing either,
3978
3993
  // but Safari has historically refused to draw SVG from one.
3979
3994
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
@@ -4021,7 +4036,15 @@
4021
4036
  * @returns {Promise<{via:'clipboard'|'download', filename?:string}>}
4022
4037
  */
4023
4038
  async function copyImage(host, { scale = 2, background = '#ffffff', name = 'diagram' } = {}) {
4024
- const blob = await toPngBlob(host, { scale, background });
4039
+ return copyPngBlob(await toPngBlob(host, { scale, background }), { name });
4040
+ }
4041
+
4042
+ /**
4043
+ * Put a PNG Blob on the clipboard, or download it where the browser cannot
4044
+ * (as copyImage). DOM only.
4045
+ * @returns {Promise<{via:'clipboard'|'download', filename?:string}>}
4046
+ */
4047
+ async function copyPngBlob(blob, { name = 'diagram' } = {}) {
4025
4048
  const canWrite = typeof ClipboardItem !== 'undefined'
4026
4049
  && typeof navigator !== 'undefined'
4027
4050
  && navigator.clipboard?.write;
@@ -4041,10 +4064,14 @@
4041
4064
  }
4042
4065
 
4043
4066
  /** Save a diagram as an SVG file. DOM only. */
4044
- function downloadSvg(host, { background = '#ffffff', name = 'diagram' } = {}) {
4067
+ function downloadSvg(host, { background = '#ffffff', name = 'diagram', scale = 1 } = {}) {
4045
4068
  const drawable = findDrawable(host);
4046
4069
  if (!drawable || drawable.tagName.toLowerCase() !== 'svg') throw new Error('No <svg> to export');
4047
- const markup = serializeSvg(drawable, { background });
4070
+ downloadSvgMarkup(serializeSvg(drawable, { background, scale }), { name });
4071
+ }
4072
+
4073
+ /** Save standalone SVG markup as a file. DOM only. */
4074
+ function downloadSvgMarkup(markup, { name = 'diagram' } = {}) {
4048
4075
  downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), imageFileName(name, 'svg'));
4049
4076
  }
4050
4077
 
@@ -9130,6 +9157,22 @@
9130
9157
  return denom > 0 ? 1 / denom : Math.max(hw, hh);
9131
9158
  }
9132
9159
 
9160
+ /** Pure. Where two boxes ({left, top, right, bottom}) face each other
9161
+ * across a gap with their x ranges (or y ranges) overlapping, the
9162
+ * shortest line between them runs straight across, at the middle of the
9163
+ * overlap: `{axis: 'v', at: x, forward}` (a vertical line, `forward` when
9164
+ * `a` is above `b`) or `{axis: 'h', at: y, forward}` (`a` left of `b`).
9165
+ * Null when they overlap neither way, or each other. */
9166
+ static acrossOverlap(a, b) {
9167
+ const left = Math.max(a.left, b.left), right = Math.min(a.right, b.right);
9168
+ const top = Math.max(a.top, b.top), bottom = Math.min(a.bottom, b.bottom);
9169
+ const apartV = a.bottom <= b.top || b.bottom <= a.top;
9170
+ const apartH = a.right <= b.left || b.right <= a.left;
9171
+ if (left < right && apartV) return { axis: 'v', at: (left + right) / 2, forward: a.bottom <= b.top };
9172
+ if (top < bottom && apartH) return { axis: 'h', at: (top + bottom) / 2, forward: a.right <= b.left };
9173
+ return null;
9174
+ }
9175
+
9133
9176
  /** Pure. Anchor for one end of an edge. A node that knows its own outline
9134
9177
  * supplies `info.boundaryPoint(dx, dy) → {x, y}` (DiagramEditor templates
9135
9178
  * do, rotation included), and the anchor is where the ray toward the other
@@ -9974,8 +10017,12 @@
9974
10017
  choices: [['straight', 'Straight'], ['orthogonal', 'Orthogonal'], ['curved', 'Curved'], ['polyline', 'Around shapes']],
9975
10018
  },
9976
10019
  {
9977
- key: 'attach', label: 'Attach to', type: 'select', default: 'floating',
9978
- choices: [['floating', 'Outline, aimed at the other end'], ['sides', 'Middle of the facing side']],
10020
+ key: 'attach', label: 'Attach to', type: 'select', default: 'shortest',
10021
+ choices: [
10022
+ ['shortest', 'Shortest: straight across where the shapes overlap, else aimed at the other end'],
10023
+ ['floating', 'Outline, aimed at the other end'],
10024
+ ['sides', 'Middle of the facing side'],
10025
+ ],
9979
10026
  },
9980
10027
  {
9981
10028
  key: 'lineStyle', label: 'Line', type: 'select', default: 'solid',
@@ -15310,6 +15357,30 @@
15310
15357
  return ticks;
15311
15358
  }
15312
15359
 
15360
+ /**
15361
+ * Ticks over a range that may go below zero: one common "nice" step, from at or below `minVal`
15362
+ * to at or above `maxVal`, always taking in 0 so bars have a baseline. Without a negative it is
15363
+ * `niceTicks(maxVal)`.
15364
+ * @param {number} minVal
15365
+ * @param {number} maxVal
15366
+ * @param {number} [count=4] desired number of intervals
15367
+ * @returns {number[]}
15368
+ */
15369
+ static niceRange(minVal, maxVal, count = 4) {
15370
+ const lo = Math.min(0, minVal || 0);
15371
+ const hi = Math.max(0, maxVal || 0);
15372
+ if (lo === 0) return Axis.niceTicks(hi, count);
15373
+ const raw = (hi - lo) / count;
15374
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
15375
+ const frac = raw / mag;
15376
+ const step = frac < 1.5 ? mag : frac < 3.5 ? 2 * mag : frac < 7.5 ? 5 * mag : 10 * mag;
15377
+ const first = Math.floor(lo / step + 1e-9);
15378
+ const last = Math.ceil(hi / step - 1e-9);
15379
+ const ticks = [];
15380
+ for (let i = first; i <= last; i++) ticks.push(parseFloat((i * step).toFixed(10)));
15381
+ return ticks;
15382
+ }
15383
+
15313
15384
  /**
15314
15385
  * How often a category label fits along an axis `width` wide: 1 (every
15315
15386
  * label), 2 (every other), … — so a long series (a minute of samples, a
@@ -15354,19 +15425,37 @@
15354
15425
  * @returns {number} the nice domain max the ticks actually span
15355
15426
  */
15356
15427
  drawYAxis(renderer, box, rawMax) {
15357
- const ticks = Axis.niceTicks(rawMax);
15358
- const niceMax = ticks[ticks.length - 1] || 1;
15428
+ return this.drawYRange(renderer, box, 0, rawMax).max;
15429
+ }
15430
+
15431
+ /**
15432
+ * Draw a y-axis over a range that may go below zero (`niceRange`): ticks, labels, gridlines,
15433
+ * and, when the range does go below zero, a zero line across the plot where it crosses.
15434
+ *
15435
+ * @param {*} renderer a renderer implementing .path() and .text()
15436
+ * @param {{x:number, y:number, width:number, height:number}} box plot-area box
15437
+ * @param {number} rawMin the raw data minimum (0 or more: the axis starts at 0)
15438
+ * @param {number} rawMax the raw data maximum
15439
+ * @returns {{min:number, max:number}} the nice domain the ticks actually span
15440
+ */
15441
+ drawYRange(renderer, box, rawMin, rawMax) {
15442
+ const ticks = Axis.niceRange(rawMin, rawMax);
15443
+ const min = ticks[0];
15444
+ const max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
15445
+ const yOf = (v) => box.y + box.height - ((v - min) / (max - min)) * box.height;
15359
15446
  for (const t of ticks) {
15360
- const ty = box.y + box.height - (t / niceMax) * box.height;
15447
+ const ty = yOf(t);
15361
15448
  renderer.path(`M${box.x - 5},${ty} L${box.x},${ty}`, 'none', { color: '#999', width: 1 });
15362
- if (t > 0) {
15449
+ if (t !== 0) {
15363
15450
  renderer.path(`M${box.x},${ty} L${box.x + box.width},${ty}`, 'none', { color: '#eee', width: 1 });
15451
+ } else if (min < 0) {
15452
+ renderer.path(`M${box.x},${ty} L${box.x + box.width},${ty}`, 'none', { color: '#666', width: 1, zeroLine: true });
15364
15453
  }
15365
15454
  renderer.text(Axis.formatValue(t), box.x - 10, ty, {
15366
15455
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
15367
15456
  });
15368
15457
  }
15369
- return niceMax;
15458
+ return { min, max };
15370
15459
  }
15371
15460
  }
15372
15461
 
@@ -15756,9 +15845,27 @@
15756
15845
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
15757
15846
  }
15758
15847
 
15759
- /** Draw a Y axis and return the nice-rounded max value. */
15760
- _drawYAxis(rawMax) {
15761
- return new Axis().drawYAxis(this.renderer, this._plotBox, rawMax);
15848
+ /**
15849
+ * Draw a Y axis and return the nice-rounded max value. With a negative `rawMin` the axis goes
15850
+ * below zero, with a zero line; `_barSpan` then draws each bar from zero, up or down.
15851
+ */
15852
+ _drawYAxis(rawMax, rawMin = 0) {
15853
+ this._domain = new Axis().drawYRange(this.renderer, this._plotBox, rawMin, rawMax);
15854
+ return this._domain.max;
15855
+ }
15856
+
15857
+ /**
15858
+ * Where a bar of `value` goes on the axis `_drawYAxis` drew: from the zero line up to it, or
15859
+ * down to it when negative.
15860
+ * @returns {{top: number, height: number, end: number}} `end` is the bar's far end from zero
15861
+ */
15862
+ _barSpan(value) {
15863
+ const { y, height } = this._plotBox;
15864
+ const { min, max } = this._domain;
15865
+ const yOf = (v) => y + height - ((v - min) / (max - min)) * height;
15866
+ const zero = yOf(0);
15867
+ const end = yOf(value);
15868
+ return { top: Math.min(zero, end), height: Math.abs(end - zero), end };
15762
15869
  }
15763
15870
 
15764
15871
  _render() {
@@ -15801,7 +15908,7 @@
15801
15908
  _computeMaxValue() {
15802
15909
  const data = this.data;
15803
15910
  const rawMax = Math.max(...data.map(d => d.value)) || 1;
15804
- return this._drawYAxis(rawMax);
15911
+ return this._drawYAxis(rawMax, Math.min(0, ...data.map(d => d.value)));
15805
15912
  }
15806
15913
 
15807
15914
  _beforeBars(maxValue) {
@@ -15823,12 +15930,12 @@
15823
15930
  }
15824
15931
 
15825
15932
  const color = colors ? this._palette.colorAt(i) : barColor;
15826
- const barHeight = (d.value / maxValue) * height;
15933
+ const bar = this._barSpan(d.value);
15827
15934
  const barX = x + i * (bandWidth + margin);
15828
- const barY = y + height - barHeight;
15829
15935
 
15830
- this._datum(this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
15831
- this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
15936
+ 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 });
15937
+ // The value just past the bar's end: above a bar going up, below one going down.
15938
+ this.renderer.text(String(d.value), barX + bandWidth / 2, d.value < 0 ? bar.end + 12 : bar.end - 5, { textAnchor, fontSize: '10px', fill: '#333' });
15832
15939
  if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
15833
15940
  });
15834
15941
  }
@@ -15910,14 +16017,30 @@
15910
16017
  return top + (1 + shown + more) * TableChart.ROW_H + bottom;
15911
16018
  }
15912
16019
 
15913
- /** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
15914
- static cellText(value, nullText = '—') {
16020
+ /**
16021
+ * A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. With a
16022
+ * `format` (`formatFor`'s), a number is written by it instead.
16023
+ */
16024
+ static cellText(value, nullText = '—', format = null) {
15915
16025
  if (value == null) return nullText;
16026
+ if (typeof value === 'number' && format) return String(typeof format === 'function' ? format(value) : format.format(value));
15916
16027
  if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
15917
16028
  if (value instanceof Date) return value.toISOString().slice(0, 10);
15918
16029
  return String(value);
15919
16030
  }
15920
16031
 
16032
+ /**
16033
+ * The number format for one column or measure, from a view's `format` option: a function
16034
+ * (number → text) or an `Intl.NumberFormat` for every number, or an object naming either per
16035
+ * column (`{ Amount: new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }) }`),
16036
+ * where a column it does not name keeps the plain text. Null when there is none.
16037
+ */
16038
+ static formatFor(format, name) {
16039
+ if (!format) return null;
16040
+ if (typeof format === 'function' || format instanceof Intl.NumberFormat) return format;
16041
+ return format[name] ?? null;
16042
+ }
16043
+
15921
16044
  _render() {
15922
16045
  const data = this.data ?? [];
15923
16046
  const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
@@ -15981,18 +16104,17 @@
15981
16104
  * Array<{name: string, values: Array<{label: string, value: number}>}>
15982
16105
  *
15983
16106
  * Each category (`label`) gets a cluster of bars side-by-side, one per series. A value a series does
15984
- * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero.
16107
+ * not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero. A negative value
16108
+ * is drawn down from a zero line, the axis taking in the smallest value too.
15985
16109
  */
15986
16110
  class GroupedBarChart extends BarChartBase {
15987
16111
  static chartType = 'groupedBar';
15988
16112
 
15989
16113
  _computeMaxValue() {
15990
16114
  const series = this.data;
15991
- const rawMax = Math.max(
15992
- ...series.flatMap(s => s.values.map(v => v.value).filter(v => v != null)),
15993
- 1,
15994
- );
15995
- return this._drawYAxis(rawMax);
16115
+ const values = series.flatMap(s => s.values.map(v => v.value).filter(v => v != null));
16116
+ const rawMax = Math.max(...values, 1);
16117
+ return this._drawYAxis(rawMax, Math.min(0, ...values));
15996
16118
  }
15997
16119
 
15998
16120
  _renderBars(maxValue) {
@@ -16034,12 +16156,11 @@
16034
16156
  series.forEach((seriesData, seriesIndex) => {
16035
16157
  const value = valueBySeriesLabel[seriesIndex].get(label);
16036
16158
  if (value == null) return;
16037
- const barHeight = (value / maxValue) * height;
16159
+ const bar = this._barSpan(value);
16038
16160
  const barX = clusterX + seriesIndex * barWidth + barGap / 2;
16039
- const barY = y + height - barHeight;
16040
16161
  const color = this._palette.colorAt(seriesIndex);
16041
16162
 
16042
- this._datum(this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
16163
+ this._datum(this.renderer.rect(barX, bar.top, finalBarWidth, bar.height, color, null, {
16043
16164
  tooltip: `${seriesData.name} — ${label}: ${value}`,
16044
16165
  }), { label, series: seriesData.name });
16045
16166
  });
@@ -18147,8 +18268,10 @@
18147
18268
  * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
18148
18269
  * @param {ChartChooser} [options.chooser]
18149
18270
  * @param {function} [options.highlight] row → true for a row drawn highlighted (a host's selection)
18271
+ * @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every column or per
18272
+ * column (`TableChart.formatFor`). A copy keeps the raw numbers.
18150
18273
  */
18151
- constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null } = {}) {
18274
+ constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null, format = null } = {}) {
18152
18275
  this.rows = rows;
18153
18276
  this.columns = ChartChooser.describe(rows, columns);
18154
18277
  this.complete = complete;
@@ -18158,6 +18281,7 @@
18158
18281
  this.copy = copy ?? TableView.clipboard;
18159
18282
  this.chooser = chooser ?? new ChartChooser();
18160
18283
  this.highlight = highlight;
18284
+ this.format = format;
18161
18285
  /** The sort keys, most significant first: `{ column, direction: 'asc' | 'desc' }`. */
18162
18286
  this.sorts = [];
18163
18287
  this.filters = []; // { column, op, value }
@@ -18165,6 +18289,11 @@
18165
18289
  this.page = 0;
18166
18290
  }
18167
18291
 
18292
+ /** A column's value as the table shows it, in the column's `format` when there is one. */
18293
+ valueText(column, value, nullText = '—') {
18294
+ return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, column));
18295
+ }
18296
+
18168
18297
  static clipboard(text) {
18169
18298
  return globalThis.navigator?.clipboard?.writeText(text);
18170
18299
  }
@@ -18502,7 +18631,7 @@
18502
18631
  columns.forEach((c, i) => {
18503
18632
  const left = x0 + i * colW;
18504
18633
  const hit = this.renderer.rect(left, top, colW, ROW_H, 'transparent', null);
18505
- const text = this._cell(TableChart.cellText(row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
18634
+ const text = this._cell(view.valueText(c.key, row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
18506
18635
  this._on(interactive, [hit, text], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(row, c.key)), null);
18507
18636
  });
18508
18637
  top += ROW_H;
@@ -18602,6 +18731,8 @@
18602
18731
  * @param {Array<{measure?, below?, atLeast?, fill, label?}>} [options.trafficLights] the host's rules:
18603
18732
  * the first whose measure (any, when absent) and range match colours a detail cell
18604
18733
  * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
18734
+ * @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
18735
+ * measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
18605
18736
  */
18606
18737
  constructor(rows, options = {}) {
18607
18738
  this.onChange = options.onChange ?? null;
@@ -18612,6 +18743,7 @@
18612
18743
  this.members = options.members ?? null;
18613
18744
  this.menus = options.menus ?? {};
18614
18745
  this.copy = options.copy ?? PivotView.clipboard;
18746
+ this.format = options.format ?? null;
18615
18747
  this._listeners = new Set();
18616
18748
  this.lightRules = options.trafficLights ?? null;
18617
18749
  /** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
@@ -19157,18 +19289,24 @@
19157
19289
  return PivotView.LIGHTS[good];
19158
19290
  }
19159
19291
 
19292
+ /** A measure's value as the grid shows it, in the measure's `format` when there is one. */
19293
+ valueText(measure, value, nullText = '—') {
19294
+ return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, measure));
19295
+ }
19296
+
19160
19297
  /** The traffic lights in words, for a line under the grid; null when off. */
19161
19298
  lightsLegend() {
19162
19299
  if (!this.lights) return null;
19163
19300
  const tail = ' (detail cells; totals not coloured)';
19164
19301
  if (this.lights === 'rules') {
19165
19302
  const said = this.lightRules.map(r => r.label ?? [
19166
- r.measure, r.atLeast !== undefined ? '≥ ' + r.atLeast : null, r.below !== undefined ? '< ' + r.below : null,
19303
+ r.measure, r.atLeast !== undefined ? '≥ ' + this.valueText(r.measure, r.atLeast) : null,
19304
+ r.below !== undefined ? '< ' + this.valueText(r.measure, r.below) : null,
19167
19305
  ].filter(Boolean).join(' '));
19168
19306
  return 'Traffic lights: ' + said.join('; ') + tail;
19169
19307
  }
19170
19308
  const better = this.lights === 'thirds-high' ? 'higher' : 'lower';
19171
- const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + TableChart.cellText(a) + ' / ' + TableChart.cellText(b)).join('; ');
19309
+ const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + this.valueText(m, a) + ' / ' + this.valueText(m, b)).join('; ');
19172
19310
  return 'Traffic lights by thirds, ' + better + ' is better — cut at ' + (cuts || 'no values') + tail;
19173
19311
  }
19174
19312
 
@@ -19386,7 +19524,7 @@
19386
19524
  const lit = view.cellFill(node, c, cuts);
19387
19525
  if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
19388
19526
  const row = view.cell(node, c.node);
19389
- const text = row === null ? '' : TableChart.cellText(row[c.measure], nullText);
19527
+ const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
19390
19528
  const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
19391
19529
  // A cell with a value opens its menu (drill-through) where the host gives one.
19392
19530
  if (row !== null && view.cellHasMenu()) {
@@ -29092,6 +29230,86 @@
29092
29230
  }
29093
29231
  }
29094
29232
 
29233
+ /**
29234
+ * DeleteFromModelCommand — elements taken out of the model (Archi's "Delete
29235
+ * from Model"), not just off one view: their relationships go too (and any
29236
+ * relationship ending on one of those), and with them every box and line
29237
+ * drawing them on every view, and every view's own line (a connection) to
29238
+ * one of those boxes. Undo puts everything back as it was, ids and all.
29239
+ *
29240
+ * What goes is ArchimateModel#usage's: say it before asking.
29241
+ */
29242
+ class DeleteFromModelCommand extends Command {
29243
+ /**
29244
+ * @param {ArchimateModel} model
29245
+ * @param {Array<string>} elementIds
29246
+ */
29247
+ constructor(model, elementIds) {
29248
+ const ids = elementIds.filter(id => model.getElement(id));
29249
+ const first = model.getElement(ids[0]);
29250
+ super(ids.length === 1 ? `Delete "${first?.name || ids[0]}" from model` : `Delete ${ids.length} elements from model`);
29251
+ this.model = model;
29252
+ this.elementIds = ids;
29253
+ this._before = null;
29254
+ }
29255
+
29256
+ execute() {
29257
+ const { model } = this;
29258
+ if (!this.elementIds.length) return;
29259
+ this._before = this._snapshot();
29260
+ const { relationships } = model.usage(this.elementIds);
29261
+ const goneElements = new Set(this.elementIds);
29262
+ const goneRelationships = new Set(relationships.map(r => r.id));
29263
+ for (const view of model.views()) {
29264
+ if (Array.isArray(view.elementIds)) view.elementIds = view.elementIds.filter(id => !goneElements.has(id));
29265
+ const goneBoxes = new Set();
29266
+ for (const inst of [...(view.elementInstances?.values() ?? [])]) {
29267
+ if (goneElements.has(inst.elementId)) { goneBoxes.add(inst.id); view.elementInstances.delete(inst.id); }
29268
+ }
29269
+ for (const inst of [...(view.relationshipInstances?.values() ?? [])]) {
29270
+ if (goneRelationships.has(inst.relationshipId)) view.relationshipInstances.delete(inst.id);
29271
+ }
29272
+ for (const line of [...(view.viewConnections?.values() ?? [])]) {
29273
+ if (goneBoxes.has(line.sourceInstanceId) || goneBoxes.has(line.targetInstanceId)) view.viewConnections.delete(line.id);
29274
+ }
29275
+ }
29276
+ for (const id of goneRelationships) model.removeRelationship(id);
29277
+ for (const id of goneElements) model.removeElement(id);
29278
+ }
29279
+
29280
+ undo() {
29281
+ if (!this._before) return;
29282
+ const { model } = this;
29283
+ const b = this._before;
29284
+ model._elements = new Map(b.elements);
29285
+ model._elementFolder = new Map(b.folders);
29286
+ model._relationships = new Map(b.relationships);
29287
+ for (const v of b.views) {
29288
+ v.view.elementIds = v.elementIds;
29289
+ if (v.instances) v.view.elementInstances = new Map(v.instances);
29290
+ if (v.lines) v.view.relationshipInstances = new Map(v.lines);
29291
+ if (v.connections) v.view.viewConnections = new Map(v.connections);
29292
+ }
29293
+ }
29294
+
29295
+ /** Everything the delete changes, to put back (the objects themselves, so ids and state are kept). @private */
29296
+ _snapshot() {
29297
+ const { model } = this;
29298
+ return {
29299
+ elements: [...model._elements],
29300
+ folders: [...model._elementFolder],
29301
+ relationships: [...model._relationships],
29302
+ views: model.views().map(view => ({
29303
+ view,
29304
+ elementIds: Array.isArray(view.elementIds) ? [...view.elementIds] : view.elementIds,
29305
+ instances: view.elementInstances ? [...view.elementInstances] : null,
29306
+ lines: view.relationshipInstances ? [...view.relationshipInstances] : null,
29307
+ connections: view.viewConnections ? [...view.viewConnections] : null,
29308
+ })),
29309
+ };
29310
+ }
29311
+ }
29312
+
29095
29313
  /**
29096
29314
  * ConceptsCompared — an ArchimateModel's elements, or its relationships, as
29097
29315
  * two models are compared on them: one's content is all it holds (name, type,
@@ -29831,6 +30049,38 @@
29831
30049
  return new MergeElementsCommand(this, keepId, otherIds, choices);
29832
30050
  }
29833
30051
 
30052
+ /**
30053
+ * Where elements are used, so deleting them from the model can say what goes
30054
+ * with them: the views drawing them (with how many boxes on each), and the
30055
+ * relationships that go too — each one ending on one of them, and each one
30056
+ * ending on such a relationship, and so on.
30057
+ * @param {Array<string>} elementIds
30058
+ * @returns {{views: Array<{view: ArchimateView, boxes: number}>, relationships: Array<Object>}}
30059
+ */
30060
+ usage(elementIds) {
30061
+ const gone = new Set(elementIds);
30062
+ const relationships = [];
30063
+ for (let grew = true; grew;) {
30064
+ grew = false;
30065
+ for (const rel of this.relationships()) {
30066
+ if (!gone.has(rel.id) && (gone.has(rel.source) || gone.has(rel.target))) {
30067
+ gone.add(rel.id);
30068
+ relationships.push(rel);
30069
+ grew = true;
30070
+ }
30071
+ }
30072
+ }
30073
+ const views = this.views()
30074
+ .map(view => ({ view, boxes: [...(view.elementInstances?.values() ?? [])].filter(i => elementIds.includes(i.elementId)).length }))
30075
+ .filter(v => v.boxes > 0);
30076
+ return { views, relationships };
30077
+ }
30078
+
30079
+ /** Take elements out of the model, with their relationships and everything drawing them (not yet executed). */
30080
+ deleteFromModelCommand(elementIds) {
30081
+ return new DeleteFromModelCommand(this, elementIds);
30082
+ }
30083
+
29834
30084
  /** Two models are compared on, and merged by, their elements, relationships and views (in the order brought). */
29835
30085
  comparedKinds() {
29836
30086
  return [new ElementsCompared(), new RelationshipsCompared(), new ArchimateViewsCompared()];
@@ -31764,6 +32014,16 @@
31764
32014
  return { x: this.x + ux * t, y: this.y + uy * t };
31765
32015
  }
31766
32016
 
32017
+ /**
32018
+ * Where a line from (px, py), a point inside the shape, heading in
32019
+ * direction (dx, dy) leaves its outline (the shape unrotated).
32020
+ * @returns {{x: number, y: number}}
32021
+ */
32022
+ exitPoint(px, py, dx, dy) {
32023
+ const t = rayExitDistance(this.outline(), px, py, dx, dy);
32024
+ return { x: px + dx * t, y: py + dy * t };
32025
+ }
32026
+
31767
32027
  /** Centre of the name text; the z-index line sits just below it. */
31768
32028
  get labelAnchor() { return { x: this.x, y: this.y - 8 }; }
31769
32029
 
@@ -33763,6 +34023,229 @@
33763
34023
  }
33764
34024
  }
33765
34025
 
34026
+ /** At most this many views or relationships are named; the rest are counted. */
34027
+ const LISTED = 10;
34028
+
34029
+ /**
34030
+ * What deleting elements from the model takes with it, in words, for the
34031
+ * confirmation: the views they are on (and how many boxes), and the
34032
+ * relationships that go too (ArchimateModel#usage).
34033
+ * @param {ArchimateModel} model
34034
+ * @param {Array<string>} elementIds
34035
+ * @returns {string}
34036
+ */
34037
+ function deleteFromModelMessage(model, elementIds) {
34038
+ const elements = elementIds.map(id => model.getElement(id)).filter(Boolean);
34039
+ const name = (el) => `"${el.name || el.id}"`;
34040
+ const { views, relationships } = model.usage(elementIds);
34041
+ const list = (items) => [...items.slice(0, LISTED).map(s => ` • ${s}`), ...(items.length > LISTED ? [` • and ${items.length - LISTED} more`] : [])];
34042
+ const end = (id) => { const c = model.getElement(id) ?? model.getRelationship(id); return c ? `${c.name || typeTitle(c.type ?? c.kind ?? '') || c.id}` : id; };
34043
+ const lines = [
34044
+ 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(', ')}.`,
34045
+ '',
34046
+ views.length
34047
+ ? `Used in ${views.length} view${views.length === 1 ? '' : 's'}, where ${elements.length === 1 ? 'its' : 'their'} boxes go:`
34048
+ : 'Used in no view.',
34049
+ ...list(views.map(({ view, boxes }) => `${view.name || view.id}${boxes > 1 ? ` (${boxes} boxes)` : ''}`)),
34050
+ '',
34051
+ relationships.length
34052
+ ? `${relationships.length} relationship${relationships.length === 1 ? '' : 's'} go${relationships.length === 1 ? 'es' : ''} too:`
34053
+ : 'No relationships.',
34054
+ ...list(relationships.map(r => `${end(r.source)} → ${end(r.target)} (${typeTitle(r.kind ?? r.type ?? '') || 'relationship'}${r.name ? `: ${r.name}` : ''})`)),
34055
+ '',
34056
+ 'Undo puts them all back.',
34057
+ ];
34058
+ return lines.join('\n');
34059
+ }
34060
+
34061
+ /**
34062
+ * Ask before deleting elements from the model: a dialog saying where they are
34063
+ * used and what goes with them; **Delete from model** runs `onConfirm()`.
34064
+ * @returns {FormDialog|null} null when none of the ids is an element
34065
+ */
34066
+ function askDeleteFromModel(model, elementIds, { onConfirm, document: doc = globalThis.document } = {}) {
34067
+ const ids = elementIds.filter(id => model.getElement(id));
34068
+ if (!ids.length) return null;
34069
+ const dialog = new FormDialog({
34070
+ title: ids.length === 1 ? 'Delete from model' : `Delete ${ids.length} elements from model`,
34071
+ description: deleteFromModelMessage(model, ids),
34072
+ fields: [],
34073
+ submitLabel: 'Delete from model',
34074
+ document: doc,
34075
+ onApply: () => onConfirm(ids),
34076
+ });
34077
+ dialog.open();
34078
+ return dialog;
34079
+ }
34080
+
34081
+ /**
34082
+ * Heat maps an ArchiMate view can be coloured by beyond its elements' own
34083
+ * fields (ArchimateCanvasEditor#colourKeys): what change a deliverable or work
34084
+ * package makes to each element, and a formula over the elements' numeric
34085
+ * properties. Pure: no editor, no DOM.
34086
+ */
34087
+
34088
+ /** The change heat map's colours. */
34089
+ const CHANGE_COLOURS = Object.freeze({ Create: '#FDD835', Update: '#1E88E5', Delete: '#D81B60' });
34090
+
34091
+ /** Element types that make changes: their relationships to an element say what they do to it. */
34092
+ const CHANGE_MAKERS = Object.freeze(['deliverable', 'work-package']);
34093
+
34094
+ /** The words for each change, synonyms included (verbs as base forms; their -s, -ed, -ing forms match too). */
34095
+ const CHANGE_WORDS = Object.freeze({
34096
+ Delete: ['delete', 'remove', 'retire', 'decommission', 'drop', 'sunset', 'terminate', 'eliminate', 'discontinue', 'deprecate', 'withdraw', 'phase out', 'shut down', 'switch off'],
34097
+ Create: ['create', 'add', 'new', 'introduce', 'build', 'built', 'establish', 'implement', 'insert', 'provision', 'set up', 'launch', 'develop', 'onboard'],
34098
+ Update: ['update', 'change', 'modify', 'amend', 'enhance', 'upgrade', 'improve', 'revise', 'extend', 'migrate', 'refactor', 'replace', 'rework', 'adapt', 'alter', 'patch'],
34099
+ });
34100
+
34101
+ /** Which change wins when an element is given more than one: the one with most consequence. */
34102
+ const PRECEDENCE = ['Delete', 'Create', 'Update'];
34103
+
34104
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34105
+
34106
+ /**
34107
+ * A word and its inflections: retire → retires, retired, retiring; drop →
34108
+ * dropped, dropping; modify → modifies, modified, modifying.
34109
+ */
34110
+ function forms(word) {
34111
+ const [verb, ...rest] = word.split(' ');
34112
+ const tail = rest.length ? `\\s+${rest.map(escape).join('\\s+')}` : '';
34113
+ const stem = verb.endsWith('e') ? verb.slice(0, -1) : verb;
34114
+ const doubled = /[^aeiou][aeiou][bdgmnpt]$/.test(verb) ? `|${escape(verb)}${verb.at(-1)}(?:ed|ing)` : '';
34115
+ const y = /[^aeiou]y$/.test(verb) ? `|${escape(verb.slice(0, -1))}i(?:es|ed)` : '';
34116
+ return `(?:${escape(verb)}(?:s|es|d|ed)?|${escape(stem)}(?:ed|ing|es)${doubled}${y})${tail}`;
34117
+ }
34118
+
34119
+ const PATTERNS = Object.fromEntries(Object.entries(CHANGE_WORDS)
34120
+ .map(([change, words]) => [change, new RegExp(`\\b(?:${words.map(forms).join('|')})\\b`, 'i')]));
34121
+
34122
+ /**
34123
+ * The change a piece of text names ('Create', 'Update' or 'Delete'), or null.
34124
+ * Where it names more than one, the one with most consequence (delete, then
34125
+ * create, then update).
34126
+ */
34127
+ function changeAction(text) {
34128
+ if (!text) return null;
34129
+ return PRECEDENCE.find(change => PATTERNS[change].test(String(text))) ?? null;
34130
+ }
34131
+
34132
+ /**
34133
+ * The change one relationship says a deliverable or work package makes: its
34134
+ * name, or, when it has none, the name of the deliverable or work package at
34135
+ * one of its ends (`toward`: the end that is not the element changed, when
34136
+ * known). Null when neither end makes changes, or nothing is said.
34137
+ * @returns {'Create'|'Update'|'Delete'|null}
34138
+ */
34139
+ function relationshipChange(model, rel, toward = null) {
34140
+ const ends = toward ? [toward] : [rel.source, rel.target];
34141
+ const maker = ends.map(id => model.getElement(id)).find(e => e && CHANGE_MAKERS.includes(e.type));
34142
+ if (!maker) return null;
34143
+ return changeAction((rel.name ?? '').trim() ? rel.name : maker.name);
34144
+ }
34145
+
34146
+ /**
34147
+ * What change the deliverables and work packages related to an element make
34148
+ * to it: for each relationship between them (either way), relationshipChange.
34149
+ * Where they differ, the one with most consequence. Null when none says.
34150
+ * @param {ArchimateModel} model
34151
+ * @param {string} elementId
34152
+ * @returns {'Create'|'Update'|'Delete'|null}
34153
+ */
34154
+ function elementChange(model, elementId) {
34155
+ const found = new Set();
34156
+ for (const rel of model.relationships()) {
34157
+ const other = rel.source === elementId ? rel.target : rel.target === elementId ? rel.source : null;
34158
+ const change = other && relationshipChange(model, rel, other);
34159
+ if (change) found.add(change);
34160
+ }
34161
+ return PRECEDENCE.find(change => found.has(change)) ?? null;
34162
+ }
34163
+
34164
+ /** Whether a model has anything that makes changes (a deliverable or work package). */
34165
+ function hasChangeMakers(model) {
34166
+ return model.elements().some(e => CHANGE_MAKERS.includes(e.type));
34167
+ }
34168
+
34169
+ /**
34170
+ * The names a formula can use for the properties: each property name as an
34171
+ * identifier (anything not a letter, digit or _ becomes _; a leading digit gets
34172
+ * a _ before it), mapped to the property it stands for.
34173
+ * @param {Array<Object>} elements
34174
+ * @returns {Map<string, string>} identifier -> property name
34175
+ */
34176
+ function propertyParams(elements) {
34177
+ const params = new Map();
34178
+ for (const e of elements) {
34179
+ for (const p of e.properties ?? []) {
34180
+ const name = p.name ?? p.key;
34181
+ if (!name) continue;
34182
+ let id = String(name).replace(/[^A-Za-z0-9_]/g, '_');
34183
+ if (/^\d/.test(id)) id = `_${id}`;
34184
+ if (!params.has(id)) params.set(id, name);
34185
+ }
34186
+ }
34187
+ return params;
34188
+ }
34189
+
34190
+ /**
34191
+ * A formula's result for each element (by id): the formula over the
34192
+ * element's numeric properties, by their propertyParams names. An element
34193
+ * missing a property the formula uses, or whose value is not a number, or
34194
+ * whose result is not a finite number, has none.
34195
+ * Throws the formula's own error when it does not parse (an unknown name, say).
34196
+ * @returns {Map<string, number>}
34197
+ */
34198
+ function expressionValues(elements, formula) {
34199
+ const params = propertyParams(elements);
34200
+ const names = [...params.keys()];
34201
+ if (!names.length) throw new Error('No element here has a property to use');
34202
+ const fn = compileFormula(formula, names);
34203
+ const used = names.filter(n => new RegExp(`\\b${n}\\b`).test(formula));
34204
+ const results = new Map();
34205
+ for (const e of elements) {
34206
+ const valueOf = (id) => {
34207
+ const p = (e.properties ?? []).find(q => (q.name ?? q.key) === params.get(id));
34208
+ const n = p === undefined || p.value === null || p.value === '' ? NaN : Number(p.value);
34209
+ return n;
34210
+ };
34211
+ if (used.some(id => !Number.isFinite(valueOf(id)))) continue;
34212
+ const result = fn(...names.map(id => { const v = valueOf(id); return Number.isFinite(v) ? v : 0; }));
34213
+ if (Number.isFinite(result)) results.set(e.id, result);
34214
+ }
34215
+ return results;
34216
+ }
34217
+
34218
+ /** A number as a label: integers as they are, others to at most 3 decimals. */
34219
+ const show = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toFixed(3))));
34220
+
34221
+ /**
34222
+ * Labels for a formula's results: each distinct result its own, in order,
34223
+ * when there are at most `distinct` of them; else `bands` equal-width bands
34224
+ * from the smallest to the largest ("10 – 20"), the last taking in the largest;
34225
+ * a band no result falls in is left out.
34226
+ * @param {Iterable<number>} numbers
34227
+ * @returns {{labels: string[], labelOf: (n: number) => string}}
34228
+ */
34229
+ function resultBands(numbers, { distinct = 8, bands = 5 } = {}) {
34230
+ const values = [...new Set(numbers)].sort((a, b) => a - b);
34231
+ if (values.length <= distinct) return { labels: values.map(show), labelOf: show };
34232
+ const lo = values[0], hi = values.at(-1), width = (hi - lo) / bands;
34233
+ const all = Array.from({ length: bands }, (_, i) => `${show(lo + i * width)} – ${show(i === bands - 1 ? hi : lo + (i + 1) * width)}`);
34234
+ const labelOf = (n) => all[Math.min(bands - 1, Math.floor((n - lo) / width))];
34235
+ const used = new Set(values.map(labelOf));
34236
+ return { labels: all.filter(l => used.has(l)), labelOf };
34237
+ }
34238
+
34239
+ /** `count` colours from light to dark (a sequential scale, light yellow to dark red), for ordered values. */
34240
+ function sequentialColours(count) {
34241
+ const from = [255, 247, 188], to = [189, 0, 38];
34242
+ const hex = (c) => Math.round(c).toString(16).padStart(2, '0');
34243
+ return Array.from({ length: count }, (_, i) => {
34244
+ const t = count === 1 ? 1 : i / (count - 1);
34245
+ return `#${from.map((f, k) => hex(f + (to[k] - f) * t)).join('')}`;
34246
+ });
34247
+ }
34248
+
33766
34249
  /**
33767
34250
  * ShowElementCommand — put an existing model element on a view (dropped from
33768
34251
  * the model tree), with its relationships to the elements already there, as
@@ -35750,6 +36233,15 @@
35750
36233
  * toolbar container) to itself.
35751
36234
  */
35752
36235
  class DiagramEditor {
36236
+ /** An exported picture's defaults: the whole drawing, at 100%. */
36237
+ static EXPORT_DEFAULTS = Object.freeze({ area: 'drawing', scale: 1 });
36238
+
36239
+ /** Space kept clear round an exported drawing, so nothing touches the picture's edge. */
36240
+ static EXPORT_MARGIN = 20;
36241
+
36242
+ /** How far an arrowhead (or other line-end marker) reaches past its line's end. */
36243
+ static MARKER_REACH = 10;
36244
+
35753
36245
  /** How a selected shape (besides the one with handles) is marked: an outline `pad` outside it, tinted. */
35754
36246
  static SELECTION_OUTLINE = Object.freeze({ pad: 4, stroke: '#0066cc', strokeWidth: 2, dash: '5,3', fill: 'rgba(0, 102, 204, 0.08)' });
35755
36247
 
@@ -35813,6 +36305,10 @@
35813
36305
  /** Off unless asked for (the `showGrid` option, or ⚙ Settings → Grid), as the settings' default says. */
35814
36306
  this.showGrid = options.showGrid ?? false;
35815
36307
  this.gridSize = 20;
36308
+ /** A faint box where an exported picture's edges fall (⚙ Settings → Export area); never exported itself. */
36309
+ this.showExportArea = options.showExportArea ?? false;
36310
+ /** What an exported picture takes in, and at what size (Export image… remembers the last choice). */
36311
+ this.exportOptions = { ...DiagramEditor.EXPORT_DEFAULTS };
35816
36312
 
35817
36313
  // Zoom and pan
35818
36314
  this.zoomLevel = 1.0;
@@ -36120,6 +36616,17 @@
36120
36616
  return [];
36121
36617
  }
36122
36618
 
36619
+ /**
36620
+ * The whole menu a right-click on empty canvas at `at` opens: paste here,
36621
+ * canvasMenuItems, then Export image (imageMenuItems), each set apart.
36622
+ */
36623
+ canvasContextMenu(at) {
36624
+ const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
36625
+ const image = this.imageMenuItems();
36626
+ const sections = [paste, this.canvasMenuItems(at), image.length ? [{ label: 'Export image', items: image }] : []].filter(s => s.length);
36627
+ return sections.flatMap((s, i) => (i ? [{ separator: true }, ...s] : s));
36628
+ }
36629
+
36123
36630
  /**
36124
36631
  * The menu for empty canvas at `at` (drawing coordinates); none here, a
36125
36632
  * subclass offers its own (place a template, ...).
@@ -36361,6 +36868,7 @@
36361
36868
 
36362
36869
  // Render edges
36363
36870
  this._renderEdges(group);
36871
+ if (this.showExportArea && container.tagName === 'svg') this._renderExportArea(group);
36364
36872
 
36365
36873
  // The selection's groups and other selected shapes, then the primary shape's handles.
36366
36874
  this._renderArrangement(group);
@@ -36459,9 +36967,13 @@
36459
36967
  ];
36460
36968
  }
36461
36969
 
36462
- /** Export the current view as a picture: PNG, SVG, or a copy to the clipboard. */
36970
+ /**
36971
+ * Export the current view as a picture: PNG, SVG, or a copy to the clipboard,
36972
+ * as Export image… last chose (the whole drawing at 100% to begin with).
36973
+ */
36463
36974
  imageMenuItems() {
36464
36975
  return [
36976
+ { label: 'Export image…', action: () => this.askExportImage() },
36465
36977
  { label: 'Export image (PNG)', action: () => this.exportImage('png') },
36466
36978
  { label: 'Export image (SVG)', action: () => this.exportImage('svg') },
36467
36979
  { label: 'Copy image', action: () => this.exportImage('copy') },
@@ -36491,17 +37003,113 @@
36491
37003
  * The current view as a picture: 'png' or 'svg' downloads it, 'copy' puts a
36492
37004
  * PNG on the clipboard (or downloads it where the browser cannot). The grid,
36493
37005
  * the selection and its handles are left out.
37006
+ *
37007
+ * `area` 'drawing' (the default) takes in everything drawn, wherever the view
37008
+ * is scrolled to: every shape and line, labels and arrowheads included, with
37009
+ * a margin clear of the edge (drawingBounds). 'view' is what is in sight.
37010
+ * `scale` sizes the picture: 1 is 100%.
36494
37011
  * @param {'png'|'svg'|'copy'} format
37012
+ * @param {{area?: 'drawing'|'view', scale?: number}} [options] - Export image…'s last choice by default
36495
37013
  */
36496
- exportImage(format = 'png') {
37014
+ exportImage(format = 'png', { area = this.exportOptions.area, scale = this.exportOptions.scale } = {}) {
36497
37015
  const host = this.drawingHost();
36498
37016
  if (!host) return null;
36499
37017
  const name = this.imageName();
36500
- return this._withPlainDrawing(() => {
36501
- if (format === 'svg') return downloadSvg(host, { name });
36502
- if (format === 'copy') return copyImage(host, { name });
36503
- return downloadPng(host, { name });
37018
+ if (area === 'view' || host.tagName?.toLowerCase() !== 'svg') {
37019
+ return this._withPlainDrawing(() => {
37020
+ if (format === 'svg') return downloadSvg(host, { name, scale });
37021
+ if (format === 'copy') return copyImage(host, { name, scale });
37022
+ return downloadPng(host, { name, scale });
37023
+ });
37024
+ }
37025
+ const viewBox = this.drawingBounds();
37026
+ if (!viewBox) return null;
37027
+ const svg = this._withPlainDrawing(() => this._croppedSvg(host, viewBox, { scale }));
37028
+ if (format === 'svg') return downloadSvgMarkup(svg, { name });
37029
+ const png = svgMarkupToPngBlob(svg, { width: viewBox.width, height: viewBox.height, scale });
37030
+ if (format === 'copy') return png.then(blob => copyPngBlob(blob, { name }));
37031
+ return png.then(blob => downloadBlob(blob, imageFileName(name, 'png')));
37032
+ }
37033
+
37034
+ /** Ask what to export: the format, the whole drawing or what is in sight, and the scale (%). */
37035
+ askExportImage() {
37036
+ const { area, scale } = this.exportOptions;
37037
+ new FormDialog({
37038
+ title: 'Export image',
37039
+ description: 'The whole drawing takes in every shape and line, with a margin; the view, only what is in sight.',
37040
+ fields: [
37041
+ { key: 'format', label: 'Format', type: 'select', value: 'png', choices: [['png', 'PNG'], ['svg', 'SVG'], ['copy', 'Copy to the clipboard (PNG)']] },
37042
+ { key: 'area', label: 'Take in', type: 'select', value: area, choices: [['drawing', 'The whole drawing'], ['view', 'What is in sight']] },
37043
+ { key: 'scale', label: 'Scale (%)', type: 'number', value: Math.round(scale * 100), min: 10, max: 800, step: 10 },
37044
+ ],
37045
+ submitLabel: 'Export',
37046
+ document: this.document ?? globalThis.document,
37047
+ onApply: (values) => this.exportImage(values.format, this.setExportOptions(values)),
37048
+ }).open();
37049
+ }
37050
+
37051
+ /**
37052
+ * Remember an export's area and scale (the scale in %), for the next one.
37053
+ * A scale that is not a positive number keeps the last. Returns the options.
37054
+ */
37055
+ setExportOptions({ area, scale } = {}) {
37056
+ if (area === 'drawing' || area === 'view') this.exportOptions.area = area;
37057
+ const pct = Number(scale);
37058
+ if (Number.isFinite(pct) && pct > 0) this.exportOptions.scale = pct / 100;
37059
+ return { ...this.exportOptions };
37060
+ }
37061
+
37062
+ /**
37063
+ * Where an exported picture of the whole drawing ends (in drawing units):
37064
+ * every shape and line shown, with line labels and arrowheads, and
37065
+ * `margin` clear all round, so nothing touches the picture's edge. Null
37066
+ * when nothing is drawn.
37067
+ * @returns {{x, y, width, height}|null}
37068
+ */
37069
+ drawingBounds({ margin = DiagramEditor.EXPORT_MARGIN } = {}) {
37070
+ const boxes = [...this.templates.values()]
37071
+ .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
37072
+ .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom }));
37073
+ const cascade = this.edgeStyleCascade();
37074
+ for (const edge of this.getAllEdges()) {
37075
+ if (!edge.visible || this.edgeEmphasis(edge) === 'hidden') continue;
37076
+ const { style } = cascade.resolve(edge);
37077
+ const path = this._edgePath(edge, style);
37078
+ if (!path) continue;
37079
+ // Every point the path names (a curve's control points bound it); half the stroke and an arrowhead past them.
37080
+ const numbers = path.d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)?.map(Number) ?? [];
37081
+ const reach = (Number(style.strokeWidth) || 1) / 2 + DiagramEditor.MARKER_REACH;
37082
+ for (let i = 0; i + 1 < numbers.length; i += 2) {
37083
+ boxes.push({ left: numbers[i] - reach, top: numbers[i + 1] - reach, right: numbers[i] + reach, bottom: numbers[i + 1] + reach });
37084
+ }
37085
+ if (edge.label) {
37086
+ // As _renderEdges writes it: centred over the middle, 12px text (about 7px a character).
37087
+ const cx = (path.x1 + path.x2) / 2, base = (path.y1 + path.y2) / 2 - 8;
37088
+ const half = String(edge.label).length * 7 / 2;
37089
+ boxes.push({ left: cx - half, top: base - 12, right: cx + half, bottom: base + 4 });
37090
+ }
37091
+ }
37092
+ const bounds = ViewFit.union(boxes);
37093
+ if (!bounds) return null;
37094
+ return { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
37095
+ }
37096
+
37097
+ /** The faint box where an exported picture of the whole drawing ends (drawingBounds). @protected */
37098
+ _renderExportArea(group) {
37099
+ const area = this.drawingBounds();
37100
+ if (!area) return;
37101
+ const rect = this._svg('rect', {
37102
+ id: 'export-area', x: area.x, y: area.y, width: area.width, height: area.height,
37103
+ fill: 'rgba(43, 95, 217, 0.03)', stroke: 'rgba(43, 95, 217, 0.25)', 'stroke-dasharray': '6 4', 'stroke-width': 1,
37104
+ 'pointer-events': 'none',
36504
37105
  });
37106
+ group.insertBefore(rect, group.firstChild);
37107
+ }
37108
+
37109
+ /** Show or hide the export area. */
37110
+ setShowExportArea(shown) {
37111
+ this.showExportArea = !!shown;
37112
+ this.render();
36505
37113
  }
36506
37114
 
36507
37115
  /**
@@ -36510,8 +37118,8 @@
36510
37118
  * @protected
36511
37119
  */
36512
37120
  _withPlainDrawing(fn) {
36513
- const saved = { showGrid: this.showGrid, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
36514
- Object.assign(this, { showGrid: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
37121
+ const saved = { showGrid: this.showGrid, showExportArea: this.showExportArea, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
37122
+ Object.assign(this, { showGrid: false, showExportArea: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
36515
37123
  this.render();
36516
37124
  try {
36517
37125
  return fn();
@@ -36534,13 +37142,7 @@
36534
37142
  if (host?.tagName?.toLowerCase() !== 'svg' || !bounds) return null;
36535
37143
  const viewBox = { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
36536
37144
  return this._withPlainDrawing(() => {
36537
- const copy = host.cloneNode(true);
36538
- copy.querySelector('#grid-layer')?.remove();
36539
- copy.querySelector('#elements')?.removeAttribute('transform');
36540
- copy.removeAttribute('style');
36541
- copy.removeAttribute('id');
36542
- copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
36543
- const svg = prepareSvgMarkup(new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy), { width: viewBox.width, height: viewBox.height, background });
37145
+ const svg = this._croppedSvg(host, viewBox, { background });
36544
37146
  const areas = [...this.templates.values()]
36545
37147
  .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
36546
37148
  .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
@@ -36556,6 +37158,22 @@
36556
37158
  }
36557
37159
 
36558
37160
  /** The drawing's extent in drawing coordinates (ViewFit.union of the visible shapes), or null. */
37161
+ /**
37162
+ * The drawing as standalone SVG markup cut to `viewBox` (drawing units),
37163
+ * unpanned and unzoomed, sized at `scale`. Call it inside _withPlainDrawing.
37164
+ * @protected
37165
+ */
37166
+ _croppedSvg(host, viewBox, { background = '#ffffff', scale = 1 } = {}) {
37167
+ const copy = host.cloneNode(true);
37168
+ copy.querySelector('#grid-layer')?.remove();
37169
+ copy.querySelector('#elements')?.removeAttribute('transform');
37170
+ copy.removeAttribute('style');
37171
+ copy.removeAttribute('id');
37172
+ copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
37173
+ const markup = new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy);
37174
+ return prepareSvgMarkup(markup, { width: viewBox.width * scale, height: viewBox.height * scale, background });
37175
+ }
37176
+
36559
37177
  contentBounds() {
36560
37178
  return ViewFit.union([...this.templates.values()].filter(t => t.visible !== false)
36561
37179
  .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom })));
@@ -36659,6 +37277,7 @@
36659
37277
  return [
36660
37278
  { label: `Grid: ${this.showGrid ? 'on' : 'off'}`, action: () => { this.showGrid = !this.showGrid; this.render(); } },
36661
37279
  { label: `Grid size: ${this.gridSize}px…`, action: () => this.editGridSize() },
37280
+ { label: `Export area: ${this.showExportArea ? 'shown' : 'hidden'}`, action: () => this.setShowExportArea(!this.showExportArea) },
36662
37281
  ...(this.edgeStyleLevels.length ? [{ separator: true }] : []),
36663
37282
  ...this.edgeStyleLevels.map(level => ({
36664
37283
  label: `Line style: ${level.label} defaults…`,
@@ -37019,13 +37638,17 @@
37019
37638
  /**
37020
37639
  * Geometry of an edge between its templates' outlines, or null if an end is
37021
37640
  * missing. `style.attach === 'sides'` attaches each unpinned end at the middle
37022
- * of the side facing the other end (or the nearest waypoint); 'polyline'
37641
+ * of the side facing the other end (or the nearest waypoint); 'shortest' (the
37642
+ * default, as Archi draws) runs a straight line straight across where the
37643
+ * shapes overlap, else aims at the other end ('floating'); 'polyline'
37023
37644
  * routing bends around the other shapes.
37024
37645
  */
37025
37646
  _edgePath(edge, style = this.resolveEdgeStyle(edge)) {
37026
37647
  const source = this.getTemplate(edge.sourceId);
37027
37648
  const target = this.getTemplate(edge.targetId);
37028
37649
  if (!source || !target) return null;
37650
+ const across = style.attach === 'shortest' ? this._acrossPath(edge, source, target, style) : null;
37651
+ if (across) return across;
37029
37652
  const box = (t) => ({ w: t.width, h: t.height });
37030
37653
  let sourceSide = edge.sourceSide || 'auto';
37031
37654
  let targetSide = edge.targetSide || 'auto';
@@ -37045,6 +37668,25 @@
37045
37668
  );
37046
37669
  }
37047
37670
 
37671
+ /**
37672
+ * The shortest straight line between two shapes that face each other across
37673
+ * a gap (EdgeGeometry.acrossOverlap): straight across the middle of their
37674
+ * overlap, from outline to outline. Null where that does not apply: a line
37675
+ * with bends, a pinned end, other routing, a rotated shape, or no overlap.
37676
+ * @protected
37677
+ */
37678
+ _acrossPath(edge, source, target, style) {
37679
+ if (edge.waypoints.length || (edge.sourceSide || 'auto') !== 'auto' || (edge.targetSide || 'auto') !== 'auto') return null;
37680
+ if ((style.routing ?? 'straight') !== 'straight' || source.rotation || target.rotation) return null;
37681
+ const across = EdgeGeometry.acrossOverlap(source, target);
37682
+ if (!across) return null;
37683
+ const step = across.forward ? 1 : -1;
37684
+ const [a, b] = across.axis === 'v'
37685
+ ? [source.exitPoint(across.at, source.y, 0, step), target.exitPoint(across.at, target.y, 0, -step)]
37686
+ : [source.exitPoint(source.x, across.at, step, 0), target.exitPoint(target.x, across.at, -step, 0)];
37687
+ return { x1: a.x, y1: a.y, x2: b.x, y2: b.y, d: `M${a.x},${a.y} L${b.x},${b.y}` };
37688
+ }
37689
+
37048
37690
  /** Anchors and waypoints in order: the polyline a waypointed edge follows. */
37049
37691
  _edgeVertices(edge, edgePath = this._edgePath(edge)) {
37050
37692
  return [{ x: edgePath.x1, y: edgePath.y1 }, ...edge.waypoints, { x: edgePath.x2, y: edgePath.y2 }];
@@ -37537,12 +38179,9 @@
37537
38179
  e.preventDefault();
37538
38180
  this._startMarquee(e, container);
37539
38181
  });
37540
- // Empty canvas: what canvasMenuItems offers there (a shape's own menu stops the event first).
38182
+ // Empty canvas: canvasContextMenu there (a shape's own menu stops the event first).
37541
38183
  container.addEventListener('contextmenu', (e) => {
37542
- const at = this._clientToDiagram(e.clientX, e.clientY);
37543
- const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
37544
- const own = this.canvasMenuItems(at);
37545
- const items = [...paste, ...(paste.length && own.length ? [{ separator: true }] : []), ...own];
38184
+ const items = this.canvasContextMenu(this._clientToDiagram(e.clientX, e.clientY));
37546
38185
  if (!items.length) return;
37547
38186
  e.preventDefault();
37548
38187
  this._showMenu(e.clientX, e.clientY, items);
@@ -37997,11 +38636,27 @@
37997
38636
  return [['kind', 'Kind of shape']];
37998
38637
  }
37999
38638
 
38639
+ /** What a colour key is called (colourKeys' label; a key not listed there, itself). */
38640
+ colourKeyLabel(key) {
38641
+ return this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
38642
+ }
38643
+
38644
+ /** The colours offered for a key's values, in order: the palette's, round again when there are more. */
38645
+ colourDefaults(key, values) {
38646
+ const palette = this.constructor.COLOUR_PALETTE;
38647
+ return values.map((_, i) => palette[i % palette.length]);
38648
+ }
38649
+
38000
38650
  /** A shape's value for a colourKeys key, as text (undefined: it has none, and is left as it is). */
38001
38651
  colourValue(template, key) {
38002
38652
  return key === 'kind' ? this.layoutCategory(template) : undefined;
38003
38653
  }
38004
38654
 
38655
+ /** A line's value for a colourKeys key (undefined: lines are not coloured by it, as here; a subclass's may be). */
38656
+ colourEdgeValue(edge, key) {
38657
+ return undefined;
38658
+ }
38659
+
38005
38660
  /** The values of `key` among the shapes, in order of first appearance, each with its shapes. */
38006
38661
  colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
38007
38662
  const values = new Map();
@@ -38017,19 +38672,26 @@
38017
38672
 
38018
38673
  /**
38019
38674
  * Colour the shapes by what `key` says of them (a heat map): each value's
38020
- * shapes filled with its colour (`colours`, by value; a value left out or
38675
+ * shapes filled with its colour, and lines with a value of their own
38676
+ * (colourEdgeValue) drawn in it (`colours`, by value; a value left out or
38021
38677
  * empty is left as it is), and with `legend` a legend of the colours
38022
38678
  * under the drawing. One undo step; locked shapes are left as they are.
38023
38679
  * @returns {Command|null}
38024
38680
  */
38025
38681
  colourByCommand(key, colours, { legend = false } = {}) {
38026
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
38682
+ const label = this.colourKeyLabel(key);
38027
38683
  const steps = [];
38028
38684
  for (const [value, templates] of this.colourValues(key)) {
38029
38685
  const fillColor = colours[value];
38030
38686
  if (!fillColor) continue;
38031
38687
  for (const t of templates) if (!this.isFixed(t.id) && t.fillColor !== fillColor) steps.push(new SetPropertiesCommand(t, { fillColor }, 'Colour'));
38032
38688
  }
38689
+ // Lines with a value of their own take its colour too (colourEdgeValue).
38690
+ for (const edge of this.getAllEdges()) {
38691
+ if (edge.editable === false) continue;
38692
+ const strokeColor = colours[this.colourEdgeValue(edge, key)];
38693
+ if (strokeColor && edge.strokeColor !== strokeColor) steps.push(new SetStyleCommand(edge, { strokeColor }, 'Colour'));
38694
+ }
38033
38695
  if (legend) {
38034
38696
  const entries = [...this.colourValues(key).keys()].filter(v => colours[v]).map(v => ({ label: v, fillColor: colours[v] }));
38035
38697
  const cmd = entries.length && this.legendCommand(entries, label);
@@ -38055,13 +38717,13 @@
38055
38717
  /** The colour of each value of `key` (the palette's by default), then colour by it. @protected */
38056
38718
  askColours(key) {
38057
38719
  const values = [...this.colourValues(key).keys()];
38058
- const palette = this.constructor.COLOUR_PALETTE;
38059
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
38720
+ const defaults = this.colourDefaults(key, values);
38721
+ const label = this.colourKeyLabel(key);
38060
38722
  if (!values.length) { (globalThis.alert ?? (() => {}))(`No shape here has a value for ${label}.`); return; }
38061
38723
  new FormDialog({
38062
38724
  title: `Colour by ${label}`,
38063
38725
  fields: [
38064
- ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: palette[i % palette.length] })),
38726
+ ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: defaults[i] })),
38065
38727
  { key: 'legend', label: 'Add a legend', type: 'checkbox', value: true },
38066
38728
  ],
38067
38729
  submitLabel: 'Colour',
@@ -38101,19 +38763,45 @@
38101
38763
  }
38102
38764
 
38103
38765
  /**
38104
- * The command that adds a legend: a title and a box per entry, in a row
38105
- * under the drawing (legendPosition). Here, notes; a subclass makes its own
38106
- * kind of box. Null with no entries.
38766
+ * Where a legend's boxes go, its top-left at `{x, y}`: a frame titled at its
38767
+ * top-left that holds every entry, the entries in rows of up to
38768
+ * `perRow` inside it. Centres and sizes, as boxes are placed.
38769
+ * @returns {{frame: {x, y, width, height}, items: Array<{x, y, width, height}>}}
38770
+ */
38771
+ static legendLayout(count, { x, y }, { perRow = 4, itemWidth = 120, itemHeight = 40, gap = 10, pad = 10, titleHeight = 24 } = {}) {
38772
+ const columns = Math.max(1, Math.min(count, perRow));
38773
+ const rows = Math.max(1, Math.ceil(count / columns));
38774
+ const width = 2 * pad + columns * itemWidth + (columns - 1) * gap;
38775
+ const height = titleHeight + rows * itemHeight + (rows - 1) * gap + pad;
38776
+ const items = Array.from({ length: count }, (_, i) => ({
38777
+ x: x + pad + (i % columns) * (itemWidth + gap) + itemWidth / 2,
38778
+ y: y + titleHeight + Math.floor(i / columns) * (itemHeight + gap) + itemHeight / 2,
38779
+ width: itemWidth, height: itemHeight,
38780
+ }));
38781
+ return { frame: { x: x + width / 2, y: y + height / 2, width, height }, items };
38782
+ }
38783
+
38784
+ /** The legend frame's text: its title, bold at the top-left (as Archi's legends are). */
38785
+ static LEGEND_TITLE_STYLE = Object.freeze({ textAlign: 'left', textPosition: 'top', fontWeight: 'bold' });
38786
+
38787
+ /**
38788
+ * The command that adds a legend under the drawing (legendPosition): a
38789
+ * frame titled `title` with a box per entry inside it, grouped so they move
38790
+ * as one. Here, notes; a subclass makes its own kind of box. Null with no
38791
+ * entries.
38107
38792
  * @param {Array<{label: string, fillColor: string}>} entries
38108
38793
  * @param {string} title
38109
38794
  */
38110
38795
  legendCommand(entries, title) {
38111
38796
  if (!entries.length) return null;
38112
- const { x, y } = this.legendPosition();
38797
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
38113
38798
  const stamp = Date.now().toString(36);
38114
- 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 });
38115
- const boxes = [note(0, title, '#ffffff'), ...entries.map((e, i) => note(i + 1, e.label, e.fillColor))];
38116
- return new CompositeCommand(`Add legend: ${title}`, boxes.map(t => new CreateTemplateCommand(this, t)));
38799
+ const note = (i, name, box, more) => new NoteTemplate(`legend-${stamp}-${i}`, name, { ...box, ...more });
38800
+ 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 }))];
38801
+ return new CompositeCommand(`Add legend: ${title}`, [
38802
+ ...boxes.map(t => new CreateTemplateCommand(this, t)),
38803
+ new GroupCommand(this.arrangement, boxes.map(t => t.id), { name: title }),
38804
+ ]);
38117
38805
  }
38118
38806
 
38119
38807
  // ===== Format painter =====
@@ -39957,974 +40645,1066 @@
39957
40645
  }).open();
39958
40646
  }
39959
40647
 
39960
- /**
39961
- * Which allowed relationship kind a new connection takes when nobody is asked:
39962
- * the dependency and dynamic kinds people usually mean first, the structural
39963
- * whole/part kinds last.
39964
- */
39965
- const KIND_PREFERENCE = Object.freeze([
39966
- 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
39967
- 'specialization', 'aggregation', 'composition', 'association',
39968
- ]);
39969
-
39970
- /**
39971
- * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
39972
- * view: its element instances as ArchimateElementTemplates, its relationship
39973
- * instances as ArchimateEdges, and its own undo history. Everything the base
39974
- * editor does (move, resize, restack, bend points, sides, styles, zoom,
39975
- * snapping) edits the view directly; the editor's maps are rebuilt from the
39976
- * view on every render, so undo of a view edit shows straight away.
39977
- *
39978
- * What stands for something else in ArchiMate is done through the view and
39979
- * model instead of the maps:
39980
- * - dropping a palette type adds a new model element and shows it;
39981
- * - connecting two elements adds a relationship, its kind the first the
39982
- * ArchiMate rules allow between them (association if none);
39983
- * - deleting removes from the view (with the element's lines); the model
39984
- * keeps its elements and relationships.
39985
- * Each is one step on the view's history.
39986
- */
39987
- class ArchimateCanvasEditor extends DiagramEditor {
39988
- /**
39989
- * @param {Object} options - DiagramEditor options, plus:
39990
- * @param {ArchimateView} options.view
39991
- * @param {ArchimateModel} options.model
39992
- * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
39993
- * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
39994
- * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
39995
- * Asks which relationship kind a new connection is, given the kinds allowed between the two
39996
- * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
39997
- * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
39998
- * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
39999
- * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
40000
- * live status for an element (a component's lifecycle state), drawn on its boxes
40001
- * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
40002
- * element (its transitions), at the end of a box's right-click menu
40003
- */
40004
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
40005
- super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
40006
- this.view = view;
40007
- this.model = model;
40008
- this.nodeOptions = nodeOptions;
40009
- this.chooseKind = chooseKind;
40010
- this.onNodeOptionsChange = onNodeOptionsChange;
40011
- /** Viewpoints of the user's own, offered with the standard ones. */
40012
- this.customViewpoints = customViewpoints;
40013
- /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
40014
- this.settingsManager = settingsManager;
40015
- /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
40016
- this.templateMenu = templateMenu;
40017
- /** A host's live status per element, drawn on its boxes, and its menu items per element. */
40018
- this.elementStatus = elementStatus;
40019
- this.elementMenuExtras = elementMenuExtras;
40020
- this.addDropHandler({
40021
- accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
40022
- drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
40023
- });
40024
- // An element from the model tree: shown here, with its relationships to what is already shown.
40025
- this.addDropHandler({
40026
- accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
40027
- drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
40028
- });
40029
- // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
40030
- if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
40031
- // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
40032
- this.addDropHandler({
40033
- accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
40034
- drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
40035
- });
40036
- }
40037
-
40038
- get version() {
40039
- return this.nodeOptions.archimateVersion ?? '4';
40040
- }
40041
-
40042
- /**
40043
- * An element's own items (a view of it and what it relates to; its
40044
- * relationships to what is shown, when some are not), a canvas box's hint,
40045
- * and the shape's selection (or the shape and its group) as a template.
40046
- */
40047
- shapeMenuExtras(elementId) {
40048
- const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
40049
- const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
40050
- const element = this._elementOf(elementId);
40051
- const missing = element ? this.missingRelationshipsOf(elementId) : [];
40052
- // Two or more elements chosen: merge them into one (choose the one kept).
40053
- const merging = this.elementIdsOf(ids);
40054
- return [
40055
- ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
40056
- ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
40057
- ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
40058
- ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
40059
- ...(items.length ? [{ label: 'Save as template', items }] : []),
40060
- ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
40061
- ];
40062
- }
40063
-
40064
- /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
40065
- missingRelationshipsOf(instanceId) {
40066
- const instance = this.view.getElementInstance(instanceId);
40067
- if (!instance) return [];
40068
- return this.view.missingRelationshipInstances(instance.elementId)
40069
- .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
40070
- }
40071
-
40072
- /** Draw them all, as one undo step. */
40073
- addMissingRelationships(instanceId) {
40074
- const missing = this.missingRelationshipsOf(instanceId);
40075
- if (!missing.length) return null;
40076
- const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
40077
- missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
40078
- this._executeCommand(cmd);
40079
- return cmd;
40080
- }
40081
-
40082
- /** 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. */
40083
- canvasMenuItems(at) {
40084
- const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
40085
- const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
40086
- // Elements from text: not on a canvas, whose boxes are not elements.
40087
- const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
40088
- return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
40089
- }
40090
-
40091
- /** The View menu, and Generate roadmap… (elements with dates on a time line). */
40092
- viewMenuItems() {
40093
- const repeated = this.view?.repeatedBoxes?.() ?? [];
40094
- return [
40095
- ...super.viewMenuItems(),
40096
- { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
40097
- { label: 'Generate roadmap…', action: () => this.askRoadmap() },
40098
- ];
40099
- }
40100
-
40101
- /** The model's own: Find likely duplicates…. */
40102
- modelMenuItems() {
40103
- return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
40104
- }
40105
-
40106
- /**
40107
- * Take off this view the boxes that only repeat another box of their
40108
- * element (ArchimateView#repeatedBoxes: same label, same lines), with
40109
- * their lines, as one undo step. Returns the command, or null with none.
40110
- */
40111
- removeRepeatedBoxes() {
40112
- const ids = this.view.repeatedBoxes();
40113
- if (!ids.length) return null;
40114
- const cmd = this.deleteTemplatesCommand(ids);
40115
- cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
40116
- this._executeCommand(cmd);
40117
- return cmd;
40118
- }
40119
-
40120
- /**
40121
- * Where Find likely duplicates… can look: the whole model, the elements on
40122
- * the view in front, the elements selected (two or more). [key, label, element ids or null].
40123
- */
40124
- duplicateScopes() {
40125
- const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
40126
- const selected = this.elementIdsOf(this.getSelectedIds());
40127
- return [
40128
- ['model', 'The whole model', null],
40129
- ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
40130
- ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
40131
- ];
40132
- }
40133
-
40134
- /**
40135
- * Find likely duplicates…: where to look (duplicateScopes), how to compare
40136
- * (names, documentation or a property's value; exactly or by their words)
40137
- * and what noise to leave out (duplicateSearchFields), then the groups
40138
- * found, each with Merge… (showDuplicates).
40139
- */
40140
- askDuplicates() {
40141
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
40142
- const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
40143
- const scopes = this.duplicateScopes();
40144
- return new FormDialog({
40145
- title: 'Find likely duplicates',
40146
- description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
40147
- fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
40148
- submitLabel: 'Find',
40149
- idPrefix: 'duplicates-',
40150
- document: this.document ?? globalThis.document,
40151
- validate: validateDuplicateSearch,
40152
- onApply: (values) => {
40153
- const { options, scope } = duplicateSearchOptions(values);
40154
- this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
40155
- },
40156
- }).open();
40157
- }
40158
-
40159
- /**
40160
- * The elements that look like one thing twice (the model check's likely
40161
- * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
40162
- * among these element ids), each group with Merge….
40163
- */
40164
- showDuplicates(options = {}, { within = null } = {}) {
40165
- const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
40166
- const groups = find();
40167
- new DuplicatesDialog({
40168
- groups,
40169
- refind: find,
40170
- merge: async (group) => !!(await this.askMerge(group.ids)),
40171
- onReveal: (group) => this.revealFinding(group),
40172
- empty: 'No elements look like the same thing twice.',
40173
- document: this.document ?? globalThis.document,
40174
- }).open();
40175
- return groups;
40176
- }
40177
-
40178
- /** The model elements of some boxes, each once (a box that is not an element is left out). */
40179
- elementIdsOf(instanceIds) {
40180
- return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
40181
- }
40182
-
40183
- /**
40184
- * Generate roadmap…: which elements (the selection's, or every element in
40185
- * the model with both dates), the properties their dates are in, and how
40186
- * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
40187
- */
40188
- askRoadmap() {
40189
- const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
40190
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
40191
- return new FormDialog({
40192
- title: 'Generate roadmap',
40193
- description: 'A view with each element a bar from its start date to its end date, under the months.',
40194
- fields: [
40195
- { 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']] },
40196
- { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
40197
- { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
40198
- { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
40199
- { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
40200
- ],
40201
- submitLabel: 'Generate',
40202
- idPrefix: 'roadmap-',
40203
- document: this.document ?? globalThis.document,
40204
- validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
40205
- onApply: (v) => this.generateRoadmap(v),
40206
- }).open();
40207
- }
40208
-
40209
- /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
40210
- roadmapFor({ from, start, end }) {
40211
- const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
40212
- const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
40213
- const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
40214
- return { ids, view: any ? true : null };
40215
- }
40216
-
40217
- /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
40218
- generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
40219
- const { ids } = this.roadmapFor({ from, start, end });
40220
- return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
40221
- }
40222
-
40223
- /** An element's box keeps room for its type badge, and Archi's height. */
40224
- textFit(template) {
40225
- return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
40226
- }
40227
-
40228
- /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
40229
- shapeTypes() {
40230
- const viewpoint = this.nodeOptions.viewpoint;
40231
- const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
40232
- return ArchimateNotation.elementTypeNames(this.version)
40233
- .filter(t => !allowed || allowed.includes(t))
40234
- .map(t => [t, typeTitle(t)]);
40235
- }
40236
-
40237
- /**
40238
- * New model elements from text, each with its box here: one undo step.
40239
- * A documentation column becomes the element's documentation.
40240
- */
40241
- createShapesCommand(items) {
40242
- const ids = this.model.elements().map(e => e.id);
40243
- const steps = [], createdIds = [];
40244
- for (const item of items) {
40245
- const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
40246
- ids.push(id);
40247
- const instance = new ElementInstance(id, item.x, item.y);
40248
- createdIds.push(instance.id);
40249
- steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
40250
- }
40251
- const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
40252
- cmd.createdIds = createdIds;
40253
- return cmd;
40254
- }
40255
-
40256
- /** What elements can be coloured by: their layer, type, specialization, and each property any of them has. */
40257
- colourKeys(templates = [...this.templates.values()]) {
40258
- const properties = new Set();
40259
- 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);
40260
- return [
40261
- ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
40262
- ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
40263
- ];
40264
- }
40265
-
40266
- /** An element's value for a colourKeys key (notes and pictures have none). */
40267
- colourValue(template, key) {
40268
- const element = this._elementOf(template.id);
40269
- if (!element) return undefined;
40270
- if (key === 'layer') return typeTitle(treeLayerOf(element));
40271
- if (key === 'type') return typeTitle(element.type ?? '');
40272
- if (key === 'specialization') return element.specialization;
40273
- if (key.startsWith('property:')) {
40274
- const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
40275
- return p?.value === undefined || p?.value === null ? undefined : String(p.value);
40276
- }
40277
- return super.colourValue(template, key);
40278
- }
40279
-
40280
- /** The kind legend: each element type on the view, in the colour of its first box. */
40281
- kindLegendEntries() {
40282
- const types = new Map();
40283
- for (const t of this.templates.values()) {
40284
- const element = this._elementOf(t.id);
40285
- if (!element || types.has(element.type)) continue;
40286
- types.set(element.type, t.fillColor);
40287
- }
40288
- return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
40289
- }
40290
-
40291
- /** A legend is notes (stickies on a canvas) on the view: one PlaceItemsCommand. */
40292
- legendCommand(entries, title) {
40293
- if (!entries.length) return null;
40294
- const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
40295
- if (!kind) return null;
40296
- const { x, y } = this.legendPosition();
40297
- const stamp = Date.now().toString(36);
40298
- const box = (i, name, fillColor) => {
40299
- const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x + 60 + i * 130, y + 20);
40300
- inst.size = { width: 120, height: 40 };
40301
- inst.appearance = { ...kind.appearance, fillColor };
40302
- return inst;
40303
- };
40304
- const boxes = [box(0, title, '#ffffff'), ...entries.map((e, i) => box(i + 1, e.label, e.fillColor))];
40305
- return new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` });
40306
- }
40307
-
40308
- /** Likely duplicates can be merged (Merge…: choose the one to keep). */
40309
- findingActions(finding) {
40310
- return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
40311
- }
40312
-
40313
- /**
40314
- * What differs among some elements, for the merge to choose: their
40315
- * documentation (when two or more have some, and it differs), each property
40316
- * with different values, and the specialization.
40317
- * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
40318
- * each choice [value, label]
40319
- */
40320
- mergeChoices(elements) {
40321
- const name = (e) => e.name || e.id;
40322
- const documented = elements.filter(e => (e.documentation ?? '').trim());
40323
- const texts = [...new Set(documented.map(e => e.documentation.trim()))];
40324
- const properties = new Map();
40325
- for (const e of elements) {
40326
- for (const p of e.properties ?? []) {
40327
- const key = p.name ?? p.key;
40328
- if (!properties.has(key)) properties.set(key, new Map());
40329
- const values = properties.get(key);
40330
- values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
40331
- }
40332
- }
40333
- const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
40334
- return {
40335
- 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}`]) : [],
40336
- properties: new Map([...properties].filter(([, values]) => values.size > 1)
40337
- .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
40338
- specializations: specializations.length > 1 ? specializations : [],
40339
- };
40340
- }
40341
-
40342
- /**
40343
- * Some elements side by side, for a merge (MergeCompareDialog's rows): their
40344
- * names, types, specializations, documentation, each property, their
40345
- * relationships (kind, direction, the element at the other end) and the
40346
- * views they are on.
40347
- */
40348
- mergeComparison(elements) {
40349
- const nameOf = (id) => this.model.getElement(id)?.name || id;
40350
- const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
40351
- const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
40352
- const lines = (e) => {
40353
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
40354
- return [
40355
- ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
40356
- ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
40357
- ].sort().join('\n');
40358
- };
40359
- const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
40360
- return [
40361
- { label: 'Name', values: elements.map(e => e.name ?? '') },
40362
- { label: 'Type', values: elements.map(e => e.type ?? '') },
40363
- ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
40364
- { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
40365
- ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
40366
- { label: 'Relationships', values: elements.map(lines) },
40367
- { label: 'Views', values: elements.map(views) },
40368
- ];
40369
- }
40370
-
40371
- /**
40372
- * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
40373
- * Their relationships always come together. When they differ in anything
40374
- * but the views they are on, they are shown side by side
40375
- * (MergeCompareDialog) to choose which is kept and, where they differ, the
40376
- * documentation (one, or all joined), each property's value, the
40377
- * specialization; OK merges, Cancel leaves them. Alike, the first is kept
40378
- * without asking. Resolves to the merge's command, or null.
40379
- */
40380
- async askMerge(elementIds) {
40381
- const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
40382
- if (elements.length < 2) return null;
40383
- const used = (e) => {
40384
- const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
40385
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
40386
- return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
40387
- };
40388
- const ids = elements.map(e => e.id);
40389
- const rows = this.mergeComparison(elements);
40390
- const compare = new MergeCompareDialog({
40391
- title: `Merge ${elements.length} elements`,
40392
- 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.',
40393
- columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
40394
- rows,
40395
- document: this.document ?? globalThis.document,
40396
- });
40397
- // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
40398
- if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
40399
- const { documentation, properties, specializations } = this.mergeChoices(elements);
40400
- const JOINED = '\u0000joined';
40401
- const propertyKeys = [...properties.keys()];
40402
- compare.fields = [
40403
- { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
40404
- ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
40405
- ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
40406
- ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
40407
- ];
40408
- const values = await compare.ask();
40409
- if (!values) return null;
40410
- const { keep } = values;
40411
- const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
40412
- return this.mergeElements(keep, ids.filter(id => id !== keep), {
40413
- ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
40414
- ...(specializations.length && { specialization: values.specialization }),
40415
- properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
40416
- });
40417
- }
40418
-
40419
- /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
40420
- mergeElements(keepId, otherIds, choices = {}) {
40421
- const cmd = this.model.mergeCommand(keepId, otherIds, choices);
40422
- this._executeCommand(cmd);
40423
- return cmd;
40424
- }
40425
-
40426
- /** The checks use this editor's ArchiMate version. */
40427
- checkerOptions() {
40428
- return { version: this.version };
40429
- }
40430
-
40431
- /** An element is shown by selecting its first occurrence on this view. */
40432
- revealFinding(finding) {
40433
- if (finding.kind !== 'element') return super.revealFinding(finding);
40434
- const instance = this.view.elementInstancesFor(finding.ids[0])[0];
40435
- if (instance && this.templates.has(instance.id)) {
40436
- this.selectTemplate(instance.id);
40437
- this.render();
40438
- }
40439
- }
40440
-
40441
- /**
40442
- * Bring the editor's templates and lines in line with the view, reusing
40443
- * the template or line of each instance still there.
40444
- */
40445
- syncFromView() {
40446
- const { view, model } = this;
40447
- const templates = new Map();
40448
- for (const inst of view.elementInstances.values()) {
40449
- if (!inst.shownIn(view) || !inst.resolve(model)) continue;
40450
- const existing = this.templates.get(inst.id);
40451
- templates.set(inst.id, existing?.instance === inst ? existing
40452
- : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
40453
- }
40454
- for (const t of templates.values()) {
40455
- t.nodeOptions = this.nodeOptions;
40456
- // Asked afresh at every sync: a host's status is live, not part of the model.
40457
- t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
40458
- }
40459
- // The view's groups (Archi groups: model folders with bounds on this view).
40460
- for (const folder of view.placedFolders) {
40461
- const existing = this.templates.get(folder.id);
40462
- templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
40463
- }
40464
- const edges = new Map();
40465
- // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
40466
- const nestedOut = (edge) => !this.nodeOptions.showNestedLines
40467
- && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
40468
- for (const inst of view.relationshipInstances.values()) {
40469
- const existing = this.edges.get(inst.id);
40470
- const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
40471
- if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
40472
- }
40473
- // Plain lines (a canvas's connections, a line from a note).
40474
- for (const line of view.viewConnections.values()) {
40475
- const existing = this.edges.get(line.id);
40476
- const edge = existing?.line === line ? existing : new ViewLineEdge(line);
40477
- if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
40478
- }
40479
- // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
40480
- if (this.nodeOptions.showDerived) {
40481
- const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
40482
- for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
40483
- const from = shown(derived.source), to = shown(derived.target);
40484
- if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
40485
- }
40486
- }
40487
- this.templates.clear();
40488
- for (const [id, t] of templates) this.templates.set(id, t);
40489
- this.edges.clear();
40490
- for (const [id, e] of edges) this.edges.set(id, e);
40491
- }
40492
-
40493
- render() {
40494
- if (this.disposed) return;
40495
- this.syncFromView();
40496
- super.render();
40497
- }
40498
-
40499
- /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
40500
- settingsMenuItems() {
40501
- const set = (patch) => {
40502
- this.nodeOptions = { ...this.nodeOptions, ...patch };
40503
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
40504
- else this.render();
40505
- };
40506
- const distinct = !!this.nodeOptions.useDistinctShapes;
40507
- const viewpoint = this.nodeOptions.viewpoint ?? 'All';
40508
- const mode = this.nodeOptions.viewpointMode ?? 'fade';
40509
- return [
40510
- {
40511
- label: `Version: ArchiMate ${this.version}`,
40512
- items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
40513
- },
40514
- { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
40515
- { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
40516
- { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
40517
- {
40518
- label: `Viewpoint: ${viewpoint}`,
40519
- items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
40520
- },
40521
- {
40522
- label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
40523
- items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
40524
- },
40525
- { separator: true },
40526
- { separator: true },
40527
- { label: 'Specializations…', action: () => this.editSpecializations() },
40528
- { label: 'Viewpoints…', action: () => this.editViewpoints() },
40529
- ...super.settingsMenuItems(),
40530
- ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
40531
- ];
40532
- }
40533
-
40534
- /**
40535
- * What the ArchiMate rules say against a relationship's line, or null
40536
- * (ArchimateValidator, for this ArchiMate version). Off when the view's
40537
- * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
40538
- */
40539
- edgeWarning(edge) {
40540
- const relationship = edge.relationship;
40541
- const mode = this.viewSettings().invalidRelationships ?? 'highlight';
40542
- if (!relationship || mode === 'off') return null;
40543
- const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
40544
- if (!from || !to) return null;
40545
- const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
40546
- return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
40547
- }
40548
-
40549
- /** Edit the specialization catalog; elements given one redraw with it. */
40550
- editSpecializations() {
40551
- return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
40552
- }
40553
-
40554
- /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
40555
- editViewpoints() {
40556
- return CustomViewpoints.dialog({
40557
- version: this.version, document: this.document ?? globalThis.document,
40558
- onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
40559
- }).open();
40560
- }
40561
-
40562
- /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
40563
- _notationChanged() {
40564
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
40565
- else this.render();
40566
- }
40567
-
40568
- /**
40569
- * The base editor's rules (a highlight), then the active filter's result
40570
- * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
40571
- */
40572
- emphasisRules() {
40573
- const { viewpoint, viewpointMode } = this.nodeOptions;
40574
- const key = `${viewpoint}|${viewpointMode}|${this.version}`;
40575
- if (this._viewpointRule?.key !== key) {
40576
- this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
40577
- }
40578
- return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
40579
- }
40580
-
40581
- /** Dim every model element but these (a filter's result); null clears it. Redraws. */
40582
- setElementFilter(elementIds) {
40583
- this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
40584
- this.render();
40585
- }
40586
-
40587
- /**
40588
- * The Element section of a shape's properties: the model element's own
40589
- * name, type, specialization, icon, documentation and typed properties (the same on every view;
40590
- * the dialog's Name above labels this occurrence only). None for a note,
40591
- * image or canvas box.
40592
- */
40593
- conceptFields(template) {
40594
- const element = this._elementOf(template.id);
40595
- if (!element) return [];
40596
- return [
40597
- { type: 'heading', label: 'Element (on every view)' },
40598
- ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
40599
- ];
40600
- }
40601
-
40602
- /** The element's changes as one EditViewItemCommand (null when nothing changed). */
40603
- conceptCommand(template, values) {
40604
- const element = this._elementOf(template.id);
40605
- return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
40606
- }
40607
-
40608
- /**
40609
- * With several shapes selected, the Elements section: their type, their
40610
- * specialization when they share a type, and one property to set on all.
40611
- * Only when every selected shape is an element.
40612
- */
40613
- manyConceptFields(templates) {
40614
- const elements = this._elementsOf(templates);
40615
- return elements ? manyElementsFields(elements, { version: this.version }) : [];
40616
- }
40617
-
40618
- /** The elements' changes as one step (null when nothing changed). */
40619
- manyConceptCommand(templates, values) {
40620
- const elements = this._elementsOf(templates);
40621
- const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
40622
- const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
40623
- return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
40624
- }
40625
-
40626
- /** The model elements the shapes show, each once; null unless every shape shows one. @private */
40627
- _elementsOf(templates) {
40628
- const elements = templates.map(t => this._elementOf(t.id));
40629
- return elements.every(Boolean) ? [...new Set(elements)] : null;
40630
- }
40631
-
40632
- /**
40633
- * The Relationship section of a line's properties: its kind (among those
40634
- * the ArchiMate rules allow between its ends, and its own), documentation
40635
- * and typed properties. None for a plain line or a derived one.
40636
- */
40637
- edgeConceptFields(edge) {
40638
- const relationship = edge.relationship;
40639
- if (!relationship || edge.editable === false) return [];
40640
- const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
40641
- return [
40642
- { type: 'heading', label: 'Relationship (on every view)' },
40643
- { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
40644
- { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
40645
- { type: 'heading', label: 'Properties' },
40646
- PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
40647
- ];
40648
- }
40649
-
40650
- /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
40651
- edgeConceptCommand(edge, values) {
40652
- return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
40653
- }
40654
-
40655
- /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
40656
- addElement(type, point) {
40657
- const name = typeTitle(type);
40658
- const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
40659
- const at = this._snapToGrid(point);
40660
- const instance = new ElementInstance(id, at.x, at.y);
40661
- this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
40662
- return instance;
40663
- }
40664
-
40665
- /**
40666
- * Show an element the model has at `point` (snapped), with its
40667
- * relationships to the elements already on the view (ShowElementCommand).
40668
- * One undo step. Null for an element the model lacks.
40669
- */
40670
- showElement(elementId, point) {
40671
- if (!this.model.getElement(elementId)) return null;
40672
- const at = this._snapToGrid(point);
40673
- const instance = new ElementInstance(elementId, at.x, at.y);
40674
- this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
40675
- this.selectTemplate(instance.id);
40676
- return instance;
40677
- }
40678
-
40679
- /**
40680
- * A picture as an image box on this view at `point` (snapped), one undo
40681
- * step, selected. Null when this kind of view has no image box.
40682
- * @param {string} dataURL - The picture
40683
- */
40684
- addImage(dataURL, point) {
40685
- if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
40686
- const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
40687
- this.render();
40688
- this.selectTemplate(box.id);
40689
- return box;
40690
- }
40691
-
40692
- /**
40693
- * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
40694
- * (snapped), as the canvas menu would. One undo step.
40695
- */
40696
- addCanvasBox(kindKey, point) {
40697
- const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
40698
- onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
40699
- }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
40700
- return item?.action();
40701
- }
40702
-
40703
- /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
40704
- allowedKinds(sourceId, targetId) {
40705
- const from = this._elementOf(sourceId), to = this._elementOf(targetId);
40706
- if (!from || !to) return [];
40707
- const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
40708
- return kinds.includes('association') ? kinds : [...kinds, 'association'];
40709
- }
40710
-
40711
- /**
40712
- * Connect two elements: the kind is asked for (`chooseKind`) among those
40713
- * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
40714
- * @returns {Command|null|Promise<Command|null>}
40715
- */
40716
- connect(sourceId, targetId) {
40717
- if (sourceId === targetId) return null;
40718
- // A box or note at either end: a plain line (the view's rule), one undo step.
40719
- if (!this.view.linesAreRelationships(sourceId, targetId)) {
40720
- if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
40721
- const line = this.view.connect(sourceId, targetId);
40722
- this.render();
40723
- return line;
40724
- }
40725
- const kinds = this.allowedKinds(sourceId, targetId);
40726
- if (!kinds.length) return null;
40727
- const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
40728
- if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
40729
- const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
40730
- return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
40731
- }
40732
-
40733
- /** @private */
40734
- _connectAs(sourceId, targetId, kind) {
40735
- const cmd = this.connectCommand(sourceId, targetId, kind);
40736
- if (cmd) this._executeCommand(cmd);
40737
- return cmd;
40738
- }
40739
-
40740
- /** The model element an instance on this canvas shows (notes and images have none). @private */
40741
- /** A box of a model element links to the element's page in a report. */
40742
- reportRef(template) {
40743
- const element = this._elementOf(template.id);
40744
- return element ? { kind: 'element', id: element.id } : null;
40745
- }
40746
-
40747
- _elementOf(instanceId) {
40748
- const inst = this.templates.get(instanceId)?.instance;
40749
- return inst ? this.model.getElement(inst.elementId) : undefined;
40750
- }
40751
-
40752
- /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
40753
- connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
40754
- const source = this.templates.get(sourceId)?.instance;
40755
- const target = this.templates.get(targetId)?.instance;
40756
- const from = this._elementOf(sourceId);
40757
- const to = this._elementOf(targetId);
40758
- // Notes and images are not model elements: they take no relationships.
40759
- if (!from || !to || !kind) return null;
40760
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
40761
- const instance = new RelationshipInstance(id, null, source.id, target.id);
40762
- return new AddRelationshipToViewCommand(this.view, instance, {
40763
- relationship: { id, source: from.id, target: to.id, kind },
40764
- label: `Add ${kind}`,
40765
- });
40766
- }
40767
-
40768
- /**
40769
- * Deleting takes a box off the view (its lines go with it, see the base's
40770
- * deleteTemplatesCommand); the model keeps its element. A group goes off the
40771
- * view and what it held stays; the model keeps its folder.
40772
- */
40773
- /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
40774
- static clipboardKind = 'archimate';
40775
-
40776
- /**
40777
- * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
40778
- * with the lines and relationships between them and the elements they show.
40779
- * Groups are not copied (they are model folders).
40780
- */
40781
- copyPayload(ids) {
40782
- const boxes = ids.filter(id => this.view.getElementInstance(id));
40783
- return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
40784
- }
40785
-
40786
- /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
40787
- pasteCommand(payload, options) {
40788
- return this.view.pasteCommand(payload, options);
40789
- }
40790
-
40791
- /** Deleting is off the view: the model keeps what it showed. */
40792
- deleteLabel(templateIds) {
40793
- return `${super.deleteLabel(templateIds)} from view`;
40794
- }
40795
-
40796
- removeTemplateCommand(templateId) {
40797
- return this.templates.get(templateId) instanceof ViewGroupTemplate
40798
- ? new RemoveGroupFromViewCommand(this.view, templateId)
40799
- : new DeleteElementCommand(this.view, templateId);
40800
- }
40801
-
40802
- /**
40803
- * What moves with a shape: for a group, the elements drawn in it (its folder's),
40804
- * 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.
40805
- */
40806
- containedIn(templateId) {
40807
- const template = this.templates.get(templateId);
40808
- const view = this.view;
40809
- if (template instanceof ViewGroupTemplate) {
40810
- const inner = [];
40811
- for (const t of this.templates.values()) {
40812
- if (t instanceof ViewGroupTemplate) {
40813
- if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
40814
- } else if (t.instance?.archi?.parent === templateId
40815
- || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
40816
- inner.push(t.id);
40817
- }
40818
- }
40819
- return inner;
40820
- }
40821
- const visualId = template?.instance?.visualId;
40822
- if (!visualId) return [];
40823
- return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
40824
- }
40825
-
40826
- // ===== Nesting =====
40827
-
40828
- /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
40829
- parentOf(templateId) {
40830
- const visualId = this.templates.get(templateId)?.instance?.nestedIn;
40831
- if (!visualId) return null;
40832
- const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
40833
- return parent?.id ?? null;
40834
- }
40835
-
40836
- /**
40837
- * An element's parts on this view: the elements it is composed of or
40838
- * aggregates (its composition and aggregation relationships' targets),
40839
- * each by its first box here.
40840
- */
40841
- partsOf(templateId) {
40842
- const element = this._elementOf(templateId);
40843
- if (!element) return [];
40844
- const parts = this.model.relationships()
40845
- .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
40846
- .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
40847
- .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
40848
- return [...new Set(parts)];
40849
- }
40850
-
40851
- /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
40852
- layoutCategory(template) {
40853
- const element = this._elementOf(template.id);
40854
- return element ? treeLayerOf(element) : 'other';
40855
- }
40856
-
40857
- /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
40858
- canHold(parentId, childId) {
40859
- const parent = this.templates.get(parentId), child = this.templates.get(childId);
40860
- if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
40861
- return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
40862
- }
40863
-
40864
- /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
40865
- nestsOnDrop(parentId, childId) {
40866
- if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
40867
- return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
40868
- }
40869
-
40870
- nestCommand(childId, parentId) {
40871
- const child = this.templates.get(childId)?.instance;
40872
- if (!child) return null;
40873
- return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
40874
- }
40875
-
40876
- /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
40877
- nested(changes) {
40878
- let chain = Promise.resolve();
40879
- for (const { childId, parentId } of changes) {
40880
- chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
40881
- }
40882
- return chain;
40883
- }
40884
-
40885
- /**
40886
- * The kinds a nesting can stand for, from parent to child, as Archi offers
40887
- * them: those of NESTING_KINDS the rules allow. None when the two already
40888
- * have such a relationship.
40889
- */
40890
- nestingKinds(parentId, childId) {
40891
- const from = this._elementOf(parentId), to = this._elementOf(childId);
40892
- if (!from || !to || from === to) return [];
40893
- if (nestingRelationships(this.model, from.id, to.id).length) return [];
40894
- const allowed = this.allowedKinds(parentId, childId);
40895
- return NESTING_KINDS.filter(k => allowed.includes(k));
40896
- }
40897
-
40898
- /**
40899
- * Ask which relationship a nesting stands for (`chooseKind`, with the
40900
- * reason 'nesting'; none chosen adds none) and add it to the model, drawn
40901
- * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
40902
- * @returns {Promise<Command|null>}
40903
- */
40904
- async offerNestingRelationship(parentId, childId) {
40905
- const kinds = this.nestingKinds(parentId, childId);
40906
- if (!kinds.length || !this.chooseKind) return null;
40907
- const from = this._elementOf(parentId), to = this._elementOf(childId);
40908
- const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
40909
- if (!kind || !kinds.includes(kind)) return null;
40910
- const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
40911
- this._executeCommand(cmd);
40912
- return cmd;
40913
- }
40914
-
40915
- /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
40916
- nestingRelationshipCommand(parentId, childId, kind) {
40917
- const from = this._elementOf(parentId), to = this._elementOf(childId);
40918
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
40919
- return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
40920
- }
40921
-
40922
- /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
40923
- deleteEdgeCommand(edgeId) {
40924
- return this.view.viewConnections.has(edgeId)
40925
- ? new RemoveLineCommand(this.view, edgeId)
40926
- : new RemoveRelationshipFromViewCommand(this.view, edgeId);
40927
- }
40648
+ /**
40649
+ * Which allowed relationship kind a new connection takes when nobody is asked:
40650
+ * the dependency and dynamic kinds people usually mean first, the structural
40651
+ * whole/part kinds last.
40652
+ */
40653
+ const KIND_PREFERENCE = Object.freeze([
40654
+ 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
40655
+ 'specialization', 'aggregation', 'composition', 'association',
40656
+ ]);
40657
+
40658
+ /**
40659
+ * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
40660
+ * view: its element instances as ArchimateElementTemplates, its relationship
40661
+ * instances as ArchimateEdges, and its own undo history. Everything the base
40662
+ * editor does (move, resize, restack, bend points, sides, styles, zoom,
40663
+ * snapping) edits the view directly; the editor's maps are rebuilt from the
40664
+ * view on every render, so undo of a view edit shows straight away.
40665
+ *
40666
+ * What stands for something else in ArchiMate is done through the view and
40667
+ * model instead of the maps:
40668
+ * - dropping a palette type adds a new model element and shows it;
40669
+ * - connecting two elements adds a relationship, its kind the first the
40670
+ * ArchiMate rules allow between them (association if none);
40671
+ * - deleting removes from the view (with the element's lines); the model
40672
+ * keeps its elements and relationships.
40673
+ * Each is one step on the view's history.
40674
+ */
40675
+ class ArchimateCanvasEditor extends DiagramEditor {
40676
+ /**
40677
+ * @param {Object} options - DiagramEditor options, plus:
40678
+ * @param {ArchimateView} options.view
40679
+ * @param {ArchimateModel} options.model
40680
+ * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
40681
+ * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
40682
+ * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
40683
+ * Asks which relationship kind a new connection is, given the kinds allowed between the two
40684
+ * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
40685
+ * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
40686
+ * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
40687
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
40688
+ * live status for an element (a component's lifecycle state), drawn on its boxes
40689
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
40690
+ * element (its transitions), at the end of a box's right-click menu
40691
+ */
40692
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
40693
+ super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
40694
+ this.view = view;
40695
+ this.model = model;
40696
+ this.nodeOptions = nodeOptions;
40697
+ this.chooseKind = chooseKind;
40698
+ this.onNodeOptionsChange = onNodeOptionsChange;
40699
+ /** Viewpoints of the user's own, offered with the standard ones. */
40700
+ this.customViewpoints = customViewpoints;
40701
+ /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
40702
+ this.settingsManager = settingsManager;
40703
+ /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
40704
+ this.templateMenu = templateMenu;
40705
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
40706
+ this.elementStatus = elementStatus;
40707
+ this.elementMenuExtras = elementMenuExtras;
40708
+ this.addDropHandler({
40709
+ accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
40710
+ drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
40711
+ });
40712
+ // An element from the model tree: shown here, with its relationships to what is already shown.
40713
+ this.addDropHandler({
40714
+ accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
40715
+ drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
40716
+ });
40717
+ // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
40718
+ if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
40719
+ // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
40720
+ this.addDropHandler({
40721
+ accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
40722
+ drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
40723
+ });
40724
+ }
40725
+
40726
+ get version() {
40727
+ return this.nodeOptions.archimateVersion ?? '4';
40728
+ }
40729
+
40730
+ /**
40731
+ * An element's own items (a view of it and what it relates to; its
40732
+ * relationships to what is shown, when some are not), a canvas box's hint,
40733
+ * and the shape's selection (or the shape and its group) as a template.
40734
+ */
40735
+ shapeMenuExtras(elementId) {
40736
+ const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
40737
+ const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
40738
+ const element = this._elementOf(elementId);
40739
+ const missing = element ? this.missingRelationshipsOf(elementId) : [];
40740
+ // Two or more elements chosen: merge them into one (choose the one kept).
40741
+ const merging = this.elementIdsOf(ids);
40742
+ return [
40743
+ ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
40744
+ ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
40745
+ ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
40746
+ ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
40747
+ ...(items.length ? [{ label: 'Save as template', items }] : []),
40748
+ ...(merging.length ? [{ label: '🗑 Delete from model…', danger: true, action: () => this.askDeleteFromModel(merging) }] : []),
40749
+ // The host's own items end the menu.
40750
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
40751
+ ];
40752
+ }
40753
+
40754
+ /** Delete elements from the model, once the dialog saying where they are used is confirmed (askDeleteFromModel). */
40755
+ askDeleteFromModel(elementIds) {
40756
+ return askDeleteFromModel(this.model, elementIds, {
40757
+ document: this.document ?? globalThis.document,
40758
+ onConfirm: (ids) => this.deleteFromModel(ids),
40759
+ });
40760
+ }
40761
+
40762
+ /** Take elements out of the model, with their relationships and every box and line of them, as one undo step. */
40763
+ deleteFromModel(elementIds) {
40764
+ const cmd = this.model.deleteFromModelCommand(elementIds);
40765
+ if (!cmd.elementIds.length) return null;
40766
+ this.clearSelection();
40767
+ this._executeCommand(cmd);
40768
+ return cmd;
40769
+ }
40770
+
40771
+ /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
40772
+ missingRelationshipsOf(instanceId) {
40773
+ const instance = this.view.getElementInstance(instanceId);
40774
+ if (!instance) return [];
40775
+ return this.view.missingRelationshipInstances(instance.elementId)
40776
+ .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
40777
+ }
40778
+
40779
+ /** Draw them all, as one undo step. */
40780
+ addMissingRelationships(instanceId) {
40781
+ const missing = this.missingRelationshipsOf(instanceId);
40782
+ if (!missing.length) return null;
40783
+ const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
40784
+ missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
40785
+ this._executeCommand(cmd);
40786
+ return cmd;
40787
+ }
40788
+
40789
+ /** 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. */
40790
+ canvasMenuItems(at) {
40791
+ const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
40792
+ const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
40793
+ // Elements from text: not on a canvas, whose boxes are not elements.
40794
+ const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
40795
+ return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
40796
+ }
40797
+
40798
+ /** The View menu, and Generate roadmap… (elements with dates on a time line). */
40799
+ viewMenuItems() {
40800
+ const repeated = this.view?.repeatedBoxes?.() ?? [];
40801
+ return [
40802
+ ...super.viewMenuItems(),
40803
+ { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
40804
+ { label: 'Generate roadmap…', action: () => this.askRoadmap() },
40805
+ ];
40806
+ }
40807
+
40808
+ /** The model's own: Find likely duplicates…. */
40809
+ modelMenuItems() {
40810
+ return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
40811
+ }
40812
+
40813
+ /**
40814
+ * Take off this view the boxes that only repeat another box of their
40815
+ * element (ArchimateView#repeatedBoxes: same label, same lines), with
40816
+ * their lines, as one undo step. Returns the command, or null with none.
40817
+ */
40818
+ removeRepeatedBoxes() {
40819
+ const ids = this.view.repeatedBoxes();
40820
+ if (!ids.length) return null;
40821
+ const cmd = this.deleteTemplatesCommand(ids);
40822
+ cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
40823
+ this._executeCommand(cmd);
40824
+ return cmd;
40825
+ }
40826
+
40827
+ /**
40828
+ * Where Find likely duplicates… can look: the whole model, the elements on
40829
+ * the view in front, the elements selected (two or more). [key, label, element ids or null].
40830
+ */
40831
+ duplicateScopes() {
40832
+ const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
40833
+ const selected = this.elementIdsOf(this.getSelectedIds());
40834
+ return [
40835
+ ['model', 'The whole model', null],
40836
+ ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
40837
+ ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
40838
+ ];
40839
+ }
40840
+
40841
+ /**
40842
+ * Find likely duplicates…: where to look (duplicateScopes), how to compare
40843
+ * (names, documentation or a property's value; exactly or by their words)
40844
+ * and what noise to leave out (duplicateSearchFields), then the groups
40845
+ * found, each with Merge… (showDuplicates).
40846
+ */
40847
+ askDuplicates() {
40848
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
40849
+ const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
40850
+ const scopes = this.duplicateScopes();
40851
+ return new FormDialog({
40852
+ title: 'Find likely duplicates',
40853
+ description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
40854
+ fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
40855
+ submitLabel: 'Find',
40856
+ idPrefix: 'duplicates-',
40857
+ document: this.document ?? globalThis.document,
40858
+ validate: validateDuplicateSearch,
40859
+ onApply: (values) => {
40860
+ const { options, scope } = duplicateSearchOptions(values);
40861
+ this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
40862
+ },
40863
+ }).open();
40864
+ }
40865
+
40866
+ /**
40867
+ * The elements that look like one thing twice (the model check's likely
40868
+ * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
40869
+ * among these element ids), each group with Merge….
40870
+ */
40871
+ showDuplicates(options = {}, { within = null } = {}) {
40872
+ const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
40873
+ const groups = find();
40874
+ new DuplicatesDialog({
40875
+ groups,
40876
+ refind: find,
40877
+ merge: async (group) => !!(await this.askMerge(group.ids)),
40878
+ onReveal: (group) => this.revealFinding(group),
40879
+ empty: 'No elements look like the same thing twice.',
40880
+ document: this.document ?? globalThis.document,
40881
+ }).open();
40882
+ return groups;
40883
+ }
40884
+
40885
+ /** The model elements of some boxes, each once (a box that is not an element is left out). */
40886
+ elementIdsOf(instanceIds) {
40887
+ return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
40888
+ }
40889
+
40890
+ /**
40891
+ * Generate roadmap…: which elements (the selection's, or every element in
40892
+ * the model with both dates), the properties their dates are in, and how
40893
+ * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
40894
+ */
40895
+ askRoadmap() {
40896
+ const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
40897
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
40898
+ return new FormDialog({
40899
+ title: 'Generate roadmap',
40900
+ description: 'A view with each element a bar from its start date to its end date, under the months.',
40901
+ fields: [
40902
+ { 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']] },
40903
+ { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
40904
+ { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
40905
+ { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
40906
+ { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
40907
+ ],
40908
+ submitLabel: 'Generate',
40909
+ idPrefix: 'roadmap-',
40910
+ document: this.document ?? globalThis.document,
40911
+ validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
40912
+ onApply: (v) => this.generateRoadmap(v),
40913
+ }).open();
40914
+ }
40915
+
40916
+ /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
40917
+ roadmapFor({ from, start, end }) {
40918
+ const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
40919
+ const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
40920
+ const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
40921
+ return { ids, view: any ? true : null };
40922
+ }
40923
+
40924
+ /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
40925
+ generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
40926
+ const { ids } = this.roadmapFor({ from, start, end });
40927
+ return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
40928
+ }
40929
+
40930
+ /** An element's box keeps room for its type badge, and Archi's height. */
40931
+ textFit(template) {
40932
+ return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
40933
+ }
40934
+
40935
+ /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
40936
+ shapeTypes() {
40937
+ const viewpoint = this.nodeOptions.viewpoint;
40938
+ const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
40939
+ return ArchimateNotation.elementTypeNames(this.version)
40940
+ .filter(t => !allowed || allowed.includes(t))
40941
+ .map(t => [t, typeTitle(t)]);
40942
+ }
40943
+
40944
+ /**
40945
+ * New model elements from text, each with its box here: one undo step.
40946
+ * A documentation column becomes the element's documentation.
40947
+ */
40948
+ createShapesCommand(items) {
40949
+ const ids = this.model.elements().map(e => e.id);
40950
+ const steps = [], createdIds = [];
40951
+ for (const item of items) {
40952
+ const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
40953
+ ids.push(id);
40954
+ const instance = new ElementInstance(id, item.x, item.y);
40955
+ createdIds.push(instance.id);
40956
+ steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
40957
+ }
40958
+ const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
40959
+ cmd.createdIds = createdIds;
40960
+ return cmd;
40961
+ }
40962
+
40963
+ /**
40964
+ * What elements can be coloured by: their layer, type, specialization, each
40965
+ * property any of them has, the change deliverables and work packages make
40966
+ * to them (when the model has any), and a formula over their properties.
40967
+ */
40968
+ colourKeys(templates = [...this.templates.values()]) {
40969
+ const properties = new Set();
40970
+ 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);
40971
+ return [
40972
+ ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
40973
+ ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
40974
+ ...(this.model && hasChangeMakers(this.model) ? [['change', 'Change (create, update, delete)']] : []),
40975
+ ...(properties.size ? [['expression', 'A formula over properties…']] : []),
40976
+ ];
40977
+ }
40978
+
40979
+ /** By change, a relationship to a deliverable or work package is drawn in the colour of the change it says. */
40980
+ colourEdgeValue(edge, key) {
40981
+ if (key !== 'change' || !edge.relationship) return super.colourEdgeValue(edge, key);
40982
+ return relationshipChange(this.model, edge.relationship) ?? undefined;
40983
+ }
40984
+
40985
+ /** A formula's key (`expression:<formula>`) is called by its formula. */
40986
+ colourKeyLabel(key) {
40987
+ return key.startsWith('expression:') ? key.slice(11) : super.colourKeyLabel(key);
40988
+ }
40989
+
40990
+ /** The change heat map's own colours; a formula's results, light to dark in order; else the palette. */
40991
+ colourDefaults(key, values) {
40992
+ if (key === 'change') return values.map(v => CHANGE_COLOURS[v]);
40993
+ if (key.startsWith('expression:')) return sequentialColours(values.length);
40994
+ return super.colourDefaults(key, values);
40995
+ }
40996
+
40997
+ /**
40998
+ * A formula's values: each element's result (expressionValues), as its own
40999
+ * value when there are few, else in bands, smallest first (resultBands).
41000
+ */
41001
+ colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
41002
+ if (!key.startsWith('expression:')) return super.colourValues(key, templates);
41003
+ const boxes = templates.map(t => [t, this._elementOf(t.id)]).filter(([, e]) => e);
41004
+ const results = expressionValues([...new Set(boxes.map(([, e]) => e))], key.slice(11));
41005
+ const { labels, labelOf } = resultBands(results.values());
41006
+ const values = new Map(labels.map(l => [l, []]));
41007
+ for (const [t, e] of boxes) if (results.has(e.id)) values.get(labelOf(results.get(e.id))).push(t);
41008
+ return values;
41009
+ }
41010
+
41011
+ /** A formula over properties asks for the formula first. @protected */
41012
+ askColours(key) {
41013
+ if (key !== 'expression') return super.askColours(key);
41014
+ const elements = [...new Set([...this.templates.values()].map(t => this._elementOf(t.id)).filter(Boolean))];
41015
+ const names = [...propertyParams(elements)].map(([id, name]) => (id === name ? id : `${id} (${name})`));
41016
+ new FormDialog({
41017
+ title: 'Colour by a formula',
41018
+ description: `A number from each element's properties, e.g. Cost * Risk, or Cost > 10000 ? 1 : 0. Properties: ${names.join(', ')}. `
41019
+ + `Arithmetic, comparisons, a ? b : c, and ${Object.keys(KERNEL_FUNCTIONS).join(', ')}. `
41020
+ + 'An element without a number for a property the formula uses is left as it is.',
41021
+ fields: [{ key: 'formula', label: 'Formula', type: 'text', value: this._lastFormula ?? '' }],
41022
+ submitLabel: 'Next',
41023
+ idPrefix: 'colour-formula-',
41024
+ document: this.document ?? globalThis.document,
41025
+ validate: ({ formula }) => {
41026
+ try { expressionValues(elements, formula); return null; } catch (e) { return e.message; }
41027
+ },
41028
+ onApply: ({ formula }) => {
41029
+ this._lastFormula = formula;
41030
+ super.askColours(`expression:${formula}`);
41031
+ },
41032
+ }).open();
41033
+ }
41034
+
41035
+ /** An element's value for a colourKeys key (notes and pictures have none). */
41036
+ colourValue(template, key) {
41037
+ const element = this._elementOf(template.id);
41038
+ if (!element) return undefined;
41039
+ if (key === 'layer') return typeTitle(treeLayerOf(element));
41040
+ if (key === 'type') return typeTitle(element.type ?? '');
41041
+ if (key === 'specialization') return element.specialization;
41042
+ if (key === 'change') return elementChange(this.model, element.id) ?? undefined;
41043
+ if (key.startsWith('property:')) {
41044
+ const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
41045
+ return p?.value === undefined || p?.value === null ? undefined : String(p.value);
41046
+ }
41047
+ return super.colourValue(template, key);
41048
+ }
41049
+
41050
+ /** The kind legend: each element type on the view, in the colour of its first box. */
41051
+ kindLegendEntries() {
41052
+ const types = new Map();
41053
+ for (const t of this.templates.values()) {
41054
+ const element = this._elementOf(t.id);
41055
+ if (!element || types.has(element.type)) continue;
41056
+ types.set(element.type, t.fillColor);
41057
+ }
41058
+ return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
41059
+ }
41060
+
41061
+ /**
41062
+ * A legend is notes (stickies on a canvas) on the view: a frame holding a
41063
+ * note per entry (DiagramEditor.legendLayout), grouped, as one undo step.
41064
+ * Placed in order, so the frame stacks behind its entries.
41065
+ */
41066
+ legendCommand(entries, title) {
41067
+ if (!entries.length) return null;
41068
+ const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
41069
+ if (!kind) return null;
41070
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
41071
+ const stamp = Date.now().toString(36);
41072
+ const box = (i, name, { x, y, width, height }, appearance) => {
41073
+ const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x, y);
41074
+ inst.size = { width, height };
41075
+ inst.appearance = { ...kind.appearance, ...appearance };
41076
+ return inst;
41077
+ };
41078
+ const boxes = [
41079
+ box(0, title, frame, { fillColor: '#ffffff', ...DiagramEditor.LEGEND_TITLE_STYLE }),
41080
+ ...entries.map((e, i) => box(i + 1, e.label, items[i], { fillColor: e.fillColor })),
41081
+ ];
41082
+ return new CompositeCommand(`Add legend: ${title}`, [
41083
+ new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` }),
41084
+ new GroupCommand(this.arrangement, boxes.map(b => b.id), { name: title }),
41085
+ ]);
41086
+ }
41087
+
41088
+ /** Likely duplicates can be merged (Merge…: choose the one to keep). */
41089
+ findingActions(finding) {
41090
+ return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
41091
+ }
41092
+
41093
+ /**
41094
+ * What differs among some elements, for the merge to choose: their
41095
+ * documentation (when two or more have some, and it differs), each property
41096
+ * with different values, and the specialization.
41097
+ * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
41098
+ * each choice [value, label]
41099
+ */
41100
+ mergeChoices(elements) {
41101
+ const name = (e) => e.name || e.id;
41102
+ const documented = elements.filter(e => (e.documentation ?? '').trim());
41103
+ const texts = [...new Set(documented.map(e => e.documentation.trim()))];
41104
+ const properties = new Map();
41105
+ for (const e of elements) {
41106
+ for (const p of e.properties ?? []) {
41107
+ const key = p.name ?? p.key;
41108
+ if (!properties.has(key)) properties.set(key, new Map());
41109
+ const values = properties.get(key);
41110
+ values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
41111
+ }
41112
+ }
41113
+ const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
41114
+ return {
41115
+ 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}`]) : [],
41116
+ properties: new Map([...properties].filter(([, values]) => values.size > 1)
41117
+ .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
41118
+ specializations: specializations.length > 1 ? specializations : [],
41119
+ };
41120
+ }
41121
+
41122
+ /**
41123
+ * Some elements side by side, for a merge (MergeCompareDialog's rows): their
41124
+ * names, types, specializations, documentation, each property, their
41125
+ * relationships (kind, direction, the element at the other end) and the
41126
+ * views they are on.
41127
+ */
41128
+ mergeComparison(elements) {
41129
+ const nameOf = (id) => this.model.getElement(id)?.name || id;
41130
+ const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
41131
+ const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
41132
+ const lines = (e) => {
41133
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
41134
+ return [
41135
+ ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
41136
+ ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
41137
+ ].sort().join('\n');
41138
+ };
41139
+ const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
41140
+ return [
41141
+ { label: 'Name', values: elements.map(e => e.name ?? '') },
41142
+ { label: 'Type', values: elements.map(e => e.type ?? '') },
41143
+ ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
41144
+ { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
41145
+ ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
41146
+ { label: 'Relationships', values: elements.map(lines) },
41147
+ { label: 'Views', values: elements.map(views) },
41148
+ ];
41149
+ }
41150
+
41151
+ /**
41152
+ * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
41153
+ * Their relationships always come together. When they differ in anything
41154
+ * but the views they are on, they are shown side by side
41155
+ * (MergeCompareDialog) to choose which is kept and, where they differ, the
41156
+ * documentation (one, or all joined), each property's value, the
41157
+ * specialization; OK merges, Cancel leaves them. Alike, the first is kept
41158
+ * without asking. Resolves to the merge's command, or null.
41159
+ */
41160
+ async askMerge(elementIds) {
41161
+ const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
41162
+ if (elements.length < 2) return null;
41163
+ const used = (e) => {
41164
+ const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
41165
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
41166
+ return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
41167
+ };
41168
+ const ids = elements.map(e => e.id);
41169
+ const rows = this.mergeComparison(elements);
41170
+ const compare = new MergeCompareDialog({
41171
+ title: `Merge ${elements.length} elements`,
41172
+ 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.',
41173
+ columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
41174
+ rows,
41175
+ document: this.document ?? globalThis.document,
41176
+ });
41177
+ // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
41178
+ if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
41179
+ const { documentation, properties, specializations } = this.mergeChoices(elements);
41180
+ const JOINED = '\u0000joined';
41181
+ const propertyKeys = [...properties.keys()];
41182
+ compare.fields = [
41183
+ { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
41184
+ ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
41185
+ ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
41186
+ ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
41187
+ ];
41188
+ const values = await compare.ask();
41189
+ if (!values) return null;
41190
+ const { keep } = values;
41191
+ const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
41192
+ return this.mergeElements(keep, ids.filter(id => id !== keep), {
41193
+ ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
41194
+ ...(specializations.length && { specialization: values.specialization }),
41195
+ properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
41196
+ });
41197
+ }
41198
+
41199
+ /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
41200
+ mergeElements(keepId, otherIds, choices = {}) {
41201
+ const cmd = this.model.mergeCommand(keepId, otherIds, choices);
41202
+ this._executeCommand(cmd);
41203
+ return cmd;
41204
+ }
41205
+
41206
+ /** The checks use this editor's ArchiMate version. */
41207
+ checkerOptions() {
41208
+ return { version: this.version };
41209
+ }
41210
+
41211
+ /** An element is shown by selecting its first occurrence on this view. */
41212
+ revealFinding(finding) {
41213
+ if (finding.kind !== 'element') return super.revealFinding(finding);
41214
+ const instance = this.view.elementInstancesFor(finding.ids[0])[0];
41215
+ if (instance && this.templates.has(instance.id)) {
41216
+ this.selectTemplate(instance.id);
41217
+ this.render();
41218
+ }
41219
+ }
41220
+
41221
+ /**
41222
+ * Bring the editor's templates and lines in line with the view, reusing
41223
+ * the template or line of each instance still there.
41224
+ */
41225
+ syncFromView() {
41226
+ const { view, model } = this;
41227
+ const templates = new Map();
41228
+ for (const inst of view.elementInstances.values()) {
41229
+ if (!inst.shownIn(view) || !inst.resolve(model)) continue;
41230
+ const existing = this.templates.get(inst.id);
41231
+ templates.set(inst.id, existing?.instance === inst ? existing
41232
+ : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
41233
+ }
41234
+ for (const t of templates.values()) {
41235
+ t.nodeOptions = this.nodeOptions;
41236
+ // Asked afresh at every sync: a host's status is live, not part of the model.
41237
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
41238
+ }
41239
+ // The view's groups (Archi groups: model folders with bounds on this view).
41240
+ for (const folder of view.placedFolders) {
41241
+ const existing = this.templates.get(folder.id);
41242
+ templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
41243
+ }
41244
+ const edges = new Map();
41245
+ // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
41246
+ const nestedOut = (edge) => !this.nodeOptions.showNestedLines
41247
+ && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
41248
+ for (const inst of view.relationshipInstances.values()) {
41249
+ const existing = this.edges.get(inst.id);
41250
+ const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
41251
+ if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
41252
+ }
41253
+ // Plain lines (a canvas's connections, a line from a note).
41254
+ for (const line of view.viewConnections.values()) {
41255
+ const existing = this.edges.get(line.id);
41256
+ const edge = existing?.line === line ? existing : new ViewLineEdge(line);
41257
+ if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
41258
+ }
41259
+ // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
41260
+ if (this.nodeOptions.showDerived) {
41261
+ const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
41262
+ for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
41263
+ const from = shown(derived.source), to = shown(derived.target);
41264
+ if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
41265
+ }
41266
+ }
41267
+ this.templates.clear();
41268
+ for (const [id, t] of templates) this.templates.set(id, t);
41269
+ this.edges.clear();
41270
+ for (const [id, e] of edges) this.edges.set(id, e);
41271
+ }
41272
+
41273
+ render() {
41274
+ if (this.disposed) return;
41275
+ this.syncFromView();
41276
+ super.render();
41277
+ }
41278
+
41279
+ /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
41280
+ settingsMenuItems() {
41281
+ const set = (patch) => {
41282
+ this.nodeOptions = { ...this.nodeOptions, ...patch };
41283
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
41284
+ else this.render();
41285
+ };
41286
+ const distinct = !!this.nodeOptions.useDistinctShapes;
41287
+ const viewpoint = this.nodeOptions.viewpoint ?? 'All';
41288
+ const mode = this.nodeOptions.viewpointMode ?? 'fade';
41289
+ return [
41290
+ {
41291
+ label: `Version: ArchiMate ${this.version}`,
41292
+ items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
41293
+ },
41294
+ { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
41295
+ { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
41296
+ { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
41297
+ {
41298
+ label: `Viewpoint: ${viewpoint}`,
41299
+ items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
41300
+ },
41301
+ {
41302
+ label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
41303
+ items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
41304
+ },
41305
+ { separator: true },
41306
+ { separator: true },
41307
+ { label: 'Specializations…', action: () => this.editSpecializations() },
41308
+ { label: 'Viewpoints…', action: () => this.editViewpoints() },
41309
+ ...super.settingsMenuItems(),
41310
+ ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
41311
+ ];
41312
+ }
41313
+
41314
+ /**
41315
+ * What the ArchiMate rules say against a relationship's line, or null
41316
+ * (ArchimateValidator, for this ArchiMate version). Off when the view's
41317
+ * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
41318
+ */
41319
+ edgeWarning(edge) {
41320
+ const relationship = edge.relationship;
41321
+ const mode = this.viewSettings().invalidRelationships ?? 'highlight';
41322
+ if (!relationship || mode === 'off') return null;
41323
+ const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
41324
+ if (!from || !to) return null;
41325
+ const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
41326
+ return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
41327
+ }
41328
+
41329
+ /** Edit the specialization catalog; elements given one redraw with it. */
41330
+ editSpecializations() {
41331
+ return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
41332
+ }
41333
+
41334
+ /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
41335
+ editViewpoints() {
41336
+ return CustomViewpoints.dialog({
41337
+ version: this.version, document: this.document ?? globalThis.document,
41338
+ onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
41339
+ }).open();
41340
+ }
41341
+
41342
+ /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
41343
+ _notationChanged() {
41344
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
41345
+ else this.render();
41346
+ }
41347
+
41348
+ /**
41349
+ * The base editor's rules (a highlight), then the active filter's result
41350
+ * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
41351
+ */
41352
+ emphasisRules() {
41353
+ const { viewpoint, viewpointMode } = this.nodeOptions;
41354
+ const key = `${viewpoint}|${viewpointMode}|${this.version}`;
41355
+ if (this._viewpointRule?.key !== key) {
41356
+ this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
41357
+ }
41358
+ return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
41359
+ }
41360
+
41361
+ /** Dim every model element but these (a filter's result); null clears it. Redraws. */
41362
+ setElementFilter(elementIds) {
41363
+ this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
41364
+ this.render();
41365
+ }
41366
+
41367
+ /**
41368
+ * The Element section of a shape's properties: the model element's own
41369
+ * name, type, specialization, icon, documentation and typed properties (the same on every view;
41370
+ * the dialog's Name above labels this occurrence only). None for a note,
41371
+ * image or canvas box.
41372
+ */
41373
+ conceptFields(template) {
41374
+ const element = this._elementOf(template.id);
41375
+ if (!element) return [];
41376
+ return [
41377
+ { type: 'heading', label: 'Element (on every view)' },
41378
+ ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
41379
+ ];
41380
+ }
41381
+
41382
+ /** The element's changes as one EditViewItemCommand (null when nothing changed). */
41383
+ conceptCommand(template, values) {
41384
+ const element = this._elementOf(template.id);
41385
+ return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
41386
+ }
41387
+
41388
+ /**
41389
+ * With several shapes selected, the Elements section: their type, their
41390
+ * specialization when they share a type, and one property to set on all.
41391
+ * Only when every selected shape is an element.
41392
+ */
41393
+ manyConceptFields(templates) {
41394
+ const elements = this._elementsOf(templates);
41395
+ return elements ? manyElementsFields(elements, { version: this.version }) : [];
41396
+ }
41397
+
41398
+ /** The elements' changes as one step (null when nothing changed). */
41399
+ manyConceptCommand(templates, values) {
41400
+ const elements = this._elementsOf(templates);
41401
+ const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
41402
+ const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
41403
+ return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
41404
+ }
41405
+
41406
+ /** The model elements the shapes show, each once; null unless every shape shows one. @private */
41407
+ _elementsOf(templates) {
41408
+ const elements = templates.map(t => this._elementOf(t.id));
41409
+ return elements.every(Boolean) ? [...new Set(elements)] : null;
41410
+ }
41411
+
41412
+ /**
41413
+ * The Relationship section of a line's properties: its kind (among those
41414
+ * the ArchiMate rules allow between its ends, and its own), documentation
41415
+ * and typed properties. None for a plain line or a derived one.
41416
+ */
41417
+ edgeConceptFields(edge) {
41418
+ const relationship = edge.relationship;
41419
+ if (!relationship || edge.editable === false) return [];
41420
+ const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
41421
+ return [
41422
+ { type: 'heading', label: 'Relationship (on every view)' },
41423
+ { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
41424
+ { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
41425
+ { type: 'heading', label: 'Properties' },
41426
+ PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
41427
+ ];
41428
+ }
41429
+
41430
+ /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
41431
+ edgeConceptCommand(edge, values) {
41432
+ return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
41433
+ }
41434
+
41435
+ /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
41436
+ addElement(type, point) {
41437
+ const name = typeTitle(type);
41438
+ const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
41439
+ const at = this._snapToGrid(point);
41440
+ const instance = new ElementInstance(id, at.x, at.y);
41441
+ this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
41442
+ return instance;
41443
+ }
41444
+
41445
+ /**
41446
+ * Show an element the model has at `point` (snapped), with its
41447
+ * relationships to the elements already on the view (ShowElementCommand).
41448
+ * One undo step. Null for an element the model lacks.
41449
+ */
41450
+ showElement(elementId, point) {
41451
+ if (!this.model.getElement(elementId)) return null;
41452
+ const at = this._snapToGrid(point);
41453
+ const instance = new ElementInstance(elementId, at.x, at.y);
41454
+ this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
41455
+ this.selectTemplate(instance.id);
41456
+ return instance;
41457
+ }
41458
+
41459
+ /**
41460
+ * A picture as an image box on this view at `point` (snapped), one undo
41461
+ * step, selected. Null when this kind of view has no image box.
41462
+ * @param {string} dataURL - The picture
41463
+ */
41464
+ addImage(dataURL, point) {
41465
+ if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
41466
+ const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
41467
+ this.render();
41468
+ this.selectTemplate(box.id);
41469
+ return box;
41470
+ }
41471
+
41472
+ /**
41473
+ * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
41474
+ * (snapped), as the canvas menu would. One undo step.
41475
+ */
41476
+ addCanvasBox(kindKey, point) {
41477
+ const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
41478
+ onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
41479
+ }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
41480
+ return item?.action();
41481
+ }
41482
+
41483
+ /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
41484
+ allowedKinds(sourceId, targetId) {
41485
+ const from = this._elementOf(sourceId), to = this._elementOf(targetId);
41486
+ if (!from || !to) return [];
41487
+ const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
41488
+ return kinds.includes('association') ? kinds : [...kinds, 'association'];
41489
+ }
41490
+
41491
+ /**
41492
+ * Connect two elements: the kind is asked for (`chooseKind`) among those
41493
+ * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
41494
+ * @returns {Command|null|Promise<Command|null>}
41495
+ */
41496
+ connect(sourceId, targetId) {
41497
+ if (sourceId === targetId) return null;
41498
+ // A box or note at either end: a plain line (the view's rule), one undo step.
41499
+ if (!this.view.linesAreRelationships(sourceId, targetId)) {
41500
+ if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
41501
+ const line = this.view.connect(sourceId, targetId);
41502
+ this.render();
41503
+ return line;
41504
+ }
41505
+ const kinds = this.allowedKinds(sourceId, targetId);
41506
+ if (!kinds.length) return null;
41507
+ const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
41508
+ if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
41509
+ const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
41510
+ return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
41511
+ }
41512
+
41513
+ /** @private */
41514
+ _connectAs(sourceId, targetId, kind) {
41515
+ const cmd = this.connectCommand(sourceId, targetId, kind);
41516
+ if (cmd) this._executeCommand(cmd);
41517
+ return cmd;
41518
+ }
41519
+
41520
+ /** The model element an instance on this canvas shows (notes and images have none). @private */
41521
+ /** A box of a model element links to the element's page in a report. */
41522
+ reportRef(template) {
41523
+ const element = this._elementOf(template.id);
41524
+ return element ? { kind: 'element', id: element.id } : null;
41525
+ }
41526
+
41527
+ _elementOf(instanceId) {
41528
+ const inst = this.templates.get(instanceId)?.instance;
41529
+ return inst ? this.model.getElement(inst.elementId) : undefined;
41530
+ }
41531
+
41532
+ /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
41533
+ connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
41534
+ const source = this.templates.get(sourceId)?.instance;
41535
+ const target = this.templates.get(targetId)?.instance;
41536
+ const from = this._elementOf(sourceId);
41537
+ const to = this._elementOf(targetId);
41538
+ // Notes and images are not model elements: they take no relationships.
41539
+ if (!from || !to || !kind) return null;
41540
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
41541
+ const instance = new RelationshipInstance(id, null, source.id, target.id);
41542
+ return new AddRelationshipToViewCommand(this.view, instance, {
41543
+ relationship: { id, source: from.id, target: to.id, kind },
41544
+ label: `Add ${kind}`,
41545
+ });
41546
+ }
41547
+
41548
+ /**
41549
+ * Deleting takes a box off the view (its lines go with it, see the base's
41550
+ * deleteTemplatesCommand); the model keeps its element. A group goes off the
41551
+ * view and what it held stays; the model keeps its folder.
41552
+ */
41553
+ /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
41554
+ static clipboardKind = 'archimate';
41555
+
41556
+ /**
41557
+ * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
41558
+ * with the lines and relationships between them and the elements they show.
41559
+ * Groups are not copied (they are model folders).
41560
+ */
41561
+ copyPayload(ids) {
41562
+ const boxes = ids.filter(id => this.view.getElementInstance(id));
41563
+ return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
41564
+ }
41565
+
41566
+ /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
41567
+ pasteCommand(payload, options) {
41568
+ return this.view.pasteCommand(payload, options);
41569
+ }
41570
+
41571
+ /** Deleting is off the view: the model keeps what it showed. */
41572
+ deleteLabel(templateIds) {
41573
+ return `${super.deleteLabel(templateIds)} from view`;
41574
+ }
41575
+
41576
+ removeTemplateCommand(templateId) {
41577
+ return this.templates.get(templateId) instanceof ViewGroupTemplate
41578
+ ? new RemoveGroupFromViewCommand(this.view, templateId)
41579
+ : new DeleteElementCommand(this.view, templateId);
41580
+ }
41581
+
41582
+ /**
41583
+ * What moves with a shape: for a group, the elements drawn in it (its folder's),
41584
+ * 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.
41585
+ */
41586
+ containedIn(templateId) {
41587
+ const template = this.templates.get(templateId);
41588
+ const view = this.view;
41589
+ if (template instanceof ViewGroupTemplate) {
41590
+ const inner = [];
41591
+ for (const t of this.templates.values()) {
41592
+ if (t instanceof ViewGroupTemplate) {
41593
+ if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
41594
+ } else if (t.instance?.archi?.parent === templateId
41595
+ || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
41596
+ inner.push(t.id);
41597
+ }
41598
+ }
41599
+ return inner;
41600
+ }
41601
+ const visualId = template?.instance?.visualId;
41602
+ if (!visualId) return [];
41603
+ return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
41604
+ }
41605
+
41606
+ // ===== Nesting =====
41607
+
41608
+ /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
41609
+ parentOf(templateId) {
41610
+ const visualId = this.templates.get(templateId)?.instance?.nestedIn;
41611
+ if (!visualId) return null;
41612
+ const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
41613
+ return parent?.id ?? null;
41614
+ }
41615
+
41616
+ /**
41617
+ * An element's parts on this view: the elements it is composed of or
41618
+ * aggregates (its composition and aggregation relationships' targets),
41619
+ * each by its first box here.
41620
+ */
41621
+ partsOf(templateId) {
41622
+ const element = this._elementOf(templateId);
41623
+ if (!element) return [];
41624
+ const parts = this.model.relationships()
41625
+ .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
41626
+ .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
41627
+ .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
41628
+ return [...new Set(parts)];
41629
+ }
41630
+
41631
+ /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
41632
+ layoutCategory(template) {
41633
+ const element = this._elementOf(template.id);
41634
+ return element ? treeLayerOf(element) : 'other';
41635
+ }
41636
+
41637
+ /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
41638
+ canHold(parentId, childId) {
41639
+ const parent = this.templates.get(parentId), child = this.templates.get(childId);
41640
+ if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
41641
+ return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
41642
+ }
41643
+
41644
+ /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
41645
+ nestsOnDrop(parentId, childId) {
41646
+ if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
41647
+ return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
41648
+ }
41649
+
41650
+ nestCommand(childId, parentId) {
41651
+ const child = this.templates.get(childId)?.instance;
41652
+ if (!child) return null;
41653
+ return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
41654
+ }
41655
+
41656
+ /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
41657
+ nested(changes) {
41658
+ let chain = Promise.resolve();
41659
+ for (const { childId, parentId } of changes) {
41660
+ chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
41661
+ }
41662
+ return chain;
41663
+ }
41664
+
41665
+ /**
41666
+ * The kinds a nesting can stand for, from parent to child, as Archi offers
41667
+ * them: those of NESTING_KINDS the rules allow. None when the two already
41668
+ * have such a relationship.
41669
+ */
41670
+ nestingKinds(parentId, childId) {
41671
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
41672
+ if (!from || !to || from === to) return [];
41673
+ if (nestingRelationships(this.model, from.id, to.id).length) return [];
41674
+ const allowed = this.allowedKinds(parentId, childId);
41675
+ return NESTING_KINDS.filter(k => allowed.includes(k));
41676
+ }
41677
+
41678
+ /**
41679
+ * Ask which relationship a nesting stands for (`chooseKind`, with the
41680
+ * reason 'nesting'; none chosen adds none) and add it to the model, drawn
41681
+ * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
41682
+ * @returns {Promise<Command|null>}
41683
+ */
41684
+ async offerNestingRelationship(parentId, childId) {
41685
+ const kinds = this.nestingKinds(parentId, childId);
41686
+ if (!kinds.length || !this.chooseKind) return null;
41687
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
41688
+ const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
41689
+ if (!kind || !kinds.includes(kind)) return null;
41690
+ const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
41691
+ this._executeCommand(cmd);
41692
+ return cmd;
41693
+ }
41694
+
41695
+ /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
41696
+ nestingRelationshipCommand(parentId, childId, kind) {
41697
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
41698
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
41699
+ return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
41700
+ }
41701
+
41702
+ /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
41703
+ deleteEdgeCommand(edgeId) {
41704
+ return this.view.viewConnections.has(edgeId)
41705
+ ? new RemoveLineCommand(this.view, edgeId)
41706
+ : new RemoveRelationshipFromViewCommand(this.view, edgeId);
41707
+ }
40928
41708
  }
40929
41709
 
40930
41710
  /**
@@ -41026,6 +41806,52 @@
41026
41806
  return groups.flatMap((group, i) => (i ? [{ separator: true }, ...group] : group));
41027
41807
  }
41028
41808
 
41809
+ /**
41810
+ * The part of pasted text that is a model in `format`: the text as it is when
41811
+ * the format recognizes it; else, as an assistant or a page would wrap it, a
41812
+ * fenced block (```json … ```), or the text from its first { (or [) to its
41813
+ * last } (or ]). Null when none is.
41814
+ * @param {ModelFileFormat} format - one that reads text
41815
+ * @param {string} text
41816
+ * @returns {string|null}
41817
+ */
41818
+ function findPayload(format, text) {
41819
+ const candidates = [String(text ?? '').trim()];
41820
+ for (const m of candidates[0].matchAll(/```[\w-]*[ \t]*\r?\n([\s\S]*?)```/g)) candidates.push(m[1].trim());
41821
+ for (const [open, close] of [['{', '}'], ['[', ']']]) {
41822
+ const from = candidates[0].indexOf(open), to = candidates[0].lastIndexOf(close);
41823
+ if (from >= 0 && to > from) candidates.push(candidates[0].slice(from, to + 1));
41824
+ }
41825
+ return candidates.find(c => c && format.recognizes(c)) ?? null;
41826
+ }
41827
+
41828
+ /**
41829
+ * Import a model from pasted text rather than a file: a dialog with a text
41830
+ * box; what is pasted may be wrapped in other text (findPayload). The new
41831
+ * model goes to `onImport`; text with no model in it is refused in the dialog.
41832
+ * @param {ModelFileFormat} format - one that reads text
41833
+ * @returns {FormDialog}
41834
+ */
41835
+ function askPasteImport(format, { onImport, document: doc = globalThis.document } = {}) {
41836
+ let model = null;
41837
+ const dialog = new FormDialog({
41838
+ title: `Paste ${format.label}`,
41839
+ description: `Paste the ${format.label} text: a file's contents, or an answer or page that has it in it (in a \`\`\` block, say).`,
41840
+ fields: [{ key: 'text', label: 'Text', type: 'textarea', rows: 14, value: '' }],
41841
+ submitLabel: 'Import',
41842
+ idPrefix: 'paste-import-',
41843
+ document: doc,
41844
+ validate: ({ text }) => {
41845
+ const payload = findPayload(format, text);
41846
+ if (!payload) return `No ${format.label} found in what is pasted`;
41847
+ try { model = format.import(payload); return null; } catch (err) { return `It could not be read: ${err.message}`; }
41848
+ },
41849
+ onApply: () => onImport(model),
41850
+ });
41851
+ dialog.open();
41852
+ return dialog;
41853
+ }
41854
+
41029
41855
  /**
41030
41856
  * Ask for a file and import it in `format`; resolves to the new model, or
41031
41857
  * null when no file was chosen or it could not be read (reported).
@@ -45826,9 +46652,10 @@
45826
46652
  * @param {(element) => void} host.editProperties
45827
46653
  * @param {(view, instanceId) => void} host.showIn - Open `view` with that box selected
45828
46654
  * @param {(element) => void} [host.addToView] - Show it on `view` (offered when it is not there)
46655
+ * @param {(element) => void} [host.deleteFromModel] - Delete it from the model, after saying where it is used
45829
46656
  * @returns {Array<Object>} menu items
45830
46657
  */
45831
- function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null }) {
46658
+ function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null, deleteFromModel = null }) {
45832
46659
  const placed = model.views()
45833
46660
  .map(v => ({ view: v, instance: v.elementInstancesFor?.(element.id)?.[0] }))
45834
46661
  .filter(({ instance }) => instance);
@@ -45843,6 +46670,7 @@
45843
46670
  : { label: 'Show in View (on none)', disabled: true, action: () => {} },
45844
46671
  ...(addToView && view && !onThisView ? [{ label: `Add to "${view.name || 'this view'}"`, action: () => addToView(element) }] : []),
45845
46672
  { label: 'Generate View For This', action: () => model.generateView([element.id]) },
46673
+ ...(deleteFromModel ? [{ separator: true }, { label: '🗑 Delete from model…', danger: true, action: () => deleteFromModel(element) }] : []),
45846
46674
  ];
45847
46675
  }
45848
46676
 
@@ -55256,6 +56084,8 @@ ${theirs}` : theirs;
55256
56084
  * @param {string} [options.title]
55257
56085
  * @param {number} [options.chartHeight] a chart's height (360)
55258
56086
  * @param {boolean} [options.fitHeight] size the container to what is drawn (true)
56087
+ * @param {function|Intl.NumberFormat|object} [options.format] how numbers show: for every column, or
56088
+ * per column ({ Amount: Intl.NumberFormat }); copies keep the raw numbers
55259
56089
  * @returns {TableView|null} the table's state, for the host to read or change
55260
56090
  */
55261
56091
  resultView(rows, options = {}) {
@@ -55295,7 +56125,8 @@ ${theirs}` : theirs;
55295
56125
  * the axes. The container's height follows the grid, as resultView's does.
55296
56126
  * @param {Array<object>} rows
55297
56127
  * @param {object} [options] rowAxis, columnAxis, measures, columns (hints, with the rollup column's),
55298
- * dimensions, onDrill, menus, title, nullText, fitHeight
56128
+ * dimensions, onDrill, menus, title, nullText, fitHeight, format (how numbers show, per measure or for
56129
+ * all: cells, totals and the traffic lights' cut-offs; the copy keeps the raw numbers)
55299
56130
  * @returns {PivotView|null} the grid's state, for the host to read or change
55300
56131
  */
55301
56132
  pivotView(rows, options = {}) {
@@ -57999,9 +58830,20 @@ th { color: #555; font-weight: 500; }
57999
58830
 
58000
58831
  /** Import ▸: each format that only reads (another tool's export), opened as a new model. */
58001
58832
  importItems() {
58002
- return modelFileMenuItems(this.fileFormats().filter(f => f.exports === false), {
58003
- model: () => this.model, only: 'import', onImport: (model) => this.onImported(model), document: () => this.document,
58004
- });
58833
+ return [
58834
+ ...modelFileMenuItems(this.fileFormats().filter(f => f.exports === false), {
58835
+ model: () => this.model, only: 'import', onImport: (model) => this.onImported(model), document: () => this.document,
58836
+ }),
58837
+ ...this.pasteItems(),
58838
+ ];
58839
+ }
58840
+
58841
+ /** Paste …: a model in each of its text formats from pasted text (an assistant's answer, say), as Open… would bring it. */
58842
+ pasteItems() {
58843
+ return this.fileFormats().filter(f => f.imports && !f.readsBytes).map(format => ({
58844
+ label: `From pasted text (${format.label})…`,
58845
+ action: () => askPasteImport(format, { onImport: (model) => this.onImported(model), document: this.document ?? globalThis.document }),
58846
+ }));
58005
58847
  }
58006
58848
 
58007
58849
  /**
@@ -59327,6 +60169,8 @@ th { color: #555; font-weight: 500; }
59327
60169
  if (previous) {
59328
60170
  this.editor.showGrid = previous.showGrid;
59329
60171
  this.editor.gridSize = previous.gridSize;
60172
+ this.editor.showExportArea = previous.showExportArea;
60173
+ this.editor.exportOptions = previous.exportOptions;
59330
60174
  }
59331
60175
  this.editor.initPaletteForDiagramType(palette, model.templateType);
59332
60176
  this.menuBar?.dispose();
@@ -59975,6 +60819,8 @@ th { color: #555; font-weight: 500; }
59975
60819
  if (previous) {
59976
60820
  this.editor.showGrid = previous.showGrid;
59977
60821
  this.editor.gridSize = previous.gridSize;
60822
+ this.editor.showExportArea = previous.showExportArea;
60823
+ this.editor.exportOptions = previous.exportOptions;
59978
60824
  }
59979
60825
 
59980
60826
  // A canvas's palette is Archi's canvas palette; another view's, ArchiMate's element types.
@@ -60073,6 +60919,7 @@ th { color: #555; font-weight: 500; }
60073
60919
  this.editor?.selectTemplate(instanceId);
60074
60920
  },
60075
60921
  addToView: (el) => this.editor?.showElement(el.id, this.editor.visibleCentre()),
60922
+ deleteFromModel: (el) => this.editor?.askDeleteFromModel([el.id]),
60076
60923
  });
60077
60924
  }
60078
60925
 
@@ -60178,6 +61025,9 @@ th { color: #555; font-weight: 500; }
60178
61025
  exports.CAMERA_MODE = CAMERA_MODE;
60179
61026
  exports.CANVAS_BOX_KINDS = CANVAS_BOX_KINDS;
60180
61027
  exports.CANVAS_BOX_MIME = CANVAS_BOX_MIME;
61028
+ exports.CHANGE_COLOURS = CHANGE_COLOURS;
61029
+ exports.CHANGE_MAKERS = CHANGE_MAKERS;
61030
+ exports.CHANGE_WORDS = CHANGE_WORDS;
60181
61031
  exports.CHART_CATALOG = CHART_CATALOG;
60182
61032
  exports.COLOR_MODE = COLOR_MODE;
60183
61033
  exports.COORD_SYSTEM = COORD_SYSTEM;
@@ -60228,6 +61078,7 @@ th { color: #555; font-weight: 500; }
60228
61078
  exports.DataStoreTemplate = DataStoreTemplate;
60229
61079
  exports.DataType = DataType;
60230
61080
  exports.DeleteEdgeCommand = DeleteEdgeCommand;
61081
+ exports.DeleteFromModelCommand = DeleteFromModelCommand;
60231
61082
  exports.DeleteWaypointCommand = DeleteWaypointCommand;
60232
61083
  exports.DerivedRelationships = DerivedRelationships;
60233
61084
  exports.DetailPart = DetailPart;
@@ -60446,6 +61297,8 @@ th { color: #555; font-weight: 500; }
60446
61297
  exports.ZipArchive = ZipArchive;
60447
61298
  exports.archiElementType = archiElementType;
60448
61299
  exports.arrangeMenuItems = arrangeMenuItems;
61300
+ exports.askDeleteFromModel = askDeleteFromModel;
61301
+ exports.askPasteImport = askPasteImport;
60449
61302
  exports.buildAnalysisData = buildAnalysisData;
60450
61303
  exports.buildAnalysisPanel = buildAnalysisPanel;
60451
61304
  exports.buildModelTreePanel = buildModelTreePanel;
@@ -60454,31 +61307,38 @@ th { color: #555; font-weight: 500; }
60454
61307
  exports.canvasBoxKind = canvasBoxKind;
60455
61308
  exports.canvasBoxMenuItems = canvasBoxMenuItems;
60456
61309
  exports.canvasTemplate = canvasTemplate;
61310
+ exports.changeAction = changeAction;
60457
61311
  exports.chartRegistry = chartRegistry;
60458
61312
  exports.closePart = closePart;
60459
61313
  exports.coerceFieldValue = coerceFieldValue;
60460
61314
  exports.copyImage = copyImage;
61315
+ exports.copyPngBlob = copyPngBlob;
60461
61316
  exports.createDataWindow = createDataWindow;
60462
61317
  exports.createLayout = createLayout;
60463
61318
  exports.defineWorkspace = defineWorkspace;
61319
+ exports.deleteFromModelMessage = deleteFromModelMessage;
60464
61320
  exports.derivedRefreshPlan = derivedRefreshPlan;
60465
61321
  exports.diagramRegistry = diagramRegistry;
60466
61322
  exports.downloadBlob = downloadBlob;
60467
61323
  exports.downloadPng = downloadPng;
60468
61324
  exports.downloadSvg = downloadSvg;
61325
+ exports.downloadSvgMarkup = downloadSvgMarkup;
60469
61326
  exports.drawGlyph = drawGlyph;
60470
61327
  exports.duplicateGroups = duplicateGroups;
60471
61328
  exports.duplicateKey = duplicateKey;
60472
61329
  exports.ebnfAstToDiagram = ebnfAstToDiagram;
60473
61330
  exports.el = el;
61331
+ exports.elementChange = elementChange;
60474
61332
  exports.elementTypeChoices = elementTypeChoices;
60475
61333
  exports.emptyWorkArea = emptyWorkArea;
60476
61334
  exports.ensureStyles = ensureStyles;
60477
61335
  exports.exportElementsCSV = exportElementsCSV;
60478
61336
  exports.exportModelCSV = exportModelCSV;
60479
61337
  exports.exportRelationshipsCSV = exportRelationshipsCSV;
61338
+ exports.expressionValues = expressionValues;
60480
61339
  exports.findDrawable = findDrawable;
60481
61340
  exports.findPart = findPart;
61341
+ exports.findPayload = findPayload;
60482
61342
  exports.focusPart = focusPart;
60483
61343
  exports.frag = frag;
60484
61344
  exports.fromArchimateView = fromArchimateView;
@@ -60512,12 +61372,16 @@ th { color: #555; font-weight: 500; }
60512
61372
  exports.parseExchangeModel = parseExchangeModel;
60513
61373
  exports.pickFile = pickFile;
60514
61374
  exports.prepareSvgMarkup = prepareSvgMarkup;
61375
+ exports.propertyParams = propertyParams;
60515
61376
  exports.propertyRowsFor = propertyRowsFor;
60516
61377
  exports.readInheritableFields = readInheritableFields;
60517
61378
  exports.registerBuiltinParts = registerBuiltinParts;
61379
+ exports.relationshipChange = relationshipChange;
60518
61380
  exports.render = render;
60519
61381
  exports.reorderedZ = reorderedZ;
60520
61382
  exports.resolveGroup = resolveGroup;
61383
+ exports.resultBands = resultBands;
61384
+ exports.sequentialColours = sequentialColours;
60521
61385
  exports.serializeSvg = serializeSvg;
60522
61386
  exports.settingsManager = settingsManager;
60523
61387
  exports.showDiagramPropertiesDialog = showDiagramPropertiesDialog;
@@ -60526,6 +61390,7 @@ th { color: #555; font-weight: 500; }
60526
61390
  exports.specializationCatalogDialog = specializationCatalogDialog;
60527
61391
  exports.stackingOrder = stackingOrder;
60528
61392
  exports.strongerEmphasis = strongerEmphasis;
61393
+ exports.svgMarkupToPngBlob = svgMarkupToPngBlob;
60529
61394
  exports.toArchiExport = toArchiExport;
60530
61395
  exports.toArchiFile = toArchiFile;
60531
61396
  exports.toArchimateModel = toArchimateModel;