Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 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),
Expand All @@ -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),
Expand Down
34 changes: 29 additions & 5 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"];

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -303,7 +326,7 @@ sqlpage_chart = (() => {
title: {
text: data.xtitle || undefined,
},
type: is_timeseries ? "datetime" : categories ? "category" : undefined,
type: xaxis_type,
labels: {
datetimeUTC: false,
},
Expand Down Expand Up @@ -363,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();
Expand Down
10 changes: 10 additions & 0 deletions tests/end-to-end/fixtures/chart/numeric-axis-irregular.sql
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions tests/end-to-end/fixtures/chart/numeric-axis-xticks.sql
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions tests/end-to-end/fixtures/chart/numeric-axis.sql
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions tests/end-to-end/fixtures/chart/numeric-horizontal-bar.sql
Original file line number Diff line number Diff line change
@@ -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;
110 changes: 110 additions & 0 deletions tests/end-to-end/fixtures/chart/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[] };
};
}[];
}
Expand Down Expand Up @@ -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<SVGGraphicsElement>(
".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<SVGGraphicsElement>(
".apexcharts-bar-area",
),
].reduce<Record<string, number[]>>((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",
Expand All @@ -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,
Expand All @@ -104,6 +140,80 @@ const fills = (chart: Awaited<ReturnType<typeof renderChart>>) =>
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");

Expand Down
27 changes: 27 additions & 0 deletions tests/js/chart_series.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }),
Expand Down