diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts index eb676375..56b0edee 100644 --- a/frontend/src/apexcharts.ts +++ b/frontend/src/apexcharts.ts @@ -67,8 +67,8 @@ 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], ]), @@ -76,14 +76,21 @@ const sqlpage_chart = (() => { 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; @@ -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 @@ -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", }, diff --git a/frontend/src/chart_series.ts b/frontend/src/chart_series.ts index 4229e8d5..45e26ba6 100644 --- a/frontend/src/chart_series.ts +++ b/frontend/src/chart_series.ts @@ -11,14 +11,14 @@ export type Series = Map; const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"]; -const Y_WHEN_A_SERIES_SKIPS_A_LABEL: Record = { - bar: 0, - line: null, - area: null, - scatter: null, - bubble: null, - heatmap: null, -}; +const Y_WHEN_A_SERIES_SKIPS_A_LABEL = new Map([ + ["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 => @@ -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; } diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts index 38b33d68..d4af324c 100644 --- a/frontend/src/globals.d.ts +++ b/frontend/src/globals.d.ts @@ -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. */ diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index 752ea2ac..4e6ce7b8 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -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. @@ -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; @@ -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)) { @@ -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(".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(".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, + }), + }); } } diff --git a/package-lock.json b/package-lock.json index e59d0948..27c22280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,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" @@ -539,6 +540,23 @@ "url": "https://github.com/sponsors/codecalm" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "26.6.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz", diff --git a/package.json b/package.json index fcca2ba5..835f28ff 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/end-to-end/fixtures/chart/builtin-chart-color.sql b/tests/end-to-end/fixtures/chart/builtin-chart-color.sql new file mode 100644 index 00000000..3b93b699 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/builtin-chart-color.sql @@ -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; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index fda72124..09b1b641 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -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, }) => {