From 70d0782b4c3e1a2e509a568e8cb4e1de846cbaa3 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Thu, 14 Sep 2023 14:58:44 +0200 Subject: [PATCH 1/9] reuse raster timeseries --- .../common/map/hooks/use-custom-marker.ts | 28 + .../common/map/hooks/use-fit-bbox.ts | 34 ++ .../common/map/hooks/use-layer-interaction.ts | 41 ++ .../style-generators/raster-timeseries.tsx | 513 ++++++++++++++++++ .../common/map/style/marker-sdf.png | Bin 0 -> 1858 bytes app/scripts/components/common/map/types.d.ts | 8 +- app/scripts/components/common/map/utils.ts | 122 ++++- .../mapbox/layers/raster-timeseries.tsx | 2 + app/scripts/components/exploration/index.tsx | 26 + 9 files changed, 772 insertions(+), 2 deletions(-) create mode 100644 app/scripts/components/common/map/hooks/use-custom-marker.ts create mode 100644 app/scripts/components/common/map/hooks/use-fit-bbox.ts create mode 100644 app/scripts/components/common/map/hooks/use-layer-interaction.ts create mode 100644 app/scripts/components/common/map/style-generators/raster-timeseries.tsx create mode 100644 app/scripts/components/common/map/style/marker-sdf.png 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 0000000000000000000000000000000000000000..78c957691a22ea3a81e1f94921fec6901295576a GIT binary patch literal 1858 zcmeHH`#aMM9R7Z{xsA-sWs)MKbioi#vagxTJYvR)QjIt#3q>SG_DyImxkc30D20&Y z#5p-`BeB9EL&|YYlvG%ex$WrhIOp>`@8^9!&-?!Jd4G75sjiM&&|A>}0BoT+k!hO@ z|9~uVbAKDp5^oY1PIDxIZ(i(UZ60!=PRGIlKpyu4AaI+D-!zD*E}r(A;{WrXO5m@- zV%g1yiz8^R9)Q9DN1FGk08rf`xN3Yu@>|fB6GekxUfuIS9*XZ@hb@02qZ%FZ;B%9%Upp?RdviQ?TFjvALWSiXDHqkV4zBTQ}cbQR6c}#swLT%V& zk}Ae6F;Do9K;Sd;VE$X^!{bx4m%542kr1j3mp}f#{6ezL{Ap?^`_6i;j@~!n6x6#^ zHNSW3qoU!Iqv4Ck^P$VWyj1QYXL~ZMtBRfBBl*`H6Enj*z=73vx24=aXCK zN<2L}J`U#jE$`XiXDXf38Mkibc(OYRFbrMGIclsG-x1>-=R8-P)>l@xfH+%*N1R<) z%dvr1XY=W{w7}VBa4|p5Ij99}sRdi8EM#SYP&!}YryB_qKQ#{br)j+$Mxg_3nP`dK zNfeshw&XE{UYH%4M=IpPuwE?jffWo#VnJsJy2geTA*hKB$3lS6yNgGFfFbYaH_;GO z!iHTTD31-FgP;sHdO5gLUK&;EFpqSA_Tr0{!6GH1hMmN$ zFZ~Lly;nd!IavtT$bEw4#zk@~)uGDM25qmw9vDn~tVWr_A$c{dbWJ7s#T)9ipiv8W z#>Sm;+yn`IH5KfJ$_Kh67kTvQrdak3hwTV(er8p-ac9^Rfc4+r*-yb2%h zS(ICHhgCG&nq}@Wf|2HMY4LR>ei+W{yn{q>+>3u^q;V=?86!gO%KEc<=iq-_v)Zc(7g%l{B0_u7UxQR_hZ!;uxT9dPwfB90@s7rrk5zDKK zZ!Rod+E#gWM@*%-x3gm-bDjTd^y+~Dj+JGN#$7xHbK%KQ&@^8RTvA-{%CoMy(_tr H [ + 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 + )} From aa1d0c4790d2068fc119f281fe3e19628166fbc9 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Mon, 18 Sep 2023 10:09:05 +0200 Subject: [PATCH 2/9] Removed idSuffix --- .../common/map/style-generators/raster-timeseries.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx index 69114fe4a..5f3b7e9a4 100644 --- a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx +++ b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx @@ -45,7 +45,6 @@ export interface RasterTimeseriesProps { bounds?: number[]; onStatusChange?: (result: { status: ActionStatus; id: string }) => void; isHidden?: boolean; - idSuffix?: string; isPositionSet?: boolean; } @@ -71,7 +70,6 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { bounds, onStatusChange, isHidden, - idSuffix = '', isPositionSet } = props; @@ -82,7 +80,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { const { updateStyle } = useMapStyle(); const minZoom = zoomExtent?.[0] ?? 0; - const generatorId = 'raster-timeseries' + idSuffix; + const generatorId = 'raster-timeseries' + id; // Status tracking. // A raster timeseries layer has a base layer and may have markers. From 9f1f6c4c7fe01e926b92195687cd3022ba98ce78 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Mon, 18 Sep 2023 10:24:25 +0200 Subject: [PATCH 3/9] Handle visibility in a generic way --- .../style-generators/raster-timeseries.tsx | 20 ++++------ app/scripts/components/common/map/styles.tsx | 37 +++++++++++-------- app/scripts/components/common/map/types.d.ts | 7 +++- app/scripts/components/exploration/index.tsx | 1 + 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx index 5f3b7e9a4..e50d6313d 100644 --- a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx +++ b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx @@ -11,7 +11,7 @@ import { } from 'mapbox-gl'; import { useTheme } from 'styled-components'; import { featureCollection, point } from '@turf/helpers'; -import { StacFeature } from '../types'; +import { BaseGeneratorParams, StacFeature } from '../types'; import { useMapStyle } from '../styles'; import { FIT_BOUNDS_PADDING, @@ -36,7 +36,7 @@ import { // Whether or not to print the request logs. const LOG = true; -export interface RasterTimeseriesProps { +export interface RasterTimeseriesProps extends BaseGeneratorParams { id: string; stacCol: string; date: Date; @@ -44,7 +44,6 @@ export interface RasterTimeseriesProps { zoomExtent?: number[]; bounds?: number[]; onStatusChange?: (result: { status: ActionStatus; id: string }) => void; - isHidden?: boolean; isPositionSet?: boolean; } @@ -69,8 +68,8 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { zoomExtent, bounds, onStatusChange, - isHidden, - isPositionSet + isPositionSet, + hidden, } = props; @@ -383,11 +382,8 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { id: id, type: 'raster', source: id, - layout: { - visibility: isHidden ? 'none' : 'visible' - }, paint: { - 'raster-opacity': Number(!isHidden), + 'raster-opacity': Number(!hidden), 'raster-opacity-transition': { duration: 320 } @@ -423,7 +419,6 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { source: pointsSourceId, layout: { ...(markerLayout as any), - visibility: isHidden ? 'none' : 'visible', 'icon-allow-overlap': true }, paint: { @@ -446,7 +441,8 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { updateStyle({ generatorId, sources, - layers + layers, + params: props as BaseGeneratorParams }); } @@ -464,7 +460,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { minZoom, points, haveSourceParamsChanged, - isHidden, + hidden, generatorId ] ); diff --git a/app/scripts/components/common/map/styles.tsx b/app/scripts/components/common/map/styles.tsx index 1c2bc3a50..9c0b6d1ca 100644 --- a/app/scripts/components/common/map/styles.tsx +++ b/app/scripts/components/common/map/styles.tsx @@ -1,4 +1,4 @@ -import { AnySourceImpl, Style } from 'mapbox-gl'; +import { AnyLayer, AnySourceImpl, Layer, Style } from 'mapbox-gl'; import React, { ReactNode, createContext, @@ -7,18 +7,18 @@ import React, { 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; } export const StylesContext = createContext({ - updateStyle: (params: GeneratorParams) => { + updateStyle: (params: GeneratorStyleParams) => { return params; } }); @@ -37,7 +37,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 +48,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; - layers = [...layers, ...layersWithMeta]; + const mapLayer = { ...generatorLayer, metadata } as Layer; + + if (generatorParams.params?.hidden) { + mapLayer.layout = { + ...mapLayer.layout, + visibility: 'none' + }; + } + return mapLayer as ExtendedLayer; + }); + + layers = [...layers, ...generatorLayers]; }); // Allow sort as it uses a copy of the array so mutating is ok @@ -93,14 +100,14 @@ export function Styles({ onStyleUpdate?: (style: ExtendedStyle) => void; children?: ReactNode; }) { - const [stylesData, setStylesData] = useState>( + const [stylesData, setStylesData] = useState>( {} ); const [style, setStyle] = useState