@peter.prib/renderengine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1479,6 +1479,16 @@
1479
1479
  dataShape: 'Array<{label, value?, children?}> — same hierarchical shape as treemap/sunburst (shares _hierarchyValue); drawn as an indented, collapsible list instead of nested rectangles/rings — a branch\'s value is auto-summed from its descendants and shown as a trailing count, a leaf\'s own extra fields (anything beyond label/value/children) shown as trailing descriptive text instead',
1480
1480
  streamShape: 'flat',
1481
1481
  },
1482
+ {
1483
+ key: 'table', label: 'Table', method: 'tableChart', category: '2d',
1484
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.NONE, camera: CAMERA_MODE.NONE,
1485
+ axes: [
1486
+ { role: 'row', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
1487
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.TEXT_VALUE, required: false },
1488
+ ],
1489
+ dataShape: 'Array<object> — one row per object, a column per key (options.columns: names or {key, label?, align?}; every key of the first row when absent); values shown exactly as text, numeric columns right-aligned; options.maxRows, options.nullText',
1490
+ streamShape: 'flat',
1491
+ },
1482
1492
  {
1483
1493
  key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
1484
1494
  coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
@@ -4283,153 +4293,155 @@
4283
4293
  return key.startsWith('--') ? key : key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
4284
4294
  }
4285
4295
 
4286
- /**
4287
- * PopupMenu — the one popup/context menu: a small menu at a screen position,
4288
- * closed by choosing an item, clicking elsewhere, Escape, or scrolling (it
4289
- * would no longer be beside what it was opened on). One is open at a time.
4290
- * Editor menus, the graph engine's ContextMenu and the model tree's menus all
4291
- * render through it.
4292
- *
4293
- * Items: `{ label, action, danger?, checked?, disabled? }`,
4294
- * `{ label, items: [...] }` for a submenu, or `{ separator: true }`.
4295
- */
4296
- class PopupMenu {
4297
- static _open = null;
4298
-
4299
- static show(x, y, items, doc = document) {
4300
- PopupMenu.close();
4301
- if (!items.length) return null;
4302
- const menu = el({
4303
- ...PopupMenu._panel(items, doc),
4304
- style: { ...PopupMenu._panelStyle, position: 'fixed', left: `${x}px`, top: `${y}px` },
4305
- }, doc);
4306
- doc.body.appendChild(menu);
4307
- // Opened near the window's right or bottom edge, it moves back into the window.
4308
- const win = doc.defaultView;
4309
- const box = menu.getBoundingClientRect?.();
4310
- if (win && box?.width) {
4311
- const at = PopupMenu.fit(x, y, box.width, box.height, win.innerWidth, win.innerHeight);
4312
- Object.assign(menu.style, { left: `${at.x}px`, top: `${at.y}px` });
4313
- }
4314
- // Registered after the opening click has finished propagating.
4315
- const dismiss = () => PopupMenu.close();
4316
- const onKey = (e) => { if (e.key === 'Escape') PopupMenu.close(); };
4317
- setTimeout(() => {
4318
- if (PopupMenu._open?.menu !== menu) return; // closed meanwhile
4319
- doc.addEventListener('click', dismiss, { once: true });
4320
- doc.addEventListener('scroll', dismiss, { once: true, capture: true });
4321
- doc.addEventListener('keydown', onKey);
4322
- }, 0);
4323
- PopupMenu._open = { menu, dismiss, onKey, doc };
4324
- return menu;
4325
- }
4326
-
4327
- /**
4328
- * Where a menu of a size goes, asked for at (x, y), to be all inside a
4329
- * window of a size: left of the point when it would pass the right edge,
4330
- * above it when it would pass the bottom, never past the top-left.
4331
- */
4332
- static fit(x, y, width, height, windowWidth, windowHeight, margin = 4) {
4333
- const left = x + width + margin > windowWidth ? x - width : x;
4334
- const top = y + height + margin > windowHeight ? y - height : y;
4335
- return {
4336
- x: Math.max(margin, Math.min(left, windowWidth - width - margin)),
4337
- y: Math.max(margin, Math.min(top, windowHeight - height - margin)),
4338
- };
4339
- }
4340
-
4341
- /**
4342
- * Where a submenu opens beside its item (the item's box `{left, right, top}`),
4343
- * to be all inside the window: to the right, or to the left when the right
4344
- * would pass the edge (and there is more room on the left); level with the
4345
- * item, or lifted by as much as it would pass the bottom. `top` is relative
4346
- * to the item, as the submenu is positioned.
4347
- */
4348
- static submenuPlacement(item, width, height, windowWidth, windowHeight, margin = 4) {
4349
- const fitsRight = item.right + width + margin <= windowWidth;
4350
- const side = fitsRight || item.left < windowWidth - item.right ? 'right' : 'left';
4351
- const top = -4;
4352
- const overflow = item.top + top + height + margin - windowHeight;
4353
- return { side, top: overflow > 0 ? Math.max(top - overflow, margin - item.top) : top };
4354
- }
4355
-
4356
- static close() {
4357
- const open = PopupMenu._open;
4358
- if (!open) return;
4359
- open.menu.remove();
4360
- open.doc.removeEventListener('click', open.dismiss);
4361
- open.doc.removeEventListener('scroll', open.dismiss, { capture: true });
4362
- open.doc.removeEventListener('keydown', open.onKey);
4363
- PopupMenu._open = null;
4364
- }
4365
-
4366
- static get isOpen() {
4367
- return !!PopupMenu._open;
4368
- }
4369
-
4370
- static _panelStyle = {
4371
- background: '#fff', border: '1px solid #ccc', borderRadius: '3px', boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
4372
- zIndex: '10000', minWidth: '170px', fontSize: '12px', fontFamily: 'sans-serif', padding: '4px 0',
4373
- };
4374
-
4375
- static _panel(items, doc) {
4376
- return { attrs: { role: 'menu' }, children: items.map(item => PopupMenu._item(item, doc)) };
4377
- }
4378
-
4379
- static _item(item, doc) {
4380
- if (item.separator) return { attrs: { role: 'separator' }, style: { borderTop: '1px solid #ddd', margin: '4px 0' } };
4381
- const hover = item.danger ? '#ffe8e8' : '#f0f0f0';
4382
- const base = {
4383
- attrs: { role: 'menuitem', 'aria-disabled': item.disabled ? 'true' : null, 'aria-checked': item.checked != null ? String(!!item.checked) : null },
4384
- style: {
4385
- padding: '7px 14px', cursor: item.disabled ? 'default' : 'pointer', position: 'relative', whiteSpace: 'nowrap',
4386
- color: item.disabled ? '#aaa' : item.danger ? '#cc3333' : '', background: item.checked ? '#e3f2fd' : '',
4387
- },
4388
- };
4389
- if (item.items) {
4390
- let sub;
4391
- return {
4392
- ...base,
4393
- attrs: { ...base.attrs, 'aria-haspopup': 'menu' },
4394
- on: {
4395
- mouseenter: (e) => {
4396
- e.currentTarget.style.background = hover;
4397
- sub.style.display = 'block';
4398
- // Opened near the window's right or bottom edge, it opens left, or lifts.
4399
- const win = e.currentTarget.ownerDocument.defaultView;
4400
- const box = sub.getBoundingClientRect?.();
4401
- if (!win || !box?.width) return;
4402
- const at = PopupMenu.submenuPlacement(e.currentTarget.getBoundingClientRect(), box.width, box.height, win.innerWidth, win.innerHeight);
4403
- Object.assign(sub.style, at.side === 'left' ? { left: 'auto', right: '100%' } : { left: '100%', right: 'auto' }, { top: `${at.top}px` });
4404
- },
4405
- mouseleave: (e) => { e.currentTarget.style.background = ''; sub.style.display = 'none'; },
4406
- click: (e) => e.stopPropagation(),
4407
- },
4408
- children: [
4409
- `${item.label} ▸`,
4410
- {
4411
- ...PopupMenu._panel(item.items, doc),
4412
- style: { ...PopupMenu._panelStyle, position: 'absolute', left: '100%', top: '-4px', display: 'none' },
4413
- ref: (n) => { sub = n; },
4414
- },
4415
- ],
4416
- };
4417
- }
4418
- return {
4419
- ...base,
4420
- text: item.label,
4421
- on: {
4422
- mouseenter: (e) => { if (!item.disabled) e.currentTarget.style.background = hover; },
4423
- mouseleave: (e) => { e.currentTarget.style.background = item.checked ? '#e3f2fd' : ''; },
4424
- click: (e) => {
4425
- e.stopPropagation();
4426
- if (item.disabled) return;
4427
- PopupMenu.close();
4428
- item.action();
4429
- },
4430
- },
4431
- };
4432
- }
4296
+ /**
4297
+ * PopupMenu — the one popup/context menu: a small menu at a screen position,
4298
+ * closed by choosing an item, clicking elsewhere, Escape, or scrolling (it
4299
+ * would no longer be beside what it was opened on). One is open at a time.
4300
+ * Editor menus, the graph engine's ContextMenu and the model tree's menus all
4301
+ * render through it.
4302
+ *
4303
+ * Items: `{ label, action, danger?, checked?, disabled? }`,
4304
+ * `{ label, items: [...] }` for a submenu, or `{ separator: true }`.
4305
+ */
4306
+ class PopupMenu {
4307
+ static _open = null;
4308
+
4309
+ static show(x, y, items, doc = document) {
4310
+ PopupMenu.close();
4311
+ if (!items.length) return null;
4312
+ const menu = el({
4313
+ ...PopupMenu._panel(items, doc),
4314
+ style: { ...PopupMenu._panelStyle, position: 'fixed', left: `${x}px`, top: `${y}px` },
4315
+ }, doc);
4316
+ doc.body.appendChild(menu);
4317
+ // Opened near the window's right or bottom edge, it moves back into the window.
4318
+ const win = doc.defaultView;
4319
+ const box = menu.getBoundingClientRect?.();
4320
+ if (win && box?.width) {
4321
+ const at = PopupMenu.fit(x, y, box.width, box.height, win.innerWidth, win.innerHeight);
4322
+ Object.assign(menu.style, { left: `${at.x}px`, top: `${at.y}px` });
4323
+ }
4324
+ // Registered after the opening click has finished propagating.
4325
+ const dismiss = () => PopupMenu.close();
4326
+ const onKey = (e) => { if (e.key === 'Escape') PopupMenu.close(); };
4327
+ setTimeout(() => {
4328
+ if (PopupMenu._open?.menu !== menu) return; // closed meanwhile
4329
+ doc.addEventListener('click', dismiss, { once: true });
4330
+ doc.addEventListener('scroll', dismiss, { once: true, capture: true });
4331
+ doc.addEventListener('keydown', onKey);
4332
+ }, 0);
4333
+ PopupMenu._open = { menu, dismiss, onKey, doc };
4334
+ return menu;
4335
+ }
4336
+
4337
+ /**
4338
+ * Where a menu of a size goes, asked for at (x, y), to be all inside a
4339
+ * window of a size: left of the point when it would pass the right edge,
4340
+ * above it when it would pass the bottom, never past the top-left.
4341
+ */
4342
+ static fit(x, y, width, height, windowWidth, windowHeight, margin = 4) {
4343
+ const left = x + width + margin > windowWidth ? x - width : x;
4344
+ const top = y + height + margin > windowHeight ? y - height : y;
4345
+ return {
4346
+ x: Math.max(margin, Math.min(left, windowWidth - width - margin)),
4347
+ y: Math.max(margin, Math.min(top, windowHeight - height - margin)),
4348
+ };
4349
+ }
4350
+
4351
+ /**
4352
+ * Where a submenu opens beside its item (the item's box `{left, right, top}`),
4353
+ * to be all inside the window: to the right, or to the left when the right
4354
+ * would pass the edge (and there is more room on the left); level with the
4355
+ * item, or lifted by as much as it would pass the bottom. `top` is relative
4356
+ * to the item, as the submenu is positioned.
4357
+ */
4358
+ static submenuPlacement(item, width, height, windowWidth, windowHeight, margin = 4) {
4359
+ const fitsRight = item.right + width + margin <= windowWidth;
4360
+ const side = fitsRight || item.left < windowWidth - item.right ? 'right' : 'left';
4361
+ const top = -4;
4362
+ const overflow = item.top + top + height + margin - windowHeight;
4363
+ return { side, top: overflow > 0 ? Math.max(top - overflow, margin - item.top) : top };
4364
+ }
4365
+
4366
+ static close() {
4367
+ const open = PopupMenu._open;
4368
+ if (!open) return;
4369
+ open.menu.remove();
4370
+ open.doc.removeEventListener('click', open.dismiss);
4371
+ open.doc.removeEventListener('scroll', open.dismiss, { capture: true });
4372
+ open.doc.removeEventListener('keydown', open.onKey);
4373
+ PopupMenu._open = null;
4374
+ }
4375
+
4376
+ static get isOpen() {
4377
+ return !!PopupMenu._open;
4378
+ }
4379
+
4380
+ // The text colour is set with the background: inherited from a dark-themed page it was light
4381
+ // grey on this white panel, and every item but a disabled or danger one was unreadable.
4382
+ static _panelStyle = {
4383
+ background: '#fff', color: '#222', border: '1px solid #ccc', borderRadius: '3px', boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
4384
+ zIndex: '10000', minWidth: '170px', fontSize: '12px', fontFamily: 'sans-serif', padding: '4px 0',
4385
+ };
4386
+
4387
+ static _panel(items, doc) {
4388
+ return { attrs: { role: 'menu' }, children: items.map(item => PopupMenu._item(item, doc)) };
4389
+ }
4390
+
4391
+ static _item(item, doc) {
4392
+ if (item.separator) return { attrs: { role: 'separator' }, style: { borderTop: '1px solid #ddd', margin: '4px 0' } };
4393
+ const hover = item.danger ? '#ffe8e8' : '#f0f0f0';
4394
+ const base = {
4395
+ attrs: { role: 'menuitem', 'aria-disabled': item.disabled ? 'true' : null, 'aria-checked': item.checked != null ? String(!!item.checked) : null },
4396
+ style: {
4397
+ padding: '7px 14px', cursor: item.disabled ? 'default' : 'pointer', position: 'relative', whiteSpace: 'nowrap',
4398
+ color: item.disabled ? '#aaa' : item.danger ? '#cc3333' : '', background: item.checked ? '#e3f2fd' : '',
4399
+ },
4400
+ };
4401
+ if (item.items) {
4402
+ let sub;
4403
+ return {
4404
+ ...base,
4405
+ attrs: { ...base.attrs, 'aria-haspopup': 'menu' },
4406
+ on: {
4407
+ mouseenter: (e) => {
4408
+ e.currentTarget.style.background = hover;
4409
+ sub.style.display = 'block';
4410
+ // Opened near the window's right or bottom edge, it opens left, or lifts.
4411
+ const win = e.currentTarget.ownerDocument.defaultView;
4412
+ const box = sub.getBoundingClientRect?.();
4413
+ if (!win || !box?.width) return;
4414
+ const at = PopupMenu.submenuPlacement(e.currentTarget.getBoundingClientRect(), box.width, box.height, win.innerWidth, win.innerHeight);
4415
+ Object.assign(sub.style, at.side === 'left' ? { left: 'auto', right: '100%' } : { left: '100%', right: 'auto' }, { top: `${at.top}px` });
4416
+ },
4417
+ mouseleave: (e) => { e.currentTarget.style.background = ''; sub.style.display = 'none'; },
4418
+ click: (e) => e.stopPropagation(),
4419
+ },
4420
+ children: [
4421
+ `${item.label} ▸`,
4422
+ {
4423
+ ...PopupMenu._panel(item.items, doc),
4424
+ style: { ...PopupMenu._panelStyle, position: 'absolute', left: '100%', top: '-4px', display: 'none' },
4425
+ ref: (n) => { sub = n; },
4426
+ },
4427
+ ],
4428
+ };
4429
+ }
4430
+ return {
4431
+ ...base,
4432
+ text: item.label,
4433
+ on: {
4434
+ mouseenter: (e) => { if (!item.disabled) e.currentTarget.style.background = hover; },
4435
+ mouseleave: (e) => { e.currentTarget.style.background = item.checked ? '#e3f2fd' : ''; },
4436
+ click: (e) => {
4437
+ e.stopPropagation();
4438
+ if (item.disabled) return;
4439
+ PopupMenu.close();
4440
+ item.action();
4441
+ },
4442
+ },
4443
+ };
4444
+ }
4433
4445
  }
4434
4446
 
4435
4447
  /**
@@ -12541,6 +12553,729 @@
12541
12553
  OutlineChart.INDENT_W = 18;
12542
12554
  OutlineChart.HIERARCHY_STRUCTURAL_KEYS = new Set(['label', 'value', 'children']);
12543
12555
 
12556
+ /**
12557
+ * TableChart — rows of values as a table: a header row of column names, then one row per record,
12558
+ * numeric columns right-aligned. Drawn with the renderer's primitives like every chart, so it works
12559
+ * under SVG and canvas and needs no DOM table. Values are shown exactly (not an axis's "1.20k"):
12560
+ * a table is where a reader goes for the actual number.
12561
+ */
12562
+ class TableChart extends Chart {
12563
+ static chartType = 'table';
12564
+
12565
+ /** Layout constants for the rows. */
12566
+ static ROW_H = 26;
12567
+ static PAD_X = 8;
12568
+ static HEADER_FILL = '#eef1f6';
12569
+ static STRIPE_FILL = '#f8f9fb';
12570
+ static RULE = { color: '#d9dde4', width: 1 };
12571
+
12572
+ /**
12573
+ * The columns to show: `options.columns` (names, or `{ key, label?, align? }`), else every key of
12574
+ * the first row. A column is right-aligned when every value it holds is a number (or null).
12575
+ */
12576
+ static columns(data, given) {
12577
+ const specs = (given ?? Object.keys(data[0] ?? {})).map(c => (typeof c === 'string' ? { key: c } : { ...c }));
12578
+ return specs.map(c => ({
12579
+ key: c.key,
12580
+ label: c.label ?? c.key,
12581
+ align: c.align ?? (data.length > 0 && data.every(r => r[c.key] == null || typeof r[c.key] === 'number') ? 'end' : 'start'),
12582
+ }));
12583
+ }
12584
+
12585
+ /** The table has no axes, so none of a chart's axis margins: an edge all round, and room for a title. */
12586
+ static margins(title) {
12587
+ return { left: 8, right: 8, top: title ? 50 : 8, bottom: 8 };
12588
+ }
12589
+
12590
+ /**
12591
+ * The height a table of `rowCount` rows needs — header, rows (at most `maxRows`), the "more rows"
12592
+ * line, a title and the edges — so a page can size its container to fit before drawing.
12593
+ */
12594
+ static heightFor(rowCount, { title, maxRows = Infinity } = {}) {
12595
+ const shown = Math.min(rowCount, maxRows);
12596
+ const more = rowCount > shown ? 1 : 0;
12597
+ const { top, bottom } = TableChart.margins(title);
12598
+ return top + (1 + shown + more) * TableChart.ROW_H + bottom;
12599
+ }
12600
+
12601
+ /** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
12602
+ static cellText(value, nullText = '—') {
12603
+ if (value == null) return nullText;
12604
+ if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
12605
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
12606
+ return String(value);
12607
+ }
12608
+
12609
+ _render() {
12610
+ const data = this.data ?? [];
12611
+ const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
12612
+ const { x = box.x, y = box.y, width = box.width, title, maxRows = Infinity, nullText = '—' } = this.options;
12613
+ const { ROW_H, PAD_X } = TableChart;
12614
+ const columns = TableChart.columns(data, this.options.columns);
12615
+ const colW = columns.length > 0 ? width / columns.length : width;
12616
+
12617
+ new ChartLabels({ title }).draw(this.renderer, { x, y, width, height: box.height });
12618
+
12619
+ const shown = data.slice(0, maxRows);
12620
+ let top = y;
12621
+ this.renderer.rect(x, top, width, ROW_H, TableChart.HEADER_FILL, null);
12622
+ columns.forEach((c, i) => this._cell(c.label, c, x + i * colW, top, colW, { fontWeight: 'bold', fill: '#333' }));
12623
+ top += ROW_H;
12624
+ this._rule(x, top, width);
12625
+
12626
+ shown.forEach((row, r) => {
12627
+ if (r % 2 === 1) this.renderer.rect(x, top, width, ROW_H, TableChart.STRIPE_FILL, null);
12628
+ columns.forEach((c, i) => this._cell(TableChart.cellText(row[c.key], nullText), c, x + i * colW, top, colW, { fill: '#222' }));
12629
+ top += ROW_H;
12630
+ this._rule(x, top, width);
12631
+ });
12632
+
12633
+ if (data.length > shown.length) {
12634
+ this.renderer.text(`${data.length - shown.length} more row(s) not shown`, x + PAD_X, top + ROW_H / 2, {
12635
+ textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888',
12636
+ });
12637
+ }
12638
+ }
12639
+
12640
+ _cell(text, column, left, top, colW, attrs) {
12641
+ const end = column.align === 'end';
12642
+ return this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
12643
+ textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
12644
+ });
12645
+ }
12646
+
12647
+ _rule(x, y, width) {
12648
+ this.renderer.path(`M${x},${y} L${x + width},${y}`, 'none', TableChart.RULE);
12649
+ }
12650
+ }
12651
+
12652
+ /**
12653
+ * ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
12654
+ * already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
12655
+ *
12656
+ * The caller may describe the columns (hints). `kind` is `'categorical'`, `'numeric'` or `'time'`; a
12657
+ * column with no hint is numeric when every value it holds is a number, else categorical. `measure`
12658
+ * is renderengine's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`), as a data model
12659
+ * gives it. The hints change the choice: a time column is drawn along a line, and only an additive
12660
+ * measure is offered as shares of a whole (a pie), since a part-of-a-whole slice of an average or a
12661
+ * balance means nothing.
12662
+ *
12663
+ * Every suggestion is checked against CHART_CATALOG: a chart type is offered only when the catalog
12664
+ * has it and every axis it requires is one the result's shape provides — so the catalog stays the
12665
+ * single source of truth for what each chart needs.
12666
+ *
12667
+ * const [best, ...others] = new ChartChooser().choose(rows, [{ name: 'Day', kind: 'time' }]);
12668
+ * engine.renderChartType(best.key, best.data, { title: best.label });
12669
+ */
12670
+ class ChartChooser {
12671
+ /** @param {ChartRegistry} [registry] — the built-in chart types, or another registry to choose from */
12672
+ constructor(registry = chartRegistry) {
12673
+ this.registry = registry;
12674
+ }
12675
+
12676
+ /**
12677
+ * @param {Array<object>} rows
12678
+ * @param {Array<{name:string, kind?:string, measure?:string}>} [columns] — hints; every key of the
12679
+ * first row when absent, in that order
12680
+ * @param {{complete?:boolean}} [options] — `complete: false` when the rows are only part of the
12681
+ * answer (a top N, a first page): then nothing is offered as shares of a whole, since the
12682
+ * slices would claim to be all of it
12683
+ * @returns {Array<{key:string, label:string, data:*, reason:string}>} best first; the table last
12684
+ */
12685
+ choose(rows, columns = null, { complete = true } = {}) {
12686
+ const described = ChartChooser.describe(rows, columns);
12687
+ const shape = RESULT_SHAPES.map(S => S.of(described, rows)).find(s => s !== null) ?? null;
12688
+ if (shape !== null) shape.complete = complete;
12689
+ const charts = shape === null ? [] : shape.candidates()
12690
+ .filter(({ key }) => this.fits(key, shape))
12691
+ .map(({ key, reason, data }) => ({ key, label: this.registry.get(key).label, data, reason }));
12692
+ return [...charts, this.table(rows, described, charts.length === 0)];
12693
+ }
12694
+
12695
+ /** A catalog entry fits a shape when every axis it requires is one the shape provides. */
12696
+ fits(key, shape) {
12697
+ const entry = this.registry.get(key);
12698
+ if (!entry) return false;
12699
+ if (shape.needsSeries && !entry.seriesCapable && !entry.axes.some(a => a.role === 'series')) return false;
12700
+ return entry.axes.filter(a => a.required).every(a => shape.provides[a.role] === a.kind);
12701
+ }
12702
+
12703
+ table(rows, described, only) {
12704
+ return {
12705
+ key: 'table', label: this.registry.get('table')?.label ?? 'Table',
12706
+ data: rows, columns: described.map(c => c.name),
12707
+ reason: only ? 'no chart fits these columns' : 'every value, exactly',
12708
+ };
12709
+ }
12710
+
12711
+ /** The columns with their kinds settled: the hint's, else derived from the rows' values. */
12712
+ static describe(rows, columns) {
12713
+ const given = columns ?? Object.keys(rows[0] ?? {}).map(name => ({ name }));
12714
+ return given.map(c => ({
12715
+ name: c.name,
12716
+ kind: c.kind ?? (rows.length > 0 && rows.every(r => r[c.name] == null || typeof r[c.name] === 'number') ? 'numeric' : 'categorical'),
12717
+ measure: c.measure ?? null,
12718
+ }));
12719
+ }
12720
+ }
12721
+
12722
+ /** The label a category value is drawn with. */
12723
+ function label(value) {
12724
+ return value == null ? '—' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
12725
+ }
12726
+
12727
+ /** Rows in time order by `column` (numbers, dates and ISO strings all sort correctly this way). */
12728
+ function inOrder(rows, column) {
12729
+ return [...rows].sort((a, b) => (a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0));
12730
+ }
12731
+
12732
+ /**
12733
+ * ResultShape — what a result's columns look like: how many category, time and numeric columns.
12734
+ * Each subclass recognises one shape (`static of`), says which catalog axes it provides, and lists
12735
+ * the chart types that suit it, best first, with the rows shaped for each.
12736
+ */
12737
+ class ResultShape {
12738
+ constructor() {
12739
+ /** Whether the rows are all of the answer (ChartChooser.choose's `complete`). */
12740
+ this.complete = true;
12741
+ }
12742
+
12743
+ /** The axes this shape can fill, role → AXIS_KIND. */
12744
+ get provides() { return {}; }
12745
+ /** Whether a chart must draw several series (one per numeric column). */
12746
+ get needsSeries() { return false; }
12747
+ /** @returns {Array<{key, reason, data}>} */
12748
+ candidates() { return []; }
12749
+
12750
+ static split(columns) {
12751
+ return {
12752
+ categories: columns.filter(c => c.kind !== 'numeric'),
12753
+ numbers: columns.filter(c => c.kind === 'numeric'),
12754
+ };
12755
+ }
12756
+
12757
+ /**
12758
+ * Whether each row has its own value of the category columns. Rows that repeat a category
12759
+ * (rows of orders, not totals per region) would be drawn as one bar per row with the same
12760
+ * label over and over — or, for two categories, one repeated pair would replace another's value
12761
+ * unseen. A chart of a value per category needs one row per category: a GROUP BY first.
12762
+ */
12763
+ static oneRowEach(rows, categories) {
12764
+ const keys = new Set(rows.map(r => JSON.stringify(categories.map(c => r[c.name] ?? null))));
12765
+ return keys.size === rows.length;
12766
+ }
12767
+ }
12768
+
12769
+ /** One number and nothing to group it by: a single figure. */
12770
+ class SingleFigure extends ResultShape {
12771
+ static of(columns, rows) {
12772
+ const { categories, numbers } = ResultShape.split(columns);
12773
+ return categories.length === 0 && numbers.length === 1 && rows.length === 1 ? new SingleFigure(numbers[0], rows) : null;
12774
+ }
12775
+ constructor(number, rows) { super(); this.number = number; this.rows = rows; }
12776
+ get provides() { return { value: AXIS_KIND.NUMERIC }; }
12777
+ candidates() {
12778
+ return [{ key: 'kpi-tile', reason: 'a single figure', data: { value: this.rows[0][this.number.name] } }];
12779
+ }
12780
+ }
12781
+
12782
+ /** One category (or time) column and one number: a value per category. */
12783
+ class CategoryValue extends ResultShape {
12784
+ static of(columns, rows) {
12785
+ const { categories, numbers } = ResultShape.split(columns);
12786
+ return categories.length === 1 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
12787
+ ? new CategoryValue(categories[0], numbers[0], rows) : null;
12788
+ }
12789
+ constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
12790
+ get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
12791
+
12792
+ candidates() {
12793
+ const { category, number } = this;
12794
+ const time = category.kind === 'time';
12795
+ const rows = time ? inOrder(this.rows, category.name) : this.rows;
12796
+ const data = rows.filter(r => r[number.name] != null).map(r => ({ label: label(r[category.name]), value: r[number.name] }));
12797
+ if (time) {
12798
+ return [
12799
+ { key: 'line', reason: `${number.name} over ${category.name}`, data },
12800
+ { key: 'area', reason: `${number.name} over ${category.name}, filled`, data },
12801
+ { key: 'bar', reason: `${number.name} for each ${category.name}`, data },
12802
+ ];
12803
+ }
12804
+ const options = [{ key: 'bar', reason: `${number.name} for each ${category.name}`, data }];
12805
+ if (this.isShareOfWhole(data)) {
12806
+ options.push({ key: 'pie', reason: `each ${category.name}'s share of the total ${number.name}`, data });
12807
+ options.push({ key: 'donut', reason: `each ${category.name}'s share of the total ${number.name}`, data });
12808
+ }
12809
+ return options;
12810
+ }
12811
+
12812
+ /**
12813
+ * Shares of a whole make sense only for an additive measure, over all of the rows (not a top N),
12814
+ * with no negatives, in few slices.
12815
+ */
12816
+ isShareOfWhole(data) {
12817
+ return this.complete && this.number.measure === 'additive' && data.length >= 2 && data.length <= CategoryValue.MAX_SLICES
12818
+ && data.every(d => d.value >= 0);
12819
+ }
12820
+ }
12821
+ CategoryValue.MAX_SLICES = 8;
12822
+
12823
+ /** One category (or time) column and several numbers: a series per number. */
12824
+ class CategorySeries extends ResultShape {
12825
+ static of(columns, rows) {
12826
+ const { categories, numbers } = ResultShape.split(columns);
12827
+ return categories.length === 1 && numbers.length >= 2 && ResultShape.oneRowEach(rows, categories)
12828
+ ? new CategorySeries(categories[0], numbers, rows) : null;
12829
+ }
12830
+ constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
12831
+ get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
12832
+ get needsSeries() { return true; }
12833
+
12834
+ candidates() {
12835
+ const { category } = this;
12836
+ const time = category.kind === 'time';
12837
+ const rows = time ? inOrder(this.rows, category.name) : this.rows;
12838
+ // lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
12839
+ // as 0, a figure the data does not hold. So a result with one is not offered as series.
12840
+ if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
12841
+ const data = this.numbers.map(n => ({
12842
+ name: n.name,
12843
+ values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
12844
+ }));
12845
+ const names = this.numbers.map(n => n.name).join(', ');
12846
+ return time
12847
+ ? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
12848
+ : [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
12849
+ }
12850
+ }
12851
+
12852
+ /** Two category (or time) columns and one number: a value per pair. */
12853
+ class TwoCategories extends ResultShape {
12854
+ static of(columns, rows) {
12855
+ const { categories, numbers } = ResultShape.split(columns);
12856
+ return categories.length === 2 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
12857
+ ? new TwoCategories(categories, numbers[0], rows) : null;
12858
+ }
12859
+ constructor(categories, number, rows) {
12860
+ super();
12861
+ // A time column goes along the x axis, whichever order the columns came in.
12862
+ this.across = categories.find(c => c.kind === 'time') ?? categories[0];
12863
+ this.down = categories.find(c => c !== this.across);
12864
+ this.number = number;
12865
+ this.rows = rows;
12866
+ }
12867
+ get provides() {
12868
+ return { x: AXIS_KIND.CATEGORICAL, y: AXIS_KIND.CATEGORICAL, category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL };
12869
+ }
12870
+
12871
+ candidates() {
12872
+ const { across, down, number } = this;
12873
+ const rows = across.kind === 'time' ? inOrder(this.rows, across.name) : this.rows;
12874
+ const heat = rows.filter(r => r[number.name] != null)
12875
+ .map(r => ({ x: label(r[across.name]), y: label(r[down.name]), value: r[number.name] }));
12876
+ const heatmap = { key: 'heatmap', reason: `${number.name} for each ${across.name} and ${down.name}`, data: heat };
12877
+ // A series needs a value for every label; where a pair is missing (or null) only the heatmap,
12878
+ // which draws just the cells present, shows the data without inventing a 0.
12879
+ const labels = [...new Set(rows.map(r => label(r[across.name])))];
12880
+ const value = new Map(rows.map(r => [`${label(r[across.name])}\u0000${label(r[down.name])}`, r[number.name]]));
12881
+ const names = [...new Set(rows.map(r => label(r[down.name])))];
12882
+ if (names.some(name => labels.some(l => value.get(`${l}\u0000${name}`) == null))) return [heatmap];
12883
+ const series = names.map(name => ({ name, values: labels.map(l => ({ label: l, value: value.get(`${l}\u0000${name}`) })) }));
12884
+ const grouped = { key: 'groupedBar', reason: `${number.name} for each ${across.name}, by ${down.name}`, data: series };
12885
+ return across.kind === 'time'
12886
+ ? [{ key: 'line', reason: `${number.name} over ${across.name}, a line per ${down.name}`, data: series }, heatmap, grouped]
12887
+ : [grouped, heatmap];
12888
+ }
12889
+ }
12890
+
12891
+ /** Shapes in the order they are tried; the first that recognises the columns wins. */
12892
+ const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
12893
+
12894
+ /**
12895
+ * TableView — the state of an interactive table over result rows, kept apart from drawing so all of
12896
+ * it can be tested without a DOM: the sort, the filters, the hidden columns and the page, and the
12897
+ * menus a table offers on a cell, a row, a column and the table itself (as PopupMenu item specs).
12898
+ *
12899
+ * Filtering is by choosing values from the table, not typing: a cell's menu keeps or excludes rows
12900
+ * with its value (and, for a number, rows above or below it); a column's menu filters to one of its
12901
+ * values. The table's menu offers "Show as", the chart types ChartChooser says fit what is showing.
12902
+ *
12903
+ * Every change calls `onChange(view)`; the host redraws. A host adds its own items with
12904
+ * `menus: { cell(ctx), row(ctx), column(ctx), table(ctx) }`, each returning PopupMenu items.
12905
+ */
12906
+ const FILTER_OPS = {
12907
+ '=': (v, x) => v === x,
12908
+ '!=': (v, x) => v !== x,
12909
+ '>': (v, x) => v != null && v > x,
12910
+ '<': (v, x) => v != null && v < x,
12911
+ 'is null': (v) => v == null,
12912
+ 'is not null': (v) => v != null,
12913
+ };
12914
+
12915
+ /** Order of two values for a sort: numbers numerically, everything else as text; nulls last. */
12916
+ function compareValues(a, b) {
12917
+ if (a == null) return b == null ? 0 : 1;
12918
+ if (b == null) return -1;
12919
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
12920
+ const x = String(a), y = String(b);
12921
+ return x < y ? -1 : x > y ? 1 : 0;
12922
+ }
12923
+
12924
+ /** A value as copied text: null as empty, an object as JSON. */
12925
+ function plain(value) {
12926
+ if (value == null) return '';
12927
+ if (value instanceof Date) return value.toISOString();
12928
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
12929
+ }
12930
+
12931
+ class TableView {
12932
+ /** The most distinct values a column's "Filter to" menu lists. */
12933
+ static MAX_FILTER_VALUES = 20;
12934
+
12935
+ /**
12936
+ * @param {Array<object>} rows
12937
+ * @param {object} [options]
12938
+ * @param {Array<{name, kind?, measure?}>} [options.columns] hints, as ChartChooser takes them
12939
+ * @param {boolean} [options.complete] false when the rows are only part of the answer (a top N)
12940
+ * @param {number} [options.pageSize] rows per page (50; Infinity for all)
12941
+ * @param {function} [options.onChange] called with the view after every change
12942
+ * @param {object} [options.menus] the host's own items: { cell, row, column, table } → items
12943
+ * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
12944
+ * @param {ChartChooser} [options.chooser]
12945
+ */
12946
+ constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null } = {}) {
12947
+ this.rows = rows;
12948
+ this.columns = ChartChooser.describe(rows, columns);
12949
+ this.complete = complete;
12950
+ this.pageSize = pageSize;
12951
+ this.onChange = onChange;
12952
+ this.menus = menus;
12953
+ this.copy = copy ?? TableView.clipboard;
12954
+ this.chooser = chooser ?? new ChartChooser();
12955
+ this.sort = null; // { column, direction: 'asc' | 'desc' }
12956
+ this.filters = []; // { column, op, value }
12957
+ this.hidden = new Set();
12958
+ this.page = 0;
12959
+ }
12960
+
12961
+ static clipboard(text) {
12962
+ return globalThis.navigator?.clipboard?.writeText(text);
12963
+ }
12964
+
12965
+ // ── What is showing ───────────────────────────────────────────────────────
12966
+
12967
+ visibleColumns() { return this.columns.filter(c => !this.hidden.has(c.name)); }
12968
+
12969
+ filtered() {
12970
+ return this.filters.length === 0 ? this.rows
12971
+ : this.rows.filter(r => this.filters.every(f => FILTER_OPS[f.op](r[f.column], f.value)));
12972
+ }
12973
+
12974
+ /** The filtered rows in sort order (a copy; equal values keep their order). */
12975
+ ordered() {
12976
+ const rows = this.filtered();
12977
+ if (this.sort === null) return rows;
12978
+ const { column, direction } = this.sort;
12979
+ const sign = direction === 'desc' ? -1 : 1;
12980
+ // Nulls stay last whichever way: a reader looks for values, not for blanks.
12981
+ return [...rows].sort((a, b) => (a[column] == null || b[column] == null ? compareValues(a[column], b[column]) : sign * compareValues(a[column], b[column])));
12982
+ }
12983
+
12984
+ pageCount() {
12985
+ return Number.isFinite(this.pageSize) ? Math.max(1, Math.ceil(this.filtered().length / this.pageSize)) : 1;
12986
+ }
12987
+
12988
+ /** The rows on the current page, each with its position in the ordered rows (`index`, from 0). */
12989
+ pageRows() {
12990
+ const rows = this.ordered();
12991
+ const start = Number.isFinite(this.pageSize) ? this.page * this.pageSize : 0;
12992
+ const end = Number.isFinite(this.pageSize) ? start + this.pageSize : rows.length;
12993
+ return rows.slice(start, end).map((row, i) => ({ row, index: start + i }));
12994
+ }
12995
+
12996
+ /** "Region = West · Revenue > 100", or '' with no filters. */
12997
+ describeFilters() {
12998
+ return this.filters.map(f => (f.op.startsWith('is') ? `${f.column} ${f.op}` : `${f.column} ${f.op} ${plain(f.value)}`)).join(' · ');
12999
+ }
13000
+
13001
+ // ── Changes ───────────────────────────────────────────────────────────────
13002
+
13003
+ changed() {
13004
+ this.page = Math.min(this.page, this.pageCount() - 1);
13005
+ this.onChange?.(this);
13006
+ }
13007
+
13008
+ /** A header click: unsorted → ascending → descending → unsorted. */
13009
+ toggleSort(column) {
13010
+ const current = this.sort?.column === column ? this.sort.direction : null;
13011
+ this.sortBy(column, current === null ? 'asc' : current === 'asc' ? 'desc' : null);
13012
+ }
13013
+
13014
+ sortBy(column, direction) {
13015
+ this.sort = direction === null ? null : { column, direction };
13016
+ this.page = 0;
13017
+ this.changed();
13018
+ }
13019
+
13020
+ addFilter(column, op, value = null) {
13021
+ if (!Object.hasOwn(FILTER_OPS, op)) throw new Error(`Unknown filter "${op}"`);
13022
+ this.filters = [...this.filters.filter(f => !(f.column === column && f.op === op && f.value === value)), { column, op, value }];
13023
+ this.page = 0;
13024
+ this.changed();
13025
+ }
13026
+
13027
+ removeFilters(column) {
13028
+ this.filters = this.filters.filter(f => f.column !== column);
13029
+ this.changed();
13030
+ }
13031
+
13032
+ clearFilters() {
13033
+ this.filters = [];
13034
+ this.changed();
13035
+ }
13036
+
13037
+ /** Hides a column; the last one showing cannot be hidden. */
13038
+ hide(column) {
13039
+ if (this.visibleColumns().length <= 1) return;
13040
+ this.hidden.add(column);
13041
+ this.changed();
13042
+ }
13043
+
13044
+ showAll() {
13045
+ this.hidden.clear();
13046
+ this.changed();
13047
+ }
13048
+
13049
+ goToPage(page) {
13050
+ this.page = Math.max(0, Math.min(page, this.pageCount() - 1));
13051
+ this.changed();
13052
+ }
13053
+
13054
+ // ── Text out ──────────────────────────────────────────────────────────────
13055
+
13056
+ /** Rows as tab-separated text, header first, the visible columns only. */
13057
+ asTsv(rows = this.ordered()) {
13058
+ const columns = this.visibleColumns().map(c => c.name);
13059
+ const clean = (v) => plain(v).replace(/[\t\r\n]+/g, ' ');
13060
+ return [columns.map(clean).join('\t'), ...rows.map(r => columns.map(c => clean(r[c])).join('\t'))].join('\n');
13061
+ }
13062
+
13063
+ // ── Menus (PopupMenu items) ──────────────────────────────────────────────
13064
+
13065
+ cellMenu(row, column) {
13066
+ const value = row[column];
13067
+ const kind = this.columns.find(c => c.name === column)?.kind;
13068
+ const shown = value == null ? 'empty' : plain(value);
13069
+ const items = value == null
13070
+ ? [{ label: `Only rows where ${column} is empty`, action: () => this.addFilter(column, 'is null') },
13071
+ { label: `Exclude rows where ${column} is empty`, action: () => this.addFilter(column, 'is not null') }]
13072
+ : [{ label: `Only rows where ${column} = ${shown}`, action: () => this.addFilter(column, '=', value) },
13073
+ { label: `Exclude rows where ${column} = ${shown}`, action: () => this.addFilter(column, '!=', value) },
13074
+ ...(kind === 'numeric' ? [
13075
+ { label: `Only rows where ${column} > ${shown}`, action: () => this.addFilter(column, '>', value) },
13076
+ { label: `Only rows where ${column} < ${shown}`, action: () => this.addFilter(column, '<', value) },
13077
+ ] : [])];
13078
+ return [
13079
+ ...items,
13080
+ { separator: true },
13081
+ { label: 'Copy value', action: () => this.copy(plain(value)) },
13082
+ ...this.extra('cell', { row, column, value }),
13083
+ ];
13084
+ }
13085
+
13086
+ rowMenu(row) {
13087
+ return [
13088
+ { label: 'Copy row', action: () => this.copy(this.asTsv([row])) },
13089
+ { label: 'Copy row as JSON', action: () => this.copy(JSON.stringify(row)) },
13090
+ ...this.extra('row', { row }),
13091
+ ];
13092
+ }
13093
+
13094
+ columnMenu(column) {
13095
+ const sorted = this.sort?.column === column ? this.sort.direction : null;
13096
+ const values = [...new Set(this.filtered().map(r => r[column]))].sort(compareValues);
13097
+ const listed = values.slice(0, TableView.MAX_FILTER_VALUES);
13098
+ return [
13099
+ { label: 'Sort ascending', checked: sorted === 'asc', action: () => this.sortBy(column, 'asc') },
13100
+ { label: 'Sort descending', checked: sorted === 'desc', action: () => this.sortBy(column, 'desc') },
13101
+ ...(sorted ? [{ label: 'Clear sort', action: () => this.sortBy(column, null) }] : []),
13102
+ { separator: true },
13103
+ {
13104
+ label: 'Filter to',
13105
+ items: [
13106
+ ...listed.map(v => (v == null
13107
+ ? { label: '(empty)', action: () => this.addFilter(column, 'is null') }
13108
+ : { label: plain(v), action: () => this.addFilter(column, '=', v) })),
13109
+ ...(values.length > listed.length ? [{ label: `${values.length - listed.length} more — filter from a cell`, disabled: true }] : []),
13110
+ ],
13111
+ },
13112
+ ...(this.filters.some(f => f.column === column) ? [{ label: `Clear filters on ${column}`, action: () => this.removeFilters(column) }] : []),
13113
+ { separator: true },
13114
+ { label: 'Hide column', disabled: this.visibleColumns().length <= 1, action: () => this.hide(column) },
13115
+ { label: 'Copy column', action: () => this.copy([column, ...this.ordered().map(r => plain(r[column]))].join('\n')) },
13116
+ ...this.extra('column', { column }),
13117
+ ];
13118
+ }
13119
+
13120
+ /** The table's menu; `onShowAs(choice)` draws a chosen chart (the host's — RenderEngine.resultView). */
13121
+ tableMenu(onShowAs = null) {
13122
+ const choices = onShowAs ? this.chartChoices() : [];
13123
+ return [
13124
+ ...(onShowAs ? [{
13125
+ label: 'Show as',
13126
+ items: choices.length > 0
13127
+ ? choices.map(c => ({ label: `${c.label} — ${c.reason}`, action: () => onShowAs(c) }))
13128
+ : [{ label: 'No chart fits these columns', disabled: true }],
13129
+ }, { separator: true }] : []),
13130
+ { label: 'Clear all filters', disabled: this.filters.length === 0, action: () => this.clearFilters() },
13131
+ { label: 'Show hidden columns', disabled: this.hidden.size === 0, action: () => this.showAll() },
13132
+ { label: 'Copy table', action: () => this.copy(this.asTsv()) },
13133
+ ...this.extra('table', {}),
13134
+ ];
13135
+ }
13136
+
13137
+ /**
13138
+ * The chart types that fit what the table is showing — its filtered, sorted rows and visible
13139
+ * columns — as ChartChooser gives them, without the table. Filtered rows are not the whole
13140
+ * answer, so they are never offered as shares of a whole.
13141
+ */
13142
+ chartChoices() {
13143
+ const columns = this.visibleColumns();
13144
+ const names = columns.map(c => c.name);
13145
+ const rows = this.ordered().map(r => Object.fromEntries(names.map(n => [n, r[n]])));
13146
+ return this.chooser.choose(rows, columns, { complete: this.complete && this.filters.length === 0 })
13147
+ .filter(c => c.key !== 'table');
13148
+ }
13149
+
13150
+ extra(kind, context) {
13151
+ const items = this.menus?.[kind]?.({ view: this, ...context }) ?? [];
13152
+ return items.length > 0 ? [{ separator: true }, ...items] : [];
13153
+ }
13154
+ }
13155
+
13156
+ /**
13157
+ * TableViewChart — the interactive table: a TableChart drawn from a TableView's state, whose parts
13158
+ * answer clicks. A header sorts (and its ▾ opens the column's menu); a cell opens the cell's menu; a
13159
+ * row number opens the row's menu; the corner ⋮ opens the table's menu (with "Show as" when the host
13160
+ * can draw a chart); the footer's ‹ › page. Each change goes through the view, whose `onChange`
13161
+ * redraws.
13162
+ *
13163
+ * Interaction needs a renderer whose shapes are DOM elements (SVG: `supportsSceneGraph`); anywhere
13164
+ * else it draws the same table without the clicks.
13165
+ */
13166
+ class TableViewChart extends TableChart {
13167
+ static GUTTER_W = 44;
13168
+ static MENU_GLYPH = '▾';
13169
+ static TABLE_GLYPH = '⋮';
13170
+
13171
+ /**
13172
+ * @param renderer
13173
+ * @param {TableView} view
13174
+ * @param {object} [options] title, nullText; onShowAs(choice) — draws a chart for the table menu's
13175
+ * "Show as"; showMenu(x, y, items) — opens a menu (PopupMenu.show by default)
13176
+ */
13177
+ constructor(renderer, view, options = {}) {
13178
+ super(renderer, view.rows, options);
13179
+ this.view = view;
13180
+ this.showMenu = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
13181
+ }
13182
+
13183
+ /** The height this draws: title, header, the page's rows and the footer. */
13184
+ static heightFor(view, { title } = {}) {
13185
+ const { top, bottom } = TableChart.margins(title);
13186
+ return top + (2 + view.pageRows().length) * TableChart.ROW_H + bottom;
13187
+ }
13188
+
13189
+ _render() {
13190
+ const { view } = this;
13191
+ const { title, nullText = '—' } = this.options;
13192
+ const { ROW_H, PAD_X } = TableChart;
13193
+ const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(title));
13194
+ const interactive = this.renderer.supportsSceneGraph === true;
13195
+ const columns = view.visibleColumns().map(c => ({ key: c.name, label: c.name, align: c.kind === 'numeric' ? 'end' : 'start' }));
13196
+ const gutter = TableViewChart.GUTTER_W;
13197
+ const colW = columns.length > 0 ? (box.width - gutter) / columns.length : box.width - gutter;
13198
+ const x0 = box.x + gutter;
13199
+
13200
+ new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
13201
+
13202
+ // Header: the corner (table menu), then a header per column (sort; ▾ column menu).
13203
+ let top = box.y;
13204
+ this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.HEADER_FILL, null);
13205
+ const corner = this.renderer.rect(box.x, top, gutter, ROW_H, TableChart.HEADER_FILL, null);
13206
+ const cornerGlyph = this._text(TableViewChart.TABLE_GLYPH, box.x + gutter / 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' });
13207
+ this._on(interactive, [corner, cornerGlyph], (e) => this.showMenu(e.clientX, e.clientY, view.tableMenu(this.options.onShowAs ?? null)), 'Table menu');
13208
+
13209
+ columns.forEach((c, i) => {
13210
+ const left = x0 + i * colW;
13211
+ const sorted = view.sort?.column === c.key ? (view.sort.direction === 'asc' ? ' ▲' : ' ▼') : '';
13212
+ // In words: an arrow-like mark here read as a second sort arrow.
13213
+ const filtered = view.filters.some(f => f.column === c.key) ? ' (filtered)' : '';
13214
+ const head = this.renderer.rect(left, top, colW, ROW_H, TableChart.HEADER_FILL, null);
13215
+ // The label leaves room at the right for the ▾.
13216
+ const label = this._cell(`${c.label}${sorted}${filtered}`, c, left, top, colW - 16, { fontWeight: 'bold', fill: '#333' });
13217
+ this._on(interactive, [head, label], () => view.toggleSort(c.key), 'Sort');
13218
+ const glyph = this._text(TableViewChart.MENU_GLYPH, left + colW - 8, top, { textAnchor: 'middle', fill: '#777' });
13219
+ this._on(interactive, [glyph], (e) => this.showMenu(e.clientX, e.clientY, view.columnMenu(c.key)), `${c.label} menu`);
13220
+ });
13221
+ top += ROW_H;
13222
+ this._rule(box.x, top, box.width);
13223
+
13224
+ // Rows: a number in the gutter (row menu), a cell per column (cell menu).
13225
+ view.pageRows().forEach(({ row, index }, r) => {
13226
+ if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
13227
+ const number = this._text(String(index + 1), box.x + gutter - PAD_X, top, { textAnchor: 'end', fill: '#999', fontSize: '11px' });
13228
+ this._on(interactive, [number], (e) => this.showMenu(e.clientX, e.clientY, view.rowMenu(row)), 'Row menu');
13229
+ columns.forEach((c, i) => {
13230
+ const left = x0 + i * colW;
13231
+ const hit = this.renderer.rect(left, top, colW, ROW_H, 'transparent', null);
13232
+ const text = this._cell(TableChart.cellText(row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
13233
+ this._on(interactive, [hit, text], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(row, c.key)), null);
13234
+ });
13235
+ top += ROW_H;
13236
+ this._rule(box.x, top, box.width);
13237
+ });
13238
+
13239
+ this._footer(box, top, interactive);
13240
+ }
13241
+
13242
+ /** "1–50 of 120 rows (filtered from 300: Region = West)", and ‹ › when there is more than a page. */
13243
+ _footer(box, top, interactive) {
13244
+ const { view } = this;
13245
+ const shown = view.pageRows();
13246
+ const total = view.filtered().length;
13247
+ const range = shown.length === 0 ? 'No rows' : `${shown[0].index + 1}–${shown.at(-1).index + 1} of ${total} row${total === 1 ? '' : 's'}`;
13248
+ const filters = view.filters.length > 0 ? ` (filtered from ${view.rows.length}: ${view.describeFilters()})` : '';
13249
+ this._text(`${range}${filters}`, box.x + TableChart.PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
13250
+ if (view.pageCount() > 1) {
13251
+ const right = box.x + box.width - TableChart.PAD_X;
13252
+ const previous = this._text('‹ previous', right - 70, top, { textAnchor: 'end', fill: view.page > 0 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
13253
+ const next = this._text('next ›', right, top, { textAnchor: 'end', fill: view.page < view.pageCount() - 1 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
13254
+ this._on(interactive, [previous], () => view.goToPage(view.page - 1), 'Previous page');
13255
+ this._on(interactive, [next], () => view.goToPage(view.page + 1), 'Next page');
13256
+ }
13257
+ }
13258
+
13259
+ _text(text, x, top, attrs) {
13260
+ return this.renderer.text(text, x, top + TableChart.ROW_H / 2, { verticalAlign: 'middle', fontSize: '12px', ...attrs });
13261
+ }
13262
+
13263
+ /** Wires a click to each element that can take one (SVG); a pointer cursor and a tooltip say so. */
13264
+ _on(interactive, elements, handler, title) {
13265
+ if (!interactive) return;
13266
+ for (const el of elements) {
13267
+ if (typeof el?.addEventListener !== 'function') continue;
13268
+ el.addEventListener('click', (event) => { event.stopPropagation?.(); handler(event); });
13269
+ if (el.style) el.style.cursor = 'pointer';
13270
+ if (title && typeof el.setAttribute === 'function' && el.ownerDocument) {
13271
+ const t = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'title');
13272
+ t.textContent = title;
13273
+ el.appendChild(t);
13274
+ }
13275
+ }
13276
+ }
13277
+ }
13278
+
12544
13279
  /**
12545
13280
  * RadarChart — one polygon per series across N shared axes, each axis scaled
12546
13281
  * independently by its own max value across all series.
@@ -49441,6 +50176,80 @@ ${theirs}` : theirs;
49441
50176
  this._renderChart(new FunnelChart(this.renderer, data, options));
49442
50177
  }
49443
50178
 
50179
+ /**
50180
+ * Renders rows as a table — a header of column names, a row per record, numeric columns
50181
+ * right-aligned, values shown exactly. Drawn with the renderer's primitives, so it works under
50182
+ * SVG and canvas. ChartChooser offers it for any result, last.
50183
+ * @param {Array<object>} data one object per row
50184
+ * @param {object} [options]
50185
+ * @param {Array<string|{key:string, label?:string, align?:'start'|'end'}>} [options.columns] every key of the first row when absent
50186
+ * @param {number} [options.maxRows] rows beyond it are counted, not drawn
50187
+ * @param {string} [options.nullText] what a null or missing value shows ('—')
50188
+ * @param {string} [options.title]
50189
+ */
50190
+ tableChart(data, options = {}) {
50191
+ if (!this.renderer) return;
50192
+ this._chart3D = false;
50193
+ this._chartState = { type: 'table', data, options };
50194
+ this._renderChart(new TableChart(this.renderer, data, options));
50195
+ }
50196
+
50197
+ /**
50198
+ * Shows result rows as an interactive table — sort, filter, hide columns, page, copy, through
50199
+ * menus on its cells, rows, columns and the table itself — whose table menu can show what it
50200
+ * holds as any chart type ChartChooser says fits ("Show as"); a chart drawn that way has a
50201
+ * "◂ Table" link back to the table as it was left. One output for a result, table or chart.
50202
+ *
50203
+ * The container's height follows what is drawn (the table's rows, or `chartHeight` for a chart),
50204
+ * so the host gives the container a width only. Interaction needs the SVG renderer; on canvas
50205
+ * the table is drawn without it.
50206
+ * @param {Array<object>} rows
50207
+ * @param {object} [options]
50208
+ * @param {Array<{name, kind?, measure?}>} [options.columns] hints (ChartChooser's)
50209
+ * @param {boolean} [options.complete] false when the rows are only part of the answer
50210
+ * @param {number} [options.pageSize] rows per page (50)
50211
+ * @param {object} [options.menus] the host's own items: { cell, row, column, table } → items
50212
+ * @param {string} [options.title]
50213
+ * @param {number} [options.chartHeight] a chart's height (360)
50214
+ * @param {boolean} [options.fitHeight] size the container to what is drawn (true)
50215
+ * @returns {TableView|null} the table's state, for the host to read or change
50216
+ */
50217
+ resultView(rows, options = {}) {
50218
+ if (!this.renderer) return null;
50219
+ const view = new TableView(rows, { ...options, onChange: () => this._drawResultTable(view, options) });
50220
+ this._drawResultTable(view, options);
50221
+ return view;
50222
+ }
50223
+
50224
+ _drawResultTable(view, options) {
50225
+ this._fitResultHeight(TableViewChart.heightFor(view, options), options);
50226
+ this._chart3D = false;
50227
+ this._chartState = { type: 'table', data: view.rows, options };
50228
+ this._renderChart(new TableViewChart(this.renderer, view, {
50229
+ ...options,
50230
+ onShowAs: (choice) => this._drawResultChart(view, choice, options),
50231
+ }));
50232
+ }
50233
+
50234
+ _drawResultChart(view, choice, options) {
50235
+ this._fitResultHeight(options.chartHeight ?? 360, options);
50236
+ this.renderChartType(choice.key, choice.data, { title: options.title ?? choice.reason });
50237
+ // The way back, drawn over the chart: the same table, as it was left.
50238
+ if (!this.renderer.supportsSceneGraph) return;
50239
+ const back = this.renderer.text('◂ Table', 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle' });
50240
+ back?.addEventListener?.('click', () => this._drawResultTable(view, options));
50241
+ if (back?.style) back.style.cursor = 'pointer';
50242
+ }
50243
+
50244
+ _fitResultHeight(height, options) {
50245
+ if (options.fitHeight === false || !this.container?.style) return;
50246
+ // min-height as well: in a flex layout (a notebook, a workbench pane) a height alone is
50247
+ // shrunk to fit, and the table drawn into the shrunk box lost its last rows.
50248
+ this.container.style.height = `${height}px`;
50249
+ this.container.style.minHeight = `${height}px`;
50250
+ this.fitToContainer();
50251
+ }
50252
+
49444
50253
  /**
49445
50254
  * Renders a waterfall chart — running-total bars, each floating from the
49446
50255
  * previous cumulative value to the new one (a "delta" bar), except
@@ -50805,6 +51614,7 @@ ${theirs}` : theirs;
50805
51614
  exports.CanvasTemplateMenu = CanvasTemplateMenu;
50806
51615
  exports.CanvasView = CanvasView;
50807
51616
  exports.CatalogDialog = CatalogDialog;
51617
+ exports.ChartChooser = ChartChooser;
50808
51618
  exports.CheckboxTreeSelector = CheckboxTreeSelector;
50809
51619
  exports.CircleMarker = CircleMarker;
50810
51620
  exports.CircleTemplate = CircleTemplate;
@@ -50986,6 +51796,9 @@ ${theirs}` : theirs;
50986
51796
  exports.TREE_LAYERS = TREE_LAYERS;
50987
51797
  exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
50988
51798
  exports.TREE_SETTINGS_PROPERTIES = TREE_SETTINGS_PROPERTIES;
51799
+ exports.TableChart = TableChart;
51800
+ exports.TableView = TableView;
51801
+ exports.TableViewChart = TableViewChart;
50989
51802
  exports.Template = Template;
50990
51803
  exports.TemplateCatalog = TemplateCatalog;
50991
51804
  exports.TemplateLibrary = TemplateLibrary;