Skip to content

Commit f603aa8

Browse files
authored
thank you very much @81reap!
fix(chart) :: align stacked series on their X values
2 parents b8f10ad + 8b7eb16 commit f603aa8

8 files changed

Lines changed: 456 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined.
88
- Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter.
99
- Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now reported in the browser console and skipped, instead of breaking the whole map.
10+
- Stacked charts now stack their series by `x` value instead of by point order, which used to give wrong totals when a series was missing a point.
1011
- `column` charts now display vertical bars instead of nothing at all.
1112
- `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart.
1213
- Screen readers now announce the title of the modal component instead of an unnamed dialog.

examples/official-site/sqlpage/migrations/01_documentation.sql

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
664664
('marker', 'Marker size', 'REAL', TRUE, TRUE),
665665
('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE),
666666
('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE),
667-
('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others.', 'BOOLEAN', TRUE, TRUE),
667+
('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others. Series are aligned on their x values, and a series that has no value for a given x counts as zero there.', 'BOOLEAN', TRUE, TRUE),
668668
('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE),
669669
('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE),
670670
('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE),
@@ -717,6 +717,20 @@ INSERT INTO example(component, description, properties) VALUES
717717
'{"series": "Marketing", "x": 2022, "value": 15}, '||
718718
'{"series": "Human resources", "x": 2021, "value": 30}, '||
719719
'{"series": "Human resources", "x": 2022, "value": 55}]')),
720+
('chart', 'A stacked area chart, showing how each series contributes to a total.
721+
The `stacked` property also works with the `line` and `bar` chart types.
722+
723+
Series are aligned on their `x` values, and a series that has no value for a given `x` counts as zero there:
724+
below, the graphics card draws no power outside of the render.
725+
If a missing value does not mean zero in your data, make all the series share the same `x` values,
726+
for instance by rounding timestamps to a common interval.',
727+
json('[{"component":"chart", "title": "Power draw", "type": "area", "stacked": true, "time": true, "ytitle": "watts", "color": ["blue", "teal"], "marker": 4}, '||
728+
'{"series": "CPU", "x": "2024-03-01T10:00:00Z", "value": 45}, '||
729+
'{"series": "CPU", "x": "2024-03-01T10:15:00Z", "value": 52}, '||
730+
'{"series": "CPU", "x": "2024-03-01T10:30:00Z", "value": 48}, '||
731+
'{"series": "CPU", "x": "2024-03-01T10:45:00Z", "value": 44}, '||
732+
'{"series": "GPU", "x": "2024-03-01T10:15:00Z", "value": 120}, '||
733+
'{"series": "GPU", "x": "2024-03-01T10:30:00Z", "value": 140}]')),
720734
('chart', 'A line chart with multiple series. One of the most common types of charts, often used to show trends over time.
721735
Also demonstrates the use of the `toolbar` attribute to allow the user to download the graph as an image or the data as a CSV file.',
722736
json('[{"component":"chart", "title": "Revenue", "ymin": 0, "toolbar": true},

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "sqlpage",
33
"version": "1.0.0",
44
"scripts": {
5-
"test": "biome check .",
5+
"test": "biome check . && node --test \"tests/js/**/*.spec.ts\"",
66
"format": "biome format --write .",
77
"fix": "biome check --fix --unsafe ."
88
},

sqlpage/apexcharts.js

Lines changed: 47 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -39,61 +39,57 @@ sqlpage_chart = (() => {
3939
const STACKABLE_CHART_TYPES = ["line", "area", "bar"];
4040
const APEXCHARTS_TYPE_ALIASES = { column: "bar" };
4141

42-
/** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */
42+
/** @typedef {number|string|Date} XValue */
43+
/** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */
44+
/** @typedef { { [name:string]: ChartSeries } } Series */
45+
46+
/** @param {XValue} x @returns {number|string} equal x values share a key */
47+
const x_key = (x) => (x instanceof Date ? x.getTime() : x);
4348

4449
/**
45-
* Aligns series data points by their x-axis categories, ensuring all series have data points
46-
* for each unique category. Missing values are filled with zeros.
47-
* Categories are ordered by their name.
48-
*
49-
* @example
50-
* // Input series:
51-
* const series = [
52-
* { name: "A", data: [{x: "X2", y: 10}, {x: "X3", y: 30}] },
53-
* { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}] }
54-
* ];
55-
*
56-
* // Output after align_categories (orderedCategories will be ["X1","X2", "X3"]):
57-
* // [
58-
* // { name: "A", data: [{x: "X1", y: 0}, {x: "X2", y: 10}, {x: "X3", y: 30}] },
59-
* // { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}, {x: "X3", y: 0}] }
60-
* // ]
61-
*
62-
* @param {(Series[string])[]} series - Array of series objects, each containing name and data points
63-
* @returns {Series[string][]} Aligned series with consistent categories across all series
50+
* @param {ChartSeries[]} series
51+
* @returns {XValue[]} every x the series hold, in their own order where they
52+
* agree and in ascending order where they diverge
6453
*/
65-
function align_categories(series) {
66-
const categoriesSet = new Set();
67-
const pointers = series.map((_) => 0); // Index of current data point in each series
68-
const x_at = (series_idx) =>
69-
series[series_idx].data[pointers[series_idx]].x;
70-
const series_idxs = series.flatMap((s, i) => (s.data.length ? i : []));
71-
while (series_idxs.length > 0) {
72-
let idx_of_xmin = series_idxs[0];
73-
for (const series_idx of series_idxs) {
74-
if (x_at(series_idx) < x_at(idx_of_xmin)) idx_of_xmin = series_idx;
75-
}
76-
77-
const new_category = x_at(idx_of_xmin);
78-
if (!categoriesSet.has(new_category)) categoriesSet.add(new_category);
79-
pointers[idx_of_xmin]++;
80-
if (pointers[idx_of_xmin] >= series[idx_of_xmin].data.length) {
81-
series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1);
82-
}
54+
function merged_x_values(series) {
55+
const unread = series.map(({ data }) => data.map(({ x }) => x));
56+
const merged = new Map();
57+
while (unread.some((xs) => xs.length > 0)) {
58+
const with_lowest_x = unread
59+
.filter((xs) => xs.length > 0)
60+
.reduce((a, b) => (b[0] < a[0] ? b : a));
61+
const x = with_lowest_x.shift();
62+
merged.set(x_key(x), x);
8363
}
84-
// Create a map of category -> value for each series and rebuild
85-
return series.map((s) => {
86-
const valueMap = new Map(s.data.map((point) => [point.x, point.y]));
64+
return [...merged.values()];
65+
}
66+
67+
/**
68+
* ApexCharts pairs points across series by index rather than by x, so a
69+
* series that skips an x stacks onto the wrong one. Give every series the
70+
* same x values, counting an x it never measured as zero.
71+
*
72+
* @param {ChartSeries[]} series
73+
* @returns {ChartSeries[]}
74+
*/
75+
function align_series(series) {
76+
const all_x = merged_x_values(series);
77+
return series.map(({ name, data }) => {
78+
const by_x = new Map(data.map((point) => [x_key(point.x), point]));
8779
return {
88-
name: s.name,
89-
data: Array.from(categoriesSet, (category) => ({
90-
x: category,
91-
y: valueMap.get(category) || 0,
92-
})),
80+
name,
81+
data: all_x.map((x) => {
82+
const point = by_x.get(x_key(x));
83+
return { ...point, x, y: point?.y || 0 };
84+
}),
9385
};
9486
});
9587
}
9688

89+
// The unit tests load this file as a CommonJS module; browsers have no `module`.
90+
if (typeof module !== "undefined")
91+
module.exports = { align_series, merged_x_values };
92+
9793
/** @param {HTMLElement} c */
9894
function build_sqlpage_chart(c) {
9995
const [data_element] = c.getElementsByTagName("data");
@@ -138,8 +134,11 @@ sqlpage_chart = (() => {
138134
if (chart_type === "pie") {
139135
labels = data.points.map(([name, x, _y]) => x || name);
140136
series = data.points.map(([_name, _x, y]) => Number.parseFloat(y));
141-
} else if (categories && chart_type === "bar" && series.length > 1)
142-
series = align_categories(series);
137+
} else if (
138+
series.length > 1 &&
139+
(is_stacked || (categories && chart_type === "bar"))
140+
)
141+
series = align_series(series);
143142

144143
const options = {
145144
chart: {

tests/end-to-end/chart-component.spec.ts

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { expect, type Page, test } from "@playwright/test";
22

33
const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/";
44

5+
type ChartPoint = { x: string | number | Date; y: number | null };
6+
57
declare global {
68
interface Window {
79
charts?: {
8-
w: { config: { chart: { type: string; stacked: boolean } } };
10+
w: {
11+
config: {
12+
chart: { type: string; stacked: boolean };
13+
series: { name: string; data: ChartPoint[] }[];
14+
};
15+
};
916
}[];
1017
}
1118
function sqlpage_chart(): void;
@@ -24,6 +31,46 @@ const TASKS_OVER_TIME: Row[] = [
2431
["Build", "Bob", ["2024-03-04", "2024-03-09"]],
2532
];
2633

34+
const CPU_AT_EVERY_MINUTE: Row[] = [
35+
["CPU", "2024-01-01T00:00:00Z", 10],
36+
["CPU", "2024-01-01T00:01:00Z", 20],
37+
["CPU", "2024-01-01T00:02:00Z", 30],
38+
["CPU", "2024-01-01T00:03:00Z", 40],
39+
];
40+
41+
const GPU_ONLY_ONCE_THE_RENDER_STARTED: Row[] = [
42+
["GPU", "2024-01-01T00:01:00Z", 50],
43+
["GPU", "2024-01-01T00:02:00Z", 50],
44+
["GPU", "2024-01-01T00:03:00Z", 50],
45+
];
46+
47+
const A_IN_EVERY_QUARTER: Row[] = [
48+
["A", "Q1", 1],
49+
["A", "Q2", 2],
50+
["A", "Q3", 3],
51+
];
52+
53+
const B_MISSING_THE_FIRST_QUARTER: Row[] = [
54+
["B", "Q2", 20],
55+
["B", "Q3", 30],
56+
];
57+
58+
const A_QUARTERS_OUT_OF_ORDER: Row[] = [
59+
["A", "Q3", 3],
60+
["A", "Q1", 1],
61+
["A", "Q2", 2],
62+
];
63+
64+
const A_FROM_THE_SECOND_CATEGORY: Row[] = [
65+
["A", "X2", 10],
66+
["A", "X3", 30],
67+
];
68+
69+
const B_UNTIL_THE_SECOND_CATEGORY: Row[] = [
70+
["B", "X1", 25],
71+
["B", "X2", 20],
72+
];
73+
2774
async function renderChart(
2875
page: Page,
2976
chart: Record<string, unknown>,
@@ -52,6 +99,21 @@ async function renderChart(
5299
console.error = reportError;
53100

54101
const rendered = window.charts?.[before];
102+
const series = (rendered?.w.config.series ?? []).map((s) => ({
103+
name: s.name,
104+
points: s.data.map((p) => [
105+
p.x instanceof Date ? p.x.toISOString() : p.x,
106+
p.y,
107+
]),
108+
}));
109+
const drawnPerSeries = series.map(({ name }) => ({
110+
name,
111+
heights: [
112+
...container.querySelectorAll<SVGGraphicsElement>(
113+
`.apexcharts-series[seriesName='${name}'] .apexcharts-marker`,
114+
),
115+
].map((m) => Math.round(m.getBBox().y)),
116+
}));
55117
const shapes = [
56118
...container.querySelectorAll<SVGGraphicsElement>(
57119
".apexcharts-bar-area, .apexcharts-rangebar-area",
@@ -65,6 +127,8 @@ async function renderChart(
65127
failures,
66128
type: rendered?.w.config.chart.type ?? null,
67129
stacked: rendered?.w.config.chart.stacked ?? null,
130+
series,
131+
drawnPerSeries,
68132
shapes,
69133
};
70134
},
@@ -88,6 +152,106 @@ test("draws a column chart as a vertical bar chart", async ({ page }) => {
88152
expect(new Set(chart.shapes.map((s) => s.height)).size).toBe(3);
89153
});
90154

155+
test("gives a stacked series a zero at every x it did not measure", async ({
156+
page,
157+
}) => {
158+
const chart = await renderChart(
159+
page,
160+
{ type: "area", stacked: true, time: true },
161+
[...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED],
162+
);
163+
164+
expect(chart.failures).toEqual([]);
165+
expect(chart.series.map((s) => s.name)).toEqual(["CPU", "GPU"]);
166+
expect(chart.series[1].points).toEqual([
167+
["2024-01-01T00:00:00.000Z", 0],
168+
["2024-01-01T00:01:00.000Z", 50],
169+
["2024-01-01T00:02:00.000Z", 50],
170+
["2024-01-01T00:03:00.000Z", 50],
171+
]);
172+
});
173+
174+
test("stacks a series above the one it shares an x with", async ({ page }) => {
175+
const chart = await renderChart(
176+
page,
177+
{ type: "area", stacked: true, time: true },
178+
[...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED],
179+
);
180+
const [cpu, gpu] = chart.drawnPerSeries;
181+
182+
expect(gpu.heights).toHaveLength(4);
183+
expect(gpu.heights[0]).toBe(cpu.heights[0]);
184+
expect(gpu.heights[1]).toBeLessThan(cpu.heights[1]);
185+
});
186+
187+
test("keeps a lone series in the order the query returned it (#930)", async ({
188+
page,
189+
}) => {
190+
const chart = await renderChart(
191+
page,
192+
{ type: "bar" },
193+
A_QUARTERS_OUT_OF_ORDER,
194+
);
195+
196+
expect(chart.failures).toEqual([]);
197+
expect(chart.series[0].points).toEqual([
198+
["Q3", 3],
199+
["Q1", 1],
200+
["Q2", 2],
201+
]);
202+
});
203+
204+
test("orders by name the categories two bar series do not share (#951)", async ({
205+
page,
206+
}) => {
207+
const chart = await renderChart(page, { type: "bar" }, [
208+
...A_FROM_THE_SECOND_CATEGORY,
209+
...B_UNTIL_THE_SECOND_CATEGORY,
210+
]);
211+
212+
expect(chart.failures).toEqual([]);
213+
expect(chart.series[0].points).toEqual([
214+
["X1", 0],
215+
["X2", 10],
216+
["X3", 30],
217+
]);
218+
expect(chart.series[1].points).toEqual([
219+
["X1", 25],
220+
["X2", 20],
221+
["X3", 0],
222+
]);
223+
});
224+
225+
test("leaves the points of a chart that does not stack alone", async ({
226+
page,
227+
}) => {
228+
const chart = await renderChart(page, { type: "area", time: true }, [
229+
...CPU_AT_EVERY_MINUTE,
230+
...GPU_ONLY_ONCE_THE_RENDER_STARTED,
231+
]);
232+
233+
expect(chart.failures).toEqual([]);
234+
expect(chart.series[1].points).toEqual([
235+
["2024-01-01T00:01:00.000Z", 50],
236+
["2024-01-01T00:02:00.000Z", 50],
237+
["2024-01-01T00:03:00.000Z", 50],
238+
]);
239+
});
240+
241+
test("stacks a bar series on the categories it skipped", async ({ page }) => {
242+
const chart = await renderChart(page, { type: "bar", stacked: true }, [
243+
...A_IN_EVERY_QUARTER,
244+
...B_MISSING_THE_FIRST_QUARTER,
245+
]);
246+
247+
expect(chart.failures).toEqual([]);
248+
expect(chart.series[1].points).toEqual([
249+
["Q1", 0],
250+
["Q2", 20],
251+
["Q3", 30],
252+
]);
253+
});
254+
91255
test("draws a rangeBar chart that asks to be stacked", async ({ page }) => {
92256
const chart = await renderChart(
93257
page,

0 commit comments

Comments
 (0)