@peter.prib/renderengine 0.2.6 → 0.2.7

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 (49) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +180 -2
  3. package/dist/renderengine-workspace.umd.js +8704 -5310
  4. package/dist/renderengine.umd.js +5675 -3398
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +223 -13
  7. package/package.json +1 -1
  8. package/src/bian.js +182 -0
  9. package/src/charting/DiagramEditor.js +5 -26
  10. package/src/charting/DiagramTypeRegistry.js +8 -2
  11. package/src/charting/ModelCheckDialog.js +63 -0
  12. package/src/charting/ModelChecker.js +26 -0
  13. package/src/charting/ViewTabStrip.js +94 -81
  14. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  15. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  16. package/src/charting/datamodel/DataModel.js +356 -10
  17. package/src/charting/datamodel/DataModelChecker.js +92 -0
  18. package/src/charting/datamodel/DataModelCompared.js +127 -0
  19. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  20. package/src/charting/datamodel/DataModelPerspective.js +45 -0
  21. package/src/charting/datamodel/DataType.js +108 -0
  22. package/src/charting/datamodel/EnumerationType.js +51 -0
  23. package/src/charting/datamodel/ServiceDomain.js +177 -0
  24. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  25. package/src/charting/datamodel/StructuredType.js +63 -0
  26. package/src/charting/datamodel/index.js +10 -0
  27. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  28. package/src/charting/diagramModel/OpenViewList.js +75 -0
  29. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  30. package/src/charting/diagrams/DataModelRelationshipStrategy.js +12 -4
  31. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  32. package/src/charting/diagrams/GraphDiagram.js +11 -1
  33. package/src/charting/diagrams/NodeGeometry.js +169 -158
  34. package/src/charting/glyphs/PersonGlyph.js +24 -23
  35. package/src/index.js +2666 -2664
  36. package/src/workspace/EditorShell.js +166 -67
  37. package/src/workspace/PanelLayout.js +56 -0
  38. package/src/workspace/layout/TabsNode.js +205 -191
  39. package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
  40. package/src/workspace/parts/DataModelPart.js +339 -29
  41. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  42. package/src/workspace/parts/EditorPart.js +461 -407
  43. package/src/workspace/parts/ModelFile.js +216 -187
  44. package/src/workspace/parts/ModelsPart.js +483 -192
  45. package/src/workspace/storage/BackupFolder.js +145 -0
  46. package/src/workspace/storage/KeyValueStore.js +92 -0
  47. package/src/workspace/storage/ModelLibrary.js +65 -0
  48. package/src/workspace/workArea.js +417 -413
  49. package/src/yaml/YamlReader.js +372 -0
@@ -3,8 +3,15 @@ import { el } from '../dom.js';
3
3
  import { MenuBar } from '../../charting/MenuBar.js';
4
4
  import { ToolbarSelect, ToolbarMenu, ToolbarText } from '../../charting/toolbar/index.js';
5
5
  import { FormDialog } from '../../charting/FormDialog.js';
6
+ import { ModelCheckDialog } from '../../charting/ModelCheckDialog.js';
6
7
  import { DataModelNotation } from '../../datamodel.js';
7
- import { DataModel, DataModelPerspective, DataModelContext } from '../../charting/datamodel/index.js';
8
+ import {
9
+ DataModel, DataModelPerspective, DataModelContext, DataModelFileFormat, ServiceDomain, ServiceLandscape, BianLandscapeReader,
10
+ BianSemanticApiReader, DataType, EnumerationType, StructuredType,
11
+ } from '../../charting/datamodel/index.js';
12
+ import { pickFile } from '../../charting/toolbar/pickFile.js';
13
+ import { ModelFile } from './ModelFile.js';
14
+ import { EditorPart } from './EditorPart.js';
8
15
  import { downloadPng, downloadSvg } from '../../exportImage.js';
9
16
 
10
17
  /**
@@ -13,12 +20,21 @@ import { downloadPng, downloadSvg } from '../../exportImage.js';
13
20
  * how they map; seen through a perspective (whose concepts, under whose names)
14
21
  * read in a context (what they mean here, the rules that hold, where and
15
22
  * when), in a notation (Chen, Crow's Foot, UML). Its toolbar switches each
16
- * of them, Level offering Mapping (the levels side by side, joined where they
17
- * map), and its Model menu makes, changes and removes perspectives and
18
- * contexts. Boxes and notes stay where they were dragged. Export ▸ saves the
23
+ * of them, Level offering the views that are no level (DataModel.OTHER_VIEWS:
24
+ * Mapping, the levels side by side joined where they map; Service landscape,
25
+ * the service domains in their business areas and domains, a double-click
26
+ * seeing the model through one), and its Model menu makes, changes and removes
27
+ * perspectives and contexts and reads service domains from a BIAN landscape. Boxes and notes stay where they were dragged. Export ▸ saves the
19
28
  * picture (PNG, SVG). The drawing, its sizing and redrawing on resize are
20
29
  * ChartPart's.
21
30
  *
31
+ * It is saved as a file (DataModelFileFormat, `.datamodel.json`) as an
32
+ * editor's model is (ModelFile): File ▸ Open…, Save, Save As… (Ctrl+O, Ctrl+S),
33
+ * • while changed since, and asking before the page is left with changes
34
+ * unsaved. Hosted by a ModelsPart (`editorType: 'data-model'`) it is one of
35
+ * several models open, a tab each, and with that part's `keep` it is kept in
36
+ * this browser and backed up (File ▸ This browser), as the modeller's are.
37
+ *
22
38
  * Spec: `{ id, type: 'data-model', title?, levels?, mappings?, mappingLayout?,
23
39
  * perspectives?, contexts?, perspective?, context?, level?, notation?, options? }`
24
40
  * — the model as DataModel takes it (or `entities`/`relationships` in the older
@@ -32,8 +48,11 @@ export class DataModelPart extends ChartPart {
32
48
  constructor(spec = {}) {
33
49
  const { perspective = null, context = null, level = 'logical', notation = 'crowsfoot', options = {} } = spec;
34
50
  super({ ...spec, chartType: 'dataModel', options: { editable: true, resizable: true, ...options, level, notation } });
35
- /** The levels, their mappings, perspectives and contexts. */
36
- this.model = new DataModel(spec);
51
+ /** The file it is open from and saved to. */
52
+ this.file = new ModelFile(this);
53
+ /** Its id among the models kept in this browser, or null. */
54
+ this.keepId = spec.keepId ?? null;
55
+ this._adoptModel(new DataModel(spec));
37
56
  /** The ids chosen, or null. */
38
57
  this.perspectiveId = perspective;
39
58
  this.contextId = context ?? this.model.perspective(perspective)?.context ?? null;
@@ -41,9 +60,114 @@ export class DataModelPart extends ChartPart {
41
60
  this._drawnLevel = level;
42
61
  }
43
62
 
63
+ // ===== A model saved as a file, kept, backed up =====
64
+
65
+ /** Its file formats: the data model's own. */
66
+ fileFormats() {
67
+ return [DataModelFileFormat.instance ??= new DataModelFileFormat()];
68
+ }
69
+
70
+ saveFormat() {
71
+ return this.fileFormats()[0];
72
+ }
73
+
74
+ /** The ModelsPart it is a tab of, or null. */
75
+ get modelsHost() {
76
+ return this.ctx?.models ?? null;
77
+ }
78
+
79
+ /** The page it is in. */
80
+ get document() {
81
+ return this.container?.ownerDocument ?? globalThis.document;
82
+ }
83
+
84
+ /** What its tab and the page title show: the file's name (or the model's), and • while changed. */
85
+ get fileLabel() {
86
+ return this.file.label;
87
+ }
88
+
89
+ /** The model as kept in this browser (ModelFile#keepEntry). */
90
+ keepEntry() {
91
+ return this.file.keepEntry();
92
+ }
93
+
94
+ /** Edit another model (one opened, imported or kept), from scratch: nothing chosen, nothing changed. */
95
+ replaceModel(model) {
96
+ this._adoptModel(model);
97
+ this.perspectiveId = null;
98
+ this.contextId = null;
99
+ if (this.mounted) this.render();
100
+ }
101
+
102
+ /** A model opened from a file in a host's new tab (as EditorPart does). */
103
+ onImported(model) {
104
+ return EditorPart.prototype.onImported.call(this, model);
105
+ }
106
+
107
+ /** The file or its saved state changed: a ModelsPart's tab follows it. */
108
+ onFileState() {
109
+ this.modelsHost?.fileStateChanged?.(this);
110
+ }
111
+
112
+ /** Edit `model`: every change of it shows (• and the title) and is kept. @private */
113
+ _adoptModel(model) {
114
+ this._unsubscribeEdits?.();
115
+ this.model = model;
116
+ model.markSaved();
117
+ this._unsubscribeEdits = model.onEdit(() => {
118
+ this.file.showState();
119
+ this.modelsHost?.modelEdited?.(this);
120
+ });
121
+ }
122
+
123
+ mount(host, ctx) {
124
+ const first = !this.mounted;
125
+ super.mount(host, ctx);
126
+ if (first) {
127
+ this.file.listen();
128
+ this.file.showState();
129
+ }
130
+ }
131
+
132
+ /**
133
+ * File ▸: Open…, Save, Save As…, then with a ModelsPart New model and Close
134
+ * model, and This browser ▸ (keeping and backups).
135
+ */
136
+ fileItems() {
137
+ const host = this.modelsHost;
138
+ return EditorPart.tidyMenu([
139
+ ...this.file.menuItems(),
140
+ ...(host ? [
141
+ { separator: true },
142
+ ...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
143
+ { label: 'Duplicate model', action: () => host.duplicate(this) },
144
+ { label: 'Close model', action: () => host.closeModel(this.id) },
145
+ ...EditorPart.menuGroup('This browser', host.keepItems?.(this) ?? []),
146
+ ] : []),
147
+ ]);
148
+ }
149
+
150
+ /** Edit ▸: undo and redo (a merge is one step). */
151
+ editItems() {
152
+ const history = this.model.history;
153
+ return [
154
+ { label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this._redraw(); } },
155
+ { label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this._redraw(); } },
156
+ ];
157
+ }
158
+
159
+ /** Run a model-wide edit (a merge) as one undo step, then redraw. */
160
+ runModelEdit(command) {
161
+ if (!command) return null;
162
+ this.model.history.execute(command);
163
+ this._changed();
164
+ this._redraw();
165
+ return command;
166
+ }
167
+
44
168
  /** The entities at the level shown (the concepts in the mapping view). */
45
169
  get entities() {
46
- return this.model.level(this.level === DataModel.MAPPING ? 'conceptual' : this.level).entities;
170
+ return this.model.level(DataModel.otherView(this.level) ? 'conceptual' : this.level).entities;
47
171
  }
48
172
  get level() { return this.chartOptions.level; }
49
173
  get notation() { return this.chartOptions.notation; }
@@ -80,7 +204,18 @@ export class DataModelPart extends ChartPart {
80
204
 
81
205
  /** What is drawn: the perspective's view of the level (or the mapping) in the context (DataModel#view), in the notation. */
82
206
  drawOptions() {
83
- return { ...this.chartOptions, ...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }) };
207
+ return {
208
+ ...this.chartOptions,
209
+ ...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }),
210
+ ...(this.level === DataModel.LANDSCAPE && { onNodeDoubleClick: (node) => this.openServiceDomain(ServiceLandscape.serviceDomainOf(node.id)) }),
211
+ };
212
+ }
213
+
214
+ /** See the model through a service domain (an id), at the logical level, where its business objects are. */
215
+ openServiceDomain(id) {
216
+ if (!this.model.perspective(id)) return;
217
+ this.chartOptions.level = 'logical';
218
+ this.setPerspective(id);
84
219
  }
85
220
 
86
221
  render() {
@@ -98,10 +233,14 @@ export class DataModelPart extends ChartPart {
98
233
  */
99
234
  menuBarItems() {
100
235
  const { perspectives, contexts } = this.model;
236
+ const host = this.modelsHost;
101
237
  return [
238
+ new ToolbarMenu('File', () => this.fileItems()),
239
+ new ToolbarMenu('Edit', () => this.editItems()),
240
+ ...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
102
241
  new ToolbarMenu('Model', () => this.modelItems()),
103
242
  ...(perspectives.length ? [new ToolbarSelect('Perspective',
104
- () => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.name, p.description])],
243
+ () => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.constructor === DataModelPerspective ? p.name : `${p.name} (${p.constructor.kindLabel.toLowerCase()})`, p.description])],
105
244
  () => this.perspectiveId ?? '', (v) => this.setPerspective(v))] : []),
106
245
  ...(contexts.length ? [
107
246
  new ToolbarSelect('Context',
@@ -111,7 +250,7 @@ export class DataModelPart extends ChartPart {
111
250
  ] : []),
112
251
  new ToolbarSelect('Level', () => [
113
252
  ...DataModelNotation.levels().map(l => [l.name, l.label, l.description]),
114
- [DataModel.MAPPING, 'Mapping', 'The levels side by side, joined where one realises another'],
253
+ ...DataModel.OTHER_VIEWS.filter(v => v.offered(this.model) || v.name === this.level).map(v => [v.name, v.label, v.description]),
115
254
  ], () => this.level, (v) => this.setLevel(v)),
116
255
  new ToolbarSelect('Notation', () => DataModelNotation.notations().map(n => [n.name, n.label]), () => this.notation, (v) => this.setNotation(v)),
117
256
  new ToolbarMenu('Export', () => this.exportItems()),
@@ -122,19 +261,170 @@ export class DataModelPart extends ChartPart {
122
261
  modelItems() {
123
262
  return [
124
263
  { label: 'New perspective…', action: () => this.editPerspective(null) },
264
+ { label: 'New service domain…', action: () => this.editPerspective(null, ServiceDomain) },
265
+ { label: 'Read service domains from a BIAN landscape…', action: () => this.readLandscape() },
266
+ { label: 'Read a BIAN service domain (semantic API)…', action: () => this.readSemanticApi() },
125
267
  { label: 'Edit perspective…', disabled: !this.perspective || undefined, action: () => this.editPerspective(this.perspective) },
126
268
  { label: 'Delete perspective', disabled: !this.perspective || undefined, action: () => this.removePerspective(this.perspectiveId) },
127
269
  { separator: true },
270
+ ...EditorPart.menuGroup('Data types', this.dataTypeItems()),
271
+ { separator: true },
128
272
  { label: 'New context…', action: () => this.editContext(null) },
129
273
  { label: 'Edit context…', disabled: !this.context || undefined, action: () => this.editContext(this.context) },
130
274
  { label: 'Delete context', disabled: !this.context || undefined, action: () => this.removeContext(this.contextId) },
275
+ { separator: true },
276
+ { label: 'Check model…', action: () => this.checkModel() },
131
277
  ];
132
278
  }
133
279
 
280
+ /**
281
+ * Service domains from BIAN's published service landscape (an ArchiMate
282
+ * model: an Archi file or the Exchange Format; BianLandscapeReader), as one
283
+ * undo step (DataModel#serviceDomainsCommand); then the landscape is shown.
284
+ * @param {Object} [options] - As readInto's, and `reader` (a BianLandscapeReader)
285
+ * @returns {Promise<Command|null>}
286
+ */
287
+ readLandscape({ reader = new BianLandscapeReader(), ...options } = {}) {
288
+ let found = 0;
289
+ return this.readInto({
290
+ ...options, accept: '.archimate,.xml', what: 'the landscape',
291
+ command: (bytes, name) => {
292
+ const specs = reader.read(BianLandscapeReader.parse(bytes));
293
+ found = specs.length;
294
+ return this.model.serviceDomainsCommand(specs, `"${name}"`);
295
+ },
296
+ nothing: (name) => (found
297
+ ? `"${name}" brings nothing new: its ${found} service domain${found === 1 ? ' is' : 's are'} here as ${found === 1 ? 'it is' : 'they are'}.`
298
+ : `"${name}" has no service domains (Capabilities with none inside them).`),
299
+ then: () => { this.chartOptions.level = DataModel.LANDSCAPE; this.perspectiveId = null; },
300
+ });
301
+ }
302
+
303
+ /**
304
+ * A service domain from one of BIAN's semantic APIs (an OpenAPI file, YAML
305
+ * or JSON; BianSemanticApiReader): its control record and behaviour
306
+ * qualifiers as logical entities, the schemas they use as data types, and
307
+ * the service domain, as one undo step (DataModel#bringCommand); then the
308
+ * model is seen through it at the logical level. An entity the model has
309
+ * by name stays as it is and is used.
310
+ * @param {Object} [options] - As readInto's, and `depth` (BianSemanticApiReader's)
311
+ * @returns {Promise<Command|null>}
312
+ */
313
+ readSemanticApi({ depth = 1, ...options } = {}) {
314
+ let read = null;
315
+ return this.readInto({
316
+ ...options, accept: '.yaml,.yml,.json', what: 'the semantic API',
317
+ command: (bytes, name) => {
318
+ const reader = new BianSemanticApiReader({ depth, entityNames: this.model.level('logical').entities.map(e => e.name) });
319
+ read = reader.read(BianSemanticApiReader.parse(new TextDecoder().decode(bytes)));
320
+ const [sd] = read.serviceDomains;
321
+ return this.model.bringCommand(read, `Read ${sd.name} from "${name}"`);
322
+ },
323
+ nothing: (name) => `"${name}" brings nothing the model does not have.`,
324
+ then: () => {
325
+ // The service domain is the one here of its id or name (bringCommand keeps it), else the new one.
326
+ const [sd] = read.serviceDomains;
327
+ const here = this.model.perspective(sd.id) ?? this.model.perspectives.find(p => p.name.toLowerCase() === sd.name.toLowerCase());
328
+ this.chartOptions.level = 'logical';
329
+ this.perspectiveId = here?.id ?? sd.id;
330
+ },
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Ask for a file, make a command from its bytes, and run it as one undo
336
+ * step; a file that cannot be read, or brings nothing, is reported.
337
+ * @param {Object} how
338
+ * @param {string} how.accept - The file chooser's filter
339
+ * @param {string} how.what - What is read, for the report ('the landscape')
340
+ * @param {(bytes: Uint8Array, name: string) => Command|null} how.command
341
+ * @param {(name: string) => string} how.nothing - The report when the command is null
342
+ * @param {() => void} [how.then] - What to show once it is run (before the redraw)
343
+ * @param {(accept: string) => Promise<File|null>} [how.chooseFile]
344
+ * @param {(message: string) => void} [how.report]
345
+ * @returns {Promise<Command|null>}
346
+ */
347
+ async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
348
+ const doc = this.container?.ownerDocument ?? globalThis.document;
349
+ const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
350
+ const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
351
+ if (!file) return null;
352
+ let command;
353
+ try {
354
+ command = make(new Uint8Array(await file.arrayBuffer()), file.name);
355
+ } catch (err) {
356
+ tell(`Could not read ${what}: ${err.message}`);
357
+ return null;
358
+ }
359
+ if (!command) {
360
+ tell(nothing(file.name));
361
+ return null;
362
+ }
363
+ then();
364
+ this.runModelEdit(command);
365
+ return command;
366
+ }
367
+
368
+ /** Model ▸ Data types ▸: a new one of each kind, then each to change or remove. */
369
+ dataTypeItems() {
370
+ const types = this.model.dataTypes;
371
+ return [
372
+ ...[EnumerationType, StructuredType, DataType].map(Kind => ({ label: `New ${Kind.kindLabel.toLowerCase()}…`, action: () => this.editDataType(null, Kind) })),
373
+ ...(types.length ? [
374
+ { separator: true },
375
+ { label: 'Show data types', action: () => this.setLevel(DataModel.DATA_TYPES) },
376
+ { label: 'Edit', items: types.map(t => ({ label: `${t.name} (${t.constructor.kindLabel.toLowerCase()})…`, action: () => this.editDataType(t) })) },
377
+ { label: 'Delete', items: types.map(t => ({ label: t.name, action: () => this.removeDataType(t.id) })) },
378
+ ] : []),
379
+ ];
380
+ }
381
+
382
+ /** A dialog making a data type of a kind (none given) or changing one (its kind's fields); applied, the data types are shown. */
383
+ editDataType(type, Kind = type?.constructor ?? DataType) {
384
+ const title = type ? `${Kind.kindLabel}: ${type.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
385
+ return this._dialog(title, Kind.fields(this.model, type), (values) => {
386
+ const spec = Kind.specFrom(values, this.model, type);
387
+ if (type) this.model.updateDataType(type.id, spec); else this.model.addDataType(spec);
388
+ this.chartOptions.level = DataModel.DATA_TYPES;
389
+ this._changed();
390
+ this._redraw();
391
+ });
392
+ }
393
+
394
+ removeDataType(id) {
395
+ this.model.removeDataType(id);
396
+ if (this.level === DataModel.DATA_TYPES && !this.model.dataTypes.length) this.chartOptions.level = 'logical';
397
+ this._changed();
398
+ this._redraw();
399
+ }
400
+
401
+ /** Check the model (DataModelChecker) and list the findings; choosing one shows it (revealFinding). Returns the findings. */
402
+ checkModel() {
403
+ const findings = this.model.checker().run();
404
+ new ModelCheckDialog({ findings, onReveal: (finding) => this.revealFinding(finding), document: this.document }).open();
405
+ return findings;
406
+ }
407
+
408
+ /** Show what a finding names: an entity's level, a service domain seen through at the logical level, the mapping or data types view. */
409
+ revealFinding(finding) {
410
+ if (finding.kind === 'perspective') {
411
+ this.chartOptions.level = 'logical';
412
+ this.setPerspective(finding.ids[0]);
413
+ } else if (finding.kind === 'mapping') {
414
+ this.setLevel(DataModel.MAPPING);
415
+ } else if (finding.kind === 'data-type') {
416
+ this.setLevel(DataModel.DATA_TYPES);
417
+ } else if (finding.level) {
418
+ this.setLevel(finding.level);
419
+ }
420
+ this.menuBar?.render?.();
421
+ }
422
+
134
423
  /** A dialog making a perspective (none given) or changing one (DataModelPerspective.fields); applied, it is the one seen through. */
135
- editPerspective(perspective) {
136
- return this._dialog(perspective ? `Perspective: ${perspective.name}` : 'New perspective', DataModelPerspective.fields(this.model, perspective), (values) => {
137
- const spec = DataModelPerspective.specFrom(values, this.model);
424
+ editPerspective(perspective, Kind = perspective?.constructor ?? DataModelPerspective) {
425
+ const title = perspective ? `${Kind.kindLabel}: ${perspective.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
426
+ return this._dialog(title, Kind.fields(this.model, perspective), (values) => {
427
+ const spec = Kind.specFrom(values, this.model, perspective);
138
428
  const saved = perspective ? this.model.updatePerspective(perspective.id, spec) : this.model.addPerspective(spec);
139
429
  this._changed();
140
430
  this.setPerspective(saved.id);
@@ -179,9 +469,9 @@ export class DataModelPart extends ChartPart {
179
469
  this.menuBar?.render?.();
180
470
  }
181
471
 
182
- /** Export ▸: the picture, as PNG or SVG, named after the part. */
472
+ /** Export ▸: the picture, as PNG or SVG, named after the model. */
183
473
  exportItems() {
184
- const name = this.title || 'data-model';
474
+ const name = this.model.name || this.title || 'data-model';
185
475
  return [
186
476
  { label: 'Image (PNG)', action: () => downloadPng(this.canvasHost, { name }) },
187
477
  { label: 'Image (SVG)', action: () => downloadSvg(this.canvasHost, { name }) },
@@ -198,26 +488,46 @@ export class DataModelPart extends ChartPart {
198
488
  * time): on the level shown, or in the mapping view's own layout. Then draw.
199
489
  */
200
490
  _draw() {
491
+ if (this._keepPlaces()) this.model.edited(); // a box or note moved is a change, kept and saved
492
+ this._drawnLevel = this.level;
493
+ super._draw();
494
+ }
495
+
496
+ /**
497
+ * Keep where the drawing on screen has its boxes and notes: on the level
498
+ * it shows, or in the mapping view's layout. Returns whether any moved. @private
499
+ */
500
+ _keepPlaces() {
201
501
  const pos = this.engine?._graph?.pos;
202
- if (pos && this._drawnLevel) {
203
- if (this._drawnLevel === DataModel.MAPPING) {
204
- for (const [id, p] of pos) if (id.includes(':')) this.model.mappingLayout[id] = { x: p.x, y: p.y };
205
- } else {
206
- for (const entity of this.model.level(this._drawnLevel).entities) {
207
- const p = pos.get(entity.id);
208
- if (p) { entity.x = p.x; entity.y = p.y; }
209
- }
210
- for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
211
- const p = pos.get(rule.id);
212
- if (p) this.model.placeNote(this._drawnLevel, rule, p);
213
- }
502
+ if (!pos || !this._drawnLevel) return false;
503
+ let moved = false;
504
+ const place = (p, before, put) => {
505
+ if (!p || (before && before.x === p.x && before.y === p.y)) return;
506
+ put({ x: p.x, y: p.y });
507
+ moved = true;
508
+ };
509
+ const other = DataModel.otherView(this._drawnLevel);
510
+ if (other && !other.keepsPlaces) return false; // laid out by its structure, anew each time
511
+ if (other) {
512
+ for (const [id, p] of pos) {
513
+ if (id.includes(':')) place(p, this.model.mappingLayout[id], (at) => { this.model.mappingLayout[id] = at; });
214
514
  }
515
+ return moved;
215
516
  }
216
- this._drawnLevel = this.level;
217
- super._draw();
517
+ for (const entity of this.model.level(this._drawnLevel).entities) {
518
+ place(pos.get(entity.id), entity, (at) => Object.assign(entity, at));
519
+ }
520
+ for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
521
+ const p = pos.get(rule.id);
522
+ const before = this.model.notePlace(this._drawnLevel, rule);
523
+ place(p, Number.isFinite(before.x) ? before : null, (at) => this.model.placeNote(this._drawnLevel, rule, at));
524
+ }
525
+ return moved;
218
526
  }
219
527
 
220
528
  dispose() {
529
+ this._unsubscribeEdits?.();
530
+ this.file.restoreTitle();
221
531
  this.menuBar?.dispose?.();
222
532
  super.dispose();
223
533
  }
@@ -4,7 +4,6 @@ import { EditorPart } from './EditorPart.js';
4
4
  import { DiagramEditor } from '../../charting/DiagramEditor.js';
5
5
  import { MenuBar } from '../../charting/MenuBar.js';
6
6
  import { DiagramModel } from '../../charting/diagramModel/index.js';
7
- import { ViewTabStrip } from '../../charting/ViewTabStrip.js';
8
7
  import { LocalJsonStore } from '../../charting/storage/LocalJsonStore.js';
9
8
  import { EdgeStyleCascade } from '../../charting/edgeStyle/index.js';
10
9
  import { DiagramViewPropertiesDialog } from '../../charting/DiagramViewPropertiesDialog.js';
@@ -64,15 +63,10 @@ export class DiagramEditorPart extends EditorPart {
64
63
  elements.id = 'elements';
65
64
  canvas.appendChild(elements);
66
65
 
67
- const tabs = new ViewTabStrip({
68
- orientation: this.tabOrientation,
69
- document: doc,
70
- onSelect: (id) => model.switchTo(id),
71
- menuItems: (id) => this.viewMenu.items(id),
72
- }).render(model.views(), model.currentViewId);
66
+ const tabs = this.viewTabs({ orientation: this.tabOrientation });
73
67
 
74
68
  // The shared workbench layout: toolbar, palette at the side, tabs + canvas in the main area.
75
- const { toolbar: menuBarEl, side: palette, main } = new EditorShell({ document: doc, sideWidth: '200px' }).mount(this.container);
69
+ const { toolbar: menuBarEl, side: palette, main } = new EditorShell({ document: doc, sideWidth: '200px', layout: this.layout, labels: { side: 'Palette' } }).mount(this.container);
76
70
  palette.classList.add('ws-diagram-palette');
77
71
  Object.assign(palette.style, { padding: '8px', fontSize: '12px', display: 'flex', flexDirection: 'column', overflowY: 'hidden' });
78
72
  main.style.flexDirection = this.tabOrientation === 'vertical' ? 'row' : 'column';