UNPKG

s2maps-gpu

Version:

S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.

1,162 lines β€’ 56.3 kB
import { isSafari, parseHash, setHash } from './util/index.js'; /** * # The S2 Map GPU Engine 🌎 πŸ—ΊοΈ * * ## Description * * Both an **S2** and **WM** Projection Map Engine Powered by `WebGL1`, `WebGL2`, and `WebGPU`. * * ### Basic JS/TS example: * Note that the most important components to build a map are the {@link MapOptions} and the {@link StyleDefinition}. * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions, StyleDefinition } from 's2maps-gpu'; * * // build a style guide of sources to fetch and layers to render * const style: StyleDefinition = { ... }; * // setup options for the map * const options: MapOptions = { * container: 'map', // the ID of the HTML element to render the map into * // You can reference a canvas instead of a container: * // canvas: userPulledCanvasElement * style, * }; * // Build the map * const map = new S2Map(options); * ``` * * ### HTML Example * ```html * <!DOCTYPE html> * <html lang="en"> * <head> * <meta charset="utf-8" /> * <title>Display a map</title> * <meta name="viewport" content="initial-scale=1,width=device-width" /> * <!-- import s2maps-gpu. BE SURE TO CHECK AND UPDATE TO LATEST VERSION --> * <script src="https://opens2.com/s2maps-gpu/v0.18.0/s2maps-gpu.min.js" crossorigin="anonymous"></script> * <link rel="stylesheet" href="https://opens2.com/s2maps-gpu/v0.18.0/s2maps-gpu.min.css" /> * </head> * <body> * <div id="map"></div> * <script> * // grab container div * const container = document.getElementById('map'); * // setup map style * const style = { ... }; * // create the map * const map = new S2Map({ style, container }); * </script> * </body> * </html> * ``` * * ## Events * - `ready`: fired when the map is ready to be interacted with / make API calls. Ships this map {@link S2Map} * - `mouseleave`: fired when the mouse leaves the map. Ships {@link MouseLeaveMessage} * - `mouseenter`: fired when the mouse enters the map. Ships {@link MouseEnterMessage} * - `click`: fired when the user clicks on the map. Ships {@link MouseClickMessage} * - `view`: fired when the map view changes. Ships {@link ViewMessage} * - `screenshot`: fired as a result of a screenshot that was requested. Ships a `Uint8ClampedArray` * - `rendered`: fired when the map is fully rendered. * - `delete`: fired to ping that the map is deleting itself. * * ## API * - {@link S2Map.setDarkMode}: Update the state of the map's UI mode. True for dark-mode, false for light-mode * - {@link S2Map.getContainer}: Get the HTML element that the map is rendered into * - {@link S2Map.getCanvasContainer}: Get the HTML element that the map's canvas is rendered into * - {@link S2Map.getContainerDimensions}: Get the dimensions of the map's container as a `[width, height]` tuple * - {@link S2Map.setStyle}: Set a new style, replacing the current one if it exists * - {@link S2Map.updateStyle}: Update the map's current style with new attributes, by checking for changes and updating accordingly * - {@link S2Map.setMoveState}: Update the users ability to move the map around or not. * - {@link S2Map.setZoomState}: Update the users ability to zoom the map in and out or not. * - {@link S2Map.getView}: Get the current projector's view of the world * - {@link S2Map.jumpTo}: Jump to a specific location's longitude, latitude, and optionally zoom * - {@link S2Map.easeTo}: Use an easing function to travel to a specific location's longitude, latitude, and optionally zoom * - {@link S2Map.flyTo}: Use an easing function to fly to a specific location's longitude, latitude, and optionally zoom * - {@link S2Map.addSource}: Add a new source to the map. Sources are references to data and how to fetch it. * - {@link S2Map.updateSource}: Update a source already added to the map and control the method the map updates the source * - {@link S2Map.resetSource}: Reset a source's data already added to the map and control the method the map updates the source * - {@link S2Map.deleteSource}: Delete a source's data from the map * - {@link S2Map.addLayer}: Add a new style layer to the map * - {@link S2Map.updateLayer}: Update the an existing style layer in a map given the layer's name or index * - {@link S2Map.deleteLayer}: Delete an existing style layer in a map given the layer's name or index * - {@link S2Map.reorderLayers}: Reorder layers in the map. * - {@link S2Map.addMarker}: Add new marker(s) to the map * - {@link S2Map.removeMarker}: Delete a marker or collection of markers from the map * - {@link S2Map.screenshot}: Take a screenshot of the current state of the map. Returns the screenshot as a `Uint8ClampedArray` * - {@link S2Map.awaitFullyRendered}: Async function to wait for the map to have all source and layer data rendered to the screen * - {@link S2Map.delete}: Delete the map instance and cleanup all it's resources * * ## Future API * - `getBounds` & `setBounds` * - `setProjection` & `getProjection` * - `getStyle` * * ## Converters * - MapLibre Map Options Converter: See {@link plugins.convertMaplibreOptions} * - MapLibre Style Converter: See {@link plugins.convertMaplibreStyle} * * ## Plugins * - Sync map movements between multiple maps: See {@link plugins.syncMove} * * ## Frameworks * - React: See {@link plugins.ReactS2MapGPU} * - Vue: See {@link plugins.VueS2MapGPU} */ export default class S2Map extends EventTarget { #container; #canvasContainer; // #navigationContainer!: HTMLElement; #canvasMultiplier; #canvas; #attributionPopup; // #watermark?: HTMLAnchorElement; #compass; #colorBlind; #attributions = {}; bearing = 0; // degrees pitch = 0; // degrees colorMode = 0; map; hash = false; offscreen; id = Math.random().toString(36).replace('0.', ''); isNative = false; isReady = false; /** @param options - map options */ constructor(options = { canvasMultiplier: window.devicePixelRatio ?? 2, interactive: true, style: {}, }) { super(); options.canvasMultiplier = this.#canvasMultiplier = Math.max(2, options.canvasMultiplier ?? 2); // set hash if necessary if (options.hash === true) { this.hash = true; // TODO: get this working even if style is a string if (typeof options.style === 'object') options.style.view = { ...options.style.view, ...parseHash() }; } // get the container if we don't already have a canvas instance if (options.canvas === undefined) { if (typeof options.container === 'string') { const container = window.document.getElementById(options.container); if (container === null) throw new Error('Container not found.'); this.#container = container; } else if (options.container instanceof HTMLElement) { this.#container = options.container; } else if (options.canvas === undefined) { throw new Error('Invalid type: "container" must be a String or HTMLElement.'); } } // we now remove container from options for potential webworker delete options.container; // prep container, creating the canvas this.#canvas = options.canvas ?? this.#setupContainer(options); if ('node' in this.#canvas) this.isNative = true; // create map via a webworker if possible, otherwise just load it in directly void this.#setupCanvas(this.#canvas, options); } /** * Add an event listener overriding the original * @param type - type of event called * @param listener - event listener * @param options - event listener options */ addEventListener(type, listener, options) { const { isReady } = this; // Call the original addEventListener method super.addEventListener(type, listener, options); // there are cases where the map loads so quickly that the ready event is missed // before the listener is added, so we need to check if the map was already ready if (type === 'ready' && isReady) { this.dispatchEvent(new CustomEvent('ready', { detail: this })); } } /* BUILD/CONSTRUCTION FUNCTIONS */ /** Interal ready function. Let the user know that the map is ready. */ #ready() { this.isReady = true; this.#onCanvasReady(); this.dispatchEvent(new CustomEvent('ready', { detail: this })); } /** * Setup the container * @param options - MapOptions * @returns the canvas container element */ #setupContainer(options) { if (this.#container === undefined) throw new Error('Container not found.'); // prep container const container = this.#container; container.classList.add('s2-map'); this.setDarkMode(options.darkMode); // build canvas-container const canvasContainer = (this.#canvasContainer = window.document.createElement('div')); canvasContainer.className = 's2-canvas-container'; container.prepend(canvasContainer); // build canvas const canvas = window.document.createElement('canvas'); canvas.className = 's2-canvas'; canvas.setAttribute('tabindex', '0'); canvas.setAttribute('aria-label', 'S2Map'); canvas.width = container.clientWidth * this.#canvasMultiplier; canvas.height = container.clientHeight * this.#canvasMultiplier; canvasContainer.appendChild(canvas); return canvas; } /** * Setup the canvas, workers, etc. * @param canvas - HTMLCanvasElement * @param options - MapOptions */ async #setupCanvas(canvas, options) { const isBrowser = options.canvas === undefined; // prep the ready function should it exist // prep webgpu/webgl type if (isBrowser && options.contextType === undefined) options.contextType = getContext(); // if browser supports it, create an instance of the mapWorker // TODO: Safari offscreenCanvas sucks currently. It's so janky. Leave this here for when it's fixed. if (options.offscreen !== false && !isSafari(window) && typeof canvas.transferControlToOffscreen === 'function') { const offscreenCanvas = canvas.transferControlToOffscreen(); const mapWorker = (this.offscreen = new Worker(new URL('./workers/map.worker', import.meta.url), { name: 'map-worker', type: 'module' })); mapWorker.onmessage = this.onMessage.bind(this); mapWorker.postMessage({ type: 'canvas', options, canvas: offscreenCanvas, id: this.id }, [ offscreenCanvas, ]); } else { const Map = await import('./ui/s2mapUI.js').then((m) => m.default); this.map = new Map(options, canvas, this.id, this); } // now that canvas is setup, add control containers as necessary this.#setupControlContainer(options); // if we interact with the map, we need to both allow interaction with styling // and watch how the mouse moves on the canvas const canvasContainer = this.#canvasContainer; if (options.interactive ?? true) { canvasContainer.classList.add('s2-interactive'); canvasContainer.addEventListener('mousemove', this.#onCanvasMouseMove.bind(this)); canvasContainer.addEventListener('contextmenu', this.#onCompassMouseDown.bind(this)); canvasContainer.addEventListener('mouseleave', this.#onCanvasMouseLeave.bind(this)); if (options.scrollZoom ?? true) canvasContainer.addEventListener('wheel', this.#onScroll.bind(this)); canvasContainer.addEventListener('mousedown', this.#onMouseDown.bind(this)); canvasContainer.addEventListener('touchstart', (e) => { this.#onTouch(e, 'touchstart'); }); canvasContainer.addEventListener('touchend', (e) => { this.#onTouch(e, 'touchend'); }); canvasContainer.addEventListener('touchmove', (e) => { this.#onTouch(e, 'touchmove'); }); } } /** If mouse leaves the canvas, clear out any features considered "active" */ #onCanvasMouseLeave() { this.#canvas.style.cursor = 'default'; this.dispatchEvent(new CustomEvent('mouseleave', { detail: null })); } /** * Internal setup the control containers * @param options - map options */ #setupControlContainer(options) { const { attributions, controls, zoomController, compassController, colorblindController, attributionOff, watermarkOff, } = options; if (this.isNative) return; // add info bar with our jollyRoger if (attributionOff !== true) { const attribution = window.document.createElement('div'); attribution.id = 's2-attribution'; const info = window.document.createElement('div'); info.className = info.id = 's2-info'; /** Handle click on info bar */ info.onclick = function () { attribution.classList.toggle('show'); }; const popup = (this.#attributionPopup = window.document.createElement('div')); popup.className = 's2-popup-container'; popup.innerHTML = '<div>Rendered with ❀ by</div><a href="https://opens2.com" target="popup"><div class="s2-jolly-roger"></div></a>'; // add attributions if (attributions !== undefined) { for (const name in attributions) { if (this.#attributions[name] === undefined) { this.#attributions[name] = attributions[name]; popup.innerHTML += `<div><a href="${attributions[name]}" target="_popup">${name}</a></div>`; } } } attribution.appendChild(info); attribution.appendChild(popup); // add watermark if (watermarkOff !== true) { const watermark = window.document.createElement('a'); watermark.className = 's2-watermark'; watermark.href = 'https://opens2.com'; watermark.target = '_popup'; attribution.appendChild(watermark); } this.#container?.appendChild(attribution); } // if zoom or compass controllers, add if (controls !== false) { let navSep; let firstNavCompSet = false; // first create the container const navigationContainer = window.document.createElement('div'); navigationContainer.className = 's2-nav-container'; this.#container?.appendChild(navigationContainer); if (zoomController !== false) { // plus const zoomPlus = window.document.createElement('button'); zoomPlus.className = 's2-control-button s2-zoom-plus'; zoomPlus.setAttribute('aria-hidden', ''); zoomPlus.tabIndex = -1; navigationContainer.appendChild(zoomPlus); zoomPlus.addEventListener('click', () => { this.#navEvent('zoomIn'); }); // seperator firstNavCompSet = true; navSep = window.document.createElement('div'); navSep.className = 's2-nav-sep'; navigationContainer.appendChild(navSep); // minus const zoomMinus = window.document.createElement('button'); zoomMinus.className = 's2-control-button s2-zoom-minus'; zoomMinus.setAttribute('aria-hidden', ''); zoomMinus.tabIndex = -1; navigationContainer.appendChild(zoomMinus); zoomMinus.addEventListener('click', () => { this.#navEvent('zoomOut'); }); } if (compassController !== false) { if (!firstNavCompSet) { firstNavCompSet = true; } else { // seperator navSep = window.document.createElement('div'); navSep.className = 's2-nav-sep'; navigationContainer.appendChild(navSep); } // compass button const compassContainer = window.document.createElement('button'); compassContainer.className = 's2-control-button'; compassContainer.setAttribute('aria-hidden', ''); compassContainer.tabIndex = -1; navigationContainer.appendChild(compassContainer); const compass = (this.#compass = window.document.createElement('div')); compass.className = 's2-compass'; compass.setAttribute('aria-hidden', ''); compass.tabIndex = -1; compassContainer.appendChild(compass); compassContainer.addEventListener('mousedown', this.#onCompassMouseDown.bind(this)); } if (colorblindController !== false) { if (!firstNavCompSet) { firstNavCompSet = true; } else { // seperator navSep = window.document.createElement('div'); navSep.className = 's2-nav-sep'; navigationContainer.appendChild(navSep); } // colorblind button const colorBlind = (this.#colorBlind = window.document.createElement('button')); colorBlind.className = 's2-control-button s2-colorblind-button'; colorBlind.id = 's2-colorblind-default'; colorBlind.setAttribute('aria-hidden', ''); colorBlind.tabIndex = -1; navigationContainer.appendChild(colorBlind); colorBlind.addEventListener('click', () => { this.#setColorMode(); }); } } } /* INTERNAL API */ /** * Inject data into the map * Used by the WorkerPool. * Anytime a Source worker or Tile Worker has data to inject into the map, * it will call this function. * @param data - The data to inject * @internal */ injectData(data) { const { type } = data; const { map, offscreen } = this; if (type === 'attributions') { this.#addAttributions(data.attributions); } else if (type === 'setStyle') { void this.setStyle(data.style, data.ignorePosition); } else if (offscreen !== undefined) { if (type === 'fill') offscreen.postMessage(data, [ data.vertexBuffer, data.indexBuffer, data.idBuffer, data.codeTypeBuffer, data.featureGuideBuffer, ]); else if (type === 'line') offscreen.postMessage(data, [data.vertexBuffer, data.featureGuideBuffer]); else if (type === 'glyph') offscreen.postMessage(data, [ data.glyphFilterBuffer, data.glyphFilterIDBuffer, data.glyphQuadBuffer, data.glyphQuadIDBuffer, data.glyphColorBuffer, data.featureGuideBuffer, ]); else if (type === 'glyphimages') offscreen.postMessage(data, data.images.map((i) => i.data)); else if (type === 'spriteimage') offscreen.postMessage(data, [data.image]); else if (type === 'raster') offscreen.postMessage(data, [data.image]); else if (type === 'point') offscreen.postMessage(data, [data.vertexBuffer, data.idBuffer, data.featureGuideBuffer]); else if (type === 'heatmap') offscreen.postMessage(data, [ data.vertexBuffer, data.weightBuffer, data.featureGuideBuffer, ]); else if (type === 'interactive') offscreen.postMessage(data, [data.interactiveGuideBuffer, data.interactiveDataBuffer]); else offscreen.postMessage(data); } else if (map !== undefined) { map.injectData(data); } } /** * Used by the MapUI either from a thread or directly. to either * send messages to Source/Tile Workers or to the user. * @param message - The message to process * @internal */ onMessage(message) { const { data } = message; const { mapID, type } = data; if (type === 'tilerequest') { window.S2WorkerPool.tileRequest(mapID, data.tiles, data.sources); } else if (type === 'timerequest') { window.S2WorkerPool.timeRequest(mapID, data.tiles, data.sourceNames); } else if (type === 'mouseenter') { const { features } = data; this.#canvas.style.cursor = features[0]?.__cursor ?? 'default'; this.dispatchEvent(new CustomEvent('mouseenter', { detail: data })); } else if (type === 'mouseleave') { const { currentFeatures } = data; if (currentFeatures.length === 0) this.#canvas.style.cursor = 'default'; this.dispatchEvent(new CustomEvent('mouseleave', { detail: data })); } else if (type === 'click') { this.dispatchEvent(new CustomEvent('click', { detail: data })); } else if (type === 'view') { if (this.hash) setHash(data.view); this.dispatchEvent(new CustomEvent('view', { detail: data })); } else if (type === 'requestStyle') { window.S2WorkerPool.requestStyle(mapID, data.style, data.analytics, data.apiKey); } else if (type === 'style') { window.S2WorkerPool.injectStyle(mapID, data.style); } else if (type === 'updateCompass') { this._updateCompass(data.bearing, data.pitch); } else if (type === 'addLayer') { window.S2WorkerPool.addLayer(mapID, data.layer, data.index, data.tileRequest); } else if (type === 'deleteLayer') { window.S2WorkerPool.deleteLayer(mapID, data.index); } else if (type === 'reorderLayers') { window.S2WorkerPool.reorderLayers(mapID, data.layerChanges); } else if (type === 'screenshot') { this.dispatchEvent(new CustomEvent('screenshot', { detail: new Uint8ClampedArray(data.screen) })); } else if (type === 'rendered') { this.dispatchEvent(new Event('rendered')); } else if (type === 'ready') { this.#ready(); } } /* INTERNAL FUNCTIONS */ /** internal function to handle canvas ready */ #onCanvasReady() { // set color mode const mode = parseInt(localStorage.getItem('s2maps:gpu:colorBlindMode') ?? '0'); this.#setColorMode(mode); // now that canvas is setup, support resizing if (this.#container !== undefined && 'ResizeObserver' in window) { new ResizeObserver(this.#resize.bind(this)).observe(this.#container); } else window.addEventListener('resize', this.#resize.bind(this)); // let the S2WorkerPool know of this maps existance window.S2WorkerPool.addMap(this); } /** * Internal function to add missing attribution data. * @param attributions - Attributions object to inject into grouped attributions container */ #addAttributions(attributions = {}) { if (this.#attributionPopup !== undefined) { for (const name in attributions) { if (this.#attributions[name] === undefined) { this.#attributions[name] = attributions[name]; this.#attributionPopup.innerHTML += `<div><a href="${attributions[name]}" target="popup">${name}</a></div>`; } } } } /** * Internal function to handle touch events * @param event - touch event * @param type - type of touch event */ #onTouch(event, type) { const { map, offscreen } = this; const canvasContainer = this.#canvasContainer; event.preventDefault(); const { touches } = event; const { length } = touches; const touchEvent = { length }; for (let i = 0; i < length; i++) { const { clientX, clientY, pageX, pageY } = touches[i]; const x = (pageX - canvasContainer.offsetLeft) * this.#canvasMultiplier; const y = (pageY - canvasContainer.offsetTop) * this.#canvasMultiplier; touchEvent[i] = { clientX, clientY, x, y }; } offscreen?.postMessage({ type, touchEvent }); if (map !== undefined) { if (type === 'touchstart') map.onTouchStart(touchEvent); else if (type === 'touchend') map.dragPan.onTouchEnd(touchEvent); else if (type === 'touchmove') map.dragPan.onTouchMove(touchEvent); } } /** * Internal function to handle scroll events * @param event - wheel event */ #onScroll(event) { event.preventDefault(); const { map, offscreen } = this; const { clientX, clientY, deltaY } = event; const rect = this.#canvas.getBoundingClientRect(); offscreen?.postMessage({ type: 'scroll', rect, clientX, clientY, deltaY }); map?.onZoom(deltaY, clientX - rect.left, clientY - rect.top); } /** * Internal function to handle mouse down event * @param event - mouse down event */ #onMouseDown(event) { if (event.button !== 0) return; const { map, offscreen } = this; // send off a mousedown offscreen?.postMessage({ type: 'mousedown' }); map?.dragPan.onMouseDown(); // build a listener to mousemovement const mouseMoveFunc = this.#onMouseMove.bind(this); window.addEventListener('mousemove', mouseMoveFunc); // upon eventual mouseup, let the map know window.addEventListener('mouseup', (e) => { const rect = this.#canvas.getBoundingClientRect(); const { clientX, clientY } = e; window.removeEventListener('mousemove', mouseMoveFunc); offscreen?.postMessage({ type: 'mouseup', clientX, clientY, rect }); map?.dragPan.onMouseUp(clientX - rect.left - rect.width / 2, rect.height / 2 - clientY - rect.top); }, { once: true }); } /** * Internal function to handle mouse move * @param event - mouse move event */ #onMouseMove(event) { const { map, offscreen } = this; let { movementX, movementY } = event; movementX *= this.#canvasMultiplier; movementY *= this.#canvasMultiplier; offscreen?.postMessage({ type: 'mousemove', movementX, movementY }); map?.dragPan.onMouseMove(movementX, movementY); } /** * Internal function to handle mouse move event inside the canvas * @param event - mouse move event */ #onCanvasMouseMove(event) { const { map, offscreen } = this; const { layerX, layerY } = getLayerCoordinates(event); const x = layerX * this.#canvasMultiplier; const y = layerY * this.#canvasMultiplier; offscreen?.postMessage({ type: 'canvasmousemove', x, y }); map?.onCanvasMouseMove(x, y); } /** * Internal function to handle compass update. Expands upon camera compass update * @param bearing - compass bearing * @param pitch - compass pitch * @internal */ _updateCompass(bearing, pitch) { this.bearing = -bearing; this.pitch = pitch; if (this.#compass !== undefined) { this.#compass.style.transform = `translate(-50%, -50%) rotate(${this.bearing}deg)`; } } /** * Internal function to handle mouse pressed down on the compass * @param event - mouse down event */ #onCompassMouseDown(event) { event.preventDefault(); const { map, offscreen } = this; const { abs } = Math; let totalMovementX = 0; let totalMovementY = 0; /** * Temp function to handle mouse movement while mouse is pressed on the compass * @param mEvent - mouse move event */ const mouseMoveFunc = (mEvent) => { const { movementX, movementY } = mEvent; if (movementX !== 0) { totalMovementX += abs(movementX); totalMovementY += abs(movementY); offscreen?.postMessage({ type: 'updateCompass', bearing: movementX, pitch: movementY }); map?.updateCompass(movementX, movementY); } }; window.addEventListener('mousemove', mouseMoveFunc); window.addEventListener('mouseup', () => { window.removeEventListener('mousemove', mouseMoveFunc); if (totalMovementX === 0 && totalMovementY === 0) { offscreen?.postMessage({ type: 'resetCompass' }); map?.resetCompass(); } else { offscreen?.postMessage({ type: 'mouseupCompass' }); map?.mouseupCompass(); } }, { once: true }); } // #onCompassClick (): void { // const { map, offscreen } = this // offscreen?.postMessage({ type: 'resetCompass' }) // map?.resetCompass() // } /** resize the map */ #resize() { const { map, offscreen } = this; const container = this.#container; if (container === undefined) return; const canvasMultiplier = this.#canvasMultiplier; // rebuild the proper width and height using the container as a guide offscreen?.postMessage({ type: 'resize', width: container.clientWidth * canvasMultiplier, height: container.clientHeight * canvasMultiplier, }); map?.resize(container.clientWidth * canvasMultiplier, container.clientHeight * canvasMultiplier); } /** * Internal function to handle zoom in and zoom out events * @param ctrl - 'zoomIn' | 'zoomOut' */ #navEvent(ctrl) { const { map, offscreen } = this; offscreen?.postMessage({ type: 'nav', ctrl }); map?.navEvent(ctrl); } /** * Internal function to set the colorblind mode * @param mode - colorblind mode */ #setColorMode(mode) { const { map, offscreen } = this; if (mode !== undefined) this.colorMode = mode; else this.colorMode++; if (this.colorMode > 4) this.colorMode = 0; localStorage.setItem('s2maps:gpu:colorBlindMode', String(this.colorMode)); // update the icon const cM = this.colorMode; if (this.#colorBlind !== undefined) this.#colorBlind.id = `s2-colorblind${cM === 0 ? '-default' : cM === 1 ? '-proto' : cM === 2 ? '-deut' : cM === 3 ? '-trit' : '-grey'}`; // tell the map to update offscreen?.postMessage({ type: 'colorMode', mode: cM }); map?.colorMode(cM); } /* API */ /** * Update the state of the map's UI mode. * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ..., darkMode: false }; * const map = new S2Map(options); * // do something with the map * map.setDarkMode(true); * ``` * @param state - which UI mode to set the map to. `true` for dark-mode, `false` for light-mode */ setDarkMode(state = false) { const classList = this.#container?.classList; if (state) classList?.add('dark-mode'); else classList?.remove('dark-mode'); } /** * Get the HTML element that the map is rendered into * @returns The HTML element */ getContainer() { return this.#container; } /** * Get the HTML element that the map's canvas is rendered into * @returns The HTML element */ getCanvasContainer() { return this.#canvasContainer; } /** * Get the dimensions of the map's container * @returns The dimensions of the map's container */ getContainerDimensions() { return [this.#container?.clientWidth ?? 0, this.#container?.clientHeight ?? 0]; } /** * Set a new style, replacing the current one if it exists * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions, StyleDefinition } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // setup and set a new style * const style: StyleDefinition = { ... }; * await map.setStyle(style); * ``` * @param style - The user defined style of how data should be rendered * @param ignorePosition - if set to true, don't update the map's position to the style's view guide [Default=`true`] */ async setStyle(style, ignorePosition = true) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'setStyle', style, ignorePosition }); await map?.setStyle(style, ignorePosition); } /** * Update the map's current style with new attributes, by checking for changes and updating accordingly * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions, StyleDefinition } from 's2maps-gpu'; * * const options: MapOptions = { ..., style: { ... } }; * const map = new S2Map(options); * // Update the style with new attributes * const newStyle: StyleDefinition = { ... }; * map.updateStyle(newStyle); * ``` * @param style - The new style to update the old style with */ updateStyle(style) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'updateStyle', style }); map?.updateStyle(style); } /** * Update the users ability to move the map around or not. * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ..., canMove: false }; * const map = new S2Map(options); * // Update the move state so the user can move around * const screen = map.setMoveState(true); * ``` * @param state - Sets the move state. If `true`, the user can move the map. */ setMoveState(state) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'moveState', state }); map?.setMoveState(state); } /** * Update the users ability to zoom the map in and out or not. * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ..., canZoom: false }; * const map = new S2Map(options); * // Update the zoom state so the user can update the zoom position * const screen = map.setZoomState(true); * ``` * @param state - Sets the zoom state. If `true`, the user can zoom the map in and out. */ setZoomState(state) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'zoomState', state }); map?.setZoomState(state); } /** * Get the current projector's view of the world * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions, View } from 's2maps-gpu'; * * const options: MapOptions = { ..., view: { lon: 0, lat: 0, zoom: 0 } }; * const map = new S2Map(options); * // Get a filled in view object * const view: Required<View> = await map.getView(); * ``` * @returns A filled in {@link View} object */ async getView() { const { offscreen, map } = this; if (map !== undefined) { const { zoom, lon, lat, bearing, pitch } = map.projector; return { zoom, lon, lat, bearing, pitch }; } return await new Promise((resolve) => { /** * Setup a listener for when the view to be shipped back * @param event - the response with the view */ const listener = (event) => { resolve(event?.detail); }; this.addEventListener('view', listener, { once: true }); // TODO: Does an empty jump to work? I think I remember no view changes don't cause a new render or updates offscreen?.postMessage({ type: 'jumpTo', view: {} }); // use an empty jump to not edit anything }); } /** * Jump to a specific location's longitude, latitude, and optionally zoom, bearing, and pitch. * Takes a {@link View} object as an input. * * NOTE: If either the `lon` or `lat` are not set, it will assume the map's current position * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ..., view: { lon: 0, lat: 0, zoom: 0 } }; * const map = new S2Map(options); * // wait for map to load, then jump to a specific location * await map.awaitFullLoaded(); * map.jumpTo({ lon: -120, lat: 60, zoom: 7 }); * ``` * @param view - The view to jump to */ jumpTo(view) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'jumpTo', view }); map?.jumpTo(view); } /** * Use an easing function to travel to a specific location's longitude, latitude, and optionally zoom * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ..., view: { lon: 0, lat: 0, zoom: 0 } }; * const map = new S2Map(options); * // wait for map to load, then jump to a specific location * await map.awaitFullLoaded(); * map.easeTo(-120, 60, 7); * ``` * @param directions - animation guide for travel directions, speed, and easing */ easeTo(directions) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'easeTo', directions }); map?.animateTo('easeTo', directions); } /** * Use an easing function to fly to a specific location's longitude, latitude, and optionally zoom * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ..., view: { lon: 0, lat: 0, zoom: 0 } }; * const map = new S2Map(options); * // wait for map to load, then jump to a specific location * await map.awaitFullLoaded(); * map.flyTo(-120, 60, 7); * ``` * @param directions - animation guide for travel directions, speed, and easing */ flyTo(directions) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'flyTo', directions }); map?.animateTo('flyTo', directions); } /** * Add a new source to the map. Sources are references to data and how to fetch it. * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Add a new source to the map * map.addSource('TheFreakinMoon', 'http://yup-im-the-moon.com/'); * ``` * @param sourceName - Name of the source * @param href - the location of the source data */ addSource(sourceName, href) { this.updateSource(sourceName, href, false, false); } /** * Update a source already added to the map and control the method the map updates the source * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Add a new source to the map * map.addSource('TheFreakinMoon', 'http://yup-im-the-moon.com/'); * // change the location of the moon * map.updateSource('TheFreakinMoon', 'http://now-im-the-moon.com/', false, true); * ``` * @param sourceName - Name of the source * @param href - the new location of the source * @param keepCache - Whether to keep the cache or not. don't delete any tiles, request replacements for all (for s2json since it's locally cached and fast) * @param awaitReplace - Whether to await the replacement of tiles or not. to avoid flickering (i.e. adding/removing markers), we can wait for an update (from source+tile workers) on how the tile should look */ updateSource(sourceName, href, keepCache = true, awaitReplace = true) { this.resetSource([[sourceName, href]], keepCache, awaitReplace); } /** * Reset a source's data already added to the map and control the method the map updates the source * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Add a new source to the map * map.addSource('TheFreakinMoon', 'http://yup-im-the-moon.com/'); * // change the location of the moon * map.resetSource(['TheFreakinMoon'], false, true); * ``` * @param sourceNames - Array of [sourceName, href]. Href is optional but if provided, the source href will be updated * @param keepCache - Whether to keep the cache or not. don't delete any tiles, request replacements for all (for s2json since it's locally cached and fast) * @param awaitReplace - Whether to await the replacement of tiles or not. to avoid flickering (i.e. adding/removing markers), we can wait for an update (from source+tile workers) on how the tile should look */ resetSource(sourceNames, keepCache = false, awaitReplace = false) { const { offscreen, map } = this; // clear old info s2json data should it exist const msg = { type: 'resetSource', sourceNames, keepCache, awaitReplace }; offscreen?.postMessage(msg); map?.resetSource(sourceNames, keepCache, awaitReplace); } /** * Delete a source's data from the map * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Add a new source to the map * map.addSource('TheFreakinMoon', 'http://yup-im-the-moon.com/'); * // Do stuff ... * * // we are done rendering the moon * map.deleteSource(['TheFreakinMoon', 'anotherSourceWeDontWantAnymore']); * ``` * @param sourceNames - A single sourceName or an array of source names */ deleteSource(sourceNames) { const { offscreen, map } = this; if (!Array.isArray(sourceNames)) sourceNames = [sourceNames]; // 1) tell worker pool we dont need info data anymore window.S2WorkerPool.deleteSource(this.id, sourceNames); // 2) clear old info s2json data should it exist offscreen?.postMessage({ type: 'clearSource', sourceNames }); map?.clearSource(sourceNames); } /** * Add a new style layer to the map * - If the nameIndex is a string, it will search through the existing layers for the layer with that name and add the layer at said index * - If the nameIndex is a number, it will add the layer at that index in the style.layers array. * - If no nameIndex is provided, it will add the layer at the end * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Add a new style layer to the map * map.addLayer({ source: 'world', type: 'fill', color: 'red', ... }); * ``` * @param layer - The style layer to add * @param nameIndex - The index to add the layer at */ addLayer(layer, nameIndex) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'addLayer', layer, nameIndex }); map?.addLayer(layer, nameIndex); } /** * Update the an existing style layer in a map given the layer's name or index * - If the nameIndex is a string, it will search through the existing layers for the layer with that name and update the layer at said index * - If the nameIndex is a number, it will use the layer at that index in the style.layers array. * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Update the style layer * map.updateLayer({ source: 'world', type: 'fill', color: 'red', ... }, 12); * ``` * @param layer - The style layer to update/replace the old layer with * @param nameIndex - The index/name of the style layer to update * @param fullUpdate - If true, force a full re-render of the layer. Recommended to keep true unless you know what you're doing */ updateLayer(layer, nameIndex, fullUpdate = true) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'updateLayer', layer, nameIndex, fullUpdate }); map?.updateLayer(layer, nameIndex, fullUpdate); } /** * Delete an existing style layer in a map given the layer's name or index * - If the nameIndex is a string, it will search through the existing layers for the layer with that name and update the layer at said index * - If the nameIndex is a number, it will use the layer at that index in the style.layers array. * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Delete an existing layer * map.updateLayer(12); * ``` * @param nameIndex - The index/name of the style layer to delete */ deleteLayer(nameIndex) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'deleteLayer', nameIndex }); map?.deleteLayer(nameIndex); } /** * Reorder layers in the map. * - The key is the index of the layer to move * - The value is the index to move the layer to * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // Reorder layers * map.reorderLayers({ 0: 1, 1: 0 }); * ``` * @param layerChanges - The guide of how to reorder the layers */ reorderLayers(layerChanges) { const { offscreen, map } = this; offscreen?.postMessage({ type: 'reorderLayers', layerChanges }); map?.reorderLayers(layerChanges); } /** * Add new marker(s) to the map * - See {@link MarkerDefinition} to see the shape of a marker * * ### Example * ```ts * import { S2Map } from 's2maps-gpu'; // or you can access it via the global `window.S2Map` * import type { MapOptions } from 's2maps-gpu'; * * const options: MapOptions = { ... }; * const map = new S2Map(options); * // add a new marker * map.addMarker({ id: 22, lat: 0, lon: 0, html: '<div>hello</div>' }); * ``` * @param markers - A single marker or an array of markers * @param sourceName - The name of the source to add the marker(s)