diff --git a/v3/cypress/e2e/map.spec.ts b/v3/cypress/e2e/map.spec.ts index a33915fff9..1dfee3741a 100644 --- a/v3/cypress/e2e/map.spec.ts +++ b/v3/cypress/e2e/map.spec.ts @@ -4,6 +4,7 @@ import { ToolbarElements as toolbar } from "../support/elements/toolbar-elements import { CfmElements as cfm } from "../support/elements/cfm" import { MapLegendHelper as mlh } from "../support/helpers/map-legend-helper" import { TableTileElements as table } from "../support/elements/table-tile" +import { WebViewTileElements as webView } from "../support/elements/web-view-tile" const filename1 = "cypress/fixtures/RollerCoastersWithLatLong.csv" const filename2 = "cypress/fixtures/map-data.csv" @@ -396,7 +397,6 @@ context("Map UI", () => { mlh.selectCategoryColorForCategoricalLegend(arrayOfValues[0].values[1]) mlh.verifyCategoricalLegendKeySelected(arrayOfValues[0].values[1], arrayOfValues[0].selected[1]) }) - it("shows connecting lines when Connecting Lines option is checked", () => { cfm.openLocalDoc(filename1) c.getIconFromToolShelf("map").click() @@ -412,5 +412,77 @@ context("Map UI", () => { cy.wait(2000) cy.get("[data-testid=connecting-lines-map-1").find("path").should("have.length", 0) }) +}) +context("Map API", () => { + beforeEach(function () { + const url = `${Cypress.config("index")}#file=examples:Four%20Seals` + cy.visit(url) + }) + const apiTesterUrl='https://concord-consortium.github.io/codap-data-interactives/DataInteractiveAPITester/index.html?lang=en' + const openAPITester = () => { + toolbar.getOptionsButton().click() + toolbar.getWebViewButton().click() + webView.enterUrl(apiTesterUrl) + cy.wait(1000) + } + it("supports a background georaster", () => { + openAPITester() + + // Make sure the API tester is loaded + webView.getTitle().should("contain.text", "CODAP API Tester") + + // Check that the geo raster layer is not there + map.getMapGeoRasterLayer().should("not.exist") + + cy.log("Handle initial setting of the geoRaster") + const cmd1 = `{ + "action": "update", + "resource": "component[Measurements]", + "values": { + "geoRaster": { + "type": "png", + "url": "https://models-resources.concord.org/neo-images/v1/GPM_3IMERGM/720x360/2007-09-01.png" + } + } + }` + webView.sendAPITesterCommand(cmd1) + webView.confirmAPITesterResponseContains(/"success":\s*true/) + webView.clearAPITesterResponses() + + map.getMapGeoRasterLayer().should("exist") + + cy.log("Handle updating the geoRaster") + const cmd2 = `{ + "action": "update", + "resource": "component[Measurements]", + "values": { + "geoRaster": { + "type": "png", + "url": "https://models-resources.concord.org/neo-images/v1/GPM_3IMERGM/720x360/2007-10-01.png" + } + } + }` + webView.sendAPITesterCommand(cmd2) + webView.confirmAPITesterResponseContains(/"success":\s*true/) + webView.clearAPITesterResponses() + + // TODO: we should check that the image updated somehow, I think this would require reading a pixel + // of the canvas and checking that it changed. + map.getMapGeoRasterLayer().should("exist") + + cy.log("Remove the geoRaster") + const cmd3 = `{ + "action": "update", + "resource": "component[Measurements]", + "values": { + "geoRaster": null + } + }` + webView.sendAPITesterCommand(cmd3) + webView.confirmAPITesterResponseContains(/"success":\s*true/) + webView.clearAPITesterResponses() + + map.getMapGeoRasterLayer().should("not.exist") + }) }) diff --git a/v3/cypress/support/elements/map-tile.ts b/v3/cypress/support/elements/map-tile.ts index 7b482eb0da..6c0754e0aa 100644 --- a/v3/cypress/support/elements/map-tile.ts +++ b/v3/cypress/support/elements/map-tile.ts @@ -50,6 +50,9 @@ export const MapTileElements = { getMapPin(index=0) { return this.getMapPins().eq(index) }, + getMapGeoRasterLayer() { + return this.getMapTile().find(".leaflet-overlay-pane .leaflet-layer") + }, getHideSelectedCases() { return c.getInspectorPanel().find("[data-testid=hide-selected-cases]") }, diff --git a/v3/doc/georasters.md b/v3/doc/georasters.md new file mode 100644 index 0000000000..98a3d94297 --- /dev/null +++ b/v3/doc/georasters.md @@ -0,0 +1,136 @@ +# Geo Rasters +CODAP supports adding a layer to the map which displays an image projected onto the map. Currently this image must be a PNG. It is only supported via the CODAP plugin API. You can test it by: +1. Add the Plugin API Tester to a CODAP document: +https://concord-consortium.github.io/codap-data-interactives/DataInteractiveAPITester/index.html?lang=en +2. Add a map to the CODAP document +3. Send a message to CODAP like the examples below. + +## JSON format + +```json +"geoRaster": { + "url": "url_to_image", + "type": "png", + "opacity": 0.5, +} +``` + +The `opacity` is optional and defaults to `0.5`. + +The image is assumed to span the whole globe from -90 to 90 latitude and -180 to 180 longitude. The image is also assumed to be in an EPSG 4326 "projection". + +## Example CODAP messages + +### Rainfall + +0.5 deg resolution: +```json +{ + "action": "update", + "resource": "component[Map]", + "values": { + "geoRaster": { + "type": "png", + "url": "https://models-resources.concord.org/neo-images/v1/GPM_3IMERGM/720x360/2007-09-01.png" + } + } +} +``` + +0.25 Resolution +```json +{ + "action": "update", + "resource": "component[Map]", + "values": { + "geoRaster": { + "type": "png", + "url": "https://neo.gsfc.nasa.gov/servlet/RenderData?si=1990677&cs=rgb&format=PNG&width=1440&height=720" + } + } +} +``` + +0.1 Resolution +```json +{ + "action": "update", + "resource": "component[Map]", + "values": { + "geoRaster": { + "type": "png", + "url": "https://neo.gsfc.nasa.gov/servlet/RenderData?si=1990677&cs=rgb&format=PNG&width=3600&height=1800" + } + } +} +``` + +### Other datasets +You can try other datasets by: +1. Find a dataset here: https://neo.gsfc.nasa.gov/dataset_index.php +2. Choose the year and month of interest. +3. Make sure the File Type is PNG. +4. Right click on the resolution of interest and select Copy Link Address. +5. Replace the `geoRaster.url` in one of the messages above. + +## What about GeoTIFFs +We initially tried to support real GeoTIFFs. They include information about the position of the raster in the world, and what map projection the raster is using. So they would be a more self contained way of sending map rasters to CODAP. However the libraries we found to support them seem to be buggy. Additionally the the size of the GeoTIFFs is a bit larger than PNGs. + +All of the Leaflet libraries we found use the [geotiff.js](https://github.com/geotiffjs/geotiff.js) library underneath. + +This library is popular, but it is difficult to get a handle on how it is maintained. There are a few open PRs that seem reasonable but don't have any responses from the maintainer. Meanwhile there are several PRs that have been merged recently with very little discussion. It looks like the maintainer trusts certain contributors or there is another channel where communication about the PRs is happening. + +We found that it crashes when trying to load some images from https://neo.gsfc.nasa.gov. For example: https://neo.gsfc.nasa.gov/servlet/RenderData?si=1990677&cs=rgb&format=TIFF&width=720&height=360 This file will cause node.js to crash with a native stack trace. When geotiff.js is used in the browser the error message about this file is more useful. If there is time and support it would probably be worthwhile for us to track down the issue and fix geotiff.js so CODAP can support GeoTIFFs. + +Besides this error we've seen errors just downloading the GeoTIFFs. + +## Projections + +EPSG 4326 is the "projection" where each x,y coordinate represents a lat,long coordinate. It might be in different scales so a pixel might be one degree, 0.5 degrees, ... + +EPSG 3857 is the "google maps" projection where things are stretched vertically as you go more north or south from the equator. The stretching is done so the distance in meters at a point stays the same both vertically and horizontally. + +## Performance + +Originally we used the georaster-layer-for-leaflet and png-codec packages to implement this. These had some performance issues which were fixed by bringing this code into CODAP and significantly modifying it. + +The speed of the rendering will vary depending on the zoom level of the map. When zoomed in the rendering inner loop has fewer raster pixels to iterate over. + +### Notes on georaster-layer-for-leaflet package with png-codec + +The creation of the GeoRaster for each 0.5 degree per pixel image took around 40ms. This was mostly taken up by the png fetching from cache (11ms), the png decoding (20ms), converting the decoded pixels to the GeoRaster format (8ms). + +Then this GeoRaster was either used to create a new GeoRasterLayer or update an existing GeoRasterLayer. Each tile location in Leaflet asks the GeoRasterLayer to create a tile for this location and render this tile. On the full map view there are 3 tiles which are really the same image repeated 3 times. The total time for all of this rendering was around 110ms. + +These times were calculated using the Chrome dev tools profile tool along with some `performance.measure(...)` calls in the code. + +Another way to profile was by looking at what speed the images can be cycled through and still give consistent updates: +- For the default map (full world view with repeats), we could update at a rate of 6 images a second before the updates started to fall behind. This matched up with the 150ms total time described above. +- For a map zoomed into just North America, it could go about 15 frames a second. So that was around 67 ms rendering time for each image. + +Note that in the North America view the creation of the GeoRaster should have taken the same amount of time as with the full world view. That means that the 40ms to create the GeoRaster really dominated the total time on the North America view. + +#### Flicker +Originally we were creating a new GeoRasterLayer on each image update. This layer was not removed until the new GeoRaster was successfully created. However this still resulted in a flicker because the layer's tiles would be hidden instantly and the drawing of the new tiles would happen async over 20ms to 110ms. So this resulted in a pretty bad flicker. + +Now the GeoRasterLayer is updated if it can be instead of making a new layer. This eliminated one reason for the flicker, but then exposed another issue. In the georaster-layer-for-leaflet package the `GeoRasterLayer.drawTile` method sets the width and height of the canvas of the tile even if width and height don't change. As documented by MDN, setting either of these properties automatically clears the canvas even if the values are the same. + +This was fixed in the `georaster-layer-leaflet.ts` module that is part of CODAP. + +### Canceling +When the geoRaster is updated too quickly, the fetching and decoding of the geoRaster might not complete before the next updated. Additionally within the `GeoRasterLayer.drawTile` the actual rendering happens async after the initial canvas configuration, so this might also not have completed. The code currently bails out of the fetching and decoding if the geoRaster URL has changed during the fetch or decoding. + +This could be further improved by bailing out of `GeoRasterLayer.drawTile` if the url has changed. + +### Notes +- the "resolution" of the GeoRasterLayer can be changed. This ought to speed up its rendering, but the "raster pixel" edges shown when zoomed in won't match up as well with the actual "raster pixels". +- for the absolute fastest rendering we would need to pre-render the projected geo-raster so the map is just displaying a tile source like any other tile source. However this would require a lot of storage and more network bandwidth as the user is zooming in and out. So in the end it might not be worth it. + +# Javascript Size +When this feature was being developed the size of main CODAP javascript bundled file was 6.6MB before the feature was added. + +Initially the georaster-layer-for-leaflet package was used along with png-codec to load in the png image. This approach caused the main javascript file to go up to 7.2MB. So about a 600KB increase. + +The georaster-layer-for-leaflet code was brought into CODAP directly and stripped down so it no longer supports lots of different types of projections which required the Proj4j package. Additionally the png-codec package was replaced with loading the png into a canvas. These changes improved the speed of the geo raster support. They also brought the size of the main javascript down to 6.7MB. So about a 100KB increase. + +The extra size could be reduced further by looking at the use of snap package by georaster-layer-for-leaflet. This snap package uses the preciso, regenerator-runtime, and snap-bb all of which are probably unnecessary and add around 24KB. diff --git a/v3/package-lock.json b/v3/package-lock.json index 9e75a959d6..db9aa222ae 100644 --- a/v3/package-lock.json +++ b/v3/package-lock.json @@ -26,6 +26,7 @@ "@lezer/lr": "^1.4.2", "@nedb/binary-search-tree": "^2.1.5", "@uiw/react-codemirror": "^4.23.10", + "bbox-fns": "^0.20.2", "clsx": "^2.1.1", "colord": "^2.9.3", "create-react-class": "^15.7.0", @@ -54,8 +55,10 @@ "react-dom-factories": "^1.0.2", "react-leaflet": "^4.2.1", "react-resize-detector": "^12.0.2", + "regenerator-runtime": "^0.14.1", "rtree": "^1.4.2", "simpleheat": "^0.4.0", + "snap-bbox": "^0.5.0", "tslib": "^2.8.1", "type-fest": "^4.37.0", "use-debounce": "^10.0.4", @@ -9149,6 +9152,15 @@ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "dev": true }, + "node_modules/bbox-fns": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/bbox-fns/-/bbox-fns-0.20.2.tgz", + "integrity": "sha512-6DyKO3B6suAEducHcELF1cqdmlYj57zMTJim0X4RunclXOXE3wuTB8efm+I9dt2kXl+zPhFxAYSrRU6AUkHlwA==", + "license": "CC0-1.0", + "dependencies": { + "preciso": "^0.12.2" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -20417,6 +20429,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/preciso": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/preciso/-/preciso-0.12.2.tgz", + "integrity": "sha512-or/2I6/6VDMwJjJdFdyzVc/L1JT29DrlfA096iXBQWryL+ytEAfDgChI/tTnDXb58l5E/kIEa4Omg6AHFLmywA==", + "license": "CC0-1.0" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -21107,9 +21125,10 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" }, "node_modules/regenerator-transform": { "version": "0.15.1", @@ -22335,6 +22354,15 @@ "tslib": "^2.0.3" } }, + "node_modules/snap-bbox": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/snap-bbox/-/snap-bbox-0.5.0.tgz", + "integrity": "sha512-UA7WegglBdWD/lYN0X1Zc+4uyXBYgGw9AKvL+lUNeP0S05zZE4TLMbcDbHQXPI8rQt9E9DNMyosg17pCC+dlPw==", + "license": "CC0-1.0", + "dependencies": { + "preciso": "^0.12.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -31687,6 +31715,14 @@ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "dev": true }, + "bbox-fns": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/bbox-fns/-/bbox-fns-0.20.2.tgz", + "integrity": "sha512-6DyKO3B6suAEducHcELF1cqdmlYj57zMTJim0X4RunclXOXE3wuTB8efm+I9dt2kXl+zPhFxAYSrRU6AUkHlwA==", + "requires": { + "preciso": "^0.12.2" + } + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -39817,6 +39853,11 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "preciso": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/preciso/-/preciso-0.12.2.tgz", + "integrity": "sha512-or/2I6/6VDMwJjJdFdyzVc/L1JT29DrlfA096iXBQWryL+ytEAfDgChI/tTnDXb58l5E/kIEa4Omg6AHFLmywA==" + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -40302,9 +40343,9 @@ } }, "regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "regenerator-transform": { "version": "0.15.1", @@ -41192,6 +41233,14 @@ "tslib": "^2.0.3" } }, + "snap-bbox": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/snap-bbox/-/snap-bbox-0.5.0.tgz", + "integrity": "sha512-UA7WegglBdWD/lYN0X1Zc+4uyXBYgGw9AKvL+lUNeP0S05zZE4TLMbcDbHQXPI8rQt9E9DNMyosg17pCC+dlPw==", + "requires": { + "preciso": "^0.12.0" + } + }, "sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", diff --git a/v3/package.json b/v3/package.json index 1cd1e54979..f1bb087f02 100644 --- a/v3/package.json +++ b/v3/package.json @@ -210,6 +210,7 @@ "@lezer/lr": "^1.4.2", "@nedb/binary-search-tree": "^2.1.5", "@uiw/react-codemirror": "^4.23.10", + "bbox-fns": "^0.20.2", "clsx": "^2.1.1", "colord": "^2.9.3", "create-react-class": "^15.7.0", @@ -238,8 +239,10 @@ "react-dom-factories": "^1.0.2", "react-leaflet": "^4.2.1", "react-resize-detector": "^12.0.2", + "regenerator-runtime": "^0.14.1", "rtree": "^1.4.2", "simpleheat": "^0.4.0", + "snap-bbox": "^0.5.0", "tslib": "^2.8.1", "type-fest": "^4.37.0", "use-debounce": "^10.0.4", diff --git a/v3/src/components/map/components/map-interior.tsx b/v3/src/components/map/components/map-interior.tsx index a156aef7cc..8892c8e104 100644 --- a/v3/src/components/map/components/map-interior.tsx +++ b/v3/src/components/map/components/map-interior.tsx @@ -12,6 +12,8 @@ import { DataConfigurationContext } from "../../data-display/hooks/use-data-conf import { useTileModelContext } from "../../../hooks/use-tile-model-context" import { isMapPinLayerModel } from "../models/map-pin-layer-model" import { MapPinLayer } from "./map-pin-layer" +import { createOrUpdateLeafletGeoRasterLayer } from "../utilities/georaster-utils" +import { mstAutorun } from "../../../utilities/mst-autorun" interface IProps { setPixiPointsLayer: (pixiPoints: PixiPoints, layerIndex: number) => void @@ -31,6 +33,16 @@ export const MapInterior = observer(function MapInterior({setPixiPointsLayer}: I } }, [tileTransitionComplete, mapModel]) + // Add or update the GeoRaster layer when URL changes + useEffect(() => { + return mstAutorun(() => { + // This reads the geoRaster.url from the model before going into an async task + // so changes to the geoRaster or its url will retrigger this autorun + createOrUpdateLeafletGeoRasterLayer(mapModel) + }, {name: "MapInterior.mstAutorun [createOrUpdateLeafletGeoRasterLayer]"}, mapModel) + }, [mapModel]) + + /** * Note that we don't have to worry about layer order because polygons will be sent to the back */ diff --git a/v3/src/components/map/map-component-handler.ts b/v3/src/components/map/map-component-handler.ts index 1d26594228..373a5c7d83 100644 --- a/v3/src/components/map/map-component-handler.ts +++ b/v3/src/components/map/map-component-handler.ts @@ -1,4 +1,5 @@ import { SetRequired } from "type-fest" +import { getSnapshot } from "mobx-state-tree" import { V2Map } from "../../data-interactive/data-interactive-component-types" import { DIValues } from "../../data-interactive/data-interactive-types" import { DIComponentHandler } from "../../data-interactive/handlers/component-handler" @@ -13,7 +14,7 @@ import { import { kMapTileType } from "./map-defs" import { kMapPointLayerType, kMapPolygonLayerType } from "./map-types" import { IMapBaseLayerModelSnapshot } from "./models/map-base-layer-model" -import { IMapModelContentSnapshot, isMapContentModel } from "./models/map-content-model" +import { GeoRasterModel, IMapModelContentSnapshot, isMapContentModel } from "./models/map-content-model" import { IMapPointLayerModelSnapshot } from "./models/map-point-layer-model" import { IMapPolygonLayerModelSnapshot } from "./models/map-polygon-layer-model" import { @@ -95,7 +96,7 @@ export const mapComponentHandler: DIComponentHandler = { if (!isMapContentModel(content)) return { success: false } // TODO: Finish implementing this function. See the Codap API docs for all properties that should be handled. - const { legendAttributeName, center: _center, zoom: _zoom } = values as V2Map + const { legendAttributeName, center: _center, zoom: _zoom, geoRaster } = values as V2Map const { dataConfiguration } = content const dataset = dataConfiguration?.dataset if (dataset && legendAttributeName != null) { @@ -110,6 +111,19 @@ export const mapComponentHandler: DIComponentHandler = { content.setCenterAndZoom(center, zoom) } + if (geoRaster !== undefined) { + // If the geoRaster is undefined that means the user didn't want to change it + // If the geoRaster is otherwise falsy (null, false, "", or 0) we remove it + if (!geoRaster) { + content.setGeoRaster(undefined) + } else { + const existingGeoRasterSnapshot = content.geoRaster ? getSnapshot(content.geoRaster) : {} + const newGeoRasterSnapshot = { ...existingGeoRasterSnapshot, ...geoRaster } + const geoRasterModel = GeoRasterModel.create(newGeoRasterSnapshot) + content.setGeoRaster(geoRasterModel) + } + } + return { success: true } } } diff --git a/v3/src/components/map/models/map-content-model.ts b/v3/src/components/map/models/map-content-model.ts index 09606bd89f..6364ba4a5b 100644 --- a/v3/src/components/map/models/map-content-model.ts +++ b/v3/src/components/map/models/map-content-model.ts @@ -26,6 +26,24 @@ import { isMapPinLayerModel, MapPinLayerModel } from "./map-pin-layer-model" import {isMapPointLayerModel, MapPointLayerModel} from "./map-point-layer-model" import {isMapPolygonLayerModel, MapPolygonLayerModel} from "./map-polygon-layer-model" +export const GeoRasterModel = types.model("GeoRasterModel", { + /** + * The image type of the raster. Currently only png is supported. + */ + type: types.enumeration(["png"]), + url: types.string, + opacity: types.optional(types.number, 0.5), + // In the future we may want to add additional properties: + // - bounds: the latitude/longitude bounds of the raster, + // currently it is assumed to be -90 to 90 latitude and -180 to 180 longitude + // - projection: the EPSG code of the projection of the image, + // currently it is assumed to be EPSG:4326 + // These are not added now because they aren't needed yet and would just complicate + // the code. +}) + +export interface IGeoRasterModel extends Instance {} + export const MapContentModel = DataDisplayContentModel .named(kMapModelName) .props({ @@ -42,6 +60,8 @@ export const MapContentModel = DataDisplayContentModel // Changes the visibility of the layer in Leaflet with the opacity parameter baseMapLayerIsVisible: true, plotBackgroundColor: '#FFFFFF01', + + geoRaster: types.maybe(GeoRasterModel), }) .volatile(() => ({ leafletMap: undefined as LeafletMap | undefined, @@ -111,6 +131,9 @@ export const MapContentModel = DataDisplayContentModel self.center = center self.zoom = zoom }, + setGeoRaster(geoRaster: Maybe) { + self.geoRaster = geoRaster + } })) // performs the specified action so that response actions are included and undo/redo strings assigned .actions(applyModelChange) diff --git a/v3/src/components/map/utilities/geo-extent.test.ts b/v3/src/components/map/utilities/geo-extent.test.ts new file mode 100644 index 0000000000..efc97c3361 --- /dev/null +++ b/v3/src/components/map/utilities/geo-extent.test.ts @@ -0,0 +1,249 @@ +import L from "leaflet" +import { GeoExtent } from "./geo-extent" + +describe("constructor", () => { + it("handles a leaflet bounds", () => { + const extent = new GeoExtent(L.latLngBounds([-85, -180], [85, 180])) + expect(extent.bbox).toEqual([-180, -85, 180, 85]) + }) +}) + +describe("reproj", () => { + it("handles basic reprojection", () => { + const extent = new GeoExtent([-180, -85, 180, 85], { srs: 4326 }) + const result = extent.reproj(3857).bbox + expect(result).toEqual([-20037508.342789244, -19971868.880408574, 20037508.342789244, 19971868.880408574]) + }) + + it("handles basic reprojection with string", () => { + const extent = new GeoExtent([-180, -85, 180, 85], { srs: 4326 }) + const result = extent.reproj("EPSG:3857").bbox + expect(result).toEqual([-20037508.342789244, -19971868.880408574, 20037508.342789244, 19971868.880408574]) + }) + + it("handles reverse reprojection with string", () => { + const extent = new GeoExtent( + [-20037508.342789244, -19971868.880408574, 20037508.342789244, 19971868.880408574], { srs: 3857 }) + const result = extent.reproj("EPSG:4326").bbox + expect(result).toEqual([-180, -85, 180, 85]) + }) + + it("throws error with unknown number code", () => { + const extent = new GeoExtent([-180, -85, 180, 85], { srs: 4326 }) + expect(() => { + extent.reproj(1234) + }).toThrow("Unsupported projection code: 1234") + }) + + it("throws error with unknown string code", () => { + const extent = new GeoExtent([-180, -85, 180, 85], { srs: 4326 }) + expect(() => { + extent.reproj("EPSG:1234") + }).toThrow("Invalid projection code: EPSG:1234") + }) + + it("handles north pole", () => { + const northPole = new GeoExtent([-180, 85, 180, 90], { srs: 4326 }) + const result = northPole.reproj(3857).bbox + // The ymin here is different from the original test from the geo-extent library + // Which expected it to be 49411788.9015311. This is probably due to how Leaflet handles + // re-projecting the north pole. + expect(result).toEqual([-20037508.342789244, 19971868.880408574, 20037508.342789244, 20037508.34278071]) + }) + + it("handles extent that crosses 180th meridian", () => { + // technically layer is supposed to be self-overlapping + const lyr = new GeoExtent([-180.00092731781535, 15.563268747733936, 179.99907268220255, 74.71076874773686], { + srs: 4326 + }) + const result = lyr.reproj(3857).bbox + // The geo-extent library would wrap this so the xmin and max values were the same value with different signs: + // -20037508.342789244 and 20037508.342789244 + // This updated library doesn't handle that wrapping, so the xmin is beyond the expected xmin value for + // a 3857 projection. + expect(result).toEqual([-20037611.57133625, 1754201.5427894332, 20037405.114244226, 12808999.953599941]) + }) +}) + +describe("contains", () => { + it("handles a mix of projections", () => { + const area = new GeoExtent([-1252344.2714243277, -7.081154551613622e-10, 0, 1252344.2714243277], { srs: 3857 }) + const globe = new GeoExtent([-180, -89.99928, 179.99856, 90], { srs: 4326 }) + expect(globe.contains(area)).toBe(true) + }) + + it("handles Continental USA in northern hemisphere", () => { + const usa = new GeoExtent([-125.248182, 25.241145, -65.308966, 49.092881], { srs: 4326 }) + const northernHemisphere = new GeoExtent([-180, 0, 180, 90], { srs: 4326 }) + expect(northernHemisphere.contains(usa)).toBe(true) + expect(usa.contains(northernHemisphere)).toBe(false) + }) +}) + +describe("unwrap", () => { + it("handles unnecessary call", () => { + const extent = new GeoExtent([-180, -90, 180, 90], { srs: 4326 }) + const unwrapped = extent.unwrap() + expect(unwrapped.length).toBe(1) + expect(unwrapped[0]).toStrictEqual(extent) + }) + + it("handles left overflow", () => { + const extent = new GeoExtent([-185, -85, 175, 90], { srs: 4326 }) + const unwrapped = extent.unwrap().map(ext => ext.bbox) + expect(unwrapped).toEqual([[-180, -85, 180, 90]]) + }) + + it("handles right overflow", () => { + const extent = new GeoExtent([175, -85, 185, 90], { srs: 4326 }) + const unwrapped = extent.unwrap().map(ext => ext.bbox) + expect(unwrapped).toEqual([ + [-180, -85, -175, 90], + [175, -85, 180, 90] + ]) + }) + + it("handles overflow on both sides", () => { + const extent = new GeoExtent([-190, -85, 190, 90], { srs: 4326 }) + const unwrapped = extent.unwrap().map(ext => ext.bbox) + expect(unwrapped.length).toBe(1) + expect(unwrapped[0]).toEqual([-180, -85, 180, 90]) + }) + + it("handles basic example", () => { + const extent = new GeoExtent([-230, 19, -155, 45], { srs: 4326 }) + const unwrapped = extent.unwrap().map(ext => ext.bbox) + expect(unwrapped).toEqual([ + [-180, 19, -155, 45], + [130, 19, 180, 45] + ]) + }) + + it("handles globe coverage", () => { + const extent = new GeoExtent([-185, -45, 175, 45], { srs: 4326 }) + const result = extent.unwrap() + expect(result.length).toBe(1) + expect(result[0].bbox).toEqual([-180, -45, 180, 45]) + expect(result[0].srs).toBe(4326) + }) + + it("handles globe coverage, overflowing right", () => { + const extent = new GeoExtent([-175, -45, 200, 45], { srs: 4326 }) + const result = extent.unwrap() + expect(result.length).toBe(1) + expect(result[0].bbox).toEqual([-180, -45, 180, 45]) + expect(result[0].srs).toBe(4326) + }) +}) + +describe("combine", () => { + it("combines southern and northern hemispheres", () => { + const ne = new GeoExtent([0, 0, 180, 90], { srs: 4326 }) + const sw = new GeoExtent([-180, -90, 0, 0], { srs: 4326 }) + const combinedBbox = [-180, -90, 180, 90] + expect(ne.combine(sw).bbox).toEqual(combinedBbox) + expect(sw.combine(ne).bbox).toEqual(combinedBbox) + }) +}) + +describe("crop", () => { + it("crops kenya by NE tile", () => { + const kenya = new GeoExtent([34.4282, -4.2367, 41.3861, 4.4296], { srs: 4326 }) + const tile = new GeoExtent([0, 0, 180, 90], { srs: 4326 }) + const result = kenya.crop(tile) + expect(result!.bbox).toEqual([34.4282, 0, 41.3861, 4.4296]) + }) + + it("crops with no overlap", () => { + const kenya = new GeoExtent([34.4282, -4.2367, 41.3861, 4.4296], { srs: 4326 }) + const nw = new GeoExtent([-180, 0, 0, 90], { srs: 4326 }) + const result = nw.crop(kenya) + expect(result).toBeNull() + }) + + it("crops with full containment", () => { + const usa = new GeoExtent([-125.248182, 25.241145, -65.308966, 49.092881], { srs: 4326 }) + const northernHemisphere = new GeoExtent([-180, 0, 180, 90], { srs: 4326 }) + const result = usa.crop(northernHemisphere) + expect(result!.bbox).toEqual([-125.248182, 25.241145, -65.308966, 49.092881]) + }) + + it("crops by left-overflow extent", () => { + const tile = new GeoExtent([-20037508.342789244, -20037508.342789255, 20037508.342789244, 20037508.342789244], { + srs: 3857 + }) + + const lyr = new GeoExtent([-180.00092731781535, 15.563268747733936, 0, 74.71076874773686], { + srs: 4326 + }) + + // Because lyr overflows the left side of the globe, it essentially crops a portion of the right side of the tile + // along with the left side of the tile up to the xmax of the layer. Then these two portions are combined to find + // their extent. The extent ends up having the full width of the globe, because of the combination of the two + // portions. + const result = tile.crop(lyr)! + expect(result.srs).toBe(3857) + expect(result.bbox).toEqual([-20037508.342789244, 1754201.5427894332, 20037508.342789244, 12808999.953599948]) + expect(result.width).toBe(40075016.68557849) + }) + + it("handles overflowing extent with no overlap", () => { + const tile = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + const lyr = new GeoExtent([-190, -10, -170, 10], { srs: 4326 }) + const result = tile.crop(lyr) + expect(result).toBeNull() + }) +}) + +describe("overlaps", () => { + it("returns true for overlapping extents", () => { + const extent1 = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + const extent2 = new GeoExtent([5, 5, 15, 15], { srs: 4326 }) + expect(extent1.overlaps(extent2)).toBe(true) + }) + + it("returns false for non-overlapping extents", () => { + const extent1 = new GeoExtent([-10, -10, -5, -5], { srs: 4326 }) + const extent2 = new GeoExtent([5, 5, 15, 15], { srs: 4326 }) + expect(extent1.overlaps(extent2)).toBe(false) + }) + + it("returns true for identical extents", () => { + const extent1 = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + const extent2 = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + expect(extent1.overlaps(extent2)).toBe(true) + }) + + it("returns true for partially overlapping extents", () => { + const extent1 = new GeoExtent([-10, -10, 0, 0], { srs: 4326 }) + const extent2 = new GeoExtent([-5, -5, 5, 5], { srs: 4326 }) + expect(extent1.overlaps(extent2)).toBe(true) + }) + + it("handles extents in different projections", () => { + const extent1 = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + const extent2 = new GeoExtent([-50, -50, 50, 50], { srs: 3857 }) + const extent3 = new GeoExtent([5_000_000, 5_000_000, 5_000_010, 5_000_010], { srs: 3857 }) + expect(extent1.overlaps(extent2)).toBe(true) + expect(extent1.overlaps(extent3)).toBe(false) + }) +}) + +describe("width and height", () => { + it("calculates width and height in EPSG:4326", () => { + const extent = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + expect(extent.width).toBe(20) + expect(extent.height).toBe(20) + }) +}) + +describe("leaflet bounds", () => { + it("converts to leaflet bounds", () => { + const extent = new GeoExtent([-10, -10, 10, 10], { srs: 4326 }) + const bounds = extent.leafletBounds + expect(bounds.getSouthWest().lat).toBe(-10) + expect(bounds.getSouthWest().lng).toBe(-10) + expect(bounds.getNorthEast().lat).toBe(10) + expect(bounds.getNorthEast().lng).toBe(10) + }) +}) diff --git a/v3/src/components/map/utilities/geo-extent.ts b/v3/src/components/map/utilities/geo-extent.ts new file mode 100644 index 0000000000..cc817df1a1 --- /dev/null +++ b/v3/src/components/map/utilities/geo-extent.ts @@ -0,0 +1,224 @@ +/* +This code was derived from https://github.com/DanielJDufour/geo-extent +It was updated to not use Proj4j +The original license is CC0 so we are free to relicense under our +default MIT license, and use it how we wish. +*/ +import * as L from "leaflet" +import unwrap from "bbox-fns/unwrap.js" + +function isFunc(o: any) { + return typeof o === "function" +} +function isObj(o: any) { + return o !== null && typeof o === "object" +} +function hasFunc(o: any, f: string) { + return isObj(o) && isFunc(o[f]) +} +function hasFuncs(o: any, fs: string[]) { + return fs.every(f => hasFunc(o, f)) +} +function isLeafletLatLngBounds(o: any): o is L.LatLngBounds { + return isObj(o) && hasFuncs(o, ["getEast", "getNorth", "getSouth", "getWest"]) +} + +export class GeoExtent { + xmin: number + xmax: number + ymin: number + ymax: number + srs: number + + constructor(bbox: [number, number, number, number], options: { srs: number }) + constructor(leafletBounds: L.LatLngBounds) + constructor(extent: GeoExtent) + constructor( + bboxOrExtentOrBounds: ([number, number, number, number]) | L.LatLngBounds | GeoExtent, + options?: { srs: number } + ) { + if (Array.isArray(bboxOrExtentOrBounds)) { + const [xmin, ymin, xmax, ymax] = bboxOrExtentOrBounds + this.xmin = xmin + this.xmax = xmax + this.ymin = ymin + this.ymax = ymax + this.srs = options!.srs + } else if (isLeafletLatLngBounds(bboxOrExtentOrBounds)) { + const bounds = bboxOrExtentOrBounds + this.xmin = bounds.getWest() + this.xmax = bounds.getEast() + this.ymin = bounds.getSouth() + this.ymax = bounds.getNorth() + this.srs = 4326 + } else { + const {xmin, xmax, ymin, ymax, srs} = bboxOrExtentOrBounds + this.xmin = xmin + this.xmax = xmax + this.ymin = ymin + this.ymax = ymax + this.srs = srs + } + } + + leafletProjection(code: number): L.Projection { + switch (code) { + case 4326: + return L.Projection.LonLat + case 3857: + return L.Projection.SphericalMercator + default: + throw new Error(`Unsupported projection code: ${code}`) + } + } + + numericProjectionCode(code: number | string): number { + if (typeof code === "number") { + return code + } + switch (code) { + case "EPSG:4326": + return 4326 + case "EPSG:3857": + return 3857 + default: + throw new Error(`Invalid projection code: ${code}`) + } + } + + reproj(code: number | string): GeoExtent { + const numCode = this.numericProjectionCode(code) + // Note: this will only be accurate for certain projections (like 4326 and 3857) + // To support other projections, we need to create a set of points on each edge + // of the bounding box and transform those points to the new projection + // The https://github.com/DanielJDufour/geo-extent library does this. + const points: L.Point[] = [ + L.point(this.xmin, this.ymin), + L.point(this.xmax, this.ymin), + L.point(this.xmax, this.ymax), + L.point(this.xmin, this.ymax) + ] + + const fromProjection = this.leafletProjection(this.srs) + const toProjection = this.leafletProjection(numCode) + const transformedPoints = points.map(point => { + const latLng = fromProjection.unproject(point) + return toProjection.project(latLng) + }) + const xmin = Math.min(...transformedPoints.map(p => p.x)) + const xmax = Math.max(...transformedPoints.map(p => p.x)) + const ymin = Math.min(...transformedPoints.map(p => p.y)) + const ymax = Math.max(...transformedPoints.map(p => p.y)) + + return new GeoExtent([xmin, ymin, xmax, ymax], { srs: numCode }) + } + + clone(): GeoExtent { + return new GeoExtent(this) + } + + contains(other: GeoExtent): boolean { + const otherInOurSrs = other.reproj(this.srs) + + // Check if this extent completely contains the other extent + return (this.xmin <= otherInOurSrs.xmin && this.xmax >= otherInOurSrs.xmax && + this.ymin <= otherInOurSrs.ymin && this.ymax >= otherInOurSrs.ymax) + } + + unwrap(): GeoExtent[] { + if (!this.shouldUnwrap()) { + return [this.clone()] + } + + const bboxes = unwrap(this.bbox, [-180, -90, 180, 90]) + + return bboxes.map((bbox: any) => new GeoExtent(bbox, { srs: 4326 })) + } + + combine(other: GeoExtent): GeoExtent { + const otherInOurSrs = other.reproj(this.srs) + + const xmin = Math.min(this.xmin, otherInOurSrs.xmin) + const xmax = Math.max(this.xmax, otherInOurSrs.xmax) + const ymin = Math.min(this.ymin, otherInOurSrs.ymin) + const ymax = Math.max(this.ymax, otherInOurSrs.ymax) + + return new GeoExtent([xmin, ymin, xmax, ymax], { srs: this.srs }) + } + + crop(other: GeoExtent): GeoExtent | null { + if (!this.overlaps(other)) { + // No overlap + return null + } + + if (other.contains(this)) { + // The other extent completely contains this extent + return this.clone() + } + + if (other.shouldUnwrap()) { + const parts = other.unwrap() + const croppedParts = parts + .map(part => this.crop(part)) + .filter(part => part !== null) + + // no overlap + if (croppedParts.length === 0) return null + + let combo = croppedParts[0] + for (let i = 1; i < croppedParts.length; i++) { + combo = combo.combine(croppedParts[i]) + } + return combo + } + + const another = this.srs === other.srs ? other.clone() : other.reproj(this.srs) + if (!this.overlaps(another)) { + // No overlap after reprojection + return null + } + const xmin = Math.max(this.xmin, another.xmin) + const ymin = Math.max(this.ymin, another.ymin) + const xmax = Math.min(this.xmax, another.xmax) + const ymax = Math.min(this.ymax, another.ymax) + return new GeoExtent([xmin, ymin, xmax, ymax], { srs: this.srs }) + } + + overlaps(other: GeoExtent): boolean { + const otherInOurSrs = other.reproj(this.srs) + + // Check if there is any overlap between this extent and the other extent + const yOverlaps = this.ymin <= otherInOurSrs.ymax && this.ymax >= otherInOurSrs.ymin + const xOverlaps = this.xmin <= otherInOurSrs.xmax && this.xmax >= otherInOurSrs.xmin + return xOverlaps && yOverlaps + } + + get width(): number { + return this.xmax - this.xmin + } + + get height(): number { + return this.ymax - this.ymin + } + + get bbox(): [number, number, number, number] { + return [this.xmin, this.ymin, this.xmax, this.ymax] + } + + get leafletBounds(): L.LatLngBounds { + const latLngExtent = this.reproj(4326) // Always project to 4326 for Leaflet bounds + + return L.latLngBounds( + L.latLng(latLngExtent.ymin, latLngExtent.xmin), + L.latLng(latLngExtent.ymax, latLngExtent.xmax) + ) + } + + /** + * Only unwrap extents in the 4326 projection that cross the dateline + */ + private shouldUnwrap(): boolean { + return this.srs === 4326 && (this.xmin < -180 || this.xmax > 180) + } +} diff --git a/v3/src/components/map/utilities/geo-image.ts b/v3/src/components/map/utilities/geo-image.ts new file mode 100644 index 0000000000..ea9c74c9cd --- /dev/null +++ b/v3/src/components/map/utilities/geo-image.ts @@ -0,0 +1,77 @@ +/** + * GeoImage represents a single geographic image and provides methods to process it. + */ +export class GeoImage { + // This has to be public so it can be asserted to be defined + img?: HTMLImageElement + private canvas?: HTMLCanvasElement + private ctx?: CanvasRenderingContext2D + private imageData?: Uint8ClampedArray + + /** + * Loads an image from a URL + * @param url - The URL to load the image from + * @returns Promise resolving to this GeoImage instance for chaining + */ + public async loadFromUrl(url: string): Promise { + return new Promise((resolve, reject) => { + this.img = new Image() + this.img.crossOrigin = "anonymous" // Required for canvas operations + this.img.onload = () => resolve(this) + this.img.onerror = () => reject(new Error(`Failed to load image ${url}`)) + this.img.src = url + }) + } + + public get width(): number { + this.makeSureImageIsReady() + return this.img.naturalWidth + } + + public get height(): number { + this.makeSureImageIsReady() + return this.img.naturalHeight + } + + public prepare() { + this.makeSureImageIsReady() + + // Create canvas and imageData on first use + if (!this.canvas || !this.ctx || !this.imageData) { + this.canvas = document.createElement("canvas") + this.canvas.width = this.img.naturalWidth + this.canvas.height = this.img.naturalHeight + const ctx = this.canvas.getContext("2d") + if (!ctx) { + throw new Error("Failed to get canvas context") + } + this.ctx = ctx + // Draw image to canvas only once + this.ctx.drawImage(this.img, 0, 0) + + this.imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height).data + } + + } + + public getColorAt(x: number, y: number) { + // Get pixel data + const start = (y * this.canvas!.width + x) * 4 + const { imageData } = this + if (!imageData) { + throw new Error("Image data not available, need to call prepare() first") + } + return `rgb(${imageData[start]},${imageData[start+1]},${imageData[start+2]})` + } + + private makeSureImageIsReady(): asserts this is { img: HTMLImageElement } { + if (!this.img) { + throw new Error("Image not loaded") + } + + // Ensure the image is loaded and has dimensions + if (!this.img.complete || !this.img.naturalWidth || !this.img.naturalHeight) { + throw new Error("Image not fully loaded or has invalid dimensions") + } + } +} diff --git a/v3/src/components/map/utilities/georaster-layer-for-leaflet-license.txt b/v3/src/components/map/utilities/georaster-layer-for-leaflet-license.txt new file mode 100644 index 0000000000..33ff863ea9 --- /dev/null +++ b/v3/src/components/map/utilities/georaster-layer-for-leaflet-license.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Daniel J. Dufour + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/v3/src/components/map/utilities/georaster-layer-for-leaflet.ts b/v3/src/components/map/utilities/georaster-layer-for-leaflet.ts new file mode 100644 index 0000000000..96a7b2b329 --- /dev/null +++ b/v3/src/components/map/utilities/georaster-layer-for-leaflet.ts @@ -0,0 +1,767 @@ +/* +This file is derived from the georaster-layer-for-leaflet package: +https://github.com/GeoTIFF/georaster-layer-for-leaflet +It has been modified quite a bit. However the logic of drawTile method is still +pretty close to the original. + +The license for the original code is Apache 2.0. +for the full text see: ./georaster-layer-for-leaflet-license.txt +*/ + +import "regenerator-runtime/runtime.js" +import * as L from "leaflet" +import type { Coords, DoneCallback, LatLngBounds } from "leaflet" +import { GeoExtent } from "./geo-extent" +import snap from "snap-bbox" + +import type { + CustomCSSStyleDeclaration, + GeoRasterLayerOptions, + GeoRaster, + GeoRasterKeys, + DrawTileOptions, + Tile, + DebugLevel, +} from "./georaster-types" + +const EPSG4326 = 4326 + +function log(...args: any[]) { + console.log("[georaster-layer-for-leaflet] ", ...args) +} + +if (!L) { + console.warn( + "[georaster-layer-for-leaflet] can't find Leaflet." + ) +} + +export class GeoRasterLayerClass extends L.GridLayer { + + // properties copied from the GeoRaster + height!: number + width!: number + noDataValue: GeoRaster["noDataValue"] + pixelHeight!: number + pixelWidth!: number + projection!: number + xmin!: number + xmax!: number + ymin!: number + ymax!: number + + // Other properties + extent!: GeoExtent + debugLevel!: DebugLevel + tileHeight!: number + tileWidth!: number + georaster!: GeoRaster + cache: Record = {} + xMinOfLayer!: number + xMaxOfLayer!: number + yMinOfLayer!: number + yMaxOfLayer!: number + + rawToRgb!: (values: number[]) => string + + // Options + options!: GeoRasterLayerOptions + + protected _cache!: {innerTile: Record, tile: Record} + + protected _bounds: LatLngBounds | undefined + + // This property is referenced but not defined by the Leaflet types. + // It is defined in the leaflet code though, and is used by the GridLayer implementation. + protected _globalTileRange!: L.Bounds + + initialize(options: GeoRasterLayerOptions) { + try { + this.georaster = options.georaster + + /* + Unpacking values for use later. + We do this in order to increase speed. + */ + const keys = [ + "height", + "width", + "noDataValue", + "pixelHeight", + "pixelWidth", + "projection", + "xmin", + "xmax", + "ymin", + "ymax" + ] as const + keys.forEach(key => { + (this as any)[key] = this.georaster[key] + }) + + this._cache = { + innerTile: {}, + tile: {} + } + + this.extent = new GeoExtent([this.xmin, this.ymin, this.xmax, this.ymax], { srs: this.projection }) + + this.debugLevel = options.debugLevel || 0 + if (this.debugLevel >= 1) log({ options }) + + // This might not be necessary. Leaflet has some special handling of options + L.Util.setOptions(this, options) + + /* + Caching the constant tile size, so we don't recalculate every time we + create a new tile + */ + const tileSize = this.getTileSize() + this.tileHeight = tileSize.y + this.tileWidth = tileSize.x + + } catch (error) { + console.error("ERROR initializing GeoTIFFLayer", error) + } + } + + onAdd(map: L.Map) { + if (!this.options.maxZoom) { + // maxZoom is needed to display the tiles in the correct order over the zIndex between the zoom levels + // https://github.com/Leaflet/Leaflet/blob/2592967aa6bd392db0db9e58dab840054e2aa291/src/layer/tile/GridLayer.js#L375C21-L375C21 + this.options.maxZoom = map.getMaxZoom() + } + + L.GridLayer.prototype.onAdd.call(this, map) + return this + } + + createTile(coords: Coords, done: DoneCallback) { + /* This tile is the square piece of the Leaflet map that we draw on */ + const tile = L.DomUtil.create("canvas", "leaflet-tile") + + // we do this because sometimes css normalizers will set * to box-sizing: border-box + tile.style.boxSizing = "content-box" + + // start tile hidden + tile.style.visibility = "hidden" + + const context = tile.getContext("2d") + + // note that we aren't setting the tile height or width here + // drawTile dynamically sets the width and padding based on + // how much the georaster takes up the tile area + const coordsKey = this._tileCoordsToKey(coords) + + const resolution = this._getResolution(coords.z) + + if (!context || resolution === undefined) { + done(new Error("Could not get canvas context or resolution is undefined"), tile) + return tile + } + + const key = `${coordsKey}:${resolution}` + const doneCb = (error?: Error, _tile?: HTMLElement): void => { + done(error, _tile) + + // caching the rendered tile, to skip the calculation for the next time + if (!error && this.options.caching && _tile) { + this.cache[key] = _tile + } + } + + if (this.options.caching && this.cache[key]) { + done(undefined, this.cache[key]) + return this.cache[key] + } else { + this.drawTile({ tile, coords, context, done: doneCb, resolution }) + } + + return tile + } + + drawTile({ tile, coords, context, done, resolution }: DrawTileOptions) { + try { + const { debugLevel = 0 } = this + + if (debugLevel >= 2) log("starting drawTile with", { tile, coords, context, done }) + + let error: Error + + const { z: zoom } = coords + + // stringified hash of tile coordinates for caching purposes + const cacheKey = [coords.x, coords.y, coords.z].join(",") + if (debugLevel >= 2) log({ cacheKey }) + + const mapCRS = this.getMapCRS() + if (debugLevel >= 2) log({ mapCRS }) + + const { xmin, xmax, ymin, ymax } = this + + const extentOfLayer = new GeoExtent(this.getBounds()) + if (debugLevel >= 2) log({ extentOfLayer }) + + const pixelHeight = this.pixelHeight + const pixelWidth = this.pixelWidth + if (debugLevel >= 2) log({ pixelHeight, pixelWidth }) + + // these values are used, so we don't try to sample outside of the raster + const { xMinOfLayer, xMaxOfLayer, yMinOfLayer, yMaxOfLayer } = this + const boundsOfTile = this._tileCoordsToBounds(coords) + if (debugLevel >= 2) log({ boundsOfTile }) + + const { code } = mapCRS + if (debugLevel >= 2) log({ code }) + const extentOfTile = new GeoExtent(boundsOfTile) + if (debugLevel >= 2) log({ extentOfTile }) + + // create blue outline around tiles + if (debugLevel >= 4) { + if (!this._cache.tile[cacheKey]) { + this._cache.tile[cacheKey] = L.rectangle(extentOfTile.leafletBounds, { fillOpacity: 0 }) + .addTo(this.getMap()) + .bindTooltip(cacheKey, { direction: "center", permanent: true }) + } + } + + // Types of extentOfTile.reproj() are messed up + // If we are not in a simple CRS, then the code of the CRS will be defined + const extentOfTileInMapCRS = extentOfTile.reproj(code!) + if (debugLevel >= 2) log({ extentOfTileInMapCRS }) + + let extentOfInnerTileInMapCRS = extentOfTileInMapCRS.crop(this.extent) + if (!extentOfInnerTileInMapCRS) { + error = new Error( + `[georaster-layer-for-leaflet] tile ${cacheKey} is outside of the extent of the layer` + ) + done(error) + return + } + if (debugLevel >= 2) { + log( + "extentOfInnerTileInMapCRS", + extentOfInnerTileInMapCRS.reproj(4326) + ) + } + if (debugLevel >= 2) log({ coords, extentOfInnerTileInMapCRS, extent: this.extent }) + + // create blue outline around tiles + if (debugLevel >= 4) { + if (!this._cache.innerTile[cacheKey]) { + this._cache.innerTile[cacheKey] = L.rectangle(extentOfInnerTileInMapCRS.leafletBounds, { + color: "#F00", + dashArray: "5, 10", + fillOpacity: 0 + }).addTo(this.getMap()) + } + } + + const widthOfScreenPixelInMapCRS = extentOfTileInMapCRS.width / this.tileWidth + const heightOfScreenPixelInMapCRS = extentOfTileInMapCRS.height / this.tileHeight + if (debugLevel >= 3) log({ heightOfScreenPixelInMapCRS, widthOfScreenPixelInMapCRS }) + + // expand tile sampling area to align with raster pixels + const oldExtentOfInnerTileInRasterCRS = extentOfInnerTileInMapCRS.reproj(this.projection) + const snapped = snap({ + bbox: oldExtentOfInnerTileInRasterCRS.bbox, + // pad xmax and ymin of container to tolerate ceil() and floor() in snap() + container: [xmin, ymin - 0.25 * pixelHeight, xmax + 0.25 * pixelWidth, ymax], + debug: debugLevel >= 2, + origin: [xmin, ymax], + scale: [pixelWidth, -pixelHeight] // negative because origin is at ymax + }) + const extentOfInnerTileInRasterCRS = new GeoExtent(snapped.bbox_in_coordinate_system, { + srs: this.projection + }) + + const gridbox = snapped.bbox_in_grid_cells + const snappedSamplesAcross = Math.abs(gridbox[2] - gridbox[0]) + const snappedSamplesDown = Math.abs(gridbox[3] - gridbox[1]) + const rasterPixelsAcross = Math.ceil(oldExtentOfInnerTileInRasterCRS.width / pixelWidth) + const rasterPixelsDown = Math.ceil(oldExtentOfInnerTileInRasterCRS.height / pixelHeight) + const layerCropExtent = this.extent + const recropTileOrig = oldExtentOfInnerTileInRasterCRS.crop(layerCropExtent) // may be null + let maxSamplesAcross = 1 + let maxSamplesDown = 1 + if (recropTileOrig !== null) { + const recropTileProj = recropTileOrig.reproj(code!) + const recropTile = recropTileProj.crop(extentOfTileInMapCRS) + if (recropTile !== null) { + maxSamplesAcross = Math.ceil(resolution * (recropTile.width / extentOfTileInMapCRS.width)) + maxSamplesDown = Math.ceil(resolution * (recropTile.height / extentOfTileInMapCRS.height)) + } + } + + const overdrawTileAcross = rasterPixelsAcross < maxSamplesAcross + const overdrawTileDown = rasterPixelsDown < maxSamplesDown + const numberOfSamplesAcross = overdrawTileAcross ? snappedSamplesAcross : maxSamplesAcross + const numberOfSamplesDown = overdrawTileDown ? snappedSamplesDown : maxSamplesDown + + if (debugLevel >= 3) { + log( + `extent of inner tile before snapping ${ + extentOfInnerTileInMapCRS.reproj(4326).bbox.toString()}` + ) + } + + // Reprojecting the bounding box back to the map CRS would expand it + // (unless the projection is purely scaling and translation), + // so instead just extend the old map bounding box proportionately. + { + const oldrb = new GeoExtent(oldExtentOfInnerTileInRasterCRS.bbox, { srs: 4326}) + const newrb = new GeoExtent(extentOfInnerTileInRasterCRS.bbox, { srs: 4326}) + const oldmb = new GeoExtent(extentOfInnerTileInMapCRS.bbox, { srs: 4326}) + if (oldrb.width !== 0 && oldrb.height !== 0) { + let n0 = ((newrb.xmin - oldrb.xmin) / oldrb.width) * oldmb.width + let n1 = ((newrb.ymin - oldrb.ymin) / oldrb.height) * oldmb.height + let n2 = ((newrb.xmax - oldrb.xmax) / oldrb.width) * oldmb.width + let n3 = ((newrb.ymax - oldrb.ymax) / oldrb.height) * oldmb.height + if (!overdrawTileAcross) { + n0 = Math.max(n0, 0) + n2 = Math.min(n2, 0) + } + if (!overdrawTileDown) { + n1 = Math.max(n1, 0) + n3 = Math.min(n3, 0) + } + const newbox = [ + oldmb.xmin + n0, oldmb.ymin + n1, oldmb.xmax + n2, oldmb.ymax + n3 + ] as [number, number, number, number] + extentOfInnerTileInMapCRS = new GeoExtent(newbox, { srs: extentOfInnerTileInMapCRS.srs }) + } + } + + // create outline around raster pixels + if (debugLevel >= 4) { + if (!this._cache.innerTile[cacheKey]) { + this._cache.innerTile[cacheKey] = L.rectangle(extentOfInnerTileInMapCRS.leafletBounds, { + color: "#F00", + dashArray: "5, 10", + fillOpacity: 0 + }).addTo(this.getMap()) + } + } + + if (debugLevel >= 3) { + log( + `extent of inner tile after snapping ${ + extentOfInnerTileInMapCRS.reproj(4326).bbox.toString()}` + ) + } + + // Note that the snapped "inner" tile may extend beyond the original tile, + // in which case the padding values will be negative. + + // we round here because sometimes there will be slight floating arithmetic issues + // where the padding is like 0.00000000000001 + const padding = { + left: Math.round((extentOfInnerTileInMapCRS.xmin - extentOfTileInMapCRS.xmin) / widthOfScreenPixelInMapCRS), + right: Math.round((extentOfTileInMapCRS.xmax - extentOfInnerTileInMapCRS.xmax) / widthOfScreenPixelInMapCRS), + top: Math.round((extentOfTileInMapCRS.ymax - extentOfInnerTileInMapCRS.ymax) / heightOfScreenPixelInMapCRS), + bottom: Math.round((extentOfInnerTileInMapCRS.ymin - extentOfTileInMapCRS.ymin) / heightOfScreenPixelInMapCRS) + } + if (debugLevel >= 3) log({ padding }) + + const innerTileHeight = this.tileHeight - padding.top - padding.bottom + const innerTileWidth = this.tileWidth - padding.left - padding.right + if (debugLevel >= 3) log({ innerTileHeight, innerTileWidth }) + + if (debugLevel >= 4) { + const xMinOfInnerTileInMapCRS = extentOfTileInMapCRS.xmin + padding.left * widthOfScreenPixelInMapCRS + const yMinOfInnerTileInMapCRS = extentOfTileInMapCRS.ymin + padding.bottom * heightOfScreenPixelInMapCRS + const xMaxOfInnerTileInMapCRS = extentOfTileInMapCRS.xmax - padding.right * widthOfScreenPixelInMapCRS + const yMaxOfInnerTileInMapCRS = extentOfTileInMapCRS.ymax - padding.top * heightOfScreenPixelInMapCRS + log({ xMinOfInnerTileInMapCRS, yMinOfInnerTileInMapCRS, xMaxOfInnerTileInMapCRS, yMaxOfInnerTileInMapCRS }) + } + + const canvasPadding = { + left: Math.max(padding.left, 0), + right: Math.max(padding.right, 0), + top: Math.max(padding.top, 0), + bottom: Math.max(padding.bottom, 0) + } + const canvasHeight = this.tileHeight - canvasPadding.top - canvasPadding.bottom + const canvasWidth = this.tileWidth - canvasPadding.left - canvasPadding.right + + // set padding and size of canvas tile + tile.style.paddingTop = `${canvasPadding.top }px` + tile.style.paddingRight = `${canvasPadding.right }px` + tile.style.paddingBottom = `${canvasPadding.bottom }px` + tile.style.paddingLeft = `${canvasPadding.left }px` + + // Only set the height and width if they are different, otherwise the canvas will be cleared + if (tile.height !== canvasHeight) { + tile.height = canvasHeight + } + tile.style.height = `${canvasHeight }px` + + if (tile.width !== canvasWidth) { + tile.width = canvasWidth + } + tile.style.width = `${canvasWidth }px` + if (debugLevel >= 3) log(`setting tile height to ${ canvasHeight }px`) + if (debugLevel >= 3) log(`setting tile width to ${ canvasWidth }px`) + + // set how large to display each sample in screen pixels + const heightOfSampleInScreenPixels = innerTileHeight / numberOfSamplesDown + const heightOfSampleInScreenPixelsInt = Math.ceil(heightOfSampleInScreenPixels) + const widthOfSampleInScreenPixels = innerTileWidth / 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) + if (debugLevel >= 4) log({ tileNwPoint }) + const xLeftOfInnerTile = tileNwPoint.x + padding.left + const yTopOfInnerTile = tileNwPoint.y + padding.top + const innerTileTopLeftPoint = { x: xLeftOfInnerTile, y: yTopOfInnerTile } + if (debugLevel >= 4) log({ innerTileTopLeftPoint }) + + // render asynchronously so tiles show up as they finish instead of all at once (which blocks the UI) + setTimeout(async () => { + try { + this.georaster.image.prepare() + + // NOTE: This might be able to be optimized further by copying the from the source image into + // the canvas with ctx.drawImage instead of fillRect + // Perhaps that won't be faster due to the overhead of the drawImage. + // An additional optimization because of the projections we are supporting, is to copy + // each horizontal row of raster pixels at the same time using drawImage. + for (let h = 0; h < numberOfSamplesDown; h++) { + const yCenterInMapPixels = yTopOfInnerTile + (h + 0.5) * heightOfSampleInScreenPixels + const latWestPoint = L.point(xLeftOfInnerTile, yCenterInMapPixels) + const { lat } = map.unproject(latWestPoint, zoom) + if (lat > yMinOfLayer && lat < yMaxOfLayer) { + const yInTilePixels = Math.round(h * heightOfSampleInScreenPixels) + Math.min(padding.top, 0) + + let yInRasterPixels = 0 + if (this.projection === EPSG4326) { + yInRasterPixels = Math.floor((yMaxOfLayer - lat) / pixelHeight) + } + + for (let w = 0; w < numberOfSamplesAcross; w++) { + const latLngPoint = L.point( + xLeftOfInnerTile + (w + 0.5) * widthOfSampleInScreenPixels, + yCenterInMapPixels + ) + const { lng: xOfLayer } = map.unproject(latLngPoint, zoom) + if (xOfLayer > xMinOfLayer && xOfLayer < xMaxOfLayer) { + let xInRasterPixels = 0 + if (this.projection === EPSG4326) { + xInRasterPixels = Math.floor((xOfLayer - xMinOfLayer) / pixelWidth) + } else { + throw new Error( + `[georaster-layer-for-leaflet] projection ${this.projection} is not supported`) + } + + // x-axis coordinate of the starting point of the rectangle representing the raster pixel + const x = Math.round(w * widthOfSampleInScreenPixels) + Math.min(padding.left, 0) + + // 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 + + const color = this.georaster.image.getColorAt(xInRasterPixels, yInRasterPixels) + if (color && context) { + context.fillStyle = color + context.fillRect(x, y, width, height) + } + } + } + } + } + + tile.style.visibility = "visible" // set to default + } catch (e: any) { + console.error(e) + error = e + } + done && done(error, tile) + }, 0) + + // return the tile so it can be rendered on screen + return tile + } catch (error: any) { + console.error(error) + done && done(error, tile) + } + } + + // copied from Leaflet with slight modifications, + // including removing the lines that set the tile size + _initTile (tile: HTMLCanvasElement) { + L.DomUtil.addClass(tile, "leaflet-tile") + + tile.onselectstart = L.Util.falseFn + tile.onmousemove = L.Util.falseFn + + // update opacity on tiles in IE7-8 because of filter inheritance problems + if (this.options.opacity !== undefined && L.Browser.ielt9 && this.options.opacity < 1) { + L.DomUtil.setOpacity(tile, this.options.opacity) + } + + // without this hack, tiles disappear after zoom on Chrome for Android + // https://github.com/Leaflet/Leaflet/issues/2078 + if (L.Browser.android && !L.Browser.android23) { + (tile.style).WebkitBackfaceVisibility = "hidden" + } + } + + // method from https://github.com/Leaflet/Leaflet/blob/bb1d94ac7f2716852213dd11563d89855f8d6bb1/src/layer/ImageOverlay.js + getBounds () { + this.initBounds() + // initBounds will throw an error if it can't initialize the bounds + return this._bounds! + } + + getMap () { + // This _mapToAdd property is not defined by typescript, but perhaps it is used + // by some versions of Leaflet? + return this._map || (this as any)._mapToAdd + } + + getMapCRS () { + return this.getMap()?.options.crs || L.CRS.EPSG3857 + } + + // add in to ensure backwards compatability with Leaflet 1.0.3 + _tileCoordsToNwSe (coords: Coords) { + const map = this.getMap() + // This normalizes the tileSize option passed to the layer into a point + // So typically a point with x and y equal to 256. + const tileSize = this.getTileSize() + // This multiplies the x and y coordinates of the tile by the tileSize + const nwPoint = coords.scaleBy(tileSize) + const sePoint = nwPoint.add(tileSize) + // Convert the pixel coordinates at this zoom level to geographical coordinates + const nw = map.unproject(nwPoint, coords.z) + const se = map.unproject(sePoint, coords.z) + return [nw, se] + } + + /** + * Lat Lng bounds of the tile at these coordinates, the bounds are wrapped if necessary + * @param coords + * @returns + */ + _tileCoordsToBounds (coords: Coords) { + const [nw, se] = this._tileCoordsToNwSe(coords) + let bounds: LatLngBounds = new L.LatLngBounds(nw, se) + + if (!this.options.noWrap) { + const crs = this.getMapCRS() + // In the types wrapLatLngBounds is defined on the L.Map class, there is a wrapLatLng defined + // on the CRS interface just not a wrapLatLngBounds. + bounds = (crs as any).wrapLatLngBounds(bounds) as LatLngBounds + } + return bounds + } + + _isValidTile (coords: Coords) { + const crs = this.getMapCRS() + + // This first part is copied from _isValidTile method in GridLayer + 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 + } + // End of copied part + + const { x, y, z } = coords + + const layerExtent = new GeoExtent(bounds) + + const boundsOfTile = this._tileCoordsToBounds(coords) + + // check given tile coordinates + // boundsOfTile is a LatLngBounds object, the types of GeoExtent only + // alow GeoExtent objects, however the code appears to allow LatLngBounds objects too + if (layerExtent.overlaps(new GeoExtent(boundsOfTile))) return true + + // 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) as Coords + leftCoords.z = z + const leftBounds = this._tileCoordsToBounds(leftCoords) + if (layerExtent.overlaps(new GeoExtent(leftBounds))) return true + + // check one world to the right + const rightCoords = L.point(x + width, y) as Coords + rightCoords.z = z + const rightBounds = this._tileCoordsToBounds(rightCoords) + if (layerExtent.overlaps(new GeoExtent(rightBounds))) return true + + return false + } + + getTiles(): Tile[] { + // transform _tiles object collection into an array + // assume the _tiles all all of our own tiles which means their elements are HTMLCanvasElements + return Object.values(this._tiles) as Tile[] + } + + getActiveTiles(): Tile[] { + const tiles: Tile[] = this.getTiles() + // only return valid tiles + return tiles.filter(tile => this._isValidTile(tile.coords)) + } + + isSupportedProjection () { + return this.projection === EPSG4326 + } + + initBounds (options?: GeoRasterLayerOptions) { + if (!options) options = this.options + if (!this._bounds) { + const { debugLevel, projection, xmin, xmax, ymin, ymax } = this + if (projection === EPSG4326) { + if (debugLevel >= 1) 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 { + throw new Error(`No support for rasters with the projection ${projection}.`) + } + + const bounds = this._bounds + // these values are used so we don't try to sample outside of the raster + this.xMinOfLayer = bounds.getWest() + this.xMaxOfLayer = bounds.getEast() + this.yMaxOfLayer = bounds.getNorth() + this.yMinOfLayer = bounds.getSouth() + + options.bounds = this._bounds + } + } + + same(array: GeoRaster[], key: GeoRasterKeys) { + return new Set(array.map(item => item[key])).size === 1 + } + + clearCache() { + this.cache = {} + } + + _getResolution(zoom: number) { + const { resolution } = this.options + + let resolutionValue: number | undefined + if (typeof resolution === "object") { + const zoomLevels = Object.keys(resolution) + + for (const key in zoomLevels) { + if (Object.prototype.hasOwnProperty.call(zoomLevels, key)) { + const zoomLvl = parseInt(zoomLevels[key], 10) + if (zoomLvl <= zoom) { + resolutionValue = resolution[zoomLvl] + } else { + break + } + } + } + } else { + resolutionValue = resolution + } + + return resolutionValue + } + + /** + * Update the GeoRaster of the layer and redraw the active tiles. + * If the new GeoRaster has incompatible dimensions or opacity, it will not be updated. + * If the GeoRaster can't be updated we return false. + * + * @param georaster + * @param opacity + * @returns + */ + updateGeoraster(georaster: GeoRaster, opacity: number): boolean { + // Make sure this is a simple update + if ( + this.georaster.width !== georaster.width || + this.georaster.height !== georaster.height || + this.georaster.pixelWidth !== georaster.pixelWidth || + this.georaster.pixelHeight !== georaster.pixelHeight || + this.options.opacity !== opacity + ) { + // The new GeoRaster is not compatible with this layer + return false + } + + this.georaster = georaster + + const tiles = this.getActiveTiles() + if (!tiles) { + console.error("No active tiles available") + // We did update the georaster, but we can't redraw the tiles + return true + } + + // We clear the cache so that the tiles all the way down will be redrawn with the new georaster + this.clearCache() + + tiles.forEach((tile: any) => { + const { coords, el } = tile + const wrappedCoords = this._wrapCoords(coords) + const resolution = this._getResolution(wrappedCoords.z) + if (resolution === undefined) { + console.error("Could not get resolution for tile") + return + } + + const done = () => {} + this.drawTile({ tile: el, coords: wrappedCoords, resolution, context: el.getContext("2d"), done }) + }) + + return true + } +} + +// We define the properties in the GeoRasterLayerClass so we can use Typescript's checking of this +// and automatically declaration of the method signature so they can be used +// by other methods of the class. +// Then we extract the properties and create an object which is what we pass +// to Leaflet's L.GridLayer.extend method. +// This should mean Leaflet's class system works as expected. +const properties = Object.getOwnPropertyNames(GeoRasterLayerClass.prototype) + .filter(name => name !== "constructor") // Exclude the constructor + .reduce((acc, propertyName) => { + acc[propertyName] = GeoRasterLayerClass.prototype[propertyName as keyof GeoRasterLayerClass] + return acc + }, {} as Record) + +const GeoRasterLayer: (new (options: GeoRasterLayerOptions) => any) & typeof L.Class = L.GridLayer.extend({ + ...properties, + + options: { + updateWhenIdle: true, + updateWhenZooming: false, + keepBuffer: 25, + resolution: 2 ** 5, + debugLevel: 0, + caching: true + }, +}) + +export default GeoRasterLayer diff --git a/v3/src/components/map/utilities/georaster-types/declarations.d.ts b/v3/src/components/map/utilities/georaster-types/declarations.d.ts new file mode 100644 index 0000000000..1dd23ec690 --- /dev/null +++ b/v3/src/components/map/utilities/georaster-types/declarations.d.ts @@ -0,0 +1,2 @@ +declare module "snap-bbox"; +declare module "bbox-fns/unwrap.js"; diff --git a/v3/src/components/map/utilities/georaster-types/index.ts b/v3/src/components/map/utilities/georaster-types/index.ts new file mode 100644 index 0000000000..016fc02d86 --- /dev/null +++ b/v3/src/components/map/utilities/georaster-types/index.ts @@ -0,0 +1,50 @@ +import type { GridLayerOptions, Coords, DoneCallback, LatLngBounds } from "leaflet" +import { GeoImage } from "../geo-image" + +export type DebugLevel = 0 | 1 | 2 | 3 | 4 | 5 + +export interface GeoRasterLayerOptions extends GridLayerOptions { + resolution?: number | { [key: number]: number }; + debugLevel?: DebugLevel; + bounds?: LatLngBounds; + caching?: boolean; + georaster: GeoRaster; +} + +export interface DrawTileOptions { + tile: HTMLCanvasElement; + coords: Coords; + context: CanvasRenderingContext2D; + done: DoneCallback; + resolution: number; +} + +// note: Tile is taken from leaflets `InternalTiles` type and should not be modified. - SFR 2021-01-19 +export type Tile = { + active?: boolean; + coords: Coords; + current: boolean; + el: HTMLCanvasElement; + loaded?: Date; + retain?: boolean; +} + +export type GeoRasterKeys = keyof GeoRaster + +export interface GeoRaster { + height: number; + noDataValue: null | undefined | number | typeof NaN; + pixelHeight: number; + pixelWidth: number; + projection: number; + image: GeoImage; + width: number; + xmax: number; + xmin: number; + ymax: number; + ymin: number; +} + +export interface CustomCSSStyleDeclaration extends CSSStyleDeclaration { + WebkitBackfaceVisibility?: string +} diff --git a/v3/src/components/map/utilities/georaster-utils.ts b/v3/src/components/map/utilities/georaster-utils.ts new file mode 100644 index 0000000000..22bcf06564 --- /dev/null +++ b/v3/src/components/map/utilities/georaster-utils.ts @@ -0,0 +1,151 @@ +import { IMapContentModel } from "../models/map-content-model" +import GeoRasterLayer, { GeoRasterLayerClass } from "./georaster-layer-for-leaflet" +import { GeoImage } from './geo-image' +import { GeoRaster } from "./georaster-types" + +async function getGeoRaster(mapModel: IMapContentModel) { + const url = mapModel.geoRaster?.url + if (!url) { + console.error("No URL provided for georaster") + return + } + try { + const geoImage = new GeoImage() + await geoImage.loadFromUrl(url) + if (url !== mapModel.geoRaster?.url) { + // The URL has changed since we started the fetch. + return + } + + const { width, height } = geoImage + + // assume the image goes from x -180 to -180 y -90 to 90 + const xmin = -180 + const xmax = 180 + const ymin = -90 + const ymax = 90 + + const xRange = xmax - xmin + + // Calculate the pixelSize in degrees. + const pixelSize = xRange/width + + const geoRaster: GeoRaster = { + pixelWidth: pixelSize, + pixelHeight: pixelSize, + width, + height, + noDataValue: 0, + image: geoImage, + xmin, + ymin, + xmax, + ymax, + projection: 4326, + } + + return geoRaster + } catch (error) { + console.error("Error fetching and processing geo raster", error) + } +} + +function findGeoRasterLayer(mapModel: IMapContentModel) { + if (!mapModel.leafletMap) { + return + } + + // Find the current layer if it exists + // And also clean up any extra geo raster layers + let currentLayer: GeoRasterLayerClass | undefined = undefined + mapModel.leafletMap.eachLayer((existingLayer) => { + // We need to remove the existing layer if it is a georaster layer + // This is a bit of a hack. It isn't clear how to tell the type of a layer. + if ("georaster" in existingLayer) { + if (!currentLayer) { + currentLayer = existingLayer as GeoRasterLayerClass + return + } + + // We've found an extra layer, remove it + existingLayer.remove() + } + }) + + return currentLayer as GeoRasterLayerClass | undefined +} + +/** + * Creates or updates a GeoRaster layer. If the url in the model changes while + * it is being processed, the function will return undefined. + * + * @returns A promise that resolves to a Leaflet layer or undefined if the there's an error + */ +export async function createOrUpdateLeafletGeoRasterLayer(mapModel: IMapContentModel) { + try { + + if (!mapModel.leafletMap) { + return + } + + if (!mapModel.geoRaster) { + // Remove the layer if it exists + const existingLayer = findGeoRasterLayer(mapModel) + if (existingLayer) { + existingLayer.remove() + } + return + } + + const { url, opacity } = mapModel.geoRaster + + const georaster = await getGeoRaster(mapModel) + if (!georaster) { + // The georaster could not be created perhaps because the URL changed + return + } + + if (url !== mapModel.geoRaster?.url) { + // The URL has changed since we started getting the geoRaster. + // Bail out, so we don't take time away from processing the new one. + return + } + + // Find the current layer if it exists + // We search for the layer here after the getGeoRaster call incase the layers were changed + // while we were waiting for the georaster to be created. + let currentLayer = findGeoRasterLayer(mapModel) + + if (currentLayer) { + if (currentLayer.updateGeoraster(georaster, opacity)) { + // We were able to update the existing layer with the new GeoRaster + return + } + + // The layer is not compatible with the new GeoRaster, so remove it + currentLayer.remove() + currentLayer = undefined + } + + const layer = new GeoRasterLayer({ + georaster, + // Add to the overlay pane so it is on top of the base map but below the + // the other layers that CODAP adds. + pane: "overlayPane", + opacity: mapModel.geoRaster?.opacity ?? 0.5, + // This is how detailed the georaster should be projected on to each Leaflet tile + // Most tiles are 256x256 some are 512x512. Using 256 shows the squares of the + // georaster nicely. It might be OK to go down to 128 or even 64. + resolution: 256, + // Uncomment to get more information about the georaster rendering process + // debugLevel: 2, + debugLevel: 1, + // Disable caching to see if the map updates + caching: false, + }) + layer.addTo(mapModel.leafletMap) + + } catch (error) { + console.error("Error initializing GeoRasterLayer", error) + } +} diff --git a/v3/src/data-interactive/data-interactive-component-types.ts b/v3/src/data-interactive/data-interactive-component-types.ts index 3d2f511a86..22e7e5f473 100644 --- a/v3/src/data-interactive/data-interactive-component-types.ts +++ b/v3/src/data-interactive/data-interactive-component-types.ts @@ -127,12 +127,19 @@ export interface V2Guide extends V2Component { items?: V2GuidePage[] type: "guideView" } + +export interface V2MapGeoRaster { + type: string + url: string + opacity?: number +} export interface V2Map extends V2Component { center?: [number, number] dataContext?: string legendAttributeName?: string type: "map" zoom?: number + geoRaster?: V2MapGeoRaster } // This really isn't the v2 form of the Slider component, that is