From 90026d4857e813281173ef513a868d78da01f494 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 13:57:51 +0000 Subject: [PATCH 1/3] fix(chart) :: keep numeric x-axis labels aligned --- CHANGELOG.md | 1 + .../sqlpage/migrations/01_documentation.sql | 4 +- sqlpage/apexcharts.js | 31 ++++- .../fixtures/chart/numeric-axis-irregular.sql | 10 ++ .../fixtures/chart/numeric-axis-xticks.sql | 10 ++ .../fixtures/chart/numeric-axis.sql | 15 +++ .../fixtures/chart/numeric-horizontal-bar.sql | 10 ++ tests/end-to-end/fixtures/chart/test.ts | 110 ++++++++++++++++++ tests/js/chart_series.spec.ts | 27 +++++ 9 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 tests/end-to-end/fixtures/chart/numeric-axis-irregular.sql create mode 100644 tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql create mode 100644 tests/end-to-end/fixtures/chart/numeric-axis.sql create mode 100644 tests/end-to-end/fixtures/chart/numeric-horizontal-bar.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1c6e9c..10c29440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v0.46.1 +- Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. - Upgraded the bundled ApexCharts from v5.13.0 to [v7.1.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.1.0) and the Tabler core from v1.4.0 to v1.5.0. The ApexCharts upgrade fixes logarithmic-axis scaling, stacked baselines on irregular data, and annotations on charts with no data, and ships a smaller default bundle. - Fixed modal dialog boxes appearing behind their backdrop, which made them impossible to close by clicking their close button. Tabler 1.5 sets `contain: layout` on the page container, which broke the fixed positioning of modals rendered inside it; modals are now moved to the top level of the page, as recommended by Bootstrap. - Fixed a regression introduced in v0.46 that could replace a variable with `NULL` while building a value that also used database expressions and `sqlpage.*` functions. For example, this API request could lose `john.doe` and produce a URL ending at `https://api.example.com/`: diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index f93a527e..cd512663 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -669,7 +669,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('xtitle', 'Title of the x axis, displayed below it.', 'TEXT', TRUE, TRUE), ('ytitle', 'Title of the y axis, displayed to its left.', 'TEXT', TRUE, TRUE), ('ztitle', 'Title of the z axis, displayed in tooltips.', 'TEXT', TRUE, TRUE), - ('xticks', 'Number of ticks on the x axis.', 'INTEGER', TRUE, TRUE), + ('xticks', 'Number of intervals on the x axis (one less than the number of ticks). Automatic selection usually works best. A point-count workaround such as count(distinct x) - 1 is only accurate for evenly spaced numeric x values; irregular numeric values remain positioned on a continuous scale.', 'INTEGER', TRUE, TRUE), ('yticks', 'Number of ticks on the y axis.', 'INTEGER', TRUE, TRUE), ('ystep', 'Step between ticks on the y axis.', 'REAL', TRUE, TRUE), ('marker', 'Marker size', 'REAL', TRUE, TRUE), @@ -682,7 +682,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('horizontal', 'Displays a bar chart with horizontal bars instead of vertical ones.', 'BOOLEAN', TRUE, TRUE), ('height', 'Height of the chart, in pixels. By default: 250', 'INTEGER', TRUE, TRUE), -- item level - ('x', 'The value of the point on the horizontal axis', 'REAL', FALSE, FALSE), + ('x', 'The value of the point on the horizontal axis. Numeric values use continuous, proportionate positioning; text values are evenly spaced categories. Set the top-level time property for dates and timestamps.', 'REAL', FALSE, FALSE), ('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE), ('z', 'A third value carried by the point. Used as the bubble radius in a bubble chart, and shown in the tooltip under the name given by the top-level "ztitle".', 'REAL', FALSE, TRUE), ('label', 'An alias for parameter "x". On a row that draws a reference line, the text to display next to the line.', 'TEXT', FALSE, TRUE), diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 133d9139..969c409e 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -39,6 +39,7 @@ sqlpage_chart = (() => { const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; + const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"]; const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; const Y_WHEN_A_SERIES_SKIPS_A_LABEL = { bar: 0, @@ -58,7 +59,19 @@ sqlpage_chart = (() => { const x_key = (x) => (x instanceof Date ? x.getTime() : x); /** @param {ChartSeries[]} series */ - const x_is_text = (series) => typeof series[0]?.data[0]?.x === "string"; + const x_is_text = (series) => typeof series[0]?.data?.[0]?.x === "string"; + + /** @param {ChartSeries[]} series @param {string} chart_type */ + function xaxis_type_for(series, chart_type, is_timeseries, is_horizontal) { + if (is_timeseries) return "datetime"; + if (x_is_text(series)) return "category"; + if ( + typeof series[0]?.data?.[0]?.x === "number" && + !is_horizontal && + NUMERIC_X_CHART_TYPES.includes(chart_type) + ) + return "numeric"; + } /** * @param {ChartSeries[]} series @@ -117,7 +130,12 @@ sqlpage_chart = (() => { // The unit tests load this file as a CommonJS module; browsers have no `module`. if (typeof module !== "undefined") - module.exports = { align_series, align_series_for, merged_x_values }; + module.exports = { + align_series, + align_series_for, + merged_x_values, + xaxis_type_for, + }; const referenceColor = colorNames[isDarkTheme ? "gray-lt" : "gray"]; @@ -207,9 +225,14 @@ sqlpage_chart = (() => { let colors = palette; let series = Object.values(series_map); + const xaxis_type = xaxis_type_for( + series, + chart_type, + is_timeseries, + !!data.horizontal, + ); let labels; - const categories = x_is_text(series); if (chart_type === "pie") { labels = points.map(([name, x, _y]) => x || name); series = points.map(([_name, _x, y]) => Number.parseFloat(y)); @@ -303,7 +326,7 @@ sqlpage_chart = (() => { title: { text: data.xtitle || undefined, }, - type: is_timeseries ? "datetime" : categories ? "category" : undefined, + type: xaxis_type, labels: { datetimeUTC: false, }, diff --git a/tests/end-to-end/fixtures/chart/numeric-axis-irregular.sql b/tests/end-to-end/fixtures/chart/numeric-axis-irregular.sql new file mode 100644 index 00000000..2dd1c452 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/numeric-axis-irregular.sql @@ -0,0 +1,10 @@ +SELECT + 'chart' AS component, + 'test-chart' AS id, + 'Irregular numeric x values' AS title, + 'bar' AS type, + TRUE AS labels; + +SELECT 'A' AS series, 0.25 AS x, 1 AS y +UNION ALL SELECT 'A', 0.5, 2 +UNION ALL SELECT 'A', 3, 3; diff --git a/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql b/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql new file mode 100644 index 00000000..9848a9a1 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql @@ -0,0 +1,10 @@ +SELECT + 'chart' AS component, + 'test-chart' AS id, + 'Explicit numeric x intervals' AS title, + 'bar' AS type, + 2 AS xticks; + +SELECT 'A' AS series, 1 AS x, 1 AS y +UNION ALL SELECT 'A', 4, 4 +UNION ALL SELECT 'A', 12, 12; diff --git a/tests/end-to-end/fixtures/chart/numeric-axis.sql b/tests/end-to-end/fixtures/chart/numeric-axis.sql new file mode 100644 index 00000000..8bfa9d5b --- /dev/null +++ b/tests/end-to-end/fixtures/chart/numeric-axis.sql @@ -0,0 +1,15 @@ +SELECT + 'chart' AS component, + 'test-chart' AS id, + 'Every bar label equals its x value' AS title, + 'bar' AS type, + TRUE AS labels; + +WITH RECURSIVE x(x) AS ( + VALUES (1) + UNION ALL + SELECT x + 1 FROM x WHERE x < 12 +) +SELECT 'A' AS series, x, x AS y FROM x +UNION ALL +SELECT 'B', x, x FROM x; diff --git a/tests/end-to-end/fixtures/chart/numeric-horizontal-bar.sql b/tests/end-to-end/fixtures/chart/numeric-horizontal-bar.sql new file mode 100644 index 00000000..2c776399 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/numeric-horizontal-bar.sql @@ -0,0 +1,10 @@ +SELECT + 'chart' AS component, + 'test-chart' AS id, + 'Numeric horizontal bar categories' AS title, + 'bar' AS type, + TRUE AS horizontal; + +SELECT 1 AS x, 10 AS y +UNION ALL SELECT 4, 20 +UNION ALL SELECT 12, 30; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index ff3d5f17..d20553a9 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -8,8 +8,10 @@ declare global { w: { config: { chart: { type: string; stacked: boolean }; + xaxis: { type?: string; tickAmount?: number }; series: { name: string; data?: ChartPoint[] }[]; }; + globals: { labels: (string | number)[] }; }; }[]; } @@ -62,6 +64,30 @@ async function renderChart(page: Page, fixture: string) { const { x, y, width, height } = shape.getBBox(); return { x, y, width, height, fill: shape.getAttribute("fill") }; }); + const axisLabels = [ + ...container.querySelectorAll( + ".apexcharts-xaxis-label tspan", + ), + ].map((label) => { + const { left, width } = label.getBoundingClientRect(); + return { text: label.textContent, center: left + width / 2 }; + }); + const barGroups = Object.values( + [ + ...container.querySelectorAll( + ".apexcharts-bar-area", + ), + ].reduce>((groups, bar) => { + const index = bar.getAttribute("j") ?? ""; + const { left, width } = bar.getBoundingClientRect(); + const centers = groups[index] ?? []; + centers.push(left + width / 2); + groups[index] = centers; + return groups; + }, {}), + ).map( + (centers) => centers.reduce((sum, x) => sum + x, 0) / centers.length, + ); const annotated = [ ...container.querySelectorAll( ".apexcharts-xaxis-annotations, .apexcharts-yaxis-annotations", @@ -84,6 +110,16 @@ async function renderChart(page: Page, fixture: string) { failures, type: rendered?.w.config.chart.type ?? null, stacked: rendered?.w.config.chart.stacked ?? null, + xaxis: { + type: rendered?.w.config.xaxis.type ?? null, + tickAmount: rendered?.w.config.xaxis.tickAmount ?? null, + }, + generatedLabels: rendered?.w.globals.labels ?? [], + axisLabels, + dataLabels: [ + ...container.querySelectorAll(".apexcharts-datalabel"), + ].map((label) => label.textContent), + barGroups, series, drawnPerSeries, shapes, @@ -104,6 +140,80 @@ const fills = (chart: Awaited>) => return `#${hex.join("")}`; }); +test("positions complete numeric bar series on an explicit numeric axis (#733)", async ({ + page, +}) => { + const chart = await renderChart(page, "numeric-axis"); + const xs = Array.from({ length: 12 }, (_, index) => index + 1); + + expect(chart.failures).toEqual([]); + expect(chart.xaxis).toEqual({ type: "numeric", tickAmount: null }); + expect(chart.generatedLabels).toEqual(xs); + expect(chart.axisLabels.map(({ text }) => Number(text))).toEqual(xs); + expect(chart.dataLabels.map(Number)).toEqual([...xs, ...xs]); + expect(chart.barGroups).toHaveLength(xs.length); + for (const [index, label] of chart.axisLabels.entries()) + expect(Math.abs(label.center - chart.barGroups[index])).toBeLessThan(1); +}); + +test("keeps irregular numeric x values proportionately spaced", async ({ + page, +}) => { + const chart = await renderChart(page, "numeric-axis-irregular"); + + expect(chart.failures).toEqual([]); + expect(chart.xaxis.type).toBe("numeric"); + expect(chart.generatedLabels).toEqual([0.25, 1.63, 3.01]); + expect(chart.axisLabels.map(({ text }) => text)).toEqual([ + "0.3", + "1.6", + "3.0", + ]); + expect(chart.axisLabels.map(({ text }) => text)).not.toContain("2"); + expect(chart.barGroups[2] - chart.barGroups[1]).toBeGreaterThan( + 5 * (chart.barGroups[1] - chart.barGroups[0]), + ); +}); + +test("keeps an explicit x interval count", async ({ page }) => { + const chart = await renderChart(page, "numeric-axis-xticks"); + + expect(chart.failures).toEqual([]); + expect(chart.xaxis).toEqual({ type: "numeric", tickAmount: 2 }); +}); + +test("keeps text x values as categories", async ({ page }) => { + const chart = await renderChart(page, "index"); + + expect(chart.xaxis.type).toBe("category"); + expect(chart.generatedLabels).toEqual(["Mon", "Tue", "Wed"]); +}); + +test("keeps time series on a datetime axis", async ({ page }) => { + const chart = await renderChart(page, "unstacked-time-series"); + + expect(chart.xaxis.type).toBe("datetime"); +}); + +test("keeps numeric horizontal bars on their category-oriented axis", async ({ + page, +}) => { + const chart = await renderChart(page, "numeric-horizontal-bar"); + + expect(chart.xaxis.type).toBeNull(); + expect(chart.shapes).toHaveLength(3); +}); + +test("keeps a rangeBar timeline on its datetime value axis", async ({ + page, +}) => { + const chart = await renderChart(page, "range-bar"); + + expect(chart.type).toBe("rangeBar"); + expect(chart.xaxis.type).toBe("datetime"); + expect(chart.shapes).toHaveLength(2); +}); + test("draws a column chart as a vertical bar chart", async ({ page }) => { const chart = await renderChart(page, "column"); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index 4a437f61..dc981d77 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -13,6 +13,7 @@ const { align_series, align_series_for, merged_x_values, + xaxis_type_for, } = require("../../sqlpage/apexcharts.js"); const ADDS_NOTHING_TO_THE_STACK = 0; @@ -32,6 +33,32 @@ type Series = { name: string; data: Point[] }; const series = (name: string, ...data: Point[]): Series => ({ name, data }); const xs = (s: Series) => s.data.map((p) => p.x); +test("uses a continuous axis for numeric Cartesian x values", () => { + const numeric = [series("a", { x: 1, y: 1 }, { x: 12, y: 12 })]; + + for (const type of ["line", "area", "bar", "scatter", "bubble"]) + assert.equal(xaxis_type_for(numeric, type, false, false), "numeric"); +}); + +test("keeps text and time x values on their respective axes", () => { + assert.equal( + xaxis_type_for([series("a", { x: "Q1", y: 1 })], "bar", false, false), + "category", + ); + assert.equal( + xaxis_type_for([series("a", { x: 1, y: 1 })], "bar", true, false), + "datetime", + ); +}); + +test("does not turn category-oriented charts into numeric axes", () => { + const numeric = [series("a", { x: 1, y: 1 })]; + + for (const type of ["heatmap", "rangeBar", "pie", "treemap"]) + assert.equal(xaxis_type_for(numeric, type, false, false), undefined); + assert.equal(xaxis_type_for(numeric, "bar", false, true), undefined); +}); + test("merged_x_values keeps the order the series agree on", () => { const merged = merged_x_values([ series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }, { x: "Q3", y: 3 }), From 803d3d1f3aa5bc691acb8bfa7922c512eeba593c Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 14:27:16 +0000 Subject: [PATCH 2/3] fix(changelog) :: leave released notes unchanged --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c29440..cc1c6e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## v0.46.1 -- Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. - Upgraded the bundled ApexCharts from v5.13.0 to [v7.1.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.1.0) and the Tabler core from v1.4.0 to v1.5.0. The ApexCharts upgrade fixes logarithmic-axis scaling, stacked baselines on irregular data, and annotations on charts with no data, and ships a smaller default bundle. - Fixed modal dialog boxes appearing behind their backdrop, which made them impossible to close by clicking their close button. Tabler 1.5 sets `contain: layout` on the page container, which broke the fixed positioning of modals rendered inside it; modals are now moved to the top level of the page, as recommended by Bootstrap. - Fixed a regression introduced in v0.46 that could replace a variable with `NULL` while building a value that also used database expressions and `sqlpage.*` functions. For example, this API request could lose `john.doe` and produce a URL ending at `https://api.example.com/`: From fd74fde3e9a3119032f88d07eeb50fd8d7ddd759 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 22:28:34 +0000 Subject: [PATCH 3/3] fix(docs) :: clarify chart tick semantics --- CHANGELOG.md | 4 ++++ .../official-site/sqlpage/migrations/01_documentation.sql | 2 +- sqlpage/apexcharts.js | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1c6e9c..eb4c7032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG.md +## unreleased + +- Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. + ## v0.46.1 - Upgraded the bundled ApexCharts from v5.13.0 to [v7.1.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.1.0) and the Tabler core from v1.4.0 to v1.5.0. The ApexCharts upgrade fixes logarithmic-axis scaling, stacked baselines on irregular data, and annotations on charts with no data, and ships a smaller default bundle. diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index cd512663..ce61b247 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -669,7 +669,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('xtitle', 'Title of the x axis, displayed below it.', 'TEXT', TRUE, TRUE), ('ytitle', 'Title of the y axis, displayed to its left.', 'TEXT', TRUE, TRUE), ('ztitle', 'Title of the z axis, displayed in tooltips.', 'TEXT', TRUE, TRUE), - ('xticks', 'Number of intervals on the x axis (one less than the number of ticks). Automatic selection usually works best. A point-count workaround such as count(distinct x) - 1 is only accurate for evenly spaced numeric x values; irregular numeric values remain positioned on a continuous scale.', 'INTEGER', TRUE, TRUE), + ('xticks', 'Number of intervals used to generate a numeric x-axis, normally producing one more tick position. On category and time axes, this is a target for label density, so the visible label count may differ.', 'INTEGER', TRUE, TRUE), ('yticks', 'Number of ticks on the y axis.', 'INTEGER', TRUE, TRUE), ('ystep', 'Step between ticks on the y axis.', 'REAL', TRUE, TRUE), ('marker', 'Marker size', 'REAL', TRUE, TRUE), diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 969c409e..b822f9e1 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -386,7 +386,8 @@ sqlpage_chart = (() => { series, }; if (labels) options.labels = labels; - // tickamount is the number of intervals, not the number of ticks + // Numeric axes count intervals; category and time axes use tickAmount as a + // target for label density. if (data.xticks) options.xaxis.tickAmount = data.xticks; const chart = new ApexCharts(chartContainer, options); chart.render();