UNPKG

stac-layer

Version:

Visualize a STAC Item or Collection on a Leaflet Map

699 lines (631 loc) 27.1 kB
import L from "leaflet"; import parseGeoRaster from "georaster"; import GeoRasterLayer from "georaster-layer-for-leaflet"; import bboxPolygon from "@turf/bbox-polygon"; import reprojectGeoJSON from "reproject-geojson"; import booleanPointInPolygon from "@turf/boolean-point-in-polygon"; import URI from "urijs"; import bboxToLatLngBounds from "./utils/bboxToLatLngBounds.js"; import bboxLayer from "./utils/bboxLayer.js"; import findAsset from "./utils/find-asset.js"; import imageOverlay from "./utils/image-overlay.js"; import tileLayer from "./utils/tile-layer.js"; import isBoundingBox from "./utils/is-bounding-box.js"; import getBoundingBox from "./utils/get-bounding-box.js"; import { DATA_TYPES, EVENT_DATA_TYPES, MIME_TYPES } from "./data.js"; import createGeoRasterLayer from "./utils/create-georaster-layer.js"; // utility functions // get asset extension, if type and if missing type or maybe throw an error // that item is missing a type const isImageType = type => MIME_TYPES.BROWSER.includes(type); const isAssetCOG = asset => isAssetGeoTiff(asset, true); const isAssetGeoTiff = (asset, cloudOptimized = false) => { let types = cloudOptimized ? MIME_TYPES.COG : MIME_TYPES.GEOTIFF; return types.includes(asset.type) && typeof asset.href === "string" && asset.href.length > 0; }; const getOverviewAsset = assets => findAsset(assets, "overview"); const hasAsset = (assets, key) => !!findAsset(assets, key); const findLinks = data => { if (Array.isArray(data)) return data; else if (data.links) return data.links; }; const findLink = (data, key) => { const links = findLinks(data); key = key.toLowerCase(); if (links) return links.find(ln => typeof ln === "object" && ln.rel.toLowerCase() === key); }; const hasLink = (data, key) => !!findLink(data, key); const findSelf = data => findLink(data, "self"); const findSelfHref = data => findSelf(data)?.href; const getLatLngBounds = item => { const bbox = getBoundingBox(item); if (bbox) return bboxToLatLngBounds(bbox); if (item.geometry) return L.geoJSON(item.geometry).getBounds(); }; function getDataType(data) { if (typeof data.type === "string") { const dataType = data.type.toUpperCase(); if (dataType === "CATALOG") { return DATA_TYPES.STAC_CATALOG; } else if (dataType === "FEATURECOLLECTION") { return DATA_TYPES.ITEM_COLLECTION; } else if (dataType === "COLLECTION") { return DATA_TYPES.STAC_COLLECTION; } else if (dataType === "FEATURE") { return DATA_TYPES.STAC_ITEM; } } if ("href" in data) { return DATA_TYPES.STAC_ASSET; } if (Array.isArray(data) && data.every(it => "href" in it)) { return DATA_TYPES.STAC_ASSETS; } if ("license" in data && "extent" in data) { return DATA_TYPES.STAC_COLLECTION; } else { return DATA_TYPES.STAC_CATALOG; } } async function addOverviewAssetForFeature(feature, layerGroup, crossOrigin, errorCallback) { if (!("bbox" in feature)) return; const { asset } = getOverviewAsset(feature.assets); if (isImageType(asset.type)) { const lyr = await imageOverlay( asset.href, [ [feature.bbox[1], feature.bbox[0]], [feature.bbox[3], feature.bbox[2]] ], crossOrigin ); if (lyr === null) { if (errorCallback) errorCallback(); return; } layerGroup.addLayer(lyr); lyr.on("error", () => { layerGroup.removeLayer(lyr); if (errorCallback) errorCallback(); }); } } async function addThumbnailAssetForFeature(feature, layerGroup, crossOrigin, errorCallback) { if (!("bbox" in feature)) return; const { asset } = findAsset(feature.assets, "thumbnail"); if (isImageType(asset.type)) { const lyr = await imageOverlay( asset.href, [ [feature.bbox[1], feature.bbox[0]], [feature.bbox[3], feature.bbox[2]] ], crossOrigin ); if (lyr === null) { if (errorCallback) errorCallback(); return; } layerGroup.addLayer(lyr); lyr.on("error", () => { layerGroup.removeLayer(lyr); if (errorCallback) errorCallback(); }); } } // relevant links: // https://github.com/radiantearth/stac-browser/blob/v3/src/stac.js const stacLayer = async (data, options = {}) => { const debugLevel = typeof options.debugLevel === "number" && options.debugLevel >= 1 ? options.debugLevel : 0; if (debugLevel >= 1) console.log("[stac-layer] starting"); if (debugLevel >= 2) console.log("[stac-layer] data:", data); if (debugLevel >= 2) console.log("[stac-layer] options:", options); const displayGeoTiffByDefault = [true, false].includes(options.displayGeoTiffByDefault) ? options.displayGeoTiffByDefault : false; if (debugLevel >= 2) console.log("[stac-layer] displayGeoTiffByDefault:", displayGeoTiffByDefault); const displayPreview = [true, false].includes(options.displayPreview) ? options.displayPreview : false; if (debugLevel >= 2) console.log("[stac-layer] displayPreview:", displayPreview); const displayOverview = [true, false].includes(options.displayOverview) ? options.displayOverview : true; if (debugLevel >= 2) console.log("[stac-layer] displayOverview:", displayOverview); const useTileLayer = options.tileUrlTemplate || options.buildTileUrlTemplate; const preferTileLayer = (useTileLayer && !options.useTileLayerAsFallback) || false; if (debugLevel >= 2) console.log("[stac-layer] preferTileLayer:", preferTileLayer); let assetsOption = options.assets ? options.assets : []; assetsOption = Array.isArray(assetsOption) ? assetsOption : [assetsOption]; // get link to self, which we might need later const selfHref = findSelfHref(data); if (debugLevel >= 2) console.log("[stac-layer] self href:", selfHref); let baseUrl = options.baseUrl || selfHref; if (debugLevel >= 2) console.log("[stac-layer] base url:", baseUrl); // default to filling in the bounds layer unless we successfully visualize an image let fillOpacity = 0.2; const toAbsoluteHref = href => { if (!href) { throw new Error("[stac-layer] can't convert nothing to an absolute href"); } let uri = URI(href); if (uri.is("relative")) { if (!baseUrl) { throw new Error(`[stac-layer] can't determine an absolute url for "${href}" without a baseUrl`); } uri = uri.absoluteTo(baseUrl); } return uri.toString(); }; const layerGroup = L.layerGroup(); // if the given layer fails for any reason, remove it from the map, and call the fallback const setFallback = (lyr, fallback) => { let count = 0; ["tileerror"].forEach(name => { lyr.on(name, async evt => { count++; // sometimes LeafletJS might issue multiple error events before // the layer is removed from the map // the following makes sure we only active the fallback sequence once if (count === 1) { console.log(`[stac-layer] activating fallback because "${evt.error.message}"`); if (layerGroup.hasLayer(lyr)) layerGroup.removeLayer(lyr); await fallback(); onFallbackHandlers.forEach(handleOnFallback => { try { handleOnFallback({ error: evt }); } catch (error) { console.error(error); } }); } }); }); }; // hijack on event to support on("click") as it isn't normally supported by layer groups const onClickHandlers = []; const onFallbackHandlers = []; layerGroup.on2 = layerGroup.on; layerGroup.on = function (name, callback) { if (name === "click") { onClickHandlers.push(callback); return this; } else if (name === "fallback") { onFallbackHandlers.push(callback); return this; } else if (this.on2) { return this.on2(...arguments); } }; const dataType = getDataType(data); if (debugLevel >= 1) console.log(`[stac-layer] data is of type "${dataType}"`); // sets up generic onClick event where a "stac" key is added to the event object // and is set to the provided data or the data used to create stacLayer const bindDataToClickEvent = (lyr, _data) => { lyr.on("click", evt => { evt.stac = { data: typeof _data === "function" ? _data(evt) : _data || data }; const clickedDataType = getDataType(evt.stac.data); if ( [ DATA_TYPES.STAC_COLLECTION, DATA_TYPES.STAC_API_COLLECTIONS, DATA_TYPES.ITEM_COLLECTION, DATA_TYPES.STAC_API_ITEMS ].includes(clickedDataType) ) { evt.stac.type = EVENT_DATA_TYPES.COLLECTION; } else if ([DATA_TYPES.STAC_ITEM].includes(clickedDataType)) { evt.stac.type = EVENT_DATA_TYPES.FEATURE; } else if ([DATA_TYPES.STAC_ASSETS].includes(clickedDataType)) { evt.stac.type = EVENT_DATA_TYPES.ASSETS; } else if ([DATA_TYPES.STAC_ASSET].includes(clickedDataType)) { evt.stac.type = EVENT_DATA_TYPES.ASSET; } onClickHandlers.forEach(handleOnClick => { try { handleOnClick(evt); } catch (error) { console.error(error); } }); }); }; if (dataType === DATA_TYPES.ITEM_COLLECTION || dataType === DATA_TYPES.STAC_API_ITEMS) { // Item Collection aka GeoJSON Feature Collection where each Feature is a STAC Item // STAC API /items endpoint also returns a similar Feature Collection const lyr = L.geoJSON(data, options); data.features.forEach(f => { if (displayPreview) { // If we've got a thumnail asset add it if (hasAsset(f.assets, "thumbnail")) { addThumbnailAssetForFeature(f, layerGroup, options.crossOrigin, () => { // If some reason it's broken try for an overview asset if (hasAsset(f.assets, "overview")) { addOverviewAssetForFeature(f, layerGroup, options.crossOrigin); } }); } else if (!hasAsset(f.assets, "thumbnail") && hasAsset(f.assets, "overview")) { // If we don't have a thumbail let's try for an overview asset addOverviewAssetForFeature(f, layerGroup, options.crossOrigin); } } }); bindDataToClickEvent(lyr, e => { try { const { lat, lng } = e.latlng; const point = [lng, lat]; const matches = data.features.filter(feature => booleanPointInPolygon(point, feature)); if (matches.length >= 2) { return { type: "FeatureCollection", features: matches }; } } catch (error) { // code above failed, so just skip intersection checks // and return feature given by LeafletJS event } return e?.layer?.feature; }); layerGroup.addLayer(lyr); } else if (dataType === DATA_TYPES.STAC_ITEM || dataType === DATA_TYPES.STAC_COLLECTION) { let addedImagery = false; const { assets = {} } = data; const bounds = getLatLngBounds(data); if (debugLevel >= 1) console.log(`[stac-layer] item bounds are: ${bounds.toBBoxString()}`); const addTileLayer = async ({ asset, href, isCOG, isVisual, key }) => { try { if (options.buildTileUrlTemplate) { const tileUrlTemplate = options.buildTileUrlTemplate({ href, url: href, asset, key, item: asset, bounds, isCOG, isVisual }); if (debugLevel >= 2) console.log(`[stac-layer] built tile url template: "${tileUrlTemplate}"`); const tileLayerOptions = { bounds, ...options, url: href }; const lyr = await tileLayer(tileUrlTemplate, tileLayerOptions); layerGroup.stac = { assets: [{ key, asset }], bands: asset?.["eo:bands"] }; bindDataToClickEvent(lyr, asset); layerGroup.addLayer(lyr); addedImagery = true; } else if (options.tileUrlTemplate) { const tileLayerOptions = { bounds, ...options, url: encodeURIComponent(href) }; const lyr = await tileLayer(options.tileUrlTemplate, tileLayerOptions); bindDataToClickEvent(lyr, asset); layerGroup.stac = { assets: [{ key, asset }], bands: asset?.["eo:bands"] }; layerGroup.addLayer(lyr); if (debugLevel >= 2) console.log("[stac-layer] added tile layer to layer group"); addedImagery = true; } } catch (error) { console.log("[stac-layer] caught the following error while trying to add a tile layer:", error); } }; // first, check if we're supposed to be showing a particular asset if (assetsOption.length > 0) { for (let index = 0; index < assetsOption.length; index++) { const assetThing = assetsOption[index]; // Handle asset key strings and objects const asset = typeof assetThing === "string" ? assets[assetThing] : assetThing; if (asset !== undefined && isAssetGeoTiff(asset)) { const href = toAbsoluteHref(asset.href); try { const georasterLayer = await createGeoRasterLayer(href, options); if (debugLevel >= 1) console.log("[stac-layer] successfully created layer for", asset); bindDataToClickEvent(georasterLayer, asset); layerGroup.stac = { assets: [{ asset }] }; setFallback(georasterLayer, () => addTileLayer({ asset, href, isCOG: isAssetCOG(asset), isVisual: false })); layerGroup.addLayer(georasterLayer); addedImagery = true; } catch (error) { console.error("[stac-layer] failed to create georaster layer because of the following error:", error); } } } } // then check for overview if (addedImagery === false && displayOverview && hasAsset(assets, "overview")) { try { if (debugLevel >= 1) console.log(`[stac-layer] found image overview`); const { key, asset } = getOverviewAsset(assets); const { type } = asset; const href = toAbsoluteHref(asset.href); if (debugLevel >= 2) console.log("[stac-layer] overview's href is:", href); if (isImageType(type)) { const overviewLayer = await imageOverlay(href, bounds, options.crossOrigin); if (overviewLayer !== null) { bindDataToClickEvent(overviewLayer, asset); // there probably aren't eo:bands attached to an overview // but we include this here just in case layerGroup.stac = { assets: [{ key, asset }], bands: asset?.["eo:bands"] }; layerGroup.addLayer(overviewLayer); addedImagery = true; if (debugLevel >= 1) console.log("[stac-layer] succesfully added overview layer"); } } else if (isAssetGeoTiff(asset, !displayGeoTiffByDefault)) { const isCOG = isAssetCOG(asset); if (preferTileLayer) { await addTileLayer({ asset, href, isCOG, isVisual: true, key }); } if (!addedImagery) { try { const georasterLayer = await createGeoRasterLayer(href, options); bindDataToClickEvent(georasterLayer, asset); layerGroup.stac = { assets: [{ key, asset }], bands: asset?.["eo:bands"] }; setFallback(georasterLayer, () => addTileLayer({ asset, href, isCOG, isVisual: true, key })); layerGroup.addLayer(georasterLayer); addedImagery = true; } catch (error) { "[stac-layer] failed to create georaster layer because of the following error:", error; } } if (!preferTileLayer && useTileLayer) { await addTileLayer({ asset, href, isCOG, isVisual: true, key }); } } } catch (error) { if (debugLevel >= 1) console.log(`[stac-layer] caught the following error while trying to render the overview`, error); } } // check for thumbnail if (addedImagery === false && displayPreview && hasAsset(assets, "thumbnail")) { try { if (debugLevel >= 1) console.log(`[stac-layer] found image thumbnail`); const { key, asset } = findAsset(assets, "thumbnail"); const { type } = asset; const href = toAbsoluteHref(asset.href); if (isImageType(type)) { const thumbLayer = await imageOverlay(href, bounds, options.crossOrigin); if (thumbLayer !== null) { bindDataToClickEvent(thumbLayer, data); layerGroup.addLayer(thumbLayer); addedImagery = true; if (debugLevel >= 1) console.log("[stac-layer] succesfully added thumbnail layer"); } } } catch (error) { if (debugLevel >= 1) console.log(`[stac-layer] caught the following error while trying to render the thumbnail`, error); } } // check for preview image if (addedImagery === false && displayPreview && hasLink(data, "preview")) { try { if (debugLevel >= 1) console.log(`[stac-layer] found image preview`); const preview = findLink(data, "preview"); const { type } = preview; const href = toAbsoluteHref(preview.href); if (isImageType(type)) { const previewLayer = await imageOverlay(href, bounds, options.crossOrigin); if (previewLayer !== null) { bindDataToClickEvent(previewLayer, data); layerGroup.addLayer(previewLayer); addedImagery = true; if (debugLevel >= 1) console.log("[stac-layer] succesfully added preview layer"); } } } catch (error) { if (debugLevel >= 1) console.log(`[stac-layer] caught the following error while trying to render the preview`, error); } } // check for non-standard asset with the key "visual" if (addedImagery === false && displayOverview && hasAsset(assets, "visual")) { const { asset, key } = findAsset(assets, "visual"); if (isAssetGeoTiff(asset, !displayGeoTiffByDefault)) { const isCOG = isAssetCOG(asset); if (debugLevel >= 1) console.log(`[stac-layer] found visual asset, so displaying that`); const href = toAbsoluteHref(asset.href); if (preferTileLayer) { await addTileLayer({ asset, href, isCOG, isVisual: true, key }); } if (addedImagery === false) { try { const georasterLayer = await createGeoRasterLayer(href, { ...options, debugLevel: (options.debugLevel || 1) - 1 }); layerGroup.stac = { assets: [{ key, asset }], bands: asset?.["eo:bands"] }; bindDataToClickEvent(georasterLayer, asset); setFallback(georasterLayer, () => addTileLayer({ asset, href, isCOG, isVisual: true, key })); layerGroup.addLayer(georasterLayer); addedImagery = true; } catch (error) { console.error("[stac-layer] failed to create georaster layer because of the following error:", error); } } if (addedImagery === false && !preferTileLayer && useTileLayer) { await addTileLayer({ asset, href, isCOG, isVisual: true, key }); } } } // if we still haven't found a valid imagery layer yet, just add the first GeoTiff (or COG) const geotiffs = Object.entries(assets).filter(entry => isAssetGeoTiff(entry[1], !displayGeoTiffByDefault)); if (!addedImagery && geotiffs.length >= 1) { if (debugLevel >= 1) console.log( `[stac-layer] defaulting to trying to display the first ${displayGeoTiffByDefault ? "GeoTiff" : "COG"} asset` ); const [key, asset] = geotiffs[0]; const href = toAbsoluteHref(asset.href); const isCOG = isAssetCOG(asset); if (preferTileLayer) { await addTileLayer({ asset, href, isCOG, isVisual: false, key }); } if (!addedImagery) { try { const georasterLayer = await createGeoRasterLayer(href, options); if (debugLevel >= 1) console.log("[stac-layer] successfully created layer for", asset); bindDataToClickEvent(georasterLayer, asset); layerGroup.stac = { assets: [{ key, asset }], bands: asset?.["eo:bands"] }; setFallback(georasterLayer, () => addTileLayer({ asset, href, isCOG, isVisual: false, key })); layerGroup.addLayer(georasterLayer); addedImagery = true; } catch (error) { console.error("[stac-layer] failed to create georaster layer because of the following error:", error); } } if (addedImagery === false && !preferTileLayer && useTileLayer) { await addTileLayer({ asset, href, isCOG, isVisual: false, key }); } } if (dataType === DATA_TYPES.STAC_ITEM) { if ("geometry" in data && typeof data.geometry === "object") { const lyr = L.geoJSON(data.geometry, { fillOpacity: layerGroup.getLayers().length > 0 ? 0 : 0.2, ...options }); bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); } else if ("bbox" in data && Array.isArray(data.bbox) && data.bbox.length === 4) { const lyr = L.bboxLayer(data, { fillOpacity: layerGroup.getLayers().length > 0 ? 0 : 0.2, ...options }); bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); } } else if (dataType === DATA_TYPES.STAC_COLLECTION) { const bbox = data?.extent?.spatial?.bbox; if (isBoundingBox(bbox)) { const lyr = bboxLayer(bbox, options); bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); } else if (Array.isArray(bbox) && bbox.length === 1 && isBoundingBox(bbox[0])) { const lyr = bboxLayer(bbox[0], options); bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); } else if (Array.isArray(bbox) && bbox.length >= 2) { const layers = bbox.slice(1).map(it => { const lyr = bboxLayer(it, options); // could we use turf to filter features by bounding box clicked // or is that over-engineering? bindDataToClickEvent(lyr); return lyr; }); const featureGroup = L.featureGroup(layers); layerGroup.addLayer(featureGroup); } } } else if (dataType === DATA_TYPES.STAC_ASSET) { const { type } = data; const href = toAbsoluteHref(data.href); let bounds; if (options.latLngBounds) { bounds = options.latLngBounds; } else if (options.bounds) { bounds = options.bounds; } else if (options.bbox) { bounds = bboxToLatLngBounds(options.bbox); } if (debugLevel >= 1) console.log("[stac-layer] visualizing " + type); if (isImageType(type)) { if (!bounds) { throw new Error( `[stac-layer] cannot visualize asset of type "${type}" without a location. Please pass in an options object with bounds or bbox set.` ); } const lyr = await imageOverlay(href, bounds, options.crossOrigin); if (lyr !== null) { bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); fillOpacity = 0; } } else if (MIME_TYPES.GEOTIFF.includes(type)) { const addTileLayer = async () => { try { if (options.buildTileUrlTemplate) { const tileUrlTemplate = options.buildTileUrlTemplate({ href, url: href, asset: data, key: null, item: null, isCOG: MIME_TYPES.COG.includes(type), isVisual: null }); if (debugLevel >= 2) console.log(`[stac-layer] built tile url template: "${tileUrlTemplate}"`); const tileLayerOptions = { ...options, bounds, url: href }; const lyr = await tileLayer(tileUrlTemplate, tileLayerOptions); layerGroup.stac = { assets: [{ key: null, asset: data }], bands: data?.["eo:bands"] }; bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); fillOpacity = 0; } else if (options.tileUrlTemplate) { const tileLayerOptions = { bounds, ...options, url: href }; const lyr = await tileLayer(options.tileUrlTemplate, tileLayerOptions); layerGroup.stac = { assets: [{ key: null, asset: data }], bands: data?.["eo:bands"] }; bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); fillOpacity = 0; } } catch (error) { console.log("[stac-layer] caught the following error while trying to add a tile layer:", error); } }; if (preferTileLayer) { await addTileLayer(); } else { try { const georaster = await parseGeoRaster(href); try { const georasterLayer = new GeoRasterLayer({ georaster, ...options }); layerGroup.stac = { assets: [{ key: null, asset: data }], bands: data?.["eo:bands"] }; bindDataToClickEvent(georasterLayer); setFallback(georasterLayer, addTileLayer); layerGroup.addLayer(georasterLayer); } catch (error) { if (useTileLayer) await addTileLayer(); } const bbox = [georaster.xmin, georaster.ymin, georaster.xmax, georaster.ymax]; bounds = reprojectGeoJSON(bboxPolygon(bbox), { from: georaster.projection, to: 4326 }); fillOpacity = 0; } catch (error) { console.error("caught error so checking geometry:", error); } } } if (bounds) { if (debugLevel >= 1) console.log("[stac-layer] adding bounds layer"); let lyr; if (bounds.type === "Feature") { lyr = L.geoJSON(bounds, { fillOpacity }); } else { lyr = L.rectangle(bounds, { fillOpacity }); } bindDataToClickEvent(lyr); layerGroup.addLayer(lyr); } } else { throw new Error(`[stac-layer] does not support visualization of data of the type "${dataType}"`); } // use the extent of the vector layer layerGroup.getBounds = () => { const lyr = layerGroup.getLayers().find(lyr => lyr.toGeoJSON); if (!lyr) { if (layerGroup.options.debugLevel >= 1) { console.log( "[stac-layer] unable to get bounds without a vector layer. " + "This often happens when there was an issue determining the bounding box of the provided data." ); } return; } const bounds = lyr.getBounds(); const southWest = [bounds.getSouth(), bounds.getWest()]; const northEast = [bounds.getNorth(), bounds.getEast()]; return [southWest, northEast]; }; layerGroup.bringToFront = () => layerGroup.getLayers().forEach(layer => layer.bringToFront()); layerGroup.bringToBack = () => layerGroup.getLayers().forEach(layer => layer.bringToBack()); if (!layerGroup.options) layerGroup.options = {}; layerGroup.options.debugLevel = debugLevel; return layerGroup; }; L.stacLayer = stacLayer; export default stacLayer;