ol-mapbox-style
Version:
Create OpenLayers layers or maps from Mapbox/MapLibre styles
423 lines (399 loc) • 13.8 kB
JavaScript
import {VectorTile} from 'ol';
import TileState from 'ol/TileState.js';
import {toPromise} from 'ol/functions.js';
import {getUid} from 'ol/util.js';
import {normalizeSourceUrl, normalizeStyleUrl} from './mapbox.js';
/** @typedef {'Style'|'Source'|'Sprite'|'SpriteImage'|'Tiles'|'GeoJSON'} ResourceType */
/** @typedef {import("ol").Map} Map */
/** @typedef {import("ol/layer/Layer.js").default} Layer */
/** @typedef {import("ol/layer/Group.js").default} LayerGroup */
/** @typedef {import("ol/layer/Vector.js").default} VectorLayer */
/** @typedef {import("ol/layer/VectorTile.js").default} VectorTileLayer */
/** @typedef {import("ol/source/Source.js").default} Source */
export const emptyObj = Object.freeze({});
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;
}
export function getResolutionForZoom(zoom, resolutions) {
const base = Math.floor(zoom);
const factor = Math.pow(2, zoom - base);
return resolutions[base] / factor;
}
const pendingRequests = {};
/**
* @param {ResourceType} resourceType Type of resource to load.
* @param {string} url Url of the resource.
* @param {Options} [options] Options.
* @param {{url?: string}} [metadata] Object to be filled with the request.
* @return {Promise<Object|Response>} 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.url = pendingRequests[url][0].url;
}
return pendingRequests[url][1];
}
const transformedRequest = options.transformRequest
? options.transformRequest(url, resourceType) || url
: url;
const handleError = function (error) {
delete pendingRequests[url];
return Promise.reject(new Error('Error fetching source ' + url));
};
const handleResponse = function (response) {
delete pendingRequests[url];
return response.ok
? response.json()
: Promise.reject(new Error('Error fetching source ' + url));
};
const pendingRequest = toPromise(() => transformedRequest)
.then((transformedRequest) => {
if (transformedRequest instanceof Response) {
if (metadata) {
metadata.url = transformedRequest.url;
}
return handleResponse(transformedRequest);
}
if (!(transformedRequest instanceof Request)) {
transformedRequest = new Request(transformedRequest);
}
if (!transformedRequest.headers.get('Accept')) {
transformedRequest.headers.set('Accept', 'application/json');
}
if (metadata) {
metadata.url = transformedRequest.url;
}
return fetch(transformedRequest).then(handleResponse).catch(handleError);
})
.catch(handleError);
pendingRequests[url] = [transformedRequest, 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);
}
}
const tilejsonCache = {};
/**
* @param {Object} glSource glStyle source object.
* @param {string} styleUrl Style URL.
* @param {Options} options Options.
* @return {Promise<{tileJson: Object, tileLoadFunction: import('ol/Tile.js').LoadFunction}?>} TileJson and load function
*/
export function getTileJson(glSource, styleUrl, options = {}) {
const cacheKey = [styleUrl, JSON.stringify(glSource)].toString();
let promise = tilejsonCache[cacheKey];
if (!promise || options.transformRequest) {
let tileLoadFunction;
if (options.transformRequest) {
tileLoadFunction = (tile, src) => {
const transformedRequest = options.transformRequest
? options.transformRequest(src, 'Tiles') || src
: src;
if (tile instanceof VectorTile) {
tile.setLoader((extent, resolution, projection) => {
const handleResponse = function (response) {
response.arrayBuffer().then((data) => {
const format = tile.getFormat();
const features = format.readFeatures(data, {
extent: extent,
featureProjection: projection,
});
// @ts-ignore
tile.setFeatures(features);
});
};
toPromise(() => transformedRequest)
.then((transformedRequest) => {
if (transformedRequest instanceof Response) {
return handleResponse(transformedRequest);
}
fetch(transformedRequest)
.then(handleResponse)
.catch((e) => tile.setState(TileState.ERROR));
})
.catch((e) => tile.setState(TileState.ERROR));
});
} else {
const img = tile.getImage();
toPromise(() => transformedRequest)
.then((transformedRequest) => {
if (typeof transformedRequest === 'string') {
img.src = transformedRequest;
return;
}
const handleResponse = (response) =>
response.blob().then((blob) => {
const url = URL.createObjectURL(blob);
img.addEventListener('load', () => URL.revokeObjectURL(url));
img.addEventListener('error', () => URL.revokeObjectURL(url));
img.src = url;
});
if (transformedRequest instanceof Response) {
return handleResponse(transformedRequest);
}
fetch(transformedRequest)
.then(handleResponse)
.catch((e) => tile.setState(TileState.ERROR));
})
.catch((e) => tile.setState(TileState.ERROR));
}
};
}
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({
tileJson: Object.assign({}, glSource, {
url: undefined,
tiles: normalizedSourceUrl,
}),
tileLoadFunction,
});
} else {
const metadata = {};
promise = fetchResource(
'Source',
normalizedSourceUrl[0],
options,
metadata,
).then(function (tileJson) {
tileJson.tiles = tileJson.tiles.map(function (tileUrl) {
if (tileJson.scheme === 'tms') {
tileUrl = tileUrl.replace('{y}', '{-y}');
}
return normalizeSourceUrl(
tileUrl,
options.accessToken,
options.accessTokenParam || 'access_token',
metadata.url,
)[0];
});
return Promise.resolve({tileJson, tileLoadFunction});
});
}
} else if (glSource.tiles) {
glSource = Object.assign({}, glSource, {
tiles: glSource.tiles.map(function (tileUrl) {
if (glSource.scheme === 'tms') {
tileUrl = tileUrl.replace('{y}', '{-y}');
}
return normalizeSourceUrl(
tileUrl,
options.accessToken,
options.accessTokenParam || 'access_token',
styleUrl || location.href,
)[0];
}),
});
promise = Promise.resolve({
tileJson: Object.assign({}, glSource),
tileLoadFunction,
});
} else {
promise = Promise.reject(new Error('source has no `tiles` nor `url`'));
}
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 imgSize = [
2 * haloWidth * spriteImageData.pixelRatio + spriteImageData.width,
2 * haloWidth * spriteImageData.pixelRatio + spriteImageData.height,
];
const imageCanvas = createCanvas(imgSize[0], 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|HTMLCanvasElement} 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 = createCanvas(area.width, 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;
}
/**
* @typedef {import("./apply.js").Options} Options
* @private
*/