UNPKG

georaster-layer-for-leaflet

Version:

Display GeoTIFFs and soon other types of raster on your Leaflet Map

523 lines (450 loc) 20 kB
/* global L, proj4 */ import "regenerator-runtime/runtime"; import chroma from "chroma-js"; import isUTM from "utm-utils/src/isUTM"; import getProjString from "utm-utils/src/getProjString"; const EPSG4326 = 4326; const PROJ4_SUPPORTED_PROJECTIONS = new Set([3857, 4269]); const MAX_NORTHING = 1000; const MAX_EASTING = 1000; const ORIGIN = [0, 0]; const GeoRasterLayer = L.GridLayer.extend({ initialize: function (options) { try { if (options.georasters) { this.georasters = options.georasters; } else if (options.georaster) { this.georasters = [options.georaster]; } else { throw new Error("You must initialize a GeoRasterLayer with a georaster or georasters value"); } /* Unpacking values for use later. We do this in order to increase speed. */ const keys = [ "height", "width", "noDataValue", "palette", "pixelHeight", "pixelWidth", "projection", "sourceType", "xmin", "xmax", "ymin", "ymax" ]; if (this.georasters.length > 1) { keys.forEach(key => { if (this.same(this.georasters, key)) { this[key] = this.georasters[0][key]; } else { throw new Error("all GeoRasters must have the same " + key); } }); } else if (this.georasters.length === 1) { keys.forEach(key => { this[key] = this.georasters[0][key]; }); } // used later if simple projection this.ratio = this.height / this.width; if (this.sourceType === "url") { if (!options.updateWhenIdle) options.updateWhenIdle = false; if (!options.updateWhenZooming) options.updateWhenZooming = true; if (!options.keepBuffer) options.keepBuffer = 16; } if (!("debugLevel" in options)) options.debugLevel = 1; if (!options.keepBuffer) options.keepBuffer = 25; if (!options.resolution) options.resolution = Math.pow(2, 5); if (options.updateWhenZooming === undefined) options.updateWhenZooming = false; this.debugLevel = options.debugLevel; if (this.debugLevel >= 1) console.log("georaster:", options); if (this.georasters.every(georaster => typeof georaster.values === "object")) { this.rasters = this.georasters.reduce((result, georaster) => { result = result.concat(georaster.values); return result; }, []); if (this.debugLevel > 1) console.log("this.rasters:", this.rasters); } this.chroma = chroma; this.scale = chroma.scale(); L.setOptions(this, options); /* Caching the constant tile size, so we don't recalculate everytime we create a new tile */ const tileSize = this.getTileSize(); this.tileHeight = tileSize.y; this.tileWidth = tileSize.x; if (this.georasters.length > 1 && !options.pixelValuesToColorFn) { throw "you must pass in a pixelValuesToColorFn if you are combining rasters"; } if ( this.georasters.length === 1 && this.georasters[0].sourceType === "url" && this.georasters[0].numberOfRasters === 1 && !options.pixelValuesToColorFn ) { // For COG, we can't determine a data min max for color scaling, // so pixelValuesToColorFn is required. throw "pixelValuesToColorFn is a required option for single-band rasters initialized via URL"; } } catch (error) { console.error("ERROR initializing GeoTIFFLayer", error); } }, getRasters: function (options) { const { tileNwPoint, heightOfSampleInScreenPixels, widthOfSampleInScreenPixels, coords, numberOfSamplesAcross, numberOfSamplesDown, ymax, xmin } = options; if (this.debugLevel >= 1) console.log("starting getRasters with options:", options); // called if georaster was constructed from URL and we need to get // data separately for each tile // aka 'COG mode' /* This function takes in coordinates in the rendered image tile and returns the y and x values in the original raster */ const rasterCoordsForTileCoords = (h, w) => { const xCenterInMapPixels = tileNwPoint.x + (w + 0.5) * widthOfSampleInScreenPixels; const yCenterInMapPixels = tileNwPoint.y + (h + 0.5) * heightOfSampleInScreenPixels; const mapPoint = L.point(xCenterInMapPixels, yCenterInMapPixels); if (this.debugLevel >= 1) console.log("mapPoint:", mapPoint); const { lat, lng } = this.getMap().unproject(mapPoint, coords.z); if (this.projection === EPSG4326) { return { y: Math.floor((ymax - lat) / this.pixelHeight), x: Math.floor((lng - xmin) / this.pixelWidth) }; } else if (this.getProjector()) { /* source raster doesn't use latitude and longitude, so need to reproject point from lat/long to projection of raster */ const [x, y] = this.getProjector().inverse([lng, lat]); if (x === Infinity || y === Infinity) { if (this.debugLevel >= 1) console.error("projector converted", [lng, lat], "to", [x, y]); } return { y: Math.floor((ymax - y) / this.pixelHeight), x: Math.floor((x - xmin) / this.pixelWidth) }; } }; // careful not to flip min_y/max_y here const topLeft = rasterCoordsForTileCoords(0, 0); const bottomRight = rasterCoordsForTileCoords(numberOfSamplesDown - 1, numberOfSamplesAcross - 1); const getValuesOptions = { bottom: bottomRight.y, height: numberOfSamplesDown, left: topLeft.x, right: bottomRight.x, top: topLeft.y, width: numberOfSamplesAcross }; if (!Object.values(getValuesOptions).every(isFinite)) { console.error("getRasters failed because not all values are finite:", getValuesOptions); } else { return Promise.all(this.georasters.map(georaster => georaster.getValues(getValuesOptions))).then( valuesByGeoRaster => valuesByGeoRaster.reduce((result, values) => { result = result.concat(values); return result; }, []) ); } }, createTile: function (coords, done) { let error; const inSimpleCRS = this.getMap().options.crs === L.CRS.Simple; // Unpacking values for increased speed const { rasters, xmin, ymax } = this; const rasterHeight = this.height; const rasterWidth = this.width; const pixelHeight = inSimpleCRS ? this.getBounds()._northEast.lat / rasterHeight : this.pixelHeight; const pixelWidth = inSimpleCRS ? this.getBounds()._northEast.lng / rasterWidth : this.pixelWidth; // these values are used, so we don't try to sample outside of the raster const { xMinOfLayer, xMaxOfLayer, yMinOfLayer, yMaxOfLayer } = this; /* This tile is the square piece of the Leaflet map that we draw on */ const tile = L.DomUtil.create("canvas", "leaflet-tile"); tile.height = this.tileHeight; tile.width = this.tileWidth; const context = tile.getContext("2d"); const boundsOfTile = this._tileCoordsToBounds(coords); const xMinOfTileInMapCRS = boundsOfTile.getWest(); const xMaxOfTileInMapCRS = boundsOfTile.getEast(); const yMinOfTileInMapCRS = boundsOfTile.getSouth(); const yMaxOfTileInMapCRS = boundsOfTile.getNorth(); let rasterPixelsAcross, rasterPixelsDown; if (inSimpleCRS || this.projection === EPSG4326) { // width of the Leaflet tile in number of pixels from original raster rasterPixelsAcross = Math.ceil((xMaxOfTileInMapCRS - xMinOfTileInMapCRS) / pixelWidth); rasterPixelsDown = Math.ceil((yMaxOfTileInMapCRS - yMinOfTileInMapCRS) / pixelHeight); } else if (this.getProjector()) { const projector = this.getProjector(); // convert extent of Leaflet tile to projection of the georaster const topLeft = projector.inverse({ x: xMinOfTileInMapCRS, y: yMaxOfTileInMapCRS }); const topRight = projector.inverse({ x: xMaxOfTileInMapCRS, y: yMaxOfTileInMapCRS }); const bottomLeft = projector.inverse({ x: xMinOfTileInMapCRS, y: yMinOfTileInMapCRS }); const bottomRight = projector.inverse({ x: xMaxOfTileInMapCRS, y: yMinOfTileInMapCRS }); rasterPixelsAcross = Math.ceil(Math.max(topRight.x - topLeft.x, bottomRight.x - bottomLeft.x) / pixelWidth); rasterPixelsDown = Math.ceil(Math.max(topLeft.y - bottomLeft.y, topRight.y - bottomRight.y) / pixelHeight); } const { resolution } = this.options; // prevent sampling more times than number of pixels to display const numberOfSamplesAcross = Math.min(resolution, rasterPixelsAcross); const numberOfSamplesDown = Math.min(resolution, rasterPixelsDown); // set how large to display each sample in screen pixels const heightOfSampleInScreenPixels = this.tileHeight / numberOfSamplesDown; const heightOfSampleInScreenPixelsInt = Math.ceil(heightOfSampleInScreenPixels); const widthOfSampleInScreenPixels = this.tileWidth / numberOfSamplesAcross; const widthOfSampleInScreenPixelsInt = Math.ceil(widthOfSampleInScreenPixels); const map = this.getMap(); const tileSize = this.getTileSize(); // this converts tile coordinates (how many tiles down and right) // to pixels from left and top of tile pane const tileNwPoint = coords.scaleBy(tileSize); // render asynchronously so tiles show up as they finish instead of all at once (which blocks the UI) setTimeout(async () => { let tileRasters; if (!rasters) { tileRasters = await this.getRasters({ tileNwPoint, heightOfSampleInScreenPixels, widthOfSampleInScreenPixels, coords, pixelHeight, pixelWidth, numberOfSamplesAcross, numberOfSamplesDown, ymax, xmin }); } for (let h = 0; h < numberOfSamplesDown; h++) { const yCenterInMapPixels = tileNwPoint.y + (h + 0.5) * heightOfSampleInScreenPixels; const latWestPoint = L.point(tileNwPoint.x, yCenterInMapPixels); const { lat } = map.unproject(latWestPoint, coords.z); if (lat > yMinOfLayer && lat < yMaxOfLayer) { const yInTilePixels = Math.round(h * heightOfSampleInScreenPixels); let yInRasterPixels; if (inSimpleCRS || this.projection === EPSG4326) { yInRasterPixels = Math.floor((yMaxOfLayer - lat) / pixelHeight); } else { yInRasterPixels = null; } for (let w = 0; w < numberOfSamplesAcross; w++) { const latLngPoint = L.point(tileNwPoint.x + (w + 0.5) * widthOfSampleInScreenPixels, yCenterInMapPixels); const { lng: xOfLayer } = map.unproject(latLngPoint, coords.z); if (xOfLayer > xMinOfLayer && xOfLayer < xMaxOfLayer) { let xInRasterPixels; if (inSimpleCRS || this.projection === EPSG4326) { xInRasterPixels = Math.floor((xOfLayer - xMinOfLayer) / pixelWidth); } else if (this.getProjector()) { const inverted = this.getProjector().inverse({ x: xOfLayer, y: lat }); const yInSrc = inverted.y; yInRasterPixels = Math.floor((ymax - yInSrc) / pixelHeight); if (yInRasterPixels < 0 || yInRasterPixels >= rasterHeight) continue; const xInSrc = inverted.x; xInRasterPixels = Math.floor((xInSrc - xmin) / pixelWidth); if (xInRasterPixels < 0 || xInRasterPixels >= rasterWidth) continue; } let values = null; if (tileRasters) { // get value from array specific to this tile values = tileRasters.map(band => band[h][w]); } else if (rasters) { // get value from array with data for entire raster values = rasters.map(band => { return band[yInRasterPixels][xInRasterPixels]; }); } else { done("no rasters are available for, so skipping value generation"); return; } // x-axis coordinate of the starting point of the rectangle representing the raster pixel const x = Math.round(w * widthOfSampleInScreenPixels); // y-axis coordinate of the starting point of the rectangle representing the raster pixel const y = yInTilePixels; // how many real screen pixels does a pixel of the sampled raster take up const width = widthOfSampleInScreenPixelsInt; const height = heightOfSampleInScreenPixelsInt; if (this.options.customDrawFunction) { this.options.customDrawFunction({ values, context, x, y, width, height, rasterX: xInRasterPixels, rasterY: yInRasterPixels, sampleX: w, sampleY: h, sampledRaster: tileRasters }); } else { const color = this.getColor(values); if (color) { context.fillStyle = color; context.fillRect(x, y, width, height); } } } } } } done(error, tile); }, 0); // return the tile so it can be rendered on screen return tile; }, // method from https://github.com/Leaflet/Leaflet/blob/bb1d94ac7f2716852213dd11563d89855f8d6bb1/src/layer/ImageOverlay.js getBounds: function () { this.initBounds(); return this._bounds; }, getMap: function () { return this._map || this._mapToAdd; }, _isValidTile: function (coords) { const crs = this.getMap().options.crs; if (!crs.infinite) { // don't load tile if it's out of bounds and not wrapped const globalBounds = this._globalTileRange; if ( (!crs.wrapLng && (coords.x < globalBounds.min.x || coords.x > globalBounds.max.x)) || (!crs.wrapLat && (coords.y < globalBounds.min.y || coords.y > globalBounds.max.y)) ) { return false; } } const bounds = this.getBounds(); if (!bounds) { return true; } const { x, y, z } = coords; const layerBounds = L.latLngBounds(bounds); const boundsOfTile = this._tileCoordsToBounds(coords); // check given tile coordinates if (layerBounds.overlaps(boundsOfTile)) return true; // if not within the original confines of the earth return false // we don't want wrapping if using Simple CRS if (crs === L.CRS.Simple) return false; // width of the globe in tiles at the given zoom level const width = Math.pow(2, z); // check one world to the left const leftCoords = L.point(x - width, y); leftCoords.z = z; if (layerBounds.overlaps(this._tileCoordsToBounds(leftCoords))) return true; // check one world to the right const rightCoords = L.point(x + width, y); rightCoords.z = z; if (layerBounds.overlaps(this._tileCoordsToBounds(rightCoords))) return true; return false; }, getColor: function (values) { if (this.options.pixelValuesToColorFn) { return this.options.pixelValuesToColorFn(values); } else { const numberOfValues = values.length; const haveDataForAllBands = values.every(value => value !== undefined && value !== this.noDataValue); if (haveDataForAllBands) { if (numberOfValues == 1) { const { mins, ranges } = this.georasters[0]; const value = values[0]; if (this.palette) { const [r, g, b, a] = this.palette[value]; return `rgba(${r},${g},${b},${a / 255})`; } else { return this.scale((values[0] - mins[0]) / ranges[0]).hex(); } } else if (numberOfValues === 2) { return `rgb(${values[0]},${values[1]},0)`; } else if (numberOfValues === 3) { return `rgb(${values[0]},${values[1]},${values[2]})`; } else if (numberOfValues === 4) { return `rgba(${values[0]},${values[1]},${values[2]},${values[3] / 255})`; } } } }, isSupportedProjection: function (projection) { if (!projection) projection = this.projection; return isUTM(projection) || PROJ4_SUPPORTED_PROJECTIONS.has(projection); }, getProjectionString: function (projection) { if (isUTM(projection)) { return getProjString(projection); } return `EPSG:${projection}`; }, initBounds: function (options = this.options) { if (!this._bounds) { const { debugLevel, height, width, projection, xmin, xmax, ymin, ymax } = this; // check if map using Simple CRS if (this.getMap().options.crs === L.CRS.Simple) { if (height === width) { this._bounds = L.latLngBounds([ORIGIN, [MAX_NORTHING, MAX_EASTING]]); } else if (height > width) { this._bounds = L.latLngBounds([ORIGIN, [MAX_NORTHING, MAX_EASTING / this.ratio]]); } else if (width > height) { this._bounds = L.latLngBounds([ORIGIN, [MAX_NORTHING * this.ratio, MAX_EASTING]]); } } else if (projection === EPSG4326) { if (debugLevel >= 1) console.log(`georaster projection is in ${EPSG4326}`); const minLatWest = L.latLng(ymin, xmin); const maxLatEast = L.latLng(ymax, xmax); this._bounds = L.latLngBounds(minLatWest, maxLatEast); } else if (this.getProjector()) { if (debugLevel >= 1) console.log("projection is UTM or supported by proj4"); const bottomLeft = this.getProjector().forward({ x: xmin, y: ymin }); const minLatWest = L.latLng(bottomLeft.y, bottomLeft.x); const topRight = this.getProjector().forward({ x: xmax, y: ymax }); const maxLatEast = L.latLng(topRight.y, topRight.x); this._bounds = L.latLngBounds(minLatWest, maxLatEast); } else { throw `georaster-layer-for-leaflet does not support rasters with the projection ${projection}`; } // these values are used so we don't try to sample outside of the raster this.xMinOfLayer = this._bounds.getWest(); this.xMaxOfLayer = this._bounds.getEast(); this.yMaxOfLayer = this._bounds.getNorth(); this.yMinOfLayer = this._bounds.getSouth(); options.bounds = this._bounds; } }, getProjector: function () { if (this.isSupportedProjection(this.projection)) { if (!proj4) { throw "proj4 must be found in the global scope in order to load a raster that uses a UTM projection"; } if (!this._projector) { this._projector = proj4(this.getProjectionString(this.projection), `EPSG:${EPSG4326}`); if (this.debugLevel >= 1) console.log("projector set"); } return this._projector; } }, same(array, key) { return new Set(array.map(item => item[key])).size === 1; } }); if (typeof module !== "undefined" && typeof module.exports !== "undefined") { module.exports = GeoRasterLayer; } if (typeof window !== "undefined") { window["GeoRasterLayer"] = GeoRasterLayer; } else if (typeof self !== "undefined") { self["GeoRasterLayer"] = GeoRasterLayer; // jshint ignore:line } console.warn('DEPRECATION WARNING: Hello. You are probably using an old link to an old version of georaster-layer-for-leaflet that will be removed at the end of 2021. You can probably remove this warning by upgrading to using https://unpkg.com/georaster-layer-for-leaflet/dist/georaster-layer-for-leaflet.min.js. If that does not work, please consult https://github.com/GeoTIFF/georaster-layer-for-leaflet for more instructions or email me directly at daniel.j.dufour@gmail.com. Happy to help! :-)');