@peter.prib/renderengine 0.13.0 → 0.14.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.
Files changed (44) hide show
  1. package/README.md +454 -302
  2. package/dist/renderengine-workspace.umd.js +2203 -113
  3. package/dist/renderengine.umd.js +2203 -113
  4. package/documentation/REFERENCE.md +30 -1
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Chart3D.js +4 -2
  8. package/src/charting/Hierarchy.js +28 -0
  9. package/src/charting/ask/AskBar.js +388 -0
  10. package/src/charting/ask/AskView.js +400 -0
  11. package/src/charting/charts/AreaChart.js +20 -3
  12. package/src/charting/charts/BarChart3D.js +2 -2
  13. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  14. package/src/charting/charts/BoxPlotChart.js +2 -2
  15. package/src/charting/charts/BubbleChart.js +4 -1
  16. package/src/charting/charts/ChordDiagram.js +4 -4
  17. package/src/charting/charts/DensityChart.js +1 -1
  18. package/src/charting/charts/FunnelChart.js +1 -1
  19. package/src/charting/charts/HeatmapChart.js +4 -1
  20. package/src/charting/charts/HistogramChart.js +2 -2
  21. package/src/charting/charts/IcicleChart.js +47 -0
  22. package/src/charting/charts/LollipopChart.js +104 -0
  23. package/src/charting/charts/MarimekkoChart.js +75 -0
  24. package/src/charting/charts/OutlineChart.js +10 -8
  25. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  26. package/src/charting/charts/ParetoChart.js +92 -0
  27. package/src/charting/charts/PipeChart.js +5 -2
  28. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  29. package/src/charting/charts/RadarChart.js +5 -4
  30. package/src/charting/charts/RadialBarChart.js +2 -2
  31. package/src/charting/charts/SankeyDiagram.js +4 -4
  32. package/src/charting/charts/ScatterChart.js +6 -3
  33. package/src/charting/charts/StackedBarChart.js +82 -11
  34. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  35. package/src/charting/charts/StreamGraphChart.js +8 -0
  36. package/src/charting/charts/SunburstChart.js +2 -2
  37. package/src/charting/charts/TreemapChart.js +4 -4
  38. package/src/charting/charts/ViolinChart.js +2 -2
  39. package/src/charting/charts/WaterfallChart.js +2 -2
  40. package/src/charting/charts/WordCloudChart.js +2 -2
  41. package/src/charting/pivot/PivotChart.js +205 -13
  42. package/src/charting/pivot/PivotGridChart.js +1 -1
  43. package/src/charting/pivot/PivotView.js +544 -21
  44. package/src/index.js +98 -2
@@ -27,9 +27,9 @@ export class ViolinChart extends GroupedDistributionChart {
27
27
  const leftPts = curve.map(p => ({ px: cx - (p.density / maxDensity) * halfWidth, py: this._toY(p.x) }));
28
28
  const path = 'M' + rightPts.map(p => `${p.px},${p.py}`).join(' L')
29
29
  + ' L' + [...leftPts].reverse().map(p => `${p.px},${p.py}`).join(' L') + ' Z';
30
- this.renderer.path(path, color, { color: '#333', width: 1 }, {
30
+ this._datum(this.renderer.path(path, color, { color: '#333', width: 1 }, {
31
31
  tooltip: `${d.label}: n=${d.values.length}`,
32
- });
32
+ }), { label: d.label, series: d.label });
33
33
 
34
34
  const median = Statistics.quantile([...d.values].sort((a, b) => a - b), 0.5);
35
35
  this.renderer.path(`M${cx - halfWidth},${this._toY(median)} L${cx + halfWidth},${this._toY(median)}`, 'none', { color: '#fff', width: 2 });
@@ -58,9 +58,9 @@ export class WaterfallChart extends Chart {
58
58
  const top = toY(Math.max(band.from, band.to));
59
59
  const bottom = toY(Math.min(band.from, band.to));
60
60
  const color = band.isTotal ? totalColor : (band.to >= band.from ? upColor : downColor);
61
- this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
61
+ this._datum(this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
62
62
  tooltip: `${d.label}: ${band.isTotal ? band.to : (d.value >= 0 ? '+' : '') + d.value} (total: ${Axis.formatValue(band.to)})`,
63
- });
63
+ }), { label: d.label });
64
64
  if (i % labelStep === 0) this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
65
65
  });
66
66
  }
@@ -66,10 +66,10 @@ export class WordCloudChart extends Chart {
66
66
  if (!pos) return; // Could not place; skip.
67
67
  placed.push({ x: pos.x, y: pos.y, w: box.w, h: box.h });
68
68
  const color = palette.colorAt(i);
69
- this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
69
+ this._datum(this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
70
70
  textAnchor, fontSize: `${fontSize}px`, fill: color, fontFamily,
71
71
  tooltip: `${d.text}: ${d.value}`,
72
- });
72
+ }), { label: d.text });
73
73
  });
74
74
  }
75
75
 
@@ -23,8 +23,182 @@ export class PivotChart {
23
23
 
24
24
  /** The charts that fit what the grid shows, best first. The grid itself is the table. */
25
25
  choices() {
26
- const { rows, columns, complete } = this.view.chartData();
27
- return this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
26
+ const chartData = this.view.chartData();
27
+ const { rows, columns, complete } = chartData;
28
+ const base = this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
29
+ const seen = new Set(base.map(c => c.key));
30
+ const extra = this._cubeChoices(chartData).filter(c => !seen.has(c.key) && this.chooser.registry.get(c.key));
31
+ return [...base, ...extra];
32
+ }
33
+
34
+ /**
35
+ * Additional chart types suited to a cube's hierarchy, series, and matrix structure beyond the
36
+ * general flat-table defaults in `ChartChooser`.
37
+ */
38
+ _cubeChoices({ rows, columns, complete, hierarchy, rowTotals = null, primaryMeasure = null }) {
39
+ if (!rows || rows.length === 0) return [];
40
+ const categories = columns.filter(c => c.kind !== 'numeric');
41
+ const numbers = columns.filter(c => c.kind === 'numeric');
42
+ if (categories.length !== 1 || numbers.length === 0) return [];
43
+ const category = categories[0];
44
+ const add = (key, reason, data, options) => ({
45
+ key,
46
+ label: this.chooser.registry.get(key)?.label ?? key,
47
+ reason,
48
+ data,
49
+ ...(options ? { options } : {}),
50
+ });
51
+ const out = [];
52
+
53
+ if (numbers.length === 1) {
54
+ const number = numbers[0];
55
+ const points = rows.filter(r => r[number.name] != null).map(r => ({
56
+ label: String(r[category.name]),
57
+ value: r[number.name],
58
+ }));
59
+ if (points.length < 2) return out;
60
+ this._addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, false);
61
+ return out;
62
+ }
63
+
64
+ // Multi-series (2+ numeric series from column-axis members or multiple measures).
65
+ const gaps = rows.some(r => numbers.some(n => r[n.name] == null));
66
+ if (gaps) return out;
67
+
68
+ const names = numbers.map(n => n.name).join(', ');
69
+ const seriesData = numbers.map(n => ({
70
+ name: n.name,
71
+ values: rows.map(r => ({ label: String(r[category.name]), value: r[n.name] })),
72
+ }));
73
+ const allNonNeg = rows.every(r => numbers.every(n => r[n.name] >= 0));
74
+ const additiveNonNeg = allNonNeg && numbers.every(n => n.measure !== 'semi-additive' && n.measure !== 'non-additive');
75
+
76
+ if (additiveNonNeg) {
77
+ out.push(add('stackedBar', `${names} stacked for each ${category.name}`, seriesData));
78
+ out.push(add('marimekko', `${names} share and total across ${category.name}, as a Marimekko mosaic`, seriesData));
79
+ }
80
+ out.push(add('lollipop', `${names} for each ${category.name}, as stems and dots`, seriesData));
81
+ if (allNonNeg && rows.length >= 2) {
82
+ out.push(add('area', `${names} across ${category.name}, filled`, seriesData));
83
+ }
84
+ if (additiveNonNeg && rows.length >= 2) {
85
+ out.push(add('stackedArea', `${names} stacked across ${category.name}`, seriesData));
86
+ out.push(add('streamGraph', `${names} flowing across ${category.name}`, seriesData));
87
+ }
88
+ if (allNonNeg && rows.length >= 2) {
89
+ out.push(add('pipe', `${names} tapering across ${category.name}`, seriesData.map(s => ({
90
+ name: s.name,
91
+ values: s.values.map(v => ({ label: v.label, value: v.value, metric: v.value })),
92
+ }))));
93
+ }
94
+ const heatData = rows.flatMap(r => numbers.map(n => ({
95
+ x: String(r[category.name]),
96
+ y: n.name,
97
+ value: r[n.name],
98
+ })));
99
+ out.push(add('heatmap', `${names} for each ${category.name}, as a colour grid`, heatData));
100
+ if (allNonNeg && rows.length >= 3 && rows.length <= 12) {
101
+ out.push(add('radar', `${names} across ${category.name} axes`, seriesData.map(s => ({
102
+ name: s.name,
103
+ values: s.values.map(v => ({ axis: v.label, value: v.value })),
104
+ }))));
105
+ }
106
+ if (rows.length >= 2) {
107
+ out.push(add('parallelCoordinates', `${names} across parallel axes for each ${category.name}`,
108
+ rows.map(r => ({
109
+ label: String(r[category.name]),
110
+ category: String(r[category.name]),
111
+ ...Object.fromEntries(numbers.map(n => [n.name, r[n.name]])),
112
+ })),
113
+ { dimensions: numbers.map(n => n.name) }));
114
+ if (allNonNeg) {
115
+ out.push(add('populationPyramid', `${numbers[0].name} vs ${numbers[1].name} for each ${category.name}`,
116
+ rows.map(r => ({ label: String(r[category.name]), left: r[numbers[0].name], right: r[numbers[1].name] })),
117
+ { leftLabel: numbers[0].name, rightLabel: numbers[1].name, showValues: true }));
118
+ const flowLinks = rows.flatMap(r => numbers
119
+ .filter(n => r[n.name] > 0)
120
+ .map(n => ({ source: String(r[category.name]), target: n.name, value: r[n.name] })));
121
+ if (flowLinks.length > 0) {
122
+ out.push(add('sankey', `flow from ${category.name} to ${names}`, { links: flowLinks }));
123
+ out.push(add('chord', `chords between ${category.name} and ${names}`, { links: flowLinks }));
124
+ }
125
+ }
126
+ if (rows.length >= 3) {
127
+ const distData = numbers.map(n => ({ label: n.name, values: rows.map(r => r[n.name]) }));
128
+ out.push(add('boxPlot', `distribution of ${category.name} values for each of ${names}`, distData));
129
+ out.push(add('density', `density curve of ${category.name} values for each of ${names}`, distData));
130
+ out.push(add('violin', `violin distribution of ${category.name} values for each of ${names}`, distData));
131
+ }
132
+ out.push(add('scatter', `${numbers[0].name} against ${numbers[1].name} for each ${category.name}`,
133
+ rows.map(r => ({ x: r[numbers[0].name], y: r[numbers[1].name], label: String(r[category.name]) }))));
134
+ if (numbers.length >= 3 || (rowTotals && rowTotals.length === rows.length)) {
135
+ out.push(add('bubble', `${numbers[0].name} and ${numbers[1].name} sized by ${numbers[2]?.name ?? (primaryMeasure?.name || 'Total')}`,
136
+ rows.map((r, idx) => ({
137
+ x: r[numbers[0].name],
138
+ y: r[numbers[1].name],
139
+ z: Math.max(1, numbers[2] ? r[numbers[2].name] : rowTotals[idx].value),
140
+ label: String(r[category.name]),
141
+ }))));
142
+ }
143
+ }
144
+ if (allNonNeg) {
145
+ out.push(add('bar3DOrbit', `${names} for each ${category.name}, in 3D`,
146
+ rows.flatMap(r => numbers.map(n => ({ x: String(r[category.name]), z: n.name, value: r[n.name] })))));
147
+ }
148
+ if (additiveNonNeg) {
149
+ out.push(add('stackedBar3DOrbit', `${names} stacked for each ${category.name}, in 3D`,
150
+ rows.map(r => ({ x: String(r[category.name]), z: category.name, values: numbers.map(n => r[n.name]) })),
151
+ { seriesLabels: numbers.map(n => n.name) }));
152
+ }
153
+ if (rowTotals && rowTotals.length >= 2 && primaryMeasure) {
154
+ this._addSingleSeriesChoices(out, add, category, primaryMeasure, rowTotals, complete, hierarchy, true);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ _addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, includeBase = false) {
160
+ const nonNeg = points.every(d => d.value >= 0);
161
+ if (includeBase) {
162
+ out.push(add('bar', `total ${number.name} for each ${category.name}`, points));
163
+ if (nonNeg && complete && number.measure === 'additive' && points.length <= 8) {
164
+ out.push(add('pie', `share of total ${number.name} across ${category.name}`, points));
165
+ out.push(add('donut', `share of total ${number.name} across ${category.name}, as a ring`, points));
166
+ }
167
+ }
168
+ if (hierarchy) {
169
+ out.push(add('treemap', `${number.name} across the ${category.name} hierarchy`, hierarchy.tree));
170
+ out.push(add('sunburst', `${number.name} across the ${category.name} hierarchy, in rings`, hierarchy.tree));
171
+ out.push(add('icicle', `${number.name} across the ${category.name} hierarchy, as an icicle partition`, hierarchy.tree));
172
+ out.push(add('outline', `${number.name} across the ${category.name} hierarchy, as an outline`, hierarchy.tree));
173
+ }
174
+ if (number.measure === 'additive') {
175
+ out.push(add('waterfall', `how each ${category.name} adds to ${number.name}`, points));
176
+ if (nonNeg && complete) {
177
+ out.push(add('pareto', `cumulative share of ${number.name} across ${category.name}`, points));
178
+ }
179
+ }
180
+ if (!includeBase) {
181
+ out.push(add('lollipop', `${number.name} for each ${category.name}, as stems and dots`, points));
182
+ if (nonNeg && points.length >= 2) {
183
+ out.push(add('pipe', `${number.name} tapering across ${category.name}`,
184
+ points.map(d => ({ label: d.label, value: d.value, metric: d.value }))));
185
+ }
186
+ }
187
+ if (nonNeg && points.length <= 12) {
188
+ out.push(add('funnel', `${number.name} across ${category.name} stages`, points));
189
+ out.push(add('radialBar', `${number.name} for each ${category.name}, around a circle`, points));
190
+ if (!includeBase && points.length >= 3) {
191
+ out.push(add('radar', `${number.name} across ${category.name} axes`, points.map(d => ({ axis: d.label, value: d.value }))));
192
+ }
193
+ out.push(add('wordCloud', `${category.name} sized by ${number.name}`, points.map(d => ({ text: d.label, value: d.value }))));
194
+ out.push(add('bar3D', `${number.name} for each ${category.name}, in 3D`, points));
195
+ }
196
+ if (points.length >= 3) {
197
+ const sample = points.map(d => d.value);
198
+ out.push(add('histogram', `frequency distribution of ${number.name} across ${category.name}`, sample));
199
+ out.push(add('qq', `normal Q-Q plot of ${number.name} across ${category.name}`, sample));
200
+ out.push(add('lag', `lag-1 autocorrelation of ${number.name} across ${category.name}`, sample));
201
+ }
28
202
  }
29
203
 
30
204
  /** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
@@ -33,18 +207,36 @@ export class PivotChart {
33
207
  return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
34
208
  }
35
209
 
36
- /** The chart's menu: "Chart as" each chart that fits, the one showing ticked. */
37
- menu(redraw) {
210
+ /** Sets the preferred chart type key (e.g. `'stackedBar'`, `'line'`, `'bar'`, `'pareto'`) and redraws if attached. */
211
+ setType(type) {
212
+ this.type = type;
213
+ this.redraw?.();
214
+ return this.chosen();
215
+ }
216
+
217
+ /** The chart's menu: "Chart as" each chart that fits (plus quick top-level items), the one showing ticked. */
218
+ menu(redraw = () => this.redraw?.()) {
38
219
  const current = this.chosen()?.key;
39
- return [{
40
- label: 'Chart as',
41
- items: this.choices().map(c => ({
42
- label: `${c.label}${c.key === current ? ' ✓' : ''}`,
43
- action: () => { this.type = c.key; redraw(); },
44
- })),
45
- }];
220
+ const all = this.choices();
221
+ const toItem = (c) => ({
222
+ label: `${c.label}${c.key === current ? ' ✓' : ''}`,
223
+ action: () => { this.type = c.key; redraw(); },
224
+ });
225
+ const quickKeys = new Set([
226
+ 'groupedBar', 'stackedBar', 'bar', 'line', 'area', 'stackedArea',
227
+ 'pareto', 'waterfall', 'marimekko', 'lollipop', 'heatmap', 'radar',
228
+ 'treemap', 'sunburst', 'icicle', 'sankey', 'pie', 'donut',
229
+ ]);
230
+ const quickItems = all.filter(c => quickKeys.has(c.key)).map(toItem);
231
+ return [
232
+ {
233
+ label: 'Chart as',
234
+ items: all.map(toItem),
235
+ },
236
+ ...(quickItems.length > 0 ? [{ separator: true }, ...quickItems] : []),
237
+ ];
46
238
  }
47
239
 
48
- /** A click on a charted value: drill the grid at its member. */
49
- click(datum) { return this.view.drillFrom(datum.label); }
240
+ /** A click on a charted value: drill the grid at its member (or its series' column member). */
241
+ click(datum) { return this.view.drillFrom(datum.label, datum.series ?? null); }
50
242
  }
@@ -110,7 +110,7 @@ export class PivotGridChart extends TableViewChart {
110
110
  const lit = view.cellFill(node, c, cuts);
111
111
  if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
112
112
  const row = view.cell(node, c.node);
113
- const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
113
+ const text = view.cellText ? view.cellText(node, c, nullText) : (row === null ? '' : view.valueText(c.measure, row[c.measure], nullText));
114
114
  const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
115
115
  // A cell with a value opens its menu (drill-through) where the host gives one.
116
116
  if (row !== null && view.cellHasMenu()) {