@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
package/dist/renderengine.umd.js
CHANGED
|
@@ -1023,7 +1023,7 @@
|
|
|
1023
1023
|
// Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
|
|
1024
1024
|
/**
|
|
1025
1025
|
* Single source of truth for "what chart types does RenderEngine support" —
|
|
1026
|
-
* see ARCHITECTURE.md's "Chart-type catalog" section for the full story.
|
|
1026
|
+
* see documentation/ARCHITECTURE.md's "Chart-type catalog" section for the full story.
|
|
1027
1027
|
*
|
|
1028
1028
|
* This is not just a flat {key,label} list. Chart types share real,
|
|
1029
1029
|
* composable capabilities — every one of them takes a title; several accept
|
|
@@ -4293,153 +4293,155 @@
|
|
|
4293
4293
|
return key.startsWith('--') ? key : key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
|
|
4294
4294
|
}
|
|
4295
4295
|
|
|
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
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
const
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
item.
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
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
|
+
}
|
|
4443
4445
|
}
|
|
4444
4446
|
|
|
4445
4447
|
/**
|
|
@@ -11216,7 +11218,7 @@
|
|
|
11216
11218
|
* not once per query. Kept as a standalone copy rather than a shared
|
|
11217
11219
|
* dependency — the two repos have no package link today, and this grammar is
|
|
11218
11220
|
* small enough that duplicating it costs less than a cross-repo dependency
|
|
11219
|
-
* would. See renderengine's own `tasks.md` for why this exists, and
|
|
11221
|
+
* would. See renderengine's own `documentation/tasks.md` for why this exists, and
|
|
11220
11222
|
* sqlengine's `documentation/kernel-language.md` for the full grammar
|
|
11221
11223
|
* reference this is a subset of.
|
|
11222
11224
|
*
|
|
@@ -11227,7 +11229,7 @@
|
|
|
11227
11229
|
*
|
|
11228
11230
|
* NO GPU/WASM/WebNN TARGETS HERE, unlike the sqlengine original — renderengine
|
|
11229
11231
|
* has no compute-backend concept and nothing has measured a need for one for
|
|
11230
|
-
* per-point chart encodings (see tasks.md's "Gated on a measurement" entry).
|
|
11232
|
+
* per-point chart encodings (see documentation/tasks.md's "Gated on a measurement" entry).
|
|
11231
11233
|
* This only ever emits JavaScript.
|
|
11232
11234
|
*/
|
|
11233
11235
|
|
|
@@ -12637,7 +12639,7 @@
|
|
|
12637
12639
|
|
|
12638
12640
|
_cell(text, column, left, top, colW, attrs) {
|
|
12639
12641
|
const end = column.align === 'end';
|
|
12640
|
-
this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
|
|
12642
|
+
return this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
|
|
12641
12643
|
textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
|
|
12642
12644
|
});
|
|
12643
12645
|
}
|
|
@@ -12751,6 +12753,17 @@
|
|
|
12751
12753
|
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
12752
12754
|
};
|
|
12753
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
|
+
}
|
|
12754
12767
|
}
|
|
12755
12768
|
|
|
12756
12769
|
/** One number and nothing to group it by: a single figure. */
|
|
@@ -12770,7 +12783,8 @@
|
|
|
12770
12783
|
class CategoryValue extends ResultShape {
|
|
12771
12784
|
static of(columns, rows) {
|
|
12772
12785
|
const { categories, numbers } = ResultShape.split(columns);
|
|
12773
|
-
return categories.length === 1 && numbers.length === 1
|
|
12786
|
+
return categories.length === 1 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
|
|
12787
|
+
? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
12774
12788
|
}
|
|
12775
12789
|
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
12776
12790
|
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
@@ -12810,7 +12824,8 @@
|
|
|
12810
12824
|
class CategorySeries extends ResultShape {
|
|
12811
12825
|
static of(columns, rows) {
|
|
12812
12826
|
const { categories, numbers } = ResultShape.split(columns);
|
|
12813
|
-
return categories.length === 1 && numbers.length >= 2
|
|
12827
|
+
return categories.length === 1 && numbers.length >= 2 && ResultShape.oneRowEach(rows, categories)
|
|
12828
|
+
? new CategorySeries(categories[0], numbers, rows) : null;
|
|
12814
12829
|
}
|
|
12815
12830
|
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
12816
12831
|
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
@@ -12838,7 +12853,8 @@
|
|
|
12838
12853
|
class TwoCategories extends ResultShape {
|
|
12839
12854
|
static of(columns, rows) {
|
|
12840
12855
|
const { categories, numbers } = ResultShape.split(columns);
|
|
12841
|
-
return categories.length === 2 && numbers.length === 1
|
|
12856
|
+
return categories.length === 2 && numbers.length === 1 && ResultShape.oneRowEach(rows, categories)
|
|
12857
|
+
? new TwoCategories(categories, numbers[0], rows) : null;
|
|
12842
12858
|
}
|
|
12843
12859
|
constructor(categories, number, rows) {
|
|
12844
12860
|
super();
|
|
@@ -12875,6 +12891,398 @@
|
|
|
12875
12891
|
/** Shapes in the order they are tried; the first that recognises the columns wins. */
|
|
12876
12892
|
const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
|
|
12877
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
|
+
* @param {function} [options.highlight] row → true for a row drawn highlighted (a host's selection)
|
|
12946
|
+
*/
|
|
12947
|
+
constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null } = {}) {
|
|
12948
|
+
this.rows = rows;
|
|
12949
|
+
this.columns = ChartChooser.describe(rows, columns);
|
|
12950
|
+
this.complete = complete;
|
|
12951
|
+
this.pageSize = pageSize;
|
|
12952
|
+
this.onChange = onChange;
|
|
12953
|
+
this.menus = menus;
|
|
12954
|
+
this.copy = copy ?? TableView.clipboard;
|
|
12955
|
+
this.chooser = chooser ?? new ChartChooser();
|
|
12956
|
+
this.highlight = highlight;
|
|
12957
|
+
this.sort = null; // { column, direction: 'asc' | 'desc' }
|
|
12958
|
+
this.filters = []; // { column, op, value }
|
|
12959
|
+
this.hidden = new Set();
|
|
12960
|
+
this.page = 0;
|
|
12961
|
+
}
|
|
12962
|
+
|
|
12963
|
+
static clipboard(text) {
|
|
12964
|
+
return globalThis.navigator?.clipboard?.writeText(text);
|
|
12965
|
+
}
|
|
12966
|
+
|
|
12967
|
+
// ── What is showing ───────────────────────────────────────────────────────
|
|
12968
|
+
|
|
12969
|
+
visibleColumns() { return this.columns.filter(c => !this.hidden.has(c.name)); }
|
|
12970
|
+
|
|
12971
|
+
/** Whether a row is drawn highlighted: the host's `highlight` says (its selection, say). */
|
|
12972
|
+
isHighlighted(row) { return this.highlight?.(row) === true; }
|
|
12973
|
+
|
|
12974
|
+
filtered() {
|
|
12975
|
+
return this.filters.length === 0 ? this.rows
|
|
12976
|
+
: this.rows.filter(r => this.filters.every(f => FILTER_OPS[f.op](r[f.column], f.value)));
|
|
12977
|
+
}
|
|
12978
|
+
|
|
12979
|
+
/** The filtered rows in sort order (a copy; equal values keep their order). */
|
|
12980
|
+
ordered() {
|
|
12981
|
+
const rows = this.filtered();
|
|
12982
|
+
if (this.sort === null) return rows;
|
|
12983
|
+
const { column, direction } = this.sort;
|
|
12984
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
12985
|
+
// Nulls stay last whichever way: a reader looks for values, not for blanks.
|
|
12986
|
+
return [...rows].sort((a, b) => (a[column] == null || b[column] == null ? compareValues(a[column], b[column]) : sign * compareValues(a[column], b[column])));
|
|
12987
|
+
}
|
|
12988
|
+
|
|
12989
|
+
pageCount() {
|
|
12990
|
+
return Number.isFinite(this.pageSize) ? Math.max(1, Math.ceil(this.filtered().length / this.pageSize)) : 1;
|
|
12991
|
+
}
|
|
12992
|
+
|
|
12993
|
+
/** The rows on the current page, each with its position in the ordered rows (`index`, from 0). */
|
|
12994
|
+
pageRows() {
|
|
12995
|
+
const rows = this.ordered();
|
|
12996
|
+
const start = Number.isFinite(this.pageSize) ? this.page * this.pageSize : 0;
|
|
12997
|
+
const end = Number.isFinite(this.pageSize) ? start + this.pageSize : rows.length;
|
|
12998
|
+
return rows.slice(start, end).map((row, i) => ({ row, index: start + i }));
|
|
12999
|
+
}
|
|
13000
|
+
|
|
13001
|
+
/** "Region = West · Revenue > 100", or '' with no filters. */
|
|
13002
|
+
describeFilters() {
|
|
13003
|
+
return this.filters.map(f => (f.op.startsWith('is') ? `${f.column} ${f.op}` : `${f.column} ${f.op} ${plain(f.value)}`)).join(' · ');
|
|
13004
|
+
}
|
|
13005
|
+
|
|
13006
|
+
// ── Changes ───────────────────────────────────────────────────────────────
|
|
13007
|
+
|
|
13008
|
+
changed() {
|
|
13009
|
+
this.page = Math.min(this.page, this.pageCount() - 1);
|
|
13010
|
+
this.onChange?.(this);
|
|
13011
|
+
}
|
|
13012
|
+
|
|
13013
|
+
/** A header click: unsorted → ascending → descending → unsorted. */
|
|
13014
|
+
toggleSort(column) {
|
|
13015
|
+
const current = this.sort?.column === column ? this.sort.direction : null;
|
|
13016
|
+
this.sortBy(column, current === null ? 'asc' : current === 'asc' ? 'desc' : null);
|
|
13017
|
+
}
|
|
13018
|
+
|
|
13019
|
+
sortBy(column, direction) {
|
|
13020
|
+
this.sort = direction === null ? null : { column, direction };
|
|
13021
|
+
this.page = 0;
|
|
13022
|
+
this.changed();
|
|
13023
|
+
}
|
|
13024
|
+
|
|
13025
|
+
addFilter(column, op, value = null) {
|
|
13026
|
+
if (!Object.hasOwn(FILTER_OPS, op)) throw new Error(`Unknown filter "${op}"`);
|
|
13027
|
+
this.filters = [...this.filters.filter(f => !(f.column === column && f.op === op && f.value === value)), { column, op, value }];
|
|
13028
|
+
this.page = 0;
|
|
13029
|
+
this.changed();
|
|
13030
|
+
}
|
|
13031
|
+
|
|
13032
|
+
removeFilters(column) {
|
|
13033
|
+
this.filters = this.filters.filter(f => f.column !== column);
|
|
13034
|
+
this.changed();
|
|
13035
|
+
}
|
|
13036
|
+
|
|
13037
|
+
clearFilters() {
|
|
13038
|
+
this.filters = [];
|
|
13039
|
+
this.changed();
|
|
13040
|
+
}
|
|
13041
|
+
|
|
13042
|
+
/** Hides a column; the last one showing cannot be hidden. */
|
|
13043
|
+
hide(column) {
|
|
13044
|
+
if (this.visibleColumns().length <= 1) return;
|
|
13045
|
+
this.hidden.add(column);
|
|
13046
|
+
this.changed();
|
|
13047
|
+
}
|
|
13048
|
+
|
|
13049
|
+
showAll() {
|
|
13050
|
+
this.hidden.clear();
|
|
13051
|
+
this.changed();
|
|
13052
|
+
}
|
|
13053
|
+
|
|
13054
|
+
goToPage(page) {
|
|
13055
|
+
this.page = Math.max(0, Math.min(page, this.pageCount() - 1));
|
|
13056
|
+
this.changed();
|
|
13057
|
+
}
|
|
13058
|
+
|
|
13059
|
+
// ── Text out ──────────────────────────────────────────────────────────────
|
|
13060
|
+
|
|
13061
|
+
/** Rows as tab-separated text, header first, the visible columns only. */
|
|
13062
|
+
asTsv(rows = this.ordered()) {
|
|
13063
|
+
const columns = this.visibleColumns().map(c => c.name);
|
|
13064
|
+
const clean = (v) => plain(v).replace(/[\t\r\n]+/g, ' ');
|
|
13065
|
+
return [columns.map(clean).join('\t'), ...rows.map(r => columns.map(c => clean(r[c])).join('\t'))].join('\n');
|
|
13066
|
+
}
|
|
13067
|
+
|
|
13068
|
+
// ── Menus (PopupMenu items) ──────────────────────────────────────────────
|
|
13069
|
+
|
|
13070
|
+
cellMenu(row, column) {
|
|
13071
|
+
const value = row[column];
|
|
13072
|
+
const kind = this.columns.find(c => c.name === column)?.kind;
|
|
13073
|
+
const shown = value == null ? 'empty' : plain(value);
|
|
13074
|
+
const items = value == null
|
|
13075
|
+
? [{ label: `Only rows where ${column} is empty`, action: () => this.addFilter(column, 'is null') },
|
|
13076
|
+
{ label: `Exclude rows where ${column} is empty`, action: () => this.addFilter(column, 'is not null') }]
|
|
13077
|
+
: [{ label: `Only rows where ${column} = ${shown}`, action: () => this.addFilter(column, '=', value) },
|
|
13078
|
+
{ label: `Exclude rows where ${column} = ${shown}`, action: () => this.addFilter(column, '!=', value) },
|
|
13079
|
+
...(kind === 'numeric' ? [
|
|
13080
|
+
{ label: `Only rows where ${column} > ${shown}`, action: () => this.addFilter(column, '>', value) },
|
|
13081
|
+
{ label: `Only rows where ${column} < ${shown}`, action: () => this.addFilter(column, '<', value) },
|
|
13082
|
+
] : [])];
|
|
13083
|
+
return [
|
|
13084
|
+
...items,
|
|
13085
|
+
{ separator: true },
|
|
13086
|
+
{ label: 'Copy value', action: () => this.copy(plain(value)) },
|
|
13087
|
+
...this.extra('cell', { row, column, value }),
|
|
13088
|
+
];
|
|
13089
|
+
}
|
|
13090
|
+
|
|
13091
|
+
rowMenu(row) {
|
|
13092
|
+
return [
|
|
13093
|
+
{ label: 'Copy row', action: () => this.copy(this.asTsv([row])) },
|
|
13094
|
+
{ label: 'Copy row as JSON', action: () => this.copy(JSON.stringify(row)) },
|
|
13095
|
+
...this.extra('row', { row }),
|
|
13096
|
+
];
|
|
13097
|
+
}
|
|
13098
|
+
|
|
13099
|
+
columnMenu(column) {
|
|
13100
|
+
const sorted = this.sort?.column === column ? this.sort.direction : null;
|
|
13101
|
+
const values = [...new Set(this.filtered().map(r => r[column]))].sort(compareValues);
|
|
13102
|
+
const listed = values.slice(0, TableView.MAX_FILTER_VALUES);
|
|
13103
|
+
return [
|
|
13104
|
+
{ label: 'Sort ascending', checked: sorted === 'asc', action: () => this.sortBy(column, 'asc') },
|
|
13105
|
+
{ label: 'Sort descending', checked: sorted === 'desc', action: () => this.sortBy(column, 'desc') },
|
|
13106
|
+
...(sorted ? [{ label: 'Clear sort', action: () => this.sortBy(column, null) }] : []),
|
|
13107
|
+
{ separator: true },
|
|
13108
|
+
{
|
|
13109
|
+
label: 'Filter to',
|
|
13110
|
+
items: [
|
|
13111
|
+
...listed.map(v => (v == null
|
|
13112
|
+
? { label: '(empty)', action: () => this.addFilter(column, 'is null') }
|
|
13113
|
+
: { label: plain(v), action: () => this.addFilter(column, '=', v) })),
|
|
13114
|
+
...(values.length > listed.length ? [{ label: `${values.length - listed.length} more — filter from a cell`, disabled: true }] : []),
|
|
13115
|
+
],
|
|
13116
|
+
},
|
|
13117
|
+
...(this.filters.some(f => f.column === column) ? [{ label: `Clear filters on ${column}`, action: () => this.removeFilters(column) }] : []),
|
|
13118
|
+
{ separator: true },
|
|
13119
|
+
{ label: 'Hide column', disabled: this.visibleColumns().length <= 1, action: () => this.hide(column) },
|
|
13120
|
+
{ label: 'Copy column', action: () => this.copy([column, ...this.ordered().map(r => plain(r[column]))].join('\n')) },
|
|
13121
|
+
...this.extra('column', { column }),
|
|
13122
|
+
];
|
|
13123
|
+
}
|
|
13124
|
+
|
|
13125
|
+
/** The table's menu; `onShowAs(choice)` draws a chosen chart (the host's — RenderEngine.resultView). */
|
|
13126
|
+
tableMenu(onShowAs = null) {
|
|
13127
|
+
const choices = onShowAs ? this.chartChoices() : [];
|
|
13128
|
+
return [
|
|
13129
|
+
...(onShowAs ? [{
|
|
13130
|
+
label: 'Show as',
|
|
13131
|
+
items: choices.length > 0
|
|
13132
|
+
? choices.map(c => ({ label: `${c.label} — ${c.reason}`, action: () => onShowAs(c) }))
|
|
13133
|
+
: [{ label: 'No chart fits these columns', disabled: true }],
|
|
13134
|
+
}, { separator: true }] : []),
|
|
13135
|
+
{ label: 'Clear all filters', disabled: this.filters.length === 0, action: () => this.clearFilters() },
|
|
13136
|
+
{ label: 'Show hidden columns', disabled: this.hidden.size === 0, action: () => this.showAll() },
|
|
13137
|
+
{ label: 'Copy table', action: () => this.copy(this.asTsv()) },
|
|
13138
|
+
...this.extra('table', {}),
|
|
13139
|
+
];
|
|
13140
|
+
}
|
|
13141
|
+
|
|
13142
|
+
/**
|
|
13143
|
+
* The chart types that fit what the table is showing — its filtered, sorted rows and visible
|
|
13144
|
+
* columns — as ChartChooser gives them, without the table. Filtered rows are not the whole
|
|
13145
|
+
* answer, so they are never offered as shares of a whole.
|
|
13146
|
+
*/
|
|
13147
|
+
chartChoices() {
|
|
13148
|
+
const columns = this.visibleColumns();
|
|
13149
|
+
const names = columns.map(c => c.name);
|
|
13150
|
+
const rows = this.ordered().map(r => Object.fromEntries(names.map(n => [n, r[n]])));
|
|
13151
|
+
return this.chooser.choose(rows, columns, { complete: this.complete && this.filters.length === 0 })
|
|
13152
|
+
.filter(c => c.key !== 'table');
|
|
13153
|
+
}
|
|
13154
|
+
|
|
13155
|
+
extra(kind, context) {
|
|
13156
|
+
const items = this.menus?.[kind]?.({ view: this, ...context }) ?? [];
|
|
13157
|
+
return items.length > 0 ? [{ separator: true }, ...items] : [];
|
|
13158
|
+
}
|
|
13159
|
+
}
|
|
13160
|
+
|
|
13161
|
+
/**
|
|
13162
|
+
* TableViewChart — the interactive table: a TableChart drawn from a TableView's state, whose parts
|
|
13163
|
+
* answer clicks. A header sorts (and its ▾ opens the column's menu); a cell opens the cell's menu; a
|
|
13164
|
+
* row number opens the row's menu; the corner ⋮ opens the table's menu (with "Show as" when the host
|
|
13165
|
+
* can draw a chart); the footer's ‹ › page. Each change goes through the view, whose `onChange`
|
|
13166
|
+
* redraws.
|
|
13167
|
+
*
|
|
13168
|
+
* Interaction needs a renderer whose shapes are DOM elements (SVG: `supportsSceneGraph`); anywhere
|
|
13169
|
+
* else it draws the same table without the clicks.
|
|
13170
|
+
*/
|
|
13171
|
+
class TableViewChart extends TableChart {
|
|
13172
|
+
static GUTTER_W = 44;
|
|
13173
|
+
static MENU_GLYPH = '▾';
|
|
13174
|
+
static TABLE_GLYPH = '⋮';
|
|
13175
|
+
static HIGHLIGHT_FILL = '#fdeeb5';
|
|
13176
|
+
|
|
13177
|
+
/**
|
|
13178
|
+
* @param renderer
|
|
13179
|
+
* @param {TableView} view
|
|
13180
|
+
* @param {object} [options] title, nullText; onShowAs(choice) — draws a chart for the table menu's
|
|
13181
|
+
* "Show as"; showMenu(x, y, items) — opens a menu (PopupMenu.show by default)
|
|
13182
|
+
*/
|
|
13183
|
+
constructor(renderer, view, options = {}) {
|
|
13184
|
+
super(renderer, view.rows, options);
|
|
13185
|
+
this.view = view;
|
|
13186
|
+
this.showMenu = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
|
|
13187
|
+
}
|
|
13188
|
+
|
|
13189
|
+
/** The height this draws: title, header, the page's rows and the footer. */
|
|
13190
|
+
static heightFor(view, { title } = {}) {
|
|
13191
|
+
const { top, bottom } = TableChart.margins(title);
|
|
13192
|
+
return top + (2 + view.pageRows().length) * TableChart.ROW_H + bottom;
|
|
13193
|
+
}
|
|
13194
|
+
|
|
13195
|
+
_render() {
|
|
13196
|
+
const { view } = this;
|
|
13197
|
+
const { title, nullText = '—' } = this.options;
|
|
13198
|
+
const { ROW_H, PAD_X } = TableChart;
|
|
13199
|
+
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(title));
|
|
13200
|
+
const interactive = this.renderer.supportsSceneGraph === true;
|
|
13201
|
+
const columns = view.visibleColumns().map(c => ({ key: c.name, label: c.name, align: c.kind === 'numeric' ? 'end' : 'start' }));
|
|
13202
|
+
const gutter = TableViewChart.GUTTER_W;
|
|
13203
|
+
const colW = columns.length > 0 ? (box.width - gutter) / columns.length : box.width - gutter;
|
|
13204
|
+
const x0 = box.x + gutter;
|
|
13205
|
+
|
|
13206
|
+
new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
|
|
13207
|
+
|
|
13208
|
+
// Header: the corner (table menu), then a header per column (sort; ▾ column menu).
|
|
13209
|
+
let top = box.y;
|
|
13210
|
+
this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.HEADER_FILL, null);
|
|
13211
|
+
const corner = this.renderer.rect(box.x, top, gutter, ROW_H, TableChart.HEADER_FILL, null);
|
|
13212
|
+
const cornerGlyph = this._text(TableViewChart.TABLE_GLYPH, box.x + gutter / 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' });
|
|
13213
|
+
this._on(interactive, [corner, cornerGlyph], (e) => this.showMenu(e.clientX, e.clientY, view.tableMenu(this.options.onShowAs ?? null)), 'Table menu');
|
|
13214
|
+
|
|
13215
|
+
columns.forEach((c, i) => {
|
|
13216
|
+
const left = x0 + i * colW;
|
|
13217
|
+
const sorted = view.sort?.column === c.key ? (view.sort.direction === 'asc' ? ' ▲' : ' ▼') : '';
|
|
13218
|
+
// In words: an arrow-like mark here read as a second sort arrow.
|
|
13219
|
+
const filtered = view.filters.some(f => f.column === c.key) ? ' (filtered)' : '';
|
|
13220
|
+
const head = this.renderer.rect(left, top, colW, ROW_H, TableChart.HEADER_FILL, null);
|
|
13221
|
+
// The label leaves room at the right for the ▾.
|
|
13222
|
+
const label = this._cell(`${c.label}${sorted}${filtered}`, c, left, top, colW - 16, { fontWeight: 'bold', fill: '#333' });
|
|
13223
|
+
this._on(interactive, [head, label], () => view.toggleSort(c.key), 'Sort');
|
|
13224
|
+
const glyph = this._text(TableViewChart.MENU_GLYPH, left + colW - 8, top, { textAnchor: 'middle', fill: '#777' });
|
|
13225
|
+
this._on(interactive, [glyph], (e) => this.showMenu(e.clientX, e.clientY, view.columnMenu(c.key)), `${c.label} menu`);
|
|
13226
|
+
});
|
|
13227
|
+
top += ROW_H;
|
|
13228
|
+
this._rule(box.x, top, box.width);
|
|
13229
|
+
|
|
13230
|
+
// Rows: a number in the gutter (row menu), a cell per column (cell menu).
|
|
13231
|
+
view.pageRows().forEach(({ row, index }, r) => {
|
|
13232
|
+
if (view.isHighlighted(row)) this.renderer.rect(box.x, top, box.width, ROW_H, TableViewChart.HIGHLIGHT_FILL, null);
|
|
13233
|
+
else if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
|
|
13234
|
+
const number = this._text(String(index + 1), box.x + gutter - PAD_X, top, { textAnchor: 'end', fill: '#999', fontSize: '11px' });
|
|
13235
|
+
this._on(interactive, [number], (e) => this.showMenu(e.clientX, e.clientY, view.rowMenu(row)), 'Row menu');
|
|
13236
|
+
columns.forEach((c, i) => {
|
|
13237
|
+
const left = x0 + i * colW;
|
|
13238
|
+
const hit = this.renderer.rect(left, top, colW, ROW_H, 'transparent', null);
|
|
13239
|
+
const text = this._cell(TableChart.cellText(row[c.key], nullText), c, left, top, colW, { fill: row[c.key] == null ? '#999' : '#222' });
|
|
13240
|
+
this._on(interactive, [hit, text], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(row, c.key)), null);
|
|
13241
|
+
});
|
|
13242
|
+
top += ROW_H;
|
|
13243
|
+
this._rule(box.x, top, box.width);
|
|
13244
|
+
});
|
|
13245
|
+
|
|
13246
|
+
this._footer(box, top, interactive);
|
|
13247
|
+
}
|
|
13248
|
+
|
|
13249
|
+
/** "1–50 of 120 rows (filtered from 300: Region = West)", and ‹ › when there is more than a page. */
|
|
13250
|
+
_footer(box, top, interactive) {
|
|
13251
|
+
const { view } = this;
|
|
13252
|
+
const shown = view.pageRows();
|
|
13253
|
+
const total = view.filtered().length;
|
|
13254
|
+
const range = shown.length === 0 ? 'No rows' : `${shown[0].index + 1}–${shown.at(-1).index + 1} of ${total} row${total === 1 ? '' : 's'}`;
|
|
13255
|
+
const filters = view.filters.length > 0 ? ` (filtered from ${view.rows.length}: ${view.describeFilters()})` : '';
|
|
13256
|
+
this._text(`${range}${filters}`, box.x + TableChart.PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
|
|
13257
|
+
if (view.pageCount() > 1) {
|
|
13258
|
+
const right = box.x + box.width - TableChart.PAD_X;
|
|
13259
|
+
const previous = this._text('‹ previous', right - 70, top, { textAnchor: 'end', fill: view.page > 0 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
|
|
13260
|
+
const next = this._text('next ›', right, top, { textAnchor: 'end', fill: view.page < view.pageCount() - 1 ? '#2b5fd9' : '#bbb', fontSize: '11px' });
|
|
13261
|
+
this._on(interactive, [previous], () => view.goToPage(view.page - 1), 'Previous page');
|
|
13262
|
+
this._on(interactive, [next], () => view.goToPage(view.page + 1), 'Next page');
|
|
13263
|
+
}
|
|
13264
|
+
}
|
|
13265
|
+
|
|
13266
|
+
_text(text, x, top, attrs) {
|
|
13267
|
+
return this.renderer.text(text, x, top + TableChart.ROW_H / 2, { verticalAlign: 'middle', fontSize: '12px', ...attrs });
|
|
13268
|
+
}
|
|
13269
|
+
|
|
13270
|
+
/** Wires a click to each element that can take one (SVG); a pointer cursor and a tooltip say so. */
|
|
13271
|
+
_on(interactive, elements, handler, title) {
|
|
13272
|
+
if (!interactive) return;
|
|
13273
|
+
for (const el of elements) {
|
|
13274
|
+
if (typeof el?.addEventListener !== 'function') continue;
|
|
13275
|
+
el.addEventListener('click', (event) => { event.stopPropagation?.(); handler(event); });
|
|
13276
|
+
if (el.style) el.style.cursor = 'pointer';
|
|
13277
|
+
if (title && typeof el.setAttribute === 'function' && el.ownerDocument) {
|
|
13278
|
+
const t = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'title');
|
|
13279
|
+
t.textContent = title;
|
|
13280
|
+
el.appendChild(t);
|
|
13281
|
+
}
|
|
13282
|
+
}
|
|
13283
|
+
}
|
|
13284
|
+
}
|
|
13285
|
+
|
|
12878
13286
|
/**
|
|
12879
13287
|
* RadarChart — one polygon per series across N shared axes, each axis scaled
|
|
12880
13288
|
* independently by its own max value across all series.
|
|
@@ -17355,7 +17763,7 @@
|
|
|
17355
17763
|
* Abstract base for "own a bounded collection, push one more item, drop the
|
|
17356
17764
|
* oldest once past a cap" — the one behavior every streamable chart data
|
|
17357
17765
|
* shape shares, however differently each stores its collection(s). See
|
|
17358
|
-
* ARCHITECTURE.md's "Data windows — streaming" section for the concrete
|
|
17766
|
+
* documentation/ARCHITECTURE.md's "Data windows — streaming" section for the concrete
|
|
17359
17767
|
* subtypes and why each exists as its own class rather than a branch here.
|
|
17360
17768
|
*/
|
|
17361
17769
|
class BoundedWindow {
|
|
@@ -17479,7 +17887,7 @@
|
|
|
17479
17887
|
|
|
17480
17888
|
/**
|
|
17481
17889
|
* A nodes+edges pair — the shape behind `graphDiagram`/`graphDiagram3D`.
|
|
17482
|
-
* Not a `CHART_CATALOG` entry (see ARCHITECTURE.md), so it isn't dispatched
|
|
17890
|
+
* Not a `CHART_CATALOG` entry (see documentation/ARCHITECTURE.md), so it isn't dispatched
|
|
17483
17891
|
* through `createDataWindow`/`streamShape`; `RenderEngine.addGraphNode`
|
|
17484
17892
|
* constructs it directly. Trimming drops the oldest nodes FIFO, then any
|
|
17485
17893
|
* edge left dangling off a removed node — dropping nodes without this would
|
|
@@ -42935,7 +43343,7 @@
|
|
|
42935
43343
|
* (github.com/bian-official/public, Apache-2.0) for the names control records
|
|
42936
43344
|
* have and, where a pattern aggregates two creating terms (Monitor: evaluate
|
|
42937
43345
|
* and initiate), the one its APIs use. Every value is BIAN's; `inferred` is
|
|
42938
|
-
* kept for a value that is not. See BIAN_ALIGNMENT.md.
|
|
43346
|
+
* kept for a value that is not. See documentation/BIAN_ALIGNMENT.md.
|
|
42939
43347
|
*/
|
|
42940
43348
|
|
|
42941
43349
|
/** One way a service domain behaves: what it does to its asset, and the record it keeps of it. */
|
|
@@ -49793,6 +50201,62 @@ ${theirs}` : theirs;
|
|
|
49793
50201
|
this._renderChart(new TableChart(this.renderer, data, options));
|
|
49794
50202
|
}
|
|
49795
50203
|
|
|
50204
|
+
/**
|
|
50205
|
+
* Shows result rows as an interactive table — sort, filter, hide columns, page, copy, through
|
|
50206
|
+
* menus on its cells, rows, columns and the table itself — whose table menu can show what it
|
|
50207
|
+
* holds as any chart type ChartChooser says fits ("Show as"); a chart drawn that way has a
|
|
50208
|
+
* "◂ Table" link back to the table as it was left. One output for a result, table or chart.
|
|
50209
|
+
*
|
|
50210
|
+
* The container's height follows what is drawn (the table's rows, or `chartHeight` for a chart),
|
|
50211
|
+
* so the host gives the container a width only. Interaction needs the SVG renderer; on canvas
|
|
50212
|
+
* the table is drawn without it.
|
|
50213
|
+
* @param {Array<object>} rows
|
|
50214
|
+
* @param {object} [options]
|
|
50215
|
+
* @param {Array<{name, kind?, measure?}>} [options.columns] hints (ChartChooser's)
|
|
50216
|
+
* @param {boolean} [options.complete] false when the rows are only part of the answer
|
|
50217
|
+
* @param {number} [options.pageSize] rows per page (50)
|
|
50218
|
+
* @param {object} [options.menus] the host's own items: { cell, row, column, table } → items
|
|
50219
|
+
* @param {string} [options.title]
|
|
50220
|
+
* @param {number} [options.chartHeight] a chart's height (360)
|
|
50221
|
+
* @param {boolean} [options.fitHeight] size the container to what is drawn (true)
|
|
50222
|
+
* @returns {TableView|null} the table's state, for the host to read or change
|
|
50223
|
+
*/
|
|
50224
|
+
resultView(rows, options = {}) {
|
|
50225
|
+
if (!this.renderer) return null;
|
|
50226
|
+
const view = new TableView(rows, { ...options, onChange: () => this._drawResultTable(view, options) });
|
|
50227
|
+
this._drawResultTable(view, options);
|
|
50228
|
+
return view;
|
|
50229
|
+
}
|
|
50230
|
+
|
|
50231
|
+
_drawResultTable(view, options) {
|
|
50232
|
+
this._fitResultHeight(TableViewChart.heightFor(view, options), options);
|
|
50233
|
+
this._chart3D = false;
|
|
50234
|
+
this._chartState = { type: 'table', data: view.rows, options };
|
|
50235
|
+
this._renderChart(new TableViewChart(this.renderer, view, {
|
|
50236
|
+
...options,
|
|
50237
|
+
onShowAs: (choice) => this._drawResultChart(view, choice, options),
|
|
50238
|
+
}));
|
|
50239
|
+
}
|
|
50240
|
+
|
|
50241
|
+
_drawResultChart(view, choice, options) {
|
|
50242
|
+
this._fitResultHeight(options.chartHeight ?? 360, options);
|
|
50243
|
+
this.renderChartType(choice.key, choice.data, { title: options.title ?? choice.reason });
|
|
50244
|
+
// The way back, drawn over the chart: the same table, as it was left.
|
|
50245
|
+
if (!this.renderer.supportsSceneGraph) return;
|
|
50246
|
+
const back = this.renderer.text('◂ Table', 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle' });
|
|
50247
|
+
back?.addEventListener?.('click', () => this._drawResultTable(view, options));
|
|
50248
|
+
if (back?.style) back.style.cursor = 'pointer';
|
|
50249
|
+
}
|
|
50250
|
+
|
|
50251
|
+
_fitResultHeight(height, options) {
|
|
50252
|
+
if (options.fitHeight === false || !this.container?.style) return;
|
|
50253
|
+
// min-height as well: in a flex layout (a notebook, a workbench pane) a height alone is
|
|
50254
|
+
// shrunk to fit, and the table drawn into the shrunk box lost its last rows.
|
|
50255
|
+
this.container.style.height = `${height}px`;
|
|
50256
|
+
this.container.style.minHeight = `${height}px`;
|
|
50257
|
+
this.fitToContainer();
|
|
50258
|
+
}
|
|
50259
|
+
|
|
49796
50260
|
/**
|
|
49797
50261
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
49798
50262
|
* previous cumulative value to the new one (a "delta" bar), except
|
|
@@ -50117,7 +50581,7 @@ ${theirs}` : theirs;
|
|
|
50117
50581
|
/**
|
|
50118
50582
|
* Renders an arc gauge: a half circle (or any start angle and sweep) filled to the value.
|
|
50119
50583
|
* @param {number|{value:number, target?:number}} value The value (0-100 unless options.min/max are given).
|
|
50120
|
-
* @param {object} [options] The gauge options (see REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
|
|
50584
|
+
* @param {object} [options] The gauge options (see documentation/REFERENCE.md, Gauges), cx, cy, r, thickness, startAngle, sweep, cap.
|
|
50121
50585
|
*/
|
|
50122
50586
|
arcGauge(value, options = {}) {
|
|
50123
50587
|
this._renderGauge(ArcGauge, 'arcGauge', value, options);
|
|
@@ -51052,7 +51516,7 @@ ${theirs}` : theirs;
|
|
|
51052
51516
|
/**
|
|
51053
51517
|
* Appends one data point to a registered chart's data and re-renders it,
|
|
51054
51518
|
* keeping at most `maxPoints` (oldest dropped first) — see
|
|
51055
|
-
* REFERENCE.md's "Dynamically adding data points in a bounded window".
|
|
51519
|
+
* documentation/REFERENCE.md's "Dynamically adding data points in a bounded window".
|
|
51056
51520
|
* Delegates the actual shape-aware append/trim to a `datawindow/`
|
|
51057
51521
|
* class, selected via the chart's `CHART_CATALOG` `streamShape`; the
|
|
51058
51522
|
* window instance is cached per `id` so repeated calls keep growing the
|
|
@@ -51340,6 +51804,8 @@ ${theirs}` : theirs;
|
|
|
51340
51804
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
51341
51805
|
exports.TREE_SETTINGS_PROPERTIES = TREE_SETTINGS_PROPERTIES;
|
|
51342
51806
|
exports.TableChart = TableChart;
|
|
51807
|
+
exports.TableView = TableView;
|
|
51808
|
+
exports.TableViewChart = TableViewChart;
|
|
51343
51809
|
exports.Template = Template;
|
|
51344
51810
|
exports.TemplateCatalog = TemplateCatalog;
|
|
51345
51811
|
exports.TemplateLibrary = TemplateLibrary;
|