@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.
@@ -0,0 +1,126 @@
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
+
20
+ /**
21
+ * @param renderer
22
+ * @param {TableView} view
23
+ * @param {object} [options] title, nullText; onShowAs(choice) — draws a chart for the table menu's
24
+ * "Show as"; showMenu(x, y, items) — opens a menu (PopupMenu.show by default)
25
+ */
26
+ constructor(renderer, view, options = {}) {
27
+ super(renderer, view.rows, options);
28
+ this.view = view;
29
+ this.showMenu = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
30
+ }
31
+
32
+ /** The height this draws: title, header, the page's rows and the footer. */
33
+ static heightFor(view, { title } = {}) {
34
+ const { top, bottom } = TableChart.margins(title);
35
+ return top + (2 + view.pageRows().length) * TableChart.ROW_H + bottom;
36
+ }
37
+
38
+ _render() {
39
+ const { view } = this;
40
+ const { title, nullText = '—' } = this.options;
41
+ const { ROW_H, PAD_X } = TableChart;
42
+ const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(title));
43
+ const interactive = this.renderer.supportsSceneGraph === true;
44
+ const columns = view.visibleColumns().map(c => ({ key: c.name, label: c.name, align: c.kind === 'numeric' ? 'end' : 'start' }));
45
+ const gutter = TableViewChart.GUTTER_W;
46
+ const colW = columns.length > 0 ? (box.width - gutter) / columns.length : box.width - gutter;
47
+ const x0 = box.x + gutter;
48
+
49
+ new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
50
+
51
+ // Header: the corner (table menu), then a header per column (sort; ▾ column menu).
52
+ let top = box.y;
53
+ this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.HEADER_FILL, null);
54
+ const corner = this.renderer.rect(box.x, top, gutter, ROW_H, TableChart.HEADER_FILL, null);
55
+ const cornerGlyph = this._text(TableViewChart.TABLE_GLYPH, box.x + gutter / 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' });
56
+ this._on(interactive, [corner, cornerGlyph], (e) => this.showMenu(e.clientX, e.clientY, view.tableMenu(this.options.onShowAs ?? null)), 'Table menu');
57
+
58
+ columns.forEach((c, i) => {
59
+ const left = x0 + i * colW;
60
+ const sorted = view.sort?.column === c.key ? (view.sort.direction === 'asc' ? ' ▲' : ' ▼') : '';
61
+ // In words: an arrow-like mark here read as a second sort arrow.
62
+ const filtered = view.filters.some(f => f.column === c.key) ? ' (filtered)' : '';
63
+ const head = this.renderer.rect(left, top, colW, ROW_H, TableChart.HEADER_FILL, null);
64
+ // The label leaves room at the right for the ▾.
65
+ const label = this._cell(`${c.label}${sorted}${filtered}`, c, left, top, colW - 16, { fontWeight: 'bold', fill: '#333' });
66
+ this._on(interactive, [head, label], () => view.toggleSort(c.key), 'Sort');
67
+ const glyph = this._text(TableViewChart.MENU_GLYPH, left + colW - 8, top, { textAnchor: 'middle', fill: '#777' });
68
+ this._on(interactive, [glyph], (e) => this.showMenu(e.clientX, e.clientY, view.columnMenu(c.key)), `${c.label} menu`);
69
+ });
70
+ top += ROW_H;
71
+ this._rule(box.x, top, box.width);
72
+
73
+ // Rows: a number in the gutter (row menu), a cell per column (cell menu).
74
+ view.pageRows().forEach(({ row, index }, r) => {
75
+ if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
76
+ const number = this._text(String(index + 1), box.x + gutter - PAD_X, top, { textAnchor: 'end', fill: '#999', fontSize: '11px' });
77
+ this._on(interactive, [number], (e) => this.showMenu(e.clientX, e.clientY, view.rowMenu(row)), 'Row menu');
78
+ columns.forEach((c, i) => {
79
+ const left = x0 + i * colW;
80
+ const hit = this.renderer.rect(left, top, colW, ROW_H, 'transparent', null);
81
+ const text = this._cell(TableChart.cellText(row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
82
+ this._on(interactive, [hit, text], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(row, c.key)), null);
83
+ });
84
+ top += ROW_H;
85
+ this._rule(box.x, top, box.width);
86
+ });
87
+
88
+ this._footer(box, top, interactive);
89
+ }
90
+
91
+ /** "1–50 of 120 rows (filtered from 300: Region = West)", and ‹ › when there is more than a page. */
92
+ _footer(box, top, interactive) {
93
+ const { view } = this;
94
+ const shown = view.pageRows();
95
+ const total = view.filtered().length;
96
+ const range = shown.length === 0 ? 'No rows' : `${shown[0].index + 1}–${shown.at(-1).index + 1} of ${total} row${total === 1 ? '' : 's'}`;
97
+ const filters = view.filters.length > 0 ? ` (filtered from ${view.rows.length}: ${view.describeFilters()})` : '';
98
+ this._text(`${range}${filters}`, box.x + TableChart.PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
99
+ if (view.pageCount() > 1) {
100
+ const right = box.x + box.width - TableChart.PAD_X;
101
+ const previous = this._text('‹ previous', right - 70, top, { textAnchor: 'end', fill: view.page > 0 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
102
+ const next = this._text('next ›', right, top, { textAnchor: 'end', fill: view.page < view.pageCount() - 1 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
103
+ this._on(interactive, [previous], () => view.goToPage(view.page - 1), 'Previous page');
104
+ this._on(interactive, [next], () => view.goToPage(view.page + 1), 'Next page');
105
+ }
106
+ }
107
+
108
+ _text(text, x, top, attrs) {
109
+ return this.renderer.text(text, x, top + TableChart.ROW_H / 2, { verticalAlign: 'middle', fontSize: '12px', ...attrs });
110
+ }
111
+
112
+ /** Wires a click to each element that can take one (SVG); a pointer cursor and a tooltip say so. */
113
+ _on(interactive, elements, handler, title) {
114
+ if (!interactive) return;
115
+ for (const el of elements) {
116
+ if (typeof el?.addEventListener !== 'function') continue;
117
+ el.addEventListener('click', (event) => { event.stopPropagation?.(); handler(event); });
118
+ if (el.style) el.style.cursor = 'pointer';
119
+ if (title && typeof el.setAttribute === 'function' && el.ownerDocument) {
120
+ const t = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'title');
121
+ t.textContent = title;
122
+ el.appendChild(t);
123
+ }
124
+ }
125
+ }
126
+ }
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