Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
- 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.
- 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

Expand Down
102 changes: 101 additions & 1 deletion examples/official-site/sqlpage/migrations/01_documentation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,15 @@ 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_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;
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 @@ -780,6 +788,98 @@ 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. 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:

```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_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},
{"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', '
## Marking events

`xline` is the counterpart of `yline`: it marks a position on the x axis instead
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, ended_at as xline_end, summary as xline_label,
case severity when ''outage'' then ''red'' else ''orange'' end as xline_color
from incidents 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_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},
{"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`, and `xline` a position on `x`,
whichever way round the chart is drawn. A `horizontal` bar chart runs its y axis
from left to right, so a `yline` is drawn down the chart and an `xline` picks out
one of the bars.

```sql
select ''chart'' as component, ''bar'' as type, true as horizontal, 100 as ymax;
select 90 as yline, ''full'' as 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, and on a `heatmap`, whose y axis
holds the names of the series, only `xline` has a meaning.
', json('[
{"component":"chart", "title": "Disk usage", "type": "bar", "horizontal": true,
"ymax": 100, "color": "azure", "labels": true},
{"yline": 90, "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)
Expand Down
124 changes: 109 additions & 15 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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
Expand All @@ -66,29 +77,90 @@ 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]));
return {
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 };

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 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 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 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,
};
// apexcharts reads label.text unconditionally, so an annotation without
// a label must not have the key at all.
if (text)
annotation.label = {
text,
orientation: column === "y" ? "horizontal" : "vertical",
borderColor: color,
style: { background: color, color: isDarkTheme ? "#000" : "#fff" },
};
return [annotation];
});
}

/** @param {HTMLElement} c */
function build_sqlpage_chart(c) {
Expand All @@ -101,9 +173,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;
Expand All @@ -129,18 +203,38 @@ 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);
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_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`]: 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,
fontFamily: "inherit",
Expand Down
9 changes: 9 additions & 0 deletions sqlpage/templates/chart.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,21 @@
"points": [
{{~#each_row~}}
{{~#if (gt @row_index 0)}},{{/if~}}
{{~#if (or xline yline)~}}
{
"xline": {{~stringify xline}}, "xline_end": {{~stringify xline_end}},
"xline_label": {{~stringify xline_label}}, "xline_color": {{~stringify xline_color}},
"yline": {{~stringify yline}}, "yline_end": {{~stringify yline_end}},
"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~}}
]
}
Expand Down
Loading