@peter.prib/renderengine 0.2.5 → 0.2.7

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 +219 -213
  2. package/REFERENCE.md +272 -6
  3. package/dist/renderengine-workspace.umd.js +11980 -7421
  4. package/dist/renderengine.umd.js +8991 -5784
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +534 -0
  7. package/package.json +70 -68
  8. package/src/bian.js +182 -0
  9. package/src/charting/CheckboxTreeSelector.js +373 -304
  10. package/src/charting/DiagramEditor.js +53 -32
  11. package/src/charting/DiagramTypeRegistry.js +13 -3
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/ViewTabStrip.js +94 -81
  15. package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
  16. package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
  17. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  18. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  19. package/src/charting/datamodel/DataModel.js +645 -0
  20. package/src/charting/datamodel/DataModelChecker.js +92 -0
  21. package/src/charting/datamodel/DataModelCompared.js +127 -0
  22. package/src/charting/datamodel/DataModelContext.js +108 -0
  23. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  24. package/src/charting/datamodel/DataModelPerspective.js +129 -0
  25. package/src/charting/datamodel/DataType.js +108 -0
  26. package/src/charting/datamodel/EnumerationType.js +51 -0
  27. package/src/charting/datamodel/LevelMapping.js +38 -0
  28. package/src/charting/datamodel/LevelSchema.js +41 -0
  29. package/src/charting/datamodel/ServiceDomain.js +177 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/StructuredType.js +63 -0
  32. package/src/charting/datamodel/index.js +15 -0
  33. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  34. package/src/charting/diagramModel/OpenViewList.js +75 -0
  35. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  36. package/src/charting/diagrams/DataModelDiagram.js +160 -86
  37. package/src/charting/diagrams/DataModelRelationshipStrategy.js +94 -38
  38. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  39. package/src/charting/diagrams/GraphDiagram.js +11 -1
  40. package/src/charting/diagrams/NodeGeometry.js +169 -158
  41. package/src/charting/diagrams/nesting.js +31 -0
  42. package/src/charting/glyphs/PersonGlyph.js +24 -23
  43. package/src/charting/modelTree/ModelTree.js +202 -164
  44. package/src/charting/modelTree/ModelTreePanel.js +36 -3
  45. package/src/charting/modelTree/TreeViewMode.js +131 -110
  46. package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
  47. package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
  48. package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
  49. package/src/datamodel.js +132 -122
  50. package/src/diagramCatalog.js +35 -30
  51. package/src/index.js +2666 -2659
  52. package/src/workspace/EditorShell.js +166 -67
  53. package/src/workspace/PanelLayout.js +56 -0
  54. package/src/workspace/index.js +1 -0
  55. package/src/workspace/layout/TabsNode.js +205 -191
  56. package/src/workspace/parts/ArchimateCanvasPart.js +10 -9
  57. package/src/workspace/parts/ChartPart.js +6 -1
  58. package/src/workspace/parts/DataModelPart.js +544 -0
  59. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  60. package/src/workspace/parts/EditorPart.js +461 -407
  61. package/src/workspace/parts/ModelFile.js +216 -187
  62. package/src/workspace/parts/ModelsPart.js +483 -192
  63. package/src/workspace/storage/BackupFolder.js +145 -0
  64. package/src/workspace/storage/KeyValueStore.js +92 -0
  65. package/src/workspace/storage/ModelLibrary.js +65 -0
  66. package/src/workspace/workArea.js +417 -413
  67. package/src/yaml/YamlReader.js +372 -0
@@ -1,815 +1,825 @@
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 } 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 { elementPropertyFields, elementEditCommand, conceptEditCommand, manyElementsFields, manyElementsEditCommands } from './elementProperties.js';
37
-
38
- /**
39
- * Which allowed relationship kind a new connection takes when nobody is asked:
40
- * the dependency and dynamic kinds people usually mean first, the structural
41
- * whole/part kinds last.
42
- */
43
- export const KIND_PREFERENCE = Object.freeze([
44
- 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
45
- 'specialization', 'aggregation', 'composition', 'association',
46
- ]);
47
-
48
- // Kept in a module of their own (archimateNames.js), so the palette needs no import of this editor.
49
- export { typeTitle, ARCHIMATE_TYPE_MIME };
50
-
51
- /**
52
- * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
53
- * view: its element instances as ArchimateElementTemplates, its relationship
54
- * instances as ArchimateEdges, and its own undo history. Everything the base
55
- * editor does (move, resize, restack, bend points, sides, styles, zoom,
56
- * snapping) edits the view directly; the editor's maps are rebuilt from the
57
- * view on every render, so undo of a view edit shows straight away.
58
- *
59
- * What stands for something else in ArchiMate is done through the view and
60
- * model instead of the maps:
61
- * - dropping a palette type adds a new model element and shows it;
62
- * - connecting two elements adds a relationship, its kind the first the
63
- * ArchiMate rules allow between them (association if none);
64
- * - deleting removes from the view (with the element's lines); the model
65
- * keeps its elements and relationships.
66
- * Each is one step on the view's history.
67
- */
68
- export class ArchimateCanvasEditor extends DiagramEditor {
69
- /**
70
- * @param {Object} options - DiagramEditor options, plus:
71
- * @param {ArchimateView} options.view
72
- * @param {ArchimateModel} options.model
73
- * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
74
- * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
75
- * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
76
- * Asks which relationship kind a new connection is, given the kinds allowed between the two
77
- * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
78
- * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
79
- * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
80
- */
81
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
82
- super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
83
- this.view = view;
84
- this.model = model;
85
- this.nodeOptions = nodeOptions;
86
- this.chooseKind = chooseKind;
87
- this.onNodeOptionsChange = onNodeOptionsChange;
88
- /** Viewpoints of the user's own, offered with the standard ones. */
89
- this.customViewpoints = customViewpoints;
90
- /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
91
- this.settingsManager = settingsManager;
92
- /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
93
- this.templateMenu = templateMenu;
94
- this.addDropHandler({
95
- accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
96
- drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
97
- });
98
- // An element from the model tree: shown here, with its relationships to what is already shown.
99
- this.addDropHandler({
100
- accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
101
- drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
102
- });
103
- // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
104
- if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
105
- // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
106
- this.addDropHandler({
107
- accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
108
- drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
109
- });
110
- }
111
-
112
- get version() {
113
- return this.nodeOptions.archimateVersion ?? '4';
114
- }
115
-
116
- /**
117
- * An element's own items (a view of it and what it relates to; its
118
- * relationships to what is shown, when some are not), a canvas box's hint,
119
- * and the shape's selection (or the shape and its group) as a template.
120
- */
121
- shapeMenuExtras(elementId) {
122
- const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
123
- const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
124
- const element = this._elementOf(elementId);
125
- const missing = element ? this.missingRelationshipsOf(elementId) : [];
126
- return [
127
- ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
128
- ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
129
- ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
130
- ...(items.length ? [{ label: 'Save as template', items }] : []),
131
- ];
132
- }
133
-
134
- /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
135
- missingRelationshipsOf(instanceId) {
136
- const instance = this.view.getElementInstance(instanceId);
137
- if (!instance) return [];
138
- return this.view.missingRelationshipInstances(instance.elementId)
139
- .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
140
- }
141
-
142
- /** Draw them all, as one undo step. */
143
- addMissingRelationships(instanceId) {
144
- const missing = this.missingRelationshipsOf(instanceId);
145
- if (!missing.length) return null;
146
- const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
147
- missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
148
- this._executeCommand(cmd);
149
- return cmd;
150
- }
151
-
152
- /** 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. */
153
- canvasMenuItems(at) {
154
- const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
155
- const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
156
- // Elements from text: not on a canvas, whose boxes are not elements.
157
- const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
158
- return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
159
- }
160
-
161
- /** The View menu, and Generate roadmap… (elements with dates on a time line). */
162
- viewMenuItems() {
163
- return [...super.viewMenuItems(), { label: 'Generate roadmap…', action: () => this.askRoadmap() }];
164
- }
165
-
166
- /**
167
- * Generate roadmap…: which elements (the selection's, or every element in
168
- * the model with both dates), the properties their dates are in, and how
169
- * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
170
- */
171
- askRoadmap() {
172
- const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
173
- const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
174
- return new FormDialog({
175
- title: 'Generate roadmap',
176
- description: 'A view with each element a bar from its start date to its end date, under the months.',
177
- fields: [
178
- { 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']] },
179
- { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
180
- { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
181
- { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
182
- { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
183
- ],
184
- submitLabel: 'Generate',
185
- idPrefix: 'roadmap-',
186
- document: this.document ?? globalThis.document,
187
- validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
188
- onApply: (v) => this.generateRoadmap(v),
189
- }).open();
190
- }
191
-
192
- /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
193
- roadmapFor({ from, start, end }) {
194
- const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
195
- const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
196
- const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
197
- return { ids, view: any ? true : null };
198
- }
199
-
200
- /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
201
- generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
202
- const { ids } = this.roadmapFor({ from, start, end });
203
- return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
204
- }
205
-
206
- /** An element's box keeps room for its type badge, and Archi's height. */
207
- textFit(template) {
208
- return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
209
- }
210
-
211
- /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
212
- shapeTypes() {
213
- const viewpoint = this.nodeOptions.viewpoint;
214
- const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
215
- return ArchimateNotation.elementTypeNames(this.version)
216
- .filter(t => !allowed || allowed.includes(t))
217
- .map(t => [t, typeTitle(t)]);
218
- }
219
-
220
- /**
221
- * New model elements from text, each with its box here: one undo step.
222
- * A documentation column becomes the element's documentation.
223
- */
224
- createShapesCommand(items) {
225
- const ids = this.model.elements().map(e => e.id);
226
- const steps = [], createdIds = [];
227
- for (const item of items) {
228
- const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
229
- ids.push(id);
230
- const instance = new ElementInstance(id, item.x, item.y);
231
- createdIds.push(instance.id);
232
- steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
233
- }
234
- const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
235
- cmd.createdIds = createdIds;
236
- return cmd;
237
- }
238
-
239
- /** What elements can be coloured by: their layer, type, specialization, and each property any of them has. */
240
- colourKeys(templates = [...this.templates.values()]) {
241
- const properties = new Set();
242
- 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);
243
- return [
244
- ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
245
- ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
246
- ];
247
- }
248
-
249
- /** An element's value for a colourKeys key (notes and pictures have none). */
250
- colourValue(template, key) {
251
- const element = this._elementOf(template.id);
252
- if (!element) return undefined;
253
- if (key === 'layer') return typeTitle(treeLayerOf(element));
254
- if (key === 'type') return typeTitle(element.type ?? '');
255
- if (key === 'specialization') return element.specialization;
256
- if (key.startsWith('property:')) {
257
- const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
258
- return p?.value === undefined || p?.value === null ? undefined : String(p.value);
259
- }
260
- return super.colourValue(template, key);
261
- }
262
-
263
- /** The kind legend: each element type on the view, in the colour of its first box. */
264
- kindLegendEntries() {
265
- const types = new Map();
266
- for (const t of this.templates.values()) {
267
- const element = this._elementOf(t.id);
268
- if (!element || types.has(element.type)) continue;
269
- types.set(element.type, t.fillColor);
270
- }
271
- return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
272
- }
273
-
274
- /** A legend is notes (stickies on a canvas) on the view: one PlaceItemsCommand. */
275
- legendCommand(entries, title) {
276
- if (!entries.length) return null;
277
- const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
278
- if (!kind) return null;
279
- const { x, y } = this.legendPosition();
280
- const stamp = Date.now().toString(36);
281
- const box = (i, name, fillColor) => {
282
- const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x + 60 + i * 130, y + 20);
283
- inst.size = { width: 120, height: 40 };
284
- inst.appearance = { ...kind.appearance, fillColor };
285
- return inst;
286
- };
287
- const boxes = [box(0, title, '#ffffff'), ...entries.map((e, i) => box(i + 1, e.label, e.fillColor))];
288
- return new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` });
289
- }
290
-
291
- /** Likely duplicates can be merged (Merge…: choose the one to keep). */
292
- findingActions(finding) {
293
- return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
294
- }
295
-
296
- /**
297
- * Ask which of some elements to keep, then merge the others into it
298
- * (ArchimateModel#mergeCommand), one undo step. Each choice says where the
299
- * element is used, to help pick.
300
- */
301
- askMerge(elementIds) {
302
- const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
303
- if (elements.length < 2) return null;
304
- const used = (e) => {
305
- const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
306
- const { incoming, outgoing } = this.model.relationshipsFor(e.id);
307
- return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
308
- };
309
- return new FormDialog({
310
- title: `Merge ${elements.length} elements`,
311
- description: 'The one kept takes the others\' relationships, their boxes on every view, and the documentation and properties it lacks; the others go.',
312
- fields: [{ key: 'keep', label: 'Keep', type: 'select', value: elements[0].id, choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) }],
313
- submitLabel: 'Merge',
314
- idPrefix: 'merge-',
315
- document: this.document ?? globalThis.document,
316
- onApply: ({ keep }) => this.mergeElements(keep, elementIds.filter(id => id !== keep)),
317
- }).open();
318
- }
319
-
320
- /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. */
321
- mergeElements(keepId, otherIds) {
322
- const cmd = this.model.mergeCommand(keepId, otherIds);
323
- this._executeCommand(cmd);
324
- return cmd;
325
- }
326
-
327
- /** The checks use this editor's ArchiMate version. */
328
- checkerOptions() {
329
- return { version: this.version };
330
- }
331
-
332
- /** An element is shown by selecting its first occurrence on this view. */
333
- revealFinding(finding) {
334
- if (finding.kind !== 'element') return super.revealFinding(finding);
335
- const instance = this.view.elementInstancesFor(finding.ids[0])[0];
336
- if (instance && this.templates.has(instance.id)) {
337
- this.selectTemplate(instance.id);
338
- this.render();
339
- }
340
- }
341
-
342
- /**
343
- * Bring the editor's templates and lines in line with the view, reusing
344
- * the template or line of each instance still there.
345
- */
346
- syncFromView() {
347
- const { view, model } = this;
348
- const templates = new Map();
349
- for (const inst of view.elementInstances.values()) {
350
- if (!inst.shownIn(view) || !inst.resolve(model)) continue;
351
- const existing = this.templates.get(inst.id);
352
- templates.set(inst.id, existing?.instance === inst ? existing
353
- : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
354
- }
355
- for (const t of templates.values()) t.nodeOptions = this.nodeOptions;
356
- // The view's groups (Archi groups: model folders with bounds on this view).
357
- for (const folder of view.placedFolders) {
358
- const existing = this.templates.get(folder.id);
359
- templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
360
- }
361
- const edges = new Map();
362
- for (const inst of view.relationshipInstances.values()) {
363
- const existing = this.edges.get(inst.id);
364
- const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
365
- if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(inst.id, edge);
366
- }
367
- // Plain lines (a canvas's connections, a line from a note).
368
- for (const line of view.viewConnections.values()) {
369
- const existing = this.edges.get(line.id);
370
- const edge = existing?.line === line ? existing : new ViewLineEdge(line);
371
- if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
372
- }
373
- // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
374
- if (this.nodeOptions.showDerived) {
375
- const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
376
- for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
377
- const from = shown(derived.source), to = shown(derived.target);
378
- if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
379
- }
380
- }
381
- this.templates.clear();
382
- for (const [id, t] of templates) this.templates.set(id, t);
383
- this.edges.clear();
384
- for (const [id, e] of edges) this.edges.set(id, e);
385
- }
386
-
387
- render() {
388
- if (this.disposed) return;
389
- this.syncFromView();
390
- super.render();
391
- }
392
-
393
- /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
394
- settingsMenuItems() {
395
- const set = (patch) => {
396
- this.nodeOptions = { ...this.nodeOptions, ...patch };
397
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
398
- else this.render();
399
- };
400
- const distinct = !!this.nodeOptions.useDistinctShapes;
401
- const viewpoint = this.nodeOptions.viewpoint ?? 'All';
402
- const mode = this.nodeOptions.viewpointMode ?? 'fade';
403
- return [
404
- {
405
- label: `Version: ArchiMate ${this.version}`,
406
- items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
407
- },
408
- { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
409
- { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
410
- {
411
- label: `Viewpoint: ${viewpoint}`,
412
- items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
413
- },
414
- {
415
- label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
416
- items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
417
- },
418
- { separator: true },
419
- { separator: true },
420
- { label: 'Specializations…', action: () => this.editSpecializations() },
421
- { label: 'Viewpoints…', action: () => this.editViewpoints() },
422
- ...super.settingsMenuItems(),
423
- ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
424
- ];
425
- }
426
-
427
- /**
428
- * What the ArchiMate rules say against a relationship's line, or null
429
- * (ArchimateValidator, for this ArchiMate version). Off when the view's
430
- * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
431
- */
432
- edgeWarning(edge) {
433
- const relationship = edge.relationship;
434
- const mode = this.viewSettings().invalidRelationships ?? 'highlight';
435
- if (!relationship || mode === 'off') return null;
436
- const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
437
- if (!from || !to) return null;
438
- const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
439
- return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
440
- }
441
-
442
- /** Edit the specialization catalog; elements given one redraw with it. */
443
- editSpecializations() {
444
- return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
445
- }
446
-
447
- /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
448
- editViewpoints() {
449
- return CustomViewpoints.dialog({
450
- version: this.version, document: this.document ?? globalThis.document,
451
- onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
452
- }).open();
453
- }
454
-
455
- /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
456
- _notationChanged() {
457
- if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
458
- else this.render();
459
- }
460
-
461
- /**
462
- * The base editor's rules (a highlight), then the active filter's result
463
- * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
464
- */
465
- emphasisRules() {
466
- const { viewpoint, viewpointMode } = this.nodeOptions;
467
- const key = `${viewpoint}|${viewpointMode}|${this.version}`;
468
- if (this._viewpointRule?.key !== key) {
469
- this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
470
- }
471
- return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
472
- }
473
-
474
- /** Dim every model element but these (a filter's result); null clears it. Redraws. */
475
- setElementFilter(elementIds) {
476
- this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
477
- this.render();
478
- }
479
-
480
- /**
481
- * The Element section of a shape's properties: the model element's own
482
- * name, type, specialization, icon, documentation and typed properties (the same on every view;
483
- * the dialog's Name above labels this occurrence only). None for a note,
484
- * image or canvas box.
485
- */
486
- conceptFields(template) {
487
- const element = this._elementOf(template.id);
488
- if (!element) return [];
489
- return [
490
- { type: 'heading', label: 'Element (on every view)' },
491
- ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
492
- ];
493
- }
494
-
495
- /** The element's changes as one EditViewItemCommand (null when nothing changed). */
496
- conceptCommand(template, values) {
497
- const element = this._elementOf(template.id);
498
- return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
499
- }
500
-
501
- /**
502
- * With several shapes selected, the Elements section: their type, their
503
- * specialization when they share a type, and one property to set on all.
504
- * Only when every selected shape is an element.
505
- */
506
- manyConceptFields(templates) {
507
- const elements = this._elementsOf(templates);
508
- return elements ? manyElementsFields(elements, { version: this.version }) : [];
509
- }
510
-
511
- /** The elements' changes as one step (null when nothing changed). */
512
- manyConceptCommand(templates, values) {
513
- const elements = this._elementsOf(templates);
514
- const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
515
- const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
516
- return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
517
- }
518
-
519
- /** The model elements the shapes show, each once; null unless every shape shows one. @private */
520
- _elementsOf(templates) {
521
- const elements = templates.map(t => this._elementOf(t.id));
522
- return elements.every(Boolean) ? [...new Set(elements)] : null;
523
- }
524
-
525
- /**
526
- * The Relationship section of a line's properties: its kind (among those
527
- * the ArchiMate rules allow between its ends, and its own), documentation
528
- * and typed properties. None for a plain line or a derived one.
529
- */
530
- edgeConceptFields(edge) {
531
- const relationship = edge.relationship;
532
- if (!relationship || edge.editable === false) return [];
533
- const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
534
- return [
535
- { type: 'heading', label: 'Relationship (on every view)' },
536
- { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
537
- { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
538
- { type: 'heading', label: 'Properties' },
539
- PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
540
- ];
541
- }
542
-
543
- /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
544
- edgeConceptCommand(edge, values) {
545
- return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
546
- }
547
-
548
- /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
549
- addElement(type, point) {
550
- const name = typeTitle(type);
551
- const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
552
- const at = this._snapToGrid(point);
553
- const instance = new ElementInstance(id, at.x, at.y);
554
- this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
555
- return instance;
556
- }
557
-
558
- /**
559
- * Show an element the model has at `point` (snapped), with its
560
- * relationships to the elements already on the view (ShowElementCommand).
561
- * One undo step. Null for an element the model lacks.
562
- */
563
- showElement(elementId, point) {
564
- if (!this.model.getElement(elementId)) return null;
565
- const at = this._snapToGrid(point);
566
- const instance = new ElementInstance(elementId, at.x, at.y);
567
- this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
568
- this.selectTemplate(instance.id);
569
- return instance;
570
- }
571
-
572
- /**
573
- * A picture as an image box on this view at `point` (snapped), one undo
574
- * step, selected. Null when this kind of view has no image box.
575
- * @param {string} dataURL - The picture
576
- */
577
- addImage(dataURL, point) {
578
- if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
579
- const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
580
- this.render();
581
- this.selectTemplate(box.id);
582
- return box;
583
- }
584
-
585
- /**
586
- * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
587
- * (snapped), as the canvas menu would. One undo step.
588
- */
589
- addCanvasBox(kindKey, point) {
590
- const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
591
- onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
592
- }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
593
- return item?.action();
594
- }
595
-
596
- /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
597
- allowedKinds(sourceId, targetId) {
598
- const from = this._elementOf(sourceId), to = this._elementOf(targetId);
599
- if (!from || !to) return [];
600
- const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
601
- return kinds.includes('association') ? kinds : [...kinds, 'association'];
602
- }
603
-
604
- /**
605
- * Connect two elements: the kind is asked for (`chooseKind`) among those
606
- * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
607
- * @returns {Command|null|Promise<Command|null>}
608
- */
609
- connect(sourceId, targetId) {
610
- if (sourceId === targetId) return null;
611
- // A box or note at either end: a plain line (the view's rule), one undo step.
612
- if (!this.view.linesAreRelationships(sourceId, targetId)) {
613
- if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
614
- const line = this.view.connect(sourceId, targetId);
615
- this.render();
616
- return line;
617
- }
618
- const kinds = this.allowedKinds(sourceId, targetId);
619
- if (!kinds.length) return null;
620
- const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
621
- if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
622
- const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
623
- return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
624
- }
625
-
626
- /** @private */
627
- _connectAs(sourceId, targetId, kind) {
628
- const cmd = this.connectCommand(sourceId, targetId, kind);
629
- if (cmd) this._executeCommand(cmd);
630
- return cmd;
631
- }
632
-
633
- /** The model element an instance on this canvas shows (notes and images have none). @private */
634
- /** A box of a model element links to the element's page in a report. */
635
- reportRef(template) {
636
- const element = this._elementOf(template.id);
637
- return element ? { kind: 'element', id: element.id } : null;
638
- }
639
-
640
- _elementOf(instanceId) {
641
- const inst = this.templates.get(instanceId)?.instance;
642
- return inst ? this.model.getElement(inst.elementId) : undefined;
643
- }
644
-
645
- /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
646
- connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
647
- const source = this.templates.get(sourceId)?.instance;
648
- const target = this.templates.get(targetId)?.instance;
649
- const from = this._elementOf(sourceId);
650
- const to = this._elementOf(targetId);
651
- // Notes and images are not model elements: they take no relationships.
652
- if (!from || !to || !kind) return null;
653
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
654
- const instance = new RelationshipInstance(id, null, source.id, target.id);
655
- return new AddRelationshipToViewCommand(this.view, instance, {
656
- relationship: { id, source: from.id, target: to.id, kind },
657
- label: `Add ${kind}`,
658
- });
659
- }
660
-
661
- /**
662
- * Deleting takes a box off the view (its lines go with it, see the base's
663
- * deleteTemplatesCommand); the model keeps its element. A group goes off the
664
- * view and what it held stays; the model keeps its folder.
665
- */
666
- /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
667
- static clipboardKind = 'archimate';
668
-
669
- /**
670
- * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
671
- * with the lines and relationships between them and the elements they show.
672
- * Groups are not copied (they are model folders).
673
- */
674
- copyPayload(ids) {
675
- const boxes = ids.filter(id => this.view.getElementInstance(id));
676
- return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
677
- }
678
-
679
- /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
680
- pasteCommand(payload, options) {
681
- return this.view.pasteCommand(payload, options);
682
- }
683
-
684
- /** Deleting is off the view: the model keeps what it showed. */
685
- deleteLabel(templateIds) {
686
- return `${super.deleteLabel(templateIds)} from view`;
687
- }
688
-
689
- removeTemplateCommand(templateId) {
690
- return this.templates.get(templateId) instanceof ViewGroupTemplate
691
- ? new RemoveGroupFromViewCommand(this.view, templateId)
692
- : new DeleteElementCommand(this.view, templateId);
693
- }
694
-
695
- /**
696
- * What moves with a shape: for a group, the elements drawn in it (its folder's),
697
- * 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.
698
- */
699
- containedIn(templateId) {
700
- const template = this.templates.get(templateId);
701
- const view = this.view;
702
- if (template instanceof ViewGroupTemplate) {
703
- const inner = [];
704
- for (const t of this.templates.values()) {
705
- if (t instanceof ViewGroupTemplate) {
706
- if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
707
- } else if (t.instance?.archi?.parent === templateId
708
- || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
709
- inner.push(t.id);
710
- }
711
- }
712
- return inner;
713
- }
714
- const visualId = template?.instance?.visualId;
715
- if (!visualId) return [];
716
- return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
717
- }
718
-
719
- // ===== Nesting =====
720
-
721
- /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
722
- parentOf(templateId) {
723
- const visualId = this.templates.get(templateId)?.instance?.nestedIn;
724
- if (!visualId) return null;
725
- const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
726
- return parent?.id ?? null;
727
- }
728
-
729
- /**
730
- * An element's parts on this view: the elements it is composed of or
731
- * aggregates (its composition and aggregation relationships' targets),
732
- * each by its first box here.
733
- */
734
- partsOf(templateId) {
735
- const element = this._elementOf(templateId);
736
- if (!element) return [];
737
- const parts = this.model.relationships()
738
- .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
739
- .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
740
- .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
741
- return [...new Set(parts)];
742
- }
743
-
744
- /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
745
- layoutCategory(template) {
746
- const element = this._elementOf(template.id);
747
- return element ? treeLayerOf(element) : 'other';
748
- }
749
-
750
- /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
751
- canHold(parentId, childId) {
752
- const parent = this.templates.get(parentId), child = this.templates.get(childId);
753
- if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
754
- return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
755
- }
756
-
757
- nestCommand(childId, parentId) {
758
- const child = this.templates.get(childId)?.instance;
759
- if (!child) return null;
760
- return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
761
- }
762
-
763
- /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
764
- nested(changes) {
765
- let chain = Promise.resolve();
766
- for (const { childId, parentId } of changes) {
767
- chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
768
- }
769
- return chain;
770
- }
771
-
772
- /**
773
- * The kinds a nesting can stand for, from parent to child, as Archi offers
774
- * them: those of NESTING_KINDS the rules allow. None when the two already
775
- * have such a relationship.
776
- */
777
- nestingKinds(parentId, childId) {
778
- const from = this._elementOf(parentId), to = this._elementOf(childId);
779
- if (!from || !to || from === to) return [];
780
- if (nestingRelationships(this.model, from.id, to.id).length) return [];
781
- const allowed = this.allowedKinds(parentId, childId);
782
- return NESTING_KINDS.filter(k => allowed.includes(k));
783
- }
784
-
785
- /**
786
- * Ask which relationship a nesting stands for (`chooseKind`, with the
787
- * reason 'nesting'; none chosen adds none) and add it to the model, drawn
788
- * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
789
- * @returns {Promise<Command|null>}
790
- */
791
- async offerNestingRelationship(parentId, childId) {
792
- const kinds = this.nestingKinds(parentId, childId);
793
- if (!kinds.length || !this.chooseKind) return null;
794
- const from = this._elementOf(parentId), to = this._elementOf(childId);
795
- const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
796
- if (!kind || !kinds.includes(kind)) return null;
797
- const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
798
- this._executeCommand(cmd);
799
- return cmd;
800
- }
801
-
802
- /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
803
- nestingRelationshipCommand(parentId, childId, kind) {
804
- const from = this._elementOf(parentId), to = this._elementOf(childId);
805
- const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
806
- return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
807
- }
808
-
809
- /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
810
- deleteEdgeCommand(edgeId) {
811
- return this.view.viewConnections.has(edgeId)
812
- ? new RemoveLineCommand(this.view, edgeId)
813
- : new RemoveRelationshipFromViewCommand(this.view, edgeId);
814
- }
815
- }
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 { elementPropertyFields, elementEditCommand, conceptEditCommand, manyElementsFields, manyElementsEditCommands } from './elementProperties.js';
37
+
38
+ /**
39
+ * Which allowed relationship kind a new connection takes when nobody is asked:
40
+ * the dependency and dynamic kinds people usually mean first, the structural
41
+ * whole/part kinds last.
42
+ */
43
+ export const KIND_PREFERENCE = Object.freeze([
44
+ 'assignment', 'realization', 'serving', 'access', 'influence', 'triggering', 'flow',
45
+ 'specialization', 'aggregation', 'composition', 'association',
46
+ ]);
47
+
48
+ // Kept in a module of their own (archimateNames.js), so the palette needs no import of this editor.
49
+ export { typeTitle, ARCHIMATE_TYPE_MIME };
50
+
51
+ /**
52
+ * ArchimateCanvasEditor — the diagram editor's canvas over one ArchiMate
53
+ * view: its element instances as ArchimateElementTemplates, its relationship
54
+ * instances as ArchimateEdges, and its own undo history. Everything the base
55
+ * editor does (move, resize, restack, bend points, sides, styles, zoom,
56
+ * snapping) edits the view directly; the editor's maps are rebuilt from the
57
+ * view on every render, so undo of a view edit shows straight away.
58
+ *
59
+ * What stands for something else in ArchiMate is done through the view and
60
+ * model instead of the maps:
61
+ * - dropping a palette type adds a new model element and shows it;
62
+ * - connecting two elements adds a relationship, its kind the first the
63
+ * ArchiMate rules allow between them (association if none);
64
+ * - deleting removes from the view (with the element's lines); the model
65
+ * keeps its elements and relationships.
66
+ * Each is one step on the view's history.
67
+ */
68
+ export class ArchimateCanvasEditor extends DiagramEditor {
69
+ /**
70
+ * @param {Object} options - DiagramEditor options, plus:
71
+ * @param {ArchimateView} options.view
72
+ * @param {ArchimateModel} options.model
73
+ * @param {Object} [options.nodeOptions] - ArchimateDiagram.buildNode options (archimateVersion, useDistinctShapes, ...),
74
+ * and the viewpoint: `viewpoint` (a name; 'All' or none for every type), `viewpointMode` ('fade' or 'hide')
75
+ * @param {(kinds: string[], from: Object, to: Object) => Promise<string|null>|string|null} [options.chooseKind]
76
+ * Asks which relationship kind a new connection is, given the kinds allowed between the two
77
+ * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
78
+ * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
79
+ * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
80
+ */
81
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
82
+ super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
83
+ this.view = view;
84
+ this.model = model;
85
+ this.nodeOptions = nodeOptions;
86
+ this.chooseKind = chooseKind;
87
+ this.onNodeOptionsChange = onNodeOptionsChange;
88
+ /** Viewpoints of the user's own, offered with the standard ones. */
89
+ this.customViewpoints = customViewpoints;
90
+ /** The settings the ⚙ Settings menu's Model settings… edits (a SettingsManager), if any. */
91
+ this.settingsManager = settingsManager;
92
+ /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
93
+ this.templateMenu = templateMenu;
94
+ this.addDropHandler({
95
+ accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
96
+ drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
97
+ });
98
+ // An element from the model tree: shown here, with its relationships to what is already shown.
99
+ this.addDropHandler({
100
+ accepts: (dt) => Array.from(dt.types).includes(MODEL_ELEMENT_MIME),
101
+ drop: (dt, point) => this.showElement(dt.getData(MODEL_ELEMENT_MIME), point),
102
+ });
103
+ // A picture file dropped (or pasted): an image box where it fell (pasted: in the middle).
104
+ if (typeof document !== 'undefined') this.enableImageDrop((dataURL, point) => this.addImage(dataURL, point));
105
+ // A canvas palette tile: the box, where it was dropped (an image asks for its picture).
106
+ this.addDropHandler({
107
+ accepts: (dt) => Array.from(dt.types).includes(CANVAS_BOX_MIME),
108
+ drop: (dt, point) => this.addCanvasBox(dt.getData(CANVAS_BOX_MIME), point),
109
+ });
110
+ }
111
+
112
+ get version() {
113
+ return this.nodeOptions.archimateVersion ?? '4';
114
+ }
115
+
116
+ /**
117
+ * An element's own items (a view of it and what it relates to; its
118
+ * relationships to what is shown, when some are not), a canvas box's hint,
119
+ * and the shape's selection (or the shape and its group) as a template.
120
+ */
121
+ shapeMenuExtras(elementId) {
122
+ const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
123
+ const items = this.templateMenu?.saveAsTemplateItems(this.view.id, { itemIds: [...this.arrangement.movers(ids)] }) ?? [];
124
+ const element = this._elementOf(elementId);
125
+ const missing = element ? this.missingRelationshipsOf(elementId) : [];
126
+ return [
127
+ ...(element ? [{ label: 'Generate View For This', action: () => this.model.generateView([element.id]) }] : []),
128
+ ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
129
+ ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
130
+ ...(items.length ? [{ label: 'Save as template', items }] : []),
131
+ ];
132
+ }
133
+
134
+ /** The model's relationships between this box's element and what is shown that the view does not draw from this box. */
135
+ missingRelationshipsOf(instanceId) {
136
+ const instance = this.view.getElementInstance(instanceId);
137
+ if (!instance) return [];
138
+ return this.view.missingRelationshipInstances(instance.elementId)
139
+ .filter(line => line.sourceInstanceId === instanceId || line.targetInstanceId === instanceId);
140
+ }
141
+
142
+ /** Draw them all, as one undo step. */
143
+ addMissingRelationships(instanceId) {
144
+ const missing = this.missingRelationshipsOf(instanceId);
145
+ if (!missing.length) return null;
146
+ const cmd = new CompositeCommand(`Add ${missing.length} relationship${missing.length === 1 ? '' : 's'}`,
147
+ missing.map(line => new AddRelationshipToViewCommand(this.view, line)));
148
+ this._executeCommand(cmd);
149
+ return cmd;
150
+ }
151
+
152
+ /** 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. */
153
+ canvasMenuItems(at) {
154
+ const boxes = canvasBoxMenuItems(this.view, at, { onAdded: (box) => { this.render(); this.selectTemplate(box.id); } });
155
+ const items = this.templateMenu?.placeTemplateItems(this.view.id, at) ?? [];
156
+ // Elements from text: not on a canvas, whose boxes are not elements.
157
+ const fromText = this.view.constructor.kind === 'canvas' ? [] : super.canvasMenuItems(at);
158
+ return [...boxes, ...fromText, ...(items.length ? [{ label: 'Place template', items }] : []), ...hintMenuItems(this.view, null, { onChange: () => this.render() })];
159
+ }
160
+
161
+ /** The View menu, and Generate roadmap… (elements with dates on a time line). */
162
+ viewMenuItems() {
163
+ return [...super.viewMenuItems(), { label: 'Generate roadmap…', action: () => this.askRoadmap() }];
164
+ }
165
+
166
+ /**
167
+ * Generate roadmap…: which elements (the selection's, or every element in
168
+ * the model with both dates), the properties their dates are in, and how
169
+ * months are named; the roadmap view comes to the front (ArchimateModel#generateRoadmap).
170
+ */
171
+ askRoadmap() {
172
+ const selected = this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean);
173
+ const names = [...new Set(this.model.elements().flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
174
+ return new FormDialog({
175
+ title: 'Generate roadmap',
176
+ description: 'A view with each element a bar from its start date to its end date, under the months.',
177
+ fields: [
178
+ { 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']] },
179
+ { key: 'start', label: 'Start date property', type: 'text', value: 'Start Date', suggestions: names },
180
+ { key: 'end', label: 'End date property', type: 'text', value: 'End Date', suggestions: names },
181
+ { key: 'months', label: 'Month names', type: 'select', value: 'short', choices: [['short', 'Short (Jan)'], ['full', 'Full (January)']] },
182
+ { key: 'name', label: 'View name', type: 'text', value: 'Roadmap' },
183
+ ],
184
+ submitLabel: 'Generate',
185
+ idPrefix: 'roadmap-',
186
+ document: this.document ?? globalThis.document,
187
+ validate: (v) => (this.roadmapFor(v).view === null ? `No element has both "${v.start}" and "${v.end}" as dates` : null),
188
+ onApply: (v) => this.generateRoadmap(v),
189
+ }).open();
190
+ }
191
+
192
+ /** What a roadmap of these dialog values would hold: `{view: null}` when nothing would be on it (a dry run). @private */
193
+ roadmapFor({ from, start, end }) {
194
+ const ids = from === 'selection' ? this.getSelectedIds().map(id => this._elementOf(id)?.id).filter(Boolean) : this.model.elements().map(e => e.id);
195
+ const date = (e, key) => parseDate((e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value);
196
+ const any = ids.map(id => this.model.getElement(id)).some(e => e && date(e, start) && date(e, end) && date(e, end) >= date(e, start));
197
+ return { ids, view: any ? true : null };
198
+ }
199
+
200
+ /** Generate the roadmap of Generate roadmap…'s values. @returns {{view, skipped}} */
201
+ generateRoadmap({ from = 'model', start = 'Start Date', end = 'End Date', months = 'short', name = 'Roadmap' } = {}) {
202
+ const { ids } = this.roadmapFor({ from, start, end });
203
+ return this.model.generateRoadmap(ids, { startProperty: start, endProperty: end, monthNames: months, name });
204
+ }
205
+
206
+ /** An element's box keeps room for its type badge, and Archi's height. */
207
+ textFit(template) {
208
+ return this._elementOf(template.id) ? { padding: 44, min: { width: 80, height: 55 } } : super.textFit(template);
209
+ }
210
+
211
+ /** Text becomes elements of any type of this version (the viewpoint's, when it limits them). */
212
+ shapeTypes() {
213
+ const viewpoint = this.nodeOptions.viewpoint;
214
+ const allowed = viewpoint && viewpoint !== 'All' ? ArchimateViewpoints.allowedElementTypes(viewpoint, this.version, this.customViewpoints) : null;
215
+ return ArchimateNotation.elementTypeNames(this.version)
216
+ .filter(t => !allowed || allowed.includes(t))
217
+ .map(t => [t, typeTitle(t)]);
218
+ }
219
+
220
+ /**
221
+ * New model elements from text, each with its box here: one undo step.
222
+ * A documentation column becomes the element's documentation.
223
+ */
224
+ createShapesCommand(items) {
225
+ const ids = this.model.elements().map(e => e.id);
226
+ const steps = [], createdIds = [];
227
+ for (const item of items) {
228
+ const id = GraphDiagram.generateNodeId(`${item.type}-${item.name}`, ids);
229
+ ids.push(id);
230
+ const instance = new ElementInstance(id, item.x, item.y);
231
+ createdIds.push(instance.id);
232
+ steps.push(new AddToViewCommand(this.view, instance, { element: { id, name: item.name, type: item.type, ...(item.documentation && { documentation: item.documentation }) } }));
233
+ }
234
+ const cmd = new CompositeCommand(`Add ${steps.length} element${steps.length === 1 ? '' : 's'} from text`, steps);
235
+ cmd.createdIds = createdIds;
236
+ return cmd;
237
+ }
238
+
239
+ /** What elements can be coloured by: their layer, type, specialization, and each property any of them has. */
240
+ colourKeys(templates = [...this.templates.values()]) {
241
+ const properties = new Set();
242
+ 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);
243
+ return [
244
+ ['layer', 'Layer'], ['type', 'Type'], ['specialization', 'Specialization'],
245
+ ...[...properties].sort().map(name => [`property:${name}`, `Property "${name}"`]),
246
+ ];
247
+ }
248
+
249
+ /** An element's value for a colourKeys key (notes and pictures have none). */
250
+ colourValue(template, key) {
251
+ const element = this._elementOf(template.id);
252
+ if (!element) return undefined;
253
+ if (key === 'layer') return typeTitle(treeLayerOf(element));
254
+ if (key === 'type') return typeTitle(element.type ?? '');
255
+ if (key === 'specialization') return element.specialization;
256
+ if (key.startsWith('property:')) {
257
+ const p = (element.properties ?? []).find(q => (q.name ?? q.key) === key.slice(9));
258
+ return p?.value === undefined || p?.value === null ? undefined : String(p.value);
259
+ }
260
+ return super.colourValue(template, key);
261
+ }
262
+
263
+ /** The kind legend: each element type on the view, in the colour of its first box. */
264
+ kindLegendEntries() {
265
+ const types = new Map();
266
+ for (const t of this.templates.values()) {
267
+ const element = this._elementOf(t.id);
268
+ if (!element || types.has(element.type)) continue;
269
+ types.set(element.type, t.fillColor);
270
+ }
271
+ return [...types].map(([type, fillColor]) => ({ label: typeTitle(type), fillColor }));
272
+ }
273
+
274
+ /** A legend is notes (stickies on a canvas) on the view: one PlaceItemsCommand. */
275
+ legendCommand(entries, title) {
276
+ if (!entries.length) return null;
277
+ const kind = this.view.constructor.BOX_KINDS.find(k => k.key === 'note') ?? this.view.constructor.BOX_KINDS.find(k => k.key === 'sticky');
278
+ if (!kind) return null;
279
+ const { x, y } = this.legendPosition();
280
+ const stamp = Date.now().toString(36);
281
+ const box = (i, name, fillColor) => {
282
+ const inst = new ViewObjectInstance(kind.content(`legend-${stamp}-${i}`, { name }), x + 60 + i * 130, y + 20);
283
+ inst.size = { width: 120, height: 40 };
284
+ inst.appearance = { ...kind.appearance, fillColor };
285
+ return inst;
286
+ };
287
+ const boxes = [box(0, title, '#ffffff'), ...entries.map((e, i) => box(i + 1, e.label, e.fillColor))];
288
+ return new PlaceItemsCommand(this.view, { elementInstances: boxes }, { label: `Add legend: ${title}` });
289
+ }
290
+
291
+ /** Likely duplicates can be merged (Merge…: choose the one to keep). */
292
+ findingActions(finding) {
293
+ return finding.code === 'possible-duplicate' ? [{ label: 'Merge…', action: () => this.askMerge(finding.ids) }] : [];
294
+ }
295
+
296
+ /**
297
+ * Ask which of some elements to keep, then merge the others into it
298
+ * (ArchimateModel#mergeCommand), one undo step. Each choice says where the
299
+ * element is used, to help pick.
300
+ */
301
+ askMerge(elementIds) {
302
+ const elements = elementIds.map(id => this.model.getElement(id)).filter(Boolean);
303
+ if (elements.length < 2) return null;
304
+ const used = (e) => {
305
+ const views = this.model.views().filter(v => v.elementInstancesFor?.(e.id).length).length;
306
+ const { incoming, outgoing } = this.model.relationshipsFor(e.id);
307
+ return `${views} view${views === 1 ? '' : 's'}, ${incoming.length + outgoing.length} relationship${incoming.length + outgoing.length === 1 ? '' : 's'}`;
308
+ };
309
+ return new FormDialog({
310
+ title: `Merge ${elements.length} elements`,
311
+ description: 'The one kept takes the others\' relationships, their boxes on every view, and the documentation and properties it lacks; the others go.',
312
+ fields: [{ key: 'keep', label: 'Keep', type: 'select', value: elements[0].id, choices: elements.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) }],
313
+ submitLabel: 'Merge',
314
+ idPrefix: 'merge-',
315
+ document: this.document ?? globalThis.document,
316
+ onApply: ({ keep }) => this.mergeElements(keep, elementIds.filter(id => id !== keep)),
317
+ }).open();
318
+ }
319
+
320
+ /** Merge elements into one (`keepId`), one undo step; the canvas and tree redraw. */
321
+ mergeElements(keepId, otherIds) {
322
+ const cmd = this.model.mergeCommand(keepId, otherIds);
323
+ this._executeCommand(cmd);
324
+ return cmd;
325
+ }
326
+
327
+ /** The checks use this editor's ArchiMate version. */
328
+ checkerOptions() {
329
+ return { version: this.version };
330
+ }
331
+
332
+ /** An element is shown by selecting its first occurrence on this view. */
333
+ revealFinding(finding) {
334
+ if (finding.kind !== 'element') return super.revealFinding(finding);
335
+ const instance = this.view.elementInstancesFor(finding.ids[0])[0];
336
+ if (instance && this.templates.has(instance.id)) {
337
+ this.selectTemplate(instance.id);
338
+ this.render();
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Bring the editor's templates and lines in line with the view, reusing
344
+ * the template or line of each instance still there.
345
+ */
346
+ syncFromView() {
347
+ const { view, model } = this;
348
+ const templates = new Map();
349
+ for (const inst of view.elementInstances.values()) {
350
+ if (!inst.shownIn(view) || !inst.resolve(model)) continue;
351
+ const existing = this.templates.get(inst.id);
352
+ templates.set(inst.id, existing?.instance === inst ? existing
353
+ : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
354
+ }
355
+ for (const t of templates.values()) t.nodeOptions = this.nodeOptions;
356
+ // The view's groups (Archi groups: model folders with bounds on this view).
357
+ for (const folder of view.placedFolders) {
358
+ const existing = this.templates.get(folder.id);
359
+ templates.set(folder.id, existing instanceof ViewGroupTemplate && existing.view === view ? existing : new ViewGroupTemplate(folder.id, view));
360
+ }
361
+ const edges = new Map();
362
+ // A line a nesting stands for, to a box nested in its source (at any depth), is not drawn unless asked (Archi's way).
363
+ const nestedOut = (edge) => !this.nodeOptions.showNestedLines
364
+ && hiddenByNesting(view, edge.relationship, templates.get(edge.sourceId).instance, templates.get(edge.targetId).instance);
365
+ for (const inst of view.relationshipInstances.values()) {
366
+ const existing = this.edges.get(inst.id);
367
+ const edge = existing?.instance === inst ? existing : new ArchimateEdge(inst, view);
368
+ if (edge.relationship && templates.has(edge.sourceId) && templates.has(edge.targetId) && !nestedOut(edge)) edges.set(inst.id, edge);
369
+ }
370
+ // Plain lines (a canvas's connections, a line from a note).
371
+ for (const line of view.viewConnections.values()) {
372
+ const existing = this.edges.get(line.id);
373
+ const edge = existing?.line === line ? existing : new ViewLineEdge(line);
374
+ if (line.visible !== false && templates.has(edge.sourceId) && templates.has(edge.targetId)) edges.set(line.id, edge);
375
+ }
376
+ // Derived relationships (⚙ Settings → Show derived): drawn between elements both on the view.
377
+ if (this.nodeOptions.showDerived) {
378
+ const shown = (elementId) => view.elementInstancesFor(elementId).find(inst => templates.has(inst.id))?.id;
379
+ for (const derived of DerivedRelationships.compute(model, { maxHops: 2 }).derived) {
380
+ const from = shown(derived.source), to = shown(derived.target);
381
+ if (from && to) edges.set(derived.id, new DerivedEdge(derived, from, to));
382
+ }
383
+ }
384
+ this.templates.clear();
385
+ for (const [id, t] of templates) this.templates.set(id, t);
386
+ this.edges.clear();
387
+ for (const [id, e] of edges) this.edges.set(id, e);
388
+ }
389
+
390
+ render() {
391
+ if (this.disposed) return;
392
+ this.syncFromView();
393
+ super.render();
394
+ }
395
+
396
+ /** ⚙ Settings: the notation (version, distinct shapes), then the base editor's grid and line styles. */
397
+ settingsMenuItems() {
398
+ const set = (patch) => {
399
+ this.nodeOptions = { ...this.nodeOptions, ...patch };
400
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
401
+ else this.render();
402
+ };
403
+ const distinct = !!this.nodeOptions.useDistinctShapes;
404
+ const viewpoint = this.nodeOptions.viewpoint ?? 'All';
405
+ const mode = this.nodeOptions.viewpointMode ?? 'fade';
406
+ return [
407
+ {
408
+ label: `Version: ArchiMate ${this.version}`,
409
+ items: ['4', '3.2'].map(v => ({ label: `ArchiMate ${v}`, checked: v === this.version, action: () => set({ archimateVersion: v }) })),
410
+ },
411
+ { label: `Distinct shapes: ${distinct ? 'on' : 'off'}`, action: () => set({ useDistinctShapes: !distinct }) },
412
+ { label: `Show derived: ${this.nodeOptions.showDerived ? 'on' : 'off'}`, action: () => set({ showDerived: !this.nodeOptions.showDerived }) },
413
+ { label: `Lines to nested boxes: ${this.nodeOptions.showNestedLines ? 'shown' : 'hidden'}`, action: () => set({ showNestedLines: !this.nodeOptions.showNestedLines }) },
414
+ {
415
+ label: `Viewpoint: ${viewpoint}`,
416
+ items: ArchimateViewpoints.all(this.customViewpoints).map(vp => ({ label: vp.name, checked: vp.name === viewpoint, action: () => set({ viewpoint: vp.name }) })),
417
+ },
418
+ {
419
+ label: `Outside the viewpoint: ${mode === 'hide' ? 'hidden' : 'faded'}`,
420
+ items: [['fade', 'Faded'], ['hide', 'Hidden']].map(([m, text]) => ({ label: text, checked: m === mode, action: () => set({ viewpointMode: m }) })),
421
+ },
422
+ { separator: true },
423
+ { separator: true },
424
+ { label: 'Specializations…', action: () => this.editSpecializations() },
425
+ { label: 'Viewpoints…', action: () => this.editViewpoints() },
426
+ ...super.settingsMenuItems(),
427
+ ...(this.settingsManager ? [{ separator: true }, { label: 'Model settings…', action: () => showGlobalSettingsDialog(this.document ?? globalThis.document, this.settingsManager, 'default') }] : []),
428
+ ];
429
+ }
430
+
431
+ /**
432
+ * What the ArchiMate rules say against a relationship's line, or null
433
+ * (ArchimateValidator, for this ArchiMate version). Off when the view's
434
+ * `invalidRelationships` setting is 'off'; with 'tooltip' it is the tooltip, not drawn red.
435
+ */
436
+ edgeWarning(edge) {
437
+ const relationship = edge.relationship;
438
+ const mode = this.viewSettings().invalidRelationships ?? 'highlight';
439
+ if (!relationship || mode === 'off') return null;
440
+ const from = this.model.getElement(relationship.source), to = this.model.getElement(relationship.target);
441
+ if (!from || !to) return null;
442
+ const result = ArchimateValidator.validateRelationship(from, to, relationship.kind, this.version);
443
+ return result.valid ? null : { message: result.message, highlight: mode === 'highlight' };
444
+ }
445
+
446
+ /** Edit the specialization catalog; elements given one redraw with it. */
447
+ editSpecializations() {
448
+ return specializationCatalogDialog({ version: this.version, document: this.document ?? globalThis.document, onChange: () => this._notationChanged() }).open();
449
+ }
450
+
451
+ /** Edit the user's own viewpoints; they are offered (and applied) from then on. */
452
+ editViewpoints() {
453
+ return CustomViewpoints.dialog({
454
+ version: this.version, document: this.document ?? globalThis.document,
455
+ onChange: (viewpoints) => { this.customViewpoints = viewpoints; this._viewpointRule = null; this._notationChanged(); },
456
+ }).open();
457
+ }
458
+
459
+ /** The notation's catalogs changed: the host redraws (its palette too), or this editor does. @private */
460
+ _notationChanged() {
461
+ if (this.onNodeOptionsChange) this.onNodeOptionsChange(this.nodeOptions);
462
+ else this.render();
463
+ }
464
+
465
+ /**
466
+ * The base editor's rules (a highlight), then the active filter's result
467
+ * (ElementFilterRule) and the viewpoint (ViewpointRule, from the notation options).
468
+ */
469
+ emphasisRules() {
470
+ const { viewpoint, viewpointMode } = this.nodeOptions;
471
+ const key = `${viewpoint}|${viewpointMode}|${this.version}`;
472
+ if (this._viewpointRule?.key !== key) {
473
+ this._viewpointRule = { key, rule: new ViewpointRule({ viewpoint, mode: viewpointMode, version: this.version, customViewpoints: this.customViewpoints }) };
474
+ }
475
+ return [...super.emphasisRules(), ...(this.elementFilter ? [this.elementFilter] : []), ...(this._viewpointRule.rule.viewpoint ? [this._viewpointRule.rule] : [])];
476
+ }
477
+
478
+ /** Dim every model element but these (a filter's result); null clears it. Redraws. */
479
+ setElementFilter(elementIds) {
480
+ this.elementFilter = elementIds ? new ElementFilterRule(elementIds) : null;
481
+ this.render();
482
+ }
483
+
484
+ /**
485
+ * The Element section of a shape's properties: the model element's own
486
+ * name, type, specialization, icon, documentation and typed properties (the same on every view;
487
+ * the dialog's Name above labels this occurrence only). None for a note,
488
+ * image or canvas box.
489
+ */
490
+ conceptFields(template) {
491
+ const element = this._elementOf(template.id);
492
+ if (!element) return [];
493
+ return [
494
+ { type: 'heading', label: 'Element (on every view)' },
495
+ ...elementPropertyFields(element, { version: this.version, document: this.document ?? globalThis.document }),
496
+ ];
497
+ }
498
+
499
+ /** The element's changes as one EditViewItemCommand (null when nothing changed). */
500
+ conceptCommand(template, values) {
501
+ const element = this._elementOf(template.id);
502
+ return element ? elementEditCommand(element, values, { instance: template.instance }) : null;
503
+ }
504
+
505
+ /**
506
+ * With several shapes selected, the Elements section: their type, their
507
+ * specialization when they share a type, and one property to set on all.
508
+ * Only when every selected shape is an element.
509
+ */
510
+ manyConceptFields(templates) {
511
+ const elements = this._elementsOf(templates);
512
+ return elements ? manyElementsFields(elements, { version: this.version }) : [];
513
+ }
514
+
515
+ /** The elements' changes as one step (null when nothing changed). */
516
+ manyConceptCommand(templates, values) {
517
+ const elements = this._elementsOf(templates);
518
+ const instances = new Map(templates.map(t => [this._elementOf(t.id), t.instance]));
519
+ const commands = elements ? manyElementsEditCommands(elements, values, { instanceOf: (e) => instances.get(e) }) : [];
520
+ return commands.length ? new CompositeCommand(`Edit ${commands.length} elements`, commands) : null;
521
+ }
522
+
523
+ /** The model elements the shapes show, each once; null unless every shape shows one. @private */
524
+ _elementsOf(templates) {
525
+ const elements = templates.map(t => this._elementOf(t.id));
526
+ return elements.every(Boolean) ? [...new Set(elements)] : null;
527
+ }
528
+
529
+ /**
530
+ * The Relationship section of a line's properties: its kind (among those
531
+ * the ArchiMate rules allow between its ends, and its own), documentation
532
+ * and typed properties. None for a plain line or a derived one.
533
+ */
534
+ edgeConceptFields(edge) {
535
+ const relationship = edge.relationship;
536
+ if (!relationship || edge.editable === false) return [];
537
+ const kinds = [...new Set([relationship.kind, ...this.allowedKinds(edge.sourceId, edge.targetId)].filter(Boolean))];
538
+ return [
539
+ { type: 'heading', label: 'Relationship (on every view)' },
540
+ { key: 'relationship.kind', label: 'Kind', type: 'select', value: relationship.kind, choices: kinds.map(k => [k, typeTitle(k)]) },
541
+ { key: 'relationship.documentation', label: 'Documentation', type: 'textarea', rows: 3, value: relationship.documentation ?? '' },
542
+ { type: 'heading', label: 'Properties' },
543
+ PropertyListEditor.field(relationship.properties, { key: 'relationship.properties', document: this.document ?? globalThis.document }),
544
+ ];
545
+ }
546
+
547
+ /** The relationship's changes as one EditViewItemCommand (null when nothing changed). */
548
+ edgeConceptCommand(edge, values) {
549
+ return edge.relationship ? conceptEditCommand(edge.relationship, values, { prefix: 'relationship', instance: edge.instance }) : null;
550
+ }
551
+
552
+ /** A new element of `type` at `point` (snapped), created in the model and shown here. One undo step. */
553
+ addElement(type, point) {
554
+ const name = typeTitle(type);
555
+ const id = GraphDiagram.generateNodeId(name, this.model.elements().map(e => e.id));
556
+ const at = this._snapToGrid(point);
557
+ const instance = new ElementInstance(id, at.x, at.y);
558
+ this._executeCommand(new AddToViewCommand(this.view, instance, { element: { id, name, type }, label: `Add ${name}` }));
559
+ return instance;
560
+ }
561
+
562
+ /**
563
+ * Show an element the model has at `point` (snapped), with its
564
+ * relationships to the elements already on the view (ShowElementCommand).
565
+ * One undo step. Null for an element the model lacks.
566
+ */
567
+ showElement(elementId, point) {
568
+ if (!this.model.getElement(elementId)) return null;
569
+ const at = this._snapToGrid(point);
570
+ const instance = new ElementInstance(elementId, at.x, at.y);
571
+ this._executeCommand(new ShowElementCommand(this.view, instance, { label: `Show ${this.model.getElement(elementId).name || elementId}` }));
572
+ this.selectTemplate(instance.id);
573
+ return instance;
574
+ }
575
+
576
+ /**
577
+ * A picture as an image box on this view at `point` (snapped), one undo
578
+ * step, selected. Null when this kind of view has no image box.
579
+ * @param {string} dataURL - The picture
580
+ */
581
+ addImage(dataURL, point) {
582
+ if (!this.view.constructor.BOX_KINDS?.some(k => k.key === 'image')) return null;
583
+ const box = this.view.addBox('image', this._snapToGrid(point), { iconUrl: dataURL });
584
+ this.render();
585
+ this.selectTemplate(box.id);
586
+ return box;
587
+ }
588
+
589
+ /**
590
+ * Add a box of a canvas palette kind ('block', 'sticky', 'image') at `point`
591
+ * (snapped), as the canvas menu would. One undo step.
592
+ */
593
+ addCanvasBox(kindKey, point) {
594
+ const item = canvasBoxMenuItems(this.view, this._snapToGrid(point), {
595
+ onAdded: (box) => { this.render(); this.selectTemplate(box.id); },
596
+ }).find((_, i) => this.view.constructor.BOX_KINDS[i].key === kindKey);
597
+ return item?.action();
598
+ }
599
+
600
+ /** The relationship kinds the ArchiMate rules allow from one instance's element to another's (association always). */
601
+ allowedKinds(sourceId, targetId) {
602
+ const from = this._elementOf(sourceId), to = this._elementOf(targetId);
603
+ if (!from || !to) return [];
604
+ const kinds = ArchimateValidator.allowedKinds(from, to, this.version);
605
+ return kinds.includes('association') ? kinds : [...kinds, 'association'];
606
+ }
607
+
608
+ /**
609
+ * Connect two elements: the kind is asked for (`chooseKind`) among those
610
+ * allowed, or taken from KIND_PREFERENCE. One undo step once chosen.
611
+ * @returns {Command|null|Promise<Command|null>}
612
+ */
613
+ connect(sourceId, targetId) {
614
+ if (sourceId === targetId) return null;
615
+ // A box or note at either end: a plain line (the view's rule), one undo step.
616
+ if (!this.view.linesAreRelationships(sourceId, targetId)) {
617
+ if (!this.templates.has(sourceId) || !this.templates.has(targetId)) return null;
618
+ const line = this.view.connect(sourceId, targetId);
619
+ this.render();
620
+ return line;
621
+ }
622
+ const kinds = this.allowedKinds(sourceId, targetId);
623
+ if (!kinds.length) return null;
624
+ const run = (kind) => (kind && kinds.includes(kind) ? this._connectAs(sourceId, targetId, kind) : null);
625
+ if (!this.chooseKind) return run(KIND_PREFERENCE.find(k => kinds.includes(k)) ?? kinds[0]);
626
+ const chosen = this.chooseKind(kinds, this._elementOf(sourceId), this._elementOf(targetId));
627
+ return chosen && typeof chosen.then === 'function' ? chosen.then(run) : run(chosen);
628
+ }
629
+
630
+ /** @private */
631
+ _connectAs(sourceId, targetId, kind) {
632
+ const cmd = this.connectCommand(sourceId, targetId, kind);
633
+ if (cmd) this._executeCommand(cmd);
634
+ return cmd;
635
+ }
636
+
637
+ /** The model element an instance on this canvas shows (notes and images have none). @private */
638
+ /** A box of a model element links to the element's page in a report. */
639
+ reportRef(template) {
640
+ const element = this._elementOf(template.id);
641
+ return element ? { kind: 'element', id: element.id } : null;
642
+ }
643
+
644
+ _elementOf(instanceId) {
645
+ const inst = this.templates.get(instanceId)?.instance;
646
+ return inst ? this.model.getElement(inst.elementId) : undefined;
647
+ }
648
+
649
+ /** A new relationship of `kind` between two elements' instances, created in the model and shown here. */
650
+ connectCommand(sourceId, targetId, kind = KIND_PREFERENCE.find(k => this.allowedKinds(sourceId, targetId).includes(k))) {
651
+ const source = this.templates.get(sourceId)?.instance;
652
+ const target = this.templates.get(targetId)?.instance;
653
+ const from = this._elementOf(sourceId);
654
+ const to = this._elementOf(targetId);
655
+ // Notes and images are not model elements: they take no relationships.
656
+ if (!from || !to || !kind) return null;
657
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
658
+ const instance = new RelationshipInstance(id, null, source.id, target.id);
659
+ return new AddRelationshipToViewCommand(this.view, instance, {
660
+ relationship: { id, source: from.id, target: to.id, kind },
661
+ label: `Add ${kind}`,
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Deleting takes a box off the view (its lines go with it, see the base's
667
+ * deleteTemplatesCommand); the model keeps its element. A group goes off the
668
+ * view and what it held stays; the model keeps its folder.
669
+ */
670
+ /** Copies are ArchiMate views' items: they paste into ArchiMate views (any model), not the template editor. */
671
+ static clipboardKind = 'archimate';
672
+
673
+ /**
674
+ * A copy of these boxes: the view's JSON of them (ArchimateView#toJSON),
675
+ * with the lines and relationships between them and the elements they show.
676
+ * Groups are not copied (they are model folders).
677
+ */
678
+ copyPayload(ids) {
679
+ const boxes = ids.filter(id => this.view.getElementInstance(id));
680
+ return boxes.length ? this.view.toJSON({ itemIds: boxes }) : null;
681
+ }
682
+
683
+ /** Paste: new boxes for the same elements (added to the model when it lacks them), one undo step. */
684
+ pasteCommand(payload, options) {
685
+ return this.view.pasteCommand(payload, options);
686
+ }
687
+
688
+ /** Deleting is off the view: the model keeps what it showed. */
689
+ deleteLabel(templateIds) {
690
+ return `${super.deleteLabel(templateIds)} from view`;
691
+ }
692
+
693
+ removeTemplateCommand(templateId) {
694
+ return this.templates.get(templateId) instanceof ViewGroupTemplate
695
+ ? new RemoveGroupFromViewCommand(this.view, templateId)
696
+ : new DeleteElementCommand(this.view, templateId);
697
+ }
698
+
699
+ /**
700
+ * What moves with a shape: for a group, the elements drawn in it (its folder's),
701
+ * 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.
702
+ */
703
+ containedIn(templateId) {
704
+ const template = this.templates.get(templateId);
705
+ const view = this.view;
706
+ if (template instanceof ViewGroupTemplate) {
707
+ const inner = [];
708
+ for (const t of this.templates.values()) {
709
+ if (t instanceof ViewGroupTemplate) {
710
+ if (t.id !== templateId && view.placedFolders.find(f => f.id === t.id)?.parentId === templateId) inner.push(t.id);
711
+ } else if (t.instance?.archi?.parent === templateId
712
+ || (t.instance?.elementId && view.placedFolderOf(t.instance.elementId) === templateId)) {
713
+ inner.push(t.id);
714
+ }
715
+ }
716
+ return inner;
717
+ }
718
+ const visualId = template?.instance?.visualId;
719
+ if (!visualId) return [];
720
+ return [...this.templates.values()].filter(t => t.instance?.nestedIn === visualId).map(t => t.id);
721
+ }
722
+
723
+ // ===== Nesting =====
724
+
725
+ /** The box this one is drawn inside: an element's box, or a canvas block (a group holds by folder, not here). */
726
+ parentOf(templateId) {
727
+ const visualId = this.templates.get(templateId)?.instance?.nestedIn;
728
+ if (!visualId) return null;
729
+ const parent = [...this.templates.values()].find(t => t.instance?.visualId === visualId);
730
+ return parent?.id ?? null;
731
+ }
732
+
733
+ /**
734
+ * An element's parts on this view: the elements it is composed of or
735
+ * aggregates (its composition and aggregation relationships' targets),
736
+ * each by its first box here.
737
+ */
738
+ partsOf(templateId) {
739
+ const element = this._elementOf(templateId);
740
+ if (!element) return [];
741
+ const parts = this.model.relationships()
742
+ .filter(r => r.source === element.id && r.target !== element.id && (r.kind === 'composition' || r.kind === 'aggregation'))
743
+ .map(r => this.view.elementInstancesFor(r.target)[0]?.id)
744
+ .filter(id => id && this.templates.has(id) && this.canHold(templateId, id));
745
+ return [...new Set(parts)];
746
+ }
747
+
748
+ /** The "by kind" layout puts an element's box in its layer's row (notes and images in their own). */
749
+ layoutCategory(template) {
750
+ const element = this._elementOf(template.id);
751
+ return element ? treeLayerOf(element) : 'other';
752
+ }
753
+
754
+ /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
755
+ canHold(parentId, childId) {
756
+ const parent = this.templates.get(parentId), child = this.templates.get(childId);
757
+ if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
758
+ return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
759
+ }
760
+
761
+ /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
762
+ nestsOnDrop(parentId, childId) {
763
+ if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
764
+ return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
765
+ }
766
+
767
+ nestCommand(childId, parentId) {
768
+ const child = this.templates.get(childId)?.instance;
769
+ if (!child) return null;
770
+ return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
771
+ }
772
+
773
+ /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
774
+ nested(changes) {
775
+ let chain = Promise.resolve();
776
+ for (const { childId, parentId } of changes) {
777
+ chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
778
+ }
779
+ return chain;
780
+ }
781
+
782
+ /**
783
+ * The kinds a nesting can stand for, from parent to child, as Archi offers
784
+ * them: those of NESTING_KINDS the rules allow. None when the two already
785
+ * have such a relationship.
786
+ */
787
+ nestingKinds(parentId, childId) {
788
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
789
+ if (!from || !to || from === to) return [];
790
+ if (nestingRelationships(this.model, from.id, to.id).length) return [];
791
+ const allowed = this.allowedKinds(parentId, childId);
792
+ return NESTING_KINDS.filter(k => allowed.includes(k));
793
+ }
794
+
795
+ /**
796
+ * Ask which relationship a nesting stands for (`chooseKind`, with the
797
+ * reason 'nesting'; none chosen adds none) and add it to the model, drawn
798
+ * on no view: the nesting shows it. One undo step. Without `chooseKind`, nothing is added.
799
+ * @returns {Promise<Command|null>}
800
+ */
801
+ async offerNestingRelationship(parentId, childId) {
802
+ const kinds = this.nestingKinds(parentId, childId);
803
+ if (!kinds.length || !this.chooseKind) return null;
804
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
805
+ const kind = await this.chooseKind(kinds, from, to, { reason: 'nesting' });
806
+ if (!kind || !kinds.includes(kind)) return null;
807
+ const cmd = this.nestingRelationshipCommand(parentId, childId, kind);
808
+ this._executeCommand(cmd);
809
+ return cmd;
810
+ }
811
+
812
+ /** The relationship of `kind` a nesting stands for, from parent to child, in the model only. */
813
+ nestingRelationshipCommand(parentId, childId, kind) {
814
+ const from = this._elementOf(parentId), to = this._elementOf(childId);
815
+ const id = GraphDiagram.generateNodeId(`${from.id}-${kind}-${to.id}`, this.model.relationships().map(r => r.id));
816
+ return new AddRelationshipCommand(this.model, { id, source: from.id, target: to.id, kind }, `Add ${kind} (nested)`);
817
+ }
818
+
819
+ /** Deleting a line takes it off the view: a plain line goes; a relationship stays in the model. */
820
+ deleteEdgeCommand(edgeId) {
821
+ return this.view.viewConnections.has(edgeId)
822
+ ? new RemoveLineCommand(this.view, edgeId)
823
+ : new RemoveRelationshipFromViewCommand(this.view, edgeId);
824
+ }
825
+ }