@peter.prib/renderengine 0.5.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.
- package/README.md +4 -0
- package/REFERENCE.md +252 -17
- package/dist/renderengine-workspace.umd.js +4995 -2932
- package/dist/renderengine.umd.js +3985 -2193
- 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/charting/ArchimateDiagramEditor.js +1 -1
- package/src/charting/Axis.js +20 -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/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 +65 -9
- 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,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
|
+
}
|
|
@@ -2,7 +2,8 @@ import { el } from '../workspace/dom.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* PopupMenu — the one popup/context menu: a small menu at a screen position,
|
|
5
|
-
* closed by choosing an item
|
|
5
|
+
* closed by choosing an item, clicking elsewhere, Escape, or scrolling (it
|
|
6
|
+
* would no longer be beside what it was opened on). One is open at a time.
|
|
6
7
|
* Editor menus, the graph engine's ContextMenu and the model tree's menus all
|
|
7
8
|
* render through it.
|
|
8
9
|
*
|
|
@@ -29,8 +30,14 @@ export class PopupMenu {
|
|
|
29
30
|
}
|
|
30
31
|
// Registered after the opening click has finished propagating.
|
|
31
32
|
const dismiss = () => PopupMenu.close();
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const onKey = (e) => { if (e.key === 'Escape') PopupMenu.close(); };
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
if (PopupMenu._open?.menu !== menu) return; // closed meanwhile
|
|
36
|
+
doc.addEventListener('click', dismiss, { once: true });
|
|
37
|
+
doc.addEventListener('scroll', dismiss, { once: true, capture: true });
|
|
38
|
+
doc.addEventListener('keydown', onKey);
|
|
39
|
+
}, 0);
|
|
40
|
+
PopupMenu._open = { menu, dismiss, onKey, doc };
|
|
34
41
|
return menu;
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -68,6 +75,8 @@ export class PopupMenu {
|
|
|
68
75
|
if (!open) return;
|
|
69
76
|
open.menu.remove();
|
|
70
77
|
open.doc.removeEventListener('click', open.dismiss);
|
|
78
|
+
open.doc.removeEventListener('scroll', open.dismiss, { capture: true });
|
|
79
|
+
open.doc.removeEventListener('keydown', open.onKey);
|
|
71
80
|
PopupMenu._open = null;
|
|
72
81
|
}
|
|
73
82
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FormDialog } from './FormDialog.js';
|
|
2
|
-
import { MODEL_SETTINGS_PROPERTIES } from './SettingsManager.js';
|
|
2
|
+
import { MODEL_SETTINGS_PROPERTIES, TREE_SETTINGS_PROPERTIES } from './SettingsManager.js';
|
|
3
3
|
import { inheritableFields, readInheritableFields } from './SettingsCascade.js';
|
|
4
4
|
|
|
5
5
|
/** Settings a model sets or inherits from the tool, and the tool sets for every model. */
|
|
@@ -33,6 +33,7 @@ export function showGlobalSettingsDialog(doc, settingsManager, modelId = 'defaul
|
|
|
33
33
|
{ type: 'heading', label: 'Tool' },
|
|
34
34
|
{ key: 'theme', label: 'Theme', type: 'select', value: tool.theme, choices: [['light', 'light'], ['dark', 'dark']] },
|
|
35
35
|
{ key: 'toolInvalidRelationships', label: checking.label, type: 'select', value: tool.invalidRelationships ?? checking.default, choices: checking.choices },
|
|
36
|
+
...TREE_SETTINGS_PROPERTIES.map(({ key, label, type, min }) => ({ key, label, type, min, value: tool[key] })),
|
|
36
37
|
{ type: 'heading', label: 'Model' },
|
|
37
38
|
...inheritableFields(CHECKING, modelOwn, fromTool),
|
|
38
39
|
{ key: 'edgeRouting', label: 'Default edge routing', type: 'select', value: model.edgeRouting, choices: ROUTING_CHOICES },
|
|
@@ -43,7 +44,10 @@ export function showGlobalSettingsDialog(doc, settingsManager, modelId = 'defaul
|
|
|
43
44
|
idPrefix: 'global-settings-',
|
|
44
45
|
document: doc,
|
|
45
46
|
onApply: ({ theme, toolInvalidRelationships, edgeRouting, gridSize, showGrid, ...rest }) => {
|
|
46
|
-
|
|
47
|
+
// A tree setting left empty keeps its value.
|
|
48
|
+
const tree = Object.fromEntries(TREE_SETTINGS_PROPERTIES.map(({ key }) => [key, rest[key]])
|
|
49
|
+
.filter(([, value]) => Number.isFinite(value)).map(([key, value]) => [key, Math.max(1, Math.trunc(value))]));
|
|
50
|
+
settingsManager.saveToolSettings({ theme, invalidRelationships: toolInvalidRelationships, ...tree });
|
|
47
51
|
// The model's own choice, or undefined to inherit the tool's.
|
|
48
52
|
settingsManager.saveModelSettings(modelId, readInheritableFields(CHECKING, rest));
|
|
49
53
|
// Observers (e.g. DiagramTabs) re-render on these notifications.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { SettingsCascade } from './SettingsCascade.js';
|
|
2
2
|
import { LocalJsonStore } from './storage/LocalJsonStore.js';
|
|
3
|
+
import { ModelTree } from './modelTree/ModelTree.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* SettingsManager — editor settings across tool, model and view levels.
|
|
6
7
|
*
|
|
7
8
|
* Hierarchy: Tool (global) ← Model (per model) ← View (per tab, overrides)
|
|
8
9
|
*
|
|
9
|
-
* Tool settings (localStorage): theme, zoom
|
|
10
|
+
* Tool settings (localStorage): theme, zoom, how the model tree searches and shows
|
|
10
11
|
* Model settings (localStorage, per model id): edge routing, grid, watermark
|
|
11
12
|
* View settings (on the view object): overrides of model settings, plus title/background
|
|
12
13
|
*
|
|
@@ -43,9 +44,16 @@ export const VIEW_SETTINGS_DEFAULTS = Object.freeze({
|
|
|
43
44
|
backgroundImage: '',
|
|
44
45
|
});
|
|
45
46
|
|
|
47
|
+
/** How the model tree searches and shows, for every model (the tool level); `option` is ModelTree's name for it. */
|
|
48
|
+
export const TREE_SETTINGS_PROPERTIES = Object.freeze([
|
|
49
|
+
{ key: 'treeLeafLimit', option: 'leafLimit', label: 'Tree: leaves shown per folder', type: 'number', default: ModelTree.LEAF_LIMIT, min: 1 },
|
|
50
|
+
{ key: 'treeSearchMinChars', option: 'minFilterChars', label: 'Tree: search from (characters)', type: 'number', default: ModelTree.MIN_FILTER_CHARS, min: 1 },
|
|
51
|
+
]);
|
|
52
|
+
|
|
46
53
|
export const TOOL_SETTINGS_DEFAULTS = Object.freeze({
|
|
47
54
|
theme: 'light',
|
|
48
55
|
defaultZoom: 1,
|
|
56
|
+
...Object.fromEntries(TREE_SETTINGS_PROPERTIES.map(p => [p.key, p.default])),
|
|
49
57
|
});
|
|
50
58
|
|
|
51
59
|
export class SettingsManager {
|
|
@@ -93,6 +101,11 @@ export class SettingsManager {
|
|
|
93
101
|
return { ...this._toolSettings };
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/** The model tree's options from the tool settings ({leafLimit, minFilterChars}), for ModelTree. */
|
|
105
|
+
treeOptions() {
|
|
106
|
+
return Object.fromEntries(TREE_SETTINGS_PROPERTIES.map(p => [p.option, this._toolSettings[p.key] ?? p.default]));
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
// ── Model Settings (per-model defaults) ──
|
|
97
110
|
|
|
98
111
|
/** The model level holds only what is set on it; unset settings come from the tool level, then defaults. */
|