@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
|
@@ -0,0 +1,268 @@
|
|
|
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
|
+
* @param {function} [options.highlight] row → true for a row drawn highlighted (a host's selection)
|
|
55
|
+
*/
|
|
56
|
+
constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null } = {}) {
|
|
57
|
+
this.rows = rows;
|
|
58
|
+
this.columns = ChartChooser.describe(rows, columns);
|
|
59
|
+
this.complete = complete;
|
|
60
|
+
this.pageSize = pageSize;
|
|
61
|
+
this.onChange = onChange;
|
|
62
|
+
this.menus = menus;
|
|
63
|
+
this.copy = copy ?? TableView.clipboard;
|
|
64
|
+
this.chooser = chooser ?? new ChartChooser();
|
|
65
|
+
this.highlight = highlight;
|
|
66
|
+
this.sort = null; // { column, direction: 'asc' | 'desc' }
|
|
67
|
+
this.filters = []; // { column, op, value }
|
|
68
|
+
this.hidden = new Set();
|
|
69
|
+
this.page = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static clipboard(text) {
|
|
73
|
+
return globalThis.navigator?.clipboard?.writeText(text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── What is showing ───────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
visibleColumns() { return this.columns.filter(c => !this.hidden.has(c.name)); }
|
|
79
|
+
|
|
80
|
+
/** Whether a row is drawn highlighted: the host's `highlight` says (its selection, say). */
|
|
81
|
+
isHighlighted(row) { return this.highlight?.(row) === true; }
|
|
82
|
+
|
|
83
|
+
filtered() {
|
|
84
|
+
return this.filters.length === 0 ? this.rows
|
|
85
|
+
: this.rows.filter(r => this.filters.every(f => FILTER_OPS[f.op](r[f.column], f.value)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The filtered rows in sort order (a copy; equal values keep their order). */
|
|
89
|
+
ordered() {
|
|
90
|
+
const rows = this.filtered();
|
|
91
|
+
if (this.sort === null) return rows;
|
|
92
|
+
const { column, direction } = this.sort;
|
|
93
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
94
|
+
// Nulls stay last whichever way: a reader looks for values, not for blanks.
|
|
95
|
+
return [...rows].sort((a, b) => (a[column] == null || b[column] == null ? compareValues(a[column], b[column]) : sign * compareValues(a[column], b[column])));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pageCount() {
|
|
99
|
+
return Number.isFinite(this.pageSize) ? Math.max(1, Math.ceil(this.filtered().length / this.pageSize)) : 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The rows on the current page, each with its position in the ordered rows (`index`, from 0). */
|
|
103
|
+
pageRows() {
|
|
104
|
+
const rows = this.ordered();
|
|
105
|
+
const start = Number.isFinite(this.pageSize) ? this.page * this.pageSize : 0;
|
|
106
|
+
const end = Number.isFinite(this.pageSize) ? start + this.pageSize : rows.length;
|
|
107
|
+
return rows.slice(start, end).map((row, i) => ({ row, index: start + i }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** "Region = West · Revenue > 100", or '' with no filters. */
|
|
111
|
+
describeFilters() {
|
|
112
|
+
return this.filters.map(f => (f.op.startsWith('is') ? `${f.column} ${f.op}` : `${f.column} ${f.op} ${plain(f.value)}`)).join(' · ');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Changes ───────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
changed() {
|
|
118
|
+
this.page = Math.min(this.page, this.pageCount() - 1);
|
|
119
|
+
this.onChange?.(this);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A header click: unsorted → ascending → descending → unsorted. */
|
|
123
|
+
toggleSort(column) {
|
|
124
|
+
const current = this.sort?.column === column ? this.sort.direction : null;
|
|
125
|
+
this.sortBy(column, current === null ? 'asc' : current === 'asc' ? 'desc' : null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sortBy(column, direction) {
|
|
129
|
+
this.sort = direction === null ? null : { column, direction };
|
|
130
|
+
this.page = 0;
|
|
131
|
+
this.changed();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
addFilter(column, op, value = null) {
|
|
135
|
+
if (!Object.hasOwn(FILTER_OPS, op)) throw new Error(`Unknown filter "${op}"`);
|
|
136
|
+
this.filters = [...this.filters.filter(f => !(f.column === column && f.op === op && f.value === value)), { column, op, value }];
|
|
137
|
+
this.page = 0;
|
|
138
|
+
this.changed();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
removeFilters(column) {
|
|
142
|
+
this.filters = this.filters.filter(f => f.column !== column);
|
|
143
|
+
this.changed();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clearFilters() {
|
|
147
|
+
this.filters = [];
|
|
148
|
+
this.changed();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Hides a column; the last one showing cannot be hidden. */
|
|
152
|
+
hide(column) {
|
|
153
|
+
if (this.visibleColumns().length <= 1) return;
|
|
154
|
+
this.hidden.add(column);
|
|
155
|
+
this.changed();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
showAll() {
|
|
159
|
+
this.hidden.clear();
|
|
160
|
+
this.changed();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
goToPage(page) {
|
|
164
|
+
this.page = Math.max(0, Math.min(page, this.pageCount() - 1));
|
|
165
|
+
this.changed();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Text out ──────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/** Rows as tab-separated text, header first, the visible columns only. */
|
|
171
|
+
asTsv(rows = this.ordered()) {
|
|
172
|
+
const columns = this.visibleColumns().map(c => c.name);
|
|
173
|
+
const clean = (v) => plain(v).replace(/[\t\r\n]+/g, ' ');
|
|
174
|
+
return [columns.map(clean).join('\t'), ...rows.map(r => columns.map(c => clean(r[c])).join('\t'))].join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Menus (PopupMenu items) ──────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
cellMenu(row, column) {
|
|
180
|
+
const value = row[column];
|
|
181
|
+
const kind = this.columns.find(c => c.name === column)?.kind;
|
|
182
|
+
const shown = value == null ? 'empty' : plain(value);
|
|
183
|
+
const items = value == null
|
|
184
|
+
? [{ label: `Only rows where ${column} is empty`, action: () => this.addFilter(column, 'is null') },
|
|
185
|
+
{ label: `Exclude rows where ${column} is empty`, action: () => this.addFilter(column, 'is not null') }]
|
|
186
|
+
: [{ label: `Only rows where ${column} = ${shown}`, action: () => this.addFilter(column, '=', value) },
|
|
187
|
+
{ label: `Exclude rows where ${column} = ${shown}`, action: () => this.addFilter(column, '!=', value) },
|
|
188
|
+
...(kind === 'numeric' ? [
|
|
189
|
+
{ label: `Only rows where ${column} > ${shown}`, action: () => this.addFilter(column, '>', value) },
|
|
190
|
+
{ label: `Only rows where ${column} < ${shown}`, action: () => this.addFilter(column, '<', value) },
|
|
191
|
+
] : [])];
|
|
192
|
+
return [
|
|
193
|
+
...items,
|
|
194
|
+
{ separator: true },
|
|
195
|
+
{ label: 'Copy value', action: () => this.copy(plain(value)) },
|
|
196
|
+
...this.extra('cell', { row, column, value }),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
rowMenu(row) {
|
|
201
|
+
return [
|
|
202
|
+
{ label: 'Copy row', action: () => this.copy(this.asTsv([row])) },
|
|
203
|
+
{ label: 'Copy row as JSON', action: () => this.copy(JSON.stringify(row)) },
|
|
204
|
+
...this.extra('row', { row }),
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
columnMenu(column) {
|
|
209
|
+
const sorted = this.sort?.column === column ? this.sort.direction : null;
|
|
210
|
+
const values = [...new Set(this.filtered().map(r => r[column]))].sort(compareValues);
|
|
211
|
+
const listed = values.slice(0, TableView.MAX_FILTER_VALUES);
|
|
212
|
+
return [
|
|
213
|
+
{ label: 'Sort ascending', checked: sorted === 'asc', action: () => this.sortBy(column, 'asc') },
|
|
214
|
+
{ label: 'Sort descending', checked: sorted === 'desc', action: () => this.sortBy(column, 'desc') },
|
|
215
|
+
...(sorted ? [{ label: 'Clear sort', action: () => this.sortBy(column, null) }] : []),
|
|
216
|
+
{ separator: true },
|
|
217
|
+
{
|
|
218
|
+
label: 'Filter to',
|
|
219
|
+
items: [
|
|
220
|
+
...listed.map(v => (v == null
|
|
221
|
+
? { label: '(empty)', action: () => this.addFilter(column, 'is null') }
|
|
222
|
+
: { label: plain(v), action: () => this.addFilter(column, '=', v) })),
|
|
223
|
+
...(values.length > listed.length ? [{ label: `${values.length - listed.length} more — filter from a cell`, disabled: true }] : []),
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
...(this.filters.some(f => f.column === column) ? [{ label: `Clear filters on ${column}`, action: () => this.removeFilters(column) }] : []),
|
|
227
|
+
{ separator: true },
|
|
228
|
+
{ label: 'Hide column', disabled: this.visibleColumns().length <= 1, action: () => this.hide(column) },
|
|
229
|
+
{ label: 'Copy column', action: () => this.copy([column, ...this.ordered().map(r => plain(r[column]))].join('\n')) },
|
|
230
|
+
...this.extra('column', { column }),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The table's menu; `onShowAs(choice)` draws a chosen chart (the host's — RenderEngine.resultView). */
|
|
235
|
+
tableMenu(onShowAs = null) {
|
|
236
|
+
const choices = onShowAs ? this.chartChoices() : [];
|
|
237
|
+
return [
|
|
238
|
+
...(onShowAs ? [{
|
|
239
|
+
label: 'Show as',
|
|
240
|
+
items: choices.length > 0
|
|
241
|
+
? choices.map(c => ({ label: `${c.label} — ${c.reason}`, action: () => onShowAs(c) }))
|
|
242
|
+
: [{ label: 'No chart fits these columns', disabled: true }],
|
|
243
|
+
}, { separator: true }] : []),
|
|
244
|
+
{ label: 'Clear all filters', disabled: this.filters.length === 0, action: () => this.clearFilters() },
|
|
245
|
+
{ label: 'Show hidden columns', disabled: this.hidden.size === 0, action: () => this.showAll() },
|
|
246
|
+
{ label: 'Copy table', action: () => this.copy(this.asTsv()) },
|
|
247
|
+
...this.extra('table', {}),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The chart types that fit what the table is showing — its filtered, sorted rows and visible
|
|
253
|
+
* columns — as ChartChooser gives them, without the table. Filtered rows are not the whole
|
|
254
|
+
* answer, so they are never offered as shares of a whole.
|
|
255
|
+
*/
|
|
256
|
+
chartChoices() {
|
|
257
|
+
const columns = this.visibleColumns();
|
|
258
|
+
const names = columns.map(c => c.name);
|
|
259
|
+
const rows = this.ordered().map(r => Object.fromEntries(names.map(n => [n, r[n]])));
|
|
260
|
+
return this.chooser.choose(rows, columns, { complete: this.complete && this.filters.length === 0 })
|
|
261
|
+
.filter(c => c.key !== 'table');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
extra(kind, context) {
|
|
265
|
+
const items = this.menus?.[kind]?.({ view: this, ...context }) ?? [];
|
|
266
|
+
return items.length > 0 ? [{ separator: true }, ...items] : [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { TableChart } from '../charts/TableChart.js';
|
|
2
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
3
|
+
import { PopupMenu } from '../PopupMenu.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TableViewChart — the interactive table: a TableChart drawn from a TableView's state, whose parts
|
|
7
|
+
* answer clicks. A header sorts (and its ▾ opens the column's menu); a cell opens the cell's menu; a
|
|
8
|
+
* row number opens the row's menu; the corner ⋮ opens the table's menu (with "Show as" when the host
|
|
9
|
+
* can draw a chart); the footer's ‹ › page. Each change goes through the view, whose `onChange`
|
|
10
|
+
* redraws.
|
|
11
|
+
*
|
|
12
|
+
* Interaction needs a renderer whose shapes are DOM elements (SVG: `supportsSceneGraph`); anywhere
|
|
13
|
+
* else it draws the same table without the clicks.
|
|
14
|
+
*/
|
|
15
|
+
export class TableViewChart extends TableChart {
|
|
16
|
+
static GUTTER_W = 44;
|
|
17
|
+
static MENU_GLYPH = '▾';
|
|
18
|
+
static TABLE_GLYPH = '⋮';
|
|
19
|
+
static HIGHLIGHT_FILL = '#fdeeb5';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param renderer
|
|
23
|
+
* @param {TableView} view
|
|
24
|
+
* @param {object} [options] title, nullText; onShowAs(choice) — draws a chart for the table menu's
|
|
25
|
+
* "Show as"; showMenu(x, y, items) — opens a menu (PopupMenu.show by default)
|
|
26
|
+
*/
|
|
27
|
+
constructor(renderer, view, options = {}) {
|
|
28
|
+
super(renderer, view.rows, options);
|
|
29
|
+
this.view = view;
|
|
30
|
+
this.showMenu = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The height this draws: title, header, the page's rows and the footer. */
|
|
34
|
+
static heightFor(view, { title } = {}) {
|
|
35
|
+
const { top, bottom } = TableChart.margins(title);
|
|
36
|
+
return top + (2 + view.pageRows().length) * TableChart.ROW_H + bottom;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_render() {
|
|
40
|
+
const { view } = this;
|
|
41
|
+
const { title, nullText = '—' } = this.options;
|
|
42
|
+
const { ROW_H, PAD_X } = TableChart;
|
|
43
|
+
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(title));
|
|
44
|
+
const interactive = this.renderer.supportsSceneGraph === true;
|
|
45
|
+
const columns = view.visibleColumns().map(c => ({ key: c.name, label: c.name, align: c.kind === 'numeric' ? 'end' : 'start' }));
|
|
46
|
+
const gutter = TableViewChart.GUTTER_W;
|
|
47
|
+
const colW = columns.length > 0 ? (box.width - gutter) / columns.length : box.width - gutter;
|
|
48
|
+
const x0 = box.x + gutter;
|
|
49
|
+
|
|
50
|
+
new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
|
|
51
|
+
|
|
52
|
+
// Header: the corner (table menu), then a header per column (sort; ▾ column menu).
|
|
53
|
+
let top = box.y;
|
|
54
|
+
this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.HEADER_FILL, null);
|
|
55
|
+
const corner = this.renderer.rect(box.x, top, gutter, ROW_H, TableChart.HEADER_FILL, null);
|
|
56
|
+
const cornerGlyph = this._text(TableViewChart.TABLE_GLYPH, box.x + gutter / 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' });
|
|
57
|
+
this._on(interactive, [corner, cornerGlyph], (e) => this.showMenu(e.clientX, e.clientY, view.tableMenu(this.options.onShowAs ?? null)), 'Table menu');
|
|
58
|
+
|
|
59
|
+
columns.forEach((c, i) => {
|
|
60
|
+
const left = x0 + i * colW;
|
|
61
|
+
const sorted = view.sort?.column === c.key ? (view.sort.direction === 'asc' ? ' ▲' : ' ▼') : '';
|
|
62
|
+
// In words: an arrow-like mark here read as a second sort arrow.
|
|
63
|
+
const filtered = view.filters.some(f => f.column === c.key) ? ' (filtered)' : '';
|
|
64
|
+
const head = this.renderer.rect(left, top, colW, ROW_H, TableChart.HEADER_FILL, null);
|
|
65
|
+
// The label leaves room at the right for the ▾.
|
|
66
|
+
const label = this._cell(`${c.label}${sorted}${filtered}`, c, left, top, colW - 16, { fontWeight: 'bold', fill: '#333' });
|
|
67
|
+
this._on(interactive, [head, label], () => view.toggleSort(c.key), 'Sort');
|
|
68
|
+
const glyph = this._text(TableViewChart.MENU_GLYPH, left + colW - 8, top, { textAnchor: 'middle', fill: '#777' });
|
|
69
|
+
this._on(interactive, [glyph], (e) => this.showMenu(e.clientX, e.clientY, view.columnMenu(c.key)), `${c.label} menu`);
|
|
70
|
+
});
|
|
71
|
+
top += ROW_H;
|
|
72
|
+
this._rule(box.x, top, box.width);
|
|
73
|
+
|
|
74
|
+
// Rows: a number in the gutter (row menu), a cell per column (cell menu).
|
|
75
|
+
view.pageRows().forEach(({ row, index }, r) => {
|
|
76
|
+
if (view.isHighlighted(row)) this.renderer.rect(box.x, top, box.width, ROW_H, TableViewChart.HIGHLIGHT_FILL, null);
|
|
77
|
+
else if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
|
|
78
|
+
const number = this._text(String(index + 1), box.x + gutter - PAD_X, top, { textAnchor: 'end', fill: '#999', fontSize: '11px' });
|
|
79
|
+
this._on(interactive, [number], (e) => this.showMenu(e.clientX, e.clientY, view.rowMenu(row)), 'Row menu');
|
|
80
|
+
columns.forEach((c, i) => {
|
|
81
|
+
const left = x0 + i * colW;
|
|
82
|
+
const hit = this.renderer.rect(left, top, colW, ROW_H, 'transparent', null);
|
|
83
|
+
const text = this._cell(TableChart.cellText(row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
|
|
84
|
+
this._on(interactive, [hit, text], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(row, c.key)), null);
|
|
85
|
+
});
|
|
86
|
+
top += ROW_H;
|
|
87
|
+
this._rule(box.x, top, box.width);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this._footer(box, top, interactive);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "1–50 of 120 rows (filtered from 300: Region = West)", and ‹ › when there is more than a page. */
|
|
94
|
+
_footer(box, top, interactive) {
|
|
95
|
+
const { view } = this;
|
|
96
|
+
const shown = view.pageRows();
|
|
97
|
+
const total = view.filtered().length;
|
|
98
|
+
const range = shown.length === 0 ? 'No rows' : `${shown[0].index + 1}–${shown.at(-1).index + 1} of ${total} row${total === 1 ? '' : 's'}`;
|
|
99
|
+
const filters = view.filters.length > 0 ? ` (filtered from ${view.rows.length}: ${view.describeFilters()})` : '';
|
|
100
|
+
this._text(`${range}${filters}`, box.x + TableChart.PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
|
|
101
|
+
if (view.pageCount() > 1) {
|
|
102
|
+
const right = box.x + box.width - TableChart.PAD_X;
|
|
103
|
+
const previous = this._text('‹ previous', right - 70, top, { textAnchor: 'end', fill: view.page > 0 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
|
|
104
|
+
const next = this._text('next ›', right, top, { textAnchor: 'end', fill: view.page < view.pageCount() - 1 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
|
|
105
|
+
this._on(interactive, [previous], () => view.goToPage(view.page - 1), 'Previous page');
|
|
106
|
+
this._on(interactive, [next], () => view.goToPage(view.page + 1), 'Next page');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_text(text, x, top, attrs) {
|
|
111
|
+
return this.renderer.text(text, x, top + TableChart.ROW_H / 2, { verticalAlign: 'middle', fontSize: '12px', ...attrs });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Wires a click to each element that can take one (SVG); a pointer cursor and a tooltip say so. */
|
|
115
|
+
_on(interactive, elements, handler, title) {
|
|
116
|
+
if (!interactive) return;
|
|
117
|
+
for (const el of elements) {
|
|
118
|
+
if (typeof el?.addEventListener !== 'function') continue;
|
|
119
|
+
el.addEventListener('click', (event) => { event.stopPropagation?.(); handler(event); });
|
|
120
|
+
if (el.style) el.style.cursor = 'pointer';
|
|
121
|
+
if (title && typeof el.setAttribute === 'function' && el.ownerDocument) {
|
|
122
|
+
const t = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'title');
|
|
123
|
+
t.textContent = title;
|
|
124
|
+
el.appendChild(t);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Abstract base for "own a bounded collection, push one more item, drop the
|
|
3
3
|
* oldest once past a cap" — the one behavior every streamable chart data
|
|
4
4
|
* shape shares, however differently each stores its collection(s). See
|
|
5
|
-
* ARCHITECTURE.md's "Data windows — streaming" section for the concrete
|
|
5
|
+
* documentation/ARCHITECTURE.md's "Data windows — streaming" section for the concrete
|
|
6
6
|
* subtypes and why each exists as its own class rather than a branch here.
|
|
7
7
|
*/
|
|
8
8
|
export class BoundedWindow {
|
|
@@ -2,7 +2,7 @@ import { BoundedWindow } from './BoundedWindow.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A nodes+edges pair — the shape behind `graphDiagram`/`graphDiagram3D`.
|
|
5
|
-
* Not a `CHART_CATALOG` entry (see ARCHITECTURE.md), so it isn't dispatched
|
|
5
|
+
* Not a `CHART_CATALOG` entry (see documentation/ARCHITECTURE.md), so it isn't dispatched
|
|
6
6
|
* through `createDataWindow`/`streamShape`; `RenderEngine.addGraphNode`
|
|
7
7
|
* constructs it directly. Trimming drops the oldest nodes FIFO, then any
|
|
8
8
|
* edge left dangling off a removed node — dropping nodes without this would
|
package/src/index.js
CHANGED
|
@@ -35,6 +35,8 @@ import { SunburstChart } from './charting/charts/SunburstChart.js';
|
|
|
35
35
|
import { OutlineChart } from './charting/charts/OutlineChart.js';
|
|
36
36
|
import { TableChart } from './charting/charts/TableChart.js';
|
|
37
37
|
import { ChartChooser } from './charting/ChartChooser.js';
|
|
38
|
+
import { TableView } from './charting/table/TableView.js';
|
|
39
|
+
import { TableViewChart } from './charting/table/TableViewChart.js';
|
|
38
40
|
import { RadarChart } from './charting/charts/RadarChart.js';
|
|
39
41
|
import { FunnelChart } from './charting/charts/FunnelChart.js';
|
|
40
42
|
import { WaterfallChart } from './charting/charts/WaterfallChart.js';
|
|
@@ -217,7 +219,7 @@ import {
|
|
|
217
219
|
// separate browser-only entry: import 'renderengine/src/workspace.js' directly.
|
|
218
220
|
|
|
219
221
|
export {
|
|
220
|
-
Mat4, OrbitCamera, CHART_CATALOG, getChartCatalogEntry, chartRegistry, ChartChooser, TableChart,
|
|
222
|
+
Mat4, OrbitCamera, CHART_CATALOG, getChartCatalogEntry, chartRegistry, ChartChooser, TableChart, TableView, TableViewChart,
|
|
221
223
|
DIAGRAM_CATALOG, getDiagramCatalogEntry, diagramRegistry,
|
|
222
224
|
TREE_MODE_CATALOG, treeModeRegistry, getTreeModeCatalogEntry,
|
|
223
225
|
AXIS_KIND, ENCODING_CHANNEL, COORD_SYSTEM, COLOR_MODE, CAMERA_MODE,
|
|
@@ -1437,6 +1439,62 @@ export class RenderEngine {
|
|
|
1437
1439
|
this._renderChart(new TableChart(this.renderer, data, options));
|
|
1438
1440
|
}
|
|
1439
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Shows result rows as an interactive table — sort, filter, hide columns, page, copy, through
|
|
1444
|
+
* menus on its cells, rows, columns and the table itself — whose table menu can show what it
|
|
1445
|
+
* holds as any chart type ChartChooser says fits ("Show as"); a chart drawn that way has a
|
|
1446
|
+
* "◂ Table" link back to the table as it was left. One output for a result, table or chart.
|
|
1447
|
+
*
|
|
1448
|
+
* The container's height follows what is drawn (the table's rows, or `chartHeight` for a chart),
|
|
1449
|
+
* so the host gives the container a width only. Interaction needs the SVG renderer; on canvas
|
|
1450
|
+
* the table is drawn without it.
|
|
1451
|
+
* @param {Array<object>} rows
|
|
1452
|
+
* @param {object} [options]
|
|
1453
|
+
* @param {Array<{name, kind?, measure?}>} [options.columns] hints (ChartChooser's)
|
|
1454
|
+
* @param {boolean} [options.complete] false when the rows are only part of the answer
|
|
1455
|
+
* @param {number} [options.pageSize] rows per page (50)
|
|
1456
|
+
* @param {object} [options.menus] the host's own items: { cell, row, column, table } → items
|
|
1457
|
+
* @param {string} [options.title]
|
|
1458
|
+
* @param {number} [options.chartHeight] a chart's height (360)
|
|
1459
|
+
* @param {boolean} [options.fitHeight] size the container to what is drawn (true)
|
|
1460
|
+
* @returns {TableView|null} the table's state, for the host to read or change
|
|
1461
|
+
*/
|
|
1462
|
+
resultView(rows, options = {}) {
|
|
1463
|
+
if (!this.renderer) return null;
|
|
1464
|
+
const view = new TableView(rows, { ...options, onChange: () => this._drawResultTable(view, options) });
|
|
1465
|
+
this._drawResultTable(view, options);
|
|
1466
|
+
return view;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
_drawResultTable(view, options) {
|
|
1470
|
+
this._fitResultHeight(TableViewChart.heightFor(view, options), options);
|
|
1471
|
+
this._chart3D = false;
|
|
1472
|
+
this._chartState = { type: 'table', data: view.rows, options };
|
|
1473
|
+
this._renderChart(new TableViewChart(this.renderer, view, {
|
|
1474
|
+
...options,
|
|
1475
|
+
onShowAs: (choice) => this._drawResultChart(view, choice, options),
|
|
1476
|
+
}));
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
_drawResultChart(view, choice, options) {
|
|
1480
|
+
this._fitResultHeight(options.chartHeight ?? 360, options);
|
|
1481
|
+
this.renderChartType(choice.key, choice.data, { title: options.title ?? choice.reason });
|
|
1482
|
+
// The way back, drawn over the chart: the same table, as it was left.
|
|
1483
|
+
if (!this.renderer.supportsSceneGraph) return;
|
|
1484
|
+
const back = this.renderer.text('◂ Table', 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle' });
|
|
1485
|
+
back?.addEventListener?.('click', () => this._drawResultTable(view, options));
|
|
1486
|
+
if (back?.style) back.style.cursor = 'pointer';
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
_fitResultHeight(height, options) {
|
|
1490
|
+
if (options.fitHeight === false || !this.container?.style) return;
|
|
1491
|
+
// min-height as well: in a flex layout (a notebook, a workbench pane) a height alone is
|
|
1492
|
+
// shrunk to fit, and the table drawn into the shrunk box lost its last rows.
|
|
1493
|
+
this.container.style.height = `${height}px`;
|
|
1494
|
+
this.container.style.minHeight = `${height}px`;
|
|
1495
|
+
this.fitToContainer();
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1440
1498
|
/**
|
|
1441
1499
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
1442
1500
|
* previous cumulative value to the new one (a "delta" bar), except
|
|
@@ -1761,7 +1819,7 @@ export class RenderEngine {
|
|
|
1761
1819
|
/**
|
|
1762
1820
|
* Renders an arc gauge: a half circle (or any start angle and sweep) filled to the value.
|
|
1763
1821
|
* @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
|
|
1764
|
-
* @param {object} [options] The gauge options (see REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
|
|
1822
|
+
* @param {object} [options] The gauge options (see documentation/REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
|
|
1765
1823
|
*/
|
|
1766
1824
|
arcGauge(value, options = {}) {
|
|
1767
1825
|
this._renderGauge(ArcGauge, 'arcGauge', value, options);
|
|
@@ -2696,7 +2754,7 @@ export class RenderEngine {
|
|
|
2696
2754
|
/**
|
|
2697
2755
|
* Appends one data point to a registered chart's data and re-renders it,
|
|
2698
2756
|
* keeping at most `maxPoints` (oldest dropped first) — see
|
|
2699
|
-
* REFERENCE.md's "Dynamically adding data points in a bounded window".
|
|
2757
|
+
* documentation/REFERENCE.md's "Dynamically adding data points in a bounded window".
|
|
2700
2758
|
* Delegates the actual shape-aware append/trim to a `datawindow/`
|
|
2701
2759
|
* class, selected via the chart's `CHART_CATALOG` `streamShape`; the
|
|
2702
2760
|
* window instance is cached per `id` so repeated calls keep growing the
|
package/src/kernelExpr.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* not once per query. Kept as a standalone copy rather than a shared
|
|
12
12
|
* dependency — the two repos have no package link today, and this grammar is
|
|
13
13
|
* small enough that duplicating it costs less than a cross-repo dependency
|
|
14
|
-
* would. See renderengine's own `tasks.md` for why this exists, and
|
|
14
|
+
* would. See renderengine's own `documentation/tasks.md` for why this exists, and
|
|
15
15
|
* sqlengine's `documentation/kernel-language.md` for the full grammar
|
|
16
16
|
* reference this is a subset of.
|
|
17
17
|
*
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* NO GPU/WASM/WebNN TARGETS HERE, unlike the sqlengine original — renderengine
|
|
24
24
|
* has no compute-backend concept and nothing has measured a need for one for
|
|
25
|
-
* per-point chart encodings (see tasks.md's "Gated on a measurement" entry).
|
|
25
|
+
* per-point chart encodings (see documentation/tasks.md's "Gated on a measurement" entry).
|
|
26
26
|
* This only ever emits JavaScript.
|
|
27
27
|
*/
|
|
28
28
|
|
package/src/workspace/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* a shell that may only ever host adapted panels. Call
|
|
13
13
|
* `registerBuiltinParts(ws)` for the standard set, or register individually.
|
|
14
14
|
*
|
|
15
|
-
* See WORKSPACE_ARCHITECTURE.md for the design.
|
|
15
|
+
* See documentation/WORKSPACE_ARCHITECTURE.md for the design.
|
|
16
16
|
*/
|
|
17
17
|
import { Workspace, WORKSPACE_TAG, WORKSPACE_SCHEMA_VERSION, defineWorkspace } from './Workspace.js';
|
|
18
18
|
import { ContextBus } from './ContextBus.js';
|
|
@@ -103,7 +103,7 @@ export class ChartPart extends Part {
|
|
|
103
103
|
return this.chartOptions;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/** Append a point to a bounded window — see REFERENCE.md's streaming section. */
|
|
106
|
+
/** Append a point to a bounded window — see documentation/REFERENCE.md's streaming section. */
|
|
107
107
|
addDataPoint(pointOrFactory, opts) {
|
|
108
108
|
this.engine?.addDataPoint(this.chartId, pointOrFactory, opts);
|
|
109
109
|
}
|
package/src/workspace.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* never actually contributed any UI. It could not express either target
|
|
8
8
|
* consumer — sqlengine workbench's sidebar+main+tool-panels, or its 12-column
|
|
9
9
|
* dashboard grid — so it was replaced by the part/layout/context model in
|
|
10
|
-
* `src/workspace/`. See WORKSPACE_ARCHITECTURE.md.
|
|
10
|
+
* `src/workspace/`. See documentation/WORKSPACE_ARCHITECTURE.md.
|
|
11
11
|
*
|
|
12
12
|
* Kept as a re-export so `import '.../src/workspace.js'` still registers the
|
|
13
13
|
* `<ws-workspace>` element. Both files defining the same tag would otherwise
|