@peter.prib/renderengine 0.2.3 → 0.2.4

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.
@@ -1,1155 +1,1170 @@
1
- import { ElementInstance } from './ElementInstance.js';
2
- import { ViewObjectInstance, instanceFromJSON } from './ViewObjectInstance.js';
3
- import { RelationshipInstance } from './RelationshipInstance.js';
4
- import { ViewConnectionInstance } from './ViewConnectionInstance.js';
5
- import { DiagramEditorState } from '../DiagramEditorState.js';
6
- import { ViewSetModel } from '../diagramModel/ViewSetModel.js';
7
- import { ArchimateModelChecker } from './ArchimateModelChecker.js';
8
- import { ArchimateModelReport } from './ArchimateModelReport.js';
9
- import { ArchimateModelSearch } from './ArchimateModelSearch.js';
10
- import { MergeElementsCommand } from './MergeElementsCommand.js';
11
- import { roadmapPlan, parseDate } from './roadmap.js';
12
- import { PlaceItemsCommand } from './PlaceItemsCommand.js';
13
- import { DIAGRAM_BOX_KINDS } from '../canvas/diagramBoxKinds.js';
14
- import { RemoveLineCommand } from './RemoveLineCommand.js';
15
- import { CompositeCommand } from '../CommandHistory.js';
16
- import { AddToViewCommand, DeleteElementCommand, AddRelationshipToViewCommand, RemoveRelationshipFromViewCommand } from './ArchimateCommands.js';
17
-
18
- /**
19
- * Canonical, id-indexed home for ArchiMate model data (elements, relationships, views).
20
- * Replaces ad-hoc flat-array handling and fixes multi-view import flattening.
21
- *
22
- * ARCHITECTURE: Model layer is view-agnostic. Elements and relationships have NO x/y/labelOverride/
23
- * or visibility properties. Those belong in ElementInstance/RelationshipInstance (view layer).
24
- */
25
-
26
- export class ArchimateModel extends ViewSetModel {
27
- constructor({ id = undefined, name = '', elements = [], relationships = [], folders = [], elementFolders = [], views = [], documentation = undefined, properties = [], archi = null } = {}) {
28
- // Views (create, duplicate, rename, delete, switch, change listeners) come from ViewSetModel.
29
- super({ idPrefix: 'view', viewNoun: 'View' });
30
- this.name = name;
31
- this._elements = new Map(); // id -> element object
32
- this._relationships = new Map(); // id -> relationship object
33
- this._folders = new Map(); // id -> folder object {id, name, documentation, parentId, children[]}
34
- /** The model's id (an Archi file's, kept on export); undefined for a new model. */
35
- this.id = id;
36
- /** The model's own documentation (Archi's "purpose") and properties ([{key, value}], keys may repeat). */
37
- this.documentation = documentation;
38
- this.properties = properties.map(p => ({ ...p }));
39
- /** What an Archi file said about the model that it does not use (profiles, ...), written back on export. */
40
- this.archi = archi;
41
- this._elementFolder = new Map(); // elementId -> folderId (which folder contains this element)
42
-
43
- // Ingest elements
44
- for (const el of elements) {
45
- if (!el.id) throw new TypeError('Element must have an id');
46
- this._validateElementNoViewProperties(el);
47
- this._elements.set(el.id, { ...el });
48
- }
49
-
50
- // Ingest relationships; auto-assign id if missing
51
- for (let i = 0; i < relationships.length; i++) {
52
- const rel = relationships[i];
53
- const id = rel.id ?? `rel-${i}`;
54
- if (this._relationships.has(id)) {
55
- throw new TypeError(`Duplicate relationship id: "${id}"`);
56
- }
57
- this._relationships.set(id, { ...rel, id });
58
- }
59
-
60
- // Ingest folders (top-level only; hierarchy maintained in folder.children)
61
- for (const folder of folders) {
62
- if (!folder.id) throw new TypeError('Folder must have an id');
63
- this._folders.set(folder.id, { ...folder });
64
- }
65
-
66
- // Ingest element-to-folder mappings
67
- for (const [elementId, folderId] of elementFolders) {
68
- this._elementFolder.set(elementId, folderId);
69
- }
70
-
71
- // Ingest views; the first is current.
72
- for (const viewSpec of views) {
73
- const view = this._adopt(this._createView(viewSpec));
74
- this._views.set(view.id, view);
75
- }
76
- this.currentViewId = views.length ? this.views()[0].id : null;
77
- }
78
-
79
- /** A new view starts empty: elements are added to it, not inherited. */
80
- _newView(id, title) {
81
- return new ArchimateView(this, { id, name: title, elementIds: [] });
82
- }
83
-
84
- /**
85
- * The view for a spec: the class registered for its Archi view type
86
- * (registerViewType: CanvasView for 'canvas-model'), else an ArchimateView.
87
- * @protected
88
- */
89
- _createView(spec) {
90
- const ViewClass = ArchimateModel.VIEW_TYPES.get(spec?.archiViewType) ?? ArchimateView;
91
- return new ViewClass(this, spec);
92
- }
93
-
94
- /** A view class for an Archi view type ('canvas-model', ...). A subclass of ArchimateView registers itself. */
95
- static registerViewType(archiViewType, ViewClass) {
96
- ArchimateModel.VIEW_TYPES.set(archiViewType, ViewClass);
97
- }
98
-
99
- /** Canvas views that are not templates, in order. */
100
- canvases() {
101
- return this.views().filter(v => v.constructor.kind === 'canvas' && !v.template);
102
- }
103
-
104
- /** The model's own templates (canvas views kept to copy from), in order. */
105
- templates() {
106
- return this.views().filter(v => v.template);
107
- }
108
-
109
- /** ArchiMate (and sketch) views: every view that is not a canvas or a template. */
110
- diagramViews() {
111
- return this.views().filter(v => v.constructor.kind !== 'canvas' && !v.template);
112
- }
113
-
114
- /**
115
- * A new canvas made from a template: a model template (a view) or a library
116
- * entry's `view` (CanvasView JSON). It gets new ids and the template's boxes and
117
- * lines; the boxes are its background (one BackgroundGroup: behind everything
118
- * added later, moved as one, locked until unlocked). It becomes the current view.
119
- * @param {ArchimateView|Object} template - A template view, or CanvasView JSON
120
- * @param {string} [title]
121
- * @returns {ArchimateView}
122
- */
123
- newCanvasFromTemplate(template, title) {
124
- const canvas = this._canvasFrom(template, title);
125
- // What the template draws is the canvas's background: one group, behind everything, locked.
126
- canvas.arrangeAsBackground(canvas.elementInstances.keys(), { name: `${template.name ?? template.title ?? 'Template'} (template)` });
127
- return this._add(canvas);
128
- }
129
-
130
- /**
131
- * A new view of some elements and what they are related to (Archi's
132
- * "Generate View For"): the elements in the middle, each related element
133
- * around them, and every relationship between what is shown (a view made
134
- * from a layout draws them all). It becomes
135
- * the current view.
136
- * @param {Array<string>} elementIds
137
- * @param {Object} [options]
138
- * @param {string} [options.name] - Defaults to the first element's name
139
- * @param {boolean} [options.related] - Bring in the elements they are related to (default true)
140
- * @returns {ArchimateView|null} the view, or null if no element is known
141
- */
142
- generateView(elementIds, { name, related = true } = {}) {
143
- const focus = [...new Set(elementIds)].filter(id => this.getElement(id));
144
- if (!focus.length) return null;
145
- const neighbours = [];
146
- if (related) {
147
- const seen = new Set(focus);
148
- for (const id of focus) {
149
- const { incoming, outgoing } = this.relationshipsFor(id);
150
- for (const r of [...incoming, ...outgoing]) {
151
- const other = r.source === id ? r.target : r.source;
152
- if (!seen.has(other) && this.getElement(other)) { seen.add(other); neighbours.push(other); }
153
- }
154
- }
155
- }
156
- // The focus in a row through the middle; the related ones on a circle around it.
157
- const GAP = 180, centre = { x: 500, y: 400 };
158
- const radius = Math.max(260, (focus.length * GAP) / 2 + 160, (neighbours.length * 150) / (2 * Math.PI));
159
- const layout = {};
160
- focus.forEach((id, i) => { layout[id] = { x: centre.x + (i - (focus.length - 1) / 2) * GAP, y: centre.y }; });
161
- neighbours.forEach((id, i) => {
162
- const angle = -Math.PI / 2 + (2 * Math.PI * i) / neighbours.length;
163
- layout[id] = { x: Math.round(centre.x + radius * Math.cos(angle)), y: Math.round(centre.y + radius * Math.sin(angle)) };
164
- });
165
- const view = this._createView({
166
- id: this._newId(),
167
- name: name ?? `${this.getElement(focus[0]).name || 'Element'} (generated)`,
168
- elementIds: [...focus, ...neighbours],
169
- layout,
170
- });
171
- return this._add(view);
172
- }
173
-
174
- /**
175
- * A new view of elements on a time line (a roadmap): each element with a
176
- * start and an end date (its `startProperty` and `endProperty` properties,
177
- * ISO or day/month/year) a bar from one to the other, under a header of
178
- * years and months; bars that overlap in time in lanes below each other.
179
- * Elements without both dates (or ending before they start) are left out
180
- * and listed. It becomes the current view; one undo step.
181
- * @param {Array<string>} elementIds
182
- * @param {Object} [options]
183
- * @param {string} [options.startProperty] - Default 'Start Date'
184
- * @param {string} [options.endProperty] - Default 'End Date'
185
- * @param {string} [options.name] - The view's name
186
- * @param {'short'|'full'} [options.monthNames]
187
- * @returns {{view: (ArchimateView|null), skipped: Array<Object>}} the view (null when no element has dates), and the elements left out
188
- */
189
- generateRoadmap(elementIds, { startProperty = 'Start Date', endProperty = 'End Date', name = 'Roadmap', monthNames = 'short' } = {}) {
190
- const prop = (e, key) => (e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value;
191
- const elements = [...new Set(elementIds)].map(id => this.getElement(id)).filter(Boolean);
192
- const items = elements.map(e => ({ id: e.id, element: e, start: parseDate(prop(e, startProperty)), end: parseDate(prop(e, endProperty)) }));
193
- const dated = items.filter(i => i.start && i.end && i.end >= i.start);
194
- const skipped = items.filter(i => !dated.includes(i)).map(i => i.element);
195
- if (!dated.length) return { view: null, skipped };
196
- const plan = roadmapPlan(dated, { monthNames, monthWidth: monthNames === 'full' ? 90 : 60 });
197
- const view = this._createView({ id: this._newId(), name, elementIds: dated.map(i => i.id) });
198
- const note = (key, label, box, extra = {}) => {
199
- const inst = new ViewObjectInstance({ id: `${view.id}-${key}`, name: label, label, shape: 'note' }, box.x + box.width / 2, box.y + box.height / 2);
200
- inst.size = { width: box.width, height: box.height };
201
- inst.appearance = { fillColor: '#ffffff', textAlign: 'center', ...extra };
202
- inst.zIndex = -1;
203
- return inst;
204
- };
205
- view.elementInstances.clear();
206
- plan.years.forEach((y, i) => { const n = note(`year-${i}`, y.label, y, { fontWeight: 'bold' }); view.elementInstances.set(n.id, n); });
207
- plan.months.forEach((m, i) => {
208
- const header = note(`month-${i}`, m.label, m);
209
- const column = note(`column-${i}`, '', { x: m.x, y: m.y + m.height, width: m.width, height: plan.height - 2 * m.height });
210
- column.appearance = { fillColor: i % 2 ? '#f5f5f5' : '#ffffff', strokeColor: '#dddddd' };
211
- column.zIndex = -2;
212
- view.elementInstances.set(header.id, header);
213
- view.elementInstances.set(column.id, column);
214
- });
215
- for (const bar of plan.bars) {
216
- const inst = new ElementInstance(bar.id, bar.x + bar.width / 2, bar.y + bar.height / 2);
217
- inst.size = { width: bar.width, height: bar.height };
218
- inst.appearance = { fontSize: 9 };
219
- view.elementInstances.set(inst.id, inst);
220
- }
221
- view.relationshipInstances?.clear?.();
222
- return { view: this._add(view), skipped };
223
- }
224
-
225
- /**
226
- * The checks this model is held to: the view-set ones and ArchiMate's own
227
- * (ArchimateModelChecker).
228
- * @param {{version?: string}} [options] - The ArchiMate version for the relationship check
229
- */
230
- checker(options) {
231
- return new ArchimateModelChecker(this, options);
232
- }
233
-
234
- /**
235
- * Make elements that are one thing twice into one: `keepId` takes the
236
- * others' relationships, boxes, missing documentation and properties
237
- * (MergeElementsCommand, not yet executed; put it on `history`).
238
- */
239
- mergeCommand(keepId, otherIds) {
240
- return new MergeElementsCommand(this, keepId, otherIds);
241
- }
242
-
243
- /** Find and replace: the views, notes, elements and relationships (ArchimateModelSearch). */
244
- search() {
245
- return new ArchimateModelSearch(this);
246
- }
247
-
248
- /** The model's report: its views, elements and relationships (ArchimateModelReport). */
249
- report(options) {
250
- return new ArchimateModelReport(this, options);
251
- }
252
-
253
- /** A new, empty canvas; it becomes the current view. */
254
- createCanvas(title = `Canvas ${this.canvases().length + 1}`) {
255
- return this._add(this._createView({ id: this._newId(), name: title, archiViewType: 'canvas-model' }));
256
- }
257
-
258
- /**
259
- * Keep a copy of a canvas as one of the model's templates. The current
260
- * view stays as it is.
261
- * @param {string} viewId - A canvas
262
- * @param {string} [title] - Defaults to the canvas's name
263
- * @param {{itemIds?: Iterable<string>}} [options] - Only these boxes (and the lines between them)
264
- * @returns {ArchimateView|null} the template, or null if `viewId` is not a canvas
265
- */
266
- saveAsTemplate(viewId, title, { itemIds = null } = {}) {
267
- const view = this.getView(viewId);
268
- if (view?.constructor.kind !== 'canvas') return null;
269
- // Some of its boxes only (a selection, a group): their JSON, with the lines between them.
270
- return this.addTemplate(itemIds ? view.toJSON({ itemIds }) : view, title ?? (itemIds ? `${view.name} (part)` : view.name));
271
- }
272
-
273
- /**
274
- * A model template copied from a canvas, a template, or a library entry's
275
- * `view` (CanvasView JSON). The current view stays as it is.
276
- * @param {ArchimateView|Object} source
277
- * @param {string} [title]
278
- * @returns {ArchimateView}
279
- */
280
- addTemplate(source, title) {
281
- const template = this._canvasFrom(source, title);
282
- template.template = true;
283
- return this._add(template, { current: false });
284
- }
285
-
286
- /** A new canvas (not a template) copied from a view or from CanvasView JSON. @private */
287
- _canvasFrom(source, title) {
288
- const id = this._newId();
289
- const name = title ?? source.name ?? source.title ?? 'Canvas';
290
- return typeof source.copy === 'function'
291
- ? source.copy(id, name)
292
- : ArchimateModel.VIEW_TYPES.get('canvas-model').fromJSON(this, source, { id, name });
293
- }
294
-
295
- /** Get element by id, or undefined. */
296
- getElement(id) {
297
- return this._elements.get(id);
298
- }
299
-
300
- /**
301
- * Add an element to the model. Position and other per-view state belong on
302
- * the ElementInstance, so they are not kept here.
303
- * @returns {Object} the stored element
304
- */
305
- addElement(el) {
306
- if (!el?.id) throw new TypeError('Element must have an id');
307
- if (this._elements.has(el.id)) throw new TypeError(`Duplicate element id: "${el.id}"`);
308
- const { x, y, labelOverride, visible, diagramInstanceId, ...modelEl } = el;
309
- this._elements.set(el.id, modelEl);
310
- return modelEl;
311
- }
312
-
313
- /** Remove an element from the model (views keep any instances; they no longer resolve). */
314
- removeElement(id) {
315
- this._elements.delete(id);
316
- this._elementFolder.delete(id);
317
- }
318
-
319
- /** Add a relationship to the model. @returns {Object} the stored relationship */
320
- addRelationship(rel) {
321
- if (!rel?.id) throw new TypeError('Relationship must have an id');
322
- if (this._relationships.has(rel.id)) throw new TypeError(`Duplicate relationship id: "${rel.id}"`);
323
- const stored = { ...rel };
324
- this._relationships.set(rel.id, stored);
325
- return stored;
326
- }
327
-
328
- /** Remove a relationship from the model (views keep any instances; they no longer resolve). */
329
- removeRelationship(id) {
330
- this._relationships.delete(id);
331
- }
332
-
333
- /** Get relationship by id, or undefined. */
334
- getRelationship(id) {
335
- return this._relationships.get(id);
336
- }
337
-
338
- /** Get folder by id, or undefined. */
339
- getFolder(id) {
340
- return this._folders.get(id);
341
- }
342
-
343
- /** Get the folder id containing an element, or undefined. */
344
- getElementFolder(elementId) {
345
- return this._elementFolder.get(elementId);
346
- }
347
-
348
- /** Set which folder contains an element. */
349
- setElementFolder(elementId, folderId) {
350
- if (folderId) {
351
- this._elementFolder.set(elementId, folderId);
352
- } else {
353
- this._elementFolder.delete(elementId);
354
- }
355
- }
356
-
357
- /** Get all elements in a folder (direct children only, not recursive). */
358
- getElementsInFolder(folderId) {
359
- const result = [];
360
- for (const [elementId, folderIdForElement] of this._elementFolder.entries()) {
361
- if (folderIdForElement === folderId) {
362
- const el = this._elements.get(elementId);
363
- if (el) result.push(el);
364
- }
365
- }
366
- return result;
367
- }
368
-
369
- /** All elements, as a flat array. */
370
- elements() {
371
- return Array.from(this._elements.values());
372
- }
373
-
374
- /** All relationships, as a flat array. */
375
- relationships() {
376
- return Array.from(this._relationships.values());
377
- }
378
-
379
- /** Top-level folders only (children tracked within folder.children). */
380
- folders() {
381
- return Array.from(this._folders.values()).filter(f => !f.parentId);
382
- }
383
-
384
- /** All folders including nested (flat array), each after its parent. Nesting
385
- * is read from `parentId`, which every folder carries (`children` may not be kept). */
386
- allFolders() {
387
- const all = Array.from(this._folders.values());
388
- const byParent = new Map();
389
- for (const f of all) {
390
- if (f.parentId) byParent.set(f.parentId, [...(byParent.get(f.parentId) ?? []), f]);
391
- }
392
- const result = [];
393
- const walk = (folder) => {
394
- result.push(folder);
395
- (byParent.get(folder.id) ?? []).forEach(walk);
396
- };
397
- all.filter(f => !f.parentId || !this._folders.has(f.parentId)).forEach(walk);
398
- return result;
399
- }
400
-
401
- /** Incoming and outgoing relationships for an element. */
402
- relationshipsFor(elementId) {
403
- const rels = Array.from(this._relationships.values());
404
- return {
405
- incoming: rels.filter(r => r.target === elementId),
406
- outgoing: rels.filter(r => r.source === elementId),
407
- };
408
- }
409
-
410
- /**
411
- * Validate that an element has no view-specific properties.
412
- * View-specific state (position, label override, visibility) belongs in ElementInstance,
413
- * not in base model elements.
414
- * @private
415
- */
416
- _validateElementNoViewProperties(el) {
417
- const viewProps = ['x', 'y', 'labelOverride', 'visible', 'diagramInstanceId'];
418
- for (const prop of viewProps) {
419
- if (prop in el) {
420
- console.warn(`WARNING: Element "${el.id}" has view-property "${prop}"; this should be in ElementInstance, not the model.`);
421
- }
422
- }
423
- }
424
-
425
- /** Add a view from a spec (as the constructor takes them) and make it current. */
426
- addView(viewSpec) {
427
- return this._add(this._createView(viewSpec));
428
- }
429
-
430
- /** Views that include a given element (or all views if elementId is null). */
431
- viewsContaining(elementId) {
432
- if (!elementId) return this.views();
433
- return this.views().filter(v => v.includesElement(elementId));
434
- }
435
-
436
- /** Convert from an Archi-format parsed model (with .elements, .relationships, .folders, .views). */
437
- static fromArchiModel(archiModel, mappers = {}) {
438
- const { mapElement = el => el, mapRelationship = rel => rel } = mappers;
439
- const elements = (archiModel.elements || []).map(mapElement);
440
- const relationships = (archiModel.relationships || []).map(mapRelationship);
441
- const folders = archiModel.folders || [];
442
- const elementFolders = archiModel.elementFolders || [];
443
- const views = (archiModel.views || []).map(view => ({
444
- id: view.id,
445
- name: view.name,
446
- elementIds: view.elementIds,
447
- viewpoint: view.viewpoint,
448
- viewpointMode: view.viewpointMode,
449
- layout: view.layout || {},
450
- edgeLayout: view.edgeLayout || {},
451
- folderLayout: view.folderLayout || {},
452
- diagramInstances: view.diagramInstances || [],
453
- }));
454
- return new ArchimateModel({ elements, relationships, folders, elementFolders, views });
455
- }
456
- }
457
-
458
- /** View classes by Archi view type; see ArchimateModel.registerViewType. */
459
- ArchimateModel.VIEW_TYPES = new Map();
460
-
461
- /**
462
- * New boxes like these, for a copied view: each gets a new id and loses its
463
- * Archi id (Archi's ids are unique in a model), and a box drawn inside another
464
- * stays inside that box's copy.
465
- * @param {Array<Object>} items - Instances' JSON (toJSON), each with its `id`
466
- * @returns {{ elementInstances: Map<string, Object>, instanceIds: Map<string, string> }}
467
- * the copies by id, and each source id's copy id
468
- */
469
- export function copyViewInstances(items) {
470
- const instanceIds = new Map();
471
- const elementInstances = new Map();
472
- const copyIdOfArchiId = new Map();
473
- for (const item of items) {
474
- const twin = instanceFromJSON(JSON.parse(JSON.stringify(item)));
475
- if (twin.archi?.id) {
476
- copyIdOfArchiId.set(twin.archi.id, twin.id);
477
- delete twin.archi.id;
478
- }
479
- instanceIds.set(item.id, twin.id);
480
- elementInstances.set(twin.id, twin);
481
- }
482
- // A nested box names its parent's Archi id; the parent's copy is exported as diag-<its id>.
483
- for (const twin of elementInstances.values()) {
484
- const parent = twin.archi?.parent;
485
- if (parent && copyIdOfArchiId.has(parent)) twin.archi.parent = `diag-${copyIdOfArchiId.get(parent)}`;
486
- }
487
- return { elementInstances, instanceIds };
488
- }
489
-
490
- export class ArchimateView extends DiagramEditorState {
491
- /** A counter for ids of boxes placed in this page (with the time, unique). */
492
- static placed = 1;
493
-
494
- /** Fields the view adds to what it hands the renderer (`elements`/`relationships`): bookkeeping, not properties. */
495
- /** The kind of view: 'archimate' here, 'canvas' for a CanvasView. */
496
- static kind = 'archimate';
497
-
498
- /** The boxes this kind of view adds besides elements (CanvasBoxKind): Archi's Note and Image here. */
499
- static BOX_KINDS = DIAGRAM_BOX_KINDS;
500
-
501
- /** Archi's class for a plain line drawn on this kind of view. */
502
- static LINE_TYPE = 'diagram-model-connection';
503
-
504
- /** Lines drawn so far: part of each new line's id. @private */
505
- static _lines = 1;
506
-
507
- static PROJECTION_KEYS = ['elementId', 'relationshipId', 'diagramInstanceId', 'visible', 'zIndex', 'viewObject', 'locked', 'moveWith', 'resizable'];
508
-
509
- constructor(model, { id = null, name = '', elementIds = null, viewpoint = null, viewpointMode = null, layout = {}, edgeLayout = {}, folderLayout = {}, diagramInstances = [], elementInstances = null, relationshipInstances = null, viewObjects = [], viewConnections = [], archiViewType = 'archimate-diagram-model', properties = [], archiFolder = undefined, archi = null, arrangement = null, documentation = undefined, settings = undefined } = {}) {
510
- super({ arrangement });
511
- if (!id) throw new TypeError('View must have an id');
512
- this.model = model;
513
- /** @type {Map<string, ElementInstance>} what this view draws, by instance id */
514
- this.elementInstances = new Map();
515
- /** @type {Map<string, RelationshipInstance>} */
516
- this.relationshipInstances = new Map();
517
- this.id = id;
518
- this.name = name;
519
- this.documentation = documentation;
520
- // This view's level of the settings cascade (SettingsManager reads and writes it).
521
- if (settings) this._settings = { ...settings };
522
- this.elementIds = elementIds; // null = "all elements"
523
- this.viewpoint = viewpoint;
524
- this.viewpointMode = viewpointMode;
525
- this.folderLayout = folderLayout; // { folderId -> {x,y,width,height,collapsed}, etc } — per-view folder bounds/state
526
-
527
- // Migrate old formats to new instance-based approach
528
- this._migrateToInstances(layout, edgeLayout, diagramInstances, elementInstances, relationshipInstances);
529
-
530
- // Notes and images that belong to this view only ({content, x, y}, e.g. from an Archi import).
531
- for (const { content, x = 0, y = 0 } of viewObjects) {
532
- const inst = new ViewObjectInstance(content, x, y);
533
- this.elementInstances.set(inst.id, inst);
534
- }
535
-
536
- /** @type {Map<string, ViewConnectionInstance>} lines on this view with no relationship behind them */
537
- this.viewConnections = new Map();
538
- for (const c of viewConnections) {
539
- const inst = c instanceof ViewConnectionInstance ? c : ViewConnectionInstance.fromJSON(c);
540
- this.viewConnections.set(inst.id, inst);
541
- }
542
- /** Archi's class for the view ('archimate-diagram-model', 'canvas-model', 'sketch-model'), kept for export. */
543
- this.archiViewType = archiViewType;
544
- /** The view's properties, [{key, value}] (keys may repeat). */
545
- this.properties = properties.map(p => ({ ...p }));
546
- /** The Views sub-folders an Archi file kept it in ([{id, name, ...}]), kept for export. */
547
- this.archiFolder = archiFolder;
548
- /** What an Archi file said about the view that the model does not use (hintTitle, viewpoint, ...). */
549
- this.archi = archi;
550
-
551
- // Legacy property kept for backward compatibility (read-only)
552
- this.layout = layout;
553
- }
554
-
555
- /**
556
- * Migrate from old layout/diagramInstances format to new ElementInstance/RelationshipInstance collections.
557
- * @private
558
- */
559
- _migrateToInstances(layout, edgeLayout, diagramInstances, elementInstances, relationshipInstances) {
560
- // If new instance maps provided, use them
561
- if (elementInstances instanceof Map) {
562
- this.elementInstances = new Map(elementInstances);
563
- } else if (elementInstances && typeof elementInstances === 'object') {
564
- // If array or object provided, convert to Map
565
- if (Array.isArray(elementInstances)) {
566
- for (const data of elementInstances) {
567
- const inst = instanceFromJSON(data);
568
- this.elementInstances.set(inst.id, inst);
569
- }
570
- }
571
- }
572
-
573
- // Migrate old diagramInstances array
574
- if (Array.isArray(diagramInstances) && diagramInstances.length > 0) {
575
- for (const inst of diagramInstances) {
576
- const elInst = new ElementInstance(inst.elementId, inst.x, inst.y, inst.labelOverride);
577
- this.elementInstances.set(elInst.id, elInst);
578
- }
579
- }
580
-
581
- // Migrate old layout object (fallback if no instances yet)
582
- if (layout && Object.keys(layout).length > 0 && this.elementInstances.size === 0) {
583
- for (const [elementId, pos] of Object.entries(layout)) {
584
- const inst = new ElementInstance(elementId, pos.x || 0, pos.y || 0);
585
- this.elementInstances.set(inst.id, inst);
586
- }
587
- }
588
-
589
- // Migrate old edgeLayout to relationship instances
590
- if (relationshipInstances instanceof Map) {
591
- this.relationshipInstances = new Map(relationshipInstances);
592
- } else if (relationshipInstances && typeof relationshipInstances === 'object') {
593
- if (Array.isArray(relationshipInstances)) {
594
- for (const data of relationshipInstances) {
595
- const inst = RelationshipInstance.fromJSON(data);
596
- this.relationshipInstances.set(inst.id, inst);
597
- }
598
- }
599
- }
600
-
601
- // A view given its instances (an import, a copy) draws exactly those, even
602
- // none; otherwise it draws its filter's elements and the lines between them.
603
- if (elementInstances == null && this.elementInstances.size === 0) {
604
- this._createDefaultElementInstances();
605
- }
606
- if (relationshipInstances == null && this.relationshipInstances.size === 0) {
607
- this._createDefaultRelationshipInstances();
608
- }
609
-
610
- this._applyEdgeLayout(edgeLayout);
611
- }
612
-
613
- /**
614
- * An imported `edgeLayout` ({relationshipId: {waypoints}}) gives a route to
615
- * every instance of that relationship that has none of its own. An entry
616
- * whose waypoints are absent means "straight", not "inherit".
617
- * @private
618
- */
619
- _applyEdgeLayout(edgeLayout = {}) {
620
- for (const [relationshipId, entry] of Object.entries(edgeLayout ?? {})) {
621
- if (!entry || !('waypoints' in entry)) continue;
622
- for (const inst of this.relationshipInstancesFor(relationshipId)) {
623
- if (inst.waypoints === null) inst.waypoints = (entry.waypoints ?? []).map(wp => ({ x: wp.x, y: wp.y }));
624
- }
625
- }
626
- }
627
-
628
- /**
629
- * Create default element instances for all elements in the view (on first creation).
630
- * Uses layout if available, otherwise positions at 0,0.
631
- * @private
632
- */
633
- _createDefaultElementInstances() {
634
- const elements = this.model.elements();
635
- const filtered = this.elementIds !== null ? elements.filter(el => this.elementIds.includes(el.id)) : elements;
636
-
637
- for (const el of filtered) {
638
- // Don't overwrite existing instances - they contain user modifications!
639
- // Only create if no instances exist for this element
640
- const existingInstances = Array.from(this.elementInstances.values())
641
- .filter(inst => inst.elementId === el.id);
642
- if (existingInstances.length > 0) {
643
- continue; // Skip - instances already exist for this element
644
- }
645
-
646
- const layoutPos = (this.layout && this.layout[el.id]) || {};
647
- const x = layoutPos.x !== undefined ? layoutPos.x : 0;
648
- const y = layoutPos.y !== undefined ? layoutPos.y : 0;
649
- const inst = new ElementInstance(el.id, x, y);
650
- this.elementInstances.set(inst.id, inst);
651
- }
652
- }
653
-
654
- /**
655
- * Create default relationship instances for all relationships in the view (on first creation).
656
- * Skips if view has no elements yet (new empty view) - relationships will be added via confirmation prompt.
657
- * @private
658
- */
659
- _createDefaultRelationshipInstances() {
660
- const relationships = this.model.relationships();
661
- for (const rel of relationships) {
662
- if (this.includesElement(rel.source) && this.includesElement(rel.target)) {
663
- const inst = new RelationshipInstance(rel.id);
664
- this.relationshipInstances.set(inst.id, inst);
665
- }
666
- }
667
- }
668
-
669
- // ==================== Instance Management ====================
670
-
671
- /**
672
- * Get element instance by ID.
673
- * @param {string} instanceId - The stable diagram instance ID
674
- * @returns {ElementInstance|undefined}
675
- */
676
- getElementInstance(instanceId) {
677
- return this.elementInstances.get(instanceId);
678
- }
679
-
680
- /**
681
- * Get relationship instance by ID.
682
- * @param {string} instanceId - The stable diagram instance ID
683
- * @returns {RelationshipInstance|undefined}
684
- */
685
- getRelationshipInstance(instanceId) {
686
- return this.relationshipInstances.get(instanceId);
687
- }
688
-
689
- /**
690
- * Add an element instance to this view.
691
- * @param {ElementInstance} instance
692
- */
693
- addElementInstance(instance) {
694
- if (!(instance instanceof ElementInstance)) {
695
- throw new TypeError('Expected ElementInstance');
696
- }
697
- this.elementInstances.set(instance.id, instance);
698
- }
699
-
700
- /**
701
- * Remove an element instance from this view.
702
- * @param {string} instanceId
703
- */
704
- removeElementInstance(instanceId) {
705
- this.elementInstances.delete(instanceId);
706
- }
707
-
708
- /**
709
- * Add a relationship instance to this view.
710
- * @param {RelationshipInstance} instance
711
- */
712
- addRelationshipInstance(instance) {
713
- if (!(instance instanceof RelationshipInstance)) {
714
- throw new TypeError('Expected RelationshipInstance');
715
- }
716
- this.relationshipInstances.set(instance.id, instance);
717
- }
718
-
719
- /**
720
- * Remove a relationship instance from this view.
721
- * @param {string} instanceId
722
- */
723
- removeRelationshipInstance(instanceId) {
724
- this.relationshipInstances.delete(instanceId);
725
- }
726
-
727
- /**
728
- * Get all instances of a specific model element in this view.
729
- * @param {string} elementId - The model element ID
730
- * @returns {ElementInstance[]}
731
- */
732
- elementInstancesFor(elementId) {
733
- return Array.from(this.elementInstances.values())
734
- .filter(inst => inst.elementId === elementId);
735
- }
736
-
737
- /**
738
- * Get all instances of a specific relationship in this view.
739
- * @param {string} relationshipId - The model relationship ID
740
- * @returns {RelationshipInstance[]}
741
- */
742
- relationshipInstancesFor(relationshipId) {
743
- return Array.from(this.relationshipInstances.values())
744
- .filter(inst => inst.relationshipId === relationshipId);
745
- }
746
-
747
- /**
748
- * Clear all element instances.
749
- */
750
- clearElementInstances() {
751
- this.elementInstances.clear();
752
- }
753
-
754
- /**
755
- * Clear all relationship instances.
756
- */
757
- clearRelationshipInstances() {
758
- this.relationshipInstances.clear();
759
- }
760
-
761
- /** A view's title is its name (ViewSetModel works with titles). */
762
- get title() { return this.name; }
763
- set title(value) { this.name = value; }
764
-
765
- /**
766
- * An independent copy under a new id: every element and relationship
767
- * instance is copied (new instance ids, relationships re-linked to the
768
- * copied ends), with the element filter, viewpoint, documentation, view
769
- * settings and folder layout.
770
- * History starts empty.
771
- */
772
- copy(id, title) {
773
- const { elementInstances, instanceIds } = copyViewInstances([...this.elementInstances.values()].map(i => ({ ...i.toJSON(), id: i.id })));
774
- const relink = (instanceId) => instanceId == null ? null : (instanceIds.get(instanceId) ?? instanceId);
775
- const relationshipInstances = new Map();
776
- for (const inst of this.relationshipInstances.values()) {
777
- const data = JSON.parse(JSON.stringify(inst.toJSON()));
778
- const twin = RelationshipInstance.fromJSON({ ...data, sourceInstanceId: relink(data.sourceInstanceId), targetInstanceId: relink(data.targetInstanceId) });
779
- if (twin.archi) delete twin.archi.id;
780
- relationshipInstances.set(twin.id, twin);
781
- }
782
- const viewConnections = [...this.viewConnections.values()].map(c => {
783
- const twin = ViewConnectionInstance.fromJSON({
784
- ...JSON.parse(JSON.stringify(c.toJSON())),
785
- id: `${c.id}-${id}`,
786
- sourceInstanceId: relink(c.sourceInstanceId),
787
- targetInstanceId: relink(c.targetInstanceId),
788
- });
789
- if (twin.archi) delete twin.archi.id;
790
- return twin;
791
- });
792
- return new this.constructor(this.model, {
793
- id,
794
- name: title,
795
- elementIds: this.elementIds ? [...this.elementIds] : null,
796
- viewpoint: this.viewpoint,
797
- viewpointMode: this.viewpointMode,
798
- documentation: this.documentation,
799
- settings: this._settings,
800
- folderLayout: JSON.parse(JSON.stringify(this.folderLayout ?? {})),
801
- elementInstances,
802
- relationshipInstances,
803
- viewConnections,
804
- archiViewType: this.archiViewType,
805
- properties: this.properties,
806
- archiFolder: this.archiFolder,
807
- archi: this.archi ? JSON.parse(JSON.stringify(this.archi)) : null,
808
- arrangement: this.arrangement.copy(relink), // the copy's instances have new ids
809
- });
810
- }
811
-
812
- /** Element instances stack (relationships are drawn between them). */
813
- layerItems() {
814
- return Array.from(this.elementInstances.values());
815
- }
816
-
817
- /** True if this view includes the element (null elementIds = all). */
818
- includesElement(id) {
819
- if (!this.elementIds) return true;
820
- return this.elementIds.includes(id);
821
- }
822
-
823
- /**
824
- * Whether a line drawn between two items here is a relationship (both ends
825
- * show model elements) or a plain line (a note, a canvas box or an image at
826
- * either end), as in Archi.
827
- */
828
- linesAreRelationships(sourceId, targetId) {
829
- const ends = [sourceId, targetId].map(id => this.getElementInstance(id));
830
- return ends.every(inst => inst && !inst.content && this.model.getElement(inst.elementId));
831
- }
832
-
833
- /** A new plain line between two items, of this view's line class (not yet on the view). */
834
- newLine(sourceId, targetId, { label = '' } = {}) {
835
- const id = `line-${Date.now().toString(36)}-${ArchimateView._lines++}`;
836
- return new ViewConnectionInstance({ id, sourceInstanceId: sourceId, targetInstanceId: targetId, label, archiType: this.constructor.LINE_TYPE });
837
- }
838
-
839
- /** Draw a plain line between two items: one undo step. @returns {ViewConnectionInstance} */
840
- connect(sourceId, targetId, options = {}) {
841
- const line = this.newLine(sourceId, targetId, options);
842
- this.history.execute(new PlaceItemsCommand(this, { elementInstances: [], viewConnections: [line] }, { label: 'Connect' }));
843
- return line;
844
- }
845
-
846
- /** Elements in this view, resolved from instances. Each instance is a fresh, independent projection. */
847
- get elements() {
848
- const result = [];
849
-
850
- for (const instance of this.elementInstances.values()) {
851
- // The element filter (null = all) applies to model elements, not view objects.
852
- if (!instance.shownIn(this)) continue;
853
- // A fresh copy each time: what the instance draws, placed for this view.
854
- const viewElement = instance.project(this.model);
855
- if (!viewElement) continue;
856
- // Fixed in place (the view's arrangement): the renderer will not drag or resize it.
857
- if (this.arrangement.isLocked(instance.id)) Object.assign(viewElement, { locked: true, resizable: false });
858
- // In a group: the renderer drags its members with it.
859
- const together = this.arrangement.movingWith(instance.id);
860
- if (together.size > 1) viewElement.moveWith = [...together];
861
- result.push(viewElement);
862
- }
863
-
864
- // Sort by zIndex so elements render in correct stacking order (lower zIndex first = background)
865
- result.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
866
-
867
- return result;
868
- }
869
-
870
- /** Relationships in this view, resolved from instances. Returns fresh copies to prevent mutations. */
871
- get relationships() {
872
- const result = [];
873
-
874
- for (const instance of this.relationshipInstances.values()) {
875
- const baseRel = this.model.getRelationship(instance.relationshipId);
876
- if (!baseRel) {
877
- continue;
878
- }
879
-
880
- // Skip if endpoints aren't in this view
881
- if (!this.includesElement(baseRel.source) || !this.includesElement(baseRel.target)) {
882
- continue;
883
- }
884
-
885
- const viewRel = { ...baseRel };
886
- viewRel.id = instance.id; // Use instance ID so rendering engine can track each relationship instance uniquely
887
- viewRel.relationshipId = baseRel.id; // Keep base relationship ID for reference
888
-
889
- // Update source/target to use instance IDs
890
- // If this relationship instance tracks specific source/target instances, use those
891
- if (instance.sourceInstanceId && instance.targetInstanceId) {
892
- viewRel.source = instance.sourceInstanceId;
893
- viewRel.target = instance.targetInstanceId;
894
- } else {
895
- // Fallback: use first instance of each element (for backward compatibility)
896
- const sourceInstances = this.elementInstancesFor(baseRel.source);
897
- const targetInstances = this.elementInstancesFor(baseRel.target);
898
- if (sourceInstances.length > 0) {
899
- viewRel.source = sourceInstances[0].id;
900
- }
901
- if (targetInstances.length > 0) {
902
- viewRel.target = targetInstances[0].id;
903
- }
904
- }
905
-
906
- // This instance's own route wins; null inherits the model's. A deliberately
907
- // straight line ([]) reads as no waypoints at all.
908
- if (instance.waypoints) {
909
- viewRel.waypoints = instance.waypoints.length ? instance.waypoints.map(wp => ({ ...wp })) : undefined;
910
- } else {
911
- viewRel.waypoints = baseRel.waypoints ? baseRel.waypoints.map(wp => ({ ...wp })) : [];
912
- }
913
- if (instance.sourceSide) viewRel.sourceSide = instance.sourceSide;
914
- if (instance.targetSide) viewRel.targetSide = instance.targetSide;
915
- // This view's line colour, in the renderer's name for it.
916
- if (instance.appearance?.strokeColor) viewRel.color = instance.appearance.strokeColor;
917
-
918
- viewRel.visible = instance.visible;
919
- viewRel.diagramInstanceId = instance.id;
920
- result.push(viewRel);
921
- }
922
-
923
- // Plain lines (no relationship behind them), drawn while both their boxes are.
924
- for (const connection of this.viewConnections.values()) {
925
- const edge = connection.project(this);
926
- if (edge) result.push(edge);
927
- }
928
- return result;
929
- }
930
-
931
- /** Top-level folders in this view, with per-view folderLayout applied.
932
- * Returns fresh copies with bounds/state from folderLayout. */
933
- get folders() {
934
- const topLevel = this.model.folders();
935
- return topLevel.map(folder => {
936
- const viewFolder = { ...folder };
937
- // Apply per-view folder layout (bounds, collapsed state, etc.)
938
- const folderOverride = this.folderLayout[folder.id];
939
- if (folderOverride) {
940
- Object.assign(viewFolder, folderOverride);
941
- }
942
- return viewFolder;
943
- });
944
- }
945
-
946
- /**
947
- * The folders drawn on this view as groups: those with bounds in its
948
- * `folderLayout`, bounds applied, each after its parent. `parentId` is the
949
- * nearest ancestor that is also drawn here (a group inside an undrawn
950
- * folder sits on the view itself).
951
- */
952
- get placedFolders() {
953
- const placed = this.model.allFolders().filter(f => this.folderLayout[f.id]);
954
- const drawn = new Set(placed.map(f => f.id));
955
- return placed.map(folder => ({
956
- ...folder,
957
- ...this.folderLayout[folder.id],
958
- parentId: this._nearestPlaced(folder.parentId, drawn),
959
- }));
960
- }
961
-
962
- /** The group on this view an element is drawn in: its folder, or that folder's nearest drawn ancestor. */
963
- placedFolderOf(elementId) {
964
- const drawn = new Set(this.model.allFolders().filter(f => this.folderLayout[f.id]).map(f => f.id));
965
- return this._nearestPlaced(this.model.getElementFolder(elementId), drawn);
966
- }
967
-
968
- /** @private */
969
- _nearestPlaced(folderId, drawn) {
970
- for (let id = folderId; id; id = this.model.getFolder(id)?.parentId) {
971
- if (drawn.has(id)) return id;
972
- }
973
- return undefined;
974
- }
975
-
976
- // ===== Undoable edits (this view's history) =====
977
-
978
- /**
979
- * Everything needed to make this view's items again elsewhere: its boxes
980
- * (with their ids), the lines and relationships drawn between them, their
981
- * groups and locks, and the model elements and relationships they show (so
982
- * another model can be given them); its documentation, properties and what
983
- * Archi said besides. Plain JSON. A canvas template is this; so is what is
984
- * copied to the clipboard.
985
- * @param {Object} [options]
986
- * @param {Iterable<string>} [options.itemIds] - Only these boxes (a selection): the lines
987
- * between them, and their groups and locks
988
- */
989
- toJSON({ itemIds = null } = {}) {
990
- const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
991
- const keep = itemIds ? new Set(itemIds) : null;
992
- const kept = (id) => !keep || keep.has(id);
993
- const boxes = [...this.elementInstances.values()].filter(i => kept(i.id));
994
- // A line's ends: the boxes it names, or (none named) its elements' first boxes, as it is drawn.
995
- const endsOf = (r) => {
996
- const rel = this.model.getRelationship(r.relationshipId);
997
- return [r.sourceInstanceId ?? this.elementInstancesFor(rel?.source)[0]?.id, r.targetInstanceId ?? this.elementInstancesFor(rel?.target)[0]?.id];
998
- };
999
- const lines = [...this.relationshipInstances.values()]
1000
- .map(r => ({ r, ends: endsOf(r) }))
1001
- .filter(({ ends: [from, to] }) => from && to && kept(from) && kept(to))
1002
- .map(({ r, ends: [from, to] }) => Object.assign(RelationshipInstance.fromJSON(r.toJSON()), { sourceInstanceId: from, targetInstanceId: to }));
1003
- const elementIds = new Set(boxes.map(b => b.elementId).filter(id => this.model.getElement(id)));
1004
- return {
1005
- name: this.name,
1006
- documentation: this.documentation,
1007
- properties: copy(this.properties),
1008
- archi: copy(this.archi ?? undefined),
1009
- elementInstances: boxes.map(i => ({ ...copy(i.toJSON()), id: i.id })),
1010
- viewConnections: [...this.viewConnections.values()]
1011
- .filter(c => kept(c.sourceInstanceId) && kept(c.targetInstanceId)).map(c => copy(c.toJSON())),
1012
- relationshipInstances: lines.map(r => copy(r.toJSON())),
1013
- elements: [...elementIds].map(id => copy(this.model.getElement(id))),
1014
- relationships: [...new Set(lines.map(r => r.relationshipId))].map(id => this.model.getRelationship(id)).filter(Boolean).map(copy),
1015
- arrangement: (keep ? this.arrangement.only(keep) : this.arrangement).toJSON(),
1016
- };
1017
- }
1018
-
1019
- /**
1020
- * New boxes and lines like a view's JSON (toJSON): new ids, Archi's ids
1021
- * left with the source, each line and relationship joined to the copies of
1022
- * its ends.
1023
- * @param {Object} data
1024
- * @param {string} salt - Makes the lines' ids new
1025
- * @returns {{ elementInstances: Map, viewConnections: Array, relationshipInstances: Array, mapId: Function }}
1026
- */
1027
- static copyItems(data, salt) {
1028
- const { elementInstances, instanceIds } = copyViewInstances(data.elementInstances ?? []);
1029
- const mapId = (old) => instanceIds.get(old) ?? old;
1030
- const viewConnections = (data.viewConnections ?? []).map(c => {
1031
- const line = ViewConnectionInstance.fromJSON({ ...c, id: `${c.id}-${salt}`, sourceInstanceId: mapId(c.sourceInstanceId), targetInstanceId: mapId(c.targetInstanceId) });
1032
- if (line.archi) delete line.archi.id;
1033
- return line;
1034
- });
1035
- const relationshipInstances = (data.relationshipInstances ?? []).map(r => {
1036
- const line = RelationshipInstance.fromJSON({ ...r, sourceInstanceId: mapId(r.sourceInstanceId), targetInstanceId: mapId(r.targetInstanceId) });
1037
- if (line.archi) delete line.archi.id;
1038
- return line;
1039
- });
1040
- return { elementInstances, viewConnections, relationshipInstances, mapId };
1041
- }
1042
-
1043
- /**
1044
- * Put copies of a view's items (toJSON of a selection, from this view or
1045
- * any other, of this model or another) on this view, moved by dx, dy: new
1046
- * boxes for the same elements, the lines and relationships between them;
1047
- * an element or relationship this model lacks is added with them. In front
1048
- * of what is there. The command is returned, not run.
1049
- * @param {Object} data
1050
- * @param {{dx?: number, dy?: number, label?: string}} [options]
1051
- * @returns {{ command: PlaceItemsCommand, ids: Array<string> }} the command, and the new boxes' ids
1052
- */
1053
- pasteCommand(data, { dx = 0, dy = 0, label = 'Paste' } = {}) {
1054
- const salt = `${this.id}-${Date.now().toString(36)}-${ArchimateView.placed++}`;
1055
- const { elementInstances, viewConnections, relationshipInstances } = ArchimateView.copyItems(data, salt);
1056
- const boxes = [...elementInstances.values()];
1057
- const move = (points) => points?.map(p => ({ x: p.x + dx, y: p.y + dy })) ?? points;
1058
- for (const b of boxes) { b.x += dx; b.y += dy; }
1059
- for (const line of viewConnections) line.waypoints = move(line.waypoints) ?? [];
1060
- for (const line of relationshipInstances) line.waypoints = move(line.waypoints) ?? null;
1061
- const elements = (data.elements ?? []).filter(e => !this.model.getElement(e.id));
1062
- const has = (id) => !!this.model.getElement(id) || elements.some(e => e.id === id);
1063
- const relationships = (data.relationships ?? []).filter(r => !this.model.getRelationship(r.id) && has(r.source) && has(r.target));
1064
- const known = (id) => !!this.model.getRelationship(id) || relationships.some(r => r.id === id);
1065
- const command = new PlaceItemsCommand(this, {
1066
- elementInstances: boxes, viewConnections, relationshipInstances: relationshipInstances.filter(r => known(r.relationshipId)),
1067
- elements, relationships,
1068
- }, { label });
1069
- return { command, ids: boxes.map(b => b.id) };
1070
- }
1071
-
1072
-
1073
- /**
1074
- * Add a box of one of this view's kinds (BOX_KINDS: a note or image here;
1075
- * a canvas's block, sticky or image) centred at `at`: one undo step, in
1076
- * front of what is there.
1077
- * @param {string} kindKey
1078
- * @param {{x: number, y: number}} at
1079
- * @param {{name?: string, iconUrl?: string}} [options] - Its text, its picture
1080
- * @returns {ViewObjectInstance}
1081
- */
1082
- addBox(kindKey, at, options = {}) {
1083
- const kind = this.constructor.BOX_KINDS.find(k => k.key === kindKey);
1084
- if (!kind) throw new TypeError(`No box kind "${kindKey}" on a ${this.constructor.kind} view`);
1085
- const id = `${kind.key}-${Date.now().toString(36)}-${ArchimateView.placed++}`;
1086
- const box = new ViewObjectInstance(kind.content(id, options), at.x, at.y);
1087
- box.size = { width: kind.width, height: kind.height };
1088
- box.appearance = { ...kind.appearance };
1089
- this.history.execute(new PlaceItemsCommand(this, { elementInstances: [box] }, { label: `Add ${kind.label}` }));
1090
- return box;
1091
- }
1092
-
1093
-
1094
- /**
1095
- * Put an instance on this view, creating its model element first when
1096
- * `element` is given. One undo step.
1097
- * @param {ElementInstance} instance
1098
- * @param {Object} [options]
1099
- * @param {Object} [options.element] - A new model element the instance shows
1100
- */
1101
- addToView(instance, { element = null } = {}) {
1102
- this.history.execute(new AddToViewCommand(this, instance, { element }));
1103
- }
1104
-
1105
- /** Take an instance off this view (the model element stays). One undo step. */
1106
- deleteElement(instanceId) {
1107
- this.history.execute(new DeleteElementCommand(this, instanceId));
1108
- }
1109
-
1110
- /**
1111
- * Relationship instances this view could show but does not: one per pairing
1112
- * of an instance of the element with an instance at the other end of each of
1113
- * its relationships, where both ends are in the view.
1114
- * @param {string} elementId - Model element id
1115
- * @returns {RelationshipInstance[]}
1116
- */
1117
- missingRelationshipInstances(elementId) {
1118
- const { incoming, outgoing } = this.model.relationshipsFor(elementId);
1119
- const shown = new Set([...this.relationshipInstances.values()].map(inst => this._pairing(inst)));
1120
- const missing = [];
1121
- for (const rel of [...incoming, ...outgoing]) {
1122
- if (!this.includesElement(rel.source) || !this.includesElement(rel.target)) continue;
1123
- for (const src of this.elementInstancesFor(rel.source)) {
1124
- for (const tgt of this.elementInstancesFor(rel.target)) {
1125
- const inst = new RelationshipInstance(rel.id, null, src.id, tgt.id);
1126
- if (!shown.has(this._pairing(inst))) {
1127
- shown.add(this._pairing(inst));
1128
- missing.push(inst);
1129
- }
1130
- }
1131
- }
1132
- }
1133
- return missing;
1134
- }
1135
-
1136
- /** "relationship|source instance|target instance" an instance draws between. @private */
1137
- _pairing(inst) {
1138
- const rel = this.model.getRelationship(inst.relationshipId);
1139
- const source = inst.sourceInstanceId ?? this.elementInstancesFor(rel?.source)[0]?.id;
1140
- const target = inst.targetInstanceId ?? this.elementInstancesFor(rel?.target)[0]?.id;
1141
- return `${inst.relationshipId}|${source}|${target}`;
1142
- }
1143
-
1144
- /** Show these relationship instances, as one undo step. */
1145
- addRelationshipsToView(instances, label = 'Add Relationships to View') {
1146
- if (!instances.length) return;
1147
- this.history.execute(new CompositeCommand(label, instances.map(inst => new AddRelationshipToViewCommand(this, inst))));
1148
- }
1149
-
1150
- /** Take a relationship instance, or a plain line, off this view (the model relationship stays). One undo step. */
1151
- removeRelationship(instanceId) {
1152
- if (this.viewConnections.has(instanceId)) this.history.execute(new RemoveLineCommand(this, instanceId));
1153
- else this.history.execute(new RemoveRelationshipFromViewCommand(this, instanceId));
1154
- }
1155
- }
1
+ import { ElementInstance } from './ElementInstance.js';
2
+ import { ViewObjectInstance, instanceFromJSON } from './ViewObjectInstance.js';
3
+ import { RelationshipInstance } from './RelationshipInstance.js';
4
+ import { ViewConnectionInstance } from './ViewConnectionInstance.js';
5
+ import { DiagramEditorState } from '../DiagramEditorState.js';
6
+ import { ViewSetModel } from '../diagramModel/ViewSetModel.js';
7
+ import { ArchimateModelChecker } from './ArchimateModelChecker.js';
8
+ import { ArchimateModelReport } from './ArchimateModelReport.js';
9
+ import { ArchimateModelSearch } from './ArchimateModelSearch.js';
10
+ import { MergeElementsCommand } from './MergeElementsCommand.js';
11
+ import { ElementsCompared, RelationshipsCompared } from './ConceptsCompared.js';
12
+ import { ArchimateViewsCompared } from './ArchimateViewsCompared.js';
13
+ import { roadmapPlan, parseDate } from './roadmap.js';
14
+ import { PlaceItemsCommand } from './PlaceItemsCommand.js';
15
+ import { DIAGRAM_BOX_KINDS } from '../canvas/diagramBoxKinds.js';
16
+ import { RemoveLineCommand } from './RemoveLineCommand.js';
17
+ import { CompositeCommand } from '../CommandHistory.js';
18
+ import { AddToViewCommand, DeleteElementCommand, AddRelationshipToViewCommand, RemoveRelationshipFromViewCommand } from './ArchimateCommands.js';
19
+
20
+ /**
21
+ * Canonical, id-indexed home for ArchiMate model data (elements, relationships, views).
22
+ * Replaces ad-hoc flat-array handling and fixes multi-view import flattening.
23
+ *
24
+ * ARCHITECTURE: Model layer is view-agnostic. Elements and relationships have NO x/y/labelOverride/
25
+ * or visibility properties. Those belong in ElementInstance/RelationshipInstance (view layer).
26
+ */
27
+
28
+ export class ArchimateModel extends ViewSetModel {
29
+ constructor({ id = undefined, name = '', elements = [], relationships = [], folders = [], elementFolders = [], views = [], documentation = undefined, properties = [], archi = null } = {}) {
30
+ // Views (create, duplicate, rename, delete, switch, change listeners) come from ViewSetModel.
31
+ super({ idPrefix: 'view', viewNoun: 'View' });
32
+ this.name = name;
33
+ this._elements = new Map(); // id -> element object
34
+ this._relationships = new Map(); // id -> relationship object
35
+ this._folders = new Map(); // id -> folder object {id, name, documentation, parentId, children[]}
36
+ /** The model's id (an Archi file's, kept on export); undefined for a new model. */
37
+ this.id = id;
38
+ /** The model's own documentation (Archi's "purpose") and properties ([{key, value}], keys may repeat). */
39
+ this.documentation = documentation;
40
+ this.properties = properties.map(p => ({ ...p }));
41
+ /** What an Archi file said about the model that it does not use (profiles, ...), written back on export. */
42
+ this.archi = archi;
43
+ this._elementFolder = new Map(); // elementId -> folderId (which folder contains this element)
44
+
45
+ // Ingest elements
46
+ for (const el of elements) {
47
+ if (!el.id) throw new TypeError('Element must have an id');
48
+ this._validateElementNoViewProperties(el);
49
+ this._elements.set(el.id, { ...el });
50
+ }
51
+
52
+ // Ingest relationships; auto-assign id if missing
53
+ for (let i = 0; i < relationships.length; i++) {
54
+ const rel = relationships[i];
55
+ const id = rel.id ?? `rel-${i}`;
56
+ if (this._relationships.has(id)) {
57
+ throw new TypeError(`Duplicate relationship id: "${id}"`);
58
+ }
59
+ this._relationships.set(id, { ...rel, id });
60
+ }
61
+
62
+ // Ingest folders (top-level only; hierarchy maintained in folder.children)
63
+ for (const folder of folders) {
64
+ if (!folder.id) throw new TypeError('Folder must have an id');
65
+ this._folders.set(folder.id, { ...folder });
66
+ }
67
+
68
+ // Ingest element-to-folder mappings
69
+ for (const [elementId, folderId] of elementFolders) {
70
+ this._elementFolder.set(elementId, folderId);
71
+ }
72
+
73
+ // Ingest views; the first is current.
74
+ for (const viewSpec of views) {
75
+ const view = this._adopt(this._createView(viewSpec));
76
+ this._views.set(view.id, view);
77
+ }
78
+ this.currentViewId = views.length ? this.views()[0].id : null;
79
+ }
80
+
81
+ /** A new view starts empty: elements are added to it, not inherited. */
82
+ _newView(id, title) {
83
+ return new ArchimateView(this, { id, name: title, elementIds: [] });
84
+ }
85
+
86
+ /**
87
+ * The view for a spec: the class registered for its Archi view type
88
+ * (registerViewType: CanvasView for 'canvas-model'), else an ArchimateView.
89
+ * @protected
90
+ */
91
+ _createView(spec) {
92
+ const ViewClass = ArchimateModel.VIEW_TYPES.get(spec?.archiViewType) ?? ArchimateView;
93
+ return new ViewClass(this, spec);
94
+ }
95
+
96
+ /** A view class for an Archi view type ('canvas-model', ...). A subclass of ArchimateView registers itself. */
97
+ static registerViewType(archiViewType, ViewClass) {
98
+ ArchimateModel.VIEW_TYPES.set(archiViewType, ViewClass);
99
+ }
100
+
101
+ /** Canvas views that are not templates, in order. */
102
+ canvases() {
103
+ return this.views().filter(v => v.constructor.kind === 'canvas' && !v.template);
104
+ }
105
+
106
+ /** The model's own templates (canvas views kept to copy from), in order. */
107
+ templates() {
108
+ return this.views().filter(v => v.template);
109
+ }
110
+
111
+ /** ArchiMate (and sketch) views: every view that is not a canvas or a template. */
112
+ diagramViews() {
113
+ return this.views().filter(v => v.constructor.kind !== 'canvas' && !v.template);
114
+ }
115
+
116
+ /**
117
+ * A new canvas made from a template: a model template (a view) or a library
118
+ * entry's `view` (CanvasView JSON). It gets new ids and the template's boxes and
119
+ * lines; the boxes are its background (one BackgroundGroup: behind everything
120
+ * added later, moved as one, locked until unlocked). It becomes the current view.
121
+ * @param {ArchimateView|Object} template - A template view, or CanvasView JSON
122
+ * @param {string} [title]
123
+ * @returns {ArchimateView}
124
+ */
125
+ newCanvasFromTemplate(template, title) {
126
+ const canvas = this._canvasFrom(template, title);
127
+ // What the template draws is the canvas's background: one group, behind everything, locked.
128
+ canvas.arrangeAsBackground(canvas.elementInstances.keys(), { name: `${template.name ?? template.title ?? 'Template'} (template)` });
129
+ return this._add(canvas);
130
+ }
131
+
132
+ /**
133
+ * A new view of some elements and what they are related to (Archi's
134
+ * "Generate View For"): the elements in the middle, each related element
135
+ * around them, and every relationship between what is shown (a view made
136
+ * from a layout draws them all). It becomes
137
+ * the current view.
138
+ * @param {Array<string>} elementIds
139
+ * @param {Object} [options]
140
+ * @param {string} [options.name] - Defaults to the first element's name
141
+ * @param {boolean} [options.related] - Bring in the elements they are related to (default true)
142
+ * @returns {ArchimateView|null} the view, or null if no element is known
143
+ */
144
+ generateView(elementIds, { name, related = true } = {}) {
145
+ const focus = [...new Set(elementIds)].filter(id => this.getElement(id));
146
+ if (!focus.length) return null;
147
+ const neighbours = [];
148
+ if (related) {
149
+ const seen = new Set(focus);
150
+ for (const id of focus) {
151
+ const { incoming, outgoing } = this.relationshipsFor(id);
152
+ for (const r of [...incoming, ...outgoing]) {
153
+ const other = r.source === id ? r.target : r.source;
154
+ if (!seen.has(other) && this.getElement(other)) { seen.add(other); neighbours.push(other); }
155
+ }
156
+ }
157
+ }
158
+ // The focus in a row through the middle; the related ones on a circle around it.
159
+ const GAP = 180, centre = { x: 500, y: 400 };
160
+ const radius = Math.max(260, (focus.length * GAP) / 2 + 160, (neighbours.length * 150) / (2 * Math.PI));
161
+ const layout = {};
162
+ focus.forEach((id, i) => { layout[id] = { x: centre.x + (i - (focus.length - 1) / 2) * GAP, y: centre.y }; });
163
+ neighbours.forEach((id, i) => {
164
+ const angle = -Math.PI / 2 + (2 * Math.PI * i) / neighbours.length;
165
+ layout[id] = { x: Math.round(centre.x + radius * Math.cos(angle)), y: Math.round(centre.y + radius * Math.sin(angle)) };
166
+ });
167
+ const view = this._createView({
168
+ id: this._newId(),
169
+ name: name ?? `${this.getElement(focus[0]).name || 'Element'} (generated)`,
170
+ elementIds: [...focus, ...neighbours],
171
+ layout,
172
+ });
173
+ return this._add(view);
174
+ }
175
+
176
+ /**
177
+ * A new view of elements on a time line (a roadmap): each element with a
178
+ * start and an end date (its `startProperty` and `endProperty` properties,
179
+ * ISO or day/month/year) a bar from one to the other, under a header of
180
+ * years and months; bars that overlap in time in lanes below each other.
181
+ * Elements without both dates (or ending before they start) are left out
182
+ * and listed. It becomes the current view; one undo step.
183
+ * @param {Array<string>} elementIds
184
+ * @param {Object} [options]
185
+ * @param {string} [options.startProperty] - Default 'Start Date'
186
+ * @param {string} [options.endProperty] - Default 'End Date'
187
+ * @param {string} [options.name] - The view's name
188
+ * @param {'short'|'full'} [options.monthNames]
189
+ * @returns {{view: (ArchimateView|null), skipped: Array<Object>}} the view (null when no element has dates), and the elements left out
190
+ */
191
+ generateRoadmap(elementIds, { startProperty = 'Start Date', endProperty = 'End Date', name = 'Roadmap', monthNames = 'short' } = {}) {
192
+ const prop = (e, key) => (e.properties ?? []).find(p => (p.name ?? p.key) === key)?.value;
193
+ const elements = [...new Set(elementIds)].map(id => this.getElement(id)).filter(Boolean);
194
+ const items = elements.map(e => ({ id: e.id, element: e, start: parseDate(prop(e, startProperty)), end: parseDate(prop(e, endProperty)) }));
195
+ const dated = items.filter(i => i.start && i.end && i.end >= i.start);
196
+ const skipped = items.filter(i => !dated.includes(i)).map(i => i.element);
197
+ if (!dated.length) return { view: null, skipped };
198
+ const plan = roadmapPlan(dated, { monthNames, monthWidth: monthNames === 'full' ? 90 : 60 });
199
+ const view = this._createView({ id: this._newId(), name, elementIds: dated.map(i => i.id) });
200
+ const note = (key, label, box, extra = {}) => {
201
+ const inst = new ViewObjectInstance({ id: `${view.id}-${key}`, name: label, label, shape: 'note' }, box.x + box.width / 2, box.y + box.height / 2);
202
+ inst.size = { width: box.width, height: box.height };
203
+ inst.appearance = { fillColor: '#ffffff', textAlign: 'center', ...extra };
204
+ inst.zIndex = -1;
205
+ return inst;
206
+ };
207
+ view.elementInstances.clear();
208
+ plan.years.forEach((y, i) => { const n = note(`year-${i}`, y.label, y, { fontWeight: 'bold' }); view.elementInstances.set(n.id, n); });
209
+ plan.months.forEach((m, i) => {
210
+ const header = note(`month-${i}`, m.label, m);
211
+ const column = note(`column-${i}`, '', { x: m.x, y: m.y + m.height, width: m.width, height: plan.height - 2 * m.height });
212
+ column.appearance = { fillColor: i % 2 ? '#f5f5f5' : '#ffffff', strokeColor: '#dddddd' };
213
+ column.zIndex = -2;
214
+ view.elementInstances.set(header.id, header);
215
+ view.elementInstances.set(column.id, column);
216
+ });
217
+ for (const bar of plan.bars) {
218
+ const inst = new ElementInstance(bar.id, bar.x + bar.width / 2, bar.y + bar.height / 2);
219
+ inst.size = { width: bar.width, height: bar.height };
220
+ inst.appearance = { fontSize: 9 };
221
+ view.elementInstances.set(inst.id, inst);
222
+ }
223
+ view.relationshipInstances?.clear?.();
224
+ return { view: this._add(view), skipped };
225
+ }
226
+
227
+ /**
228
+ * The checks this model is held to: the view-set ones and ArchiMate's own
229
+ * (ArchimateModelChecker).
230
+ * @param {{version?: string}} [options] - The ArchiMate version for the relationship check
231
+ */
232
+ checker(options) {
233
+ return new ArchimateModelChecker(this, options);
234
+ }
235
+
236
+ /**
237
+ * Make elements that are one thing twice into one: `keepId` takes the
238
+ * others' relationships, boxes, missing documentation and properties
239
+ * (MergeElementsCommand, not yet executed; put it on `history`).
240
+ */
241
+ mergeCommand(keepId, otherIds) {
242
+ return new MergeElementsCommand(this, keepId, otherIds);
243
+ }
244
+
245
+ /** Two models are compared on, and merged by, their elements, relationships and views (in the order brought). */
246
+ comparedKinds() {
247
+ return [new ElementsCompared(), new RelationshipsCompared(), new ArchimateViewsCompared()];
248
+ }
249
+
250
+ /** Another model's view as one of this model's, under the same id; a template stays one. @protected */
251
+ _viewFrom(view) {
252
+ const twin = view.copy(view.id, view.name, { model: this });
253
+ if (view.template) twin.template = true;
254
+ return this._adopt(twin);
255
+ }
256
+
257
+ /** Find and replace: the views, notes, elements and relationships (ArchimateModelSearch). */
258
+ search() {
259
+ return new ArchimateModelSearch(this);
260
+ }
261
+
262
+ /** The model's report: its views, elements and relationships (ArchimateModelReport). */
263
+ report(options) {
264
+ return new ArchimateModelReport(this, options);
265
+ }
266
+
267
+ /** A new, empty canvas; it becomes the current view. */
268
+ createCanvas(title = `Canvas ${this.canvases().length + 1}`) {
269
+ return this._add(this._createView({ id: this._newId(), name: title, archiViewType: 'canvas-model' }));
270
+ }
271
+
272
+ /**
273
+ * Keep a copy of a canvas as one of the model's templates. The current
274
+ * view stays as it is.
275
+ * @param {string} viewId - A canvas
276
+ * @param {string} [title] - Defaults to the canvas's name
277
+ * @param {{itemIds?: Iterable<string>}} [options] - Only these boxes (and the lines between them)
278
+ * @returns {ArchimateView|null} the template, or null if `viewId` is not a canvas
279
+ */
280
+ saveAsTemplate(viewId, title, { itemIds = null } = {}) {
281
+ const view = this.getView(viewId);
282
+ if (view?.constructor.kind !== 'canvas') return null;
283
+ // Some of its boxes only (a selection, a group): their JSON, with the lines between them.
284
+ return this.addTemplate(itemIds ? view.toJSON({ itemIds }) : view, title ?? (itemIds ? `${view.name} (part)` : view.name));
285
+ }
286
+
287
+ /**
288
+ * A model template copied from a canvas, a template, or a library entry's
289
+ * `view` (CanvasView JSON). The current view stays as it is.
290
+ * @param {ArchimateView|Object} source
291
+ * @param {string} [title]
292
+ * @returns {ArchimateView}
293
+ */
294
+ addTemplate(source, title) {
295
+ const template = this._canvasFrom(source, title);
296
+ template.template = true;
297
+ return this._add(template, { current: false });
298
+ }
299
+
300
+ /** A new canvas (not a template) copied from a view or from CanvasView JSON. @private */
301
+ _canvasFrom(source, title) {
302
+ const id = this._newId();
303
+ const name = title ?? source.name ?? source.title ?? 'Canvas';
304
+ return typeof source.copy === 'function'
305
+ ? source.copy(id, name)
306
+ : ArchimateModel.VIEW_TYPES.get('canvas-model').fromJSON(this, source, { id, name });
307
+ }
308
+
309
+ /** Get element by id, or undefined. */
310
+ getElement(id) {
311
+ return this._elements.get(id);
312
+ }
313
+
314
+ /**
315
+ * Add an element to the model. Position and other per-view state belong on
316
+ * the ElementInstance, so they are not kept here.
317
+ * @returns {Object} the stored element
318
+ */
319
+ addElement(el) {
320
+ if (!el?.id) throw new TypeError('Element must have an id');
321
+ if (this._elements.has(el.id)) throw new TypeError(`Duplicate element id: "${el.id}"`);
322
+ const { x, y, labelOverride, visible, diagramInstanceId, ...modelEl } = el;
323
+ this._elements.set(el.id, modelEl);
324
+ return modelEl;
325
+ }
326
+
327
+ /** Remove an element from the model (views keep any instances; they no longer resolve). */
328
+ removeElement(id) {
329
+ this._elements.delete(id);
330
+ this._elementFolder.delete(id);
331
+ }
332
+
333
+ /** Add a relationship to the model. @returns {Object} the stored relationship */
334
+ addRelationship(rel) {
335
+ if (!rel?.id) throw new TypeError('Relationship must have an id');
336
+ if (this._relationships.has(rel.id)) throw new TypeError(`Duplicate relationship id: "${rel.id}"`);
337
+ const stored = { ...rel };
338
+ this._relationships.set(rel.id, stored);
339
+ return stored;
340
+ }
341
+
342
+ /** Remove a relationship from the model (views keep any instances; they no longer resolve). */
343
+ removeRelationship(id) {
344
+ this._relationships.delete(id);
345
+ }
346
+
347
+ /** Get relationship by id, or undefined. */
348
+ getRelationship(id) {
349
+ return this._relationships.get(id);
350
+ }
351
+
352
+ /** Get folder by id, or undefined. */
353
+ getFolder(id) {
354
+ return this._folders.get(id);
355
+ }
356
+
357
+ /** Get the folder id containing an element, or undefined. */
358
+ getElementFolder(elementId) {
359
+ return this._elementFolder.get(elementId);
360
+ }
361
+
362
+ /** Set which folder contains an element. */
363
+ setElementFolder(elementId, folderId) {
364
+ if (folderId) {
365
+ this._elementFolder.set(elementId, folderId);
366
+ } else {
367
+ this._elementFolder.delete(elementId);
368
+ }
369
+ }
370
+
371
+ /** Get all elements in a folder (direct children only, not recursive). */
372
+ getElementsInFolder(folderId) {
373
+ const result = [];
374
+ for (const [elementId, folderIdForElement] of this._elementFolder.entries()) {
375
+ if (folderIdForElement === folderId) {
376
+ const el = this._elements.get(elementId);
377
+ if (el) result.push(el);
378
+ }
379
+ }
380
+ return result;
381
+ }
382
+
383
+ /** All elements, as a flat array. */
384
+ elements() {
385
+ return Array.from(this._elements.values());
386
+ }
387
+
388
+ /** All relationships, as a flat array. */
389
+ relationships() {
390
+ return Array.from(this._relationships.values());
391
+ }
392
+
393
+ /** Top-level folders only (children tracked within folder.children). */
394
+ folders() {
395
+ return Array.from(this._folders.values()).filter(f => !f.parentId);
396
+ }
397
+
398
+ /** All folders including nested (flat array), each after its parent. Nesting
399
+ * is read from `parentId`, which every folder carries (`children` may not be kept). */
400
+ allFolders() {
401
+ const all = Array.from(this._folders.values());
402
+ const byParent = new Map();
403
+ for (const f of all) {
404
+ if (f.parentId) byParent.set(f.parentId, [...(byParent.get(f.parentId) ?? []), f]);
405
+ }
406
+ const result = [];
407
+ const walk = (folder) => {
408
+ result.push(folder);
409
+ (byParent.get(folder.id) ?? []).forEach(walk);
410
+ };
411
+ all.filter(f => !f.parentId || !this._folders.has(f.parentId)).forEach(walk);
412
+ return result;
413
+ }
414
+
415
+ /** Incoming and outgoing relationships for an element. */
416
+ relationshipsFor(elementId) {
417
+ const rels = Array.from(this._relationships.values());
418
+ return {
419
+ incoming: rels.filter(r => r.target === elementId),
420
+ outgoing: rels.filter(r => r.source === elementId),
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Validate that an element has no view-specific properties.
426
+ * View-specific state (position, label override, visibility) belongs in ElementInstance,
427
+ * not in base model elements.
428
+ * @private
429
+ */
430
+ _validateElementNoViewProperties(el) {
431
+ const viewProps = ['x', 'y', 'labelOverride', 'visible', 'diagramInstanceId'];
432
+ for (const prop of viewProps) {
433
+ if (prop in el) {
434
+ console.warn(`WARNING: Element "${el.id}" has view-property "${prop}"; this should be in ElementInstance, not the model.`);
435
+ }
436
+ }
437
+ }
438
+
439
+ /** Add a view from a spec (as the constructor takes them) and make it current. */
440
+ addView(viewSpec) {
441
+ return this._add(this._createView(viewSpec));
442
+ }
443
+
444
+ /** Views that include a given element (or all views if elementId is null). */
445
+ viewsContaining(elementId) {
446
+ if (!elementId) return this.views();
447
+ return this.views().filter(v => v.includesElement(elementId));
448
+ }
449
+
450
+ /** Convert from an Archi-format parsed model (with .elements, .relationships, .folders, .views). */
451
+ static fromArchiModel(archiModel, mappers = {}) {
452
+ const { mapElement = el => el, mapRelationship = rel => rel } = mappers;
453
+ const elements = (archiModel.elements || []).map(mapElement);
454
+ const relationships = (archiModel.relationships || []).map(mapRelationship);
455
+ const folders = archiModel.folders || [];
456
+ const elementFolders = archiModel.elementFolders || [];
457
+ const views = (archiModel.views || []).map(view => ({
458
+ id: view.id,
459
+ name: view.name,
460
+ elementIds: view.elementIds,
461
+ viewpoint: view.viewpoint,
462
+ viewpointMode: view.viewpointMode,
463
+ layout: view.layout || {},
464
+ edgeLayout: view.edgeLayout || {},
465
+ folderLayout: view.folderLayout || {},
466
+ diagramInstances: view.diagramInstances || [],
467
+ }));
468
+ return new ArchimateModel({ elements, relationships, folders, elementFolders, views });
469
+ }
470
+ }
471
+
472
+ /** View classes by Archi view type; see ArchimateModel.registerViewType. */
473
+ ArchimateModel.VIEW_TYPES = new Map();
474
+
475
+ /**
476
+ * New boxes like these, for a copied view: each gets a new id and loses its
477
+ * Archi id (Archi's ids are unique in a model), and a box drawn inside another
478
+ * stays inside that box's copy.
479
+ * @param {Array<Object>} items - Instances' JSON (toJSON), each with its `id`
480
+ * @returns {{ elementInstances: Map<string, Object>, instanceIds: Map<string, string> }}
481
+ * the copies by id, and each source id's copy id
482
+ */
483
+ export function copyViewInstances(items) {
484
+ const instanceIds = new Map();
485
+ const elementInstances = new Map();
486
+ const copyIdOfArchiId = new Map();
487
+ for (const item of items) {
488
+ const twin = instanceFromJSON(JSON.parse(JSON.stringify(item)));
489
+ if (twin.archi?.id) {
490
+ copyIdOfArchiId.set(twin.archi.id, twin.id);
491
+ delete twin.archi.id;
492
+ }
493
+ instanceIds.set(item.id, twin.id);
494
+ elementInstances.set(twin.id, twin);
495
+ }
496
+ // A nested box names its parent's Archi id; the parent's copy is exported as diag-<its id>.
497
+ for (const twin of elementInstances.values()) {
498
+ const parent = twin.archi?.parent;
499
+ if (parent && copyIdOfArchiId.has(parent)) twin.archi.parent = `diag-${copyIdOfArchiId.get(parent)}`;
500
+ }
501
+ return { elementInstances, instanceIds };
502
+ }
503
+
504
+ export class ArchimateView extends DiagramEditorState {
505
+ /** A counter for ids of boxes placed in this page (with the time, unique). */
506
+ static placed = 1;
507
+
508
+ /** Fields the view adds to what it hands the renderer (`elements`/`relationships`): bookkeeping, not properties. */
509
+ /** The kind of view: 'archimate' here, 'canvas' for a CanvasView. */
510
+ static kind = 'archimate';
511
+
512
+ /** The boxes this kind of view adds besides elements (CanvasBoxKind): Archi's Note and Image here. */
513
+ static BOX_KINDS = DIAGRAM_BOX_KINDS;
514
+
515
+ /** Archi's class for a plain line drawn on this kind of view. */
516
+ static LINE_TYPE = 'diagram-model-connection';
517
+
518
+ /** Lines drawn so far: part of each new line's id. @private */
519
+ static _lines = 1;
520
+
521
+ static PROJECTION_KEYS = ['elementId', 'relationshipId', 'diagramInstanceId', 'visible', 'zIndex', 'viewObject', 'locked', 'moveWith', 'resizable'];
522
+
523
+ constructor(model, { id = null, name = '', elementIds = null, viewpoint = null, viewpointMode = null, layout = {}, edgeLayout = {}, folderLayout = {}, diagramInstances = [], elementInstances = null, relationshipInstances = null, viewObjects = [], viewConnections = [], archiViewType = 'archimate-diagram-model', properties = [], archiFolder = undefined, archi = null, arrangement = null, documentation = undefined, settings = undefined } = {}) {
524
+ super({ arrangement });
525
+ if (!id) throw new TypeError('View must have an id');
526
+ this.model = model;
527
+ /** @type {Map<string, ElementInstance>} what this view draws, by instance id */
528
+ this.elementInstances = new Map();
529
+ /** @type {Map<string, RelationshipInstance>} */
530
+ this.relationshipInstances = new Map();
531
+ this.id = id;
532
+ this.name = name;
533
+ this.documentation = documentation;
534
+ // This view's level of the settings cascade (SettingsManager reads and writes it).
535
+ if (settings) this._settings = { ...settings };
536
+ this.elementIds = elementIds; // null = "all elements"
537
+ this.viewpoint = viewpoint;
538
+ this.viewpointMode = viewpointMode;
539
+ this.folderLayout = folderLayout; // { folderId -> {x,y,width,height,collapsed}, etc } — per-view folder bounds/state
540
+
541
+ // Migrate old formats to new instance-based approach
542
+ this._migrateToInstances(layout, edgeLayout, diagramInstances, elementInstances, relationshipInstances);
543
+
544
+ // Notes and images that belong to this view only ({content, x, y}, e.g. from an Archi import).
545
+ for (const { content, x = 0, y = 0 } of viewObjects) {
546
+ const inst = new ViewObjectInstance(content, x, y);
547
+ this.elementInstances.set(inst.id, inst);
548
+ }
549
+
550
+ /** @type {Map<string, ViewConnectionInstance>} lines on this view with no relationship behind them */
551
+ this.viewConnections = new Map();
552
+ for (const c of viewConnections) {
553
+ const inst = c instanceof ViewConnectionInstance ? c : ViewConnectionInstance.fromJSON(c);
554
+ this.viewConnections.set(inst.id, inst);
555
+ }
556
+ /** Archi's class for the view ('archimate-diagram-model', 'canvas-model', 'sketch-model'), kept for export. */
557
+ this.archiViewType = archiViewType;
558
+ /** The view's properties, [{key, value}] (keys may repeat). */
559
+ this.properties = properties.map(p => ({ ...p }));
560
+ /** The Views sub-folders an Archi file kept it in ([{id, name, ...}]), kept for export. */
561
+ this.archiFolder = archiFolder;
562
+ /** What an Archi file said about the view that the model does not use (hintTitle, viewpoint, ...). */
563
+ this.archi = archi;
564
+
565
+ // Legacy property kept for backward compatibility (read-only)
566
+ this.layout = layout;
567
+ }
568
+
569
+ /**
570
+ * Migrate from old layout/diagramInstances format to new ElementInstance/RelationshipInstance collections.
571
+ * @private
572
+ */
573
+ _migrateToInstances(layout, edgeLayout, diagramInstances, elementInstances, relationshipInstances) {
574
+ // If new instance maps provided, use them
575
+ if (elementInstances instanceof Map) {
576
+ this.elementInstances = new Map(elementInstances);
577
+ } else if (elementInstances && typeof elementInstances === 'object') {
578
+ // If array or object provided, convert to Map
579
+ if (Array.isArray(elementInstances)) {
580
+ for (const data of elementInstances) {
581
+ const inst = instanceFromJSON(data);
582
+ this.elementInstances.set(inst.id, inst);
583
+ }
584
+ }
585
+ }
586
+
587
+ // Migrate old diagramInstances array
588
+ if (Array.isArray(diagramInstances) && diagramInstances.length > 0) {
589
+ for (const inst of diagramInstances) {
590
+ const elInst = new ElementInstance(inst.elementId, inst.x, inst.y, inst.labelOverride);
591
+ this.elementInstances.set(elInst.id, elInst);
592
+ }
593
+ }
594
+
595
+ // Migrate old layout object (fallback if no instances yet)
596
+ if (layout && Object.keys(layout).length > 0 && this.elementInstances.size === 0) {
597
+ for (const [elementId, pos] of Object.entries(layout)) {
598
+ const inst = new ElementInstance(elementId, pos.x || 0, pos.y || 0);
599
+ this.elementInstances.set(inst.id, inst);
600
+ }
601
+ }
602
+
603
+ // Migrate old edgeLayout to relationship instances
604
+ if (relationshipInstances instanceof Map) {
605
+ this.relationshipInstances = new Map(relationshipInstances);
606
+ } else if (relationshipInstances && typeof relationshipInstances === 'object') {
607
+ if (Array.isArray(relationshipInstances)) {
608
+ for (const data of relationshipInstances) {
609
+ const inst = RelationshipInstance.fromJSON(data);
610
+ this.relationshipInstances.set(inst.id, inst);
611
+ }
612
+ }
613
+ }
614
+
615
+ // A view given its instances (an import, a copy) draws exactly those, even
616
+ // none; otherwise it draws its filter's elements and the lines between them.
617
+ if (elementInstances == null && this.elementInstances.size === 0) {
618
+ this._createDefaultElementInstances();
619
+ }
620
+ if (relationshipInstances == null && this.relationshipInstances.size === 0) {
621
+ this._createDefaultRelationshipInstances();
622
+ }
623
+
624
+ this._applyEdgeLayout(edgeLayout);
625
+ }
626
+
627
+ /**
628
+ * An imported `edgeLayout` ({relationshipId: {waypoints}}) gives a route to
629
+ * every instance of that relationship that has none of its own. An entry
630
+ * whose waypoints are absent means "straight", not "inherit".
631
+ * @private
632
+ */
633
+ _applyEdgeLayout(edgeLayout = {}) {
634
+ for (const [relationshipId, entry] of Object.entries(edgeLayout ?? {})) {
635
+ if (!entry || !('waypoints' in entry)) continue;
636
+ for (const inst of this.relationshipInstancesFor(relationshipId)) {
637
+ if (inst.waypoints === null) inst.waypoints = (entry.waypoints ?? []).map(wp => ({ x: wp.x, y: wp.y }));
638
+ }
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Create default element instances for all elements in the view (on first creation).
644
+ * Uses layout if available, otherwise positions at 0,0.
645
+ * @private
646
+ */
647
+ _createDefaultElementInstances() {
648
+ const elements = this.model.elements();
649
+ const filtered = this.elementIds !== null ? elements.filter(el => this.elementIds.includes(el.id)) : elements;
650
+
651
+ for (const el of filtered) {
652
+ // Don't overwrite existing instances - they contain user modifications!
653
+ // Only create if no instances exist for this element
654
+ const existingInstances = Array.from(this.elementInstances.values())
655
+ .filter(inst => inst.elementId === el.id);
656
+ if (existingInstances.length > 0) {
657
+ continue; // Skip - instances already exist for this element
658
+ }
659
+
660
+ const layoutPos = (this.layout && this.layout[el.id]) || {};
661
+ const x = layoutPos.x !== undefined ? layoutPos.x : 0;
662
+ const y = layoutPos.y !== undefined ? layoutPos.y : 0;
663
+ const inst = new ElementInstance(el.id, x, y);
664
+ this.elementInstances.set(inst.id, inst);
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Create default relationship instances for all relationships in the view (on first creation).
670
+ * Skips if view has no elements yet (new empty view) - relationships will be added via confirmation prompt.
671
+ * @private
672
+ */
673
+ _createDefaultRelationshipInstances() {
674
+ const relationships = this.model.relationships();
675
+ for (const rel of relationships) {
676
+ if (this.includesElement(rel.source) && this.includesElement(rel.target)) {
677
+ const inst = new RelationshipInstance(rel.id);
678
+ this.relationshipInstances.set(inst.id, inst);
679
+ }
680
+ }
681
+ }
682
+
683
+ // ==================== Instance Management ====================
684
+
685
+ /**
686
+ * Get element instance by ID.
687
+ * @param {string} instanceId - The stable diagram instance ID
688
+ * @returns {ElementInstance|undefined}
689
+ */
690
+ getElementInstance(instanceId) {
691
+ return this.elementInstances.get(instanceId);
692
+ }
693
+
694
+ /**
695
+ * Get relationship instance by ID.
696
+ * @param {string} instanceId - The stable diagram instance ID
697
+ * @returns {RelationshipInstance|undefined}
698
+ */
699
+ getRelationshipInstance(instanceId) {
700
+ return this.relationshipInstances.get(instanceId);
701
+ }
702
+
703
+ /**
704
+ * Add an element instance to this view.
705
+ * @param {ElementInstance} instance
706
+ */
707
+ addElementInstance(instance) {
708
+ if (!(instance instanceof ElementInstance)) {
709
+ throw new TypeError('Expected ElementInstance');
710
+ }
711
+ this.elementInstances.set(instance.id, instance);
712
+ }
713
+
714
+ /**
715
+ * Remove an element instance from this view.
716
+ * @param {string} instanceId
717
+ */
718
+ removeElementInstance(instanceId) {
719
+ this.elementInstances.delete(instanceId);
720
+ }
721
+
722
+ /**
723
+ * Add a relationship instance to this view.
724
+ * @param {RelationshipInstance} instance
725
+ */
726
+ addRelationshipInstance(instance) {
727
+ if (!(instance instanceof RelationshipInstance)) {
728
+ throw new TypeError('Expected RelationshipInstance');
729
+ }
730
+ this.relationshipInstances.set(instance.id, instance);
731
+ }
732
+
733
+ /**
734
+ * Remove a relationship instance from this view.
735
+ * @param {string} instanceId
736
+ */
737
+ removeRelationshipInstance(instanceId) {
738
+ this.relationshipInstances.delete(instanceId);
739
+ }
740
+
741
+ /**
742
+ * Get all instances of a specific model element in this view.
743
+ * @param {string} elementId - The model element ID
744
+ * @returns {ElementInstance[]}
745
+ */
746
+ elementInstancesFor(elementId) {
747
+ return Array.from(this.elementInstances.values())
748
+ .filter(inst => inst.elementId === elementId);
749
+ }
750
+
751
+ /**
752
+ * Get all instances of a specific relationship in this view.
753
+ * @param {string} relationshipId - The model relationship ID
754
+ * @returns {RelationshipInstance[]}
755
+ */
756
+ relationshipInstancesFor(relationshipId) {
757
+ return Array.from(this.relationshipInstances.values())
758
+ .filter(inst => inst.relationshipId === relationshipId);
759
+ }
760
+
761
+ /**
762
+ * Clear all element instances.
763
+ */
764
+ clearElementInstances() {
765
+ this.elementInstances.clear();
766
+ }
767
+
768
+ /**
769
+ * Clear all relationship instances.
770
+ */
771
+ clearRelationshipInstances() {
772
+ this.relationshipInstances.clear();
773
+ }
774
+
775
+ /** A view's title is its name (ViewSetModel works with titles). */
776
+ get title() { return this.name; }
777
+ set title(value) { this.name = value; }
778
+
779
+ /**
780
+ * An independent copy under a new id: every element and relationship
781
+ * instance is copied (new instance ids, relationships re-linked to the
782
+ * copied ends), with the element filter, viewpoint, documentation, view
783
+ * settings and folder layout.
784
+ * History starts empty. With `model`, the copy is that model's (a merge
785
+ * brings a view from another model).
786
+ */
787
+ copy(id, title, { model = this.model } = {}) {
788
+ const { elementInstances, instanceIds } = copyViewInstances([...this.elementInstances.values()].map(i => ({ ...i.toJSON(), id: i.id })));
789
+ const relink = (instanceId) => instanceId == null ? null : (instanceIds.get(instanceId) ?? instanceId);
790
+ const relationshipInstances = new Map();
791
+ for (const inst of this.relationshipInstances.values()) {
792
+ const data = JSON.parse(JSON.stringify(inst.toJSON()));
793
+ const twin = RelationshipInstance.fromJSON({ ...data, sourceInstanceId: relink(data.sourceInstanceId), targetInstanceId: relink(data.targetInstanceId) });
794
+ if (twin.archi) delete twin.archi.id;
795
+ relationshipInstances.set(twin.id, twin);
796
+ }
797
+ const viewConnections = [...this.viewConnections.values()].map(c => {
798
+ const twin = ViewConnectionInstance.fromJSON({
799
+ ...JSON.parse(JSON.stringify(c.toJSON())),
800
+ id: `${c.id}-${id}`,
801
+ sourceInstanceId: relink(c.sourceInstanceId),
802
+ targetInstanceId: relink(c.targetInstanceId),
803
+ });
804
+ if (twin.archi) delete twin.archi.id;
805
+ return twin;
806
+ });
807
+ return new this.constructor(model, {
808
+ id,
809
+ name: title,
810
+ elementIds: this.elementIds ? [...this.elementIds] : null,
811
+ viewpoint: this.viewpoint,
812
+ viewpointMode: this.viewpointMode,
813
+ documentation: this.documentation,
814
+ settings: this._settings,
815
+ folderLayout: JSON.parse(JSON.stringify(this.folderLayout ?? {})),
816
+ elementInstances,
817
+ relationshipInstances,
818
+ viewConnections,
819
+ archiViewType: this.archiViewType,
820
+ properties: this.properties,
821
+ archiFolder: this.archiFolder,
822
+ archi: this.archi ? JSON.parse(JSON.stringify(this.archi)) : null,
823
+ arrangement: this.arrangement.copy(relink), // the copy's instances have new ids
824
+ });
825
+ }
826
+
827
+ /** Element instances stack (relationships are drawn between them). */
828
+ layerItems() {
829
+ return Array.from(this.elementInstances.values());
830
+ }
831
+
832
+ /** True if this view includes the element (null elementIds = all). */
833
+ includesElement(id) {
834
+ if (!this.elementIds) return true;
835
+ return this.elementIds.includes(id);
836
+ }
837
+
838
+ /**
839
+ * Whether a line drawn between two items here is a relationship (both ends
840
+ * show model elements) or a plain line (a note, a canvas box or an image at
841
+ * either end), as in Archi.
842
+ */
843
+ linesAreRelationships(sourceId, targetId) {
844
+ const ends = [sourceId, targetId].map(id => this.getElementInstance(id));
845
+ return ends.every(inst => inst && !inst.content && this.model.getElement(inst.elementId));
846
+ }
847
+
848
+ /** A new plain line between two items, of this view's line class (not yet on the view). */
849
+ newLine(sourceId, targetId, { label = '' } = {}) {
850
+ const id = `line-${Date.now().toString(36)}-${ArchimateView._lines++}`;
851
+ return new ViewConnectionInstance({ id, sourceInstanceId: sourceId, targetInstanceId: targetId, label, archiType: this.constructor.LINE_TYPE });
852
+ }
853
+
854
+ /** Draw a plain line between two items: one undo step. @returns {ViewConnectionInstance} */
855
+ connect(sourceId, targetId, options = {}) {
856
+ const line = this.newLine(sourceId, targetId, options);
857
+ this.history.execute(new PlaceItemsCommand(this, { elementInstances: [], viewConnections: [line] }, { label: 'Connect' }));
858
+ return line;
859
+ }
860
+
861
+ /** Elements in this view, resolved from instances. Each instance is a fresh, independent projection. */
862
+ get elements() {
863
+ const result = [];
864
+
865
+ for (const instance of this.elementInstances.values()) {
866
+ // The element filter (null = all) applies to model elements, not view objects.
867
+ if (!instance.shownIn(this)) continue;
868
+ // A fresh copy each time: what the instance draws, placed for this view.
869
+ const viewElement = instance.project(this.model);
870
+ if (!viewElement) continue;
871
+ // Fixed in place (the view's arrangement): the renderer will not drag or resize it.
872
+ if (this.arrangement.isLocked(instance.id)) Object.assign(viewElement, { locked: true, resizable: false });
873
+ // In a group: the renderer drags its members with it.
874
+ const together = this.arrangement.movingWith(instance.id);
875
+ if (together.size > 1) viewElement.moveWith = [...together];
876
+ result.push(viewElement);
877
+ }
878
+
879
+ // Sort by zIndex so elements render in correct stacking order (lower zIndex first = background)
880
+ result.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
881
+
882
+ return result;
883
+ }
884
+
885
+ /** Relationships in this view, resolved from instances. Returns fresh copies to prevent mutations. */
886
+ get relationships() {
887
+ const result = [];
888
+
889
+ for (const instance of this.relationshipInstances.values()) {
890
+ const baseRel = this.model.getRelationship(instance.relationshipId);
891
+ if (!baseRel) {
892
+ continue;
893
+ }
894
+
895
+ // Skip if endpoints aren't in this view
896
+ if (!this.includesElement(baseRel.source) || !this.includesElement(baseRel.target)) {
897
+ continue;
898
+ }
899
+
900
+ const viewRel = { ...baseRel };
901
+ viewRel.id = instance.id; // Use instance ID so rendering engine can track each relationship instance uniquely
902
+ viewRel.relationshipId = baseRel.id; // Keep base relationship ID for reference
903
+
904
+ // Update source/target to use instance IDs
905
+ // If this relationship instance tracks specific source/target instances, use those
906
+ if (instance.sourceInstanceId && instance.targetInstanceId) {
907
+ viewRel.source = instance.sourceInstanceId;
908
+ viewRel.target = instance.targetInstanceId;
909
+ } else {
910
+ // Fallback: use first instance of each element (for backward compatibility)
911
+ const sourceInstances = this.elementInstancesFor(baseRel.source);
912
+ const targetInstances = this.elementInstancesFor(baseRel.target);
913
+ if (sourceInstances.length > 0) {
914
+ viewRel.source = sourceInstances[0].id;
915
+ }
916
+ if (targetInstances.length > 0) {
917
+ viewRel.target = targetInstances[0].id;
918
+ }
919
+ }
920
+
921
+ // This instance's own route wins; null inherits the model's. A deliberately
922
+ // straight line ([]) reads as no waypoints at all.
923
+ if (instance.waypoints) {
924
+ viewRel.waypoints = instance.waypoints.length ? instance.waypoints.map(wp => ({ ...wp })) : undefined;
925
+ } else {
926
+ viewRel.waypoints = baseRel.waypoints ? baseRel.waypoints.map(wp => ({ ...wp })) : [];
927
+ }
928
+ if (instance.sourceSide) viewRel.sourceSide = instance.sourceSide;
929
+ if (instance.targetSide) viewRel.targetSide = instance.targetSide;
930
+ // This view's line colour, in the renderer's name for it.
931
+ if (instance.appearance?.strokeColor) viewRel.color = instance.appearance.strokeColor;
932
+
933
+ viewRel.visible = instance.visible;
934
+ viewRel.diagramInstanceId = instance.id;
935
+ result.push(viewRel);
936
+ }
937
+
938
+ // Plain lines (no relationship behind them), drawn while both their boxes are.
939
+ for (const connection of this.viewConnections.values()) {
940
+ const edge = connection.project(this);
941
+ if (edge) result.push(edge);
942
+ }
943
+ return result;
944
+ }
945
+
946
+ /** Top-level folders in this view, with per-view folderLayout applied.
947
+ * Returns fresh copies with bounds/state from folderLayout. */
948
+ get folders() {
949
+ const topLevel = this.model.folders();
950
+ return topLevel.map(folder => {
951
+ const viewFolder = { ...folder };
952
+ // Apply per-view folder layout (bounds, collapsed state, etc.)
953
+ const folderOverride = this.folderLayout[folder.id];
954
+ if (folderOverride) {
955
+ Object.assign(viewFolder, folderOverride);
956
+ }
957
+ return viewFolder;
958
+ });
959
+ }
960
+
961
+ /**
962
+ * The folders drawn on this view as groups: those with bounds in its
963
+ * `folderLayout`, bounds applied, each after its parent. `parentId` is the
964
+ * nearest ancestor that is also drawn here (a group inside an undrawn
965
+ * folder sits on the view itself).
966
+ */
967
+ get placedFolders() {
968
+ const placed = this.model.allFolders().filter(f => this.folderLayout[f.id]);
969
+ const drawn = new Set(placed.map(f => f.id));
970
+ return placed.map(folder => ({
971
+ ...folder,
972
+ ...this.folderLayout[folder.id],
973
+ parentId: this._nearestPlaced(folder.parentId, drawn),
974
+ }));
975
+ }
976
+
977
+ /** The group on this view an element is drawn in: its folder, or that folder's nearest drawn ancestor. */
978
+ placedFolderOf(elementId) {
979
+ const drawn = new Set(this.model.allFolders().filter(f => this.folderLayout[f.id]).map(f => f.id));
980
+ return this._nearestPlaced(this.model.getElementFolder(elementId), drawn);
981
+ }
982
+
983
+ /** @private */
984
+ _nearestPlaced(folderId, drawn) {
985
+ for (let id = folderId; id; id = this.model.getFolder(id)?.parentId) {
986
+ if (drawn.has(id)) return id;
987
+ }
988
+ return undefined;
989
+ }
990
+
991
+ // ===== Undoable edits (this view's history) =====
992
+
993
+ /**
994
+ * Everything needed to make this view's items again elsewhere: its boxes
995
+ * (with their ids), the lines and relationships drawn between them, their
996
+ * groups and locks, and the model elements and relationships they show (so
997
+ * another model can be given them); its documentation, properties and what
998
+ * Archi said besides. Plain JSON. A canvas template is this; so is what is
999
+ * copied to the clipboard.
1000
+ * @param {Object} [options]
1001
+ * @param {Iterable<string>} [options.itemIds] - Only these boxes (a selection): the lines
1002
+ * between them, and their groups and locks
1003
+ */
1004
+ toJSON({ itemIds = null } = {}) {
1005
+ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
1006
+ const keep = itemIds ? new Set(itemIds) : null;
1007
+ const kept = (id) => !keep || keep.has(id);
1008
+ const boxes = [...this.elementInstances.values()].filter(i => kept(i.id));
1009
+ // A line's ends: the boxes it names, or (none named) its elements' first boxes, as it is drawn.
1010
+ const endsOf = (r) => {
1011
+ const rel = this.model.getRelationship(r.relationshipId);
1012
+ return [r.sourceInstanceId ?? this.elementInstancesFor(rel?.source)[0]?.id, r.targetInstanceId ?? this.elementInstancesFor(rel?.target)[0]?.id];
1013
+ };
1014
+ const lines = [...this.relationshipInstances.values()]
1015
+ .map(r => ({ r, ends: endsOf(r) }))
1016
+ .filter(({ ends: [from, to] }) => from && to && kept(from) && kept(to))
1017
+ .map(({ r, ends: [from, to] }) => Object.assign(RelationshipInstance.fromJSON(r.toJSON()), { sourceInstanceId: from, targetInstanceId: to }));
1018
+ const elementIds = new Set(boxes.map(b => b.elementId).filter(id => this.model.getElement(id)));
1019
+ return {
1020
+ name: this.name,
1021
+ documentation: this.documentation,
1022
+ properties: copy(this.properties),
1023
+ archi: copy(this.archi ?? undefined),
1024
+ elementInstances: boxes.map(i => ({ ...copy(i.toJSON()), id: i.id })),
1025
+ viewConnections: [...this.viewConnections.values()]
1026
+ .filter(c => kept(c.sourceInstanceId) && kept(c.targetInstanceId)).map(c => copy(c.toJSON())),
1027
+ relationshipInstances: lines.map(r => copy(r.toJSON())),
1028
+ elements: [...elementIds].map(id => copy(this.model.getElement(id))),
1029
+ relationships: [...new Set(lines.map(r => r.relationshipId))].map(id => this.model.getRelationship(id)).filter(Boolean).map(copy),
1030
+ arrangement: (keep ? this.arrangement.only(keep) : this.arrangement).toJSON(),
1031
+ };
1032
+ }
1033
+
1034
+ /**
1035
+ * New boxes and lines like a view's JSON (toJSON): new ids, Archi's ids
1036
+ * left with the source, each line and relationship joined to the copies of
1037
+ * its ends.
1038
+ * @param {Object} data
1039
+ * @param {string} salt - Makes the lines' ids new
1040
+ * @returns {{ elementInstances: Map, viewConnections: Array, relationshipInstances: Array, mapId: Function }}
1041
+ */
1042
+ static copyItems(data, salt) {
1043
+ const { elementInstances, instanceIds } = copyViewInstances(data.elementInstances ?? []);
1044
+ const mapId = (old) => instanceIds.get(old) ?? old;
1045
+ const viewConnections = (data.viewConnections ?? []).map(c => {
1046
+ const line = ViewConnectionInstance.fromJSON({ ...c, id: `${c.id}-${salt}`, sourceInstanceId: mapId(c.sourceInstanceId), targetInstanceId: mapId(c.targetInstanceId) });
1047
+ if (line.archi) delete line.archi.id;
1048
+ return line;
1049
+ });
1050
+ const relationshipInstances = (data.relationshipInstances ?? []).map(r => {
1051
+ const line = RelationshipInstance.fromJSON({ ...r, sourceInstanceId: mapId(r.sourceInstanceId), targetInstanceId: mapId(r.targetInstanceId) });
1052
+ if (line.archi) delete line.archi.id;
1053
+ return line;
1054
+ });
1055
+ return { elementInstances, viewConnections, relationshipInstances, mapId };
1056
+ }
1057
+
1058
+ /**
1059
+ * Put copies of a view's items (toJSON of a selection, from this view or
1060
+ * any other, of this model or another) on this view, moved by dx, dy: new
1061
+ * boxes for the same elements, the lines and relationships between them;
1062
+ * an element or relationship this model lacks is added with them. In front
1063
+ * of what is there. The command is returned, not run.
1064
+ * @param {Object} data
1065
+ * @param {{dx?: number, dy?: number, label?: string}} [options]
1066
+ * @returns {{ command: PlaceItemsCommand, ids: Array<string> }} the command, and the new boxes' ids
1067
+ */
1068
+ pasteCommand(data, { dx = 0, dy = 0, label = 'Paste' } = {}) {
1069
+ const salt = `${this.id}-${Date.now().toString(36)}-${ArchimateView.placed++}`;
1070
+ const { elementInstances, viewConnections, relationshipInstances } = ArchimateView.copyItems(data, salt);
1071
+ const boxes = [...elementInstances.values()];
1072
+ const move = (points) => points?.map(p => ({ x: p.x + dx, y: p.y + dy })) ?? points;
1073
+ for (const b of boxes) { b.x += dx; b.y += dy; }
1074
+ for (const line of viewConnections) line.waypoints = move(line.waypoints) ?? [];
1075
+ for (const line of relationshipInstances) line.waypoints = move(line.waypoints) ?? null;
1076
+ const elements = (data.elements ?? []).filter(e => !this.model.getElement(e.id));
1077
+ const has = (id) => !!this.model.getElement(id) || elements.some(e => e.id === id);
1078
+ const relationships = (data.relationships ?? []).filter(r => !this.model.getRelationship(r.id) && has(r.source) && has(r.target));
1079
+ const known = (id) => !!this.model.getRelationship(id) || relationships.some(r => r.id === id);
1080
+ const command = new PlaceItemsCommand(this, {
1081
+ elementInstances: boxes, viewConnections, relationshipInstances: relationshipInstances.filter(r => known(r.relationshipId)),
1082
+ elements, relationships,
1083
+ }, { label });
1084
+ return { command, ids: boxes.map(b => b.id) };
1085
+ }
1086
+
1087
+
1088
+ /**
1089
+ * Add a box of one of this view's kinds (BOX_KINDS: a note or image here;
1090
+ * a canvas's block, sticky or image) centred at `at`: one undo step, in
1091
+ * front of what is there.
1092
+ * @param {string} kindKey
1093
+ * @param {{x: number, y: number}} at
1094
+ * @param {{name?: string, iconUrl?: string}} [options] - Its text, its picture
1095
+ * @returns {ViewObjectInstance}
1096
+ */
1097
+ addBox(kindKey, at, options = {}) {
1098
+ const kind = this.constructor.BOX_KINDS.find(k => k.key === kindKey);
1099
+ if (!kind) throw new TypeError(`No box kind "${kindKey}" on a ${this.constructor.kind} view`);
1100
+ const id = `${kind.key}-${Date.now().toString(36)}-${ArchimateView.placed++}`;
1101
+ const box = new ViewObjectInstance(kind.content(id, options), at.x, at.y);
1102
+ box.size = { width: kind.width, height: kind.height };
1103
+ box.appearance = { ...kind.appearance };
1104
+ this.history.execute(new PlaceItemsCommand(this, { elementInstances: [box] }, { label: `Add ${kind.label}` }));
1105
+ return box;
1106
+ }
1107
+
1108
+
1109
+ /**
1110
+ * Put an instance on this view, creating its model element first when
1111
+ * `element` is given. One undo step.
1112
+ * @param {ElementInstance} instance
1113
+ * @param {Object} [options]
1114
+ * @param {Object} [options.element] - A new model element the instance shows
1115
+ */
1116
+ addToView(instance, { element = null } = {}) {
1117
+ this.history.execute(new AddToViewCommand(this, instance, { element }));
1118
+ }
1119
+
1120
+ /** Take an instance off this view (the model element stays). One undo step. */
1121
+ deleteElement(instanceId) {
1122
+ this.history.execute(new DeleteElementCommand(this, instanceId));
1123
+ }
1124
+
1125
+ /**
1126
+ * Relationship instances this view could show but does not: one per pairing
1127
+ * of an instance of the element with an instance at the other end of each of
1128
+ * its relationships, where both ends are in the view.
1129
+ * @param {string} elementId - Model element id
1130
+ * @returns {RelationshipInstance[]}
1131
+ */
1132
+ missingRelationshipInstances(elementId) {
1133
+ const { incoming, outgoing } = this.model.relationshipsFor(elementId);
1134
+ const shown = new Set([...this.relationshipInstances.values()].map(inst => this._pairing(inst)));
1135
+ const missing = [];
1136
+ for (const rel of [...incoming, ...outgoing]) {
1137
+ if (!this.includesElement(rel.source) || !this.includesElement(rel.target)) continue;
1138
+ for (const src of this.elementInstancesFor(rel.source)) {
1139
+ for (const tgt of this.elementInstancesFor(rel.target)) {
1140
+ const inst = new RelationshipInstance(rel.id, null, src.id, tgt.id);
1141
+ if (!shown.has(this._pairing(inst))) {
1142
+ shown.add(this._pairing(inst));
1143
+ missing.push(inst);
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ return missing;
1149
+ }
1150
+
1151
+ /** "relationship|source instance|target instance" an instance draws between. @private */
1152
+ _pairing(inst) {
1153
+ const rel = this.model.getRelationship(inst.relationshipId);
1154
+ const source = inst.sourceInstanceId ?? this.elementInstancesFor(rel?.source)[0]?.id;
1155
+ const target = inst.targetInstanceId ?? this.elementInstancesFor(rel?.target)[0]?.id;
1156
+ return `${inst.relationshipId}|${source}|${target}`;
1157
+ }
1158
+
1159
+ /** Show these relationship instances, as one undo step. */
1160
+ addRelationshipsToView(instances, label = 'Add Relationships to View') {
1161
+ if (!instances.length) return;
1162
+ this.history.execute(new CompositeCommand(label, instances.map(inst => new AddRelationshipToViewCommand(this, inst))));
1163
+ }
1164
+
1165
+ /** Take a relationship instance, or a plain line, off this view (the model relationship stays). One undo step. */
1166
+ removeRelationship(instanceId) {
1167
+ if (this.viewConnections.has(instanceId)) this.history.execute(new RemoveLineCommand(this, instanceId));
1168
+ else this.history.execute(new RemoveRelationshipFromViewCommand(this, instanceId));
1169
+ }
1170
+ }