Skip to content

Commit 344304e

Browse files
81reaplovasoa
andauthored
feat(chart) :: add the ability to customize chart data colours (#1404)
* feat(chart) :: add the ability to customize chart data colours * fix(ci): satisfy newer clippy lints --------- Co-authored-by: Ophir Lojkine <contact@ophir.dev>
1 parent a49ad65 commit 344304e

7 files changed

Lines changed: 252 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Datagrid rows with an icon or image no longer display an unnecessary en-dash placeholder, and an explicitly empty description remains empty.
2525
- Tooltip title text is now inhertis the same colour as the tooltip text.
2626
- 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.
27+
- 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.
2728

2829
## v0.45
2930

examples/official-site/sqlpage/migrations/01_documentation.sql

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
692692
('yline_end', 'Makes the yline a band instead of a line, reaching to this value.', 'REAL', FALSE, TRUE),
693693
('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),
694694
('xline_end', 'Makes the xline a band instead of a line, reaching to this value.', 'TEXT', FALSE, TRUE),
695-
('color', 'The name of a color for the reference line this row draws. Grey by default.', 'COLOR', FALSE, TRUE)
695+
('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)
696696
) x;
697697
INSERT INTO example(component, description, properties) VALUES
698698
('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.
797797
{"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]}
798798
]')),
799799
('chart', '
800+
## Coloring a single value
801+
802+
A data row can carry its own `color`, to paint the one bar, slice or point it
803+
plots. Use it when the color says something the axes do not: a threshold
804+
crossed, a status, the one category the reader should look at first.
805+
806+
```sql
807+
select ''chart'' as component, ''bar'' as type, true as horizontal,
808+
true as labels, false as show_legend;
809+
select
810+
window_label as label,
811+
accounts as value,
812+
case when days <= 30 then ''red'' when days <= 60 then ''orange'' else ''green'' end as color
813+
from expiring_accounts order by days;
814+
```
815+
816+
A row color takes precedence over the color of its series. On a `line` or an
817+
`area` chart it paints the marker of the point, so set `marker` for it to show.
818+
A `heatmap` shades its cells from their own value and ignores it.
819+
', json('[
820+
{"component":"chart", "title": "Accounts expiring soon", "type": "bar",
821+
"horizontal": true, "labels": true, "show_legend": false},
822+
{"label": "30 days", "value": 100, "color": "red"},
823+
{"label": "60 days", "value": 200, "color": "orange"},
824+
{"label": "90 days", "value": 300, "color": "green"}
825+
]')),
826+
('chart', 'A pie chart whose rows choose their own slice colors.', json('[
827+
{"component":"chart", "title": "Support tickets", "type": "pie", "labels": true},
828+
{"label": "Resolved", "value": 72, "color": "green"},
829+
{"label": "In progress", "value": 21, "color": "yellow"},
830+
{"label": "Overdue", "value": 7, "color": "red"}
831+
]')),
832+
('chart', '
800833
## Reference lines
801834
802835
A row with a `yline` is not plotted as a data point, but drawn as a line across

sqlpage/apexcharts.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ sqlpage_chart = (() => {
5050
};
5151

5252
/** @typedef {number|string|Date} XValue */
53-
/** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */
53+
/** @typedef { {x:XValue, y:number|null, z?:number, fillColor?:string} } ChartPoint */
54+
/** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */
5455
/** @typedef { { [name:string]: ChartSeries } } Series */
5556

5657
/** @param {XValue} x @returns {number|string} equal x values share a key */
@@ -122,9 +123,12 @@ sqlpage_chart = (() => {
122123

123124
/** @typedef { {[property:string]: string|number|null} } ReferenceLine */
124125

126+
/** @param {unknown} name @returns {string|undefined} */
127+
const named_color = (name) =>
128+
typeof name === "string" ? colorNames[name] : undefined;
129+
125130
/** @param {string|number|null} name */
126-
const reference_color = (name) =>
127-
(typeof name === "string" && colorNames[name]) || referenceColor;
131+
const reference_color = (name) => named_color(name) || referenceColor;
128132

129133
/**
130134
* @param {ReferenceLine[]} rows - the rows that carry an xline or a yline
@@ -178,7 +182,7 @@ sqlpage_chart = (() => {
178182
const reference_rows = data.points.filter((row) => !Array.isArray(row));
179183
/** @type { Series } */
180184
const series_map = {};
181-
for (const [name, old_x, old_y, z] of points) {
185+
for (const [name, old_x, old_y, color, z] of points) {
182186
series_map[name] = series_map[name] || { name, data: [] };
183187
let x = old_x;
184188
let y = old_y;
@@ -188,18 +192,19 @@ sqlpage_chart = (() => {
188192
y = y.map((y) => new Date(y).getTime());
189193
else x = new Date(x);
190194
}
191-
series_map[name].data.push({ x, y, z });
195+
series_map[name].data.push({ x, y, z, fillColor: named_color(color) });
192196
}
193197
if (data.xmin == null) data.xmin = undefined;
194198
if (data.xmax == null) data.xmax = undefined;
195199
if (data.ymin == null) data.ymin = undefined;
196200
if (data.ymax == null) data.ymax = undefined;
197201

198-
const colors = [
202+
const palette = [
199203
...data.colors.filter((c) => c).map((c) => colorNames[c]),
200204
...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)),
201205
...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)),
202206
];
207+
let colors = palette;
203208

204209
let series = Object.values(series_map);
205210

@@ -208,6 +213,9 @@ sqlpage_chart = (() => {
208213
if (chart_type === "pie") {
209214
labels = points.map(([name, x, _y]) => x || name);
210215
series = points.map(([_name, _x, y]) => Number.parseFloat(y));
216+
colors = points.map(
217+
([, , , color], i) => named_color(color) || palette[i % palette.length],
218+
);
211219
} else if (series.length > 1)
212220
series = align_series_for(series, chart_type, is_stacked);
213221

sqlpage/templates/chart.handlebars

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
{{~ stringify (default series (default ../title "")) ~}},
5252
{{~ stringify (default x label) ~}},
5353
{{~ stringify (default y value) ~}}
54+
{{~#if (or color z)}}, {{~ stringify color ~}} {{~/if~}}
5455
{{~#if z}}, {{~ stringify z ~}} {{~/if~}}
5556
]
5657
{{~/if~}}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
SELECT 'chart' AS component, 'It works !' AS title;
2+
SELECT 'plain' AS x, '1' AS y;
3+
SELECT 'colored' AS x, '2' AS y, 'red' AS color;
4+
SELECT 'sized' AS x, '3' AS y, '30' AS z;
5+
SELECT 'both' AS x, '4' AS y, 'green' AS color, '40' AS z;
6+
SELECT '70' AS yline, 'limit' AS label, 'orange' AS color;

tests/end-to-end/chart-component.spec.ts

Lines changed: 175 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,24 @@ declare global {
1010
w: {
1111
config: {
1212
chart: { type: string; stacked: boolean };
13-
series: { name: string; data: ChartPoint[] }[];
13+
series: { name: string; data?: ChartPoint[] }[];
1414
};
1515
};
1616
}[];
1717
}
1818
function sqlpage_chart(): void;
1919
}
2020

21-
type Row = [series: string, x: unknown, y: unknown, z?: unknown];
21+
type Row = [
22+
series: string,
23+
x: unknown,
24+
y: unknown,
25+
color?: unknown,
26+
z?: unknown,
27+
];
28+
29+
const MARKS =
30+
".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect, .apexcharts-pie-area, .apexcharts-heatmap-rect, .apexcharts-marker";
2231

2332
type ReferenceRow = {
2433
xline?: string | number;
@@ -68,6 +77,37 @@ const A_QUARTERS_OUT_OF_ORDER: Row[] = [
6877
["A", "Q2", 2],
6978
];
7079

80+
const EXPIRING_ACCOUNTS: Row[] = [
81+
["Accounts", "30 days", 100, "red"],
82+
["Accounts", "60 days", 200, "orange"],
83+
["Accounts", "90 days", 300, "green"],
84+
];
85+
86+
const RED = "#f03e3e";
87+
const ORANGE = "#f76707";
88+
const GREEN = "#37b24d";
89+
90+
const A_RED_ROW_AND_A_GREEN_ROW: Row[] = [
91+
["A", "Q1", 1, "red"],
92+
["A", "Q2", 2, "green"],
93+
];
94+
95+
const THE_SAME_ROWS_UNCOLORED: Row[] = [
96+
["A", "Q1", 1],
97+
["A", "Q2", 2],
98+
];
99+
100+
const COLORED_ROWS_OF: Record<string, Row[]> = {
101+
rangeBar: [
102+
["A", "one", ["2024-03-01", "2024-03-05"], "red"],
103+
["A", "two", ["2024-03-04", "2024-03-09"], "green"],
104+
],
105+
bubble: [
106+
["A", "Q1", 1, "red", 30],
107+
["A", "Q2", 2, "green", 30],
108+
],
109+
};
110+
71111
const A_FROM_THE_SECOND_CATEGORY: Row[] = [
72112
["A", "X2", 10],
73113
["A", "X3", 30],
@@ -84,7 +124,7 @@ async function renderChart(
84124
rows: (Row | ReferenceRow)[],
85125
) {
86126
return page.evaluate(
87-
({ chart, rows }) => {
127+
({ chart, rows, marks }) => {
88128
document.getElementById("test-chart")?.remove();
89129
const container = document.createElement("div");
90130
container.id = "test-chart";
@@ -108,7 +148,7 @@ async function renderChart(
108148
const rendered = window.charts?.[before];
109149
const series = (rendered?.w.config.series ?? []).map((s) => ({
110150
name: s.name,
111-
points: s.data.map((p) => [
151+
points: (s.data ?? []).map((p) => [
112152
p.x instanceof Date ? p.x.toISOString() : p.x,
113153
p.y,
114154
]),
@@ -126,14 +166,11 @@ async function renderChart(
126166
};
127167
});
128168
const shapes = [
129-
...container.querySelectorAll<SVGGraphicsElement>(
130-
".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect",
131-
),
169+
...container.querySelectorAll<SVGGraphicsElement>(marks),
132170
].map((shape) => {
133171
const { x, y, width, height } = shape.getBBox();
134-
return { x, y, width, height };
172+
return { x, y, width, height, fill: shape.getAttribute("fill") };
135173
});
136-
137174
const annotated = [
138175
...container.querySelectorAll(
139176
".apexcharts-xaxis-annotations, .apexcharts-yaxis-annotations",
@@ -147,6 +184,9 @@ async function renderChart(
147184
labelTexts: annotated.flatMap((g) =>
148185
[...g.querySelectorAll("text")].map((t) => t.textContent),
149186
),
187+
strokes: annotated.flatMap((g) =>
188+
[...g.querySelectorAll("line")].map((l) => l.getAttribute("stroke")),
189+
),
150190
};
151191

152192
return {
@@ -159,10 +199,20 @@ async function renderChart(
159199
referenceLines,
160200
};
161201
},
162-
{ chart, rows },
202+
{ chart, rows, marks: MARKS },
163203
);
164204
}
165205

206+
const fills = (chart: Awaited<ReturnType<typeof renderChart>>) =>
207+
chart.shapes.map(({ fill }) => {
208+
const channels = fill?.match(/^rgba\((\d+),(\d+),(\d+),[\d.]+\)$/);
209+
if (!channels) return fill;
210+
const hex = channels
211+
.slice(1)
212+
.map((c) => Number(c).toString(16).padStart(2, "0"));
213+
return `#${hex.join("")}`;
214+
});
215+
166216
test.beforeEach(async ({ page }) => {
167217
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
168218
await page.waitForSelector(".apexcharts-canvas");
@@ -340,9 +390,9 @@ for (const type of ["area", "scatter", "heatmap"]) {
340390

341391
test("keeps the bubble size of the points it lined up", async ({ page }) => {
342392
const chart = await renderChart(page, { type: "bubble" }, [
343-
["A", "Q1", 1, 30],
344-
["A", "Q2", 2, 30],
345-
["B", "Q2", 5, 70],
393+
["A", "Q1", 1, null, 30],
394+
["A", "Q2", 2, null, 30],
395+
["B", "Q2", 5, null, 70],
346396
]);
347397

348398
expect(chart.failures).toEqual([]);
@@ -429,3 +479,115 @@ test("draws a box behind the label of a reference line that carries one", async
429479
expect(chart.referenceLines.labelBoxes).toBe(1);
430480
expect(chart.referenceLines.labelTexts).toEqual(["limit"]);
431481
});
482+
483+
for (const type of [
484+
"bar",
485+
"column",
486+
"rangeBar",
487+
"pie",
488+
"treemap",
489+
"line",
490+
"area",
491+
"scatter",
492+
"bubble",
493+
]) {
494+
test(`colors every mark of a ${type} chart from its own row`, async ({
495+
page,
496+
}) => {
497+
const chart = await renderChart(
498+
page,
499+
{ type, time: type === "rangeBar" },
500+
COLORED_ROWS_OF[type] ?? A_RED_ROW_AND_A_GREEN_ROW,
501+
);
502+
503+
expect(chart.failures).toEqual([]);
504+
expect(fills(chart)).toEqual([RED, GREEN]);
505+
});
506+
}
507+
508+
test("colors each bar of a horizontal bar chart from its own row (#1228)", async ({
509+
page,
510+
}) => {
511+
const chart = await renderChart(
512+
page,
513+
{ type: "bar", horizontal: true },
514+
EXPIRING_ACCOUNTS,
515+
);
516+
517+
expect(chart.failures).toEqual([]);
518+
expect(fills(chart)).toEqual([RED, ORANGE, GREEN]);
519+
});
520+
521+
test("leaves a heatmap, which shades its cells from their own value, alone", async ({
522+
page,
523+
}) => {
524+
const shaded = await renderChart(
525+
page,
526+
{ type: "heatmap" },
527+
THE_SAME_ROWS_UNCOLORED,
528+
);
529+
const colored = await renderChart(
530+
page,
531+
{ type: "heatmap" },
532+
A_RED_ROW_AND_A_GREEN_ROW,
533+
);
534+
535+
expect(colored.failures).toEqual([]);
536+
expect(fills(colored)).toEqual(fills(shaded));
537+
});
538+
539+
test("leaves a row without a color on the color of its series", async ({
540+
page,
541+
}) => {
542+
const plain = await renderChart(
543+
page,
544+
{ type: "bar" },
545+
THE_SAME_ROWS_UNCOLORED,
546+
);
547+
const mixed = await renderChart(page, { type: "bar" }, [
548+
THE_SAME_ROWS_UNCOLORED[0],
549+
["A", "Q2", 2, "red"],
550+
]);
551+
552+
expect(mixed.failures).toEqual([]);
553+
expect(fills(mixed)).toEqual([fills(plain)[0], RED]);
554+
});
555+
556+
test("lets a row color override the color given to the whole chart", async ({
557+
page,
558+
}) => {
559+
const chart = await renderChart(page, { type: "bar", colors: ["azure"] }, [
560+
["A", "Q1", 1],
561+
["A", "Q2", 2, "red"],
562+
]);
563+
564+
expect(chart.failures).toEqual([]);
565+
expect(fills(chart)).toEqual(["#339af0", RED]);
566+
});
567+
568+
test("keeps the color of the series when a row names a color SQLPage does not know", async ({
569+
page,
570+
}) => {
571+
const plain = await renderChart(
572+
page,
573+
{ type: "bar" },
574+
THE_SAME_ROWS_UNCOLORED,
575+
);
576+
const unknown = await renderChart(page, { type: "bar" }, [
577+
["A", "Q1", 1, "#ff0000"],
578+
["A", "Q2", 2, "chartreuse"],
579+
]);
580+
581+
expect(unknown.failures).toEqual([]);
582+
expect(fills(unknown)).toEqual(fills(plain));
583+
});
584+
585+
test("keeps coloring reference lines from their own row", async ({ page }) => {
586+
const chart = await renderChart(page, { type: "line", ymax: 100 }, [
587+
{ yline: 70, label: "target", color: "green" },
588+
...THE_SAME_ROWS_UNCOLORED,
589+
]);
590+
591+
expect(chart.failures).toEqual([]);
592+
expect(chart.referenceLines.strokes).toEqual([GREEN]);
593+
});

0 commit comments

Comments
 (0)