Skip to content

Commit c4b3fcb

Browse files
committed
feat(chart) :: draw horizontal reference lines
1 parent 071d4df commit c4b3fcb

5 files changed

Lines changed: 162 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart.
1515
- Screen readers now announce the title of the modal component instead of an unnamed dialog.
1616
- `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.
17+
- 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 `yline_label` and `yline_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.
1718

1819
## v0.45
1920

examples/official-site/sqlpage/migrations/01_documentation.sql

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,10 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
687687
('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),
688688
('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE),
689689
('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE),
690-
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE)
690+
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE),
691+
('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),
692+
('yline_label', 'A text to display next to the yline.', 'TEXT', FALSE, TRUE),
693+
('yline_color', 'The name of a color for the yline. Grey by default.', 'COLOR', FALSE, TRUE)
691694
) x;
692695
INSERT INTO example(component, description, properties) VALUES
693696
('chart', 'An area chart representing a time series, using the top-level property `time`.
@@ -792,6 +795,65 @@ The `color` property sets the color of each series separately, in order.
792795
{"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]}
793796
]')),
794797
('chart', '
798+
## Reference lines
799+
800+
A row with a `yline` is not plotted as a data point, but drawn as a line across
801+
the whole chart, at that value of the y axis. Use it for the limit that the data
802+
should be read against: a disk quota, an alarm threshold, a service level
803+
objective.
804+
805+
Reference lines are rows, so they come from a query like everything else,
806+
and a chart can have as many of them as the query returns:
807+
808+
```sql
809+
select ''chart'' as component, ''CPU temperature'' as title, true as time, 100 as ymax;
810+
select celsius as yline, name as yline_label, color as yline_color from thresholds;
811+
select measured_at as x, celsius as y from readings order by measured_at;
812+
```
813+
814+
They are drawn as annotations rather than as an extra series, so they are not
815+
added to the total of a `stacked` chart, and are not filled in an `area` chart.
816+
817+
A line outside of the y axis is not drawn, and does not stretch the axis to fit,
818+
so set `ymax` when the limit is above the data.
819+
', json('[
820+
{"component":"chart", "title": "CPU temperature", "type": "line", "time": true,
821+
"ytitle": "°C", "ymax": 100, "color": "azure", "marker": 4},
822+
{"yline": 70, "yline_label": "target", "yline_color": "green"},
823+
{"yline": 90, "yline_label": "throttling", "yline_color": "red"},
824+
{"x": "2024-05-01T08:00:00Z", "y": 52},
825+
{"x": "2024-05-01T09:00:00Z", "y": 58},
826+
{"x": "2024-05-01T10:00:00Z", "y": 71},
827+
{"x": "2024-05-01T11:00:00Z", "y": 83},
828+
{"x": "2024-05-01T12:00:00Z", "y": 94},
829+
{"x": "2024-05-01T13:00:00Z", "y": 76},
830+
{"x": "2024-05-01T14:00:00Z", "y": 63}
831+
]')),
832+
('chart', '
833+
## Reference lines follow their axis
834+
835+
A reference belongs to the column it is written in, not to a direction on the
836+
screen: `yline` always marks a value of `y`, whichever way round the chart is
837+
drawn. A `horizontal` bar chart runs its y axis from left to right, so a `yline`
838+
is drawn down the chart rather than across it.
839+
840+
```sql
841+
select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax;
842+
select 90 as yline, ''full'' as yline_label, ''red'' as yline_color;
843+
select host as x, percent_used as y from disks order by percent_used;
844+
```
845+
846+
A `pie` has no axes, and ignores reference lines.
847+
', json('[
848+
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
849+
"ymax": 100, "color": "azure", "labels": true},
850+
{"yline": 90, "yline_label": "full", "yline_color": "red"},
851+
{"x": "backup-1", "y": 41},
852+
{"x": "web-2", "y": 63},
853+
{"x": "db-1", "y": 88},
854+
{"x": "web-1", "y": 96}
855+
]')),
856+
('chart', '
795857
## Multiple charts on the same line
796858
797859
You can create information-dense dashboards by using the [card component](?component=card#component)

sqlpage/apexcharts.js

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,45 @@ sqlpage_chart = (() => {
118118
if (typeof module !== "undefined")
119119
module.exports = { align_series, align_series_for, merged_x_values };
120120

121+
const referenceColor = colorNames[isDarkTheme ? "gray-lt" : "gray"];
122+
123+
/** @typedef { {[property:string]: string|number|null} } ReferenceLine */
124+
125+
/** @param {string|number|null} name */
126+
const reference_color = (name) =>
127+
(typeof name === "string" && colorNames[name]) || referenceColor;
128+
129+
/**
130+
* @param {ReferenceLine[]} rows - the rows that carry a yline
131+
* @param {"x"|"y"} axis - the apexcharts axis the y column is drawn on
132+
* @param {(value: any) => any} to_axis_value - puts a SQL value on the axis
133+
* @returns {object[]} apexcharts axis annotations
134+
*/
135+
function y_reference_lines(rows, axis, to_axis_value) {
136+
return rows.flatMap((row) => {
137+
if (row.yline == null) return [];
138+
const from = to_axis_value(row.yline);
139+
if (Number.isNaN(from)) return [];
140+
const color = reference_color(row.yline_color);
141+
const annotation = {
142+
[axis]: from,
143+
borderColor: color,
144+
fillColor: color,
145+
strokeDashArray: 4,
146+
};
147+
// apexcharts reads label.text unconditionally, so an annotation without
148+
// a label must not have the key at all.
149+
if (row.yline_label)
150+
annotation.label = {
151+
text: row.yline_label,
152+
orientation: "horizontal",
153+
borderColor: color,
154+
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
155+
};
156+
return [annotation];
157+
});
158+
}
159+
121160
/** @param {HTMLElement} c */
122161
function build_sqlpage_chart(c) {
123162
const [data_element] = c.getElementsByTagName("data");
@@ -131,9 +170,11 @@ sqlpage_chart = (() => {
131170
APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line";
132171
const is_stacked =
133172
!!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type);
173+
const points = data.points.filter(Array.isArray);
174+
const reference_rows = data.points.filter((row) => !Array.isArray(row));
134175
/** @type { Series } */
135176
const series_map = {};
136-
for (const [name, old_x, old_y, z] of data.points) {
177+
for (const [name, old_x, old_y, z] of points) {
137178
series_map[name] = series_map[name] || { name, data: [] };
138179
let x = old_x;
139180
let y = old_y;
@@ -161,12 +202,27 @@ sqlpage_chart = (() => {
161202
let labels;
162203
const categories = x_is_text(series);
163204
if (chart_type === "pie") {
164-
labels = data.points.map(([name, x, _y]) => x || name);
165-
series = data.points.map(([_name, _x, y]) => Number.parseFloat(y));
205+
labels = points.map(([name, x, _y]) => x || name);
206+
series = points.map(([_name, _x, y]) => Number.parseFloat(y));
166207
} else if (series.length > 1)
167208
series = align_series_for(series, chart_type, is_stacked);
168209

210+
const to_value =
211+
is_timeseries && chart_type === "rangeBar"
212+
? (v) =>
213+
(typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime()
214+
: Number;
215+
const inverted =
216+
chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal);
217+
const value_axis = inverted ? "x" : "y";
169218
const options = {
219+
annotations: {
220+
[`${value_axis}axis`]: y_reference_lines(
221+
reference_rows,
222+
value_axis,
223+
to_value,
224+
),
225+
},
170226
chart: {
171227
type: chart_type,
172228
fontFamily: "inherit",

sqlpage/templates/chart.handlebars

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,19 @@
4040
"points": [
4141
{{~#each_row~}}
4242
{{~#if (gt @row_index 0)}},{{/if~}}
43+
{{~#if yline~}}
44+
{
45+
"yline": {{~stringify yline}},
46+
"yline_label": {{~stringify yline_label}}, "yline_color": {{~stringify yline_color}}
47+
}
48+
{{~else~}}
4349
[
4450
{{~ stringify (default series (default ../title "")) ~}},
4551
{{~ stringify (default x label) ~}},
4652
{{~ stringify (default y value) ~}}
4753
{{~#if z}}, {{~ stringify z ~}} {{~/if~}}
4854
]
55+
{{~/if~}}
4956
{{~/each_row~}}
5057
]
5158
}

tests/end-to-end/official-site.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,38 @@ test("stacked chart raises a series only where it has a value", async ({
7676
expect(Number(gpu[1].y)).toBeLessThan(Number(cpu[1].y));
7777
});
7878

79+
test("chart draws a reference line for every yline", async ({ page }) => {
80+
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
81+
82+
const temperature = page.locator(".card", {
83+
has: page.getByRole("heading", { name: "CPU temperature" }),
84+
});
85+
await expect(temperature.locator(".apexcharts-canvas")).toBeVisible();
86+
87+
const annotations = temperature.locator(".apexcharts-yaxis-annotations");
88+
89+
await expect(annotations.locator("line")).toHaveCount(2);
90+
await expect(annotations.getByText("target")).toBeVisible();
91+
await expect(annotations.getByText("throttling")).toBeVisible();
92+
});
93+
94+
test("chart draws a yline down a horizontal chart", async ({ page }) => {
95+
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
96+
97+
const disks = page.locator(".card", {
98+
has: page.getByRole("heading", { name: "Disk usage" }),
99+
});
100+
await expect(disks.locator(".apexcharts-canvas")).toBeVisible();
101+
102+
await expect(disks.locator(".apexcharts-xaxis-annotations line")).toHaveCount(
103+
1,
104+
);
105+
await expect(disks.locator(".apexcharts-yaxis-annotations line")).toHaveCount(
106+
0,
107+
);
108+
await expect(disks.getByText("full")).toBeVisible();
109+
});
110+
79111
test("map", async ({ page }) => {
80112
await page.goto(`${BASE}/documentation.sql?component=map#component`);
81113
await expect(page.getByText("Loading...")).not.toBeVisible();

0 commit comments

Comments
 (0)