Skip to content
Open
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
27 changes: 15 additions & 12 deletions frontend/src/apexcharts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,30 @@ const sqlpage_chart = (() => {
["black", "#000000", "#000000"],
["white", "#ffffff", "#f8f9fa"],
];
const colorNames = Object.fromEntries(
tblrColors.flatMap(([name, dark, light]) => [
const colorNames = new Map(
tblrColors.flatMap(([name, dark, light]): [string, string][] => [
[name, dark],
[`${name}-lt`, light],
]),
);
const isDarkTheme = document.body?.dataset?.bsTheme === "dark";

const STACKABLE_CHART_TYPES = ["line", "area", "bar"];
const APEXCHARTS_TYPE_ALIASES = { column: "bar" };
const STROKE_WIDTHS = new Map([
["area", 3],
["line", 2],
]);
const APEXCHARTS_TYPE_ALIASES = new Map([["column", "bar"]]);

const referenceColor = colorNames[isDarkTheme ? "gray-lt" : "gray"];
const referenceColor = colorNames.get(isDarkTheme ? "gray-lt" : "gray");

type ReferenceLine = { [property: string]: string | number | null };
type ReferenceLine = Record<
"xline" | "xline_end" | "yline" | "yline_end" | "label" | "color",
string | number | null
>;

const named_color = (name: unknown): string | undefined =>
typeof name === "string" ? colorNames[name] : undefined;
typeof name === "string" ? colorNames.get(name) : undefined;

const reference_color = (name: string | number | null) =>
named_color(name) || referenceColor;
Expand Down Expand Up @@ -128,7 +135,7 @@ const sqlpage_chart = (() => {
chartContainer.innerHTML = "";
const is_timeseries = !!data.time;
const chart_type =
APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line";
APEXCHARTS_TYPE_ALIASES.get(data.type) || data.type || "line";
const is_stacked =
!!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type);
const points: DataPoint[] = data.points
Expand Down Expand Up @@ -277,11 +284,7 @@ const sqlpage_chart = (() => {
type: chart_type === "area" ? "gradient" : "solid",
},
stroke: {
width:
{
area: 3,
line: 2,
}[chart_type] || 0,
width: STROKE_WIDTHS.get(chart_type) ?? 0,
lineCap: "round",
curve: "smooth",
},
Expand Down
21 changes: 11 additions & 10 deletions frontend/src/chart_series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ export type Series = Map<string, ChartSeries>;

const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"];

const Y_WHEN_A_SERIES_SKIPS_A_LABEL: Record<string, number | null> = {
bar: 0,
line: null,
area: null,
scatter: null,
bubble: null,
heatmap: null,
};
const Y_WHEN_A_SERIES_SKIPS_A_LABEL = new Map<string, number | null>([
["bar", 0],
["line", null],
["area", null],
["scatter", null],
["bubble", null],
["heatmap", null],
]);

/** equal x values share a key */
const x_key = (x: XValue): number | string =>
Expand Down Expand Up @@ -92,7 +92,8 @@ export function align_series_for(
is_stacked: boolean,
): ChartSeries[] {
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]);
const y_when_missing = Y_WHEN_A_SERIES_SKIPS_A_LABEL.get(chart_type);
if (x_is_text(series) && y_when_missing !== undefined)
return align_series(series, y_when_missing);
return series;
}
13 changes: 1 addition & 12 deletions frontend/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,4 @@
// Names the browser bundle relies on at runtime rather than through an import:
// libraries loaded on demand, and what a page may provide for our scripts.

/**
* A library this project ships no type definitions for. Saying `unknown`
* instead would only move the guesswork to a cast at every call site.
*/
// biome-ignore lint/suspicious/noExplicitAny: that is what an untyped library is
type Untyped = any;

/** Leaflet, loaded from a CDN by sqlpage_map when a page holds a map. */
declare const L: Untyped;
// Names the browser bundle relies on at runtime rather than through an import.

interface Window {
/** Every chart rendered on the page, in the order they were built. */
Expand Down
128 changes: 79 additions & 49 deletions frontend/src/sqlpage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { bootstrap as bundled_bootstrap } from "@tabler/core";
import type * as Leaflet from "leaflet";
import { add_init_fn } from "./init.ts";

// A page may load its own Bootstrap; prefer it over the bundled copy.
Expand Down Expand Up @@ -186,6 +187,15 @@ function sqlpage_table() {
}
}

// Leaflet is loaded from a CDN by sqlpage_map, so it is a global, not an import.
declare const L: typeof Leaflet;

type MarkerStyle = Leaflet.MarkerOptions &
Leaflet.PathOptions & {
/** A GeoJSON feature may size its own icon, overriding the SVG's width. */
size?: number | string;
};

let is_leaflet_injected = false;
let is_leaflet_loaded = false;

Expand Down Expand Up @@ -214,11 +224,9 @@ function sqlpage_map() {
if (first_map && is_leaflet_loaded) {
onLeafletLoad();
}
/**
*/
function parseCoords(
coords: string | undefined,
): [number, number] | undefined {
): Leaflet.LatLngTuple | undefined {
if (!coords) return undefined;
const parsed = coords.split(",", 2).map((c) => Number.parseFloat(c));
if (parsed.length !== 2 || !parsed.every(Number.isFinite)) {
Expand All @@ -241,76 +249,98 @@ function sqlpage_map() {
const center = parseCoords(m.dataset.center);
if (tile_source)
L.tileLayer(tile_source, { attribution, maxZoom }).addTo(map);
map._sqlpage_markers = [];
for (const marker_elem of m.getElementsByClassName("marker")) {
setTimeout(addMarker, 0, marker_elem, map);
const markers: (Leaflet.Marker | Leaflet.GeoJSON)[] = [];
for (const marker_elem of m.querySelectorAll<HTMLElement>(".marker")) {
setTimeout(() => {
const marker = addMarker(marker_elem, map);
if (marker) markers.push(marker);
}, 0);
}
setTimeout(() => {
if (center) map.setView(center, zoom);
else {
const markerBounds = (m) =>
m.getLatLng ? m.getLatLng() : m.getBounds();
const bounds = map._sqlpage_markers.map(markerBounds);
if (bounds.length > 0) map.fitBounds(bounds);
const bounds = L.latLngBounds([]);
for (const marker of markers)
bounds.extend(
marker instanceof L.Marker
? marker.getLatLng()
: marker.getBounds(),
);
if (markers.length > 0) map.fitBounds(bounds);
else map.setView([51.505, 10], zoom);
if (!Number.isNaN(zoom)) map.setZoom(zoom);
}
}, 100);
m.removeAttribute("data-pre-init");
m.getElementsByClassName("spinner-border")[0]?.remove();
m.querySelector(".spinner-border")?.remove();
}
}

function addMarker(marker_elem, map) {
const { dataset } = marker_elem;
const options = {
color: marker_elem.dataset.color,
title: marker_elem.getElementsByTagName("h3")[0].textContent.trim(),
function addMarker(marker_elem: HTMLElement, map: Leaflet.Map) {
const { color, coords, geojson, link } = marker_elem.dataset;
const options: MarkerStyle = {
color,
title: marker_elem.querySelector("h3")?.textContent?.trim(),
};
const marker = dataset.coords
const marker = coords
? createMarker(marker_elem, options)
: createGeoJSONMarker(marker_elem, options);
if (!marker) return;
: geojson && createGeoJSONMarker(marker_elem, geojson, options);
if (!marker) return undefined;
marker.addTo(map);
map._sqlpage_markers.push(marker);
if (marker_elem.textContent.trim()) marker.bindPopup(marker_elem);
else if (marker_elem.dataset.link) {
if (marker_elem.textContent?.trim()) marker.bindPopup(marker_elem);
else if (link) {
marker.on("click", () => {
window.location.href = marker_elem.dataset.link;
window.location.href = link;
});
}
return marker;
}
function createMarker(marker_elem, options) {
function createMarker(marker_elem: HTMLElement, options: MarkerStyle) {
const coords = parseCoords(marker_elem.dataset.coords);
if (!coords) return undefined;
const icon_obj = marker_elem.getElementsByClassName("mapicon")[0];
if (icon_obj) {
const size =
1.5 *
+(options.size || icon_obj.firstChild?.getAttribute("width") || 24);
options.icon = L.divIcon({
html: icon_obj,
className: `border-0 bg-${options.color || "primary"} bg-gradient text-white rounded-circle shadow d-flex justify-content-center align-items-center`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
return L.marker(coords, options);
return coords && createMarkerAt(marker_elem, coords, options);
}
function createGeoJSONMarker(marker_elem, options) {
const geojson = JSON.parse(marker_elem.dataset.geojson);
function createMarkerAt(
marker_elem: HTMLElement,
coords: Leaflet.LatLngTuple,
options: MarkerStyle,
) {
const icon_obj = marker_elem.querySelector<HTMLElement>(".mapicon");
if (!icon_obj) return L.marker(coords, options);
const size =
1.5 *
+(
options.size ||
icon_obj.firstElementChild?.getAttribute("width") ||
24
);
const icon = L.divIcon({
html: icon_obj,
className: `border-0 bg-${options.color || "primary"} bg-gradient text-white rounded-circle shadow d-flex justify-content-center align-items-center`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
return L.marker(coords, { ...options, icon });
}
function createGeoJSONMarker(
marker_elem: HTMLElement,
geojson: string,
options: MarkerStyle,
) {
if (options.color) {
options.color = get_tabler_color(options.color) || options.color;
}
function style({ properties }) {
if (typeof properties !== "object") return options;
return { ...options, ...properties };
}
function pointToLayer(feature, latlng) {
marker_elem.dataset.coords = `${latlng.lat},${latlng.lng}`;
return createMarker(marker_elem, { ...options, ...feature.properties });
}
return L.geoJSON(geojson, { style, pointToLayer });
return L.geoJSON(JSON.parse(geojson), {
style: (feature) => {
const properties = feature?.properties;
if (typeof properties !== "object") return options;
return { ...options, ...properties };
},
pointToLayer: (feature, latlng) =>
createMarkerAt(marker_elem, [latlng.lat, latlng.lng], {
...options,
...feature.properties,
}),
});
}
}

Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.11",
"@types/leaflet": "^1.9.22",
"@types/node": "^26.4.0",
"rolldown": "^1.2.8",
"typescript": "^7.0.2"
Expand Down
2 changes: 2 additions & 0 deletions tests/end-to-end/fixtures/chart/builtin-chart-color.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 'toString' AS color, 4 AS marker;
WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2)) SELECT * FROM points;
10 changes: 10 additions & 0 deletions tests/end-to-end/fixtures/chart/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,16 @@ test("keeps the default palette when the chart names a color SQLPage does not kn
expect(fills(unknown)).toEqual(fills(plain));
});

test("draws a chart whose color names a built-in JavaScript property", async ({
page,
}) => {
const plain = await renderChart(page, "uncolored-bar");
const chart = await renderChart(page, "builtin-chart-color");

expect(chart.failures).toEqual([]);
expect(fills(chart)).toEqual(fills(plain));
});

test("renders series named after built-in JavaScript properties", async ({
page,
}) => {
Expand Down
Loading