Skip to content

Commit 27c1482

Browse files
committed
feat(chart) :: draw horizontal reference lines
1 parent cca79df commit 27c1482

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
@@ -12,6 +12,7 @@
1212
- `column` charts now display vertical bars instead of nothing at all.
1313
- `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart.
1414
- Screen readers now announce the title of the modal component instead of an unnamed dialog.
15+
- 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.
1516

1617
## v0.45
1718

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,10 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
675675
('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE),
676676
('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE),
677677
('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE),
678-
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE)
678+
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE),
679+
('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),
680+
('yline_label', 'A text to display next to the yline.', 'TEXT', FALSE, TRUE),
681+
('yline_color', 'The name of a color for the yline. Grey by default.', 'COLOR', FALSE, TRUE)
679682
) x;
680683
INSERT INTO example(component, description, properties) VALUES
681684
('chart', 'An area chart representing a time series, using the top-level property `time`.
@@ -780,6 +783,65 @@ The `color` property sets the color of each series separately, in order.
780783
{"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]}
781784
]')),
782785
('chart', '
786+
## Reference lines
787+
788+
A row with a `yline` is not plotted as a data point, but drawn as a line across
789+
the whole chart, at that value of the y axis. Use it for the limit that the data
790+
should be read against: a disk quota, an alarm threshold, a service level
791+
objective.
792+
793+
Reference lines are rows, so they come from a query like everything else,
794+
and a chart can have as many of them as the query returns:
795+
796+
```sql
797+
select ''chart'' as component, ''CPU temperature'' as title, true as time, 100 as ymax;
798+
select celsius as yline, name as yline_label, color as yline_color from thresholds;
799+
select measured_at as x, celsius as y from readings order by measured_at;
800+
```
801+
802+
They are drawn as annotations rather than as an extra series, so they are not
803+
added to the total of a `stacked` chart, and are not filled in an `area` chart.
804+
805+
A line outside of the y axis is not drawn, and does not stretch the axis to fit,
806+
so set `ymax` when the limit is above the data.
807+
', json('[
808+
{"component":"chart", "title": "CPU temperature", "type": "line", "time": true,
809+
"ytitle": "°C", "ymax": 100, "color": "azure", "marker": 4},
810+
{"yline": 70, "yline_label": "target", "yline_color": "green"},
811+
{"yline": 90, "yline_label": "throttling", "yline_color": "red"},
812+
{"x": "2024-05-01T08:00:00Z", "y": 52},
813+
{"x": "2024-05-01T09:00:00Z", "y": 58},
814+
{"x": "2024-05-01T10:00:00Z", "y": 71},
815+
{"x": "2024-05-01T11:00:00Z", "y": 83},
816+
{"x": "2024-05-01T12:00:00Z", "y": 94},
817+
{"x": "2024-05-01T13:00:00Z", "y": 76},
818+
{"x": "2024-05-01T14:00:00Z", "y": 63}
819+
]')),
820+
('chart', '
821+
## Reference lines follow their axis
822+
823+
A reference belongs to the column it is written in, not to a direction on the
824+
screen: `yline` always marks a value of `y`, whichever way round the chart is
825+
drawn. A `horizontal` bar chart runs its y axis from left to right, so a `yline`
826+
is drawn down the chart rather than across it.
827+
828+
```sql
829+
select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax;
830+
select 90 as yline, ''full'' as yline_label, ''red'' as yline_color;
831+
select host as x, percent_used as y from disks order by percent_used;
832+
```
833+
834+
A `pie` has no axes, and ignores reference lines.
835+
', json('[
836+
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
837+
"ymax": 100, "color": "azure", "labels": true},
838+
{"yline": 90, "yline_label": "full", "yline_color": "red"},
839+
{"x": "backup-1", "y": 41},
840+
{"x": "web-2", "y": 63},
841+
{"x": "db-1", "y": 88},
842+
{"x": "web-1", "y": 96}
843+
]')),
844+
('chart', '
783845
## Multiple charts on the same line
784846
785847
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
@@ -116,6 +116,45 @@ sqlpage_chart = (() => {
116116
if (typeof module !== "undefined")
117117
module.exports = { align_series, align_series_for, merged_x_values };
118118

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

206+
const to_value =
207+
is_timeseries && chart_type === "rangeBar"
208+
? (v) =>
209+
(typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime()
210+
: Number;
211+
const inverted =
212+
chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal);
213+
const value_axis = inverted ? "x" : "y";
165214
const options = {
215+
annotations: {
216+
[`${value_axis}axis`]: y_reference_lines(
217+
reference_rows,
218+
value_axis,
219+
to_value,
220+
),
221+
},
166222
chart: {
167223
type: chart_type,
168224
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
@@ -78,6 +78,38 @@ test("stacked chart raises a series only where it has a value", async ({
7878
expect(Number(gpu[1].y)).toBeLessThan(Number(cpu[1].y));
7979
});
8080

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

0 commit comments

Comments
 (0)