Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
29 changes: 17 additions & 12 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand All @@ -128,21 +135,20 @@ 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,
fontFamily: "inherit",
background: "transparent",
parentHeightOffset: 0,
height: chartContainer.style.height,
stacked: !!data.stacked,
stacked: is_stacked,
toolbar: {
show: !!data.toolbar,
},
Expand All @@ -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:
Expand Down Expand Up @@ -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();
Expand All @@ -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 },
Expand All @@ -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);
Expand Down
101 changes: 101 additions & 0 deletions tests/end-to-end/chart-component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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 = `<data hidden>${payload}</data><div class="chart" style="height:250px"></div>`;
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<SVGGraphicsElement>(
".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);
});