From 8161928b9ff44c407fe85dd9bd2d3b02283a9d88 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 8 Sep 2023 15:35:52 +0100 Subject: [PATCH 01/14] Add analysis data request --- .../components/exploration/analysis-data.ts | 195 ++++++++++++++++++ .../components/exploration/atoms/atoms.ts | 8 +- .../components/exploration/atoms/hooks.ts | 15 ++ .../components/datasets/dataset-list-item.tsx | 21 +- .../components/timeline/timeline-controls.tsx | 8 +- .../components/timeline/timeline.tsx | 30 ++- .../components/exploration/concurrency.ts | 52 +++++ .../components/exploration/datasets-mock.tsx | 52 ++++- .../hooks/use-analysis-data-request.ts | 107 ++++++++++ .../components/exploration/types.d.ts.ts | 20 +- 10 files changed, 480 insertions(+), 28 deletions(-) create mode 100644 app/scripts/components/exploration/analysis-data.ts create mode 100644 app/scripts/components/exploration/concurrency.ts create mode 100644 app/scripts/components/exploration/hooks/use-analysis-data-request.ts diff --git a/app/scripts/components/exploration/analysis-data.ts b/app/scripts/components/exploration/analysis-data.ts new file mode 100644 index 000000000..bfb6ba40e --- /dev/null +++ b/app/scripts/components/exploration/analysis-data.ts @@ -0,0 +1,195 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { QueryClient } from '@tanstack/react-query'; +import { FeatureCollection, Polygon } from 'geojson'; +import { ConcurrencyManagerInstance } from './concurrency'; +import { + TimelineDataset, + TimelineDatasetAnalysis, + TimelineDatasetStatus +} from './types.d.ts'; +import { + combineFeatureCollection, + getFilterPayload +} from '$components/analysis/utils'; + +interface DatasetAssetsRequestParams { + stacCol: string; + assets: string; + dateStart: Date; + dateEnd: Date; + aoi: FeatureCollection; +} + +/** + * Gets the asset urls for all datasets in the results of a STAC search given by + * the input parameters. + * + * @param params Dataset search request parameters + * @param opts Options for the request (see Axios) + * @param concurrencyManager The concurrency manager instance + * @returns Promise with the asset urls + */ +async function getDatasetAssets( + { dateStart, dateEnd, stacCol, assets, aoi }: DatasetAssetsRequestParams, + opts: AxiosRequestConfig, + concurrencyManager: ConcurrencyManagerInstance +): Promise<{ assets: { date: Date; url: string }[] }> { + const data = await concurrencyManager.queue(async () => { + const searchReqRes = await axios.post( + `${process.env.API_STAC_ENDPOINT}/search`, + { + 'filter-lang': 'cql2-json', + limit: 10000, + fields: { + include: [ + `assets.${assets}.href`, + 'properties.start_datetime', + 'properties.datetime' + ], + exclude: ['collection', 'links'] + }, + filter: getFilterPayload(dateStart, dateEnd, aoi, [stacCol]) + }, + opts + ); + + return { + assets: searchReqRes.data.features.map((o) => ({ + date: new Date(o.properties.start_datetime || o.properties.datetime), + url: o.assets[assets].href + })) + }; + }); + + return data; +} + +interface TimeseriesRequesterParams { + start: Date; + end: Date; + aoi: FeatureCollection; + dataset: TimelineDataset; + queryClient: QueryClient; + concurrencyManager: ConcurrencyManagerInstance; + onProgress: (data: TimelineDatasetAnalysis) => void; +} + +/** + * Gets the statistics for the given dataset within the given time range and + * area of interest. + */ +export async function requestDatasetTimeseriesData({ + start, + end, + aoi, + dataset, + queryClient, + concurrencyManager, + onProgress +}: TimeseriesRequesterParams) { + const datasetData = dataset.data; + const datasetAnalysis = dataset.analysis; + + const id = datasetData.id; + + onProgress({ + status: TimelineDatasetStatus.LOADING, + error: null, + data: null, + meta: {} + }); + + try { + const layerInfoFromSTAC = await queryClient.fetchQuery( + ['analysis', 'dataset', id, aoi, start, end], + ({ signal }) => + getDatasetAssets( + { + stacCol: datasetData.stacCol, + assets: datasetData.sourceParams?.assets || 'cog_default', + aoi, + dateStart: start, + dateEnd: end + }, + { signal }, + concurrencyManager + ), + { + staleTime: Infinity + } + ); + + const { assets } = layerInfoFromSTAC; + + onProgress({ + status: TimelineDatasetStatus.LOADING, + error: null, + data: null, + meta: { + total: assets.length, + loaded: 0 + } + }); + + let loaded = 0; + + const layerStatistics = await Promise.all( + assets.map(async ({ date, url }) => { + const statistics = await queryClient.fetchQuery( + ['analysis', 'asset', url], + async ({ signal }) => { + return concurrencyManager.queue(async () => { + const { data } = await axios.post( + `${process.env.API_RASTER_ENDPOINT}/cog/statistics?url=${url}`, + // Making a request with a FC causes a 500 (as of 2023/01/20) + combineFeatureCollection(aoi), + { signal } + ); + return { + date, + ...data.properties.statistics.b1 + }; + }); + }, + { + staleTime: Infinity + } + ); + + onProgress({ + status: TimelineDatasetStatus.LOADING, + error: null, + data: null, + meta: { + total: assets.length, + loaded: ++loaded + } + }); + + return statistics; + }) + ); + + onProgress({ + status: TimelineDatasetStatus.SUCCESS, + meta: { + total: assets.length, + loaded: assets.length + }, + error: null, + data: { + timeseries: layerStatistics + } + }); + } catch (error) { + // Discard abort related errors. + if (error.revert) return; + + onProgress({ + ...datasetAnalysis, + status: TimelineDatasetStatus.ERROR, + error, + data: null + }); + } +} diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index d20be9f22..edd055fbf 100644 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ b/app/scripts/components/exploration/atoms/atoms.ts @@ -41,5 +41,9 @@ export const isExpandedAtom = atom(false); // What analysis metrics are enabled export const activeAnalysisMetricsAtom = atom(dataMetrics); -// 🛑 Whether or not an analysis is being performed. Temporary!!! -export const isAnalysisAtom = atom(false); +// Analysis controller. Stores high level state about the analysis process. +export const analysisControllerAtom = atom({ + isAnalyzing: false, + runId: 0, + isObsolete: false +}); diff --git a/app/scripts/components/exploration/atoms/hooks.ts b/app/scripts/components/exploration/atoms/hooks.ts index e8eac26c9..865f6e9c0 100644 --- a/app/scripts/components/exploration/atoms/hooks.ts +++ b/app/scripts/components/exploration/atoms/hooks.ts @@ -137,3 +137,18 @@ export function useTimelineDatasetVisibility( return useAtom(visibilityAtom); } + +/** + * Hook to get/set the dataset analysis + * @param datasetAtom Single dataset atom. + * @returns State getter/setter for the dataset analysis. + */ +export function useTimelineDatasetAnalysis( + datasetAtom: PrimitiveAtom +) { + const analysisAtom = useMemo(() => { + return focusAtom(datasetAtom, (optic) => optic.prop('analysis')); + }, [datasetAtom]); + + return useAtom(analysisAtom); +} diff --git a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index 690764527..13501a8c5 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -46,8 +46,9 @@ import { } from '$components/exploration/atoms/hooks'; import { activeAnalysisMetricsAtom, - isAnalysisAtom + analysisControllerAtom } from '$components/exploration/atoms/atoms'; +import { useAnalysisDataRequest } from '$components/exploration/hooks/use-analysis-data-request'; const DatasetItem = styled.article` width: 100%; @@ -133,7 +134,7 @@ export function DatasetListItem(props: DatasetListItemProps) { const dataset = useAtomValue(datasetAtom); const activeMetrics = useAtomValue(activeAnalysisMetricsAtom); - const isAnalysis = useAtomValue(isAnalysisAtom); + const { isAnalyzing } = useAtomValue(analysisControllerAtom); const [isVisible, setVisible] = useTimelineDatasetVisibility(datasetAtom); @@ -179,16 +180,18 @@ export function DatasetListItem(props: DatasetListItemProps) { data: dataPoint }); + useAnalysisDataRequest({ datasetAtom }); + const isDatasetError = dataset.status === TimelineDatasetStatus.ERROR; const isDatasetLoading = dataset.status === TimelineDatasetStatus.LOADING; const isDatasetSuccess = dataset.status === TimelineDatasetStatus.SUCCESS; const isAnalysisAndError = - isAnalysis && dataset.analysis.status === TimelineDatasetStatus.ERROR; + isAnalyzing && dataset.analysis.status === TimelineDatasetStatus.ERROR; const isAnalysisAndLoading = - isAnalysis && dataset.analysis.status === TimelineDatasetStatus.LOADING; + isAnalyzing && dataset.analysis.status === TimelineDatasetStatus.LOADING; const isAnalysisAndSuccess = - isAnalysis && dataset.analysis.status === TimelineDatasetStatus.SUCCESS; + isAnalyzing && dataset.analysis.status === TimelineDatasetStatus.SUCCESS; const datasetLegend = dataset.data.legend; @@ -265,7 +268,11 @@ export function DatasetListItem(props: DatasetListItemProps) { <> {isAnalysisAndLoading && ( )} {isAnalysisAndError && ( @@ -290,7 +297,7 @@ export function DatasetListItem(props: DatasetListItemProps) { )} - {isDatasetSuccess && !isAnalysis && ( + {isDatasetSuccess && !isAnalyzing && ( { setExpanded((v) => !v); @@ -150,7 +150,7 @@ export function TimelineControls(props: TimelineControlsProps) { diff --git a/app/scripts/components/exploration/components/timeline/timeline.tsx b/app/scripts/components/exploration/components/timeline/timeline.tsx index 51722b8b8..c45058fe0 100644 --- a/app/scripts/components/exploration/components/timeline/timeline.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline.tsx @@ -46,9 +46,13 @@ import { useScaleFactors, useScales } from '$components/exploration/hooks/scales-hooks'; -import { TimelineDatasetStatus, ZoomTransformPlain } from '$components/exploration/types.d.ts'; +import { + TimelineDatasetStatus, + ZoomTransformPlain +} from '$components/exploration/types.d.ts'; import { useInteractionRectHover } from '$components/exploration/hooks/use-dataset-hover'; import { datasetLayers } from '$components/exploration/data-utils'; +import { useAnalysisController } from '$components/exploration/hooks/use-analysis-data-request'; const TimelineWrapper = styled.div` position: relative; @@ -156,6 +160,13 @@ export default function Timeline(props: TimelineProps) { const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); const [selectedInterval, setSelectedInterval] = useAtom(selectedIntervalAtom); + const { setObsolete } = useAnalysisController(); + + useEffect(() => { + // Set the analysis as obsolete when the selected interval changes. + setObsolete(); + }, [setObsolete, selectedInterval]); + const translateExtent = useMemo<[[number, number], [number, number]]>( () => [ [0, 0], @@ -212,7 +223,22 @@ export default function Timeline(props: TimelineProps) { .on('dblclick.zoom', null) .on('click', (event) => { const d = xScaled?.invert(event.layerX); - d && setSelectedDay(startOfDay(d)); + if (!d) return; + + // TODO: Key click has to be improved! Fixes needed: + // - Preventing setting start day after end day and vice versa. + // - Handling when there's no selected interval. + if (event.shiftKey) { + setSelectedInterval((interval) => + interval ? { ...interval, start: d } : null + ); + } else if (event.altKey) { + setSelectedInterval((interval) => + interval ? { ...interval, end: d } : null + ); + } else { + setSelectedDay(startOfDay(d)); + } }) .on('wheel', function (event) { // Wheel is triggered when an horizontal wheel is used or when shift diff --git a/app/scripts/components/exploration/concurrency.ts b/app/scripts/components/exploration/concurrency.ts new file mode 100644 index 000000000..d759d26ed --- /dev/null +++ b/app/scripts/components/exploration/concurrency.ts @@ -0,0 +1,52 @@ +export interface ConcurrencyManagerInstance { + clear: () => void; + queue: (taskFn: () => Promise) => Promise; +} + +export function ConcurrencyManager( + concurrentRequests = 15 +): ConcurrencyManagerInstance { + let queue: (() => Promise)[] = []; + let running = 0; + + const run = async () => { + if (!queue.length || running > concurrentRequests) return; + + const task = queue.shift(); + if (!task) return; + running++; + await task(); + running--; + run(); + }; + + return { + clear: () => { + queue = []; + }, + queue: (taskFn: () => Promise): Promise => { + let resolve; + let reject; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + queue.push(async () => { + try { + const result = await taskFn(); + resolve(result); + } catch (error) { + reject(error); + } + }); + + run(); + + return promise; + } + }; +} + +// Global concurrency manager instance +export const analysisConcurrencyManager = ConcurrencyManager(); diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index f1c5b2b19..09ae5bdd4 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -6,7 +6,6 @@ import { themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; import { - isAnalysisAtom, isExpandedAtom, timelineDatasetsAtom } from './atoms/atoms'; @@ -15,6 +14,7 @@ import { TimelineDatasetAnalysis, TimelineDatasetStatus } from './types.d.ts'; +import { useAnalysisController } from './hooks/use-analysis-data-request'; const chartData = { status: 'success', @@ -381,7 +381,8 @@ function makeDataset( mocked: true, status, data, - error: status === TimelineDatasetStatus.ERROR ? new Error('Mock error') : null, + error: + status === TimelineDatasetStatus.ERROR ? new Error('Mock error') : null, settings: { ...settings, isVisible: settings.isVisible === undefined ? true : settings.isVisible @@ -418,7 +419,9 @@ export function MockControls({ onCompareClick, comparing }: any) { const set = useSetAtom(timelineDatasetsAtom); const setIsExpanded = useSetAtom(isExpandedAtom); - const setIsAnalysis = useSetAtom(isAnalysisAtom); + + const { isObsolete, runAnalysis, cancelAnalysis, isAnalyzing } = + useAnalysisController(); useEffect(() => { const listener = (e) => { @@ -600,17 +603,44 @@ export function MockControls({ onCompareClick, comparing }: any) { > Toggle expanded - +
+ {isAnalyzing ? ( + <> + In Analysis (obsolete: {isObsolete.toString()}) + + + + ) : ( + <> + NOT Analysis (obsolete: {isObsolete.toString()}) + + + )} +
); } diff --git a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts new file mode 100644 index 000000000..420daf58e --- /dev/null +++ b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts @@ -0,0 +1,107 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; +import { useCallback, useEffect } from 'react'; +import { requestDatasetTimeseriesData } from '../analysis-data'; +import { analysisControllerAtom, selectedIntervalAtom } from '../atoms/atoms'; +import { useTimelineDatasetAnalysis } from '../atoms/hooks'; +import { analysisConcurrencyManager } from '../concurrency'; +import { TimelineDataset, TimelineDatasetStatus } from '../types.d.ts'; + +// 🛑 Temporary!!! Use map data +const aoi: any = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'world', + properties: {}, + geometry: { + coordinates: [ + [ + [-180, -89], + [180, -89], + [180, 89], + [-180, 89], + [-180, -89] + ] + ], + type: 'Polygon' + } + } + ] +}; + +export function useAnalysisController() { + const [controller, setController] = useAtom(analysisControllerAtom); + + return { + analysisRun: controller.runId, + isAnalyzing: controller.isAnalyzing, + isObsolete: controller.isObsolete, + setObsolete: useCallback( + () => setController((v) => ({ ...v, isObsolete: true })), + [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable + ), + runAnalysis: useCallback( + () => + setController((v) => ({ + ...v, + runId: v.runId + 1, + isAnalyzing: true, + isObsolete: false + })), + [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable + ), + cancelAnalysis: useCallback( + () => + setController((v) => ({ + ...v, + runId: 0, + isAnalyzing: false, + isObsolete: false + })), + [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable + ) + }; +} + +export function useAnalysisDataRequest({ + datasetAtom +}: { + datasetAtom: PrimitiveAtom; +}) { + const queryClient = useQueryClient(); + + const selectedInterval = useAtomValue(selectedIntervalAtom); + const { analysisRun } = useAnalysisController(); + + const dataset = useAtomValue(datasetAtom); + const datasetStatus = dataset.status; + + const [, setAnalysis] = useTimelineDatasetAnalysis(datasetAtom); + + useEffect(() => { + if (datasetStatus !== TimelineDatasetStatus.SUCCESS || !selectedInterval) { + return; + } + + const { start, end } = selectedInterval; + + requestDatasetTimeseriesData({ + start, + end, + aoi, + dataset, + queryClient, + concurrencyManager: analysisConcurrencyManager, + onProgress: (data) => { + setAnalysis(data); + } + }); + // We want great control when this effect run which is done by incrementing + // the analysisRun. This is done when the user refreshes the analysis or + // when they enter the analysis. It is certain that when this effect runs + // the other values will be up to date. Adding all dependencies would cause + // the hook to continuously run. + }, [analysisRun, datasetStatus]); +} diff --git a/app/scripts/components/exploration/types.d.ts.ts b/app/scripts/components/exploration/types.d.ts.ts index bfc8ddc17..cf902fc10 100644 --- a/app/scripts/components/exploration/types.d.ts.ts +++ b/app/scripts/components/exploration/types.d.ts.ts @@ -19,9 +19,25 @@ export interface StacDatasetData { domain: string[]; } -export type AnalysisTimeseriesEntry = Record & { +export interface AnalysisTimeseriesEntry { date: Date; -}; + min: number; + max: number; + mean: number; + count: number; + sum: number; + std: number; + median: number; + majority: number; + minority: number; + unique: number; + histogram: [number[], number[]]; + valid_percent: number; + masked_pixels: number; + valid_pixels: number; + percentile_2: number; + percentile_98: number; +} interface AnalysisMeta { loaded: number; From c3be0016e03649c26b61f108b659575383f27f8e Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 11 Oct 2023 18:18:26 +0100 Subject: [PATCH 02/14] include aoi in stats query key --- app/scripts/components/exploration/analysis-data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scripts/components/exploration/analysis-data.ts b/app/scripts/components/exploration/analysis-data.ts index bfb6ba40e..4ebcb2fdc 100644 --- a/app/scripts/components/exploration/analysis-data.ts +++ b/app/scripts/components/exploration/analysis-data.ts @@ -136,7 +136,7 @@ export async function requestDatasetTimeseriesData({ const layerStatistics = await Promise.all( assets.map(async ({ date, url }) => { const statistics = await queryClient.fetchQuery( - ['analysis', 'asset', url], + ['analysis', 'asset', url, aoi], async ({ signal }) => { return concurrencyManager.queue(async () => { const { data } = await axios.post( From 15a2dba3bef4434e611bea3a80d5f8483eb9f771 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 11 Oct 2023 18:19:38 +0100 Subject: [PATCH 03/14] Use map aoi to run analysis --- .../hooks/use-analysis-data-request.ts | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts index 420daf58e..2bbf5f303 100644 --- a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts +++ b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts @@ -1,35 +1,14 @@ +import { useCallback, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { FeatureCollection, Polygon } from 'geojson'; import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; -import { useCallback, useEffect } from 'react'; + import { requestDatasetTimeseriesData } from '../analysis-data'; import { analysisControllerAtom, selectedIntervalAtom } from '../atoms/atoms'; import { useTimelineDatasetAnalysis } from '../atoms/hooks'; import { analysisConcurrencyManager } from '../concurrency'; import { TimelineDataset, TimelineDatasetStatus } from '../types.d.ts'; - -// 🛑 Temporary!!! Use map data -const aoi: any = { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'world', - properties: {}, - geometry: { - coordinates: [ - [ - [-180, -89], - [180, -89], - [180, 89], - [-180, 89], - [-180, -89] - ] - ], - type: 'Polygon' - } - } - ] -}; +import useAois from '$components/common/map/controls/hooks/use-aois'; export function useAnalysisController() { const [controller, setController] = useAtom(analysisControllerAtom); @@ -73,6 +52,9 @@ export function useAnalysisDataRequest({ const queryClient = useQueryClient(); const selectedInterval = useAtomValue(selectedIntervalAtom); + const { features } = useAois(); + const selectedFeatures = features.filter((f) => f.selected); + const { analysisRun } = useAnalysisController(); const dataset = useAtomValue(datasetAtom); @@ -81,10 +63,19 @@ export function useAnalysisDataRequest({ const [, setAnalysis] = useTimelineDatasetAnalysis(datasetAtom); useEffect(() => { - if (datasetStatus !== TimelineDatasetStatus.SUCCESS || !selectedInterval) { + if ( + datasetStatus !== TimelineDatasetStatus.SUCCESS || + !selectedInterval || + !selectedFeatures.length + ) { return; } + const aoi: FeatureCollection = { + type: 'FeatureCollection', + features: selectedFeatures + }; + const { start, end } = selectedInterval; requestDatasetTimeseriesData({ From 4f6d556411f87038364f93ffe0e499da4dcf0823 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 11 Oct 2023 18:19:58 +0100 Subject: [PATCH 04/14] Add preliminary map message --- .../components/map/analysis-message.tsx | 91 +++++++++++++++++++ .../exploration/components/map/index.tsx | 4 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 app/scripts/components/exploration/components/map/analysis-message.tsx diff --git a/app/scripts/components/exploration/components/map/analysis-message.tsx b/app/scripts/components/exploration/components/map/analysis-message.tsx new file mode 100644 index 000000000..d46851148 --- /dev/null +++ b/app/scripts/components/exploration/components/map/analysis-message.tsx @@ -0,0 +1,91 @@ +import React, { useEffect } from 'react'; +import { useAtomValue } from 'jotai'; +import styled from 'styled-components'; +import { Button } from '@devseed-ui/button'; +import { + CollecticonChartLine, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons'; + +import { selectedIntervalAtom } from '../../atoms/atoms'; + +import useAois from '$components/common/map/controls/hooks/use-aois'; +import { calcFeatCollArea } from '$components/common/aoi/utils'; +import { formatDateRange } from '$utils/date'; +import { useAnalysisController } from '$components/exploration/hooks/use-analysis-data-request'; + +const AnalysisMessageWrapper = styled.div` + position: absolute; + background-color: #fff; + top: 2rem; + left: 5rem; + padding: 0.25rem 0.5rem; +`; + +export function AnalysisMessage() { + const { isObsolete, setObsolete, runAnalysis, cancelAnalysis, isAnalyzing } = + useAnalysisController(); + + const { features } = useAois(); + const selectedInterval = useAtomValue(selectedIntervalAtom); + const dateLabel = + selectedInterval && + formatDateRange(selectedInterval.start, selectedInterval.end); + + const selectedFeatures = features.filter((f) => f.selected); + const selectedFeatureIds = selectedFeatures.map((f) => f.id).join(','); + + useEffect(() => { + // Set the analysis as obsolete when the selected features change. + setObsolete(); + }, [setObsolete, selectedFeatureIds]); + + if (!selectedFeatures.length) return null; + + const area = calcFeatCollArea({ + type: 'FeatureCollection', + features: selectedFeatures + }); + + return ( + + An area of {area} km2 is selected + {dateLabel && ` from ${dateLabel}`}.{' '} + {isAnalyzing ? ( + + ) : ( + + )} + {isAnalyzing && isObsolete && ( + <> + The current analysis is obsolete.{' '} + + + )} + + ); +} diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index 54eef4fe3..9342d85ad 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -9,6 +9,7 @@ import { TimelineDatasetSuccess } from '../../types.d.ts'; import { Layer } from './layer'; +import { AnalysisMessage } from './analysis-message'; import Map, { Compare } from '$components/common/map'; import { Basemap } from '$components/common/map/style-generators/basemap'; @@ -49,10 +50,11 @@ export function ExplorationMap(props: { comparing: boolean }) { .reverse(); const { onUpdate, onDelete, onSelectionChange } = useAois(); - // console.log(features); return ( + + {/* Map layers */} Date: Thu, 12 Oct 2023 17:06:15 +0100 Subject: [PATCH 05/14] Handle analysis metrics on a dataset basis --- .../components/exploration/atoms/atoms.ts | 7 -- .../exploration/components/chart-popover.tsx | 2 +- .../analysis-metrics.tsx} | 83 ++++++++----------- .../components/datasets/dataset-chart.tsx | 2 +- .../components/datasets/dataset-list-item.tsx | 16 ++-- .../components/datasets/dataset-options.tsx | 13 ++- .../components/timeline/timeline-controls.tsx | 9 -- .../components/exploration/data-utils.ts | 29 ++++++- .../components/exploration/types.d.ts.ts | 3 + 9 files changed, 85 insertions(+), 79 deletions(-) rename app/scripts/components/exploration/components/{analysis-metrics-dropdown.tsx => datasets/analysis-metrics.tsx} (51%) diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index edd055fbf..2918e5761 100644 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ b/app/scripts/components/exploration/atoms/atoms.ts @@ -1,8 +1,4 @@ import { atom } from 'jotai'; -import { - DataMetric, - dataMetrics -} from '../components/analysis-metrics-dropdown'; import { HEADER_COLUMN_WIDTH, RIGHT_AXIS_SPACE } from '../constants'; import { DateRange, TimelineDataset, ZoomTransformPlain } from '../types.d.ts'; @@ -38,9 +34,6 @@ export const timelineSizesAtom = atom((get) => { // Whether or not the dataset rows are expanded. export const isExpandedAtom = atom(false); -// What analysis metrics are enabled -export const activeAnalysisMetricsAtom = atom(dataMetrics); - // Analysis controller. Stores high level state about the analysis process. export const analysisControllerAtom = atom({ isAnalyzing: false, diff --git a/app/scripts/components/exploration/components/chart-popover.tsx b/app/scripts/components/exploration/components/chart-popover.tsx index 0fe3b1ad4..aabcb4c27 100644 --- a/app/scripts/components/exploration/components/chart-popover.tsx +++ b/app/scripts/components/exploration/components/chart-popover.tsx @@ -20,7 +20,7 @@ import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { AnalysisTimeseriesEntry, TimeDensity } from '../types.d.ts'; import { isExpandedAtom } from '../atoms/atoms'; -import { DataMetric } from './analysis-metrics-dropdown'; +import { DataMetric } from './datasets/analysis-metrics'; import { getNumForChart } from '$components/common/chart/utils'; diff --git a/app/scripts/components/exploration/components/analysis-metrics-dropdown.tsx b/app/scripts/components/exploration/components/datasets/analysis-metrics.tsx similarity index 51% rename from app/scripts/components/exploration/components/analysis-metrics-dropdown.tsx rename to app/scripts/components/exploration/components/datasets/analysis-metrics.tsx index 3a142a58b..2fd62c072 100644 --- a/app/scripts/components/exploration/components/analysis-metrics-dropdown.tsx +++ b/app/scripts/components/exploration/components/datasets/analysis-metrics.tsx @@ -1,10 +1,8 @@ import React from 'react'; import styled from 'styled-components'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { Dropdown, DropTitle } from '@devseed-ui/dropdown'; -import { Button } from '@devseed-ui/button'; -import { CollecticonChartLine } from '@devseed-ui/collecticons'; import { FormSwitch } from '@devseed-ui/form'; +import { DropMenu, DropTitle } from '@devseed-ui/dropdown'; export interface DataMetric { id: string; @@ -18,7 +16,7 @@ export interface DataMetric { | 'infographicE'; } -export const dataMetrics: DataMetric[] = [ +export const DATA_METRICS: DataMetric[] = [ { id: 'min', label: 'Min', @@ -51,22 +49,25 @@ export const dataMetrics: DataMetric[] = [ } ]; -const MetricList = styled.ul` +const MetricList = styled(DropMenu)` display: flex; flex-flow: column; list-style: none; - margin: 0 -${glsp()}; - padding: 0; + margin: ${glsp(0, -1, 1, -1)}; + padding: ${glsp(0, 0, 1, 0)}; gap: ${glsp(0.5)}; > li { padding: ${glsp(0, 1)}; + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; } `; const MetricSwitch = styled(FormSwitch)<{ metricThemeColor: string }>` display: grid; grid-template-columns: min-content 1fr auto; + gap: ${glsp(0.5)}; &::before { content: ''; @@ -79,16 +80,13 @@ const MetricSwitch = styled(FormSwitch)<{ metricThemeColor: string }>` } `; -interface AnalysisMetricsDropdownProps { +interface AnalysisMetricsProps { activeMetrics: DataMetric[]; onMetricsChange: (metrics: DataMetric[]) => void; - isDisabled: boolean; } -export default function AnalysisMetricsDropdown( - props: AnalysisMetricsDropdownProps -) { - const { activeMetrics, onMetricsChange, isDisabled } = props; +export default function AnalysisMetrics(props: AnalysisMetricsProps) { + const { activeMetrics, onMetricsChange } = props; const handleMetricChange = (metric: DataMetric, shouldAdd: boolean) => { onMetricsChange( @@ -99,41 +97,28 @@ export default function AnalysisMetricsDropdown( }; return ( - ( - - )} - > - View options - - {dataMetrics.map((metric) => { - const checked = !!activeMetrics.find((m) => m.id === metric.id); - return ( -
  • - handleMetricChange(metric, !checked)} - > - {metric.label} - -
  • - ); - })} -
    -
    + <> + Analysis metrics + + {DATA_METRICS.map((metric) => { + const checked = !!activeMetrics.find((m) => m.id === metric.id); + return ( +
  • + handleMetricChange(metric, !checked)} + > + {metric.label} + +
  • + ); + })} +
    + ); } diff --git a/app/scripts/components/exploration/components/datasets/dataset-chart.tsx b/app/scripts/components/exploration/components/datasets/dataset-chart.tsx index eaf9997fb..e7c7b82f6 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-chart.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-chart.tsx @@ -6,8 +6,8 @@ import { AnimatePresence, motion } from 'framer-motion'; import { isExpandedAtom } from '../../atoms/atoms'; import { RIGHT_AXIS_SPACE } from '../../constants'; -import { DataMetric } from '../analysis-metrics-dropdown'; import { DatasetTrackMessage } from './dataset-track-message'; +import { DataMetric } from './analysis-metrics'; import { getNumForChart } from '$components/common/chart/utils'; diff --git a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index 13501a8c5..c0a099e4a 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -44,10 +44,7 @@ import { useTimelineDatasetAtom, useTimelineDatasetVisibility } from '$components/exploration/atoms/hooks'; -import { - activeAnalysisMetricsAtom, - analysisControllerAtom -} from '$components/exploration/atoms/atoms'; +import { analysisControllerAtom } from '$components/exploration/atoms/atoms'; import { useAnalysisDataRequest } from '$components/exploration/hooks/use-analysis-data-request'; const DatasetItem = styled.article` @@ -132,8 +129,6 @@ export function DatasetListItem(props: DatasetListItemProps) { const datasetAtom = useTimelineDatasetAtom(datasetId); const dataset = useAtomValue(datasetAtom); - const activeMetrics = useAtomValue(activeAnalysisMetricsAtom); - const { isAnalyzing } = useAtomValue(analysisControllerAtom); const [isVisible, setVisible] = useTimelineDatasetVisibility(datasetAtom); @@ -195,6 +190,11 @@ export function DatasetListItem(props: DatasetListItemProps) { const datasetLegend = dataset.data.legend; + const analysisMetrics = useMemo( + () => dataset.settings.analysisMetrics ?? [], + [dataset] + ); + return ( )} @@ -311,7 +311,7 @@ export function DatasetListItem(props: DatasetListItemProps) { ref={popoverRefs.setFloating} style={floatingStyles} timeDensity={dataset.data.timeDensity} - activeMetrics={activeMetrics} + activeMetrics={analysisMetrics} data={dataPoint} /> )} diff --git a/app/scripts/components/exploration/components/datasets/dataset-options.tsx b/app/scripts/components/exploration/components/datasets/dataset-options.tsx index 208a011e3..905fabc3f 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-options.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-options.tsx @@ -8,6 +8,8 @@ import { Button } from '@devseed-ui/button'; import { CollecticonCog, CollecticonTrashBin } from '@devseed-ui/collecticons'; import { Overline } from '@devseed-ui/typography'; +import AnalysisMetrics from './analysis-metrics'; + import DropMenuItemButton from '$styles/drop-menu-item-button'; import { SliderInput, SliderInputProps } from '$styles/range-slider'; import { composeVisuallyDisabled } from '$utils/utils'; @@ -15,7 +17,6 @@ import { Tip } from '$components/common/tip'; import { TimelineDataset } from '$components/exploration/types.d.ts'; import { timelineDatasetsAtom } from '$components/exploration/atoms/atoms'; import { useTimelineDatasetSettings } from '$components/exploration/atoms/hooks'; - const RemoveButton = composeVisuallyDisabled(DropMenuItemButton); interface DatasetOptionsProps { @@ -31,6 +32,8 @@ export default function DatasetOptions(props: DatasetOptionsProps) { const opacity = (getSettings('opacity') ?? 100) as number; + const activeMetrics = (getSettings('analysisMetrics') ?? []); + return ( )} > - View options + Display options
  • + setSetting('analysisMetrics', m)} + />
  • - Opacity + Map Opacity {value} diff --git a/app/scripts/components/exploration/components/timeline/timeline-controls.tsx b/app/scripts/components/exploration/components/timeline/timeline-controls.tsx index 2f4abaace..b4cd11c15 100644 --- a/app/scripts/components/exploration/components/timeline/timeline-controls.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline-controls.tsx @@ -19,11 +19,9 @@ import { VerticalDivider } from '@devseed-ui/toolbar'; -import AnalysisMetricsDropdown from '../analysis-metrics-dropdown'; import { DateAxis } from './date-axis'; import { - activeAnalysisMetricsAtom, analysisControllerAtom, isExpandedAtom, selectedDateAtom, @@ -70,7 +68,6 @@ export function TimelineControls(props: TimelineControlsProps) { const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); const [selectedInterval, setSelectedInterval] = useAtom(selectedIntervalAtom); - const [activeMetrics, setActiveMetrics] = useAtom(activeAnalysisMetricsAtom); const { isAnalyzing } = useAtomValue(analysisControllerAtom); const [isExpanded, setExpanded] = useAtom(isExpandedAtom); @@ -146,12 +143,6 @@ export function TimelineControls(props: TimelineControlsProps) { )} - - diff --git a/app/scripts/components/exploration/data-utils.ts b/app/scripts/components/exploration/data-utils.ts index cca52fd68..dd481bd85 100644 --- a/app/scripts/components/exploration/data-utils.ts +++ b/app/scripts/components/exploration/data-utils.ts @@ -13,6 +13,7 @@ import { TimelineDataset, TimelineDatasetStatus } from './types.d.ts'; +import { DataMetric, DATA_METRICS } from './components/datasets/analysis-metrics'; import { utcString2userTzDate } from '$utils/date'; @@ -27,6 +28,31 @@ export const datasetLayers = Object.values(datasets).flatMap( (dataset) => dataset!.data.layers ); + +/** + * Returns an array of metrics based on the given Dataset Layer configuration. + * If the layer has metrics defined, it returns only the metrics that match the + * ids. Otherwise, it returns all available metrics. + * + * @param data - The Datase tLayer object to get metrics for. + * @returns An array of metrics objects. + */ +function getInitialMetrics(data: DatasetLayer): DataMetric[] { + const metricsIds = data.analysis?.metrics ?? []; + + const foundMetrics = metricsIds + .map((metric: string) => { + return DATA_METRICS.find((m) => m.id === metric)!; + }) + .filter(Boolean); + + if (!foundMetrics.length) { + return DATA_METRICS; + } + + return foundMetrics; +} + /** * Converts the datasets to a format that can be used by the timeline, skipping * the ones that have already been reconciled. @@ -59,7 +85,8 @@ export function reconcileDatasets( error: null, settings: { isVisible: true, - opacity: 100 + opacity: 100, + analysisMetrics: getInitialMetrics(dataset) }, analysis: { status: TimelineDatasetStatus.IDLE, diff --git a/app/scripts/components/exploration/types.d.ts.ts b/app/scripts/components/exploration/types.d.ts.ts index cf902fc10..181df04d1 100644 --- a/app/scripts/components/exploration/types.d.ts.ts +++ b/app/scripts/components/exploration/types.d.ts.ts @@ -1,4 +1,5 @@ import { DatasetLayer } from 'veda'; +import { DataMetric } from './components/datasets/analysis-metrics'; export enum TimeDensity { YEAR = 'year', @@ -91,6 +92,8 @@ export interface TimelineDatasetSettings { isVisible?: boolean; // Opacity of the layer on the map. opacity?: number; + // Active metrics for the analysis chart. + analysisMetrics?: DataMetric[]; } // TimelineDataset type discriminants From d13a827e6b7399d3f4478ef11964658343f122b0 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 16 Oct 2023 17:19:49 +0100 Subject: [PATCH 06/14] Move taxonomy filters above text search --- .../common/browse-controls/index.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/scripts/components/common/browse-controls/index.tsx b/app/scripts/components/common/browse-controls/index.tsx index 245fef34d..dab0c28f0 100644 --- a/app/scripts/components/common/browse-controls/index.tsx +++ b/app/scripts/components/common/browse-controls/index.tsx @@ -85,6 +85,20 @@ function BrowseControls(props: BrowseControlsProps) { return ( + + {taxonomiesOptions.map(({ name, values }) => ( + { + onAction(Actions.TAXONOMY, { key: name, value: v }); + }} + size={isLargeUp ? 'large' : 'medium'} + /> + ))} + - - {taxonomiesOptions.map(({ name, values }) => ( - { - onAction(Actions.TAXONOMY, { key: name, value: v }); - }} - size={isLargeUp ? 'large' : 'medium'} - /> - ))} - ); } From d6cdb72b554f7619429e4b6988284c59c2dc3162 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 16 Oct 2023 17:20:18 +0100 Subject: [PATCH 07/14] Add onLinkClick to ElementInteractive --- app/scripts/components/common/card.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/scripts/components/common/card.tsx b/app/scripts/components/common/card.tsx index 85e69af2e..94c5addfb 100644 --- a/app/scripts/components/common/card.tsx +++ b/app/scripts/components/common/card.tsx @@ -299,6 +299,7 @@ interface CardComponentProps { parentTo?: string; footerContent?: ReactNode; onCardClickCapture?: MouseEventHandler; + onLinkClick?: MouseEventHandler; } function CardComponent(props: CardComponentProps) { @@ -316,7 +317,8 @@ function CardComponent(props: CardComponentProps) { parentName, parentTo, footerContent, - onCardClickCapture + onCardClickCapture, + onLinkClick } = props; return ( @@ -327,7 +329,8 @@ function CardComponent(props: CardComponentProps) { linkLabel={linkLabel || 'View more'} linkProps={{ as: Link, - to: linkTo + to: linkTo, + onClick: onLinkClick }} onClickCapture={onCardClickCapture} > From 3171d8182110bcfbd6e2b11ec0bc381d44fcf9be Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 16 Oct 2023 17:20:37 +0100 Subject: [PATCH 08/14] Convert EmptyHub to styled component --- app/scripts/components/common/empty-hub.tsx | 26 +++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/scripts/components/common/empty-hub.tsx b/app/scripts/components/common/empty-hub.tsx index 060168767..1260744d3 100644 --- a/app/scripts/components/common/empty-hub.tsx +++ b/app/scripts/components/common/empty-hub.tsx @@ -5,7 +5,20 @@ import { themeVal } from '@devseed-ui/theme-provider'; import { variableGlsp } from '$styles/variable-utils'; -const EmptyHubWrapper = styled.div` +function EmptyHub(props: { children: ReactNode }) { + const theme = useTheme(); + + const { children, ...rest } = props; + + return ( +
    + + {children} +
    + ); +} + +export default styled(EmptyHub)` max-width: 100%; grid-column: 1/-1; display: flex; @@ -16,14 +29,3 @@ const EmptyHubWrapper = styled.div` border: 1px dashed ${themeVal('color.base-300')}; gap: ${variableGlsp(1)}; `; - -export default function EmptyHub(props: { children: ReactNode }) { - const theme = useTheme(); - - return ( - - - {props.children} - - ); -} \ No newline at end of file From e65121218cf34e36c90f10dfaa62b63b4d82dc54 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 16 Oct 2023 17:21:14 +0100 Subject: [PATCH 09/14] Implement analysis dataset selector modal --- .../browse-controls/use-browse-controls.ts | 7 +- app/scripts/components/data-catalog/index.tsx | 9 +- .../components/dataset-selector-modal.tsx | 384 +++++++++++++++--- .../components/exploration/data-utils.ts | 2 + app/scripts/styles/theme.ts | 2 +- 5 files changed, 336 insertions(+), 68 deletions(-) diff --git a/app/scripts/components/common/browse-controls/use-browse-controls.ts b/app/scripts/components/common/browse-controls/use-browse-controls.ts index 51c0664b7..c973fc74b 100644 --- a/app/scripts/components/common/browse-controls/use-browse-controls.ts +++ b/app/scripts/components/common/browse-controls/use-browse-controls.ts @@ -4,13 +4,14 @@ import useQsStateCreator from 'qs-state-hook'; import { set, omit } from 'lodash'; export enum Actions { + CLEAR = 'clear', SEARCH = 'search', SORT_FIELD = 'sfield', SORT_DIR = 'sdir', TAXONOMY = 'taxonomy' } -export type BrowserControlsAction = (what: Actions, value: any) => void; +export type BrowserControlsAction = (what: Actions, value?: any) => void; export interface FilterOption { id: string; @@ -84,6 +85,10 @@ export function useBrowserControls({ sortOptions }: BrowseControlsHookParams) { const onAction = useCallback( (what, value) => { switch (what) { + case Actions.CLEAR: + setSearch(''); + setTaxonomies({}); + break; case Actions.SEARCH: setSearch(value); break; diff --git a/app/scripts/components/data-catalog/index.tsx b/app/scripts/components/data-catalog/index.tsx index 56b5e99a2..7af9992fe 100644 --- a/app/scripts/components/data-catalog/index.tsx +++ b/app/scripts/components/data-catalog/index.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef } from 'react'; import styled from 'styled-components'; -import { DatasetData, datasets, datasetTaxonomies, getString } from 'veda'; +import { DatasetData, datasetTaxonomies, getString } from 'veda'; import { Link } from 'react-router-dom'; import { glsp } from '@devseed-ui/theme-provider'; import { Subtitle } from '@devseed-ui/typography'; @@ -47,8 +47,7 @@ import { TAXONOMY_TOPICS } from '$utils/veda-data'; import { DatasetClassification } from '$components/common/dataset-classification'; - -const allDatasets = Object.values(datasets).map((d) => d!.data); +import { allDatasets } from '$components/exploration/data-utils'; const DatasetCount = styled(Subtitle)` grid-column: 1 / -1; @@ -66,9 +65,9 @@ const BrowseFoldHeader = styled(FoldHeader)` align-items: flex-start; `; -const sortOptions = [{ id: 'name', name: 'Name' }]; +export const sortOptions = [{ id: 'name', name: 'Name' }]; -const prepareDatasets = ( +export const prepareDatasets = ( data: DatasetData[], options: { search: string; diff --git a/app/scripts/components/exploration/components/dataset-selector-modal.tsx b/app/scripts/components/exploration/components/dataset-selector-modal.tsx index 12befd3f1..aff8e78f1 100644 --- a/app/scripts/components/exploration/components/dataset-selector-modal.tsx +++ b/app/scripts/components/exploration/components/dataset-selector-modal.tsx @@ -1,39 +1,165 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import styled from 'styled-components'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; import { useAtom } from 'jotai'; -import { Modal } from '@devseed-ui/modal'; -import { media, themeVal } from '@devseed-ui/theme-provider'; -import { Form, FormCheckable } from '@devseed-ui/form'; -import { Overline } from '@devseed-ui/typography'; +import { DatasetData, DatasetLayer, datasetTaxonomies } from 'veda'; +import { Modal, ModalBody, ModalFooter } from '@devseed-ui/modal'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; +import { Subtitle } from '@devseed-ui/typography'; +import { + CollecticonTickSmall, + CollecticonXmarkSmall, + iconDataURI +} from '@devseed-ui/collecticons'; import { timelineDatasetsAtom } from '../atoms/atoms'; -import { datasetLayers, findParentDataset, reconcileDatasets } from '../data-utils'; +import { + allDatasets, + datasetLayers, + findParentDataset, + reconcileDatasets +} from '../data-utils'; -import { variableGlsp } from '$styles/variable-utils'; +import EmptyHub from '$components/common/empty-hub'; +import { + Card, + CardList, + CardMeta, + CardTopicsList +} from '$components/common/card'; +import { DatasetClassification } from '$components/common/dataset-classification'; +import { CardSourcesList } from '$components/common/card-sources'; +import DatasetMenu from '$components/data-catalog/dataset-menu'; +import { getDatasetPath } from '$utils/routes'; +import { + getTaxonomy, + TAXONOMY_SOURCE, + TAXONOMY_TOPICS +} from '$utils/veda-data'; +import { Pill } from '$styles/pill'; +import BrowseControls from '$components/common/browse-controls'; +import { + Actions, + useBrowserControls +} from '$components/common/browse-controls/use-browse-controls'; +import { prepareDatasets, sortOptions } from '$components/data-catalog'; +import Pluralize from '$utils/pluralize'; -const CheckableGroup = styled.div` - display: grid; - gap: ${variableGlsp(0.5)}; - grid-template-columns: repeat(2, 1fr); - background: ${themeVal('color.surface')}; +const DatasetModal = styled(Modal)` + z-index: ${themeVal('zIndices.modal')}; - ${media.mediumUp` - grid-template-columns: repeat(3, 1fr); - `} + /* Override ModalContents */ + > div { + height: calc(100vh - ${glsp(4)}); + display: flex; + flex-flow: column; + } - ${media.xlargeUp` - grid-template-columns: repeat(4, 1fr); - `} + ${ModalBody} { + height: 100%; + min-height: 0; + display: flex; + flex-flow: column; + gap: ${glsp(1)}; + } + + ${ModalFooter} { + display: flex; + gap: ${glsp(1)}; + align-items: center; + + > .selection-info { + margin-right: auto; + } + } +`; + +const ModalIntro = styled.div` + margin-bottom: ${glsp(2)}; +`; + +const DatasetCount = styled(Subtitle)` + display: flex; + gap: ${glsp(0.5)}; + + span { + text-transform: uppercase; + line-height: 1.5rem; + } `; -const FormCheckableCustom = styled(FormCheckable)` - padding: ${variableGlsp(0.5)}; - background: ${themeVal('color.surface')}; - box-shadow: 0 0 0 1px ${themeVal('color.base-100a')}; - border-radius: ${themeVal('shape.rounded')}; - align-items: center; +const DatasetContainer = styled.div` + height: 100%; + min-height: 0; + display: flex; + margin: ${glsp(0, -2)}; + box-shadow: 0 -1px 0 0 ${themeVal('color.base-100a')}, + inset 0 -1px 0 0 ${themeVal('color.base-100a')}; + + ${CardList} { + overflow-y: auto; + padding: ${glsp(2)}; + } + + ${EmptyHub} { + flex-grow: 1; + } +`; + +const LayerCard = styled(Card)<{ checked: boolean }>` + outline: 4px solid transparent; + ${({ checked }) => + checked && + css` + outline-color: ${themeVal('color.primary')}; + `} + + &:hover { + &::before, + &::after { + opacity: 1; + } + } + + &::before, + &::after { + display: block; + content: ''; + position: absolute; + transition: opacity 320ms ease-in-out; + opacity: 0.32; + } + + &::before { + top: 0; + right: 0; + width: 4rem; + height: 4rem; + clip-path: polygon(0 0, 100% 0, 100% 100%); + background: ${themeVal('color.primary')}; + } + + &::after { + top: 0.25rem; + right: 0.25rem; + width: 1.5rem; + height: 1.5rem; + background-image: url(${({ theme }) => + iconDataURI(CollecticonTickSmall, { + color: theme.color?.surface, + size: 'large' + })}); + } + + ${({ checked }) => + checked && + css` + &::before, + &::after { + opacity: 1; + } + `} `; interface DatasetSelectorModalProps { @@ -44,16 +170,16 @@ interface DatasetSelectorModalProps { export function DatasetSelectorModal(props: DatasetSelectorModalProps) { const { revealed, close } = props; - const [datasets, setDatasets] = useAtom(timelineDatasetsAtom); + const [timelineDatasets, setTimelineDatasets] = useAtom(timelineDatasetsAtom); // Store a list of selected datasets and only confirm on save. const [selectedIds, setSelectedIds] = useState( - datasets.map((dataset) => dataset.data.id) + timelineDatasets.map((dataset) => dataset.data.id) ); useEffect(() => { - setSelectedIds(datasets.map((dataset) => dataset.data.id)); - }, [datasets]); + setSelectedIds(timelineDatasets.map((dataset) => dataset.data.id)); + }, [timelineDatasets]); const onCheck = useCallback((id) => { setSelectedIds((ids) => @@ -63,50 +189,186 @@ export function DatasetSelectorModal(props: DatasetSelectorModalProps) { const onConfirm = useCallback(() => { // Reconcile selectedIds with datasets. - setDatasets(reconcileDatasets(selectedIds, datasetLayers, datasets)); + setTimelineDatasets( + reconcileDatasets(selectedIds, datasetLayers, timelineDatasets) + ); close(); - }, [close, selectedIds, datasets, setDatasets]); + }, [close, selectedIds, timelineDatasets, setTimelineDatasets]); + + const controlVars = useBrowserControls({ + sortOptions + }); + const { taxonomies, sortField, sortDir, onAction } = controlVars; + const search = controlVars.search ?? ''; + + // Clear filters when the modal is revealed. + useEffect(() => { + if (revealed) { + onAction(Actions.CLEAR); + } + }, [revealed]); + + // Filters are applies to the veda datasets, but then we want to display the + // dataset layers since those are shown on the map. + const displayDatasetLayers = useMemo( + () => + prepareDatasets(allDatasets, { + search, + taxonomies, + sortField, + sortDir + }).flatMap((dataset) => dataset.layers), + [search, taxonomies, sortField, sortDir] + ); + + const isFiltering = !!( + (taxonomies && Object.keys(taxonomies).length) || + search + ); + + const isFirstSelection = timelineDatasets.length === 0; return ( - -
    - - {datasetLayers.map((datasetLayer) => ( - onCheck(datasetLayer.id)} - checked={selectedIds.includes(datasetLayer.id)} - > - - From: {findParentDataset(datasetLayer.id)?.name} - - {datasetLayer.name} - - ))} - -
    + + {isFirstSelection ? ( +

    Select datasets to start the exploration.

    + ) : ( +

    Add or remove datasets to the exploration.

    + )} +
    + + + + Showing{' '} + {' '} + out of {datasetLayers.length}. + + {isFiltering && ( + + )} + + + + {displayDatasetLayers.length ? ( + + {displayDatasetLayers.map((datasetLayer) => { + const parent = findParentDataset(datasetLayer.id); + if (!parent) return null; + + return ( +
  • + onCheck(datasetLayer.id)} + /> +
  • + ); + })} + + ) : ( + + There are no datasets to show with the selected filters. + + )} + } footerContent={ - + <> +

    + {selectedIds.length + ? `${selectedIds.length} out of ${datasetLayers.length} datasets selected.` + : 'No datasets selected.'} +

    + {!isFirstSelection && ( + + )} + + + } + /> + ); +} + +interface DatasetLayerProps { + parent: DatasetData; + layer: DatasetLayer; + selected: boolean; + onDatasetClick: () => void; +} + +function DatasetLayerCard(props: DatasetLayerProps) { + const { parent, layer, onDatasetClick, selected } = props; + + const topics = getTaxonomy(parent, TAXONOMY_TOPICS)?.values; + + return ( + + + + {/* */} + + } + linkTo={getDatasetPath(parent)} + linkLabel='View dataset' + onLinkClick={(e) => { + e.preventDefault(); + onDatasetClick(); + }} + title={layer.name} + description={`From: ${parent.name}`} + imgSrc={parent.media?.src} + imgAlt={parent.media?.alt} + footerContent={ + <> + {topics?.length ? ( + +
    Topics
    + {topics.map((t) => ( +
    + {t.name} +
    + ))} +
    + ) : null} + + } /> ); diff --git a/app/scripts/components/exploration/data-utils.ts b/app/scripts/components/exploration/data-utils.ts index dd481bd85..57ebd33a2 100644 --- a/app/scripts/components/exploration/data-utils.ts +++ b/app/scripts/components/exploration/data-utils.ts @@ -24,6 +24,8 @@ export const findParentDataset = (layerId: string) => { return parentDataset?.data; }; +export const allDatasets = Object.values(datasets).map((d) => d!.data); + export const datasetLayers = Object.values(datasets).flatMap( (dataset) => dataset!.data.layers ); diff --git a/app/scripts/styles/theme.ts b/app/scripts/styles/theme.ts index f5498e7a4..f33a17aa3 100644 --- a/app/scripts/styles/theme.ts +++ b/app/scripts/styles/theme.ts @@ -10,7 +10,7 @@ export const VEDA_OVERRIDE_THEME = { hide: -1, docked: 10, sticky: 900, - dropdown: 1000, + dropdown: 1550, overlay: 1300, modal: 1400, popover: 1500, From c85e6429fa242c87c39abe6665ac4e151a0579fe Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 16 Oct 2023 18:10:29 +0100 Subject: [PATCH 10/14] Fix analysis cancellation not stopping requests --- .../exploration/hooks/use-analysis-data-request.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts index 2bbf5f303..8e49de943 100644 --- a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts +++ b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts @@ -35,7 +35,6 @@ export function useAnalysisController() { () => setController((v) => ({ ...v, - runId: 0, isAnalyzing: false, isObsolete: false })), @@ -55,13 +54,23 @@ export function useAnalysisDataRequest({ const { features } = useAois(); const selectedFeatures = features.filter((f) => f.selected); - const { analysisRun } = useAnalysisController(); + const { analysisRun, isAnalyzing } = useAnalysisController(); const dataset = useAtomValue(datasetAtom); const datasetStatus = dataset.status; const [, setAnalysis] = useTimelineDatasetAnalysis(datasetAtom); + useEffect(() => { + if (!isAnalyzing) { + queryClient.cancelQueries({ + queryKey: ['analysis'], + fetchStatus: 'fetching' + }); + analysisConcurrencyManager.clear(); + } + }, [isAnalyzing]); + useEffect(() => { if ( datasetStatus !== TimelineDatasetStatus.SUCCESS || From 23a31e7bb478f2ae5c756ed36d4167e030ddf238 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 17 Oct 2023 12:26:27 +0100 Subject: [PATCH 11/14] Update analysis controller Allow analysis runs to be controlled on a dataset level. This means having an analysis run per dataset as well as having a way to dequeue specific requests. --- app/scripts/components/common/map/utils.ts | 12 +- .../components/exploration/analysis-data.ts | 132 +++++++++--------- .../components/exploration/atoms/atoms.ts | 2 +- .../components/datasets/dataset-list-item.tsx | 7 +- .../components/map/analysis-message.tsx | 9 +- .../components/exploration/concurrency.ts | 32 +++-- .../hooks/use-analysis-data-request.ts | 22 ++- 7 files changed, 123 insertions(+), 93 deletions(-) diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index 498d65a8a..cf960b79d 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -168,12 +168,12 @@ export function resolveConfigFunctions( 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 - ); + // console.error( + // 'Failed to resolve function %s(%o) with error %s', + // datum.name, + // bag, + // error.message + // ); return null; } } diff --git a/app/scripts/components/exploration/analysis-data.ts b/app/scripts/components/exploration/analysis-data.ts index 4ebcb2fdc..485a5bb30 100644 --- a/app/scripts/components/exploration/analysis-data.ts +++ b/app/scripts/components/exploration/analysis-data.ts @@ -26,42 +26,36 @@ interface DatasetAssetsRequestParams { * * @param params Dataset search request parameters * @param opts Options for the request (see Axios) - * @param concurrencyManager The concurrency manager instance * @returns Promise with the asset urls */ async function getDatasetAssets( { dateStart, dateEnd, stacCol, assets, aoi }: DatasetAssetsRequestParams, - opts: AxiosRequestConfig, - concurrencyManager: ConcurrencyManagerInstance + opts: AxiosRequestConfig ): Promise<{ assets: { date: Date; url: string }[] }> { - const data = await concurrencyManager.queue(async () => { - const searchReqRes = await axios.post( - `${process.env.API_STAC_ENDPOINT}/search`, - { - 'filter-lang': 'cql2-json', - limit: 10000, - fields: { - include: [ - `assets.${assets}.href`, - 'properties.start_datetime', - 'properties.datetime' - ], - exclude: ['collection', 'links'] - }, - filter: getFilterPayload(dateStart, dateEnd, aoi, [stacCol]) + const searchReqRes = await axios.post( + `${process.env.API_STAC_ENDPOINT}/search`, + { + 'filter-lang': 'cql2-json', + limit: 10000, + fields: { + include: [ + `assets.${assets}.href`, + 'properties.start_datetime', + 'properties.datetime' + ], + exclude: ['collection', 'links'] }, - opts - ); - - return { - assets: searchReqRes.data.features.map((o) => ({ - date: new Date(o.properties.start_datetime || o.properties.datetime), - url: o.assets[assets].href - })) - }; - }); - - return data; + filter: getFilterPayload(dateStart, dateEnd, aoi, [stacCol]) + }, + opts + ); + + return { + assets: searchReqRes.data.features.map((o) => ({ + date: new Date(o.properties.start_datetime || o.properties.datetime), + url: o.assets[assets].href + })) + }; } interface TimeseriesRequesterParams { @@ -100,22 +94,26 @@ export async function requestDatasetTimeseriesData({ }); try { - const layerInfoFromSTAC = await queryClient.fetchQuery( - ['analysis', 'dataset', id, aoi, start, end], - ({ signal }) => - getDatasetAssets( + const layerInfoFromSTAC = await concurrencyManager.queue( + `${id}-analysis`, + () => { + return queryClient.fetchQuery( + ['analysis', 'dataset', id, aoi, start, end], + ({ signal }) => + getDatasetAssets( + { + stacCol: datasetData.stacCol, + assets: datasetData.sourceParams?.assets || 'cog_default', + aoi, + dateStart: start, + dateEnd: end + }, + { signal } + ), { - stacCol: datasetData.stacCol, - assets: datasetData.sourceParams?.assets || 'cog_default', - aoi, - dateStart: start, - dateEnd: end - }, - { signal }, - concurrencyManager - ), - { - staleTime: Infinity + staleTime: Infinity + } + ); } ); @@ -135,24 +133,27 @@ export async function requestDatasetTimeseriesData({ const layerStatistics = await Promise.all( assets.map(async ({ date, url }) => { - const statistics = await queryClient.fetchQuery( - ['analysis', 'asset', url, aoi], - async ({ signal }) => { - return concurrencyManager.queue(async () => { - const { data } = await axios.post( - `${process.env.API_RASTER_ENDPOINT}/cog/statistics?url=${url}`, - // Making a request with a FC causes a 500 (as of 2023/01/20) - combineFeatureCollection(aoi), - { signal } - ); - return { - date, - ...data.properties.statistics.b1 - }; - }); - }, - { - staleTime: Infinity + const statistics = await concurrencyManager.queue( + `${id}-analysis-asset`, + () => { + return queryClient.fetchQuery( + ['analysis', id, 'asset', url, aoi], + async ({ signal }) => { + const { data } = await axios.post( + `${process.env.API_RASTER_ENDPOINT}/cog/statistics?url=${url}`, + // Making a request with a FC causes a 500 (as of 2023/01/20) + combineFeatureCollection(aoi), + { signal } + ); + return { + date, + ...data.properties.statistics.b1 + }; + }, + { + staleTime: Infinity + } + ); } ); @@ -185,6 +186,11 @@ export async function requestDatasetTimeseriesData({ // Discard abort related errors. if (error.revert) return; + // Cancel any inflight queries. + queryClient.cancelQueries({ queryKey: ['analysis', id] }); + // Remove other requests from the queue. + concurrencyManager.dequeue(`${id}-analysis-asset`); + onProgress({ ...datasetAnalysis, status: TimelineDatasetStatus.ERROR, diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index 2918e5761..3a42d8df0 100644 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ b/app/scripts/components/exploration/atoms/atoms.ts @@ -37,6 +37,6 @@ export const isExpandedAtom = atom(false); // Analysis controller. Stores high level state about the analysis process. export const analysisControllerAtom = atom({ isAnalyzing: false, - runId: 0, + runIds: {} as Record, isObsolete: false }); diff --git a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index c0a099e4a..00996f2ba 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -45,7 +45,10 @@ import { useTimelineDatasetVisibility } from '$components/exploration/atoms/hooks'; import { analysisControllerAtom } from '$components/exploration/atoms/atoms'; -import { useAnalysisDataRequest } from '$components/exploration/hooks/use-analysis-data-request'; +import { + useAnalysisController, + useAnalysisDataRequest +} from '$components/exploration/hooks/use-analysis-data-request'; const DatasetItem = styled.article` width: 100%; @@ -176,6 +179,7 @@ export function DatasetListItem(props: DatasetListItemProps) { }); useAnalysisDataRequest({ datasetAtom }); + const { runAnalysis } = useAnalysisController(); const isDatasetError = dataset.status === TimelineDatasetStatus.ERROR; const isDatasetLoading = dataset.status === TimelineDatasetStatus.LOADING; @@ -281,6 +285,7 @@ export function DatasetListItem(props: DatasetListItemProps) { onRetryClick={() => { /* eslint-disable-next-line no-console */ console.log('Retry analysis loading'); + runAnalysis(dataset.data.id); }} /> )} diff --git a/app/scripts/components/exploration/components/map/analysis-message.tsx b/app/scripts/components/exploration/components/map/analysis-message.tsx index d46851148..5f3d19f0f 100644 --- a/app/scripts/components/exploration/components/map/analysis-message.tsx +++ b/app/scripts/components/exploration/components/map/analysis-message.tsx @@ -7,7 +7,7 @@ import { CollecticonXmarkSmall } from '@devseed-ui/collecticons'; -import { selectedIntervalAtom } from '../../atoms/atoms'; +import { selectedIntervalAtom, timelineDatasetsAtom } from '../../atoms/atoms'; import useAois from '$components/common/map/controls/hooks/use-aois'; import { calcFeatCollArea } from '$components/common/aoi/utils'; @@ -26,6 +26,9 @@ export function AnalysisMessage() { const { isObsolete, setObsolete, runAnalysis, cancelAnalysis, isAnalyzing } = useAnalysisController(); + const datasets = useAtomValue(timelineDatasetsAtom); + const datasetIds = datasets.map((d) => d.data.id); + const { features } = useAois(); const selectedInterval = useAtomValue(selectedIntervalAtom); const dateLabel = @@ -66,7 +69,7 @@ export function AnalysisMessage() { variation='base-text' size='small' onClick={() => { - runAnalysis(); + runAnalysis(datasetIds); }} > Analyze @@ -79,7 +82,7 @@ export function AnalysisMessage() { variation='base-text' size='small' onClick={() => { - runAnalysis(); + runAnalysis(datasetIds); }} > Update diff --git a/app/scripts/components/exploration/concurrency.ts b/app/scripts/components/exploration/concurrency.ts index d759d26ed..2cc79adb9 100644 --- a/app/scripts/components/exploration/concurrency.ts +++ b/app/scripts/components/exploration/concurrency.ts @@ -1,18 +1,19 @@ export interface ConcurrencyManagerInstance { clear: () => void; - queue: (taskFn: () => Promise) => Promise; + queue: (key: string, askFn: () => Promise) => Promise; + dequeue: (key: string) => void; } export function ConcurrencyManager( concurrentRequests = 15 ): ConcurrencyManagerInstance { - let queue: (() => Promise)[] = []; + let queue: [string, () => Promise][] = []; let running = 0; const run = async () => { if (!queue.length || running > concurrentRequests) return; - - const task = queue.shift(); + /* eslint-disable-next-line fp/no-mutating-methods */ + const [, task] = queue.shift() ?? []; if (!task) return; running++; await task(); @@ -24,7 +25,7 @@ export function ConcurrencyManager( clear: () => { queue = []; }, - queue: (taskFn: () => Promise): Promise => { + queue: (key: string, taskFn: () => Promise): Promise => { let resolve; let reject; const promise = new Promise((_resolve, _reject) => { @@ -32,18 +33,25 @@ export function ConcurrencyManager( reject = _reject; }); - queue.push(async () => { - try { - const result = await taskFn(); - resolve(result); - } catch (error) { - reject(error); + /* eslint-disable-next-line fp/no-mutating-methods */ + queue.push([ + key, + async () => { + try { + const result = await taskFn(); + resolve(result); + } catch (error) { + reject(error); + } } - }); + ]); run(); return promise; + }, + dequeue: (key: string) => { + queue = queue.filter(([k]) => k !== key); } }; } diff --git a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts index 8e49de943..fd3385d35 100644 --- a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts +++ b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts @@ -14,7 +14,6 @@ export function useAnalysisController() { const [controller, setController] = useAtom(analysisControllerAtom); return { - analysisRun: controller.runId, isAnalyzing: controller.isAnalyzing, isObsolete: controller.isObsolete, setObsolete: useCallback( @@ -22,13 +21,19 @@ export function useAnalysisController() { [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable ), runAnalysis: useCallback( - () => + (datasetsIds) => { + const ids = Array.isArray(datasetsIds) ? datasetsIds : [datasetsIds]; setController((v) => ({ ...v, - runId: v.runId + 1, + // Increment each id count by 1 + runIds: ids.reduce( + (acc, id) => ({ ...acc, [id]: (acc[id] ?? 0) + 1 }), + v.runIds + ), isAnalyzing: true, isObsolete: false - })), + })); + }, [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable ), cancelAnalysis: useCallback( @@ -39,7 +44,8 @@ export function useAnalysisController() { isObsolete: false })), [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable - ) + ), + getRunId: (id: string) => controller.runIds[id] ?? 0, }; } @@ -54,13 +60,15 @@ export function useAnalysisDataRequest({ const { features } = useAois(); const selectedFeatures = features.filter((f) => f.selected); - const { analysisRun, isAnalyzing } = useAnalysisController(); + const { getRunId, isAnalyzing } = useAnalysisController(); const dataset = useAtomValue(datasetAtom); const datasetStatus = dataset.status; const [, setAnalysis] = useTimelineDatasetAnalysis(datasetAtom); + const analysisRunId = getRunId(dataset.data.id); + useEffect(() => { if (!isAnalyzing) { queryClient.cancelQueries({ @@ -103,5 +111,5 @@ export function useAnalysisDataRequest({ // when they enter the analysis. It is certain that when this effect runs // the other values will be up to date. Adding all dependencies would cause // the hook to continuously run. - }, [analysisRun, datasetStatus]); + }, [analysisRunId, datasetStatus]); } From d86ff7eebc1f97e0bfc83aefe2a7c04f10b5823c Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 17 Oct 2023 13:08:50 +0100 Subject: [PATCH 12/14] Update modal style --- .../components/dataset-selector-modal.tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/app/scripts/components/exploration/components/dataset-selector-modal.tsx b/app/scripts/components/exploration/components/dataset-selector-modal.tsx index aff8e78f1..e3e3fed32 100644 --- a/app/scripts/components/exploration/components/dataset-selector-modal.tsx +++ b/app/scripts/components/exploration/components/dataset-selector-modal.tsx @@ -3,10 +3,16 @@ import styled, { css } from 'styled-components'; import { useAtom } from 'jotai'; import { DatasetData, DatasetLayer, datasetTaxonomies } from 'veda'; -import { Modal, ModalBody, ModalFooter } from '@devseed-ui/modal'; +import { + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalHeadline +} from '@devseed-ui/modal'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; -import { Subtitle } from '@devseed-ui/typography'; +import { Heading, Subtitle } from '@devseed-ui/typography'; import { CollecticonTickSmall, CollecticonXmarkSmall, @@ -51,11 +57,18 @@ const DatasetModal = styled(Modal)` /* Override ModalContents */ > div { - height: calc(100vh - ${glsp(4)}); display: flex; flex-flow: column; } + ${ModalHeader} { + position: sticky; + top: ${glsp(-2)}; + z-index: 100; + box-shadow: 0 1px 0 0 ${themeVal('color.base-100a')}; + margin-bottom: ${glsp(2)}; + } + ${ModalBody} { height: 100%; min-height: 0; @@ -68,6 +81,10 @@ const DatasetModal = styled(Modal)` display: flex; gap: ${glsp(1)}; align-items: center; + position: sticky; + bottom: ${glsp(-2)}; + z-index: 100; + box-shadow: 0 -1px 0 0 ${themeVal('color.base-100a')}; > .selection-info { margin-right: auto; @@ -75,9 +92,7 @@ const DatasetModal = styled(Modal)` } `; -const ModalIntro = styled.div` - margin-bottom: ${glsp(2)}; -`; +const ModalIntro = styled.div``; const DatasetCount = styled(Subtitle)` display: flex; @@ -93,13 +108,10 @@ const DatasetContainer = styled.div` height: 100%; min-height: 0; display: flex; - margin: ${glsp(0, -2)}; - box-shadow: 0 -1px 0 0 ${themeVal('color.base-100a')}, - inset 0 -1px 0 0 ${themeVal('color.base-100a')}; + margin-bottom: ${glsp(2)}; ${CardList} { - overflow-y: auto; - padding: ${glsp(2)}; + width: 100%; } ${EmptyHub} { @@ -236,8 +248,9 @@ export function DatasetSelectorModal(props: DatasetSelectorModalProps) { revealed={revealed} closeButton={!isFirstSelection} onCloseClick={close} - content={ - <> + renderHeadline={() => ( + + Select datasets {isFirstSelection ? (

    Select datasets to start the exploration.

    @@ -245,6 +258,10 @@ export function DatasetSelectorModal(props: DatasetSelectorModalProps) {

    Add or remove datasets to the exploration.

    )}
    +
    + )} + content={ + <> - {/* */} } linkTo={getDatasetPath(parent)} From 1617956ac24947dd0a9df1fd03964b54bc2400a8 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 17 Oct 2023 17:35:11 +0100 Subject: [PATCH 13/14] Fix ts errors --- app/scripts/components/exploration/datasets-mock.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index 09ae5bdd4..ae9c6d1a9 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { eachDayOfInterval, eachMonthOfInterval } from 'date-fns'; -import { useSetAtom } from 'jotai'; +import { useAtom, useSetAtom } from 'jotai'; import styled from 'styled-components'; import { themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; @@ -417,12 +417,14 @@ const MockPanel = styled.div` export function MockControls({ onCompareClick, comparing }: any) { const [mockRevealed, setMockRevealed] = useState(false); - const set = useSetAtom(timelineDatasetsAtom); + const [timelineDatasets, set] = useAtom(timelineDatasetsAtom); const setIsExpanded = useSetAtom(isExpandedAtom); const { isObsolete, runAnalysis, cancelAnalysis, isAnalyzing } = useAnalysisController(); + const datasetIds = timelineDatasets.map((d) => d.data.id); + useEffect(() => { const listener = (e) => { if (e.altKey && e.shiftKey && e.code === 'KeyM') { @@ -612,7 +614,7 @@ export function MockControls({ onCompareClick, comparing }: any) { In Analysis (obsolete: {isObsolete.toString()}) + )} + + + ) : ( + + )} + + + ); +} + +export function AnalysisMessageControl() { + useThemedControl(() => , { position: 'top-left' }); + + return null; +} diff --git a/app/scripts/components/exploration/components/map/analysis-message.tsx b/app/scripts/components/exploration/components/map/analysis-message.tsx deleted file mode 100644 index 5f3d19f0f..000000000 --- a/app/scripts/components/exploration/components/map/analysis-message.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect } from 'react'; -import { useAtomValue } from 'jotai'; -import styled from 'styled-components'; -import { Button } from '@devseed-ui/button'; -import { - CollecticonChartLine, - CollecticonXmarkSmall -} from '@devseed-ui/collecticons'; - -import { selectedIntervalAtom, timelineDatasetsAtom } from '../../atoms/atoms'; - -import useAois from '$components/common/map/controls/hooks/use-aois'; -import { calcFeatCollArea } from '$components/common/aoi/utils'; -import { formatDateRange } from '$utils/date'; -import { useAnalysisController } from '$components/exploration/hooks/use-analysis-data-request'; - -const AnalysisMessageWrapper = styled.div` - position: absolute; - background-color: #fff; - top: 2rem; - left: 5rem; - padding: 0.25rem 0.5rem; -`; - -export function AnalysisMessage() { - const { isObsolete, setObsolete, runAnalysis, cancelAnalysis, isAnalyzing } = - useAnalysisController(); - - const datasets = useAtomValue(timelineDatasetsAtom); - const datasetIds = datasets.map((d) => d.data.id); - - const { features } = useAois(); - const selectedInterval = useAtomValue(selectedIntervalAtom); - const dateLabel = - selectedInterval && - formatDateRange(selectedInterval.start, selectedInterval.end); - - const selectedFeatures = features.filter((f) => f.selected); - const selectedFeatureIds = selectedFeatures.map((f) => f.id).join(','); - - useEffect(() => { - // Set the analysis as obsolete when the selected features change. - setObsolete(); - }, [setObsolete, selectedFeatureIds]); - - if (!selectedFeatures.length) return null; - - const area = calcFeatCollArea({ - type: 'FeatureCollection', - features: selectedFeatures - }); - - return ( - - An area of {area} km2 is selected - {dateLabel && ` from ${dateLabel}`}.{' '} - {isAnalyzing ? ( - - ) : ( - - )} - {isAnalyzing && isObsolete && ( - <> - The current analysis is obsolete.{' '} - - - )} - - ); -} diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index 9342d85ad..6d89f6430 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -9,7 +9,7 @@ import { TimelineDatasetSuccess } from '../../types.d.ts'; import { Layer } from './layer'; -import { AnalysisMessage } from './analysis-message'; +import { AnalysisMessageControl } from './analysis-message-control'; import Map, { Compare } from '$components/common/map'; import { Basemap } from '$components/common/map/style-generators/basemap'; @@ -53,8 +53,6 @@ export function ExplorationMap(props: { comparing: boolean }) { return ( - - {/* Map layers */} +