diff --git a/mock/datasets/hls-external.data.mdx b/mock/datasets/hls-external.data.mdx new file mode 100644 index 000000000..57fe5426b --- /dev/null +++ b/mock/datasets/hls-external.data.mdx @@ -0,0 +1,50 @@ +--- +id: hls-l30-external +name: 'HLS Landsat 30m (External STAC)' +featured: true +sourceExclusive: Mock +description: "Harmonized Landsat Sentinel-2 (HLS) Landsat 30m Surface Reflectance from NASA CMR STAC." +media: + src: ::file ./img-placeholder-3.jpg + alt: HLS Landsat imagery placeholder +taxonomy: + - name: Source + values: + - Mock + - name: Topics + values: + - Agriculture +layers: + - id: hls-l30-external + stacCol: HLSL30_2.0 + stacApiEndpoint: https://cmr.earthdata.nasa.gov/cloudstac/LPCLOUD + tileApiEndpoint: https://openveda.cloud/api/raster + name: HLS Landsat 30m + type: raster + tilingMode: cog + time_density: day + description: Harmonized Landsat Sentinel-2 (HLS) Landsat Operational Land Imager Surface Reflectance and TOA Brightness Daily Global 30m v2.0. + zoomExtent: + - 7 + - 20 + sourceParams: + assets: s3_B04 + colormap_name: viridis + rescale: + - 0 + - 3000 + legend: + type: gradient + min: "0" + max: "3000" + stops: + - "#440154" + - "#3b528b" + - "#21918c" + - "#5ec962" + - "#fde725" +--- + +Test dataset for the `raster` type with `tilingMode: cog` using NASA CMR STAC (LPCLOUD). + +HLS provides consistent surface reflectance data from a virtual constellation of Landsat and Sentinel-2 sensors. diff --git a/mock/datasets/landsat-element84.data.mdx b/mock/datasets/landsat-element84.data.mdx new file mode 100644 index 000000000..fe6022a2d --- /dev/null +++ b/mock/datasets/landsat-element84.data.mdx @@ -0,0 +1,51 @@ +--- +id: landsat-element84 +name: 'Landsat C2 L2 (Element84 Earth Search)' +featured: true +sourceExclusive: Mock +description: "Atmospherically corrected global Landsat Collection 2 Level-2 data from Element84 Earth Search." +media: + src: ::file ./img-placeholder-3.jpg + alt: Landsat satellite imagery placeholder +taxonomy: + - name: Source + values: + - Mock + - name: Topics + values: + - Agriculture +layers: + - id: landsat-c2-l2-element84 + stacCol: landsat-c2-l2 + stacApiEndpoint: https://earth-search.aws.element84.com/v1 + tileApiEndpoint: https://openveda.cloud/api/raster + name: Landsat Collection 2 Level-2 + type: raster + tilingMode: cog + time_density: day + description: Atmospherically corrected global Landsat Collection 2 Level-2 data from Element84 Earth Search. + zoomExtent: + - 7 + - 20 + searchLimit: 100 + sourceParams: + assets: red + colormap_name: viridis + rescale: + - 0 + - 20000 + legend: + type: gradient + min: "0" + max: "20000" + stops: + - "#440154" + - "#3b528b" + - "#21918c" + - "#5ec962" + - "#fde725" +--- + +Test dataset for the `raster` type with `tilingMode: cog` using Element84 Earth Search STAC API. + +Landsat Collection 2 Level-2 includes atmospherically corrected surface reflectance data from Landsat 4-9 satellites. diff --git a/mock/datasets/sentinel2-element84.data.mdx b/mock/datasets/sentinel2-element84.data.mdx new file mode 100644 index 000000000..8ec1ea940 --- /dev/null +++ b/mock/datasets/sentinel2-element84.data.mdx @@ -0,0 +1,51 @@ +--- +id: sentinel2-element84 +name: 'Sentinel-2 L2A (Element84 Earth Search)' +featured: true +sourceExclusive: Mock +description: "Global Sentinel-2 Level-2A data from the Multispectral Instrument (MSI) via Element84 Earth Search." +media: + src: ::file ./img-placeholder-3.jpg + alt: Sentinel-2 satellite imagery placeholder +taxonomy: + - name: Source + values: + - Mock + - name: Topics + values: + - Agriculture +layers: + - id: sentinel-2-l2a-element84 + stacCol: sentinel-2-l2a + stacApiEndpoint: https://earth-search.aws.element84.com/v1 + tileApiEndpoint: https://openveda.cloud/api/raster + name: Sentinel-2 Level-2A + type: raster + tilingMode: cog + time_density: day + description: Global Sentinel-2 Level-2A surface reflectance data from Element84 Earth Search. + zoomExtent: + - 7 + - 20 + searchLimit: 100 + sourceParams: + assets: red + colormap_name: viridis + rescale: + - 0 + - 10000 + legend: + type: gradient + min: "0" + max: "10000" + stops: + - "#440154" + - "#3b528b" + - "#21918c" + - "#5ec962" + - "#fde725" +--- + +Test dataset for the `raster` type with `tilingMode: cog` using Element84 Earth Search STAC API. + +Sentinel-2 Level-2A provides atmospherically corrected surface reflectance data from the Sentinel-2 constellation. diff --git a/packages/veda-ui/src/components/common/map/style-generators/footprints-layer.tsx b/packages/veda-ui/src/components/common/map/style-generators/footprints-layer.tsx new file mode 100644 index 000000000..cbedea680 --- /dev/null +++ b/packages/veda-ui/src/components/common/map/style-generators/footprints-layer.tsx @@ -0,0 +1,145 @@ +import { useEffect } from 'react'; +import { + LayerSpecification, + SourceSpecification, + GeoJSONSourceSpecification, + FillLayerSpecification, + LineLayerSpecification +} from 'mapbox-gl'; +import { useTheme } from 'styled-components'; +import { featureCollection, polygon, Feature, Polygon } from '@turf/helpers'; +import bboxPolygon from '@turf/bbox-polygon'; +import useMapStyle from '../hooks/use-map-style'; + +import useLayerInteraction from '../hooks/use-layer-interaction'; +import useGeneratorParams from '../hooks/use-generator-params'; + +interface Footprint { + bounds: [[number, number], [number, number]]; + geometry?: Polygon; +} + +interface FootprintsLayerProps { + id: string; + footprints: Footprint[] | null; + zoomExtent?: number[]; + hidden?: boolean; + opacity?: number; + generatorOrder?: number; + onFootprintsClick?: (features: Feature[]) => void; +} + +export default function FootprintsLayer(props: FootprintsLayerProps) { + const { + id, + footprints, + zoomExtent, + hidden, + opacity, + generatorOrder, + onFootprintsClick + } = props; + + const generatorParams = useGeneratorParams({ + generatorOrder: generatorOrder ?? 1000000, // on top of any layers + hidden: !!hidden, + opacity: opacity ?? 1 + }); + + const { updateStyle } = useMapStyle(); + const minZoom = zoomExtent?.[0] ?? 0; + const generatorId = `footprints-${id}`; + + const theme = useTheme(); + + useEffect(() => { + let layers: LayerSpecification[] = []; + let sources: Record = {}; + + const footprintsSourceId = `${id}-footprints`; + if (footprints && minZoom > 0) { + const features: Feature[] = footprints.map((f) => { + const props = { bounds: f.bounds }; + if (f.geometry) { + return polygon(f.geometry.coordinates, props); + } + const [[w, s], [e, n]] = f.bounds; + return bboxPolygon([w, s, e, n], { + properties: props + }) as Feature; + }); + + const footprintsSource: GeoJSONSourceSpecification = { + type: 'geojson', + data: featureCollection(features) + }; + + const fillLayer: FillLayerSpecification = { + type: 'fill', + id: footprintsSourceId, + source: footprintsSourceId, + paint: { + 'fill-color': theme.color?.primary, + 'fill-opacity': 0.4, + 'fill-outline-color': theme.color?.primary + }, + maxzoom: minZoom, + metadata: { + layerOrderPosition: 'markers' + } + }; + + const lineLayer: LineLayerSpecification = { + type: 'line', + id: `${footprintsSourceId}-outline`, + source: footprintsSourceId, + paint: { + 'line-color': theme.color?.primary, + 'line-width': 3, + 'line-opacity': 1 + }, + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + maxzoom: minZoom, + metadata: { + layerOrderPosition: 'markers' + } + }; + + sources = { + [footprintsSourceId]: footprintsSource as SourceSpecification + }; + layers = [fillLayer, lineLayer]; + } + + updateStyle({ + generatorId, + sources, + layers, + params: generatorParams + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, footprints, minZoom, generatorParams]); + + // + // Cleanup layers on unmount. + // + useEffect(() => { + return () => { + updateStyle({ + generatorId, + sources: {}, + layers: [] + }); + }; + }, [updateStyle, generatorId]); + + useLayerInteraction({ + layerId: `${id}-footprints`, + onClick: onFootprintsClick + }); + + return null; +} diff --git a/packages/veda-ui/src/components/common/map/style-generators/pagination-overlay.tsx b/packages/veda-ui/src/components/common/map/style-generators/pagination-overlay.tsx new file mode 100644 index 000000000..edc8be659 --- /dev/null +++ b/packages/veda-ui/src/components/common/map/style-generators/pagination-overlay.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Button } from '@devseed-ui/button'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; + +const Overlay = styled.div` + position: absolute; + bottom: ${glsp(1)}; + left: 50%; + transform: translateX(-50%); + z-index: 10; + background: ${themeVal('color.surface')}; + padding: ${glsp(0.5, 1)}; + border-radius: ${themeVal('shape.rounded')}; + box-shadow: ${themeVal('boxShadow.elevationA')}; + display: flex; + align-items: center; + gap: ${glsp(0.5)}; + font-size: 0.875rem; +`; + +interface PaginationOverlayProps { + loadedCount: number; + totalMatched: number; + isLoadingMore: boolean; + onLoadMore: () => void; +} + +export default function PaginationOverlay(props: PaginationOverlayProps) { + const { loadedCount, totalMatched, isLoadingMore, onLoadMore } = props; + + return ( + + + Showing {loadedCount} of {totalMatched} items + + + + ); +} diff --git a/packages/veda-ui/src/components/common/map/style-generators/raster-cog-timeseries.tsx b/packages/veda-ui/src/components/common/map/style-generators/raster-cog-timeseries.tsx new file mode 100644 index 000000000..ed21d190a --- /dev/null +++ b/packages/veda-ui/src/components/common/map/style-generators/raster-cog-timeseries.tsx @@ -0,0 +1,500 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; +import startOfDay from 'date-fns/startOfDay'; +import endOfDay from 'date-fns/endOfDay'; + +import { RasterCogTimeseriesProps, StacItemWithAssets } from '../types'; +import { FIT_BOUNDS_PADDING, getMergedBBox, requestQuickCache } from '../utils'; +import useFitBbox from '../hooks/use-fit-bbox'; +import useMaps from '../hooks/use-maps'; +import FootprintsLayer from './footprints-layer'; +import { useRequestStatus, STATUS_KEY } from './hooks'; +import { RasterPaintLayer } from './raster-paint-layer'; +import PaginationOverlay from './pagination-overlay'; +import { userTzDate2utcString } from '$utils/date'; +import { S_FAILED, S_LOADING, S_SUCCEEDED } from '$utils/status'; + +// Whether or not to print the request logs. +const LOG = process.env.NODE_ENV !== 'production' ? true : false; + +interface TileSource { + id: string; + tilesTemplate: string; + bbox: [number, number, number, number]; + assetHref: string; +} + +interface StacLink { + rel: string; + href: string; + method?: string; + body?: Record; +} + +interface StacSearchResponse { + features: StacItemWithAssets[]; + context?: { + matched?: number; + returned?: number; + }; + numberMatched?: number; + numberReturned?: number; + links?: StacLink[]; +} + +interface PaginationState { + hasMore: boolean; + totalMatched: number; + loadedCount: number; + isLoadingMore: boolean; + nextLink: StacLink | null; +} + +/** + * Extracts the asset href from a STAC item, preferring S3 alternate if available. + */ +function getAssetHref( + item: StacItemWithAssets, + assetKey: string +): string | null { + const asset = item.assets[assetKey]; + if (!asset) { + return null; + } + + // Prefer S3 alternate if available (usually faster for titiler) + if (asset.alternate?.s3?.href) { + return asset.alternate.s3.href; + } + + return asset.href; +} + +/** + * Hook to fetch STAC items from a STAC server with pagination support. + */ +function useStacItemsSearch({ + id, + changeStatus, + stacCol, + date, + stacApiEndpointToUse, + searchLimit +}: { + id: string; + changeStatus: (params: { status: string; context: STATUS_KEY }) => void; + stacCol: string; + date: Date; + stacApiEndpointToUse: string; + searchLimit: number; +}): { + stacItems: StacItemWithAssets[]; + footprints: Array<{ + bounds: [[number, number], [number, number]]; + geometry?: StacItemWithAssets['geometry']; + }> | null; + pagination: PaginationState; + loadMore: () => void; +} { + const [stacItems, setStacItems] = useState([]); + const [pagination, setPagination] = useState({ + hasMore: false, + totalMatched: 0, + loadedCount: 0, + isLoadingMore: false, + nextLink: null + }); + const loadMoreControllerRef = useRef(null); + + // Reset when date or collection changes. Also abort any in-flight loadMore + // request so its setState calls don't land after the dataset has changed. + useEffect(() => { + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = null; + setStacItems([]); + setPagination({ + hasMore: false, + totalMatched: 0, + loadedCount: 0, + isLoadingMore: false, + nextLink: null + }); + }, [stacCol, date, stacApiEndpointToUse]); + + useEffect(() => { + if (!id || !stacCol) return; + + const controller = new AbortController(); + + const load = async () => { + try { + changeStatus({ status: S_LOADING, context: STATUS_KEY.StacSearch }); + + const searchUrl = `${stacApiEndpointToUse}/search`; + + // Build search payload using standard STAC parameters + const payload = { + collections: [stacCol], + datetime: `${userTzDate2utcString( + startOfDay(date) + )}/${userTzDate2utcString(endOfDay(date))}`, + limit: searchLimit + }; + + if (LOG) { + /* eslint-disable no-console */ + console.groupCollapsed( + 'RasterCogTimeseries %cLoading STAC items', + 'color: orange;', + id + ); + console.log('Search URL', searchUrl); + console.log('Payload', payload); + console.groupEnd(); + /* eslint-enable no-console */ + } + + const responseData = await requestQuickCache({ + url: searchUrl, + payload, + controller + }); + + if (LOG) { + /* eslint-disable no-console */ + console.groupCollapsed( + 'RasterCogTimeseries %cReceived STAC items', + 'color: green;', + id + ); + console.log('STAC response', responseData); + console.groupEnd(); + /* eslint-enable no-console */ + } + + const features = responseData.features || []; + const totalMatched = + responseData.context?.matched || + responseData.numberMatched || + features.length; + const nextLink = responseData.links?.find((l) => l.rel === 'next'); + + setStacItems(features); + setPagination({ + hasMore: !!nextLink, + totalMatched, + loadedCount: features.length, + isLoadingMore: false, + nextLink: nextLink || null + }); + changeStatus({ status: S_SUCCEEDED, context: STATUS_KEY.StacSearch }); + } catch (error) { + if (!controller.signal.aborted) { + setStacItems([]); + setPagination({ + hasMore: false, + totalMatched: 0, + loadedCount: 0, + isLoadingMore: false, + nextLink: null + }); + changeStatus({ status: S_FAILED, context: STATUS_KEY.StacSearch }); + } + if (LOG) { + /* eslint-disable no-console */ + console.log( + 'RasterCogTimeseries %cAborted STAC search', + 'color: red;', + id + ); + console.log(error); + /* eslint-enable no-console */ + } + return; + } + }; + + load(); + + return () => { + controller.abort(); + changeStatus({ status: 'idle', context: STATUS_KEY.StacSearch }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, stacCol, date, stacApiEndpointToUse, searchLimit]); + + const loadMore = useCallback(async () => { + if (!pagination.nextLink || pagination.isLoadingMore) return; + + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = controller; + + setPagination((prev) => ({ ...prev, isLoadingMore: true })); + + try { + const isPost = + pagination.nextLink.method === 'POST' && !!pagination.nextLink.body; + const responseData = await requestQuickCache({ + url: pagination.nextLink.href, + method: isPost ? 'post' : 'get', + payload: isPost ? pagination.nextLink.body : null, + controller + }); + + if (controller.signal.aborted) return; + + const newFeatures = responseData.features || []; + const nextLink = responseData.links?.find((l) => l.rel === 'next'); + + setStacItems((prev) => [...prev, ...newFeatures]); + setPagination((prev) => ({ + ...prev, + hasMore: !!nextLink, + loadedCount: prev.loadedCount + newFeatures.length, + isLoadingMore: false, + nextLink: nextLink || null + })); + } catch (error) { + if (controller.signal.aborted) return; + /* eslint-disable-next-line no-console */ + console.error('Error loading more items:', error); + setPagination((prev) => ({ ...prev, isLoadingMore: false })); + } finally { + if (loadMoreControllerRef.current === controller) { + loadMoreControllerRef.current = null; + } + } + }, [pagination.nextLink, pagination.isLoadingMore]); + + // Footprints to show where the data is when zoom is low + const footprints = useMemo(() => { + if (!stacItems.length) return null; + return stacItems.map((f) => { + const [w, s, e, n] = f.bbox; + return { + bounds: [ + [w, s], + [e, n] + ] as [[number, number], [number, number]], + geometry: f.geometry + }; + }); + }, [stacItems]); + + return { stacItems, footprints, pagination, loadMore }; +} + +/** + * Hook to build COG tile sources from STAC items. + */ +function useCogTileSources({ + id, + stacItems, + tileApiEndpointToUse, + assetKey, + changeStatus +}: { + id: string; + stacItems: StacItemWithAssets[]; + tileApiEndpointToUse: string; + assetKey: string; + changeStatus: (params: { status: string; context: STATUS_KEY }) => void; +}): TileSource[] { + const [tileSources, setTileSources] = useState([]); + + useEffect(() => { + if (!stacItems.length) { + setTileSources([]); + return; + } + + changeStatus({ status: S_LOADING, context: STATUS_KEY.Layer }); + + try { + const sources: TileSource[] = stacItems.reduce( + (acc, item, i) => { + const assetHref = getAssetHref(item, assetKey); + + if (!assetHref) { + if (LOG) { + /* eslint-disable-next-line no-console */ + console.warn( + `RasterCogTimeseries: Asset '${assetKey}' not found in item ${item.id}` + ); + } + return acc; + } + + // Use titiler's COG tile template directly so Mapbox can fetch + // tiles without a tilejson round-trip per item. + const tilesTemplate = `${tileApiEndpointToUse}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png`; + + return [ + ...acc, + { + id: `${id}-item-${i}`, + tilesTemplate, + bbox: item.bbox, + assetHref + } + ]; + }, + [] + ); + + if (LOG) { + /* eslint-disable no-console */ + console.groupCollapsed( + 'RasterCogTimeseries %cBuilt tile sources', + 'color: green;', + id + ); + console.log('Sources', sources); + console.groupEnd(); + /* eslint-enable no-console */ + } + + setTileSources(sources); + changeStatus({ status: S_SUCCEEDED, context: STATUS_KEY.Layer }); + } catch (error) { + /* eslint-disable-next-line no-console */ + console.error('RasterCogTimeseries: Error building tile sources', error); + setTileSources([]); + changeStatus({ status: S_FAILED, context: STATUS_KEY.Layer }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, stacItems, tileApiEndpointToUse, assetKey]); + + return tileSources; +} + +// Default search limit for STAC search +const DEFAULT_SEARCH_LIMIT = 500; + +export function RasterCogTimeseries(props: RasterCogTimeseriesProps) { + const { + id, + stacCol, + date, + sourceParams, + zoomExtent, + bounds, + onStatusChange, + isPositionSet, + hidden, + opacity, + generatorOrder, + stacApiEndpoint, + tileApiEndpoint, + colorMap, + reScale, + envApiStacEndpoint, + envApiRasterEndpoint, + searchLimit = DEFAULT_SEARCH_LIMIT + } = props; + + const { current: mapInstance } = useMaps(); + + const stacApiEndpointToUse = stacApiEndpoint ?? envApiStacEndpoint ?? ''; + const tileApiEndpointToUse = tileApiEndpoint ?? envApiRasterEndpoint ?? ''; + + const { changeStatus } = useRequestStatus({ + id, + onStatusChange, + requestsToTrack: [STATUS_KEY.StacSearch, STATUS_KEY.Layer] + }); + + const { stacItems, footprints, pagination, loadMore } = useStacItemsSearch({ + id, + changeStatus, + stacCol, + date, + stacApiEndpointToUse, + searchLimit + }); + + // Get asset key from sourceParams for tile params + const assetKey = sourceParams?.assets || 'cog_default'; + + const tileSources = useCogTileSources({ + id, + stacItems, + tileApiEndpointToUse, + assetKey, + changeStatus + }); + + // Listen to mouse events on the footprints layer + const onFootprintsClick = useCallback( + (features) => { + const bounds = JSON.parse(features[0].properties.bounds); + mapInstance?.fitBounds(bounds, { padding: FIT_BOUNDS_PADDING }); + }, + [mapInstance] + ); + + // FitBounds when needed + const layerBounds = useMemo( + () => (stacItems?.length ? getMergedBBox(stacItems) : undefined), + [stacItems] + ); + useFitBbox(!!isPositionSet, bounds, layerBounds); + + // Build tile params without the 'assets' key (it's used for asset selection, not tile rendering) + const tileParams = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { assets: _, ...rest } = sourceParams || {}; + return rest; + }, [sourceParams]); + + return ( + <> + {footprints && ( +