diff --git a/app/scripts/components/common/map/hooks/use-generator-params.ts b/app/scripts/components/common/map/hooks/use-generator-params.ts new file mode 100644 index 000000000..5eed747f3 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-generator-params.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; +import { BaseGeneratorParams } from '../types'; + +export default function useGeneratorParams(props: BaseGeneratorParams) { + return useMemo(() => { + return props; + // Memoize only required abse params + }, [props.generatorOrder, props.hidden, props.opacity]); +} 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 ecc88e709..9f2bbac5c 100644 --- a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx +++ b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx @@ -17,12 +17,13 @@ import { FIT_BOUNDS_PADDING, getFilterPayload, getMergedBBox, - requestQuickCache, + requestQuickCache } from '../utils'; import useFitBbox from '../hooks/use-fit-bbox'; import useLayerInteraction from '../hooks/use-layer-interaction'; import { MARKER_LAYOUT } from '../hooks/use-custom-marker'; import useMaps from '../hooks/use-maps'; +import useGeneratorParams from '../hooks/use-generator-params'; import { ActionStatus, @@ -32,7 +33,6 @@ import { S_SUCCEEDED } from '$utils/status'; - // Whether or not to print the request logs. const LOG = true; @@ -70,16 +70,16 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { onStatusChange, isPositionSet, hidden, + opacity } = props; - const { current: mapInstance } = useMaps(); const theme = useTheme(); const { updateStyle } = useMapStyle(); const minZoom = zoomExtent?.[0] ?? 0; - const generatorId = 'raster-timeseries' + id; + const generatorId = `raster-timeseries-${id}`; // Status tracking. // A raster timeseries layer has a base layer and may have markers. @@ -288,11 +288,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { } LOG && /* eslint-disable-next-line no-console */ - console.log( - 'RasterTimeseries %cAborted Mosaic', - 'color: red;', - id - ); + console.log('RasterTimeseries %cAborted Mosaic', 'color: red;', id); return; } }; @@ -331,6 +327,8 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { [sourceParams] ); + const generatorParams = useGeneratorParams(props); + useEffect( () => { const controller = new AbortController(); @@ -343,7 +341,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { const tileParams = qs.stringify( { assets: 'cog_default', - ...sourceParams + ...(sourceParams ?? {}) }, // Temporary solution to pass different tile parameters for hls data { @@ -376,12 +374,14 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { url: tilejsonUrl }; + const rasterOpacity = typeof opacity === 'number' ? opacity / 100 : 1; + const mosaicLayer: RasterLayer = { id: id, type: 'raster', source: id, paint: { - 'raster-opacity': Number(!hidden), + 'raster-opacity': hidden ? 0 : rasterOpacity, 'raster-opacity-transition': { duration: 320 } @@ -416,7 +416,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { id: pointsSourceId, source: pointsSourceId, layout: { - ...MARKER_LAYOUT as any, + ...(MARKER_LAYOUT as any), 'icon-allow-overlap': true }, paint: { @@ -440,7 +440,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { generatorId, sources, layers, - params: props as BaseGeneratorParams + params: generatorParams }); } @@ -450,7 +450,8 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { controller.abort(); }; }, - // sourceParams not included, but using a stringified version of it to detect changes (haveSourceParamsChanged) + // sourceParams not included, but using a stringified version of it to + // detect changes (haveSourceParamsChanged) [ updateStyle, id, @@ -458,8 +459,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { minZoom, points, haveSourceParamsChanged, - hidden, - generatorId + generatorParams ] ); diff --git a/app/scripts/components/common/map/styles.tsx b/app/scripts/components/common/map/styles.tsx index 315ae07ed..334d0fb5c 100644 --- a/app/scripts/components/common/map/styles.tsx +++ b/app/scripts/components/common/map/styles.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import { ExtendedLayer, + ExtendedMetadata, GeneratorStyleParams, LayerOrderPosition } from './types'; @@ -52,7 +53,7 @@ const generateStyle = (stylesData: Record) => { }; const generatorLayers = generatorParams.layers.map((generatorLayer) => { - const metadata = generatorLayer.metadata ?? {}; + const metadata: ExtendedMetadata = generatorLayer.metadata ?? {}; metadata.generatorId = generatorId; const mapLayer = { ...generatorLayer, metadata } as Layer; @@ -76,13 +77,17 @@ const generateStyle = (stylesData: Record) => { const layerBOrder = layerB.metadata?.layerOrderPosition; const layerAIndex = LAYER_ORDER.indexOf(layerAOrder); const layerBIndex = LAYER_ORDER.indexOf(layerBOrder); - const sortDeltaOrder = layerAIndex - layerBIndex; - const sortDeltaGeneratorId = layerA.metadata?.generatorId.localeCompare( - layerB.metadata?.generatorId - ); + const layerOrder = layerAIndex - layerBIndex; + const generatorA = stylesData[layerA.metadata?.generatorId]; + const generatorB = stylesData[layerB.metadata?.generatorId]; + const generatorOrder = + generatorA.params?.generatorOrder !== undefined && + generatorB.params?.generatorOrder !== undefined + ? generatorA.params.generatorOrder - generatorB.params.generatorOrder + : 0; // If compared layers have different layer orders, sort by layer order, otherwise // fallback on generatorId to ensure layer stacks from the same generator stay contiguous - return sortDeltaOrder !== 0 ? sortDeltaOrder : sortDeltaGeneratorId; + return layerOrder !== 0 ? layerOrder : generatorOrder; }); return { diff --git a/app/scripts/components/common/map/types.d.ts b/app/scripts/components/common/map/types.d.ts index 980f33fbe..89ae4cd77 100644 --- a/app/scripts/components/common/map/types.d.ts +++ b/app/scripts/components/common/map/types.d.ts @@ -1,14 +1,18 @@ import { AnyLayer, AnySourceImpl } from "mapbox-gl"; +export interface ExtendedMetadata { + layerOrderPosition?: LayerOrderPosition; + [key: string]: any; +} + export type ExtendedLayer = AnyLayer & { - metadata?: { - layerOrderPosition?: LayerOrderPosition; - [key: string]: any; - }; + metadata?: ExtendedMetadata; }; export interface BaseGeneratorParams { hidden?: boolean; + generatorOrder?: number; + opacity?: number; } export interface GeneratorStyleParams { generatorId: string; diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index f533ece4a..4b70ecc96 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -1,19 +1,23 @@ import axios, { Method } from 'axios'; import { Map as MapboxMap } from 'mapbox-gl'; import { MapRef } from 'react-map-gl'; -import { endOfDay, startOfDay } from "date-fns"; -import { StacFeature } from "./types"; -import { userTzDate2utcString } from "$utils/date"; -import { validateRangeNum } from "$utils/utils"; +import { endOfDay, startOfDay } from 'date-fns'; +import { + DatasetDatumFn, + DatasetDatumFnResolverBag, + DatasetDatumReturnType +} from 'veda'; +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); - - export function getMergedBBox(features: StacFeature[]) { const mergedBBox = [ Number.POSITIVE_INFINITY, @@ -32,8 +36,6 @@ export function getMergedBBox(features: StacFeature[]) { ) as [number, number, number, number]; } - - export function checkFitBoundsFromLayer( layerBounds?: [number, number, number, number], mapInstance?: MapboxMap | MapRef @@ -58,8 +60,6 @@ export function checkFitBoundsFromLayer( return layerExtentSmaller || isOutside; } - - /** * Creates the appropriate filter object to send to STAC. * @@ -87,7 +87,6 @@ export function getFilterPayload(date: Date, collection: string) { }; } - // 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 @@ -123,3 +122,61 @@ export async function requestQuickCache({ return quickCache.get(key); } +type Fn = (...args: any[]) => any; + +type ObjResMap = { + [K in keyof T]: Res; +}; + +type Res = T extends Fn + ? T extends DatasetDatumFn + ? DatasetDatumReturnType + : never + : T extends any[] + ? Res[] + : T extends object + ? ObjResMap + : T; + +export function resolveConfigFunctions( + datum: T, + bag: DatasetDatumFnResolverBag +): Res; +export function resolveConfigFunctions( + datum: T, + bag: DatasetDatumFnResolverBag +): Res[]; +export function resolveConfigFunctions( + datum: any, + bag: DatasetDatumFnResolverBag +): any { + if (Array.isArray(datum)) { + return datum.map((v) => resolveConfigFunctions(v, bag)); + } + + if (datum != null && typeof datum === 'object') { + // Use for loop instead of reduce as it faster. + const ready = {}; + for (const [k, v] of Object.entries(datum as object)) { + ready[k] = resolveConfigFunctions(v, bag); + } + return ready; + } + + if (typeof datum === 'function') { + try { + return datum(bag); + } catch (error) { + /* eslint-disable-next-line no-console */ + console.error( + 'Failed to resolve function %s(%o) with error %s', + datum.name, + bag, + error.message + ); + return null; + } + } + + return datum; +} diff --git a/app/scripts/components/exploration/atoms/hooks.ts b/app/scripts/components/exploration/atoms/hooks.ts index 6de88a1bc..e8eac26c9 100644 --- a/app/scripts/components/exploration/atoms/hooks.ts +++ b/app/scripts/components/exploration/atoms/hooks.ts @@ -73,17 +73,13 @@ export function useTimelineDatasetAtom(id: string) { return datasetAtom as PrimitiveAtom; } +type Settings = TimelineDataset['settings']; + type TimelineDatasetSettingsReturn = [ - ( - prop: keyof TimelineDataset['settings'] - ) => TimelineDataset['settings'][keyof TimelineDataset['settings']], - ( - prop: keyof TimelineDataset['settings'], - value: - | TimelineDataset['settings'][keyof TimelineDataset['settings']] - | (( - prev: TimelineDataset['settings'][keyof TimelineDataset['settings']] - ) => TimelineDataset['settings'][keyof TimelineDataset['settings']]) + (prop: T) => Settings[T], + ( + prop: T, + value: Settings[T] | ((prev: Settings[T]) => Settings[T]) ) => void ]; diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx new file mode 100644 index 000000000..d07f2f34e --- /dev/null +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { addMonths } from 'date-fns'; + +import { useStacMetadataOnDatasets } from '../../hooks/use-stac-metadata-datasets'; +import { selectedDateAtom, timelineDatasetsAtom } from '../../atoms/atoms'; +import { + TimelineDatasetStatus, + TimelineDatasetSuccess +} from '../../types.d.ts'; +import { Layer } from './layer'; + +import Map, { Compare } from '$components/common/map'; +import { Basemap } from '$components/common/map/style-generators/basemap'; +import GeocoderControl from '$components/common/map/controls/geocoder'; +import { + NavigationControl, + ScaleControl +} from '$components/common/map/controls'; +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'; + +export function ExplorationMap(props: { comparing: boolean }) { + const [projection, setProjection] = useState(projectionDefault); + + const { + mapBasemapId, + setBasemapId, + labelsOption, + boundariesOption, + onOptionChange + } = useBasemap(); + + useStacMetadataOnDatasets(); + + const datasets = useAtomValue(timelineDatasetsAtom); + const selectedDay = useAtomValue(selectedDateAtom); + + // Reverse the datasets order to have the "top" layer, list-wise, at the "top" layer, z-order wise + // Disabled eslint rule as slice() creates a shallow copy + // eslint-disable-next-line fp/no-mutating-methods + const loadedDatasets = datasets + .filter( + (d): d is TimelineDatasetSuccess => + d.status === TimelineDatasetStatus.SUCCESS + ) + .slice() + .reverse(); + + return ( + + {/* Map layers */} + + {selectedDay && + loadedDatasets.map((dataset, idx) => ( + + ))} + {/* Map controls */} + + + + + + {props.comparing && ( + // Compare map layers + + + {selectedDay && + loadedDatasets.map((dataset, idx) => ( + + ))} + + )} + + ); +} diff --git a/app/scripts/components/exploration/components/map/layer.tsx b/app/scripts/components/exploration/components/map/layer.tsx new file mode 100644 index 000000000..f93d4286e --- /dev/null +++ b/app/scripts/components/exploration/components/map/layer.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +// Avoid error: node_modules/date-fns/esm/index.js does not export 'default' +import * as dateFns from 'date-fns'; + +import { TimelineDatasetSuccess } from '../../types.d.ts'; +import { getTimeDensityStartDate } from '../../data-utils'; +import { + useTimelineDatasetAtom, + useTimelineDatasetSettings +} from '../../atoms/hooks'; + +import { RasterTimeseries } from '$components/common/map/style-generators/raster-timeseries'; +import { resolveConfigFunctions } from '$components/common/map/utils'; + +interface LayerProps { + id: string; + dataset: TimelineDatasetSuccess; + order: number; + selectedDay: Date; +} + +export function Layer(props: LayerProps) { + const { id: layerId, dataset, order, selectedDay } = props; + + const datasetAtom = useTimelineDatasetAtom(dataset.data.id); + const [getSettings] = useTimelineDatasetSettings(datasetAtom); + + const isVisible = getSettings('isVisible'); + const opacity = getSettings('opacity'); + + // The date needs to match the dataset's time density. + const relevantDate = useMemo( + () => getTimeDensityStartDate(selectedDay, dataset.data.timeDensity), + [selectedDay, dataset.data.timeDensity] + ); + + // Resolve config functions. + const params = useMemo(() => { + const bag = { + date: relevantDate, + compareDatetime: relevantDate, + dateFns, + raw: dataset.data + }; + return resolveConfigFunctions(dataset.data, bag); + }, [dataset, relevantDate]); + + return ( +