Skip to content
Merged
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
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
Loading