@peter.prib/renderengine 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +219 -213
  2. package/REFERENCE.md +272 -6
  3. package/dist/renderengine-workspace.umd.js +11980 -7421
  4. package/dist/renderengine.umd.js +8991 -5784
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +534 -0
  7. package/package.json +70 -68
  8. package/src/bian.js +182 -0
  9. package/src/charting/CheckboxTreeSelector.js +373 -304
  10. package/src/charting/DiagramEditor.js +53 -32
  11. package/src/charting/DiagramTypeRegistry.js +13 -3
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/ViewTabStrip.js +94 -81
  15. package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
  16. package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
  17. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  18. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  19. package/src/charting/datamodel/DataModel.js +645 -0
  20. package/src/charting/datamodel/DataModelChecker.js +92 -0
  21. package/src/charting/datamodel/DataModelCompared.js +127 -0
  22. package/src/charting/datamodel/DataModelContext.js +108 -0
  23. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  24. package/src/charting/datamodel/DataModelPerspective.js +129 -0
  25. package/src/charting/datamodel/DataType.js +108 -0
  26. package/src/charting/datamodel/EnumerationType.js +51 -0
  27. package/src/charting/datamodel/LevelMapping.js +38 -0
  28. package/src/charting/datamodel/LevelSchema.js +41 -0
  29. package/src/charting/datamodel/ServiceDomain.js +177 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/StructuredType.js +63 -0
  32. package/src/charting/datamodel/index.js +15 -0
  33. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  34. package/src/charting/diagramModel/OpenViewList.js +75 -0
  35. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  36. package/src/charting/diagrams/DataModelDiagram.js +160 -86
  37. package/src/charting/diagrams/DataModelRelationshipStrategy.js +94 -38
  38. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  39. package/src/charting/diagrams/GraphDiagram.js +11 -1
  40. package/src/charting/diagrams/NodeGeometry.js +169 -158
  41. package/src/charting/diagrams/nesting.js +31 -0
  42. package/src/charting/glyphs/PersonGlyph.js +24 -23
  43. package/src/charting/modelTree/ModelTree.js +202 -164
  44. package/src/charting/modelTree/ModelTreePanel.js +36 -3
  45. package/src/charting/modelTree/TreeViewMode.js +131 -110
  46. package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
  47. package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
  48. package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
  49. package/src/datamodel.js +132 -122
  50. package/src/diagramCatalog.js +35 -30
  51. package/src/index.js +2666 -2659
  52. package/src/workspace/EditorShell.js +166 -67
  53. package/src/workspace/PanelLayout.js +56 -0
  54. package/src/workspace/index.js +1 -0
  55. package/src/workspace/layout/TabsNode.js +205 -191
  56. package/src/workspace/parts/ArchimateCanvasPart.js +10 -9
  57. package/src/workspace/parts/ChartPart.js +6 -1
  58. package/src/workspace/parts/DataModelPart.js +544 -0
  59. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  60. package/src/workspace/parts/EditorPart.js +461 -407
  61. package/src/workspace/parts/ModelFile.js +216 -187
  62. package/src/workspace/parts/ModelsPart.js +483 -192
  63. package/src/workspace/storage/BackupFolder.js +145 -0
  64. package/src/workspace/storage/KeyValueStore.js +92 -0
  65. package/src/workspace/storage/ModelLibrary.js +65 -0
  66. package/src/workspace/workArea.js +417 -413
  67. package/src/yaml/YamlReader.js +372 -0
@@ -0,0 +1,645 @@
1
+ import { DataModelPerspective } from './DataModelPerspective.js';
2
+ import { DataModelContext } from './DataModelContext.js';
3
+ import { DataType } from './DataType.js';
4
+ import './EnumerationType.js';
5
+ import './StructuredType.js';
6
+ import { LevelSchema } from './LevelSchema.js';
7
+ import { LevelMapping } from './LevelMapping.js';
8
+ import { NodeGeometry } from '../diagrams/NodeGeometry.js';
9
+ import { ModelComparison } from '../diagramModel/ModelComparison.js';
10
+ import { DataModelChecker } from './DataModelChecker.js';
11
+ import { CommandHistory } from '../CommandHistory.js';
12
+ import { MergeModelCommand } from '../diagramModel/MergeModelCommand.js';
13
+ import { ServiceLandscape } from './ServiceLandscape.js';
14
+ import {
15
+ LevelEntitiesCompared, LevelRelationshipsCompared, MappingsCompared, ContextsCompared, PerspectivesCompared, DataTypesCompared,
16
+ } from './DataModelCompared.js';
17
+
18
+ /** The levels, outermost first: each realised by the next. */
19
+ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
20
+
21
+ /**
22
+ * DataModel — a data model read many ways. Its structure is three levels, each
23
+ * its own schema (LevelSchema: conceptual concepts, logical entities, physical
24
+ * tables, with their relationships), joined by mappings (LevelMapping:
25
+ * conceptual → logical, logical → physical, any number either way). A
26
+ * perspective (DataModelPerspective) is a stakeholder's viewpoint on the
27
+ * concepts; a context (DataModelContext) their meaning, scope and constraints.
28
+ * Perspective → context → schema: `view({ perspective, context, level })`
29
+ * gives what to draw at a level, or ('mapping') how the levels map; the
30
+ * schema is left as it is.
31
+ *
32
+ * The older form, one entity per thing with each level's detail on it
33
+ * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
34
+ * three levels mapped one to one.
35
+ *
36
+ * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
37
+ * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
38
+ * keep it and ask before it is lost.
39
+ */
40
+ export class DataModel {
41
+ /** The levels, outermost first. */
42
+ static LEVELS = LEVELS;
43
+
44
+ /** The view of how the levels map (a `level` for view()). */
45
+ static MAPPING = 'mapping';
46
+
47
+ /** The view of its service domains in their business areas and domains (a `level` for view()). */
48
+ static LANDSCAPE = 'landscape';
49
+
50
+ /** The view of its data types (a `level` for view()). */
51
+ static DATA_TYPES = 'datatypes';
52
+
53
+ /** How many data type boxes side by side in the data types view, and how far apart. */
54
+ static DATA_TYPE_COLUMNS = 4;
55
+ static DATA_TYPE_SPACING = { x: 260, gap: 40 };
56
+
57
+ /**
58
+ * The views that are no level of the schema, each one way to read the
59
+ * whole model: its name (a `level` for view()), label and description,
60
+ * whether a model has it to show (`offered`), whether a box dragged in it
61
+ * keeps its place (`keepsPlaces`), and what it draws.
62
+ */
63
+ static OTHER_VIEWS = Object.freeze([
64
+ Object.freeze({
65
+ name: 'mapping', label: 'Mapping', description: 'The levels side by side, joined where one realises another',
66
+ offered: () => true, keepsPlaces: true,
67
+ view: (model, choice) => model.mappingView(choice),
68
+ }),
69
+ Object.freeze({
70
+ name: 'datatypes', label: 'Data types', description: 'Its data types: enumerations with their values, structures with their fields',
71
+ offered: (model) => model.dataTypes.length > 0, keepsPlaces: false,
72
+ view: (model) => model.dataTypesView(),
73
+ }),
74
+ Object.freeze({
75
+ name: 'landscape', label: 'Service landscape', description: 'The service domains in their business areas and business domains (BIAN)',
76
+ offered: (model) => model.serviceDomains().length > 0, keepsPlaces: false,
77
+ view: (model) => ServiceLandscape.view(model.serviceDomains()),
78
+ }),
79
+ ]);
80
+
81
+ /** One of OTHER_VIEWS by name, or null for a level of the schema. */
82
+ static otherView(name) {
83
+ return DataModel.OTHER_VIEWS.find(v => v.name === name) ?? null;
84
+ }
85
+
86
+ /** The room between the mapping view's columns, and between the boxes in one. */
87
+ static MAPPING_COLUMN = 380;
88
+ static MAPPING_GAP = 40;
89
+
90
+ /** How far below the lowest box notes with no place of their own sit, and how far apart. */
91
+ static NOTE_ROW = 200;
92
+ static NOTE_SPACING = 220;
93
+
94
+ /**
95
+ * @param {Object} spec
96
+ * @param {Object<string, {entities, relationships}>} [spec.levels] - Each level's schema
97
+ * @param {Array<Object|LevelMapping>} [spec.mappings] - Conceptual → logical, logical → physical
98
+ * @param {Object<string, {x, y}>} [spec.mappingLayout] - Where the mapping view's boxes are, by 'level:id'
99
+ * @param {Object<string, Object<string, {x, y}>>} [spec.noteLayout] - Where a rule's note is on a level, by level then rule id
100
+ * (the conceptual level's is the rule's own x, y)
101
+ * @param {Array<Object|DataModelPerspective>} [spec.perspectives]
102
+ * @param {Array<Object|DataModelContext>} [spec.contexts]
103
+ * @param {Array<Object|DataType>} [spec.dataTypes] - Its data types (DataType and its kinds)
104
+ * @param {Array<Object>} [spec.entities] - The older, one-entity form (levelsFromCombined)
105
+ * @param {Array<Object>} [spec.relationships]
106
+ * @param {string} [spec.name] - What the model is called
107
+ */
108
+ constructor(spec = {}) {
109
+ /** What the model is called. */
110
+ this.name = spec.name ?? '';
111
+ this._version = 0;
112
+ this._saved = 0;
113
+ this._editListeners = [];
114
+ const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
115
+ this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
116
+ this.mappings = LEVELS.slice(1).map((to, i) => {
117
+ const from = LEVELS[i];
118
+ const given = mappings.find(m => m.from === from && m.to === to);
119
+ return given instanceof LevelMapping ? given : new LevelMapping(given ?? { from, to });
120
+ });
121
+ this.mappingLayout = { ...mappingLayout };
122
+ this.noteLayout = Object.fromEntries(Object.entries(noteLayout).map(([level, at]) => [level, { ...at }]));
123
+ this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p : DataModelPerspective.from(p)));
124
+ this.contexts = (spec.contexts ?? []).map(c => (c instanceof DataModelContext ? c : new DataModelContext(c)));
125
+ /** Its data types: the types attributes name (`attribute.type`, a data type's id or name). */
126
+ this.dataTypes = (spec.dataTypes ?? []).map(t => (t instanceof DataType ? t : DataType.from(t)));
127
+ }
128
+
129
+ /**
130
+ * Levels from the older form: one entity per thing, each level's detail on
131
+ * it (`entity.logical.attributes`, `entity.physical.tableName`...). Every
132
+ * entity is a concept, and a logical entity and a table when it has their
133
+ * detail (or none of either), mapped one to one; every relationship is at
134
+ * each level between what is there.
135
+ */
136
+ static levelsFromCombined({ entities = [], relationships = [] } = {}) {
137
+ const common = (e) => {
138
+ const { conceptual, logical, physical, ...rest } = e;
139
+ return rest;
140
+ };
141
+ const at = {
142
+ conceptual: entities.map(e => common(e)),
143
+ logical: entities.map(e => ({ ...common(e), attributes: e.logical?.attributes ?? [] })),
144
+ physical: entities.map(e => ({ ...common(e), name: e.physical?.tableName ?? e.name, attributes: e.physical?.attributes ?? [] })),
145
+ };
146
+ const levels = Object.fromEntries(LEVELS.map(level => [level, { entities: at[level], relationships: relationships.map(r => ({ ...r })) }]));
147
+ const oneToOne = (from, to) => ({ from, to, pairs: entities.map(e => [e.id, e.id]) });
148
+ return { levels, mappings: [oneToOne('conceptual', 'logical'), oneToOne('logical', 'physical')] };
149
+ }
150
+
151
+ // ===== Compared and merged =====
152
+
153
+ /**
154
+ * What two data models are compared on and merged by, what is needed first:
155
+ * each level's entities, then each level's relationships, the mappings,
156
+ * contexts and perspectives.
157
+ */
158
+ comparedKinds() {
159
+ return [
160
+ ...LEVELS.map(level => new LevelEntitiesCompared(level)),
161
+ ...LEVELS.map(level => new LevelRelationshipsCompared(level)),
162
+ new MappingsCompared(),
163
+ new ContextsCompared(),
164
+ new DataTypesCompared(),
165
+ new PerspectivesCompared(),
166
+ ];
167
+ }
168
+
169
+ /** How this model differs from another (ModelComparison), which can merge the other's into it. */
170
+ compareWith(other) {
171
+ return new ModelComparison(this, other);
172
+ }
173
+
174
+ /** Its undo history: a merge is one step on it. */
175
+ get history() {
176
+ return (this._history ??= new CommandHistory());
177
+ }
178
+
179
+ // ===== Changed since saved =====
180
+
181
+ /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
182
+ edited() {
183
+ this._version++;
184
+ this._emitEdit();
185
+ }
186
+
187
+ /** This state is the saved one (a file opened or saved). */
188
+ markSaved() {
189
+ this._saved = this._version;
190
+ this._emitEdit();
191
+ }
192
+
193
+ /** Whether it changed since markSaved. */
194
+ isDirty() {
195
+ return this._version !== this._saved;
196
+ }
197
+
198
+ /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
199
+ onEdit(fn) {
200
+ this._editListeners.push(fn);
201
+ return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
202
+ }
203
+
204
+ /** @private */
205
+ _emitEdit() {
206
+ for (const fn of this._editListeners) fn(this);
207
+ }
208
+
209
+ /** A level's schema (LevelSchema). */
210
+ level(name) {
211
+ const schema = this.levels[name];
212
+ if (!schema) throw new Error(`Unknown level: "${name}"`);
213
+ return schema;
214
+ }
215
+
216
+ /** The mapping from one level to the next (LevelMapping). */
217
+ mapping(from, to) {
218
+ return this.mappings.find(m => m.from === from && m.to === to) ?? null;
219
+ }
220
+
221
+ /** The concepts' entities, for perspectives and contexts to name. */
222
+ get concepts() {
223
+ return this.level('conceptual').entities;
224
+ }
225
+
226
+ /** The entities at `level` that realise some of `conceptIds` (through the mappings); null for null (every one). */
227
+ realising(level, conceptIds) {
228
+ if (!conceptIds) return null;
229
+ let ids = new Set(conceptIds);
230
+ for (let i = 1; i <= LEVELS.indexOf(level); i++) {
231
+ const mapping = this.mapping(LEVELS[i - 1], LEVELS[i]);
232
+ ids = new Set([...ids].flatMap(id => mapping.targetsOf(id)));
233
+ }
234
+ return ids;
235
+ }
236
+
237
+ /** The concepts an entity at `level` realises (itself at the conceptual level). */
238
+ conceptsOf(level, id) {
239
+ let ids = new Set([id]);
240
+ for (let i = LEVELS.indexOf(level); i > 0; i--) {
241
+ const mapping = this.mapping(LEVELS[i - 1], LEVELS[i]);
242
+ ids = new Set([...ids].flatMap(x => mapping.sourcesOf(x)));
243
+ }
244
+ return [...ids];
245
+ }
246
+
247
+ /** The perspectives (service domains) that own a business object (a logical entity). */
248
+ ownersOf(entityId) {
249
+ return this.perspectives.filter(p => p.ownedEntities().includes(entityId));
250
+ }
251
+
252
+ /** The checks this model is held to (DataModelChecker): ownership, service domains, mappings. */
253
+ checker() {
254
+ return new DataModelChecker(this);
255
+ }
256
+
257
+ /** A perspective by id, or null (the whole schema). */
258
+ perspective(id) {
259
+ return this.perspectives.find(p => p.id === id) ?? null;
260
+ }
261
+
262
+ /** A context by id, or null (the schema's own descriptions). */
263
+ context(id) {
264
+ return this.contexts.find(c => c.id === id) ?? null;
265
+ }
266
+
267
+ /**
268
+ * What to draw for a perspective read in a context (either may be null) at
269
+ * a level ('conceptual', 'logical', 'physical'; 'mapping': mappingView).
270
+ * Entities: those realising the concepts the perspective shows (the
271
+ * concepts themselves under its names), each with its level's detail for
272
+ * dataModelDiagram (`entity[level]`), meaning what the context says of its
273
+ * concepts, and `details` (shown on hover): the schema entity behind a
274
+ * renamed concept, the concepts a logical entity or table realises, the
275
+ * context and the rules on them. Relationships: the level's, between shown
276
+ * entities. Annotations: the context's rules, each a note on what realises
277
+ * the concepts it applies to. Links: none (mappingView has them).
278
+ * @param {{perspective?: string|null, context?: string|null, level?: string}} [choice]
279
+ * @returns {{entities, relationships, annotations, links}}
280
+ */
281
+ view({ perspective = null, context = null, level = 'conceptual' } = {}) {
282
+ const other = DataModel.otherView(level);
283
+ if (other) return other.view(this, { perspective, context });
284
+ const lens = this.perspective(perspective);
285
+ const meaning = this.context(context);
286
+ const schema = this.level(level);
287
+ const allowed = this.realising(level, lens ? this.concepts.filter(c => lens.shows(c.id)).map(c => c.id) : null);
288
+ // A kind of perspective may show more at a level (a service domain: its control record and qualifiers).
289
+ for (const id of lens?.alsoShows(level) ?? []) allowed?.add(id);
290
+ const shown = schema.entities.filter(e => !allowed || allowed.has(e.id));
291
+ const ids = new Set(shown.map(e => e.id));
292
+ const conceptName = (id) => {
293
+ const concept = this.level('conceptual').entity(id);
294
+ return concept ? (lens ? lens.nameOf(concept) : concept.name) : id;
295
+ };
296
+ const entities = shown.map(entity => {
297
+ const concepts = this.conceptsOf(level, entity.id);
298
+ const name = level === 'conceptual' && lens ? lens.nameOf(entity) : entity.name;
299
+ const meanings = meaning ? concepts.map(c => meaning.meaningOf(c)).filter(Boolean) : [];
300
+ const rules = meaning ? [...new Set(concepts.flatMap(c => meaning.constraintsOn(c).map(r => r.text)))] : [];
301
+ const owners = level === 'logical' ? this.ownersOf(entity.id).map(o => o.name) : [];
302
+ const typed = this.typedAttributes(entity);
303
+ const iso = this.iso20022Of(entity);
304
+ const details = {
305
+ ...entity.details,
306
+ ...(owners.length && { 'owned by': owners.join(', ') }),
307
+ ...(typed.length && { 'data types': typed.map(({ attribute, type }) => `${attribute.name}: ${type.summary()}`) }),
308
+ ...(iso.length && { 'ISO 20022': iso }),
309
+ ...(name !== entity.name && { schema: entity.name }),
310
+ ...(level !== 'conceptual' && concepts.length && { concepts: concepts.map(conceptName).join(', ') }),
311
+ ...(meaning && { context: meaning.name }),
312
+ ...(rules.length && { rules }),
313
+ };
314
+ const { attributes, ...rest } = entity;
315
+ const mark = lens?.decorate(entity, level) ?? {};
316
+ const allDetails = { ...details, ...mark.details };
317
+ return {
318
+ ...rest,
319
+ name,
320
+ ...(mark.color && { color: mark.color }),
321
+ description: meanings.length ? meanings.join(' ') : entity.description,
322
+ ...(level !== 'conceptual' && { [level]: { attributes: attributes ?? [], ...(level === 'physical' && { tableName: entity.name }) } }),
323
+ ...(Object.keys(allDetails).length && { details: allDetails }),
324
+ };
325
+ });
326
+ const relationships = schema.relationships
327
+ .filter(r => ids.has(r.source) && ids.has(r.target))
328
+ .map(r => (lens && level === 'conceptual' ? { ...r, name: lens.relationshipNameOf(r) } : r));
329
+ const notes = [
330
+ ...(meaning?.constraints ?? []).map(c => {
331
+ const on = this.realising(level, c.applies) ?? new Set(c.applies);
332
+ return { id: c.id, text: c.text, attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
333
+ }),
334
+ ...(lens?.notes(level) ?? []).map(n => ({ ...n, attachTo: n.attachTo.filter(id => ids.has(id)), ...this.notePlace(level, n) })),
335
+ ].filter(a => a.attachTo.length);
336
+ // A note with no place on this level goes in a row below every box, so it covers none.
337
+ const below = Math.max(0, ...shown.map(e => (Number.isFinite(e.y) ? e.y : 0))) + DataModel.NOTE_ROW;
338
+ let free = 0;
339
+ const annotations = notes.map(a => (Number.isFinite(a.x) ? a : { ...a, x: 130 + DataModel.NOTE_SPACING * free++, y: below }));
340
+ return { entities, relationships, annotations, links: [] };
341
+ }
342
+
343
+ /**
344
+ * How the levels map: a column per level (conceptual, logical, physical),
345
+ * each entity the perspective shows drawn at its own level (`level` on it,
346
+ * its id 'level:id'), and a link for each mapping between shown ones.
347
+ * A box sits where it was left (`mappingLayout`), else down its column.
348
+ */
349
+ mappingView({ perspective = null, context = null } = {}) {
350
+ const columns = LEVELS.map(level => [level, this.view({ perspective, context, level })]);
351
+ const entities = columns.flatMap(([level, { entities: shown }], column) => {
352
+ let y = 60;
353
+ return shown.map(entity => {
354
+ const key = `${level}:${entity.id}`;
355
+ const height = DataModel.boxHeight(level, entity);
356
+ const place = this.mappingLayout[key] ?? { x: 120 + column * DataModel.MAPPING_COLUMN, y: y + height / 2 };
357
+ y += height + DataModel.MAPPING_GAP;
358
+ return { ...entity, id: key, level, x: place.x, y: place.y };
359
+ });
360
+ });
361
+ const shown = new Set(entities.map(e => e.id));
362
+ const links = this.mappings.flatMap(m => m.pairs
363
+ .map(([a, b]) => ({ id: `map:${m.from}:${a}->${m.to}:${b}`, source: `${m.from}:${a}`, target: `${m.to}:${b}` }))
364
+ .filter(l => shown.has(l.source) && shown.has(l.target)));
365
+ return { entities, relationships: [], annotations: [], links };
366
+ }
367
+
368
+ /** Where a rule's note is on a level: on the conceptual level the rule's own place, else the level's note layout; {} for none. */
369
+ notePlace(level, constraint) {
370
+ const at = level === 'conceptual' ? constraint : this.noteLayout[level]?.[constraint.id];
371
+ return Number.isFinite(at?.x) ? { x: at.x, y: at.y } : {};
372
+ }
373
+
374
+ /** Keep where a rule's note was left on a level (notePlace reads it back). */
375
+ placeNote(level, constraint, { x, y }) {
376
+ if (level === 'conceptual') Object.assign(constraint, { x, y });
377
+ else (this.noteLayout[level] ??= {})[constraint.id] = { x, y };
378
+ }
379
+
380
+ /** How tall an entity's box is at a level (a table grows with its columns), to stack a column. */
381
+ static boxHeight(level, entity) {
382
+ const attributes = entity[level]?.attributes ?? [];
383
+ return attributes.length ? NodeGeometry.tableNodeSize({ columns: attributes }).h : 56;
384
+ }
385
+
386
+ // ===== Perspectives and contexts of one's own =====
387
+
388
+ // ===== Data types =====
389
+
390
+ /** A data type by its id, else its name (any case), or null. */
391
+ dataType(ref) {
392
+ if (!ref) return null;
393
+ const name = String(ref).toLowerCase();
394
+ return this.dataTypes.find(t => t.id === ref) ?? this.dataTypes.find(t => t.name.toLowerCase() === name) ?? null;
395
+ }
396
+
397
+ /** An attribute's data type (its `type` naming one), or null for a plain type ('VARCHAR(20)'). */
398
+ typeOf(attribute) {
399
+ return this.dataType(attribute?.type);
400
+ }
401
+
402
+ /** An entity's attributes that have a data type: `[{ attribute, type }]`. */
403
+ typedAttributes(entity) {
404
+ return (entity.attributes ?? []).map(attribute => ({ attribute, type: this.typeOf(attribute) })).filter(a => a.type);
405
+ }
406
+
407
+ /**
408
+ * What an entity says of ISO 20022, a line each: its own type, then each
409
+ * attribute's (its own `iso20022`, else its data type's).
410
+ */
411
+ iso20022Of(entity) {
412
+ return [
413
+ ...(entity.iso20022 ? [`${entity.name}: ${entity.iso20022}`] : []),
414
+ ...(entity.attributes ?? []).map(a => [a.name, a.iso20022 || this.typeOf(a)?.iso20022]).filter(([, t]) => t).map(([n, t]) => `${n}: ${t}`),
415
+ ];
416
+ }
417
+
418
+ /** Where a data type is used: `{ attributes: [{ level, entity, attribute }], types: [DataType] }`. */
419
+ usesOf(id) {
420
+ const type = this.dataType(id);
421
+ if (!type) return { attributes: [], types: [] };
422
+ const names = (ref) => this.dataType(ref) === type;
423
+ return {
424
+ attributes: LEVELS.flatMap(level => this.level(level).entities.flatMap(entity => (entity.attributes ?? [])
425
+ .filter(a => names(a.type)).map(attribute => ({ level, entity, attribute })))),
426
+ types: this.dataTypes.filter(t => t !== type && t.uses().some(names)),
427
+ };
428
+ }
429
+
430
+ /** Add a data type (a DataType spec, its `kind` saying which; an id is made from its name when it has none). */
431
+ addDataType(spec) {
432
+ const type = DataType.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.dataTypes) });
433
+ this.dataTypes.push(type);
434
+ this.edited();
435
+ return type;
436
+ }
437
+
438
+ /** Change a data type (its id and kind stay). */
439
+ updateDataType(id, spec) {
440
+ const i = this.dataTypes.findIndex(t => t.id === id);
441
+ if (i < 0) return null;
442
+ this.dataTypes[i] = DataType.from({ ...spec, kind: this.dataTypes[i].constructor.kind, id });
443
+ this.edited();
444
+ return this.dataTypes[i];
445
+ }
446
+
447
+ removeDataType(id) {
448
+ this.dataTypes = this.dataTypes.filter(t => t.id !== id);
449
+ this.edited();
450
+ }
451
+
452
+ /**
453
+ * The data types view: each a box with a row per value or field (as a
454
+ * table), enumerations and structures coloured apart, in a grid; a link
455
+ * from a structure to each data type its fields are.
456
+ */
457
+ dataTypesView() {
458
+ const { x: across, gap } = DataModel.DATA_TYPE_SPACING;
459
+ const columns = DataModel.DATA_TYPE_COLUMNS;
460
+ const color = { enumeration: '#fef3c7', structure: '#e0f2fe', type: '#f3f4f6' };
461
+ const heights = this.dataTypes.map(t => DataModel.boxHeight('datatypes', { datatypes: { attributes: t.rows() } }));
462
+ const tops = [];
463
+ this.dataTypes.forEach((t, i) => {
464
+ const above = i - columns;
465
+ tops.push(above < 0 ? 60 : tops[above] + heights[above] + gap);
466
+ });
467
+ const entities = this.dataTypes.map((t, i) => ({
468
+ id: `type:${t.id}`, name: `${t.constructor === DataType ? '' : `«${t.constructor.kindLabel.toLowerCase()}» `}${t.name}`,
469
+ level: 'datatypes', datatypes: { attributes: t.rows().length ? t.rows() : [{ name: t.basedOn || '—' }] },
470
+ x: 140 + (i % columns) * across, y: tops[i] + heights[i] / 2,
471
+ color: color[t.constructor.kind] ?? color.type,
472
+ description: t.description,
473
+ details: { kind: t.constructor.kindLabel, ...(t.iso20022 && { 'ISO 20022': t.iso20022 }), used: this._usedSummary(t) },
474
+ }));
475
+ const links = this.dataTypes.flatMap(t => [...new Set(t.uses().map(ref => this.dataType(ref)).filter(u => u && u !== t))]
476
+ .map(u => ({ id: `uses:${t.id}->${u.id}`, source: `type:${t.id}`, target: `type:${u.id}` })));
477
+ return { entities, relationships: [], annotations: [], links, notation: 'crowsfoot' };
478
+ }
479
+
480
+ /** Where a data type is used, in a few words. @private */
481
+ _usedSummary(type) {
482
+ const { attributes, types } = this.usesOf(type.id);
483
+ const parts = [
484
+ ...(attributes.length ? [`${attributes.length} attribute${attributes.length === 1 ? '' : 's'}`] : []),
485
+ ...(types.length ? [`by ${types.map(t => t.name).join(', ')}`] : []),
486
+ ];
487
+ return parts.join('; ') || 'nowhere';
488
+ }
489
+
490
+ /** Its service domains (the perspectives that are ServiceDomains). */
491
+ serviceDomains() {
492
+ return this.perspectives.filter(p => p.constructor.kind === 'service-domain');
493
+ }
494
+
495
+ /**
496
+ * Service domains read from elsewhere (BianLandscapeReader), as one undo
497
+ * step (bringCommand). Null when there is nothing to bring.
498
+ * @param {Array<Object>} specs - ServiceDomain specs
499
+ * @param {string} [source] - Where they were read from, for the undo label
500
+ * @returns {MergeModelCommand|null}
501
+ */
502
+ serviceDomainsCommand(specs, source = 'a service landscape') {
503
+ return this.bringCommand({ serviceDomains: specs },
504
+ `Read ${specs.length} service domain${specs.length === 1 ? '' : 's'} from ${source}`);
505
+ }
506
+
507
+ /**
508
+ * Things read from elsewhere (a BIAN landscape, a BIAN semantic API)
509
+ * brought in as one undo step (a MergeModelCommand). A logical entity
510
+ * already here (by id, else by name) stays as it is, and what was read
511
+ * refers to it; so does a data type already here by name. Only the data
512
+ * types what is brought needs come (those its new entities' attributes
513
+ * are, and theirs in turn). A service domain already here (by id, else by
514
+ * name) keeps all it has, taking its business area and domain from what
515
+ * was read, its asset, pattern and control record where it has none, and
516
+ * the behaviour qualifiers it lacks (by name or entity); one that would
517
+ * come out the same is left alone. Null when there is nothing to bring.
518
+ * @param {Object} read
519
+ * @param {Array<Object>} [read.entities] - Logical entities
520
+ * @param {Array<Object>} [read.relationships] - Logical relationships between them
521
+ * @param {Array<Object>} [read.dataTypes] - DataType specs
522
+ * @param {Array<Object>} [read.serviceDomains] - ServiceDomain specs
523
+ * @param {string} label - The undo label
524
+ * @returns {MergeModelCommand|null}
525
+ */
526
+ bringCommand({ entities = [], relationships = [], dataTypes = [], serviceDomains = [] }, label) {
527
+ const logical = this.level('logical');
528
+ const sameName = (list, name) => list.find(x => String(x.name).toLowerCase() === String(name).toLowerCase());
529
+ // What was read refers to an entity already here by that one's id.
530
+ const idHere = new Map(entities.map(e => [e.id, (logical.entity(e.id) ?? sameName(logical.entities, e.name))?.id ?? null]));
531
+ const as = (id) => idHere.get(id) ?? id;
532
+ const newEntities = entities.filter(e => !idHere.get(e.id));
533
+ const newRelationships = relationships.map(r => ({ ...r, source: as(r.source), target: as(r.target) }))
534
+ .filter(r => !logical.relationships.some(h => h.source === r.source && h.target === r.target && (h.name ?? '') === (r.name ?? '')));
535
+ // The data types the new entities need, then the ones those need; none already here by id or name.
536
+ const readType = (ref) => dataTypes.find(t => t.id === ref) ?? sameName(dataTypes, ref);
537
+ const needed = new Set();
538
+ const need = (ref) => {
539
+ const t = readType(ref);
540
+ if (!t || needed.has(t) || this.dataType(t.id) || sameName(this.dataTypes, t.name)) return;
541
+ needed.add(t);
542
+ DataType.from(t).uses().forEach(need);
543
+ };
544
+ newEntities.flatMap(e => e.attributes ?? []).forEach(a => need(a.type));
545
+ const newTypes = dataTypes.filter(t => needed.has(t));
546
+ const domains = serviceDomains.map(spec => {
547
+ const read = { ...spec, controlRecord: spec.controlRecord ? as(spec.controlRecord) : spec.controlRecord, qualifiers: (spec.qualifiers ?? []).map(q => ({ ...q, ...(q.entity && { entity: as(q.entity) }) })) };
548
+ const here = this.perspective(read.id) ?? this.perspectives.find(p => p.name.toLowerCase() === String(read.name).toLowerCase());
549
+ if (!here) return read;
550
+ const kept = here.toJSON();
551
+ return {
552
+ ...kept, kind: read.kind,
553
+ businessArea: read.businessArea || kept.businessArea || '', businessDomain: read.businessDomain || kept.businessDomain || '',
554
+ asset: kept.asset || read.asset || '', pattern: here.functionalPattern ? kept.pattern : (read.pattern || kept.pattern || ''),
555
+ controlRecord: kept.controlRecord || read.controlRecord || null,
556
+ qualifiers: [...kept.qualifiers, ...read.qualifiers.filter(q => !kept.qualifiers.some(k =>
557
+ k.name.toLowerCase() === String(q.name).toLowerCase() || (q.entity && k.entity === q.entity)))],
558
+ };
559
+ }).filter(spec => {
560
+ // One here that would come out the same is nothing to bring.
561
+ const here = this.perspective(spec.id);
562
+ return !here || JSON.stringify(DataModelPerspective.from(spec).toJSON()) !== JSON.stringify(here.toJSON());
563
+ });
564
+ if (!newEntities.length && !newRelationships.length && !newTypes.length && !domains.length) return null;
565
+ const from = new DataModel({
566
+ name: label,
567
+ levels: { logical: { entities: newEntities, relationships: newRelationships } },
568
+ dataTypes: newTypes, perspectives: domains,
569
+ });
570
+ const kinds = new Map(this.comparedKinds().map(k => [k.key, k]));
571
+ const steps = [
572
+ ...from.level('logical').entities.map(item => ({ kind: kinds.get('logical-entity'), item })),
573
+ ...from.level('logical').relationships.map(item => ({ kind: kinds.get('logical-relationship'), item })),
574
+ ...from.dataTypes.map(item => ({ kind: kinds.get('data-type'), item })),
575
+ ...from.perspectives.map(item => ({ kind: kinds.get('perspective'), item })),
576
+ ];
577
+ return new MergeModelCommand(this, from, steps, label);
578
+ }
579
+
580
+ /** Add a perspective (DataModelPerspective spec; an id is made from its name when it has none). */
581
+ addPerspective(spec) {
582
+ const perspective = DataModelPerspective.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
583
+ this.perspectives.push(perspective);
584
+ this.edited();
585
+ return perspective;
586
+ }
587
+
588
+ /** Change a perspective (its id stays). */
589
+ updatePerspective(id, spec) {
590
+ const i = this.perspectives.findIndex(p => p.id === id);
591
+ if (i < 0) return null;
592
+ this.perspectives[i] = DataModelPerspective.from({ kind: this.perspectives[i].constructor.kind, ...spec, id });
593
+ this.edited();
594
+ return this.perspectives[i];
595
+ }
596
+
597
+ removePerspective(id) {
598
+ this.perspectives = this.perspectives.filter(p => p.id !== id);
599
+ this.edited();
600
+ }
601
+
602
+ /** Add a context (DataModelContext spec; an id is made from its name when it has none). */
603
+ addContext(spec) {
604
+ const context = new DataModelContext({ ...spec, id: spec.id ?? this._freshId(spec.name, this.contexts) });
605
+ this.contexts.push(context);
606
+ this.edited();
607
+ return context;
608
+ }
609
+
610
+ /** Change a context (its id stays). */
611
+ updateContext(id, spec) {
612
+ const i = this.contexts.findIndex(c => c.id === id);
613
+ if (i < 0) return null;
614
+ this.contexts[i] = new DataModelContext({ ...spec, id });
615
+ this.edited();
616
+ return this.contexts[i];
617
+ }
618
+
619
+ /** Remove a context; a perspective read in it is then read in none. */
620
+ removeContext(id) {
621
+ this.contexts = this.contexts.filter(c => c.id !== id);
622
+ for (const p of this.perspectives) if (p.context === id) p.context = null;
623
+ this.edited();
624
+ }
625
+
626
+ /** An id from a name, not yet among `taken`. @private */
627
+ _freshId(name, taken) {
628
+ const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
629
+ let id = base;
630
+ for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
631
+ return id;
632
+ }
633
+
634
+ toJSON() {
635
+ return {
636
+ name: this.name,
637
+ levels: Object.fromEntries(LEVELS.map(level => [level, this.level(level).toJSON()])),
638
+ mappings: this.mappings.map(m => m.toJSON()),
639
+ mappingLayout: { ...this.mappingLayout },
640
+ noteLayout: Object.fromEntries(Object.entries(this.noteLayout).map(([level, at]) => [level, { ...at }])),
641
+ perspectives: this.perspectives.map(p => p.toJSON()), contexts: this.contexts.map(c => c.toJSON()),
642
+ ...(this.dataTypes.length && { dataTypes: this.dataTypes.map(t => t.toJSON()) }),
643
+ };
644
+ }
645
+ }