@peter.prib/renderengine 0.2.4 → 0.2.6

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 (35) hide show
  1. package/README.md +215 -213
  2. package/REFERENCE.md +99 -7
  3. package/dist/renderengine-workspace.umd.js +13168 -11966
  4. package/dist/renderengine.umd.js +7439 -6472
  5. package/examples/data-model.html +324 -0
  6. package/package.json +70 -68
  7. package/src/charting/CheckboxTreeSelector.js +373 -304
  8. package/src/charting/DiagramEditor.js +3372 -3330
  9. package/src/charting/DiagramTypeRegistry.js +365 -326
  10. package/src/charting/Palette.js +119 -119
  11. package/src/charting/TemplateCatalog.js +61 -59
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
  13. package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
  14. package/src/charting/datamodel/DataModel.js +299 -0
  15. package/src/charting/datamodel/DataModelContext.js +108 -0
  16. package/src/charting/datamodel/DataModelPerspective.js +84 -0
  17. package/src/charting/datamodel/LevelMapping.js +38 -0
  18. package/src/charting/datamodel/LevelSchema.js +41 -0
  19. package/src/charting/datamodel/index.js +5 -0
  20. package/src/charting/diagrams/DataModelDiagram.js +160 -86
  21. package/src/charting/diagrams/DataModelRelationshipStrategy.js +86 -38
  22. package/src/charting/diagrams/nesting.js +31 -0
  23. package/src/charting/modelTree/ModelTree.js +202 -164
  24. package/src/charting/modelTree/ModelTreePanel.js +36 -3
  25. package/src/charting/modelTree/TreeViewMode.js +131 -110
  26. package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
  27. package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
  28. package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
  29. package/src/datamodel.js +132 -122
  30. package/src/diagramCatalog.js +35 -30
  31. package/src/index.js +6 -1
  32. package/src/workspace/index.js +1 -0
  33. package/src/workspace/parts/ArchimateCanvasPart.js +5 -2
  34. package/src/workspace/parts/ChartPart.js +6 -1
  35. package/src/workspace/parts/DataModelPart.js +234 -0
@@ -0,0 +1,299 @@
1
+ import { DataModelPerspective } from './DataModelPerspective.js';
2
+ import { DataModelContext } from './DataModelContext.js';
3
+ import { LevelSchema } from './LevelSchema.js';
4
+ import { LevelMapping } from './LevelMapping.js';
5
+ import { NodeGeometry } from '../diagrams/NodeGeometry.js';
6
+
7
+ /** The levels, outermost first: each realised by the next. */
8
+ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
9
+
10
+ /**
11
+ * DataModel — a data model read many ways. Its structure is three levels, each
12
+ * its own schema (LevelSchema: conceptual concepts, logical entities, physical
13
+ * tables, with their relationships), joined by mappings (LevelMapping:
14
+ * conceptual → logical, logical → physical, any number either way). A
15
+ * perspective (DataModelPerspective) is a stakeholder's viewpoint on the
16
+ * concepts; a context (DataModelContext) their meaning, scope and constraints.
17
+ * Perspective → context → schema: `view({ perspective, context, level })`
18
+ * gives what to draw at a level, or ('mapping') how the levels map; the
19
+ * schema is left as it is.
20
+ *
21
+ * The older form, one entity per thing with each level's detail on it
22
+ * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
23
+ * three levels mapped one to one.
24
+ */
25
+ export class DataModel {
26
+ /** The levels, outermost first. */
27
+ static LEVELS = LEVELS;
28
+
29
+ /** The view of how the levels map (a `level` for view()). */
30
+ static MAPPING = 'mapping';
31
+
32
+ /** The room between the mapping view's columns, and between the boxes in one. */
33
+ static MAPPING_COLUMN = 380;
34
+ static MAPPING_GAP = 40;
35
+
36
+ /** How far below the lowest box notes with no place of their own sit, and how far apart. */
37
+ static NOTE_ROW = 200;
38
+ static NOTE_SPACING = 220;
39
+
40
+ /**
41
+ * @param {Object} spec
42
+ * @param {Object<string, {entities, relationships}>} [spec.levels] - Each level's schema
43
+ * @param {Array<Object|LevelMapping>} [spec.mappings] - Conceptual → logical, logical → physical
44
+ * @param {Object<string, {x, y}>} [spec.mappingLayout] - Where the mapping view's boxes are, by 'level:id'
45
+ * @param {Object<string, Object<string, {x, y}>>} [spec.noteLayout] - Where a rule's note is on a level, by level then rule id
46
+ * (the conceptual level's is the rule's own x, y)
47
+ * @param {Array<Object|DataModelPerspective>} [spec.perspectives]
48
+ * @param {Array<Object|DataModelContext>} [spec.contexts]
49
+ * @param {Array<Object>} [spec.entities] - The older, one-entity form (levelsFromCombined)
50
+ * @param {Array<Object>} [spec.relationships]
51
+ */
52
+ constructor(spec = {}) {
53
+ const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
54
+ this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
55
+ this.mappings = LEVELS.slice(1).map((to, i) => {
56
+ const from = LEVELS[i];
57
+ const given = mappings.find(m => m.from === from && m.to === to);
58
+ return given instanceof LevelMapping ? given : new LevelMapping(given ?? { from, to });
59
+ });
60
+ this.mappingLayout = { ...mappingLayout };
61
+ 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)));
63
+ this.contexts = (spec.contexts ?? []).map(c => (c instanceof DataModelContext ? c : new DataModelContext(c)));
64
+ }
65
+
66
+ /**
67
+ * Levels from the older form: one entity per thing, each level's detail on
68
+ * it (`entity.logical.attributes`, `entity.physical.tableName`...). Every
69
+ * entity is a concept, and a logical entity and a table when it has their
70
+ * detail (or none of either), mapped one to one; every relationship is at
71
+ * each level between what is there.
72
+ */
73
+ static levelsFromCombined({ entities = [], relationships = [] } = {}) {
74
+ const common = (e) => {
75
+ const { conceptual, logical, physical, ...rest } = e;
76
+ return rest;
77
+ };
78
+ const at = {
79
+ conceptual: entities.map(e => common(e)),
80
+ logical: entities.map(e => ({ ...common(e), attributes: e.logical?.attributes ?? [] })),
81
+ physical: entities.map(e => ({ ...common(e), name: e.physical?.tableName ?? e.name, attributes: e.physical?.attributes ?? [] })),
82
+ };
83
+ const levels = Object.fromEntries(LEVELS.map(level => [level, { entities: at[level], relationships: relationships.map(r => ({ ...r })) }]));
84
+ const oneToOne = (from, to) => ({ from, to, pairs: entities.map(e => [e.id, e.id]) });
85
+ return { levels, mappings: [oneToOne('conceptual', 'logical'), oneToOne('logical', 'physical')] };
86
+ }
87
+
88
+ /** A level's schema (LevelSchema). */
89
+ level(name) {
90
+ const schema = this.levels[name];
91
+ if (!schema) throw new Error(`Unknown level: "${name}"`);
92
+ return schema;
93
+ }
94
+
95
+ /** The mapping from one level to the next (LevelMapping). */
96
+ mapping(from, to) {
97
+ return this.mappings.find(m => m.from === from && m.to === to) ?? null;
98
+ }
99
+
100
+ /** The concepts' entities, for perspectives and contexts to name. */
101
+ get concepts() {
102
+ return this.level('conceptual').entities;
103
+ }
104
+
105
+ /** The entities at `level` that realise some of `conceptIds` (through the mappings); null for null (every one). */
106
+ realising(level, conceptIds) {
107
+ if (!conceptIds) return null;
108
+ let ids = new Set(conceptIds);
109
+ for (let i = 1; i <= LEVELS.indexOf(level); i++) {
110
+ const mapping = this.mapping(LEVELS[i - 1], LEVELS[i]);
111
+ ids = new Set([...ids].flatMap(id => mapping.targetsOf(id)));
112
+ }
113
+ return ids;
114
+ }
115
+
116
+ /** The concepts an entity at `level` realises (itself at the conceptual level). */
117
+ conceptsOf(level, id) {
118
+ let ids = new Set([id]);
119
+ for (let i = LEVELS.indexOf(level); i > 0; i--) {
120
+ const mapping = this.mapping(LEVELS[i - 1], LEVELS[i]);
121
+ ids = new Set([...ids].flatMap(x => mapping.sourcesOf(x)));
122
+ }
123
+ return [...ids];
124
+ }
125
+
126
+ /** A perspective by id, or null (the whole schema). */
127
+ perspective(id) {
128
+ return this.perspectives.find(p => p.id === id) ?? null;
129
+ }
130
+
131
+ /** A context by id, or null (the schema's own descriptions). */
132
+ context(id) {
133
+ return this.contexts.find(c => c.id === id) ?? null;
134
+ }
135
+
136
+ /**
137
+ * What to draw for a perspective read in a context (either may be null) at
138
+ * a level ('conceptual', 'logical', 'physical'; 'mapping': mappingView).
139
+ * Entities: those realising the concepts the perspective shows (the
140
+ * concepts themselves under its names), each with its level's detail for
141
+ * dataModelDiagram (`entity[level]`), meaning what the context says of its
142
+ * concepts, and `details` (shown on hover): the schema entity behind a
143
+ * renamed concept, the concepts a logical entity or table realises, the
144
+ * context and the rules on them. Relationships: the level's, between shown
145
+ * entities. Annotations: the context's rules, each a note on what realises
146
+ * the concepts it applies to. Links: none (mappingView has them).
147
+ * @param {{perspective?: string|null, context?: string|null, level?: string}} [choice]
148
+ * @returns {{entities, relationships, annotations, links}}
149
+ */
150
+ view({ perspective = null, context = null, level = 'conceptual' } = {}) {
151
+ if (level === DataModel.MAPPING) return this.mappingView({ perspective, context });
152
+ const lens = this.perspective(perspective);
153
+ const meaning = this.context(context);
154
+ const schema = this.level(level);
155
+ const allowed = this.realising(level, lens ? this.concepts.filter(c => lens.shows(c.id)).map(c => c.id) : null);
156
+ const shown = schema.entities.filter(e => !allowed || allowed.has(e.id));
157
+ const ids = new Set(shown.map(e => e.id));
158
+ const conceptName = (id) => {
159
+ const concept = this.level('conceptual').entity(id);
160
+ return concept ? (lens ? lens.nameOf(concept) : concept.name) : id;
161
+ };
162
+ const entities = shown.map(entity => {
163
+ const concepts = this.conceptsOf(level, entity.id);
164
+ const name = level === 'conceptual' && lens ? lens.nameOf(entity) : entity.name;
165
+ 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)))] : [];
167
+ const details = {
168
+ ...entity.details,
169
+ ...(name !== entity.name && { schema: entity.name }),
170
+ ...(level !== 'conceptual' && concepts.length && { concepts: concepts.map(conceptName).join(', ') }),
171
+ ...(meaning && { context: meaning.name }),
172
+ ...(rules.length && { rules }),
173
+ };
174
+ const { attributes, ...rest } = entity;
175
+ return {
176
+ ...rest,
177
+ name,
178
+ description: meanings.length ? meanings.join(' ') : entity.description,
179
+ ...(level !== 'conceptual' && { [level]: { attributes: attributes ?? [], ...(level === 'physical' && { tableName: entity.name }) } }),
180
+ ...(Object.keys(details).length && { details }),
181
+ };
182
+ });
183
+ const relationships = schema.relationships
184
+ .filter(r => ids.has(r.source) && ids.has(r.target))
185
+ .map(r => (lens && level === 'conceptual' ? { ...r, name: lens.relationshipNameOf(r) } : r));
186
+ const notes = (meaning?.constraints ?? [])
187
+ .map(c => {
188
+ 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);
192
+ // A note with no place on this level goes in a row below every box, so it covers none.
193
+ const below = Math.max(0, ...shown.map(e => (Number.isFinite(e.y) ? e.y : 0))) + DataModel.NOTE_ROW;
194
+ let free = 0;
195
+ const annotations = notes.map(a => (Number.isFinite(a.x) ? a : { ...a, x: 130 + DataModel.NOTE_SPACING * free++, y: below }));
196
+ return { entities, relationships, annotations, links: [] };
197
+ }
198
+
199
+ /**
200
+ * How the levels map: a column per level (conceptual, logical, physical),
201
+ * each entity the perspective shows drawn at its own level (`level` on it,
202
+ * its id 'level:id'), and a link for each mapping between shown ones.
203
+ * A box sits where it was left (`mappingLayout`), else down its column.
204
+ */
205
+ mappingView({ perspective = null, context = null } = {}) {
206
+ const columns = LEVELS.map(level => [level, this.view({ perspective, context, level })]);
207
+ const entities = columns.flatMap(([level, { entities: shown }], column) => {
208
+ let y = 60;
209
+ return shown.map(entity => {
210
+ const key = `${level}:${entity.id}`;
211
+ const height = DataModel.boxHeight(level, entity);
212
+ const place = this.mappingLayout[key] ?? { x: 120 + column * DataModel.MAPPING_COLUMN, y: y + height / 2 };
213
+ y += height + DataModel.MAPPING_GAP;
214
+ return { ...entity, id: key, level, x: place.x, y: place.y };
215
+ });
216
+ });
217
+ const shown = new Set(entities.map(e => e.id));
218
+ const links = this.mappings.flatMap(m => m.pairs
219
+ .map(([a, b]) => ({ id: `map:${m.from}:${a}->${m.to}:${b}`, source: `${m.from}:${a}`, target: `${m.to}:${b}` }))
220
+ .filter(l => shown.has(l.source) && shown.has(l.target)));
221
+ return { entities, relationships: [], annotations: [], links };
222
+ }
223
+
224
+ /** Where a rule's note is on a level: on the conceptual level the rule's own place, else the level's note layout; {} for none. */
225
+ notePlace(level, constraint) {
226
+ const at = level === 'conceptual' ? constraint : this.noteLayout[level]?.[constraint.id];
227
+ return Number.isFinite(at?.x) ? { x: at.x, y: at.y } : {};
228
+ }
229
+
230
+ /** Keep where a rule's note was left on a level (notePlace reads it back). */
231
+ placeNote(level, constraint, { x, y }) {
232
+ if (level === 'conceptual') Object.assign(constraint, { x, y });
233
+ else (this.noteLayout[level] ??= {})[constraint.id] = { x, y };
234
+ }
235
+
236
+ /** How tall an entity's box is at a level (a table grows with its columns), to stack a column. */
237
+ static boxHeight(level, entity) {
238
+ const attributes = entity[level]?.attributes ?? [];
239
+ return attributes.length ? NodeGeometry.tableNodeSize({ columns: attributes }).h : 56;
240
+ }
241
+
242
+ // ===== Perspectives and contexts of one's own =====
243
+
244
+ /** Add a perspective (DataModelPerspective spec; an id is made from its name when it has none). */
245
+ addPerspective(spec) {
246
+ const perspective = new DataModelPerspective({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
247
+ this.perspectives.push(perspective);
248
+ return perspective;
249
+ }
250
+
251
+ /** Change a perspective (its id stays). */
252
+ updatePerspective(id, spec) {
253
+ const i = this.perspectives.findIndex(p => p.id === id);
254
+ if (i < 0) return null;
255
+ return (this.perspectives[i] = new DataModelPerspective({ ...spec, id }));
256
+ }
257
+
258
+ removePerspective(id) {
259
+ this.perspectives = this.perspectives.filter(p => p.id !== id);
260
+ }
261
+
262
+ /** Add a context (DataModelContext spec; an id is made from its name when it has none). */
263
+ addContext(spec) {
264
+ const context = new DataModelContext({ ...spec, id: spec.id ?? this._freshId(spec.name, this.contexts) });
265
+ this.contexts.push(context);
266
+ return context;
267
+ }
268
+
269
+ /** Change a context (its id stays). */
270
+ updateContext(id, spec) {
271
+ const i = this.contexts.findIndex(c => c.id === id);
272
+ if (i < 0) return null;
273
+ return (this.contexts[i] = new DataModelContext({ ...spec, id }));
274
+ }
275
+
276
+ /** Remove a context; a perspective read in it is then read in none. */
277
+ removeContext(id) {
278
+ this.contexts = this.contexts.filter(c => c.id !== id);
279
+ for (const p of this.perspectives) if (p.context === id) p.context = null;
280
+ }
281
+
282
+ /** An id from a name, not yet among `taken`. @private */
283
+ _freshId(name, taken) {
284
+ const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
285
+ let id = base;
286
+ for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
287
+ return id;
288
+ }
289
+
290
+ toJSON() {
291
+ return {
292
+ levels: Object.fromEntries(LEVELS.map(level => [level, this.level(level).toJSON()])),
293
+ mappings: this.mappings.map(m => m.toJSON()),
294
+ mappingLayout: { ...this.mappingLayout },
295
+ noteLayout: Object.fromEntries(Object.entries(this.noteLayout).map(([level, at]) => [level, { ...at }])),
296
+ perspectives: this.perspectives.map(p => p.toJSON()), contexts: this.contexts.map(c => c.toJSON()),
297
+ };
298
+ }
299
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * DataModelContext — the semantic environment a data model is read in: what
3
+ * its entities mean here (`meanings`, by entity id: "Account" in banking is
4
+ * not "Account" in a CRM), the rules that hold here (`constraints`, each on
5
+ * some entities), and where and when that applies (`jurisdiction`,
6
+ * `validFrom`, `validTo`). The schema stays as it is; a context gives it
7
+ * meaning, scope and constraints.
8
+ */
9
+ export class DataModelContext {
10
+ /**
11
+ * @param {Object} spec
12
+ * @param {string} spec.id
13
+ * @param {string} [spec.name]
14
+ * @param {string} [spec.description]
15
+ * @param {Object<string, string>} [spec.meanings] - What an entity means here, by entity id
16
+ * @param {Array<{id?: string, text: string, applies?: string[], x?: number, y?: number}>} [spec.constraints]
17
+ * Rules that hold here, each on the entities it `applies` to (drawn as notes attached to them)
18
+ * @param {string} [spec.jurisdiction]
19
+ * @param {string} [spec.validFrom] - ISO date
20
+ * @param {string} [spec.validTo] - ISO date
21
+ */
22
+ constructor({ id, name = id, description = '', meanings = {}, constraints = [], jurisdiction = '', validFrom = '', validTo = '' } = {}) {
23
+ if (!id) throw new TypeError('DataModelContext must have an id');
24
+ this.id = id;
25
+ this.name = name;
26
+ this.description = description;
27
+ this.meanings = { ...meanings };
28
+ this.constraints = constraints.map((c, i) => ({ ...c, id: c.id ?? `${id}-rule-${i + 1}`, applies: [...(c.applies ?? [])] }));
29
+ this.jurisdiction = jurisdiction;
30
+ this.validFrom = validFrom;
31
+ this.validTo = validTo;
32
+ }
33
+
34
+ /** What an entity means here, or null (then its schema description holds). */
35
+ meaningOf(entityId) {
36
+ return this.meanings[entityId] ?? null;
37
+ }
38
+
39
+ /** The rules that hold here on an entity. */
40
+ constraintsOn(entityId) {
41
+ return this.constraints.filter(c => c.applies.includes(entityId));
42
+ }
43
+
44
+ /** Where and when this context applies, in a few words ('AU · from 2024-07-01'), or ''. */
45
+ get scope() {
46
+ const when = this.validFrom && this.validTo ? `${this.validFrom} – ${this.validTo}`
47
+ : this.validFrom ? `from ${this.validFrom}`
48
+ : this.validTo ? `until ${this.validTo}` : '';
49
+ return [this.jurisdiction, when].filter(Boolean).join(' · ');
50
+ }
51
+
52
+ /**
53
+ * The fields of a dialog making or changing a context of `model`
54
+ * (FormDialog fields): its name, description, jurisdiction and dates, what
55
+ * each concept means in it, and its rules, one a line: the rule, then
56
+ * after '|' the concepts it applies to.
57
+ * @param {DataModel} model
58
+ * @param {DataModelContext|null} [context] - The one changed; none for a new one
59
+ */
60
+ static fields(model, context = null) {
61
+ const concepts = model.concepts;
62
+ const nameOf = (id) => model.level('conceptual').entity(id)?.name ?? id;
63
+ return [
64
+ { key: 'name', label: 'Name', type: 'text', value: context?.name ?? '' },
65
+ { key: 'description', label: 'Description', type: 'textarea', rows: 2, value: context?.description ?? '' },
66
+ { key: 'jurisdiction', label: 'Jurisdiction', type: 'text', value: context?.jurisdiction ?? '' },
67
+ { key: 'validFrom', label: 'Valid from', type: 'date', value: context?.validFrom ?? '' },
68
+ { key: 'validTo', label: 'Valid to', type: 'date', value: context?.validTo ?? '' },
69
+ { type: 'heading', label: 'Means here' },
70
+ ...concepts.map(c => ({ key: `meaning:${c.id}`, label: c.name, type: 'textarea', rows: 2, value: context?.meanings[c.id] ?? '', placeholder: 'as the schema describes it' })),
71
+ { type: 'heading', label: 'Rules' },
72
+ {
73
+ key: 'rules', label: '', type: 'textarea', rows: 5,
74
+ value: (context?.constraints ?? []).map(c => `${c.text} | ${c.applies.map(nameOf).join(', ')}`).join('\n'),
75
+ hint: 'One a line: the rule | the concepts it applies to (by name)',
76
+ },
77
+ ];
78
+ }
79
+
80
+ /**
81
+ * A context's spec from those fields' values. A rule kept from `context`
82
+ * (the same text) keeps its id and place; a concept is named by name or id.
83
+ */
84
+ static specFrom(values, model, context = null) {
85
+ const concepts = model.concepts;
86
+ const conceptOf = (word) => concepts.find(c => c.id === word || c.name.toLowerCase() === word.toLowerCase())?.id ?? null;
87
+ const meanings = Object.fromEntries(concepts.map(c => [c.id, (values[`meaning:${c.id}`] ?? '').trim()]).filter(([, text]) => text));
88
+ const constraints = String(values.rules ?? '').split('\n').map(line => line.trim()).filter(Boolean).map(line => {
89
+ const [text, on = ''] = line.split('|').map(part => part.trim());
90
+ const applies = on.split(',').map(w => w.trim()).filter(Boolean).map(conceptOf).filter(Boolean);
91
+ const kept = context?.constraints.find(c => c.text === text);
92
+ return { text, applies, ...(kept && { id: kept.id, ...(Number.isFinite(kept.x) && { x: kept.x, y: kept.y }) }) };
93
+ });
94
+ return {
95
+ name: (values.name ?? '').trim(), description: (values.description ?? '').trim(),
96
+ jurisdiction: (values.jurisdiction ?? '').trim(), validFrom: values.validFrom ?? '', validTo: values.validTo ?? '',
97
+ meanings, constraints,
98
+ };
99
+ }
100
+
101
+ toJSON() {
102
+ return {
103
+ id: this.id, name: this.name, description: this.description, meanings: { ...this.meanings },
104
+ constraints: this.constraints.map(c => ({ ...c, applies: [...c.applies] })),
105
+ jurisdiction: this.jurisdiction, validFrom: this.validFrom, validTo: this.validTo,
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * DataModelPerspective — how one stakeholder sees a data model: a lens on the
3
+ * schema, not another schema. It shows some of the entities (`entities`; all
4
+ * when unset) under the names that stakeholder uses (`names`: Party is the
5
+ * Customer, Ledger the Billing), with relationships renamed too
6
+ * (`relationshipNames`, by relationship id), and names the context it is
7
+ * usually read in (`context`, a DataModelContext id).
8
+ */
9
+ export class DataModelPerspective {
10
+ /**
11
+ * @param {Object} spec
12
+ * @param {string} spec.id
13
+ * @param {string} [spec.name]
14
+ * @param {string} [spec.description]
15
+ * @param {string[]|null} [spec.entities] - The entity ids it shows; null: every one
16
+ * @param {Object<string, string>} [spec.names] - An entity's name here, by entity id
17
+ * @param {Object<string, string>} [spec.relationshipNames] - A relationship's name here, by relationship id
18
+ * @param {string|null} [spec.context] - The context it is read in by default
19
+ */
20
+ constructor({ id, name = id, description = '', entities = null, names = {}, relationshipNames = {}, context = null } = {}) {
21
+ if (!id) throw new TypeError('DataModelPerspective must have an id');
22
+ this.id = id;
23
+ this.name = name;
24
+ this.description = description;
25
+ this.entities = entities ? [...entities] : null;
26
+ this.names = { ...names };
27
+ this.relationshipNames = { ...relationshipNames };
28
+ this.context = context;
29
+ }
30
+
31
+ /** Whether it shows an entity. */
32
+ shows(entityId) {
33
+ return !this.entities || this.entities.includes(entityId);
34
+ }
35
+
36
+ /** What an entity is called here: its name in this perspective, else the schema's. */
37
+ nameOf(entity) {
38
+ return this.names[entity.id] ?? entity.name;
39
+ }
40
+
41
+ /** What a relationship is called here. */
42
+ relationshipNameOf(relationship) {
43
+ return (relationship.id && this.relationshipNames[relationship.id]) ?? relationship.name;
44
+ }
45
+
46
+ /**
47
+ * The fields of a dialog making or changing a perspective of `model`
48
+ * (FormDialog fields): its name, description, the context it is read in,
49
+ * the concepts it shows, and what it calls each.
50
+ * @param {DataModel} model
51
+ * @param {DataModelPerspective|null} [perspective] - The one changed; none for a new one
52
+ */
53
+ static fields(model, perspective = null) {
54
+ const concepts = model.concepts;
55
+ return [
56
+ { key: 'name', label: 'Name', type: 'text', value: perspective?.name ?? '' },
57
+ { key: 'description', label: 'Whose view', type: 'textarea', rows: 2, value: perspective?.description ?? '' },
58
+ { key: 'context', label: 'Read in', type: 'select', value: perspective?.context ?? '', choices: [['', 'No context'], ...model.contexts.map(c => [c.id, c.name])] },
59
+ { type: 'heading', label: 'Shows' },
60
+ { key: 'entities', label: '', type: 'checklist', value: perspective?.entities ?? concepts.map(c => c.id), choices: concepts.map(c => [c.id, c.name]) },
61
+ { type: 'heading', label: 'Called here' },
62
+ ...concepts.map(c => ({ key: `name:${c.id}`, label: c.name, type: 'text', value: perspective?.names[c.id] ?? '', placeholder: 'as in the schema' })),
63
+ ];
64
+ }
65
+
66
+ /** A perspective's spec from those fields' values (all shown: `entities` null; names left blank: none). */
67
+ static specFrom(values, model) {
68
+ const ids = model.concepts.map(c => c.id);
69
+ const shown = values.entities ?? ids;
70
+ const names = Object.fromEntries(ids.map(id => [id, (values[`name:${id}`] ?? '').trim()]).filter(([, name]) => name));
71
+ return {
72
+ name: (values.name ?? '').trim(), description: (values.description ?? '').trim(), context: values.context || null,
73
+ entities: shown.length === ids.length ? null : [...shown], names,
74
+ };
75
+ }
76
+
77
+ toJSON() {
78
+ return {
79
+ id: this.id, name: this.name, description: this.description,
80
+ entities: this.entities ? [...this.entities] : null,
81
+ names: { ...this.names }, relationshipNames: { ...this.relationshipNames }, context: this.context,
82
+ };
83
+ }
84
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * LevelMapping — which entities of one level realise which of another
3
+ * (conceptual → logical, logical → physical): pairs of ids, any number either
4
+ * way, so one concept may become several logical entities (Party → Person,
5
+ * Organisation), several one table (Person, Organisation → party), or many
6
+ * many.
7
+ */
8
+ export class LevelMapping {
9
+ /**
10
+ * @param {{from: string, to: string, pairs?: Array<[string, string]>}} spec
11
+ * `from`/`to` levels; each pair [id at `from`, id at `to`]
12
+ */
13
+ constructor({ from, to, pairs = [] } = {}) {
14
+ if (!from || !to) throw new TypeError('LevelMapping needs the levels it maps, from and to');
15
+ this.from = from;
16
+ this.to = to;
17
+ this.pairs = pairs.map(([a, b]) => [a, b]);
18
+ }
19
+
20
+ /** What realises an entity of `from` at `to`. */
21
+ targetsOf(id) {
22
+ return this.pairs.filter(([a]) => a === id).map(([, b]) => b);
23
+ }
24
+
25
+ /** What an entity of `to` realises at `from`. */
26
+ sourcesOf(id) {
27
+ return this.pairs.filter(([, b]) => b === id).map(([a]) => a);
28
+ }
29
+
30
+ /** Map one more pair (none twice). */
31
+ add(fromId, toId) {
32
+ if (!this.pairs.some(([a, b]) => a === fromId && b === toId)) this.pairs.push([fromId, toId]);
33
+ }
34
+
35
+ toJSON() {
36
+ return { from: this.from, to: this.to, pairs: this.pairs.map(p => [...p]) };
37
+ }
38
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * LevelSchema — one level of a data model (conceptual, logical, physical) as
3
+ * its own structure: its entities (a concept; a logical entity with its
4
+ * attributes and keys; a table with its columns and types) and the
5
+ * relationships between them. The levels are not one-to-one: a concept may be
6
+ * realised by several logical entities, several by one table (LevelMapping
7
+ * says which).
8
+ *
9
+ * An entity: `{ id, name, description?, attributes?, x?, y?, color?, shape?,
10
+ * labelPosition? }` — `attributes` `[{name, type?, isPK?, isFK?}]` (none at the
11
+ * conceptual level); a relationship as dataModelDiagram takes it.
12
+ */
13
+ export class LevelSchema {
14
+ /**
15
+ * @param {string} level - 'conceptual', 'logical' or 'physical'
16
+ * @param {{entities?: Array<Object>, relationships?: Array<Object>}} [spec]
17
+ */
18
+ constructor(level, { entities = [], relationships = [] } = {}) {
19
+ if (!level) throw new TypeError('LevelSchema needs its level');
20
+ this.level = level;
21
+ this.entities = entities;
22
+ this.relationships = relationships;
23
+ }
24
+
25
+ /** An entity by id, or null. */
26
+ entity(id) {
27
+ return this.entities.find(e => e.id === id) ?? null;
28
+ }
29
+
30
+ /** Add an entity (its id unique at this level). */
31
+ addEntity(entity) {
32
+ if (!entity?.id) throw new TypeError('An entity needs an id');
33
+ if (this.entity(entity.id)) throw new TypeError(`The ${this.level} level already has "${entity.id}"`);
34
+ this.entities.push(entity);
35
+ return entity;
36
+ }
37
+
38
+ toJSON() {
39
+ return { entities: this.entities, relationships: this.relationships };
40
+ }
41
+ }
@@ -0,0 +1,5 @@
1
+ export { DataModel } from './DataModel.js';
2
+ export { DataModelPerspective } from './DataModelPerspective.js';
3
+ export { DataModelContext } from './DataModelContext.js';
4
+ export { LevelSchema } from './LevelSchema.js';
5
+ export { LevelMapping } from './LevelMapping.js';