From 862d9cccaeb84b7429b30a2507e5ad97834cef97 Mon Sep 17 00:00:00 2001 From: 81reap Date: Mon, 28 Sep 2026 23:45:34 -0400 Subject: [PATCH 1/2] fix(chart) :: name refrence line feilds explictly `chart.handlebars` emits 6 keys for a reference line and stringifies so that unset fields come in as null. This is now explictly set in the type by making it a `Record`. From c91e5a2a49d1444aeda9309af81de6d51a28c315 Mon Sep 17 00:00:00 2001 From: 81reap Date: Tue, 29 Sep 2026 19:30:25 -0400 Subject: [PATCH 2/2] refactor(map) :: import and use 3rd party Leaflet types `declare const L: Untyped` meant every Leaflet value in sqlpage_map was typed `any`. Now we import `@types/leaflet` and use the types in SQLPage. --- frontend/src/globals.d.ts | 13 +--- frontend/src/sqlpage.ts | 128 +++++++++++++++++++++++--------------- package-lock.json | 18 ++++++ package.json | 1 + 4 files changed, 99 insertions(+), 61 deletions(-) diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts index 38b33d684..d4af324cc 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 752ea2ac4..4e6ce7b8d 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 e59d09484..27c22280c 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 fcca2ba5d..835f28ffd 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"