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'} - /> - ))} - ); } 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/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} > 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 diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index 2e29a3e29..6ee71bf2e 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -169,12 +169,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/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/analysis-data.ts b/app/scripts/components/exploration/analysis-data.ts new file mode 100644 index 000000000..485a5bb30 --- /dev/null +++ b/app/scripts/components/exploration/analysis-data.ts @@ -0,0 +1,201 @@ +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) + * @returns Promise with the asset urls + */ +async function getDatasetAssets( + { dateStart, dateEnd, stacCol, assets, aoi }: DatasetAssetsRequestParams, + opts: AxiosRequestConfig +): Promise<{ assets: { date: Date; url: string }[] }> { + 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 + })) + }; +} + +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 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 } + ), + { + 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 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 + } + ); + } + ); + + 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; + + // 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, + error, + data: null + }); + } +} diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index d20be9f22..3a42d8df0 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,8 +34,9 @@ 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); - -// 🛑 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, + runIds: {} as Record, + 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/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/dataset-selector-modal.tsx b/app/scripts/components/exploration/components/dataset-selector-modal.tsx index 12befd3f1..e3e3fed32 100644 --- a/app/scripts/components/exploration/components/dataset-selector-modal.tsx +++ b/app/scripts/components/exploration/components/dataset-selector-modal.tsx @@ -1,39 +1,177 @@ -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, + ModalHeader, + ModalHeadline +} from '@devseed-ui/modal'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; +import { Heading, 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 { + display: flex; + flex-flow: column; + } - ${media.xlargeUp` - grid-template-columns: repeat(4, 1fr); - `} + ${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; + display: flex; + flex-flow: column; + gap: ${glsp(1)}; + } + + ${ModalFooter} { + 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; + } + } +`; + +const ModalIntro = styled.div``; + +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-bottom: ${glsp(2)}; + + ${CardList} { + width: 100%; + } + + ${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 +182,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 +201,190 @@ 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 ( - ( + + Select datasets + + {isFirstSelection ? ( +

Select datasets to start the exploration.

+ ) : ( +

Add or remove datasets to the exploration.

+ )} +
+
+ )} content={ <> -
- - {datasetLayers.map((datasetLayer) => ( - onCheck(datasetLayer.id)} - checked={selectedIds.includes(datasetLayer.id)} - > - - From: {findParentDataset(datasetLayer.id)?.name} - - {datasetLayer.name} - - ))} - -
+ + + + 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/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 690764527..00996f2ba 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,11 @@ import { useTimelineDatasetAtom, useTimelineDatasetVisibility } from '$components/exploration/atoms/hooks'; +import { analysisControllerAtom } from '$components/exploration/atoms/atoms'; import { - activeAnalysisMetricsAtom, - isAnalysisAtom -} from '$components/exploration/atoms/atoms'; + useAnalysisController, + useAnalysisDataRequest +} from '$components/exploration/hooks/use-analysis-data-request'; const DatasetItem = styled.article` width: 100%; @@ -131,9 +132,7 @@ export function DatasetListItem(props: DatasetListItemProps) { const datasetAtom = useTimelineDatasetAtom(datasetId); const dataset = useAtomValue(datasetAtom); - const activeMetrics = useAtomValue(activeAnalysisMetricsAtom); - - const isAnalysis = useAtomValue(isAnalysisAtom); + const { isAnalyzing } = useAtomValue(analysisControllerAtom); const [isVisible, setVisible] = useTimelineDatasetVisibility(datasetAtom); @@ -179,19 +178,27 @@ export function DatasetListItem(props: DatasetListItemProps) { data: dataPoint }); + useAnalysisDataRequest({ datasetAtom }); + const { runAnalysis } = useAnalysisController(); + 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; + const analysisMetrics = useMemo( + () => dataset.settings.analysisMetrics ?? [], + [dataset] + ); + return ( {isAnalysisAndLoading && ( )} {isAnalysisAndError && ( @@ -274,6 +285,7 @@ export function DatasetListItem(props: DatasetListItemProps) { onRetryClick={() => { /* eslint-disable-next-line no-console */ console.log('Retry analysis loading'); + runAnalysis(dataset.data.id); }} /> )} @@ -283,14 +295,14 @@ export function DatasetListItem(props: DatasetListItemProps) { width={width} isVisible={!!isVisible} data={dataset.analysis} - activeMetrics={activeMetrics} + activeMetrics={analysisMetrics} highlightDate={dataPoint?.date} /> )} )} - {isDatasetSuccess && !isAnalysis && ( + {isDatasetSuccess && !isAnalyzing && ( )} 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/map/analysis-message-control.tsx b/app/scripts/components/exploration/components/map/analysis-message-control.tsx new file mode 100644 index 000000000..2b7c15597 --- /dev/null +++ b/app/scripts/components/exploration/components/map/analysis-message-control.tsx @@ -0,0 +1,180 @@ +import React, { useEffect } from 'react'; +import { useAtomValue } from 'jotai'; +import styled, { css } from 'styled-components'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Button } from '@devseed-ui/button'; +import { VerticalDivider } from '@devseed-ui/toolbar'; +import { + CollecticonArrowLoop, + CollecticonChartLine, + CollecticonCircleInformation, + CollecticonSignDanger, + 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'; +import useThemedControl from '$components/common/map/controls/hooks/use-themed-control'; + +const AnalysisMessageWrapper = styled.div` + background-color: ${themeVal('color.base-400a')}; + color: ${themeVal('color.surface')}; + border-radius: ${themeVal('shape.rounded')}; + overflow: hidden; + display: flex; + align-items: center; + min-height: 2rem; + gap: ${glsp(0.5)}; + padding: ${glsp(0, 0.5)}; +`; + +interface MessageStatusIndicatorProps { + status: 'info' | 'analyzing' | 'obsolete'; +} +const MessageStatusIndicator = styled.div` + display: flex; + align-items: center; + padding: ${glsp(0, 0.5)}; + margin-left: ${glsp(-0.5)}; + align-self: stretch; + + ${({ status }) => { + switch (status) { + case 'info': + return css` + background-color: ${themeVal('color.info')}; + `; + case 'analyzing': + return css` + background-color: ${themeVal('color.success')}; + `; + case 'obsolete': + return css` + background-color: ${themeVal('color.danger')}; + `; + } + }} +`; +const MessageContent = styled.div``; +const MessageControls = styled.div` + display: flex; + gap: ${glsp(0.5)}; +`; + +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 ( + + {isAnalyzing ? ( + isObsolete ? ( + + + + ) : ( + + + + ) + ) : ( + + + + )} + + {isAnalyzing ? ( + isObsolete ? ( + <> + Outdated! Refresh to analyze an area covering {area} km + 2 {dateLabel && ` from ${dateLabel}.`} + + ) : ( + <> + Analyzing an area covering {area} km2{' '} + {dateLabel && ` from ${dateLabel}`}. + + ) + ) : ( + <> + An area of {area} km2 {dateLabel && ` from ${dateLabel}`}{' '} + is selected. + + )} + + + + {isAnalyzing ? ( + <> + {isObsolete && ( + + )} + + + ) : ( + + )} + + + ); +} + +export function AnalysisMessageControl() { + useThemedControl(() => , { position: 'top-left' }); + + return null; +} diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index 3fd3c7db1..e76fe0c1e 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -10,6 +10,8 @@ import { TimelineDatasetSuccess } from '../../types.d.ts'; import { Layer } from './layer'; +import { AnalysisMessageControl } from './analysis-message-control'; + import Map, { Compare } from '$components/common/map'; import { Basemap } from '$components/common/map/style-generators/basemap'; import GeocoderControl from '$components/common/map/controls/geocoder'; @@ -91,6 +93,7 @@ export function ExplorationMap(props: { comparing: boolean }) { /> + { setExpanded((v) => !v); @@ -146,12 +143,6 @@ 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..2cc79adb9 --- /dev/null +++ b/app/scripts/components/exploration/concurrency.ts @@ -0,0 +1,60 @@ +export interface ConcurrencyManagerInstance { + clear: () => void; + queue: (key: string, askFn: () => Promise) => Promise; + dequeue: (key: string) => void; +} + +export function ConcurrencyManager( + concurrentRequests = 15 +): ConcurrencyManagerInstance { + let queue: [string, () => Promise][] = []; + let running = 0; + + const run = async () => { + if (!queue.length || running > concurrentRequests) return; + /* eslint-disable-next-line fp/no-mutating-methods */ + const [, task] = queue.shift() ?? []; + if (!task) return; + running++; + await task(); + running--; + run(); + }; + + return { + clear: () => { + queue = []; + }, + queue: (key: string, taskFn: () => Promise): Promise => { + let resolve; + let reject; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + /* 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); + } + }; +} + +// Global concurrency manager instance +export const analysisConcurrencyManager = ConcurrencyManager(); diff --git a/app/scripts/components/exploration/data-utils.ts b/app/scripts/components/exploration/data-utils.ts index cca52fd68..57ebd33a2 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'; @@ -23,10 +24,37 @@ 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 ); + +/** + * 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 +87,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/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index f1c5b2b19..ae9c6d1a9 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -1,12 +1,11 @@ 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'; 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 @@ -416,9 +417,13 @@ 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 setIsAnalysis = useSetAtom(isAnalysisAtom); + + const { isObsolete, runAnalysis, cancelAnalysis, isAnalyzing } = + useAnalysisController(); + + const datasetIds = timelineDatasets.map((d) => d.data.id); useEffect(() => { const listener = (e) => { @@ -600,17 +605,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..fd3385d35 --- /dev/null +++ b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { FeatureCollection, Polygon } from 'geojson'; +import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; + +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'; +import useAois from '$components/common/map/controls/hooks/use-aois'; + +export function useAnalysisController() { + const [controller, setController] = useAtom(analysisControllerAtom); + + return { + 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( + (datasetsIds) => { + const ids = Array.isArray(datasetsIds) ? datasetsIds : [datasetsIds]; + setController((v) => ({ + ...v, + // 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( + () => + setController((v) => ({ + ...v, + isAnalyzing: false, + isObsolete: false + })), + [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable + ), + getRunId: (id: string) => controller.runIds[id] ?? 0, + }; +} + +export function useAnalysisDataRequest({ + datasetAtom +}: { + datasetAtom: PrimitiveAtom; +}) { + const queryClient = useQueryClient(); + + const selectedInterval = useAtomValue(selectedIntervalAtom); + const { features } = useAois(); + const selectedFeatures = features.filter((f) => f.selected); + + 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({ + queryKey: ['analysis'], + fetchStatus: 'fetching' + }); + analysisConcurrencyManager.clear(); + } + }, [isAnalyzing]); + + useEffect(() => { + if ( + datasetStatus !== TimelineDatasetStatus.SUCCESS || + !selectedInterval || + !selectedFeatures.length + ) { + return; + } + + const aoi: FeatureCollection = { + type: 'FeatureCollection', + features: selectedFeatures + }; + + 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. + }, [analysisRunId, datasetStatus]); +} diff --git a/app/scripts/components/exploration/types.d.ts.ts b/app/scripts/components/exploration/types.d.ts.ts index bfc8ddc17..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', @@ -19,9 +20,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; @@ -75,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 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,