@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
@@ -0,0 +1,195 @@
1
+ import { el } from '../workspace/dom.js';
2
+
3
+ /**
4
+ * Dialog — the one dialog frame: a title (the handle that moves it), an
5
+ * optional ✕, a body that scrolls between the title and the buttons (which
6
+ * stay in view), and Escape or a click outside to close. It fits the window.
7
+ * A subclass (FormDialog) or a host gives it its content: `content()` and
8
+ * `buttons` as specs, or fill `body` after open(). `titleBar` takes more
9
+ * beside the title (a link); `footer`, more beside the buttons.
10
+ *
11
+ * open() shows it over the page; open(host) places it in `host` instead, with
12
+ * no overlay — a dialog's content shown as a pane or a tab, which has a frame
13
+ * and a close control of its own.
14
+ */
15
+ export class Dialog {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {string} options.title
19
+ * @param {Array<{label: string, action: (dialog: Dialog) => void, primary?: boolean, submit?: boolean}>} [options.buttons]
20
+ * @param {boolean} [options.closeButton] - A ✕ in the title bar
21
+ * @param {'tall'|'wide'} [options.size] - Tall: most of the window's height; wide: most of its width
22
+ * @param {string|string[]} [options.cls] - Extra class names on the frame
23
+ * @param {Document} [options.document]
24
+ * @param {(applied: boolean) => void} [options.onClose] - Once, when it closes: whether it was applied
25
+ */
26
+ constructor({ title, buttons = [], closeButton = false, size = null, cls = null, document: doc = globalThis.document, onClose = null } = {}) {
27
+ this.title = title;
28
+ this.buttons = buttons;
29
+ this.closeButton = closeButton;
30
+ this.size = size;
31
+ this.cls = cls;
32
+ this.doc = doc;
33
+ this.onClose = onClose;
34
+ this._applied = false;
35
+ /** The page-covering backdrop, while open over the page. */
36
+ this.overlay = null;
37
+ /** The dialog itself. */
38
+ this.frame = null;
39
+ this.titleBar = null;
40
+ this.body = null;
41
+ this.footer = null;
42
+ this._host = null;
43
+ }
44
+
45
+ /** The frame's tag: a FormDialog's is a <form>. @protected */
46
+ get frameTag() { return 'div'; }
47
+
48
+ /** The body's specs (empty: a host fills `body` after open()). @protected */
49
+ content() { return []; }
50
+
51
+ /** Events on the frame itself (a form's submit). @protected */
52
+ frameEvents() { return {}; }
53
+
54
+ /** Open over the page, or in `host` (no overlay, not moved, no ✕). */
55
+ open(host = null) {
56
+ this._host = host;
57
+ const inline = !!host;
58
+ const frame = {
59
+ tag: this.frameTag,
60
+ cls: ['re-dialog', this.size && `re-dialog-${this.size}`, inline && 're-dialog-inline', ...[].concat(this.cls ?? [])],
61
+ attrs: { role: inline ? null : 'dialog', 'aria-label': this.title },
62
+ style: inline ? Dialog.INLINE_STYLE : { ...Dialog.FRAME_STYLE, ...(Dialog.SIZE_STYLE[this.size] ?? {}) },
63
+ ref: (n) => { this.frame = n; },
64
+ on: {
65
+ ...this.frameEvents(),
66
+ keydown: (e) => { if (e.key === 'Escape' && !inline) this.close(); },
67
+ },
68
+ children: [
69
+ {
70
+ cls: 're-dialog-title', ref: (n) => { this.titleBar = n; },
71
+ style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '0 0 8px', flex: '0 0 auto' },
72
+ children: [
73
+ {
74
+ // The title is the handle that moves the dialog.
75
+ tag: 'h3', text: this.title, attrs: { title: inline ? null : 'Drag to move' },
76
+ style: { margin: '0', fontSize: '14px', cursor: inline ? 'default' : 'move', userSelect: 'none', flex: '1 1 auto' },
77
+ on: inline ? {} : { mousedown: (e) => this._startMove(e) },
78
+ },
79
+ this.closeButton && !inline && {
80
+ tag: 'button', cls: 're-dialog-close', text: '✕', attrs: { type: 'button', title: 'Close', 'aria-label': 'Close' },
81
+ style: { border: 'none', background: 'transparent', fontSize: '16px', cursor: 'pointer', color: '#666', padding: '0 4px' },
82
+ on: { click: () => this.close() },
83
+ },
84
+ ].filter(Boolean),
85
+ },
86
+ {
87
+ // The content scrolls; the title and the buttons stay in view.
88
+ cls: 're-dialog-body', ref: (n) => { this.body = n; },
89
+ style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
90
+ children: this.content(),
91
+ },
92
+ {
93
+ cls: 're-dialog-footer', ref: (n) => { this.footer = n; },
94
+ style: { display: this.buttons.length ? 'flex' : 'none', justifyContent: 'flex-end', alignItems: 'center', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
95
+ children: this.buttons.map(b => ({
96
+ tag: 'button', text: b.label, attrs: { type: b.submit ? 'submit' : 'button' },
97
+ style: b.primary ? Dialog.PRIMARY_BUTTON_STYLE : null,
98
+ on: b.submit ? {} : { click: () => b.action(this) },
99
+ })),
100
+ },
101
+ ],
102
+ };
103
+ if (inline) {
104
+ host.appendChild(el(frame, this.doc));
105
+ } else {
106
+ this.overlay = el({
107
+ cls: 're-dialog-overlay', style: Dialog.OVERLAY_STYLE,
108
+ on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
109
+ children: [frame],
110
+ }, this.doc);
111
+ this.doc.body.appendChild(this.overlay);
112
+ }
113
+ this.frame.querySelector?.('input:not([type=checkbox]), select, textarea')?.focus();
114
+ return this;
115
+ }
116
+
117
+ /** Add to the footer (a host's own buttons, a pager), and show it. */
118
+ addToFooter(...nodes) {
119
+ this.footer.append(...nodes);
120
+ this.footer.style.display = 'flex';
121
+ return this;
122
+ }
123
+
124
+ /** Add beside the title (a link), before the ✕. */
125
+ addToTitle(...nodes) {
126
+ const close = this.titleBar.children[1] ?? null;
127
+ for (const node of nodes) this.titleBar.insertBefore(node, close);
128
+ return this;
129
+ }
130
+
131
+ /** Whether it is showing. */
132
+ get isOpen() { return !!(this.overlay || (this._host && this.frame?.parentNode)); }
133
+
134
+ /** Close it (once), telling onClose whether it was applied. */
135
+ close() {
136
+ if (!this.isOpen) return;
137
+ (this.overlay ?? this.frame).remove();
138
+ this.overlay = null;
139
+ this._host = null;
140
+ this.onClose?.(this._applied);
141
+ }
142
+
143
+ /**
144
+ * Move the dialog by its title: from then on it sits where it was dropped
145
+ * (fixed position), kept inside the window.
146
+ * @protected
147
+ */
148
+ _startMove(e) {
149
+ if (e.button !== 0 || !this.frame) return;
150
+ e.preventDefault();
151
+ const view = this.doc.defaultView;
152
+ const box = this.frame.getBoundingClientRect();
153
+ const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
154
+ Object.assign(this.frame.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
155
+ const move = (ev) => {
156
+ const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
157
+ const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
158
+ const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
159
+ this.frame.style.left = `${left}px`;
160
+ this.frame.style.top = `${top}px`;
161
+ };
162
+ const end = () => {
163
+ this.doc.removeEventListener('mousemove', move);
164
+ this.doc.removeEventListener('mouseup', end);
165
+ };
166
+ this.doc.addEventListener('mousemove', move);
167
+ this.doc.addEventListener('mouseup', end);
168
+ }
169
+
170
+ static OVERLAY_STYLE = {
171
+ position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
172
+ display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
173
+ };
174
+
175
+ static FRAME_STYLE = {
176
+ background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
177
+ minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
178
+ display: 'flex', flexDirection: 'column',
179
+ boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
180
+ };
181
+
182
+ static SIZE_STYLE = {
183
+ tall: { height: '85vh', width: 'min(1100px, 90vw)' },
184
+ wide: { width: '90vw' },
185
+ };
186
+
187
+ /** In a host: fills it, as content, not a floating box. */
188
+ static INLINE_STYLE = {
189
+ display: 'flex', flexDirection: 'column', height: '100%', boxSizing: 'border-box', padding: '10px 12px', fontSize: '12px',
190
+ };
191
+
192
+ static PRIMARY_BUTTON_STYLE = {
193
+ background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px',
194
+ };
195
+ }
@@ -0,0 +1,108 @@
1
+ import { el } from '../workspace/dom.js';
2
+ import { Dialog } from './Dialog.js';
3
+
4
+ /**
5
+ * DuplicatesDialog — the groups Find likely duplicates… found, a row each:
6
+ * a box to tick it, its message (choosing it shows it: `onReveal`), and
7
+ * Merge…. It stays open while they are merged: **Merge selected** merges the
8
+ * ticked groups one after another, and after each merge the list is found
9
+ * again (`refind`), so what was merged leaves it. Any modeller lists its
10
+ * duplicates this way.
11
+ *
12
+ * `merge(group)` does one group's merge (asking what it must: MergeCompareDialog)
13
+ * and resolves to whether it merged.
14
+ */
15
+ export class DuplicatesDialog extends Dialog {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {Array<{ids: string[], message: string}>} options.groups - What was found (model-check findings)
19
+ * @param {(group) => (boolean|Promise<boolean>)} options.merge
20
+ * @param {() => Array<Object>} [options.refind] - The groups found again, after a merge (else those left)
21
+ * @param {(group) => void} [options.onReveal]
22
+ * @param {string} [options.empty] - What an empty list says
23
+ * @param {Document} [options.document]
24
+ */
25
+ constructor({ groups, merge, refind = null, onReveal = () => {}, empty = 'No likely duplicates.', document: doc = globalThis.document }) {
26
+ super({
27
+ title: DuplicatesDialog.titleFor(groups),
28
+ closeButton: true,
29
+ document: doc,
30
+ buttons: [
31
+ { label: 'Merge selected', primary: true, action: () => this.mergeSelected() },
32
+ { label: 'Close', action: (dialog) => dialog.close() },
33
+ ],
34
+ });
35
+ Object.assign(this, { groups, mergeGroup: merge, refind, onReveal, empty });
36
+ /** The groups ticked, by key. */
37
+ this.ticked = new Set();
38
+ }
39
+
40
+ /** "Likely duplicates: n groups". */
41
+ static titleFor(groups) {
42
+ return `Likely duplicates: ${groups.length} group${groups.length === 1 ? '' : 's'}`;
43
+ }
44
+
45
+ /** A group's key: its members. */
46
+ static keyOf(group) {
47
+ return group.ids.join('\u0000');
48
+ }
49
+
50
+ content() {
51
+ return [this._list()];
52
+ }
53
+
54
+ /** The rows. @private */
55
+ _list() {
56
+ return {
57
+ cls: 're-duplicates', style: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '440px' },
58
+ children: this.groups.length ? this.groups.map(group => {
59
+ const key = DuplicatesDialog.keyOf(group);
60
+ return {
61
+ attrs: { 'data-group': key }, style: { display: 'flex', gap: '6px', alignItems: 'center' },
62
+ children: [
63
+ {
64
+ tag: 'input', attrs: { type: 'checkbox', 'aria-label': 'Merge this group', checked: this.ticked.has(key) || null },
65
+ on: { change: (e) => { if (e.target.checked) this.ticked.add(key); else this.ticked.delete(key); } },
66
+ },
67
+ {
68
+ tag: 'button', attrs: { type: 'button', title: 'Show it' }, text: `⚠ ${group.message}`,
69
+ style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: '#fff6e5', cursor: 'pointer' },
70
+ on: { click: () => this.onReveal(group) },
71
+ },
72
+ { tag: 'button', attrs: { type: 'button', 'data-action': 'Merge…' }, text: 'Merge…', style: { padding: '4px 8px', cursor: 'pointer' }, on: { click: () => this.mergeOne(group) } },
73
+ ],
74
+ };
75
+ }) : [{ text: this.empty }],
76
+ };
77
+ }
78
+
79
+ /** Merge one group (asking what it must); the list is found again. Resolves to whether it merged. */
80
+ async mergeOne(group) {
81
+ const merged = await this.mergeGroup(group);
82
+ if (merged) this.refresh([group]);
83
+ return !!merged;
84
+ }
85
+
86
+ /** Merge the ticked groups, one after another; the list is found again. Resolves to how many merged. */
87
+ async mergeSelected() {
88
+ const chosen = this.groups.filter(g => this.ticked.has(DuplicatesDialog.keyOf(g)));
89
+ const done = [];
90
+ for (const group of chosen) {
91
+ if (await this.mergeGroup(group)) done.push(group);
92
+ }
93
+ if (done.length) this.refresh(done);
94
+ return done.length;
95
+ }
96
+
97
+ /** Show the groups as they are now: found again, or without those merged. */
98
+ refresh(merged = []) {
99
+ const gone = new Set(merged.map(DuplicatesDialog.keyOf));
100
+ this.groups = this.refind ? this.refind() : this.groups.filter(g => !gone.has(DuplicatesDialog.keyOf(g)));
101
+ const still = new Set(this.groups.map(DuplicatesDialog.keyOf));
102
+ this.ticked = new Set([...this.ticked].filter(k => still.has(k)));
103
+ this.title = DuplicatesDialog.titleFor(this.groups);
104
+ const heading = this.titleBar?.querySelector?.('h3') ?? this.titleBar?.children?.[0];
105
+ if (heading) heading.textContent = this.title;
106
+ this.body?.replaceChildren(el(this._list(), this.doc));
107
+ }
108
+ }
@@ -1,4 +1,5 @@
1
1
  import { el } from '../workspace/dom.js';
2
+ import { Dialog } from './Dialog.js';
2
3
  import { ColorField } from './ColorField.js';
3
4
  import { RowsField } from './RowsField.js';
4
5
 
@@ -34,15 +35,16 @@ const INHERIT = '__inherit__';
34
35
  * inherited field reads as `undefined`. `FormDialog.many` uses it for a form
35
36
  * that edits several items at once.
36
37
  *
37
- * The dialog fits the window: its fields scroll between the title and the
38
- * buttons, which stay in view, and it moves by dragging its title. A textarea
39
- * scrolls inside its own box and can be resized taller.
38
+ * A Dialog (its frame: it fits the window, its fields scroll between the
39
+ * title and the buttons, which stay in view, and it moves by dragging its
40
+ * title), built as a <form>. A textarea scrolls inside its own box and can be
41
+ * resized taller.
40
42
  *
41
43
  * `validate(values)` may return a message to show inline instead of closing.
42
44
  * After open(), `controls[key]` is each field's control, for wiring fields that
43
45
  * depend on one another.
44
46
  */
45
- export class FormDialog {
47
+ export class FormDialog extends Dialog {
46
48
  /**
47
49
  * @param {Object} options
48
50
  * @param {string} options.title
@@ -58,7 +60,13 @@ export class FormDialog {
58
60
  * applied (false for Cancel, Escape or a click outside)
59
61
  */
60
62
  constructor({ title, description, fields, onApply, validate, submitLabel = 'Apply', cancelLabel = 'Cancel', idPrefix = 'form-dialog-', document: doc = document, onClose = null }) {
61
- this.title = title;
63
+ super({
64
+ title, document: doc, onClose,
65
+ buttons: [
66
+ { label: cancelLabel, action: (dialog) => dialog.close() },
67
+ { label: submitLabel, primary: true, submit: true },
68
+ ],
69
+ });
62
70
  this.description = description;
63
71
  this.fields = fields;
64
72
  this.onApply = onApply;
@@ -66,107 +74,30 @@ export class FormDialog {
66
74
  this.submitLabel = submitLabel;
67
75
  this.cancelLabel = cancelLabel;
68
76
  this.idPrefix = idPrefix;
69
- this.doc = doc;
70
- this.onClose = onClose;
71
- this._applied = false;
72
- this.overlay = null;
73
77
  /** @type {Object<string, HTMLElement>} */
74
78
  this.controls = {};
75
79
  this._readers = [];
76
80
  this._error = null;
77
81
  }
78
82
 
79
- open() {
80
- this._readers = [];
81
- this.controls = {};
82
- const rows = this.fields.map(f => this._row(f));
83
- this.overlay = el({
84
- style: {
85
- position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
86
- display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
87
- },
88
- on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
89
- children: [{
90
- tag: 'form',
91
- attrs: { role: 'dialog', 'aria-label': this.title },
92
- style: {
93
- background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
94
- minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
95
- display: 'flex', flexDirection: 'column',
96
- boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
97
- },
98
- ref: (n) => { this.form = n; },
99
- on: {
100
- submit: (e) => { e.preventDefault(); this._apply(); },
101
- keydown: (e) => { if (e.key === 'Escape') this.close(); },
102
- },
103
- children: [
104
- {
105
- // The title is the handle that moves the dialog.
106
- tag: 'h3', text: this.title, attrs: { title: 'Drag to move' },
107
- style: { margin: '0 0 8px', fontSize: '14px', cursor: 'move', userSelect: 'none', flex: '0 0 auto' },
108
- on: { mousedown: (e) => this._startMove(e) },
109
- },
110
- {
111
- // The fields scroll; the title and the buttons stay in view.
112
- style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
113
- ref: (n) => { this.body = n; },
114
- children: [
115
- this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
116
- { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
117
- { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
118
- ].filter(Boolean),
119
- },
120
- {
121
- style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
122
- children: [
123
- { tag: 'button', attrs: { type: 'button' }, text: this.cancelLabel, on: { click: () => this.close() } },
124
- {
125
- tag: 'button', attrs: { type: 'submit' }, text: this.submitLabel,
126
- style: { background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px' },
127
- },
128
- ],
129
- },
130
- ].filter(Boolean),
131
- }],
132
- }, this.doc);
133
- this.doc.body.appendChild(this.overlay);
134
- this.overlay.querySelector('input:not([type=checkbox]), select, textarea')?.focus();
135
- return this;
136
- }
83
+ get frameTag() { return 'form'; }
137
84
 
138
- /**
139
- * Move the dialog by its title: from then on it sits where it was dropped
140
- * (fixed position), kept inside the window.
141
- * @protected
142
- */
143
- _startMove(e) {
144
- if (e.button !== 0 || !this.form) return;
145
- e.preventDefault();
146
- const view = this.doc.defaultView;
147
- const box = this.form.getBoundingClientRect();
148
- const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
149
- Object.assign(this.form.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
150
- const move = (ev) => {
151
- const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
152
- const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
153
- const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
154
- this.form.style.left = `${left}px`;
155
- this.form.style.top = `${top}px`;
156
- };
157
- const end = () => {
158
- this.doc.removeEventListener('mousemove', move);
159
- this.doc.removeEventListener('mouseup', end);
160
- };
161
- this.doc.addEventListener('mousemove', move);
162
- this.doc.addEventListener('mouseup', end);
85
+ /** The dialog's <form>: its frame. */
86
+ get form() { return this.frame; }
87
+
88
+ frameEvents() {
89
+ return { submit: (e) => { e.preventDefault(); this._apply(); } };
163
90
  }
164
91
 
165
- close() {
166
- if (!this.overlay) return;
167
- this.overlay.remove();
168
- this.overlay = null;
169
- this.onClose?.(this._applied);
92
+ content() {
93
+ this._readers = [];
94
+ this.controls = {};
95
+ const rows = this.fields.map(f => this._row(f));
96
+ return [
97
+ this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
98
+ { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
99
+ { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
100
+ ].filter(Boolean);
170
101
  }
171
102
 
172
103
  values() {
@@ -624,8 +624,11 @@ export class InteractionManager {
624
624
  const engine = this._engine;
625
625
  if (!engine.renderer || !engine.renderer.svg) return;
626
626
  if (!engine.renderer.supportsSceneGraph) return;
627
+ const zoom = engine.options?.zoom;
628
+ if (zoom === false) return;
627
629
 
628
630
  engine._svgZoom = new SvgZoom(engine.container, engine.renderer.svg, {
631
+ wheelModifier: zoom?.wheelModifier ?? null,
629
632
  clientToSvgPoint: (clientX, clientY) => engine._clientToSvgPoint(clientX, clientY),
630
633
  hitTest: (clientX, clientY) => engine.hitTest(clientX, clientY),
631
634
  is3D: () => engine._chart3D,
@@ -13,14 +13,49 @@ export class Legend {
13
13
  * @param {number} [options.itemHeight=20]
14
14
  * @param {number} [options.markerSize=10]
15
15
  * @param {string} [options.fontSize='12px']
16
+ * @param {'square'|'circle'} [options.marker='square'] - A circle for a chart of points (scatter, bubble)
17
+ * @param {boolean} [options.inset=false] - Drawn over the plot: on a backing, so it reads over the data
16
18
  */
17
- constructor({ items, x, y, itemHeight = 20, markerSize = 10, fontSize = '12px' }) {
19
+ constructor({ items, x, y, itemHeight = 20, markerSize = 10, fontSize = '12px', marker = 'square', inset = false }) {
18
20
  this.items = items;
19
21
  this.x = x;
20
22
  this.y = y;
21
23
  this.itemHeight = itemHeight;
22
24
  this.markerSize = markerSize;
23
25
  this.fontSize = fontSize;
26
+ this.marker = marker;
27
+ this.inset = inset;
28
+ }
29
+
30
+ /**
31
+ * A legend for a plot `box` on a drawing `drawingWidth` wide: to the right
32
+ * of the plot when it fits there, otherwise inside the plot's top-right
33
+ * corner (inset, on a backing). A chart's plot usually fills its drawing,
34
+ * so "to the right" alone would put the legend off the edge.
35
+ * @param {{x:number, y:number, width:number}} box - The plot area
36
+ * @param {number} drawingWidth - The renderer's width
37
+ * @param {object} options - As the constructor's, without x and y
38
+ */
39
+ static beside(box, drawingWidth, options) {
40
+ const legend = new Legend({ ...options, x: 0, y: 0 });
41
+ const { width } = legend.size();
42
+ const outside = box.x + box.width + 20;
43
+ if (!(drawingWidth > 0) || outside + width <= drawingWidth) {
44
+ legend.x = outside;
45
+ legend.y = box.y;
46
+ } else {
47
+ legend.inset = true;
48
+ legend.x = box.x + box.width - width - 10;
49
+ legend.y = box.y + 10;
50
+ }
51
+ return legend;
52
+ }
53
+
54
+ /** Its size: the marker and the widest label (a character is about 0.6 em), a row per item. */
55
+ size() {
56
+ const px = parseFloat(this.fontSize) || 12;
57
+ const longest = Math.max(0, ...this.items.map(item => String(this._label(item)).length));
58
+ return { width: this.markerSize + 5 + longest * px * 0.6, height: this.items.length * this.itemHeight };
24
59
  }
25
60
 
26
61
  /** Label text for an item — uses `label` or `name`. */
@@ -33,6 +68,10 @@ export class Legend {
33
68
  * @param {*} renderer a renderer implementing .rect() and .text()
34
69
  */
35
70
  draw(renderer) {
71
+ if (this.inset) {
72
+ const { width, height } = this.size();
73
+ renderer.rect(this.x - 6, this.y - 6, width + 12, height + 2, 'rgba(255,255,255,0.85)', '#ddd');
74
+ }
36
75
  let currentY = this.y;
37
76
  for (const item of this.items) {
38
77
  const hidden = !!item.hidden;
@@ -44,7 +83,11 @@ export class Legend {
44
83
  attrs.dataIndex = item.dataIndex;
45
84
  }
46
85
 
47
- renderer.rect(this.x, currentY, this.markerSize, this.markerSize, color, null, attrs);
86
+ if (this.marker === 'circle') {
87
+ renderer.circle(this.x + this.markerSize / 2, currentY + this.markerSize / 2, this.markerSize / 2, color, null, attrs);
88
+ } else {
89
+ renderer.rect(this.x, currentY, this.markerSize, this.markerSize, color, null, attrs);
90
+ }
48
91
  renderer.text(this._label(item), this.x + this.markerSize + 5, currentY + this.markerSize / 2, {
49
92
  textAnchor: 'start',
50
93
  verticalAlign: 'middle',
@@ -0,0 +1,81 @@
1
+ import { el } from '../workspace/dom.js';
2
+ import { FormDialog } from './FormDialog.js';
3
+
4
+ /**
5
+ * MergeCompareDialog — things about to be merged, side by side: a column
6
+ * each, a row for each thing they have (type, documentation, each property,
7
+ * relationships …), the rows where they differ marked; under it, the choices
8
+ * a merge needs (which one is kept, which documentation, which value of a
9
+ * property). OK merges; Cancel leaves them be. Any modeller's merge asks this
10
+ * way (the ArchiMate canvas's elements, a data model's entities).
11
+ *
12
+ * `ask()` resolves to the choices' values, or null when cancelled.
13
+ */
14
+ export class MergeCompareDialog {
15
+ /**
16
+ * @param {Object} options
17
+ * @param {string} options.title
18
+ * @param {string[]} options.columns - Each thing's heading
19
+ * @param {Array<{label: string, values: string[]}>} options.rows - What each has, in column order ('' for nothing)
20
+ * @param {Array<Object>} [options.fields] - The choices (FormDialog fields)
21
+ * @param {string} [options.description]
22
+ * @param {Document} [options.document]
23
+ */
24
+ constructor({ title, columns, rows, fields = [], description = '', document: doc = globalThis.document }) {
25
+ Object.assign(this, { title, columns, rows, fields, description, document: doc });
26
+ }
27
+
28
+ /** Whether a row's values are not all the same. */
29
+ static differs(row) {
30
+ return new Set(row.values.map(v => String(v ?? '').trim())).size > 1;
31
+ }
32
+
33
+ /** Whether any row differs: if none does, there is nothing to look at before merging. */
34
+ get hasDifferences() {
35
+ return this.rows.some(row => MergeCompareDialog.differs(row));
36
+ }
37
+
38
+ /** The side-by-side table. */
39
+ table() {
40
+ const cell = (text, extra = {}) => ({
41
+ tag: 'td', text: text || '—',
42
+ style: { padding: '3px 6px', borderBottom: '1px solid #eee', verticalAlign: 'top', whiteSpace: 'pre-wrap', color: text ? null : '#999', ...extra },
43
+ });
44
+ return el({
45
+ style: { gridColumn: '1 / -1', overflowX: 'auto', maxWidth: '80vw' },
46
+ children: [{
47
+ tag: 'table', attrs: { 'data-merge-compare': '' }, style: { borderCollapse: 'collapse', fontSize: '12px', minWidth: '420px' },
48
+ children: [
49
+ { tag: 'thead', children: [{ tag: 'tr', children: [{ tag: 'th', text: '' }, ...this.columns.map(c => ({ tag: 'th', text: c, style: { textAlign: 'left', padding: '3px 6px', borderBottom: '2px solid #ccc' } }))] }] },
50
+ {
51
+ tag: 'tbody',
52
+ children: this.rows.map(row => {
53
+ const differs = MergeCompareDialog.differs(row);
54
+ return {
55
+ tag: 'tr', attrs: { 'data-differs': differs ? '' : null }, style: { background: differs ? '#fff3cd' : null },
56
+ children: [cell(`${differs ? '≠ ' : ''}${row.label}`, { fontWeight: 'bold', color: '#333' }), ...row.values.map(v => cell(v))],
57
+ };
58
+ }),
59
+ },
60
+ ],
61
+ }],
62
+ }, this.document);
63
+ }
64
+
65
+ /** Show it (over the page, or in `host`); resolves to the choices' values on OK, null on Cancel. */
66
+ ask(host = null) {
67
+ return new Promise((resolve) => {
68
+ new FormDialog({
69
+ title: this.title,
70
+ description: this.description,
71
+ fields: [{ key: 'compare', label: '', type: 'custom', element: this.table(), read: () => null }, ...this.fields],
72
+ submitLabel: 'OK, merge',
73
+ idPrefix: 'merge-compare-',
74
+ document: this.document,
75
+ onApply: ({ compare, ...choices }) => resolve(choices),
76
+ // A form closes before it applies: closed unapplied (Cancel, Escape, a click outside) is no.
77
+ onClose: (applied) => { if (!applied) resolve(null); },
78
+ }).open(host);
79
+ });
80
+ }
81
+ }