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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
- Added a `toast` component with plain-text or Markdown content, icons, colors, six screen placements, configurable auto-dismiss timing, optional manual dismissal, URL-fragment triggers, and automatic stacking of queued notifications.
- `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined.
- 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.

## v0.45
Expand Down
10 changes: 10 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ async fn process_input_file(client: &awc::Client, path_out: &Path, original: Fil
return;
}
outfile.write_all(b"\n").unwrap();
} else if let Some(name) = line
.strip_prefix("/* !include ./")
.and_then(|rest| rest.strip_suffix(" */"))
{
let included_path = format!("sqlpage/{name}");
println!("cargo:rerun-if-changed={included_path}");
let included = std::fs::read(&included_path)
.unwrap_or_else(|e| panic!("Unable to read {included_path}: {e}"));
outfile.write_all(&included).unwrap();
outfile.write_all(b"\n").unwrap();
} else {
writeln!(outfile, "{line}").unwrap();
}
Expand Down
18 changes: 16 additions & 2 deletions examples/official-site/sqlpage/migrations/01_documentation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ INSERT INTO component(name, icon, description) VALUES
INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'chart', * FROM (VALUES
-- top level
('title', 'The name of the chart.', 'TEXT', TRUE, TRUE),
('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar"', 'TEXT', TRUE, FALSE),
('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar". "column" is a synonym of "bar".', 'TEXT', TRUE, FALSE),
('time', 'Whether the x-axis represents time. If set to true, the x values will be parsed and formatted as dates for the user.', 'BOOLEAN', TRUE, TRUE),
('xmin', 'The minimal value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE),
('xmax', 'The maximum value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE),
Expand All @@ -664,7 +664,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
('marker', 'Marker size', 'REAL', TRUE, TRUE),
('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE),
('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE),
('stacked', 'Whether to cumulate values from different series.', 'BOOLEAN', TRUE, TRUE),
('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others. Series are aligned on their x values, and a series that has no value for a given x counts as zero there.', 'BOOLEAN', TRUE, TRUE),
('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE),
('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE),
('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE),
Expand Down Expand Up @@ -717,6 +717,20 @@ INSERT INTO example(component, description, properties) VALUES
'{"series": "Marketing", "x": 2022, "value": 15}, '||
'{"series": "Human resources", "x": 2021, "value": 30}, '||
'{"series": "Human resources", "x": 2022, "value": 55}]')),
('chart', 'A stacked area chart, showing how each series contributes to a total.
The `stacked` property also works with the `line` and `bar` chart types.

Series are aligned on their `x` values, and a series that has no value for a given `x` counts as zero there:
below, the graphics card draws no power outside of the render.
If a missing value does not mean zero in your data, make all the series share the same `x` values,
for instance by rounding timestamps to a common interval.',
json('[{"component":"chart", "title": "Power draw", "type": "area", "stacked": true, "time": true, "ytitle": "watts", "color": ["blue", "teal"], "marker": 4}, '||
'{"series": "CPU", "x": "2024-03-01T10:00:00Z", "value": 45}, '||
'{"series": "CPU", "x": "2024-03-01T10:15:00Z", "value": 52}, '||
'{"series": "CPU", "x": "2024-03-01T10:30:00Z", "value": 48}, '||
'{"series": "CPU", "x": "2024-03-01T10:45:00Z", "value": 44}, '||
'{"series": "GPU", "x": "2024-03-01T10:15:00Z", "value": 120}, '||
'{"series": "GPU", "x": "2024-03-01T10:30:00Z", "value": 140}]')),
('chart', 'A line chart with multiple series. One of the most common types of charts, often used to show trends over time.
Also demonstrates the use of the `toolbar` attribute to allow the user to download the graph as an image or the data as a CSV file.',
json('[{"component":"chart", "title": "Revenue", "ymin": 0, "toolbar": true},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "sqlpage",
"version": "1.0.0",
"scripts": {
"test": "biome check .",
"test": "biome check . && node --test \"tests/js/**/*.spec.ts\"",
"format": "biome format --write .",
"fix": "biome check --fix --unsafe ."
},
Expand Down
88 changes: 23 additions & 65 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* !include https://cdn.jsdelivr.net/npm/apexcharts@5.13.0/dist/apexcharts.min.js */
/* !include ./chart_series.js */

sqlpage_chart = (() => {
function sqlpage_chart() {
Expand Down Expand Up @@ -36,60 +37,12 @@ sqlpage_chart = (() => {
);
const isDarkTheme = document.body?.dataset?.bsTheme === "dark";

/** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */
const STACKABLE_CHART_TYPES = ["line", "area", "bar"];
const APEXCHARTS_TYPE_ALIASES = { column: "bar" };

/**
* Aligns series data points by their x-axis categories, ensuring all series have data points
* for each unique category. Missing values are filled with zeros.
* Categories are ordered by their name.
*
* @example
* // Input series:
* const series = [
* { name: "A", data: [{x: "X2", y: 10}, {x: "X3", y: 30}] },
* { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}] }
* ];
*
* // Output after align_categories (orderedCategories will be ["X1","X2", "X3"]):
* // [
* // { name: "A", data: [{x: "X1", y: 0}, {x: "X2", y: 10}, {x: "X3", y: 30}] },
* // { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}, {x: "X3", y: 0}] }
* // ]
*
* @param {(Series[string])[]} series - Array of series objects, each containing name and data points
* @returns {Series[string][]} Aligned series with consistent categories across all series
*/
function align_categories(series) {
const categoriesSet = new Set();
const pointers = series.map((_) => 0); // Index of current data point in each series
const x_at = (series_idx) =>
series[series_idx].data[pointers[series_idx]].x;
const series_idxs = series.flatMap((s, i) => (s.data.length ? i : []));
while (series_idxs.length > 0) {
let idx_of_xmin = series_idxs[0];
for (const series_idx of series_idxs) {
if (x_at(series_idx) < x_at(idx_of_xmin)) idx_of_xmin = series_idx;
}
/** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */

const new_category = x_at(idx_of_xmin);
if (!categoriesSet.has(new_category)) categoriesSet.add(new_category);
pointers[idx_of_xmin]++;
if (pointers[idx_of_xmin] >= series[idx_of_xmin].data.length) {
series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1);
}
}
// Create a map of category -> value for each series and rebuild
return series.map((s) => {
const valueMap = new Map(s.data.map((point) => [point.x, point.y]));
return {
name: s.name,
data: Array.from(categoriesSet, (category) => ({
x: category,
y: valueMap.get(category) || 0,
})),
};
});
}
const { align_series } = chart_series;

/** @param {HTMLElement} c */
function build_sqlpage_chart(c) {
Expand All @@ -98,6 +51,10 @@ sqlpage_chart = (() => {
const chartContainer = c.querySelector(".chart");
chartContainer.innerHTML = "";
const is_timeseries = !!data.time;
const chart_type =
APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line";
const is_stacked =
!!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type);
/** @type { Series } */
const series_map = {};
for (const [name, old_x, old_y, z] of data.points) {
Expand All @@ -106,7 +63,7 @@ sqlpage_chart = (() => {
let y = old_y;
if (is_timeseries) {
if (typeof x === "number") x = new Date(x * 1000);
else if (data.type === "rangeBar" && Array.isArray(y))
else if (chart_type === "rangeBar" && Array.isArray(y))
y = y.map((y) => new Date(y).getTime());
else x = new Date(x);
}
Expand All @@ -128,21 +85,23 @@ sqlpage_chart = (() => {
let labels;
const categories =
series.length > 0 && typeof series[0].data[0].x === "string";
if (data.type === "pie") {
const y_is_a_range = chart_type === "rangeBar";
const apexcharts_pairs_points_by_index =
is_stacked || (categories && !y_is_a_range);
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 (categories && data.type === "bar" && series.length > 1)
series = align_categories(series);
} else if (series.length > 1 && apexcharts_pairs_points_by_index)
series = align_series(series, is_stacked ? 0 : null);

const chart_type = data.type || "line";
const options = {
chart: {
type: chart_type,
fontFamily: "inherit",
background: "transparent",
parentHeightOffset: 0,
height: chartContainer.style.height,
stacked: !!data.stacked,
stacked: is_stacked,
toolbar: {
show: !!data.toolbar,
},
Expand All @@ -167,15 +126,15 @@ sqlpage_chart = (() => {
color: "var(--tblr-primary-bg-subtle)",
},
formatter:
data.type === "rangeBar"
chart_type === "rangeBar"
? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name
: data.type === "pie"
: chart_type === "pie"
? (value, { seriesIndex, w }) =>
`${w.config.labels[seriesIndex]}: ${value.toFixed()}%`
: (value) => value?.toLocaleString?.() || value,
},
fill: {
type: data.type === "area" ? "gradient" : "solid",
type: chart_type === "area" ? "gradient" : "solid",
},
stroke: {
width:
Expand Down Expand Up @@ -225,13 +184,13 @@ sqlpage_chart = (() => {
tooltip: {
fillSeriesColor: false,
custom:
data.type === "bubble" || data.type === "scatter"
chart_type === "bubble" || chart_type === "scatter"
? bubbleTooltip
: undefined,
y: {
formatter: (value) => {
if (value == null) return "";
if (is_timeseries && data.type === "rangeBar") {
if (is_timeseries && chart_type === "rangeBar") {
const d = new Date(value);
if (d.getHours() === 0 && d.getMinutes() === 0)
return d.toLocaleDateString();
Expand All @@ -246,7 +205,7 @@ sqlpage_chart = (() => {
},
plotOptions: {
bar: {
horizontal: !!data.horizontal || data.type === "rangeBar",
horizontal: !!data.horizontal || chart_type === "rangeBar",
borderRadius: 5,
},
bubble: { minBubbleRadius: 5 },
Expand All @@ -257,7 +216,6 @@ sqlpage_chart = (() => {
if (labels) options.labels = labels;
// tickamount is the number of intervals, not the number of ticks
if (data.xticks) options.xaxis.tickAmount = data.xticks;
console.log("Rendering chart", options);
const chart = new ApexCharts(chartContainer, options);
chart.render();
if (window.charts) window.charts.push(chart);
Expand Down
58 changes: 58 additions & 0 deletions sqlpage/chart_series.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const chart_series = (() => {
/** @typedef {number|string|Date} XValue */
/** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */

/**
* @param {XValue} x
* @returns {number|string}
*/
const x_map_key = (x) => (x instanceof Date ? x.getTime() : x);

/**
* The x values of every series, in the order the series wrote them when they
* agree, and in ascending order where they diverge.
*
* @param {ChartSeries[]} series
* @returns {XValue[]}
*/
function merge_x_values(series) {
const queues = series.map(({ data }) => data.map(({ x }) => x));
const merged = new Map();

while (queues.some((queue) => queue.length > 0)) {
const lowest = queues
.filter((queue) => queue.length > 0)
.reduce((a, b) => (b[0] < a[0] ? b : a));
const x = lowest.shift();
merged.set(x_map_key(x), x);
}
return [...merged.values()];
}

/**
* Gives every series a point at every x any of them has, so that charts that
* pair their points by index line them up by x value instead.
*
* @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, y_when_missing) {
const all_x = merge_x_values(series);
return series.map(({ name, data }) => {
const points_by_x = new Map(data.map((p) => [x_map_key(p.x), p]));
return {
name,
data: all_x.map((x) => {
const point = points_by_x.get(x_map_key(x));
return point?.y == null ? { x, y: y_when_missing } : point;
}),
};
});
}

return { align_series, merge_x_values };
})();

if (typeof module !== "undefined") module.exports = chart_series;
13 changes: 11 additions & 2 deletions sqlpage/sqlpage.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,19 @@ function sqlpage_map() {
onLeafletLoad();
}
/**
*
* @param {string|undefined} coords
* @returns {[number, number] | undefined}
*/
function parseCoords(coords) {
return coords?.split(",", 2).map((c) => Number.parseFloat(c));
if (!coords) return undefined;
const parsed = coords.split(",", 2).map((c) => Number.parseFloat(c));
if (parsed.length !== 2 || !parsed.every(Number.isFinite)) {
console.error(
`Invalid map coordinates: ${JSON.stringify(coords)}. Expected a "latitude,longitude" pair of numbers.`,
);
return undefined;
}
return [parsed[0], parsed[1]];
}
function onLeafletLoad() {
is_leaflet_loaded = true;
Expand Down Expand Up @@ -230,6 +237,7 @@ function sqlpage_map() {
const marker = dataset.coords
? createMarker(marker_elem, options)
: createGeoJSONMarker(marker_elem, options);
if (!marker) return;
marker.addTo(map);
map._sqlpage_markers.push(marker);
if (marker_elem.textContent.trim()) marker.bindPopup(marker_elem);
Expand All @@ -241,6 +249,7 @@ function sqlpage_map() {
}
function createMarker(marker_elem, options) {
const coords = parseCoords(marker_elem.dataset.coords);
if (!coords) return undefined;
const icon_obj = marker_elem.getElementsByClassName("mapicon")[0];
if (icon_obj) {
const size =
Expand Down
Loading
Loading