From cca79df18b9a5cee61baee697b2f23daf3173f77 Mon Sep 17 00:00:00 2001 From: 81reap Date: Wed, 12 Aug 2026 21:39:57 -0400 Subject: [PATCH 1/4] fix(chart) :: line series up on a category axis for every chart type --- CHANGELOG.md | 1 + sqlpage/apexcharts.js | 46 +++-- tests/end-to-end/chart-component.spec.ts | 114 ++++++++++++- tests/js/chart_series.spec.ts | 209 ++++++++++++++++++----- 4 files changed, 310 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f3b443..b7a5e5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter. - Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now reported in the browser console and skipped, instead of breaking the whole map. - Stacked charts now stack their series by `x` value instead of by point order, which used to give wrong totals when a series was missing a point. + - `line`, `area`, `scatter`, `bubble` and `heatmap` charts with text labels on the x axis now line their series up by label, leaving a gap where a series skips one. - `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. diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index f4f0cc61..cd1d1d58 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -38,6 +38,14 @@ sqlpage_chart = (() => { const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; + const Y_WHEN_A_SERIES_SKIPS_A_LABEL = { + bar: 0, + line: null, + area: null, + scatter: null, + bubble: null, + heatmap: null, + }; /** @typedef {number|string|Date} XValue */ /** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */ @@ -46,6 +54,9 @@ sqlpage_chart = (() => { /** @param {XValue} x @returns {number|string} equal x values share a key */ const x_key = (x) => (x instanceof Date ? x.getTime() : x); + /** @param {ChartSeries[]} series */ + const x_is_text = (series) => typeof series[0]?.data[0]?.x === "string"; + /** * @param {ChartSeries[]} series * @returns {XValue[]} every x the series hold, in their own order where they @@ -66,13 +77,15 @@ sqlpage_chart = (() => { /** * ApexCharts pairs points across series by index rather than by x, so a - * series that skips an x stacks onto the wrong one. Give every series the - * same x values, counting an x it never measured as zero. + * series that skips an x lands on the wrong one. Give every series the same + * amount of x values. * * @param {ChartSeries[]} series + * @param {number|null} y_when_missing what a series with no value at an x is + * worth there: zero to add nothing to a stack, null to leave a gap. * @returns {ChartSeries[]} */ - function align_series(series) { + function align_series(series, y_when_missing) { const all_x = merged_x_values(series); return series.map(({ name, data }) => { const by_x = new Map(data.map((point) => [x_key(point.x), point])); @@ -80,15 +93,28 @@ sqlpage_chart = (() => { name, data: all_x.map((x) => { const point = by_x.get(x_key(x)); - return { ...point, x, y: point?.y || 0 }; + return { ...point, x, y: point?.y ?? y_when_missing }; }), }; }); } + /** + * @param {ChartSeries[]} series + * @param {string} chart_type + * @param {boolean} is_stacked + * @returns {ChartSeries[]} + */ + function align_series_for(series, chart_type, is_stacked) { + if (is_stacked) return align_series(series, 0); + if (x_is_text(series) && chart_type in Y_WHEN_A_SERIES_SKIPS_A_LABEL) + return align_series(series, Y_WHEN_A_SERIES_SKIPS_A_LABEL[chart_type]); + return series; + } + // The unit tests load this file as a CommonJS module; browsers have no `module`. if (typeof module !== "undefined") - module.exports = { align_series, merged_x_values }; + module.exports = { align_series, align_series_for, merged_x_values }; /** @param {HTMLElement} c */ function build_sqlpage_chart(c) { @@ -129,16 +155,12 @@ sqlpage_chart = (() => { let series = Object.values(series_map); let labels; - const categories = - series.length > 0 && typeof series[0].data[0].x === "string"; + 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)); - } else if ( - series.length > 1 && - (is_stacked || (categories && chart_type === "bar")) - ) - series = align_series(series); + } else if (series.length > 1) + series = align_series_for(series, chart_type, is_stacked); const options = { chart: { diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts index 8792a230..3c32f101 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/chart-component.spec.ts @@ -106,17 +106,21 @@ async function renderChart( p.y, ]), })); - const drawnPerSeries = series.map(({ name }) => ({ - name, - heights: [ + const drawnPerSeries = series.map(({ name }) => { + const markers = [ ...container.querySelectorAll( - `.apexcharts-series[seriesName='${name}'] .apexcharts-marker`, + `.apexcharts-series[seriesName='${name}'] .apexcharts-series-markers > .apexcharts-marker`, ), - ].map((m) => Math.round(m.getBBox().y)), - })); + ].map((m) => m.getBBox()); + return { + name, + lefts: markers.map((b) => Math.round(b.x)), + heights: markers.map((b) => Math.round(b.y)), + }; + }); const shapes = [ ...container.querySelectorAll( - ".apexcharts-bar-area, .apexcharts-rangebar-area", + ".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect", ), ].map((shape) => { const { x, y, width, height } = shape.getBBox(); @@ -252,6 +256,102 @@ test("stacks a bar series on the categories it skipped", async ({ page }) => { ]); }); +test("lines an unstacked series up with the categories it skipped", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 20], + ["Q3", 30], + ]); +}); + +test("draws nothing where an unstacked series has no value", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + const [a, b] = chart.drawnPerSeries; + + expect(a.lefts).toHaveLength(3); + expect(b.lefts).toEqual(a.lefts.slice(1)); +}); + +test("keeps a measured zero apart from a missing value", async ({ page }) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ["B", "Q2", 0], + ["B", "Q3", 30], + ]); + const [a, b] = chart.drawnPerSeries; + + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 0], + ["Q3", 30], + ]); + expect(b.lefts).toEqual(a.lefts.slice(1)); +}); + +for (const type of ["area", "scatter", "heatmap"]) { + test(`lines up the series of a ${type} chart on a category axis`, async ({ + page, + }) => { + const chart = await renderChart(page, { type }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points.map((p) => p[0])).toEqual(["Q1", "Q2", "Q3"]); + }); +} + +test("keeps the bubble size of the points it lined up", async ({ page }) => { + const chart = await renderChart(page, { type: "bubble" }, [ + ["A", "Q1", 1, 30], + ["A", "Q2", 2, 30], + ["B", "Q2", 5, 70], + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 5], + ]); +}); + +test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { + const chart = await renderChart( + page, + { type: "rangeBar", time: true }, + TASKS_OVER_TIME, + ); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(2); +}); + +test("leaves a treemap chart alone", async ({ page }) => { + const chart = await renderChart(page, { type: "treemap" }, [ + ["North America", "United States", 35], + ["North America", "Canada", 15], + ["Europe", "France", 30], + ["Europe", "Germany", 55], + ]); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(4); +}); + test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { const chart = await renderChart( page, diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index 9f91d34a..7ef7ddc1 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -11,11 +11,17 @@ Object.assign(globalThis, browser_globals_apexcharts_reads_when_it_loads); const require = createRequire(import.meta.url); const { align_series, + align_series_for, merged_x_values, } = require("../../sqlpage/apexcharts.js"); +const ADDS_NOTHING_TO_THE_STACK = 0; +const LEAVES_A_GAP = null; +const STACKED = true; +const UNSTACKED = false; + type XValue = number | string | Date; -type Point = { x: XValue; y: number | string | null; z?: number }; +type Point = { x: XValue; y: number | string | null | number[]; z?: number }; type Series = { name: string; data: Point[] }; const series = (name: string, ...data: Point[]): Series => ({ name, data }); @@ -67,20 +73,26 @@ test("merged_x_values ignores series that hold no points", () => { }); test("align_series gives every series a point at every x (#727)", () => { - const [a, b] = align_series([ - series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), - series("b", { x: "Q2", y: 3 }), - ]); + const [a, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); assert.deepEqual(xs(a), ["Q1", "Q2"]); assert.deepEqual(xs(b), ["Q1", "Q2"]); }); -test("align_series counts an x a series skipped as zero (#727)", () => { - const [, b] = align_series([ - series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), - series("b", { x: "Q2", y: 3 }), - ]); +test("align_series counts an x a stacked series skipped as zero (#727)", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); assert.deepEqual(b.data, [ { x: "Q1", y: 0 }, @@ -88,47 +100,80 @@ test("align_series counts an x a series skipped as zero (#727)", () => { ]); }); -test("align_series counts a null value as a value the series never measured", () => { - const [, b] = align_series([ - series("a", { x: "Q1", y: 1 }), - series("b", { x: "Q1", y: null }), - ]); +test("align_series leaves a gap where an unstacked series has no value", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + LEAVES_A_GAP, + ); - assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 3 }, + ]); }); -test("align_series counts a blank value as zero", () => { - const [, b] = align_series([ - series("a", { x: "Q1", y: 1 }), - series("b", { x: "Q1", y: "" }), +test("align_series keeps a measured zero apart from a missing value", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 0 }), + ], + LEAVES_A_GAP, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 0 }, ]); +}); + +test("align_series counts a null value as missing", () => { + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: null })], + ADDS_NOTHING_TO_THE_STACK, + ); assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); }); +test("align_series keeps a blank value the series wrote", () => { + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: "" })], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [{ x: "Q1", y: "" }]); +}); + test("align_series keeps a value the series wrote as text", () => { - const [, b] = align_series([ - series("a", { x: "Q1", y: 1 }), - series("b", { x: "Q1", y: "7" }), - ]); + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: "7" })], + ADDS_NOTHING_TO_THE_STACK, + ); assert.deepEqual(b.data, [{ x: "Q1", y: "7" }]); }); test("align_series keeps the third dimension of points it did not fill in", () => { - const [a] = align_series([ - series("a", { x: "Q1", y: 1, z: 42 }), - series("b", { x: "Q2", y: 2 }), - ]); + const [a] = align_series( + [series("a", { x: "Q1", y: 1, z: 42 }), series("b", { x: "Q2", y: 2 })], + LEAVES_A_GAP, + ); assert.equal(a.data[0].z, 42); }); test("align_series matches dates by value rather than by identity", () => { - const [a, b] = align_series([ - series("a", { x: new Date("2024-03-01"), y: 1 }), - series("b", { x: new Date("2024-03-01"), y: 2 }), - ]); + const [a, b] = align_series( + [ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); assert.equal(a.data.length, 1); assert.equal(b.data.length, 1); @@ -136,9 +181,10 @@ test("align_series matches dates by value rather than by identity", () => { }); test("align_series leaves a lone series in the order it arrived (#930)", () => { - const [only] = align_series([ - series("a", { x: "Q2", y: 1 }, { x: "Q1", y: 2 }), - ]); + const [only] = align_series( + [series("a", { x: "Q2", y: 1 }, { x: "Q1", y: 2 })], + LEAVES_A_GAP, + ); assert.deepEqual(only.data, [ { x: "Q2", y: 1 }, @@ -152,7 +198,7 @@ test("align_series returns series that already share every x unchanged", () => { series("b", { x: "Q3", y: 4 }, { x: "Q1", y: 5 }, { x: "Q2", y: 6 }), ]; - assert.deepEqual(align_series(given), given); + assert.deepEqual(align_series(given, ADDS_NOTHING_TO_THE_STACK), given); }); test("align_series does not mutate the series it is given", () => { @@ -162,19 +208,100 @@ test("align_series does not mutate the series it is given", () => { ]; const before = JSON.stringify(given); - align_series(given); + align_series(given, LEAVES_A_GAP); assert.equal(JSON.stringify(given), before); }); test("align_series keeps the last of duplicated x values", () => { - const [a] = align_series([ - series("a", { x: "Q1", y: 1 }, { x: "Q1", y: 9 }), - series("b", { x: "Q2", y: 2 }), - ]); + const [a] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q1", y: 9 }), + series("b", { x: "Q2", y: 2 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); assert.deepEqual(a.data, [ { x: "Q1", y: 9 }, { x: "Q2", y: 0 }, ]); }); + +test("align_series_for gives a stacked series a zero at every x it skipped", () => { + const [, b] = align_series_for( + [series("a", { x: 1, y: 1 }, { x: 2, y: 2 }), series("b", { x: 2, y: 3 })], + "area", + STACKED, + ); + + assert.deepEqual(b.data, [ + { x: 1, y: 0 }, + { x: 2, y: 3 }, + ]); +}); + +for (const type of ["line", "area", "scatter", "bubble", "heatmap"]) { + test(`align_series_for leaves a gap where a ${type} series skips a label`, () => { + const [, b] = align_series_for( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + type, + UNSTACKED, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 3 }, + ]); + }); +} + +test("align_series_for counts a label a bar series skipped as zero", () => { + const [, b] = align_series_for( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + "bar", + UNSTACKED, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: 0 }, + { x: "Q2", y: 3 }, + ]); +}); + +test("align_series_for leaves a treemap's regions their own labels", () => { + const regions = [ + series( + "North America", + { x: "United States", y: 35 }, + { x: "Canada", y: 15 }, + ), + series("Europe", { x: "France", y: 30 }, { x: "Germany", y: 55 }), + ]; + + assert.equal(align_series_for(regions, "treemap", UNSTACKED), regions); +}); + +test("align_series_for leaves a rangeBar timeline alone", () => { + const tasks = [ + series("Design", { x: "Alice", y: [1, 5] }), + series("Build", { x: "Bob", y: [4, 9] }), + ]; + + assert.equal(align_series_for(tasks, "rangeBar", UNSTACKED), tasks); +}); + +test("align_series_for leaves unstacked series without labels alone", () => { + const lines = [ + series("a", { x: 1, y: 1 }, { x: 2, y: 2 }), + series("b", { x: 2, y: 3 }), + ]; + + assert.equal(align_series_for(lines, "line", UNSTACKED), lines); +}); From 27c1482120863811f6e548d98655c059ecee052d Mon Sep 17 00:00:00 2001 From: 81reap Date: Sun, 9 Aug 2026 13:53:00 -0400 Subject: [PATCH 2/4] 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 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(); From 0e891d82f8bff0e77255c44248b1527094bbb1bd Mon Sep 17 00:00:00 2001 From: 81reap Date: Sun, 9 Aug 2026 14:03:38 -0400 Subject: [PATCH 3/4] feat(chart) :: draw vertical reference lines --- CHANGELOG.md | 2 +- .../sqlpage/migrations/01_documentation.sql | 44 ++++++++++++++++--- sqlpage/apexcharts.js | 42 +++++++++++------- sqlpage/templates/chart.handlebars | 4 +- tests/end-to-end/official-site.spec.ts | 15 +++++++ 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54333135..e69b1eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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. + - 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 an `xline` marks a position on the x axis. `yline_label`, `xline_label`, `yline_color` and `xline_color` set its text and its color. Reference lines are rows, so a chart can have as many of them as the query returns. Each one follows its own 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 72e9eef5..2eee8391 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -678,7 +678,10 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('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) + ('yline_color', 'The name of a color for the yline. Grey by default.', 'COLOR', 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), + ('xline_label', 'A text to display next to the xline.', 'TEXT', FALSE, TRUE), + ('xline_color', 'The name of a color for the xline. 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`. @@ -818,12 +821,42 @@ 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 xline_label, + case severity when ''outage'' then ''red'' else ''orange'' end as xline_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", "xline_label": "deploy", "xline_color": "green"}, + {"xline": "2024-05-01T11:30:00Z", "xline_label": "incident", "xline_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; @@ -831,7 +864,8 @@ 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. +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}, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index b461fa31..3195b43b 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -125,17 +125,20 @@ 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.yline_color); + const color = reference_color(row[`${column}line_color`]); + const text = row[`${column}line_label`]; const annotation = { [axis]: from, borderColor: color, @@ -144,10 +147,10 @@ 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 (text) annotation.label = { - text: row.yline_label, - orientation: "horizontal", + text, + orientation: column === "y" ? "horizontal" : "vertical", borderColor: color, style: { background: color, color: isDarkTheme ? "#000" : "#fff" }, }; @@ -203,21 +206,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 4621bdad..d3c36ea3 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -40,8 +40,10 @@ "points": [ {{~#each_row~}} {{~#if (gt @row_index 0)}},{{/if~}} - {{~#if yline~}} + {{~#if (or xline yline)~}} { + "xline": {{~stringify xline}}, + "xline_label": {{~stringify xline_label}}, "xline_color": {{~stringify xline_color}}, "yline": {{~stringify yline}}, "yline_label": {{~stringify yline_label}}, "yline_color": {{~stringify yline_color}} } diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 8afad3c2..2908988a 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -93,6 +93,21 @@ test("chart draws a reference line for every yline", async ({ page }) => { await expect(annotations.getByText("throttling")).toBeVisible(); }); +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("chart draws a yline down a horizontal chart", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=chart#component`); From f57edb509b640bf7e5f885fba3a5dc25a41c7d03 Mon Sep 17 00:00:00 2001 From: 81reap Date: Sun, 9 Aug 2026 14:32:35 -0400 Subject: [PATCH 4/4] feat(chart) :: turn a reference line into a band --- CHANGELOG.md | 2 +- .../sqlpage/migrations/01_documentation.sql | 18 +++++++++++------- sqlpage/apexcharts.js | 12 ++++++++---- sqlpage/templates/chart.handlebars | 4 ++-- tests/end-to-end/official-site.spec.ts | 18 ++++++++++++++---- 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69b1eee..9c547084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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, and a row with an `xline` marks a position on the x axis. `yline_label`, `xline_label`, `yline_color` and `xline_color` set its text and its color. Reference lines are rows, so a chart can have as many of them as the query returns. Each one follows its own 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 an `xline` marks a position on the x axis. Adding `yline_end` or `xline_end` makes a line a band, and `yline_label`, `xline_label`, `yline_color` and `xline_color` set its text and its color. Reference lines are rows, so a chart can have as many of them as the query returns. Each one follows its own 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 2eee8391..c0b9d363 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -677,9 +677,11 @@ 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), + ('yline_end', 'Makes the yline a band instead of a line, reaching to this value.', '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), ('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), + ('xline_end', 'Makes the xline a band instead of a line, reaching to this value, for an event that lasted.', 'TEXT', FALSE, TRUE), ('xline_label', 'A text to display next to the xline.', 'TEXT', FALSE, TRUE), ('xline_color', 'The name of a color for the xline. Grey by default.', 'COLOR', FALSE, TRUE) ) x; @@ -791,7 +793,7 @@ The `color` property sets the color of each series separately, in order. 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. +objective. Add `yline_end` to make it a band instead of a line. 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: @@ -811,7 +813,7 @@ so set `ymax` when the limit is above the data. {"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": 90, "yline_end": 100, "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}, @@ -824,13 +826,14 @@ so set `ymax` when the limit is above the data. ## 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: +of a value on the y axis. On its own it marks a moment, like a deployment. +With `xline_end`, it covers everything in between, like an incident or a +maintenance window. A single query can draw a whole log of them: ```sql -select started_at as xline, summary as xline_label, +select started_at as xline, ended_at as xline_end, summary as xline_label, case severity when ''outage'' then ''red'' else ''orange'' end as xline_color -from deployments where started_at > $since; +from incidents where started_at > $since; ``` When `time` is set, an `xline` is a date or a timestamp, written like the `x` of @@ -839,7 +842,8 @@ a data point. On a chart with text labels on the x axis, it is one of those labe {"component":"chart", "title": "Request latency", "type": "area", "time": true, "ytitle": "ms", "color": "blue-lt", "marker": 3}, {"xline": "2024-05-01T10:00:00Z", "xline_label": "deploy", "xline_color": "green"}, - {"xline": "2024-05-01T11:30:00Z", "xline_label": "incident", "xline_color": "red"}, + {"xline": "2024-05-01T11:30:00Z", "xline_end": "2024-05-01T13:00:00Z", + "xline_label": "incident", "xline_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}, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 3195b43b..cc606ad8 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -132,15 +132,19 @@ sqlpage_chart = (() => { * @returns {object[]} apexcharts axis annotations */ function reference_lines(rows, column, axis, to_axis_value) { + const on_axis = (value) => { + if (value == null) return null; + const placed = to_axis_value(value); + return Number.isNaN(placed) ? null : placed; + }; return rows.flatMap((row) => { - const value = row[`${column}line`]; - if (value == null) return []; - const from = to_axis_value(value); - if (Number.isNaN(from)) return []; + const from = on_axis(row[`${column}line`]); + if (from == null) return []; const color = reference_color(row[`${column}line_color`]); const text = row[`${column}line_label`]; const annotation = { [axis]: from, + [`${axis}2`]: on_axis(row[`${column}line_end`]), borderColor: color, fillColor: color, strokeDashArray: 4, diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index d3c36ea3..72ece444 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -42,9 +42,9 @@ {{~#if (gt @row_index 0)}},{{/if~}} {{~#if (or xline yline)~}} { - "xline": {{~stringify xline}}, + "xline": {{~stringify xline}}, "xline_end": {{~stringify xline_end}}, "xline_label": {{~stringify xline_label}}, "xline_color": {{~stringify xline_color}}, - "yline": {{~stringify yline}}, + "yline": {{~stringify yline}}, "yline_end": {{~stringify yline_end}}, "yline_label": {{~stringify yline_label}}, "yline_color": {{~stringify yline_color}} } {{~else~}} diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 2908988a..30c2a2bd 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -78,7 +78,9 @@ 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 }) => { +test("chart draws a yline as a line and a yline_end as a band", async ({ + page, +}) => { await page.goto(`${BASE}/documentation.sql?component=chart#component`); const temperature = page.locator(".card", { @@ -87,13 +89,18 @@ test("chart draws a reference line for every yline", async ({ page }) => { await expect(temperature.locator(".apexcharts-canvas")).toBeVisible(); const annotations = temperature.locator(".apexcharts-yaxis-annotations"); + const lines = annotations.locator("line"); + const bands = annotations.locator(".apexcharts-annotation-rect"); - await expect(annotations.locator("line")).toHaveCount(2); + await expect(lines).toHaveCount(1); + await expect(bands).toHaveCount(1); await expect(annotations.getByText("target")).toBeVisible(); await expect(annotations.getByText("throttling")).toBeVisible(); }); -test("chart draws a reference line for every xline", async ({ page }) => { +test("chart draws an xline as a line and an xline_end as a band", async ({ + page, +}) => { await page.goto(`${BASE}/documentation.sql?component=chart#component`); const latency = page.locator(".card", { @@ -102,8 +109,11 @@ test("chart draws a reference line for every xline", async ({ page }) => { await expect(latency.locator(".apexcharts-canvas")).toBeVisible(); const annotations = latency.locator(".apexcharts-xaxis-annotations"); + const lines = annotations.locator("line"); + const bands = annotations.locator(".apexcharts-annotation-rect"); - await expect(annotations.locator("line")).toHaveCount(2); + await expect(lines).toHaveCount(1); + await expect(bands).toHaveCount(1); await expect(annotations.getByText("deploy")).toBeVisible(); await expect(annotations.getByText("incident")).toBeVisible(); });