@peter.prib/renderengine 0.5.0 → 0.8.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 +4 -0
- package/REFERENCE.md +297 -17
- package/dist/renderengine-workspace.umd.js +5369 -2952
- package/dist/renderengine.umd.js +4466 -2320
- package/examples/archimate.html +8 -28
- package/examples/data-model.html +45 -556
- package/package.json +70 -70
- package/src/archimateArchiFormat.js +11 -3
- package/src/archimateExchangeFormat.js +17 -9
- package/src/chartCatalog.js +10 -0
- package/src/charting/ArchimateDiagramEditor.js +1 -1
- package/src/charting/Axis.js +20 -0
- package/src/charting/ChartChooser.js +229 -0
- package/src/charting/DiagramEditor.js +35 -3
- package/src/charting/Dialog.js +195 -0
- package/src/charting/DuplicatesDialog.js +108 -0
- package/src/charting/FormDialog.js +28 -97
- package/src/charting/InteractionManager.js +3 -0
- package/src/charting/Legend.js +45 -2
- package/src/charting/MergeCompareDialog.js +81 -0
- package/src/charting/PopupMenu.js +12 -3
- package/src/charting/SettingsDialog.js +6 -2
- package/src/charting/SettingsManager.js +14 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
- package/src/charting/archimate/ArchimatePalette.js +1 -1
- package/src/charting/charts/AreaChart.js +5 -2
- package/src/charting/charts/BarChart.js +5 -3
- package/src/charting/charts/BarChart3DOrbit.js +5 -7
- package/src/charting/charts/BubbleChart.js +5 -7
- package/src/charting/charts/BubbleChart3D.js +5 -7
- package/src/charting/charts/CandlestickChart.js +3 -1
- package/src/charting/charts/DensityChart.js +4 -6
- package/src/charting/charts/GroupedBarChart.js +2 -2
- package/src/charting/charts/LineChart.js +5 -2
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/PipeChart.js +4 -6
- package/src/charting/charts/RadarChart.js +5 -6
- package/src/charting/charts/ScatterChart.js +5 -7
- package/src/charting/charts/StackedBarChart.js +4 -2
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
- package/src/charting/charts/StreamGraphChart.js +5 -2
- package/src/charting/charts/TableChart.js +98 -0
- package/src/charting/charts/WaterfallChart.js +2 -1
- package/src/charting/content/ContentSource.js +129 -0
- package/src/charting/content/ContentSources.js +77 -0
- package/src/charting/datamodel/DataModel.js +116 -0
- package/src/charting/diagrams/ArchimateModel.js +38 -3
- package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
- package/src/charting/diagrams/MergeElementsCommand.js +124 -101
- package/src/charting/diagrams/SvgZoom.js +20 -1
- package/src/charting/diagrams/duplicates.js +95 -13
- package/src/charting/duplicateSearchForm.js +58 -0
- package/src/charting/modelTree/ModelTree.js +239 -202
- package/src/charting/modelTree/ModelTreePanel.js +955 -919
- package/src/charting/modelTree/TreeBuilder.js +93 -45
- package/src/charting/modelTree/TreeViewMode.js +9 -6
- package/src/charting/modelTree/viewFolders.js +15 -6
- package/src/icons/IconSet.js +86 -0
- package/src/icons/index.js +59 -0
- package/src/index.js +86 -10
- package/src/workspace/dom.js +56 -42
- package/src/workspace/index.js +1 -1
- package/src/workspace/nav/NavEntry.js +42 -0
- package/src/workspace/nav/NavGroup.js +43 -0
- package/src/workspace/nav/NavItem.js +53 -0
- package/src/workspace/nav/NavMenu.js +143 -0
- package/src/workspace/nav/NavSection.js +17 -0
- package/src/workspace/nav/index.js +5 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
- package/src/workspace/parts/DataModelPart.js +91 -0
- package/src/workspace/parts/EditorPart.js +8 -0
- package/src/workspace/parts/ModelHosted.js +1 -0
- package/src/workspace/parts/ModelsPart.js +765 -622
- package/src/workspace/styles.js +28 -1
package/src/workspace/dom.js
CHANGED
|
@@ -20,11 +20,20 @@
|
|
|
20
20
|
* on: { click: fn }, // addEventListener
|
|
21
21
|
* children: [spec | Node | string], // strings become text nodes
|
|
22
22
|
* ref: (element) => {}, // capture the node without querySelector
|
|
23
|
+
* ns: SVG_NS, // the element's namespace (see below)
|
|
23
24
|
* }
|
|
24
25
|
*
|
|
25
26
|
* A bare string spec produces a text node, so `children: ['plain text']` works.
|
|
27
|
+
*
|
|
28
|
+
* An `svg` spec, and everything inside it, is built in the SVG namespace
|
|
29
|
+
* (`createElementNS`) — so a drawing or an icon is a spec too, never parsed
|
|
30
|
+
* markup — until a `foreignObject`, whose children are HTML again. `ns` sets
|
|
31
|
+
* a namespace explicitly (a `path` spec built on its own, to append to an
|
|
32
|
+
* existing <svg>).
|
|
26
33
|
*/
|
|
27
|
-
export
|
|
34
|
+
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
35
|
+
|
|
36
|
+
export function el(spec, doc = document, ns = null) {
|
|
28
37
|
if (spec == null) return doc.createTextNode('');
|
|
29
38
|
if (typeof spec === 'string' || typeof spec === 'number') return doc.createTextNode(String(spec));
|
|
30
39
|
// Duck-typed rather than `instanceof Node`: `Node` is a browser global, so
|
|
@@ -32,11 +41,15 @@ export function el(spec, doc = document) {
|
|
|
32
41
|
// substituted document. A spec object never carries either of these.
|
|
33
42
|
if (typeof spec.nodeType === 'number' || typeof spec.appendChild === 'function') return spec;
|
|
34
43
|
|
|
35
|
-
const
|
|
44
|
+
const tag = spec.tag ?? 'div';
|
|
45
|
+
const space = spec.ns ?? (tag === 'svg' ? SVG_NS : ns);
|
|
46
|
+
const node = space ? doc.createElementNS(space, tag) : doc.createElement(tag);
|
|
36
47
|
|
|
37
48
|
if (spec.cls) {
|
|
38
|
-
const classes = Array.isArray(spec.cls) ? spec.cls : [spec.cls];
|
|
39
|
-
|
|
49
|
+
const classes = (Array.isArray(spec.cls) ? spec.cls : [spec.cls]).filter(Boolean).join(' ');
|
|
50
|
+
// An SVG element's className is an SVGAnimatedString, not a string.
|
|
51
|
+
if (space) node.setAttribute('class', classes);
|
|
52
|
+
else node.className = classes;
|
|
40
53
|
}
|
|
41
54
|
if (spec.text != null) node.textContent = String(spec.text);
|
|
42
55
|
|
|
@@ -55,8 +68,9 @@ export function el(spec, doc = document) {
|
|
|
55
68
|
for (const [event, handler] of Object.entries(spec.on ?? {})) {
|
|
56
69
|
if (typeof handler === 'function') node.addEventListener(event, handler);
|
|
57
70
|
}
|
|
71
|
+
const childSpace = space && tag !== 'foreignObject' ? space : null;
|
|
58
72
|
for (const child of spec.children ?? []) {
|
|
59
|
-
node.appendChild(el(child, doc));
|
|
73
|
+
node.appendChild(el(child, doc, childSpace));
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
spec.ref?.(node);
|
|
@@ -81,40 +95,40 @@ export function render(host, specs, doc = document) {
|
|
|
81
95
|
function cssProp(key) {
|
|
82
96
|
return key.startsWith('--') ? key : key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
|
|
83
97
|
}
|
|
84
|
-
|
|
85
|
-
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
86
|
-
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* The same spec as el(), written out as HTML text: for a page that is saved
|
|
90
|
-
* rather than shown (a report). Text and attribute values are escaped; `on`
|
|
91
|
-
* and `ref` have no meaning in text and are left out. `script` is the one
|
|
92
|
-
* extra field: the trusted contents of a <script> or <style> (not escaped,
|
|
93
|
-
* but `</` is, so it cannot close its element early).
|
|
94
|
-
*/
|
|
95
|
-
export function toHtml(spec) {
|
|
96
|
-
if (spec == null) return '';
|
|
97
|
-
if (typeof spec === 'string' || typeof spec === 'number') return escapeHtml(spec);
|
|
98
|
-
if (Array.isArray(spec)) return spec.map(toHtml).join('');
|
|
99
|
-
const tag = spec.tag ?? 'div';
|
|
100
|
-
const attrs = [];
|
|
101
|
-
if (spec.cls) {
|
|
102
|
-
const classes = (Array.isArray(spec.cls) ? spec.cls : [spec.cls]).filter(Boolean);
|
|
103
|
-
if (classes.length) attrs.push(['class', classes.join(' ')]);
|
|
104
|
-
}
|
|
105
|
-
for (const [k, v] of Object.entries(spec.attrs ?? {})) {
|
|
106
|
-
if (v == null || v === false) continue;
|
|
107
|
-
attrs.push([k, v === true ? '' : String(v)]);
|
|
108
|
-
}
|
|
109
|
-
for (const [k, v] of Object.entries(spec.dataset ?? {})) {
|
|
110
|
-
if (v != null) attrs.push([`data-${cssProp(k)}`, String(v)]);
|
|
111
|
-
}
|
|
112
|
-
const style = Object.entries(spec.style ?? {}).filter(([, v]) => v != null).map(([k, v]) => `${cssProp(k)}: ${v}`).join('; ');
|
|
113
|
-
if (style) attrs.push(['style', style]);
|
|
114
|
-
const open = `<${tag}${attrs.map(([k, v]) => ` ${k}="${escapeHtml(v)}"`).join('')}>`;
|
|
115
|
-
if (VOID_TAGS.has(tag)) return open;
|
|
116
|
-
const inner = spec.script != null
|
|
117
|
-
? String(spec.script).replace(/<\//g, '<\\/')
|
|
118
|
-
: (spec.text != null ? escapeHtml(spec.text) : '') + (spec.children ?? []).map(toHtml).join('');
|
|
119
|
-
return `${open}${inner}</${tag}>`;
|
|
120
|
-
}
|
|
98
|
+
|
|
99
|
+
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
100
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The same spec as el(), written out as HTML text: for a page that is saved
|
|
104
|
+
* rather than shown (a report). Text and attribute values are escaped; `on`
|
|
105
|
+
* and `ref` have no meaning in text and are left out. `script` is the one
|
|
106
|
+
* extra field: the trusted contents of a <script> or <style> (not escaped,
|
|
107
|
+
* but `</` is, so it cannot close its element early).
|
|
108
|
+
*/
|
|
109
|
+
export function toHtml(spec) {
|
|
110
|
+
if (spec == null) return '';
|
|
111
|
+
if (typeof spec === 'string' || typeof spec === 'number') return escapeHtml(spec);
|
|
112
|
+
if (Array.isArray(spec)) return spec.map(toHtml).join('');
|
|
113
|
+
const tag = spec.tag ?? 'div';
|
|
114
|
+
const attrs = [];
|
|
115
|
+
if (spec.cls) {
|
|
116
|
+
const classes = (Array.isArray(spec.cls) ? spec.cls : [spec.cls]).filter(Boolean);
|
|
117
|
+
if (classes.length) attrs.push(['class', classes.join(' ')]);
|
|
118
|
+
}
|
|
119
|
+
for (const [k, v] of Object.entries(spec.attrs ?? {})) {
|
|
120
|
+
if (v == null || v === false) continue;
|
|
121
|
+
attrs.push([k, v === true ? '' : String(v)]);
|
|
122
|
+
}
|
|
123
|
+
for (const [k, v] of Object.entries(spec.dataset ?? {})) {
|
|
124
|
+
if (v != null) attrs.push([`data-${cssProp(k)}`, String(v)]);
|
|
125
|
+
}
|
|
126
|
+
const style = Object.entries(spec.style ?? {}).filter(([, v]) => v != null).map(([k, v]) => `${cssProp(k)}: ${v}`).join('; ');
|
|
127
|
+
if (style) attrs.push(['style', style]);
|
|
128
|
+
const open = `<${tag}${attrs.map(([k, v]) => ` ${k}="${escapeHtml(v)}"`).join('')}>`;
|
|
129
|
+
if (VOID_TAGS.has(tag)) return open;
|
|
130
|
+
const inner = spec.script != null
|
|
131
|
+
? String(spec.script).replace(/<\//g, '<\\/')
|
|
132
|
+
: (spec.text != null ? escapeHtml(spec.text) : '') + (spec.children ?? []).map(toHtml).join('');
|
|
133
|
+
return `${open}${inner}</${tag}>`;
|
|
134
|
+
}
|
package/src/workspace/index.js
CHANGED
|
@@ -42,7 +42,7 @@ export {
|
|
|
42
42
|
ContextBus, Part, PartRegistry, PartFactory,
|
|
43
43
|
ContentPart, HtmlPart, ListPart, DetailPart, AdapterPart, ModelsPart,
|
|
44
44
|
};
|
|
45
|
-
export { el, frag, render } from './dom.js';
|
|
45
|
+
export { el, frag, render, SVG_NS } from './dom.js';
|
|
46
46
|
|
|
47
47
|
// The engine's whole public API, re-exported so the workspace UMD bundle is a
|
|
48
48
|
// superset of the main one: a page that loads renderengine-workspace.umd.js
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavEntry — one entry in a NavMenu: an item, a group of items, or a section
|
|
3
|
+
* heading. Subclasses return an el() spec; the menu builds every entry from
|
|
4
|
+
* JSON (NavMenu.entry), so a host writes its navigation as data, not markup.
|
|
5
|
+
*
|
|
6
|
+
* `visible` is a boolean or a function of the menu's context (`{ role }`, …),
|
|
7
|
+
* so one definition serves every user and the menu re-renders when the
|
|
8
|
+
* context changes (NavMenu#setContext).
|
|
9
|
+
*/
|
|
10
|
+
export class NavEntry {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} [options]
|
|
13
|
+
* @param {string} [options.id]
|
|
14
|
+
* @param {string|string[]} [options.cls] - Extra class names
|
|
15
|
+
* @param {string} [options.title] - Tooltip
|
|
16
|
+
* @param {boolean|((context: Object) => boolean)} [options.visible]
|
|
17
|
+
*/
|
|
18
|
+
constructor({ id = null, cls = null, title = null, visible = true } = {}) {
|
|
19
|
+
this.id = id;
|
|
20
|
+
this.cls = cls;
|
|
21
|
+
this.title = title;
|
|
22
|
+
this.visible = visible;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether the entry shows, given the menu's context. */
|
|
26
|
+
isVisible(menu) {
|
|
27
|
+
return typeof this.visible === 'function' ? !!this.visible(menu.context) : this.visible !== false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @returns {Object} el() spec */
|
|
31
|
+
spec(menu) {
|
|
32
|
+
throw new Error(`${this.constructor.name} must implement spec()`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The entries this one holds (a group's items); none by default. */
|
|
36
|
+
get children() { return []; }
|
|
37
|
+
|
|
38
|
+
/** Extra class names as a list, for el()'s `cls`. */
|
|
39
|
+
extraClasses() {
|
|
40
|
+
return Array.isArray(this.cls) ? this.cls : this.cls ? [this.cls] : [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NavEntry } from './NavEntry.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NavGroup — entries under a heading that opens and closes (a `<details>`).
|
|
5
|
+
* Whether it is open is state the menu keeps (NavMenu#serialize) and reports
|
|
6
|
+
* (`onToggle`), so a host can remember it. Hidden when none of its entries
|
|
7
|
+
* shows.
|
|
8
|
+
*/
|
|
9
|
+
export class NavGroup extends NavEntry {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string} options.group - The heading's text
|
|
13
|
+
* @param {Array<Object|NavEntry>} options.items - Entries, as JSON or built
|
|
14
|
+
* @param {boolean} [options.open]
|
|
15
|
+
* @param {(json: Object) => NavEntry} entry - Builds an entry from JSON (NavMenu.entry)
|
|
16
|
+
*/
|
|
17
|
+
constructor({ group, items = [], open = false, ...rest }, entry) {
|
|
18
|
+
super(rest);
|
|
19
|
+
this.label = group;
|
|
20
|
+
this.open = !!open;
|
|
21
|
+
this.items = items.map(entry);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get children() { return this.items; }
|
|
25
|
+
|
|
26
|
+
isVisible(menu) {
|
|
27
|
+
return super.isVisible(menu) && this.items.some(e => e.isVisible(menu));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
spec(menu) {
|
|
31
|
+
return {
|
|
32
|
+
tag: 'details',
|
|
33
|
+
cls: ['ws-nav-group', ...this.extraClasses()],
|
|
34
|
+
attrs: { open: this.open },
|
|
35
|
+
dataset: { navId: this.id },
|
|
36
|
+
children: [
|
|
37
|
+
{ tag: 'summary', cls: 'ws-nav-group-summary', text: this.label, attrs: { title: this.title } },
|
|
38
|
+
...menu.specsOf(this.items),
|
|
39
|
+
],
|
|
40
|
+
on: { toggle: (event) => menu.toggled(this, !!event.target.open) },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NavEntry } from './NavEntry.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NavItem — a place to go or a thing to do. With `href` it is a link (the
|
|
5
|
+
* browser navigates, and the address can be opened in a new tab); without, a
|
|
6
|
+
* button. `action(event, item, menu)` runs on a click either way, and may
|
|
7
|
+
* call `event.preventDefault()` to keep a link from navigating — e.g. a
|
|
8
|
+
* modifier-click that opens the target beside the current view instead.
|
|
9
|
+
*/
|
|
10
|
+
export class NavItem extends NavEntry {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {string} options.label
|
|
14
|
+
* @param {string} [options.icon] - Shown before the label, hidden from screen readers: an icon's
|
|
15
|
+
* name in the menu's IconSet (`icons`), or text (an emoji)
|
|
16
|
+
* @param {string|((context: Object) => string|null)} [options.href] - Or a function of the menu's context (an address known later)
|
|
17
|
+
* @param {(event: Event, item: NavItem, menu: NavMenu) => void} [options.action]
|
|
18
|
+
* @param {boolean} [options.selectable] - Whether a click makes it the active item (default: true)
|
|
19
|
+
*/
|
|
20
|
+
constructor({ label, icon = null, href = null, action = null, selectable = true, ...rest }) {
|
|
21
|
+
super(rest);
|
|
22
|
+
this.label = label;
|
|
23
|
+
this.icon = icon;
|
|
24
|
+
this.href = href;
|
|
25
|
+
this.action = action;
|
|
26
|
+
this.selectable = selectable;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
spec(menu) {
|
|
30
|
+
const active = this.id != null && this.id === menu.activeId;
|
|
31
|
+
const href = typeof this.href === 'function' ? this.href(menu.context) : this.href;
|
|
32
|
+
return {
|
|
33
|
+
tag: href != null ? 'a' : 'button',
|
|
34
|
+
cls: ['ws-nav-item', active && 'ws-nav-item-active', ...this.extraClasses()],
|
|
35
|
+
attrs: {
|
|
36
|
+
href,
|
|
37
|
+
type: href != null ? null : 'button',
|
|
38
|
+
title: this.title,
|
|
39
|
+
'aria-current': active ? 'page' : null,
|
|
40
|
+
},
|
|
41
|
+
dataset: { navId: this.id },
|
|
42
|
+
children: [
|
|
43
|
+
this.icon != null && {
|
|
44
|
+
tag: 'span', cls: 'ws-nav-icon', attrs: { 'aria-hidden': 'true' },
|
|
45
|
+
...(menu.icons?.has(this.icon) ? { children: [menu.icons.spec(this.icon, { size: 16 })] } : { text: this.icon }),
|
|
46
|
+
},
|
|
47
|
+
{ tag: 'span', cls: 'ws-nav-label', text: this.label },
|
|
48
|
+
].filter(Boolean),
|
|
49
|
+
on: { click: (event) => menu.select(this, event) },
|
|
50
|
+
ref: (n) => menu.nodes.set(this, n),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { render as renderSpecs } from '../dom.js';
|
|
2
|
+
import { NavEntry } from './NavEntry.js';
|
|
3
|
+
import { NavItem } from './NavItem.js';
|
|
4
|
+
import { NavGroup } from './NavGroup.js';
|
|
5
|
+
import { NavSection } from './NavSection.js';
|
|
6
|
+
import { ICONS } from '../../icons/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* NavMenu — a vertical navigation menu (a sidebar's), written as data:
|
|
10
|
+
*
|
|
11
|
+
* new NavMenu({
|
|
12
|
+
* label: 'Workbench',
|
|
13
|
+
* items: [
|
|
14
|
+
* { section: 'Notebooks' },
|
|
15
|
+
* { id: 'new-nb', label: '+ New Notebook', action: newNotebook, selectable: false },
|
|
16
|
+
* { group: 'Dev Tools', id: 'devtools', items: [
|
|
17
|
+
* { id: 'datamodel', icon: '◫', label: 'Data Model', action: (e) => open('datamodel', e) },
|
|
18
|
+
* ] },
|
|
19
|
+
* { id: 'admin', icon: '⚙', label: 'Admin', href: '/admin', visible: (ctx) => ctx.role === 'admin' },
|
|
20
|
+
* ],
|
|
21
|
+
* context: { role },
|
|
22
|
+
* }).render(host);
|
|
23
|
+
*
|
|
24
|
+
* An entry is an item (`label`), a collapsible group (`group`, `items`) or a
|
|
25
|
+
* section heading (`section`) — NavItem, NavGroup, NavSection, built from the
|
|
26
|
+
* JSON by NavMenu.entry. The menu keeps which item is active (`activeId`,
|
|
27
|
+
* marked with `aria-current`) and which groups are open; serialize() returns
|
|
28
|
+
* both as JSON for a host to keep, and the constructor takes them back.
|
|
29
|
+
*/
|
|
30
|
+
export class NavMenu {
|
|
31
|
+
/**
|
|
32
|
+
* @param {Object} options
|
|
33
|
+
* @param {Array<Object|NavEntry>} options.items
|
|
34
|
+
* @param {string} [options.label] - The menu's accessible name
|
|
35
|
+
* @param {string} [options.activeId]
|
|
36
|
+
* @param {Object<string, boolean>} [options.open] - Group id → open, overriding each group's own
|
|
37
|
+
* @param {Object} [options.context] - What `visible` functions read (`{ role }`)
|
|
38
|
+
* @param {(item: NavItem, event: Event) => void} [options.onSelect] - After an item's own action
|
|
39
|
+
* @param {(groupId: string, open: boolean) => void} [options.onToggle]
|
|
40
|
+
* @param {import('../../icons/IconSet.js').IconSet} [options.icons] - Where an item's `icon` is looked up (default: the built-in ICONS)
|
|
41
|
+
* @param {Document} [options.document]
|
|
42
|
+
*/
|
|
43
|
+
constructor({ items = [], label = null, activeId = null, open = {}, context = {}, onSelect = null, onToggle = null, icons = ICONS, document: doc = globalThis.document } = {}) {
|
|
44
|
+
this.entries = items.map(NavMenu.entry);
|
|
45
|
+
this.label = label;
|
|
46
|
+
this.activeId = activeId;
|
|
47
|
+
this.context = context;
|
|
48
|
+
this.onSelect = onSelect;
|
|
49
|
+
this.onToggle = onToggle;
|
|
50
|
+
this.icons = icons;
|
|
51
|
+
this.doc = doc;
|
|
52
|
+
this.host = null;
|
|
53
|
+
/** Rendered item → its element, for marking the active one in place. */
|
|
54
|
+
this.nodes = new Map();
|
|
55
|
+
for (const group of this.groups()) {
|
|
56
|
+
if (group.id != null && group.id in open) group.open = !!open[group.id];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An entry from its JSON: a group, a section heading or an item. */
|
|
61
|
+
static entry(json) {
|
|
62
|
+
if (json instanceof NavEntry) return json;
|
|
63
|
+
if (json == null || typeof json !== 'object') throw new Error(`NavMenu: an entry is an object, not ${json}`);
|
|
64
|
+
if ('group' in json) return new NavGroup(json, NavMenu.entry);
|
|
65
|
+
if ('section' in json) return new NavSection(json);
|
|
66
|
+
if (json.label == null) throw new Error(`NavMenu: an item needs a label (or "group", or "section")`);
|
|
67
|
+
return new NavItem(json);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Render into `host`, replacing what it held. */
|
|
71
|
+
render(host = this.host) {
|
|
72
|
+
if (!host) throw new Error('NavMenu: no host to render into');
|
|
73
|
+
this.host = host;
|
|
74
|
+
this.nodes = new Map();
|
|
75
|
+
renderSpecs(host, {
|
|
76
|
+
tag: 'nav',
|
|
77
|
+
cls: 'ws-nav',
|
|
78
|
+
attrs: { 'aria-label': this.label },
|
|
79
|
+
children: this.specsOf(this.entries),
|
|
80
|
+
}, this.doc);
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The specs of the entries that show. */
|
|
85
|
+
specsOf(entries) {
|
|
86
|
+
return entries.filter(e => e.isVisible(this)).map(e => e.spec(this));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A click on `item`: its action, then (if it is selectable) it becomes active. */
|
|
90
|
+
select(item, event) {
|
|
91
|
+
item.action?.(event, item, this);
|
|
92
|
+
if (item.selectable && item.id != null) this.setActive(item.id);
|
|
93
|
+
this.onSelect?.(item, event);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Mark the item `id` as the active one (null: none), in place. */
|
|
97
|
+
setActive(id) {
|
|
98
|
+
this.activeId = id;
|
|
99
|
+
for (const [item, node] of this.nodes) {
|
|
100
|
+
const on = id != null && item.id === id;
|
|
101
|
+
node.classList.toggle('ws-nav-item-active', on);
|
|
102
|
+
if (on) node.setAttribute('aria-current', 'page');
|
|
103
|
+
else node.removeAttribute('aria-current');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A group opened or closed (by the user, or by setOpen). */
|
|
108
|
+
toggled(group, open) {
|
|
109
|
+
if (group.open === open) return;
|
|
110
|
+
group.open = open;
|
|
111
|
+
this.onToggle?.(group.id, open);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Change what `visible` functions read, and re-render if rendered. */
|
|
115
|
+
setContext(context) {
|
|
116
|
+
this.context = { ...this.context, ...context };
|
|
117
|
+
if (this.host) this.render();
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The entry with this id, anywhere in the menu. */
|
|
122
|
+
find(id) {
|
|
123
|
+
return this.all().find(e => e.id === id) ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every entry, depth first. */
|
|
127
|
+
all(entries = this.entries, out = []) {
|
|
128
|
+
for (const e of entries) { out.push(e); this.all(e.children, out); }
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
groups() {
|
|
133
|
+
return this.all().filter(e => e instanceof NavGroup);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The menu's state as JSON: the active item and each group's open state. */
|
|
137
|
+
serialize() {
|
|
138
|
+
return {
|
|
139
|
+
activeId: this.activeId,
|
|
140
|
+
open: Object.fromEntries(this.groups().filter(g => g.id != null).map(g => [g.id, g.open])),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NavEntry } from './NavEntry.js';
|
|
2
|
+
|
|
3
|
+
/** NavSection — a heading between entries ("Notebooks"); not clickable. */
|
|
4
|
+
export class NavSection extends NavEntry {
|
|
5
|
+
/**
|
|
6
|
+
* @param {Object} options
|
|
7
|
+
* @param {string} options.section - The heading's text
|
|
8
|
+
*/
|
|
9
|
+
constructor({ section, ...rest }) {
|
|
10
|
+
super(rest);
|
|
11
|
+
this.label = section;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
spec() {
|
|
15
|
+
return { cls: ['ws-nav-section', ...this.extraClasses()], text: this.label, attrs: { role: 'heading', title: this.title } };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -57,8 +57,13 @@ export class ArchimateCanvasPart extends EditorPart {
|
|
|
57
57
|
/** Whether a line to a box nested in its source is drawn (Archi leaves it out). */
|
|
58
58
|
this.showNestedLines = !!spec.showNestedLines;
|
|
59
59
|
this.settings = new SettingsManager();
|
|
60
|
-
// A settings change (Model settings…, a view's properties) redraws the canvas.
|
|
61
|
-
this._unsubscribeSettings = this.settings.onSettingsChange(() => {
|
|
60
|
+
// A settings change (Model settings…, a view's properties) redraws the canvas; the tool's, the tree too.
|
|
61
|
+
this._unsubscribeSettings = this.settings.onSettingsChange((event) => {
|
|
62
|
+
if (event?.type === 'tool') this.tree?.setOptions(this.settings.treeOptions());
|
|
63
|
+
if (!this.mounted) return;
|
|
64
|
+
this.editor?.render();
|
|
65
|
+
if (event?.type === 'tool') this._treePanel?.refresh();
|
|
66
|
+
});
|
|
62
67
|
// The user's specializations (or the defaults), as the main editor offers them; one already loaded is kept.
|
|
63
68
|
if (!ArchimateSpecializationRegistry.list().length) ArchimateSpecializationRegistry.loadFrom();
|
|
64
69
|
this._follow(this.model);
|
|
@@ -124,7 +129,7 @@ export class ArchimateCanvasPart extends EditorPart {
|
|
|
124
129
|
/** The model tree over the current model (kept across renders, so its mode and selection stay). */
|
|
125
130
|
get modelTree() {
|
|
126
131
|
if (this.tree?.model !== this.model) {
|
|
127
|
-
this.tree = new ModelTree(this.model, { mode: 'byLayer' });
|
|
132
|
+
this.tree = new ModelTree(this.model, { mode: 'byLayer', ...this.settings.treeOptions() });
|
|
128
133
|
// A view chosen in the tree opens it; an element chosen there is selected on the canvas.
|
|
129
134
|
this.tree.onSelectionChange((data) => {
|
|
130
135
|
if (data.kind === 'view' && data.view.id !== this.model.currentViewId) this.model.switchTo(data.view.id);
|
|
@@ -4,6 +4,9 @@ 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
6
|
import { ModelCheckDialog } from '../../charting/ModelCheckDialog.js';
|
|
7
|
+
import { duplicateSearchFields, duplicateSearchOptions, validateDuplicateSearch } from '../../charting/duplicateSearchForm.js';
|
|
8
|
+
import { DuplicatesDialog } from '../../charting/DuplicatesDialog.js';
|
|
9
|
+
import { MergeCompareDialog } from '../../charting/MergeCompareDialog.js';
|
|
7
10
|
import { DataModelNotation } from '../../datamodel.js';
|
|
8
11
|
import {
|
|
9
12
|
DataModel, DataModelPerspective, DataModelContext, DataModelFileFormat, ServiceDomain, ServiceLandscape, BianLandscapeReader,
|
|
@@ -364,6 +367,7 @@ export class DataModelPart extends ModelHosted(ChartPart) {
|
|
|
364
367
|
{ label: 'Delete context', disabled: !this.context || undefined, action: () => this.removeContext(this.contextId) },
|
|
365
368
|
{ separator: true },
|
|
366
369
|
{ label: 'Check model…', action: () => this.checkModel() },
|
|
370
|
+
...this.modelMenuItems(),
|
|
367
371
|
{ label: 'Scorecard…', action: () => this.scoreModel() },
|
|
368
372
|
...EditorPart.menuGroup('Naming standard', this.namingItems()),
|
|
369
373
|
{ label: 'Classification scheme…', action: () => this.editClassificationScheme() },
|
|
@@ -509,6 +513,93 @@ export class DataModelPart extends ModelHosted(ChartPart) {
|
|
|
509
513
|
return findings;
|
|
510
514
|
}
|
|
511
515
|
|
|
516
|
+
/** What can be done to the whole model: Find likely duplicates… (the Tools menu and the model's tab show it too). */
|
|
517
|
+
modelMenuItems() {
|
|
518
|
+
return [{ label: 'Find likely duplicates…', action: () => this.askDuplicates() }];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Find likely duplicates…: at which level (the one in front first, when it
|
|
523
|
+
* is one), how to compare (names, definitions or attributes; exactly or
|
|
524
|
+
* by their words) and what noise to leave out (duplicateSearchFields),
|
|
525
|
+
* then the groups found, each with Merge… (showDuplicates).
|
|
526
|
+
*/
|
|
527
|
+
askDuplicates() {
|
|
528
|
+
const levels = DataModel.LEVELS.filter(l => this.model.level(l).entities.length > 1);
|
|
529
|
+
const ordered = levels.includes(this.level) ? [this.level, ...levels.filter(l => l !== this.level)] : levels;
|
|
530
|
+
const entities = levels.flatMap(l => this.model.level(l).entities);
|
|
531
|
+
const properties = [...new Set(entities.flatMap(e => (e.properties ?? []).map(p => p.name ?? p.key)).filter(Boolean))].sort();
|
|
532
|
+
return new FormDialog({
|
|
533
|
+
title: 'Find likely duplicates',
|
|
534
|
+
description: 'Entities at one level are compared by their words: case, punctuation, word order and the noise words do not count.',
|
|
535
|
+
fields: duplicateSearchFields({
|
|
536
|
+
scope: { label: 'Level', choices: (ordered.length ? ordered : [this.level]).map(l => [l, DataModelNotation.level(l)?.label ?? l]) },
|
|
537
|
+
compare: [['name', 'Names'], ['documentation', 'Definitions'], ['attributes', 'Attributes']],
|
|
538
|
+
properties,
|
|
539
|
+
}).filter(f => f.key !== 'acrossTypes'),
|
|
540
|
+
submitLabel: 'Find', idPrefix: 'duplicates-',
|
|
541
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
542
|
+
validate: validateDuplicateSearch,
|
|
543
|
+
onApply: (values) => {
|
|
544
|
+
const { options, scope } = duplicateSearchOptions(values);
|
|
545
|
+
this.showDuplicates(scope, options);
|
|
546
|
+
},
|
|
547
|
+
}).open();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The entities at `level` that look like one thing twice (DataModel#duplicateEntities), listed, each group with Merge…. */
|
|
551
|
+
showDuplicates(level, options = {}) {
|
|
552
|
+
const find = () => this.model.duplicateEntities(level, options);
|
|
553
|
+
const groups = find();
|
|
554
|
+
new DuplicatesDialog({
|
|
555
|
+
groups,
|
|
556
|
+
refind: find,
|
|
557
|
+
merge: async (group) => !!(await this.askMergeEntities(group.level, group.ids)),
|
|
558
|
+
onReveal: (group) => this.revealFinding(group),
|
|
559
|
+
empty: 'No entities look like the same thing twice.',
|
|
560
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
561
|
+
}).open();
|
|
562
|
+
return groups;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Merge some entities at a level into one (DataModel#mergeEntitiesCommand),
|
|
567
|
+
* one undo step. When they differ, they are shown side by side
|
|
568
|
+
* (DataModel#entityComparison, MergeCompareDialog) to choose which is kept
|
|
569
|
+
* and, when their definitions differ, which (or all of them joined); OK
|
|
570
|
+
* merges, Cancel leaves them. Alike, the first is kept without asking.
|
|
571
|
+
* Resolves to the merge's command, or null.
|
|
572
|
+
*/
|
|
573
|
+
async askMergeEntities(level, ids) {
|
|
574
|
+
const schema = this.model.level(level);
|
|
575
|
+
const entities = ids.map(id => schema.entity(id)).filter(Boolean);
|
|
576
|
+
if (entities.length < 2) return null;
|
|
577
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
578
|
+
const used = (e) => `${count((e.attributes ?? []).length, 'attribute')}, ${count(schema.relationships.filter(r => r.source === e.id || r.target === e.id).length, 'relationship')}`;
|
|
579
|
+
const kept = entities.map(e => e.id);
|
|
580
|
+
const merge = (keep, choices = {}) => this.runModelEdit(this.model.mergeEntitiesCommand(level, keep, kept.filter(id => id !== keep), choices));
|
|
581
|
+
const rows = this.model.entityComparison(level, kept);
|
|
582
|
+
if (!rows.some(row => MergeCompareDialog.differs(row))) return merge(kept[0]);
|
|
583
|
+
const texts = [...new Set(entities.map(e => String(e.description ?? '').trim()).filter(Boolean))];
|
|
584
|
+
const JOINED = '\u0000joined';
|
|
585
|
+
const values = await new MergeCompareDialog({
|
|
586
|
+
title: `Merge ${entities.length} entities`,
|
|
587
|
+
description: 'The one kept takes the others\' attributes it lacks, their relationships and their mappings; the others go. Where they differ (marked ≠), choose.',
|
|
588
|
+
columns: entities.map(e => `${e.name || e.id} (${used(e)})`),
|
|
589
|
+
rows,
|
|
590
|
+
fields: [
|
|
591
|
+
{ key: 'keep', label: 'Keep', type: 'select', value: kept[0], choices: entities.map(e => [e.id, `${e.name || e.id} (${used(e)})`]) },
|
|
592
|
+
...(texts.length > 1 ? [{
|
|
593
|
+
key: 'description', label: 'Definition', type: 'select', value: JOINED,
|
|
594
|
+
choices: [[JOINED, 'All of them, joined'], ...texts.map(t => [t, t.length > 60 ? `${t.slice(0, 60)}…` : t])],
|
|
595
|
+
}] : []),
|
|
596
|
+
],
|
|
597
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
598
|
+
}).ask();
|
|
599
|
+
if (!values) return null;
|
|
600
|
+
return merge(values.keep, texts.length > 1 ? { description: values.description === JOINED ? texts.join('\n\n') : values.description } : {});
|
|
601
|
+
}
|
|
602
|
+
|
|
512
603
|
/** Show what a finding names: an entity's level, a service domain seen through at the logical level, the mapping or data types view. */
|
|
513
604
|
revealFinding(finding) {
|
|
514
605
|
if (finding.kind === 'perspective') {
|
|
@@ -181,9 +181,16 @@ export class EditorPart extends Part {
|
|
|
181
181
|
{ label: 'Transform names…', action: () => this.askTransformNames() },
|
|
182
182
|
...(this.model.search().propertyNames().length ? [{ label: 'Rename property…', action: () => this.askRenameProperty() }] : []),
|
|
183
183
|
] : []),
|
|
184
|
+
// The editor's own edits of the whole model (Find likely duplicates…), when no Tools menu has them.
|
|
185
|
+
...(() => { const own = this.modelsHost ? [] : this.modelMenuItems(); return own.length ? [{ separator: true }, ...own] : []; })(),
|
|
184
186
|
];
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
/** What can be done to the whole model (Find likely duplicates…): the editor's; the Tools menu and the model's tab show them. */
|
|
190
|
+
modelMenuItems() {
|
|
191
|
+
return this.editor?.modelMenuItems?.() ?? [];
|
|
192
|
+
}
|
|
193
|
+
|
|
187
194
|
/** Run a model-wide edit (null: nothing to do; a rename, a merge) as one undo step, then redraw. */
|
|
188
195
|
runModelEdit(command) {
|
|
189
196
|
if (!command) return null;
|
|
@@ -305,6 +312,7 @@ export class EditorPart extends Part {
|
|
|
305
312
|
{ separator: true },
|
|
306
313
|
...some(open, models['Open… in a new tab'], ...saving, models['Duplicate model'], models['Close model']),
|
|
307
314
|
...EditorPart.menuGroup(this.modelsHost?.keptWhere ?? 'This browser', this.modelsHost?.keepItems?.(this) ?? []),
|
|
315
|
+
...(this.modelsHost?.contentItems ? [{ label: 'Open from', items: this.modelsHost.contentItems() }] : []),
|
|
308
316
|
{ separator: true },
|
|
309
317
|
...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
|
|
310
318
|
...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
|
|
@@ -114,6 +114,7 @@ export const ModelHosted = (Base) => class extends Base {
|
|
|
114
114
|
{ label: 'Duplicate model', action: () => host.duplicate(this) },
|
|
115
115
|
{ label: 'Close model', action: () => host.closeModel(this.id) },
|
|
116
116
|
...EditorPart.menuGroup(host.keptWhere ?? 'This browser', host.keepItems?.(this) ?? []),
|
|
117
|
+
...(host.contentItems ? [{ label: 'Open from', items: host.contentItems() }] : []),
|
|
117
118
|
] : []),
|
|
118
119
|
]);
|
|
119
120
|
}
|