@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.
- package/README.md +454 -302
- package/dist/renderengine-workspace.umd.js +2203 -113
- package/dist/renderengine.umd.js +2203 -113
- package/documentation/REFERENCE.md +30 -1
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +544 -21
- 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
|
|
27
|
-
|
|
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
|
-
/**
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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()) {
|