Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- `column` charts now display vertical bars instead of nothing at all.
- `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.
- 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.

## v0.45

Expand Down
64 changes: 63 additions & 1 deletion examples/official-site/sqlpage/migrations/01_documentation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,10 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE),
('label', 'An alias for parameter "x"', 'REAL', 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),
('yline_label', 'A text to display next to the yline.', 'TEXT', FALSE, TRUE),
('yline_color', 'The name of a color for the yline. 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`.
Expand Down Expand Up @@ -780,6 +783,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 yline_label, color as yline_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, "yline_label": "target", "yline_color": "green"},
{"yline": 90, "yline_label": "throttling", "yline_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 yline_label, ''red'' as yline_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, "yline_label": "full", "yline_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)
Expand Down
62 changes: 59 additions & 3 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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.yline_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.yline_label)
annotation.label = {
text: row.yline_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");
Expand All @@ -127,9 +166,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;
Expand Down Expand Up @@ -157,12 +198,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",
Expand Down
7 changes: 7 additions & 0 deletions sqlpage/templates/chart.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,19 @@
"points": [
{{~#each_row~}}
{{~#if (gt @row_index 0)}},{{/if~}}
{{~#if yline~}}
{
"yline": {{~stringify yline}},
"yline_label": {{~stringify yline_label}}, "yline_color": {{~stringify yline_color}}
}
{{~else~}}
[
{{~ stringify (default series (default ../title "")) ~}},
{{~ stringify (default x label) ~}},
{{~ stringify (default y value) ~}}
{{~#if z}}, {{~ stringify z ~}} {{~/if~}}
]
{{~/if~}}
{{~/each_row~}}
]
}
Expand Down
32 changes: 32 additions & 0 deletions tests/end-to-end/official-site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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();
Expand Down