@peter.prib/renderengine 0.8.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 +40 -0
- package/dist/renderengine-workspace.umd.js +610 -151
- package/dist/renderengine.umd.js +610 -151
- package/package.json +2 -2
- 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 +263 -0
- package/src/charting/table/TableViewChart.js +126 -0
- package/src/index.js +59 -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"
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { ChartChooser } from '../ChartChooser.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TableView — the state of an interactive table over result rows, kept apart from drawing so all of
|
|
5
|
+
* it can be tested without a DOM: the sort, the filters, the hidden columns and the page, and the
|
|
6
|
+
* menus a table offers on a cell, a row, a column and the table itself (as PopupMenu item specs).
|
|
7
|
+
*
|
|
8
|
+
* Filtering is by choosing values from the table, not typing: a cell's menu keeps or excludes rows
|
|
9
|
+
* with its value (and, for a number, rows above or below it); a column's menu filters to one of its
|
|
10
|
+
* values. The table's menu offers "Show as", the chart types ChartChooser says fit what is showing.
|
|
11
|
+
*
|
|
12
|
+
* Every change calls `onChange(view)`; the host redraws. A host adds its own items with
|
|
13
|
+
* `menus: { cell(ctx), row(ctx), column(ctx), table(ctx) }`, each returning PopupMenu items.
|
|
14
|
+
*/
|
|
15
|
+
const FILTER_OPS = {
|
|
16
|
+
'=': (v, x) => v === x,
|
|
17
|
+
'!=': (v, x) => v !== x,
|
|
18
|
+
'>': (v, x) => v != null && v > x,
|
|
19
|
+
'<': (v, x) => v != null && v < x,
|
|
20
|
+
'is null': (v) => v == null,
|
|
21
|
+
'is not null': (v) => v != null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Order of two values for a sort: numbers numerically, everything else as text; nulls last. */
|
|
25
|
+
function compareValues(a, b) {
|
|
26
|
+
if (a == null) return b == null ? 0 : 1;
|
|
27
|
+
if (b == null) return -1;
|
|
28
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
29
|
+
const x = String(a), y = String(b);
|
|
30
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A value as copied text: null as empty, an object as JSON. */
|
|
34
|
+
function plain(value) {
|
|
35
|
+
if (value == null) return '';
|
|
36
|
+
if (value instanceof Date) return value.toISOString();
|
|
37
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TableView {
|
|
41
|
+
/** The most distinct values a column's "Filter to" menu lists. */
|
|
42
|
+
static MAX_FILTER_VALUES = 20;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {Array<object>} rows
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {Array<{name, kind?, measure?}>} [options.columns] hints, as ChartChooser takes them
|
|
48
|
+
* @param {boolean} [options.complete] false when the rows are only part of the answer (a top N)
|
|
49
|
+
* @param {number} [options.pageSize] rows per page (50; Infinity for all)
|
|
50
|
+
* @param {function} [options.onChange] called with the view after every change
|
|
51
|
+
* @param {object} [options.menus] the host's own items: { cell, row, column, table } → items
|
|
52
|
+
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
53
|
+
* @param {ChartChooser} [options.chooser]
|
|
54
|
+
*/
|
|
55
|
+
constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null } = {}) {
|
|
56
|
+
this.rows = rows;
|
|
57
|
+
this.columns = ChartChooser.describe(rows, columns);
|
|
58
|
+
this.complete = complete;
|
|
59
|
+
this.pageSize = pageSize;
|
|
60
|
+
this.onChange = onChange;
|
|
61
|
+
this.menus = menus;
|
|
62
|
+
this.copy = copy ?? TableView.clipboard;
|
|
63
|
+
this.chooser = chooser ?? new ChartChooser();
|
|
64
|
+
this.sort = null; // { column, direction: 'asc' | 'desc' }
|
|
65
|
+
this.filters = []; // { column, op, value }
|
|
66
|
+
this.hidden = new Set();
|
|
67
|
+
this.page = 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static clipboard(text) {
|
|
71
|
+
return globalThis.navigator?.clipboard?.writeText(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── What is showing ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
visibleColumns() { return this.columns.filter(c => !this.hidden.has(c.name)); }
|
|
77
|
+
|
|
78
|
+
filtered() {
|
|
79
|
+
return this.filters.length === 0 ? this.rows
|
|
80
|
+
: this.rows.filter(r => this.filters.every(f => FILTER_OPS[f.op](r[f.column], f.value)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The filtered rows in sort order (a copy; equal values keep their order). */
|
|
84
|
+
ordered() {
|
|
85
|
+
const rows = this.filtered();
|
|
86
|
+
if (this.sort === null) return rows;
|
|
87
|
+
const { column, direction } = this.sort;
|
|
88
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
89
|
+
// Nulls stay last whichever way: a reader looks for values, not for blanks.
|
|
90
|
+
return [...rows].sort((a, b) => (a[column] == null || b[column] == null ? compareValues(a[column], b[column]) : sign * compareValues(a[column], b[column])));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pageCount() {
|
|
94
|
+
return Number.isFinite(this.pageSize) ? Math.max(1, Math.ceil(this.filtered().length / this.pageSize)) : 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The rows on the current page, each with its position in the ordered rows (`index`, from 0). */
|
|
98
|
+
pageRows() {
|
|
99
|
+
const rows = this.ordered();
|
|
100
|
+
const start = Number.isFinite(this.pageSize) ? this.page * this.pageSize : 0;
|
|
101
|
+
const end = Number.isFinite(this.pageSize) ? start + this.pageSize : rows.length;
|
|
102
|
+
return rows.slice(start, end).map((row, i) => ({ row, index: start + i }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** "Region = West · Revenue > 100", or '' with no filters. */
|
|
106
|
+
describeFilters() {
|
|
107
|
+
return this.filters.map(f => (f.op.startsWith('is') ? `${f.column} ${f.op}` : `${f.column} ${f.op} ${plain(f.value)}`)).join(' · ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Changes ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
changed() {
|
|
113
|
+
this.page = Math.min(this.page, this.pageCount() - 1);
|
|
114
|
+
this.onChange?.(this);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A header click: unsorted → ascending → descending → unsorted. */
|
|
118
|
+
toggleSort(column) {
|
|
119
|
+
const current = this.sort?.column === column ? this.sort.direction : null;
|
|
120
|
+
this.sortBy(column, current === null ? 'asc' : current === 'asc' ? 'desc' : null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sortBy(column, direction) {
|
|
124
|
+
this.sort = direction === null ? null : { column, direction };
|
|
125
|
+
this.page = 0;
|
|
126
|
+
this.changed();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
addFilter(column, op, value = null) {
|
|
130
|
+
if (!Object.hasOwn(FILTER_OPS, op)) throw new Error(`Unknown filter "${op}"`);
|
|
131
|
+
this.filters = [...this.filters.filter(f => !(f.column === column && f.op === op && f.value === value)), { column, op, value }];
|
|
132
|
+
this.page = 0;
|
|
133
|
+
this.changed();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
removeFilters(column) {
|
|
137
|
+
this.filters = this.filters.filter(f => f.column !== column);
|
|
138
|
+
this.changed();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
clearFilters() {
|
|
142
|
+
this.filters = [];
|
|
143
|
+
this.changed();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Hides a column; the last one showing cannot be hidden. */
|
|
147
|
+
hide(column) {
|
|
148
|
+
if (this.visibleColumns().length <= 1) return;
|
|
149
|
+
this.hidden.add(column);
|
|
150
|
+
this.changed();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
showAll() {
|
|
154
|
+
this.hidden.clear();
|
|
155
|
+
this.changed();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
goToPage(page) {
|
|
159
|
+
this.page = Math.max(0, Math.min(page, this.pageCount() - 1));
|
|
160
|
+
this.changed();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Text out ──────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Rows as tab-separated text, header first, the visible columns only. */
|
|
166
|
+
asTsv(rows = this.ordered()) {
|
|
167
|
+
const columns = this.visibleColumns().map(c => c.name);
|
|
168
|
+
const clean = (v) => plain(v).replace(/[\t\r\n]+/g, ' ');
|
|
169
|
+
return [columns.map(clean).join('\t'), ...rows.map(r => columns.map(c => clean(r[c])).join('\t'))].join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Menus (PopupMenu items) ──────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
cellMenu(row, column) {
|
|
175
|
+
const value = row[column];
|
|
176
|
+
const kind = this.columns.find(c => c.name === column)?.kind;
|
|
177
|
+
const shown = value == null ? 'empty' : plain(value);
|
|
178
|
+
const items = value == null
|
|
179
|
+
? [{ label: `Only rows where ${column} is empty`, action: () => this.addFilter(column, 'is null') },
|
|
180
|
+
{ label: `Exclude rows where ${column} is empty`, action: () => this.addFilter(column, 'is not null') }]
|
|
181
|
+
: [{ label: `Only rows where ${column} = ${shown}`, action: () => this.addFilter(column, '=', value) },
|
|
182
|
+
{ label: `Exclude rows where ${column} = ${shown}`, action: () => this.addFilter(column, '!=', value) },
|
|
183
|
+
...(kind === 'numeric' ? [
|
|
184
|
+
{ label: `Only rows where ${column} > ${shown}`, action: () => this.addFilter(column, '>', value) },
|
|
185
|
+
{ label: `Only rows where ${column} < ${shown}`, action: () => this.addFilter(column, '<', value) },
|
|
186
|
+
] : [])];
|
|
187
|
+
return [
|
|
188
|
+
...items,
|
|
189
|
+
{ separator: true },
|
|
190
|
+
{ label: 'Copy value', action: () => this.copy(plain(value)) },
|
|
191
|
+
...this.extra('cell', { row, column, value }),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
rowMenu(row) {
|
|
196
|
+
return [
|
|
197
|
+
{ label: 'Copy row', action: () => this.copy(this.asTsv([row])) },
|
|
198
|
+
{ label: 'Copy row as JSON', action: () => this.copy(JSON.stringify(row)) },
|
|
199
|
+
...this.extra('row', { row }),
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
columnMenu(column) {
|
|
204
|
+
const sorted = this.sort?.column === column ? this.sort.direction : null;
|
|
205
|
+
const values = [...new Set(this.filtered().map(r => r[column]))].sort(compareValues);
|
|
206
|
+
const listed = values.slice(0, TableView.MAX_FILTER_VALUES);
|
|
207
|
+
return [
|
|
208
|
+
{ label: 'Sort ascending', checked: sorted === 'asc', action: () => this.sortBy(column, 'asc') },
|
|
209
|
+
{ label: 'Sort descending', checked: sorted === 'desc', action: () => this.sortBy(column, 'desc') },
|
|
210
|
+
...(sorted ? [{ label: 'Clear sort', action: () => this.sortBy(column, null) }] : []),
|
|
211
|
+
{ separator: true },
|
|
212
|
+
{
|
|
213
|
+
label: 'Filter to',
|
|
214
|
+
items: [
|
|
215
|
+
...listed.map(v => (v == null
|
|
216
|
+
? { label: '(empty)', action: () => this.addFilter(column, 'is null') }
|
|
217
|
+
: { label: plain(v), action: () => this.addFilter(column, '=', v) })),
|
|
218
|
+
...(values.length > listed.length ? [{ label: `${values.length - listed.length} more — filter from a cell`, disabled: true }] : []),
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
...(this.filters.some(f => f.column === column) ? [{ label: `Clear filters on ${column}`, action: () => this.removeFilters(column) }] : []),
|
|
222
|
+
{ separator: true },
|
|
223
|
+
{ label: 'Hide column', disabled: this.visibleColumns().length <= 1, action: () => this.hide(column) },
|
|
224
|
+
{ label: 'Copy column', action: () => this.copy([column, ...this.ordered().map(r => plain(r[column]))].join('\n')) },
|
|
225
|
+
...this.extra('column', { column }),
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The table's menu; `onShowAs(choice)` draws a chosen chart (the host's — RenderEngine.resultView). */
|
|
230
|
+
tableMenu(onShowAs = null) {
|
|
231
|
+
const choices = onShowAs ? this.chartChoices() : [];
|
|
232
|
+
return [
|
|
233
|
+
...(onShowAs ? [{
|
|
234
|
+
label: 'Show as',
|
|
235
|
+
items: choices.length > 0
|
|
236
|
+
? choices.map(c => ({ label: `${c.label} — ${c.reason}`, action: () => onShowAs(c) }))
|
|
237
|
+
: [{ label: 'No chart fits these columns', disabled: true }],
|
|
238
|
+
}, { separator: true }] : []),
|
|
239
|
+
{ label: 'Clear all filters', disabled: this.filters.length === 0, action: () => this.clearFilters() },
|
|
240
|
+
{ label: 'Show hidden columns', disabled: this.hidden.size === 0, action: () => this.showAll() },
|
|
241
|
+
{ label: 'Copy table', action: () => this.copy(this.asTsv()) },
|
|
242
|
+
...this.extra('table', {}),
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The chart types that fit what the table is showing — its filtered, sorted rows and visible
|
|
248
|
+
* columns — as ChartChooser gives them, without the table. Filtered rows are not the whole
|
|
249
|
+
* answer, so they are never offered as shares of a whole.
|
|
250
|
+
*/
|
|
251
|
+
chartChoices() {
|
|
252
|
+
const columns = this.visibleColumns();
|
|
253
|
+
const names = columns.map(c => c.name);
|
|
254
|
+
const rows = this.ordered().map(r => Object.fromEntries(names.map(n => [n, r[n]])));
|
|
255
|
+
return this.chooser.choose(rows, columns, { complete: this.complete && this.filters.length === 0 })
|
|
256
|
+
.filter(c => c.key !== 'table');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
extra(kind, context) {
|
|
260
|
+
const items = this.menus?.[kind]?.({ view: this, ...context }) ?? [];
|
|
261
|
+
return items.length > 0 ? [{ separator: true }, ...items] : [];
|
|
262
|
+
}
|
|
263
|
+
}
|