diff --git a/src/apply.js b/src/apply.js index 8c562b66..06dff9fd 100644 --- a/src/apply.js +++ b/src/apply.js @@ -50,7 +50,7 @@ import { } from 'ol/proj.js'; import {getFonts} from './text.js'; import {getTopLeft} from 'ol/extent.js'; -import {hillshade} from './shaders.js'; +import {hillshade, raster as rasterShader} from './shaders.js'; import { normalizeSourceUrl, normalizeSpriteUrl, @@ -689,6 +689,7 @@ function setupRasterSource(glSource, styleUrl, options) { } return src; }); + source.set('mapbox-source', glSource); resolve(source); }) @@ -698,7 +699,7 @@ function setupRasterSource(glSource, styleUrl, options) { }); } -function setupRasterLayer(glSource, styleUrl, options) { +function setupRasterLayerAbstract(glSource, styleUrl, options) { const layer = new TileLayer(); setupRasterSource(glSource, styleUrl, options) .then(function (source) { @@ -710,6 +711,38 @@ function setupRasterLayer(glSource, styleUrl, options) { return layer; } +/** + * + * @param {Object} glSource "source" entry from a Mapbox Style object. + * @param {string} styleUrl Style url + * @param {Options} options ol-mapbox-style options. + * @return {TileLayer} The raster layer + */ +function setupRasterLayer(glSource, styleUrl, options) { + const tileLayer = setupRasterLayerAbstract(glSource, styleUrl, options); + return tileLayer; +} + +/** + * + * @param {Object} glSource "source" entry from a Mapbox Style object. + * @param {string} styleUrl Style url + * @param {Options} options ol-mapbox-style options. + * @return {ImageLayer} The raster layer + */ +function setupRasterOpLayer(glSource, styleUrl, options) { + const tileLayer = setupRasterLayerAbstract(glSource, styleUrl, options); + /** @type {ImageLayer} */ + const layer = new ImageLayer({ + source: new Raster({ + operationType: 'image', + operation: rasterShader, + sources: [tileLayer], + }), + }); + return layer; +} + /** * * @param {Object} glSource "source" entry from a Mapbox Style object. @@ -718,7 +751,7 @@ function setupRasterLayer(glSource, styleUrl, options) { * @return {ImageLayer} The raster layer */ function setupHillshadeLayer(glSource, styleUrl, options) { - const tileLayer = setupRasterLayer(glSource, styleUrl, options); + const tileLayer = setupRasterLayerAbstract(glSource, styleUrl, options); /** @type {ImageLayer} */ const layer = new ImageLayer({ source: new Raster({ @@ -897,10 +930,79 @@ export function setupLayer(glStyle, styleUrl, glLayer, options) { } else if (glSource.type == 'vector') { layer = setupVectorLayer(glSource, styleUrl, options); } else if (glSource.type == 'raster') { - layer = setupRasterLayer(glSource, styleUrl, options); + const keys = [ + 'raster-saturation', + 'raster-contrast', + 'raster-brightness-max', + 'raster-brightness-min', + 'raster-hue-rotate', + ]; + const requiresOperations = !!Object.keys(glLayer.paint || {}).find( + (key) => { + return keys.includes(key); + } + ); + + if (requiresOperations) { + layer = setupRasterOpLayer(glSource, styleUrl, options); + layer.getSource().on('beforeoperations', function (event) { + const zoom = getZoomForResolution( + event.resolution, + options.resolutions || defaultResolutions + ); + + const data = event.data; + data.saturation = getValue( + glLayer, + 'paint', + 'raster-saturation', + zoom, + emptyObj, + functionCache + ); + data.contrast = getValue( + glLayer, + 'paint', + 'raster-contrast', + zoom, + emptyObj, + functionCache + ); + + data.brightnessHigh = getValue( + glLayer, + 'paint', + 'raster-brightness-max', + zoom, + emptyObj, + functionCache + ); + + data.brightnessLow = getValue( + glLayer, + 'paint', + 'raster-brightness-min', + zoom, + emptyObj, + functionCache + ); + + data.hueRotate = getValue( + glLayer, + 'paint', + 'raster-hue-rotate', + zoom, + emptyObj, + functionCache + ); + }); + } else { + layer = setupRasterLayer(glSource, styleUrl, options); + } layer.setVisible( glLayer.layout ? glLayer.layout.visibility !== 'none' : true ); + layer.on('prerender', prerenderRasterLayer(glLayer, layer, functionCache)); } else if (glSource.type == 'geojson') { layer = setupGeoJSONLayer(glSource, styleUrl, options); @@ -1031,7 +1133,12 @@ function processStyle(glStyle, mapOrGroup, styleUrl, options) { } else { id = glLayer.source || getSourceIdByRef(glLayers, glLayer.ref); // this technique assumes gl layers will be in a particular order - if (!id || id != glSourceId) { + if ( + // This line is because rasters set properties on their source + glLayer.type === 'raster' || + !id || + id != glSourceId + ) { if (layerIds.length) { promises.push( finalizeLayer( diff --git a/src/shaders.js b/src/shaders.js index edb8ee87..bc18aa89 100644 --- a/src/shaders.js +++ b/src/shaders.js @@ -180,3 +180,104 @@ export function hillshade(inputs, data) { return new ImageData(shadeData, width, height); } + +export function raster(inputs, data) { + const image = inputs[0]; + const width = image.width; + const height = image.height; + const imageData = image.data; + const shadeData = new Uint8ClampedArray(imageData.length); + const maxX = width - 1; + const maxY = height - 1; + const pixel = [0, 0, 0, 0]; + + let pixelX, pixelY, x0, offset; + + /* + * The following functions have the same math as + * - calculateContrastFactor + * - calculateSaturationFactor + * - generateSpinWeights + */ + function calculateContrastFactor(contrast) { + return contrast > 0 ? 1 / (1 - contrast) : 1 + contrast; + } + + function calculateSaturationFactor(saturation) { + return saturation > 0 ? 1 - 1 / (1.001 - saturation) : -saturation; + } + + function generateSpinWeights(angle) { + angle *= Math.PI / 180; + const s = Math.sin(angle); + const c = Math.cos(angle); + return [ + (2 * c + 1) / 3, + (-Math.sqrt(3) * s - c + 1) / 3, + (Math.sqrt(3) * s - c + 1) / 3, + ]; + } + + const sFactor = calculateSaturationFactor(data.saturation); + const cFactor = calculateContrastFactor(data.contrast); + + const cSpinWeights = generateSpinWeights(data.hueRotate); + const cSpinWeightsXYZ = cSpinWeights; + const cSpinWeightsZXY = [cSpinWeights[2], cSpinWeights[0], cSpinWeights[1]]; + const cSpinWeightsYZX = [cSpinWeights[1], cSpinWeights[2], cSpinWeights[0]]; + + const bLow = data.brightnessLow; + const bHigh = data.brightnessHigh; + + for (pixelY = 0; pixelY <= maxY; ++pixelY) { + for (pixelX = 0; pixelX <= maxX; ++pixelX) { + x0 = pixelX === 0 ? 0 : pixelX - 1; + + offset = (pixelY * width + x0) * 4; + pixel[0] = imageData[offset]; + pixel[1] = imageData[offset + 1]; + pixel[2] = imageData[offset + 2]; + pixel[3] = imageData[offset + 3]; + + const or = pixel[0]; + const og = pixel[1]; + const ob = pixel[2]; + + const dotProduct = (vector1, vector2) => { + let result = 0; + for (let i = 0; i < vector1.length; i++) { + result += vector1[i] * vector2[i]; + } + return result; + }; + + // hue-rotate + let r = dotProduct([or, og, ob], cSpinWeightsXYZ); + let g = dotProduct([or, og, ob], cSpinWeightsZXY); + let b = dotProduct([or, og, ob], cSpinWeightsYZX); + + // saturation + const average = (r + g + b) / 3; + r += (average - r) * sFactor; + g += (average - g) * sFactor; + b += (average - b) * sFactor; + + // contrast + r = (r - 0.5) * cFactor + 0.5; + g = (g - 0.5) * cFactor + 0.5; + b = (b - 0.5) * cFactor + 0.5; + + // brightness + r = bLow * (1 - r) + bHigh * r; + g = bLow * (1 - r) + bHigh * g; + b = bLow * (1 - r) + bHigh * b; + + shadeData[offset] = r; + shadeData[offset + 1] = g; + shadeData[offset + 2] = b; + shadeData[offset + 3] = pixel[3]; + } + } + + return new ImageData(shadeData, width, height); +}