@peter.prib/renderengine 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +45 -0
- package/dist/renderengine-workspace.umd.js +354 -0
- package/dist/renderengine.umd.js +354 -0
- package/package.json +1 -1
- package/src/chartCatalog.js +10 -0
- package/src/charting/ChartChooser.js +229 -0
- package/src/charting/charts/TableChart.js +98 -0
- package/src/index.js +21 -1
package/REFERENCE.md
CHANGED
|
@@ -88,6 +88,7 @@ catalog `key` for you.
|
|
|
88
88
|
| `treemap` | `treemapChart(data, opts)` | `Array<{label, value?, children?}>` (hierarchical) | squarified layout, palette by top-level branch |
|
|
89
89
|
| `sunburst` | `sunburstChart(data, opts)` | same hierarchical shape as `treemap` | nested rings by depth |
|
|
90
90
|
| `outline` | `outlineChart(data, opts)` | same hierarchical shape as `treemap`/`sunburst` | `onNodeClick`; collapsible indented list |
|
|
91
|
+
| `table` | `tableChart(data, opts)` | `Array<object>` — a row per object, a column per key | `columns` (names or `{key, label?, align?}`), `maxRows`, `nullText` (`'—'`); values exact, numeric columns right-aligned; drawn with the renderer's primitives, so SVG or canvas |
|
|
91
92
|
| `gauge-dial` | `dialGauge(value, opts)` | `number`, `{value, target?}`, or `[{value, label?, color?}]` for a needle each (0–100 unless `min`/`max`) | the gauge options below; `cx`, `cy`, `r`, `thickness`, `startAngle`, `sweep`, `needleColors`; several needles get a legend, the first fills the arc |
|
|
92
93
|
| `gauge-bar` | `barGauge(value, opts)` | `number` or `{value, target?}` (0–100 unless `min`/`max`) | the gauge options below; `x`, `y`, `width`, `height`, `orientation: 'vertical'` |
|
|
93
94
|
| `gauge-arc` | `arcGauge(value, opts)` | `number` or `{value, target?}` | the gauge options below; `cx`, `cy`, `r`, `thickness`, `startAngle`, `sweep` (a half circle by default), `cap` |
|
|
@@ -1080,6 +1081,50 @@ this to `line`/`area`/`stackedBar`/etc. is the same three-line change: call
|
|
|
1080
1081
|
`this._drawYAxisTicks(x, y, width, height, rawMax)` before drawing marks, and scale against its
|
|
1081
1082
|
return value instead of the raw max — not done yet for the rest, tracked as a follow-up.
|
|
1082
1083
|
|
|
1084
|
+
### Choosing a chart for result rows — `ChartChooser`
|
|
1085
|
+
|
|
1086
|
+
`new ChartChooser().choose(rows, columns?, { complete? })` says which chart types fit a set of result rows (a query
|
|
1087
|
+
answer, a CSV), best first, each with the rows already shaped as that type's `dataShape` — so
|
|
1088
|
+
drawing the best one is `engine.renderChartType(best.key, best.data)`. A `table` always fits and is
|
|
1089
|
+
always offered, last.
|
|
1090
|
+
|
|
1091
|
+
```javascript
|
|
1092
|
+
import { RenderEngine, ChartChooser } from '@peter.prib/renderengine';
|
|
1093
|
+
|
|
1094
|
+
const rows = [{ Region: 'West', Revenue: 200 }, { Region: 'East', Revenue: 405 }];
|
|
1095
|
+
const choices = new ChartChooser().choose(rows, [{ name: 'Region' }, { name: 'Revenue', measure: 'additive' }]);
|
|
1096
|
+
// → bar, pie, donut, table — each { key, label, data, reason }
|
|
1097
|
+
const [best] = choices;
|
|
1098
|
+
new RenderEngine(container).renderChartType(best.key, best.data, { title: best.reason });
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
`columns` are optional hints, `{ name, kind?, measure? }`: `kind` is `'categorical'`, `'numeric'`
|
|
1102
|
+
or `'time'` (without a hint, a column whose values are all numbers is numeric, else categorical);
|
|
1103
|
+
`measure` is the data model's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`).
|
|
1104
|
+
`complete: false` says the rows are only part of the answer (a top N, a first page).
|
|
1105
|
+
|
|
1106
|
+
| Rows look like | Offered, best first |
|
|
1107
|
+
|---|---|
|
|
1108
|
+
| one number, one row | `kpi-tile` |
|
|
1109
|
+
| one category, one number | `bar`; also `pie`, `donut` when the measure is additive, the rows are complete, no value is negative and there are 2–8 categories |
|
|
1110
|
+
| one time column, one number | `line`, `area`, `bar` — in time order |
|
|
1111
|
+
| one category, several numbers | `groupedBar`, `line` (a series per number); with time, `line` first |
|
|
1112
|
+
| two categories, one number | `groupedBar`, `heatmap`; with a time column, `line` (a line per category) first |
|
|
1113
|
+
| anything else | `table` only |
|
|
1114
|
+
|
|
1115
|
+
Three rules keep a choice from misleading: only an **additive** measure is offered as shares of a
|
|
1116
|
+
whole (a slice of an average or a balance means nothing); only **complete** rows are (three
|
|
1117
|
+
customers' slices of a top 3 would claim to be the whole); and a **missing value is never drawn as
|
|
1118
|
+
0** — a result with a hole in a series is not offered as series charts (the heatmap, which draws
|
|
1119
|
+
only the cells present, still is), and a bar chart simply has no bar for a null.
|
|
1120
|
+
|
|
1121
|
+
A table has no axes, so it takes none of a chart's axis margins. `TableChart.heightFor(rowCount,
|
|
1122
|
+
{ title?, maxRows? })` is the height it will draw, for sizing its container first.
|
|
1123
|
+
|
|
1124
|
+
Every suggestion is checked against the registry the chooser is given (`chartRegistry` by default):
|
|
1125
|
+
a type is offered only if the catalog has it and every axis it marks `required` is one the result's
|
|
1126
|
+
shape provides, so the catalog stays the single source of truth for what each chart needs.
|
|
1127
|
+
|
|
1083
1128
|
## Common options
|
|
1084
1129
|
|
|
1085
1130
|
Not every method takes every option below — the table above calls out what's notable per type —
|
|
@@ -6898,6 +6898,16 @@
|
|
|
6898
6898
|
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',
|
|
6899
6899
|
streamShape: 'flat',
|
|
6900
6900
|
},
|
|
6901
|
+
{
|
|
6902
|
+
key: 'table', label: 'Table', method: 'tableChart', category: '2d',
|
|
6903
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.NONE, camera: CAMERA_MODE.NONE,
|
|
6904
|
+
axes: [
|
|
6905
|
+
{ role: 'row', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
|
|
6906
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.TEXT_VALUE, required: false },
|
|
6907
|
+
],
|
|
6908
|
+
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',
|
|
6909
|
+
streamShape: 'flat',
|
|
6910
|
+
},
|
|
6901
6911
|
{
|
|
6902
6912
|
key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
|
|
6903
6913
|
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
@@ -17669,6 +17679,330 @@
|
|
|
17669
17679
|
OutlineChart.INDENT_W = 18;
|
|
17670
17680
|
OutlineChart.HIERARCHY_STRUCTURAL_KEYS = new Set(['label', 'value', 'children']);
|
|
17671
17681
|
|
|
17682
|
+
/**
|
|
17683
|
+
* TableChart — rows of values as a table: a header row of column names, then one row per record,
|
|
17684
|
+
* numeric columns right-aligned. Drawn with the renderer's primitives like every chart, so it works
|
|
17685
|
+
* under SVG and canvas and needs no DOM table. Values are shown exactly (not an axis's "1.20k"):
|
|
17686
|
+
* a table is where a reader goes for the actual number.
|
|
17687
|
+
*/
|
|
17688
|
+
class TableChart extends Chart {
|
|
17689
|
+
static chartType = 'table';
|
|
17690
|
+
|
|
17691
|
+
/** Layout constants for the rows. */
|
|
17692
|
+
static ROW_H = 26;
|
|
17693
|
+
static PAD_X = 8;
|
|
17694
|
+
static HEADER_FILL = '#eef1f6';
|
|
17695
|
+
static STRIPE_FILL = '#f8f9fb';
|
|
17696
|
+
static RULE = { color: '#d9dde4', width: 1 };
|
|
17697
|
+
|
|
17698
|
+
/**
|
|
17699
|
+
* The columns to show: `options.columns` (names, or `{ key, label?, align? }`), else every key of
|
|
17700
|
+
* the first row. A column is right-aligned when every value it holds is a number (or null).
|
|
17701
|
+
*/
|
|
17702
|
+
static columns(data, given) {
|
|
17703
|
+
const specs = (given ?? Object.keys(data[0] ?? {})).map(c => (typeof c === 'string' ? { key: c } : { ...c }));
|
|
17704
|
+
return specs.map(c => ({
|
|
17705
|
+
key: c.key,
|
|
17706
|
+
label: c.label ?? c.key,
|
|
17707
|
+
align: c.align ?? (data.length > 0 && data.every(r => r[c.key] == null || typeof r[c.key] === 'number') ? 'end' : 'start'),
|
|
17708
|
+
}));
|
|
17709
|
+
}
|
|
17710
|
+
|
|
17711
|
+
/** The table has no axes, so none of a chart's axis margins: an edge all round, and room for a title. */
|
|
17712
|
+
static margins(title) {
|
|
17713
|
+
return { left: 8, right: 8, top: title ? 50 : 8, bottom: 8 };
|
|
17714
|
+
}
|
|
17715
|
+
|
|
17716
|
+
/**
|
|
17717
|
+
* The height a table of `rowCount` rows needs — header, rows (at most `maxRows`), the "more rows"
|
|
17718
|
+
* line, a title and the edges — so a page can size its container to fit before drawing.
|
|
17719
|
+
*/
|
|
17720
|
+
static heightFor(rowCount, { title, maxRows = Infinity } = {}) {
|
|
17721
|
+
const shown = Math.min(rowCount, maxRows);
|
|
17722
|
+
const more = rowCount > shown ? 1 : 0;
|
|
17723
|
+
const { top, bottom } = TableChart.margins(title);
|
|
17724
|
+
return top + (1 + shown + more) * TableChart.ROW_H + bottom;
|
|
17725
|
+
}
|
|
17726
|
+
|
|
17727
|
+
/** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
|
|
17728
|
+
static cellText(value, nullText = '—') {
|
|
17729
|
+
if (value == null) return nullText;
|
|
17730
|
+
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
17731
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
17732
|
+
return String(value);
|
|
17733
|
+
}
|
|
17734
|
+
|
|
17735
|
+
_render() {
|
|
17736
|
+
const data = this.data ?? [];
|
|
17737
|
+
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
|
|
17738
|
+
const { x = box.x, y = box.y, width = box.width, title, maxRows = Infinity, nullText = '—' } = this.options;
|
|
17739
|
+
const { ROW_H, PAD_X } = TableChart;
|
|
17740
|
+
const columns = TableChart.columns(data, this.options.columns);
|
|
17741
|
+
const colW = columns.length > 0 ? width / columns.length : width;
|
|
17742
|
+
|
|
17743
|
+
new ChartLabels({ title }).draw(this.renderer, { x, y, width, height: box.height });
|
|
17744
|
+
|
|
17745
|
+
const shown = data.slice(0, maxRows);
|
|
17746
|
+
let top = y;
|
|
17747
|
+
this.renderer.rect(x, top, width, ROW_H, TableChart.HEADER_FILL, null);
|
|
17748
|
+
columns.forEach((c, i) => this._cell(c.label, c, x + i * colW, top, colW, { fontWeight: 'bold', fill: '#333' }));
|
|
17749
|
+
top += ROW_H;
|
|
17750
|
+
this._rule(x, top, width);
|
|
17751
|
+
|
|
17752
|
+
shown.forEach((row, r) => {
|
|
17753
|
+
if (r % 2 === 1) this.renderer.rect(x, top, width, ROW_H, TableChart.STRIPE_FILL, null);
|
|
17754
|
+
columns.forEach((c, i) => this._cell(TableChart.cellText(row[c.key], nullText), c, x + i * colW, top, colW, { fill: '#222' }));
|
|
17755
|
+
top += ROW_H;
|
|
17756
|
+
this._rule(x, top, width);
|
|
17757
|
+
});
|
|
17758
|
+
|
|
17759
|
+
if (data.length > shown.length) {
|
|
17760
|
+
this.renderer.text(`${data.length - shown.length} more row(s) not shown`, x + PAD_X, top + ROW_H / 2, {
|
|
17761
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888',
|
|
17762
|
+
});
|
|
17763
|
+
}
|
|
17764
|
+
}
|
|
17765
|
+
|
|
17766
|
+
_cell(text, column, left, top, colW, attrs) {
|
|
17767
|
+
const end = column.align === 'end';
|
|
17768
|
+
this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
|
|
17769
|
+
textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
|
|
17770
|
+
});
|
|
17771
|
+
}
|
|
17772
|
+
|
|
17773
|
+
_rule(x, y, width) {
|
|
17774
|
+
this.renderer.path(`M${x},${y} L${x + width},${y}`, 'none', TableChart.RULE);
|
|
17775
|
+
}
|
|
17776
|
+
}
|
|
17777
|
+
|
|
17778
|
+
/**
|
|
17779
|
+
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
17780
|
+
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
17781
|
+
*
|
|
17782
|
+
* The caller may describe the columns (hints). `kind` is `'categorical'`, `'numeric'` or `'time'`; a
|
|
17783
|
+
* column with no hint is numeric when every value it holds is a number, else categorical. `measure`
|
|
17784
|
+
* is renderengine's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`), as a data model
|
|
17785
|
+
* gives it. The hints change the choice: a time column is drawn along a line, and only an additive
|
|
17786
|
+
* measure is offered as shares of a whole (a pie), since a part-of-a-whole slice of an average or a
|
|
17787
|
+
* balance means nothing.
|
|
17788
|
+
*
|
|
17789
|
+
* Every suggestion is checked against CHART_CATALOG: a chart type is offered only when the catalog
|
|
17790
|
+
* has it and every axis it requires is one the result's shape provides — so the catalog stays the
|
|
17791
|
+
* single source of truth for what each chart needs.
|
|
17792
|
+
*
|
|
17793
|
+
* const [best, ...others] = new ChartChooser().choose(rows, [{ name: 'Day', kind: 'time' }]);
|
|
17794
|
+
* engine.renderChartType(best.key, best.data, { title: best.label });
|
|
17795
|
+
*/
|
|
17796
|
+
class ChartChooser {
|
|
17797
|
+
/** @param {ChartRegistry} [registry] — the built-in chart types, or another registry to choose from */
|
|
17798
|
+
constructor(registry = chartRegistry) {
|
|
17799
|
+
this.registry = registry;
|
|
17800
|
+
}
|
|
17801
|
+
|
|
17802
|
+
/**
|
|
17803
|
+
* @param {Array<object>} rows
|
|
17804
|
+
* @param {Array<{name:string, kind?:string, measure?:string}>} [columns] — hints; every key of the
|
|
17805
|
+
* first row when absent, in that order
|
|
17806
|
+
* @param {{complete?:boolean}} [options] — `complete: false` when the rows are only part of the
|
|
17807
|
+
* answer (a top N, a first page): then nothing is offered as shares of a whole, since the
|
|
17808
|
+
* slices would claim to be all of it
|
|
17809
|
+
* @returns {Array<{key:string, label:string, data:*, reason:string}>} best first; the table last
|
|
17810
|
+
*/
|
|
17811
|
+
choose(rows, columns = null, { complete = true } = {}) {
|
|
17812
|
+
const described = ChartChooser.describe(rows, columns);
|
|
17813
|
+
const shape = RESULT_SHAPES.map(S => S.of(described, rows)).find(s => s !== null) ?? null;
|
|
17814
|
+
if (shape !== null) shape.complete = complete;
|
|
17815
|
+
const charts = shape === null ? [] : shape.candidates()
|
|
17816
|
+
.filter(({ key }) => this.fits(key, shape))
|
|
17817
|
+
.map(({ key, reason, data }) => ({ key, label: this.registry.get(key).label, data, reason }));
|
|
17818
|
+
return [...charts, this.table(rows, described, charts.length === 0)];
|
|
17819
|
+
}
|
|
17820
|
+
|
|
17821
|
+
/** A catalog entry fits a shape when every axis it requires is one the shape provides. */
|
|
17822
|
+
fits(key, shape) {
|
|
17823
|
+
const entry = this.registry.get(key);
|
|
17824
|
+
if (!entry) return false;
|
|
17825
|
+
if (shape.needsSeries && !entry.seriesCapable && !entry.axes.some(a => a.role === 'series')) return false;
|
|
17826
|
+
return entry.axes.filter(a => a.required).every(a => shape.provides[a.role] === a.kind);
|
|
17827
|
+
}
|
|
17828
|
+
|
|
17829
|
+
table(rows, described, only) {
|
|
17830
|
+
return {
|
|
17831
|
+
key: 'table', label: this.registry.get('table')?.label ?? 'Table',
|
|
17832
|
+
data: rows, columns: described.map(c => c.name),
|
|
17833
|
+
reason: only ? 'no chart fits these columns' : 'every value, exactly',
|
|
17834
|
+
};
|
|
17835
|
+
}
|
|
17836
|
+
|
|
17837
|
+
/** The columns with their kinds settled: the hint's, else derived from the rows' values. */
|
|
17838
|
+
static describe(rows, columns) {
|
|
17839
|
+
const given = columns ?? Object.keys(rows[0] ?? {}).map(name => ({ name }));
|
|
17840
|
+
return given.map(c => ({
|
|
17841
|
+
name: c.name,
|
|
17842
|
+
kind: c.kind ?? (rows.length > 0 && rows.every(r => r[c.name] == null || typeof r[c.name] === 'number') ? 'numeric' : 'categorical'),
|
|
17843
|
+
measure: c.measure ?? null,
|
|
17844
|
+
}));
|
|
17845
|
+
}
|
|
17846
|
+
}
|
|
17847
|
+
|
|
17848
|
+
/** The label a category value is drawn with. */
|
|
17849
|
+
function label(value) {
|
|
17850
|
+
return value == null ? '—' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
|
17851
|
+
}
|
|
17852
|
+
|
|
17853
|
+
/** Rows in time order by `column` (numbers, dates and ISO strings all sort correctly this way). */
|
|
17854
|
+
function inOrder(rows, column) {
|
|
17855
|
+
return [...rows].sort((a, b) => (a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0));
|
|
17856
|
+
}
|
|
17857
|
+
|
|
17858
|
+
/**
|
|
17859
|
+
* ResultShape — what a result's columns look like: how many category, time and numeric columns.
|
|
17860
|
+
* Each subclass recognises one shape (`static of`), says which catalog axes it provides, and lists
|
|
17861
|
+
* the chart types that suit it, best first, with the rows shaped for each.
|
|
17862
|
+
*/
|
|
17863
|
+
class ResultShape {
|
|
17864
|
+
constructor() {
|
|
17865
|
+
/** Whether the rows are all of the answer (ChartChooser.choose's `complete`). */
|
|
17866
|
+
this.complete = true;
|
|
17867
|
+
}
|
|
17868
|
+
|
|
17869
|
+
/** The axes this shape can fill, role → AXIS_KIND. */
|
|
17870
|
+
get provides() { return {}; }
|
|
17871
|
+
/** Whether a chart must draw several series (one per numeric column). */
|
|
17872
|
+
get needsSeries() { return false; }
|
|
17873
|
+
/** @returns {Array<{key, reason, data}>} */
|
|
17874
|
+
candidates() { return []; }
|
|
17875
|
+
|
|
17876
|
+
static split(columns) {
|
|
17877
|
+
return {
|
|
17878
|
+
categories: columns.filter(c => c.kind !== 'numeric'),
|
|
17879
|
+
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
17880
|
+
};
|
|
17881
|
+
}
|
|
17882
|
+
}
|
|
17883
|
+
|
|
17884
|
+
/** One number and nothing to group it by: a single figure. */
|
|
17885
|
+
class SingleFigure extends ResultShape {
|
|
17886
|
+
static of(columns, rows) {
|
|
17887
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
17888
|
+
return categories.length === 0 && numbers.length === 1 && rows.length === 1 ? new SingleFigure(numbers[0], rows) : null;
|
|
17889
|
+
}
|
|
17890
|
+
constructor(number, rows) { super(); this.number = number; this.rows = rows; }
|
|
17891
|
+
get provides() { return { value: AXIS_KIND.NUMERIC }; }
|
|
17892
|
+
candidates() {
|
|
17893
|
+
return [{ key: 'kpi-tile', reason: 'a single figure', data: { value: this.rows[0][this.number.name] } }];
|
|
17894
|
+
}
|
|
17895
|
+
}
|
|
17896
|
+
|
|
17897
|
+
/** One category (or time) column and one number: a value per category. */
|
|
17898
|
+
class CategoryValue extends ResultShape {
|
|
17899
|
+
static of(columns, rows) {
|
|
17900
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
17901
|
+
return categories.length === 1 && numbers.length === 1 ? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
17902
|
+
}
|
|
17903
|
+
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
17904
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
17905
|
+
|
|
17906
|
+
candidates() {
|
|
17907
|
+
const { category, number } = this;
|
|
17908
|
+
const time = category.kind === 'time';
|
|
17909
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
17910
|
+
const data = rows.filter(r => r[number.name] != null).map(r => ({ label: label(r[category.name]), value: r[number.name] }));
|
|
17911
|
+
if (time) {
|
|
17912
|
+
return [
|
|
17913
|
+
{ key: 'line', reason: `${number.name} over ${category.name}`, data },
|
|
17914
|
+
{ key: 'area', reason: `${number.name} over ${category.name}, filled`, data },
|
|
17915
|
+
{ key: 'bar', reason: `${number.name} for each ${category.name}`, data },
|
|
17916
|
+
];
|
|
17917
|
+
}
|
|
17918
|
+
const options = [{ key: 'bar', reason: `${number.name} for each ${category.name}`, data }];
|
|
17919
|
+
if (this.isShareOfWhole(data)) {
|
|
17920
|
+
options.push({ key: 'pie', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
17921
|
+
options.push({ key: 'donut', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
17922
|
+
}
|
|
17923
|
+
return options;
|
|
17924
|
+
}
|
|
17925
|
+
|
|
17926
|
+
/**
|
|
17927
|
+
* Shares of a whole make sense only for an additive measure, over all of the rows (not a top N),
|
|
17928
|
+
* with no negatives, in few slices.
|
|
17929
|
+
*/
|
|
17930
|
+
isShareOfWhole(data) {
|
|
17931
|
+
return this.complete && this.number.measure === 'additive' && data.length >= 2 && data.length <= CategoryValue.MAX_SLICES
|
|
17932
|
+
&& data.every(d => d.value >= 0);
|
|
17933
|
+
}
|
|
17934
|
+
}
|
|
17935
|
+
CategoryValue.MAX_SLICES = 8;
|
|
17936
|
+
|
|
17937
|
+
/** One category (or time) column and several numbers: a series per number. */
|
|
17938
|
+
class CategorySeries extends ResultShape {
|
|
17939
|
+
static of(columns, rows) {
|
|
17940
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
17941
|
+
return categories.length === 1 && numbers.length >= 2 ? new CategorySeries(categories[0], numbers, rows) : null;
|
|
17942
|
+
}
|
|
17943
|
+
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
17944
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
17945
|
+
get needsSeries() { return true; }
|
|
17946
|
+
|
|
17947
|
+
candidates() {
|
|
17948
|
+
const { category } = this;
|
|
17949
|
+
const time = category.kind === 'time';
|
|
17950
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
17951
|
+
// lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
|
|
17952
|
+
// as 0, a figure the data does not hold. So a result with one is not offered as series.
|
|
17953
|
+
if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
|
|
17954
|
+
const data = this.numbers.map(n => ({
|
|
17955
|
+
name: n.name,
|
|
17956
|
+
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
|
|
17957
|
+
}));
|
|
17958
|
+
const names = this.numbers.map(n => n.name).join(', ');
|
|
17959
|
+
return time
|
|
17960
|
+
? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
|
|
17961
|
+
: [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
|
|
17962
|
+
}
|
|
17963
|
+
}
|
|
17964
|
+
|
|
17965
|
+
/** Two category (or time) columns and one number: a value per pair. */
|
|
17966
|
+
class TwoCategories extends ResultShape {
|
|
17967
|
+
static of(columns, rows) {
|
|
17968
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
17969
|
+
return categories.length === 2 && numbers.length === 1 ? new TwoCategories(categories, numbers[0], rows) : null;
|
|
17970
|
+
}
|
|
17971
|
+
constructor(categories, number, rows) {
|
|
17972
|
+
super();
|
|
17973
|
+
// A time column goes along the x axis, whichever order the columns came in.
|
|
17974
|
+
this.across = categories.find(c => c.kind === 'time') ?? categories[0];
|
|
17975
|
+
this.down = categories.find(c => c !== this.across);
|
|
17976
|
+
this.number = number;
|
|
17977
|
+
this.rows = rows;
|
|
17978
|
+
}
|
|
17979
|
+
get provides() {
|
|
17980
|
+
return { x: AXIS_KIND.CATEGORICAL, y: AXIS_KIND.CATEGORICAL, category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL };
|
|
17981
|
+
}
|
|
17982
|
+
|
|
17983
|
+
candidates() {
|
|
17984
|
+
const { across, down, number } = this;
|
|
17985
|
+
const rows = across.kind === 'time' ? inOrder(this.rows, across.name) : this.rows;
|
|
17986
|
+
const heat = rows.filter(r => r[number.name] != null)
|
|
17987
|
+
.map(r => ({ x: label(r[across.name]), y: label(r[down.name]), value: r[number.name] }));
|
|
17988
|
+
const heatmap = { key: 'heatmap', reason: `${number.name} for each ${across.name} and ${down.name}`, data: heat };
|
|
17989
|
+
// A series needs a value for every label; where a pair is missing (or null) only the heatmap,
|
|
17990
|
+
// which draws just the cells present, shows the data without inventing a 0.
|
|
17991
|
+
const labels = [...new Set(rows.map(r => label(r[across.name])))];
|
|
17992
|
+
const value = new Map(rows.map(r => [`${label(r[across.name])}\u0000${label(r[down.name])}`, r[number.name]]));
|
|
17993
|
+
const names = [...new Set(rows.map(r => label(r[down.name])))];
|
|
17994
|
+
if (names.some(name => labels.some(l => value.get(`${l}\u0000${name}`) == null))) return [heatmap];
|
|
17995
|
+
const series = names.map(name => ({ name, values: labels.map(l => ({ label: l, value: value.get(`${l}\u0000${name}`) })) }));
|
|
17996
|
+
const grouped = { key: 'groupedBar', reason: `${number.name} for each ${across.name}, by ${down.name}`, data: series };
|
|
17997
|
+
return across.kind === 'time'
|
|
17998
|
+
? [{ key: 'line', reason: `${number.name} over ${across.name}, a line per ${down.name}`, data: series }, heatmap, grouped]
|
|
17999
|
+
: [grouped, heatmap];
|
|
18000
|
+
}
|
|
18001
|
+
}
|
|
18002
|
+
|
|
18003
|
+
/** Shapes in the order they are tried; the first that recognises the columns wins. */
|
|
18004
|
+
const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
|
|
18005
|
+
|
|
17672
18006
|
/**
|
|
17673
18007
|
* RadarChart — one polygon per series across N shared axes, each axis scaled
|
|
17674
18008
|
* independently by its own max value across all series.
|
|
@@ -53374,6 +53708,24 @@ ${theirs}` : theirs;
|
|
|
53374
53708
|
this._renderChart(new FunnelChart(this.renderer, data, options));
|
|
53375
53709
|
}
|
|
53376
53710
|
|
|
53711
|
+
/**
|
|
53712
|
+
* Renders rows as a table — a header of column names, a row per record, numeric columns
|
|
53713
|
+
* right-aligned, values shown exactly. Drawn with the renderer's primitives, so it works under
|
|
53714
|
+
* SVG and canvas. ChartChooser offers it for any result, last.
|
|
53715
|
+
* @param {Array<object>} data one object per row
|
|
53716
|
+
* @param {object} [options]
|
|
53717
|
+
* @param {Array<string|{key:string, label?:string, align?:'start'|'end'}>} [options.columns] every key of the first row when absent
|
|
53718
|
+
* @param {number} [options.maxRows] rows beyond it are counted, not drawn
|
|
53719
|
+
* @param {string} [options.nullText] what a null or missing value shows ('—')
|
|
53720
|
+
* @param {string} [options.title]
|
|
53721
|
+
*/
|
|
53722
|
+
tableChart(data, options = {}) {
|
|
53723
|
+
if (!this.renderer) return;
|
|
53724
|
+
this._chart3D = false;
|
|
53725
|
+
this._chartState = { type: 'table', data, options };
|
|
53726
|
+
this._renderChart(new TableChart(this.renderer, data, options));
|
|
53727
|
+
}
|
|
53728
|
+
|
|
53377
53729
|
/**
|
|
53378
53730
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
53379
53731
|
* previous cumulative value to the new one (a "delta" bar), except
|
|
@@ -58192,6 +58544,7 @@ th { color: #555; font-weight: 500; }
|
|
|
58192
58544
|
exports.CanvasTemplateMenu = CanvasTemplateMenu;
|
|
58193
58545
|
exports.CanvasView = CanvasView;
|
|
58194
58546
|
exports.CatalogDialog = CatalogDialog;
|
|
58547
|
+
exports.ChartChooser = ChartChooser;
|
|
58195
58548
|
exports.ChartPart = ChartPart;
|
|
58196
58549
|
exports.CheckboxTreeSelector = CheckboxTreeSelector;
|
|
58197
58550
|
exports.CircleMarker = CircleMarker;
|
|
@@ -58406,6 +58759,7 @@ th { color: #555; font-weight: 500; }
|
|
|
58406
58759
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
58407
58760
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
58408
58761
|
exports.TREE_SETTINGS_PROPERTIES = TREE_SETTINGS_PROPERTIES;
|
|
58762
|
+
exports.TableChart = TableChart;
|
|
58409
58763
|
exports.TabsNode = TabsNode;
|
|
58410
58764
|
exports.Template = Template;
|
|
58411
58765
|
exports.TemplateCatalog = TemplateCatalog;
|
package/dist/renderengine.umd.js
CHANGED
|
@@ -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,
|
|
@@ -12541,6 +12551,330 @@
|
|
|
12541
12551
|
OutlineChart.INDENT_W = 18;
|
|
12542
12552
|
OutlineChart.HIERARCHY_STRUCTURAL_KEYS = new Set(['label', 'value', 'children']);
|
|
12543
12553
|
|
|
12554
|
+
/**
|
|
12555
|
+
* TableChart — rows of values as a table: a header row of column names, then one row per record,
|
|
12556
|
+
* numeric columns right-aligned. Drawn with the renderer's primitives like every chart, so it works
|
|
12557
|
+
* under SVG and canvas and needs no DOM table. Values are shown exactly (not an axis's "1.20k"):
|
|
12558
|
+
* a table is where a reader goes for the actual number.
|
|
12559
|
+
*/
|
|
12560
|
+
class TableChart extends Chart {
|
|
12561
|
+
static chartType = 'table';
|
|
12562
|
+
|
|
12563
|
+
/** Layout constants for the rows. */
|
|
12564
|
+
static ROW_H = 26;
|
|
12565
|
+
static PAD_X = 8;
|
|
12566
|
+
static HEADER_FILL = '#eef1f6';
|
|
12567
|
+
static STRIPE_FILL = '#f8f9fb';
|
|
12568
|
+
static RULE = { color: '#d9dde4', width: 1 };
|
|
12569
|
+
|
|
12570
|
+
/**
|
|
12571
|
+
* The columns to show: `options.columns` (names, or `{ key, label?, align? }`), else every key of
|
|
12572
|
+
* the first row. A column is right-aligned when every value it holds is a number (or null).
|
|
12573
|
+
*/
|
|
12574
|
+
static columns(data, given) {
|
|
12575
|
+
const specs = (given ?? Object.keys(data[0] ?? {})).map(c => (typeof c === 'string' ? { key: c } : { ...c }));
|
|
12576
|
+
return specs.map(c => ({
|
|
12577
|
+
key: c.key,
|
|
12578
|
+
label: c.label ?? c.key,
|
|
12579
|
+
align: c.align ?? (data.length > 0 && data.every(r => r[c.key] == null || typeof r[c.key] === 'number') ? 'end' : 'start'),
|
|
12580
|
+
}));
|
|
12581
|
+
}
|
|
12582
|
+
|
|
12583
|
+
/** The table has no axes, so none of a chart's axis margins: an edge all round, and room for a title. */
|
|
12584
|
+
static margins(title) {
|
|
12585
|
+
return { left: 8, right: 8, top: title ? 50 : 8, bottom: 8 };
|
|
12586
|
+
}
|
|
12587
|
+
|
|
12588
|
+
/**
|
|
12589
|
+
* The height a table of `rowCount` rows needs — header, rows (at most `maxRows`), the "more rows"
|
|
12590
|
+
* line, a title and the edges — so a page can size its container to fit before drawing.
|
|
12591
|
+
*/
|
|
12592
|
+
static heightFor(rowCount, { title, maxRows = Infinity } = {}) {
|
|
12593
|
+
const shown = Math.min(rowCount, maxRows);
|
|
12594
|
+
const more = rowCount > shown ? 1 : 0;
|
|
12595
|
+
const { top, bottom } = TableChart.margins(title);
|
|
12596
|
+
return top + (1 + shown + more) * TableChart.ROW_H + bottom;
|
|
12597
|
+
}
|
|
12598
|
+
|
|
12599
|
+
/** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
|
|
12600
|
+
static cellText(value, nullText = '—') {
|
|
12601
|
+
if (value == null) return nullText;
|
|
12602
|
+
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
12603
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
12604
|
+
return String(value);
|
|
12605
|
+
}
|
|
12606
|
+
|
|
12607
|
+
_render() {
|
|
12608
|
+
const data = this.data ?? [];
|
|
12609
|
+
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
|
|
12610
|
+
const { x = box.x, y = box.y, width = box.width, title, maxRows = Infinity, nullText = '—' } = this.options;
|
|
12611
|
+
const { ROW_H, PAD_X } = TableChart;
|
|
12612
|
+
const columns = TableChart.columns(data, this.options.columns);
|
|
12613
|
+
const colW = columns.length > 0 ? width / columns.length : width;
|
|
12614
|
+
|
|
12615
|
+
new ChartLabels({ title }).draw(this.renderer, { x, y, width, height: box.height });
|
|
12616
|
+
|
|
12617
|
+
const shown = data.slice(0, maxRows);
|
|
12618
|
+
let top = y;
|
|
12619
|
+
this.renderer.rect(x, top, width, ROW_H, TableChart.HEADER_FILL, null);
|
|
12620
|
+
columns.forEach((c, i) => this._cell(c.label, c, x + i * colW, top, colW, { fontWeight: 'bold', fill: '#333' }));
|
|
12621
|
+
top += ROW_H;
|
|
12622
|
+
this._rule(x, top, width);
|
|
12623
|
+
|
|
12624
|
+
shown.forEach((row, r) => {
|
|
12625
|
+
if (r % 2 === 1) this.renderer.rect(x, top, width, ROW_H, TableChart.STRIPE_FILL, null);
|
|
12626
|
+
columns.forEach((c, i) => this._cell(TableChart.cellText(row[c.key], nullText), c, x + i * colW, top, colW, { fill: '#222' }));
|
|
12627
|
+
top += ROW_H;
|
|
12628
|
+
this._rule(x, top, width);
|
|
12629
|
+
});
|
|
12630
|
+
|
|
12631
|
+
if (data.length > shown.length) {
|
|
12632
|
+
this.renderer.text(`${data.length - shown.length} more row(s) not shown`, x + PAD_X, top + ROW_H / 2, {
|
|
12633
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888',
|
|
12634
|
+
});
|
|
12635
|
+
}
|
|
12636
|
+
}
|
|
12637
|
+
|
|
12638
|
+
_cell(text, column, left, top, colW, attrs) {
|
|
12639
|
+
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, {
|
|
12641
|
+
textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
|
|
12642
|
+
});
|
|
12643
|
+
}
|
|
12644
|
+
|
|
12645
|
+
_rule(x, y, width) {
|
|
12646
|
+
this.renderer.path(`M${x},${y} L${x + width},${y}`, 'none', TableChart.RULE);
|
|
12647
|
+
}
|
|
12648
|
+
}
|
|
12649
|
+
|
|
12650
|
+
/**
|
|
12651
|
+
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
12652
|
+
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
12653
|
+
*
|
|
12654
|
+
* The caller may describe the columns (hints). `kind` is `'categorical'`, `'numeric'` or `'time'`; a
|
|
12655
|
+
* column with no hint is numeric when every value it holds is a number, else categorical. `measure`
|
|
12656
|
+
* is renderengine's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`), as a data model
|
|
12657
|
+
* gives it. The hints change the choice: a time column is drawn along a line, and only an additive
|
|
12658
|
+
* measure is offered as shares of a whole (a pie), since a part-of-a-whole slice of an average or a
|
|
12659
|
+
* balance means nothing.
|
|
12660
|
+
*
|
|
12661
|
+
* Every suggestion is checked against CHART_CATALOG: a chart type is offered only when the catalog
|
|
12662
|
+
* has it and every axis it requires is one the result's shape provides — so the catalog stays the
|
|
12663
|
+
* single source of truth for what each chart needs.
|
|
12664
|
+
*
|
|
12665
|
+
* const [best, ...others] = new ChartChooser().choose(rows, [{ name: 'Day', kind: 'time' }]);
|
|
12666
|
+
* engine.renderChartType(best.key, best.data, { title: best.label });
|
|
12667
|
+
*/
|
|
12668
|
+
class ChartChooser {
|
|
12669
|
+
/** @param {ChartRegistry} [registry] — the built-in chart types, or another registry to choose from */
|
|
12670
|
+
constructor(registry = chartRegistry) {
|
|
12671
|
+
this.registry = registry;
|
|
12672
|
+
}
|
|
12673
|
+
|
|
12674
|
+
/**
|
|
12675
|
+
* @param {Array<object>} rows
|
|
12676
|
+
* @param {Array<{name:string, kind?:string, measure?:string}>} [columns] — hints; every key of the
|
|
12677
|
+
* first row when absent, in that order
|
|
12678
|
+
* @param {{complete?:boolean}} [options] — `complete: false` when the rows are only part of the
|
|
12679
|
+
* answer (a top N, a first page): then nothing is offered as shares of a whole, since the
|
|
12680
|
+
* slices would claim to be all of it
|
|
12681
|
+
* @returns {Array<{key:string, label:string, data:*, reason:string}>} best first; the table last
|
|
12682
|
+
*/
|
|
12683
|
+
choose(rows, columns = null, { complete = true } = {}) {
|
|
12684
|
+
const described = ChartChooser.describe(rows, columns);
|
|
12685
|
+
const shape = RESULT_SHAPES.map(S => S.of(described, rows)).find(s => s !== null) ?? null;
|
|
12686
|
+
if (shape !== null) shape.complete = complete;
|
|
12687
|
+
const charts = shape === null ? [] : shape.candidates()
|
|
12688
|
+
.filter(({ key }) => this.fits(key, shape))
|
|
12689
|
+
.map(({ key, reason, data }) => ({ key, label: this.registry.get(key).label, data, reason }));
|
|
12690
|
+
return [...charts, this.table(rows, described, charts.length === 0)];
|
|
12691
|
+
}
|
|
12692
|
+
|
|
12693
|
+
/** A catalog entry fits a shape when every axis it requires is one the shape provides. */
|
|
12694
|
+
fits(key, shape) {
|
|
12695
|
+
const entry = this.registry.get(key);
|
|
12696
|
+
if (!entry) return false;
|
|
12697
|
+
if (shape.needsSeries && !entry.seriesCapable && !entry.axes.some(a => a.role === 'series')) return false;
|
|
12698
|
+
return entry.axes.filter(a => a.required).every(a => shape.provides[a.role] === a.kind);
|
|
12699
|
+
}
|
|
12700
|
+
|
|
12701
|
+
table(rows, described, only) {
|
|
12702
|
+
return {
|
|
12703
|
+
key: 'table', label: this.registry.get('table')?.label ?? 'Table',
|
|
12704
|
+
data: rows, columns: described.map(c => c.name),
|
|
12705
|
+
reason: only ? 'no chart fits these columns' : 'every value, exactly',
|
|
12706
|
+
};
|
|
12707
|
+
}
|
|
12708
|
+
|
|
12709
|
+
/** The columns with their kinds settled: the hint's, else derived from the rows' values. */
|
|
12710
|
+
static describe(rows, columns) {
|
|
12711
|
+
const given = columns ?? Object.keys(rows[0] ?? {}).map(name => ({ name }));
|
|
12712
|
+
return given.map(c => ({
|
|
12713
|
+
name: c.name,
|
|
12714
|
+
kind: c.kind ?? (rows.length > 0 && rows.every(r => r[c.name] == null || typeof r[c.name] === 'number') ? 'numeric' : 'categorical'),
|
|
12715
|
+
measure: c.measure ?? null,
|
|
12716
|
+
}));
|
|
12717
|
+
}
|
|
12718
|
+
}
|
|
12719
|
+
|
|
12720
|
+
/** The label a category value is drawn with. */
|
|
12721
|
+
function label(value) {
|
|
12722
|
+
return value == null ? '—' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
|
12723
|
+
}
|
|
12724
|
+
|
|
12725
|
+
/** Rows in time order by `column` (numbers, dates and ISO strings all sort correctly this way). */
|
|
12726
|
+
function inOrder(rows, column) {
|
|
12727
|
+
return [...rows].sort((a, b) => (a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0));
|
|
12728
|
+
}
|
|
12729
|
+
|
|
12730
|
+
/**
|
|
12731
|
+
* ResultShape — what a result's columns look like: how many category, time and numeric columns.
|
|
12732
|
+
* Each subclass recognises one shape (`static of`), says which catalog axes it provides, and lists
|
|
12733
|
+
* the chart types that suit it, best first, with the rows shaped for each.
|
|
12734
|
+
*/
|
|
12735
|
+
class ResultShape {
|
|
12736
|
+
constructor() {
|
|
12737
|
+
/** Whether the rows are all of the answer (ChartChooser.choose's `complete`). */
|
|
12738
|
+
this.complete = true;
|
|
12739
|
+
}
|
|
12740
|
+
|
|
12741
|
+
/** The axes this shape can fill, role → AXIS_KIND. */
|
|
12742
|
+
get provides() { return {}; }
|
|
12743
|
+
/** Whether a chart must draw several series (one per numeric column). */
|
|
12744
|
+
get needsSeries() { return false; }
|
|
12745
|
+
/** @returns {Array<{key, reason, data}>} */
|
|
12746
|
+
candidates() { return []; }
|
|
12747
|
+
|
|
12748
|
+
static split(columns) {
|
|
12749
|
+
return {
|
|
12750
|
+
categories: columns.filter(c => c.kind !== 'numeric'),
|
|
12751
|
+
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
12752
|
+
};
|
|
12753
|
+
}
|
|
12754
|
+
}
|
|
12755
|
+
|
|
12756
|
+
/** One number and nothing to group it by: a single figure. */
|
|
12757
|
+
class SingleFigure extends ResultShape {
|
|
12758
|
+
static of(columns, rows) {
|
|
12759
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
12760
|
+
return categories.length === 0 && numbers.length === 1 && rows.length === 1 ? new SingleFigure(numbers[0], rows) : null;
|
|
12761
|
+
}
|
|
12762
|
+
constructor(number, rows) { super(); this.number = number; this.rows = rows; }
|
|
12763
|
+
get provides() { return { value: AXIS_KIND.NUMERIC }; }
|
|
12764
|
+
candidates() {
|
|
12765
|
+
return [{ key: 'kpi-tile', reason: 'a single figure', data: { value: this.rows[0][this.number.name] } }];
|
|
12766
|
+
}
|
|
12767
|
+
}
|
|
12768
|
+
|
|
12769
|
+
/** One category (or time) column and one number: a value per category. */
|
|
12770
|
+
class CategoryValue extends ResultShape {
|
|
12771
|
+
static of(columns, rows) {
|
|
12772
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
12773
|
+
return categories.length === 1 && numbers.length === 1 ? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
12774
|
+
}
|
|
12775
|
+
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
12776
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
12777
|
+
|
|
12778
|
+
candidates() {
|
|
12779
|
+
const { category, number } = this;
|
|
12780
|
+
const time = category.kind === 'time';
|
|
12781
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
12782
|
+
const data = rows.filter(r => r[number.name] != null).map(r => ({ label: label(r[category.name]), value: r[number.name] }));
|
|
12783
|
+
if (time) {
|
|
12784
|
+
return [
|
|
12785
|
+
{ key: 'line', reason: `${number.name} over ${category.name}`, data },
|
|
12786
|
+
{ key: 'area', reason: `${number.name} over ${category.name}, filled`, data },
|
|
12787
|
+
{ key: 'bar', reason: `${number.name} for each ${category.name}`, data },
|
|
12788
|
+
];
|
|
12789
|
+
}
|
|
12790
|
+
const options = [{ key: 'bar', reason: `${number.name} for each ${category.name}`, data }];
|
|
12791
|
+
if (this.isShareOfWhole(data)) {
|
|
12792
|
+
options.push({ key: 'pie', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
12793
|
+
options.push({ key: 'donut', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
12794
|
+
}
|
|
12795
|
+
return options;
|
|
12796
|
+
}
|
|
12797
|
+
|
|
12798
|
+
/**
|
|
12799
|
+
* Shares of a whole make sense only for an additive measure, over all of the rows (not a top N),
|
|
12800
|
+
* with no negatives, in few slices.
|
|
12801
|
+
*/
|
|
12802
|
+
isShareOfWhole(data) {
|
|
12803
|
+
return this.complete && this.number.measure === 'additive' && data.length >= 2 && data.length <= CategoryValue.MAX_SLICES
|
|
12804
|
+
&& data.every(d => d.value >= 0);
|
|
12805
|
+
}
|
|
12806
|
+
}
|
|
12807
|
+
CategoryValue.MAX_SLICES = 8;
|
|
12808
|
+
|
|
12809
|
+
/** One category (or time) column and several numbers: a series per number. */
|
|
12810
|
+
class CategorySeries extends ResultShape {
|
|
12811
|
+
static of(columns, rows) {
|
|
12812
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
12813
|
+
return categories.length === 1 && numbers.length >= 2 ? new CategorySeries(categories[0], numbers, rows) : null;
|
|
12814
|
+
}
|
|
12815
|
+
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
12816
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
12817
|
+
get needsSeries() { return true; }
|
|
12818
|
+
|
|
12819
|
+
candidates() {
|
|
12820
|
+
const { category } = this;
|
|
12821
|
+
const time = category.kind === 'time';
|
|
12822
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
12823
|
+
// lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
|
|
12824
|
+
// as 0, a figure the data does not hold. So a result with one is not offered as series.
|
|
12825
|
+
if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
|
|
12826
|
+
const data = this.numbers.map(n => ({
|
|
12827
|
+
name: n.name,
|
|
12828
|
+
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
|
|
12829
|
+
}));
|
|
12830
|
+
const names = this.numbers.map(n => n.name).join(', ');
|
|
12831
|
+
return time
|
|
12832
|
+
? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
|
|
12833
|
+
: [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
|
|
12834
|
+
}
|
|
12835
|
+
}
|
|
12836
|
+
|
|
12837
|
+
/** Two category (or time) columns and one number: a value per pair. */
|
|
12838
|
+
class TwoCategories extends ResultShape {
|
|
12839
|
+
static of(columns, rows) {
|
|
12840
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
12841
|
+
return categories.length === 2 && numbers.length === 1 ? new TwoCategories(categories, numbers[0], rows) : null;
|
|
12842
|
+
}
|
|
12843
|
+
constructor(categories, number, rows) {
|
|
12844
|
+
super();
|
|
12845
|
+
// A time column goes along the x axis, whichever order the columns came in.
|
|
12846
|
+
this.across = categories.find(c => c.kind === 'time') ?? categories[0];
|
|
12847
|
+
this.down = categories.find(c => c !== this.across);
|
|
12848
|
+
this.number = number;
|
|
12849
|
+
this.rows = rows;
|
|
12850
|
+
}
|
|
12851
|
+
get provides() {
|
|
12852
|
+
return { x: AXIS_KIND.CATEGORICAL, y: AXIS_KIND.CATEGORICAL, category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL };
|
|
12853
|
+
}
|
|
12854
|
+
|
|
12855
|
+
candidates() {
|
|
12856
|
+
const { across, down, number } = this;
|
|
12857
|
+
const rows = across.kind === 'time' ? inOrder(this.rows, across.name) : this.rows;
|
|
12858
|
+
const heat = rows.filter(r => r[number.name] != null)
|
|
12859
|
+
.map(r => ({ x: label(r[across.name]), y: label(r[down.name]), value: r[number.name] }));
|
|
12860
|
+
const heatmap = { key: 'heatmap', reason: `${number.name} for each ${across.name} and ${down.name}`, data: heat };
|
|
12861
|
+
// A series needs a value for every label; where a pair is missing (or null) only the heatmap,
|
|
12862
|
+
// which draws just the cells present, shows the data without inventing a 0.
|
|
12863
|
+
const labels = [...new Set(rows.map(r => label(r[across.name])))];
|
|
12864
|
+
const value = new Map(rows.map(r => [`${label(r[across.name])}\u0000${label(r[down.name])}`, r[number.name]]));
|
|
12865
|
+
const names = [...new Set(rows.map(r => label(r[down.name])))];
|
|
12866
|
+
if (names.some(name => labels.some(l => value.get(`${l}\u0000${name}`) == null))) return [heatmap];
|
|
12867
|
+
const series = names.map(name => ({ name, values: labels.map(l => ({ label: l, value: value.get(`${l}\u0000${name}`) })) }));
|
|
12868
|
+
const grouped = { key: 'groupedBar', reason: `${number.name} for each ${across.name}, by ${down.name}`, data: series };
|
|
12869
|
+
return across.kind === 'time'
|
|
12870
|
+
? [{ key: 'line', reason: `${number.name} over ${across.name}, a line per ${down.name}`, data: series }, heatmap, grouped]
|
|
12871
|
+
: [grouped, heatmap];
|
|
12872
|
+
}
|
|
12873
|
+
}
|
|
12874
|
+
|
|
12875
|
+
/** Shapes in the order they are tried; the first that recognises the columns wins. */
|
|
12876
|
+
const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
|
|
12877
|
+
|
|
12544
12878
|
/**
|
|
12545
12879
|
* RadarChart — one polygon per series across N shared axes, each axis scaled
|
|
12546
12880
|
* independently by its own max value across all series.
|
|
@@ -49441,6 +49775,24 @@ ${theirs}` : theirs;
|
|
|
49441
49775
|
this._renderChart(new FunnelChart(this.renderer, data, options));
|
|
49442
49776
|
}
|
|
49443
49777
|
|
|
49778
|
+
/**
|
|
49779
|
+
* Renders rows as a table — a header of column names, a row per record, numeric columns
|
|
49780
|
+
* right-aligned, values shown exactly. Drawn with the renderer's primitives, so it works under
|
|
49781
|
+
* SVG and canvas. ChartChooser offers it for any result, last.
|
|
49782
|
+
* @param {Array<object>} data one object per row
|
|
49783
|
+
* @param {object} [options]
|
|
49784
|
+
* @param {Array<string|{key:string, label?:string, align?:'start'|'end'}>} [options.columns] every key of the first row when absent
|
|
49785
|
+
* @param {number} [options.maxRows] rows beyond it are counted, not drawn
|
|
49786
|
+
* @param {string} [options.nullText] what a null or missing value shows ('—')
|
|
49787
|
+
* @param {string} [options.title]
|
|
49788
|
+
*/
|
|
49789
|
+
tableChart(data, options = {}) {
|
|
49790
|
+
if (!this.renderer) return;
|
|
49791
|
+
this._chart3D = false;
|
|
49792
|
+
this._chartState = { type: 'table', data, options };
|
|
49793
|
+
this._renderChart(new TableChart(this.renderer, data, options));
|
|
49794
|
+
}
|
|
49795
|
+
|
|
49444
49796
|
/**
|
|
49445
49797
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
49446
49798
|
* previous cumulative value to the new one (a "delta" bar), except
|
|
@@ -50805,6 +51157,7 @@ ${theirs}` : theirs;
|
|
|
50805
51157
|
exports.CanvasTemplateMenu = CanvasTemplateMenu;
|
|
50806
51158
|
exports.CanvasView = CanvasView;
|
|
50807
51159
|
exports.CatalogDialog = CatalogDialog;
|
|
51160
|
+
exports.ChartChooser = ChartChooser;
|
|
50808
51161
|
exports.CheckboxTreeSelector = CheckboxTreeSelector;
|
|
50809
51162
|
exports.CircleMarker = CircleMarker;
|
|
50810
51163
|
exports.CircleTemplate = CircleTemplate;
|
|
@@ -50986,6 +51339,7 @@ ${theirs}` : theirs;
|
|
|
50986
51339
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
50987
51340
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
50988
51341
|
exports.TREE_SETTINGS_PROPERTIES = TREE_SETTINGS_PROPERTIES;
|
|
51342
|
+
exports.TableChart = TableChart;
|
|
50989
51343
|
exports.Template = Template;
|
|
50990
51344
|
exports.TemplateCatalog = TemplateCatalog;
|
|
50991
51345
|
exports.TemplateLibrary = TemplateLibrary;
|
package/package.json
CHANGED
package/src/chartCatalog.js
CHANGED
|
@@ -457,6 +457,16 @@ export const CHART_CATALOG = [
|
|
|
457
457
|
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',
|
|
458
458
|
streamShape: 'flat',
|
|
459
459
|
},
|
|
460
|
+
{
|
|
461
|
+
key: 'table', label: 'Table', method: 'tableChart', category: '2d',
|
|
462
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.NONE, camera: CAMERA_MODE.NONE,
|
|
463
|
+
axes: [
|
|
464
|
+
{ role: 'row', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
|
|
465
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.TEXT_VALUE, required: false },
|
|
466
|
+
],
|
|
467
|
+
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',
|
|
468
|
+
streamShape: 'flat',
|
|
469
|
+
},
|
|
460
470
|
{
|
|
461
471
|
key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
|
|
462
472
|
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { AXIS_KIND, chartRegistry } from '../chartCatalog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
5
|
+
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
6
|
+
*
|
|
7
|
+
* The caller may describe the columns (hints). `kind` is `'categorical'`, `'numeric'` or `'time'`; a
|
|
8
|
+
* column with no hint is numeric when every value it holds is a number, else categorical. `measure`
|
|
9
|
+
* is renderengine's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`), as a data model
|
|
10
|
+
* gives it. The hints change the choice: a time column is drawn along a line, and only an additive
|
|
11
|
+
* measure is offered as shares of a whole (a pie), since a part-of-a-whole slice of an average or a
|
|
12
|
+
* balance means nothing.
|
|
13
|
+
*
|
|
14
|
+
* Every suggestion is checked against CHART_CATALOG: a chart type is offered only when the catalog
|
|
15
|
+
* has it and every axis it requires is one the result's shape provides — so the catalog stays the
|
|
16
|
+
* single source of truth for what each chart needs.
|
|
17
|
+
*
|
|
18
|
+
* const [best, ...others] = new ChartChooser().choose(rows, [{ name: 'Day', kind: 'time' }]);
|
|
19
|
+
* engine.renderChartType(best.key, best.data, { title: best.label });
|
|
20
|
+
*/
|
|
21
|
+
export class ChartChooser {
|
|
22
|
+
/** @param {ChartRegistry} [registry] — the built-in chart types, or another registry to choose from */
|
|
23
|
+
constructor(registry = chartRegistry) {
|
|
24
|
+
this.registry = registry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Array<object>} rows
|
|
29
|
+
* @param {Array<{name:string, kind?:string, measure?:string}>} [columns] — hints; every key of the
|
|
30
|
+
* first row when absent, in that order
|
|
31
|
+
* @param {{complete?:boolean}} [options] — `complete: false` when the rows are only part of the
|
|
32
|
+
* answer (a top N, a first page): then nothing is offered as shares of a whole, since the
|
|
33
|
+
* slices would claim to be all of it
|
|
34
|
+
* @returns {Array<{key:string, label:string, data:*, reason:string}>} best first; the table last
|
|
35
|
+
*/
|
|
36
|
+
choose(rows, columns = null, { complete = true } = {}) {
|
|
37
|
+
const described = ChartChooser.describe(rows, columns);
|
|
38
|
+
const shape = RESULT_SHAPES.map(S => S.of(described, rows)).find(s => s !== null) ?? null;
|
|
39
|
+
if (shape !== null) shape.complete = complete;
|
|
40
|
+
const charts = shape === null ? [] : shape.candidates()
|
|
41
|
+
.filter(({ key }) => this.fits(key, shape))
|
|
42
|
+
.map(({ key, reason, data }) => ({ key, label: this.registry.get(key).label, data, reason }));
|
|
43
|
+
return [...charts, this.table(rows, described, charts.length === 0)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A catalog entry fits a shape when every axis it requires is one the shape provides. */
|
|
47
|
+
fits(key, shape) {
|
|
48
|
+
const entry = this.registry.get(key);
|
|
49
|
+
if (!entry) return false;
|
|
50
|
+
if (shape.needsSeries && !entry.seriesCapable && !entry.axes.some(a => a.role === 'series')) return false;
|
|
51
|
+
return entry.axes.filter(a => a.required).every(a => shape.provides[a.role] === a.kind);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
table(rows, described, only) {
|
|
55
|
+
return {
|
|
56
|
+
key: 'table', label: this.registry.get('table')?.label ?? 'Table',
|
|
57
|
+
data: rows, columns: described.map(c => c.name),
|
|
58
|
+
reason: only ? 'no chart fits these columns' : 'every value, exactly',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The columns with their kinds settled: the hint's, else derived from the rows' values. */
|
|
63
|
+
static describe(rows, columns) {
|
|
64
|
+
const given = columns ?? Object.keys(rows[0] ?? {}).map(name => ({ name }));
|
|
65
|
+
return given.map(c => ({
|
|
66
|
+
name: c.name,
|
|
67
|
+
kind: c.kind ?? (rows.length > 0 && rows.every(r => r[c.name] == null || typeof r[c.name] === 'number') ? 'numeric' : 'categorical'),
|
|
68
|
+
measure: c.measure ?? null,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The label a category value is drawn with. */
|
|
74
|
+
function label(value) {
|
|
75
|
+
return value == null ? '—' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Rows in time order by `column` (numbers, dates and ISO strings all sort correctly this way). */
|
|
79
|
+
function inOrder(rows, column) {
|
|
80
|
+
return [...rows].sort((a, b) => (a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ResultShape — what a result's columns look like: how many category, time and numeric columns.
|
|
85
|
+
* Each subclass recognises one shape (`static of`), says which catalog axes it provides, and lists
|
|
86
|
+
* the chart types that suit it, best first, with the rows shaped for each.
|
|
87
|
+
*/
|
|
88
|
+
class ResultShape {
|
|
89
|
+
constructor() {
|
|
90
|
+
/** Whether the rows are all of the answer (ChartChooser.choose's `complete`). */
|
|
91
|
+
this.complete = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The axes this shape can fill, role → AXIS_KIND. */
|
|
95
|
+
get provides() { return {}; }
|
|
96
|
+
/** Whether a chart must draw several series (one per numeric column). */
|
|
97
|
+
get needsSeries() { return false; }
|
|
98
|
+
/** @returns {Array<{key, reason, data}>} */
|
|
99
|
+
candidates() { return []; }
|
|
100
|
+
|
|
101
|
+
static split(columns) {
|
|
102
|
+
return {
|
|
103
|
+
categories: columns.filter(c => c.kind !== 'numeric'),
|
|
104
|
+
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One number and nothing to group it by: a single figure. */
|
|
110
|
+
class SingleFigure extends ResultShape {
|
|
111
|
+
static of(columns, rows) {
|
|
112
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
113
|
+
return categories.length === 0 && numbers.length === 1 && rows.length === 1 ? new SingleFigure(numbers[0], rows) : null;
|
|
114
|
+
}
|
|
115
|
+
constructor(number, rows) { super(); this.number = number; this.rows = rows; }
|
|
116
|
+
get provides() { return { value: AXIS_KIND.NUMERIC }; }
|
|
117
|
+
candidates() {
|
|
118
|
+
return [{ key: 'kpi-tile', reason: 'a single figure', data: { value: this.rows[0][this.number.name] } }];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One category (or time) column and one number: a value per category. */
|
|
123
|
+
class CategoryValue extends ResultShape {
|
|
124
|
+
static of(columns, rows) {
|
|
125
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
126
|
+
return categories.length === 1 && numbers.length === 1 ? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
127
|
+
}
|
|
128
|
+
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
129
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
130
|
+
|
|
131
|
+
candidates() {
|
|
132
|
+
const { category, number } = this;
|
|
133
|
+
const time = category.kind === 'time';
|
|
134
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
135
|
+
const data = rows.filter(r => r[number.name] != null).map(r => ({ label: label(r[category.name]), value: r[number.name] }));
|
|
136
|
+
if (time) {
|
|
137
|
+
return [
|
|
138
|
+
{ key: 'line', reason: `${number.name} over ${category.name}`, data },
|
|
139
|
+
{ key: 'area', reason: `${number.name} over ${category.name}, filled`, data },
|
|
140
|
+
{ key: 'bar', reason: `${number.name} for each ${category.name}`, data },
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
const options = [{ key: 'bar', reason: `${number.name} for each ${category.name}`, data }];
|
|
144
|
+
if (this.isShareOfWhole(data)) {
|
|
145
|
+
options.push({ key: 'pie', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
146
|
+
options.push({ key: 'donut', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
147
|
+
}
|
|
148
|
+
return options;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shares of a whole make sense only for an additive measure, over all of the rows (not a top N),
|
|
153
|
+
* with no negatives, in few slices.
|
|
154
|
+
*/
|
|
155
|
+
isShareOfWhole(data) {
|
|
156
|
+
return this.complete && this.number.measure === 'additive' && data.length >= 2 && data.length <= CategoryValue.MAX_SLICES
|
|
157
|
+
&& data.every(d => d.value >= 0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
CategoryValue.MAX_SLICES = 8;
|
|
161
|
+
|
|
162
|
+
/** One category (or time) column and several numbers: a series per number. */
|
|
163
|
+
class CategorySeries extends ResultShape {
|
|
164
|
+
static of(columns, rows) {
|
|
165
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
166
|
+
return categories.length === 1 && numbers.length >= 2 ? new CategorySeries(categories[0], numbers, rows) : null;
|
|
167
|
+
}
|
|
168
|
+
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
169
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
170
|
+
get needsSeries() { return true; }
|
|
171
|
+
|
|
172
|
+
candidates() {
|
|
173
|
+
const { category } = this;
|
|
174
|
+
const time = category.kind === 'time';
|
|
175
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
176
|
+
// lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
|
|
177
|
+
// as 0, a figure the data does not hold. So a result with one is not offered as series.
|
|
178
|
+
if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
|
|
179
|
+
const data = this.numbers.map(n => ({
|
|
180
|
+
name: n.name,
|
|
181
|
+
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
|
|
182
|
+
}));
|
|
183
|
+
const names = this.numbers.map(n => n.name).join(', ');
|
|
184
|
+
return time
|
|
185
|
+
? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
|
|
186
|
+
: [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Two category (or time) columns and one number: a value per pair. */
|
|
191
|
+
class TwoCategories extends ResultShape {
|
|
192
|
+
static of(columns, rows) {
|
|
193
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
194
|
+
return categories.length === 2 && numbers.length === 1 ? new TwoCategories(categories, numbers[0], rows) : null;
|
|
195
|
+
}
|
|
196
|
+
constructor(categories, number, rows) {
|
|
197
|
+
super();
|
|
198
|
+
// A time column goes along the x axis, whichever order the columns came in.
|
|
199
|
+
this.across = categories.find(c => c.kind === 'time') ?? categories[0];
|
|
200
|
+
this.down = categories.find(c => c !== this.across);
|
|
201
|
+
this.number = number;
|
|
202
|
+
this.rows = rows;
|
|
203
|
+
}
|
|
204
|
+
get provides() {
|
|
205
|
+
return { x: AXIS_KIND.CATEGORICAL, y: AXIS_KIND.CATEGORICAL, category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
candidates() {
|
|
209
|
+
const { across, down, number } = this;
|
|
210
|
+
const rows = across.kind === 'time' ? inOrder(this.rows, across.name) : this.rows;
|
|
211
|
+
const heat = rows.filter(r => r[number.name] != null)
|
|
212
|
+
.map(r => ({ x: label(r[across.name]), y: label(r[down.name]), value: r[number.name] }));
|
|
213
|
+
const heatmap = { key: 'heatmap', reason: `${number.name} for each ${across.name} and ${down.name}`, data: heat };
|
|
214
|
+
// A series needs a value for every label; where a pair is missing (or null) only the heatmap,
|
|
215
|
+
// which draws just the cells present, shows the data without inventing a 0.
|
|
216
|
+
const labels = [...new Set(rows.map(r => label(r[across.name])))];
|
|
217
|
+
const value = new Map(rows.map(r => [`${label(r[across.name])}\u0000${label(r[down.name])}`, r[number.name]]));
|
|
218
|
+
const names = [...new Set(rows.map(r => label(r[down.name])))];
|
|
219
|
+
if (names.some(name => labels.some(l => value.get(`${l}\u0000${name}`) == null))) return [heatmap];
|
|
220
|
+
const series = names.map(name => ({ name, values: labels.map(l => ({ label: l, value: value.get(`${l}\u0000${name}`) })) }));
|
|
221
|
+
const grouped = { key: 'groupedBar', reason: `${number.name} for each ${across.name}, by ${down.name}`, data: series };
|
|
222
|
+
return across.kind === 'time'
|
|
223
|
+
? [{ key: 'line', reason: `${number.name} over ${across.name}, a line per ${down.name}`, data: series }, heatmap, grouped]
|
|
224
|
+
: [grouped, heatmap];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Shapes in the order they are tried; the first that recognises the columns wins. */
|
|
229
|
+
const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Chart } from '../Chart.js';
|
|
2
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TableChart — rows of values as a table: a header row of column names, then one row per record,
|
|
6
|
+
* numeric columns right-aligned. Drawn with the renderer's primitives like every chart, so it works
|
|
7
|
+
* under SVG and canvas and needs no DOM table. Values are shown exactly (not an axis's "1.20k"):
|
|
8
|
+
* a table is where a reader goes for the actual number.
|
|
9
|
+
*/
|
|
10
|
+
export class TableChart extends Chart {
|
|
11
|
+
static chartType = 'table';
|
|
12
|
+
|
|
13
|
+
/** Layout constants for the rows. */
|
|
14
|
+
static ROW_H = 26;
|
|
15
|
+
static PAD_X = 8;
|
|
16
|
+
static HEADER_FILL = '#eef1f6';
|
|
17
|
+
static STRIPE_FILL = '#f8f9fb';
|
|
18
|
+
static RULE = { color: '#d9dde4', width: 1 };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The columns to show: `options.columns` (names, or `{ key, label?, align? }`), else every key of
|
|
22
|
+
* the first row. A column is right-aligned when every value it holds is a number (or null).
|
|
23
|
+
*/
|
|
24
|
+
static columns(data, given) {
|
|
25
|
+
const specs = (given ?? Object.keys(data[0] ?? {})).map(c => (typeof c === 'string' ? { key: c } : { ...c }));
|
|
26
|
+
return specs.map(c => ({
|
|
27
|
+
key: c.key,
|
|
28
|
+
label: c.label ?? c.key,
|
|
29
|
+
align: c.align ?? (data.length > 0 && data.every(r => r[c.key] == null || typeof r[c.key] === 'number') ? 'end' : 'start'),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The table has no axes, so none of a chart's axis margins: an edge all round, and room for a title. */
|
|
34
|
+
static margins(title) {
|
|
35
|
+
return { left: 8, right: 8, top: title ? 50 : 8, bottom: 8 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The height a table of `rowCount` rows needs — header, rows (at most `maxRows`), the "more rows"
|
|
40
|
+
* line, a title and the edges — so a page can size its container to fit before drawing.
|
|
41
|
+
*/
|
|
42
|
+
static heightFor(rowCount, { title, maxRows = Infinity } = {}) {
|
|
43
|
+
const shown = Math.min(rowCount, maxRows);
|
|
44
|
+
const more = rowCount > shown ? 1 : 0;
|
|
45
|
+
const { top, bottom } = TableChart.margins(title);
|
|
46
|
+
return top + (1 + shown + more) * TableChart.ROW_H + bottom;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
|
|
50
|
+
static cellText(value, nullText = '—') {
|
|
51
|
+
if (value == null) return nullText;
|
|
52
|
+
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
53
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_render() {
|
|
58
|
+
const data = this.data ?? [];
|
|
59
|
+
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
|
|
60
|
+
const { x = box.x, y = box.y, width = box.width, title, maxRows = Infinity, nullText = '—' } = this.options;
|
|
61
|
+
const { ROW_H, PAD_X } = TableChart;
|
|
62
|
+
const columns = TableChart.columns(data, this.options.columns);
|
|
63
|
+
const colW = columns.length > 0 ? width / columns.length : width;
|
|
64
|
+
|
|
65
|
+
new ChartLabels({ title }).draw(this.renderer, { x, y, width, height: box.height });
|
|
66
|
+
|
|
67
|
+
const shown = data.slice(0, maxRows);
|
|
68
|
+
let top = y;
|
|
69
|
+
this.renderer.rect(x, top, width, ROW_H, TableChart.HEADER_FILL, null);
|
|
70
|
+
columns.forEach((c, i) => this._cell(c.label, c, x + i * colW, top, colW, { fontWeight: 'bold', fill: '#333' }));
|
|
71
|
+
top += ROW_H;
|
|
72
|
+
this._rule(x, top, width);
|
|
73
|
+
|
|
74
|
+
shown.forEach((row, r) => {
|
|
75
|
+
if (r % 2 === 1) this.renderer.rect(x, top, width, ROW_H, TableChart.STRIPE_FILL, null);
|
|
76
|
+
columns.forEach((c, i) => this._cell(TableChart.cellText(row[c.key], nullText), c, x + i * colW, top, colW, { fill: '#222' }));
|
|
77
|
+
top += ROW_H;
|
|
78
|
+
this._rule(x, top, width);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (data.length > shown.length) {
|
|
82
|
+
this.renderer.text(`${data.length - shown.length} more row(s) not shown`, x + PAD_X, top + ROW_H / 2, {
|
|
83
|
+
textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_cell(text, column, left, top, colW, attrs) {
|
|
89
|
+
const end = column.align === 'end';
|
|
90
|
+
this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
|
|
91
|
+
textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_rule(x, y, width) {
|
|
96
|
+
this.renderer.path(`M${x},${y} L${x + width},${y}`, 'none', TableChart.RULE);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/index.js
CHANGED
|
@@ -33,6 +33,8 @@ import { LagPlotChart } from './charting/charts/LagPlotChart.js';
|
|
|
33
33
|
import { TreemapChart } from './charting/charts/TreemapChart.js';
|
|
34
34
|
import { SunburstChart } from './charting/charts/SunburstChart.js';
|
|
35
35
|
import { OutlineChart } from './charting/charts/OutlineChart.js';
|
|
36
|
+
import { TableChart } from './charting/charts/TableChart.js';
|
|
37
|
+
import { ChartChooser } from './charting/ChartChooser.js';
|
|
36
38
|
import { RadarChart } from './charting/charts/RadarChart.js';
|
|
37
39
|
import { FunnelChart } from './charting/charts/FunnelChart.js';
|
|
38
40
|
import { WaterfallChart } from './charting/charts/WaterfallChart.js';
|
|
@@ -215,7 +217,7 @@ import {
|
|
|
215
217
|
// separate browser-only entry: import 'renderengine/src/workspace.js' directly.
|
|
216
218
|
|
|
217
219
|
export {
|
|
218
|
-
Mat4, OrbitCamera, CHART_CATALOG, getChartCatalogEntry, chartRegistry,
|
|
220
|
+
Mat4, OrbitCamera, CHART_CATALOG, getChartCatalogEntry, chartRegistry, ChartChooser, TableChart,
|
|
219
221
|
DIAGRAM_CATALOG, getDiagramCatalogEntry, diagramRegistry,
|
|
220
222
|
TREE_MODE_CATALOG, treeModeRegistry, getTreeModeCatalogEntry,
|
|
221
223
|
AXIS_KIND, ENCODING_CHANNEL, COORD_SYSTEM, COLOR_MODE, CAMERA_MODE,
|
|
@@ -1417,6 +1419,24 @@ export class RenderEngine {
|
|
|
1417
1419
|
this._renderChart(new FunnelChart(this.renderer, data, options));
|
|
1418
1420
|
}
|
|
1419
1421
|
|
|
1422
|
+
/**
|
|
1423
|
+
* Renders rows as a table — a header of column names, a row per record, numeric columns
|
|
1424
|
+
* right-aligned, values shown exactly. Drawn with the renderer's primitives, so it works under
|
|
1425
|
+
* SVG and canvas. ChartChooser offers it for any result, last.
|
|
1426
|
+
* @param {Array<object>} data one object per row
|
|
1427
|
+
* @param {object} [options]
|
|
1428
|
+
* @param {Array<string|{key:string, label?:string, align?:'start'|'end'}>} [options.columns] every key of the first row when absent
|
|
1429
|
+
* @param {number} [options.maxRows] rows beyond it are counted, not drawn
|
|
1430
|
+
* @param {string} [options.nullText] what a null or missing value shows ('—')
|
|
1431
|
+
* @param {string} [options.title]
|
|
1432
|
+
*/
|
|
1433
|
+
tableChart(data, options = {}) {
|
|
1434
|
+
if (!this.renderer) return;
|
|
1435
|
+
this._chart3D = false;
|
|
1436
|
+
this._chartState = { type: 'table', data, options };
|
|
1437
|
+
this._renderChart(new TableChart(this.renderer, data, options));
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1420
1440
|
/**
|
|
1421
1441
|
* Renders a waterfall chart — running-total bars, each floating from the
|
|
1422
1442
|
* previous cumulative value to the new one (a "delta" bar), except
|