Skip to content

Commit cca79df

Browse files
committed
fix(chart) :: line series up on a category axis for every chart type
1 parent f603aa8 commit cca79df

4 files changed

Lines changed: 310 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
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.
1010
- 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.
11+
- `line`, `area`, `scatter`, `bubble` and `heatmap` charts with text labels on the x axis now line their series up by label, leaving a gap where a series skips one.
1112
- `column` charts now display vertical bars instead of nothing at all.
1213
- `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart.
1314
- Screen readers now announce the title of the modal component instead of an unnamed dialog.

sqlpage/apexcharts.js

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ sqlpage_chart = (() => {
3838

3939
const STACKABLE_CHART_TYPES = ["line", "area", "bar"];
4040
const APEXCHARTS_TYPE_ALIASES = { column: "bar" };
41+
const Y_WHEN_A_SERIES_SKIPS_A_LABEL = {
42+
bar: 0,
43+
line: null,
44+
area: null,
45+
scatter: null,
46+
bubble: null,
47+
heatmap: null,
48+
};
4149

4250
/** @typedef {number|string|Date} XValue */
4351
/** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */
@@ -46,6 +54,9 @@ sqlpage_chart = (() => {
4654
/** @param {XValue} x @returns {number|string} equal x values share a key */
4755
const x_key = (x) => (x instanceof Date ? x.getTime() : x);
4856

57+
/** @param {ChartSeries[]} series */
58+
const x_is_text = (series) => typeof series[0]?.data[0]?.x === "string";
59+
4960
/**
5061
* @param {ChartSeries[]} series
5162
* @returns {XValue[]} every x the series hold, in their own order where they
@@ -66,29 +77,44 @@ sqlpage_chart = (() => {
6677

6778
/**
6879
* 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.
80+
* series that skips an x lands on the wrong one. Give every series the same
81+
* amount of x values.
7182
*
7283
* @param {ChartSeries[]} series
84+
* @param {number|null} y_when_missing what a series with no value at an x is
85+
* worth there: zero to add nothing to a stack, null to leave a gap.
7386
* @returns {ChartSeries[]}
7487
*/
75-
function align_series(series) {
88+
function align_series(series, y_when_missing) {
7689
const all_x = merged_x_values(series);
7790
return series.map(({ name, data }) => {
7891
const by_x = new Map(data.map((point) => [x_key(point.x), point]));
7992
return {
8093
name,
8194
data: all_x.map((x) => {
8295
const point = by_x.get(x_key(x));
83-
return { ...point, x, y: point?.y || 0 };
96+
return { ...point, x, y: point?.y ?? y_when_missing };
8497
}),
8598
};
8699
});
87100
}
88101

102+
/**
103+
* @param {ChartSeries[]} series
104+
* @param {string} chart_type
105+
* @param {boolean} is_stacked
106+
* @returns {ChartSeries[]}
107+
*/
108+
function align_series_for(series, chart_type, is_stacked) {
109+
if (is_stacked) return align_series(series, 0);
110+
if (x_is_text(series) && chart_type in Y_WHEN_A_SERIES_SKIPS_A_LABEL)
111+
return align_series(series, Y_WHEN_A_SERIES_SKIPS_A_LABEL[chart_type]);
112+
return series;
113+
}
114+
89115
// The unit tests load this file as a CommonJS module; browsers have no `module`.
90116
if (typeof module !== "undefined")
91-
module.exports = { align_series, merged_x_values };
117+
module.exports = { align_series, align_series_for, merged_x_values };
92118

93119
/** @param {HTMLElement} c */
94120
function build_sqlpage_chart(c) {
@@ -129,16 +155,12 @@ sqlpage_chart = (() => {
129155
let series = Object.values(series_map);
130156

131157
let labels;
132-
const categories =
133-
series.length > 0 && typeof series[0].data[0].x === "string";
158+
const categories = x_is_text(series);
134159
if (chart_type === "pie") {
135160
labels = data.points.map(([name, x, _y]) => x || name);
136161
series = data.points.map(([_name, _x, y]) => Number.parseFloat(y));
137-
} else if (
138-
series.length > 1 &&
139-
(is_stacked || (categories && chart_type === "bar"))
140-
)
141-
series = align_series(series);
162+
} else if (series.length > 1)
163+
series = align_series_for(series, chart_type, is_stacked);
142164

143165
const options = {
144166
chart: {

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

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,21 @@ async function renderChart(
106106
p.y,
107107
]),
108108
}));
109-
const drawnPerSeries = series.map(({ name }) => ({
110-
name,
111-
heights: [
109+
const drawnPerSeries = series.map(({ name }) => {
110+
const markers = [
112111
...container.querySelectorAll<SVGGraphicsElement>(
113-
`.apexcharts-series[seriesName='${name}'] .apexcharts-marker`,
112+
`.apexcharts-series[seriesName='${name}'] .apexcharts-series-markers > .apexcharts-marker`,
114113
),
115-
].map((m) => Math.round(m.getBBox().y)),
116-
}));
114+
].map((m) => m.getBBox());
115+
return {
116+
name,
117+
lefts: markers.map((b) => Math.round(b.x)),
118+
heights: markers.map((b) => Math.round(b.y)),
119+
};
120+
});
117121
const shapes = [
118122
...container.querySelectorAll<SVGGraphicsElement>(
119-
".apexcharts-bar-area, .apexcharts-rangebar-area",
123+
".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect",
120124
),
121125
].map((shape) => {
122126
const { x, y, width, height } = shape.getBBox();
@@ -252,6 +256,102 @@ test("stacks a bar series on the categories it skipped", async ({ page }) => {
252256
]);
253257
});
254258

259+
test("lines an unstacked series up with the categories it skipped", async ({
260+
page,
261+
}) => {
262+
const chart = await renderChart(page, { type: "line" }, [
263+
...A_IN_EVERY_QUARTER,
264+
...B_MISSING_THE_FIRST_QUARTER,
265+
]);
266+
267+
expect(chart.failures).toEqual([]);
268+
expect(chart.series[1].points).toEqual([
269+
["Q1", null],
270+
["Q2", 20],
271+
["Q3", 30],
272+
]);
273+
});
274+
275+
test("draws nothing where an unstacked series has no value", async ({
276+
page,
277+
}) => {
278+
const chart = await renderChart(page, { type: "line" }, [
279+
...A_IN_EVERY_QUARTER,
280+
...B_MISSING_THE_FIRST_QUARTER,
281+
]);
282+
const [a, b] = chart.drawnPerSeries;
283+
284+
expect(a.lefts).toHaveLength(3);
285+
expect(b.lefts).toEqual(a.lefts.slice(1));
286+
});
287+
288+
test("keeps a measured zero apart from a missing value", async ({ page }) => {
289+
const chart = await renderChart(page, { type: "line" }, [
290+
...A_IN_EVERY_QUARTER,
291+
["B", "Q2", 0],
292+
["B", "Q3", 30],
293+
]);
294+
const [a, b] = chart.drawnPerSeries;
295+
296+
expect(chart.series[1].points).toEqual([
297+
["Q1", null],
298+
["Q2", 0],
299+
["Q3", 30],
300+
]);
301+
expect(b.lefts).toEqual(a.lefts.slice(1));
302+
});
303+
304+
for (const type of ["area", "scatter", "heatmap"]) {
305+
test(`lines up the series of a ${type} chart on a category axis`, async ({
306+
page,
307+
}) => {
308+
const chart = await renderChart(page, { type }, [
309+
...A_IN_EVERY_QUARTER,
310+
...B_MISSING_THE_FIRST_QUARTER,
311+
]);
312+
313+
expect(chart.failures).toEqual([]);
314+
expect(chart.series[1].points.map((p) => p[0])).toEqual(["Q1", "Q2", "Q3"]);
315+
});
316+
}
317+
318+
test("keeps the bubble size of the points it lined up", async ({ page }) => {
319+
const chart = await renderChart(page, { type: "bubble" }, [
320+
["A", "Q1", 1, 30],
321+
["A", "Q2", 2, 30],
322+
["B", "Q2", 5, 70],
323+
]);
324+
325+
expect(chart.failures).toEqual([]);
326+
expect(chart.series[1].points).toEqual([
327+
["Q1", null],
328+
["Q2", 5],
329+
]);
330+
});
331+
332+
test("leaves a rangeBar chart on a category axis alone", async ({ page }) => {
333+
const chart = await renderChart(
334+
page,
335+
{ type: "rangeBar", time: true },
336+
TASKS_OVER_TIME,
337+
);
338+
339+
expect(chart.failures).toEqual([]);
340+
expect(chart.shapes).toHaveLength(2);
341+
});
342+
343+
test("leaves a treemap chart alone", async ({ page }) => {
344+
const chart = await renderChart(page, { type: "treemap" }, [
345+
["North America", "United States", 35],
346+
["North America", "Canada", 15],
347+
["Europe", "France", 30],
348+
["Europe", "Germany", 55],
349+
]);
350+
351+
expect(chart.failures).toEqual([]);
352+
expect(chart.shapes).toHaveLength(4);
353+
});
354+
255355
test("draws a rangeBar chart that asks to be stacked", async ({ page }) => {
256356
const chart = await renderChart(
257357
page,

0 commit comments

Comments
 (0)