@peter.prib/renderengine 0.2.6 → 0.2.10

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 (78) hide show
  1. package/README.md +70 -9
  2. package/REFERENCE.md +303 -2
  3. package/dist/renderengine-workspace.umd.js +8851 -3305
  4. package/dist/renderengine.umd.js +5621 -1382
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +251 -19
  7. package/package.json +1 -1
  8. package/src/bian.js +182 -0
  9. package/src/charting/DiagramEditor.js +5 -26
  10. package/src/charting/DiagramTypeRegistry.js +8 -2
  11. package/src/charting/FormDialog.js +398 -385
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/RowsField.js +127 -0
  15. package/src/charting/ViewTabStrip.js +94 -81
  16. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  17. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  18. package/src/charting/datamodel/ClassificationLens.js +33 -0
  19. package/src/charting/datamodel/ClassificationScheme.js +98 -0
  20. package/src/charting/datamodel/DataModel.js +574 -12
  21. package/src/charting/datamodel/DataModelChecker.js +338 -0
  22. package/src/charting/datamodel/DataModelCompared.js +127 -0
  23. package/src/charting/datamodel/DataModelContext.js +40 -10
  24. package/src/charting/datamodel/DataModelEditCommand.js +47 -0
  25. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  26. package/src/charting/datamodel/DataModelPerspective.js +55 -0
  27. package/src/charting/datamodel/DataType.js +108 -0
  28. package/src/charting/datamodel/EnumerationType.js +64 -0
  29. package/src/charting/datamodel/ServiceDomain.js +186 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/SqlDdl.js +80 -0
  32. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  33. package/src/charting/datamodel/StructuredType.js +63 -0
  34. package/src/charting/datamodel/index.js +21 -5
  35. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  36. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  37. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  38. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  39. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  40. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  41. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  42. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  43. package/src/charting/datamodel/naming/index.js +8 -0
  44. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  45. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  46. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  47. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  48. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  49. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  50. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  51. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  52. package/src/charting/datamodel/scorecard/index.js +8 -0
  53. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  54. package/src/charting/diagramModel/OpenViewList.js +75 -0
  55. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  56. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  57. package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
  58. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  59. package/src/charting/diagrams/GraphDiagram.js +11 -1
  60. package/src/charting/diagrams/NodeGeometry.js +173 -158
  61. package/src/charting/glyphs/PersonGlyph.js +24 -23
  62. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  63. package/src/datamodel.js +402 -4
  64. package/src/index.js +6 -4
  65. package/src/workspace/EditorShell.js +166 -67
  66. package/src/workspace/PanelLayout.js +56 -0
  67. package/src/workspace/layout/TabsNode.js +205 -191
  68. package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
  69. package/src/workspace/parts/DataModelPart.js +534 -33
  70. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  71. package/src/workspace/parts/EditorPart.js +461 -407
  72. package/src/workspace/parts/ModelFile.js +216 -187
  73. package/src/workspace/parts/ModelsPart.js +483 -192
  74. package/src/workspace/storage/BackupFolder.js +145 -0
  75. package/src/workspace/storage/KeyValueStore.js +92 -0
  76. package/src/workspace/storage/ModelLibrary.js +65 -0
  77. package/src/workspace/workArea.js +417 -413
  78. package/src/yaml/YamlReader.js +372 -0
@@ -1,8 +1,24 @@
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 { DataModelEditCommand } from './DataModelEditCommand.js';
14
+ import { DataModelNotation } from '../../datamodel.js';
15
+ import { NamingStandard } from './naming/NamingStandard.js';
16
+ import { ClassificationScheme } from './ClassificationScheme.js';
17
+ import { ModelScorecard } from './scorecard/ModelScorecard.js';
18
+ import { ServiceLandscape } from './ServiceLandscape.js';
19
+ import {
20
+ LevelEntitiesCompared, LevelRelationshipsCompared, MappingsCompared, ContextsCompared, PerspectivesCompared, DataTypesCompared,
21
+ } from './DataModelCompared.js';
6
22
 
7
23
  /** The levels, outermost first: each realised by the next. */
8
24
  const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
@@ -21,6 +37,10 @@ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
21
37
  * The older form, one entity per thing with each level's detail on it
22
38
  * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
23
39
  * three levels mapped one to one.
40
+ *
41
+ * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
42
+ * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
43
+ * keep it and ask before it is lost.
24
44
  */
25
45
  export class DataModel {
26
46
  /** The levels, outermost first. */
@@ -29,6 +49,57 @@ export class DataModel {
29
49
  /** The view of how the levels map (a `level` for view()). */
30
50
  static MAPPING = 'mapping';
31
51
 
52
+ /** The view of its service domains in their business areas and domains (a `level` for view()). */
53
+ static LANDSCAPE = 'landscape';
54
+
55
+ /** The view of its data types (a `level` for view()). */
56
+ static DATA_TYPES = 'datatypes';
57
+
58
+ /** The view of its star schemas (a `level` for view()). */
59
+ static STAR = 'star';
60
+
61
+ /** How far a star's dimensions sit from its fact, and how far apart stars are. */
62
+ static STAR_RADIUS = 300;
63
+ static STAR_SPACING = 820;
64
+
65
+ /** How many data type boxes side by side in the data types view, and how far apart. */
66
+ static DATA_TYPE_COLUMNS = 4;
67
+ static DATA_TYPE_SPACING = { x: 260, gap: 40 };
68
+
69
+ /**
70
+ * The views that are no level of the schema, each one way to read the
71
+ * whole model: its name (a `level` for view()), label and description,
72
+ * whether a model has it to show (`offered`), whether a box dragged in it
73
+ * keeps its place (`keepsPlaces`), and what it draws.
74
+ */
75
+ static OTHER_VIEWS = Object.freeze([
76
+ Object.freeze({
77
+ name: 'mapping', label: 'Mapping', description: 'The levels side by side, joined where one realises another',
78
+ offered: () => true, keepsPlaces: true,
79
+ view: (model, choice) => model.mappingView(choice),
80
+ }),
81
+ Object.freeze({
82
+ name: 'datatypes', label: 'Data types', description: 'Its data types: enumerations with their values, structures with their fields',
83
+ offered: (model) => model.dataTypes.length > 0, keepsPlaces: false,
84
+ view: (model) => model.dataTypesView(),
85
+ }),
86
+ Object.freeze({
87
+ name: 'star', label: 'Star schemas', description: 'Each fact with its dimensions around it (a dimensional scheme)',
88
+ offered: (model) => !!model.starLevel(), keepsPlaces: false,
89
+ view: (model) => model.starView(),
90
+ }),
91
+ Object.freeze({
92
+ name: 'landscape', label: 'Service landscape', description: 'The service domains in their business areas and business domains (BIAN)',
93
+ offered: (model) => model.serviceDomains().length > 0, keepsPlaces: false,
94
+ view: (model) => ServiceLandscape.view(model.serviceDomains()),
95
+ }),
96
+ ]);
97
+
98
+ /** One of OTHER_VIEWS by name, or null for a level of the schema. */
99
+ static otherView(name) {
100
+ return DataModel.OTHER_VIEWS.find(v => v.name === name) ?? null;
101
+ }
102
+
32
103
  /** The room between the mapping view's columns, and between the boxes in one. */
33
104
  static MAPPING_COLUMN = 380;
34
105
  static MAPPING_GAP = 40;
@@ -46,10 +117,20 @@ export class DataModel {
46
117
  * (the conceptual level's is the rule's own x, y)
47
118
  * @param {Array<Object|DataModelPerspective>} [spec.perspectives]
48
119
  * @param {Array<Object|DataModelContext>} [spec.contexts]
120
+ * @param {Array<Object|DataType>} [spec.dataTypes] - Its data types (DataType and its kinds)
121
+ * @param {Object|NamingStandard|null} [spec.namingStandard] - How its names are written (none: no standard)
122
+ * @param {Object|ClassificationScheme} [spec.classificationScheme] - How its data is classified (the default if none)
123
+ * @param {{weights?: Object, reviews?: Object}} [spec.scorecard] - Its reviewer's scores and weights (ModelScorecard)
49
124
  * @param {Array<Object>} [spec.entities] - The older, one-entity form (levelsFromCombined)
50
125
  * @param {Array<Object>} [spec.relationships]
126
+ * @param {string} [spec.name] - What the model is called
51
127
  */
52
128
  constructor(spec = {}) {
129
+ /** What the model is called. */
130
+ this.name = spec.name ?? '';
131
+ this._version = 0;
132
+ this._saved = 0;
133
+ this._editListeners = [];
53
134
  const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
54
135
  this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
55
136
  this.mappings = LEVELS.slice(1).map((to, i) => {
@@ -59,8 +140,28 @@ export class DataModel {
59
140
  });
60
141
  this.mappingLayout = { ...mappingLayout };
61
142
  this.noteLayout = Object.fromEntries(Object.entries(noteLayout).map(([level, at]) => [level, { ...at }]));
62
- this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p : new DataModelPerspective(p)));
143
+ this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p : DataModelPerspective.from(p)));
63
144
  this.contexts = (spec.contexts ?? []).map(c => (c instanceof DataModelContext ? c : new DataModelContext(c)));
145
+ /** Its data types: the types attributes name (`attribute.type`, a data type's id or name). */
146
+ this.dataTypes = (spec.dataTypes ?? []).map(t => (t instanceof DataType ? t : DataType.from(t)));
147
+ /** How its names are written (NamingStandard), or null: no standard, nothing checked. */
148
+ this.namingStandard = spec.namingStandard ? (spec.namingStandard instanceof NamingStandard ? spec.namingStandard : new NamingStandard(spec.namingStandard)) : null;
149
+ /** How its data is classified (ClassificationScheme): the default unless it keeps its own. */
150
+ this.classificationScheme = spec.classificationScheme instanceof ClassificationScheme ? spec.classificationScheme : new ClassificationScheme(spec.classificationScheme ?? {});
151
+ /** What a reviewer said on the Scorecard, and the weights reviewed by (ModelScorecard), or null. */
152
+ this.scorecard = spec.scorecard ? JSON.parse(JSON.stringify(spec.scorecard)) : null;
153
+ }
154
+
155
+ /** The model scored on the Data Model Scorecard (ModelScorecard). */
156
+ scored() {
157
+ return new ModelScorecard(this);
158
+ }
159
+
160
+ /** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
161
+ scorecardCommand(spec) {
162
+ return this.editCommand('Score the model', (model) => {
163
+ model.scorecard = spec && Object.keys(spec).length ? JSON.parse(JSON.stringify(spec)) : null;
164
+ });
64
165
  }
65
166
 
66
167
  /**
@@ -85,6 +186,64 @@ export class DataModel {
85
186
  return { levels, mappings: [oneToOne('conceptual', 'logical'), oneToOne('logical', 'physical')] };
86
187
  }
87
188
 
189
+ // ===== Compared and merged =====
190
+
191
+ /**
192
+ * What two data models are compared on and merged by, what is needed first:
193
+ * each level's entities, then each level's relationships, the mappings,
194
+ * contexts and perspectives.
195
+ */
196
+ comparedKinds() {
197
+ return [
198
+ ...LEVELS.map(level => new LevelEntitiesCompared(level)),
199
+ ...LEVELS.map(level => new LevelRelationshipsCompared(level)),
200
+ new MappingsCompared(),
201
+ new ContextsCompared(),
202
+ new DataTypesCompared(),
203
+ new PerspectivesCompared(),
204
+ ];
205
+ }
206
+
207
+ /** How this model differs from another (ModelComparison), which can merge the other's into it. */
208
+ compareWith(other) {
209
+ return new ModelComparison(this, other);
210
+ }
211
+
212
+ /** Its undo history: a merge is one step on it. */
213
+ get history() {
214
+ return (this._history ??= new CommandHistory());
215
+ }
216
+
217
+ // ===== Changed since saved =====
218
+
219
+ /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
220
+ edited() {
221
+ this._version++;
222
+ this._emitEdit();
223
+ }
224
+
225
+ /** This state is the saved one (a file opened or saved). */
226
+ markSaved() {
227
+ this._saved = this._version;
228
+ this._emitEdit();
229
+ }
230
+
231
+ /** Whether it changed since markSaved. */
232
+ isDirty() {
233
+ return this._version !== this._saved;
234
+ }
235
+
236
+ /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
237
+ onEdit(fn) {
238
+ this._editListeners.push(fn);
239
+ return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
240
+ }
241
+
242
+ /** @private */
243
+ _emitEdit() {
244
+ for (const fn of this._editListeners) fn(this);
245
+ }
246
+
88
247
  /** A level's schema (LevelSchema). */
89
248
  level(name) {
90
249
  const schema = this.levels[name];
@@ -123,6 +282,47 @@ export class DataModel {
123
282
  return [...ids];
124
283
  }
125
284
 
285
+ /**
286
+ * An entity's lineage across the levels: what it realises (`up`, level by
287
+ * level to the concepts) and what realises it (`down`, to the tables), each
288
+ * `{ level, entity }`, as the mappings say, any number either way.
289
+ */
290
+ lineage(level, id) {
291
+ const at = LEVELS.indexOf(level);
292
+ const walk = (from, step) => {
293
+ const out = [];
294
+ let ids = [id];
295
+ for (let i = from; i >= 0 && i < LEVELS.length; i += step) {
296
+ const [a, b] = step < 0 ? [LEVELS[i], LEVELS[i + 1]] : [LEVELS[i - 1], LEVELS[i]];
297
+ const mapping = this.mapping(a, b);
298
+ ids = [...new Set(ids.flatMap(x => (step < 0 ? mapping.sourcesOf(x) : mapping.targetsOf(x))))];
299
+ out.push(...ids.map(x => ({ level: LEVELS[i], entity: this.level(LEVELS[i]).entity(x) })).filter(e => e.entity));
300
+ }
301
+ return out;
302
+ };
303
+ return { up: walk(at - 1, -1), down: walk(at + 1, 1) };
304
+ }
305
+
306
+ /** Lineage in words, for a hover: 'Party (conceptual) → Person (logical)'. */
307
+ lineageText(level, id) {
308
+ const { up, down } = this.lineage(level, id);
309
+ const name = ({ level: l, entity }) => `${entity.name} (${l})`;
310
+ return {
311
+ ...(up.length && { realises: up.map(name).join(', ') }),
312
+ ...(down.length && { 'realised by': down.map(name).join(', ') }),
313
+ };
314
+ }
315
+
316
+ /** The perspectives (service domains) that own a business object (a logical entity). */
317
+ ownersOf(entityId) {
318
+ return this.perspectives.filter(p => p.ownedEntities().includes(entityId));
319
+ }
320
+
321
+ /** The checks this model is held to (DataModelChecker): ownership, service domains, mappings. */
322
+ checker() {
323
+ return new DataModelChecker(this);
324
+ }
325
+
126
326
  /** A perspective by id, or null (the whole schema). */
127
327
  perspective(id) {
128
328
  return this.perspectives.find(p => p.id === id) ?? null;
@@ -148,11 +348,14 @@ export class DataModel {
148
348
  * @returns {{entities, relationships, annotations, links}}
149
349
  */
150
350
  view({ perspective = null, context = null, level = 'conceptual' } = {}) {
151
- if (level === DataModel.MAPPING) return this.mappingView({ perspective, context });
351
+ const other = DataModel.otherView(level);
352
+ if (other) return other.view(this, { perspective, context });
152
353
  const lens = this.perspective(perspective);
153
354
  const meaning = this.context(context);
154
355
  const schema = this.level(level);
155
356
  const allowed = this.realising(level, lens ? this.concepts.filter(c => lens.shows(c.id)).map(c => c.id) : null);
357
+ // A kind of perspective may show more at a level (a service domain: its control record and qualifiers).
358
+ for (const id of lens?.alsoShows(level) ?? []) allowed?.add(id);
156
359
  const shown = schema.entities.filter(e => !allowed || allowed.has(e.id));
157
360
  const ids = new Set(shown.map(e => e.id));
158
361
  const conceptName = (id) => {
@@ -163,32 +366,47 @@ export class DataModel {
163
366
  const concepts = this.conceptsOf(level, entity.id);
164
367
  const name = level === 'conceptual' && lens ? lens.nameOf(entity) : entity.name;
165
368
  const meanings = meaning ? concepts.map(c => meaning.meaningOf(c)).filter(Boolean) : [];
166
- const rules = meaning ? [...new Set(concepts.flatMap(c => meaning.constraintsOn(c).map(r => r.text)))] : [];
369
+ const rules = meaning ? [...new Set(concepts.flatMap(c => meaning.constraintsOn(c).map(r => DataModelContext.ruleText(r))))] : [];
370
+ const owners = level === 'logical' ? this.ownersOf(entity.id).map(o => o.name) : [];
371
+ const typed = this.typedAttributes(entity);
372
+ const iso = this.iso20022Of(entity);
373
+ const classified = ClassificationScheme.describe(this.classificationScheme.effective(entity));
167
374
  const details = {
168
375
  ...entity.details,
376
+ ...(owners.length && { 'owned by': owners.join(', ') }),
377
+ ...Object.fromEntries(Object.entries(entity.stewardship ?? {}).filter(([, v]) => v)),
378
+ ...(classified && { classification: classified }),
379
+ ...DataModelNotation.entityRole(entity).details(entity),
380
+ ...this.lineageText(level, entity.id),
381
+ ...(typed.length && { 'data types': typed.map(({ attribute, type }) => `${attribute.name}: ${type.summary()}`) }),
382
+ ...(iso.length && { 'ISO 20022': iso }),
169
383
  ...(name !== entity.name && { schema: entity.name }),
170
384
  ...(level !== 'conceptual' && concepts.length && { concepts: concepts.map(conceptName).join(', ') }),
171
385
  ...(meaning && { context: meaning.name }),
172
386
  ...(rules.length && { rules }),
173
387
  };
174
388
  const { attributes, ...rest } = entity;
389
+ const mark = lens?.decorate(entity, level, this) ?? {};
390
+ const allDetails = { ...details, ...mark.details };
175
391
  return {
176
392
  ...rest,
177
393
  name,
394
+ ...(mark.color && { color: mark.color }),
178
395
  description: meanings.length ? meanings.join(' ') : entity.description,
179
396
  ...(level !== 'conceptual' && { [level]: { attributes: attributes ?? [], ...(level === 'physical' && { tableName: entity.name }) } }),
180
- ...(Object.keys(details).length && { details }),
397
+ ...(Object.keys(allDetails).length && { details: allDetails }),
181
398
  };
182
399
  });
183
400
  const relationships = schema.relationships
184
401
  .filter(r => ids.has(r.source) && ids.has(r.target))
185
402
  .map(r => (lens && level === 'conceptual' ? { ...r, name: lens.relationshipNameOf(r) } : r));
186
- const notes = (meaning?.constraints ?? [])
187
- .map(c => {
403
+ const notes = [
404
+ ...(meaning?.constraints ?? []).map(c => {
188
405
  const on = this.realising(level, c.applies) ?? new Set(c.applies);
189
- return { id: c.id, text: c.text, attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
190
- })
191
- .filter(a => a.attachTo.length);
406
+ return { id: c.id, text: DataModelContext.ruleText(c), attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
407
+ }),
408
+ ...(lens?.notes(level) ?? []).map(n => ({ ...n, attachTo: n.attachTo.filter(id => ids.has(id)), ...this.notePlace(level, n) })),
409
+ ].filter(a => a.attachTo.length);
192
410
  // A note with no place on this level goes in a row below every box, so it covers none.
193
411
  const below = Math.max(0, ...shown.map(e => (Number.isFinite(e.y) ? e.y : 0))) + DataModel.NOTE_ROW;
194
412
  let free = 0;
@@ -241,10 +459,342 @@ export class DataModel {
241
459
 
242
460
  // ===== Perspectives and contexts of one's own =====
243
461
 
462
+ // ===== Editing what a level holds (each a DataModelEditCommand: one undo step) =====
463
+
464
+ /** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
465
+ restore(json) {
466
+ const back = new DataModel(JSON.parse(JSON.stringify(json)));
467
+ for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
468
+ }
469
+
470
+ /** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
471
+ classificationSchemeCommand(spec) {
472
+ return this.editCommand('Set the classification scheme', (model) => {
473
+ model.classificationScheme = new ClassificationScheme(spec);
474
+ });
475
+ }
476
+
477
+ /** Set how its names are written (a NamingStandard spec; null: none), as one undo step. */
478
+ namingStandardCommand(spec) {
479
+ return this.editCommand(spec ? 'Set the naming standard' : 'Remove the naming standard', (model) => {
480
+ model.namingStandard = spec ? new NamingStandard(spec) : null;
481
+ });
482
+ }
483
+
484
+ /** A change made as one undo step. */
485
+ editCommand(label, change) {
486
+ return new DataModelEditCommand(this, label, change);
487
+ }
488
+
489
+ /**
490
+ * Add an entity at a level (`id` null: one made from its name) or change
491
+ * one (what the spec leaves out, where it is drawn among it, kept).
492
+ * The command's `result` is the entity.
493
+ */
494
+ saveEntityCommand(level, id, spec) {
495
+ const noun = DataModelNotation.level(level)?.entityNoun ?? 'entity';
496
+ return this.editCommand(id ? `Edit ${spec.name || id}` : `New ${noun.toLowerCase()} ${spec.name ?? ''}`.trim(), (model) => {
497
+ const schema = model.level(level);
498
+ if (!id) return schema.addEntity({ ...spec, id: spec.id ?? model._freshId(spec.name || noun, schema.entities) });
499
+ const i = schema.entities.findIndex(e => e.id === id);
500
+ const { x, y } = schema.entities[i];
501
+ schema.entities[i] = { x, y, ...spec, id };
502
+ return schema.entities[i];
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Remove an entity from a level, with what refers to it: its
508
+ * relationships, its mapping pairs, and what perspectives and contexts
509
+ * say of it (each forgets it: forgetEntity).
510
+ */
511
+ removeEntityCommand(level, id) {
512
+ const name = this.level(level).entity(id)?.name ?? id;
513
+ return this.editCommand(`Delete ${name}`, (model) => {
514
+ const schema = model.level(level);
515
+ schema.entities = schema.entities.filter(e => e.id !== id);
516
+ schema.relationships = schema.relationships.filter(r => r.source !== id && r.target !== id);
517
+ for (const m of model.mappings) {
518
+ m.pairs = m.pairs.filter(([a, b]) => !(m.from === level && a === id) && !(m.to === level && b === id));
519
+ }
520
+ for (const holder of [...model.perspectives, ...model.contexts]) holder.forgetEntity(level, id);
521
+ });
522
+ }
523
+
524
+ /**
525
+ * Which of a level's relationships something drawn is (a line, or a
526
+ * Chen diamond), by its id, the id a diamond is given, else its ends and
527
+ * name; -1 for none.
528
+ */
529
+ relationshipIndex(level, drawn) {
530
+ const rels = this.level(level).relationships;
531
+ const diamond = (r, i) => r.id || `${r.source}__${r.target}__rel${i}`;
532
+ const byId = rels.findIndex((r, i) => drawn?.id && (r.id === drawn.id || diamond(r, i) === drawn.id));
533
+ if (byId >= 0) return byId;
534
+ // A line to or from a Chen diamond is its relationship's.
535
+ const ends = [drawn?.source, drawn?.target];
536
+ const toDiamond = rels.findIndex((r, i) => ends.includes(diamond(r, i)));
537
+ if (toDiamond >= 0) return toDiamond;
538
+ return rels.findIndex(r => r.source === drawn?.source && r.target === drawn?.target && (r.name ?? '') === (drawn?.name ?? r.name ?? ''));
539
+ }
540
+
541
+ /** Add a relationship at a level (`index` null) or change the one at `index`. The command's `result` is it. */
542
+ saveRelationshipCommand(level, index, spec) {
543
+ return this.editCommand(index == null ? `New relationship ${spec.name ?? ''}`.trim() : `Edit relationship ${spec.name ?? ''}`.trim(), (model) => {
544
+ const rels = model.level(level).relationships;
545
+ if (index == null) {
546
+ const relationship = { id: spec.id ?? model._freshId(`${spec.source}-${spec.target}`, rels), ...spec };
547
+ rels.push(relationship);
548
+ return relationship;
549
+ }
550
+ rels[index] = { ...spec };
551
+ return rels[index];
552
+ });
553
+ }
554
+
555
+ removeRelationshipCommand(level, index) {
556
+ const r = this.level(level).relationships[index];
557
+ return this.editCommand(`Delete relationship ${r?.name ?? ''}`.trim(), (model) => {
558
+ model.level(level).relationships.splice(index, 1);
559
+ });
560
+ }
561
+
562
+ // ===== Data types =====
563
+
564
+ /** A data type by its id, else its name (any case), or null. */
565
+ dataType(ref) {
566
+ if (!ref) return null;
567
+ const name = String(ref).toLowerCase();
568
+ return this.dataTypes.find(t => t.id === ref) ?? this.dataTypes.find(t => t.name.toLowerCase() === name) ?? null;
569
+ }
570
+
571
+ /** An attribute's data type (its `type` naming one), or null for a plain type ('VARCHAR(20)'). */
572
+ typeOf(attribute) {
573
+ return this.dataType(attribute?.type);
574
+ }
575
+
576
+ /** An entity's attributes that have a data type: `[{ attribute, type }]`. */
577
+ typedAttributes(entity) {
578
+ return (entity.attributes ?? []).map(attribute => ({ attribute, type: this.typeOf(attribute) })).filter(a => a.type);
579
+ }
580
+
581
+ /**
582
+ * What an entity says of ISO 20022, a line each: its own type, then each
583
+ * attribute's (its own `iso20022`, else its data type's).
584
+ */
585
+ iso20022Of(entity) {
586
+ return [
587
+ ...(entity.iso20022 ? [`${entity.name}: ${entity.iso20022}`] : []),
588
+ ...(entity.attributes ?? []).map(a => [a.name, a.iso20022 || this.typeOf(a)?.iso20022]).filter(([, t]) => t).map(([n, t]) => `${n}: ${t}`),
589
+ ];
590
+ }
591
+
592
+ /** Where a data type is used: `{ attributes: [{ level, entity, attribute }], types: [DataType] }`. */
593
+ usesOf(id) {
594
+ const type = this.dataType(id);
595
+ if (!type) return { attributes: [], types: [] };
596
+ const names = (ref) => this.dataType(ref) === type;
597
+ return {
598
+ attributes: LEVELS.flatMap(level => this.level(level).entities.flatMap(entity => (entity.attributes ?? [])
599
+ .filter(a => names(a.type)).map(attribute => ({ level, entity, attribute })))),
600
+ types: this.dataTypes.filter(t => t !== type && t.uses().some(names)),
601
+ };
602
+ }
603
+
604
+ /** Add a data type (a DataType spec, its `kind` saying which; an id is made from its name when it has none). */
605
+ addDataType(spec) {
606
+ const type = DataType.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.dataTypes) });
607
+ this.dataTypes.push(type);
608
+ this.edited();
609
+ return type;
610
+ }
611
+
612
+ /** Change a data type (its id and kind stay). */
613
+ updateDataType(id, spec) {
614
+ const i = this.dataTypes.findIndex(t => t.id === id);
615
+ if (i < 0) return null;
616
+ this.dataTypes[i] = DataType.from({ ...spec, kind: this.dataTypes[i].constructor.kind, id });
617
+ this.edited();
618
+ return this.dataTypes[i];
619
+ }
620
+
621
+ removeDataType(id) {
622
+ this.dataTypes = this.dataTypes.filter(t => t.id !== id);
623
+ this.edited();
624
+ }
625
+
626
+ /**
627
+ * The data types view: each a box with a row per value or field (as a
628
+ * table), enumerations and structures coloured apart, in a grid; a link
629
+ * from a structure to each data type its fields are.
630
+ */
631
+ dataTypesView() {
632
+ const { x: across, gap } = DataModel.DATA_TYPE_SPACING;
633
+ const columns = DataModel.DATA_TYPE_COLUMNS;
634
+ const color = { enumeration: '#fef3c7', structure: '#e0f2fe', type: '#f3f4f6' };
635
+ const heights = this.dataTypes.map(t => DataModel.boxHeight('datatypes', { datatypes: { attributes: t.rows() } }));
636
+ const tops = [];
637
+ this.dataTypes.forEach((t, i) => {
638
+ const above = i - columns;
639
+ tops.push(above < 0 ? 60 : tops[above] + heights[above] + gap);
640
+ });
641
+ const entities = this.dataTypes.map((t, i) => ({
642
+ id: `type:${t.id}`, name: `${t.constructor === DataType ? '' : `«${t.constructor.kindLabel.toLowerCase()}» `}${t.name}`,
643
+ level: 'datatypes', datatypes: { attributes: t.rows().length ? t.rows() : [{ name: t.basedOn || '—' }] },
644
+ x: 140 + (i % columns) * across, y: tops[i] + heights[i] / 2,
645
+ color: color[t.constructor.kind] ?? color.type,
646
+ description: t.description,
647
+ details: { kind: t.constructor.kindLabel, ...t.codeSet, ...(t.iso20022 && { 'ISO 20022': t.iso20022 }), used: this._usedSummary(t) },
648
+ }));
649
+ const links = this.dataTypes.flatMap(t => [...new Set(t.uses().map(ref => this.dataType(ref)).filter(u => u && u !== t))]
650
+ .map(u => ({ id: `uses:${t.id}->${u.id}`, source: `type:${t.id}`, target: `type:${u.id}` })));
651
+ return { entities, relationships: [], annotations: [], links, notation: 'crowsfoot' };
652
+ }
653
+
654
+ /** Where a data type is used, in a few words. @private */
655
+ _usedSummary(type) {
656
+ const { attributes, types } = this.usesOf(type.id);
657
+ const parts = [
658
+ ...(attributes.length ? [`${attributes.length} attribute${attributes.length === 1 ? '' : 's'}`] : []),
659
+ ...(types.length ? [`by ${types.map(t => t.name).join(', ')}`] : []),
660
+ ];
661
+ return parts.join('; ') || 'nowhere';
662
+ }
663
+
664
+ /** The level its dimensional scheme is at: the physical if it has a fact there, else the logical, else null. */
665
+ starLevel() {
666
+ return ['physical', 'logical'].find(level => this.level(level).entities.some(e => DataModelNotation.entityRole(e).role === 'fact')) ?? null;
667
+ }
668
+
669
+ /**
670
+ * The star view: at the level with facts (starLevel), each fact in a row
671
+ * of stars with the dimensions it is related to around it, a dimension two
672
+ * share drawn once, by the first; facts and dimensions in their role's
673
+ * colour, as tables, with the relationships between them.
674
+ */
675
+ starView() {
676
+ const level = this.starLevel();
677
+ if (!level) return { entities: [], relationships: [], annotations: [], links: [] };
678
+ const schema = this.level(level);
679
+ const roleOf = (e) => DataModelNotation.entityRole(e);
680
+ const facts = schema.entities.filter(e => roleOf(e).role === 'fact');
681
+ const placed = new Map();
682
+ facts.forEach((fact, i) => {
683
+ const centre = { x: 400 + i * DataModel.STAR_SPACING, y: 420 };
684
+ placed.set(fact.id, centre);
685
+ const dims = schema.relationships
686
+ .filter(r => r.source === fact.id || r.target === fact.id)
687
+ .map(r => schema.entity(r.source === fact.id ? r.target : r.source))
688
+ .filter(d => d && roleOf(d).role === 'dimension' && !placed.has(d.id));
689
+ dims.forEach((d, k) => {
690
+ const angle = -Math.PI / 2 + (2 * Math.PI * k) / Math.max(dims.length, 1);
691
+ placed.set(d.id, { x: Math.round(centre.x + DataModel.STAR_RADIUS * Math.cos(angle)), y: Math.round(centre.y + DataModel.STAR_RADIUS * Math.sin(angle)) });
692
+ });
693
+ });
694
+ const base = this.view({ level });
695
+ const entities = base.entities.filter(e => placed.has(e.id)).map(e => ({ ...e, ...placed.get(e.id), color: roleOf(e).color ?? e.color }));
696
+ const shown = new Set(entities.map(e => e.id));
697
+ return {
698
+ entities, relationships: base.relationships.filter(r => shown.has(r.source) && shown.has(r.target)),
699
+ annotations: [], links: [], level, notation: 'crowsfoot',
700
+ };
701
+ }
702
+
703
+ /** Its service domains (the perspectives that are ServiceDomains). */
704
+ serviceDomains() {
705
+ return this.perspectives.filter(p => p.constructor.kind === 'service-domain');
706
+ }
707
+
708
+ /**
709
+ * Service domains read from elsewhere (BianLandscapeReader), as one undo
710
+ * step (bringCommand). Null when there is nothing to bring.
711
+ * @param {Array<Object>} specs - ServiceDomain specs
712
+ * @param {string} [source] - Where they were read from, for the undo label
713
+ * @returns {MergeModelCommand|null}
714
+ */
715
+ serviceDomainsCommand(specs, source = 'a service landscape') {
716
+ return this.bringCommand({ serviceDomains: specs },
717
+ `Read ${specs.length} service domain${specs.length === 1 ? '' : 's'} from ${source}`);
718
+ }
719
+
720
+ /**
721
+ * Things read from elsewhere (a BIAN landscape, a BIAN semantic API)
722
+ * brought in as one undo step (a MergeModelCommand). A logical entity
723
+ * already here (by id, else by name) stays as it is, and what was read
724
+ * refers to it; so does a data type already here by name. Only the data
725
+ * types what is brought needs come (those its new entities' attributes
726
+ * are, and theirs in turn). A service domain already here (by id, else by
727
+ * name) keeps all it has, taking its business area and domain from what
728
+ * was read, its asset, pattern and control record where it has none, and
729
+ * the behaviour qualifiers it lacks (by name or entity); one that would
730
+ * come out the same is left alone. Null when there is nothing to bring.
731
+ * @param {Object} read
732
+ * @param {Array<Object>} [read.entities] - Logical entities
733
+ * @param {Array<Object>} [read.relationships] - Logical relationships between them
734
+ * @param {Array<Object>} [read.dataTypes] - DataType specs
735
+ * @param {Array<Object>} [read.serviceDomains] - ServiceDomain specs
736
+ * @param {string} label - The undo label
737
+ * @returns {MergeModelCommand|null}
738
+ */
739
+ bringCommand({ entities = [], relationships = [], dataTypes = [], serviceDomains = [] }, label) {
740
+ const logical = this.level('logical');
741
+ const sameName = (list, name) => list.find(x => String(x.name).toLowerCase() === String(name).toLowerCase());
742
+ // What was read refers to an entity already here by that one's id.
743
+ const idHere = new Map(entities.map(e => [e.id, (logical.entity(e.id) ?? sameName(logical.entities, e.name))?.id ?? null]));
744
+ const as = (id) => idHere.get(id) ?? id;
745
+ const newEntities = entities.filter(e => !idHere.get(e.id));
746
+ const newRelationships = relationships.map(r => ({ ...r, source: as(r.source), target: as(r.target) }))
747
+ .filter(r => !logical.relationships.some(h => h.source === r.source && h.target === r.target && (h.name ?? '') === (r.name ?? '')));
748
+ // The data types the new entities need, then the ones those need; none already here by id or name.
749
+ const readType = (ref) => dataTypes.find(t => t.id === ref) ?? sameName(dataTypes, ref);
750
+ const needed = new Set();
751
+ const need = (ref) => {
752
+ const t = readType(ref);
753
+ if (!t || needed.has(t) || this.dataType(t.id) || sameName(this.dataTypes, t.name)) return;
754
+ needed.add(t);
755
+ DataType.from(t).uses().forEach(need);
756
+ };
757
+ newEntities.flatMap(e => e.attributes ?? []).forEach(a => need(a.type));
758
+ const newTypes = dataTypes.filter(t => needed.has(t));
759
+ const domains = serviceDomains.map(spec => {
760
+ const read = { ...spec, controlRecord: spec.controlRecord ? as(spec.controlRecord) : spec.controlRecord, qualifiers: (spec.qualifiers ?? []).map(q => ({ ...q, ...(q.entity && { entity: as(q.entity) }) })) };
761
+ const here = this.perspective(read.id) ?? this.perspectives.find(p => p.name.toLowerCase() === String(read.name).toLowerCase());
762
+ if (!here) return read;
763
+ const kept = here.toJSON();
764
+ return {
765
+ ...kept, kind: read.kind,
766
+ businessArea: read.businessArea || kept.businessArea || '', businessDomain: read.businessDomain || kept.businessDomain || '',
767
+ asset: kept.asset || read.asset || '', pattern: here.functionalPattern ? kept.pattern : (read.pattern || kept.pattern || ''),
768
+ controlRecord: kept.controlRecord || read.controlRecord || null,
769
+ qualifiers: [...kept.qualifiers, ...read.qualifiers.filter(q => !kept.qualifiers.some(k =>
770
+ k.name.toLowerCase() === String(q.name).toLowerCase() || (q.entity && k.entity === q.entity)))],
771
+ };
772
+ }).filter(spec => {
773
+ // One here that would come out the same is nothing to bring.
774
+ const here = this.perspective(spec.id);
775
+ return !here || JSON.stringify(DataModelPerspective.from(spec).toJSON()) !== JSON.stringify(here.toJSON());
776
+ });
777
+ if (!newEntities.length && !newRelationships.length && !newTypes.length && !domains.length) return null;
778
+ const from = new DataModel({
779
+ name: label,
780
+ levels: { logical: { entities: newEntities, relationships: newRelationships } },
781
+ dataTypes: newTypes, perspectives: domains,
782
+ });
783
+ const kinds = new Map(this.comparedKinds().map(k => [k.key, k]));
784
+ const steps = [
785
+ ...from.level('logical').entities.map(item => ({ kind: kinds.get('logical-entity'), item })),
786
+ ...from.level('logical').relationships.map(item => ({ kind: kinds.get('logical-relationship'), item })),
787
+ ...from.dataTypes.map(item => ({ kind: kinds.get('data-type'), item })),
788
+ ...from.perspectives.map(item => ({ kind: kinds.get('perspective'), item })),
789
+ ];
790
+ return new MergeModelCommand(this, from, steps, label);
791
+ }
792
+
244
793
  /** Add a perspective (DataModelPerspective spec; an id is made from its name when it has none). */
245
794
  addPerspective(spec) {
246
- const perspective = new DataModelPerspective({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
795
+ const perspective = DataModelPerspective.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
247
796
  this.perspectives.push(perspective);
797
+ this.edited();
248
798
  return perspective;
249
799
  }
250
800
 
@@ -252,17 +802,21 @@ export class DataModel {
252
802
  updatePerspective(id, spec) {
253
803
  const i = this.perspectives.findIndex(p => p.id === id);
254
804
  if (i < 0) return null;
255
- return (this.perspectives[i] = new DataModelPerspective({ ...spec, id }));
805
+ this.perspectives[i] = DataModelPerspective.from({ kind: this.perspectives[i].constructor.kind, ...spec, id });
806
+ this.edited();
807
+ return this.perspectives[i];
256
808
  }
257
809
 
258
810
  removePerspective(id) {
259
811
  this.perspectives = this.perspectives.filter(p => p.id !== id);
812
+ this.edited();
260
813
  }
261
814
 
262
815
  /** Add a context (DataModelContext spec; an id is made from its name when it has none). */
263
816
  addContext(spec) {
264
817
  const context = new DataModelContext({ ...spec, id: spec.id ?? this._freshId(spec.name, this.contexts) });
265
818
  this.contexts.push(context);
819
+ this.edited();
266
820
  return context;
267
821
  }
268
822
 
@@ -270,13 +824,16 @@ export class DataModel {
270
824
  updateContext(id, spec) {
271
825
  const i = this.contexts.findIndex(c => c.id === id);
272
826
  if (i < 0) return null;
273
- return (this.contexts[i] = new DataModelContext({ ...spec, id }));
827
+ this.contexts[i] = new DataModelContext({ ...spec, id });
828
+ this.edited();
829
+ return this.contexts[i];
274
830
  }
275
831
 
276
832
  /** Remove a context; a perspective read in it is then read in none. */
277
833
  removeContext(id) {
278
834
  this.contexts = this.contexts.filter(c => c.id !== id);
279
835
  for (const p of this.perspectives) if (p.context === id) p.context = null;
836
+ this.edited();
280
837
  }
281
838
 
282
839
  /** An id from a name, not yet among `taken`. @private */
@@ -289,11 +846,16 @@ export class DataModel {
289
846
 
290
847
  toJSON() {
291
848
  return {
849
+ name: this.name,
292
850
  levels: Object.fromEntries(LEVELS.map(level => [level, this.level(level).toJSON()])),
293
851
  mappings: this.mappings.map(m => m.toJSON()),
294
852
  mappingLayout: { ...this.mappingLayout },
295
853
  noteLayout: Object.fromEntries(Object.entries(this.noteLayout).map(([level, at]) => [level, { ...at }])),
296
854
  perspectives: this.perspectives.map(p => p.toJSON()), contexts: this.contexts.map(c => c.toJSON()),
855
+ ...(this.dataTypes.length && { dataTypes: this.dataTypes.map(t => t.toJSON()) }),
856
+ ...(this.namingStandard && { namingStandard: this.namingStandard.toJSON() }),
857
+ ...(!this.classificationScheme.isDefault && { classificationScheme: this.classificationScheme.toJSON() }),
858
+ ...(this.scorecard && { scorecard: JSON.parse(JSON.stringify(this.scorecard)) }),
297
859
  };
298
860
  }
299
861
  }