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