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..af38c3980 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-custom-marker.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import markerSdfUrl from '../style/marker-sdf.png'; + +const CUSTOM_MARKER_ID = 'marker-sdf'; + +export const MARKER_LAYOUT = { + '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]); +} 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..0b40b4603 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-fit-bbox.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { OptionalBbox } from "../types"; +import { FIT_BOUNDS_PADDING, checkFitBoundsFromLayer } from "../utils"; +import useMaps from "./use-maps"; + +/** + * 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 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( + isUserPositionSet: boolean, + initialBbox: OptionalBbox, + stacBbox: OptionalBbox +) { + const { current: mapInstance } = useMaps(); + useEffect(() => { + if (isUserPositionSet || !mapInstance) 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..cddab9d80 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-layer-interaction.ts @@ -0,0 +1,40 @@ + +import { Feature } from 'geojson'; +import { useEffect } from 'react'; +import useMaps from './use-maps'; + +interface LayerInteractionHookOptions { + layerId: string; + onClick: (features: Feature[]) => void; +} +export default function useLayerInteraction({ + layerId, + onClick +}: LayerInteractionHookOptions) { + const { current: mapInstance } = useMaps(); + 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/hooks/use-map-compare.ts b/app/scripts/components/common/map/hooks/use-map-compare.ts index b5d17b732..275b1a7f1 100644 --- a/app/scripts/components/common/map/hooks/use-map-compare.ts +++ b/app/scripts/components/common/map/hooks/use-map-compare.ts @@ -1,11 +1,10 @@ -import { useContext, useEffect } from 'react'; +import { useEffect } from 'react'; import MapboxCompare from 'mapbox-gl-compare'; -import { MapsContext } from '../maps'; -import useMaps from './use-maps'; +import useMaps, { useMapsContext } from './use-maps'; export default function useMapCompare() { const { main, compared } = useMaps(); - const { containerId } = useContext(MapsContext); + const { containerId } = useMapsContext(); const hasMapCompare = !!compared; useEffect(() => { if (!main) return; diff --git a/app/scripts/components/common/map/hooks/use-map-style.ts b/app/scripts/components/common/map/hooks/use-map-style.ts new file mode 100644 index 000000000..d57e15622 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-map-style.ts @@ -0,0 +1,16 @@ +import { useContext } from "react"; +import { StylesContext } from "../styles"; +import useCustomMarker from "./use-custom-marker"; +import useMaps from "./use-maps"; + +export function useStylesContext() { + return useContext(StylesContext); +} + +export default function useMapStyle() { + const { updateStyle, style } = useStylesContext(); + const { current } = useMaps(); + useCustomMarker(current); + + return { updateStyle, style }; +} diff --git a/app/scripts/components/common/map/hooks/use-maps.ts b/app/scripts/components/common/map/hooks/use-maps.ts index 56f877e2b..3433fa1b8 100644 --- a/app/scripts/components/common/map/hooks/use-maps.ts +++ b/app/scripts/components/common/map/hooks/use-maps.ts @@ -1,13 +1,23 @@ import { useContext } from 'react'; import { useMap } from 'react-map-gl'; import { MapsContext } from '../maps'; +import { useStylesContext } from './use-map-style'; + +export function useMapsContext() { + return useContext(MapsContext); +} export default function useMaps() { - const { mainId, comparedId } = useContext(MapsContext); + const { mainId, comparedId } = useMapsContext(); + const { isCompared } = useStylesContext(); const maps = useMap(); + const main = maps[mainId]; + const compared = maps[comparedId]; + const current = isCompared ? compared : main; return { - main: maps[mainId], - compared: maps[comparedId] + main, + compared, + current }; } diff --git a/app/scripts/components/common/map/map-component.tsx b/app/scripts/components/common/map/map-component.tsx index 1e6ac3660..ac6dec08e 100644 --- a/app/scripts/components/common/map/map-component.tsx +++ b/app/scripts/components/common/map/map-component.tsx @@ -1,11 +1,11 @@ -import React, { useCallback, ReactElement, useContext, useMemo } from 'react'; +import React, { useCallback, ReactElement, useMemo } from 'react'; import ReactMapGlMap from 'react-map-gl'; import { ProjectionOptions } from 'veda'; import 'mapbox-gl/dist/mapbox-gl.css'; import 'mapbox-gl-compare/dist/mapbox-gl-compare.css'; import { convertProjectionToMapbox } from '../mapbox/map-options/utils'; -import { useMapStyle } from './styles'; -import { MapsContext } from './maps'; +import useMapStyle from './hooks/use-map-style'; +import { useMapsContext } from './hooks/use-maps'; export default function MapComponent({ controls, @@ -17,7 +17,7 @@ export default function MapComponent({ projection?: ProjectionOptions; }) { const { initialViewState, setInitialViewState, mainId, comparedId } = - useContext(MapsContext); + useMapsContext(); const id = isCompared ? comparedId : mainId; @@ -30,11 +30,11 @@ export default function MapComponent({ [isCompared, setInitialViewState] ); - // Get MGL projection from Veda projection - const mapboxProjection = useMemo(() => { - if (!projection) return undefined; - return convertProjectionToMapbox(projection); - }, [projection]); + // Get MGL projection from Veda projection + const mapboxProjection = useMemo(() => { + if (!projection) return undefined; + return convertProjectionToMapbox(projection); + }, [projection]); const { style } = useMapStyle(); diff --git a/app/scripts/components/common/map/maps.tsx b/app/scripts/components/common/map/maps.tsx index 9899b7977..b3864132a 100644 --- a/app/scripts/components/common/map/maps.tsx +++ b/app/scripts/components/common/map/maps.tsx @@ -5,8 +5,7 @@ import React, { ReactElement, JSXElementConstructor, useState, - createContext, - useContext + createContext } from 'react'; import styled from 'styled-components'; import { @@ -23,7 +22,7 @@ import MapboxStyleOverride from './mapbox-style-override'; import { Styles } from './styles'; import useMapCompare from './hooks/use-map-compare'; import MapComponent from './map-component'; -import useMaps from './hooks/use-maps'; +import useMaps, { useMapsContext } from './hooks/use-maps'; const chevronRightURI = () => iconDataURI(CollecticonChevronRightSmall, { @@ -116,7 +115,7 @@ function Maps({ children, projection }: MapsProps) { } }); - const { containerId } = useContext(MapsContext); + const { containerId } = useMapsContext(); return ( @@ -125,7 +124,7 @@ function Maps({ children, projection }: MapsProps) { {!!compareGenerators.length && ( - + {compareGenerators} ; + zoomExtent?: number[]; + bounds?: number[]; + onStatusChange?: (result: { status: ActionStatus; id: string }) => void; + 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, + isPositionSet, + hidden, + } = props; + + + const { current: mapInstance } = useMaps(); + + const theme = useTheme(); + const { updateStyle } = useMapStyle(); + + const minZoom = zoomExtent?.[0] ?? 0; + const generatorId = 'raster-timeseries' + id; + + // 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. + ]); + + // + // 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, + paint: { + 'raster-opacity': Number(!hidden), + '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: { + ...MARKER_LAYOUT as any, + '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, + params: props as BaseGeneratorParams + }); + } + + run(); + + return () => { + controller.abort(); + }; + }, + // sourceParams not included, but using a stringified version of it to detect changes (haveSourceParamsChanged) + [ + updateStyle, + id, + mosaicUrl, + minZoom, + points, + haveSourceParamsChanged, + hidden, + 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`, + onClick: onPointsClick + }); + + // + // FitBounds when needed + // + const layerBounds = useMemo( + () => (stacCollection.length ? getMergedBBox(stacCollection) : undefined), + [stacCollection] + ); + useFitBbox(!!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/styles.tsx b/app/scripts/components/common/map/styles.tsx index 1c2bc3a50..315ae07ed 100644 --- a/app/scripts/components/common/map/styles.tsx +++ b/app/scripts/components/common/map/styles.tsx @@ -1,29 +1,32 @@ -import { AnySourceImpl, Style } from 'mapbox-gl'; +import { AnySourceImpl, Layer, Style } from 'mapbox-gl'; import React, { ReactNode, createContext, useCallback, - useContext, useEffect, useState } from 'react'; -import { ExtendedLayer, GeneratorParams, LayerOrderPosition } from './types'; - +import { + ExtendedLayer, + GeneratorStyleParams, + LayerOrderPosition +} from './types'; interface StylesContextType { - updateStyle: (params: GeneratorParams) => void; + updateStyle: (params: GeneratorStyleParams) => void; style?: Style; updateMetaData?: (params: unknown) => void; metaData?: unknown; + isCompared?: boolean; } export const StylesContext = createContext({ - updateStyle: (params: GeneratorParams) => { + updateStyle: (params: GeneratorStyleParams) => { return params; - } + }, + isCompared: false }); - const LAYER_ORDER: LayerOrderPosition[] = [ 'basemap-background', 'raster', @@ -37,7 +40,7 @@ export type ExtendedStyle = ReturnType; // Takes in a dictionary associating each generator id with a series of // Mapbox layers and sources to be added to the final style. Outputs // a style object directly usable by the map instance. -const generateStyle = (stylesData: Record) => { +const generateStyle = (stylesData: Record) => { let sources: Record = {}; let layers: ExtendedLayer[] = []; @@ -48,15 +51,22 @@ const generateStyle = (stylesData: Record) => { ...generatorParams.sources }; - const layersWithMeta = [ - ...generatorParams.layers.map((layer) => { - const metadata = layer.metadata ?? {}; - metadata.generatorId = generatorId; - return { ...layer, metadata }; - }) - ]; + const generatorLayers = generatorParams.layers.map((generatorLayer) => { + const metadata = generatorLayer.metadata ?? {}; + metadata.generatorId = generatorId; + + const mapLayer = { ...generatorLayer, metadata } as Layer; + + if (generatorParams.params?.hidden) { + mapLayer.layout = { + ...mapLayer.layout, + visibility: 'none' + }; + } + return mapLayer as ExtendedLayer; + }); - layers = [...layers, ...layersWithMeta]; + layers = [...layers, ...generatorLayers]; }); // Allow sort as it uses a copy of the array so mutating is ok @@ -88,19 +98,21 @@ const generateStyle = (stylesData: Record) => { export function Styles({ onStyleUpdate, - children + children, + isCompared }: { onStyleUpdate?: (style: ExtendedStyle) => void; children?: ReactNode; + isCompared?: boolean; }) { - const [stylesData, setStylesData] = useState>( - {} - ); + const [stylesData, setStylesData] = useState< + Record + >({}); const [style, setStyle] = useState