@peter.prib/renderengine 0.10.0 → 0.12.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/dist/renderengine-workspace.umd.js +4093 -2910
- package/dist/renderengine.umd.js +4093 -2910
- package/documentation/REFERENCE.md +3230 -3077
- package/package.json +2 -2
- package/src/charting/Chart.js +25 -0
- package/src/charting/ChartChooser.js +5 -4
- package/src/charting/Legend.js +16 -0
- package/src/charting/charts/BarChart.js +1 -1
- package/src/charting/charts/GroupedBarChart.js +38 -16
- package/src/charting/charts/LineChart.js +9 -8
- package/src/charting/charts/PieChart.js +2 -2
- package/src/charting/charts/TableChart.js +16 -3
- package/src/charting/pivot/PivotChart.js +50 -0
- package/src/charting/pivot/PivotGridChart.js +191 -0
- package/src/charting/pivot/PivotView.js +731 -0
- package/src/charting/table/TableView.js +74 -14
- package/src/charting/table/TableViewChart.js +4 -2
- package/src/index.js +2886 -2807
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"archimate": "node scripts/open.mjs examples/archimate.html",
|
|
29
29
|
"diagram": "node scripts/open.mjs examples/diagram-editor.html",
|
|
30
30
|
"datamodel": "node scripts/open.mjs examples/data-model.html",
|
|
31
|
-
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
31
|
+
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
32
32
|
"build": "rollup -c",
|
|
33
33
|
"prepublishOnly": "npm test && npm run build",
|
|
34
34
|
"screenshots": "node scripts/screenshots/screenshots.mjs"
|
package/src/charting/Chart.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* Subclasses implement `_render()` and are instantiated by RenderEngine's
|
|
5
5
|
* chart methods. Keeping each chart in its own class removes the giant
|
|
6
6
|
* RenderEngine God class one chart at a time.
|
|
7
|
+
*
|
|
8
|
+
* A chart that draws one mark per value (a bar, a slice, a point) records it with `_datum()`; when
|
|
9
|
+
* the options carry `onDatumClick(datum, event)` and the renderer's shapes take clicks (SVG), each
|
|
10
|
+
* such mark answers a click with its datum, `{ label, series }` — how a host drills from a chart.
|
|
7
11
|
*/
|
|
8
12
|
export class Chart {
|
|
9
13
|
/** Registry key / catalog key for this chart type. Subclasses override. */
|
|
@@ -16,13 +20,34 @@ export class Chart {
|
|
|
16
20
|
this.renderer = renderer;
|
|
17
21
|
this.data = data;
|
|
18
22
|
this.options = options;
|
|
23
|
+
/** The marks drawn for values, `{ shape, datum }`, as the last render() drew them. */
|
|
24
|
+
this.datums = [];
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/** Render the chart. Subclasses implement `_render()`. */
|
|
22
28
|
render() {
|
|
23
29
|
if (!this.renderer) return;
|
|
24
30
|
this.renderer.clear();
|
|
31
|
+
this.datums = [];
|
|
25
32
|
this._render();
|
|
33
|
+
this._wireDatums();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Records that `shape` (what the renderer returned) shows one value, `datum` = { label, series? }. */
|
|
37
|
+
_datum(shape, datum) {
|
|
38
|
+
this.datums.push({ shape, datum: { series: null, ...datum } });
|
|
39
|
+
return shape;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Gives each recorded mark the host's onDatumClick, where the renderer's shapes take clicks. */
|
|
43
|
+
_wireDatums() {
|
|
44
|
+
const handler = this.options?.onDatumClick;
|
|
45
|
+
if (typeof handler !== 'function' || this.renderer.supportsSceneGraph !== true) return;
|
|
46
|
+
for (const { shape, datum } of this.datums) {
|
|
47
|
+
if (typeof shape?.addEventListener !== 'function') continue;
|
|
48
|
+
shape.addEventListener('click', (event) => { event.stopPropagation?.(); handler(datum, event); });
|
|
49
|
+
if (shape.style) shape.style.cursor = 'pointer';
|
|
50
|
+
}
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
/** Subclasses implement the actual drawing here. */
|
|
@@ -186,14 +186,15 @@ class CategorySeries extends ResultShape {
|
|
|
186
186
|
const { category } = this;
|
|
187
187
|
const time = category.kind === 'time';
|
|
188
188
|
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
189
|
-
//
|
|
190
|
-
// as 0, a figure the data does not hold
|
|
191
|
-
|
|
189
|
+
// groupedBarChart leaves a gap for a missing value; lineChart has none (a null would have to be
|
|
190
|
+
// drawn as 0, a figure the data does not hold), so a result with one is offered as bars only.
|
|
191
|
+
const gaps = rows.some(r => this.numbers.some(n => r[n.name] == null));
|
|
192
192
|
const data = this.numbers.map(n => ({
|
|
193
193
|
name: n.name,
|
|
194
|
-
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
|
|
194
|
+
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] ?? null })),
|
|
195
195
|
}));
|
|
196
196
|
const names = this.numbers.map(n => n.name).join(', ');
|
|
197
|
+
if (gaps) return [{ key: 'groupedBar', reason: `${names} for each ${category.name} (a gap where there is no value)`, data }];
|
|
197
198
|
return time
|
|
198
199
|
? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
|
|
199
200
|
: [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
|
package/src/charting/Legend.js
CHANGED
|
@@ -36,6 +36,19 @@ export class Legend {
|
|
|
36
36
|
* @param {number} drawingWidth - The renderer's width
|
|
37
37
|
* @param {object} options - As the constructor's, without x and y
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* The plot width that leaves room for this legend beside the plot, so it never covers the data:
|
|
41
|
+
* narrower by as much as the legend would run off the drawing — unless that would leave the plot
|
|
42
|
+
* under MIN_PLOT_SHARE of its width; then the plot keeps its width and beside() insets the legend.
|
|
43
|
+
*/
|
|
44
|
+
static plotWidthBeside(box, drawingWidth, options) {
|
|
45
|
+
const { width } = new Legend({ ...options, x: 0, y: 0 }).size();
|
|
46
|
+
const over = box.x + box.width + 20 + width - drawingWidth;
|
|
47
|
+
if (!(drawingWidth > 0) || over <= 0) return box.width;
|
|
48
|
+
const narrower = box.width - over;
|
|
49
|
+
return narrower >= box.width * Legend.MIN_PLOT_SHARE ? narrower : box.width;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
static beside(box, drawingWidth, options) {
|
|
40
53
|
const legend = new Legend({ ...options, x: 0, y: 0 });
|
|
41
54
|
const { width } = legend.size();
|
|
@@ -99,3 +112,6 @@ export class Legend {
|
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
}
|
|
115
|
+
|
|
116
|
+
/** The least share of its width a plot keeps when it narrows for a legend beside it. */
|
|
117
|
+
Legend.MIN_PLOT_SHARE = 0.65;
|
|
@@ -43,7 +43,7 @@ export class BarChart extends BarChartBase {
|
|
|
43
43
|
const barX = x + i * (bandWidth + margin);
|
|
44
44
|
const barY = y + height - barHeight;
|
|
45
45
|
|
|
46
|
-
this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` });
|
|
46
|
+
this._datum(this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
|
|
47
47
|
this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
|
|
48
48
|
if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
49
49
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { Axis } from '../Axis.js';
|
|
1
2
|
import { Legend } from '../Legend.js';
|
|
2
3
|
import { BarChartBase } from './BarChartBase.js';
|
|
4
|
+
import { TableChart } from './TableChart.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* GroupedBarChart — vertical grouped (multi-set) bar chart.
|
|
@@ -7,7 +9,8 @@ import { BarChartBase } from './BarChartBase.js';
|
|
|
7
9
|
* Data shape:
|
|
8
10
|
* Array<{name: string, values: Array<{label: string, value: number}>}>
|
|
9
11
|
*
|
|
10
|
-
* Each category (`label`) gets a cluster of bars side-by-side, one per series.
|
|
12
|
+
* Each category (`label`) gets a cluster of bars side-by-side, one per series. A value a series does
|
|
13
|
+
* not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero.
|
|
11
14
|
*/
|
|
12
15
|
export class GroupedBarChart extends BarChartBase {
|
|
13
16
|
static chartType = 'groupedBar';
|
|
@@ -15,7 +18,7 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
15
18
|
_computeMaxValue() {
|
|
16
19
|
const series = this.data;
|
|
17
20
|
const rawMax = Math.max(
|
|
18
|
-
...series.flatMap(s => s.values.map(v => v.value)),
|
|
21
|
+
...series.flatMap(s => s.values.map(v => v.value).filter(v => v != null)),
|
|
19
22
|
1,
|
|
20
23
|
);
|
|
21
24
|
return this._drawYAxis(rawMax);
|
|
@@ -32,7 +35,7 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
32
35
|
const labels = [...new Set(series.flatMap(s => s.values.map(v => v.label)))];
|
|
33
36
|
const categoryCount = labels.length;
|
|
34
37
|
|
|
35
|
-
//
|
|
38
|
+
// A value lookup per series; a label a series lacks is a gap.
|
|
36
39
|
const valueBySeriesLabel = series.map(s => {
|
|
37
40
|
const map = new Map();
|
|
38
41
|
for (const v of s.values) map.set(v.label, v.value);
|
|
@@ -47,26 +50,50 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
47
50
|
const finalBarWidth = barWidth - barGap;
|
|
48
51
|
|
|
49
52
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
53
|
+
// Labels that would overlap: each cut to its cluster ("…") while a cluster has room for a few
|
|
54
|
+
// characters, so every cluster stays named; past that, every nth label, as BarChart does.
|
|
55
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
|
|
56
|
+
const room = clusterWidth - 4;
|
|
57
|
+
const cut = room >= measure('x'.repeat(GroupedBarChart.MIN_LABEL_CHARS));
|
|
58
|
+
const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
|
|
50
59
|
|
|
51
60
|
labels.forEach((label, categoryIndex) => {
|
|
52
61
|
const clusterX = x + categoryIndex * clusterWidth + groupGap / 2;
|
|
53
62
|
|
|
54
63
|
series.forEach((seriesData, seriesIndex) => {
|
|
55
|
-
const value = valueBySeriesLabel[seriesIndex].get(label)
|
|
64
|
+
const value = valueBySeriesLabel[seriesIndex].get(label);
|
|
65
|
+
if (value == null) return;
|
|
56
66
|
const barHeight = (value / maxValue) * height;
|
|
57
67
|
const barX = clusterX + seriesIndex * barWidth + barGap / 2;
|
|
58
68
|
const barY = y + height - barHeight;
|
|
59
69
|
const color = this._palette.colorAt(seriesIndex);
|
|
60
70
|
|
|
61
|
-
this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
|
|
71
|
+
this._datum(this.renderer.rect(barX, barY, finalBarWidth, barHeight, color, null, {
|
|
62
72
|
tooltip: `${seriesData.name} — ${label}: ${value}`,
|
|
63
|
-
});
|
|
73
|
+
}), { label, series: seriesData.name });
|
|
64
74
|
});
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
if (categoryIndex % labelStep === 0) {
|
|
77
|
+
this.renderer.text(cut ? TableChart.fit(String(label), room, measure) : label, clusterX + availableWidth / 2, y + height + 20, { textAnchor });
|
|
78
|
+
}
|
|
67
79
|
});
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
/** With a legend, the plot leaves room for it beside, so it does not cover the last bars. */
|
|
83
|
+
_setup() {
|
|
84
|
+
super._setup();
|
|
85
|
+
if (this.options.legend && this.options.width === undefined) {
|
|
86
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_legendOptions() {
|
|
91
|
+
return {
|
|
92
|
+
items: this.data.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
|
|
93
|
+
itemHeight: 20, markerSize: 10,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
_afterBars() {
|
|
71
98
|
const series = this.data;
|
|
72
99
|
const options = this.options;
|
|
@@ -74,15 +101,10 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
74
101
|
const { legend: showLegend = false } = options;
|
|
75
102
|
|
|
76
103
|
if (showLegend) {
|
|
77
|
-
|
|
78
|
-
label: s.name,
|
|
79
|
-
color: this._palette.colorAt(i),
|
|
80
|
-
dataIndex: i,
|
|
81
|
-
}));
|
|
82
|
-
Legend.beside({ x, y, width }, this.renderer.width, {
|
|
83
|
-
items: legendItems,
|
|
84
|
-
itemHeight: 20, markerSize: 10,
|
|
85
|
-
}).draw(this.renderer);
|
|
104
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
107
|
}
|
|
108
|
+
|
|
109
|
+
/** The fewest characters a cut label keeps; clusters narrower than that thin their labels instead. */
|
|
110
|
+
GroupedBarChart.MIN_LABEL_CHARS = 6;
|
|
@@ -17,15 +17,19 @@ export class LineChart extends Chart {
|
|
|
17
17
|
const options = this.options;
|
|
18
18
|
const box = ChartBox.fromRenderer(this.renderer);
|
|
19
19
|
const {
|
|
20
|
-
x = box.x, y = box.y,
|
|
20
|
+
x = box.x, y = box.y, height = box.height,
|
|
21
21
|
lineColor = 'steelblue', pointColor = '#fff', title, xAxisLabel, yAxisLabel,
|
|
22
22
|
colors, zAxis = false, legend: showLegend = false, depth: zDepth = 20,
|
|
23
23
|
} = options;
|
|
24
|
-
const plotBox = { x, y, width, height };
|
|
25
24
|
|
|
26
25
|
// Check if data is multiline (array of series) or single line
|
|
27
26
|
const isMultiline = Array.isArray(data[0]?.values);
|
|
28
27
|
const series = isMultiline ? data : [{ name: 'default', values: data }];
|
|
28
|
+
const legendOptions = { items: series.map(s => ({ name: s.name })), itemHeight: 20, markerSize: 12 };
|
|
29
|
+
// With a legend, the plot leaves room for it beside, so it does not cover the last points.
|
|
30
|
+
const width = options.width ?? (isMultiline && showLegend
|
|
31
|
+
? Legend.plotWidthBeside({ x, width: box.width }, this.renderer.width, legendOptions) : box.width);
|
|
32
|
+
const plotBox = { x, y, width, height };
|
|
29
33
|
|
|
30
34
|
// Determine max value across all series
|
|
31
35
|
const maxValue = Math.max(...series.flatMap(s =>
|
|
@@ -82,13 +86,13 @@ export class LineChart extends Chart {
|
|
|
82
86
|
const pointY = y + height - (d.value / maxValue) * height - seriesDepth;
|
|
83
87
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
84
88
|
|
|
85
|
-
this.renderer.circle(
|
|
89
|
+
this._datum(this.renderer.circle(
|
|
86
90
|
pointX, pointY,
|
|
87
91
|
3,
|
|
88
92
|
seriesColor,
|
|
89
93
|
{ color: pointColor, width: 1.5 },
|
|
90
94
|
{ tooltip: `${isMultiline ? seriesData.name + ': ' : ''}${d.label}: ${d.value}` }
|
|
91
|
-
);
|
|
95
|
+
), { label: d.label, series: isMultiline ? seriesData.name : null });
|
|
92
96
|
|
|
93
97
|
const errMinus = d.errorMinus != null ? d.errorMinus : d.error != null ? d.error : null;
|
|
94
98
|
const errPlus = d.errorPlus != null ? d.errorPlus : d.error != null ? d.error : null;
|
|
@@ -118,10 +122,7 @@ export class LineChart extends Chart {
|
|
|
118
122
|
// Draw legend for multiline
|
|
119
123
|
if (isMultiline && showLegend) {
|
|
120
124
|
const legendItems = series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) }));
|
|
121
|
-
Legend.beside({ x, y, width }, this.renderer.width, {
|
|
122
|
-
items: legendItems,
|
|
123
|
-
itemHeight: 20, markerSize: 12,
|
|
124
|
-
}).draw(this.renderer);
|
|
125
|
+
Legend.beside({ x, y, width }, this.renderer.width, { ...legendOptions, items: legendItems }).draw(this.renderer);
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
}
|
|
@@ -33,9 +33,9 @@ export class PieChart extends Chart {
|
|
|
33
33
|
const slice = (d.value / total) * Math.PI * 2;
|
|
34
34
|
const color = palette.colorAt(i);
|
|
35
35
|
const path = ShapePaths.donutSlicePath(cx, cy, radius, rInner, angle, angle + slice);
|
|
36
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
36
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
37
37
|
tooltip: `${d.label}: ${d.value} (${(d.value / total * 100).toFixed(1)}%)`,
|
|
38
|
-
});
|
|
38
|
+
}), { label: d.label });
|
|
39
39
|
angle += slice;
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -85,11 +85,24 @@ export class TableChart extends Chart {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* `text` cut to fit `room` px, ending in "…" when cut, so a long value never draws over the next
|
|
90
|
+
* column. `measure(s)` gives a string's width; the whole text comes back when it fits.
|
|
91
|
+
*/
|
|
92
|
+
static fit(text, room, measure) {
|
|
93
|
+
if (measure(text) <= room) return text;
|
|
94
|
+
let keep = text.length - 1;
|
|
95
|
+
while (keep > 0 && measure(text.slice(0, keep) + '…') > room) keep--;
|
|
96
|
+
return keep > 0 ? text.slice(0, keep) + '…' : '';
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
_cell(text, column, left, top, colW, attrs) {
|
|
89
100
|
const end = column.align === 'end';
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
const style = { textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs };
|
|
102
|
+
const font = { fontSize: style.fontSize, fontWeight: style.fontWeight };
|
|
103
|
+
const measure = (s) => this.renderer.measureText?.(s, font) ?? s.length * (parseFloat(font.fontSize) || 12) * 0.55;
|
|
104
|
+
const shown = TableChart.fit(text, colW - 2 * TableChart.PAD_X, measure);
|
|
105
|
+
return this.renderer.text(shown, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, style);
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
_rule(x, y, width) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ChartChooser } from '../ChartChooser.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PivotChart — a chart linked to a pivot grid (Alphablox's ChartBlox): it charts what the grid shows
|
|
5
|
+
* (PivotView.chartData: the members not broken down further, no totals), follows every change to the
|
|
6
|
+
* grid, and a click on a bar, point or slice drills the grid there (PivotView.drillFrom: expand a
|
|
7
|
+
* collapsed member, or ask the host to drill a level down). Which chart is ChartChooser's, from the
|
|
8
|
+
* charts that fit; the chart's own menu changes it. Kept apart from drawing, as PivotView is;
|
|
9
|
+
* RenderEngine.pivotChart draws it.
|
|
10
|
+
*/
|
|
11
|
+
export class PivotChart {
|
|
12
|
+
/**
|
|
13
|
+
* @param {PivotView} view the grid's state
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {string} [options.type] a catalog key to prefer while it fits (else ChartChooser's best)
|
|
16
|
+
* @param {ChartChooser} [options.chooser]
|
|
17
|
+
*/
|
|
18
|
+
constructor(view, { type = null, chooser = null } = {}) {
|
|
19
|
+
this.view = view;
|
|
20
|
+
this.type = type;
|
|
21
|
+
this.chooser = chooser ?? new ChartChooser();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The charts that fit what the grid shows, best first. The grid itself is the table. */
|
|
25
|
+
choices() {
|
|
26
|
+
const { rows, columns, complete } = this.view.chartData();
|
|
27
|
+
return this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
|
|
31
|
+
chosen() {
|
|
32
|
+
const choices = this.choices();
|
|
33
|
+
return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The chart's menu: "Chart as" each chart that fits, the one showing ticked. */
|
|
37
|
+
menu(redraw) {
|
|
38
|
+
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
|
+
}];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A click on a charted value: drill the grid at its member. */
|
|
49
|
+
click(datum) { return this.view.drillFrom(datum.label); }
|
|
50
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { TableChart } from '../charts/TableChart.js';
|
|
2
|
+
import { TableViewChart } from '../table/TableViewChart.js';
|
|
3
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
4
|
+
import { PivotView } from './PivotView.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PivotGridChart — a pivot grid drawn from a PivotView's state, whose parts answer clicks: the
|
|
8
|
+
* column-axis members across the top (spanning the members beneath them), the row-axis members down
|
|
9
|
+
* the side (indented by level), and the measures in the cells. A member's ▸ / ▾ collapses or expands
|
|
10
|
+
* it; its label opens its menu (expand, collapse, drill down or up); the corner ⋮ opens the grid's
|
|
11
|
+
* menu (swap the axes, expand all, copy). Each change goes through the view, whose `onChange` redraws.
|
|
12
|
+
* With the view's `onLayout`, a layout strip above the grid shows the rows, the columns, the page
|
|
13
|
+
* filters and the dimensions that may be added, each a chip whose menu moves, filters or removes it.
|
|
14
|
+
*
|
|
15
|
+
* It shares the interactive table's drawing and click plumbing (TableViewChart). Interaction needs a
|
|
16
|
+
* renderer whose shapes are DOM elements (SVG); anywhere else the same grid is drawn without clicks.
|
|
17
|
+
*/
|
|
18
|
+
export class PivotGridChart extends TableViewChart {
|
|
19
|
+
static INDENT = 14;
|
|
20
|
+
/** A value column is never narrower: past it the grid is drawn wider than its container, which scrolls. */
|
|
21
|
+
static MIN_COL_W = 88;
|
|
22
|
+
/** What a container that scrolls sideways adds for its scrollbar (and its border). */
|
|
23
|
+
static SCROLLBAR_H = 20;
|
|
24
|
+
static TOTAL_FILL = '#eef2f8';
|
|
25
|
+
static EXPANDED = '▾';
|
|
26
|
+
static COLLAPSED = '▸';
|
|
27
|
+
|
|
28
|
+
/** Lines the layout strip takes, when the view's layout is the host's to change. */
|
|
29
|
+
static layoutLines(view) { return view.onLayout ? 2 : 0; }
|
|
30
|
+
|
|
31
|
+
/** The height this draws: title, layout strip, the column headers' lines, a line per row, and a note if any. */
|
|
32
|
+
static heightFor(view, { title } = {}) {
|
|
33
|
+
const { top, bottom } = TableChart.margins(title);
|
|
34
|
+
const notes = (view.unplaced > 0 ? 1 : 0) + (view.lights ? 1 : 0);
|
|
35
|
+
const lines = PivotGridChart.layoutLines(view) + view.columnHeaders().length + view.visibleRows().length + notes;
|
|
36
|
+
return top + lines * TableChart.ROW_H + bottom;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The row headers' width: room for the deepest indent and a label, within a third of the grid. */
|
|
40
|
+
static rowHeaderWidth(view, width) {
|
|
41
|
+
const depth = Math.max(0, view.rowAxis.length - 1);
|
|
42
|
+
return Math.min(Math.max(120, 110 + depth * PivotGridChart.INDENT), 240, Math.max(90, width / 3));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The width this draws in a container `available` wide: that width, or wider when the value
|
|
47
|
+
* columns would otherwise be narrower than MIN_COL_W (a value cut short would mislead).
|
|
48
|
+
*/
|
|
49
|
+
static widthFor(view, available) {
|
|
50
|
+
const { left, right } = TableChart.margins();
|
|
51
|
+
const columns = view.visibleColumns().length;
|
|
52
|
+
let width = available;
|
|
53
|
+
for (let pass = 0; pass < 2; pass++) { // the side's width depends on the total; twice settles it
|
|
54
|
+
width = Math.max(available, left + right + PivotGridChart.rowHeaderWidth(view, width) + columns * PivotGridChart.MIN_COL_W);
|
|
55
|
+
}
|
|
56
|
+
return width;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_render() {
|
|
60
|
+
const { view } = this;
|
|
61
|
+
const { title, nullText = '—' } = this.options;
|
|
62
|
+
const { ROW_H, PAD_X } = TableChart;
|
|
63
|
+
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(title));
|
|
64
|
+
const interactive = this.renderer.supportsSceneGraph === true;
|
|
65
|
+
const lines = view.columnHeaders();
|
|
66
|
+
const columns = view.visibleColumns();
|
|
67
|
+
const sideW = PivotGridChart.rowHeaderWidth(view, box.width);
|
|
68
|
+
const colW = Math.max(PivotGridChart.MIN_COL_W, columns.length > 0 ? (box.width - sideW) / columns.length : box.width - sideW);
|
|
69
|
+
const x0 = box.x + sideW;
|
|
70
|
+
const end = { align: 'end' };
|
|
71
|
+
|
|
72
|
+
new ChartLabels({ title }).draw(this.renderer, { x: box.x, y: box.y, width: box.width, height: box.height });
|
|
73
|
+
|
|
74
|
+
// The corner: the grid's menu, and what each axis holds.
|
|
75
|
+
let top = box.y;
|
|
76
|
+
if (view.onLayout) top = this._layoutStrip(box, top, interactive);
|
|
77
|
+
const corner = this.renderer.rect(box.x, top, sideW, lines.length * ROW_H, TableChart.HEADER_FILL, null);
|
|
78
|
+
const glyph = this._text(TableViewChart.TABLE_GLYPH, box.x + PAD_X + 2, top, { textAnchor: 'middle', fontWeight: 'bold', fill: '#555' });
|
|
79
|
+
this._on(interactive, [corner, glyph], (e) => this.showMenu(e.clientX, e.clientY, view.gridMenu()), 'Grid menu');
|
|
80
|
+
if (view.columnAxis.length > 0 && lines.length > 1) {
|
|
81
|
+
this._cell(view.columnAxis.join(' › '), end, box.x + 16, top, sideW - 16, { fill: '#666', fontSize: '11px' });
|
|
82
|
+
}
|
|
83
|
+
if (view.rowAxis.length > 0) {
|
|
84
|
+
this._cell(view.rowAxis.join(' › '), {}, box.x + 12, top + (lines.length - 1) * ROW_H, sideW - 12, { fontWeight: 'bold', fill: '#333' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The column headers: a line per level, a member spanning the columns beneath it.
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
let left = x0;
|
|
90
|
+
for (const cell of line) {
|
|
91
|
+
const w = cell.span * colW;
|
|
92
|
+
this.renderer.rect(left, top, w, ROW_H, TableChart.HEADER_FILL, null);
|
|
93
|
+
this._member('columns', cell.node, cell.text, left, top, w, interactive, { fontWeight: 'bold', fill: '#333' });
|
|
94
|
+
left += w;
|
|
95
|
+
}
|
|
96
|
+
top += ROW_H;
|
|
97
|
+
this._rule(box.x, top, box.width);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The rows: a member (indented, with its ▸ / ▾) and its cells; members with members below are totals.
|
|
101
|
+
const cuts = view.lights && view.lights !== 'rules' ? view.thirds() : null; // once per drawing, not per cell
|
|
102
|
+
view.visibleRows().forEach(({ node, depth }, r) => {
|
|
103
|
+
const total = node.children.size > 0 || node.path.length === 0;
|
|
104
|
+
if (total) this.renderer.rect(box.x, top, box.width, ROW_H, PivotGridChart.TOTAL_FILL, null);
|
|
105
|
+
else if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
|
|
106
|
+
const indent = node.path.length === 0 ? 0 : depth * PivotGridChart.INDENT;
|
|
107
|
+
const weight = total ? { fontWeight: 'bold' } : {};
|
|
108
|
+
this._member('rows', node.path.length === 0 ? null : node, PivotView.label(node), box.x + indent, top, sideW - indent, interactive, { fill: '#222', ...weight });
|
|
109
|
+
columns.forEach((c, i) => {
|
|
110
|
+
const lit = view.cellFill(node, c, cuts);
|
|
111
|
+
if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
|
|
112
|
+
const row = view.cell(node, c.node);
|
|
113
|
+
const text = row === null ? '' : TableChart.cellText(row[c.measure], nullText);
|
|
114
|
+
const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
|
|
115
|
+
// A cell with a value opens its menu (drill-through) where the host gives one.
|
|
116
|
+
if (row !== null && view.cellHasMenu()) {
|
|
117
|
+
const hit = this.renderer.rect(x0 + i * colW, top, colW, ROW_H, 'transparent', null);
|
|
118
|
+
this._on(interactive, [hit, shown], (e) => this.showMenu(e.clientX, e.clientY, view.cellMenu(node, c)), 'Cell menu');
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
top += ROW_H;
|
|
122
|
+
this._rule(box.x, top, box.width);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (view.unplaced > 0) {
|
|
126
|
+
this._text(`${view.unplaced} row(s) total an outer level but not an inner one, and are not shown`, box.x + PAD_X, top,
|
|
127
|
+
{ textAnchor: 'start', fill: '#888', fontSize: '11px' });
|
|
128
|
+
top += ROW_H;
|
|
129
|
+
}
|
|
130
|
+
const legend = view.lightsLegend();
|
|
131
|
+
if (legend) this._text(legend, box.x + PAD_X, top, { textAnchor: 'start', fill: '#666', fontSize: '11px' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The layout strip: "Rows" and "Columns" with their dimensions, then "Filters" (each held to a
|
|
136
|
+
* member, or All) and "Add" (the dimensions not placed). Each dimension is a chip; a click opens
|
|
137
|
+
* its layout menu. Returns where the grid starts.
|
|
138
|
+
*/
|
|
139
|
+
_layoutStrip(box, top, interactive) {
|
|
140
|
+
const { view } = this;
|
|
141
|
+
const zones = [
|
|
142
|
+
[['Rows', view.rowAxis.map(d => [d, d + view.memberFilterText(d)])], ['Columns', view.columnAxis.map(d => [d, d + view.memberFilterText(d)])]],
|
|
143
|
+
[['Filters', view.pageAxis.map(p => [p.dimension, `${p.dimension}: ${p.value === null ? 'All' : TableChart.cellText(p.value)}`])],
|
|
144
|
+
['Add', view.addable().map(d => [d, `+ ${d}`])]],
|
|
145
|
+
];
|
|
146
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
|
|
147
|
+
for (const line of zones) {
|
|
148
|
+
let x = box.x + TableChart.PAD_X;
|
|
149
|
+
for (const [name, chips] of line) {
|
|
150
|
+
this._text(name, x, top, { textAnchor: 'start', fill: '#666', fontSize: '11px', fontWeight: 'bold' });
|
|
151
|
+
x += measure(name) + 8;
|
|
152
|
+
if (chips.length === 0) {
|
|
153
|
+
this._text('—', x, top, { textAnchor: 'start', fill: '#aaa' });
|
|
154
|
+
x += 16;
|
|
155
|
+
}
|
|
156
|
+
for (const [dimension, text] of chips) {
|
|
157
|
+
const label = `${text} ▾`;
|
|
158
|
+
const w = measure(label) + 14;
|
|
159
|
+
const chip = this.renderer.rect(x, top + 3, w, TableChart.ROW_H - 6, PivotGridChart.TOTAL_FILL, { color: '#c5cde0', width: 1 });
|
|
160
|
+
const t = this._text(label, x + 7, top, { textAnchor: 'start', fill: '#2b3f66' });
|
|
161
|
+
this._on(interactive, [chip, t], (e) => this.showMenu(e.clientX, e.clientY, view.layoutMenu(dimension)), `${dimension}: move, filter or remove`);
|
|
162
|
+
x += w + 6;
|
|
163
|
+
}
|
|
164
|
+
x += 18;
|
|
165
|
+
}
|
|
166
|
+
top += TableChart.ROW_H;
|
|
167
|
+
}
|
|
168
|
+
return top;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A member's header: ▸ / ▾ when it can collapse onto its total (a click toggles it), then its label
|
|
173
|
+
* (a click opens its menu). Without a member (a total's or a blank header) just the text.
|
|
174
|
+
*/
|
|
175
|
+
_member(axis, node, text, left, top, width, interactive, attrs) {
|
|
176
|
+
const { PAD_X } = TableChart;
|
|
177
|
+
if (node === null) {
|
|
178
|
+
this._cell(text, {}, left, top, width, attrs);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Room for the ▸ / ▾ on every member, so a level down is indented whether or not it has one.
|
|
182
|
+
const labelLeft = left + 14;
|
|
183
|
+
if (this.view.collapsible(node)) {
|
|
184
|
+
const sign = this.view.isCollapsed(axis, node) ? PivotGridChart.COLLAPSED : PivotGridChart.EXPANDED;
|
|
185
|
+
const toggle = this._text(sign, left + PAD_X + 3, top, { textAnchor: 'middle', fill: '#555' });
|
|
186
|
+
this._on(interactive, [toggle], () => this.view.toggle(axis, node), this.view.isCollapsed(axis, node) ? 'Expand' : 'Collapse');
|
|
187
|
+
}
|
|
188
|
+
const label = this._cell(text, {}, labelLeft, top, width - (labelLeft - left), attrs);
|
|
189
|
+
this._on(interactive, [label], (e) => this.showMenu(e.clientX, e.clientY, this.view.memberMenu(axis, node)), `${text} menu`);
|
|
190
|
+
}
|
|
191
|
+
}
|