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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
- 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.
- 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.
- Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now ignored instead of breaking the whole map.
- Screen readers now announce the title of the modal component instead of an unnamed dialog.

## v0.45

Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"!**/*.svg",
"!examples/official-site/pgconf",
"!tests/end-to-end/test-results",
"!.zed/*.json"
"!.zed/*.json",
"!.claude/*.json"
],
"ignoreUnknown": true
},
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
10 changes: 5 additions & 5 deletions examples/CRUD - Authentication/www/css/style.css
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
.menu_options_slim {
min-width: inherit !important;
min-width: inherit;
}

.menu_language_slim {
min-width: inherit !important;
min-width: inherit;
}

.menu_language {
min-width: 200%;
}

div.dropdown-menu- {
min-width: inherit !important;
min-width: inherit;
}

a.dropdown-item- {
min-width: inherit !important;
min-width: inherit;
}

.slim_item {
min-width: inherit !important;
min-width: inherit;
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
--tblr-red: var(--tblr-danger);

/* Luminous links */
--tblr-link-color: hsl(212, 70%, 75%) !important; /* Star glow */
--tblr-link-hover-color: hsl(212, 70%, 85%) !important; /* Supernova */
--tblr-link-color: hsl(212, 70%, 75%); /* Star glow */
--tblr-link-hover-color: hsl(212, 70%, 85%); /* Supernova */
--tblr-carousel-caption-color: var(--tblr-muted-color);

/* Ethereal shadows */
Expand Down Expand Up @@ -241,7 +241,8 @@ pre:has(code) {

@media print {
pre:has(code) {
/* biome-ignore lint/complexity/noImportantStyles: .markdown pre is more specific and caps the height at 20rem */
max-height: none !important;
box-shadow: none !important;
box-shadow: none;
}
}
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
3 changes: 1 addition & 2 deletions examples/rich-text-editor/rich_text_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function markdownToDelta(markdown) {
*/
function mdastToDelta(tree) {
const delta = { ops: [] };
if (!tree || !tree.children) return delta;
if (!tree?.children) return delta;

for (const node of tree.children) {
traverseMdastNode(node, delta);
Expand Down Expand Up @@ -230,7 +230,6 @@ function traverseMdastNode(node, delta, attributes = {}) {
break;

case "listItem": {
// biome-ignore lint/correctness/noUnusedVariables: object destructuring with a spread
const { list, ...listItemChildrenAttributes } = attributes;

for (const child of node.children || []) {
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;
Loading