diff --git a/app/scripts/components/common/map/hooks/use-custom-marker.ts b/app/scripts/components/common/map/hooks/use-custom-marker.ts new file mode 100644 index 000000000..dad0541b9 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-custom-marker.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; + +import markerSdfUrl from '../style/marker-sdf.png'; + +const CUSTOM_MARKER_ID = 'marker-sdf'; + +const markerLayout = { + 'icon-image': CUSTOM_MARKER_ID, + 'icon-size': 0.25, + 'icon-anchor': 'bottom' +}; + +export default function useCustomMarker(mapInstance) { + useEffect(() => { + if (!mapInstance) return; + mapInstance.loadImage(markerSdfUrl, (error, image) => { + if (error) throw error; + if (!image) return; + if (mapInstance.hasImage(CUSTOM_MARKER_ID)) { + mapInstance.removeImage(CUSTOM_MARKER_ID); + } + // add image to the active style and make it SDF-enabled + mapInstance.addImage(CUSTOM_MARKER_ID, image, { sdf: true }); + }); + }, [mapInstance]); + + return markerLayout; +} diff --git a/app/scripts/components/common/map/hooks/use-fit-bbox.ts b/app/scripts/components/common/map/hooks/use-fit-bbox.ts new file mode 100644 index 000000000..740642324 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-fit-bbox.ts @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { Map as MapboxMap } from 'mapbox-gl'; +import { OptionalBbox } from "../types"; +import { FIT_BOUNDS_PADDING, checkFitBoundsFromLayer } from "../utils"; + +/** + * Centers on the given bounds if the current position is not within the bounds, + * and there's no user defined position (via user initiated map movement). Gives + * preference to the layer defined bounds over the STAC collection bounds. + * + * @param mapInstance Mapbox instance + * @param isUserPositionSet Whether the user has set a position + * @param initialBbox Bounding box from the layer + * @param stacBbox Bounds from the STAC collection + */ +export default function useFitBbox( + mapInstance: MapboxMap, + isUserPositionSet: boolean, + initialBbox: OptionalBbox, + stacBbox: OptionalBbox +) { + useEffect(() => { + if (isUserPositionSet) return; + + // Prefer layer defined bounds to STAC collection bounds. + const bounds = (initialBbox ?? stacBbox) as + | [number, number, number, number] + | undefined; + + if (bounds?.length && checkFitBoundsFromLayer(bounds, mapInstance)) { + mapInstance.fitBounds(bounds, { padding: FIT_BOUNDS_PADDING }); + } + }, [mapInstance, isUserPositionSet, initialBbox, stacBbox]); +} diff --git a/app/scripts/components/common/map/hooks/use-layer-interaction.ts b/app/scripts/components/common/map/hooks/use-layer-interaction.ts new file mode 100644 index 000000000..b5667272d --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-layer-interaction.ts @@ -0,0 +1,41 @@ + +import { Feature } from 'geojson'; +import { Map as MapboxMap } from 'mapbox-gl'; +import { useEffect } from 'react'; + +interface LayerInteractionHookOptions { + layerId: string; + mapInstance: MapboxMap; + onClick: (features: Feature[]) => void; +} +export default function useLayerInteraction({ + layerId, + mapInstance, + onClick +}: LayerInteractionHookOptions) { + useEffect(() => { + if (!mapInstance) return; + const onPointsClick = (e) => { + if (!e.features.length) return; + onClick(e.features); + }; + + const onPointsEnter = () => { + mapInstance.getCanvas().style.cursor = 'pointer'; + }; + + const onPointsLeave = () => { + mapInstance.getCanvas().style.cursor = ''; + }; + + mapInstance.on('click', layerId, onPointsClick); + mapInstance.on('mouseenter', layerId, onPointsEnter); + mapInstance.on('mouseleave', layerId, onPointsLeave); + + return () => { + mapInstance.off('click', layerId, onPointsClick); + mapInstance.off('mouseenter', layerId, onPointsEnter); + mapInstance.off('mouseleave', layerId, onPointsLeave); + }; + }, [layerId, mapInstance, onClick]); +} \ No newline at end of file diff --git a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx new file mode 100644 index 000000000..69114fe4a --- /dev/null +++ b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx @@ -0,0 +1,513 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import qs from 'qs'; +import { + AnyLayer, + AnySourceImpl, + GeoJSONSourceRaw, + LngLatBoundsLike, + RasterLayer, + RasterSource, + SymbolLayer +} from 'mapbox-gl'; +import { useTheme } from 'styled-components'; +import { featureCollection, point } from '@turf/helpers'; +import { StacFeature } from '../types'; +import { useMapStyle } from '../styles'; +import { + FIT_BOUNDS_PADDING, + getFilterPayload, + getMergedBBox, + requestQuickCache, +} from '../utils'; +import useFitBbox from '../hooks/use-fit-bbox'; +import useLayerInteraction from '../hooks/use-layer-interaction'; +import useCustomMarker from '../hooks/use-custom-marker'; +import useMaps from '../hooks/use-maps'; + +import { + ActionStatus, + S_FAILED, + S_IDLE, + S_LOADING, + S_SUCCEEDED +} from '$utils/status'; + + +// Whether or not to print the request logs. +const LOG = true; + +export interface RasterTimeseriesProps { + id: string; + stacCol: string; + date: Date; + sourceParams?: Record; + zoomExtent?: number[]; + bounds?: number[]; + onStatusChange?: (result: { status: ActionStatus; id: string }) => void; + isHidden?: boolean; + idSuffix?: string; + isPositionSet?: boolean; +} + +enum STATUS_KEY { + Global, + Layer, + StacSearch +} + +interface Statuses { + [STATUS_KEY.Global]: ActionStatus; + [STATUS_KEY.Layer]: ActionStatus; + [STATUS_KEY.StacSearch]: ActionStatus; +} + +export function RasterTimeseries(props: RasterTimeseriesProps) { + const { + id, + stacCol, + date, + sourceParams, + zoomExtent, + bounds, + onStatusChange, + isHidden, + idSuffix = '', + isPositionSet + } = props; + + + const { main: mapInstance } = useMaps(); + + const theme = useTheme(); + const { updateStyle } = useMapStyle(); + + const minZoom = zoomExtent?.[0] ?? 0; + const generatorId = 'raster-timeseries' + idSuffix; + + // Status tracking. + // A raster timeseries layer has a base layer and may have markers. + // The status is succeeded only if all requests succeed. + const statuses = useRef({ + [STATUS_KEY.Global]: S_IDLE, + [STATUS_KEY.Layer]: S_IDLE, + [STATUS_KEY.StacSearch]: S_IDLE + }); + + const changeStatus = useCallback( + ({ + status, + context + }: { + status: ActionStatus; + context: STATUS_KEY.StacSearch | STATUS_KEY.Layer; + }) => { + // Set the new status + statuses.current[context] = status; + + const layersToCheck = [ + statuses.current[STATUS_KEY.StacSearch], + statuses.current[STATUS_KEY.Layer] + ]; + + let newStatus = statuses.current[STATUS_KEY.Global]; + // All must succeed to be considered successful. + if (layersToCheck.every((s) => s === S_SUCCEEDED)) { + newStatus = S_SUCCEEDED; + + // One failed status is enough for all. + // Failed takes priority over loading. + } else if (layersToCheck.some((s) => s === S_FAILED)) { + newStatus = S_FAILED; + // One loading status is enough for all. + } else if (layersToCheck.some((s) => s === S_LOADING)) { + newStatus = S_LOADING; + } else if (layersToCheck.some((s) => s === S_IDLE)) { + newStatus = S_IDLE; + } + + // Only emit on status change. + if (newStatus !== statuses.current[STATUS_KEY.Global]) { + statuses.current[STATUS_KEY.Global] = newStatus; + onStatusChange?.({ status: newStatus, id }); + } + }, + [id, onStatusChange] + ); + + // + // Load stac collection features + // + const [stacCollection, setStacCollection] = useState([]); + useEffect(() => { + if (!id || !stacCol || !date) return; + + const controller = new AbortController(); + + const load = async () => { + try { + changeStatus({ status: S_LOADING, context: STATUS_KEY.StacSearch }); + const payload = { + 'filter-lang': 'cql2-json', + filter: getFilterPayload(date, stacCol), + limit: 500, + fields: { + include: ['bbox'], + exclude: ['collection', 'links'] + } + }; + + /* eslint-disable no-console */ + LOG && + console.groupCollapsed( + 'RasterTimeseries %cLoading STAC features', + 'color: orange;', + id + ); + LOG && console.log('Payload', payload); + LOG && console.groupEnd(); + /* eslint-enable no-console */ + + const responseData = await requestQuickCache({ + url: `${process.env.API_STAC_ENDPOINT}/search`, + payload, + controller + }); + + /* eslint-disable no-console */ + LOG && + console.groupCollapsed( + 'RasterTimeseries %cAdding STAC features', + 'color: green;', + id + ); + LOG && console.log('STAC response', responseData); + LOG && console.groupEnd(); + /* eslint-enable no-console */ + + setStacCollection(responseData.features); + changeStatus({ status: S_SUCCEEDED, context: STATUS_KEY.StacSearch }); + } catch (error) { + if (!controller.signal.aborted) { + setStacCollection([]); + changeStatus({ status: S_FAILED, context: STATUS_KEY.StacSearch }); + } + LOG && + /* eslint-disable-next-line no-console */ + console.log( + 'RasterTimeseries %cAborted STAC features', + 'color: red;', + id + ); + return; + } + }; + load(); + return () => { + controller.abort(); + changeStatus({ status: 'idle', context: STATUS_KEY.StacSearch }); + }; + }, [id, changeStatus, stacCol, date]); + + // + // Markers + // + const points = useMemo(() => { + if (!stacCollection.length) return null; + const points = stacCollection.map((f) => { + const [w, s, e, n] = f.bbox; + return { + bounds: [ + [w, s], + [e, n] + ] as LngLatBoundsLike, + center: [(w + e) / 2, (s + n) / 2] as [number, number] + }; + }); + + return points; + }, [stacCollection]); + + // + // Tiles + // + const [mosaicUrl, setMosaicUrl] = useState(null); + useEffect(() => { + if (!id || !stacCol) return; + + // If the search returned no data, remove anything previously there so we + // don't run the risk that the selected date and data don't match, even + // though if a search returns no data, that date should not be available for + // the dataset - may be a case of bad configuration. + if (!stacCollection.length) { + setMosaicUrl(null); + return; + } + + const controller = new AbortController(); + + const load = async () => { + changeStatus({ status: S_LOADING, context: STATUS_KEY.Layer }); + try { + const payload = { + 'filter-lang': 'cql2-json', + filter: getFilterPayload(date, stacCol) + }; + + /* eslint-disable no-console */ + LOG && + console.groupCollapsed( + 'RasterTimeseries %cLoading Mosaic', + 'color: orange;', + id + ); + LOG && console.log('Payload', payload); + LOG && console.groupEnd(); + /* eslint-enable no-console */ + + const responseData = await requestQuickCache({ + url: `${process.env.API_RASTER_ENDPOINT}/mosaic/register`, + payload, + controller + }); + + setMosaicUrl(responseData.links[1].href); + + /* eslint-disable no-console */ + LOG && + console.groupCollapsed( + 'RasterTimeseries %cAdding Mosaic', + 'color: green;', + id + ); + // links[0] : metadata , links[1]: tile + LOG && console.log('Url', responseData.links[1].href); + LOG && console.log('STAC response', responseData); + LOG && console.groupEnd(); + /* eslint-enable no-console */ + changeStatus({ status: S_SUCCEEDED, context: STATUS_KEY.Layer }); + } catch (error) { + if (!controller.signal.aborted) { + changeStatus({ status: S_FAILED, context: STATUS_KEY.Layer }); + } + LOG && + /* eslint-disable-next-line no-console */ + console.log( + 'RasterTimeseries %cAborted Mosaic', + 'color: red;', + id + ); + return; + } + }; + + load(); + + return () => { + controller.abort(); + changeStatus({ status: 'idle', context: STATUS_KEY.Layer }); + }; + }, [ + // The `showMarkers` and `isHidden` dep are left out on purpose, as visibility + // is controlled below, but we need the value to initialize the layer + // visibility. + stacCollection + // This hook depends on a series of properties, but whenever they change the + // `stacCollection` is guaranteed to change because a new STAC request is + // needed to show the data. The following properties are therefore removed + // from the dependency array: + // - id + // - changeStatus + // - stacCol + // - date + // Keeping then in would cause multiple requests because for example when + // `date` changes the hook runs, then the STAC request in the hook above + // fires and `stacCollection` changes, causing this hook to run again. This + // resulted in a race condition when adding the source to the map leading to + // an error. + ]); + + const markerLayout = useCustomMarker(mapInstance); + + // + // Generate Mapbox GL layers and sources for raster timeseries + // + const haveSourceParamsChanged = useMemo( + () => JSON.stringify(sourceParams), + [sourceParams] + ); + + useEffect( + () => { + const controller = new AbortController(); + + async function run() { + let layers: AnyLayer[] = []; + let sources: Record = {}; + + if (mosaicUrl) { + const tileParams = qs.stringify( + { + assets: 'cog_default', + ...sourceParams + }, + // Temporary solution to pass different tile parameters for hls data + { + arrayFormat: id.toLowerCase().includes('hls') ? 'repeat' : 'comma' + } + ); + + const tilejsonUrl = `${mosaicUrl}?${tileParams}`; + + let tileServerUrl: string | undefined = undefined; + try { + const tilejsonData = await requestQuickCache({ + url: tilejsonUrl, + method: 'GET', + payload: null, + controller + }); + tileServerUrl = tilejsonData.tiles[0]; + } catch (error) { + // Ignore errors. + } + + const wmtsBaseUrl = mosaicUrl.replace( + 'tilejson.json', + 'WMTSCapabilities.xml' + ); + + const mosaicSource: RasterSource = { + type: 'raster', + url: tilejsonUrl + }; + + const mosaicLayer: RasterLayer = { + id: id, + type: 'raster', + source: id, + layout: { + visibility: isHidden ? 'none' : 'visible' + }, + paint: { + 'raster-opacity': Number(!isHidden), + 'raster-opacity-transition': { + duration: 320 + } + }, + minzoom: minZoom, + metadata: { + id, + layerOrderPosition: 'raster', + xyzTileUrl: tileServerUrl, + wmtsTileUrl: `${wmtsBaseUrl}?${tileParams}` + } + }; + + sources = { + ...sources, + [id]: mosaicSource + }; + layers = [...layers, mosaicLayer]; + } + + if (points && minZoom > 0) { + const pointsSourceId = `${id}-points`; + const pointsSource: GeoJSONSourceRaw = { + type: 'geojson', + data: featureCollection( + points.map((p) => point(p.center, { bounds: p.bounds })) + ) + }; + + const pointsLayer: SymbolLayer = { + type: 'symbol', + id: pointsSourceId, + source: pointsSourceId, + layout: { + ...(markerLayout as any), + visibility: isHidden ? 'none' : 'visible', + 'icon-allow-overlap': true + }, + paint: { + 'icon-color': theme.color?.primary, + 'icon-halo-color': theme.color?.base, + 'icon-halo-width': 1 + }, + maxzoom: minZoom, + metadata: { + layerOrderPosition: 'markers' + } + }; + sources = { + ...sources, + [pointsSourceId]: pointsSource as AnySourceImpl + }; + layers = [...layers, pointsLayer]; + } + + updateStyle({ + generatorId, + sources, + layers + }); + } + + run(); + + return () => { + controller.abort(); + }; + }, + // sourceParams not included, but using a stringified version of it to detect changes (haveSourceParamsChanged) + [ + updateStyle, + id, + mosaicUrl, + minZoom, + points, + haveSourceParamsChanged, + isHidden, + generatorId + ] + ); + + // + // Cleanup layers on unmount. + // + useEffect(() => { + return () => { + updateStyle({ + generatorId, + sources: {}, + layers: [] + }); + }; + }, [updateStyle, generatorId]); + + // + // Listen to mouse events on the markers layer + // + const onPointsClick = useCallback( + (features) => { + const bounds = JSON.parse(features[0].properties.bounds); + mapInstance?.fitBounds(bounds, { padding: FIT_BOUNDS_PADDING }); + }, + [mapInstance] + ); + useLayerInteraction({ + layerId: `${id}-points`, + mapInstance, + onClick: onPointsClick + }); + + // + // FitBounds when needed + // + const layerBounds = useMemo( + () => (stacCollection.length ? getMergedBBox(stacCollection) : undefined), + [stacCollection] + ); + useFitBbox(mapInstance as any, !!isPositionSet, bounds, layerBounds); + + return null; +} diff --git a/app/scripts/components/common/map/style/marker-sdf.png b/app/scripts/components/common/map/style/marker-sdf.png new file mode 100644 index 000000000..78c957691 Binary files /dev/null and b/app/scripts/components/common/map/style/marker-sdf.png differ diff --git a/app/scripts/components/common/map/types.d.ts b/app/scripts/components/common/map/types.d.ts index d7d2a2198..8997ee01f 100644 --- a/app/scripts/components/common/map/types.d.ts +++ b/app/scripts/components/common/map/types.d.ts @@ -20,4 +20,10 @@ export type LayerOrderPosition = | 'vector' | 'basemap-foreground'; -export type MapId = 'main' | 'compared' \ No newline at end of file +export type MapId = 'main' | 'compared' + +export interface StacFeature { + bbox: [number, number, number, number]; +} + +export type OptionalBbox = number[] | undefined | null; \ No newline at end of file diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index 4ff661f57..555466c64 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -1,4 +1,124 @@ +import axios, { Method } from 'axios'; +import { Map as MapboxMap } from 'mapbox-gl'; +import { endOfDay, startOfDay } from "date-fns"; +import { StacFeature } from "./types"; +import { userTzDate2utcString } from "$utils/date"; import { validateRangeNum } from "$utils/utils"; + +export const FIT_BOUNDS_PADDING = 32; + export const validateLon = validateRangeNum(-180, 180); -export const validateLat = validateRangeNum(-90, 90); \ No newline at end of file +export const validateLat = validateRangeNum(-90, 90); + + + +export function getMergedBBox(features: StacFeature[]) { + const mergedBBox = [ + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY + ]; + return features.reduce( + (acc, feature) => [ + feature.bbox[0] < acc[0] ? feature.bbox[0] : acc[0], + feature.bbox[1] < acc[1] ? feature.bbox[1] : acc[1], + feature.bbox[2] > acc[2] ? feature.bbox[2] : acc[2], + feature.bbox[3] > acc[3] ? feature.bbox[3] : acc[3] + ], + mergedBBox + ) as [number, number, number, number]; +} + + + +export function checkFitBoundsFromLayer( + layerBounds?: [number, number, number, number], + mapInstance?: MapboxMap +) { + if (!layerBounds || !mapInstance) return false; + + const [minXLayer, minYLayer, maxXLayer, maxYLayer] = layerBounds; + const [[minXMap, minYMap], [maxXMap, maxYMap]] = mapInstance + .getBounds() + .toArray(); + const isOutside = + maxXLayer < minXMap || + minXLayer > maxXMap || + maxYLayer < minYMap || + minYLayer > maxYMap; + const layerExtentSmaller = + maxXLayer - minXLayer < maxXMap - minXMap && + maxYLayer - minYLayer < maxYMap - minYMap; + + // only fitBounds if layer extent is smaller than viewport extent (ie zoom to area of interest), + // or if layer extent does not overlap at all with viewport extent (ie pan to area of interest) + return layerExtentSmaller || isOutside; +} + + + +/** + * Creates the appropriate filter object to send to STAC. + * + * @param {Date} date Date to request + * @param {string} collection STAC collection to request + * @returns Object + */ +export function getFilterPayload(date: Date, collection: string) { + return { + op: 'and', + args: [ + { + op: '>=', + args: [{ property: 'datetime' }, userTzDate2utcString(startOfDay(date))] + }, + { + op: '<=', + args: [{ property: 'datetime' }, userTzDate2utcString(endOfDay(date))] + }, + { + op: 'eq', + args: [{ property: 'collection' }, collection] + } + ] + }; +} + + +// There are cases when the data can't be displayed properly on low zoom levels. +// In these cases instead of displaying the raster tiles, we display markers to +// indicate whether or not there is data in a given location. When the user +// crosses the marker threshold, if below the min zoom we have to request the +// marker position, and if above we have to register a mosaic query. Since this +// switching can happen several times, we cache the api response using the +// request params as key. +const quickCache = new Map(); +interface RequestQuickCacheParams { + url: string; + method?: Method; + payload?: any; + controller: AbortController; +} +export async function requestQuickCache({ + url, + payload, + controller, + method = 'post' +}: RequestQuickCacheParams) { + const key = `${method}:${url}${JSON.stringify(payload)}`; + + // No cache found, make request. + if (!quickCache.has(key)) { + const response = await axios({ + url, + method, + data: payload, + signal: controller.signal + }); + quickCache.set(key, response.data); + } + return quickCache.get(key); +} + diff --git a/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx b/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx index c6b7497d9..8d8f06038 100644 --- a/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx +++ b/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx @@ -80,6 +80,8 @@ export function MapLayerRasterTimeseries(props: MapLayerRasterTimeseriesProps) { isPositionSet } = props; + console.log(props) + const theme = useTheme(); const { updateStyle } = useMapStyle(); diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index 4f284dc0b..0bb3ff431 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -21,6 +21,10 @@ import MapCoordsControl from '$components/common/map/controls/coords'; import MapOptionsControl from '$components/common/map/controls/options'; import { projectionDefault } from '$components/common/map/controls/map-options/projections'; import { useBasemap } from '$components/common/map/controls/hooks/use-basemap'; +import { + MapLayerRasterTimeseries, + RasterTimeseries +} from '$components/common/map/style-generators/raster-timeseries'; const Container = styled.div` display: flex; @@ -101,6 +105,17 @@ function Exploration() { labelsOption={labelsOption} boundariesOption={boundariesOption} /> + {/* Map controls */} @@ -119,6 +134,17 @@ function Exploration() { // Compare map layers + )}