@peter.prib/renderengine 0.2.6 → 0.2.10
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 +70 -9
- package/REFERENCE.md +303 -2
- package/dist/renderengine-workspace.umd.js +8851 -3305
- package/dist/renderengine.umd.js +5621 -1382
- package/examples/archimate.html +68 -66
- package/examples/data-model.html +251 -19
- package/package.json +1 -1
- package/src/bian.js +182 -0
- package/src/charting/DiagramEditor.js +5 -26
- package/src/charting/DiagramTypeRegistry.js +8 -2
- package/src/charting/FormDialog.js +398 -385
- package/src/charting/ModelCheckDialog.js +63 -0
- package/src/charting/ModelChecker.js +26 -0
- package/src/charting/RowsField.js +127 -0
- package/src/charting/ViewTabStrip.js +94 -81
- package/src/charting/datamodel/BianLandscapeReader.js +185 -0
- package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
- package/src/charting/datamodel/ClassificationLens.js +33 -0
- package/src/charting/datamodel/ClassificationScheme.js +98 -0
- package/src/charting/datamodel/DataModel.js +574 -12
- package/src/charting/datamodel/DataModelChecker.js +338 -0
- package/src/charting/datamodel/DataModelCompared.js +127 -0
- package/src/charting/datamodel/DataModelContext.js +40 -10
- package/src/charting/datamodel/DataModelEditCommand.js +47 -0
- package/src/charting/datamodel/DataModelFileFormat.js +33 -0
- package/src/charting/datamodel/DataModelPerspective.js +55 -0
- package/src/charting/datamodel/DataType.js +108 -0
- package/src/charting/datamodel/EnumerationType.js +64 -0
- package/src/charting/datamodel/ServiceDomain.js +186 -0
- package/src/charting/datamodel/ServiceLandscape.js +104 -0
- package/src/charting/datamodel/SqlDdl.js +80 -0
- package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
- package/src/charting/datamodel/StructuredType.js +63 -0
- package/src/charting/datamodel/index.js +21 -5
- package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
- package/src/charting/datamodel/naming/NameStyle.js +57 -0
- package/src/charting/datamodel/naming/NamingStandard.js +132 -0
- package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
- package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
- package/src/charting/datamodel/naming/index.js +8 -0
- package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
- package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
- package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
- package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
- package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
- package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
- package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
- package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
- package/src/charting/datamodel/scorecard/index.js +8 -0
- package/src/charting/diagramModel/MergeModelCommand.js +43 -37
- package/src/charting/diagramModel/OpenViewList.js +75 -0
- package/src/charting/diagramModel/ViewSetChecker.js +22 -34
- package/src/charting/diagrams/DataModelDiagram.js +4 -11
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
- package/src/charting/diagrams/EdgeGeometry.js +578 -553
- package/src/charting/diagrams/GraphDiagram.js +11 -1
- package/src/charting/diagrams/NodeGeometry.js +173 -158
- package/src/charting/glyphs/PersonGlyph.js +24 -23
- package/src/charting/toolbar/ToolbarSelect.js +6 -0
- package/src/datamodel.js +402 -4
- package/src/index.js +6 -4
- package/src/workspace/EditorShell.js +166 -67
- package/src/workspace/PanelLayout.js +56 -0
- package/src/workspace/layout/TabsNode.js +205 -191
- package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
- package/src/workspace/parts/DataModelPart.js +534 -33
- package/src/workspace/parts/DiagramEditorPart.js +2 -8
- package/src/workspace/parts/EditorPart.js +461 -407
- package/src/workspace/parts/ModelFile.js +216 -187
- package/src/workspace/parts/ModelsPart.js +483 -192
- package/src/workspace/storage/BackupFolder.js +145 -0
- package/src/workspace/storage/KeyValueStore.js +92 -0
- package/src/workspace/storage/ModelLibrary.js +65 -0
- package/src/workspace/workArea.js +417 -413
- package/src/yaml/YamlReader.js +372 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { el } from '../workspace/dom.js';
|
|
2
|
+
import { FormDialog } from './FormDialog.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
|
|
6
|
+
* each a button (⚠ a warning, ℹ for a look) that shows what it names
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export class ModelCheckDialog {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {Array<Object>} options.findings
|
|
14
|
+
* @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
|
|
15
|
+
* @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
|
|
16
|
+
* @param {Document} [options.document]
|
|
17
|
+
*/
|
|
18
|
+
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
|
|
19
|
+
this.findings = findings;
|
|
20
|
+
this.onReveal = onReveal;
|
|
21
|
+
this.actionsFor = actionsFor;
|
|
22
|
+
this.document = doc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The dialog's title: how many findings. */
|
|
26
|
+
get title() {
|
|
27
|
+
const n = this.findings.length;
|
|
28
|
+
return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Open it; returns the FormDialog. */
|
|
32
|
+
open() {
|
|
33
|
+
let dialog = null;
|
|
34
|
+
const list = el({
|
|
35
|
+
style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '420px' },
|
|
36
|
+
children: this.findings.length ? this.findings.map(finding => ({
|
|
37
|
+
style: { display: 'flex', gap: '4px', alignItems: 'stretch' },
|
|
38
|
+
children: [
|
|
39
|
+
{
|
|
40
|
+
tag: 'button',
|
|
41
|
+
attrs: { type: 'button', title: 'Show it', 'data-code': finding.code },
|
|
42
|
+
style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: finding.severity === 'warning' ? '#fff6e5' : '#f5f8fc', cursor: 'pointer' },
|
|
43
|
+
text: `${finding.severity === 'warning' ? '⚠' : 'ℹ'} ${finding.message}`,
|
|
44
|
+
on: { click: () => { dialog?.close(); this.onReveal(finding); } },
|
|
45
|
+
},
|
|
46
|
+
...this.actionsFor(finding).map(act => ({
|
|
47
|
+
tag: 'button', attrs: { type: 'button', 'data-action': act.label }, text: act.label,
|
|
48
|
+
style: { padding: '4px 8px', cursor: 'pointer' },
|
|
49
|
+
on: { click: () => { dialog?.close(); act.action(); } },
|
|
50
|
+
})),
|
|
51
|
+
],
|
|
52
|
+
})) : [{ text: 'Nothing found: the model passes every check.' }],
|
|
53
|
+
}, this.document);
|
|
54
|
+
dialog = new FormDialog({
|
|
55
|
+
title: this.title,
|
|
56
|
+
fields: [{ key: 'findings', label: '', type: 'custom', element: list, read: () => null }],
|
|
57
|
+
submitLabel: 'Close',
|
|
58
|
+
document: this.document,
|
|
59
|
+
onApply: () => {},
|
|
60
|
+
});
|
|
61
|
+
return dialog.open();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelChecker — checks over a model: what is wrong or worth a look in it.
|
|
3
|
+
* Each check is a method listed by `checks()`, run in that order, so a
|
|
4
|
+
* subclass adds, drops or reorders them; `run()` gives every finding. The
|
|
5
|
+
* base of ViewSetChecker (models of views) and DataModelChecker (data
|
|
6
|
+
* models). DOM-free and advisory: findings are shown, never enforced.
|
|
7
|
+
*
|
|
8
|
+
* Each finding: `{ code, severity: 'warning'|'info', message, kind, ids }`,
|
|
9
|
+
* `kind` saying what the ids name (a view, an entity, ...).
|
|
10
|
+
*/
|
|
11
|
+
export class ModelChecker {
|
|
12
|
+
/** @param {Object} model */
|
|
13
|
+
constructor(model) {
|
|
14
|
+
this.model = model;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The checks to run, in the order their findings are listed. None here. */
|
|
18
|
+
checks() {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Every finding. @returns {Array<Object>} */
|
|
23
|
+
run() {
|
|
24
|
+
return this.checks().flatMap(check => check.call(this));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { el } from '../workspace/dom.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RowsField — the control a FormDialog shows for a `rows` field: a list of
|
|
5
|
+
* items edited as a table, a row each, its columns from the field
|
|
6
|
+
* (`columns: [{ key, label, type?: 'text'|'checkbox'|'select', choices?,
|
|
7
|
+
* suggestions?, placeholder?, width? }]`), each row movable up and down and
|
|
8
|
+
* removable, and **Add** for a new one (`addLabel`). It reads back as the rows
|
|
9
|
+
* in their order, each the item it was made from with what the columns say
|
|
10
|
+
* laid over it, so what a dialog does not show (an attribute's ISO 20022
|
|
11
|
+
* type, say) is kept: an empty text or an unticked box leaves its key out. A
|
|
12
|
+
* new row left empty is no row.
|
|
13
|
+
*/
|
|
14
|
+
export class RowsField {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} field - The FormDialog field (`columns`, `value`: the items, `addLabel`)
|
|
17
|
+
* @param {Document} doc
|
|
18
|
+
* @param {string} [id] - The table's element id
|
|
19
|
+
*/
|
|
20
|
+
constructor(field, doc, id = '') {
|
|
21
|
+
this.field = field;
|
|
22
|
+
this.doc = doc;
|
|
23
|
+
this.id = id;
|
|
24
|
+
/** Each row's element and how to read it, in display order. */
|
|
25
|
+
this.rows = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The control: the table, its rows, and Add. */
|
|
29
|
+
element() {
|
|
30
|
+
const { columns, value = [], addLabel = 'Add' } = this.field;
|
|
31
|
+
const lists = columns.filter(c => c.suggestions).map(c => ({
|
|
32
|
+
tag: 'datalist', attrs: { id: this._listId(c) }, children: c.suggestions.map(v => ({ tag: 'option', attrs: { value: v } })),
|
|
33
|
+
}));
|
|
34
|
+
this.root = el({
|
|
35
|
+
attrs: { id: this.id }, style: { display: 'flex', flexDirection: 'column', gap: '4px' },
|
|
36
|
+
children: [
|
|
37
|
+
{
|
|
38
|
+
tag: 'table', style: { borderCollapse: 'collapse', fontSize: '12px' },
|
|
39
|
+
children: [
|
|
40
|
+
{ tag: 'thead', children: [{ tag: 'tr', children: [...columns.map(c => ({ tag: 'th', text: c.label, style: { textAlign: 'left', fontWeight: 'normal', color: '#666', padding: '0 4px' } })), { tag: 'th' }] }] },
|
|
41
|
+
{ tag: 'tbody', ref: (n) => { this.body = n; } },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
...lists,
|
|
45
|
+
{ tag: 'button', attrs: { type: 'button', 'data-action': 'add' }, text: `+ ${addLabel}`, style: { alignSelf: 'flex-start' }, on: { click: () => this.add() } },
|
|
46
|
+
],
|
|
47
|
+
}, this.doc);
|
|
48
|
+
for (const item of value) this.add(item);
|
|
49
|
+
return this.root;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Add a row for an item (none: an empty one). Returns its element. */
|
|
53
|
+
add(item = null) {
|
|
54
|
+
const readers = [];
|
|
55
|
+
const cell = (c) => {
|
|
56
|
+
const current = item?.[c.key];
|
|
57
|
+
const width = c.width ?? (c.type === 'checkbox' ? '40px' : undefined);
|
|
58
|
+
let control;
|
|
59
|
+
if (c.type === 'checkbox') {
|
|
60
|
+
control = { tag: 'input', attrs: { type: 'checkbox', checked: !!current, 'data-key': c.key }, ref: (n) => { n.checked = !!current; readers.push([c, () => n.checked]); } };
|
|
61
|
+
} else if (c.type === 'select') {
|
|
62
|
+
const choices = c.choices.some(([v]) => v === String(current ?? '')) || current == null ? c.choices : [[String(current), String(current)], ...c.choices];
|
|
63
|
+
control = {
|
|
64
|
+
// The value set as well as the option marked: a parent's ref runs after its children are in.
|
|
65
|
+
tag: 'select', attrs: { 'data-key': c.key }, ref: (n) => { n.value = String(current ?? choices[0]?.[0] ?? ''); readers.push([c, () => n.value]); },
|
|
66
|
+
children: choices.map(([v, text]) => ({ tag: 'option', attrs: { value: v, selected: v === String(current ?? '') }, text })),
|
|
67
|
+
};
|
|
68
|
+
} else {
|
|
69
|
+
control = {
|
|
70
|
+
tag: 'input', attrs: { type: 'text', value: current == null ? '' : String(current), placeholder: c.placeholder, list: c.suggestions ? this._listId(c) : undefined, 'data-key': c.key },
|
|
71
|
+
style: { width: '100%', boxSizing: 'border-box' }, ref: (n) => { n.value = current == null ? '' : String(current); readers.push([c, () => String(n.value ?? '').trim()]); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { tag: 'td', style: { padding: '1px 2px', ...(width && { width }) }, children: [control] };
|
|
75
|
+
};
|
|
76
|
+
const button = (text, title, action) => ({ tag: 'button', attrs: { type: 'button', title, 'data-action': action }, text, style: { padding: '0 4px' } });
|
|
77
|
+
let row;
|
|
78
|
+
const node = el({
|
|
79
|
+
tag: 'tr',
|
|
80
|
+
children: [
|
|
81
|
+
...this.field.columns.map(cell),
|
|
82
|
+
{
|
|
83
|
+
tag: 'td', style: { whiteSpace: 'nowrap' },
|
|
84
|
+
children: [
|
|
85
|
+
{ ...button('↑', 'Move up', 'up'), on: { click: () => this.move(row, -1) } },
|
|
86
|
+
{ ...button('↓', 'Move down', 'down'), on: { click: () => this.move(row, 1) } },
|
|
87
|
+
{ ...button('✕', 'Remove', 'remove'), on: { click: () => this.remove(row) } },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
}, this.doc);
|
|
92
|
+
row = { node, item, readers };
|
|
93
|
+
this.rows.push(row);
|
|
94
|
+
this.body.appendChild(node);
|
|
95
|
+
return node;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Move a row up (-1) or down (1). */
|
|
99
|
+
move(row, by) {
|
|
100
|
+
const i = this.rows.indexOf(row), j = i + by;
|
|
101
|
+
if (i < 0 || j < 0 || j >= this.rows.length) return;
|
|
102
|
+
[this.rows[i], this.rows[j]] = [this.rows[j], this.rows[i]];
|
|
103
|
+
for (const r of this.rows) this.body.appendChild(r.node);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
remove(row) {
|
|
107
|
+
this.rows = this.rows.filter(r => r !== row);
|
|
108
|
+
row.node.remove();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The rows as items, in order (see the class). */
|
|
112
|
+
read() {
|
|
113
|
+
return this.rows.map(({ item, readers }) => {
|
|
114
|
+
const out = { ...(item ?? {}) };
|
|
115
|
+
for (const [c, read] of readers) {
|
|
116
|
+
const v = read();
|
|
117
|
+
if (v === '' || v === false) delete out[c.key];
|
|
118
|
+
else out[c.key] = v;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}).filter((out, i) => this.rows[i].item || this.field.columns.some(c => out[c.key] !== undefined));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_listId(column) {
|
|
125
|
+
return `${this.id || 'rows'}-${column.key}-suggestions`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -1,81 +1,94 @@
|
|
|
1
|
-
import { el } from '../workspace/dom.js';
|
|
2
|
-
import { PopupMenu } from './PopupMenu.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ViewTabStrip — a row (or column) of tabs, one per view,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {(id: string) => void}
|
|
15
|
-
* @param {(id: string) =>
|
|
16
|
-
* @param {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
1
|
+
import { el } from '../workspace/dom.js';
|
|
2
|
+
import { PopupMenu } from './PopupMenu.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ViewTabStrip — a row (or column) of tabs, one per open view, as a browser
|
|
6
|
+
* has them: an optional × to close a tab (`onClose`; `canClose` says which may
|
|
7
|
+
* be), a + after them to add one (`onAdd`), and a right-click menu per tab.
|
|
8
|
+
* Stateless: render() builds the strip from the items given.
|
|
9
|
+
*/
|
|
10
|
+
export class ViewTabStrip {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {'horizontal'|'vertical'} [options.orientation]
|
|
14
|
+
* @param {(id: string) => void} options.onSelect
|
|
15
|
+
* @param {(id: string) => void} [options.onClose] - Shows a × on each tab
|
|
16
|
+
* @param {(id: string) => boolean} [options.canClose] - Which tabs get the × (default all)
|
|
17
|
+
* @param {(event: Event) => void} [options.onAdd] - Shows a + after the tabs
|
|
18
|
+
* @param {(id: string) => Array} [options.menuItems] - PopupMenu items for a tab
|
|
19
|
+
* @param {Document} [options.document]
|
|
20
|
+
*/
|
|
21
|
+
constructor({ orientation = 'horizontal', onSelect, onClose = null, canClose = () => true, onAdd = null, menuItems = () => [], document: doc = document }) {
|
|
22
|
+
this.orientation = orientation;
|
|
23
|
+
this.onSelect = onSelect;
|
|
24
|
+
this.onClose = onClose;
|
|
25
|
+
this.canClose = canClose;
|
|
26
|
+
this.onAdd = onAdd;
|
|
27
|
+
this.menuItems = menuItems;
|
|
28
|
+
this.doc = doc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Array<{id: string, title: string}>} items
|
|
33
|
+
* @param {string} activeId
|
|
34
|
+
* @returns {HTMLElement}
|
|
35
|
+
*/
|
|
36
|
+
render(items, activeId) {
|
|
37
|
+
const vertical = this.orientation === 'vertical';
|
|
38
|
+
return el({
|
|
39
|
+
attrs: { role: 'tablist', 'aria-orientation': this.orientation },
|
|
40
|
+
style: {
|
|
41
|
+
flex: vertical ? '0 0 150px' : '0 0 auto', display: 'flex', flexDirection: vertical ? 'column' : 'row',
|
|
42
|
+
gap: '2px', background: '#f0f0f0', padding: '4px 8px', fontSize: '12px',
|
|
43
|
+
borderRight: vertical ? '1px solid #ccc' : 'none', borderBottom: vertical ? 'none' : '1px solid #ccc',
|
|
44
|
+
overflowX: vertical ? 'visible' : 'auto', overflowY: vertical ? 'auto' : 'visible',
|
|
45
|
+
},
|
|
46
|
+
children: [
|
|
47
|
+
...items.map(({ id, title }) => this._tab(id, title, id === activeId, vertical)),
|
|
48
|
+
this.onAdd && {
|
|
49
|
+
tag: 'button', text: '+',
|
|
50
|
+
attrs: { type: 'button', 'aria-label': 'New view', title: 'New view', 'data-tab-add': '' },
|
|
51
|
+
style: { border: '1px solid transparent', background: 'none', cursor: 'pointer', fontSize: '15px', lineHeight: '1', color: '#555', padding: '2px 8px' },
|
|
52
|
+
on: { click: (e) => this.onAdd(e) },
|
|
53
|
+
},
|
|
54
|
+
].filter(Boolean),
|
|
55
|
+
}, this.doc);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_tab(id, title, active, vertical) {
|
|
59
|
+
const menuItems = this.menuItems(id);
|
|
60
|
+
return {
|
|
61
|
+
attrs: { role: 'tab', tabindex: '0', 'aria-selected': String(active), 'data-view-id': id },
|
|
62
|
+
style: {
|
|
63
|
+
display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', border: '1px solid #ccc',
|
|
64
|
+
cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap', userSelect: 'none',
|
|
65
|
+
borderRadius: vertical ? '3px' : '3px 3px 0 0',
|
|
66
|
+
background: active ? '#fff' : '#e8e8e8', fontWeight: active ? 'bold' : 'normal',
|
|
67
|
+
},
|
|
68
|
+
on: {
|
|
69
|
+
click: () => this.onSelect(id),
|
|
70
|
+
keydown: (e) => {
|
|
71
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
this.onSelect(id);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
contextmenu: (e) => {
|
|
77
|
+
if (!menuItems.length) return;
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
PopupMenu.show(e.clientX, e.clientY, menuItems, this.doc);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
children: [
|
|
83
|
+
{ tag: 'span', text: title },
|
|
84
|
+
this.onClose && this.canClose(id) && {
|
|
85
|
+
tag: 'button',
|
|
86
|
+
text: '×',
|
|
87
|
+
attrs: { type: 'button', 'aria-label': `Close ${title}`, title: 'Close tab' },
|
|
88
|
+
style: { border: 'none', background: 'none', padding: '0', cursor: 'pointer', fontSize: '14px', lineHeight: '1', color: '#888' },
|
|
89
|
+
on: { click: (e) => { e.stopPropagation(); this.onClose(id); } },
|
|
90
|
+
},
|
|
91
|
+
].filter(Boolean),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { BianNotation } from '../../bian.js';
|
|
2
|
+
import { ServiceDomain } from './ServiceDomain.js';
|
|
3
|
+
import { parseArchiFile } from '../../archimateArchive.js';
|
|
4
|
+
import { parseExchangeModel } from '../../archimateExchangeFormat.js';
|
|
5
|
+
import { ZipArchive } from '../../zip/ZipArchive.js';
|
|
6
|
+
|
|
7
|
+
/** A stereotype as compared: no case, no spaces ('Service Domain' is 'servicedomain'). */
|
|
8
|
+
const plainWord = (text) => String(text ?? '').toLowerCase().replace(/[\s_-]+/g, '');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* BianLandscapeReader — service domains from BIAN's published service
|
|
12
|
+
* landscape, an ArchiMate model (an Archi file or the Exchange Format, as
|
|
13
|
+
* parseArchiFile / parseExchangeModel give it), rather than retyped.
|
|
14
|
+
*
|
|
15
|
+
* BIAN's own model (its v14 landscape) marks each element with a stereotype:
|
|
16
|
+
* a business area is a Grouping marked BusinessArea, composed of business
|
|
17
|
+
* domains (Capabilities marked BusinessDomain), each composed of service
|
|
18
|
+
* domains (Capabilities marked ServiceDomain); other Groupings aggregate
|
|
19
|
+
* service domains as overlays (a value chain, a release wave). Where the file
|
|
20
|
+
* keeps the stereotypes (an Archi specialization, or a "Stereotype" property:
|
|
21
|
+
* `stereotypeKeys`), they say which element is which: a service domain's
|
|
22
|
+
* business domain is the container marked so, its area the one that holds that.
|
|
23
|
+
* Without them it goes by structure: a service domain is a service-domain-type
|
|
24
|
+
* element nothing of those types sits inside, its nearest container its
|
|
25
|
+
* business domain and the outermost its area.
|
|
26
|
+
*
|
|
27
|
+
* What contains what: a composition from the container to what it holds before
|
|
28
|
+
* an aggregation (an overlay's), and failing both, a box drawn inside another's
|
|
29
|
+
* in a view. A service domain's functional pattern is the one realizing it (an
|
|
30
|
+
* element named as a BIAN pattern, as BIAN's model has them), else a property
|
|
31
|
+
* (`patternKeys`); its asset a property (`assetKeys`). A pattern BIAN lacks is
|
|
32
|
+
* left for the model check to report.
|
|
33
|
+
*/
|
|
34
|
+
export class BianLandscapeReader {
|
|
35
|
+
/**
|
|
36
|
+
* @param {Object} [options]
|
|
37
|
+
* @param {string[]} [options.serviceDomainTypes] - Element types a service domain may be
|
|
38
|
+
* @param {string[]} [options.containerTypes] - Element types a business area or domain may be
|
|
39
|
+
* @param {string[]} [options.containmentTypes] - Relationship types from a container to what it holds, the first preferred
|
|
40
|
+
* @param {{serviceDomain: string, businessDomain: string, businessArea: string}} [options.stereotypes] - BIAN's stereotypes for each
|
|
41
|
+
* @param {string[]} [options.stereotypeKeys] - Property keys a stereotype may be under
|
|
42
|
+
* @param {string[]} [options.patternKeys] - Property keys naming its functional pattern
|
|
43
|
+
* @param {string[]} [options.assetKeys] - Property keys naming the asset it manages
|
|
44
|
+
*/
|
|
45
|
+
constructor({
|
|
46
|
+
serviceDomainTypes = ['Capability'],
|
|
47
|
+
containerTypes = ['Grouping', 'Capability'],
|
|
48
|
+
containmentTypes = ['CompositionRelationship', 'AggregationRelationship'],
|
|
49
|
+
stereotypes = { serviceDomain: 'ServiceDomain', businessDomain: 'BusinessDomain', businessArea: 'BusinessArea' },
|
|
50
|
+
stereotypeKeys = ['Stereotype', 'Stereotypes', 'stereotype', 'stereotypes'],
|
|
51
|
+
patternKeys = ['Functional Pattern', 'functional pattern', 'FunctionalPattern', 'pattern'],
|
|
52
|
+
assetKeys = ['Asset Type', 'asset type', 'Asset', 'asset'],
|
|
53
|
+
} = {}) {
|
|
54
|
+
Object.assign(this, { serviceDomainTypes, containerTypes, containmentTypes, stereotypes, stereotypeKeys, patternKeys, assetKeys });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An ArchiMate model from a file: an Archi file (XML or an archive) or the
|
|
59
|
+
* Exchange Format, as parseArchiFile / parseExchangeModel give it.
|
|
60
|
+
* @param {string|Uint8Array|ArrayBuffer} data
|
|
61
|
+
*/
|
|
62
|
+
static parse(data) {
|
|
63
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
64
|
+
if (typeof bytes !== 'string' && ZipArchive.isZip(bytes)) return parseArchiFile(bytes);
|
|
65
|
+
const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
|
|
66
|
+
return /opengroup\.org\/xsd\/archimate/.test(text.slice(0, 2000)) ? parseExchangeModel(text) : parseArchiFile(text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Service domain specs (ServiceDomain's), one per service domain in the landscape.
|
|
71
|
+
* @param {{elements: Array, relationships?: Array, views?: Array}} parsed
|
|
72
|
+
* @returns {Array<Object>}
|
|
73
|
+
*/
|
|
74
|
+
read(parsed) {
|
|
75
|
+
const elements = new Map((parsed.elements ?? []).map(e => [e.id, e]));
|
|
76
|
+
const containers = this.containers(parsed, elements);
|
|
77
|
+
const stereotypeOf = this._stereotypes(parsed);
|
|
78
|
+
const is = (id, which) => stereotypeOf(elements.get(id)).includes(plainWord(this.stereotypes[which]));
|
|
79
|
+
const name = (id) => elements.get(id)?.name ?? '';
|
|
80
|
+
const marked = [...elements.keys()].some(id => is(id, 'serviceDomain'));
|
|
81
|
+
let found;
|
|
82
|
+
if (marked) {
|
|
83
|
+
// Marked so, or (as some in BIAN's own model are not) a service-domain-type element in a business domain.
|
|
84
|
+
const isServiceDomain = (e) => is(e.id, 'serviceDomain') || (this.serviceDomainTypes.includes(e.type)
|
|
85
|
+
&& !is(e.id, 'businessDomain') && !is(e.id, 'businessArea') && (containers.get(e.id) ?? []).some(c => is(c, 'businessDomain')));
|
|
86
|
+
found = [...elements.values()].filter(isServiceDomain).map(e => {
|
|
87
|
+
const domain = (containers.get(e.id) ?? []).find(c => is(c, 'businessDomain'));
|
|
88
|
+
const area = [...(containers.get(domain) ?? []), ...(containers.get(e.id) ?? [])].find(c => is(c, 'businessArea'));
|
|
89
|
+
return [e, area ? name(area) : '', domain ? name(domain) : ''];
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
const holdsAny = new Set([...containers.values()].flat());
|
|
93
|
+
found = [...elements.values()].filter(e => this.serviceDomainTypes.includes(e.type) && !holdsAny.has(e.id)).map(e => {
|
|
94
|
+
const chain = [];
|
|
95
|
+
for (let at = containers.get(e.id)?.[0]; at && !chain.includes(at); at = containers.get(at)?.[0]) chain.push(at);
|
|
96
|
+
return [e, chain.length ? name(chain[chain.length - 1]) : '', chain.length > 1 ? name(chain[0]) : ''];
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// A model may hold copies of one service domain (BIAN's has some, for other diagrams): the placed one is it.
|
|
100
|
+
const placed = new Set(found.filter(([, , domain]) => domain).map(([e]) => plainWord(e.name)));
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
found = found.filter(([e, , domain]) => {
|
|
103
|
+
const key = plainWord(e.name);
|
|
104
|
+
if (seen.has(key) || (!domain && placed.has(key))) return false;
|
|
105
|
+
if (domain || !placed.has(key)) seen.add(key);
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
const realizedBy = this._patternsRealizing(parsed, elements);
|
|
109
|
+
return found.map(([e, businessArea, businessDomain]) => {
|
|
110
|
+
const pattern = realizedBy.get(e.id) ?? this._property(e, this.patternKeys);
|
|
111
|
+
return {
|
|
112
|
+
kind: ServiceDomain.kind,
|
|
113
|
+
id: e.id,
|
|
114
|
+
name: e.name ?? '',
|
|
115
|
+
...(e.documentation && { description: e.documentation }),
|
|
116
|
+
businessArea, businessDomain,
|
|
117
|
+
asset: this._property(e, this.assetKeys) ?? '',
|
|
118
|
+
pattern: BianNotation.findPattern(pattern)?.name ?? pattern ?? '',
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What each element sits in (child id -> its containers, the likeliest
|
|
125
|
+
* first): by the containment relationships, a type's before the next's
|
|
126
|
+
* (composition before aggregation), then by being drawn inside a container
|
|
127
|
+
* in a view.
|
|
128
|
+
*/
|
|
129
|
+
containers(parsed, elements = new Map((parsed.elements ?? []).map(e => [e.id, e]))) {
|
|
130
|
+
const isContainer = (id) => this.containerTypes.includes(elements.get(id)?.type);
|
|
131
|
+
const of = new Map();
|
|
132
|
+
const add = (child, container) => {
|
|
133
|
+
if (!elements.has(child) || child === container) return;
|
|
134
|
+
const list = of.get(child) ?? [];
|
|
135
|
+
if (!list.includes(container)) of.set(child, [...list, container]);
|
|
136
|
+
};
|
|
137
|
+
for (const type of this.containmentTypes) {
|
|
138
|
+
for (const r of parsed.relationships ?? []) if (r.type === type && isContainer(r.source)) add(r.target, r.source);
|
|
139
|
+
}
|
|
140
|
+
const walk = (visuals, container) => {
|
|
141
|
+
for (const v of visuals ?? []) {
|
|
142
|
+
const id = v.archimateElement;
|
|
143
|
+
if (id && container) add(id, container);
|
|
144
|
+
walk(v.children, id && isContainer(id) ? id : container);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
for (const view of parsed.views ?? []) walk(view.children, null);
|
|
148
|
+
return of;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An element's stereotypes (compared plainly): its Archi specialization's name, and any stereotype property. @private */
|
|
152
|
+
_stereotypes(parsed) {
|
|
153
|
+
const profiles = new Map((parsed.archi?.elements ?? []).filter(e => e.tag === 'profile').map(p => [p.attributes?.id, p.attributes?.name]));
|
|
154
|
+
return (element) => {
|
|
155
|
+
if (!element) return [];
|
|
156
|
+
const profile = element.archi?.attributes?.profiles;
|
|
157
|
+
return [
|
|
158
|
+
...(profile ? String(profile).split(/\s+/).map(id => profiles.get(id)).filter(Boolean) : []),
|
|
159
|
+
...String(this._property(element, this.stereotypeKeys) ?? '').split(/[,;|]/),
|
|
160
|
+
].map(plainWord).filter(Boolean);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Each service domain's pattern by what realizes it: an element named as a BIAN functional pattern. @private */
|
|
165
|
+
_patternsRealizing(parsed, elements) {
|
|
166
|
+
const patternOf = new Map();
|
|
167
|
+
for (const r of parsed.relationships ?? []) {
|
|
168
|
+
if (r.type !== 'RealizationRelationship' || patternOf.has(r.target)) continue;
|
|
169
|
+
const pattern = BianNotation.findPattern(elements.get(r.source)?.name);
|
|
170
|
+
if (pattern) patternOf.set(r.target, pattern.name);
|
|
171
|
+
}
|
|
172
|
+
return patternOf;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The first of some property keys an element has a value for. @private */
|
|
176
|
+
_property(element, keys) {
|
|
177
|
+
const props = element.properties ?? {};
|
|
178
|
+
const pairs = Array.isArray(props) ? props.map(p => [p.key, p.value]) : Object.entries(props);
|
|
179
|
+
for (const key of keys) {
|
|
180
|
+
const hit = pairs.find(([k, v]) => k === key && v);
|
|
181
|
+
if (hit) return String(hit[1]).trim();
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|