Skip to content

Commit 2c2819f

Browse files
authored
feat(chart) :: draw horizontal reference lines (#1375)
* feat(chart) :: draw horizontal reference lines * rev 2 (please squash + merge)
1 parent 6fb9a35 commit 2c2819f

5 files changed

Lines changed: 162 additions & 5 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 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.
1718

1819
## v0.45
1920

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,9 +685,11 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
685685
('x', 'The value of the point on the horizontal axis', 'REAL', FALSE, FALSE),
686686
('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE),
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),
688-
('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE),
688+
('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),
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+
('color', 'The name of a color for the reference line this row draws. Grey by default.', 'COLOR', FALSE, TRUE)
691693
) x;
692694
INSERT INTO example(component, description, properties) VALUES
693695
('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.
792794
{"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]}
793795
]')),
794796
('chart', '
797+
## Reference lines
798+
799+
A row with a `yline` is not plotted as a data point, but drawn as a line across
800+
the whole chart, at that value of the y axis. Use it for the limit that the data
801+
should be read against: a disk quota, an alarm threshold, a service level
802+
objective.
803+
804+
Reference lines are rows, so they come from a query like everything else,
805+
and a chart can have as many of them as the query returns:
806+
807+
```sql
808+
select ''chart'' as component, ''CPU temperature'' as title, true as time, 100 as ymax;
809+
select celsius as yline, name as label, color as color from thresholds;
810+
select measured_at as x, celsius as y from readings order by measured_at;
811+
```
812+
813+
They are drawn as annotations rather than as an extra series, so they are not
814+
added to the total of a `stacked` chart, and are not filled in an `area` chart.
815+
816+
A line outside of the y axis is not drawn, and does not stretch the axis to fit,
817+
so set `ymax` when the limit is above the data.
818+
', json('[
819+
{"component":"chart", "title": "CPU temperature", "type": "line", "time": true,
820+
"ytitle": "°C", "ymax": 100, "color": "azure", "marker": 4},
821+
{"yline": 70, "label": "target", "color": "green"},
822+
{"yline": 90, "label": "throttling", "color": "red"},
823+
{"x": "2024-05-01T08:00:00Z", "y": 52},
824+
{"x": "2024-05-01T09:00:00Z", "y": 58},
825+
{"x": "2024-05-01T10:00:00Z", "y": 71},
826+
{"x": "2024-05-01T11:00:00Z", "y": 83},
827+
{"x": "2024-05-01T12:00:00Z", "y": 94},
828+
{"x": "2024-05-01T13:00:00Z", "y": 76},
829+
{"x": "2024-05-01T14:00:00Z", "y": 63}
830+
]')),
831+
('chart', '
832+
## Reference lines follow their axis
833+
834+
A reference belongs to the column it is written in, not to a direction on the
835+
screen: `yline` always marks a value of `y`, whichever way round the chart is
836+
drawn. A `horizontal` bar chart runs its y axis from left to right, so a `yline`
837+
is drawn down the chart rather than across it.
838+
839+
```sql
840+
select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax;
841+
select 90 as yline, ''full'' as label, ''red'' as color;
842+
select host as x, percent_used as y from disks order by percent_used;
843+
```
844+
845+
A `pie` has no axes, and ignores reference lines.
846+
', json('[
847+
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
848+
"ymax": 100, "color": "azure", "labels": true},
849+
{"yline": 90, "label": "full", "color": "red"},
850+
{"x": "backup-1", "y": 41},
851+
{"x": "web-2", "y": 63},
852+
{"x": "db-1", "y": 88},
853+
{"x": "web-1", "y": 96}
854+
]')),
855+
('chart', '
795856
## Multiple charts on the same line
796857
797858
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.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.label)
150+
annotation.label = {
151+
text: row.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+
"label": {{~stringify label}}, "color": {{~stringify 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)