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