Skip to content
Open
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: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
- Screen readers now announce the title of the modal component instead of an unnamed dialog.
- `sqlpage.request_body` and `sqlpage.request_body_base64` now return NULL when the request has no body. A body that cannot be read, such as one exceeding the payload limit, is now reported as an error instead of being silently replaced with an empty body.
- Datagrid rows with an icon or image no longer display an unnecessary en-dash placeholder, and an explicitly empty description remains empty.
- Charts can display reference lines. A row with a `yline` is drawn as a line across the chart at that value of the y axis, with the row's `label` and `color` for its text and its color. Reference lines are rows, so a chart can have as many of them as the query returns. A line follows its axis, so on a `horizontal` bar chart a `yline` is drawn down the chart rather than across it. They are not added to the total of a `stacked` chart, and are not filled in an `area` chart.
- Charts can display reference lines. A row with a `yline` is drawn as a line across the chart at that value of the y axis, and a row with `xline` marks a position on the x axis. `label` and `color` set the line's text and its color. Reference lines are rows, so a chart can have as many of them as the query returns. A line follows its axis, so on a `horizontal` bar chart a `yline` is drawn down the chart rather than across it. They are not added to the total of a `stacked` chart, and are not filled in an `area` chart.

## v0.45

Expand Down
42 changes: 38 additions & 4 deletions examples/official-site/sqlpage/migrations/01_documentation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE),
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE),
('yline', 'Draws a reference line across the chart at this value of the y axis instead of plotting a point, to show a limit such as a quota or an alarm threshold. Not drawn if it falls outside of the axis, so set ymax when the limit is above the data.', 'REAL', FALSE, TRUE),
('xline', 'Draws a reference line across the chart at this position of the x axis instead of plotting a point, to mark an event such as a deployment. A date or a timestamp when time is set, otherwise one of the x values.', 'TEXT', FALSE, TRUE),
('color', 'The name of a color for the reference line this row draws. Grey by default.', 'COLOR', FALSE, TRUE)
) x;
INSERT INTO example(component, description, properties) VALUES
Expand Down Expand Up @@ -829,24 +830,57 @@ so set `ymax` when the limit is above the data.
{"x": "2024-05-01T14:00:00Z", "y": 63}
]')),
('chart', '
## Marking events

`xline` is the counterpart of `yline`: it marks a position on the x axis instead
of a value on the y axis, for a moment rather than a limit. A single query can
draw a whole log of them:

```sql
select started_at as xline, summary as label,
case severity when ''outage'' then ''red'' else ''orange'' end as color
from deployments where started_at > $since;
```

When `time` is set, an `xline` is a date or a timestamp, written like the `x` of
a data point. On a chart with text labels on the x axis, it is one of those labels.
', json('[
{"component":"chart", "title": "Request latency", "type": "area", "time": true,
"ytitle": "ms", "color": "blue-lt", "marker": 3},
{"xline": "2024-05-01T10:00:00Z", "label": "deploy", "color": "green"},
{"xline": "2024-05-01T11:30:00Z", "label": "incident", "color": "red"},
{"x": "2024-05-01T08:00:00Z", "y": 120},
{"x": "2024-05-01T09:00:00Z", "y": 134},
{"x": "2024-05-01T10:00:00Z", "y": 128},
{"x": "2024-05-01T11:00:00Z", "y": 141},
{"x": "2024-05-01T12:00:00Z", "y": 512},
{"x": "2024-05-01T13:00:00Z", "y": 470},
{"x": "2024-05-01T14:00:00Z", "y": 156},
{"x": "2024-05-01T15:00:00Z", "y": 133}
]')),
('chart', '
## Reference lines follow their axis

A reference belongs to the column it is written in, not to a direction on the
screen: `yline` always marks a value of `y`, whichever way round the chart is
drawn. A `horizontal` bar chart runs its y axis from left to right, so a `yline`
is drawn down the chart rather than across it.
screen. `yline` always marks a value of `y`, and `xline` a position on `x`,
whichever way round the chart is drawn. A `horizontal` bar chart runs its y axis
from left to right, so a `yline` is drawn down the chart and an `xline` picks out
one of the bars.

```sql
select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax;
select 90 as yline, ''full'' as label, ''red'' as color;
select ''db-1'' as xline, ''watched'' as label, ''purple'' as color;
select host as x, percent_used as y from disks order by percent_used;
```

A `pie` has no axes, and ignores reference lines.
A `pie` has no axes and ignores reference lines, and on a `heatmap`, whose y axis
holds the names of the series, only `xline` has a meaning.
', json('[
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
"ymax": 100, "color": "azure", "labels": true},
{"yline": 90, "label": "full", "color": "red"},
{"xline": "db-1", "label": "watched", "color": "purple"},
{"x": "backup-1", "y": 41},
{"x": "web-2", "y": 63},
{"x": "db-1", "y": 88},
Expand Down
61 changes: 35 additions & 26 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,33 +127,33 @@ sqlpage_chart = (() => {
(typeof name === "string" && colorNames[name]) || referenceColor;

/**
* @param {ReferenceLine[]} rows - the rows that carry a yline
* @param {"x"|"y"} axis - the apexcharts axis the y column is drawn on
* @param {ReferenceLine[]} rows - the rows that carry an xline or a yline
* @param {"x"|"y"} column - the column the reference is written in
* @param {"x"|"y"} axis - the apexcharts axis that column is drawn on
* @param {(value: any) => any} to_axis_value - puts a SQL value on the axis
* @returns {object[]} apexcharts axis annotations
*/
function y_reference_lines(rows, axis, to_axis_value) {
function reference_lines(rows, column, axis, to_axis_value) {
return rows.flatMap((row) => {
if (row.yline == null) return [];
const from = to_axis_value(row.yline);
const value = row[`${column}line`];
if (value == null) return [];
const from = to_axis_value(value);
if (Number.isNaN(from)) return [];
const color = reference_color(row.color);
const annotation = {
[axis]: from,
borderColor: color,
fillColor: color,
strokeDashArray: 4,
};
// apexcharts reads label.text unconditionally, so an annotation without
// a label must not have the key at all.
if (row.label)
annotation.label = {
text: row.label,
orientation: "horizontal",
return [
{
[axis]: from,
borderColor: color,
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
};
return [annotation];
fillColor: color,
strokeDashArray: 4,
label: {
text: row.label,
orientation: column === "y" ? "horizontal" : "vertical",
borderColor: color,
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
},
},
];
});
}

Expand Down Expand Up @@ -207,21 +207,30 @@ sqlpage_chart = (() => {
} else if (series.length > 1)
series = align_series_for(series, chart_type, is_stacked);

const to_value =
is_timeseries && chart_type === "rangeBar"
? (v) =>
(typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime()
: Number;
const to_timestamp = (v) =>
(typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime();
const dates_are_values = is_timeseries && chart_type === "rangeBar";
const to_value = dates_are_values ? to_timestamp : Number;
const to_category =
is_timeseries && !dates_are_values ? to_timestamp : (v) => v;
const inverted =
chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal);
const value_axis = inverted ? "x" : "y";
const category_axis = inverted ? "y" : "x";
const options = {
annotations: {
[`${value_axis}axis`]: y_reference_lines(
[`${value_axis}axis`]: reference_lines(
reference_rows,
"y",
value_axis,
to_value,
),
[`${category_axis}axis`]: reference_lines(
reference_rows,
"x",
category_axis,
to_category,
),
},
chart: {
type: chart_type,
Expand Down
3 changes: 2 additions & 1 deletion sqlpage/templates/chart.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@
"points": [
{{~#each_row~}}
{{~#if (gt @row_index 0)}},{{/if~}}
{{~#if yline~}}
{{~#if (or xline yline)~}}
{
"xline": {{~stringify xline}},
"yline": {{~stringify yline}},
"label": {{~stringify label}}, "color": {{~stringify color}}
}
Expand Down
52 changes: 51 additions & 1 deletion tests/end-to-end/chart-component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ declare global {

type Row = [series: string, x: unknown, y: unknown, z?: unknown];

type ReferenceRow = {
xline?: string | number;
yline?: number;
label?: string;
color?: string;
};

const A_DAY_OF_WORK: Row[] = [
["Coding", "Mon", 6],
["Coding", "Tue", 4],
Expand Down Expand Up @@ -74,7 +81,7 @@ const B_UNTIL_THE_SECOND_CATEGORY: Row[] = [
async function renderChart(
page: Page,
chart: Record<string, unknown>,
rows: Row[],
rows: (Row | ReferenceRow)[],
) {
return page.evaluate(
({ chart, rows }) => {
Expand Down Expand Up @@ -127,13 +134,29 @@ async function renderChart(
return { x, y, width, height };
});

const annotated = [
...container.querySelectorAll(
".apexcharts-xaxis-annotations, .apexcharts-yaxis-annotations",
),
];
const count = (selector: string) =>
annotated.reduce((n, g) => n + g.querySelectorAll(selector).length, 0);
const referenceLines = {
lines: count("line"),
labelBoxes: count("rect"),
labelTexts: annotated.flatMap((g) =>
[...g.querySelectorAll("text")].map((t) => t.textContent),
),
};

return {
failures,
type: rendered?.w.config.chart.type ?? null,
stacked: rendered?.w.config.chart.stacked ?? null,
series,
drawnPerSeries,
shapes,
referenceLines,
};
},
{ chart, rows },
Expand Down Expand Up @@ -363,3 +386,30 @@ test("draws a rangeBar chart that asks to be stacked", async ({ page }) => {
expect(chart.shapes).toHaveLength(2);
expect(chart.stacked).toBe(false);
});

test("draws a reference line that carries no label", async ({ page }) => {
const chart = await renderChart(page, { type: "line" }, [
...A_IN_EVERY_QUARTER,
{ yline: 2 },
{ xline: "Q2" },
]);

expect(chart.failures).toEqual([]);
expect(chart.referenceLines.lines).toBe(2);
expect(chart.referenceLines.labelBoxes).toBe(0);
expect(chart.referenceLines.labelTexts).toEqual(["", ""]);
});

test("draws a box behind the label of a reference line that carries one", async ({
page,
}) => {
const chart = await renderChart(page, { type: "line" }, [
...A_IN_EVERY_QUARTER,
{ yline: 2, label: "limit" },
]);

expect(chart.failures).toEqual([]);
expect(chart.referenceLines.lines).toBe(1);
expect(chart.referenceLines.labelBoxes).toBe(1);
expect(chart.referenceLines.labelTexts).toEqual(["limit"]);
});
33 changes: 25 additions & 8 deletions tests/end-to-end/official-site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,38 @@ test("chart draws a reference line for every yline", async ({ page }) => {
await expect(annotations.getByText("throttling")).toBeVisible();
});

test("chart draws a yline down a horizontal chart", async ({ page }) => {
test("chart draws a reference line for every xline", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=chart#component`);

const latency = page.locator(".card", {
has: page.getByRole("heading", { name: "Request latency" }),
});
await expect(latency.locator(".apexcharts-canvas")).toBeVisible();

const annotations = latency.locator(".apexcharts-xaxis-annotations");

await expect(annotations.locator("line")).toHaveCount(2);
await expect(annotations.getByText("deploy")).toBeVisible();
await expect(annotations.getByText("incident")).toBeVisible();
});

test("horizontal chart draws a yline down it and an xline across it", async ({
page,
}) => {
await page.goto(`${BASE}/documentation.sql?component=chart#component`);

const disks = page.locator(".card", {
has: page.getByRole("heading", { name: "Disk usage" }),
});
await expect(disks.locator(".apexcharts-canvas")).toBeVisible();

await expect(disks.locator(".apexcharts-xaxis-annotations line")).toHaveCount(
1,
);
await expect(disks.locator(".apexcharts-yaxis-annotations line")).toHaveCount(
0,
);
await expect(disks.getByText("full")).toBeVisible();
const down = disks.locator(".apexcharts-xaxis-annotations");
const across = disks.locator(".apexcharts-yaxis-annotations");

await expect(down.locator("line")).toHaveCount(1);
await expect(down.getByText("full")).toBeVisible();
await expect(across.locator("line")).toHaveCount(1);
await expect(across.getByText("watched")).toBeVisible();
});

test("map", async ({ page }) => {
Expand Down