Skip to content

Commit f454791

Browse files
authored
feat(chart) :: draw vertical reference lines (#1376)
1 parent f7ebba6 commit f454791

6 files changed

Lines changed: 152 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
- List-valued configuration options, including OIDC paths and trusted audiences, can now be set through environment variables as space-separated lists.
2323
- `sqlpage.fetch_with_meta` now correctly documents server JSON responses sent under `json_body`, not `body`.
2424
- Datagrid rows with an icon or image no longer display an unnecessary en-dash placeholder, and an explicitly empty description remains empty.
25-
- 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.
25+
- 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.
2626

2727
## v0.45
2828

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
689689
('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE),
690690
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE),
691691
('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+
('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),
692693
('color', 'The name of a color for the reference line this row draws. Grey by default.', 'COLOR', FALSE, TRUE)
693694
) x;
694695
INSERT INTO example(component, description, properties) VALUES
@@ -829,24 +830,57 @@ so set `ymax` when the limit is above the data.
829830
{"x": "2024-05-01T14:00:00Z", "y": 63}
830831
]')),
831832
('chart', '
833+
## Marking events
834+
835+
`xline` is the counterpart of `yline`: it marks a position on the x axis instead
836+
of a value on the y axis, for a moment rather than a limit. A single query can
837+
draw a whole log of them:
838+
839+
```sql
840+
select started_at as xline, summary as label,
841+
case severity when ''outage'' then ''red'' else ''orange'' end as color
842+
from deployments where started_at > $since;
843+
```
844+
845+
When `time` is set, an `xline` is a date or a timestamp, written like the `x` of
846+
a data point. On a chart with text labels on the x axis, it is one of those labels.
847+
', json('[
848+
{"component":"chart", "title": "Request latency", "type": "area", "time": true,
849+
"ytitle": "ms", "color": "blue-lt", "marker": 3},
850+
{"xline": "2024-05-01T10:00:00Z", "label": "deploy", "color": "green"},
851+
{"xline": "2024-05-01T11:30:00Z", "label": "incident", "color": "red"},
852+
{"x": "2024-05-01T08:00:00Z", "y": 120},
853+
{"x": "2024-05-01T09:00:00Z", "y": 134},
854+
{"x": "2024-05-01T10:00:00Z", "y": 128},
855+
{"x": "2024-05-01T11:00:00Z", "y": 141},
856+
{"x": "2024-05-01T12:00:00Z", "y": 512},
857+
{"x": "2024-05-01T13:00:00Z", "y": 470},
858+
{"x": "2024-05-01T14:00:00Z", "y": 156},
859+
{"x": "2024-05-01T15:00:00Z", "y": 133}
860+
]')),
861+
('chart', '
832862
## Reference lines follow their axis
833863
834864
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.
865+
screen. `yline` always marks a value of `y`, and `xline` a position on `x`,
866+
whichever way round the chart is drawn. A `horizontal` bar chart runs its y axis
867+
from left to right, so a `yline` is drawn down the chart and an `xline` picks out
868+
one of the bars.
838869
839870
```sql
840871
select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax;
841872
select 90 as yline, ''full'' as label, ''red'' as color;
873+
select ''db-1'' as xline, ''watched'' as label, ''purple'' as color;
842874
select host as x, percent_used as y from disks order by percent_used;
843875
```
844876
845-
A `pie` has no axes, and ignores reference lines.
877+
A `pie` has no axes and ignores reference lines, and on a `heatmap`, whose y axis
878+
holds the names of the series, only `xline` has a meaning.
846879
', json('[
847880
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
848881
"ymax": 100, "color": "azure", "labels": true},
849882
{"yline": 90, "label": "full", "color": "red"},
883+
{"xline": "db-1", "label": "watched", "color": "purple"},
850884
{"x": "backup-1", "y": 41},
851885
{"x": "web-2", "y": 63},
852886
{"x": "db-1", "y": 88},

sqlpage/apexcharts.js

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -127,33 +127,33 @@ sqlpage_chart = (() => {
127127
(typeof name === "string" && colorNames[name]) || referenceColor;
128128

129129
/**
130-
* @param {ReferenceLine[]} rows - the rows that carry a yline
131-
* @param {"x"|"y"} axis - the apexcharts axis the y column is drawn on
130+
* @param {ReferenceLine[]} rows - the rows that carry an xline or a yline
131+
* @param {"x"|"y"} column - the column the reference is written in
132+
* @param {"x"|"y"} axis - the apexcharts axis that column is drawn on
132133
* @param {(value: any) => any} to_axis_value - puts a SQL value on the axis
133134
* @returns {object[]} apexcharts axis annotations
134135
*/
135-
function y_reference_lines(rows, axis, to_axis_value) {
136+
function reference_lines(rows, column, axis, to_axis_value) {
136137
return rows.flatMap((row) => {
137-
if (row.yline == null) return [];
138-
const from = to_axis_value(row.yline);
138+
const value = row[`${column}line`];
139+
if (value == null) return [];
140+
const from = to_axis_value(value);
139141
if (Number.isNaN(from)) return [];
140142
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",
143+
return [
144+
{
145+
[axis]: from,
153146
borderColor: color,
154-
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
155-
};
156-
return [annotation];
147+
fillColor: color,
148+
strokeDashArray: 4,
149+
label: {
150+
text: row.label,
151+
orientation: column === "y" ? "horizontal" : "vertical",
152+
borderColor: color,
153+
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
154+
},
155+
},
156+
];
157157
});
158158
}
159159

@@ -207,21 +207,30 @@ sqlpage_chart = (() => {
207207
} else if (series.length > 1)
208208
series = align_series_for(series, chart_type, is_stacked);
209209

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;
210+
const to_timestamp = (v) =>
211+
(typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime();
212+
const dates_are_values = is_timeseries && chart_type === "rangeBar";
213+
const to_value = dates_are_values ? to_timestamp : Number;
214+
const to_category =
215+
is_timeseries && !dates_are_values ? to_timestamp : (v) => v;
215216
const inverted =
216217
chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal);
217218
const value_axis = inverted ? "x" : "y";
219+
const category_axis = inverted ? "y" : "x";
218220
const options = {
219221
annotations: {
220-
[`${value_axis}axis`]: y_reference_lines(
222+
[`${value_axis}axis`]: reference_lines(
221223
reference_rows,
224+
"y",
222225
value_axis,
223226
to_value,
224227
),
228+
[`${category_axis}axis`]: reference_lines(
229+
reference_rows,
230+
"x",
231+
category_axis,
232+
to_category,
233+
),
225234
},
226235
chart: {
227236
type: chart_type,

sqlpage/templates/chart.handlebars

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@
4040
"points": [
4141
{{~#each_row~}}
4242
{{~#if (gt @row_index 0)}},{{/if~}}
43-
{{~#if yline~}}
43+
{{~#if (or xline yline)~}}
4444
{
45+
"xline": {{~stringify xline}},
4546
"yline": {{~stringify yline}},
4647
"label": {{~stringify label}}, "color": {{~stringify color}}
4748
}

tests/end-to-end/chart-component.spec.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ declare global {
2020

2121
type Row = [series: string, x: unknown, y: unknown, z?: unknown];
2222

23+
type ReferenceRow = {
24+
xline?: string | number;
25+
yline?: number;
26+
label?: string;
27+
color?: string;
28+
};
29+
2330
const A_DAY_OF_WORK: Row[] = [
2431
["Coding", "Mon", 6],
2532
["Coding", "Tue", 4],
@@ -74,7 +81,7 @@ const B_UNTIL_THE_SECOND_CATEGORY: Row[] = [
7481
async function renderChart(
7582
page: Page,
7683
chart: Record<string, unknown>,
77-
rows: Row[],
84+
rows: (Row | ReferenceRow)[],
7885
) {
7986
return page.evaluate(
8087
({ chart, rows }) => {
@@ -127,13 +134,29 @@ async function renderChart(
127134
return { x, y, width, height };
128135
});
129136

137+
const annotated = [
138+
...container.querySelectorAll(
139+
".apexcharts-xaxis-annotations, .apexcharts-yaxis-annotations",
140+
),
141+
];
142+
const count = (selector: string) =>
143+
annotated.reduce((n, g) => n + g.querySelectorAll(selector).length, 0);
144+
const referenceLines = {
145+
lines: count("line"),
146+
labelBoxes: count("rect"),
147+
labelTexts: annotated.flatMap((g) =>
148+
[...g.querySelectorAll("text")].map((t) => t.textContent),
149+
),
150+
};
151+
130152
return {
131153
failures,
132154
type: rendered?.w.config.chart.type ?? null,
133155
stacked: rendered?.w.config.chart.stacked ?? null,
134156
series,
135157
drawnPerSeries,
136158
shapes,
159+
referenceLines,
137160
};
138161
},
139162
{ chart, rows },
@@ -363,3 +386,30 @@ test("draws a rangeBar chart that asks to be stacked", async ({ page }) => {
363386
expect(chart.shapes).toHaveLength(2);
364387
expect(chart.stacked).toBe(false);
365388
});
389+
390+
test("draws a reference line that carries no label", async ({ page }) => {
391+
const chart = await renderChart(page, { type: "line" }, [
392+
...A_IN_EVERY_QUARTER,
393+
{ yline: 2 },
394+
{ xline: "Q2" },
395+
]);
396+
397+
expect(chart.failures).toEqual([]);
398+
expect(chart.referenceLines.lines).toBe(2);
399+
expect(chart.referenceLines.labelBoxes).toBe(0);
400+
expect(chart.referenceLines.labelTexts).toEqual(["", ""]);
401+
});
402+
403+
test("draws a box behind the label of a reference line that carries one", async ({
404+
page,
405+
}) => {
406+
const chart = await renderChart(page, { type: "line" }, [
407+
...A_IN_EVERY_QUARTER,
408+
{ yline: 2, label: "limit" },
409+
]);
410+
411+
expect(chart.failures).toEqual([]);
412+
expect(chart.referenceLines.lines).toBe(1);
413+
expect(chart.referenceLines.labelBoxes).toBe(1);
414+
expect(chart.referenceLines.labelTexts).toEqual(["limit"]);
415+
});

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,38 @@ test("chart draws a reference line for every yline", async ({ page }) => {
9191
await expect(annotations.getByText("throttling")).toBeVisible();
9292
});
9393

94-
test("chart draws a yline down a horizontal chart", async ({ page }) => {
94+
test("chart draws a reference line for every xline", async ({ page }) => {
95+
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
96+
97+
const latency = page.locator(".card", {
98+
has: page.getByRole("heading", { name: "Request latency" }),
99+
});
100+
await expect(latency.locator(".apexcharts-canvas")).toBeVisible();
101+
102+
const annotations = latency.locator(".apexcharts-xaxis-annotations");
103+
104+
await expect(annotations.locator("line")).toHaveCount(2);
105+
await expect(annotations.getByText("deploy")).toBeVisible();
106+
await expect(annotations.getByText("incident")).toBeVisible();
107+
});
108+
109+
test("horizontal chart draws a yline down it and an xline across it", async ({
110+
page,
111+
}) => {
95112
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
96113

97114
const disks = page.locator(".card", {
98115
has: page.getByRole("heading", { name: "Disk usage" }),
99116
});
100117
await expect(disks.locator(".apexcharts-canvas")).toBeVisible();
101118

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();
119+
const down = disks.locator(".apexcharts-xaxis-annotations");
120+
const across = disks.locator(".apexcharts-yaxis-annotations");
121+
122+
await expect(down.locator("line")).toHaveCount(1);
123+
await expect(down.getByText("full")).toBeVisible();
124+
await expect(across.locator("line")).toHaveCount(1);
125+
await expect(across.getByText("watched")).toBeVisible();
109126
});
110127

111128
test("map", async ({ page }) => {

0 commit comments

Comments
 (0)