diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9c0a75..aa526fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Datagrid rows with an icon or image no longer display an unnecessary en-dash placeholder, and an explicitly empty description remains empty. - Tooltip title text is now inhertis the same colour as the tooltip text. - 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 the row's `label` and `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. + - Chart data rows can set their own `color`, painting a single bar, slice or point instead of the whole series. It applies to `bar`, `column`, `rangeBar`, `pie`, `treemap`, `scatter` and `bubble` charts, and to the markers of a `line` or 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 30061923..f93a527e 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -692,7 +692,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('yline_end', 'Makes the yline a band instead of a line, reaching to this value.', '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), ('xline_end', 'Makes the xline a band instead of a line, reaching to this value.', 'TEXT', FALSE, TRUE), - ('color', 'The name of a color for the reference line this row draws. Grey by default.', 'COLOR', FALSE, TRUE) + ('color', 'The name of a color for what this row draws: the bar, slice or point it plots, or the reference line it draws. Defaults to the color of the series for a data point, and to grey for a reference line.', '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`. @@ -797,6 +797,39 @@ 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', ' +## Coloring a single value + +A data row can carry its own `color`, to paint the one bar, slice or point it +plots. Use it when the color says something the axes do not: a threshold +crossed, a status, the one category the reader should look at first. + +```sql +select ''chart'' as component, ''bar'' as type, true as horizontal, + true as labels, false as show_legend; +select + window_label as label, + accounts as value, + case when days <= 30 then ''red'' when days <= 60 then ''orange'' else ''green'' end as color +from expiring_accounts order by days; +``` + +A row color takes precedence over the color of its series. On a `line` or an +`area` chart it paints the marker of the point, so set `marker` for it to show. +A `heatmap` shades its cells from their own value and ignores it. +', json('[ + {"component":"chart", "title": "Accounts expiring soon", "type": "bar", + "horizontal": true, "labels": true, "show_legend": false}, + {"label": "30 days", "value": 100, "color": "red"}, + {"label": "60 days", "value": 200, "color": "orange"}, + {"label": "90 days", "value": 300, "color": "green"} + ]')), + ('chart', 'A pie chart whose rows choose their own slice colors.', json('[ + {"component":"chart", "title": "Support tickets", "type": "pie", "labels": true}, + {"label": "Resolved", "value": 72, "color": "green"}, + {"label": "In progress", "value": 21, "color": "yellow"}, + {"label": "Overdue", "value": 7, "color": "red"} + ]')), + ('chart', ' ## Reference lines A row with a `yline` is not plotted as a data point, but drawn as a line across diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index b078e346..a5838ec0 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -50,7 +50,8 @@ sqlpage_chart = (() => { }; /** @typedef {number|string|Date} XValue */ - /** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */ + /** @typedef { {x:XValue, y:number|null, z?:number, fillColor?:string} } ChartPoint */ + /** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ /** @typedef { { [name:string]: ChartSeries } } Series */ /** @param {XValue} x @returns {number|string} equal x values share a key */ @@ -122,9 +123,12 @@ sqlpage_chart = (() => { /** @typedef { {[property:string]: string|number|null} } ReferenceLine */ + /** @param {unknown} name @returns {string|undefined} */ + const named_color = (name) => + typeof name === "string" ? colorNames[name] : undefined; + /** @param {string|number|null} name */ - const reference_color = (name) => - (typeof name === "string" && colorNames[name]) || referenceColor; + const reference_color = (name) => named_color(name) || referenceColor; /** * @param {ReferenceLine[]} rows - the rows that carry an xline or a yline @@ -178,7 +182,7 @@ sqlpage_chart = (() => { const reference_rows = data.points.filter((row) => !Array.isArray(row)); /** @type { Series } */ const series_map = {}; - for (const [name, old_x, old_y, z] of points) { + for (const [name, old_x, old_y, color, z] of points) { series_map[name] = series_map[name] || { name, data: [] }; let x = old_x; let y = old_y; @@ -188,18 +192,19 @@ sqlpage_chart = (() => { y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } - series_map[name].data.push({ x, y, z }); + series_map[name].data.push({ x, y, z, fillColor: named_color(color) }); } if (data.xmin == null) data.xmin = undefined; if (data.xmax == null) data.xmax = undefined; if (data.ymin == null) data.ymin = undefined; if (data.ymax == null) data.ymax = undefined; - const colors = [ + const palette = [ ...data.colors.filter((c) => c).map((c) => colorNames[c]), ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)), ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)), ]; + let colors = palette; let series = Object.values(series_map); @@ -208,6 +213,9 @@ sqlpage_chart = (() => { if (chart_type === "pie") { labels = points.map(([name, x, _y]) => x || name); series = points.map(([_name, _x, y]) => Number.parseFloat(y)); + colors = points.map( + ([, , , color], i) => named_color(color) || palette[i % palette.length], + ); } else if (series.length > 1) series = align_series_for(series, chart_type, is_stacked); diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index fcb7f9fe..6631b53d 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -51,6 +51,7 @@ {{~ stringify (default series (default ../title "")) ~}}, {{~ stringify (default x label) ~}}, {{~ stringify (default y value) ~}} + {{~#if (or color z)}}, {{~ stringify color ~}} {{~/if~}} {{~#if z}}, {{~ stringify z ~}} {{~/if~}} ] {{~/if~}} diff --git a/tests/components/chart_point_serialization.sql b/tests/components/chart_point_serialization.sql new file mode 100644 index 00000000..246251e3 --- /dev/null +++ b/tests/components/chart_point_serialization.sql @@ -0,0 +1,6 @@ +SELECT 'chart' AS component, 'It works !' AS title; +SELECT 'plain' AS x, '1' AS y; +SELECT 'colored' AS x, '2' AS y, 'red' AS color; +SELECT 'sized' AS x, '3' AS y, '30' AS z; +SELECT 'both' AS x, '4' AS y, 'green' AS color, '40' AS z; +SELECT '70' AS yline, 'limit' AS label, 'orange' AS color; diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts index c593b977..3918b2a5 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/chart-component.spec.ts @@ -10,7 +10,7 @@ declare global { w: { config: { chart: { type: string; stacked: boolean }; - series: { name: string; data: ChartPoint[] }[]; + series: { name: string; data?: ChartPoint[] }[]; }; }; }[]; @@ -18,7 +18,16 @@ declare global { function sqlpage_chart(): void; } -type Row = [series: string, x: unknown, y: unknown, z?: unknown]; +type Row = [ + series: string, + x: unknown, + y: unknown, + color?: unknown, + z?: unknown, +]; + +const MARKS = + ".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect, .apexcharts-pie-area, .apexcharts-heatmap-rect, .apexcharts-marker"; type ReferenceRow = { xline?: string | number; @@ -68,6 +77,37 @@ const A_QUARTERS_OUT_OF_ORDER: Row[] = [ ["A", "Q2", 2], ]; +const EXPIRING_ACCOUNTS: Row[] = [ + ["Accounts", "30 days", 100, "red"], + ["Accounts", "60 days", 200, "orange"], + ["Accounts", "90 days", 300, "green"], +]; + +const RED = "#f03e3e"; +const ORANGE = "#f76707"; +const GREEN = "#37b24d"; + +const A_RED_ROW_AND_A_GREEN_ROW: Row[] = [ + ["A", "Q1", 1, "red"], + ["A", "Q2", 2, "green"], +]; + +const THE_SAME_ROWS_UNCOLORED: Row[] = [ + ["A", "Q1", 1], + ["A", "Q2", 2], +]; + +const COLORED_ROWS_OF: Record = { + rangeBar: [ + ["A", "one", ["2024-03-01", "2024-03-05"], "red"], + ["A", "two", ["2024-03-04", "2024-03-09"], "green"], + ], + bubble: [ + ["A", "Q1", 1, "red", 30], + ["A", "Q2", 2, "green", 30], + ], +}; + const A_FROM_THE_SECOND_CATEGORY: Row[] = [ ["A", "X2", 10], ["A", "X3", 30], @@ -84,7 +124,7 @@ async function renderChart( rows: (Row | ReferenceRow)[], ) { return page.evaluate( - ({ chart, rows }) => { + ({ chart, rows, marks }) => { document.getElementById("test-chart")?.remove(); const container = document.createElement("div"); container.id = "test-chart"; @@ -108,7 +148,7 @@ async function renderChart( const rendered = window.charts?.[before]; const series = (rendered?.w.config.series ?? []).map((s) => ({ name: s.name, - points: s.data.map((p) => [ + points: (s.data ?? []).map((p) => [ p.x instanceof Date ? p.x.toISOString() : p.x, p.y, ]), @@ -126,14 +166,11 @@ async function renderChart( }; }); const shapes = [ - ...container.querySelectorAll( - ".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect", - ), + ...container.querySelectorAll(marks), ].map((shape) => { const { x, y, width, height } = shape.getBBox(); - return { x, y, width, height }; + return { x, y, width, height, fill: shape.getAttribute("fill") }; }); - const annotated = [ ...container.querySelectorAll( ".apexcharts-xaxis-annotations, .apexcharts-yaxis-annotations", @@ -147,6 +184,9 @@ async function renderChart( labelTexts: annotated.flatMap((g) => [...g.querySelectorAll("text")].map((t) => t.textContent), ), + strokes: annotated.flatMap((g) => + [...g.querySelectorAll("line")].map((l) => l.getAttribute("stroke")), + ), }; return { @@ -159,10 +199,20 @@ async function renderChart( referenceLines, }; }, - { chart, rows }, + { chart, rows, marks: MARKS }, ); } +const fills = (chart: Awaited>) => + chart.shapes.map(({ fill }) => { + const channels = fill?.match(/^rgba\((\d+),(\d+),(\d+),[\d.]+\)$/); + if (!channels) return fill; + const hex = channels + .slice(1) + .map((c) => Number(c).toString(16).padStart(2, "0")); + return `#${hex.join("")}`; + }); + test.beforeEach(async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=chart#component`); await page.waitForSelector(".apexcharts-canvas"); @@ -340,9 +390,9 @@ for (const type of ["area", "scatter", "heatmap"]) { 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], + ["A", "Q1", 1, null, 30], + ["A", "Q2", 2, null, 30], + ["B", "Q2", 5, null, 70], ]); expect(chart.failures).toEqual([]); @@ -429,3 +479,115 @@ test("draws a box behind the label of a reference line that carries one", async expect(chart.referenceLines.labelBoxes).toBe(1); expect(chart.referenceLines.labelTexts).toEqual(["limit"]); }); + +for (const type of [ + "bar", + "column", + "rangeBar", + "pie", + "treemap", + "line", + "area", + "scatter", + "bubble", +]) { + test(`colors every mark of a ${type} chart from its own row`, async ({ + page, + }) => { + const chart = await renderChart( + page, + { type, time: type === "rangeBar" }, + COLORED_ROWS_OF[type] ?? A_RED_ROW_AND_A_GREEN_ROW, + ); + + expect(chart.failures).toEqual([]); + expect(fills(chart)).toEqual([RED, GREEN]); + }); +} + +test("colors each bar of a horizontal bar chart from its own row (#1228)", async ({ + page, +}) => { + const chart = await renderChart( + page, + { type: "bar", horizontal: true }, + EXPIRING_ACCOUNTS, + ); + + expect(chart.failures).toEqual([]); + expect(fills(chart)).toEqual([RED, ORANGE, GREEN]); +}); + +test("leaves a heatmap, which shades its cells from their own value, alone", async ({ + page, +}) => { + const shaded = await renderChart( + page, + { type: "heatmap" }, + THE_SAME_ROWS_UNCOLORED, + ); + const colored = await renderChart( + page, + { type: "heatmap" }, + A_RED_ROW_AND_A_GREEN_ROW, + ); + + expect(colored.failures).toEqual([]); + expect(fills(colored)).toEqual(fills(shaded)); +}); + +test("leaves a row without a color on the color of its series", async ({ + page, +}) => { + const plain = await renderChart( + page, + { type: "bar" }, + THE_SAME_ROWS_UNCOLORED, + ); + const mixed = await renderChart(page, { type: "bar" }, [ + THE_SAME_ROWS_UNCOLORED[0], + ["A", "Q2", 2, "red"], + ]); + + expect(mixed.failures).toEqual([]); + expect(fills(mixed)).toEqual([fills(plain)[0], RED]); +}); + +test("lets a row color override the color given to the whole chart", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "bar", colors: ["azure"] }, [ + ["A", "Q1", 1], + ["A", "Q2", 2, "red"], + ]); + + expect(chart.failures).toEqual([]); + expect(fills(chart)).toEqual(["#339af0", RED]); +}); + +test("keeps the color of the series when a row names a color SQLPage does not know", async ({ + page, +}) => { + const plain = await renderChart( + page, + { type: "bar" }, + THE_SAME_ROWS_UNCOLORED, + ); + const unknown = await renderChart(page, { type: "bar" }, [ + ["A", "Q1", 1, "#ff0000"], + ["A", "Q2", 2, "chartreuse"], + ]); + + expect(unknown.failures).toEqual([]); + expect(fills(unknown)).toEqual(fills(plain)); +}); + +test("keeps coloring reference lines from their own row", async ({ page }) => { + const chart = await renderChart(page, { type: "line", ymax: 100 }, [ + { yline: 70, label: "target", color: "green" }, + ...THE_SAME_ROWS_UNCOLORED, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.referenceLines.strokes).toEqual([GREEN]); +}); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index 7ef7ddc1..4a437f61 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -21,7 +21,12 @@ const STACKED = true; const UNSTACKED = false; type XValue = number | string | Date; -type Point = { x: XValue; y: number | string | null | number[]; z?: number }; +type Point = { + x: XValue; + y: number | string | null | number[]; + z?: number; + fillColor?: string; +}; type Series = { name: string; data: Point[] }; const series = (name: string, ...data: Point[]): Series => ({ name, data }); @@ -166,6 +171,21 @@ test("align_series keeps the third dimension of points it did not fill in", () = assert.equal(a.data[0].z, 42); }); +test("align_series keeps the color of a point on the x it belongs to", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 20, fillColor: "#37b24d" }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual( + b.data.map((point) => point.fillColor), + [undefined, "#37b24d"], + ); +}); + test("align_series matches dates by value rather than by identity", () => { const [a, b] = align_series( [