@peter.prib/renderengine 0.7.0 → 0.9.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/REFERENCE.md +85 -0
- package/dist/renderengine-workspace.umd.js +960 -147
- package/dist/renderengine.umd.js +960 -147
- package/package.json +2 -2
- package/src/chartCatalog.js +10 -0
- package/src/charting/ChartChooser.js +243 -0
- package/src/charting/PopupMenu.js +152 -150
- package/src/charting/charts/TableChart.js +98 -0
- package/src/charting/table/TableView.js +263 -0
- package/src/charting/table/TableViewChart.js +126 -0
- package/src/index.js +79 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"archimate": "node scripts/open.mjs examples/archimate.html",
|
|
29
29
|
"diagram": "node scripts/open.mjs examples/diagram-editor.html",
|
|
30
30
|
"datamodel": "node scripts/open.mjs examples/data-model.html",
|
|
31
|
-
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
31
|
+
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
32
32
|
"build": "rollup -c",
|
|
33
33
|
"prepublishOnly": "npm test && npm run build",
|
|
34
34
|
"screenshots": "node scripts/screenshots/screenshots.mjs"
|
package/src/chartCatalog.js
CHANGED
|
@@ -457,6 +457,16 @@ export const CHART_CATALOG = [
|
|
|
457
457
|
dataShape: 'Array<{label, value?, children?}> — same hierarchical shape as treemap/sunburst (shares _hierarchyValue); drawn as an indented, collapsible list instead of nested rectangles/rings — a branch\'s value is auto-summed from its descendants and shown as a trailing count, a leaf\'s own extra fields (anything beyond label/value/children) shown as trailing descriptive text instead',
|
|
458
458
|
streamShape: 'flat',
|
|
459
459
|
},
|
|
460
|
+
{
|
|
461
|
+
key: 'table', label: 'Table', method: 'tableChart', category: '2d',
|
|
462
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.NONE, camera: CAMERA_MODE.NONE,
|
|
463
|
+
axes: [
|
|
464
|
+
{ role: 'row', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
|
|
465
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.TEXT_VALUE, required: false },
|
|
466
|
+
],
|
|
467
|
+
dataShape: 'Array<object> — one row per object, a column per key (options.columns: names or {key, label?, align?}; every key of the first row when absent); values shown exactly as text, numeric columns right-aligned; options.maxRows, options.nullText',
|
|
468
|
+
streamShape: 'flat',
|
|
469
|
+
},
|
|
460
470
|
{
|
|
461
471
|
key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
|
|
462
472
|
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { AXIS_KIND, chartRegistry } from '../chartCatalog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
5
|
+
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
6
|
+
*
|
|
7
|
+
* The caller may describe the columns (hints). `kind` is `'categorical'`, `'numeric'` or `'time'`; a
|
|
8
|
+
* column with no hint is numeric when every value it holds is a number, else categorical. `measure`
|
|
9
|
+
* is renderengine's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`), as a data model
|
|
10
|
+
* gives it. The hints change the choice: a time column is drawn along a line, and only an additive
|
|
11
|
+
* measure is offered as shares of a whole (a pie), since a part-of-a-whole slice of an average or a
|
|
12
|
+
* balance means nothing.
|
|
13
|
+
*
|
|
14
|
+
* Every suggestion is checked against CHART_CATALOG: a chart type is offered only when the catalog
|
|
15
|
+
* has it and every axis it requires is one the result's shape provides — so the catalog stays the
|
|
16
|
+
* single source of truth for what each chart needs.
|
|
17
|
+
*
|
|
18
|
+
* const [best, ...others] = new ChartChooser().choose(rows, [{ name: 'Day', kind: 'time' }]);
|
|
19
|
+
* engine.renderChartType(best.key, best.data, { title: best.label });
|
|
20
|
+
*/
|
|
21
|
+
export class ChartChooser {
|
|
22
|
+
/** @param {ChartRegistry} [registry] — the built-in chart types, or another registry to choose from */
|
|
23
|
+
constructor(registry = chartRegistry) {
|
|
24
|
+
this.registry = registry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Array<object>} rows
|
|
29
|
+
* @param {Array<{name:string, kind?:string, measure?:string}>} [columns] — hints; every key of the
|
|
30
|
+
* first row when absent, in that order
|
|
31
|
+
* @param {{complete?:boolean}} [options] — `complete: false` when the rows are only part of the
|
|
32
|
+
* answer (a top N, a first page): then nothing is offered as shares of a whole, since the
|
|
33
|
+
* slices would claim to be all of it
|
|
34
|
+
* @returns {Array<{key:string, label:string, data:*, reason:string}>} best first; the table last
|
|
35
|
+
*/
|
|
36
|
+
choose(rows, columns = null, { complete = true } = {}) {
|
|
37
|
+
const described = ChartChooser.describe(rows, columns);
|
|
38
|
+
const shape = RESULT_SHAPES.map(S => S.of(described, rows)).find(s => s !== null) ?? null;
|
|
39
|
+
if (shape !== null) shape.complete = complete;
|
|
40
|
+
const charts = shape === null ? [] : shape.candidates()
|
|
41
|
+
.filter(({ key }) => this.fits(key, shape))
|
|
42
|
+
.map(({ key, reason, data }) => ({ key, label: this.registry.get(key).label, data, reason }));
|
|
43
|
+
return [...charts, this.table(rows, described, charts.length === 0)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A catalog entry fits a shape when every axis it requires is one the shape provides. */
|
|
47
|
+
fits(key, shape) {
|
|
48
|
+
const entry = this.registry.get(key);
|
|
49
|
+
if (!entry) return false;
|
|
50
|
+
if (shape.needsSeries && !entry.seriesCapable && !entry.axes.some(a => a.role === 'series')) return false;
|
|
51
|
+
return entry.axes.filter(a => a.required).every(a => shape.provides[a.role] === a.kind);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
table(rows, described, only) {
|
|
55
|
+
return {
|
|
56
|
+
key: 'table', label: this.registry.get('table')?.label ?? 'Table',
|
|
57
|
+
data: rows, columns: described.map(c => c.name),
|
|
58
|
+
reason: only ? 'no chart fits these columns' : 'every value, exactly',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The columns with their kinds settled: the hint's, else derived from the rows' values. */
|
|
63
|
+
static describe(rows, columns) {
|
|
64
|
+
const given = columns ?? Object.keys(rows[0] ?? {}).map(name => ({ name }));
|
|
65
|
+
return given.map(c => ({
|
|
66
|
+
name: c.name,
|
|
67
|
+
kind: c.kind ?? (rows.length > 0 && rows.every(r => r[c.name] == null || typeof r[c.name] === 'number') ? 'numeric' : 'categorical'),
|
|
68
|
+
measure: c.measure ?? null,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The label a category value is drawn with. */
|
|
74
|
+
function label(value) {
|
|
75
|
+
return value == null ? '—' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Rows in time order by `column` (numbers, dates and ISO strings all sort correctly this way). */
|
|
79
|
+
function inOrder(rows, column) {
|
|
80
|
+
return [...rows].sort((a, b) => (a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ResultShape — what a result's columns look like: how many category, time and numeric columns.
|
|
85
|
+
* Each subclass recognises one shape (`static of`), says which catalog axes it provides, and lists
|
|
86
|
+
* the chart types that suit it, best first, with the rows shaped for each.
|
|
87
|
+
*/
|
|
88
|
+
class ResultShape {
|
|
89
|
+
constructor() {
|
|
90
|
+
/** Whether the rows are all of the answer (ChartChooser.choose's `complete`). */
|
|
91
|
+
this.complete = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The axes this shape can fill, role → AXIS_KIND. */
|
|
95
|
+
get provides() { return {}; }
|
|
96
|
+
/** Whether a chart must draw several series (one per numeric column). */
|
|
97
|
+
get needsSeries() { return false; }
|
|
98
|
+
/** @returns {Array<{key, reason, data}>} */
|
|
99
|
+
candidates() { return []; }
|
|
100
|
+
|
|
101
|
+
static split(columns) {
|
|
102
|
+
return {
|
|
103
|
+
categories: columns.filter(c => c.kind !== 'numeric'),
|
|
104
|
+
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether each row has its own value of the category columns. Rows that repeat a category
|
|
110
|
+
* (rows of orders, not totals per region) would be drawn as one bar per row with the same
|
|
111
|
+
* label over and over — or, for two categories, one repeated pair would replace another's value
|
|
112
|
+
* unseen. A chart of a value per category needs one row per category: a GROUP BY first.
|
|
113
|
+
*/
|
|
114
|
+
static oneRowEach(rows, categories) {
|
|
115
|
+
const keys = new Set(rows.map(r => JSON.stringify(categories.map(c => r[c.name] ?? null))));
|
|
116
|
+
return keys.size === rows.length;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One number and nothing to group it by: a single figure. */
|
|
121
|
+
class SingleFigure extends ResultShape {
|
|
122
|
+
static of(columns, rows) {
|
|
123
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
124
|
+
return categories.length === 0 && numbers.length === 1 && rows.length === 1 ? new SingleFigure(numbers[0], rows) : null;
|
|
125
|
+
}
|
|
126
|
+
constructor(number, rows) { super(); this.number = number; this.rows = rows; }
|
|
127
|
+
get provides() { return { value: AXIS_KIND.NUMERIC }; }
|
|
128
|
+
candidates() {
|
|
129
|
+
return [{ key: 'kpi-tile', reason: 'a single figure', data: { value: this.rows[0][this.number.name] } }];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One category (or time) column and one number: a value per category. */
|
|
134
|
+
class CategoryValue extends ResultShape {
|
|
135
|
+
static of(columns, rows) {
|
|
136
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
137
|
+
return categories.length === 1 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
|
|
138
|
+
? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
139
|
+
}
|
|
140
|
+
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
141
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
142
|
+
|
|
143
|
+
candidates() {
|
|
144
|
+
const { category, number } = this;
|
|
145
|
+
const time = category.kind === 'time';
|
|
146
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
147
|
+
const data = rows.filter(r => r[number.name] != null).map(r => ({ label: label(r[category.name]), value: r[number.name] }));
|
|
148
|
+
if (time) {
|
|
149
|
+
return [
|
|
150
|
+
{ key: 'line', reason: `${number.name} over ${category.name}`, data },
|
|
151
|
+
{ key: 'area', reason: `${number.name} over ${category.name}, filled`, data },
|
|
152
|
+
{ key: 'bar', reason: `${number.name} for each ${category.name}`, data },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
const options = [{ key: 'bar', reason: `${number.name} for each ${category.name}`, data }];
|
|
156
|
+
if (this.isShareOfWhole(data)) {
|
|
157
|
+
options.push({ key: 'pie', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
158
|
+
options.push({ key: 'donut', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
159
|
+
}
|
|
160
|
+
return options;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Shares of a whole make sense only for an additive measure, over all of the rows (not a top N),
|
|
165
|
+
* with no negatives, in few slices.
|
|
166
|
+
*/
|
|
167
|
+
isShareOfWhole(data) {
|
|
168
|
+
return this.complete && this.number.measure === 'additive' && data.length >= 2 && data.length <= CategoryValue.MAX_SLICES
|
|
169
|
+
&& data.every(d => d.value >= 0);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
CategoryValue.MAX_SLICES = 8;
|
|
173
|
+
|
|
174
|
+
/** One category (or time) column and several numbers: a series per number. */
|
|
175
|
+
class CategorySeries extends ResultShape {
|
|
176
|
+
static of(columns, rows) {
|
|
177
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
178
|
+
return categories.length === 1 && numbers.length >= 2 && ResultShape.oneRowEach(rows, categories)
|
|
179
|
+
? new CategorySeries(categories[0], numbers, rows) : null;
|
|
180
|
+
}
|
|
181
|
+
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
182
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
183
|
+
get needsSeries() { return true; }
|
|
184
|
+
|
|
185
|
+
candidates() {
|
|
186
|
+
const { category } = this;
|
|
187
|
+
const time = category.kind === 'time';
|
|
188
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
189
|
+
// lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
|
|
190
|
+
// as 0, a figure the data does not hold. So a result with one is not offered as series.
|
|
191
|
+
if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
|
|
192
|
+
const data = this.numbers.map(n => ({
|
|
193
|
+
name: n.name,
|
|
194
|
+
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
|
|
195
|
+
}));
|
|
196
|
+
const names = this.numbers.map(n => n.name).join(', ');
|
|
197
|
+
return time
|
|
198
|
+
? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
|
|
199
|
+
: [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Two category (or time) columns and one number: a value per pair. */
|
|
204
|
+
class TwoCategories extends ResultShape {
|
|
205
|
+
static of(columns, rows) {
|
|
206
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
207
|
+
return categories.length === 2 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
|
|
208
|
+
? new TwoCategories(categories, numbers[0], rows) : null;
|
|
209
|
+
}
|
|
210
|
+
constructor(categories, number, rows) {
|
|
211
|
+
super();
|
|
212
|
+
// A time column goes along the x axis, whichever order the columns came in.
|
|
213
|
+
this.across = categories.find(c => c.kind === 'time') ?? categories[0];
|
|
214
|
+
this.down = categories.find(c => c !== this.across);
|
|
215
|
+
this.number = number;
|
|
216
|
+
this.rows = rows;
|
|
217
|
+
}
|
|
218
|
+
get provides() {
|
|
219
|
+
return { x: AXIS_KIND.CATEGORICAL, y: AXIS_KIND.CATEGORICAL, category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
candidates() {
|
|
223
|
+
const { across, down, number } = this;
|
|
224
|
+
const rows = across.kind === 'time' ? inOrder(this.rows, across.name) : this.rows;
|
|
225
|
+
const heat = rows.filter(r => r[number.name] != null)
|
|
226
|
+
.map(r => ({ x: label(r[across.name]), y: label(r[down.name]), value: r[number.name] }));
|
|
227
|
+
const heatmap = { key: 'heatmap', reason: `${number.name} for each ${across.name} and ${down.name}`, data: heat };
|
|
228
|
+
// A series needs a value for every label; where a pair is missing (or null) only the heatmap,
|
|
229
|
+
// which draws just the cells present, shows the data without inventing a 0.
|
|
230
|
+
const labels = [...new Set(rows.map(r => label(r[across.name])))];
|
|
231
|
+
const value = new Map(rows.map(r => [`${label(r[across.name])}\u0000${label(r[down.name])}`, r[number.name]]));
|
|
232
|
+
const names = [...new Set(rows.map(r => label(r[down.name])))];
|
|
233
|
+
if (names.some(name => labels.some(l => value.get(`${l}\u0000${name}`) == null))) return [heatmap];
|
|
234
|
+
const series = names.map(name => ({ name, values: labels.map(l => ({ label: l, value: value.get(`${l}\u0000${name}`) })) }));
|
|
235
|
+
const grouped = { key: 'groupedBar', reason: `${number.name} for each ${across.name}, by ${down.name}`, data: series };
|
|
236
|
+
return across.kind === 'time'
|
|
237
|
+
? [{ key: 'line', reason: `${number.name} over ${across.name}, a line per ${down.name}`, data: series }, heatmap, grouped]
|
|
238
|
+
: [grouped, heatmap];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Shapes in the order they are tried; the first that recognises the columns wins. */
|
|
243
|
+
const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
|
|
@@ -1,150 +1,152 @@
|
|
|
1
|
-
import { el } from '../workspace/dom.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* PopupMenu — the one popup/context menu: a small menu at a screen position,
|
|
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.
|
|
7
|
-
* Editor menus, the graph engine's ContextMenu and the model tree's menus all
|
|
8
|
-
* render through it.
|
|
9
|
-
*
|
|
10
|
-
* Items: `{ label, action, danger?, checked?, disabled? }`,
|
|
11
|
-
* `{ label, items: [...] }` for a submenu, or `{ separator: true }`.
|
|
12
|
-
*/
|
|
13
|
-
export class PopupMenu {
|
|
14
|
-
static _open = null;
|
|
15
|
-
|
|
16
|
-
static show(x, y, items, doc = document) {
|
|
17
|
-
PopupMenu.close();
|
|
18
|
-
if (!items.length) return null;
|
|
19
|
-
const menu = el({
|
|
20
|
-
...PopupMenu._panel(items, doc),
|
|
21
|
-
style: { ...PopupMenu._panelStyle, position: 'fixed', left: `${x}px`, top: `${y}px` },
|
|
22
|
-
}, doc);
|
|
23
|
-
doc.body.appendChild(menu);
|
|
24
|
-
// Opened near the window's right or bottom edge, it moves back into the window.
|
|
25
|
-
const win = doc.defaultView;
|
|
26
|
-
const box = menu.getBoundingClientRect?.();
|
|
27
|
-
if (win && box?.width) {
|
|
28
|
-
const at = PopupMenu.fit(x, y, box.width, box.height, win.innerWidth, win.innerHeight);
|
|
29
|
-
Object.assign(menu.style, { left: `${at.x}px`, top: `${at.y}px` });
|
|
30
|
-
}
|
|
31
|
-
// Registered after the opening click has finished propagating.
|
|
32
|
-
const dismiss = () => PopupMenu.close();
|
|
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 };
|
|
41
|
-
return menu;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Where a menu of a size goes, asked for at (x, y), to be all inside a
|
|
46
|
-
* window of a size: left of the point when it would pass the right edge,
|
|
47
|
-
* above it when it would pass the bottom, never past the top-left.
|
|
48
|
-
*/
|
|
49
|
-
static fit(x, y, width, height, windowWidth, windowHeight, margin = 4) {
|
|
50
|
-
const left = x + width + margin > windowWidth ? x - width : x;
|
|
51
|
-
const top = y + height + margin > windowHeight ? y - height : y;
|
|
52
|
-
return {
|
|
53
|
-
x: Math.max(margin, Math.min(left, windowWidth - width - margin)),
|
|
54
|
-
y: Math.max(margin, Math.min(top, windowHeight - height - margin)),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Where a submenu opens beside its item (the item's box `{left, right, top}`),
|
|
60
|
-
* to be all inside the window: to the right, or to the left when the right
|
|
61
|
-
* would pass the edge (and there is more room on the left); level with the
|
|
62
|
-
* item, or lifted by as much as it would pass the bottom. `top` is relative
|
|
63
|
-
* to the item, as the submenu is positioned.
|
|
64
|
-
*/
|
|
65
|
-
static submenuPlacement(item, width, height, windowWidth, windowHeight, margin = 4) {
|
|
66
|
-
const fitsRight = item.right + width + margin <= windowWidth;
|
|
67
|
-
const side = fitsRight || item.left < windowWidth - item.right ? 'right' : 'left';
|
|
68
|
-
const top = -4;
|
|
69
|
-
const overflow = item.top + top + height + margin - windowHeight;
|
|
70
|
-
return { side, top: overflow > 0 ? Math.max(top - overflow, margin - item.top) : top };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
static close() {
|
|
74
|
-
const open = PopupMenu._open;
|
|
75
|
-
if (!open) return;
|
|
76
|
-
open.menu.remove();
|
|
77
|
-
open.doc.removeEventListener('click', open.dismiss);
|
|
78
|
-
open.doc.removeEventListener('scroll', open.dismiss, { capture: true });
|
|
79
|
-
open.doc.removeEventListener('keydown', open.onKey);
|
|
80
|
-
PopupMenu._open = null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
static get isOpen() {
|
|
84
|
-
return !!PopupMenu._open;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
item.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
1
|
+
import { el } from '../workspace/dom.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PopupMenu — the one popup/context menu: a small menu at a screen position,
|
|
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.
|
|
7
|
+
* Editor menus, the graph engine's ContextMenu and the model tree's menus all
|
|
8
|
+
* render through it.
|
|
9
|
+
*
|
|
10
|
+
* Items: `{ label, action, danger?, checked?, disabled? }`,
|
|
11
|
+
* `{ label, items: [...] }` for a submenu, or `{ separator: true }`.
|
|
12
|
+
*/
|
|
13
|
+
export class PopupMenu {
|
|
14
|
+
static _open = null;
|
|
15
|
+
|
|
16
|
+
static show(x, y, items, doc = document) {
|
|
17
|
+
PopupMenu.close();
|
|
18
|
+
if (!items.length) return null;
|
|
19
|
+
const menu = el({
|
|
20
|
+
...PopupMenu._panel(items, doc),
|
|
21
|
+
style: { ...PopupMenu._panelStyle, position: 'fixed', left: `${x}px`, top: `${y}px` },
|
|
22
|
+
}, doc);
|
|
23
|
+
doc.body.appendChild(menu);
|
|
24
|
+
// Opened near the window's right or bottom edge, it moves back into the window.
|
|
25
|
+
const win = doc.defaultView;
|
|
26
|
+
const box = menu.getBoundingClientRect?.();
|
|
27
|
+
if (win && box?.width) {
|
|
28
|
+
const at = PopupMenu.fit(x, y, box.width, box.height, win.innerWidth, win.innerHeight);
|
|
29
|
+
Object.assign(menu.style, { left: `${at.x}px`, top: `${at.y}px` });
|
|
30
|
+
}
|
|
31
|
+
// Registered after the opening click has finished propagating.
|
|
32
|
+
const dismiss = () => PopupMenu.close();
|
|
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 };
|
|
41
|
+
return menu;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where a menu of a size goes, asked for at (x, y), to be all inside a
|
|
46
|
+
* window of a size: left of the point when it would pass the right edge,
|
|
47
|
+
* above it when it would pass the bottom, never past the top-left.
|
|
48
|
+
*/
|
|
49
|
+
static fit(x, y, width, height, windowWidth, windowHeight, margin = 4) {
|
|
50
|
+
const left = x + width + margin > windowWidth ? x - width : x;
|
|
51
|
+
const top = y + height + margin > windowHeight ? y - height : y;
|
|
52
|
+
return {
|
|
53
|
+
x: Math.max(margin, Math.min(left, windowWidth - width - margin)),
|
|
54
|
+
y: Math.max(margin, Math.min(top, windowHeight - height - margin)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a submenu opens beside its item (the item's box `{left, right, top}`),
|
|
60
|
+
* to be all inside the window: to the right, or to the left when the right
|
|
61
|
+
* would pass the edge (and there is more room on the left); level with the
|
|
62
|
+
* item, or lifted by as much as it would pass the bottom. `top` is relative
|
|
63
|
+
* to the item, as the submenu is positioned.
|
|
64
|
+
*/
|
|
65
|
+
static submenuPlacement(item, width, height, windowWidth, windowHeight, margin = 4) {
|
|
66
|
+
const fitsRight = item.right + width + margin <= windowWidth;
|
|
67
|
+
const side = fitsRight || item.left < windowWidth - item.right ? 'right' : 'left';
|
|
68
|
+
const top = -4;
|
|
69
|
+
const overflow = item.top + top + height + margin - windowHeight;
|
|
70
|
+
return { side, top: overflow > 0 ? Math.max(top - overflow, margin - item.top) : top };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static close() {
|
|
74
|
+
const open = PopupMenu._open;
|
|
75
|
+
if (!open) return;
|
|
76
|
+
open.menu.remove();
|
|
77
|
+
open.doc.removeEventListener('click', open.dismiss);
|
|
78
|
+
open.doc.removeEventListener('scroll', open.dismiss, { capture: true });
|
|
79
|
+
open.doc.removeEventListener('keydown', open.onKey);
|
|
80
|
+
PopupMenu._open = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static get isOpen() {
|
|
84
|
+
return !!PopupMenu._open;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The text colour is set with the background: inherited from a dark-themed page it was light
|
|
88
|
+
// grey on this white panel, and every item but a disabled or danger one was unreadable.
|
|
89
|
+
static _panelStyle = {
|
|
90
|
+
background: '#fff', color: '#222', border: '1px solid #ccc', borderRadius: '3px', boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
|
91
|
+
zIndex: '10000', minWidth: '170px', fontSize: '12px', fontFamily: 'sans-serif', padding: '4px 0',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
static _panel(items, doc) {
|
|
95
|
+
return { attrs: { role: 'menu' }, children: items.map(item => PopupMenu._item(item, doc)) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static _item(item, doc) {
|
|
99
|
+
if (item.separator) return { attrs: { role: 'separator' }, style: { borderTop: '1px solid #ddd', margin: '4px 0' } };
|
|
100
|
+
const hover = item.danger ? '#ffe8e8' : '#f0f0f0';
|
|
101
|
+
const base = {
|
|
102
|
+
attrs: { role: 'menuitem', 'aria-disabled': item.disabled ? 'true' : null, 'aria-checked': item.checked != null ? String(!!item.checked) : null },
|
|
103
|
+
style: {
|
|
104
|
+
padding: '7px 14px', cursor: item.disabled ? 'default' : 'pointer', position: 'relative', whiteSpace: 'nowrap',
|
|
105
|
+
color: item.disabled ? '#aaa' : item.danger ? '#cc3333' : '', background: item.checked ? '#e3f2fd' : '',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
if (item.items) {
|
|
109
|
+
let sub;
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
attrs: { ...base.attrs, 'aria-haspopup': 'menu' },
|
|
113
|
+
on: {
|
|
114
|
+
mouseenter: (e) => {
|
|
115
|
+
e.currentTarget.style.background = hover;
|
|
116
|
+
sub.style.display = 'block';
|
|
117
|
+
// Opened near the window's right or bottom edge, it opens left, or lifts.
|
|
118
|
+
const win = e.currentTarget.ownerDocument.defaultView;
|
|
119
|
+
const box = sub.getBoundingClientRect?.();
|
|
120
|
+
if (!win || !box?.width) return;
|
|
121
|
+
const at = PopupMenu.submenuPlacement(e.currentTarget.getBoundingClientRect(), box.width, box.height, win.innerWidth, win.innerHeight);
|
|
122
|
+
Object.assign(sub.style, at.side === 'left' ? { left: 'auto', right: '100%' } : { left: '100%', right: 'auto' }, { top: `${at.top}px` });
|
|
123
|
+
},
|
|
124
|
+
mouseleave: (e) => { e.currentTarget.style.background = ''; sub.style.display = 'none'; },
|
|
125
|
+
click: (e) => e.stopPropagation(),
|
|
126
|
+
},
|
|
127
|
+
children: [
|
|
128
|
+
`${item.label} ▸`,
|
|
129
|
+
{
|
|
130
|
+
...PopupMenu._panel(item.items, doc),
|
|
131
|
+
style: { ...PopupMenu._panelStyle, position: 'absolute', left: '100%', top: '-4px', display: 'none' },
|
|
132
|
+
ref: (n) => { sub = n; },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
text: item.label,
|
|
140
|
+
on: {
|
|
141
|
+
mouseenter: (e) => { if (!item.disabled) e.currentTarget.style.background = hover; },
|
|
142
|
+
mouseleave: (e) => { e.currentTarget.style.background = item.checked ? '#e3f2fd' : ''; },
|
|
143
|
+
click: (e) => {
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
if (item.disabled) return;
|
|
146
|
+
PopupMenu.close();
|
|
147
|
+
item.action();
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|