@peter.prib/renderengine 0.4.0 → 0.7.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 (78) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +267 -17
  3. package/dist/renderengine-workspace.umd.js +5289 -2895
  4. package/dist/renderengine.umd.js +4243 -2120
  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/charting/ArchimateDiagramEditor.js +1 -1
  11. package/src/charting/Axis.js +20 -0
  12. package/src/charting/DiagramEditor.js +35 -3
  13. package/src/charting/Dialog.js +195 -0
  14. package/src/charting/DuplicatesDialog.js +108 -0
  15. package/src/charting/FormDialog.js +28 -97
  16. package/src/charting/InteractionManager.js +3 -0
  17. package/src/charting/Legend.js +45 -2
  18. package/src/charting/MergeCompareDialog.js +81 -0
  19. package/src/charting/PopupMenu.js +12 -3
  20. package/src/charting/SettingsDialog.js +6 -2
  21. package/src/charting/SettingsManager.js +14 -1
  22. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  23. package/src/charting/archimate/ArchimatePalette.js +1 -1
  24. package/src/charting/charts/AreaChart.js +5 -2
  25. package/src/charting/charts/BarChart.js +5 -3
  26. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  27. package/src/charting/charts/BubbleChart.js +5 -7
  28. package/src/charting/charts/BubbleChart3D.js +5 -7
  29. package/src/charting/charts/CandlestickChart.js +3 -1
  30. package/src/charting/charts/DensityChart.js +4 -6
  31. package/src/charting/charts/GroupedBarChart.js +2 -2
  32. package/src/charting/charts/LineChart.js +5 -2
  33. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  34. package/src/charting/charts/PipeChart.js +4 -6
  35. package/src/charting/charts/RadarChart.js +5 -6
  36. package/src/charting/charts/ScatterChart.js +5 -7
  37. package/src/charting/charts/StackedBarChart.js +4 -2
  38. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  39. package/src/charting/charts/StreamGraphChart.js +5 -2
  40. package/src/charting/charts/WaterfallChart.js +2 -1
  41. package/src/charting/content/ContentSource.js +129 -0
  42. package/src/charting/content/ContentSources.js +77 -0
  43. package/src/charting/datamodel/DataModel.js +116 -0
  44. package/src/charting/diagrams/ArchimateModel.js +38 -3
  45. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  46. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  47. package/src/charting/diagrams/SvgZoom.js +20 -1
  48. package/src/charting/diagrams/duplicates.js +95 -13
  49. package/src/charting/duplicateSearchForm.js +58 -0
  50. package/src/charting/modelTree/ModelTree.js +239 -202
  51. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  52. package/src/charting/modelTree/TreeBuilder.js +93 -45
  53. package/src/charting/modelTree/TreeViewMode.js +9 -6
  54. package/src/charting/modelTree/viewFolders.js +15 -6
  55. package/src/charting/requirements/AzureDevOpsApi.js +55 -0
  56. package/src/charting/requirements/GitLabApi.js +43 -0
  57. package/src/charting/requirements/JiraApi.js +69 -0
  58. package/src/charting/requirements/TrackerApi.js +142 -0
  59. package/src/charting/requirements/TrackerExport.js +3 -2
  60. package/src/charting/requirements/index.js +5 -0
  61. package/src/charting/requirements/trackerApis.js +21 -0
  62. package/src/icons/IconSet.js +86 -0
  63. package/src/icons/index.js +59 -0
  64. package/src/index.js +67 -10
  65. package/src/workspace/dom.js +56 -42
  66. package/src/workspace/index.js +1 -1
  67. package/src/workspace/nav/NavEntry.js +42 -0
  68. package/src/workspace/nav/NavGroup.js +43 -0
  69. package/src/workspace/nav/NavItem.js +53 -0
  70. package/src/workspace/nav/NavMenu.js +143 -0
  71. package/src/workspace/nav/NavSection.js +17 -0
  72. package/src/workspace/nav/index.js +5 -0
  73. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  74. package/src/workspace/parts/DataModelPart.js +91 -0
  75. package/src/workspace/parts/EditorPart.js +8 -0
  76. package/src/workspace/parts/ModelHosted.js +1 -0
  77. package/src/workspace/parts/ModelsPart.js +765 -622
  78. package/src/workspace/styles.js +28 -1
@@ -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
  }