@peter.prib/renderengine 0.8.0 → 0.10.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 +5 -5
- package/dist/renderengine-workspace.umd.js +627 -161
- package/dist/renderengine.umd.js +625 -159
- package/{REFERENCE.md → documentation/REFERENCE.md} +41 -0
- package/package.json +3 -3
- package/src/bian.js +1 -1
- package/src/chartCatalog.js +1 -1
- package/src/charting/ChartChooser.js +17 -3
- package/src/charting/PopupMenu.js +152 -150
- package/src/charting/charts/TableChart.js +1 -1
- package/src/charting/table/TableView.js +268 -0
- package/src/charting/table/TableViewChart.js +128 -0
- package/src/datawindow/BoundedWindow.js +1 -1
- package/src/datawindow/GraphDataWindow.js +1 -1
- package/src/index.js +61 -3
- package/src/kernelExpr.js +2 -2
- package/src/workspace/index.js +1 -1
- package/src/workspace/parts/ChartPart.js +1 -1
- package/src/workspace.js +1 -1
|
@@ -1112,6 +1112,10 @@ or `'time'` (without a hint, a column whose values are all numbers is numeric, e
|
|
|
1112
1112
|
| two categories, one number | `groupedBar`, `heatmap`; with a time column, `line` (a line per category) first |
|
|
1113
1113
|
| anything else | `table` only |
|
|
1114
1114
|
|
|
1115
|
+
A chart is of **one value per category**: rows that repeat a category (orders, not totals per
|
|
1116
|
+
region) get no chart — group them in the query first — since a bar per row would repeat its label,
|
|
1117
|
+
and a repeated pair of categories would replace another's value unseen.
|
|
1118
|
+
|
|
1115
1119
|
Three rules keep a choice from misleading: only an **additive** measure is offered as shares of a
|
|
1116
1120
|
whole (a slice of an average or a balance means nothing); only **complete** rows are (three
|
|
1117
1121
|
customers' slices of a top 3 would claim to be the whole); and a **missing value is never drawn as
|
|
@@ -1121,6 +1125,43 @@ only the cells present, still is), and a bar chart simply has no bar for a null.
|
|
|
1121
1125
|
A table has no axes, so it takes none of a chart's axis margins. `TableChart.heightFor(rowCount,
|
|
1122
1126
|
{ title?, maxRows? })` is the height it will draw, for sizing its container first.
|
|
1123
1127
|
|
|
1128
|
+
### One output for a result — `resultView`
|
|
1129
|
+
|
|
1130
|
+
`engine.resultView(rows, options)` shows result rows as an **interactive table** whose own menu
|
|
1131
|
+
can show them as any chart that fits — table or chart, one output. It returns the table's state, a
|
|
1132
|
+
`TableView`.
|
|
1133
|
+
|
|
1134
|
+
```javascript
|
|
1135
|
+
const view = new RenderEngine(container, { rendererType: 'svg' }).resultView(rows, {
|
|
1136
|
+
columns: [{ name: 'Day', kind: 'time' }, { name: 'Revenue', measure: 'additive' }], // ChartChooser hints
|
|
1137
|
+
complete: true, // false when the rows are only part of the answer (a top N)
|
|
1138
|
+
pageSize: 50,
|
|
1139
|
+
menus: { row: ({ row }) => [{ label: 'Open order', action: () => open(row.id) }] },
|
|
1140
|
+
highlight: (row) => row.id === selectedId, // rows drawn highlighted: the host's selection
|
|
1141
|
+
});
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
| Click | Does |
|
|
1145
|
+
|---|---|
|
|
1146
|
+
| a column header | sorts: ascending, descending, unsorted (nulls always last; equal values keep their order) |
|
|
1147
|
+
| a header's ▾ | the column menu: sort, **Filter to** one of its values (up to 20 listed), clear its filters, hide it, copy it |
|
|
1148
|
+
| a cell | the cell menu: only / exclude rows with this value (or with an empty one); for a number, only rows above / below it; copy the value |
|
|
1149
|
+
| a row number | the row menu: copy the row (tab-separated, or as JSON) |
|
|
1150
|
+
| the corner ⋮ | the table menu: **Show as** (the chart types ChartChooser says fit what is showing), clear all filters, show hidden columns, copy the table |
|
|
1151
|
+
| the footer's ‹ › | pages; the footer says "1–50 of 120 rows (filtered from 300: Region = West)" |
|
|
1152
|
+
|
|
1153
|
+
Filtering is by choosing values in the table, never by typing. A filter, a sort or a hidden column
|
|
1154
|
+
carries into **Show as**: the chart is drawn from the rows and columns showing, in their order, and
|
|
1155
|
+
filtered rows are never offered as shares of a whole. A chart drawn that way has a **◂ Table** link
|
|
1156
|
+
back to the table as it was left. The container's height follows what is drawn (the table's rows,
|
|
1157
|
+
or `chartHeight`, 360); pass `fitHeight: false` to size it yourself. A host adds its own items to
|
|
1158
|
+
any menu with `menus: { cell, row, column, table }`, each `(context) → PopupMenu items`, shown after
|
|
1159
|
+
a separator.
|
|
1160
|
+
|
|
1161
|
+
Interaction needs the SVG renderer (its shapes take clicks); on canvas the same table is drawn
|
|
1162
|
+
without it. `TableView` (`src/charting/table/TableView.js`) holds all of the state and builds every
|
|
1163
|
+
menu as data, so it can be driven and tested without a DOM; `TableViewChart` draws it.
|
|
1164
|
+
|
|
1124
1165
|
Every suggestion is checked against the registry the chooser is given (`chartRegistry` by default):
|
|
1125
1166
|
a type is offered only if the catalog has it and every axis it marks `required` is one the result's
|
|
1126
1167
|
shape provides, so the catalog stays the single source of truth for what each chart needs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"examples/diagram-editor.html",
|
|
21
21
|
"examples/data-model.html",
|
|
22
22
|
"README.md",
|
|
23
|
-
"REFERENCE.md",
|
|
23
|
+
"documentation/REFERENCE.md",
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
@@ -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/bian.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (github.com/bian-official/public, Apache-2.0) for the names control records
|
|
13
13
|
* have and, where a pattern aggregates two creating terms (Monitor: evaluate
|
|
14
14
|
* and initiate), the one its APIs use. Every value is BIAN's; `inferred` is
|
|
15
|
-
* kept for a value that is not. See BIAN_ALIGNMENT.md.
|
|
15
|
+
* kept for a value that is not. See documentation/BIAN_ALIGNMENT.md.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/** One way a service domain behaves: what it does to its asset, and the record it keeps of it. */
|
package/src/chartCatalog.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
|
|
2
2
|
/**
|
|
3
3
|
* Single source of truth for "what chart types does RenderEngine support" —
|
|
4
|
-
* see ARCHITECTURE.md's "Chart-type catalog" section for the full story.
|
|
4
|
+
* see documentation/ARCHITECTURE.md's "Chart-type catalog" section for the full story.
|
|
5
5
|
*
|
|
6
6
|
* This is not just a flat {key,label} list. Chart types share real,
|
|
7
7
|
* composable capabilities — every one of them takes a title; several accept
|
|
@@ -104,6 +104,17 @@ class ResultShape {
|
|
|
104
104
|
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
105
105
|
};
|
|
106
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
|
+
}
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
/** One number and nothing to group it by: a single figure. */
|
|
@@ -123,7 +134,8 @@ class SingleFigure extends ResultShape {
|
|
|
123
134
|
class CategoryValue extends ResultShape {
|
|
124
135
|
static of(columns, rows) {
|
|
125
136
|
const { categories, numbers } = ResultShape.split(columns);
|
|
126
|
-
return categories.length === 1 && numbers.length === 1
|
|
137
|
+
return categories.length === 1 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
|
|
138
|
+
? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
127
139
|
}
|
|
128
140
|
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
129
141
|
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
@@ -163,7 +175,8 @@ CategoryValue.MAX_SLICES = 8;
|
|
|
163
175
|
class CategorySeries extends ResultShape {
|
|
164
176
|
static of(columns, rows) {
|
|
165
177
|
const { categories, numbers } = ResultShape.split(columns);
|
|
166
|
-
return categories.length === 1 && numbers.length >= 2
|
|
178
|
+
return categories.length === 1 && numbers.length >= 2 && ResultShape.oneRowEach(rows, categories)
|
|
179
|
+
? new CategorySeries(categories[0], numbers, rows) : null;
|
|
167
180
|
}
|
|
168
181
|
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
169
182
|
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
@@ -191,7 +204,8 @@ class CategorySeries extends ResultShape {
|
|
|
191
204
|
class TwoCategories extends ResultShape {
|
|
192
205
|
static of(columns, rows) {
|
|
193
206
|
const { categories, numbers } = ResultShape.split(columns);
|
|
194
|
-
return categories.length === 2 && numbers.length === 1
|
|
207
|
+
return categories.length === 2 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
|
|
208
|
+
? new TwoCategories(categories, numbers[0], rows) : null;
|
|
195
209
|
}
|
|
196
210
|
constructor(categories, number, rows) {
|
|
197
211
|
super();
|
|
@@ -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
|
+
}
|
|
@@ -87,7 +87,7 @@ export class TableChart extends Chart {
|
|
|
87
87
|
|
|
88
88
|
_cell(text, column, left, top, colW, attrs) {
|
|
89
89
|
const end = column.align === 'end';
|
|
90
|
-
this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
|
|
90
|
+
return this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
|
|
91
91
|
textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
|
|
92
92
|
});
|
|
93
93
|
}
|