@peter.prib/renderengine 0.2.11 → 0.4.0
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 +22 -6
- package/REFERENCE.md +117 -2
- package/dist/renderengine-workspace.umd.js +2973 -1203
- package/dist/renderengine.umd.js +1178 -158
- package/package.json +1 -1
- package/src/charting/ModelCheckDialog.js +10 -3
- package/src/charting/ModelComparisonDialog.js +10 -3
- package/src/charting/ModelFileFormat.js +4 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
- package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
- package/src/charting/arrangement/layouts.js +9 -4
- package/src/charting/datamodel/DataModel.js +18 -71
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/diagramModel/ListCompared.js +35 -0
- package/src/charting/model/Benchmark.js +59 -0
- package/src/charting/model/EditableModel.js +104 -0
- package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
- package/src/charting/requirements/RequirementsChecker.js +76 -0
- package/src/charting/requirements/RequirementsCompared.js +33 -0
- package/src/charting/requirements/RequirementsFileFormat.js +32 -0
- package/src/charting/requirements/RequirementsImportFormats.js +48 -0
- package/src/charting/requirements/RequirementsModel.js +305 -0
- package/src/charting/requirements/Storyboard.js +94 -0
- package/src/charting/requirements/TrackerExport.js +273 -0
- package/src/charting/requirements/index.js +7 -0
- package/src/index.js +6 -0
- package/src/workspace/Workspace.js +329 -321
- package/src/workspace/index.js +2 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
- package/src/workspace/parts/DataModelPart.js +668 -735
- package/src/workspace/parts/EditorPart.js +1 -1
- package/src/workspace/parts/ModelFile.js +3 -1
- package/src/workspace/parts/ModelHosted.js +176 -0
- package/src/workspace/parts/ModelsPart.js +146 -7
- package/src/workspace/parts/RequirementsPart.js +465 -0
- package/src/workspace/storage/ModelLibrary.js +8 -2
package/package.json
CHANGED
|
@@ -5,7 +5,9 @@ import { FormDialog } from './FormDialog.js';
|
|
|
5
5
|
* ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
|
|
6
6
|
* each a button (⚠ a warning, ℹ for a look) that shows what it names
|
|
7
7
|
* (`onReveal`), with what can be done about it beside (`actionsFor`), or
|
|
8
|
-
* "nothing found". Any editor or part that checks a model shows it this way
|
|
8
|
+
* "nothing found". Any editor or part that checks a model shows it this way;
|
|
9
|
+
* anything else listed to be opened (search results) too, with its own
|
|
10
|
+
* `title` and `empty` text.
|
|
9
11
|
*/
|
|
10
12
|
export class ModelCheckDialog {
|
|
11
13
|
/**
|
|
@@ -14,9 +16,13 @@ export class ModelCheckDialog {
|
|
|
14
16
|
* @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
|
|
15
17
|
* @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
|
|
16
18
|
* @param {Document} [options.document]
|
|
19
|
+
* @param {string} [options.title] - In place of "Model check: n findings"
|
|
20
|
+
* @param {string} [options.empty] - What an empty list says
|
|
17
21
|
*/
|
|
18
|
-
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
|
|
22
|
+
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
|
|
19
23
|
this.findings = findings;
|
|
24
|
+
this._title = title;
|
|
25
|
+
this.empty = empty;
|
|
20
26
|
this.onReveal = onReveal;
|
|
21
27
|
this.actionsFor = actionsFor;
|
|
22
28
|
this.document = doc;
|
|
@@ -24,6 +30,7 @@ export class ModelCheckDialog {
|
|
|
24
30
|
|
|
25
31
|
/** The dialog's title: how many findings. */
|
|
26
32
|
get title() {
|
|
33
|
+
if (this._title) return this._title;
|
|
27
34
|
const n = this.findings.length;
|
|
28
35
|
return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
|
|
29
36
|
}
|
|
@@ -49,7 +56,7 @@ export class ModelCheckDialog {
|
|
|
49
56
|
on: { click: () => { dialog?.close(); act.action(); } },
|
|
50
57
|
})),
|
|
51
58
|
],
|
|
52
|
-
})) : [{ text:
|
|
59
|
+
})) : [{ text: this.empty }],
|
|
53
60
|
}, this.document);
|
|
54
61
|
dialog = new FormDialog({
|
|
55
62
|
title: this.title,
|
|
@@ -26,16 +26,23 @@ export class ModelCompareDialog {
|
|
|
26
26
|
* @param {string} options.mineName - What the model compared is called
|
|
27
27
|
* @param {string} options.theirsName - What the other is called
|
|
28
28
|
* @param {() => void} [options.onMerge] - Merge from the other (offered when there is something to merge)
|
|
29
|
+
* @param {string} [options.note] - Said above the differences: why the other is offered (a proposal's rationale)
|
|
29
30
|
* @param {Document} [options.document]
|
|
30
31
|
*/
|
|
31
|
-
constructor({ comparison, mineName, theirsName, onMerge = null, document: doc = globalThis.document }) {
|
|
32
|
+
constructor({ comparison, mineName, theirsName, onMerge = null, note = '', document: doc = globalThis.document }) {
|
|
32
33
|
this.comparison = comparison;
|
|
33
34
|
this.mineName = mineName;
|
|
34
35
|
this.theirsName = theirsName;
|
|
35
36
|
this.onMerge = onMerge;
|
|
37
|
+
this.note = note;
|
|
36
38
|
this.document = doc;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/** The dialog's description: the note, then `text`. */
|
|
42
|
+
described(text) {
|
|
43
|
+
return this.note ? `${this.note}\n\n${text}` : text;
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
get title() {
|
|
40
47
|
return `Compare "${this.mineName}" with "${this.theirsName}"`;
|
|
41
48
|
}
|
|
@@ -74,7 +81,7 @@ export class ModelCompareDialog {
|
|
|
74
81
|
const canMerge = !!this.onMerge && this.comparison.mergeable().length > 0;
|
|
75
82
|
return new FormDialog({
|
|
76
83
|
title: this.title,
|
|
77
|
-
description: this.summaryText(),
|
|
84
|
+
description: this.described(this.summaryText()),
|
|
78
85
|
fields: this.fields(),
|
|
79
86
|
submitLabel: canMerge ? 'Merge…' : 'Close',
|
|
80
87
|
cancelLabel: 'Close',
|
|
@@ -107,7 +114,7 @@ export class ModelMergeDialog extends ModelCompareDialog {
|
|
|
107
114
|
const nothing = !this.comparison.mergeable().length;
|
|
108
115
|
return new FormDialog({
|
|
109
116
|
title: this.title,
|
|
110
|
-
description: nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too
|
|
117
|
+
description: this.described(nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`),
|
|
111
118
|
fields: this.fields(),
|
|
112
119
|
submitLabel: nothing ? 'Close' : 'Merge',
|
|
113
120
|
idPrefix: 'model-merge-',
|
|
@@ -28,6 +28,9 @@ export class ModelFileFormat {
|
|
|
28
28
|
/** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
|
|
29
29
|
get imports() { return true; }
|
|
30
30
|
|
|
31
|
+
/** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
|
|
32
|
+
get exports() { return true; }
|
|
33
|
+
|
|
31
34
|
/** True when import takes the file's bytes (a zip archive), else its text. */
|
|
32
35
|
get readsBytes() { return false; }
|
|
33
36
|
|
|
@@ -87,7 +90,7 @@ export function modelFileMenuItems(formats, {
|
|
|
87
90
|
const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
|
|
88
91
|
const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
|
|
89
92
|
const groups = formats.map(format => [
|
|
90
|
-
only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
|
|
93
|
+
only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
|
|
91
94
|
only !== 'export' && format.imports && enabled(format, 'import') && {
|
|
92
95
|
label: only ? `${format.label}…` : `Import ${format.label}…`,
|
|
93
96
|
action: () => importFile(format, { choose, onImport, report: tell }),
|
|
@@ -77,8 +77,12 @@ export class ArchimateCanvasEditor extends DiagramEditor {
|
|
|
77
77
|
* elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
|
|
78
78
|
* @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
|
|
79
79
|
* the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
|
|
80
|
+
* @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
|
|
81
|
+
* live status for an element (a component's lifecycle state), drawn on its boxes
|
|
82
|
+
* @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
|
|
83
|
+
* element (its transitions), at the end of a box's right-click menu
|
|
80
84
|
*/
|
|
81
|
-
constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
|
|
85
|
+
constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
|
|
82
86
|
super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
|
|
83
87
|
this.view = view;
|
|
84
88
|
this.model = model;
|
|
@@ -91,6 +95,9 @@ export class ArchimateCanvasEditor extends DiagramEditor {
|
|
|
91
95
|
this.settingsManager = settingsManager;
|
|
92
96
|
/** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
|
|
93
97
|
this.templateMenu = templateMenu;
|
|
98
|
+
/** A host's live status per element, drawn on its boxes, and its menu items per element. */
|
|
99
|
+
this.elementStatus = elementStatus;
|
|
100
|
+
this.elementMenuExtras = elementMenuExtras;
|
|
94
101
|
this.addDropHandler({
|
|
95
102
|
accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
|
|
96
103
|
drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
|
|
@@ -128,6 +135,7 @@ export class ArchimateCanvasEditor extends DiagramEditor {
|
|
|
128
135
|
...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
|
|
129
136
|
...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
|
|
130
137
|
...(items.length ? [{ label: 'Save as template', items }] : []),
|
|
138
|
+
...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
|
|
131
139
|
];
|
|
132
140
|
}
|
|
133
141
|
|
|
@@ -352,7 +360,11 @@ export class ArchimateCanvasEditor extends DiagramEditor {
|
|
|
352
360
|
templates.set(inst.id, existing?.instance === inst ? existing
|
|
353
361
|
: new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
|
|
354
362
|
}
|
|
355
|
-
for (const t of templates.values())
|
|
363
|
+
for (const t of templates.values()) {
|
|
364
|
+
t.nodeOptions = this.nodeOptions;
|
|
365
|
+
// Asked afresh at every sync: a host's status is live, not part of the model.
|
|
366
|
+
t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
|
|
367
|
+
}
|
|
356
368
|
// The view's groups (Archi groups: model folders with bounds on this view).
|
|
357
369
|
for (const folder of view.placedFolders) {
|
|
358
370
|
const existing = this.templates.get(folder.id);
|
|
@@ -41,6 +41,8 @@ export class ArchimateElementTemplate extends GlyphTemplate {
|
|
|
41
41
|
this.instance = instance;
|
|
42
42
|
this.model = model;
|
|
43
43
|
this.nodeOptions = nodeOptions;
|
|
44
|
+
/** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
|
|
45
|
+
this.status = null;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** State lives on the instance and the model element, not here. @protected */
|
|
@@ -156,6 +158,27 @@ export class ArchimateElementTemplate extends GlyphTemplate {
|
|
|
156
158
|
* gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
|
|
157
159
|
*/
|
|
158
160
|
decorations(ctx) {
|
|
161
|
+
return [...this._badge(ctx), ...this.statusPill(ctx)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The host's status (`status`), as a small pill inside the box's bottom
|
|
166
|
+
* right corner: its text, on its colour. Nothing without one.
|
|
167
|
+
*/
|
|
168
|
+
statusPill({ w, h }) {
|
|
169
|
+
const text = this.status?.text;
|
|
170
|
+
if (!text) return [];
|
|
171
|
+
const label = String(text);
|
|
172
|
+
const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
|
|
173
|
+
const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
|
|
174
|
+
return [Glyph.group([
|
|
175
|
+
Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
|
|
176
|
+
Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
|
|
177
|
+
], { 'pointer-events': 'none', class: 'element-status' })];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The type badge (decorations). @private */
|
|
181
|
+
_badge(ctx) {
|
|
159
182
|
const node = this.node;
|
|
160
183
|
const { w, h } = ctx;
|
|
161
184
|
const fixed = ArchimateElementTemplate.badgeSize(w, h);
|
|
@@ -153,14 +153,19 @@ function tree(boxes, { edges, gap }, direction) {
|
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* Fruchterman–Reingold: lines pull their ends together, every pair of boxes
|
|
156
|
-
* pushes apart, cooling over the iterations
|
|
157
|
-
* where the boxes
|
|
156
|
+
* pushes apart, cooling over the iterations, all within a frame — a square
|
|
157
|
+
* round where the boxes were, room for them a grid's worth — so a box with no
|
|
158
|
+
* lines is not pushed away for ever. Deterministic: it starts from where the
|
|
159
|
+
* boxes are (boxes on one spot are spread round it first).
|
|
158
160
|
*/
|
|
159
161
|
function forceDirected(boxes, edges, gap) {
|
|
160
162
|
const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
|
|
161
163
|
const k = size * 1.2;
|
|
162
164
|
const pos = boxes.map((b, i) => ({ x: b.left + b.width / 2 + (i % 3) * 0.5, y: b.top + b.height / 2 + Math.floor(i / 3) * 0.5 }));
|
|
163
165
|
const index = new Map(boxes.map((b, i) => [b.id, i]));
|
|
166
|
+
const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
|
|
167
|
+
const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
|
|
168
|
+
const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
|
|
164
169
|
let temperature = size * 2;
|
|
165
170
|
for (let iteration = 0; iteration < 120; iteration++) {
|
|
166
171
|
const shift = pos.map(() => ({ x: 0, y: 0 }));
|
|
@@ -185,8 +190,8 @@ function forceDirected(boxes, edges, gap) {
|
|
|
185
190
|
for (let i = 0; i < pos.length; i++) {
|
|
186
191
|
const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
|
|
187
192
|
const step = Math.min(d, temperature);
|
|
188
|
-
pos[i].x
|
|
189
|
-
pos[i].y
|
|
193
|
+
pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
|
|
194
|
+
pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
|
|
190
195
|
}
|
|
191
196
|
temperature *= 0.95;
|
|
192
197
|
}
|
|
@@ -6,11 +6,10 @@ import './StructuredType.js';
|
|
|
6
6
|
import { LevelSchema } from './LevelSchema.js';
|
|
7
7
|
import { LevelMapping } from './LevelMapping.js';
|
|
8
8
|
import { NodeGeometry } from '../diagrams/NodeGeometry.js';
|
|
9
|
-
import { ModelComparison } from '../diagramModel/ModelComparison.js';
|
|
10
9
|
import { DataModelChecker } from './DataModelChecker.js';
|
|
11
|
-
import { CommandHistory } from '../CommandHistory.js';
|
|
12
10
|
import { MergeModelCommand } from '../diagramModel/MergeModelCommand.js';
|
|
13
|
-
import {
|
|
11
|
+
import { EditableModel } from '../model/EditableModel.js';
|
|
12
|
+
import { Benchmark } from '../model/Benchmark.js';
|
|
14
13
|
import { DataModelNotation } from '../../datamodel.js';
|
|
15
14
|
import { NamingStandard } from './naming/NamingStandard.js';
|
|
16
15
|
import { ClassificationScheme } from './ClassificationScheme.js';
|
|
@@ -38,11 +37,13 @@ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
|
|
|
38
37
|
* (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
|
|
39
38
|
* three levels mapped one to one.
|
|
40
39
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* keep it and ask before it is lost.
|
|
40
|
+
* What any model a part edits has — whether it changed since saved, its undo
|
|
41
|
+
* history, compare and merge — is EditableModel's.
|
|
44
42
|
*/
|
|
45
|
-
export class DataModel {
|
|
43
|
+
export class DataModel extends EditableModel {
|
|
44
|
+
/** What a snapshot puts back (EditableModel#restore). */
|
|
45
|
+
static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
|
|
46
|
+
|
|
46
47
|
/** The levels, outermost first. */
|
|
47
48
|
static LEVELS = LEVELS;
|
|
48
49
|
|
|
@@ -126,11 +127,7 @@ export class DataModel {
|
|
|
126
127
|
* @param {string} [spec.name] - What the model is called
|
|
127
128
|
*/
|
|
128
129
|
constructor(spec = {}) {
|
|
129
|
-
|
|
130
|
-
this.name = spec.name ?? '';
|
|
131
|
-
this._version = 0;
|
|
132
|
-
this._saved = 0;
|
|
133
|
-
this._editListeners = [];
|
|
130
|
+
super(spec);
|
|
134
131
|
const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
|
|
135
132
|
this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
|
|
136
133
|
this.mappings = LEVELS.slice(1).map((to, i) => {
|
|
@@ -157,6 +154,14 @@ export class DataModel {
|
|
|
157
154
|
return new ModelScorecard(this);
|
|
158
155
|
}
|
|
159
156
|
|
|
157
|
+
/** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
|
|
158
|
+
benchmark() {
|
|
159
|
+
return [
|
|
160
|
+
...Benchmark.findings(this),
|
|
161
|
+
{ key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
160
165
|
/** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
|
|
161
166
|
scorecardCommand(spec) {
|
|
162
167
|
return this.editCommand('Score the model', (model) => {
|
|
@@ -204,46 +209,6 @@ export class DataModel {
|
|
|
204
209
|
];
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
208
|
-
compareWith(other) {
|
|
209
|
-
return new ModelComparison(this, other);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Its undo history: a merge is one step on it. */
|
|
213
|
-
get history() {
|
|
214
|
-
return (this._history ??= new CommandHistory());
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ===== Changed since saved =====
|
|
218
|
-
|
|
219
|
-
/** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
|
|
220
|
-
edited() {
|
|
221
|
-
this._version++;
|
|
222
|
-
this._emitEdit();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** This state is the saved one (a file opened or saved). */
|
|
226
|
-
markSaved() {
|
|
227
|
-
this._saved = this._version;
|
|
228
|
-
this._emitEdit();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Whether it changed since markSaved. */
|
|
232
|
-
isDirty() {
|
|
233
|
-
return this._version !== this._saved;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
237
|
-
onEdit(fn) {
|
|
238
|
-
this._editListeners.push(fn);
|
|
239
|
-
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** @private */
|
|
243
|
-
_emitEdit() {
|
|
244
|
-
for (const fn of this._editListeners) fn(this);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
212
|
/** A level's schema (LevelSchema). */
|
|
248
213
|
level(name) {
|
|
249
214
|
const schema = this.levels[name];
|
|
@@ -459,13 +424,7 @@ export class DataModel {
|
|
|
459
424
|
|
|
460
425
|
// ===== Perspectives and contexts of one's own =====
|
|
461
426
|
|
|
462
|
-
// ===== Editing what a level holds (each
|
|
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
|
-
}
|
|
427
|
+
// ===== Editing what a level holds (each an editCommand: one undo step) =====
|
|
469
428
|
|
|
470
429
|
/** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
|
|
471
430
|
classificationSchemeCommand(spec) {
|
|
@@ -481,11 +440,6 @@ export class DataModel {
|
|
|
481
440
|
});
|
|
482
441
|
}
|
|
483
442
|
|
|
484
|
-
/** A change made as one undo step. */
|
|
485
|
-
editCommand(label, change) {
|
|
486
|
-
return new DataModelEditCommand(this, label, change);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
443
|
/**
|
|
490
444
|
* Add an entity at a level (`id` null: one made from its name) or change
|
|
491
445
|
* one (what the spec leaves out, where it is drawn among it, kept).
|
|
@@ -837,13 +791,6 @@ export class DataModel {
|
|
|
837
791
|
}
|
|
838
792
|
|
|
839
793
|
/** An id from a name, not yet among `taken`. @private */
|
|
840
|
-
_freshId(name, taken) {
|
|
841
|
-
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
842
|
-
let id = base;
|
|
843
|
-
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
844
|
-
return id;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
794
|
toJSON() {
|
|
848
795
|
return {
|
|
849
796
|
name: this.name,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ComparedKind } from '../diagramModel/ComparedKind.js';
|
|
2
|
+
import { ListCompared } from '../diagramModel/ListCompared.js';
|
|
2
3
|
|
|
3
4
|
/** A plain copy of JSON-shaped data. */
|
|
4
5
|
const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
@@ -11,37 +12,6 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin
|
|
|
11
12
|
* two models alike but for where their boxes are, are alike.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
/** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
|
|
15
|
-
class ListCompared extends ComparedKind {
|
|
16
|
-
constructor() {
|
|
17
|
-
super();
|
|
18
|
-
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** The array the model holds them in. @abstract */
|
|
22
|
-
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
23
|
-
|
|
24
|
-
items(model) { return this.list(model); }
|
|
25
|
-
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
26
|
-
|
|
27
|
-
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
28
|
-
bring(model, item) {
|
|
29
|
-
const list = this.list(model);
|
|
30
|
-
const twin = this.copyOf(item);
|
|
31
|
-
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
32
|
-
if (at < 0) {
|
|
33
|
-
list.push(twin);
|
|
34
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
35
|
-
}
|
|
36
|
-
const old = list[at];
|
|
37
|
-
list[at] = twin;
|
|
38
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** A copy to put in the other model. */
|
|
42
|
-
copyOf(item) { return copy(item); }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
15
|
/** One level's entities: compared on all but where they are drawn. */
|
|
46
16
|
export class LevelEntitiesCompared extends ListCompared {
|
|
47
17
|
constructor(level) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ComparedKind } from './ComparedKind.js';
|
|
2
|
+
|
|
3
|
+
/** A plain copy of JSON-shaped data. */
|
|
4
|
+
const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
5
|
+
|
|
6
|
+
/** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
|
|
7
|
+
export class ListCompared extends ComparedKind {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The array the model holds them in. @abstract */
|
|
14
|
+
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
15
|
+
|
|
16
|
+
items(model) { return this.list(model); }
|
|
17
|
+
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
18
|
+
|
|
19
|
+
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
20
|
+
bring(model, item) {
|
|
21
|
+
const list = this.list(model);
|
|
22
|
+
const twin = this.copyOf(item);
|
|
23
|
+
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
24
|
+
if (at < 0) {
|
|
25
|
+
list.push(twin);
|
|
26
|
+
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
27
|
+
}
|
|
28
|
+
const old = list[at];
|
|
29
|
+
list[at] = twin;
|
|
30
|
+
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A copy to put in the other model. */
|
|
34
|
+
copyOf(item) { return copy(item); }
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark — what a model is measured by, so a change to it (a proposal, a
|
|
3
|
+
* merge) says whether it made the model better. Each measure:
|
|
4
|
+
* `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
|
|
5
|
+
* (a share is 0..1; a value of null is not measured yet).
|
|
6
|
+
*
|
|
7
|
+
* Every model with a checker is measured by its findings — warnings counted
|
|
8
|
+
* apart (`Benchmark.findings`); a model with more to say gives its own
|
|
9
|
+
* `benchmark()` (a data model its Scorecard, a requirements model how many
|
|
10
|
+
* stories say when they are done and trace to something), starting from those.
|
|
11
|
+
*/
|
|
12
|
+
export class Benchmark {
|
|
13
|
+
/** A model's measures: its own benchmark(), else its checker's findings, else none. */
|
|
14
|
+
static of(model) {
|
|
15
|
+
if (typeof model?.benchmark === 'function') return model.benchmark();
|
|
16
|
+
return Benchmark.findings(model);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The check's warnings and all its findings, each fewer the better. */
|
|
20
|
+
static findings(model) {
|
|
21
|
+
if (typeof model?.checker !== 'function') return [];
|
|
22
|
+
const found = model.checker().run();
|
|
23
|
+
return [
|
|
24
|
+
{ key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
|
|
25
|
+
{ key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Two models' measures side by side, matched by key: `{key, label, format,
|
|
31
|
+
* before, after, change}` — change 'better', 'worse', 'same', or null when
|
|
32
|
+
* either is not measured.
|
|
33
|
+
*/
|
|
34
|
+
static compare(before, after) {
|
|
35
|
+
const then = new Map(before.map(m => [m.key, m]));
|
|
36
|
+
return after.filter(m => then.has(m.key)).map(m => {
|
|
37
|
+
const was = then.get(m.key).value, now = m.value;
|
|
38
|
+
const change = was == null || now == null ? null
|
|
39
|
+
: was === now ? 'same'
|
|
40
|
+
: (now < was) === (m.better === 'lower') ? 'better' : 'worse';
|
|
41
|
+
return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
|
|
46
|
+
static format(value, format = 'count') {
|
|
47
|
+
if (value == null) return '—';
|
|
48
|
+
if (format === 'share') return `${Math.round(value * 100)}%`;
|
|
49
|
+
if (format === 'score') return `${value} of 100`;
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
|
|
54
|
+
static text(rows) {
|
|
55
|
+
const moved = rows.filter(r => r.change !== 'same');
|
|
56
|
+
const shown = moved.length ? moved : rows;
|
|
57
|
+
return shown.map(r => `${r.label} ${Benchmark.format(r.before, r.format)} → ${Benchmark.format(r.after, r.format)}${r.change && r.change !== 'same' ? ` (${r.change})` : ''}`).join('; ');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CommandHistory } from '../CommandHistory.js';
|
|
2
|
+
import { ModelComparison } from '../diagramModel/ModelComparison.js';
|
|
3
|
+
import { ModelEditCommand } from './ModelEditCommand.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EditableModel — base of a model edited as a whole rather than view by view
|
|
7
|
+
* (DataModel, RequirementsModel; a ViewSetModel keeps its views' histories
|
|
8
|
+
* its own way). What a part needs of any model it edits, keeps and merges:
|
|
9
|
+
*
|
|
10
|
+
* - a name;
|
|
11
|
+
* - whether it changed since it was saved (`edited()`, `markSaved()`,
|
|
12
|
+
* `isDirty()`, `onEdit(fn)`), so a part shows it, keeps it and asks before
|
|
13
|
+
* it is lost;
|
|
14
|
+
* - an undo history, every edit one step on it (`editCommand(label, change)`:
|
|
15
|
+
* the model before and after, as its toJSON, put back by `restore`);
|
|
16
|
+
* - how it differs from another of its kind (`compareWith`, over the
|
|
17
|
+
* subclass's `comparedKinds()`), which merges the other's into it.
|
|
18
|
+
*
|
|
19
|
+
* A subclass gives `toJSON()`, its constructor reading that back, and
|
|
20
|
+
* `static STATE`: the fields `restore` puts back.
|
|
21
|
+
*/
|
|
22
|
+
export class EditableModel {
|
|
23
|
+
/** The fields a snapshot puts back (restore). A subclass lists its own, `name` among them. */
|
|
24
|
+
static STATE = Object.freeze(['name']);
|
|
25
|
+
|
|
26
|
+
/** @param {{name?: string}} [spec] */
|
|
27
|
+
constructor(spec = {}) {
|
|
28
|
+
if (new.target === EditableModel) throw new TypeError('EditableModel is abstract — subclass it');
|
|
29
|
+
/** What the model is called. */
|
|
30
|
+
this.name = spec.name ?? '';
|
|
31
|
+
this._version = 0;
|
|
32
|
+
this._saved = 0;
|
|
33
|
+
this._editListeners = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The kinds it is compared on and merged by (ComparedKind), in the order a merge brings them. None here. */
|
|
37
|
+
comparedKinds() {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
42
|
+
compareWith(other) {
|
|
43
|
+
return new ModelComparison(this, other);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Its undo history: a merge is one step on it. */
|
|
47
|
+
get history() {
|
|
48
|
+
return (this._history ??= new CommandHistory());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A change made as one undo step (ModelEditCommand), not yet run. */
|
|
52
|
+
editCommand(label, change) {
|
|
53
|
+
return new ModelEditCommand(this, label, change);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Put the model back as a snapshot (its toJSON) says: the fields its class lists (STATE). */
|
|
57
|
+
restore(json) {
|
|
58
|
+
const back = new this.constructor(JSON.parse(JSON.stringify(json)));
|
|
59
|
+
for (const key of this.constructor.STATE) this[key] = back[key];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ===== Changed since saved =====
|
|
63
|
+
|
|
64
|
+
/** Something changed: listeners hear of it. */
|
|
65
|
+
edited() {
|
|
66
|
+
this._version++;
|
|
67
|
+
this._emitEdit();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** This state is the saved one (a file opened or saved). */
|
|
71
|
+
markSaved() {
|
|
72
|
+
this._saved = this._version;
|
|
73
|
+
this._emitEdit();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether it changed since markSaved. */
|
|
77
|
+
isDirty() {
|
|
78
|
+
return this._version !== this._saved;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
82
|
+
onEdit(fn) {
|
|
83
|
+
this._editListeners.push(fn);
|
|
84
|
+
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @private */
|
|
88
|
+
_emitEdit() {
|
|
89
|
+
for (const fn of this._editListeners) fn(this);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An id made from `name`, unlike any of `taken`'s ids ('customer', 'customer-2'). */
|
|
93
|
+
_freshId(name, taken) {
|
|
94
|
+
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
95
|
+
let id = base;
|
|
96
|
+
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
97
|
+
return id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The model as plain JSON, read back by its constructor. @abstract */
|
|
101
|
+
toJSON() {
|
|
102
|
+
throw new Error(`${this.constructor.name} must implement toJSON()`);
|
|
103
|
+
}
|
|
104
|
+
}
|