@trailstash/ultra
Version:
A web based tool for making MapLibre GL maps with data from sources such as Overpass, GeoJSON, GPX, KML, TCX, etc
442 lines (407 loc) • 12 kB
JavaScript
import pick from "lodash.pick";
import bbox from "@turf/bbox";
import { h, t } from "../lib/dom.js";
import { setQueryBounds } from "../lib/bounds.js";
import { setBaseStyle } from "../lib/style.js";
import { parseSettings } from "../lib/settings.js";
import queryProviders from "../lib/queryProviders/index.js";
import {
hasHash,
getOptionsFromQueryParams,
getQueryFromQueryParams,
} from "../lib/queryParams.js";
import { getStyle } from "../lib/style.js";
import { handleStyleImageMissing } from "../lib/sprites.js";
import { handleMouseClick, handleMouseMove } from "../lib/queryMap.js";
import { localStorage, optionsFromStorage } from "../lib/localStorage.js";
import { HTMLControl } from "./html-control.js";
import { sandbox } from "../lib/sandbox.js";
import { alertOnError } from "../lib/error.js";
const css = new CSSStyleSheet();
css.replaceSync(`
:host, main {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
main {
justify-content: center;
align-items: center;
}
.loading-indicator {
width: 5em;
position: absolute;
background: rgba(0,0,0,0.5);
color: white;
z-index: 1;
padding: 1em;
display: none;
}
.loading-indicator:after {
display: inline-block;
animation: dotty steps(1,end) 1s infinite;
content: '';
}
dotty {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
100% { content: ''; }
}
`);
export class UltraMap extends HTMLElement {
#run;
#init;
#shadow;
#cachedBBox;
#cachedTransform;
#cachedType;
#cachedQuery;
#cachedSource;
#autoProvider;
#extraControls = { HTMLControl };
static defaults = {
loadSettingsFromQueryParams: true,
type: "auto",
query: "",
queryOptions: {},
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
popupTemplate: null,
popupOnHover: false,
querySources: ["ultra"],
options: {},
controls: [],
fitBounds: undefined,
queryProviders,
persistState: false,
transform: undefined,
title: undefined,
description: undefined,
};
loadSettingsFromQueryParams = UltraMap.defaults.loadSettingsFromQueryParams;
type = UltraMap.defaults.type;
query = UltraMap.defaults.query;
queryOptions = UltraMap.defaults.queryOptions;
mapStyle = UltraMap.defaults.mapStyle;
popupTemplate = UltraMap.defaults.popupTemplate;
popupOnHover = UltraMap.defaults.popupOnHover;
querySources = UltraMap.defaults.querySources;
options = UltraMap.defaults.options;
#controls = UltraMap.defaults.controls;
fitBounds = UltraMap.defaults.fitBounds;
persistState = UltraMap.defaults.persistState;
queryProviders = UltraMap.defaults.queryProviders;
transform = UltraMap.defaults.transform;
title = UltraMap.defaults.title;
description = UltraMap.defaults.description;
static CONFIG_SETTINGS = [
"queryProviders",
"loadSettingsFromQueryParams",
"persistState",
];
static INIT_SETTINGS = [
"options",
"controls",
"type",
"queryOptions",
"popupTemplate",
"popupOnHover",
"querySources",
"query",
"fitBounds",
"mapStyle",
"icon",
"title",
"description",
];
static RUNTIME_SETTINGS = [
"type",
"queryOptions",
"popupTemplate",
"popupOnHover",
"querySources",
"query",
"fitBounds",
"mapStyle",
"transform",
];
constructor() {
super();
this.#run = this.#runUnbound.bind(this);
this.run = this.run.bind(this);
this.#init = this.#initUnbound.bind(this);
this.onMoveEnd = this.onMoveEnd.bind(this);
}
get map() {
return this.refs?.mapLibre?.map;
}
get zoom() {
return this.refs?.mapLibre?.zoom;
}
get center() {
return this.refs?.mapLibre?.center;
}
get controls() {
return this.#controls;
}
set controls(value) {
this.#controls = value.map
? value.map(({ type, options, position }) =>
this.#extraControls[type]
? { type: new this.#extraControls[type](options), position }
: { type, options, position },
)
: value;
}
connectedCallback() {
this.#shadow = this.attachShadow({ mode: "open" });
this.#shadow.adoptedStyleSheets.push(css);
if (this.loadSettingsFromQueryParams) {
return Promise.resolve(getQueryFromQueryParams() || this.query)
.catch(alertOnError)
.then(async (query) => {
const querySettings = parseSettings(query);
const settings = {
...this,
...querySettings,
options: {
...this.options,
...querySettings.options,
...(this.persistState ? optionsFromStorage() : {}),
...getOptionsFromQueryParams(),
},
};
Object.assign(this, {
...pick(settings, [
...UltraMap.INIT_SETTINGS,
...UltraMap.RUNTIME_SETTINGS,
]),
mapStyle: setBaseStyle(settings.mapStyle, this.mapStyle),
});
return this.#init(await getStyle(this.mapStyle).catch(alertOnError));
})
.catch(alert);
} else if (this.persistState) {
this.options = {
...this.options,
...optionsFromStorage(),
...getOptionsFromQueryParams(),
};
}
if (!this.mapStyle || typeof this.mapStyle === "string") {
return this.#init(this.mapStyle);
} else {
return getStyle(this.mapStyle).catch(alertOnError).then(this.#init);
}
}
#initUnbound(mapStyle) {
const hadHash = hasHash(this.options.hash);
if (this.title) {
document.title = this.title;
}
if (this.icon) {
document.querySelector("[rel=icon]").href = this.icon;
}
if (this.description) {
// TODO?
}
this.refs = {
loadingIndicator: h(
"div",
{ className: "loading-indicator" },
t("Loading"),
),
mapLibre: h("map-libre", {
mapStyle,
options: this.options,
controls: this.controls,
}),
};
this.#shadow.appendChild(
h("main", {}, this.refs.loadingIndicator, this.refs.mapLibre),
);
this.refs.mapLibre.map.on("click", (e) =>
handleMouseClick(
e,
this.#popupTemplate,
this.#popupContextBuilder,
this.querySources,
),
);
this.refs.mapLibre.map.on("mousemove", (e) =>
handleMouseMove(
e,
this.#popupTemplate,
this.#popupContextBuilder,
this.querySources,
this.popupOnHover,
),
);
this.refs.mapLibre.map.setMissingStyleImageResolver(handleStyleImageMissing(this.refs.mapLibre.map));
this.refs.mapLibre.map.on("moveend", this.onMoveEnd);
this.refs.mapLibre.map.once("idle", this.onMoveEnd);
return this.#run().then((data) => {
if (
this.#fitBounds &&
(!this.options.hash || !hadHash) &&
!this.options.bounds &&
this.options.zoom === undefined &&
!this.options.center
) {
if (
this.#cachedSource?.type === "geojson" &&
typeof this.#cachedSource?.data === "object" &&
(this.#cachedSource.data.features?.length > 0 ||
this.#cachedSource.data.geometry)
) {
this.refs.mapLibre.map.fitBounds(
bbox(this.#cachedSource.data),
typeof this.#fitBounds === "object"
? this.#fitBounds
: { padding: 100, maxZoom: 17, animate: false },
);
}
}
return data;
});
}
async run(controller) {
const mapStyle = await getStyle(this.mapStyle).catch(alertOnError);
this.refs.mapLibre.mapStyle = mapStyle;
const result = await this.#run(controller);
if (this.#fitBounds) {
if (
this.#cachedSource?.type === "geojson" &&
typeof this.#cachedSource?.data === "object" &&
(this.#cachedSource.data.features?.length > 0 ||
this.#cachedSource.data.geometry)
) {
this.refs.mapLibre.map.fitBounds(
bbox(this.#cachedSource.data),
typeof this.#fitBounds === "object"
? this.#fitBounds
: { padding: 100, maxZoom: 17, animate: false },
);
}
}
return result || { mapStyle };
}
async #runUnbound(controller) {
if (!this.query) {
return;
}
this.refs.loadingIndicator.style.display = "block";
try {
if (!controller) {
controller = new AbortController();
}
const queryProvider = this.queryProviders[this.type];
if (!queryProvider) {
throw new Error(`invalid query provider: ${this.type}`);
}
const query = setQueryBounds(
this.query,
this.refs.mapLibre.bounds,
this.refs.mapLibre.zoom,
);
if (
!this.#cachedSource ||
this.transform !== this.#cachedTransform ||
this.type !== this.#cachedType ||
query !== this.#cachedQuery ||
(queryProvider.invalidateCacheOnBBox &&
this.#cachedBBox !=
setQueryBounds(
"{{bbox}}",
this.refs.mapLibre.bounds,
this.refs.mapLibre.zoom,
))
) {
this.#cachedBBox = setQueryBounds(
"{{bbox}}",
this.refs.mapLibre.bounds,
this.refs.mapLibre.zoom,
);
this.#cachedQuery = query;
this.#cachedTransform = this.transform;
this.#cachedType = this.type;
let source = await Promise.resolve(
queryProvider.source(query, controller, {
...this.queryOptions,
bounds: this.refs.mapLibre.bounds,
zoom: this.refs.mapLibre.zoom,
}),
).catch(alertOnError);
if (this.transform) {
if (sandbox) {
source.data = await Promise.race([
sandbox("default", this.transform, source.data),
new Promise((resolve, reject) => {
controller.signal.onabort = () => {
this.#cachedTransform = null;
reject(new DOMException("", "AbortError"));
};
}),
]);
} else {
throw new Error("sandbox could not be initialized");
}
}
this.#cachedSource = source;
}
const mapStyle = await getStyle(this.mapStyle, {
// Don't love this...
source: this.#cachedSource,
layers: queryProvider.layers
? await Promise.resolve(queryProvider.layers("ultra", query))
: [],
}).catch(alertOnError);
this.refs.mapLibre.mapStyle = mapStyle;
return { data: this.#cachedSource.data, mapStyle };
} finally {
this.refs.loadingIndicator.style.display = "none";
}
}
get #fitBounds() {
if (this.fitBounds !== undefined) {
return this.fitBounds;
}
const queryProvider = this.queryProviders[this.type];
if (
queryProvider &&
queryProvider.fitBounds &&
setQueryBounds(
this.query,
this.refs.mapLibre.bounds,
this.refs.mapLibre.zoom,
) === this.query
) {
return queryProvider.fitBounds;
}
return this.fitBounds;
}
get #popupTemplate() {
if (this.popupTemplate) {
return this.popupTemplate;
}
const queryProvider = this.queryProviders[this.type];
if (queryProvider && queryProvider.popupTemplate) {
return queryProvider.popupTemplate;
}
return this.popupTemplate;
}
get #popupContextBuilder() {
const queryProvider = this.queryProviders[this.type];
if (queryProvider && queryProvider.popupContextBuilder) {
return queryProvider.popupContextBuilder;
}
}
onMoveEnd() {
const zoom = this.refs.mapLibre.zoom;
const center = this.refs.mapLibre.center.toArray();
localStorage.setItem("mapZoom", zoom);
localStorage.setItem("mapCenter", center);
}
}