Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 34 additions & 1 deletion examples/official-site/sqlpage/migrations/01_documentation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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);

Expand Down
1 change: 1 addition & 0 deletions sqlpage/templates/chart.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -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~}}
Expand Down
6 changes: 6 additions & 0 deletions tests/components/chart_point_serialization.sql
Original file line number Diff line number Diff line change
@@ -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;
188 changes: 175 additions & 13 deletions tests/end-to-end/chart-component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@ declare global {
w: {
config: {
chart: { type: string; stacked: boolean };
series: { name: string; data: ChartPoint[] }[];
series: { name: string; data?: ChartPoint[] }[];
};
};
}[];
}
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;
Expand Down Expand Up @@ -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<string, Row[]> = {
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],
Expand All @@ -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";
Expand All @@ -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,
]),
Expand All @@ -126,14 +166,11 @@ async function renderChart(
};
});
const shapes = [
...container.querySelectorAll<SVGGraphicsElement>(
".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect",
),
...container.querySelectorAll<SVGGraphicsElement>(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",
Expand All @@ -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 {
Expand All @@ -159,10 +199,20 @@ async function renderChart(
referenceLines,
};
},
{ chart, rows },
{ chart, rows, marks: MARKS },
);
}

const fills = (chart: Awaited<ReturnType<typeof renderChart>>) =>
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");
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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]);
});
Loading