@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
|
@@ -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
|
-
*
|
|
38
|
-
* buttons, which stay in view, and it moves by dragging its
|
|
39
|
-
* scrolls inside its own box and can be
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
169
|
-
|
|
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,
|
package/src/charting/Legend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|