@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.
Files changed (74) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +297 -17
  3. package/dist/renderengine-workspace.umd.js +5369 -2952
  4. package/dist/renderengine.umd.js +4466 -2320
  5. package/examples/archimate.html +8 -28
  6. package/examples/data-model.html +45 -556
  7. package/package.json +70 -70
  8. package/src/archimateArchiFormat.js +11 -3
  9. package/src/archimateExchangeFormat.js +17 -9
  10. package/src/chartCatalog.js +10 -0
  11. package/src/charting/ArchimateDiagramEditor.js +1 -1
  12. package/src/charting/Axis.js +20 -0
  13. package/src/charting/ChartChooser.js +229 -0
  14. package/src/charting/DiagramEditor.js +35 -3
  15. package/src/charting/Dialog.js +195 -0
  16. package/src/charting/DuplicatesDialog.js +108 -0
  17. package/src/charting/FormDialog.js +28 -97
  18. package/src/charting/InteractionManager.js +3 -0
  19. package/src/charting/Legend.js +45 -2
  20. package/src/charting/MergeCompareDialog.js +81 -0
  21. package/src/charting/PopupMenu.js +12 -3
  22. package/src/charting/SettingsDialog.js +6 -2
  23. package/src/charting/SettingsManager.js +14 -1
  24. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  25. package/src/charting/archimate/ArchimatePalette.js +1 -1
  26. package/src/charting/charts/AreaChart.js +5 -2
  27. package/src/charting/charts/BarChart.js +5 -3
  28. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  29. package/src/charting/charts/BubbleChart.js +5 -7
  30. package/src/charting/charts/BubbleChart3D.js +5 -7
  31. package/src/charting/charts/CandlestickChart.js +3 -1
  32. package/src/charting/charts/DensityChart.js +4 -6
  33. package/src/charting/charts/GroupedBarChart.js +2 -2
  34. package/src/charting/charts/LineChart.js +5 -2
  35. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  36. package/src/charting/charts/PipeChart.js +4 -6
  37. package/src/charting/charts/RadarChart.js +5 -6
  38. package/src/charting/charts/ScatterChart.js +5 -7
  39. package/src/charting/charts/StackedBarChart.js +4 -2
  40. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  41. package/src/charting/charts/StreamGraphChart.js +5 -2
  42. package/src/charting/charts/TableChart.js +98 -0
  43. package/src/charting/charts/WaterfallChart.js +2 -1
  44. package/src/charting/content/ContentSource.js +129 -0
  45. package/src/charting/content/ContentSources.js +77 -0
  46. package/src/charting/datamodel/DataModel.js +116 -0
  47. package/src/charting/diagrams/ArchimateModel.js +38 -3
  48. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  49. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  50. package/src/charting/diagrams/SvgZoom.js +20 -1
  51. package/src/charting/diagrams/duplicates.js +95 -13
  52. package/src/charting/duplicateSearchForm.js +58 -0
  53. package/src/charting/modelTree/ModelTree.js +239 -202
  54. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  55. package/src/charting/modelTree/TreeBuilder.js +93 -45
  56. package/src/charting/modelTree/TreeViewMode.js +9 -6
  57. package/src/charting/modelTree/viewFolders.js +15 -6
  58. package/src/icons/IconSet.js +86 -0
  59. package/src/icons/index.js +59 -0
  60. package/src/index.js +86 -10
  61. package/src/workspace/dom.js +56 -42
  62. package/src/workspace/index.js +1 -1
  63. package/src/workspace/nav/NavEntry.js +42 -0
  64. package/src/workspace/nav/NavGroup.js +43 -0
  65. package/src/workspace/nav/NavItem.js +53 -0
  66. package/src/workspace/nav/NavMenu.js +143 -0
  67. package/src/workspace/nav/NavSection.js +17 -0
  68. package/src/workspace/nav/index.js +5 -0
  69. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  70. package/src/workspace/parts/DataModelPart.js +91 -0
  71. package/src/workspace/parts/EditorPart.js +8 -0
  72. package/src/workspace/parts/ModelHosted.js +1 -0
  73. package/src/workspace/parts/ModelsPart.js +765 -622
  74. package/src/workspace/styles.js +28 -1
@@ -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 function el(spec, doc = document) {
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 node = doc.createElement(spec.tag ?? 'div');
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
- node.className = classes.filter(Boolean).join(' ');
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export { NavEntry } from './NavEntry.js';
2
+ export { NavItem } from './NavItem.js';
3
+ export { NavGroup } from './NavGroup.js';
4
+ export { NavSection } from './NavSection.js';
5
+ export { NavMenu } from './NavMenu.js';
@@ -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(() => { if (this.mounted) this.editor?.render(); });
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
  }