@peter.prib/renderengine 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +454 -296
  2. package/dist/renderengine-workspace.umd.js +4122 -1167
  3. package/dist/renderengine.umd.js +4103 -1164
  4. package/documentation/REFERENCE.md +96 -6
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Axis.js +47 -5
  8. package/src/charting/Chart3D.js +4 -2
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Hierarchy.js +28 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/ask/AskBar.js +388 -0
  18. package/src/charting/ask/AskView.js +400 -0
  19. package/src/charting/charts/AreaChart.js +20 -3
  20. package/src/charting/charts/BarChart.js +5 -5
  21. package/src/charting/charts/BarChart3D.js +2 -2
  22. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  23. package/src/charting/charts/BarChartBase.js +21 -3
  24. package/src/charting/charts/BoxPlotChart.js +2 -2
  25. package/src/charting/charts/BubbleChart.js +4 -1
  26. package/src/charting/charts/ChordDiagram.js +4 -4
  27. package/src/charting/charts/DensityChart.js +1 -1
  28. package/src/charting/charts/FunnelChart.js +1 -1
  29. package/src/charting/charts/GroupedBarChart.js +7 -9
  30. package/src/charting/charts/HeatmapChart.js +4 -1
  31. package/src/charting/charts/HistogramChart.js +2 -2
  32. package/src/charting/charts/IcicleChart.js +47 -0
  33. package/src/charting/charts/LollipopChart.js +104 -0
  34. package/src/charting/charts/MarimekkoChart.js +75 -0
  35. package/src/charting/charts/OutlineChart.js +10 -8
  36. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  37. package/src/charting/charts/ParetoChart.js +92 -0
  38. package/src/charting/charts/PipeChart.js +5 -2
  39. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  40. package/src/charting/charts/RadarChart.js +5 -4
  41. package/src/charting/charts/RadialBarChart.js +2 -2
  42. package/src/charting/charts/SankeyDiagram.js +4 -4
  43. package/src/charting/charts/ScatterChart.js +6 -3
  44. package/src/charting/charts/StackedBarChart.js +82 -11
  45. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  46. package/src/charting/charts/StreamGraphChart.js +8 -0
  47. package/src/charting/charts/SunburstChart.js +2 -2
  48. package/src/charting/charts/TableChart.js +18 -2
  49. package/src/charting/charts/TreemapChart.js +4 -4
  50. package/src/charting/charts/ViolinChart.js +2 -2
  51. package/src/charting/charts/WaterfallChart.js +2 -2
  52. package/src/charting/charts/WordCloudChart.js +2 -2
  53. package/src/charting/diagrams/ArchimateModel.js +33 -0
  54. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  55. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  56. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  57. package/src/charting/pivot/PivotChart.js +205 -13
  58. package/src/charting/pivot/PivotGridChart.js +1 -1
  59. package/src/charting/pivot/PivotView.js +555 -23
  60. package/src/charting/table/TableView.js +10 -1
  61. package/src/charting/table/TableViewChart.js +1 -1
  62. package/src/charting/templates/Template.js +10 -0
  63. package/src/exportImage.js +45 -18
  64. package/src/index.js +115 -6
  65. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  66. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  67. package/src/workspace/parts/ModelHosted.js +15 -4
@@ -1,1012 +1,1108 @@
1
- import { ViewLineEdge } from './ViewLineEdge.js';
2
- import { typeTitle, ARCHIMATE_TYPE_MIME } from './archimateNames.js';
3
- import { RemoveLineCommand } from '../diagrams/RemoveLineCommand.js';
4
- import { canvasBoxMenuItems } from '../canvas/canvasBoxMenuItems.js';
5
- import { hintMenuItems } from '../canvas/hintMenuItems.js';
6
- import { CANVAS_BOX_MIME } from '../canvas/CanvasPalette.js';
7
- import { MODEL_ELEMENT_MIME } from '../modelTree/ModelTreePanel.js';
8
- import { showGlobalSettingsDialog } from '../SettingsDialog.js';
9
- import { specializationCatalogDialog } from './specializationCatalogDialog.js';
10
- import { CustomViewpoints } from './CustomViewpoints.js';
11
- import { ShowElementCommand } from '../diagrams/ShowElementCommand.js';
12
- import { DiagramEditor } from '../DiagramEditor.js';
13
- import { CompositeCommand } from '../CommandHistory.js';
14
- import { ElementInstance } from '../diagrams/ElementInstance.js';
15
- import { RelationshipInstance } from '../diagrams/RelationshipInstance.js';
16
- import { ViewObjectInstance } from '../diagrams/ViewObjectInstance.js';
17
- import { PlaceItemsCommand } from '../diagrams/PlaceItemsCommand.js';
18
- import { AddRelationshipToViewCommand, AddRelationshipCommand, RemoveRelationshipFromViewCommand, DeleteElementCommand, AddToViewCommand } from '../diagrams/ArchimateCommands.js';
19
- import { NestInstanceCommand } from '../diagrams/NestInstanceCommand.js';
20
- import { NESTING_KINDS, nestingRelationships, hiddenByNesting } from '../diagrams/nesting.js';
21
- import { treeLayerOf } from '../modelTree/treeLayers.js';
22
- import { parseDate } from '../diagrams/roadmap.js';
23
- import { ArchimateValidator } from '../diagrams/ArchimateValidator.js';
24
- import { GraphDiagram } from '../diagrams/GraphDiagram.js';
25
- import { ArchimateElementTemplate } from './ArchimateElementTemplate.js';
26
- import { ArchimateEdge } from './ArchimateEdge.js';
27
- import { ViewpointRule } from './ViewpointRule.js';
28
- import { DerivedEdge } from './DerivedEdge.js';
29
- import { ViewGroupTemplate } from './ViewGroupTemplate.js';
30
- import { RemoveGroupFromViewCommand } from '../diagrams/RemoveGroupFromViewCommand.js';
31
- import { DerivedRelationships } from '../diagrams/DerivedRelationships.js';
32
- import { ElementFilterRule } from './ElementFilterRule.js';
33
- import { ArchimateViewpoints, ArchimateNotation } from '../../archimate.js';
34
- import { PropertyListEditor } from '../PropertyListEditor.js';
35
- import { FormDialog } from '../FormDialog.js';
36
- import { duplicateSearchFields, duplicateSearchOptions, validateDuplicateSearch } from '../duplicateSearchForm.js';
37
- import { DuplicatesDialog } from '../DuplicatesDialog.js';
38
- import { MergeCompareDialog } from '../MergeCompareDialog.js';
39
- import { elementPropertyFields, elementEditCommand, conceptEditCommand, manyElementsFields, manyElementsEditCommands } from './elementProperties.js';
40
-
41
- /**
42
- * Which allowed relationship kind a new connection takes when nobody is asked:
43
- * the dependency and dynamic kinds people usually mean first, the structural
44
- * whole/part kinds last.
45
- */
46
- export const KIND_PREFERENCE = Object.freeze([
47
- 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
48
- 'specialization', 'aggregation', 'composition', 'association',
49
- ]);
50
-
51
- // Kept in a module of their own (archimateNames.js), so the palette needs no import of this editor.
52
- export { typeTitle, ARCHIMATE_TYPE_MIME };
53
-
54
- /**
55
- * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
56
- * view: its element instances as ArchimateElementTemplates, its relationship
57
- * instances as ArchimateEdges, and its own undo history. Everything the base
58
- * editor does (move, resize, restack, bend points, sides, styles, zoom,
59
- * snapping) edits the view directly; the editor's maps are rebuilt from the
60
- * view on every render, so undo of a view edit shows straight away.
61
- *
62
- * What stands for something else in ArchiMate is done through the view and
63
- * model instead of the maps:
64
- * - dropping a palette type adds a new model element and shows it;
65
- * - connecting two elements adds a relationship, its kind the first the
66
- * ArchiMate rules allow between them (association if none);
67
- * - deleting removes from the view (with the element's lines); the model
68
- * keeps its elements and relationships.
69
- * Each is one step on the view's history.
70
- */
71
- export class ArchimateCanvasEditor extends DiagramEditor {
72
- /**
73
- * @param {Object} options - DiagramEditor options, plus:
74
- * @param {ArchimateView} options.view
75
- * @param {ArchimateModel} options.model
76
- * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
77
- * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
78
- * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
79
- * Asks which relationship kind a new connection is, given the kinds allowed between the two
80
- * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
81
- * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
82
- * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
83
- * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
84
- * live status for an element (a component's lifecycle state), drawn on its boxes
85
- * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
86
- * element (its transitions), at the end of a box's right-click menu
87
- */
88
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
89
- super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
90
- this.view = view;
91
- this.model = model;
92
- this.nodeOptions = nodeOptions;
93
- this.chooseKind = chooseKind;
94
- this.onNodeOptionsChange = onNodeOptionsChange;
95
- /** Viewpoints of the user's own, offered with the standard ones. */
96
- this.customViewpoints = customViewpoints;
97
- /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
98
- this.settingsManager = settingsManager;
99
- /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
100
- this.templateMenu = templateMenu;
101
- /** A host's live status per element, drawn on its boxes, and its menu items per element. */
102
- this.elementStatus = elementStatus;
103
- this.elementMenuExtras = elementMenuExtras;
104
- this.addDropHandler({
105
- accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
106
- drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
107
- });
108
- // An element from the model tree: shown here, with its relationships to what is already shown.
109
- this.addDropHandler({
110
- accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
111
- drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
112
- });
113
- // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
114
- if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
115
- // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
116
- this.addDropHandler({
117
- accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
118
- drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
119
- });
120
- }
121
-
122
- get version() {
123
- return this.nodeOptions.archimateVersion ?? '4';
124
- }
125
-
126
- /**
127
- * An element's own items (a view of it and what it relates to; its
128
- * relationships to what is shown, when some are not), a canvas box's hint,
129
- * and the shape's selection (or the shape and its group) as a template.
130
- */
131
- shapeMenuExtras(elementId) {
132
- const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
133
- const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
134
- const element = this._elementOf(elementId);
135
- const missing = element ? this.missingRelationshipsOf(elementId) : [];
136
- // Two or more elements chosen: merge them into one (choose the one kept).
137
- const merging = this.elementIdsOf(ids);
138
- return [
139
- ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
140
- ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
141
- ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
142
- ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
143
- ...(items.length ? [{ label: 'Save as template', items }] : []),
144
- ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
145
- ];
146
- }
147
-
148
- /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
149
- missingRelationshipsOf(instanceId) {
150
- const instance = this.view.getElementInstance(instanceId);
151
- if (!instance) return [];
152
- return this.view.missingRelationshipInstances(instance.elementId)
153
- .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
154
- }
155
-
156
- /** Draw them all, as one undo step. */
157
- addMissingRelationships(instanceId) {
158
- const missing = this.missingRelationshipsOf(instanceId);
159
- if (!missing.length) return null;
160
- const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
161
- missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
162
- this._executeCommand(cmd);
163
- return cmd;
164
- }
165
-
166
- /** 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. */
167
- canvasMenuItems(at) {
168
- const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
169
- const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
170
- // Elements from text: not on a canvas, whose boxes are not elements.
171
- const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
172
- return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
173
- }
174
-
175
- /** The View menu, and Generate roadmap… (elements with dates on a time line). */
176
- viewMenuItems() {
177
- const repeated = this.view?.repeatedBoxes?.() ?? [];
178
- return [
179
- ...super.viewMenuItems(),
180
- { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
181
- { label: 'Generate roadmap…', action: () => this.askRoadmap() },
182
- ];
183
- }
184
-
185
- /** The model's own: Find likely duplicates…. */
186
- modelMenuItems() {
187
- return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
188
- }
189
-
190
- /**
191
- * Take off this view the boxes that only repeat another box of their
192
- * element (ArchimateView#repeatedBoxes: same label, same lines), with
193
- * their lines, as one undo step. Returns the command, or null with none.
194
- */
195
- removeRepeatedBoxes() {
196
- const ids = this.view.repeatedBoxes();
197
- if (!ids.length) return null;
198
- const cmd = this.deleteTemplatesCommand(ids);
199
- cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
200
- this._executeCommand(cmd);
201
- return cmd;
202
- }
203
-
204
- /**
205
- * Where Find likely duplicates… can look: the whole model, the elements on
206
- * the view in front, the elements selected (two or more). [key, label, element ids or null].
207
- */
208
- duplicateScopes() {
209
- const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
210
- const selected = this.elementIdsOf(this.getSelectedIds());
211
- return [
212
- ['model', 'The whole model', null],
213
- ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
214
- ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
215
- ];
216
- }
217
-
218
- /**
219
- * Find likely duplicates…: where to look (duplicateScopes), how to compare
220
- * (names, documentation or a property's value; exactly or by their words)
221
- * and what noise to leave out (duplicateSearchFields), then the groups
222
- * found, each with Merge… (showDuplicates).
223
- */
224
- askDuplicates() {
225
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
226
- const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
227
- const scopes = this.duplicateScopes();
228
- return new FormDialog({
229
- title: 'Find likely duplicates',
230
- description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
231
- fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
232
- submitLabel: 'Find',
233
- idPrefix: 'duplicates-',
234
- document: this.document ?? globalThis.document,
235
- validate: validateDuplicateSearch,
236
- onApply: (values) => {
237
- const { options, scope } = duplicateSearchOptions(values);
238
- this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
239
- },
240
- }).open();
241
- }
242
-
243
- /**
244
- * The elements that look like one thing twice (the model check's likely
245
- * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
246
- * among these element ids), each group with Merge….
247
- */
248
- showDuplicates(options = {}, { within = null } = {}) {
249
- const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
250
- const groups = find();
251
- new DuplicatesDialog({
252
- groups,
253
- refind: find,
254
- merge: async (group) => !!(await this.askMerge(group.ids)),
255
- onReveal: (group) => this.revealFinding(group),
256
- empty: 'No elements look like the same thing twice.',
257
- document: this.document ?? globalThis.document,
258
- }).open();
259
- return groups;
260
- }
261
-
262
- /** The model elements of some boxes, each once (a box that is not an element is left out). */
263
- elementIdsOf(instanceIds) {
264
- return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
265
- }
266
-
267
- /**
268
- * Generate roadmap…: which elements (the selection's, or every element in
269
- * the model with both dates), the properties their dates are in, and how
270
- * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
271
- */
272
- askRoadmap() {
273
- const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
274
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
275
- return new FormDialog({
276
- title: 'Generate roadmap',
277
- description: 'A view with each element a bar from its start date to its end date, under the months.',
278
- fields: [
279
- { 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']] },
280
- { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
281
- { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
282
- { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
283
- { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
284
- ],
285
- submitLabel: 'Generate',
286
- idPrefix: 'roadmap-',
287
- document: this.document ?? globalThis.document,
288
- validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
289
- onApply: (v) => this.generateRoadmap(v),
290
- }).open();
291
- }
292
-
293
- /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
294
- roadmapFor({ from, start, end }) {
295
- const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
296
- const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
297
- const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
298
- return { ids, view: any ? true : null };
299
- }
300
-
301
- /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
302
- generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
303
- const { ids } = this.roadmapFor({ from, start, end });
304
- return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
305
- }
306
-
307
- /** An element's box keeps room for its type badge, and Archi's height. */
308
- textFit(template) {
309
- return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
310
- }
311
-
312
- /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
313
- shapeTypes() {
314
- const viewpoint = this.nodeOptions.viewpoint;
315
- const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
316
- return ArchimateNotation.elementTypeNames(this.version)
317
- .filter(t => !allowed || allowed.includes(t))
318
- .map(t => [t, typeTitle(t)]);
319
- }
320
-
321
- /**
322
- * New model elements from text, each with its box here: one undo step.
323
- * A documentation column becomes the element's documentation.
324
- */
325
- createShapesCommand(items) {
326
- const ids = this.model.elements().map(e => e.id);
327
- const steps = [], createdIds = [];
328
- for (const item of items) {
329
- const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
330
- ids.push(id);
331
- const instance = new ElementInstance(id, item.x, item.y);
332
- createdIds.push(instance.id);
333
- steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
334
- }
335
- const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
336
- cmd.createdIds = createdIds;
337
- return cmd;
338
- }
339
-
340
- /** What elements can be coloured by: their layer, type, specialization, and each property any of them has. */
341
- colourKeys(templates = [...this.templates.values()]) {
342
- const properties = new Set();
343
- 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);
344
- return [
345
- ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
346
- ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
347
- ];
348
- }
349
-
350
- /** An element's value for a colourKeys key (notes and pictures have none). */
351
- colourValue(template, key) {
352
- const element = this._elementOf(template.id);
353
- if (!element) return undefined;
354
- if (key === 'layer') return typeTitle(treeLayerOf(element));
355
- if (key === 'type') return typeTitle(element.type ?? '');
356
- if (key === 'specialization') return element.specialization;
357
- if (key.startsWith('property:')) {
358
- const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
359
- return p?.value === undefined || p?.value === null ? undefined : String(p.value);
360
- }
361
- return super.colourValue(template, key);
362
- }
363
-
364
- /** The kind legend: each element type on the view, in the colour of its first box. */
365
- kindLegendEntries() {
366
- const types = new Map();
367
- for (const t of this.templates.values()) {
368
- const element = this._elementOf(t.id);
369
- if (!element || types.has(element.type)) continue;
370
- types.set(element.type, t.fillColor);
371
- }
372
- return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
373
- }
374
-
375
- /** A legend is notes (stickies on a canvas) on the view: one PlaceItemsCommand. */
376
- legendCommand(entries, title) {
377
- if (!entries.length) return null;
378
- const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
379
- if (!kind) return null;
380
- const { x, y } = this.legendPosition();
381
- const stamp = Date.now().toString(36);
382
- const box = (i, name, fillColor) => {
383
- const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x + 60 + i * 130, y + 20);
384
- inst.size = { width: 120, height: 40 };
385
- inst.appearance = { ...kind.appearance, fillColor };
386
- return inst;
387
- };
388
- const boxes = [box(0, title, '#ffffff'), ...entries.map((e, i) => box(i + 1, e.label, e.fillColor))];
389
- return new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` });
390
- }
391
-
392
- /** Likely duplicates can be merged (Merge…: choose the one to keep). */
393
- findingActions(finding) {
394
- return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
395
- }
396
-
397
- /**
398
- * What differs among some elements, for the merge to choose: their
399
- * documentation (when two or more have some, and it differs), each property
400
- * with different values, and the specialization.
401
- * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
402
- * each choice [value, label]
403
- */
404
- mergeChoices(elements) {
405
- const name = (e) => e.name || e.id;
406
- const documented = elements.filter(e => (e.documentation ?? '').trim());
407
- const texts = [...new Set(documented.map(e => e.documentation.trim()))];
408
- const properties = new Map();
409
- for (const e of elements) {
410
- for (const p of e.properties ?? []) {
411
- const key = p.name ?? p.key;
412
- if (!properties.has(key)) properties.set(key, new Map());
413
- const values = properties.get(key);
414
- values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
415
- }
416
- }
417
- const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
418
- return {
419
- 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}`]) : [],
420
- properties: new Map([...properties].filter(([, values]) => values.size > 1)
421
- .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
422
- specializations: specializations.length > 1 ? specializations : [],
423
- };
424
- }
425
-
426
- /**
427
- * Some elements side by side, for a merge (MergeCompareDialog's rows): their
428
- * names, types, specializations, documentation, each property, their
429
- * relationships (kind, direction, the element at the other end) and the
430
- * views they are on.
431
- */
432
- mergeComparison(elements) {
433
- const nameOf = (id) => this.model.getElement(id)?.name || id;
434
- const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
435
- const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
436
- const lines = (e) => {
437
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
438
- return [
439
- ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
440
- ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
441
- ].sort().join('\n');
442
- };
443
- const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
444
- return [
445
- { label: 'Name', values: elements.map(e => e.name ?? '') },
446
- { label: 'Type', values: elements.map(e => e.type ?? '') },
447
- ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
448
- { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
449
- ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
450
- { label: 'Relationships', values: elements.map(lines) },
451
- { label: 'Views', values: elements.map(views) },
452
- ];
453
- }
454
-
455
- /**
456
- * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
457
- * Their relationships always come together. When they differ in anything
458
- * but the views they are on, they are shown side by side
459
- * (MergeCompareDialog) to choose which is kept and, where they differ, the
460
- * documentation (one, or all joined), each property's value, the
461
- * specialization; OK merges, Cancel leaves them. Alike, the first is kept
462
- * without asking. Resolves to the merge's command, or null.
463
- */
464
- async askMerge(elementIds) {
465
- const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
466
- if (elements.length < 2) return null;
467
- const used = (e) => {
468
- const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
469
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
470
- return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
471
- };
472
- const ids = elements.map(e => e.id);
473
- const rows = this.mergeComparison(elements);
474
- const compare = new MergeCompareDialog({
475
- title: `Merge ${elements.length} elements`,
476
- 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.',
477
- columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
478
- rows,
479
- document: this.document ?? globalThis.document,
480
- });
481
- // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
482
- if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
483
- const { documentation, properties, specializations } = this.mergeChoices(elements);
484
- const JOINED = '\u0000joined';
485
- const propertyKeys = [...properties.keys()];
486
- compare.fields = [
487
- { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
488
- ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
489
- ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
490
- ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
491
- ];
492
- const values = await compare.ask();
493
- if (!values) return null;
494
- const { keep } = values;
495
- const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
496
- return this.mergeElements(keep, ids.filter(id => id !== keep), {
497
- ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
498
- ...(specializations.length && { specialization: values.specialization }),
499
- properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
500
- });
501
- }
502
-
503
- /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
504
- mergeElements(keepId, otherIds, choices = {}) {
505
- const cmd = this.model.mergeCommand(keepId, otherIds, choices);
506
- this._executeCommand(cmd);
507
- return cmd;
508
- }
509
-
510
- /** The checks use this editor's ArchiMate version. */
511
- checkerOptions() {
512
- return { version: this.version };
513
- }
514
-
515
- /** An element is shown by selecting its first occurrence on this view. */
516
- revealFinding(finding) {
517
- if (finding.kind !== 'element') return super.revealFinding(finding);
518
- const instance = this.view.elementInstancesFor(finding.ids[0])[0];
519
- if (instance && this.templates.has(instance.id)) {
520
- this.selectTemplate(instance.id);
521
- this.render();
522
- }
523
- }
524
-
525
- /**
526
- * Bring the editor's templates and lines in line with the view, reusing
527
- * the template or line of each instance still there.
528
- */
529
- syncFromView() {
530
- const { view, model } = this;
531
- const templates = new Map();
532
- for (const inst of view.elementInstances.values()) {
533
- if (!inst.shownIn(view) || !inst.resolve(model)) continue;
534
- const existing = this.templates.get(inst.id);
535
- templates.set(inst.id, existing?.instance === inst ? existing
536
- : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
537
- }
538
- for (const t of templates.values()) {
539
- t.nodeOptions = this.nodeOptions;
540
- // Asked afresh at every sync: a host's status is live, not part of the model.
541
- t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
542
- }
543
- // The view's groups (Archi groups: model folders with bounds on this view).
544
- for (const folder of view.placedFolders) {
545
- const existing = this.templates.get(folder.id);
546
- templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
547
- }
548
- const edges = new Map();
549
- // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
550
- const nestedOut = (edge) => !this.nodeOptions.showNestedLines
551
- && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
552
- for (const inst of view.relationshipInstances.values()) {
553
- const existing = this.edges.get(inst.id);
554
- const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
555
- if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
556
- }
557
- // Plain lines (a canvas's connections, a line from a note).
558
- for (const line of view.viewConnections.values()) {
559
- const existing = this.edges.get(line.id);
560
- const edge = existing?.line === line ? existing : new ViewLineEdge(line);
561
- if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
562
- }
563
- // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
564
- if (this.nodeOptions.showDerived) {
565
- const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
566
- for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
567
- const from = shown(derived.source), to = shown(derived.target);
568
- if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
569
- }
570
- }
571
- this.templates.clear();
572
- for (const [id, t] of templates) this.templates.set(id, t);
573
- this.edges.clear();
574
- for (const [id, e] of edges) this.edges.set(id, e);
575
- }
576
-
577
- render() {
578
- if (this.disposed) return;
579
- this.syncFromView();
580
- super.render();
581
- }
582
-
583
- /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
584
- settingsMenuItems() {
585
- const set = (patch) => {
586
- this.nodeOptions = { ...this.nodeOptions, ...patch };
587
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
588
- else this.render();
589
- };
590
- const distinct = !!this.nodeOptions.useDistinctShapes;
591
- const viewpoint = this.nodeOptions.viewpoint ?? 'All';
592
- const mode = this.nodeOptions.viewpointMode ?? 'fade';
593
- return [
594
- {
595
- label: `Version: ArchiMate ${this.version}`,
596
- items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
597
- },
598
- { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
599
- { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
600
- { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
601
- {
602
- label: `Viewpoint: ${viewpoint}`,
603
- items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
604
- },
605
- {
606
- label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
607
- items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
608
- },
609
- { separator: true },
610
- { separator: true },
611
- { label: 'Specializations…', action: () => this.editSpecializations() },
612
- { label: 'Viewpoints…', action: () => this.editViewpoints() },
613
- ...super.settingsMenuItems(),
614
- ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
615
- ];
616
- }
617
-
618
- /**
619
- * What the ArchiMate rules say against a relationship's line, or null
620
- * (ArchimateValidator, for this ArchiMate version). Off when the view's
621
- * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
622
- */
623
- edgeWarning(edge) {
624
- const relationship = edge.relationship;
625
- const mode = this.viewSettings().invalidRelationships ?? 'highlight';
626
- if (!relationship || mode === 'off') return null;
627
- const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
628
- if (!from || !to) return null;
629
- const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
630
- return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
631
- }
632
-
633
- /** Edit the specialization catalog; elements given one redraw with it. */
634
- editSpecializations() {
635
- return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
636
- }
637
-
638
- /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
639
- editViewpoints() {
640
- return CustomViewpoints.dialog({
641
- version: this.version, document: this.document ?? globalThis.document,
642
- onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
643
- }).open();
644
- }
645
-
646
- /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
647
- _notationChanged() {
648
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
649
- else this.render();
650
- }
651
-
652
- /**
653
- * The base editor's rules (a highlight), then the active filter's result
654
- * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
655
- */
656
- emphasisRules() {
657
- const { viewpoint, viewpointMode } = this.nodeOptions;
658
- const key = `${viewpoint}|${viewpointMode}|${this.version}`;
659
- if (this._viewpointRule?.key !== key) {
660
- this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
661
- }
662
- return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
663
- }
664
-
665
- /** Dim every model element but these (a filter's result); null clears it. Redraws. */
666
- setElementFilter(elementIds) {
667
- this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
668
- this.render();
669
- }
670
-
671
- /**
672
- * The Element section of a shape's properties: the model element's own
673
- * name, type, specialization, icon, documentation and typed properties (the same on every view;
674
- * the dialog's Name above labels this occurrence only). None for a note,
675
- * image or canvas box.
676
- */
677
- conceptFields(template) {
678
- const element = this._elementOf(template.id);
679
- if (!element) return [];
680
- return [
681
- { type: 'heading', label: 'Element (on every view)' },
682
- ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
683
- ];
684
- }
685
-
686
- /** The element's changes as one EditViewItemCommand (null when nothing changed). */
687
- conceptCommand(template, values) {
688
- const element = this._elementOf(template.id);
689
- return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
690
- }
691
-
692
- /**
693
- * With several shapes selected, the Elements section: their type, their
694
- * specialization when they share a type, and one property to set on all.
695
- * Only when every selected shape is an element.
696
- */
697
- manyConceptFields(templates) {
698
- const elements = this._elementsOf(templates);
699
- return elements ? manyElementsFields(elements, { version: this.version }) : [];
700
- }
701
-
702
- /** The elements' changes as one step (null when nothing changed). */
703
- manyConceptCommand(templates, values) {
704
- const elements = this._elementsOf(templates);
705
- const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
706
- const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
707
- return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
708
- }
709
-
710
- /** The model elements the shapes show, each once; null unless every shape shows one. @private */
711
- _elementsOf(templates) {
712
- const elements = templates.map(t => this._elementOf(t.id));
713
- return elements.every(Boolean) ? [...new Set(elements)] : null;
714
- }
715
-
716
- /**
717
- * The Relationship section of a line's properties: its kind (among those
718
- * the ArchiMate rules allow between its ends, and its own), documentation
719
- * and typed properties. None for a plain line or a derived one.
720
- */
721
- edgeConceptFields(edge) {
722
- const relationship = edge.relationship;
723
- if (!relationship || edge.editable === false) return [];
724
- const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
725
- return [
726
- { type: 'heading', label: 'Relationship (on every view)' },
727
- { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
728
- { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
729
- { type: 'heading', label: 'Properties' },
730
- PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
731
- ];
732
- }
733
-
734
- /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
735
- edgeConceptCommand(edge, values) {
736
- return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
737
- }
738
-
739
- /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
740
- addElement(type, point) {
741
- const name = typeTitle(type);
742
- const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
743
- const at = this._snapToGrid(point);
744
- const instance = new ElementInstance(id, at.x, at.y);
745
- this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
746
- return instance;
747
- }
748
-
749
- /**
750
- * Show an element the model has at `point` (snapped), with its
751
- * relationships to the elements already on the view (ShowElementCommand).
752
- * One undo step. Null for an element the model lacks.
753
- */
754
- showElement(elementId, point) {
755
- if (!this.model.getElement(elementId)) return null;
756
- const at = this._snapToGrid(point);
757
- const instance = new ElementInstance(elementId, at.x, at.y);
758
- this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
759
- this.selectTemplate(instance.id);
760
- return instance;
761
- }
762
-
763
- /**
764
- * A picture as an image box on this view at `point` (snapped), one undo
765
- * step, selected. Null when this kind of view has no image box.
766
- * @param {string} dataURL - The picture
767
- */
768
- addImage(dataURL, point) {
769
- if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
770
- const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
771
- this.render();
772
- this.selectTemplate(box.id);
773
- return box;
774
- }
775
-
776
- /**
777
- * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
778
- * (snapped), as the canvas menu would. One undo step.
779
- */
780
- addCanvasBox(kindKey, point) {
781
- const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
782
- onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
783
- }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
784
- return item?.action();
785
- }
786
-
787
- /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
788
- allowedKinds(sourceId, targetId) {
789
- const from = this._elementOf(sourceId), to = this._elementOf(targetId);
790
- if (!from || !to) return [];
791
- const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
792
- return kinds.includes('association') ? kinds : [...kinds, 'association'];
793
- }
794
-
795
- /**
796
- * Connect two elements: the kind is asked for (`chooseKind`) among those
797
- * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
798
- * @returns {Command|null|Promise<Command|null>}
799
- */
800
- connect(sourceId, targetId) {
801
- if (sourceId === targetId) return null;
802
- // A box or note at either end: a plain line (the view's rule), one undo step.
803
- if (!this.view.linesAreRelationships(sourceId, targetId)) {
804
- if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
805
- const line = this.view.connect(sourceId, targetId);
806
- this.render();
807
- return line;
808
- }
809
- const kinds = this.allowedKinds(sourceId, targetId);
810
- if (!kinds.length) return null;
811
- const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
812
- if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
813
- const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
814
- return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
815
- }
816
-
817
- /** @private */
818
- _connectAs(sourceId, targetId, kind) {
819
- const cmd = this.connectCommand(sourceId, targetId, kind);
820
- if (cmd) this._executeCommand(cmd);
821
- return cmd;
822
- }
823
-
824
- /** The model element an instance on this canvas shows (notes and images have none). @private */
825
- /** A box of a model element links to the element's page in a report. */
826
- reportRef(template) {
827
- const element = this._elementOf(template.id);
828
- return element ? { kind: 'element', id: element.id } : null;
829
- }
830
-
831
- _elementOf(instanceId) {
832
- const inst = this.templates.get(instanceId)?.instance;
833
- return inst ? this.model.getElement(inst.elementId) : undefined;
834
- }
835
-
836
- /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
837
- connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
838
- const source = this.templates.get(sourceId)?.instance;
839
- const target = this.templates.get(targetId)?.instance;
840
- const from = this._elementOf(sourceId);
841
- const to = this._elementOf(targetId);
842
- // Notes and images are not model elements: they take no relationships.
843
- if (!from || !to || !kind) return null;
844
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
845
- const instance = new RelationshipInstance(id, null, source.id, target.id);
846
- return new AddRelationshipToViewCommand(this.view, instance, {
847
- relationship: { id, source: from.id, target: to.id, kind },
848
- label: `Add ${kind}`,
849
- });
850
- }
851
-
852
- /**
853
- * Deleting takes a box off the view (its lines go with it, see the base's
854
- * deleteTemplatesCommand); the model keeps its element. A group goes off the
855
- * view and what it held stays; the model keeps its folder.
856
- */
857
- /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
858
- static clipboardKind = 'archimate';
859
-
860
- /**
861
- * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
862
- * with the lines and relationships between them and the elements they show.
863
- * Groups are not copied (they are model folders).
864
- */
865
- copyPayload(ids) {
866
- const boxes = ids.filter(id => this.view.getElementInstance(id));
867
- return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
868
- }
869
-
870
- /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
871
- pasteCommand(payload, options) {
872
- return this.view.pasteCommand(payload, options);
873
- }
874
-
875
- /** Deleting is off the view: the model keeps what it showed. */
876
- deleteLabel(templateIds) {
877
- return `${super.deleteLabel(templateIds)} from view`;
878
- }
879
-
880
- removeTemplateCommand(templateId) {
881
- return this.templates.get(templateId) instanceof ViewGroupTemplate
882
- ? new RemoveGroupFromViewCommand(this.view, templateId)
883
- : new DeleteElementCommand(this.view, templateId);
884
- }
885
-
886
- /**
887
- * What moves with a shape: for a group, the elements drawn in it (its folder's),
888
- * 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.
889
- */
890
- containedIn(templateId) {
891
- const template = this.templates.get(templateId);
892
- const view = this.view;
893
- if (template instanceof ViewGroupTemplate) {
894
- const inner = [];
895
- for (const t of this.templates.values()) {
896
- if (t instanceof ViewGroupTemplate) {
897
- if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
898
- } else if (t.instance?.archi?.parent === templateId
899
- || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
900
- inner.push(t.id);
901
- }
902
- }
903
- return inner;
904
- }
905
- const visualId = template?.instance?.visualId;
906
- if (!visualId) return [];
907
- return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
908
- }
909
-
910
- // ===== Nesting =====
911
-
912
- /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
913
- parentOf(templateId) {
914
- const visualId = this.templates.get(templateId)?.instance?.nestedIn;
915
- if (!visualId) return null;
916
- const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
917
- return parent?.id ?? null;
918
- }
919
-
920
- /**
921
- * An element's parts on this view: the elements it is composed of or
922
- * aggregates (its composition and aggregation relationships' targets),
923
- * each by its first box here.
924
- */
925
- partsOf(templateId) {
926
- const element = this._elementOf(templateId);
927
- if (!element) return [];
928
- const parts = this.model.relationships()
929
- .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
930
- .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
931
- .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
932
- return [...new Set(parts)];
933
- }
934
-
935
- /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
936
- layoutCategory(template) {
937
- const element = this._elementOf(template.id);
938
- return element ? treeLayerOf(element) : 'other';
939
- }
940
-
941
- /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
942
- canHold(parentId, childId) {
943
- const parent = this.templates.get(parentId), child = this.templates.get(childId);
944
- if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
945
- return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
946
- }
947
-
948
- /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
949
- nestsOnDrop(parentId, childId) {
950
- if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
951
- return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
952
- }
953
-
954
- nestCommand(childId, parentId) {
955
- const child = this.templates.get(childId)?.instance;
956
- if (!child) return null;
957
- return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
958
- }
959
-
960
- /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
961
- nested(changes) {
962
- let chain = Promise.resolve();
963
- for (const { childId, parentId } of changes) {
964
- chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
965
- }
966
- return chain;
967
- }
968
-
969
- /**
970
- * The kinds a nesting can stand for, from parent to child, as Archi offers
971
- * them: those of NESTING_KINDS the rules allow. None when the two already
972
- * have such a relationship.
973
- */
974
- nestingKinds(parentId, childId) {
975
- const from = this._elementOf(parentId), to = this._elementOf(childId);
976
- if (!from || !to || from === to) return [];
977
- if (nestingRelationships(this.model, from.id, to.id).length) return [];
978
- const allowed = this.allowedKinds(parentId, childId);
979
- return NESTING_KINDS.filter(k => allowed.includes(k));
980
- }
981
-
982
- /**
983
- * Ask which relationship a nesting stands for (`chooseKind`, with the
984
- * reason 'nesting'; none chosen adds none) and add it to the model, drawn
985
- * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
986
- * @returns {Promise<Command|null>}
987
- */
988
- async offerNestingRelationship(parentId, childId) {
989
- const kinds = this.nestingKinds(parentId, childId);
990
- if (!kinds.length || !this.chooseKind) return null;
991
- const from = this._elementOf(parentId), to = this._elementOf(childId);
992
- const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
993
- if (!kind || !kinds.includes(kind)) return null;
994
- const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
995
- this._executeCommand(cmd);
996
- return cmd;
997
- }
998
-
999
- /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
1000
- nestingRelationshipCommand(parentId, childId, kind) {
1001
- const from = this._elementOf(parentId), to = this._elementOf(childId);
1002
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
1003
- return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
1004
- }
1005
-
1006
- /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
1007
- deleteEdgeCommand(edgeId) {
1008
- return this.view.viewConnections.has(edgeId)
1009
- ? new RemoveLineCommand(this.view, edgeId)
1010
- : new RemoveRelationshipFromViewCommand(this.view, edgeId);
1011
- }
1012
- }
1
+ import { ViewLineEdge } from './ViewLineEdge.js';
2
+ import { typeTitle, ARCHIMATE_TYPE_MIME } from './archimateNames.js';
3
+ import { RemoveLineCommand } from '../diagrams/RemoveLineCommand.js';
4
+ import { canvasBoxMenuItems } from '../canvas/canvasBoxMenuItems.js';
5
+ import { hintMenuItems } from '../canvas/hintMenuItems.js';
6
+ import { CANVAS_BOX_MIME } from '../canvas/CanvasPalette.js';
7
+ import { MODEL_ELEMENT_MIME } from '../modelTree/ModelTreePanel.js';
8
+ import { showGlobalSettingsDialog } from '../SettingsDialog.js';
9
+ import { specializationCatalogDialog } from './specializationCatalogDialog.js';
10
+ import { CustomViewpoints } from './CustomViewpoints.js';
11
+ import { askDeleteFromModel } from './deleteFromModel.js';
12
+ import { KERNEL_FUNCTIONS } from '../../kernelExpr.js';
13
+ import { CHANGE_COLOURS, elementChange, relationshipChange, hasChangeMakers, propertyParams, expressionValues, resultBands, sequentialColours } from './heatMaps.js';
14
+ import { ShowElementCommand } from '../diagrams/ShowElementCommand.js';
15
+ import { DiagramEditor } from '../DiagramEditor.js';
16
+ import { CompositeCommand } from '../CommandHistory.js';
17
+ import { GroupCommand } from '../arrangement/index.js';
18
+ import { ElementInstance } from '../diagrams/ElementInstance.js';
19
+ import { RelationshipInstance } from '../diagrams/RelationshipInstance.js';
20
+ import { ViewObjectInstance } from '../diagrams/ViewObjectInstance.js';
21
+ import { PlaceItemsCommand } from '../diagrams/PlaceItemsCommand.js';
22
+ import { AddRelationshipToViewCommand, AddRelationshipCommand, RemoveRelationshipFromViewCommand, DeleteElementCommand, AddToViewCommand } from '../diagrams/ArchimateCommands.js';
23
+ import { NestInstanceCommand } from '../diagrams/NestInstanceCommand.js';
24
+ import { NESTING_KINDS, nestingRelationships, hiddenByNesting } from '../diagrams/nesting.js';
25
+ import { treeLayerOf } from '../modelTree/treeLayers.js';
26
+ import { parseDate } from '../diagrams/roadmap.js';
27
+ import { ArchimateValidator } from '../diagrams/ArchimateValidator.js';
28
+ import { GraphDiagram } from '../diagrams/GraphDiagram.js';
29
+ import { ArchimateElementTemplate } from './ArchimateElementTemplate.js';
30
+ import { ArchimateEdge } from './ArchimateEdge.js';
31
+ import { ViewpointRule } from './ViewpointRule.js';
32
+ import { DerivedEdge } from './DerivedEdge.js';
33
+ import { ViewGroupTemplate } from './ViewGroupTemplate.js';
34
+ import { RemoveGroupFromViewCommand } from '../diagrams/RemoveGroupFromViewCommand.js';
35
+ import { DerivedRelationships } from '../diagrams/DerivedRelationships.js';
36
+ import { ElementFilterRule } from './ElementFilterRule.js';
37
+ import { ArchimateViewpoints, ArchimateNotation } from '../../archimate.js';
38
+ import { PropertyListEditor } from '../PropertyListEditor.js';
39
+ import { FormDialog } from '../FormDialog.js';
40
+ import { duplicateSearchFields, duplicateSearchOptions, validateDuplicateSearch } from '../duplicateSearchForm.js';
41
+ import { DuplicatesDialog } from '../DuplicatesDialog.js';
42
+ import { MergeCompareDialog } from '../MergeCompareDialog.js';
43
+ import { elementPropertyFields, elementEditCommand, conceptEditCommand, manyElementsFields, manyElementsEditCommands } from './elementProperties.js';
44
+
45
+ /**
46
+ * Which allowed relationship kind a new connection takes when nobody is asked:
47
+ * the dependency and dynamic kinds people usually mean first, the structural
48
+ * whole/part kinds last.
49
+ */
50
+ export const KIND_PREFERENCE = Object.freeze([
51
+ 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
52
+ 'specialization', 'aggregation', 'composition', 'association',
53
+ ]);
54
+
55
+ // Kept in a module of their own (archimateNames.js), so the palette needs no import of this editor.
56
+ export { typeTitle, ARCHIMATE_TYPE_MIME };
57
+
58
+ /**
59
+ * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
60
+ * view: its element instances as ArchimateElementTemplates, its relationship
61
+ * instances as ArchimateEdges, and its own undo history. Everything the base
62
+ * editor does (move, resize, restack, bend points, sides, styles, zoom,
63
+ * snapping) edits the view directly; the editor's maps are rebuilt from the
64
+ * view on every render, so undo of a view edit shows straight away.
65
+ *
66
+ * What stands for something else in ArchiMate is done through the view and
67
+ * model instead of the maps:
68
+ * - dropping a palette type adds a new model element and shows it;
69
+ * - connecting two elements adds a relationship, its kind the first the
70
+ * ArchiMate rules allow between them (association if none);
71
+ * - deleting removes from the view (with the element's lines); the model
72
+ * keeps its elements and relationships.
73
+ * Each is one step on the view's history.
74
+ */
75
+ export class ArchimateCanvasEditor extends DiagramEditor {
76
+ /**
77
+ * @param {Object} options - DiagramEditor options, plus:
78
+ * @param {ArchimateView} options.view
79
+ * @param {ArchimateModel} options.model
80
+ * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
81
+ * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
82
+ * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
83
+ * Asks which relationship kind a new connection is, given the kinds allowed between the two
84
+ * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
85
+ * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
86
+ * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
87
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
88
+ * live status for an element (a component's lifecycle state), drawn on its boxes
89
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
90
+ * element (its transitions), at the end of a box's right-click menu
91
+ */
92
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
93
+ super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
94
+ this.view = view;
95
+ this.model = model;
96
+ this.nodeOptions = nodeOptions;
97
+ this.chooseKind = chooseKind;
98
+ this.onNodeOptionsChange = onNodeOptionsChange;
99
+ /** Viewpoints of the user's own, offered with the standard ones. */
100
+ this.customViewpoints = customViewpoints;
101
+ /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
102
+ this.settingsManager = settingsManager;
103
+ /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
104
+ this.templateMenu = templateMenu;
105
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
106
+ this.elementStatus = elementStatus;
107
+ this.elementMenuExtras = elementMenuExtras;
108
+ this.addDropHandler({
109
+ accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
110
+ drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
111
+ });
112
+ // An element from the model tree: shown here, with its relationships to what is already shown.
113
+ this.addDropHandler({
114
+ accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
115
+ drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
116
+ });
117
+ // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
118
+ if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
119
+ // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
120
+ this.addDropHandler({
121
+ accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
122
+ drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
123
+ });
124
+ }
125
+
126
+ get version() {
127
+ return this.nodeOptions.archimateVersion ?? '4';
128
+ }
129
+
130
+ /**
131
+ * An element's own items (a view of it and what it relates to; its
132
+ * relationships to what is shown, when some are not), a canvas box's hint,
133
+ * and the shape's selection (or the shape and its group) as a template.
134
+ */
135
+ shapeMenuExtras(elementId) {
136
+ const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
137
+ const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
138
+ const element = this._elementOf(elementId);
139
+ const missing = element ? this.missingRelationshipsOf(elementId) : [];
140
+ // Two or more elements chosen: merge them into one (choose the one kept).
141
+ const merging = this.elementIdsOf(ids);
142
+ return [
143
+ ...(merging.length > 1 ? [{ label: `Merge ${merging.length} elements…`, action: () => this.askMerge(merging) }] : []),
144
+ ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
145
+ ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
146
+ ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
147
+ ...(items.length ? [{ label: 'Save as template', items }] : []),
148
+ ...(merging.length ? [{ label: '🗑 Delete from model…', danger: true, action: () => this.askDeleteFromModel(merging) }] : []),
149
+ // The host's own items end the menu.
150
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
151
+ ];
152
+ }
153
+
154
+ /** Delete elements from the model, once the dialog saying where they are used is confirmed (askDeleteFromModel). */
155
+ askDeleteFromModel(elementIds) {
156
+ return askDeleteFromModel(this.model, elementIds, {
157
+ document: this.document ?? globalThis.document,
158
+ onConfirm: (ids) => this.deleteFromModel(ids),
159
+ });
160
+ }
161
+
162
+ /** Take elements out of the model, with their relationships and every box and line of them, as one undo step. */
163
+ deleteFromModel(elementIds) {
164
+ const cmd = this.model.deleteFromModelCommand(elementIds);
165
+ if (!cmd.elementIds.length) return null;
166
+ this.clearSelection();
167
+ this._executeCommand(cmd);
168
+ return cmd;
169
+ }
170
+
171
+ /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
172
+ missingRelationshipsOf(instanceId) {
173
+ const instance = this.view.getElementInstance(instanceId);
174
+ if (!instance) return [];
175
+ return this.view.missingRelationshipInstances(instance.elementId)
176
+ .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
177
+ }
178
+
179
+ /** Draw them all, as one undo step. */
180
+ addMissingRelationships(instanceId) {
181
+ const missing = this.missingRelationshipsOf(instanceId);
182
+ if (!missing.length) return null;
183
+ const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
184
+ missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
185
+ this._executeCommand(cmd);
186
+ return cmd;
187
+ }
188
+
189
+ /** 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. */
190
+ canvasMenuItems(at) {
191
+ const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
192
+ const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
193
+ // Elements from text: not on a canvas, whose boxes are not elements.
194
+ const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
195
+ return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
196
+ }
197
+
198
+ /** The View menu, and Generate roadmap… (elements with dates on a time line). */
199
+ viewMenuItems() {
200
+ const repeated = this.view?.repeatedBoxes?.() ?? [];
201
+ return [
202
+ ...super.viewMenuItems(),
203
+ { label: `Remove repeated boxes${repeated.length ? ` (${repeated.length})` : ''}`, disabled: !repeated.length || undefined, action: () => this.removeRepeatedBoxes() },
204
+ { label: 'Generate roadmap…', action: () => this.askRoadmap() },
205
+ ];
206
+ }
207
+
208
+ /** The model's own: Find likely duplicates…. */
209
+ modelMenuItems() {
210
+ return [...super.modelMenuItems(), { label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
211
+ }
212
+
213
+ /**
214
+ * Take off this view the boxes that only repeat another box of their
215
+ * element (ArchimateView#repeatedBoxes: same label, same lines), with
216
+ * their lines, as one undo step. Returns the command, or null with none.
217
+ */
218
+ removeRepeatedBoxes() {
219
+ const ids = this.view.repeatedBoxes();
220
+ if (!ids.length) return null;
221
+ const cmd = this.deleteTemplatesCommand(ids);
222
+ cmd.label = `Remove ${ids.length} repeated box${ids.length === 1 ? '' : 'es'}`;
223
+ this._executeCommand(cmd);
224
+ return cmd;
225
+ }
226
+
227
+ /**
228
+ * Where Find likely duplicates… can look: the whole model, the elements on
229
+ * the view in front, the elements selected (two or more). [key, label, element ids or null].
230
+ */
231
+ duplicateScopes() {
232
+ const onView = this.elementIdsOf([...(this.view?.elementInstances?.keys() ?? [])]);
233
+ const selected = this.elementIdsOf(this.getSelectedIds());
234
+ return [
235
+ ['model', 'The whole model', null],
236
+ ...(onView.length > 1 ? [['view', `This view (${this.view.name || this.view.id})`, onView]] : []),
237
+ ...(selected.length > 1 ? [['selection', `The ${selected.length} elements selected`, selected]] : []),
238
+ ];
239
+ }
240
+
241
+ /**
242
+ * Find likely duplicates…: where to look (duplicateScopes), how to compare
243
+ * (names, documentation or a property's value; exactly or by their words)
244
+ * and what noise to leave out (duplicateSearchFields), then the groups
245
+ * found, each with Merge… (showDuplicates).
246
+ */
247
+ askDuplicates() {
248
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
249
+ const specializations = [...new Set(this.model.elements().map(e => e.specialization).filter(Boolean))].sort();
250
+ const scopes = this.duplicateScopes();
251
+ return new FormDialog({
252
+ title: 'Find likely duplicates',
253
+ description: 'Elements are compared by their words: case, punctuation, word order and the noise words do not count.',
254
+ fields: duplicateSearchFields({ scope: { label: 'Look in', choices: scopes.map(([key, label]) => [key, label]) }, properties: names, specializations }),
255
+ submitLabel: 'Find',
256
+ idPrefix: 'duplicates-',
257
+ document: this.document ?? globalThis.document,
258
+ validate: validateDuplicateSearch,
259
+ onApply: (values) => {
260
+ const { options, scope } = duplicateSearchOptions(values);
261
+ this.showDuplicates(options, { within: scopes.find(([key]) => key === scope)?.[2] ?? null });
262
+ },
263
+ }).open();
264
+ }
265
+
266
+ /**
267
+ * The elements that look like one thing twice (the model check's likely
268
+ * duplicates, compared as `options` say: DUPLICATE_OPTIONS; `within`: only
269
+ * among these element ids), each group with Merge….
270
+ */
271
+ showDuplicates(options = {}, { within = null } = {}) {
272
+ const find = () => this.model.checker(this.checkerOptions()).possibleDuplicates(options, { within });
273
+ const groups = find();
274
+ new DuplicatesDialog({
275
+ groups,
276
+ refind: find,
277
+ merge: async (group) => !!(await this.askMerge(group.ids)),
278
+ onReveal: (group) => this.revealFinding(group),
279
+ empty: 'No elements look like the same thing twice.',
280
+ document: this.document ?? globalThis.document,
281
+ }).open();
282
+ return groups;
283
+ }
284
+
285
+ /** The model elements of some boxes, each once (a box that is not an element is left out). */
286
+ elementIdsOf(instanceIds) {
287
+ return [...new Set(instanceIds.map(id => this._elementOf(id)?.id).filter(Boolean))];
288
+ }
289
+
290
+ /**
291
+ * Generate roadmap…: which elements (the selection's, or every element in
292
+ * the model with both dates), the properties their dates are in, and how
293
+ * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
294
+ */
295
+ askRoadmap() {
296
+ const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
297
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
298
+ return new FormDialog({
299
+ title: 'Generate roadmap',
300
+ description: 'A view with each element a bar from its start date to its end date, under the months.',
301
+ fields: [
302
+ { 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']] },
303
+ { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
304
+ { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
305
+ { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
306
+ { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
307
+ ],
308
+ submitLabel: 'Generate',
309
+ idPrefix: 'roadmap-',
310
+ document: this.document ?? globalThis.document,
311
+ validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
312
+ onApply: (v) => this.generateRoadmap(v),
313
+ }).open();
314
+ }
315
+
316
+ /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
317
+ roadmapFor({ from, start, end }) {
318
+ const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
319
+ const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
320
+ const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
321
+ return { ids, view: any ? true : null };
322
+ }
323
+
324
+ /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
325
+ generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
326
+ const { ids } = this.roadmapFor({ from, start, end });
327
+ return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
328
+ }
329
+
330
+ /** An element's box keeps room for its type badge, and Archi's height. */
331
+ textFit(template) {
332
+ return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
333
+ }
334
+
335
+ /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
336
+ shapeTypes() {
337
+ const viewpoint = this.nodeOptions.viewpoint;
338
+ const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
339
+ return ArchimateNotation.elementTypeNames(this.version)
340
+ .filter(t => !allowed || allowed.includes(t))
341
+ .map(t => [t, typeTitle(t)]);
342
+ }
343
+
344
+ /**
345
+ * New model elements from text, each with its box here: one undo step.
346
+ * A documentation column becomes the element's documentation.
347
+ */
348
+ createShapesCommand(items) {
349
+ const ids = this.model.elements().map(e => e.id);
350
+ const steps = [], createdIds = [];
351
+ for (const item of items) {
352
+ const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
353
+ ids.push(id);
354
+ const instance = new ElementInstance(id, item.x, item.y);
355
+ createdIds.push(instance.id);
356
+ steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
357
+ }
358
+ const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
359
+ cmd.createdIds = createdIds;
360
+ return cmd;
361
+ }
362
+
363
+ /**
364
+ * What elements can be coloured by: their layer, type, specialization, each
365
+ * property any of them has, the change deliverables and work packages make
366
+ * to them (when the model has any), and a formula over their properties.
367
+ */
368
+ colourKeys(templates = [...this.templates.values()]) {
369
+ const properties = new Set();
370
+ 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);
371
+ return [
372
+ ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
373
+ ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
374
+ ...(this.model && hasChangeMakers(this.model) ? [['change', 'Change (create, update, delete)']] : []),
375
+ ...(properties.size ? [['expression', 'A formula over properties…']] : []),
376
+ ];
377
+ }
378
+
379
+ /** By change, a relationship to a deliverable or work package is drawn in the colour of the change it says. */
380
+ colourEdgeValue(edge, key) {
381
+ if (key !== 'change' || !edge.relationship) return super.colourEdgeValue(edge, key);
382
+ return relationshipChange(this.model, edge.relationship) ?? undefined;
383
+ }
384
+
385
+ /** A formula's key (`expression:<formula>`) is called by its formula. */
386
+ colourKeyLabel(key) {
387
+ return key.startsWith('expression:') ? key.slice(11) : super.colourKeyLabel(key);
388
+ }
389
+
390
+ /** The change heat map's own colours; a formula's results, light to dark in order; else the palette. */
391
+ colourDefaults(key, values) {
392
+ if (key === 'change') return values.map(v => CHANGE_COLOURS[v]);
393
+ if (key.startsWith('expression:')) return sequentialColours(values.length);
394
+ return super.colourDefaults(key, values);
395
+ }
396
+
397
+ /**
398
+ * A formula's values: each element's result (expressionValues), as its own
399
+ * value when there are few, else in bands, smallest first (resultBands).
400
+ */
401
+ colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
402
+ if (!key.startsWith('expression:')) return super.colourValues(key, templates);
403
+ const boxes = templates.map(t => [t, this._elementOf(t.id)]).filter(([, e]) => e);
404
+ const results = expressionValues([...new Set(boxes.map(([, e]) => e))], key.slice(11));
405
+ const { labels, labelOf } = resultBands(results.values());
406
+ const values = new Map(labels.map(l => [l, []]));
407
+ for (const [t, e] of boxes) if (results.has(e.id)) values.get(labelOf(results.get(e.id))).push(t);
408
+ return values;
409
+ }
410
+
411
+ /** A formula over properties asks for the formula first. @protected */
412
+ askColours(key) {
413
+ if (key !== 'expression') return super.askColours(key);
414
+ const elements = [...new Set([...this.templates.values()].map(t => this._elementOf(t.id)).filter(Boolean))];
415
+ const names = [...propertyParams(elements)].map(([id, name]) => (id === name ? id : `${id} (${name})`));
416
+ new FormDialog({
417
+ title: 'Colour by a formula',
418
+ description: `A number from each element's properties, e.g. Cost * Risk, or Cost > 10000 ? 1 : 0. Properties: ${names.join(', ')}. `
419
+ + `Arithmetic, comparisons, a ? b : c, and ${Object.keys(KERNEL_FUNCTIONS).join(', ')}. `
420
+ + 'An element without a number for a property the formula uses is left as it is.',
421
+ fields: [{ key: 'formula', label: 'Formula', type: 'text', value: this._lastFormula ?? '' }],
422
+ submitLabel: 'Next',
423
+ idPrefix: 'colour-formula-',
424
+ document: this.document ?? globalThis.document,
425
+ validate: ({ formula }) => {
426
+ try { expressionValues(elements, formula); return null; } catch (e) { return e.message; }
427
+ },
428
+ onApply: ({ formula }) => {
429
+ this._lastFormula = formula;
430
+ super.askColours(`expression:${formula}`);
431
+ },
432
+ }).open();
433
+ }
434
+
435
+ /** An element's value for a colourKeys key (notes and pictures have none). */
436
+ colourValue(template, key) {
437
+ const element = this._elementOf(template.id);
438
+ if (!element) return undefined;
439
+ if (key === 'layer') return typeTitle(treeLayerOf(element));
440
+ if (key === 'type') return typeTitle(element.type ?? '');
441
+ if (key === 'specialization') return element.specialization;
442
+ if (key === 'change') return elementChange(this.model, element.id) ?? undefined;
443
+ if (key.startsWith('property:')) {
444
+ const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
445
+ return p?.value === undefined || p?.value === null ? undefined : String(p.value);
446
+ }
447
+ return super.colourValue(template, key);
448
+ }
449
+
450
+ /** The kind legend: each element type on the view, in the colour of its first box. */
451
+ kindLegendEntries() {
452
+ const types = new Map();
453
+ for (const t of this.templates.values()) {
454
+ const element = this._elementOf(t.id);
455
+ if (!element || types.has(element.type)) continue;
456
+ types.set(element.type, t.fillColor);
457
+ }
458
+ return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
459
+ }
460
+
461
+ /**
462
+ * A legend is notes (stickies on a canvas) on the view: a frame holding a
463
+ * note per entry (DiagramEditor.legendLayout), grouped, as one undo step.
464
+ * Placed in order, so the frame stacks behind its entries.
465
+ */
466
+ legendCommand(entries, title) {
467
+ if (!entries.length) return null;
468
+ const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
469
+ if (!kind) return null;
470
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
471
+ const stamp = Date.now().toString(36);
472
+ const box = (i, name, { x, y, width, height }, appearance) => {
473
+ const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x, y);
474
+ inst.size = { width, height };
475
+ inst.appearance = { ...kind.appearance, ...appearance };
476
+ return inst;
477
+ };
478
+ const boxes = [
479
+ box(0, title, frame, { fillColor: '#ffffff', ...DiagramEditor.LEGEND_TITLE_STYLE }),
480
+ ...entries.map((e, i) => box(i + 1, e.label, items[i], { fillColor: e.fillColor })),
481
+ ];
482
+ return new CompositeCommand(`Add legend: ${title}`, [
483
+ new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` }),
484
+ new GroupCommand(this.arrangement, boxes.map(b => b.id), { name: title }),
485
+ ]);
486
+ }
487
+
488
+ /** Likely duplicates can be merged (Merge…: choose the one to keep). */
489
+ findingActions(finding) {
490
+ return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
491
+ }
492
+
493
+ /**
494
+ * What differs among some elements, for the merge to choose: their
495
+ * documentation (when two or more have some, and it differs), each property
496
+ * with different values, and the specialization.
497
+ * @returns {{documentation: Array<[string, string]>, properties: Map<string, Array<[string, string]>>, specializations: string[]}}
498
+ * each choice [value, label]
499
+ */
500
+ mergeChoices(elements) {
501
+ const name = (e) => e.name || e.id;
502
+ const documented = elements.filter(e => (e.documentation ?? '').trim());
503
+ const texts = [...new Set(documented.map(e => e.documentation.trim()))];
504
+ const properties = new Map();
505
+ for (const e of elements) {
506
+ for (const p of e.properties ?? []) {
507
+ const key = p.name ?? p.key;
508
+ if (!properties.has(key)) properties.set(key, new Map());
509
+ const values = properties.get(key);
510
+ values.set(String(p.value ?? ''), [...(values.get(String(p.value ?? '')) ?? []), name(e)]);
511
+ }
512
+ }
513
+ const specializations = [...new Set(elements.map(e => e.specialization).filter(Boolean))];
514
+ return {
515
+ 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}`]) : [],
516
+ properties: new Map([...properties].filter(([, values]) => values.size > 1)
517
+ .map(([key, values]) => [key, [...values].map(([value, from]) => [value, `${value || '(empty)'} (${from.join(', ')})`])])),
518
+ specializations: specializations.length > 1 ? specializations : [],
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Some elements side by side, for a merge (MergeCompareDialog's rows): their
524
+ * names, types, specializations, documentation, each property, their
525
+ * relationships (kind, direction, the element at the other end) and the
526
+ * views they are on.
527
+ */
528
+ mergeComparison(elements) {
529
+ const nameOf = (id) => this.model.getElement(id)?.name || id;
530
+ const valueOf = (e, key) => (e.properties ?? []).filter(p => (p.name ?? p.key) === key).map(p => p.value ?? '').join(', ');
531
+ const keys = [...new Set(elements.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))];
532
+ const lines = (e) => {
533
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
534
+ return [
535
+ ...outgoing.map(r => `${r.kind} → ${nameOf(r.target)}`),
536
+ ...incoming.map(r => `${r.kind} ← ${nameOf(r.source)}`),
537
+ ].sort().join('\n');
538
+ };
539
+ const views = (e) => this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).map(v => v.name || v.id).join('\n');
540
+ return [
541
+ { label: 'Name', values: elements.map(e => e.name ?? '') },
542
+ { label: 'Type', values: elements.map(e => e.type ?? '') },
543
+ ...(elements.some(e => e.specialization) ? [{ label: 'Specialization', values: elements.map(e => e.specialization ?? '') }] : []),
544
+ { label: 'Documentation', values: elements.map(e => (e.documentation ?? '').trim()) },
545
+ ...keys.map(key => ({ label: `${key} (property)`, values: elements.map(e => valueOf(e, key)) })),
546
+ { label: 'Relationships', values: elements.map(lines) },
547
+ { label: 'Views', values: elements.map(views) },
548
+ ];
549
+ }
550
+
551
+ /**
552
+ * Merge some elements into one, one undo step (ArchimateModel#mergeCommand).
553
+ * Their relationships always come together. When they differ in anything
554
+ * but the views they are on, they are shown side by side
555
+ * (MergeCompareDialog) to choose which is kept and, where they differ, the
556
+ * documentation (one, or all joined), each property's value, the
557
+ * specialization; OK merges, Cancel leaves them. Alike, the first is kept
558
+ * without asking. Resolves to the merge's command, or null.
559
+ */
560
+ async askMerge(elementIds) {
561
+ const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
562
+ if (elements.length < 2) return null;
563
+ const used = (e) => {
564
+ const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
565
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
566
+ return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
567
+ };
568
+ const ids = elements.map(e => e.id);
569
+ const rows = this.mergeComparison(elements);
570
+ const compare = new MergeCompareDialog({
571
+ title: `Merge ${elements.length} elements`,
572
+ 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.',
573
+ columns: elements.map(e => `${e.name || e.id} (${used(e)})`),
574
+ rows,
575
+ document: this.document ?? globalThis.document,
576
+ });
577
+ // Views apart (their boxes all come to the kept one), nothing to choose: the first is kept.
578
+ if (!rows.some(row => row.label !== 'Views' && MergeCompareDialog.differs(row))) return this.mergeElements(ids[0], ids.slice(1));
579
+ const { documentation, properties, specializations } = this.mergeChoices(elements);
580
+ const JOINED = '\u0000joined';
581
+ const propertyKeys = [...properties.keys()];
582
+ compare.fields = [
583
+ { key: 'keep', label: 'Keep', type: 'select', value: ids[0], choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
584
+ ...(documentation.length ? [{ key: 'documentation', label: 'Documentation', type: 'select', value: JOINED, choices: [[JOINED, 'All of them, joined'], ...documentation] }] : []),
585
+ ...(specializations.length ? [{ key: 'specialization', label: 'Specialization', type: 'select', value: specializations[0], choices: specializations.map(s => [s, s]) }] : []),
586
+ ...propertyKeys.map((key, i) => ({ key: `property-${i}`, label: key, type: 'select', value: properties.get(key)[0][0], choices: properties.get(key) })),
587
+ ];
588
+ const values = await compare.ask();
589
+ if (!values) return null;
590
+ const { keep } = values;
591
+ const joined = () => [...new Set(elements.map(e => (e.documentation ?? '').trim()).filter(Boolean))].join('\n\n');
592
+ return this.mergeElements(keep, ids.filter(id => id !== keep), {
593
+ ...(documentation.length && { documentation: values.documentation === JOINED ? joined() : values.documentation }),
594
+ ...(specializations.length && { specialization: values.specialization }),
595
+ properties: Object.fromEntries(propertyKeys.map((key, i) => [key, values[`property-${i}`]])),
596
+ });
597
+ }
598
+
599
+ /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. `choices`: MergeElementsCommand's. */
600
+ mergeElements(keepId, otherIds, choices = {}) {
601
+ const cmd = this.model.mergeCommand(keepId, otherIds, choices);
602
+ this._executeCommand(cmd);
603
+ return cmd;
604
+ }
605
+
606
+ /** The checks use this editor's ArchiMate version. */
607
+ checkerOptions() {
608
+ return { version: this.version };
609
+ }
610
+
611
+ /** An element is shown by selecting its first occurrence on this view. */
612
+ revealFinding(finding) {
613
+ if (finding.kind !== 'element') return super.revealFinding(finding);
614
+ const instance = this.view.elementInstancesFor(finding.ids[0])[0];
615
+ if (instance && this.templates.has(instance.id)) {
616
+ this.selectTemplate(instance.id);
617
+ this.render();
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Bring the editor's templates and lines in line with the view, reusing
623
+ * the template or line of each instance still there.
624
+ */
625
+ syncFromView() {
626
+ const { view, model } = this;
627
+ const templates = new Map();
628
+ for (const inst of view.elementInstances.values()) {
629
+ if (!inst.shownIn(view) || !inst.resolve(model)) continue;
630
+ const existing = this.templates.get(inst.id);
631
+ templates.set(inst.id, existing?.instance === inst ? existing
632
+ : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
633
+ }
634
+ for (const t of templates.values()) {
635
+ t.nodeOptions = this.nodeOptions;
636
+ // Asked afresh at every sync: a host's status is live, not part of the model.
637
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
638
+ }
639
+ // The view's groups (Archi groups: model folders with bounds on this view).
640
+ for (const folder of view.placedFolders) {
641
+ const existing = this.templates.get(folder.id);
642
+ templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
643
+ }
644
+ const edges = new Map();
645
+ // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
646
+ const nestedOut = (edge) => !this.nodeOptions.showNestedLines
647
+ && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
648
+ for (const inst of view.relationshipInstances.values()) {
649
+ const existing = this.edges.get(inst.id);
650
+ const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
651
+ if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
652
+ }
653
+ // Plain lines (a canvas's connections, a line from a note).
654
+ for (const line of view.viewConnections.values()) {
655
+ const existing = this.edges.get(line.id);
656
+ const edge = existing?.line === line ? existing : new ViewLineEdge(line);
657
+ if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
658
+ }
659
+ // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
660
+ if (this.nodeOptions.showDerived) {
661
+ const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
662
+ for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
663
+ const from = shown(derived.source), to = shown(derived.target);
664
+ if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
665
+ }
666
+ }
667
+ this.templates.clear();
668
+ for (const [id, t] of templates) this.templates.set(id, t);
669
+ this.edges.clear();
670
+ for (const [id, e] of edges) this.edges.set(id, e);
671
+ }
672
+
673
+ render() {
674
+ if (this.disposed) return;
675
+ this.syncFromView();
676
+ super.render();
677
+ }
678
+
679
+ /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
680
+ settingsMenuItems() {
681
+ const set = (patch) => {
682
+ this.nodeOptions = { ...this.nodeOptions, ...patch };
683
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
684
+ else this.render();
685
+ };
686
+ const distinct = !!this.nodeOptions.useDistinctShapes;
687
+ const viewpoint = this.nodeOptions.viewpoint ?? 'All';
688
+ const mode = this.nodeOptions.viewpointMode ?? 'fade';
689
+ return [
690
+ {
691
+ label: `Version: ArchiMate ${this.version}`,
692
+ items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
693
+ },
694
+ { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
695
+ { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
696
+ { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
697
+ {
698
+ label: `Viewpoint: ${viewpoint}`,
699
+ items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
700
+ },
701
+ {
702
+ label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
703
+ items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
704
+ },
705
+ { separator: true },
706
+ { separator: true },
707
+ { label: 'Specializations…', action: () => this.editSpecializations() },
708
+ { label: 'Viewpoints…', action: () => this.editViewpoints() },
709
+ ...super.settingsMenuItems(),
710
+ ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
711
+ ];
712
+ }
713
+
714
+ /**
715
+ * What the ArchiMate rules say against a relationship's line, or null
716
+ * (ArchimateValidator, for this ArchiMate version). Off when the view's
717
+ * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
718
+ */
719
+ edgeWarning(edge) {
720
+ const relationship = edge.relationship;
721
+ const mode = this.viewSettings().invalidRelationships ?? 'highlight';
722
+ if (!relationship || mode === 'off') return null;
723
+ const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
724
+ if (!from || !to) return null;
725
+ const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
726
+ return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
727
+ }
728
+
729
+ /** Edit the specialization catalog; elements given one redraw with it. */
730
+ editSpecializations() {
731
+ return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
732
+ }
733
+
734
+ /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
735
+ editViewpoints() {
736
+ return CustomViewpoints.dialog({
737
+ version: this.version, document: this.document ?? globalThis.document,
738
+ onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
739
+ }).open();
740
+ }
741
+
742
+ /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
743
+ _notationChanged() {
744
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
745
+ else this.render();
746
+ }
747
+
748
+ /**
749
+ * The base editor's rules (a highlight), then the active filter's result
750
+ * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
751
+ */
752
+ emphasisRules() {
753
+ const { viewpoint, viewpointMode } = this.nodeOptions;
754
+ const key = `${viewpoint}|${viewpointMode}|${this.version}`;
755
+ if (this._viewpointRule?.key !== key) {
756
+ this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
757
+ }
758
+ return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
759
+ }
760
+
761
+ /** Dim every model element but these (a filter's result); null clears it. Redraws. */
762
+ setElementFilter(elementIds) {
763
+ this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
764
+ this.render();
765
+ }
766
+
767
+ /**
768
+ * The Element section of a shape's properties: the model element's own
769
+ * name, type, specialization, icon, documentation and typed properties (the same on every view;
770
+ * the dialog's Name above labels this occurrence only). None for a note,
771
+ * image or canvas box.
772
+ */
773
+ conceptFields(template) {
774
+ const element = this._elementOf(template.id);
775
+ if (!element) return [];
776
+ return [
777
+ { type: 'heading', label: 'Element (on every view)' },
778
+ ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
779
+ ];
780
+ }
781
+
782
+ /** The element's changes as one EditViewItemCommand (null when nothing changed). */
783
+ conceptCommand(template, values) {
784
+ const element = this._elementOf(template.id);
785
+ return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
786
+ }
787
+
788
+ /**
789
+ * With several shapes selected, the Elements section: their type, their
790
+ * specialization when they share a type, and one property to set on all.
791
+ * Only when every selected shape is an element.
792
+ */
793
+ manyConceptFields(templates) {
794
+ const elements = this._elementsOf(templates);
795
+ return elements ? manyElementsFields(elements, { version: this.version }) : [];
796
+ }
797
+
798
+ /** The elements' changes as one step (null when nothing changed). */
799
+ manyConceptCommand(templates, values) {
800
+ const elements = this._elementsOf(templates);
801
+ const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
802
+ const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
803
+ return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
804
+ }
805
+
806
+ /** The model elements the shapes show, each once; null unless every shape shows one. @private */
807
+ _elementsOf(templates) {
808
+ const elements = templates.map(t => this._elementOf(t.id));
809
+ return elements.every(Boolean) ? [...new Set(elements)] : null;
810
+ }
811
+
812
+ /**
813
+ * The Relationship section of a line's properties: its kind (among those
814
+ * the ArchiMate rules allow between its ends, and its own), documentation
815
+ * and typed properties. None for a plain line or a derived one.
816
+ */
817
+ edgeConceptFields(edge) {
818
+ const relationship = edge.relationship;
819
+ if (!relationship || edge.editable === false) return [];
820
+ const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
821
+ return [
822
+ { type: 'heading', label: 'Relationship (on every view)' },
823
+ { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
824
+ { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
825
+ { type: 'heading', label: 'Properties' },
826
+ PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
827
+ ];
828
+ }
829
+
830
+ /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
831
+ edgeConceptCommand(edge, values) {
832
+ return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
833
+ }
834
+
835
+ /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
836
+ addElement(type, point) {
837
+ const name = typeTitle(type);
838
+ const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
839
+ const at = this._snapToGrid(point);
840
+ const instance = new ElementInstance(id, at.x, at.y);
841
+ this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
842
+ return instance;
843
+ }
844
+
845
+ /**
846
+ * Show an element the model has at `point` (snapped), with its
847
+ * relationships to the elements already on the view (ShowElementCommand).
848
+ * One undo step. Null for an element the model lacks.
849
+ */
850
+ showElement(elementId, point) {
851
+ if (!this.model.getElement(elementId)) return null;
852
+ const at = this._snapToGrid(point);
853
+ const instance = new ElementInstance(elementId, at.x, at.y);
854
+ this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
855
+ this.selectTemplate(instance.id);
856
+ return instance;
857
+ }
858
+
859
+ /**
860
+ * A picture as an image box on this view at `point` (snapped), one undo
861
+ * step, selected. Null when this kind of view has no image box.
862
+ * @param {string} dataURL - The picture
863
+ */
864
+ addImage(dataURL, point) {
865
+ if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
866
+ const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
867
+ this.render();
868
+ this.selectTemplate(box.id);
869
+ return box;
870
+ }
871
+
872
+ /**
873
+ * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
874
+ * (snapped), as the canvas menu would. One undo step.
875
+ */
876
+ addCanvasBox(kindKey, point) {
877
+ const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
878
+ onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
879
+ }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
880
+ return item?.action();
881
+ }
882
+
883
+ /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
884
+ allowedKinds(sourceId, targetId) {
885
+ const from = this._elementOf(sourceId), to = this._elementOf(targetId);
886
+ if (!from || !to) return [];
887
+ const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
888
+ return kinds.includes('association') ? kinds : [...kinds, 'association'];
889
+ }
890
+
891
+ /**
892
+ * Connect two elements: the kind is asked for (`chooseKind`) among those
893
+ * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
894
+ * @returns {Command|null|Promise<Command|null>}
895
+ */
896
+ connect(sourceId, targetId) {
897
+ if (sourceId === targetId) return null;
898
+ // A box or note at either end: a plain line (the view's rule), one undo step.
899
+ if (!this.view.linesAreRelationships(sourceId, targetId)) {
900
+ if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
901
+ const line = this.view.connect(sourceId, targetId);
902
+ this.render();
903
+ return line;
904
+ }
905
+ const kinds = this.allowedKinds(sourceId, targetId);
906
+ if (!kinds.length) return null;
907
+ const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
908
+ if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
909
+ const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
910
+ return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
911
+ }
912
+
913
+ /** @private */
914
+ _connectAs(sourceId, targetId, kind) {
915
+ const cmd = this.connectCommand(sourceId, targetId, kind);
916
+ if (cmd) this._executeCommand(cmd);
917
+ return cmd;
918
+ }
919
+
920
+ /** The model element an instance on this canvas shows (notes and images have none). @private */
921
+ /** A box of a model element links to the element's page in a report. */
922
+ reportRef(template) {
923
+ const element = this._elementOf(template.id);
924
+ return element ? { kind: 'element', id: element.id } : null;
925
+ }
926
+
927
+ _elementOf(instanceId) {
928
+ const inst = this.templates.get(instanceId)?.instance;
929
+ return inst ? this.model.getElement(inst.elementId) : undefined;
930
+ }
931
+
932
+ /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
933
+ connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
934
+ const source = this.templates.get(sourceId)?.instance;
935
+ const target = this.templates.get(targetId)?.instance;
936
+ const from = this._elementOf(sourceId);
937
+ const to = this._elementOf(targetId);
938
+ // Notes and images are not model elements: they take no relationships.
939
+ if (!from || !to || !kind) return null;
940
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
941
+ const instance = new RelationshipInstance(id, null, source.id, target.id);
942
+ return new AddRelationshipToViewCommand(this.view, instance, {
943
+ relationship: { id, source: from.id, target: to.id, kind },
944
+ label: `Add ${kind}`,
945
+ });
946
+ }
947
+
948
+ /**
949
+ * Deleting takes a box off the view (its lines go with it, see the base's
950
+ * deleteTemplatesCommand); the model keeps its element. A group goes off the
951
+ * view and what it held stays; the model keeps its folder.
952
+ */
953
+ /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
954
+ static clipboardKind = 'archimate';
955
+
956
+ /**
957
+ * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
958
+ * with the lines and relationships between them and the elements they show.
959
+ * Groups are not copied (they are model folders).
960
+ */
961
+ copyPayload(ids) {
962
+ const boxes = ids.filter(id => this.view.getElementInstance(id));
963
+ return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
964
+ }
965
+
966
+ /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
967
+ pasteCommand(payload, options) {
968
+ return this.view.pasteCommand(payload, options);
969
+ }
970
+
971
+ /** Deleting is off the view: the model keeps what it showed. */
972
+ deleteLabel(templateIds) {
973
+ return `${super.deleteLabel(templateIds)} from view`;
974
+ }
975
+
976
+ removeTemplateCommand(templateId) {
977
+ return this.templates.get(templateId) instanceof ViewGroupTemplate
978
+ ? new RemoveGroupFromViewCommand(this.view, templateId)
979
+ : new DeleteElementCommand(this.view, templateId);
980
+ }
981
+
982
+ /**
983
+ * What moves with a shape: for a group, the elements drawn in it (its folder's),
984
+ * 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.
985
+ */
986
+ containedIn(templateId) {
987
+ const template = this.templates.get(templateId);
988
+ const view = this.view;
989
+ if (template instanceof ViewGroupTemplate) {
990
+ const inner = [];
991
+ for (const t of this.templates.values()) {
992
+ if (t instanceof ViewGroupTemplate) {
993
+ if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
994
+ } else if (t.instance?.archi?.parent === templateId
995
+ || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
996
+ inner.push(t.id);
997
+ }
998
+ }
999
+ return inner;
1000
+ }
1001
+ const visualId = template?.instance?.visualId;
1002
+ if (!visualId) return [];
1003
+ return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
1004
+ }
1005
+
1006
+ // ===== Nesting =====
1007
+
1008
+ /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
1009
+ parentOf(templateId) {
1010
+ const visualId = this.templates.get(templateId)?.instance?.nestedIn;
1011
+ if (!visualId) return null;
1012
+ const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
1013
+ return parent?.id ?? null;
1014
+ }
1015
+
1016
+ /**
1017
+ * An element's parts on this view: the elements it is composed of or
1018
+ * aggregates (its composition and aggregation relationships' targets),
1019
+ * each by its first box here.
1020
+ */
1021
+ partsOf(templateId) {
1022
+ const element = this._elementOf(templateId);
1023
+ if (!element) return [];
1024
+ const parts = this.model.relationships()
1025
+ .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
1026
+ .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
1027
+ .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
1028
+ return [...new Set(parts)];
1029
+ }
1030
+
1031
+ /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
1032
+ layoutCategory(template) {
1033
+ const element = this._elementOf(template.id);
1034
+ return element ? treeLayerOf(element) : 'other';
1035
+ }
1036
+
1037
+ /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
1038
+ canHold(parentId, childId) {
1039
+ const parent = this.templates.get(parentId), child = this.templates.get(childId);
1040
+ if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
1041
+ return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
1042
+ }
1043
+
1044
+ /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
1045
+ nestsOnDrop(parentId, childId) {
1046
+ if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
1047
+ return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
1048
+ }
1049
+
1050
+ nestCommand(childId, parentId) {
1051
+ const child = this.templates.get(childId)?.instance;
1052
+ if (!child) return null;
1053
+ return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
1054
+ }
1055
+
1056
+ /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
1057
+ nested(changes) {
1058
+ let chain = Promise.resolve();
1059
+ for (const { childId, parentId } of changes) {
1060
+ chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
1061
+ }
1062
+ return chain;
1063
+ }
1064
+
1065
+ /**
1066
+ * The kinds a nesting can stand for, from parent to child, as Archi offers
1067
+ * them: those of NESTING_KINDS the rules allow. None when the two already
1068
+ * have such a relationship.
1069
+ */
1070
+ nestingKinds(parentId, childId) {
1071
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
1072
+ if (!from || !to || from === to) return [];
1073
+ if (nestingRelationships(this.model, from.id, to.id).length) return [];
1074
+ const allowed = this.allowedKinds(parentId, childId);
1075
+ return NESTING_KINDS.filter(k => allowed.includes(k));
1076
+ }
1077
+
1078
+ /**
1079
+ * Ask which relationship a nesting stands for (`chooseKind`, with the
1080
+ * reason 'nesting'; none chosen adds none) and add it to the model, drawn
1081
+ * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
1082
+ * @returns {Promise<Command|null>}
1083
+ */
1084
+ async offerNestingRelationship(parentId, childId) {
1085
+ const kinds = this.nestingKinds(parentId, childId);
1086
+ if (!kinds.length || !this.chooseKind) return null;
1087
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
1088
+ const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
1089
+ if (!kind || !kinds.includes(kind)) return null;
1090
+ const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
1091
+ this._executeCommand(cmd);
1092
+ return cmd;
1093
+ }
1094
+
1095
+ /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
1096
+ nestingRelationshipCommand(parentId, childId, kind) {
1097
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
1098
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
1099
+ return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
1100
+ }
1101
+
1102
+ /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
1103
+ deleteEdgeCommand(edgeId) {
1104
+ return this.view.viewConnections.has(edgeId)
1105
+ ? new RemoveLineCommand(this.view, edgeId)
1106
+ : new RemoveRelationshipFromViewCommand(this.view, edgeId);
1107
+ }
1108
+ }