feat(chart) :: draw vertical reference lines (#1376)

This commit is contained in:
Prayag Bhakar
2026-08-24 10:31:23 -04:00
committed by GitHub
parent f7ebba6230
commit f454791323
6 changed files with 152 additions and 41 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
- List-valued configuration options, including OIDC paths and trusted audiences, can now be set through environment variables as space-separated lists.
- `sqlpage.fetch_with_meta` now correctly documents server JSON responses sent under `json_body`, not `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
@@ -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
@@ -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},
+35 -26
View File
@@ -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" },
},
},
];
});
}
@@ -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,
+2 -1
View File
@@ -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}}
}
+51 -1
View File
@@ -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],
@@ -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 }) => {
@@ -127,6 +134,21 @@ 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,
@@ -134,6 +156,7 @@ async function renderChart(
series,
drawnPerSeries,
shapes,
referenceLines,
};
},
{ chart, rows },
@@ -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"]);
});
+25 -8
View File
@@ -91,7 +91,24 @@ 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", {
@@ -99,13 +116,13 @@ test("chart draws a yline down a horizontal chart", async ({ page }) => {
});
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 }) => {