import { stylefunction as applyStylefunction, styleFunctionArgs, } from './stylefunction.js'; import {expandUrl} from 'ol/tileurlfunction.js'; import {finalizeLayer, setupLayer} from './apply.js'; import {getUid} from 'ol/util.js'; import {normalizeSourceUrl, normalizeStyleUrl} from './mapbox.js'; /** @typedef {import("ol").Map} Map */ /** @typedef {import("ol/layer").Layer} Layer */ /** @typedef {import("ol/layer").Group} LayerGroup */ /** @typedef {import("ol/layer").Vector} VectorLayer */ /** @typedef {import("ol/layer").VectorTile} VectorTileLayer */ /** @typedef {import("ol/source").Source} Source */ /** * @typedef {Object} FeatureIdentifier * @property {string|number} id The feature id. * @property {string} source The source id. */ const functionCacheByStyleId = {}; const filterCacheByStyleId = {}; let styleId = 0; export function getStyleId(glStyle) { if (!glStyle.id) { glStyle.id = styleId++; } return glStyle.id; } export function getStyleFunctionKey(glStyle, olLayer) { return getStyleId(glStyle) + '.' + getUid(olLayer); } /** * @param {Object} glStyle Mapboox style object. * @return {Object} Function cache. */ export function getFunctionCache(glStyle) { let functionCache = functionCacheByStyleId[glStyle.id]; if (!functionCache) { functionCache = {}; functionCacheByStyleId[getStyleId(glStyle)] = functionCache; } return functionCache; } export function clearFunctionCache() { for (const key in functionCacheByStyleId) { delete functionCacheByStyleId[key]; } } /** * @param {Object} glStyle Mapboox style object. * @return {Object} Filter cache. */ export function getFilterCache(glStyle) { let filterCache = filterCacheByStyleId[glStyle.id]; if (!filterCache) { filterCache = {}; filterCacheByStyleId[getStyleId(glStyle)] = filterCache; } return filterCache; } export function deg2rad(degrees) { return (degrees * Math.PI) / 180; } export const defaultResolutions = (function () { const resolutions = []; for (let res = 78271.51696402048; resolutions.length <= 24; res /= 2) { resolutions.push(res); } return resolutions; })(); /** * @param {number} width Width of the canvas. * @param {number} height Height of the canvas. * @return {HTMLCanvasElement} Canvas. */ export function createCanvas(width, height) { if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope && typeof OffscreenCanvas !== 'undefined') { // eslint-disable-line return /** @type {?} */ (new OffscreenCanvas(width, height)); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; return canvas; } export function getZoomForResolution(resolution, resolutions) { let i = 0; const ii = resolutions.length; for (; i < ii; ++i) { const candidate = resolutions[i]; if (candidate < resolution && i + 1 < ii) { const zoomFactor = resolutions[i] / resolutions[i + 1]; return i + Math.log(resolutions[i] / resolution) / Math.log(zoomFactor); } } return ii - 1; } const pendingRequests = {}; /** * @param {ResourceType} resourceType Type of resource to load. * @param {string} url Url of the resource. * @param {Options} [options={}] Options. * @param {{request?: Request}} [metadata] Object to be filled with the request. * @return {Promise} Promise that resolves with the loaded resource * or rejects with the Response object. * @private */ export function fetchResource(resourceType, url, options = {}, metadata) { if (url in pendingRequests) { if (metadata) { metadata.request = pendingRequests[url][0]; } return pendingRequests[url][1]; } const request = options.transformRequest ? options.transformRequest(url, resourceType) || new Request(url) : new Request(url); if (!request.headers.get('Accept')) { request.headers.set('Accept', 'application/json'); } if (metadata) { metadata.request = request; } const pendingRequest = fetch(request) .then(function (response) { delete pendingRequests[url]; return response.ok ? response.json() : Promise.reject(new Error('Error fetching source ' + url)); }) .catch(function (error) { delete pendingRequests[url]; return Promise.reject(new Error('Error fetching source ' + url)); }); pendingRequests[url] = [request, pendingRequest]; return pendingRequest; } export function getGlStyle(glStyleOrUrl, options) { if (typeof glStyleOrUrl === 'string') { if (glStyleOrUrl.trim().startsWith('{')) { try { const glStyle = JSON.parse(glStyleOrUrl); return Promise.resolve(glStyle); } catch (error) { return Promise.reject(error); } } else { glStyleOrUrl = normalizeStyleUrl(glStyleOrUrl, options.accessToken); return fetchResource('Style', glStyleOrUrl, options); } } else { return Promise.resolve(glStyleOrUrl); } } function getTransformedTilesUrl(tilesUrl, options) { if (options.transformRequest) { const transformedRequest = options.transformRequest(tilesUrl, 'Tiles'); if (transformedRequest instanceof Request) { return decodeURI(transformedRequest.url); } } return tilesUrl; } const tilejsonCache = {}; /** * @param {Object} glSource glStyle source object. * @param {string} styleUrl Style URL. * @param {Options} options Options. * @return {Object} TileJson */ export function getTileJson(glSource, styleUrl, options = {}) { const cacheKey = [styleUrl, JSON.stringify(glSource)].toString(); let promise = tilejsonCache[cacheKey]; if (!promise || options.transformRequest) { const url = glSource.url; if (url && !glSource.tiles) { const normalizedSourceUrl = normalizeSourceUrl( url, options.accessToken, options.accessTokenParam || 'access_token', styleUrl || location.href ); if (url.startsWith('mapbox://')) { promise = Promise.resolve( Object.assign({}, glSource, { url: undefined, tiles: expandUrl(normalizedSourceUrl), }) ); } else { const metadata = {}; promise = fetchResource( 'Source', normalizedSourceUrl, options, metadata ).then(function (tileJson) { tileJson.tiles = tileJson.tiles.map(function (tileUrl) { if (tileJson.scheme === 'tms') { tileUrl = tileUrl.replace('{y}', '{-y}'); } return getTransformedTilesUrl( normalizeSourceUrl( tileUrl, options.accessToken, options.accessTokenParam || 'access_token', metadata.request.url ), options ); }); return Promise.resolve(tileJson); }); } } else { glSource = Object.assign({}, glSource, { tiles: glSource.tiles.map(function (tileUrl) { if (glSource.scheme === 'tms') { tileUrl = tileUrl.replace('{y}', '{-y}'); } return getTransformedTilesUrl( normalizeSourceUrl( tileUrl, options.accessToken, options.accessTokenParam || 'access_token', styleUrl || location.href ), options ); }), }); promise = Promise.resolve(Object.assign({}, glSource)); } tilejsonCache[cacheKey] = promise; } return promise; } /** * @param {HTMLImageElement|HTMLCanvasElement} spriteImage Sprite image id. * @param {{x: number, y: number, width: number, height: number, pixelRatio: number}} spriteImageData Sprite image data. * @param {number} haloWidth Halo width. * @param {{r: number, g: number, b: number, a: number}} haloColor Halo color. * @return {HTMLCanvasElement} Canvas element with the halo. */ export function drawIconHalo( spriteImage, spriteImageData, haloWidth, haloColor ) { const imageCanvas = document.createElement('canvas'); const imgSize = [ 2 * haloWidth * spriteImageData.pixelRatio + spriteImageData.width, 2 * haloWidth * spriteImageData.pixelRatio + spriteImageData.height, ]; imageCanvas.width = imgSize[0]; imageCanvas.height = imgSize[1]; const imageContext = imageCanvas.getContext('2d'); imageContext.drawImage( spriteImage, spriteImageData.x, spriteImageData.y, spriteImageData.width, spriteImageData.height, haloWidth * spriteImageData.pixelRatio, haloWidth * spriteImageData.pixelRatio, spriteImageData.width, spriteImageData.height ); const imageData = imageContext.getImageData(0, 0, imgSize[0], imgSize[1]); imageContext.globalCompositeOperation = 'destination-over'; imageContext.fillStyle = `rgba(${haloColor.r * 255},${haloColor.g * 255},${ haloColor.b * 255 },${haloColor.a})`; const data = imageData.data; for (let i = 0, ii = imageData.width; i < ii; ++i) { for (let j = 0, jj = imageData.height; j < jj; ++j) { const index = (j * ii + i) * 4; const alpha = data[index + 3]; if (alpha > 0) { imageContext.arc( i, j, haloWidth * spriteImageData.pixelRatio, 0, 2 * Math.PI ); } } } imageContext.fill(); return imageCanvas; } function smoothstep(min, max, value) { const x = Math.max(0, Math.min(1, (value - min) / (max - min))); return x * x * (3 - 2 * x); } /** * @param {HTMLImageElement} image SDF image * @param {{x: number, y: number, width: number, height: number}} area Area to unSDF * @param {{r: number, g: number, b: number, a: number}} color Color to use * @return {HTMLCanvasElement} Regular image */ export function drawSDF(image, area, color) { const imageCanvas = document.createElement('canvas'); imageCanvas.width = area.width; imageCanvas.height = area.height; const imageContext = imageCanvas.getContext('2d'); imageContext.drawImage( image, area.x, area.y, area.width, area.height, 0, 0, area.width, area.height ); const imageData = imageContext.getImageData(0, 0, area.width, area.height); const data = imageData.data; for (let i = 0, ii = imageData.width; i < ii; ++i) { for (let j = 0, jj = imageData.height; j < jj; ++j) { const index = (j * ii + i) * 4; const dist = data[index + 3] / 255; const buffer = 0.75; const gamma = 0.1; const alpha = smoothstep(buffer - gamma, buffer + gamma, dist); if (alpha > 0) { data[index + 0] = Math.round(255 * color.r * alpha); data[index + 1] = Math.round(255 * color.g * alpha); data[index + 2] = Math.round(255 * color.b * alpha); data[index + 3] = Math.round(255 * alpha); } else { data[index + 3] = 0; } } } imageContext.putImageData(imageData, 0, 0); return imageCanvas; } /** * Get the OpenLayers layer instance that contains the provided Mapbox Style * `layer`. Note that multiple Mapbox Style layers are combined in a single * OpenLayers layer instance when they use the same Mapbox Style `source`. * @param {Map|LayerGroup} map OpenLayers Map or LayerGroup. * @param {string} layerId Mapbox Style layer id. * @return {Layer} OpenLayers layer instance. */ export function getLayer(map, layerId) { const layers = map.getLayers().getArray(); for (let i = 0, ii = layers.length; i < ii; ++i) { const mapboxLayers = layers[i].get('mapbox-layers'); if (mapboxLayers && mapboxLayers.indexOf(layerId) !== -1) { return /** @type {Layer} */ (layers[i]); } } return undefined; } /** * Get the OpenLayers layer instances for the provided Mapbox Style `source`. * @param {Map|LayerGroup} map OpenLayers Map or LayerGroup. * @param {string} sourceId Mapbox Style source id. * @return {Array} OpenLayers layer instances. */ export function getLayers(map, sourceId) { const result = []; const layers = map.getLayers().getArray(); for (let i = 0, ii = layers.length; i < ii; ++i) { if (layers[i].get('mapbox-source') === sourceId) { result.push(/** @type {Layer} */ (layers[i])); } } return result; } /** * Get the OpenLayers source instance for the provided Mapbox Style `source`. * @param {Map|LayerGroup} map OpenLayers Map or LayerGroup. * @param {string} sourceId Mapbox Style source id. * @return {Source} OpenLayers source instance. */ export function getSource(map, sourceId) { const layers = map.getLayers().getArray(); for (let i = 0, ii = layers.length; i < ii; ++i) { const source = /** @type {Layer} */ (layers[i]).getSource(); if (layers[i].get('mapbox-source') === sourceId) { return source; } } return undefined; } /** * Sets or removes a feature state. The feature state is taken into account for styling, * just like the feature's properties, and can be used e.g. to conditionally render selected * features differently. * * The feature state will be stored on the OpenLayers layer matching the feature identifier, in the * `mapbox-featurestate` property. * @param {Map|VectorLayer|VectorTileLayer} mapOrLayer OpenLayers Map or layer to set the feature * state on. * @param {FeatureIdentifier} feature Feature identifier. * @param {Object|null} state Feature state. Set to `null` to remove the feature state. */ export function setFeatureState(mapOrLayer, feature, state) { const layers = 'getLayers' in mapOrLayer ? getLayers(mapOrLayer, feature.source) : [mapOrLayer]; for (let i = 0, ii = layers.length; i < ii; ++i) { const featureState = layers[i].get('mapbox-featurestate'); if (featureState) { if (state) { featureState[feature.id] = state; } else { delete featureState[feature.id]; } layers[i].changed(); } else { throw new Error(`Map or layer for source "${feature.source}" not found.`); } } } /** * Sets or removes a feature state. The feature state is taken into account for styling, * just like the feature's properties, and can be used e.g. to conditionally render selected * features differently. * @param {Map|VectorLayer|VectorTileLayer} mapOrLayer Map or layer to set the feature state on. * @param {FeatureIdentifier} feature Feature identifier. * @return {Object|null} Feature state or `null` when no feature state is set for the given * feature identifier. */ export function getFeatureState(mapOrLayer, feature) { const layers = 'getLayers' in mapOrLayer ? getLayers(mapOrLayer, feature.source) : [mapOrLayer]; for (let i = 0, ii = layers.length; i < ii; ++i) { const featureState = layers[i].get('mapbox-featurestate'); if (featureState && featureState[feature.id]) { return featureState[feature.id]; } } return undefined; } /** * Get the Mapbox Layer object for the provided `layerId`. * @param {Map|LayerGroup} mapOrGroup Map or LayerGroup. * @param {string} layerId Mapbox Layer id. * @return {Object} Mapbox Layer object. */ export function getMapboxLayer(mapOrGroup, layerId) { const style = mapOrGroup.get('mapbox-style'); const layerStyle = style.layers.find(function (layer) { return layer.id === layerId; }); return layerStyle; } /** * Add a new Mapbox Layer object to the style. The map will be re-rendered. * @param {Map|LayerGroup} mapOrGroup The Map or LayerGroup `apply` was called on. * @param {Object} mapboxLayer Mapbox Layer object. * @param {string} [beforeLayerId] Optional id of the Mapbox Layer before the new layer that will be added. * @return {Promise} Resolves when the added layer is available. */ export function addMapboxLayer(mapOrGroup, mapboxLayer, beforeLayerId) { const glStyle = mapOrGroup.get('mapbox-style'); const mapboxLayers = glStyle.layers; let spliceIndex; let sourceIndex = -1; if (beforeLayerId !== undefined) { const beforeMapboxLayer = getMapboxLayer(mapOrGroup, beforeLayerId); if (beforeMapboxLayer === undefined) { throw new Error(`Layer with id "${beforeLayerId}" not found.`); } spliceIndex = mapboxLayers.indexOf(beforeMapboxLayer); } else { spliceIndex = mapboxLayers.length; } let sourceOffset; if ( spliceIndex > 0 && mapboxLayers[spliceIndex - 1].source === mapboxLayer.source ) { sourceIndex = spliceIndex - 1; sourceOffset = -1; } else if ( spliceIndex < mapboxLayers.length && mapboxLayers[spliceIndex].source === mapboxLayer.source ) { sourceIndex = spliceIndex; sourceOffset = 0; } if (sourceIndex === -1) { const {options, styleUrl} = mapOrGroup.get('mapbox-metadata'); const layer = setupLayer(glStyle, styleUrl, mapboxLayer, options); if (beforeLayerId) { const beforeLayer = getLayer(mapOrGroup, beforeLayerId); const beforeLayerIndex = mapOrGroup .getLayers() .getArray() .indexOf(beforeLayer); mapOrGroup.getLayers().insertAt(beforeLayerIndex, layer); } mapboxLayers.splice(spliceIndex, 0, mapboxLayer); return finalizeLayer( layer, [mapboxLayer.id], glStyle, styleUrl, mapOrGroup, options ); } if (mapboxLayers.some((layer) => layer.id === mapboxLayer.id)) { throw new Error(`Layer with id "${mapboxLayer.id}" already exists.`); } const sourceLayerId = mapboxLayers[sourceIndex].id; const args = styleFunctionArgs[ getStyleFunctionKey( mapOrGroup.get('mapbox-style'), getLayer(mapOrGroup, sourceLayerId) ) ]; mapboxLayers.splice(spliceIndex, 0, mapboxLayer); if (args) { const [ olLayer, glStyle, sourceOrLayers, resolutions, spriteData, spriteImageUrl, getFonts, getImage, ] = args; if (Array.isArray(sourceOrLayers)) { const layerIndex = sourceOrLayers.indexOf(sourceLayerId) + sourceOffset; sourceOrLayers.splice(layerIndex, 0, mapboxLayer.id); } applyStylefunction( olLayer, glStyle, sourceOrLayers, resolutions, spriteData, spriteImageUrl, getFonts, getImage ); } else { getLayer(mapOrGroup, mapboxLayers[sourceIndex].id).changed(); } return Promise.resolve(); } /** * Update a Mapbox Layer object in the style. The map will be re-rendered with the new style. * @param {Map|LayerGroup} mapOrGroup The Map or LayerGroup `apply` was called on. * @param {Object} mapboxLayer Updated Mapbox Layer object. */ export function updateMapboxLayer(mapOrGroup, mapboxLayer) { const glStyle = mapOrGroup.get('mapbox-style'); const mapboxLayers = glStyle.layers; const index = mapboxLayers.findIndex(function (layer) { return layer.id === mapboxLayer.id; }); if (index === -1) { throw new Error(`Layer with id "${mapboxLayer.id}" not found.`); } const oldLayer = mapboxLayers[index]; if (oldLayer.source !== mapboxLayer.source) { throw new Error( 'Updated layer and previous version must use the same source.' ); } delete getFunctionCache(glStyle)[mapboxLayer.id]; delete getFilterCache(glStyle)[mapboxLayer.id]; mapboxLayers[index] = mapboxLayer; const args = styleFunctionArgs[ getStyleFunctionKey( mapOrGroup.get('mapbox-style'), getLayer(mapOrGroup, mapboxLayer.id) ) ]; if (args) { applyStylefunction.apply(undefined, args); } else { getLayer(mapOrGroup, mapboxLayer.id).changed(); } } /** * Remove a Mapbox Layer object from the style. The map will be re-rendered. * @param {Map|LayerGroup} mapOrGroup The Map or LayerGroup `apply` was called on. * @param {string|Object} mapboxLayerIdOrLayer Mapbox Layer id or Mapbox Layer object. */ export function removeMapboxLayer(mapOrGroup, mapboxLayerIdOrLayer) { const mapboxLayerId = typeof mapboxLayerIdOrLayer === 'string' ? mapboxLayerIdOrLayer : mapboxLayerIdOrLayer.id; const layer = getLayer(mapOrGroup, mapboxLayerId); /** @type {Array} */ const layerMapboxLayers = layer.get('mapbox-layers'); if (layerMapboxLayers.length === 1) { throw new Error( 'Cannot remove last Mapbox layer from an OpenLayers layer.' ); } layerMapboxLayers.splice(layerMapboxLayers.indexOf(mapboxLayerId), 1); const glStyle = mapOrGroup.get('mapbox-style'); const layers = glStyle.layers; layers.splice( layers.findIndex((layer) => layer.id === mapboxLayerId), 1 ); const args = styleFunctionArgs[getStyleFunctionKey(glStyle, layer)]; if (args) { const [ olLayer, glStyle, sourceOrLayers, resolutions, spriteData, spriteImageUrl, getFonts, getImage, ] = args; if (Array.isArray(sourceOrLayers)) { sourceOrLayers.splice( sourceOrLayers.findIndex((layer) => layer === mapboxLayerId), 1 ); } applyStylefunction( olLayer, glStyle, sourceOrLayers, resolutions, spriteData, spriteImageUrl, getFonts, getImage ); } else { getLayer(mapOrGroup, mapboxLayerId).changed(); } } /** * @typedef {import("./apply.js").Options} Options * @typedef {import('./apply.js').ResourceType} ResourceType * @private */