feat(chart) :: draw vertical reference lines (#1376)
This commit is contained in:
+1
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user