UNPKG

@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
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: ''; } @keyframes 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); } }