diff --git a/CHANGELOG.md b/CHANGELOG.md index aefbeb94..62c07b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - 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. - Datagrid rows with an icon or image no longer display an unnecessary en-dash placeholder, and an explicitly empty description remains empty. - - 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. + - 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. ## v0.45 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index c9f68e39..97a960b7 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -689,6 +689,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('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), + ('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), ('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 @@ -829,24 +830,57 @@ so set `ymax` when the limit is above the data. {"x": "2024-05-01T14:00:00Z", "y": 63} ]')), ('chart', ' +## Marking events + +`xline` is the counterpart of `yline`: it marks a position on the x axis instead +of a value on the y axis, for a moment rather than a limit. A single query can +draw a whole log of them: + +```sql +select started_at as xline, summary as label, + case severity when ''outage'' then ''red'' else ''orange'' end as color +from deployments where started_at > $since; +``` + +When `time` is set, an `xline` is a date or a timestamp, written like the `x` of +a data point. On a chart with text labels on the x axis, it is one of those labels. +', json('[ + {"component":"chart", "title": "Request latency", "type": "area", "time": true, + "ytitle": "ms", "color": "blue-lt", "marker": 3}, + {"xline": "2024-05-01T10:00:00Z", "label": "deploy", "color": "green"}, + {"xline": "2024-05-01T11:30:00Z", "label": "incident", "color": "red"}, + {"x": "2024-05-01T08:00:00Z", "y": 120}, + {"x": "2024-05-01T09:00:00Z", "y": 134}, + {"x": "2024-05-01T10:00:00Z", "y": 128}, + {"x": "2024-05-01T11:00:00Z", "y": 141}, + {"x": "2024-05-01T12:00:00Z", "y": 512}, + {"x": "2024-05-01T13:00:00Z", "y": 470}, + {"x": "2024-05-01T14:00:00Z", "y": 156}, + {"x": "2024-05-01T15:00:00Z", "y": 133} + ]')), + ('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. +screen. `yline` always marks a value of `y`, and `xline` a position on `x`, +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 and an `xline` picks out +one of the bars. ```sql select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax; select 90 as yline, ''full'' as label, ''red'' as color; +select ''db-1'' as xline, ''watched'' as label, ''purple'' as color; select host as x, percent_used as y from disks order by percent_used; ``` -A `pie` has no axes, and ignores reference lines. +A `pie` has no axes and ignores reference lines, and on a `heatmap`, whose y axis +holds the names of the series, only `xline` has a meaning. ', json('[ {"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true, "ymax": 100, "color": "azure", "labels": true}, {"yline": 90, "label": "full", "color": "red"}, + {"xline": "db-1", "label": "watched", "color": "purple"}, {"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 e01ff8a7..65db39cb 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -127,33 +127,33 @@ sqlpage_chart = (() => { (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 {ReferenceLine[]} rows - the rows that carry an xline or a yline + * @param {"x"|"y"} column - the column the reference is written in + * @param {"x"|"y"} axis - the apexcharts axis that 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) { + function reference_lines(rows, column, axis, to_axis_value) { return rows.flatMap((row) => { - if (row.yline == null) return []; - const from = to_axis_value(row.yline); + const value = row[`${column}line`]; + if (value == null) return []; + const from = to_axis_value(value); if (Number.isNaN(from)) return []; const color = reference_color(row.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.label) - annotation.label = { - text: row.label, - orientation: "horizontal", + return [ + { + [axis]: from, borderColor: color, - style: { background: color, color: isDarkTheme ? "#000" : "#fff" }, - }; - return [annotation]; + fillColor: color, + strokeDashArray: 4, + label: { + text: row.label, + orientation: column === "y" ? "horizontal" : "vertical", + borderColor: color, + style: { background: color, color: isDarkTheme ? "#000" : "#fff" }, + }, + }, + ]; }); } @@ -207,21 +207,30 @@ sqlpage_chart = (() => { } 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 to_timestamp = (v) => + (typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime(); + const dates_are_values = is_timeseries && chart_type === "rangeBar"; + const to_value = dates_are_values ? to_timestamp : Number; + const to_category = + is_timeseries && !dates_are_values ? to_timestamp : (v) => v; const inverted = chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal); const value_axis = inverted ? "x" : "y"; + const category_axis = inverted ? "y" : "x"; const options = { annotations: { - [`${value_axis}axis`]: y_reference_lines( + [`${value_axis}axis`]: reference_lines( reference_rows, + "y", value_axis, to_value, ), + [`${category_axis}axis`]: reference_lines( + reference_rows, + "x", + category_axis, + to_category, + ), }, chart: { type: chart_type, diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index a9703aad..337b433a 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -40,8 +40,9 @@ "points": [ {{~#each_row~}} {{~#if (gt @row_index 0)}},{{/if~}} - {{~#if yline~}} + {{~#if (or xline yline)~}} { + "xline": {{~stringify xline}}, "yline": {{~stringify yline}}, "label": {{~stringify label}}, "color": {{~stringify color}} } diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts index 3c32f101..1e5d8b12 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/chart-component.spec.ts @@ -20,6 +20,13 @@ declare global { type Row = [series: string, x: unknown, y: unknown, z?: unknown]; +type ReferenceRow = { + xline?: string | number; + yline?: number; + label?: string; + color?: string; +}; + const A_DAY_OF_WORK: Row[] = [ ["Coding", "Mon", 6], ["Coding", "Tue", 4], @@ -74,7 +81,7 @@ const B_UNTIL_THE_SECOND_CATEGORY: Row[] = [ async function renderChart( page: Page, chart: Record, - rows: Row[], + rows: (Row | ReferenceRow)[], ) { return page.evaluate( ({ chart, rows }) => { @@ -127,6 +134,21 @@ async function renderChart( return { x, y, width, height }; }); + const annotated = [ + ...container.querySelectorAll( + ".apexcharts-xaxis-annotations, .apexcharts-yaxis-annotations", + ), + ]; + const count = (selector: string) => + annotated.reduce((n, g) => n + g.querySelectorAll(selector).length, 0); + const referenceLines = { + lines: count("line"), + labelBoxes: count("rect"), + labelTexts: annotated.flatMap((g) => + [...g.querySelectorAll("text")].map((t) => t.textContent), + ), + }; + return { failures, type: rendered?.w.config.chart.type ?? null, @@ -134,6 +156,7 @@ async function renderChart( series, drawnPerSeries, shapes, + referenceLines, }; }, { chart, rows }, @@ -363,3 +386,30 @@ test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { expect(chart.shapes).toHaveLength(2); expect(chart.stacked).toBe(false); }); + +test("draws a reference line that carries no label", async ({ page }) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + { yline: 2 }, + { xline: "Q2" }, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.referenceLines.lines).toBe(2); + expect(chart.referenceLines.labelBoxes).toBe(0); + expect(chart.referenceLines.labelTexts).toEqual(["", ""]); +}); + +test("draws a box behind the label of a reference line that carries one", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + { yline: 2, label: "limit" }, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.referenceLines.lines).toBe(1); + expect(chart.referenceLines.labelBoxes).toBe(1); + expect(chart.referenceLines.labelTexts).toEqual(["limit"]); +}); diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 4a0d0f0a..e1a1bd3c 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -91,7 +91,24 @@ test("chart draws a reference line for every yline", async ({ page }) => { await expect(annotations.getByText("throttling")).toBeVisible(); }); -test("chart draws a yline down a horizontal chart", async ({ page }) => { +test("chart draws a reference line for every xline", async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + + const latency = page.locator(".card", { + has: page.getByRole("heading", { name: "Request latency" }), + }); + await expect(latency.locator(".apexcharts-canvas")).toBeVisible(); + + const annotations = latency.locator(".apexcharts-xaxis-annotations"); + + await expect(annotations.locator("line")).toHaveCount(2); + await expect(annotations.getByText("deploy")).toBeVisible(); + await expect(annotations.getByText("incident")).toBeVisible(); +}); + +test("horizontal chart draws a yline down it and an xline across it", async ({ + page, +}) => { await page.goto(`${BASE}/documentation.sql?component=chart#component`); const disks = page.locator(".card", { @@ -99,13 +116,13 @@ test("chart draws a yline down a horizontal chart", async ({ page }) => { }); 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(); + const down = disks.locator(".apexcharts-xaxis-annotations"); + const across = disks.locator(".apexcharts-yaxis-annotations"); + + await expect(down.locator("line")).toHaveCount(1); + await expect(down.getByText("full")).toBeVisible(); + await expect(across.locator("line")).toHaveCount(1); + await expect(across.getByText("watched")).toBeVisible(); }); test("map", async ({ page }) => {