From c6e0f5452403410b606171bb2e9aa46b37cc0a90 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Sat, 23 Sep 2023 11:50:46 +0100 Subject: [PATCH 1/5] Allow chart metrics to be configured per dataset analysed Fix #669 --- .../results/analysis-head-actions.tsx | 199 ------------------ .../analysis/results/analysis-head.tsx | 79 +++++++ .../results/analysis-metrics-dropdown.tsx | 139 ++++++++++++ .../analysis/results/chart-card.tsx | 67 ++++-- .../components/analysis/results/index.tsx | 18 +- .../components/common/chart/analysis/utils.ts | 2 +- 6 files changed, 270 insertions(+), 234 deletions(-) delete mode 100644 app/scripts/components/analysis/results/analysis-head-actions.tsx create mode 100644 app/scripts/components/analysis/results/analysis-head.tsx create mode 100644 app/scripts/components/analysis/results/analysis-metrics-dropdown.tsx diff --git a/app/scripts/components/analysis/results/analysis-head-actions.tsx b/app/scripts/components/analysis/results/analysis-head-actions.tsx deleted file mode 100644 index 0372793f1..000000000 --- a/app/scripts/components/analysis/results/analysis-head-actions.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, { Fragment } from 'react'; -import styled, { useTheme } from 'styled-components'; -import { glsp, media, themeVal } from '@devseed-ui/theme-provider'; -import { Dropdown, DropTitle } from '@devseed-ui/dropdown'; -import { Button } from '@devseed-ui/button'; -import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; -import { FormSwitch } from '@devseed-ui/form'; - -import { FoldHeadActions } from '$components/common/fold'; -import { - Legend, - LegendTitle, - LegendList, - LegendSwatch, - LegendLabel -} from '$styles/infographics'; - -export interface DataMetric { - id: string; - label: string; - chartLabel: string; - themeColor: - | 'infographicA' - | 'infographicB' - | 'infographicC' - | 'infographicD' - | 'infographicE'; -} - -export const dataMetrics: DataMetric[] = [ - { - id: 'min', - label: 'Min', - chartLabel: 'Min', - themeColor: 'infographicA' - }, - { - id: 'mean', - label: 'Average', - chartLabel: 'Avg', - themeColor: 'infographicB' - }, - { - id: 'max', - label: 'Max', - chartLabel: 'Max', - themeColor: 'infographicC' - }, - { - id: 'std', - label: 'St Deviation', - chartLabel: 'STD', - themeColor: 'infographicD' - }, - { - id: 'median', - label: 'Median', - chartLabel: 'Median', - themeColor: 'infographicE' - } -]; - -const MetricList = styled.ul` - display: flex; - flex-flow: column; - list-style: none; - margin: 0 -${glsp()}; - padding: 0; - gap: ${glsp(0.5)}; - - > li { - padding: ${glsp(0, 1)}; - } -`; - -const MetricSwitch = styled(FormSwitch)<{ metricThemeColor: string }>` - display: grid; - grid-template-columns: min-content 1fr auto; - - &::before { - content: ''; - width: 0.5rem; - height: 0.5rem; - background: ${({ metricThemeColor }) => - themeVal(`color.${metricThemeColor}` as any)}; - border-radius: ${themeVal('shape.ellipsoid')}; - align-self: center; - } -`; - -const AnalysisFoldHeadActions = styled(FoldHeadActions)` - width: 100%; - - ${media.mediumUp` - width: auto; - `} - - ${Button} { - margin-left: auto; - } -`; - -const AnalysisLegend = styled(Legend)` - flex-flow: column nowrap; - align-items: flex-start; - - ${media.smallUp` - flex-flow: row nowrap; - align-items: center; - `}; -`; - -const AnalysisLegendList = styled(LegendList)` - display: grid; - grid-template-columns: repeat(6, auto); - - ${media.smallUp` - display: flex; - flex-flow: row nowrap; - `}; -`; - -interface AnalysisHeadActionsProps { - activeMetrics: DataMetric[]; - onMetricsChange: (metrics: DataMetric[]) => void; -} - -export default function AnalysisHeadActions(props: AnalysisHeadActionsProps) { - const { activeMetrics, onMetricsChange } = props; - const theme = useTheme(); - - const handleMetricChange = (metric: DataMetric, shouldAdd: boolean) => { - onMetricsChange( - shouldAdd - ? activeMetrics.concat(metric) - : activeMetrics.filter((m) => m.id !== metric.id) - ); - }; - - return ( - - - Legend - - {dataMetrics.map((metric) => { - const active = !!activeMetrics.find((m) => m.id === metric.id); - return ( - - - - {theme.color?.[metric.themeColor]} - - - - {metric.label} - - ); - })} - - - - ( - - )} - > - View options - - {dataMetrics.map((metric) => { - const checked = !!activeMetrics.find((m) => m.id === metric.id); - return ( -
  • - handleMetricChange(metric, !checked)} - > - {metric.label} - -
  • - ); - })} -
    -
    -
    - ); -} diff --git a/app/scripts/components/analysis/results/analysis-head.tsx b/app/scripts/components/analysis/results/analysis-head.tsx new file mode 100644 index 000000000..19f803269 --- /dev/null +++ b/app/scripts/components/analysis/results/analysis-head.tsx @@ -0,0 +1,79 @@ +import React, { Fragment } from 'react'; +import styled, { useTheme } from 'styled-components'; +import { media } from '@devseed-ui/theme-provider'; +import { Button } from '@devseed-ui/button'; + +import { DATA_METRICS } from './analysis-metrics-dropdown'; + +import { FoldHeadActions } from '$components/common/fold'; +import { + Legend, + LegendTitle, + LegendList, + LegendSwatch, + LegendLabel +} from '$styles/infographics'; + +const AnalysisFoldHeadActions = styled(FoldHeadActions)` + width: 100%; + + ${media.mediumUp` + width: auto; + `} + + ${Button} { + margin-left: auto; + } +`; + +const AnalysisLegend = styled(Legend)` + flex-flow: column nowrap; + align-items: flex-start; + + ${media.smallUp` + flex-flow: row nowrap; + align-items: center; + `}; +`; + +const AnalysisLegendList = styled(LegendList)` + display: grid; + grid-template-columns: repeat(6, auto); + + ${media.smallUp` + display: flex; + flex-flow: row nowrap; + `}; +`; + +export default function AnalysisHead() { + const theme = useTheme(); + + return ( + + + Legend + + {DATA_METRICS.map((metric) => { + return ( + + + + {theme.color?.[metric.themeColor]} + + + + {metric.label} + + ); + })} + + + + ); +} diff --git a/app/scripts/components/analysis/results/analysis-metrics-dropdown.tsx b/app/scripts/components/analysis/results/analysis-metrics-dropdown.tsx new file mode 100644 index 000000000..bf2b268ea --- /dev/null +++ b/app/scripts/components/analysis/results/analysis-metrics-dropdown.tsx @@ -0,0 +1,139 @@ +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'; + +export interface DataMetric { + id: string; + label: string; + chartLabel: string; + themeColor: + | 'infographicA' + | 'infographicB' + | 'infographicC' + | 'infographicD' + | 'infographicE'; +} + +export const DATA_METRICS: DataMetric[] = [ + { + id: 'min', + label: 'Min', + chartLabel: 'Min', + themeColor: 'infographicA' + }, + { + id: 'mean', + label: 'Average', + chartLabel: 'Avg', + themeColor: 'infographicB' + }, + { + id: 'max', + label: 'Max', + chartLabel: 'Max', + themeColor: 'infographicC' + }, + { + id: 'std', + label: 'St Deviation', + chartLabel: 'STD', + themeColor: 'infographicD' + }, + { + id: 'median', + label: 'Median', + chartLabel: 'Median', + themeColor: 'infographicE' + } +]; + +const MetricList = styled.ul` + display: flex; + flex-flow: column; + list-style: none; + margin: 0 -${glsp()}; + padding: 0; + gap: ${glsp(0.5)}; + + > li { + padding: ${glsp(0, 1)}; + } +`; + +const MetricSwitch = styled(FormSwitch)<{ metricThemeColor: string }>` + display: grid; + grid-template-columns: min-content 1fr auto; + + &::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + background: ${({ metricThemeColor }) => + themeVal(`color.${metricThemeColor}` as any)}; + border-radius: ${themeVal('shape.ellipsoid')}; + align-self: center; + } +`; + +interface AnalysisMetricsDropdownProps { + activeMetrics: DataMetric[]; + onMetricsChange: (metrics: DataMetric[]) => void; + isDisabled: boolean; +} + +export default function AnalysisMetricsDropdown( + props: AnalysisMetricsDropdownProps +) { + const { activeMetrics, onMetricsChange, isDisabled } = props; + + const handleMetricChange = (metric: DataMetric, shouldAdd: boolean) => { + onMetricsChange( + shouldAdd + ? activeMetrics.concat(metric) + : activeMetrics.filter((m) => m.id !== metric.id) + ); + }; + + return ( + ( + + )} + > + View options + + {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/analysis/results/chart-card.tsx b/app/scripts/components/analysis/results/chart-card.tsx index 0cb54fb75..8eea39dbc 100644 --- a/app/scripts/components/analysis/results/chart-card.tsx +++ b/app/scripts/components/analysis/results/chart-card.tsx @@ -1,4 +1,13 @@ -import React, { useCallback, useRef, useMemo, MouseEvent, ReactNode } from 'react'; +import React, { + useCallback, + useRef, + useMemo, + MouseEvent, + ReactNode, + useState +} from 'react'; +import { DatasetLayer } from 'veda'; +import { get } from 'lodash'; import { reverse } from 'd3'; import styled, { useTheme } from 'styled-components'; import { Link } from 'react-router-dom'; @@ -23,7 +32,11 @@ import { ChartCardNoData, ChartCardNoMetric } from './chart-card-message'; -import { DataMetric } from './analysis-head-actions'; +import AnalysisMetricsDropdown, { + DataMetric, + DATA_METRICS +} from './analysis-metrics-dropdown'; + import { CardSelf, CardHeader, @@ -56,10 +69,25 @@ const InfoTipContent = styled.div` } `; +function getInitialMetrics(data: DatasetLayer): DataMetric[] { + const metricsIds = get(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; +} + interface ChartCardProps { title: ReactNode; chartData: TimeseriesData; - activeMetrics: DataMetric[]; availableDomain: [Date, Date]; brushRange: [Date, Date]; onBrushRangeChange: (range: [Date, Date]) => void; @@ -93,29 +121,23 @@ const getNoDownloadReason = ({ status, data }: TimeseriesData) => { * * @returns Internal path for Link */ -const getDatasetOverviewPath = ( - layerId: string -) => { +const getDatasetOverviewPath = (layerId: string) => { const dataset = allDatasetsProps.find((d) => d.layers.find((l) => l.id === layerId) ); - return dataset - ? getDatasetPath(dataset) - : '/'; + return dataset ? getDatasetPath(dataset) : '/'; }; export default function ChartCard(props: ChartCardProps) { - const { - title, - chartData, - activeMetrics, - availableDomain, - brushRange, - onBrushRangeChange - } = props; + const { title, chartData, availableDomain, brushRange, onBrushRangeChange } = + props; const { status, meta, data, error, name, id, layer } = chartData; + const [activeMetrics, setActiveMetrics] = useState( + getInitialMetrics(layer) + ); + const chartRef = useRef(null); const noDownloadReason = getNoDownloadReason(chartData); @@ -132,7 +154,10 @@ export default function ChartCard(props: ChartCardProps) { // The indexes expect the data to be ascending, so we have to reverse the // data. const data = reverse(chartData.data.timeseries); - const filename = `chart.${id}.${getDateRangeFormatted(startDate, endDate)}`; + const filename = `chart.${id}.${getDateRangeFormatted( + startDate, + endDate + )}`; if (type === 'image') { chartRef.current?.saveAsImage(filename); @@ -206,7 +231,11 @@ export default function ChartCard(props: ChartCardProps) { - + (dataMetrics); - useEffect(() => { if (!start || !end || !datasetsLayers || !aoi) return; @@ -186,10 +181,7 @@ export default function AnalysisResults() { return ( - + Results - + {!!requestStatus.length && availableDomain && brushRange && ( @@ -230,7 +219,6 @@ export default function AnalysisResults() { setBrushRange(range)} diff --git a/app/scripts/components/common/chart/analysis/utils.ts b/app/scripts/components/common/chart/analysis/utils.ts index 05b9ca28f..357bc1c91 100644 --- a/app/scripts/components/common/chart/analysis/utils.ts +++ b/app/scripts/components/common/chart/analysis/utils.ts @@ -5,7 +5,7 @@ import { chartAspectRatio } from '$components/common/chart/constant'; import { TimeseriesDataUnit } from '$components/analysis/results/timeseries-data'; -import { DataMetric } from '$components/analysis/results/analysis-head-actions'; +import { DataMetric } from '$components/analysis/results/analysis-head'; import { TimeDensity } from '$context/layer-data'; const URL = window.URL; From 88c046c80b143e4225756c00ffb8c30e46b29df8 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Sat, 23 Sep 2023 11:51:25 +0100 Subject: [PATCH 2/5] Add analysis metrics documentation and mock data --- docs/content/frontmatter/layer.md | 22 ++++++++++++++++++++++ mock/datasets/no2.data.mdx | 5 +++++ parcel-resolver-veda/index.d.ts | 3 +++ 3 files changed, 30 insertions(+) diff --git a/docs/content/frontmatter/layer.md b/docs/content/frontmatter/layer.md index 74ddcaae5..800fb2e74 100644 --- a/docs/content/frontmatter/layer.md +++ b/docs/content/frontmatter/layer.md @@ -4,6 +4,7 @@ - [Properties](#properties) - [Projection](#projection) - [Legend](#legend) + - [Analysis](#analysis) - [Compare](#compare) - [Function values](#function-values) @@ -22,6 +23,7 @@ sourceParams: [key]: value | fn(bag) compare: Compare legend: Legend +analysis: Analysis ``` ## Properties @@ -203,6 +205,26 @@ stops: label: Barley ``` +### Analysis + +**analysis** +`object` +Configuration options for the analysis of the dataset layer. + +```yaml +metrics: string[] +``` + +**analysis.metrics** +`string[]` +List of metric ids to enable by default when analysis is performed. The user will then be able to select which metrics to display. +Available metrics: +- min (Min) +- mean (Average) +- max (Max) +- std (Standard Deviation) +- median (Median) + ### Compare **compare** diff --git a/mock/datasets/no2.data.mdx b/mock/datasets/no2.data.mdx index 49a5addd8..3359b9642 100644 --- a/mock/datasets/no2.data.mdx +++ b/mock/datasets/no2.data.mdx @@ -86,6 +86,11 @@ layers: - "#c13b72" - "#461070" - "#050308" + analysis: + metrics: + - min + - max + - non-existent - id: no2-monthly-2 stacCol: no2-monthly name: No2 US diff --git a/parcel-resolver-veda/index.d.ts b/parcel-resolver-veda/index.d.ts index 6c610201c..8c30cd5cb 100644 --- a/parcel-resolver-veda/index.d.ts +++ b/parcel-resolver-veda/index.d.ts @@ -62,6 +62,9 @@ declare module 'veda' { type: DatasetLayerType; compare: DatasetLayerCompareSTAC | DatasetLayerCompareInternal | null; legend?: LayerLegendCategorical | LayerLegendGradient; + analysis?: { + metrics: string[]; + } } // A normalized compare layer is the result after the compare definition is From 04cd7df881dbc4030869138db6148c44d7433419 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Sat, 23 Sep 2023 12:13:33 +0100 Subject: [PATCH 3/5] Adds option to exclude dataset from analysis --- app/scripts/components/analysis/define/index.tsx | 2 +- .../components/analysis/results/use-analysis-params.ts | 2 +- docs/content/frontmatter/layer.md | 5 +++++ mock/datasets/no2.data.mdx | 2 ++ parcel-resolver-veda/index.d.ts | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/scripts/components/analysis/define/index.tsx b/app/scripts/components/analysis/define/index.tsx index 789f1615e..503613123 100644 --- a/app/scripts/components/analysis/define/index.tsx +++ b/app/scripts/components/analysis/define/index.tsx @@ -114,7 +114,7 @@ export const allAvailableDatasetsLayers: DatasetLayer[] = Object.values( ) .map((dataset) => (dataset as VedaDatum).data.layers) .flat() - .filter((d) => d.type !== 'vector'); + .filter((d) => d.type !== 'vector' && !d.analysis?.exclude); export default function Analysis() { const { params, setAnalysisParam } = useAnalysisParams(); diff --git a/app/scripts/components/analysis/results/use-analysis-params.ts b/app/scripts/components/analysis/results/use-analysis-params.ts index 95734d4a6..b377be0f8 100644 --- a/app/scripts/components/analysis/results/use-analysis-params.ts +++ b/app/scripts/components/analysis/results/use-analysis-params.ts @@ -89,7 +89,7 @@ export function useAnalysisParams(): { // When accessing the object values with Object.values, they'll always // be defined. (d) => d!.data.layers - ); + ).filter((l) => !l.analysis?.exclude); const layers = datasetsLayers.split('|').map((id) => // Find the one we're looking for. allDatasetLayers.find((l) => l.id === id) diff --git a/docs/content/frontmatter/layer.md b/docs/content/frontmatter/layer.md index 800fb2e74..ca074b182 100644 --- a/docs/content/frontmatter/layer.md +++ b/docs/content/frontmatter/layer.md @@ -213,6 +213,7 @@ Configuration options for the analysis of the dataset layer. ```yaml metrics: string[] +exclude: boolean ``` **analysis.metrics** @@ -224,6 +225,10 @@ Available metrics: - max (Max) - std (Standard Deviation) - median (Median) +- +**analysis.exclude** +`boolean` +Controls whether this layer should be excluded from the analysis page. If set to `true` the layer will not be available for analysis. ### Compare diff --git a/mock/datasets/no2.data.mdx b/mock/datasets/no2.data.mdx index 3359b9642..d9f5160bb 100644 --- a/mock/datasets/no2.data.mdx +++ b/mock/datasets/no2.data.mdx @@ -122,6 +122,8 @@ layers: - "#c13b72" - "#461070" - "#050308" + analysis: + exclude: true - id: no2-monthly-diff stacCol: no2-monthly-diff name: No2 (Diff) - let's make this title reaaaaaaly long diff --git a/parcel-resolver-veda/index.d.ts b/parcel-resolver-veda/index.d.ts index 8c30cd5cb..43ea624f0 100644 --- a/parcel-resolver-veda/index.d.ts +++ b/parcel-resolver-veda/index.d.ts @@ -64,6 +64,7 @@ declare module 'veda' { legend?: LayerLegendCategorical | LayerLegendGradient; analysis?: { metrics: string[]; + exclude: boolean; } } From 2fccb190664686fac431bfc2533a7917ad2129e7 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Sat, 23 Sep 2023 12:16:53 +0100 Subject: [PATCH 4/5] Fix ts error --- app/scripts/components/common/chart/analysis/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scripts/components/common/chart/analysis/utils.ts b/app/scripts/components/common/chart/analysis/utils.ts index 357bc1c91..8e4c2c453 100644 --- a/app/scripts/components/common/chart/analysis/utils.ts +++ b/app/scripts/components/common/chart/analysis/utils.ts @@ -5,8 +5,8 @@ import { chartAspectRatio } from '$components/common/chart/constant'; import { TimeseriesDataUnit } from '$components/analysis/results/timeseries-data'; -import { DataMetric } from '$components/analysis/results/analysis-head'; import { TimeDensity } from '$context/layer-data'; +import { DataMetric } from '$components/analysis/results/analysis-metrics-dropdown'; const URL = window.URL; From fd3a7245fa2eeda23460ac6c07068db9ee5589d5 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Mon, 25 Sep 2023 11:14:37 +0200 Subject: [PATCH 5/5] Do not use lodash when unneeded --- app/scripts/components/analysis/results/chart-card.tsx | 5 ++--- mock/datasets/no2.data.mdx | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/scripts/components/analysis/results/chart-card.tsx b/app/scripts/components/analysis/results/chart-card.tsx index 8eea39dbc..9b580b289 100644 --- a/app/scripts/components/analysis/results/chart-card.tsx +++ b/app/scripts/components/analysis/results/chart-card.tsx @@ -7,7 +7,6 @@ import React, { useState } from 'react'; import { DatasetLayer } from 'veda'; -import { get } from 'lodash'; import { reverse } from 'd3'; import styled, { useTheme } from 'styled-components'; import { Link } from 'react-router-dom'; @@ -70,11 +69,11 @@ const InfoTipContent = styled.div` `; function getInitialMetrics(data: DatasetLayer): DataMetric[] { - const metricsIds = get(data, 'analysis.metrics', []); + const metricsIds = data.analysis?.metrics ?? []; const foundMetrics = metricsIds .map((metric: string) => { - return DATA_METRICS.find((m) => m.id === metric); + return DATA_METRICS.find((m) => m.id === metric)!; }) .filter(Boolean); diff --git a/mock/datasets/no2.data.mdx b/mock/datasets/no2.data.mdx index d9f5160bb..8d4d82d08 100644 --- a/mock/datasets/no2.data.mdx +++ b/mock/datasets/no2.data.mdx @@ -90,6 +90,7 @@ layers: metrics: - min - max + # dummy value to make sure non existent values are sagfely discarded - non-existent - id: no2-monthly-2 stacCol: no2-monthly