Skip to content

Commit 42e7f53

Browse files
committed
feat(chart) :: render column charts as bar charts + filter malformed stack points
1 parent 9115594 commit 42e7f53

4 files changed

Lines changed: 122 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
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+
- `column` charts now display vertical bars instead of nothing at all.
11+
- `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart.
1012
- Screen readers now announce the title of the modal component instead of an unnamed dialog.
1113

1214
## v0.45

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ INSERT INTO component(name, icon, description) VALUES
650650
INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'chart', * FROM (VALUES
651651
-- top level
652652
('title', 'The name of the chart.', 'TEXT', TRUE, TRUE),
653-
('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar"', 'TEXT', TRUE, FALSE),
653+
('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar". "column" is a synonym of "bar".', 'TEXT', TRUE, FALSE),
654654
('time', 'Whether the x-axis represents time. If set to true, the x values will be parsed and formatted as dates for the user.', 'BOOLEAN', TRUE, TRUE),
655655
('xmin', 'The minimal value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE),
656656
('xmax', 'The maximum value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE),
@@ -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.', '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.', '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),

sqlpage/apexcharts.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ sqlpage_chart = (() => {
3636
);
3737
const isDarkTheme = document.body?.dataset?.bsTheme === "dark";
3838

39+
const STACKABLE_CHART_TYPES = ["line", "area", "bar"];
40+
const APEXCHARTS_TYPE_ALIASES = { column: "bar" };
41+
3942
/** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */
4043

4144
/**
@@ -98,6 +101,10 @@ sqlpage_chart = (() => {
98101
const chartContainer = c.querySelector(".chart");
99102
chartContainer.innerHTML = "";
100103
const is_timeseries = !!data.time;
104+
const chart_type =
105+
APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line";
106+
const is_stacked =
107+
!!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type);
101108
/** @type { Series } */
102109
const series_map = {};
103110
for (const [name, old_x, old_y, z] of data.points) {
@@ -106,7 +113,7 @@ sqlpage_chart = (() => {
106113
let y = old_y;
107114
if (is_timeseries) {
108115
if (typeof x === "number") x = new Date(x * 1000);
109-
else if (data.type === "rangeBar" && Array.isArray(y))
116+
else if (chart_type === "rangeBar" && Array.isArray(y))
110117
y = y.map((y) => new Date(y).getTime());
111118
else x = new Date(x);
112119
}
@@ -128,21 +135,20 @@ sqlpage_chart = (() => {
128135
let labels;
129136
const categories =
130137
series.length > 0 && typeof series[0].data[0].x === "string";
131-
if (data.type === "pie") {
138+
if (chart_type === "pie") {
132139
labels = data.points.map(([name, x, _y]) => x || name);
133140
series = data.points.map(([_name, _x, y]) => Number.parseFloat(y));
134-
} else if (categories && data.type === "bar" && series.length > 1)
141+
} else if (categories && chart_type === "bar" && series.length > 1)
135142
series = align_categories(series);
136143

137-
const chart_type = data.type || "line";
138144
const options = {
139145
chart: {
140146
type: chart_type,
141147
fontFamily: "inherit",
142148
background: "transparent",
143149
parentHeightOffset: 0,
144150
height: chartContainer.style.height,
145-
stacked: !!data.stacked,
151+
stacked: is_stacked,
146152
toolbar: {
147153
show: !!data.toolbar,
148154
},
@@ -167,15 +173,15 @@ sqlpage_chart = (() => {
167173
color: "var(--tblr-primary-bg-subtle)",
168174
},
169175
formatter:
170-
data.type === "rangeBar"
176+
chart_type === "rangeBar"
171177
? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name
172-
: data.type === "pie"
178+
: chart_type === "pie"
173179
? (value, { seriesIndex, w }) =>
174180
`${w.config.labels[seriesIndex]}: ${value.toFixed()}%`
175181
: (value) => value?.toLocaleString?.() || value,
176182
},
177183
fill: {
178-
type: data.type === "area" ? "gradient" : "solid",
184+
type: chart_type === "area" ? "gradient" : "solid",
179185
},
180186
stroke: {
181187
width:
@@ -225,13 +231,13 @@ sqlpage_chart = (() => {
225231
tooltip: {
226232
fillSeriesColor: false,
227233
custom:
228-
data.type === "bubble" || data.type === "scatter"
234+
chart_type === "bubble" || chart_type === "scatter"
229235
? bubbleTooltip
230236
: undefined,
231237
y: {
232238
formatter: (value) => {
233239
if (value == null) return "";
234-
if (is_timeseries && data.type === "rangeBar") {
240+
if (is_timeseries && chart_type === "rangeBar") {
235241
const d = new Date(value);
236242
if (d.getHours() === 0 && d.getMinutes() === 0)
237243
return d.toLocaleDateString();
@@ -246,7 +252,7 @@ sqlpage_chart = (() => {
246252
},
247253
plotOptions: {
248254
bar: {
249-
horizontal: !!data.horizontal || data.type === "rangeBar",
255+
horizontal: !!data.horizontal || chart_type === "rangeBar",
250256
borderRadius: 5,
251257
},
252258
bubble: { minBubbleRadius: 5 },
@@ -257,7 +263,6 @@ sqlpage_chart = (() => {
257263
if (labels) options.labels = labels;
258264
// tickamount is the number of intervals, not the number of ticks
259265
if (data.xticks) options.xaxis.tickAmount = data.xticks;
260-
console.log("Rendering chart", options);
261266
const chart = new ApexCharts(chartContainer, options);
262267
chart.render();
263268
if (window.charts) window.charts.push(chart);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { expect, type Page, test } from "@playwright/test";
2+
3+
const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/";
4+
5+
declare global {
6+
interface Window {
7+
charts?: {
8+
w: { config: { chart: { type: string; stacked: boolean } } };
9+
}[];
10+
}
11+
function sqlpage_chart(): void;
12+
}
13+
14+
type Row = [series: string, x: unknown, y: unknown, z?: unknown];
15+
16+
const A_DAY_OF_WORK: Row[] = [
17+
["Coding", "Mon", 6],
18+
["Coding", "Tue", 4],
19+
["Coding", "Wed", 7],
20+
];
21+
22+
const TASKS_OVER_TIME: Row[] = [
23+
["Design", "Alice", ["2024-03-01", "2024-03-05"]],
24+
["Build", "Bob", ["2024-03-04", "2024-03-09"]],
25+
];
26+
27+
async function renderChart(
28+
page: Page,
29+
chart: Record<string, unknown>,
30+
rows: Row[],
31+
) {
32+
return page.evaluate(
33+
({ chart, rows }) => {
34+
document.getElementById("test-chart")?.remove();
35+
const container = document.createElement("div");
36+
container.id = "test-chart";
37+
container.setAttribute("data-pre-init", "chart");
38+
const payload = JSON.stringify({
39+
colors: [],
40+
marker: 4,
41+
...chart,
42+
points: rows,
43+
});
44+
container.innerHTML = `<data hidden>${payload}</data><div class="chart" style="height:250px"></div>`;
45+
document.body.appendChild(container);
46+
47+
const failures: string[] = [];
48+
const reportError = console.error;
49+
console.error = (...args) => failures.push(args.map(String).join(" "));
50+
const before = window.charts?.length ?? 0;
51+
sqlpage_chart();
52+
console.error = reportError;
53+
54+
const rendered = window.charts?.[before];
55+
const shapes = [
56+
...container.querySelectorAll<SVGGraphicsElement>(
57+
".apexcharts-bar-area, .apexcharts-rangebar-area",
58+
),
59+
].map((shape) => {
60+
const { x, y, width, height } = shape.getBBox();
61+
return { x, y, width, height };
62+
});
63+
64+
return {
65+
failures,
66+
type: rendered?.w.config.chart.type ?? null,
67+
stacked: rendered?.w.config.chart.stacked ?? null,
68+
shapes,
69+
};
70+
},
71+
{ chart, rows },
72+
);
73+
}
74+
75+
test.beforeEach(async ({ page }) => {
76+
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
77+
await page.waitForSelector(".apexcharts-canvas");
78+
});
79+
80+
test("draws a column chart as a vertical bar chart", async ({ page }) => {
81+
const chart = await renderChart(page, { type: "column" }, A_DAY_OF_WORK);
82+
83+
expect(chart.failures).toEqual([]);
84+
expect(chart.shapes).toHaveLength(3);
85+
expect(chart.type).toBe("bar");
86+
87+
expect(new Set(chart.shapes.map((s) => s.x)).size).toBe(3);
88+
expect(new Set(chart.shapes.map((s) => s.height)).size).toBe(3);
89+
});
90+
91+
test("draws a rangeBar chart that asks to be stacked", async ({ page }) => {
92+
const chart = await renderChart(
93+
page,
94+
{ type: "rangeBar", stacked: true, time: true },
95+
TASKS_OVER_TIME,
96+
);
97+
98+
expect(chart.failures).toEqual([]);
99+
expect(chart.shapes).toHaveLength(2);
100+
expect(chart.stacked).toBe(false);
101+
});

0 commit comments

Comments
 (0)