From 49eb033e32911af867438748c6e05a8aa2082b1a Mon Sep 17 00:00:00 2001 From: 81reap Date: Fri, 7 Aug 2026 19:32:44 -0400 Subject: [PATCH] feat(chart) :: render column charts as bar charts + filter malformed stack points --- CHANGELOG.md | 2 + .../sqlpage/migrations/01_documentation.sql | 4 +- sqlpage/apexcharts.js | 29 ++--- tests/end-to-end/chart-component.spec.ts | 101 ++++++++++++++++++ 4 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/end-to-end/chart-component.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f837be..2584a569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - `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. - Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter. - 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. + - `column` charts now display vertical bars instead of nothing at all. + - `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart. - Screen readers now announce the title of the modal component instead of an unnamed dialog. ## v0.45 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 5f1ee7ca..93f03219 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -650,7 +650,7 @@ INSERT INTO component(name, icon, description) VALUES INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'chart', * FROM (VALUES -- top level ('title', 'The name of the chart.', 'TEXT', TRUE, TRUE), - ('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar"', 'TEXT', TRUE, FALSE), + ('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), ('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), ('xmin', 'The minimal value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), ('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 ('marker', 'Marker size', 'REAL', TRUE, TRUE), ('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE), ('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), - ('stacked', 'Whether to cumulate values from different series.', 'BOOLEAN', TRUE, TRUE), + ('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), ('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE), ('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE), ('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE), diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index f4fc1bcd..e5ee4efe 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -36,6 +36,9 @@ sqlpage_chart = (() => { ); const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; + const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; + const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; + /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ /** @@ -98,6 +101,10 @@ sqlpage_chart = (() => { const chartContainer = c.querySelector(".chart"); chartContainer.innerHTML = ""; const is_timeseries = !!data.time; + const chart_type = + APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line"; + const is_stacked = + !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); /** @type { Series } */ const series_map = {}; for (const [name, old_x, old_y, z] of data.points) { @@ -106,7 +113,7 @@ sqlpage_chart = (() => { let y = old_y; if (is_timeseries) { if (typeof x === "number") x = new Date(x * 1000); - else if (data.type === "rangeBar" && Array.isArray(y)) + else if (chart_type === "rangeBar" && Array.isArray(y)) y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } @@ -128,13 +135,12 @@ sqlpage_chart = (() => { let labels; const categories = series.length > 0 && typeof series[0].data[0].x === "string"; - if (data.type === "pie") { + if (chart_type === "pie") { labels = data.points.map(([name, x, _y]) => x || name); series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); - } else if (categories && data.type === "bar" && series.length > 1) + } else if (categories && chart_type === "bar" && series.length > 1) series = align_categories(series); - const chart_type = data.type || "line"; const options = { chart: { type: chart_type, @@ -142,7 +148,7 @@ sqlpage_chart = (() => { background: "transparent", parentHeightOffset: 0, height: chartContainer.style.height, - stacked: !!data.stacked, + stacked: is_stacked, toolbar: { show: !!data.toolbar, }, @@ -167,15 +173,15 @@ sqlpage_chart = (() => { color: "var(--tblr-primary-bg-subtle)", }, formatter: - data.type === "rangeBar" + chart_type === "rangeBar" ? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name - : data.type === "pie" + : chart_type === "pie" ? (value, { seriesIndex, w }) => `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` : (value) => value?.toLocaleString?.() || value, }, fill: { - type: data.type === "area" ? "gradient" : "solid", + type: chart_type === "area" ? "gradient" : "solid", }, stroke: { width: @@ -225,13 +231,13 @@ sqlpage_chart = (() => { tooltip: { fillSeriesColor: false, custom: - data.type === "bubble" || data.type === "scatter" + chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, y: { formatter: (value) => { if (value == null) return ""; - if (is_timeseries && data.type === "rangeBar") { + if (is_timeseries && chart_type === "rangeBar") { const d = new Date(value); if (d.getHours() === 0 && d.getMinutes() === 0) return d.toLocaleDateString(); @@ -246,7 +252,7 @@ sqlpage_chart = (() => { }, plotOptions: { bar: { - horizontal: !!data.horizontal || data.type === "rangeBar", + horizontal: !!data.horizontal || chart_type === "rangeBar", borderRadius: 5, }, bubble: { minBubbleRadius: 5 }, @@ -257,7 +263,6 @@ sqlpage_chart = (() => { if (labels) options.labels = labels; // tickamount is the number of intervals, not the number of ticks if (data.xticks) options.xaxis.tickAmount = data.xticks; - console.log("Rendering chart", options); const chart = new ApexCharts(chartContainer, options); chart.render(); if (window.charts) window.charts.push(chart); diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts new file mode 100644 index 00000000..55724888 --- /dev/null +++ b/tests/end-to-end/chart-component.spec.ts @@ -0,0 +1,101 @@ +import { expect, type Page, test } from "@playwright/test"; + +const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; + +declare global { + interface Window { + charts?: { + w: { config: { chart: { type: string; stacked: boolean } } }; + }[]; + } + function sqlpage_chart(): void; +} + +type Row = [series: string, x: unknown, y: unknown, z?: unknown]; + +const A_DAY_OF_WORK: Row[] = [ + ["Coding", "Mon", 6], + ["Coding", "Tue", 4], + ["Coding", "Wed", 7], +]; + +const TASKS_OVER_TIME: Row[] = [ + ["Design", "Alice", ["2024-03-01", "2024-03-05"]], + ["Build", "Bob", ["2024-03-04", "2024-03-09"]], +]; + +async function renderChart( + page: Page, + chart: Record, + rows: Row[], +) { + return page.evaluate( + ({ chart, rows }) => { + document.getElementById("test-chart")?.remove(); + const container = document.createElement("div"); + container.id = "test-chart"; + container.setAttribute("data-pre-init", "chart"); + const payload = JSON.stringify({ + colors: [], + marker: 4, + ...chart, + points: rows, + }); + container.innerHTML = `
`; + document.body.appendChild(container); + + const failures: string[] = []; + const reportError = console.error; + console.error = (...args) => failures.push(args.map(String).join(" ")); + const before = window.charts?.length ?? 0; + sqlpage_chart(); + console.error = reportError; + + const rendered = window.charts?.[before]; + const shapes = [ + ...container.querySelectorAll( + ".apexcharts-bar-area, .apexcharts-rangebar-area", + ), + ].map((shape) => { + const { x, y, width, height } = shape.getBBox(); + return { x, y, width, height }; + }); + + return { + failures, + type: rendered?.w.config.chart.type ?? null, + stacked: rendered?.w.config.chart.stacked ?? null, + shapes, + }; + }, + { chart, rows }, + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.waitForSelector(".apexcharts-canvas"); +}); + +test("draws a column chart as a vertical bar chart", async ({ page }) => { + const chart = await renderChart(page, { type: "column" }, A_DAY_OF_WORK); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(3); + expect(chart.type).toBe("bar"); + + expect(new Set(chart.shapes.map((s) => s.x)).size).toBe(3); + expect(new Set(chart.shapes.map((s) => s.height)).size).toBe(3); +}); + +test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { + const chart = await renderChart( + page, + { type: "rangeBar", stacked: true, time: true }, + TASKS_OVER_TIME, + ); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(2); + expect(chart.stacked).toBe(false); +});