@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 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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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