@peter.prib/renderengine 0.2.10 → 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.
Files changed (39) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +123 -2
  3. package/dist/renderengine-workspace.umd.js +3015 -1221
  4. package/dist/renderengine.umd.js +1220 -176
  5. package/package.json +1 -1
  6. package/src/charting/ModelCheckDialog.js +10 -3
  7. package/src/charting/ModelComparisonDialog.js +10 -3
  8. package/src/charting/ModelFileFormat.js +4 -1
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
  10. package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
  11. package/src/charting/arrangement/layouts.js +9 -4
  12. package/src/charting/datamodel/DataModel.js +18 -71
  13. package/src/charting/datamodel/DataModelChecker.js +1 -5
  14. package/src/charting/datamodel/DataModelCompared.js +1 -31
  15. package/src/charting/datamodel/LevelSchema.js +23 -0
  16. package/src/charting/datamodel/SqlDdl.js +18 -13
  17. package/src/charting/diagramModel/ListCompared.js +35 -0
  18. package/src/charting/model/Benchmark.js +59 -0
  19. package/src/charting/model/EditableModel.js +104 -0
  20. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  21. package/src/charting/requirements/RequirementsChecker.js +76 -0
  22. package/src/charting/requirements/RequirementsCompared.js +33 -0
  23. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  24. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  25. package/src/charting/requirements/RequirementsModel.js +305 -0
  26. package/src/charting/requirements/Storyboard.js +94 -0
  27. package/src/charting/requirements/TrackerExport.js +273 -0
  28. package/src/charting/requirements/index.js +7 -0
  29. package/src/index.js +6 -0
  30. package/src/workspace/Workspace.js +329 -321
  31. package/src/workspace/index.js +2 -0
  32. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  33. package/src/workspace/parts/DataModelPart.js +668 -735
  34. package/src/workspace/parts/EditorPart.js +1 -1
  35. package/src/workspace/parts/ModelFile.js +3 -1
  36. package/src/workspace/parts/ModelHosted.js +176 -0
  37. package/src/workspace/parts/ModelsPart.js +146 -7
  38. package/src/workspace/parts/RequirementsPart.js +465 -0
  39. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -304,7 +304,7 @@ export class EditorPart extends Part {
304
304
  ...EditorPart.menuGroup('New', some(...this.newItems(), models['New model'], ...(this.modelsHost?.baseItems?.() ?? []))),
305
305
  { separator: true },
306
306
  ...some(open, models['Open… in a new tab'], ...saving, models['Duplicate model'], models['Close model']),
307
- ...EditorPart.menuGroup('This browser', this.modelsHost?.keepItems?.(this) ?? []),
307
+ ...EditorPart.menuGroup(this.modelsHost?.keptWhere ?? 'This browser', this.modelsHost?.keepItems?.(this) ?? []),
308
308
  { separator: true },
309
309
  ...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
310
310
  ...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
@@ -133,7 +133,9 @@ export class ModelFile {
133
133
  /** Open what `choose()` read: the part edits its model, from that file. Returns the model. */
134
134
  adopt({ model, name, format, handle = null }) {
135
135
  this.part.replaceModel(model);
136
- Object.assign(this, { name, format, handle });
136
+ // Read from another tool's export (a format that only reads): a new model, saved in the part's own format.
137
+ if (format && format.exports === false) Object.assign(this, { name: null, format: null, handle: null });
138
+ else Object.assign(this, { name, format, handle });
137
139
  this.showState();
138
140
  return model;
139
141
  }
@@ -0,0 +1,176 @@
1
+ import { ModelFile } from './ModelFile.js';
2
+ import { EditorPart } from './EditorPart.js';
3
+ import { modelFileMenuItems } from '../../charting/ModelFileFormat.js';
4
+ import { pickFile } from '../../charting/toolbar/pickFile.js';
5
+
6
+ /**
7
+ * ModelHosted — what a part that edits one model as a whole (an
8
+ * EditableModel: DataModelPart, RequirementsPart) needs to be one of a
9
+ * ModelsPart's models, as an EditorPart is: its file (ModelFile: Open…, Save,
10
+ * Save As…, • while changed, asking before the page is left), kept by the
11
+ * host (`keepId`, `keepEntry`), its File menu (New, Duplicate, Close model,
12
+ * where models are kept, Import of what other tools wrote), Undo and Redo,
13
+ * running a model-wide edit (a merge, a proposal taken) as one undo step,
14
+ * and reading a file into the model as one (readInto).
15
+ *
16
+ * A mixin, for parts on different bases (a ChartPart, a plain Part):
17
+ * `class DataModelPart extends ModelHosted(ChartPart)`. The part gives
18
+ * `fileFormats()` (its own first: what it saves as), and `afterModelEdit()`
19
+ * to show an edit (redraw).
20
+ */
21
+ export const ModelHosted = (Base) => class extends Base {
22
+ constructor(spec = {}) {
23
+ super(spec);
24
+ /** The file it is open from and saved to. */
25
+ this.file = new ModelFile(this);
26
+ /** Its id among the models kept by its host, or null. */
27
+ this.keepId = spec.keepId ?? null;
28
+ }
29
+
30
+ /** Its file formats: none here. @abstract */
31
+ fileFormats() {
32
+ return [];
33
+ }
34
+
35
+ /** What a new file is saved as: its first format. */
36
+ saveFormat() {
37
+ return this.fileFormats()[0];
38
+ }
39
+
40
+ /** The ModelsPart it is a tab of, or null. */
41
+ get modelsHost() {
42
+ return this.ctx?.models ?? null;
43
+ }
44
+
45
+ /** The page it is in. */
46
+ get document() {
47
+ return this.container?.ownerDocument ?? globalThis.document;
48
+ }
49
+
50
+ /** What its tab and the page title show: the file's name (or the model's), and • while changed. */
51
+ get fileLabel() {
52
+ return this.file.label;
53
+ }
54
+
55
+ /** The model as kept by its host (ModelFile#keepEntry). */
56
+ keepEntry() {
57
+ return this.file.keepEntry();
58
+ }
59
+
60
+ /** A model opened from a file: in a host's new tab, else in place of this one (as EditorPart does). */
61
+ onImported(model) {
62
+ return EditorPart.prototype.onImported.call(this, model);
63
+ }
64
+
65
+ /** The file or its saved state changed: a ModelsPart's tab follows it. */
66
+ onFileState() {
67
+ this.modelsHost?.fileStateChanged?.(this);
68
+ }
69
+
70
+ /** Edit `model`: as saved, and every change of it shows (• and the title) and is kept. @protected */
71
+ _adoptModel(model) {
72
+ this._unsubscribeEdits?.();
73
+ this.model = model;
74
+ model.markSaved();
75
+ this._unsubscribeEdits = model.onEdit(() => {
76
+ this.file.showState();
77
+ this.modelsHost?.modelEdited?.(this);
78
+ });
79
+ }
80
+
81
+ mount(host, ctx) {
82
+ const first = !this.mounted;
83
+ super.mount(host, ctx);
84
+ if (first) {
85
+ this.file.listen();
86
+ this.file.showState();
87
+ }
88
+ }
89
+
90
+ /** Show an edit of the model (a redraw): a subclass's. */
91
+ afterModelEdit() {}
92
+
93
+ /** Run a model-wide edit (a merge) as one undo step, then show it. */
94
+ runModelEdit(command) {
95
+ if (!command) return null;
96
+ this.model.history.execute(command);
97
+ this.afterModelEdit();
98
+ return command;
99
+ }
100
+
101
+ /**
102
+ * File ▸: Open…, Save, Save As…, Import of what other tools wrote (its
103
+ * formats that only read), then with a ModelsPart New model, Duplicate,
104
+ * Close model, and where models are kept ▸.
105
+ */
106
+ fileItems() {
107
+ const host = this.modelsHost;
108
+ return EditorPart.tidyMenu([
109
+ ...this.file.menuItems(),
110
+ ...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
111
+ ...(host ? [
112
+ { separator: true },
113
+ ...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
114
+ { label: 'Duplicate model', action: () => host.duplicate(this) },
115
+ { label: 'Close model', action: () => host.closeModel(this.id) },
116
+ ...EditorPart.menuGroup(host.keptWhere ?? 'This browser', host.keepItems?.(this) ?? []),
117
+ ] : []),
118
+ ]);
119
+ }
120
+
121
+ /** Import ▸: each format that only reads (another tool's export), opened as a new model. */
122
+ importItems() {
123
+ return modelFileMenuItems(this.fileFormats().filter(f => f.exports === false), {
124
+ model: () => this.model, only: 'import', onImport: (model) => this.onImported(model), document: () => this.document,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Ask for a file, make a command from its bytes, and run it as one undo
130
+ * step; a file that cannot be read, or brings nothing, is reported.
131
+ * @param {Object} how
132
+ * @param {string} how.accept - The file chooser's filter
133
+ * @param {string} how.what - What is read, for the report ('the landscape')
134
+ * @param {(bytes: Uint8Array, name: string) => Command|null} how.command
135
+ * @param {(name: string) => string} how.nothing - The report when the command is null
136
+ * @param {() => void} [how.then] - What to show once it is run (before the redraw)
137
+ * @param {(accept: string) => Promise<File|null>} [how.chooseFile]
138
+ * @param {(message: string) => void} [how.report]
139
+ * @returns {Promise<Command|null>}
140
+ */
141
+ async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
142
+ const doc = this.container?.ownerDocument ?? globalThis.document;
143
+ const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
144
+ const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
145
+ if (!file) return null;
146
+ let command;
147
+ try {
148
+ command = make(new Uint8Array(await file.arrayBuffer()), file.name);
149
+ } catch (err) {
150
+ tell(`Could not read ${what}: ${err.message}`);
151
+ return null;
152
+ }
153
+ if (!command) {
154
+ tell(nothing(file.name));
155
+ return null;
156
+ }
157
+ then();
158
+ this.runModelEdit(command);
159
+ return command;
160
+ }
161
+
162
+ /** Undo and Redo, each named by what it undoes (a merge is one step). */
163
+ undoItems() {
164
+ const history = this.model.history;
165
+ return [
166
+ { label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this.afterModelEdit(); } },
167
+ { label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this.afterModelEdit(); } },
168
+ ];
169
+ }
170
+
171
+ dispose() {
172
+ this._unsubscribeEdits?.();
173
+ this.file.restoreTitle();
174
+ super.dispose();
175
+ }
176
+ };
@@ -8,6 +8,7 @@ import { downloadBlob } from '../../exportImage.js';
8
8
  import { IndexedDbStore } from '../storage/KeyValueStore.js';
9
9
  import { ModelLibrary } from '../storage/ModelLibrary.js';
10
10
  import { BackupFolder } from '../storage/BackupFolder.js';
11
+ import { Benchmark } from '../../charting/model/Benchmark.js';
11
12
 
12
13
  /**
13
14
  * ModelsPart — several models open at once (as Archi's model tree holds
@@ -41,6 +42,23 @@ import { BackupFolder } from '../storage/BackupFolder.js';
41
42
  * closed open again next time. File ▸ This browser opens a kept model, stops
42
43
  * keeping one, backs them all up (BackupFolder: a folder chosen once, such as
43
44
  * one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
45
+ *
46
+ * Where models are kept is the host's to say: a `modelLibrary` among the
47
+ * workspace's services (`workspace.services`, reaching this part as
48
+ * `ctx.modelLibrary`) is used in place of the browser's — anything with
49
+ * ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
50
+ * `label` for the menu ('The engine'). An entry is the model in its editor's
51
+ * file format, so a host stores it as it likes: a database row, a file, a
52
+ * graph built from it. The backup folder stays in the browser.
53
+ *
54
+ * Proposals: a model offered as a change to the one in front — by an agent,
55
+ * a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
56
+ * each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
57
+ * is taken is chosen and is one undo step. Taking or dismissing one removes it.
58
+ *
59
+ * A host adds tools of its own for the model in front — an agent to run on
60
+ * it, say — as `modelTools(part, models)` among the workspace's services: its
61
+ * items end the Tools menu.
44
62
  */
45
63
  export class ModelsPart extends Part {
46
64
  static type = 'models';
@@ -58,6 +76,9 @@ export class ModelsPart extends Part {
58
76
  this.backups = null;
59
77
  /** Edits waiting to be kept, by part id. @private */
60
78
  this._pending = new Map();
79
+ /** Changes offered, not yet taken or dismissed (propose). */
80
+ this.proposals = [];
81
+ this._proposalSeq = 0;
61
82
  }
62
83
 
63
84
  /** What a copy's name ends with (copyName). */
@@ -104,8 +125,8 @@ export class ModelsPart extends Part {
104
125
  * @param {Array<EditorPart>} starting - The models the spec opened
105
126
  */
106
127
  async startKeeping(starting = []) {
107
- const store = await this.openStore();
108
- this.library = new ModelLibrary(store);
128
+ const store = await this.openStore(); // the backup folder's, whoever keeps the models
129
+ this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
109
130
  const win = this.container?.ownerDocument?.defaultView ?? globalThis;
110
131
  this.backups = new BackupFolder({
111
132
  store, window: win,
@@ -195,7 +216,7 @@ export class ModelsPart extends Part {
195
216
  async askOpenKept() {
196
217
  const open = new Set(this.models().map(p => p.keepId));
197
218
  const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
198
- if (!entries.length) { this.tell('Every model kept in this browser is open.'); return null; }
219
+ if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
199
220
  return new FormDialog({
200
221
  title: 'Open a kept model',
201
222
  fields: [{
@@ -234,7 +255,7 @@ export class ModelsPart extends Part {
234
255
  async backUpAndTell() {
235
256
  for (const part of this.models()) await this.keep(part);
236
257
  const files = await this.backupFiles();
237
- if (!files.length) { this.tell('Nothing is kept in this browser yet.'); return null; }
258
+ if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
238
259
  const { location, names } = await this.backups.backUp(files);
239
260
  this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
240
261
  return { location, names };
@@ -247,6 +268,83 @@ export class ModelsPart extends Part {
247
268
  return dir;
248
269
  }
249
270
 
271
+ /** Where models are kept, as the File menu names it: the library's label. */
272
+ get keptWhere() {
273
+ return this.library?.label ?? 'This browser';
274
+ }
275
+
276
+ // ===== Proposals =====
277
+
278
+ /**
279
+ * Offer a change: the model proposed — `spec`, a model as this workbench's
280
+ * editor takes it (as a base is given), or `entry`, a model in one of the
281
+ * editor's file formats (`{format, data}`, as a kept one is) — with a
282
+ * `title` and why (`rationale`), from `source` (who or what proposed it).
283
+ * `onSettle(outcome, {bench})` hears whether it was 'taken' or
284
+ * 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
285
+ * @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
286
+ */
287
+ propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
288
+ if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
289
+ const id = `${this.id}-proposal-${++this._proposalSeq}`;
290
+ const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
291
+ if (entry) {
292
+ const format = part.fileFormats().find(f => f.label === entry.format);
293
+ if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
294
+ part.replaceModel(format.import(entry.data));
295
+ }
296
+ const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
297
+ this.proposals.push(proposal);
298
+ return proposal;
299
+ }
300
+
301
+ /**
302
+ * How the proposal would move the model's measures (Benchmark), were it
303
+ * taken whole: `[{label, before, after, change, …}]`.
304
+ */
305
+ proposalBench(part, proposal) {
306
+ return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
307
+ }
308
+
309
+ /** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
310
+ dismissProposal(proposal, outcome = 'dismissed', note = {}) {
311
+ if (!this.proposals.includes(proposal)) return;
312
+ this.proposals = this.proposals.filter(p => p !== proposal);
313
+ proposal.onSettle?.(outcome, note);
314
+ proposal.part?.dispose?.();
315
+ }
316
+
317
+ /**
318
+ * Review a proposal against `part`'s model: its differences, chosen as a
319
+ * merge (ModelMergeDialog), its rationale above them. Merging takes it.
320
+ */
321
+ reviewProposal(part, proposal) {
322
+ const bench = this.proposalBench(part, proposal);
323
+ return new ModelMergeDialog({
324
+ ...this._comparing(part, proposal.part),
325
+ theirsName: proposal.title,
326
+ note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
327
+ onMerge: (command) => {
328
+ // What was taken (the parts chosen), measured: the model before the merge and after.
329
+ const before = Benchmark.of(part.model);
330
+ part.runModelEdit(command);
331
+ this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
332
+ },
333
+ }).open();
334
+ }
335
+
336
+ /** Tools ▸ Proposals: each, to review or dismiss. */
337
+ proposalItems(part) {
338
+ if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
339
+ return this.proposals.map(p => ({
340
+ label: p.source ? `${p.title} — ${p.source}` : p.title,
341
+ items: [
342
+ { label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
343
+ { label: 'Dismiss', action: () => this.dismissProposal(p) },
344
+ ],
345
+ }));
346
+ }
347
+
250
348
  /** Tell the user (the page's alert). @protected */
251
349
  tell(message) {
252
350
  const win = this.container?.ownerDocument?.defaultView ?? globalThis;
@@ -301,6 +399,19 @@ export class ModelsPart extends Part {
301
399
  this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
302
400
  }
303
401
 
402
+ /**
403
+ * Replace the bases: a host whose reference models change (a live
404
+ * database after its schema changed) gives them afresh. The parts made
405
+ * for the old ones are disposed; nothing open is touched.
406
+ * @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
407
+ */
408
+ setBases(specs = []) {
409
+ for (const base of this._bases ?? []) base.dispose?.();
410
+ this._bases = null;
411
+ this.spec.bases = specs;
412
+ return this.bases();
413
+ }
414
+
304
415
  /** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
305
416
  static copyName(name) {
306
417
  const plain = String(name || 'Untitled');
@@ -327,9 +438,33 @@ export class ModelsPart extends Part {
327
438
  return part;
328
439
  }
329
440
 
330
- /** File ▸ New ▸'s items from bases: From base: <name>, one a base. */
441
+ /**
442
+ * Open a base to look at (by its place in `bases`): a tab of its own copy,
443
+ * named as the base is with " (view)", that can be laid out and moved
444
+ * about but is never kept — a live database seen as it is, without making a
445
+ * design of it. Returns the part.
446
+ */
447
+ viewBase(index) {
448
+ const spec = this.bases()[index]?.spec;
449
+ if (!spec) return null;
450
+ const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
451
+ const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
452
+ part.keepOff = true;
453
+ this.work.focus(part.id);
454
+ return part;
455
+ }
456
+
457
+ /** What a base viewed is called: " (view)" after its name. */
458
+ static VIEW_SUFFIX = ' (view)';
459
+
460
+ /** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
331
461
  baseItems() {
332
- return this.bases().map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) }));
462
+ const bases = this.bases();
463
+ return [
464
+ ...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
465
+ ...(bases.length ? [{ separator: true }] : []),
466
+ ...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
467
+ ];
333
468
  }
334
469
 
335
470
  /**
@@ -362,6 +497,10 @@ export class ModelsPart extends Part {
362
497
  return [
363
498
  { label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
364
499
  { label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
500
+ { separator: true },
501
+ { label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
502
+ // The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
503
+ ...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
365
504
  ];
366
505
  }
367
506
 
@@ -446,7 +585,7 @@ export class ModelsPart extends Part {
446
585
  return [
447
586
  ...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
448
587
  ...(compare ? [compare, merge] : []),
449
- ...(keeping.length ? [{ label: 'This browser', items: keeping }] : []),
588
+ ...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
450
589
  { separator: true },
451
590
  { label: 'Close model', action: () => this.closeModel(part.id) },
452
591
  { label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },