diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a5e5ea..54333135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 9e4ce254..72e9eef5 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -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`. @@ -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) diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index cd1d1d58..b461fa31 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -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"); @@ -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; @@ -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", diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index 34d3256e..4621bdad 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -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~}} ] } diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index c81117c3..8afad3c2 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -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();