@peter.prib/renderengine 0.2.7 → 0.2.11

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 (46) hide show
  1. package/README.md +66 -9
  2. package/REFERENCE.md +129 -0
  3. package/dist/renderengine-workspace.umd.js +5068 -2892
  4. package/dist/renderengine.umd.js +4874 -2888
  5. package/examples/data-model.html +40 -18
  6. package/package.json +1 -1
  7. package/src/charting/FormDialog.js +398 -385
  8. package/src/charting/RowsField.js +127 -0
  9. package/src/charting/datamodel/ClassificationLens.js +33 -0
  10. package/src/charting/datamodel/ClassificationScheme.js +98 -0
  11. package/src/charting/datamodel/DataModel.js +220 -4
  12. package/src/charting/datamodel/DataModelChecker.js +244 -2
  13. package/src/charting/datamodel/DataModelContext.js +40 -10
  14. package/src/charting/datamodel/DataModelEditCommand.js +47 -0
  15. package/src/charting/datamodel/DataModelPerspective.js +12 -2
  16. package/src/charting/datamodel/EnumerationType.js +64 -51
  17. package/src/charting/datamodel/LevelSchema.js +23 -0
  18. package/src/charting/datamodel/ServiceDomain.js +9 -0
  19. package/src/charting/datamodel/SqlDdl.js +85 -0
  20. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  21. package/src/charting/datamodel/index.js +21 -15
  22. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  23. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  24. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  25. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  26. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  27. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  28. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  29. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  30. package/src/charting/datamodel/naming/index.js +8 -0
  31. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  32. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  33. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  34. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  35. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  36. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  37. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  38. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  39. package/src/charting/datamodel/scorecard/index.js +8 -0
  40. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  41. package/src/charting/diagrams/DataModelRelationshipStrategy.js +7 -1
  42. package/src/charting/diagrams/NodeGeometry.js +6 -2
  43. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  44. package/src/datamodel.js +402 -4
  45. package/src/index.js +2666 -2666
  46. package/src/workspace/parts/DataModelPart.js +197 -6
@@ -0,0 +1,127 @@
1
+ import { el } from '../workspace/dom.js';
2
+
3
+ /**
4
+ * RowsField — the control a FormDialog shows for a `rows` field: a list of
5
+ * items edited as a table, a row each, its columns from the field
6
+ * (`columns: [{ key, label, type?: 'text'|'checkbox'|'select', choices?,
7
+ * suggestions?, placeholder?, width? }]`), each row movable up and down and
8
+ * removable, and **Add** for a new one (`addLabel`). It reads back as the rows
9
+ * in their order, each the item it was made from with what the columns say
10
+ * laid over it, so what a dialog does not show (an attribute's ISO 20022
11
+ * type, say) is kept: an empty text or an unticked box leaves its key out. A
12
+ * new row left empty is no row.
13
+ */
14
+ export class RowsField {
15
+ /**
16
+ * @param {Object} field - The FormDialog field (`columns`, `value`: the items, `addLabel`)
17
+ * @param {Document} doc
18
+ * @param {string} [id] - The table's element id
19
+ */
20
+ constructor(field, doc, id = '') {
21
+ this.field = field;
22
+ this.doc = doc;
23
+ this.id = id;
24
+ /** Each row's element and how to read it, in display order. */
25
+ this.rows = [];
26
+ }
27
+
28
+ /** The control: the table, its rows, and Add. */
29
+ element() {
30
+ const { columns, value = [], addLabel = 'Add' } = this.field;
31
+ const lists = columns.filter(c => c.suggestions).map(c => ({
32
+ tag: 'datalist', attrs: { id: this._listId(c) }, children: c.suggestions.map(v => ({ tag: 'option', attrs: { value: v } })),
33
+ }));
34
+ this.root = el({
35
+ attrs: { id: this.id }, style: { display: 'flex', flexDirection: 'column', gap: '4px' },
36
+ children: [
37
+ {
38
+ tag: 'table', style: { borderCollapse: 'collapse', fontSize: '12px' },
39
+ children: [
40
+ { tag: 'thead', children: [{ tag: 'tr', children: [...columns.map(c => ({ tag: 'th', text: c.label, style: { textAlign: 'left', fontWeight: 'normal', color: '#666', padding: '0 4px' } })), { tag: 'th' }] }] },
41
+ { tag: 'tbody', ref: (n) => { this.body = n; } },
42
+ ],
43
+ },
44
+ ...lists,
45
+ { tag: 'button', attrs: { type: 'button', 'data-action': 'add' }, text: `+ ${addLabel}`, style: { alignSelf: 'flex-start' }, on: { click: () => this.add() } },
46
+ ],
47
+ }, this.doc);
48
+ for (const item of value) this.add(item);
49
+ return this.root;
50
+ }
51
+
52
+ /** Add a row for an item (none: an empty one). Returns its element. */
53
+ add(item = null) {
54
+ const readers = [];
55
+ const cell = (c) => {
56
+ const current = item?.[c.key];
57
+ const width = c.width ?? (c.type === 'checkbox' ? '40px' : undefined);
58
+ let control;
59
+ if (c.type === 'checkbox') {
60
+ control = { tag: 'input', attrs: { type: 'checkbox', checked: !!current, 'data-key': c.key }, ref: (n) => { n.checked = !!current; readers.push([c, () => n.checked]); } };
61
+ } else if (c.type === 'select') {
62
+ const choices = c.choices.some(([v]) => v === String(current ?? '')) || current == null ? c.choices : [[String(current), String(current)], ...c.choices];
63
+ control = {
64
+ // The value set as well as the option marked: a parent's ref runs after its children are in.
65
+ tag: 'select', attrs: { 'data-key': c.key }, ref: (n) => { n.value = String(current ?? choices[0]?.[0] ?? ''); readers.push([c, () => n.value]); },
66
+ children: choices.map(([v, text]) => ({ tag: 'option', attrs: { value: v, selected: v === String(current ?? '') }, text })),
67
+ };
68
+ } else {
69
+ control = {
70
+ tag: 'input', attrs: { type: 'text', value: current == null ? '' : String(current), placeholder: c.placeholder, list: c.suggestions ? this._listId(c) : undefined, 'data-key': c.key },
71
+ style: { width: '100%', boxSizing: 'border-box' }, ref: (n) => { n.value = current == null ? '' : String(current); readers.push([c, () => String(n.value ?? '').trim()]); },
72
+ };
73
+ }
74
+ return { tag: 'td', style: { padding: '1px 2px', ...(width && { width }) }, children: [control] };
75
+ };
76
+ const button = (text, title, action) => ({ tag: 'button', attrs: { type: 'button', title, 'data-action': action }, text, style: { padding: '0 4px' } });
77
+ let row;
78
+ const node = el({
79
+ tag: 'tr',
80
+ children: [
81
+ ...this.field.columns.map(cell),
82
+ {
83
+ tag: 'td', style: { whiteSpace: 'nowrap' },
84
+ children: [
85
+ { ...button('↑', 'Move up', 'up'), on: { click: () => this.move(row, -1) } },
86
+ { ...button('↓', 'Move down', 'down'), on: { click: () => this.move(row, 1) } },
87
+ { ...button('✕', 'Remove', 'remove'), on: { click: () => this.remove(row) } },
88
+ ],
89
+ },
90
+ ],
91
+ }, this.doc);
92
+ row = { node, item, readers };
93
+ this.rows.push(row);
94
+ this.body.appendChild(node);
95
+ return node;
96
+ }
97
+
98
+ /** Move a row up (-1) or down (1). */
99
+ move(row, by) {
100
+ const i = this.rows.indexOf(row), j = i + by;
101
+ if (i < 0 || j < 0 || j >= this.rows.length) return;
102
+ [this.rows[i], this.rows[j]] = [this.rows[j], this.rows[i]];
103
+ for (const r of this.rows) this.body.appendChild(r.node);
104
+ }
105
+
106
+ remove(row) {
107
+ this.rows = this.rows.filter(r => r !== row);
108
+ row.node.remove();
109
+ }
110
+
111
+ /** The rows as items, in order (see the class). */
112
+ read() {
113
+ return this.rows.map(({ item, readers }) => {
114
+ const out = { ...(item ?? {}) };
115
+ for (const [c, read] of readers) {
116
+ const v = read();
117
+ if (v === '' || v === false) delete out[c.key];
118
+ else out[c.key] = v;
119
+ }
120
+ return out;
121
+ }).filter((out, i) => this.rows[i].item || this.field.columns.some(c => out[c.key] !== undefined));
122
+ }
123
+
124
+ _listId(column) {
125
+ return `${this.id || 'rows'}-${column.key}-suggestions`;
126
+ }
127
+ }
@@ -0,0 +1,33 @@
1
+ import { DataModelPerspective } from './DataModelPerspective.js';
2
+ import { ClassificationScheme } from './ClassificationScheme.js';
3
+
4
+ /** How an entity with no classification is drawn through the lens. */
5
+ const UNCLASSIFIED = '#f3f4f6';
6
+
7
+ /**
8
+ * ClassificationLens — a perspective that colours what it shows by its
9
+ * classification (DMBOK2's data security): each entity in its sensitivity's
10
+ * colour (the model's ClassificationScheme), at its effective classification
11
+ * (the highest of its own and its attributes'), unclassified ones grey, and
12
+ * the classification on hover. It sees the concepts any perspective does.
13
+ */
14
+ export class ClassificationLens extends DataModelPerspective {
15
+ static kind = 'classification';
16
+ static kindLabel = 'Classification lens';
17
+
18
+ decorate(entity, level, model) {
19
+ const scheme = model?.classificationScheme ?? new ClassificationScheme();
20
+ const effective = scheme.effective(entity);
21
+ return {
22
+ color: scheme.colorOf(effective.sensitivity) ?? UNCLASSIFIED,
23
+ details: { classification: ClassificationScheme.describe(effective) || 'unclassified' },
24
+ };
25
+ }
26
+
27
+ /** As a perspective's, its kind kept. */
28
+ static specFrom(values, model) {
29
+ return { ...DataModelPerspective.specFrom(values, model), kind: ClassificationLens.kind };
30
+ }
31
+ }
32
+
33
+ DataModelPerspective.register(ClassificationLens);
@@ -0,0 +1,98 @@
1
+ /** The colours sensitivity levels are drawn in, lowest first, when a scheme names none. */
2
+ const LEVEL_COLORS = ['#dcfce7', '#e0f2fe', '#fef3c7', '#fecaca', '#e9d5ff', '#fed7aa'];
3
+
4
+ /**
5
+ * ClassificationScheme — how a model's data is classified (DMBOK2's data
6
+ * security): sensitivity `levels` in order, lowest first, each with the colour
7
+ * a classification lens draws it in, and `categories` a piece of data may fall
8
+ * in (PII, PCI...). The default is the common four levels and categories; a
9
+ * model may keep its own.
10
+ *
11
+ * A classification, on an entity or an attribute, is its `sensitivity` (a
12
+ * level's name) and its `categories`; an entity's effective one is the highest
13
+ * of its own and its attributes', with all their categories.
14
+ */
15
+ export class ClassificationScheme {
16
+ static DEFAULT = Object.freeze({
17
+ levels: [
18
+ { name: 'Public', color: LEVEL_COLORS[0] },
19
+ { name: 'Internal', color: LEVEL_COLORS[1] },
20
+ { name: 'Confidential', color: LEVEL_COLORS[2] },
21
+ { name: 'Restricted', color: LEVEL_COLORS[3] },
22
+ ],
23
+ categories: ['PII', 'PCI', 'PHI', 'Financial'],
24
+ });
25
+
26
+ /** @param {{levels?: Array<{name: string, color?: string}|string>, categories?: string[]}} [spec] */
27
+ constructor({ levels = ClassificationScheme.DEFAULT.levels, categories = ClassificationScheme.DEFAULT.categories } = {}) {
28
+ this.levels = levels.map((l, i) => {
29
+ const level = typeof l === 'string' ? { name: l } : l;
30
+ return { name: String(level.name), color: level.color || LEVEL_COLORS[i % LEVEL_COLORS.length] };
31
+ });
32
+ this.categories = [...categories].map(String);
33
+ }
34
+
35
+ /** A sensitivity's rank (0 the lowest), or -1 for none or one the scheme lacks. */
36
+ rank(sensitivity) {
37
+ return this.levels.findIndex(l => l.name.toLowerCase() === String(sensitivity ?? '').toLowerCase());
38
+ }
39
+
40
+ /** The colour a sensitivity is drawn in, or null. */
41
+ colorOf(sensitivity) {
42
+ return this.levels[this.rank(sensitivity)]?.color ?? null;
43
+ }
44
+
45
+ /** Categories as written ('PII, PCI' or a list) as a list. */
46
+ static categoriesOf(value) {
47
+ return (Array.isArray(value) ? value : String(value ?? '').split(/[,;]/)).map(c => String(c).trim()).filter(Boolean);
48
+ }
49
+
50
+ /**
51
+ * An entity's effective classification: the highest sensitivity of its own
52
+ * and its attributes', and all their categories.
53
+ * @returns {{sensitivity: string|null, categories: string[]}}
54
+ */
55
+ effective(entity) {
56
+ const parts = [entity, ...(entity?.attributes ?? [])];
57
+ let best = null;
58
+ for (const p of parts) if (this.rank(p?.sensitivity) > this.rank(best)) best = this.levels[this.rank(p.sensitivity)].name;
59
+ const categories = [...new Set(parts.flatMap(p => ClassificationScheme.categoriesOf(p?.categories)))];
60
+ return { sensitivity: best, categories };
61
+ }
62
+
63
+ /** A classification in words: 'Confidential (PII, PCI)', or '' for none. */
64
+ static describe({ sensitivity, categories = [] } = {}) {
65
+ if (!sensitivity && !categories.length) return '';
66
+ return [sensitivity || 'Unclassified', categories.length ? `(${categories.join(', ')})` : ''].filter(Boolean).join(' ');
67
+ }
68
+
69
+ toJSON() {
70
+ return { levels: this.levels.map(l => ({ ...l })), categories: [...this.categories] };
71
+ }
72
+
73
+ /** Whether it is the default (so a model need not keep it). */
74
+ get isDefault() {
75
+ return JSON.stringify(this.toJSON()) === JSON.stringify(new ClassificationScheme().toJSON());
76
+ }
77
+
78
+ /** Its dialog's fields: its levels a line each, lowest first (`name | colour`), its categories. */
79
+ static fields(scheme) {
80
+ return [
81
+ {
82
+ key: 'levels', label: 'Sensitivity levels', type: 'textarea', rows: 5,
83
+ value: scheme.levels.map(l => `${l.name} | ${l.color}`).join('\n'),
84
+ hint: 'Lowest first, one a line: name | colour',
85
+ },
86
+ { key: 'categories', label: 'Categories', type: 'text', value: scheme.categories.join(', '), placeholder: 'PII, PCI, PHI' },
87
+ ];
88
+ }
89
+
90
+ /** A scheme's spec from those fields. */
91
+ static specFrom(values) {
92
+ return {
93
+ levels: String(values.levels ?? '').split('\n').map(l => l.split('|').map(p => p.trim())).filter(([name]) => name)
94
+ .map(([name, color]) => ({ name, ...(color && { color }) })),
95
+ categories: ClassificationScheme.categoriesOf(values.categories),
96
+ };
97
+ }
98
+ }
@@ -10,6 +10,11 @@ import { ModelComparison } from '../diagramModel/ModelComparison.js';
10
10
  import { DataModelChecker } from './DataModelChecker.js';
11
11
  import { CommandHistory } from '../CommandHistory.js';
12
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';
13
18
  import { ServiceLandscape } from './ServiceLandscape.js';
14
19
  import {
15
20
  LevelEntitiesCompared, LevelRelationshipsCompared, MappingsCompared, ContextsCompared, PerspectivesCompared, DataTypesCompared,
@@ -50,6 +55,13 @@ export class DataModel {
50
55
  /** The view of its data types (a `level` for view()). */
51
56
  static DATA_TYPES = 'datatypes';
52
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
+
53
65
  /** How many data type boxes side by side in the data types view, and how far apart. */
54
66
  static DATA_TYPE_COLUMNS = 4;
55
67
  static DATA_TYPE_SPACING = { x: 260, gap: 40 };
@@ -71,6 +83,11 @@ export class DataModel {
71
83
  offered: (model) => model.dataTypes.length > 0, keepsPlaces: false,
72
84
  view: (model) => model.dataTypesView(),
73
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
+ }),
74
91
  Object.freeze({
75
92
  name: 'landscape', label: 'Service landscape', description: 'The service domains in their business areas and business domains (BIAN)',
76
93
  offered: (model) => model.serviceDomains().length > 0, keepsPlaces: false,
@@ -101,6 +118,9 @@ export class DataModel {
101
118
  * @param {Array<Object|DataModelPerspective>} [spec.perspectives]
102
119
  * @param {Array<Object|DataModelContext>} [spec.contexts]
103
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)
104
124
  * @param {Array<Object>} [spec.entities] - The older, one-entity form (levelsFromCombined)
105
125
  * @param {Array<Object>} [spec.relationships]
106
126
  * @param {string} [spec.name] - What the model is called
@@ -124,6 +144,24 @@ export class DataModel {
124
144
  this.contexts = (spec.contexts ?? []).map(c => (c instanceof DataModelContext ? c : new DataModelContext(c)));
125
145
  /** Its data types: the types attributes name (`attribute.type`, a data type's id or name). */
126
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
+ });
127
165
  }
128
166
 
129
167
  /**
@@ -244,6 +282,37 @@ export class DataModel {
244
282
  return [...ids];
245
283
  }
246
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
+
247
316
  /** The perspectives (service domains) that own a business object (a logical entity). */
248
317
  ownersOf(entityId) {
249
318
  return this.perspectives.filter(p => p.ownedEntities().includes(entityId));
@@ -297,13 +366,18 @@ export class DataModel {
297
366
  const concepts = this.conceptsOf(level, entity.id);
298
367
  const name = level === 'conceptual' && lens ? lens.nameOf(entity) : entity.name;
299
368
  const meanings = meaning ? concepts.map(c => meaning.meaningOf(c)).filter(Boolean) : [];
300
- const rules = meaning ? [...new Set(concepts.flatMap(c => meaning.constraintsOn(c).map(r => r.text)))] : [];
369
+ const rules = meaning ? [...new Set(concepts.flatMap(c => meaning.constraintsOn(c).map(r => DataModelContext.ruleText(r))))] : [];
301
370
  const owners = level === 'logical' ? this.ownersOf(entity.id).map(o => o.name) : [];
302
371
  const typed = this.typedAttributes(entity);
303
372
  const iso = this.iso20022Of(entity);
373
+ const classified = ClassificationScheme.describe(this.classificationScheme.effective(entity));
304
374
  const details = {
305
375
  ...entity.details,
306
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),
307
381
  ...(typed.length && { 'data types': typed.map(({ attribute, type }) => `${attribute.name}: ${type.summary()}`) }),
308
382
  ...(iso.length && { 'ISO 20022': iso }),
309
383
  ...(name !== entity.name && { schema: entity.name }),
@@ -312,7 +386,7 @@ export class DataModel {
312
386
  ...(rules.length && { rules }),
313
387
  };
314
388
  const { attributes, ...rest } = entity;
315
- const mark = lens?.decorate(entity, level) ?? {};
389
+ const mark = lens?.decorate(entity, level, this) ?? {};
316
390
  const allDetails = { ...details, ...mark.details };
317
391
  return {
318
392
  ...rest,
@@ -329,7 +403,7 @@ export class DataModel {
329
403
  const notes = [
330
404
  ...(meaning?.constraints ?? []).map(c => {
331
405
  const on = this.realising(level, c.applies) ?? new Set(c.applies);
332
- return { id: c.id, text: c.text, attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
406
+ return { id: c.id, text: DataModelContext.ruleText(c), attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
333
407
  }),
334
408
  ...(lens?.notes(level) ?? []).map(n => ({ ...n, attachTo: n.attachTo.filter(id => ids.has(id)), ...this.notePlace(level, n) })),
335
409
  ].filter(a => a.attachTo.length);
@@ -385,6 +459,106 @@ export class DataModel {
385
459
 
386
460
  // ===== Perspectives and contexts of one's own =====
387
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
+
388
562
  // ===== Data types =====
389
563
 
390
564
  /** A data type by its id, else its name (any case), or null. */
@@ -470,7 +644,7 @@ export class DataModel {
470
644
  x: 140 + (i % columns) * across, y: tops[i] + heights[i] / 2,
471
645
  color: color[t.constructor.kind] ?? color.type,
472
646
  description: t.description,
473
- details: { kind: t.constructor.kindLabel, ...(t.iso20022 && { 'ISO 20022': t.iso20022 }), used: this._usedSummary(t) },
647
+ details: { kind: t.constructor.kindLabel, ...t.codeSet, ...(t.iso20022 && { 'ISO 20022': t.iso20022 }), used: this._usedSummary(t) },
474
648
  }));
475
649
  const links = this.dataTypes.flatMap(t => [...new Set(t.uses().map(ref => this.dataType(ref)).filter(u => u && u !== t))]
476
650
  .map(u => ({ id: `uses:${t.id}->${u.id}`, source: `type:${t.id}`, target: `type:${u.id}` })));
@@ -487,6 +661,45 @@ export class DataModel {
487
661
  return parts.join('; ') || 'nowhere';
488
662
  }
489
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
+
490
703
  /** Its service domains (the perspectives that are ServiceDomains). */
491
704
  serviceDomains() {
492
705
  return this.perspectives.filter(p => p.constructor.kind === 'service-domain');
@@ -640,6 +853,9 @@ export class DataModel {
640
853
  noteLayout: Object.fromEntries(Object.entries(this.noteLayout).map(([level, at]) => [level, { ...at }])),
641
854
  perspectives: this.perspectives.map(p => p.toJSON()), contexts: this.contexts.map(c => c.toJSON()),
642
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)) }),
643
859
  };
644
860
  }
645
861
  }