@peter.prib/renderengine 0.2.5 → 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.
- package/README.md +215 -213
- package/REFERENCE.md +94 -6
- package/dist/renderengine-workspace.umd.js +3254 -2089
- package/dist/renderengine.umd.js +3016 -2086
- package/examples/data-model.html +324 -0
- package/package.json +70 -68
- package/src/charting/CheckboxTreeSelector.js +373 -304
- package/src/charting/DiagramEditor.js +48 -6
- package/src/charting/DiagramTypeRegistry.js +5 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
- package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
- package/src/charting/datamodel/DataModel.js +299 -0
- package/src/charting/datamodel/DataModelContext.js +108 -0
- package/src/charting/datamodel/DataModelPerspective.js +84 -0
- package/src/charting/datamodel/LevelMapping.js +38 -0
- package/src/charting/datamodel/LevelSchema.js +41 -0
- package/src/charting/datamodel/index.js +5 -0
- package/src/charting/diagrams/DataModelDiagram.js +160 -86
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +86 -38
- package/src/charting/diagrams/nesting.js +31 -0
- package/src/charting/modelTree/ModelTree.js +202 -164
- package/src/charting/modelTree/ModelTreePanel.js +36 -3
- package/src/charting/modelTree/TreeViewMode.js +131 -110
- package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
- package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
- package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
- package/src/datamodel.js +132 -122
- package/src/diagramCatalog.js +35 -30
- package/src/index.js +6 -1
- package/src/workspace/index.js +1 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +5 -2
- package/src/workspace/parts/ChartPart.js +6 -1
- 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';
|