feat(chart) :: draw horizontal reference lines (#1375)
* feat(chart) :: draw horizontal reference lines * rev 2 (please squash + merge)
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
- `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.
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
## v0.45
|
||||
|
||||
|
||||
@@ -685,9 +685,11 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
|
||||
('x', 'The value of the point on the horizontal axis', '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"', '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),
|
||||
('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)
|
||||
('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),
|
||||
('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
|
||||
('chart', 'An area chart representing a time series, using the top-level property `time`.
|
||||
@@ -792,6 +794,65 @@ The `color` property sets the color of each series separately, in order.
|
||||
{"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]}
|
||||
]')),
|
||||
('chart', '
|
||||
## Reference lines
|
||||
|
||||
A row with a `yline` is not plotted as a data point, but drawn as a line across
|
||||
the whole chart, at that value of the y axis. Use it for the limit that the data
|
||||
should be read against: a disk quota, an alarm threshold, a service level
|
||||
objective.
|
||||
|
||||
Reference lines are rows, so they come from a query like everything else,
|
||||
and a chart can have as many of them as the query returns:
|
||||
|
||||
```sql
|
||||
select ''chart'' as component, ''CPU temperature'' as title, true as time, 100 as ymax;
|
||||
select celsius as yline, name as label, color as color from thresholds;
|
||||
select measured_at as x, celsius as y from readings order by measured_at;
|
||||
```
|
||||
|
||||
They are drawn as annotations rather than as an extra series, so they are not
|
||||
added to the total of a `stacked` chart, and are not filled in an `area` chart.
|
||||
|
||||
A line outside of the y axis is not drawn, and does not stretch the axis to fit,
|
||||
so set `ymax` when the limit is above the data.
|
||||
', json('[
|
||||
{"component":"chart", "title": "CPU temperature", "type": "line", "time": true,
|
||||
"ytitle": "°C", "ymax": 100, "color": "azure", "marker": 4},
|
||||
{"yline": 70, "label": "target", "color": "green"},
|
||||
{"yline": 90, "label": "throttling", "color": "red"},
|
||||
{"x": "2024-05-01T08:00:00Z", "y": 52},
|
||||
{"x": "2024-05-01T09:00:00Z", "y": 58},
|
||||
{"x": "2024-05-01T10:00:00Z", "y": 71},
|
||||
{"x": "2024-05-01T11:00:00Z", "y": 83},
|
||||
{"x": "2024-05-01T12:00:00Z", "y": 94},
|
||||
{"x": "2024-05-01T13:00:00Z", "y": 76},
|
||||
{"x": "2024-05-01T14:00:00Z", "y": 63}
|
||||
]')),
|
||||
('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.
|
||||
|
||||
```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 host as x, percent_used as y from disks order by percent_used;
|
||||
```
|
||||
|
||||
A `pie` has no axes, and ignores reference lines.
|
||||
', json('[
|
||||
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
|
||||
"ymax": 100, "color": "azure", "labels": true},
|
||||
{"yline": 90, "label": "full", "color": "red"},
|
||||
{"x": "backup-1", "y": 41},
|
||||
{"x": "web-2", "y": 63},
|
||||
{"x": "db-1", "y": 88},
|
||||
{"x": "web-1", "y": 96}
|
||||
]')),
|
||||
('chart', '
|
||||
## Multiple charts on the same line
|
||||
|
||||
You can create information-dense dashboards by using the [card component](?component=card#component)
|
||||
|
||||
+59
-3
@@ -118,6 +118,45 @@ sqlpage_chart = (() => {
|
||||
if (typeof module !== "undefined")
|
||||
module.exports = { align_series, align_series_for, merged_x_values };
|
||||
|
||||
const referenceColor = colorNames[isDarkTheme ? "gray-lt" : "gray"];
|
||||
|
||||
/** @typedef { {[property:string]: string|number|null} } ReferenceLine */
|
||||
|
||||
/** @param {string|number|null} name */
|
||||
const reference_color = (name) =>
|
||||
(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 {(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) {
|
||||
return rows.flatMap((row) => {
|
||||
if (row.yline == null) return [];
|
||||
const from = to_axis_value(row.yline);
|
||||
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",
|
||||
borderColor: color,
|
||||
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
|
||||
};
|
||||
return [annotation];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} c */
|
||||
function build_sqlpage_chart(c) {
|
||||
const [data_element] = c.getElementsByTagName("data");
|
||||
@@ -131,9 +170,11 @@ sqlpage_chart = (() => {
|
||||
APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line";
|
||||
const is_stacked =
|
||||
!!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type);
|
||||
const points = data.points.filter(Array.isArray);
|
||||
const reference_rows = data.points.filter((row) => !Array.isArray(row));
|
||||
/** @type { Series } */
|
||||
const series_map = {};
|
||||
for (const [name, old_x, old_y, z] of data.points) {
|
||||
for (const [name, old_x, old_y, z] of points) {
|
||||
series_map[name] = series_map[name] || { name, data: [] };
|
||||
let x = old_x;
|
||||
let y = old_y;
|
||||
@@ -161,12 +202,27 @@ sqlpage_chart = (() => {
|
||||
let labels;
|
||||
const categories = x_is_text(series);
|
||||
if (chart_type === "pie") {
|
||||
labels = data.points.map(([name, x, _y]) => x || name);
|
||||
series = data.points.map(([_name, _x, y]) => Number.parseFloat(y));
|
||||
labels = points.map(([name, x, _y]) => x || name);
|
||||
series = points.map(([_name, _x, y]) => Number.parseFloat(y));
|
||||
} 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 inverted =
|
||||
chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal);
|
||||
const value_axis = inverted ? "x" : "y";
|
||||
const options = {
|
||||
annotations: {
|
||||
[`${value_axis}axis`]: y_reference_lines(
|
||||
reference_rows,
|
||||
value_axis,
|
||||
to_value,
|
||||
),
|
||||
},
|
||||
chart: {
|
||||
type: chart_type,
|
||||
fontFamily: "inherit",
|
||||
|
||||
@@ -40,12 +40,19 @@
|
||||
"points": [
|
||||
{{~#each_row~}}
|
||||
{{~#if (gt @row_index 0)}},{{/if~}}
|
||||
{{~#if yline~}}
|
||||
{
|
||||
"yline": {{~stringify yline}},
|
||||
"label": {{~stringify label}}, "color": {{~stringify color}}
|
||||
}
|
||||
{{~else~}}
|
||||
[
|
||||
{{~ stringify (default series (default ../title "")) ~}},
|
||||
{{~ stringify (default x label) ~}},
|
||||
{{~ stringify (default y value) ~}}
|
||||
{{~#if z}}, {{~ stringify z ~}} {{~/if~}}
|
||||
]
|
||||
{{~/if~}}
|
||||
{{~/each_row~}}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,38 @@ test("stacked chart raises a series only where it has a value", async ({
|
||||
expect(Number(gpu[1].y)).toBeLessThan(Number(cpu[1].y));
|
||||
});
|
||||
|
||||
test("chart draws a reference line for every yline", async ({ page }) => {
|
||||
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
|
||||
|
||||
const temperature = page.locator(".card", {
|
||||
has: page.getByRole("heading", { name: "CPU temperature" }),
|
||||
});
|
||||
await expect(temperature.locator(".apexcharts-canvas")).toBeVisible();
|
||||
|
||||
const annotations = temperature.locator(".apexcharts-yaxis-annotations");
|
||||
|
||||
await expect(annotations.locator("line")).toHaveCount(2);
|
||||
await expect(annotations.getByText("target")).toBeVisible();
|
||||
await expect(annotations.getByText("throttling")).toBeVisible();
|
||||
});
|
||||
|
||||
test("chart draws a yline down a horizontal chart", 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();
|
||||
});
|
||||
|
||||
test("map", async ({ page }) => {
|
||||
await page.goto(`${BASE}/documentation.sql?component=map#component`);
|
||||
await expect(page.getByText("Loading...")).not.toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user