From 400c18c39d17c9e3c9c2582fc4a8278688abf498 Mon Sep 17 00:00:00 2001 From: 81reap Date: Sun, 9 Aug 2026 13:53:00 -0400 Subject: [PATCH 1/2] feat(chart) :: draw horizontal reference lines --- CHANGELOG.md | 1 + .../sqlpage/migrations/01_documentation.sql | 64 ++++++++++++++++++- sqlpage/apexcharts.js | 62 +++++++++++++++++- sqlpage/templates/chart.handlebars | 7 ++ tests/end-to-end/official-site.spec.ts | 32 ++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5144ae1e..05ab5ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - `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. - `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. + - 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 3491c5cf..102d434a 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -687,7 +687,10 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('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), ('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`. @@ -792,6 +795,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 98fa1ce3..75d81402 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -118,6 +118,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"); @@ -131,9 +170,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; @@ -161,12 +202,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 4b3bd468..4a0d0f0a 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -76,6 +76,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(); From b320633ac429326ddd113372eaa0c8a2632aaf94 Mon Sep 17 00:00:00 2001 From: 81reap Date: Sat, 22 Aug 2026 17:52:10 -0400 Subject: [PATCH 2/2] rev 2 (please squash + merge) --- CHANGELOG.md | 2 +- .../sqlpage/migrations/01_documentation.sql | 15 +++++++-------- sqlpage/apexcharts.js | 6 +++--- sqlpage/templates/chart.handlebars | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ab5ff5..285550f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - `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. - `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. - - 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. + - 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. ## v0.45 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 102d434a..c9f68e39 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -685,12 +685,11 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('x', 'The value of the point on the horizontal axis', 'REAL', FALSE, FALSE), ('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE), ('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), - ('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE), + ('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), ('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), ('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) + ('color', 'The name of a color for the reference line this row draws. 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`. @@ -807,7 +806,7 @@ 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 celsius as yline, name as label, color as color from thresholds; select measured_at as x, celsius as y from readings order by measured_at; ``` @@ -819,8 +818,8 @@ 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"}, + {"yline": 70, "label": "target", "color": "green"}, + {"yline": 90, "label": "throttling", "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}, @@ -839,7 +838,7 @@ 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 90 as yline, ''full'' as label, ''red'' as color; select host as x, percent_used as y from disks order by percent_used; ``` @@ -847,7 +846,7 @@ 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"}, + {"yline": 90, "label": "full", "color": "red"}, {"x": "backup-1", "y": 41}, {"x": "web-2", "y": 63}, {"x": "db-1", "y": 88}, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 75d81402..e01ff8a7 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -137,7 +137,7 @@ sqlpage_chart = (() => { 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 color = reference_color(row.color); const annotation = { [axis]: from, borderColor: color, @@ -146,9 +146,9 @@ sqlpage_chart = (() => { }; // apexcharts reads label.text unconditionally, so an annotation without // a label must not have the key at all. - if (row.yline_label) + if (row.label) annotation.label = { - text: row.yline_label, + text: row.label, orientation: "horizontal", borderColor: color, style: { background: color, color: isDarkTheme ? "#000" : "#fff" }, diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index 4621bdad..a9703aad 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -43,7 +43,7 @@ {{~#if yline~}} { "yline": {{~stringify yline}}, - "yline_label": {{~stringify yline_label}}, "yline_color": {{~stringify yline_color}} + "label": {{~stringify label}}, "color": {{~stringify color}} } {{~else~}} [