├── stac-layer.gif ├── babel.config.json ├── test ├── stac-item │ ├── cogs │ │ ├── California-Vegetation-CanopyBaseHeight-2016-Summer-00010m.png │ │ ├── thumb.html │ │ ├── cog-1.html │ │ ├── cog-no-cors.html │ │ ├── cog-selected-asset.html │ │ ├── cog-selected-asset-object.html │ │ └── cog-no-cors-overview.html │ ├── opentopography.html │ ├── usgs-lidar.html │ ├── pdd.html │ ├── skrafotos.html │ ├── pdd-nodata.html │ ├── pangeo-sea-surface.html │ ├── swiss-federal-sdi.html │ ├── astrogeology.html │ ├── minmax.html │ ├── landsat-2-cogs.html │ ├── digital-earth-au-local-2.html │ ├── digital-earth-au-local-3.html │ ├── digital-earth-au.html │ ├── geotiff-default.html │ └── planetary-computer.html ├── tests.js ├── stac-api-collections │ └── gee.html ├── stac-collection │ ├── swiss-federal-sdi.html │ ├── pdd.html │ ├── gee-landsat-8.html │ ├── gee-aafc-aci-display-preview.html │ ├── preview-one-bbox.html │ ├── gee-aafc-aci.html │ ├── antimeridian.html │ └── collection-assets.html ├── stac-catalog │ └── pdd.html ├── stac-api-items │ ├── dxf.html │ ├── swiss-federal-sdi.html │ ├── cogs_with_overview_no_bbox.html │ └── cogs_with_overview.html ├── stac-item-asset │ ├── no-cors.html │ ├── no-bounds.html │ ├── pdd │ │ ├── udm.html │ │ ├── visual.html │ │ ├── thumbnail.html │ │ ├── visual-bands-reversed.html │ │ ├── analytic-bands.html │ │ ├── one-band.html │ │ └── analytic-georaster-band-math.html │ ├── bands.html │ └── relative-asset.html └── tilers │ ├── titiler │ ├── pdd.html │ ├── cbers.html │ ├── cog-no-cors-with-tile-url-template.html │ └── build-tile-url-template.html │ └── marblecutter │ ├── pdd.html │ ├── bug.html │ └── one-band.html ├── data └── setup.sh ├── DEVELOPMENT.md ├── src ├── utils │ ├── bbox-to-bounds.js │ ├── get-bounds.js │ ├── with-timeout.js │ ├── image-overlay.js │ ├── tile-layer.js │ ├── parse-alphas.js │ └── create-georaster-layer.js ├── events.js ├── index.js └── add.js ├── RELEASE.md ├── webpack.config.cjs ├── .gitignore ├── package.json ├── LICENSE └── README.md /stac-layer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-layer/HEAD/stac-layer.gif -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": "3" 8 | } 9 | ] 10 | ] 11 | } -------------------------------------------------------------------------------- /test/stac-item/cogs/California-Vegetation-CanopyBaseHeight-2016-Summer-00010m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stac-utils/stac-layer/HEAD/test/stac-item/cogs/California-Vegetation-CanopyBaseHeight-2016-Summer-00010m.png -------------------------------------------------------------------------------- /data/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # download from https://github.com/GeoTIFF/test-data/ 4 | wget https://github.com/GeoTIFF/test-data/archive/refs/heads/main.zip -O geotiff-test-data.zip 5 | unzip -j -o geotiff-test-data.zip "test-data-*/files/*" -d . 6 | rm geotiff-test-data.zip 7 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | 1. Install dependencies: `npm install` 4 | 2. Run the dev server: `npm run dev` 5 | 3. Run the tests: 6 | - Display links to tests: `npm test` 7 | - Open all tests in your Browser: `npm test -- open` 8 | - Open tests from a specific folder (e.g. `stac-item`) in your Browser: `npm test -- open stac-item` -------------------------------------------------------------------------------- /src/utils/bbox-to-bounds.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Number[]} bbox 4 | * @description convert bounding box in format [xmin, ymin, xmax, ymax] to Leaflet Bounds 5 | * @returns 6 | */ 7 | export default function bboxToLatLngBounds(bbox) { 8 | const [xmin, ymin, xmax, ymax] = bbox; 9 | const southWest = [ymin, xmin]; 10 | const northEast = [ymax, xmax]; 11 | return L.latLngBounds(southWest, northEast); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/get-bounds.js: -------------------------------------------------------------------------------- 1 | import { STACReference } from "stac-js"; 2 | import { isBoundingBox } from "stac-js/src/geo.js"; 3 | import bboxToLatLngBounds from "./bbox-to-bounds.js"; 4 | 5 | export default function getBounds(object, options) { 6 | if (object instanceof STACReference && object.getContext()) { 7 | let bbox = object.getContext().getBoundingBox(); 8 | if (isBoundingBox(bbox)) { 9 | return bboxToLatLngBounds(bbox); 10 | } 11 | } 12 | 13 | if (options.latLngBounds) { 14 | return options.latLngBounds; 15 | } else if (isBoundingBox(options.bbox)) { 16 | return bboxToLatLngBounds(options.bbox); 17 | } 18 | return null; 19 | } 20 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | import open from 'open'; 2 | import { globSync } from 'glob'; 3 | 4 | const args = process.argv.slice(2); 5 | const testFolders = ['item-collection', 'stac-api-items', 'stac-catalog', 'stac-item', 'stac-item-asset', 'tilers/marblecutter', 'tilers/titiler']; 6 | 7 | const openInBrowser = args.includes("open"); 8 | const folder = args.find(arg => testFolders.includes(arg)) || '**'; 9 | 10 | const files = globSync(`test/${folder}/*.html`, {}); 11 | files.forEach(file => { 12 | file = file.replaceAll('\\', '/'); 13 | let url = `http://localhost:8080/${file}`; 14 | if (openInBrowser) { 15 | open(url); 16 | } 17 | else { 18 | console.log(url); 19 | } 20 | }); -------------------------------------------------------------------------------- /src/utils/with-timeout.js: -------------------------------------------------------------------------------- 1 | // wraps a function or promise in a timeout 2 | export const TIMEOUT = 5 * 1000; 3 | 4 | export default function withTimeout(ms, promiseOrFunction) { 5 | return new Promise((resolve, reject) => { 6 | let timeout = setTimeout(() => reject("timed out"), ms); 7 | let promise; 8 | if ("then" in promiseOrFunction) { 9 | promise = promiseOrFunction; 10 | } else { 11 | promise = Promise.resolve(promiseOrFunction()); 12 | } 13 | promise 14 | .then(result => { 15 | clearTimeout(timeout); 16 | resolve(result); 17 | }) 18 | .catch(error => { 19 | clearTimeout(timeout); 20 | reject(error); 21 | }); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/image-overlay.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | import loadImage from "easy-image-loader"; 3 | import { TIMEOUT } from "./with-timeout.js"; 4 | 5 | // pratically identical to L.imageOverlay 6 | // with the following exceptions: 7 | // (1) it is async and returns a promise 8 | // (2) if there is any error, the returned promise resolves to null 9 | export default async function imageOverlay(url, bounds, crossOrigin, options) { 10 | try { 11 | let img = null; 12 | try { 13 | img = await loadImage(url, { crossOrigin, TIMEOUT }); 14 | } catch { 15 | return null; 16 | } 17 | const lyr = L.imageOverlay(url, bounds, options); 18 | return lyr; 19 | } catch { 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/tile-layer.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | import tilebelt from "@mapbox/tilebelt"; 3 | import loadImage from "easy-image-loader"; 4 | import { TIMEOUT } from "./with-timeout.js"; 5 | 6 | // pratically identical to L.tileLayer 7 | // with the following exceptions: 8 | // (1) it is async and returns a promise 9 | // (2) rejects the promise if there is an issue loading the image 10 | // (3) rejects the promise if it takes more than 5 seconds for the image to load 11 | // (4) rejects the promise if attempt to fetch a test tile fails 12 | export default async function tileLayer(tileUrlTemplate, bounds, options = {}) { 13 | const lyr = L.tileLayer(tileUrlTemplate, options); 14 | 15 | // if know layer bounds, send a request for center of the layer at zoom level 10 16 | if (bounds) { 17 | const center = bounds.getCenter(); 18 | const tile = tilebelt.pointToTile(center.lng, center.lat, 10); 19 | const [x, y, z] = tile; 20 | const tileURL = L.Util.template(tileUrlTemplate, { s: options.subdomains?.[0], x, y, z, ...options }); 21 | 22 | // will throw an error if it fails 23 | await loadImage(tileURL, { debug: false, timeout: TIMEOUT }); 24 | } 25 | return lyr; 26 | } 27 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | **this is a living document and should be updated as new steps are discovered** 3 | 4 | 1. Checkout main branch 5 | 2. Pull down most recent changes to main 6 | 3. Run `pnpm update` to see if there are any new dependency updates. (And then install any new version of depdencies) 7 | 4. Run rm -fr node_modules to clean your node_modules folder 8 | 5. Run `npm install` to make sure we are installing with the latest 9 | 6. Run `npm run format` to make sure everything is using the correct format. Sometimes code can be merged without being formatted first. Or sometimes the formatting rules change after a code merge. 10 | 7. Run `npm run build` to get the newest build in the dist folder. (Running npm run dev might not re-build until code is changed) 11 | 8. Run npm run dev, go to chrome, open up dev tools console, and navigate manually to each test in the test folder (and subfolders). If you want to be thorough and check tiling tests, you can run `npm run tiler` and check the tiler tests. 12 | 9. Run npm run build again (just in case) 13 | 10. Run `npx pkg-ok` to make sure there aren't files in your package.json files property array that don't exist 14 | 11. Run `np` to publish a new version (https://github.com/sindresorhus/np) 15 | -------------------------------------------------------------------------------- /test/stac-api-collections/gee.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 38 | 39 | -------------------------------------------------------------------------------- /src/utils/parse-alphas.js: -------------------------------------------------------------------------------- 1 | function range(count) { 2 | return new Array(count).fill(null).map((_, i) => i); 3 | } 4 | 5 | export default async function parseAlphas(georaster) { 6 | const geotiff = georaster._geotiff; 7 | const image = await geotiff.getImage(); 8 | 9 | let { BitsPerSample, ExtraSamples, SampleFormat } = image.fileDirectory; 10 | if (!SampleFormat) SampleFormat = new Array(BitsPerSample.length).fill(1); 11 | 12 | const bands = range(BitsPerSample.length).map(i => { 13 | const sFormat = SampleFormat[i]; 14 | const nbits = BitsPerSample[i]; 15 | 16 | let int, min, range; 17 | if (sFormat === 1) { 18 | // unsigned integer data 19 | int = true; 20 | min = 0; 21 | const max = Math.pow(2, nbits) - 1; 22 | range = max - min; 23 | } else if (sFormat === 2) { 24 | // two's complement signed integer data 25 | min = -1 * Math.pow(2, nbits - 1); 26 | const max = Math.pow(2, nbits - 1) - 1; 27 | range = max - min; 28 | } else if (sFormat === 3) { 29 | // IEEE floating point data 30 | } else if (sFormat === 4) { 31 | // undefined data format 32 | } 33 | 34 | return [i, { int, min, range }]; 35 | }); 36 | const extra = ExtraSamples ? ExtraSamples.length : 0; 37 | const alphas = bands.slice(bands.length - extra); 38 | 39 | return Object.fromEntries(alphas); 40 | } 41 | -------------------------------------------------------------------------------- /test/stac-item/opentopography.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /test/stac-item/usgs-lidar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /test/stac-collection/swiss-federal-sdi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /test/stac-catalog/pdd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 40 | 41 | -------------------------------------------------------------------------------- /test/stac-item/cogs/thumb.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /test/stac-item/pdd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const envisage = require("envisage"); 2 | const path = require("path"); 3 | 4 | const config = { 5 | entry: "./src/index.js", 6 | mode: "production", 7 | target: "web", 8 | output: { 9 | filename: "stac-layer.min.js", 10 | path: path.resolve(__dirname, "dist"), 11 | library: { 12 | export: "default", 13 | name: "STACLayer", 14 | type: "umd" 15 | } 16 | }, 17 | devtool: "source-map", 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(ts|js)x?$/, 22 | use: { 23 | loader: "babel-loader", 24 | options: { 25 | "presets": [ 26 | [ 27 | "@babel/preset-env", 28 | { 29 | "targets": { 30 | "ie": 11 31 | } 32 | } 33 | ] 34 | ], 35 | "plugins": [ 36 | "@babel/plugin-proposal-nullish-coalescing-operator", 37 | "@babel/plugin-proposal-optional-chaining" 38 | ] 39 | } 40 | } 41 | } 42 | ].filter(Boolean) 43 | }, 44 | resolve: { 45 | modules: ["node_modules"] 46 | }, 47 | externals: { 48 | leaflet: { 49 | root: "L", 50 | commonjs: "leaflet", 51 | amd: "leaflet", 52 | commonjs2: "leaflet" 53 | } 54 | } 55 | }; 56 | 57 | envisage.assign({ target: config, prefix: "WEBPACK" }); 58 | 59 | module.exports = config; 60 | -------------------------------------------------------------------------------- /test/stac-collection/pdd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /test/stac-api-items/dxf.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 44 | 45 | -------------------------------------------------------------------------------- /test/stac-item/skrafotos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 38 | 39 | -------------------------------------------------------------------------------- /test/stac-collection/gee-landsat-8.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /test/stac-item-asset/no-cors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 47 | 48 | -------------------------------------------------------------------------------- /test/stac-item/cogs/cog-1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 40 | 41 | -------------------------------------------------------------------------------- /test/stac-api-items/swiss-federal-sdi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 45 | 46 | -------------------------------------------------------------------------------- /test/stac-collection/gee-aafc-aci-display-preview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 46 | 47 | -------------------------------------------------------------------------------- /test/stac-item/pdd-nodata.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 40 | 41 | -------------------------------------------------------------------------------- /test/stac-item/pangeo-sea-surface.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 41 | 42 | -------------------------------------------------------------------------------- /test/stac-item/cogs/cog-no-cors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 42 | 43 | -------------------------------------------------------------------------------- /test/stac-collection/preview-one-bbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 47 | 48 | -------------------------------------------------------------------------------- /test/stac-item-asset/no-bounds.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 46 | 47 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/udm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 42 | 43 | -------------------------------------------------------------------------------- /test/stac-item/cogs/cog-selected-asset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /test/tilers/titiler/pdd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 41 | 42 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/visual.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 44 | 45 | -------------------------------------------------------------------------------- /test/tilers/titiler/cbers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /test/stac-collection/gee-aafc-aci.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 44 | 45 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/thumbnail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 42 | 43 | -------------------------------------------------------------------------------- /test/tilers/marblecutter/pdd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /test/stac-item/cogs/cog-selected-asset-object.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /test/stac-item/swiss-federal-sdi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 43 | 44 | -------------------------------------------------------------------------------- /src/utils/create-georaster-layer.js: -------------------------------------------------------------------------------- 1 | import parseGeoRaster from "georaster"; 2 | import GeoRasterLayer from "georaster-layer-for-leaflet"; 3 | import get_epsg_code from "geotiff-epsg-code"; 4 | import withTimeout, { TIMEOUT } from "./with-timeout.js"; 5 | 6 | export default function createGeoRasterLayer(asset, options) { 7 | return withTimeout(TIMEOUT, async () => { 8 | const georaster = await parseGeoRaster(asset.getAbsoluteUrl()); 9 | 10 | // Handle no-data values 11 | let noDataValues = asset.getNoDataValues(); 12 | if (noDataValues.length > 0) { 13 | georaster.noDataValue = noDataValues[0]; 14 | } 15 | 16 | if ([undefined, null, "", 32767].includes(georaster.projection) && georaster._geotiff) { 17 | georaster.projection = await get_epsg_code(georaster._geotiff); 18 | } 19 | 20 | const layer = new GeoRasterLayer({ georaster, ...options }); 21 | 22 | let mins = []; 23 | let maxs = []; 24 | let ranges = []; 25 | for (let i = 0; i < georaster.numberOfRasters; i++) { 26 | let { minimum, maximum } = asset.getMinMaxValues(i); 27 | mins.push(minimum); 28 | maxs.push(maximum); 29 | ranges.push(maximum - minimum); 30 | } 31 | if (mins.every(min => min !== null) && maxs.every(max => max !== null)) { 32 | layer.currentStats = { mins, maxs, ranges }; 33 | layer.calcStats = false; 34 | } else if (Array.isArray(options.bands) && options.bands.length >= 1 && options.bands.length <= 4) { 35 | // hack to force GeoRasterLayer to calculate statistics 36 | layer.calcStats = true; 37 | } 38 | 39 | return layer; 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /test/stac-item/astrogeology.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 44 | 45 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/visual-bands-reversed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 45 | 46 | -------------------------------------------------------------------------------- /test/stac-item-asset/bands.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 48 | 49 | -------------------------------------------------------------------------------- /test/tilers/titiler/cog-no-cors-with-tile-url-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 44 | 45 | -------------------------------------------------------------------------------- /test/stac-item/minmax.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 48 | 49 | -------------------------------------------------------------------------------- /test/stac-item/landsat-2-cogs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 46 | 47 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/analytic-bands.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 47 | 48 | -------------------------------------------------------------------------------- /test/stac-collection/antimeridian.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 53 | 54 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/one-band.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 48 | 49 | -------------------------------------------------------------------------------- /test/tilers/marblecutter/bug.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 49 | 50 | -------------------------------------------------------------------------------- /test/stac-item/digital-earth-au-local-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 50 | 51 | -------------------------------------------------------------------------------- /test/stac-item/digital-earth-au-local-3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 50 | 51 | -------------------------------------------------------------------------------- /test/stac-item/cogs/cog-no-cors-overview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 49 | 50 | -------------------------------------------------------------------------------- /test/stac-item/digital-earth-au.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 47 | 48 | -------------------------------------------------------------------------------- /test/stac-api-items/cogs_with_overview_no_bbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 48 | 49 | -------------------------------------------------------------------------------- /test/tilers/marblecutter/one-band.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 51 | 52 | -------------------------------------------------------------------------------- /test/stac-item-asset/pdd/analytic-georaster-band-math.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 49 | 50 | -------------------------------------------------------------------------------- /test/stac-api-items/cogs_with_overview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /test/tilers/titiler/build-tile-url-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 53 | 54 | -------------------------------------------------------------------------------- /test/stac-collection/collection-assets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 64 | 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Backup files 107 | *bak* 108 | 109 | # Dev files 110 | build/ 111 | 112 | # Notes 113 | *.txt 114 | 115 | # other files 116 | .DS_Store 117 | 118 | # lock files 119 | pnpm-lock.yaml 120 | .pnpm-store/ 121 | data/*.cog* 122 | data/*.tif* 123 | /package-lock.json 124 | -------------------------------------------------------------------------------- /test/stac-item-asset/relative-asset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stac-layer", 3 | "version": "1.0.0-beta.1", 4 | "description": "Visualize a STAC Item or Collection on a Leaflet Map", 5 | "type": "module", 6 | "main": "./src/index.js", 7 | "browser": "./dist/stac-layer.min.js", 8 | "module": "./src/index.js", 9 | "exports": "./src/index.js", 10 | "directories": { 11 | "test": "test" 12 | }, 13 | "files": [ 14 | "babel.config.json", 15 | "src/", 16 | "dist/stac-layer.min.js", 17 | "dist/stac-layer.min.js.map" 18 | ], 19 | "scripts": { 20 | "build": "webpack", 21 | "dev": "concurrently \"webpack --watch\" \"npm run serve\"", 22 | "format": "npx prettier --arrow-parens=avoid --print-width=120 --trailing-comma=none --write src/*.js src/*/*.js", 23 | "test": "node test/tests.js", 24 | "serve": "npx srvd --debug --wait=Infinity", 25 | "setup": "cd data && bash ./setup.sh", 26 | "tiler": "docker run --name titiler -p 8000:8000 --env PORT=8000 --rm -t developmentseed/titiler" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/stac-utils/stac-layer.git" 31 | }, 32 | "keywords": [ 33 | "cog", 34 | "geojson", 35 | "geotiff", 36 | "jpg", 37 | "png", 38 | "shapefile", 39 | "stac" 40 | ], 41 | "author": "Daniel J. Dufour", 42 | "license": "CC0-1.0", 43 | "bugs": { 44 | "url": "https://github.com/stac-utils/stac-layer/issues" 45 | }, 46 | "homepage": "https://github.com/stac-utils/stac-layer#readme", 47 | "dependencies": { 48 | "@mapbox/tilebelt": "^1.0.2", 49 | "@turf/boolean-point-in-polygon": "^6.5.0", 50 | "easy-image-loader": "^0.1.0", 51 | "georaster": "^1.5.5", 52 | "georaster-layer-for-leaflet": "^3.10.0", 53 | "geotiff-epsg-code": "^0.3.1", 54 | "leaflet": "^1.8.0", 55 | "reproject-bbox": "^0.12.0", 56 | "stac-js": "0.0.8" 57 | }, 58 | "devDependencies": { 59 | "@babel/cli": "^7.17.10", 60 | "@babel/core": "^7.18.2", 61 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", 62 | "@babel/plugin-proposal-optional-chaining": "^7.14.5", 63 | "@babel/preset-env": "^7.18.2", 64 | "babel-loader": "^8.2.2", 65 | "concurrently": "^7.3.0", 66 | "envisage": "^0.1.0", 67 | "eslint-config-prettier": "^8.3.0", 68 | "glob": "^9.1.2", 69 | "http-server": "^14.1.0", 70 | "open": "^8.4.2", 71 | "webpack": "^5.42.0", 72 | "webpack-cli": "^4.9.2" 73 | }, 74 | "browserslist": [ 75 | "> 1%", 76 | "last 2 versions", 77 | "not dead" 78 | ], 79 | "engines": { 80 | "node": "^18.0.0" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/stac-item/geotiff-default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /src/events.js: -------------------------------------------------------------------------------- 1 | import booleanPointInPolygon from "@turf/boolean-point-in-polygon"; 2 | import { APICollection, STACReference } from "stac-js"; 3 | 4 | const eventHandlers = { 5 | loaded: [], 6 | fallback: [], 7 | click: [], 8 | imageLayerAdded: [] 9 | }; 10 | const queue = []; 11 | let logLevel = 0; 12 | 13 | export function enableLogging(level) { 14 | logLevel = typeof level === "number" && level > 0 ? level : 0; 15 | } 16 | 17 | export function log(level, ...args) { 18 | if (logLevel >= level) { 19 | let method = args.some(e => e instanceof Error) ? "error" : "log"; 20 | console[method]("[stac-layer]", ...args); 21 | } 22 | } 23 | 24 | export function logPromise(error) { 25 | return log(1, error); 26 | } 27 | 28 | export function registerEvents(layerGroup) { 29 | layerGroup.on2 = layerGroup.on; 30 | layerGroup.on = function (name, callback) { 31 | if (name in eventHandlers) { 32 | eventHandlers[name].push(callback); 33 | return this; 34 | } else if (this.on2) { 35 | return this.on2(...arguments); 36 | } 37 | }; 38 | } 39 | 40 | export function flushEventQueue() { 41 | while (queue.length > 0) { 42 | let evt = queue.shift(); 43 | triggerEvent(evt.name, evt.data); 44 | } 45 | } 46 | 47 | // some events are sent before the you can react on it 48 | // make a queue and only trigger once added to map 49 | export function triggerEvent(name, data, layerGroup = null) { 50 | // Layer has not been added to map yet, queue events for later 51 | if (layerGroup && layerGroup.orphan) { 52 | queue.push({ name, data }); 53 | return; 54 | } 55 | eventHandlers[name].forEach(callback => { 56 | try { 57 | callback(data); 58 | } catch (error) { 59 | log(1, error); 60 | } 61 | }); 62 | } 63 | 64 | // sets up generic onClick event where a "stac" key is added to the event object 65 | // and is set to the provided data or the data used to create stacLayer 66 | export function onLayerGroupClick(event, layerGroup) { 67 | let list = layerGroup 68 | // Get all layers 69 | .getLayers() 70 | // Expand/Reduce the list to contain all STAC entities 71 | .reduce((layers, layer) => { 72 | let stac = layer.stac; 73 | if (!stac || layer === layerGroup.footprintLayer) { 74 | return layers; 75 | } 76 | if (stac instanceof APICollection) { 77 | stac.getAll().forEach(obj => layers.add(obj)); 78 | } else { 79 | if (stac instanceof STACReference) { 80 | stac = stac.getContext(); 81 | } 82 | if (stac) { 83 | layers.add(stac); 84 | } 85 | } 86 | return layers; 87 | }, new Set()); 88 | // Keep only STAC entities for which the click point is inside the geojson 89 | list = [...list].filter((stac, i) => { 90 | try { 91 | const geojson = stac.toGeoJSON(); 92 | const point = [event.latlng.lng, event.latlng.lat]; 93 | return booleanPointInPolygon(point, geojson); 94 | } catch (error) { 95 | console.log(error); 96 | } 97 | }); 98 | 99 | if (list.length > 0) { 100 | event.stac = list; 101 | triggerEvent("click", event, layerGroup); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/stac-item/planetary-computer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 39 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | 3 | import { default as createStacObject, STAC, Asset, Catalog } from "stac-js"; 4 | import { toAbsolute } from "stac-js/src/http.js"; 5 | import { enableLogging, flushEventQueue, log, logPromise, registerEvents, triggerEvent } from "./events.js"; 6 | import { addAsset, addDefaultGeoTiff, addFootprintLayer, addLayer, addThumbnails } from "./add.js"; 7 | import { isBoundingBox } from "stac-js/src/geo.js"; 8 | 9 | // Data must be: Catalog, Collection, Item, API Items, or API Collections 10 | const stacLayer = async (data, options = {}) => { 11 | if (!data) { 12 | throw new Error("No data provided"); 13 | } 14 | 15 | options = Object.assign( 16 | { 17 | // defaults: 18 | displayGeoTiffByDefault: false, 19 | displayPreview: false, 20 | displayOverview: true, 21 | debugLevel: 0, 22 | resolution: 32, 23 | useTileLayerAsFallback: false, 24 | collectionStyle: {}, 25 | boundsStyle: {} 26 | }, 27 | options 28 | ); // shallow clone options 29 | 30 | enableLogging(options.debugLevel); 31 | 32 | log(1, "starting"); 33 | 34 | // Deprecated: 35 | // Allow just passing assets in data as before 36 | if ("href" in data) { 37 | options.assets = [data]; 38 | data = {}; // This will result in an empty Catalog 39 | } else if (Array.isArray(data) && data.every(asset => "href" in asset)) { 40 | options.assets = data; 41 | data = {}; // This will result in an empty Catalog 42 | } 43 | 44 | // Convert to stac-js and set baseUrl 45 | if (!(data instanceof STAC)) { 46 | data = createStacObject(data); 47 | if (options.baseUrl) { 48 | data.setAbsoluteUrl(options.baseUrl); 49 | } 50 | } 51 | log(2, "data:", data); 52 | log(2, "url:", data.getAbsoluteUrl()); 53 | 54 | if (data instanceof Catalog) { 55 | log(1, "Catalogs don't have spatial information, you may see an empty map"); 56 | } 57 | 58 | // Tile layer preferences 59 | options.useTileLayer = options.tileUrlTemplate || options.buildTileUrlTemplate; 60 | options.preferTileLayer = (options.useTileLayer && !options.useTileLayerAsFallback) || false; 61 | log(2, "preferTileLayer:", options.preferTileLayer); 62 | 63 | if (options.bbox && !isBoundingBox(options.bbox)) { 64 | log(1, "The provided bbox is invalid"); 65 | } 66 | 67 | // Handle assets 68 | if (typeof options.assets === "string") { 69 | options.assets = [options.assets]; 70 | } 71 | options.assets = (options.assets || []) 72 | .map(asset => { 73 | const original = asset; 74 | if (typeof asset === "string") { 75 | asset = data.getAsset(asset); 76 | if (!(asset instanceof Asset)) { 77 | log(1, "can't find asset with the given key:", original); 78 | } 79 | return asset; 80 | } 81 | if (!(asset instanceof Asset)) { 82 | return new Asset(asset, toAbsolute(asset.href, data.getAbsoluteUrl()), data); 83 | } 84 | log(1, "invalid asset provided:", original); 85 | return null; 86 | }) 87 | .filter(asset => asset instanceof Asset); 88 | 89 | // Compose a view for multi-bands 90 | if (Array.isArray(options.bands) && options.bands.length >= 1 && options.bands.length <= 4) { 91 | if (options.bands.length === 1) { 92 | let [g] = options.bands; 93 | options.bands = [g, g, g]; 94 | } else if (options.bands.length === 2) { 95 | let [g, a] = options.bands; 96 | options.bands = [g, g, g, a]; 97 | } 98 | 99 | options.pixelValuesToColorFn = values => { 100 | const { mins, maxs, ranges } = options.currentStats; 101 | const fitted = values.map((v, i) => { 102 | if (options.alphas[i]) { 103 | const { int, min, range } = options.alphas[i]; 104 | if (int) { 105 | return Math.round((255 * (v - min)) / range); 106 | } else { 107 | const currentMin = Math.min(v, mins[i]); 108 | const currentMax = Math.max(v, maxs[i]); 109 | if (currentMin >= 0 && currentMax <= 1) { 110 | return Math.round(255 * v); 111 | } else if (currentMin >= 0 && currentMax <= 100) { 112 | return Math.round((255 * v) / 100); 113 | } else if (currentMin >= 0 && currentMax <= 255) { 114 | return Math.round(v); 115 | } else if (currentMin === currentMax) { 116 | return 255; 117 | } else { 118 | return Math.round((255 * (v - Math.min(v, min))) / range); 119 | } 120 | } 121 | } else { 122 | return Math.round((255 * (v - Math.min(v, mins[i]))) / ranges[i]); 123 | } 124 | }); 125 | const mapped = options.bands.map(bandIndex => fitted[bandIndex]); 126 | const [r, g, b, a = 255] = mapped; 127 | return `rgba(${r},${g},${b},${a / 255})`; 128 | }; 129 | } 130 | 131 | options.collectionStyle = Object.assign({}, options.collectionStyle, { fillOpacity: 0, weight: 1, color: "#ff8833" }); 132 | 133 | log(2, "options:", options); 134 | 135 | // Create the layer group that we add all layers to 136 | const layerGroup = L.layerGroup(); 137 | if (!layerGroup.options) layerGroup.options = {}; 138 | layerGroup.options.debugLevel = options.debugLevel; 139 | layerGroup.orphan = true; 140 | registerEvents(layerGroup); 141 | 142 | let promises = []; 143 | 144 | if (data.isCollectionCollection() || data.isItemCollection()) { 145 | const layer = L.geoJSON(data.toGeoJSON(), options.collectionStyle); 146 | promises = data.getAll().map(obj => { 147 | return addThumbnails(obj, layerGroup, options) 148 | .then(layer => { 149 | if (!layer) { 150 | return addDefaultGeoTiff(obj, layerGroup, options).catch(logPromise); 151 | } 152 | }) 153 | .catch(logPromise); 154 | }); 155 | addLayer(layer, layerGroup, data); 156 | } else if (data.isItem() || data.isCollection() || options.assets.length > 0) { 157 | if (options.assets.length > 0) { 158 | log(2, "number of assets in options:", options.assets.length); 159 | promises = options.assets.map(asset => addAsset(asset, layerGroup, options).catch(logPromise)); 160 | } else { 161 | // No specific asset given by the user, visualize the default geotiff 162 | promises.push( 163 | addDefaultGeoTiff(data, layerGroup, options) 164 | .then(layer => { 165 | if (!layer) { 166 | return addThumbnails(data, layerGroup, options).catch(logPromise); 167 | } 168 | }) 169 | .catch(logPromise) 170 | ); 171 | } 172 | } 173 | 174 | addFootprintLayer(data, layerGroup, options); 175 | 176 | // use the extent of the vector layer 177 | layerGroup.getBounds = () => { 178 | const lyr = layerGroup.getLayers().find(lyr => lyr.toGeoJSON); 179 | if (!lyr) { 180 | log( 181 | 1, 182 | "unable to get bounds without a vector layer. This often happens when there was an issue determining the bounding box of the provided data." 183 | ); 184 | return; 185 | } 186 | const bounds = lyr.getBounds(); 187 | const southWest = [bounds.getSouth(), bounds.getWest()]; 188 | const northEast = [bounds.getNorth(), bounds.getEast()]; 189 | return [southWest, northEast]; 190 | }; 191 | layerGroup.bringToFront = () => layerGroup.getLayers().forEach(layer => layer.bringToFront()); 192 | layerGroup.bringToBack = () => layerGroup.getLayers().forEach(layer => layer.bringToBack()); 193 | 194 | layerGroup.on("add", () => { 195 | layerGroup.orphan = false; 196 | flushEventQueue(); 197 | }); 198 | layerGroup.on("remove", () => (layerGroup.orphan = true)); 199 | 200 | const result = Promise.all(promises) 201 | .then(() => triggerEvent("loaded", { data }, layerGroup)) 202 | .catch(logPromise); 203 | 204 | if (!layerGroup.footprintLayer) { 205 | await result; 206 | } 207 | 208 | return layerGroup; 209 | }; 210 | 211 | L.stacLayer = stacLayer; 212 | 213 | export default stacLayer; 214 | -------------------------------------------------------------------------------- /src/add.js: -------------------------------------------------------------------------------- 1 | import reprojectBoundingBox from "reproject-bbox"; 2 | import { log, onLayerGroupClick, triggerEvent } from "./events.js"; 3 | import imageOverlay from "./utils/image-overlay.js"; 4 | import tileLayer from "./utils/tile-layer.js"; 5 | import createGeoRasterLayer from "./utils/create-georaster-layer.js"; 6 | import getBounds from "./utils/get-bounds.js"; 7 | import parseAlphas from "./utils/parse-alphas.js"; 8 | import { toGeoJSON } from "stac-js/src/geo.js"; 9 | import { CollectionCollection, ItemCollection, STAC } from "stac-js"; 10 | import withTimeout, { TIMEOUT } from "./utils/with-timeout.js"; 11 | 12 | export function addLayer(layer, layerGroup, data) { 13 | layer.stac = data; 14 | layer.on("click", evt => onLayerGroupClick(evt, layerGroup)); 15 | layerGroup.addLayer(layer); 16 | } 17 | 18 | function getGeoJson(data, options) { 19 | // Add the geometry/bbox 20 | let geojson = null; 21 | if (data instanceof ItemCollection || data instanceof CollectionCollection) { 22 | geojson = toGeoJSON(data.getBoundingBox()); 23 | } else if (data instanceof STAC) { 24 | geojson = data.toGeoJSON(); 25 | } 26 | if (!geojson) { 27 | const bounds = getBounds(data, options); 28 | log(2, "No geojson found for footprint, falling back to bbox if available", bounds); 29 | if (bounds) { 30 | const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]; 31 | geojson = toGeoJSON(bbox); 32 | } 33 | } 34 | return geojson; 35 | } 36 | 37 | export function addFootprintLayer(data, layerGroup, options) { 38 | let geojson = getGeoJson(data, options); 39 | if (geojson) { 40 | log(1, "adding footprint layer"); 41 | const layer = L.geoJSON(geojson); 42 | addLayer(layer, layerGroup, data); 43 | layerGroup.footprintLayer = layer; 44 | setFootprintLayerStyle(layerGroup, options); 45 | layerGroup.on("imageLayerAdded", () => setFootprintLayerStyle(layerGroup, options)); 46 | return layer; 47 | } 48 | return null; 49 | } 50 | 51 | export function setFootprintLayerStyle(layerGroup, options) { 52 | let style = {}; 53 | if (layerGroup.getLayers().length > 1) { 54 | style.fillOpacity = 0; 55 | } 56 | style = Object.assign({}, options.boundsStyle, style); 57 | layerGroup.footprintLayer.setStyle(style); 58 | } 59 | 60 | export async function addTileLayer(asset, layerGroup, options) { 61 | try { 62 | log(2, "add tile layer", asset); 63 | const href = asset.getAbsoluteUrl(); 64 | const key = asset.getKey(); 65 | const bounds = getBounds(asset, options); 66 | if (options.buildTileUrlTemplate) { 67 | const tileUrlTemplate = await options.buildTileUrlTemplate({ 68 | href, 69 | url: href, 70 | asset, 71 | key, 72 | stac: asset.getContext(), 73 | bounds, 74 | isCOG: asset.isCOG() 75 | }); 76 | log(2, `built tile url template: "${tileUrlTemplate}"`); 77 | const tileLayerOptions = { ...options, url: href }; 78 | const layer = await tileLayer(tileUrlTemplate, bounds, tileLayerOptions); 79 | addLayer(layer, layerGroup, asset); 80 | triggerEvent("imageLayerAdded", { type: "tilelayer", layer, asset }, layerGroup); 81 | return layer; 82 | } else if (options.tileUrlTemplate) { 83 | const tileLayerOptions = { ...options, url: encodeURIComponent(href) }; 84 | const layer = await tileLayer(options.tileUrlTemplate, bounds, tileLayerOptions); 85 | addLayer(layer, layerGroup, asset); 86 | triggerEvent("imageLayerAdded", { type: "tilelayer", layer, asset }, layerGroup); 87 | log(2, "added tile layer to layer group"); 88 | return layer; 89 | } 90 | } catch (error) { 91 | log(1, "caught the following error while trying to add a tile layer:", error); 92 | return null; 93 | } 94 | } 95 | 96 | export async function addAsset(asset, layerGroup, options) { 97 | log(2, "add asset", asset); 98 | if (asset.isGeoTIFF()) { 99 | return await addGeoTiff(asset, layerGroup, options); 100 | } else { 101 | return await addThumbnail([asset], layerGroup, options); 102 | } 103 | } 104 | 105 | export async function addDefaultGeoTiff(stac, layerGroup, options) { 106 | if (options.displayOverview) { 107 | const geotiff = stac.getDefaultGeoTIFF(true, !options.displayGeoTiffByDefault); 108 | if (geotiff) { 109 | log(2, "add default geotiff", geotiff); 110 | return addGeoTiff(geotiff, layerGroup, options); 111 | } 112 | } 113 | return null; 114 | } 115 | 116 | export async function addGeoTiff(asset, layerGroup, options) { 117 | return new Promise(async resolve => { 118 | if (options.preferTileLayer) { 119 | return resolve(await addTileLayer(asset, layerGroup, options)); 120 | } 121 | 122 | const fallback = async error => { 123 | log(1, `activating fallback because "${error.message}"`); 124 | triggerEvent("fallback", { asset, error }, layerGroup); 125 | return await addTileLayer(asset, layerGroup, options); 126 | }; 127 | 128 | try { 129 | log(2, "add geotiff", asset); 130 | 131 | const layer = await createGeoRasterLayer(asset, options); 132 | const georaster = layer.options.georaster; 133 | options.alphas = await parseAlphas(georaster); 134 | options.currentStats = layer.currentStats; 135 | log(1, "successfully created georaster layer for", asset); 136 | 137 | if (!layerGroup.footprintLayer) { 138 | try { 139 | let bbox = [georaster.xmin, georaster.ymin, georaster.xmax, georaster.ymax]; 140 | options.bbox = reprojectBoundingBox({ bbox, from: georaster.projection, to: 4326, density: 100 }); 141 | addFootprintLayer(asset, layerGroup, options); 142 | } catch (error) { 143 | console.trace(error); 144 | } 145 | } 146 | 147 | let count = 0; 148 | layer.on("tileerror", async event => { 149 | // sometimes LeafletJS might issue multiple error events before the layer is removed from the map. 150 | // the counter makes sure we only active the fallback sequence once 151 | count++; 152 | if (count === 1) { 153 | if (layerGroup.hasLayer(layer)) { 154 | layerGroup.removeLayer(layer); 155 | } 156 | resolve(await fallback(event.error)); 157 | } 158 | }); 159 | layer.on("load", () => resolve(layer)); 160 | addLayer(layer, layerGroup, asset); 161 | 162 | triggerEvent("imageLayerAdded", { type: "overview", layer, asset }, layerGroup); 163 | // Make sure resolve is always called 164 | withTimeout(TIMEOUT, () => resolve(layer)); 165 | } catch (error) { 166 | resolve(await fallback(error)); 167 | } 168 | }); 169 | } 170 | 171 | export async function addThumbnails(stac, layerGroup, options) { 172 | if (options.displayPreview) { 173 | const thumbnails = stac.getThumbnails(true, "thumbnail"); 174 | return await addThumbnail(thumbnails, layerGroup, options); 175 | } 176 | } 177 | 178 | export async function addThumbnail(thumbnails, layerGroup, options) { 179 | if (thumbnails.length === 0) { 180 | return null; 181 | } 182 | try { 183 | const asset = thumbnails.shift(); // Try the first thumbnail 184 | log(2, "add thumbnail", asset); 185 | const bounds = getBounds(asset, options); 186 | if (!bounds) { 187 | log(1, "Can't visualize an asset without a location."); 188 | return null; 189 | } 190 | 191 | const url = asset.getAbsoluteUrl(); 192 | const layer = await imageOverlay(url, bounds, options.crossOrigin); 193 | if (layer === null) { 194 | log(1, "image layer is null", url); 195 | return addThumbnail(thumbnails, layerGroup, options); // Retry with the remaining thumbnails 196 | } 197 | addLayer(layer, layerGroup, asset); 198 | return await new Promise(resolve => { 199 | layer.on("load", () => { 200 | triggerEvent("imageLayerAdded", { type: "preview", layer, asset }, layerGroup); 201 | return resolve(layer); 202 | }); 203 | layer.on("error", async () => { 204 | log(1, "create image layer errored", url); 205 | layerGroup.removeLayer(layer); 206 | const otherLyr = await addThumbnail(thumbnails, layerGroup, options); // Retry with the remaining thumbnails 207 | return resolve(otherLyr); 208 | }); 209 | }); 210 | } catch (error) { 211 | log(1, "failed to create image layer because of the following error:", error); 212 | return null; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stac-layer 2 | > Visualize [STAC](https://stacspec.org/) data on a [LeafletJS](https://leafletjs.com/) map 3 | 4 |
5 |
6 | # Install
7 |
8 | To install the version 1.0.0-beta.1 (rewrite based on stac-js):
9 |
10 | ```bash
11 | npm install stac-layer@next
12 | ```
13 |
14 | To install the old version 0.15.0:
15 |
16 | ```bash
17 | npm install stac-layer
18 | ```
19 |
20 | # Supported STAC types
21 |
22 | - STAC Collection (+ Assets)
23 | - STAC Item (+ Assets)
24 | - STAC API Collections
25 | - STAC API Items (ItemCollection)
26 |
27 | # Usage
28 |
29 | ```js
30 | import stacLayer from 'stac-layer';
31 |
32 | // create your Leaflet map
33 | const map = L.map('map');
34 |
35 | // set options for the STAC layer
36 | const options = {
37 | // see table below for supported options, for example:
38 | resolution: 128,
39 | map
40 | };
41 |
42 | const data = fetch('https://example.com/stac/item.json')
43 |
44 | // create layer
45 | const layer = stacLayer(data, options);
46 |
47 | // if the map option has not been provided:
48 | // add layer to map and fit map to layer
49 | // layer.addTo(map);
50 | // map.fitBounds(layer.getBounds());
51 | ```
52 |
53 | ## Parameters
54 |
55 | The first parameter `data` is the STAC entity.
56 | It can be provided as a plain JavaScript object (i.e. a deserialized STAC JSON) or
57 | you can project a [stac-js](https://github.com/m-mohr/stac-js) object.
58 | - STAC Collection (stac-js: `Collection`)
59 | - STAC Item (stac-js: `Item`)
60 | - STAC API Collections (stac-js: `CollectionCollection`)
61 | - STAC API Items (stac-js: `ItemCollection`)
62 |
63 | The second parameter `options` allows to customize stac-layer.
64 | The following options are supported:
65 |
66 | ### assets
67 | > array of objects or strings (default: undefined)
68 |
69 | If you want to show specific assets, provide them as an array here.
70 | The array can contain either the asset keys (as strings) or asset objects (plain or stac.js).
71 | Passing assets via this option will override `displayPreview` and `displayOverview`.
72 |
73 | ### bands
74 | > array of numbers (default: undefined)
75 |
76 | An array mapping the bands to the output bands. The following lengths are supported:
77 | - `1`: Grayscale only
78 | - `2`: Grayscale + Alpha
79 | - `3`: RGB only
80 | - `4`: RGB + Alpha
81 |
82 | ### baseUrl
83 | > string (default: the self link of the STAC entity)
84 |
85 | The base URL for relative links.
86 | Should be provided if the STAC data has no self link and links are not absolute.
87 |
88 | ### crossOrigin
89 | > string\|null (default: undefined)
90 |
91 | The value for the [`crossorigin` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin) that is set when loading images through the browser.
92 |
93 | ### displayGeoTiffByDefault
94 | > boolean (default: false)
95 |
96 | Allow to display non-cloud-optimized GeoTiffs by default, which might not work well for larger files.
97 |
98 | ### displayPreview
99 | > boolean (default: false)
100 |
101 | Allow to display images that a browser can display (e.g. PNG, JPEG), usually assets with role `thumbnail` or the link with relation type `preview`.
102 | The previews are usually not covering the full extents and as such may be placed incorrectly on the map.
103 | For performance reasons, it is recommended to enable this option if you pass in STAC API Items.
104 |
105 | ### displayOverview
106 | > boolean (default: true)
107 |
108 | Allow to display COGS and/or GeoTiffs (depending on `displayGeoTiffByDefault`), usually the assets with role `overview` or `visual`.
109 |
110 | For performance reasons, it is recommended to disable this option if you pass in STAC API Items.
111 |
112 | ### debugLevel
113 | > boolean (default: 0)
114 |
115 | The higher the value the more debugging messages will be logged to the console. `0` to disable logging.
116 |
117 | ### resolution
118 | > integer (default: 32)
119 |
120 | Adjust the display resolution, a power of two such as 32, 64, 128 or 256. By default the value is set to render quickly, but with limited resolution. Increase this value for better resolution, but slower rendering speed (e.g., 128).
121 |
122 | ### Styling
123 |
124 | #### boundsStyle
125 | > object (default: *Leaflet defaults, but `{fillOpacity: 0}` may be set if layers are shown inside of the bounds*)
126 |
127 | [Leaflet Path](https://leafletjs.com/reference.html#path-option) (i.e. the vector style) for the bounds / footprint of the container.
128 |
129 | #### collectionStyle
130 | > object (default: *Leaflet default, but `{fillOpacity: 0, weight: 1, color: '#ff8833'}`*)
131 |
132 | [Leaflet Path](https://leafletjs.com/reference.html#path-option) (i.e. the vector style) for individual items of API Items (ItemCollection) or collections of API Collections.
133 |
134 | ### Display specific assets
135 |
136 | #### bbox / latLngBounds
137 | > 1. [latLngBounds](https://leafletjs.com/reference.html#latlngbounds)
138 | > 2. [West, South, East, North]
139 | >
140 | > Default: undefined
141 |
142 | Provide one of these options if you want to override the bounding box for a STAC Asset.
143 |
144 | ### Using a Tiler
145 |
146 | There's are a couple different ways to use a tiler to serve images of assets
147 | that are Cloud-Optimized GeoTIFFs.
148 |
149 | **Note:** To enforce using server-side rendering of imagery `useTileLayerAsFallback` must be set to `false` and either `tileUrlTemplate` or `buildTileUrlTemplate` must be given.
150 |
151 | #### buildTileUrlTemplate
152 | > function (default: undefined)
153 |
154 | If you need more dynamic customization, consider passing in an async `buildTileUrlTemplate` function. You can use this function to change the tile url and its parameters depending on the
155 | type of asset.
156 |
157 | ```js
158 | const layer = stacLayer(data, {
159 | buildTileUrlTemplate: async ({
160 | href, // the url to the GeoTIFF
161 | asset, // the STAC Asset object
162 | key, // the key or name in the assets object that points to the particular asset
163 | stac, // the STAC Item or STAC Collection, if available
164 | bounds, // LatLngBounds of the STAC asset
165 | isCOG: true, // true if the asset is definitely a cloud-optimized GeoTIFF
166 | }) => {
167 | let bands = asset.findVisualBands();
168 | if (!bands) {
169 | return "https://tiles.rdnt.io/tiles/{z}/{x}/{y}@2x?url={url}";
170 | }
171 | else {
172 | let indices = [
173 | bands.red.index,
174 | bands.green.index,
175 | bands.blue.index
176 | ].join(',');
177 | return "https://tiles.rdnt.io/tiles/{z}/{x}/{y}@2x?url={url}&bands=" + indices;
178 | }
179 | }
180 | });
181 | ```
182 |
183 | ### tileUrlTemplate
184 | > string (default: undefined)
185 |
186 | You can set `tileUrlTemplate`, which will be passed to Leaflet's [TileLayer](https://leafletjs.com/reference-1.7.1.html#tilelayer). This will apply to whichever asset stac-layer chooses as the best GeoTIFF for visualization.
187 | ```js
188 | // a STAC Feature
189 | const layer = stacLayer(data, {
190 | tileUrlTemplate: "https://tiles.rdnt.io/tiles/{z}/{x}/{y}@2x?url={url}"
191 | });
192 | ```
193 |
194 | ### useTileLayerAsFallback
195 | > boolean (default: false)
196 |
197 | Enables server-side rendering of imagery in case an error has happened on the client-side.
198 | If you'd like to only use a tiler if [GeoRasterLayer](https://github.com/geotiff/georaster-layer-for-leaflet) fails, set `useTileLayerAsFallback` to `true`.
199 |
200 | ```js
201 | const layer = stacLayer(data, {
202 | tileUrlTemplate: "https://tiles.rdnt.io/tiles/{z}/{x}/{y}@2x?url={url}",
203 | useTileLayerAsFallback: true
204 | });
205 | ```
206 |
207 | ## Events
208 |
209 | ## `click`: listening to click events
210 |
211 | STAC Layer added a "stac" property to Leaflet's onClick events that include the STAC information of what the user clicked. It can be a STAC collection, feature, asset, or even an array of assets when a composite of multiple assets are being visualized.
212 |
213 | ```js
214 | const featureCollection = ....; // a GeoJSON Feature Collection of STAC Features
215 |
216 | const layer = stacLayer(featureCollection);
217 | layer.on("click", event => {
218 | const { type, data } = event.stac;
219 | // type is one of "Collection", "CollectionCollection", "Feature", "FeatureCollection" or "Asset"
220 | // data is the item that was clicked in the collection
221 | });
222 | ```
223 |
224 | ## `loaded`: once all imagery is shown
225 |
226 | Sometimes you might like to know information about what is being visualized.
227 | You can access this information through the `loaded` event.
228 |
229 | ```js
230 | const layer = stacLayer(data, options);
231 | layer.on("loaded", ({ data }) => {
232 | // data is the stac-js object shown
233 | });
234 | ```
235 |
236 | ## `imageLayerAdded`: once a new image layer is added
237 |
238 | Whenever a new layer with imagery is added to the map.
239 | Helps to get information about which data is being visualized.
240 | Has parameters: `type`, `layer`, `asset`
241 |
242 | Multiple types are possible:
243 | - `tilelayer` A tile server layer.
244 | - `overview` Overview imagery layer, usually a GeoTiff or COG.
245 | - `preview` Preview imagery layer, usually a thumbnail in a browser-supported format such as PNG or JPEG.
246 |
247 | ```js
248 | const layer = stacLayer(data, options);
249 | layer.on("imageLayerAdded", ({ type, layer, asset }) => {
250 | // type is the type of the layer
251 | // layer is the Leaflet layer object
252 | // asset can be a stac-js asset object
253 | });
254 | ```
255 |
256 | ## `fallback`: listening to fallback events
257 |
258 | STAC Layer fires a custom "fallback" event when an error occurs rendering
259 | with GeoRasterLayer and it falls back to trying to use a tiler.
260 |
261 | ```js
262 | const layer = stacLayer(data, options);
263 | layer.on("fallback", ({ error, asset }) => {
264 | // error is the initial LeafletJS error event that triggered the fallback
265 | // asset is the stac-js object for which it errored
266 | });
267 | ```
268 |
--------------------------------------------------------------------------------