@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
@@ -3,9 +3,17 @@ import { el } from '../dom.js';
3
3
  import { MenuBar } from '../../charting/MenuBar.js';
4
4
  import { ToolbarSelect, ToolbarMenu, ToolbarText } from '../../charting/toolbar/index.js';
5
5
  import { FormDialog } from '../../charting/FormDialog.js';
6
+ import { ModelCheckDialog } from '../../charting/ModelCheckDialog.js';
6
7
  import { DataModelNotation } from '../../datamodel.js';
7
- import { DataModel, DataModelPerspective, DataModelContext } from '../../charting/datamodel/index.js';
8
- import { downloadPng, downloadSvg } from '../../exportImage.js';
8
+ import {
9
+ DataModel, DataModelPerspective, DataModelContext, DataModelFileFormat, ServiceDomain, ServiceLandscape, BianLandscapeReader,
10
+ BianSemanticApiReader, DataType, EnumerationType, StructuredType, NamingStandard, ClassificationScheme, ClassificationLens,
11
+ SqlDdlFileFormat, ModelScorecard,
12
+ } from '../../charting/datamodel/index.js';
13
+ import { pickFile } from '../../charting/toolbar/pickFile.js';
14
+ import { ModelFile } from './ModelFile.js';
15
+ import { EditorPart } from './EditorPart.js';
16
+ import { downloadPng, downloadSvg, downloadBlob } from '../../exportImage.js';
9
17
 
10
18
  /**
11
19
  * DataModelPart — a data model (DataModel, drawn by dataModelDiagram) in a
@@ -13,12 +21,21 @@ import { downloadPng, downloadSvg } from '../../exportImage.js';
13
21
  * how they map; seen through a perspective (whose concepts, under whose names)
14
22
  * read in a context (what they mean here, the rules that hold, where and
15
23
  * when), in a notation (Chen, Crow's Foot, UML). Its toolbar switches each
16
- * of them, Level offering Mapping (the levels side by side, joined where they
17
- * map), and its Model menu makes, changes and removes perspectives and
18
- * contexts. Boxes and notes stay where they were dragged. Export ▸ saves the
24
+ * of them, Level offering the views that are no level (DataModel.OTHER_VIEWS:
25
+ * Mapping, the levels side by side joined where they map; Service landscape,
26
+ * the service domains in their business areas and domains, a double-click
27
+ * seeing the model through one), and its Model menu makes, changes and removes
28
+ * perspectives and contexts and reads service domains from a BIAN landscape. Boxes and notes stay where they were dragged. Export ▸ saves the
19
29
  * picture (PNG, SVG). The drawing, its sizing and redrawing on resize are
20
30
  * ChartPart's.
21
31
  *
32
+ * It is saved as a file (DataModelFileFormat, `.datamodel.json`) as an
33
+ * editor's model is (ModelFile): File ▸ Open…, Save, Save As… (Ctrl+O, Ctrl+S),
34
+ * • while changed since, and asking before the page is left with changes
35
+ * unsaved. Hosted by a ModelsPart (`editorType: 'data-model'`) it is one of
36
+ * several models open, a tab each, and with that part's `keep` it is kept in
37
+ * this browser and backed up (File ▸ This browser), as the modeller's are.
38
+ *
22
39
  * Spec: `{ id, type: 'data-model', title?, levels?, mappings?, mappingLayout?,
23
40
  * perspectives?, contexts?, perspective?, context?, level?, notation?, options? }`
24
41
  * — the model as DataModel takes it (or `entities`/`relationships` in the older
@@ -32,8 +49,11 @@ export class DataModelPart extends ChartPart {
32
49
  constructor(spec = {}) {
33
50
  const { perspective = null, context = null, level = 'logical', notation = 'crowsfoot', options = {} } = spec;
34
51
  super({ ...spec, chartType: 'dataModel', options: { editable: true, resizable: true, ...options, level, notation } });
35
- /** The levels, their mappings, perspectives and contexts. */
36
- this.model = new DataModel(spec);
52
+ /** The file it is open from and saved to. */
53
+ this.file = new ModelFile(this);
54
+ /** Its id among the models kept in this browser, or null. */
55
+ this.keepId = spec.keepId ?? null;
56
+ this._adoptModel(new DataModel(spec));
37
57
  /** The ids chosen, or null. */
38
58
  this.perspectiveId = perspective;
39
59
  this.contextId = context ?? this.model.perspective(perspective)?.context ?? null;
@@ -41,9 +61,120 @@ export class DataModelPart extends ChartPart {
41
61
  this._drawnLevel = level;
42
62
  }
43
63
 
64
+ // ===== A model saved as a file, kept, backed up =====
65
+
66
+ /** Its file formats: the data model's own. */
67
+ fileFormats() {
68
+ return [DataModelFileFormat.instance ??= new DataModelFileFormat()];
69
+ }
70
+
71
+ saveFormat() {
72
+ return this.fileFormats()[0];
73
+ }
74
+
75
+ /** The ModelsPart it is a tab of, or null. */
76
+ get modelsHost() {
77
+ return this.ctx?.models ?? null;
78
+ }
79
+
80
+ /** The page it is in. */
81
+ get document() {
82
+ return this.container?.ownerDocument ?? globalThis.document;
83
+ }
84
+
85
+ /** What its tab and the page title show: the file's name (or the model's), and • while changed. */
86
+ get fileLabel() {
87
+ return this.file.label;
88
+ }
89
+
90
+ /** The model as kept in this browser (ModelFile#keepEntry). */
91
+ keepEntry() {
92
+ return this.file.keepEntry();
93
+ }
94
+
95
+ /** Edit another model (one opened, imported or kept), from scratch: nothing chosen, nothing changed. */
96
+ replaceModel(model) {
97
+ this._adoptModel(model);
98
+ this.perspectiveId = null;
99
+ this.contextId = null;
100
+ if (!this.mounted) return;
101
+ // Not rendered again (that would add a second toolbar and canvas): the toolbar
102
+ // shows the new model's choices, and it is drawn. What was on screen is the
103
+ // other model's, so where its boxes were is not kept into this one.
104
+ this._drawnLevel = null;
105
+ this._changed();
106
+ this._redraw();
107
+ }
108
+
109
+ /** A model opened from a file in a host's new tab (as EditorPart does). */
110
+ onImported(model) {
111
+ return EditorPart.prototype.onImported.call(this, model);
112
+ }
113
+
114
+ /** The file or its saved state changed: a ModelsPart's tab follows it. */
115
+ onFileState() {
116
+ this.modelsHost?.fileStateChanged?.(this);
117
+ }
118
+
119
+ /** Edit `model`: every change of it shows (• and the title) and is kept. @private */
120
+ _adoptModel(model) {
121
+ this._unsubscribeEdits?.();
122
+ this.model = model;
123
+ model.markSaved();
124
+ this._unsubscribeEdits = model.onEdit(() => {
125
+ this.file.showState();
126
+ this.modelsHost?.modelEdited?.(this);
127
+ });
128
+ }
129
+
130
+ mount(host, ctx) {
131
+ const first = !this.mounted;
132
+ super.mount(host, ctx);
133
+ if (first) {
134
+ this.file.listen();
135
+ this.file.showState();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * File ▸: Open…, Save, Save As…, then with a ModelsPart New model and Close
141
+ * model, and This browser ▸ (keeping and backups).
142
+ */
143
+ fileItems() {
144
+ const host = this.modelsHost;
145
+ return EditorPart.tidyMenu([
146
+ ...this.file.menuItems(),
147
+ ...(host ? [
148
+ { separator: true },
149
+ ...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
150
+ { label: 'Duplicate model', action: () => host.duplicate(this) },
151
+ { label: 'Close model', action: () => host.closeModel(this.id) },
152
+ ...EditorPart.menuGroup('This browser', host.keepItems?.(this) ?? []),
153
+ ] : []),
154
+ ]);
155
+ }
156
+
157
+ /** Edit ▸: undo and redo (a merge is one step). */
158
+ editItems() {
159
+ const history = this.model.history;
160
+ return [
161
+ { label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this._redraw(); } },
162
+ { label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this._redraw(); } },
163
+ ];
164
+ }
165
+
166
+ /** Run a model-wide edit (a merge) as one undo step, then redraw. */
167
+ runModelEdit(command) {
168
+ if (!command) return null;
169
+ this.model.history.execute(command);
170
+ this._changed();
171
+ this._redraw();
172
+ return command;
173
+ }
174
+
44
175
  /** The entities at the level shown (the concepts in the mapping view). */
45
176
  get entities() {
46
- return this.model.level(this.level === DataModel.MAPPING ? 'conceptual' : this.level).entities;
177
+ return this.model.level(DataModel.otherView(this.level) ? 'conceptual' : this.level).entities;
47
178
  }
48
179
  get level() { return this.chartOptions.level; }
49
180
  get notation() { return this.chartOptions.notation; }
@@ -80,7 +211,140 @@ export class DataModelPart extends ChartPart {
80
211
 
81
212
  /** What is drawn: the perspective's view of the level (or the mapping) in the context (DataModel#view), in the notation. */
82
213
  drawOptions() {
83
- return { ...this.chartOptions, ...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }) };
214
+ return {
215
+ ...this.chartOptions,
216
+ ...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }),
217
+ ...(this.level === DataModel.LANDSCAPE && { onNodeDoubleClick: (node) => this.openServiceDomain(ServiceLandscape.serviceDomainOf(node.id)) }),
218
+ ...(this.editsLevel && this.editingOptions()),
219
+ ...(this.level === DataModel.MAPPING && this.lineageOptions()),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Show an entity's lineage: the mapping view, what it realises and what
225
+ * realises it (DataModel#lineage) as they are, the rest greyed.
226
+ */
227
+ showLineage(level, id) {
228
+ this.lineageFocus = { level, id };
229
+ this.setLevel(DataModel.MAPPING);
230
+ }
231
+
232
+ /**
233
+ * In the mapping view: an entity's lineage picked out (`lineageFocus`), and
234
+ * its menus to show another's or show all again.
235
+ */
236
+ lineageOptions() {
237
+ const view = {};
238
+ const focus = this.lineageFocus;
239
+ if (focus) {
240
+ const { up, down } = this.model.lineage(focus.level, focus.id);
241
+ const on = new Set([`${focus.level}:${focus.id}`, ...[...up, ...down].map(({ level, entity }) => `${level}:${entity.id}`)]);
242
+ const drawn = this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: DataModel.MAPPING });
243
+ view.entities = drawn.entities.map(e => (on.has(e.id) ? e : { ...e, color: '#f3f4f6' }));
244
+ view.links = drawn.links.map(l => (on.has(l.source) && on.has(l.target) ? l : { ...l, color: '#e5e7eb' }));
245
+ }
246
+ const split = (nodeId) => { const i = String(nodeId).indexOf(':'); return [nodeId.slice(0, i), nodeId.slice(i + 1)]; };
247
+ return {
248
+ ...view,
249
+ contextMenuItems: [{ label: 'Show lineage', action: (node) => { const [level, id] = split(node.id); this.showLineage(level, id); } }],
250
+ canvasContextMenuItems: [{ label: 'Show all', action: () => { this.lineageFocus = null; this._redraw(); } }],
251
+ };
252
+ }
253
+
254
+ /** Whether what is shown is a level of the schema (so its entities and relationships can be edited here). */
255
+ get editsLevel() {
256
+ return !DataModel.otherView(this.level);
257
+ }
258
+
259
+ /**
260
+ * At a level of the schema: double-click an entity or a relationship to
261
+ * edit it; right-click one to edit or delete it, or the canvas for a new
262
+ * entity there or a new relationship. (These stand in for graphDiagram's
263
+ * own, which change only the drawing.)
264
+ */
265
+ editingOptions() {
266
+ const relationshipOf = (drawn) => this.model.relationshipIndex(this.level, drawn);
267
+ const isEntity = (node) => !!this.model.level(this.level).entity(node.id);
268
+ return {
269
+ onNodeDoubleClick: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))),
270
+ onEdgeDoubleClick: (edge) => this.editRelationship(relationshipOf(edge)),
271
+ contextMenuItems: [
272
+ { label: 'Edit…', action: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))) },
273
+ { label: 'Delete', action: (node) => (isEntity(node) ? this.removeEntity(node.id) : this.removeRelationship(relationshipOf(node))) },
274
+ { label: 'Show lineage', action: (node) => { if (isEntity(node)) this.showLineage(this.level, node.id); } },
275
+ ],
276
+ edgeContextMenuItems: [
277
+ { label: 'Edit relationship…', action: (edge) => this.editRelationship(relationshipOf(edge)) },
278
+ { label: 'Delete relationship', action: (edge) => this.removeRelationship(relationshipOf(edge)) },
279
+ ],
280
+ canvasContextMenuItems: [
281
+ { label: `New ${this.levelInfo.entityNoun.toLowerCase()} here…`, action: (subject, engine, point) => this.editEntity(null, point) },
282
+ { label: 'New relationship…', action: () => this.editRelationship(null) },
283
+ ],
284
+ };
285
+ }
286
+
287
+ /** The level shown, as DataModelNotation has it (its nouns, its dialogs' fields). */
288
+ get levelInfo() {
289
+ return DataModelNotation.level(this.level);
290
+ }
291
+
292
+ /**
293
+ * An entity's dialog at the level shown (none: a new one, placed at
294
+ * `point` or below the others): its level's fields (name, definition, and
295
+ * at the logical and physical levels its attributes as a table). Saved, it
296
+ * is one undo step.
297
+ */
298
+ editEntity(id, point = null) {
299
+ if (!this.editsLevel) return null;
300
+ const info = this.levelInfo;
301
+ const schema = this.model.level(this.level);
302
+ const entity = id ? schema.entity(id) : null;
303
+ if (id && !entity) return null;
304
+ const scheme = this.model.classificationScheme;
305
+ const context = { typeNames: this.model.dataTypes.map(t => t.name), sensitivities: scheme.levels.map(l => l.name), categories: scheme.categories };
306
+ const title = entity ? `${info.entityNoun}: ${entity.name}` : `New ${info.entityNoun.toLowerCase()}`;
307
+ return this._dialog(title, info.entityFields(entity, context), (values) => {
308
+ const spec = info.entitySpecFrom(values, entity);
309
+ if (!entity) Object.assign(spec, point ?? this._freeSpot(schema));
310
+ this.runModelEdit(this.model.saveEntityCommand(this.level, id, spec));
311
+ });
312
+ }
313
+
314
+ /** Where a new entity goes with no point given: below what is there. @private */
315
+ _freeSpot(schema) {
316
+ const ys = schema.entities.map(e => e.y).filter(Number.isFinite);
317
+ return { x: 160, y: (ys.length ? Math.max(...ys) : 0) + 160 };
318
+ }
319
+
320
+ removeEntity(id) {
321
+ if (!this.editsLevel || !this.model.level(this.level).entity(id)) return null;
322
+ return this.runModelEdit(this.model.removeEntityCommand(this.level, id));
323
+ }
324
+
325
+ /** A relationship's dialog at the level shown (`index` null or -1: a new one); saved, one undo step. */
326
+ editRelationship(index) {
327
+ if (!this.editsLevel) return null;
328
+ const info = this.levelInfo;
329
+ const schema = this.model.level(this.level);
330
+ const relationship = index == null || index < 0 ? null : schema.relationships[index];
331
+ if (!schema.entities.length) return null;
332
+ const title = relationship ? `Relationship: ${relationship.name || `${relationship.source} – ${relationship.target}`}` : 'New relationship';
333
+ return this._dialog(title, info.relationshipFields(schema.entities, relationship), (values) => {
334
+ this.runModelEdit(this.model.saveRelationshipCommand(this.level, relationship ? index : null, info.relationshipSpecFrom(values, relationship)));
335
+ }, ({ source, target }) => (source && target ? null : 'Choose both ends'));
336
+ }
337
+
338
+ removeRelationship(index) {
339
+ if (!this.editsLevel || index == null || index < 0) return null;
340
+ return this.runModelEdit(this.model.removeRelationshipCommand(this.level, index));
341
+ }
342
+
343
+ /** See the model through a service domain (an id), at the logical level, where its business objects are. */
344
+ openServiceDomain(id) {
345
+ if (!this.model.perspective(id)) return;
346
+ this.chartOptions.level = 'logical';
347
+ this.setPerspective(id);
84
348
  }
85
349
 
86
350
  render() {
@@ -98,10 +362,14 @@ export class DataModelPart extends ChartPart {
98
362
  */
99
363
  menuBarItems() {
100
364
  const { perspectives, contexts } = this.model;
365
+ const host = this.modelsHost;
101
366
  return [
367
+ new ToolbarMenu('File', () => this.fileItems()),
368
+ new ToolbarMenu('Edit', () => this.editItems()),
369
+ ...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
102
370
  new ToolbarMenu('Model', () => this.modelItems()),
103
371
  ...(perspectives.length ? [new ToolbarSelect('Perspective',
104
- () => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.name, p.description])],
372
+ () => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.constructor === DataModelPerspective ? p.name : `${p.name} (${p.constructor.kindLabel.toLowerCase()})`, p.description])],
105
373
  () => this.perspectiveId ?? '', (v) => this.setPerspective(v))] : []),
106
374
  ...(contexts.length ? [
107
375
  new ToolbarSelect('Context',
@@ -111,7 +379,7 @@ export class DataModelPart extends ChartPart {
111
379
  ] : []),
112
380
  new ToolbarSelect('Level', () => [
113
381
  ...DataModelNotation.levels().map(l => [l.name, l.label, l.description]),
114
- [DataModel.MAPPING, 'Mapping', 'The levels side by side, joined where one realises another'],
382
+ ...DataModel.OTHER_VIEWS.filter(v => v.offered(this.model) || v.name === this.level).map(v => [v.name, v.label, v.description]),
115
383
  ], () => this.level, (v) => this.setLevel(v)),
116
384
  new ToolbarSelect('Notation', () => DataModelNotation.notations().map(n => [n.name, n.label]), () => this.notation, (v) => this.setNotation(v)),
117
385
  new ToolbarMenu('Export', () => this.exportItems()),
@@ -121,20 +389,223 @@ export class DataModelPart extends ChartPart {
121
389
  /** Model ▸: make, change or remove a perspective or a context (the one chosen). */
122
390
  modelItems() {
123
391
  return [
392
+ { label: `New ${this.editsLevel ? this.levelInfo.entityNoun.toLowerCase() : 'entity'}…`, disabled: !this.editsLevel || undefined, action: () => this.editEntity(null) },
393
+ { label: 'New relationship…', disabled: !this.editsLevel || !this.model.level(this.level).entities.length || undefined, action: () => this.editRelationship(null) },
394
+ { separator: true },
124
395
  { label: 'New perspective…', action: () => this.editPerspective(null) },
396
+ { label: 'New service domain…', action: () => this.editPerspective(null, ServiceDomain) },
397
+ { label: 'New classification lens…', action: () => this.editPerspective(null, ClassificationLens) },
398
+ { label: 'Read service domains from a BIAN landscape…', action: () => this.readLandscape() },
399
+ { label: 'Read a BIAN service domain (semantic API)…', action: () => this.readSemanticApi() },
125
400
  { label: 'Edit perspective…', disabled: !this.perspective || undefined, action: () => this.editPerspective(this.perspective) },
126
401
  { label: 'Delete perspective', disabled: !this.perspective || undefined, action: () => this.removePerspective(this.perspectiveId) },
127
402
  { separator: true },
403
+ ...EditorPart.menuGroup('Data types', this.dataTypeItems()),
404
+ { separator: true },
128
405
  { label: 'New context…', action: () => this.editContext(null) },
129
406
  { label: 'Edit context…', disabled: !this.context || undefined, action: () => this.editContext(this.context) },
130
407
  { label: 'Delete context', disabled: !this.context || undefined, action: () => this.removeContext(this.contextId) },
408
+ { separator: true },
409
+ { label: 'Check model…', action: () => this.checkModel() },
410
+ { label: 'Scorecard…', action: () => this.scoreModel() },
411
+ ...EditorPart.menuGroup('Naming standard', this.namingItems()),
412
+ { label: 'Classification scheme…', action: () => this.editClassificationScheme() },
413
+ ];
414
+ }
415
+
416
+ /**
417
+ * Service domains from BIAN's published service landscape (an ArchiMate
418
+ * model: an Archi file or the Exchange Format; BianLandscapeReader), as one
419
+ * undo step (DataModel#serviceDomainsCommand); then the landscape is shown.
420
+ * @param {Object} [options] - As readInto's, and `reader` (a BianLandscapeReader)
421
+ * @returns {Promise<Command|null>}
422
+ */
423
+ readLandscape({ reader = new BianLandscapeReader(), ...options } = {}) {
424
+ let found = 0;
425
+ return this.readInto({
426
+ ...options, accept: '.archimate,.xml', what: 'the landscape',
427
+ command: (bytes, name) => {
428
+ const specs = reader.read(BianLandscapeReader.parse(bytes));
429
+ found = specs.length;
430
+ return this.model.serviceDomainsCommand(specs, `"${name}"`);
431
+ },
432
+ nothing: (name) => (found
433
+ ? `"${name}" brings nothing new: its ${found} service domain${found === 1 ? ' is' : 's are'} here as ${found === 1 ? 'it is' : 'they are'}.`
434
+ : `"${name}" has no service domains (Capabilities with none inside them).`),
435
+ then: () => { this.chartOptions.level = DataModel.LANDSCAPE; this.perspectiveId = null; },
436
+ });
437
+ }
438
+
439
+ /**
440
+ * A service domain from one of BIAN's semantic APIs (an OpenAPI file, YAML
441
+ * or JSON; BianSemanticApiReader): its control record and behaviour
442
+ * qualifiers as logical entities, the schemas they use as data types, and
443
+ * the service domain, as one undo step (DataModel#bringCommand); then the
444
+ * model is seen through it at the logical level. An entity the model has
445
+ * by name stays as it is and is used.
446
+ * @param {Object} [options] - As readInto's, and `depth` (BianSemanticApiReader's)
447
+ * @returns {Promise<Command|null>}
448
+ */
449
+ readSemanticApi({ depth = 1, ...options } = {}) {
450
+ let read = null;
451
+ return this.readInto({
452
+ ...options, accept: '.yaml,.yml,.json', what: 'the semantic API',
453
+ command: (bytes, name) => {
454
+ const reader = new BianSemanticApiReader({ depth, entityNames: this.model.level('logical').entities.map(e => e.name) });
455
+ read = reader.read(BianSemanticApiReader.parse(new TextDecoder().decode(bytes)));
456
+ const [sd] = read.serviceDomains;
457
+ return this.model.bringCommand(read, `Read ${sd.name} from "${name}"`);
458
+ },
459
+ nothing: (name) => `"${name}" brings nothing the model does not have.`,
460
+ then: () => {
461
+ // The service domain is the one here of its id or name (bringCommand keeps it), else the new one.
462
+ const [sd] = read.serviceDomains;
463
+ const here = this.model.perspective(sd.id) ?? this.model.perspectives.find(p => p.name.toLowerCase() === sd.name.toLowerCase());
464
+ this.chartOptions.level = 'logical';
465
+ this.perspectiveId = here?.id ?? sd.id;
466
+ },
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Ask for a file, make a command from its bytes, and run it as one undo
472
+ * step; a file that cannot be read, or brings nothing, is reported.
473
+ * @param {Object} how
474
+ * @param {string} how.accept - The file chooser's filter
475
+ * @param {string} how.what - What is read, for the report ('the landscape')
476
+ * @param {(bytes: Uint8Array, name: string) => Command|null} how.command
477
+ * @param {(name: string) => string} how.nothing - The report when the command is null
478
+ * @param {() => void} [how.then] - What to show once it is run (before the redraw)
479
+ * @param {(accept: string) => Promise<File|null>} [how.chooseFile]
480
+ * @param {(message: string) => void} [how.report]
481
+ * @returns {Promise<Command|null>}
482
+ */
483
+ async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
484
+ const doc = this.container?.ownerDocument ?? globalThis.document;
485
+ const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
486
+ const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
487
+ if (!file) return null;
488
+ let command;
489
+ try {
490
+ command = make(new Uint8Array(await file.arrayBuffer()), file.name);
491
+ } catch (err) {
492
+ tell(`Could not read ${what}: ${err.message}`);
493
+ return null;
494
+ }
495
+ if (!command) {
496
+ tell(nothing(file.name));
497
+ return null;
498
+ }
499
+ then();
500
+ this.runModelEdit(command);
501
+ return command;
502
+ }
503
+
504
+ /**
505
+ * The Data Model Scorecard (ModelScorecard): each category as the model
506
+ * measures it, a reviewer's score and note, its weight; the total in the
507
+ * title. Saved, the reviews and weights are kept (one undo step).
508
+ */
509
+ scoreModel() {
510
+ const card = this.model.scored();
511
+ const results = card.results();
512
+ const { score, coverage } = card.total(results);
513
+ const title = score == null ? 'Scorecard: nothing scored yet' : `Scorecard: ${score} of 100 (${coverage}% of the weight scored)`;
514
+ return this._dialog(title, card.fields(results), (values) => {
515
+ this.runModelEdit(this.model.scorecardCommand(ModelScorecard.specFrom(values)));
516
+ }, () => null);
517
+ }
518
+
519
+ /** The classification scheme's dialog (its sensitivity levels in order, its categories); saved, one undo step. */
520
+ editClassificationScheme() {
521
+ return this._dialog('Classification scheme', ClassificationScheme.fields(this.model.classificationScheme), (values) => {
522
+ this.runModelEdit(this.model.classificationSchemeCommand(ClassificationScheme.specFrom(values)));
523
+ }, (values) => (ClassificationScheme.specFrom(values).levels.length ? null : 'Give it at least one level'));
524
+ }
525
+
526
+ /** Model ▸ Naming standard ▸: edit it, start from the conventional one, or remove it (Check model… checks names against it). */
527
+ namingItems() {
528
+ const has = !!this.model.namingStandard;
529
+ return [
530
+ { label: has ? 'Edit…' : 'Set…', action: () => this.editNamingStandard() },
531
+ ...(has ? [] : [{ label: 'Use the conventional one', action: () => this.runModelEdit(this.model.namingStandardCommand(NamingStandard.CONVENTIONAL)) }]),
532
+ ...(has ? [{ label: 'Remove', action: () => this.runModelEdit(this.model.namingStandardCommand(null)) }] : []),
533
+ ];
534
+ }
535
+
536
+ /**
537
+ * The naming standard's dialog (NamingStandard.fields: a rule per level for
538
+ * entities and attributes); saved, one undo step. A standard that asks
539
+ * nothing is none.
540
+ */
541
+ editNamingStandard() {
542
+ return this._dialog('Naming standard', NamingStandard.fields(this.model.namingStandard), (values) => {
543
+ const spec = NamingStandard.specFrom(values);
544
+ const asks = Object.values(spec).some(kinds => Object.values(kinds).some(r => r.style || r.classWords.length || r.maxLength || Object.keys(r.abbreviations).length));
545
+ this.runModelEdit(this.model.namingStandardCommand(asks ? spec : null));
546
+ }, () => null);
547
+ }
548
+
549
+ /** Model ▸ Data types ▸: a new one of each kind, then each to change or remove. */
550
+ dataTypeItems() {
551
+ const types = this.model.dataTypes;
552
+ return [
553
+ ...[EnumerationType, StructuredType, DataType].map(Kind => ({ label: `New ${Kind.kindLabel.toLowerCase()}…`, action: () => this.editDataType(null, Kind) })),
554
+ ...(types.length ? [
555
+ { separator: true },
556
+ { label: 'Show data types', action: () => this.setLevel(DataModel.DATA_TYPES) },
557
+ { label: 'Edit', items: types.map(t => ({ label: `${t.name} (${t.constructor.kindLabel.toLowerCase()})…`, action: () => this.editDataType(t) })) },
558
+ { label: 'Delete', items: types.map(t => ({ label: t.name, action: () => this.removeDataType(t.id) })) },
559
+ ] : []),
131
560
  ];
132
561
  }
133
562
 
563
+ /** A dialog making a data type of a kind (none given) or changing one (its kind's fields); applied, the data types are shown. */
564
+ editDataType(type, Kind = type?.constructor ?? DataType) {
565
+ const title = type ? `${Kind.kindLabel}: ${type.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
566
+ return this._dialog(title, Kind.fields(this.model, type), (values) => {
567
+ const spec = Kind.specFrom(values, this.model, type);
568
+ if (type) this.model.updateDataType(type.id, spec); else this.model.addDataType(spec);
569
+ this.chartOptions.level = DataModel.DATA_TYPES;
570
+ this._changed();
571
+ this._redraw();
572
+ });
573
+ }
574
+
575
+ removeDataType(id) {
576
+ this.model.removeDataType(id);
577
+ if (this.level === DataModel.DATA_TYPES && !this.model.dataTypes.length) this.chartOptions.level = 'logical';
578
+ this._changed();
579
+ this._redraw();
580
+ }
581
+
582
+ /** Check the model (DataModelChecker) and list the findings; choosing one shows it (revealFinding). Returns the findings. */
583
+ checkModel() {
584
+ const findings = this.model.checker().run();
585
+ new ModelCheckDialog({ findings, onReveal: (finding) => this.revealFinding(finding), document: this.document }).open();
586
+ return findings;
587
+ }
588
+
589
+ /** Show what a finding names: an entity's level, a service domain seen through at the logical level, the mapping or data types view. */
590
+ revealFinding(finding) {
591
+ if (finding.kind === 'perspective') {
592
+ this.chartOptions.level = 'logical';
593
+ this.setPerspective(finding.ids[0]);
594
+ } else if (finding.kind === 'mapping') {
595
+ this.setLevel(DataModel.MAPPING);
596
+ } else if (finding.kind === 'data-type') {
597
+ this.setLevel(DataModel.DATA_TYPES);
598
+ } else if (finding.level) {
599
+ this.setLevel(finding.level);
600
+ }
601
+ this.menuBar?.render?.();
602
+ }
603
+
134
604
  /** A dialog making a perspective (none given) or changing one (DataModelPerspective.fields); applied, it is the one seen through. */
135
- editPerspective(perspective) {
136
- return this._dialog(perspective ? `Perspective: ${perspective.name}` : 'New perspective', DataModelPerspective.fields(this.model, perspective), (values) => {
137
- const spec = DataModelPerspective.specFrom(values, this.model);
605
+ editPerspective(perspective, Kind = perspective?.constructor ?? DataModelPerspective) {
606
+ const title = perspective ? `${Kind.kindLabel}: ${perspective.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
607
+ return this._dialog(title, Kind.fields(this.model, perspective), (values) => {
608
+ const spec = Kind.specFrom(values, this.model, perspective);
138
609
  const saved = perspective ? this.model.updatePerspective(perspective.id, spec) : this.model.addPerspective(spec);
139
610
  this._changed();
140
611
  this.setPerspective(saved.id);
@@ -164,12 +635,11 @@ export class DataModelPart extends ChartPart {
164
635
  }
165
636
 
166
637
  /** Open a form (a name is needed). @private */
167
- _dialog(title, fields, apply) {
638
+ _dialog(title, fields, apply, validate = ({ name }) => ((name ?? '').trim() ? null : 'Give it a name')) {
168
639
  return new FormDialog({
169
640
  title, fields, submitLabel: 'Save', idPrefix: 'data-model-',
170
641
  document: this.container?.ownerDocument ?? globalThis.document,
171
- validate: ({ name }) => ((name ?? '').trim() ? null : 'Give it a name'),
172
- onApply: apply,
642
+ validate, onApply: apply,
173
643
  }).open();
174
644
  }
175
645
 
@@ -179,18 +649,29 @@ export class DataModelPart extends ChartPart {
179
649
  this.menuBar?.render?.();
180
650
  }
181
651
 
182
- /** Export ▸: the picture, as PNG or SVG, named after the part. */
652
+ /** Export ▸: the picture, as PNG or SVG, named after the model. */
183
653
  exportItems() {
184
- const name = this.title || 'data-model';
654
+ const name = this.model.name || this.title || 'data-model';
185
655
  return [
186
656
  { label: 'Image (PNG)', action: () => downloadPng(this.canvasHost, { name }) },
187
657
  { label: 'Image (SVG)', action: () => downloadSvg(this.canvasHost, { name }) },
658
+ { separator: true },
659
+ { label: 'SQL DDL (physical level)', action: () => this.exportDdl() },
188
660
  ];
189
661
  }
190
662
 
663
+ /** The physical level as SQL DDL (SqlDdlFileFormat), downloaded as the model's name. Returns the file. */
664
+ exportDdl({ download = (file, fileName) => downloadBlob(new (this.document.defaultView ?? globalThis).Blob([file.data], { type: file.mimeType }), fileName) } = {}) {
665
+ const file = new SqlDdlFileFormat().export(this.model);
666
+ download(file, `${String(this.model.name || this.title || 'model').replace(/[^A-Za-z0-9._-]+/g, '-')}.sql`);
667
+ return file;
668
+ }
669
+
191
670
  /** Redraw now (a choice made), when there is somewhere to draw. @private */
192
671
  _redraw() {
193
672
  if (this.canvasHost) this._draw();
673
+ // Its toolbar shows what is drawn, however it was chosen (a finding, a lineage, a service domain opened).
674
+ this.menuBar?.refresh?.();
194
675
  }
195
676
 
196
677
  /**
@@ -198,26 +679,46 @@ export class DataModelPart extends ChartPart {
198
679
  * time): on the level shown, or in the mapping view's own layout. Then draw.
199
680
  */
200
681
  _draw() {
682
+ if (this._keepPlaces()) this.model.edited(); // a box or note moved is a change, kept and saved
683
+ this._drawnLevel = this.level;
684
+ super._draw();
685
+ }
686
+
687
+ /**
688
+ * Keep where the drawing on screen has its boxes and notes: on the level
689
+ * it shows, or in the mapping view's layout. Returns whether any moved. @private
690
+ */
691
+ _keepPlaces() {
201
692
  const pos = this.engine?._graph?.pos;
202
- if (pos && this._drawnLevel) {
203
- if (this._drawnLevel === DataModel.MAPPING) {
204
- for (const [id, p] of pos) if (id.includes(':')) this.model.mappingLayout[id] = { x: p.x, y: p.y };
205
- } else {
206
- for (const entity of this.model.level(this._drawnLevel).entities) {
207
- const p = pos.get(entity.id);
208
- if (p) { entity.x = p.x; entity.y = p.y; }
209
- }
210
- for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
211
- const p = pos.get(rule.id);
212
- if (p) this.model.placeNote(this._drawnLevel, rule, p);
213
- }
693
+ if (!pos || !this._drawnLevel) return false;
694
+ let moved = false;
695
+ const place = (p, before, put) => {
696
+ if (!p || (before && before.x === p.x && before.y === p.y)) return;
697
+ put({ x: p.x, y: p.y });
698
+ moved = true;
699
+ };
700
+ const other = DataModel.otherView(this._drawnLevel);
701
+ if (other && !other.keepsPlaces) return false; // laid out by its structure, anew each time
702
+ if (other) {
703
+ for (const [id, p] of pos) {
704
+ if (id.includes(':')) place(p, this.model.mappingLayout[id], (at) => { this.model.mappingLayout[id] = at; });
214
705
  }
706
+ return moved;
215
707
  }
216
- this._drawnLevel = this.level;
217
- super._draw();
708
+ for (const entity of this.model.level(this._drawnLevel).entities) {
709
+ place(pos.get(entity.id), entity, (at) => Object.assign(entity, at));
710
+ }
711
+ for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
712
+ const p = pos.get(rule.id);
713
+ const before = this.model.notePlace(this._drawnLevel, rule);
714
+ place(p, Number.isFinite(before.x) ? before : null, (at) => this.model.placeNote(this._drawnLevel, rule, at));
715
+ }
716
+ return moved;
218
717
  }
219
718
 
220
719
  dispose() {
720
+ this._unsubscribeEdits?.();
721
+ this.file.restoreTitle();
221
722
  this.menuBar?.dispose?.();
222
723
  super.dispose();
223
724
  }