@peter.prib/renderengine 0.2.6 → 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.
- package/README.md +4 -0
- package/REFERENCE.md +180 -2
- package/dist/renderengine-workspace.umd.js +8704 -5310
- package/dist/renderengine.umd.js +5675 -3398
- package/examples/archimate.html +68 -66
- package/examples/data-model.html +223 -13
- package/package.json +1 -1
- package/src/bian.js +182 -0
- package/src/charting/DiagramEditor.js +5 -26
- package/src/charting/DiagramTypeRegistry.js +8 -2
- package/src/charting/ModelCheckDialog.js +63 -0
- package/src/charting/ModelChecker.js +26 -0
- package/src/charting/ViewTabStrip.js +94 -81
- package/src/charting/datamodel/BianLandscapeReader.js +185 -0
- package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
- package/src/charting/datamodel/DataModel.js +356 -10
- package/src/charting/datamodel/DataModelChecker.js +92 -0
- package/src/charting/datamodel/DataModelCompared.js +127 -0
- package/src/charting/datamodel/DataModelFileFormat.js +33 -0
- package/src/charting/datamodel/DataModelPerspective.js +45 -0
- package/src/charting/datamodel/DataType.js +108 -0
- package/src/charting/datamodel/EnumerationType.js +51 -0
- package/src/charting/datamodel/ServiceDomain.js +177 -0
- package/src/charting/datamodel/ServiceLandscape.js +104 -0
- package/src/charting/datamodel/StructuredType.js +63 -0
- package/src/charting/datamodel/index.js +10 -0
- package/src/charting/diagramModel/MergeModelCommand.js +43 -37
- package/src/charting/diagramModel/OpenViewList.js +75 -0
- package/src/charting/diagramModel/ViewSetChecker.js +22 -34
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +12 -4
- package/src/charting/diagrams/EdgeGeometry.js +578 -553
- package/src/charting/diagrams/GraphDiagram.js +11 -1
- package/src/charting/diagrams/NodeGeometry.js +169 -158
- package/src/charting/glyphs/PersonGlyph.js +24 -23
- package/src/index.js +2666 -2664
- package/src/workspace/EditorShell.js +166 -67
- package/src/workspace/PanelLayout.js +56 -0
- package/src/workspace/layout/TabsNode.js +205 -191
- package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
- package/src/workspace/parts/DataModelPart.js +339 -29
- package/src/workspace/parts/DiagramEditorPart.js +2 -8
- package/src/workspace/parts/EditorPart.js +461 -407
- package/src/workspace/parts/ModelFile.js +216 -187
- package/src/workspace/parts/ModelsPart.js +483 -192
- package/src/workspace/storage/BackupFolder.js +145 -0
- package/src/workspace/storage/KeyValueStore.js +92 -0
- package/src/workspace/storage/ModelLibrary.js +65 -0
- package/src/workspace/workArea.js +417 -413
- package/src/yaml/YamlReader.js +372 -0
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { DataModelPerspective } from './DataModelPerspective.js';
|
|
2
2
|
import { DataModelContext } from './DataModelContext.js';
|
|
3
|
+
import { DataType } from './DataType.js';
|
|
4
|
+
import './EnumerationType.js';
|
|
5
|
+
import './StructuredType.js';
|
|
3
6
|
import { LevelSchema } from './LevelSchema.js';
|
|
4
7
|
import { LevelMapping } from './LevelMapping.js';
|
|
5
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';
|
|
6
17
|
|
|
7
18
|
/** The levels, outermost first: each realised by the next. */
|
|
8
19
|
const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
|
|
@@ -21,6 +32,10 @@ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
|
|
|
21
32
|
* The older form, one entity per thing with each level's detail on it
|
|
22
33
|
* (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
|
|
23
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.
|
|
24
39
|
*/
|
|
25
40
|
export class DataModel {
|
|
26
41
|
/** The levels, outermost first. */
|
|
@@ -29,6 +44,45 @@ export class DataModel {
|
|
|
29
44
|
/** The view of how the levels map (a `level` for view()). */
|
|
30
45
|
static MAPPING = 'mapping';
|
|
31
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
|
+
|
|
32
86
|
/** The room between the mapping view's columns, and between the boxes in one. */
|
|
33
87
|
static MAPPING_COLUMN = 380;
|
|
34
88
|
static MAPPING_GAP = 40;
|
|
@@ -46,10 +100,17 @@ export class DataModel {
|
|
|
46
100
|
* (the conceptual level's is the rule's own x, y)
|
|
47
101
|
* @param {Array<Object|DataModelPerspective>} [spec.perspectives]
|
|
48
102
|
* @param {Array<Object|DataModelContext>} [spec.contexts]
|
|
103
|
+
* @param {Array<Object|DataType>} [spec.dataTypes] - Its data types (DataType and its kinds)
|
|
49
104
|
* @param {Array<Object>} [spec.entities] - The older, one-entity form (levelsFromCombined)
|
|
50
105
|
* @param {Array<Object>} [spec.relationships]
|
|
106
|
+
* @param {string} [spec.name] - What the model is called
|
|
51
107
|
*/
|
|
52
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 = [];
|
|
53
114
|
const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
|
|
54
115
|
this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
|
|
55
116
|
this.mappings = LEVELS.slice(1).map((to, i) => {
|
|
@@ -59,8 +120,10 @@ export class DataModel {
|
|
|
59
120
|
});
|
|
60
121
|
this.mappingLayout = { ...mappingLayout };
|
|
61
122
|
this.noteLayout = Object.fromEntries(Object.entries(noteLayout).map(([level, at]) => [level, { ...at }]));
|
|
62
|
-
this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p :
|
|
123
|
+
this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p : DataModelPerspective.from(p)));
|
|
63
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)));
|
|
64
127
|
}
|
|
65
128
|
|
|
66
129
|
/**
|
|
@@ -85,6 +148,64 @@ export class DataModel {
|
|
|
85
148
|
return { levels, mappings: [oneToOne('conceptual', 'logical'), oneToOne('logical', 'physical')] };
|
|
86
149
|
}
|
|
87
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
|
+
|
|
88
209
|
/** A level's schema (LevelSchema). */
|
|
89
210
|
level(name) {
|
|
90
211
|
const schema = this.levels[name];
|
|
@@ -123,6 +244,16 @@ export class DataModel {
|
|
|
123
244
|
return [...ids];
|
|
124
245
|
}
|
|
125
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
|
+
|
|
126
257
|
/** A perspective by id, or null (the whole schema). */
|
|
127
258
|
perspective(id) {
|
|
128
259
|
return this.perspectives.find(p => p.id === id) ?? null;
|
|
@@ -148,11 +279,14 @@ export class DataModel {
|
|
|
148
279
|
* @returns {{entities, relationships, annotations, links}}
|
|
149
280
|
*/
|
|
150
281
|
view({ perspective = null, context = null, level = 'conceptual' } = {}) {
|
|
151
|
-
|
|
282
|
+
const other = DataModel.otherView(level);
|
|
283
|
+
if (other) return other.view(this, { perspective, context });
|
|
152
284
|
const lens = this.perspective(perspective);
|
|
153
285
|
const meaning = this.context(context);
|
|
154
286
|
const schema = this.level(level);
|
|
155
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);
|
|
156
290
|
const shown = schema.entities.filter(e => !allowed || allowed.has(e.id));
|
|
157
291
|
const ids = new Set(shown.map(e => e.id));
|
|
158
292
|
const conceptName = (id) => {
|
|
@@ -164,31 +298,41 @@ export class DataModel {
|
|
|
164
298
|
const name = level === 'conceptual' && lens ? lens.nameOf(entity) : entity.name;
|
|
165
299
|
const meanings = meaning ? concepts.map(c => meaning.meaningOf(c)).filter(Boolean) : [];
|
|
166
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);
|
|
167
304
|
const details = {
|
|
168
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 }),
|
|
169
309
|
...(name !== entity.name && { schema: entity.name }),
|
|
170
310
|
...(level !== 'conceptual' && concepts.length && { concepts: concepts.map(conceptName).join(', ') }),
|
|
171
311
|
...(meaning && { context: meaning.name }),
|
|
172
312
|
...(rules.length && { rules }),
|
|
173
313
|
};
|
|
174
314
|
const { attributes, ...rest } = entity;
|
|
315
|
+
const mark = lens?.decorate(entity, level) ?? {};
|
|
316
|
+
const allDetails = { ...details, ...mark.details };
|
|
175
317
|
return {
|
|
176
318
|
...rest,
|
|
177
319
|
name,
|
|
320
|
+
...(mark.color && { color: mark.color }),
|
|
178
321
|
description: meanings.length ? meanings.join(' ') : entity.description,
|
|
179
322
|
...(level !== 'conceptual' && { [level]: { attributes: attributes ?? [], ...(level === 'physical' && { tableName: entity.name }) } }),
|
|
180
|
-
...(Object.keys(
|
|
323
|
+
...(Object.keys(allDetails).length && { details: allDetails }),
|
|
181
324
|
};
|
|
182
325
|
});
|
|
183
326
|
const relationships = schema.relationships
|
|
184
327
|
.filter(r => ids.has(r.source) && ids.has(r.target))
|
|
185
328
|
.map(r => (lens && level === 'conceptual' ? { ...r, name: lens.relationshipNameOf(r) } : r));
|
|
186
|
-
const notes =
|
|
187
|
-
.map(c => {
|
|
329
|
+
const notes = [
|
|
330
|
+
...(meaning?.constraints ?? []).map(c => {
|
|
188
331
|
const on = this.realising(level, c.applies) ?? new Set(c.applies);
|
|
189
332
|
return { id: c.id, text: c.text, attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
|
|
190
|
-
})
|
|
191
|
-
.
|
|
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);
|
|
192
336
|
// A note with no place on this level goes in a row below every box, so it covers none.
|
|
193
337
|
const below = Math.max(0, ...shown.map(e => (Number.isFinite(e.y) ? e.y : 0))) + DataModel.NOTE_ROW;
|
|
194
338
|
let free = 0;
|
|
@@ -241,10 +385,203 @@ export class DataModel {
|
|
|
241
385
|
|
|
242
386
|
// ===== Perspectives and contexts of one's own =====
|
|
243
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
|
+
|
|
244
580
|
/** Add a perspective (DataModelPerspective spec; an id is made from its name when it has none). */
|
|
245
581
|
addPerspective(spec) {
|
|
246
|
-
const perspective =
|
|
582
|
+
const perspective = DataModelPerspective.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
|
|
247
583
|
this.perspectives.push(perspective);
|
|
584
|
+
this.edited();
|
|
248
585
|
return perspective;
|
|
249
586
|
}
|
|
250
587
|
|
|
@@ -252,17 +589,21 @@ export class DataModel {
|
|
|
252
589
|
updatePerspective(id, spec) {
|
|
253
590
|
const i = this.perspectives.findIndex(p => p.id === id);
|
|
254
591
|
if (i < 0) return null;
|
|
255
|
-
|
|
592
|
+
this.perspectives[i] = DataModelPerspective.from({ kind: this.perspectives[i].constructor.kind, ...spec, id });
|
|
593
|
+
this.edited();
|
|
594
|
+
return this.perspectives[i];
|
|
256
595
|
}
|
|
257
596
|
|
|
258
597
|
removePerspective(id) {
|
|
259
598
|
this.perspectives = this.perspectives.filter(p => p.id !== id);
|
|
599
|
+
this.edited();
|
|
260
600
|
}
|
|
261
601
|
|
|
262
602
|
/** Add a context (DataModelContext spec; an id is made from its name when it has none). */
|
|
263
603
|
addContext(spec) {
|
|
264
604
|
const context = new DataModelContext({ ...spec, id: spec.id ?? this._freshId(spec.name, this.contexts) });
|
|
265
605
|
this.contexts.push(context);
|
|
606
|
+
this.edited();
|
|
266
607
|
return context;
|
|
267
608
|
}
|
|
268
609
|
|
|
@@ -270,13 +611,16 @@ export class DataModel {
|
|
|
270
611
|
updateContext(id, spec) {
|
|
271
612
|
const i = this.contexts.findIndex(c => c.id === id);
|
|
272
613
|
if (i < 0) return null;
|
|
273
|
-
|
|
614
|
+
this.contexts[i] = new DataModelContext({ ...spec, id });
|
|
615
|
+
this.edited();
|
|
616
|
+
return this.contexts[i];
|
|
274
617
|
}
|
|
275
618
|
|
|
276
619
|
/** Remove a context; a perspective read in it is then read in none. */
|
|
277
620
|
removeContext(id) {
|
|
278
621
|
this.contexts = this.contexts.filter(c => c.id !== id);
|
|
279
622
|
for (const p of this.perspectives) if (p.context === id) p.context = null;
|
|
623
|
+
this.edited();
|
|
280
624
|
}
|
|
281
625
|
|
|
282
626
|
/** An id from a name, not yet among `taken`. @private */
|
|
@@ -289,11 +633,13 @@ export class DataModel {
|
|
|
289
633
|
|
|
290
634
|
toJSON() {
|
|
291
635
|
return {
|
|
636
|
+
name: this.name,
|
|
292
637
|
levels: Object.fromEntries(LEVELS.map(level => [level, this.level(level).toJSON()])),
|
|
293
638
|
mappings: this.mappings.map(m => m.toJSON()),
|
|
294
639
|
mappingLayout: { ...this.mappingLayout },
|
|
295
640
|
noteLayout: Object.fromEntries(Object.entries(this.noteLayout).map(([level, at]) => [level, { ...at }])),
|
|
296
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()) }),
|
|
297
643
|
};
|
|
298
644
|
}
|
|
299
645
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ModelChecker } from '../ModelChecker.js';
|
|
2
|
+
import { BianNotation } from '../../bian.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DataModelChecker — checks over a DataModel (a ModelChecker): each business
|
|
6
|
+
* object (a logical entity) owned by one service domain, as BIAN has it
|
|
7
|
+
* (none, once there are service domains, is worth a look; more than one is
|
|
8
|
+
* wrong); service domains complete (a control record, qualifiers that are
|
|
9
|
+
* entities, a pattern BIAN has); mappings joining entities that are there;
|
|
10
|
+
* data types sound (an enumeration with values, each once; one name a type)
|
|
11
|
+
* and used. Each finding names what it is about (`kind`: 'entity',
|
|
12
|
+
* 'perspective', 'mapping', 'data-type') and, for an entity, its `level`.
|
|
13
|
+
*/
|
|
14
|
+
export class DataModelChecker extends ModelChecker {
|
|
15
|
+
checks() {
|
|
16
|
+
return [this.ownedTwice, this.unowned, this.serviceDomains, this.mappings, this.dataTypes];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The service domains (perspectives that own business objects). @private */
|
|
20
|
+
_owners() {
|
|
21
|
+
return this.model.perspectives.filter(p => p.ownedEntities().length);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A business object owned by more than one service domain. */
|
|
25
|
+
ownedTwice() {
|
|
26
|
+
return this.model.level('logical').entities
|
|
27
|
+
.map(e => [e, this.model.ownersOf(e.id)])
|
|
28
|
+
.filter(([, owners]) => owners.length > 1)
|
|
29
|
+
.map(([e, owners]) => ({
|
|
30
|
+
code: 'owned-twice', severity: 'warning', kind: 'entity', level: 'logical', ids: [e.id],
|
|
31
|
+
message: `"${e.name}" is owned by ${owners.length} service domains (${owners.map(o => o.name).join(', ')}); BIAN gives a business object one`,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A business object no service domain owns, once some own business objects. */
|
|
36
|
+
unowned() {
|
|
37
|
+
if (!this._owners().length) return [];
|
|
38
|
+
return this.model.level('logical').entities
|
|
39
|
+
.filter(e => !this.model.ownersOf(e.id).length)
|
|
40
|
+
.map(e => ({
|
|
41
|
+
code: 'unowned', severity: 'info', kind: 'entity', level: 'logical', ids: [e.id],
|
|
42
|
+
message: `"${e.name}" is owned by no service domain`,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A service domain with no control record, a pattern BIAN lacks, or a qualifier that is no entity. */
|
|
47
|
+
serviceDomains() {
|
|
48
|
+
const logical = this.model.level('logical');
|
|
49
|
+
return this._serviceDomains().flatMap(sd => [
|
|
50
|
+
...(!sd.controlRecord ? [{ code: 'no-control-record', severity: 'warning', message: `Service domain "${sd.name}" has no control record` }]
|
|
51
|
+
: !logical.entity(sd.controlRecord) ? [{ code: 'control-record-missing', severity: 'warning', message: `Service domain "${sd.name}": its control record "${sd.controlRecord}" is no logical entity` }]
|
|
52
|
+
: []),
|
|
53
|
+
...(!BianNotation.functionalPattern(sd.pattern) ? [{ code: 'unknown-pattern', severity: 'warning', message: `Service domain "${sd.name}": "${sd.pattern}" is no BIAN functional pattern` }] : []),
|
|
54
|
+
...sd.qualifiers.filter(q => !q.entity || !logical.entity(q.entity)).map(q => ({
|
|
55
|
+
code: 'qualifier-entity-missing', severity: 'info', message: `Service domain "${sd.name}": behaviour qualifier "${q.name}" is no logical entity`,
|
|
56
|
+
})),
|
|
57
|
+
].map(f => ({ ...f, kind: 'perspective', ids: [sd.id] })));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The service domains among the perspectives. @private */
|
|
61
|
+
_serviceDomains() {
|
|
62
|
+
return this.model.perspectives.filter(p => p.constructor.kind === 'service-domain');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An enumeration with no values or a value twice, two data types of one name, a data type nothing uses. */
|
|
66
|
+
dataTypes() {
|
|
67
|
+
const types = this.model.dataTypes;
|
|
68
|
+
const finding = (t, code, severity, message) => ({ code, severity, kind: 'data-type', ids: [t.id], message });
|
|
69
|
+
return types.flatMap(t => {
|
|
70
|
+
const values = (t.values ?? []).map(v => v.name);
|
|
71
|
+
const twice = [...new Set(values.filter((v, i) => values.indexOf(v) !== i))];
|
|
72
|
+
const sameName = types.filter(o => o !== t && o.name.toLowerCase() === t.name.toLowerCase());
|
|
73
|
+
const { attributes, types: users } = this.model.usesOf(t.id);
|
|
74
|
+
return [
|
|
75
|
+
...(t.values && !values.length ? [finding(t, 'enumeration-empty', 'warning', `Enumeration "${t.name}" has no values`)] : []),
|
|
76
|
+
...(twice.length ? [finding(t, 'value-twice', 'warning', `Enumeration "${t.name}" has ${twice.map(v => `"${v}"`).join(', ')} more than once`)] : []),
|
|
77
|
+
...(sameName.length && types.indexOf(t) < types.indexOf(sameName[0]) ? [finding(t, 'type-name-twice', 'warning', `${sameName.length + 1} data types are called "${t.name}": an attribute naming it gets the first`)] : []),
|
|
78
|
+
...(!attributes.length && !users.length ? [finding(t, 'type-unused', 'info', `Data type "${t.name}" is used by no attribute or other data type`)] : []),
|
|
79
|
+
];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A mapping pair joining an entity that is not there. */
|
|
84
|
+
mappings() {
|
|
85
|
+
return this.model.mappings.flatMap(m => m.pairs
|
|
86
|
+
.filter(([a, b]) => !this.model.level(m.from).entity(a) || !this.model.level(m.to).entity(b))
|
|
87
|
+
.map(([a, b]) => ({
|
|
88
|
+
code: 'mapping-dangling', severity: 'warning', kind: 'mapping', ids: [`${m.from}:${a}->${m.to}:${b}`],
|
|
89
|
+
message: `A mapping from ${m.from} "${a}" to ${m.to} "${b}" joins an entity that is not there`,
|
|
90
|
+
})));
|
|
91
|
+
}
|
|
92
|
+
}
|