From 385cef4222198fb145be8e71276ad401f383bc59 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Thu, 10 Sep 2026 06:15:05 +0000 Subject: [PATCH 1/2] fix(chart) :: make xticks count tick positions --- CHANGELOG.md | 1 + .../official-site/sqlpage/migrations/01_documentation.sql | 2 +- frontend/src/apexcharts.js | 7 ++++--- tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql | 4 ++-- tests/end-to-end/fixtures/chart/test.ts | 3 ++- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2786e9b..70e269a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ## v0.46.2 - Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. +- `xticks` now represents the requested number of tick positions on numeric x-axes, rather than the number of intervals between them. ## v0.46.1 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index ce61b247a..8fe7f73a2 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 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), + ('xticks', 'Number of tick positions on a numeric x-axis. 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/frontend/src/apexcharts.js b/frontend/src/apexcharts.js index 820a72667..17d4c2490 100644 --- a/frontend/src/apexcharts.js +++ b/frontend/src/apexcharts.js @@ -299,9 +299,10 @@ const sqlpage_chart = (() => { series, }; if (labels) options.labels = labels; - // Numeric axes count intervals; category and time axes use tickAmount as a - // target for label density. - if (data.xticks) options.xaxis.tickAmount = data.xticks; + // ApexCharts counts numeric intervals rather than tick positions. + if (data.xticks) + options.xaxis.tickAmount = + xaxis_type === "numeric" ? Math.max(1, data.xticks - 1) : data.xticks; const chart = new ApexCharts( chartContainer, /** @type {import("apexcharts").ApexOptions} */ (options), diff --git a/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql b/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql index 9848a9a1a..667fa36ff 100644 --- a/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql +++ b/tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql @@ -1,9 +1,9 @@ SELECT 'chart' AS component, 'test-chart' AS id, - 'Explicit numeric x intervals' AS title, + 'Explicit numeric x tick count' AS title, 'bar' AS type, - 2 AS xticks; + 3 AS xticks; SELECT 'A' AS series, 1 AS x, 1 AS y UNION ALL SELECT 'A', 4, 4 diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index b2cdcba26..03a315115 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -175,11 +175,12 @@ test("keeps irregular numeric x values proportionately spaced", async ({ ); }); -test("keeps an explicit x interval count", async ({ page }) => { +test("renders the requested number of numeric x ticks", async ({ page }) => { const chart = await renderChart(page, "numeric-axis-xticks"); expect(chart.failures).toEqual([]); expect(chart.xaxis).toEqual({ type: "numeric", tickAmount: 2 }); + expect(chart.axisLabels).toHaveLength(3); }); test("keeps text x values as categories", async ({ page }) => { From 912c986775468eda3a3a25d83fd4714e852ef218 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Thu, 10 Sep 2026 08:01:51 +0000 Subject: [PATCH 2/2] refactor(chart): clarify x-axis helpers --- CHANGELOG.md | 2 +- .../sqlpage/migrations/01_documentation.sql | 2 +- frontend/src/apexcharts.js | 18 ++++----- frontend/src/chart_series.js | 24 ++++++++++-- tests/js/chart_series.spec.ts | 39 ++++++++++++++++--- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e269a61..70726a455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) +- `xticks` now represents the requested number of tick positions on numeric x-axes, rather than the number of intervals between them. - **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements. - Updated sqlx-oldapi to v0.6.57 to fix SQL Server fallback expressions such as `ISNULL($missing, 'default')` truncating defaults or failing for date values when the bound variable is `NULL`. - Fixed MSSQL `JSON_OBJECT('key': value)` expressions being rejected by SQLPage's parser, including when used in `SET` statements or nested in `sqlpage.*` function calls. @@ -18,7 +19,6 @@ ## v0.46.2 - Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. -- `xticks` now represents the requested number of tick positions on numeric x-axes, rather than the number of intervals between them. ## v0.46.1 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 8fe7f73a2..52c607042 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 tick positions on a numeric x-axis. On category and time axes, this is a target for label density, so the visible label count may differ.', 'INTEGER', TRUE, TRUE), + ('xticks', 'Number of tick positions on a numeric x-axis. For example, 5 requests five evenly spaced positions, including the two ends. On category and time axes, it asks ApexCharts to show up to that many labels; it may show fewer to keep labels from overlapping.', '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/frontend/src/apexcharts.js b/frontend/src/apexcharts.js index 17d4c2490..c4d466148 100644 --- a/frontend/src/apexcharts.js +++ b/frontend/src/apexcharts.js @@ -1,5 +1,9 @@ import ApexCharts from "apexcharts"; -import { align_series_for, xaxis_type_for } from "./chart_series.js"; +import { + align_series_for, + xaxis_tick_amount, + xaxis_type_for, +} from "./chart_series.js"; import { add_init_fn } from "./init.js"; /** @@ -138,12 +142,11 @@ const sqlpage_chart = (() => { let colors = palette; let series = [...series_map.values()]; - const xaxis_type = xaxis_type_for( - series, + const xaxis_type = xaxis_type_for(series, { chart_type, is_timeseries, - !!data.horizontal, - ); + is_horizontal: !!data.horizontal, + }); let labels; if (chart_type === "pie") { @@ -299,10 +302,7 @@ const sqlpage_chart = (() => { series, }; if (labels) options.labels = labels; - // ApexCharts counts numeric intervals rather than tick positions. - if (data.xticks) - options.xaxis.tickAmount = - xaxis_type === "numeric" ? Math.max(1, data.xticks - 1) : data.xticks; + options.xaxis.tickAmount = xaxis_tick_amount(data.xticks, xaxis_type); const chart = new ApexCharts( chartContainer, /** @type {import("apexcharts").ApexOptions} */ (options), diff --git a/frontend/src/chart_series.js b/frontend/src/chart_series.js index d3134101e..a7be008a4 100644 --- a/frontend/src/chart_series.js +++ b/frontend/src/chart_series.js @@ -20,12 +20,16 @@ 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"; -/** @param {ChartSeries[]} series @param {string} chart_type */ +/** + * Numeric x values need an explicit axis type to retain their proportional + * spacing; otherwise ApexCharts treats them as evenly spaced categories. + * + * @param {ChartSeries[]} series + * @param {{chart_type:string, is_timeseries:boolean, is_horizontal:boolean}} options + */ export function xaxis_type_for( series, - chart_type, - is_timeseries, - is_horizontal, + { chart_type, is_timeseries, is_horizontal }, ) { if (is_timeseries) return "datetime"; if (x_is_text(series)) return "category"; @@ -37,6 +41,18 @@ export function xaxis_type_for( return "numeric"; } +/** + * ApexCharts expects intervals for numeric axes, while SQLPage exposes the + * more intuitive number of tick positions to users. + * + * @param {number|undefined} xticks + * @param {string|undefined} xaxis_type + */ +export function xaxis_tick_amount(xticks, xaxis_type) { + if (!xticks) return; + return xaxis_type === "numeric" ? Math.max(1, xticks - 1) : xticks; +} + /** * @param {ChartSeries[]} series * @returns {XValue[]} every x the series hold, in their own order where they diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index 22dbd9acd..92e0ac0f4 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -28,16 +28,31 @@ 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"); + assert.equal( + xaxis_type_for(numeric, { + chart_type: type, + is_timeseries: false, + is_horizontal: 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), + xaxis_type_for([series("a", { x: "Q1", y: 1 })], { + chart_type: "bar", + is_timeseries: false, + is_horizontal: false, + }), "category", ); assert.equal( - xaxis_type_for([series("a", { x: 1, y: 1 })], "bar", true, false), + xaxis_type_for([series("a", { x: 1, y: 1 })], { + chart_type: "bar", + is_timeseries: true, + is_horizontal: false, + }), "datetime", ); }); @@ -46,8 +61,22 @@ 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); + assert.equal( + xaxis_type_for(numeric, { + chart_type: type, + is_timeseries: false, + is_horizontal: false, + }), + undefined, + ); + assert.equal( + xaxis_type_for(numeric, { + chart_type: "bar", + is_timeseries: false, + is_horizontal: true, + }), + undefined, + ); }); test("merged_x_values keeps the order the series agree on", () => {