diff --git a/app/scripts/components/analysis/constants.ts b/app/scripts/components/analysis/constants.ts new file mode 100644 index 000000000..a5605fad5 --- /dev/null +++ b/app/scripts/components/analysis/constants.ts @@ -0,0 +1,50 @@ +import { FeatureCollection, Polygon } from 'geojson'; +import { featureCollection } from '@turf/helpers'; + +export type RegionPreset = 'world' | 'north-america'; + +export const FeatureByRegionPreset: Record< + RegionPreset, + FeatureCollection +> = { + world: featureCollection([ + { + type: 'Feature', + id: 'world', + properties: {}, + geometry: { + coordinates: [ + [ + [-180, -89], + [180, -89], + [180, 89], + [-180, 89], + [-180, -89] + ] + ], + type: 'Polygon' + } + } + ]), + 'north-america': featureCollection([ + { + type: 'Feature', + id: 'north-america', + properties: {}, + geometry: { + coordinates: [ + [ + [-180, 0], + [-180, 89], + [-60, 89], + [-60, 0], + [-180, 0] + ] + ], + type: 'Polygon' + } + } + ]) +}; + +export const MAX_QUERY_NUM = 300; \ No newline at end of file diff --git a/app/scripts/components/analysis/define/aoi-selector.tsx b/app/scripts/components/analysis/define/aoi-selector.tsx index 2915c87c4..4ba1a3d87 100644 --- a/app/scripts/components/analysis/define/aoi-selector.tsx +++ b/app/scripts/components/analysis/define/aoi-selector.tsx @@ -19,20 +19,19 @@ import { import { Button, ButtonGroup } from '@devseed-ui/button'; import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; import { - CollecticonArrowLoop, + CollecticonTrashBin, CollecticonHandPan, CollecticonMarker, CollecticonPencil, CollecticonUpload2 } from '@devseed-ui/collecticons'; -import { FeatureByRegionPreset, RegionPreset } from './constants'; +import { FeatureByRegionPreset, RegionPreset } from '../constants'; import AoIUploadModal from './aoi-upload-modal'; +import { FoldWGuideLine, FoldTitleWOAccent } from '.'; import { - Fold, FoldHeader, FoldHeadline, FoldHeadActions, - FoldTitle, FoldBody } from '$components/common/fold'; import MapboxMap, { MapboxMapRef } from '$components/common/mapbox'; @@ -120,7 +119,7 @@ export default function AoiSelector({ const [aoiModalRevealed, setAoIModalRevealed] = useState(false); return ( - + - Area + Select area of interest +

+ Use the pencil tool to draw a shape on the map or upload your own + shapefile. +

onAoiEvent('aoi.clear')} disabled={!featureCollection?.features.length} > - + @@ -179,6 +182,13 @@ export default function AoiSelector({ World +
  • + onRegionPresetClick('north-america')} + > + North America + +
  • @@ -194,6 +204,6 @@ export default function AoiSelector({ /> -
    + ); } diff --git a/app/scripts/components/analysis/define/constants.ts b/app/scripts/components/analysis/define/constants.ts deleted file mode 100644 index 79c6101ba..000000000 --- a/app/scripts/components/analysis/define/constants.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { FeatureCollection, Polygon } from 'geojson'; -import { makeFeatureCollection } from '$components/common/aoi/utils'; - -export type RegionPreset = 'world'; - -export const FeatureByRegionPreset: Record< - RegionPreset, - FeatureCollection -> = { - world: makeFeatureCollection([ - { - type: 'Feature', - id: 'world', - properties: {}, - geometry: { - coordinates: [ - [ - [-180, -89], - [180, -89], - [180, 89], - [-180, 89], - [-180, -89] - ] - ], - type: 'Polygon' - } - } - ]) -}; diff --git a/app/scripts/components/analysis/define/index.tsx b/app/scripts/components/analysis/define/index.tsx index 503613123..4eeb35af9 100644 --- a/app/scripts/components/analysis/define/index.tsx +++ b/app/scripts/components/analysis/define/index.tsx @@ -5,10 +5,9 @@ import React, { MouseEvent, useRef } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { media, multiply, themeVal } from '@devseed-ui/theme-provider'; -import { Toolbar, ToolbarIconButton, ToolbarLabel } from '@devseed-ui/toolbar'; -import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; +import { Toolbar, ToolbarLabel } from '@devseed-ui/toolbar'; import { Form, FormCheckable, @@ -17,15 +16,17 @@ import { } from '@devseed-ui/form'; import { CollecticonCircleInformation, - CollecticonEllipsisVertical + CollecticonSignDanger } from '@devseed-ui/collecticons'; import { Overline } from '@devseed-ui/typography'; -import { datasets, DatasetLayer, VedaDatum, DatasetData } from 'veda'; +import { datasets, DatasetLayer } from 'veda'; +import { Button, ButtonGroup } from '@devseed-ui/button'; import { useAnalysisParams } from '../results/use-analysis-params'; +import SavedAnalysisControl from '../saved-analysis-control'; import AoiSelector from './aoi-selector'; -import PageHeroActions from './page-hero-actions'; import { useStacCollectionSearch } from './use-stac-collection-search'; +import PageFooterActions from './page-footer.actions'; import { variableGlsp } from '$styles/variable-utils'; import { PageMainContent } from '$styles/page'; @@ -47,14 +48,14 @@ import { getRangeFromPreset, inputFormatToDate } from '$utils/date'; -import DropMenuItemButton from '$styles/drop-menu-item-button'; + import { MapboxMapRef } from '$components/common/mapbox'; +import { ANALYSIS_PATH } from '$utils/routes'; const FormBlock = styled.div` display: flex; flex-flow: row nowrap; gap: ${variableGlsp(0.5)}; - > * { width: 50%; } @@ -100,11 +101,135 @@ export const Note = styled.div` } `; +const UnselectableInfo = styled.div` + font-size: 0.825rem; + font-weight: bold; + display: flex; + align-items: center; + gap: ${variableGlsp(0.5)}; + + & path { + fill: ${themeVal('color.danger')}; + } +`; + +const FormCheckableUnselectable = styled(FormCheckableCustom)` + pointer-events: none; + background: #f0f0f5; +`; + +const DataPointsWarning = styled.div` + display: flex; + align-items: center; + background: #fc3d2119; + border-radius: 99px; + font-size: 0.825rem; + font-weight: bold; + margin-top: ${variableGlsp(0.5)}; + padding: 2px 0 2px 6px; + color: ${themeVal('color.danger')}; + + & path { + fill: ${themeVal('color.danger')}; + } +`; + +const FloatingFooter = styled.div<{ isSticky: boolean }>` + position: sticky; + left: 0; + right: 0; + bottom: -1px; + padding: ${variableGlsp(0.5)}; + background: ${themeVal('color.surface')}; + z-index: 99; + margin-bottom: ${variableGlsp(1)}; + ${(props) => + props.isSticky && + css` + box-shadow: ${themeVal('boxShadow.elevationD')}; + `} +`; + +const FoldWithBullet = styled(Fold)<{ number: string }>` + ${media.largeUp` + padding-left: ${variableGlsp(1)}; + + > div { + padding-left: ${variableGlsp(2)}; + position: relative; + + /* bullet */ + &::after { + position: absolute; + width: ${variableGlsp(1.5)}; + height: ${variableGlsp(1.5)}; + background-color: ${themeVal('color.primary')}; + color: ${themeVal('color.surface')}; + border-radius: ${themeVal('shape.ellipsoid')}; + font-size: 1.75rem; + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + ${(props: { number: string }) => + css` + content: '${props.number}'; + `} + } + } +`} +`; + +export const FoldWGuideLine = styled(FoldWithBullet)` + ${media.largeUp` + padding-bottom: 0; + > div { + padding-bottom: ${variableGlsp(2)}; + &::before { + position: absolute; + content: ''; + height: 100%; + left: ${variableGlsp(0.7)}; + border-left : 3px solid ${themeVal('color.base-200a')}; + } + } + `} +`; + +const FoldWOPadding = styled(Fold)` + padding: 0; +`; + +export const FoldTitleWOAccent = styled(FoldTitle)` + ${media.largeUp` + &::before { + content: none; + } + `} +`; + +const FormGroupStructureCustom = styled(FormGroupStructure)` + ${media.largeUp` + display: inline-flex; + align-items: center; + `} +`; + +const ToolbarLabelWithSpace = styled(ToolbarLabel)` + margin-right: ${variableGlsp(0.5)}; +`; + +const FoldBodyCustom = styled(FoldBody)` + ${media.largeUp` + flex-flow: row; + flex-grow: 3; + justify-content: space-between; + `} +`; + const findParentDataset = (layerId: string) => { const parentDataset = Object.values(datasets).find((dataset) => - (dataset as VedaDatum).data.layers.find( - (l) => l.id === layerId - ) + dataset!.data.layers.find((l) => l.id === layerId) ); return parentDataset?.data; }; @@ -112,7 +237,7 @@ const findParentDataset = (layerId: string) => { export const allAvailableDatasetsLayers: DatasetLayer[] = Object.values( datasets ) - .map((dataset) => (dataset as VedaDatum).data.layers) + .map((dataset) => dataset!.data.layers) .flat() .filter((d) => d.type !== 'vector' && !d.analysis?.exclude); @@ -183,12 +308,16 @@ export default function Analysis() { [setAnalysisParam, datasetsLayers] ); - const { selectableDatasetLayers, stacSearchStatus, readyToLoadDatasets } = - useStacCollectionSearch({ - start, - end, - aoi: aoiDrawState.featureCollection - }); + const { + selectableDatasetLayers, + unselectableDatasetLayers, + stacSearchStatus, + readyToLoadDatasets + } = useStacCollectionSearch({ + start, + end, + aoi: aoiDrawState.featureCollection + }); // Update datasetsLayers when stac search is refreshed in case some // datasetsLayers are not available anymore @@ -197,7 +326,7 @@ export default function Analysis() { const selectableDatasetLayersIds = selectableDatasetLayers.map( (layer) => layer.id ); - const cleanedDatasetsLayers = datasetsLayers?.filter((l) => + const cleanedDatasetsLayers = datasetsLayers.filter((l) => selectableDatasetLayersIds.includes(l.id) ); @@ -206,7 +335,7 @@ export default function Analysis() { // read/set state loop }, [selectableDatasetLayers, setAnalysisParam]); - const showTip = !readyToLoadDatasets || !datasetsLayers?.length; + const notReady = !readyToLoadDatasets || !datasetsLayers?.length; const infoboxMessage = useMemo(() => { if ( @@ -230,163 +359,196 @@ export default function Analysis() { } }, [readyToLoadDatasets, stacSearchStatus, selectableDatasetLayers.length]); + const footerRef = useRef(null); + const [isFooterSticky, setIsFooterSticky] = React.useState(false); + useEffect(() => { + if (!footerRef.current) return; + const observer = new IntersectionObserver( + ([e]) => { + setIsFooterSticky(e.intersectionRatio < 1); + }, + { threshold: [1] } + ); + observer.observe(footerRef.current); + return () => observer.disconnect(); + }, []); + return ( - - - ( - - )} - /> - - - - - - - Date - - - - Actions - ( - - - - )} - > - Select a date preset - -
  • - onDatePresetClick(e, 'yearToDate')} - > - This year - -
  • -
  • - onDatePresetClick(e, 'last30Days')} - > - Last 30 days - -
  • -
  • - onDatePresetClick(e, 'lastYear')} - > - Last year - -
  • -
  • - onDatePresetClick(e, 'last10Years')} - > - Last 10 years - -
  • -
    -
    -
    -
    -
    - -
    - - - - - - - - - -
    -
    -
    - - - - - Datasets - - - - {!infoboxMessage ? ( + <> + + + ( + + )} + /> + + + + + + Pick a date period +

    + Select start and end date of time series, or choose a pre-set + date range. +

    +
    + +
    +
    - - {selectableDatasetLayers.map((datasetLayer) => ( - - - From: {findParentDataset(datasetLayer.id)?.name} - - {datasetLayer.name} - - ))} - + + + + + + + + +
    - ) : ( - - -

    {infoboxMessage}

    -
    - )} -
    -
    -
    + + Presets + + + + + + + + + + + + Select datasets +

    + Select from available dataset layers for the area and date range + selected. +

    +
    +
    + + {!infoboxMessage ? ( + <> +
    + + {selectableDatasetLayers.map((datasetLayer) => ( + + + From: {findParentDataset(datasetLayer.id)?.name} + + {datasetLayer.name} + + ))} + +
    + {!!unselectableDatasetLayers.length && ( + <> + + + The current area and date selection has returned ( + {unselectableDatasetLayers.length}) datasets with a very + large number of data points. To make them available, + please define a smaller area or a select a shorter date + period. + + +
    + + {unselectableDatasetLayers.map((datasetLayer) => ( + + + From: {findParentDataset(datasetLayer.id)?.name} + + {datasetLayer.name} + + ~ + {datasetLayer.numberOfItems} data points + + + ))} + +
    + + )} + + ) : ( + + +

    {infoboxMessage}

    +
    + )} +
    +
    + + + + + + + + + ); } diff --git a/app/scripts/components/analysis/define/page-footer.actions.tsx b/app/scripts/components/analysis/define/page-footer.actions.tsx new file mode 100644 index 000000000..8c41a2e69 --- /dev/null +++ b/app/scripts/components/analysis/define/page-footer.actions.tsx @@ -0,0 +1,133 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { format } from 'date-fns'; +import { Button } from '@devseed-ui/button'; +import { CollecticonTickSmall } from '@devseed-ui/collecticons'; + +import { DatasetLayer } from 'veda'; +import { FeatureCollection, Polygon } from 'geojson'; +import styled from 'styled-components'; +import { analysisParams2QueryString } from '../results/use-analysis-params'; +import useSavedSettings from '../use-saved-settings'; + +import { composeVisuallyDisabled } from '$utils/utils'; +import { ANALYSIS_RESULTS_PATH } from '$utils/routes'; +import { calcFeatCollArea } from '$components/common/aoi/utils'; + +const SaveButton = composeVisuallyDisabled(Button); + +interface PageFooterActionsProps { + isNewAnalysis: boolean; + start?: Date; + end?: Date; + datasetsLayers?: DatasetLayer[]; + aoi?: FeatureCollection | null; + disabled?: boolean; +} + +const FooterActions = styled.div` + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + gap: 1rem; +`; + +const FooterRight = styled.div` + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 1rem; +`; + +const AnalysisDescription = styled.div` + font-size: 0.875rem; + opacity: 0.5; +`; + +export default function PageFooterActions({ + // size, + isNewAnalysis, + start, + end, + datasetsLayers, + aoi, + disabled +}: PageFooterActionsProps) { + const analysisParamsQs = useMemo(() => { + if (!start || !end || !datasetsLayers || !aoi) return ''; + return analysisParams2QueryString({ + start, + end, + datasetsLayers, + aoi + }); + }, [start, end, datasetsLayers, aoi]); + + const { onGenerateClick } = useSavedSettings({ + analysisParamsQs, + params: { + start, + end, + datasets: datasetsLayers?.map((d) => d.name), + aoi: aoi ?? undefined + } + }); + + const analysisDescription = useMemo(() => { + if (!start || !end || !datasetsLayers || !aoi || !datasetsLayers.length) + return ''; + const dataset = + datasetsLayers.length === 1 + ? datasetsLayers[0].name + : `${datasetsLayers.length} datasets`; + const area = `over a ${calcFeatCollArea(aoi)} kmĀ² area`; + const dates = `from ${format(start, 'MMM d, yyyy')} to ${format( + end, + 'MMM d, yyyy' + )}`; + return [dataset, area, dates].join(' '); + }, [start, end, datasetsLayers, aoi]); + + return ( + +
    + {!isNewAnalysis && ( + + )} +
    + + {analysisDescription} + + {disabled ? ( + + Generate analysis + + ) : ( + + )} + +
    + ); +} diff --git a/app/scripts/components/analysis/define/page-hero-actions.tsx b/app/scripts/components/analysis/define/page-hero-actions.tsx deleted file mode 100644 index 225ad2aab..000000000 --- a/app/scripts/components/analysis/define/page-hero-actions.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { useMemo } from 'react'; -import { Link } from 'react-router-dom'; -import { sticky } from 'tippy.js'; -import { FeatureCollection, Polygon } from 'geojson'; -import { Button, ButtonProps } from '@devseed-ui/button'; -import { - CollecticonTickSmall, - CollecticonXmarkSmall -} from '@devseed-ui/collecticons'; -import { VerticalDivider } from '@devseed-ui/toolbar'; -import { DatasetLayer } from 'veda'; - -import { analysisParams2QueryString } from '../results/use-analysis-params'; -import useSavedSettings from '../use-saved-settings'; -import SavedAnalysisControl from '../saved-analysis-control'; - -import { Tip } from '$components/common/tip'; -import { composeVisuallyDisabled } from '$utils/utils'; -import { useMediaQuery } from '$utils/use-media-query'; -import { ANALYSIS_PATH, ANALYSIS_RESULTS_PATH } from '$utils/routes'; - -const SaveButton = composeVisuallyDisabled(Button); - -interface PageHeroActionsProps { - size: ButtonProps['size']; - isNewAnalysis: boolean; - showTip: boolean; - start?: Date; - end?: Date; - datasetsLayers?: DatasetLayer[]; - aoi?: FeatureCollection | null; -} - -export default function PageHeroActions({ - size, - isNewAnalysis, - showTip, - start, - end, - datasetsLayers, - aoi -}: PageHeroActionsProps) { - const { isLargeUp } = useMediaQuery(); - - const analysisParamsQs = useMemo(() => { - if (!start || !end || !datasetsLayers || !aoi) return ''; - return analysisParams2QueryString({ - start, - end, - datasetsLayers, - aoi - }); - }, [start, end, datasetsLayers, aoi]); - - const { onGenerateClick } = useSavedSettings({ - analysisParamsQs, - params: { - start, - end, - datasets: datasetsLayers?.map((d) => d.name), - aoi: aoi ?? undefined - } - }); - - let tipContents; - - if (showTip) { - tipContents = 'To get results, '; - let instructions: string[] = []; - if (!start || !end) - instructions = [...instructions, 'pick start and end dates']; - if (!aoi) instructions = [...instructions, 'define an area']; - if (!datasetsLayers?.length) - instructions = [...instructions, 'select datasets']; - - const instructionsString = instructions - .join(', ') - .replace(/,\s([^,]+)$/, ' and $1.'); - - tipContents = [tipContents, instructionsString].join(''); - } - - return ( - <> - {!isNewAnalysis && ( - - )} - {showTip ? ( - - - Generate - - - ) : ( - - )} - - - - - - ); -} diff --git a/app/scripts/components/analysis/define/use-stac-collection-search.ts b/app/scripts/components/analysis/define/use-stac-collection-search.ts index 2fed3097f..86e324a7f 100644 --- a/app/scripts/components/analysis/define/use-stac-collection-search.ts +++ b/app/scripts/components/analysis/define/use-stac-collection-search.ts @@ -4,8 +4,14 @@ import axios from 'axios'; import { useQuery } from '@tanstack/react-query'; import booleanIntersects from '@turf/boolean-intersects'; import bboxPolygon from '@turf/bbox-polygon'; -import { areIntervalsOverlapping } from 'date-fns'; - +import { + areIntervalsOverlapping +} from 'date-fns'; +import { DatasetLayer } from 'veda'; + +import { MAX_QUERY_NUM } from '../constants'; +import { TimeseriesDataResult } from '../results/timeseries-data'; +import { getNumberOfItemsWithinTimeRange } from './utils'; import { allAvailableDatasetsLayers } from '.'; import { utcString2userTzDate } from '$utils/date'; @@ -16,6 +22,9 @@ interface UseStacSearchProps { aoi?: FeatureCollection | null; } +export type DatasetWithTimeseriesData = TimeseriesDataResult & + DatasetLayer & { numberOfItems: number }; + const collectionUrl = `${process.env.API_STAC_ENDPOINT}/collections`; export function useStacCollectionSearch({ @@ -36,7 +45,7 @@ export function useStacCollectionSearch({ enabled: readyToLoadDatasets }); - const selectableDatasetLayers = useMemo(() => { + const datasetLayersInRange = useMemo(() => { try { return getInTemporalAndSpatialExtent(result.data, aoi, { start, @@ -47,8 +56,29 @@ export function useStacCollectionSearch({ } }, [result.data, aoi, start, end]); + const datasetLayersInRangeWithNumberOfItems: DatasetWithTimeseriesData[] = + useMemo(() => { + return datasetLayersInRange.map((l) => { + const numberOfItems = getNumberOfItemsWithinTimeRange(start, end, l); + return { ...l, numberOfItems }; + }); + }, [datasetLayersInRange, start, end]); + + const selectableDatasetLayers = useMemo(() => { + return datasetLayersInRangeWithNumberOfItems.filter( + (l) => l.numberOfItems <= MAX_QUERY_NUM + ); + }, [datasetLayersInRangeWithNumberOfItems]); + + const unselectableDatasetLayers = useMemo(() => { + return datasetLayersInRangeWithNumberOfItems.filter( + (l) => l.numberOfItems > MAX_QUERY_NUM + ); + }, [datasetLayersInRangeWithNumberOfItems]); + return { - selectableDatasetLayers: selectableDatasetLayers, + selectableDatasetLayers, + unselectableDatasetLayers, stacSearchStatus: result.status, readyToLoadDatasets }; @@ -95,7 +125,20 @@ function getInTemporalAndSpatialExtent(collectionData, aoi, timeRange) { } }, []); - return allAvailableDatasetsLayers.filter((l) => + const filteredDatasets = allAvailableDatasetsLayers.filter((l) => matchingCollectionIds.includes(l.stacCol) ); + + const filteredDatasetsWithCollections = filteredDatasets.map((l) => { + const collection = collectionData.find((c) => c.id === l.stacCol); + return { + ...l, + isPeriodic: collection['dashboard:is_periodic'], + timeDensity: collection['dashboard:time_density'], + domain: collection.extent.temporal.interval[0], + timeseries: collection.summaries.datetime + }; + }); + + return filteredDatasetsWithCollections; } diff --git a/app/scripts/components/analysis/define/utils.test.ts b/app/scripts/components/analysis/define/utils.test.ts new file mode 100644 index 000000000..d4f6a2cb6 --- /dev/null +++ b/app/scripts/components/analysis/define/utils.test.ts @@ -0,0 +1,38 @@ +import { getNumberOfItemsWithinTimeRange } from './utils'; + +describe('Item number logic', () => { + + it('checks when the dataset is not periodic', () => { + const notPeriodicDataset = { + isPeriodic: false, + timeseries: [ + "2001-01-16T15:07:02Z", + "2001-12-02T15:05:04Z", + "2002-12-21T15:04:52Z", + "2004-02-26T15:05:40Z", + "2005-02-12T15:06:08Z", + "2006-12-16T15:06:44Z", + "2007-01-17T15:06:46Z", + "2008-01-04T15:06:55Z", + "2008-02-21T15:06:48Z", + "2008-12-05T15:05:57Z", + "2009-12-08T15:07:25Z", + "2010-01-09T15:07:59Z", // match + "2010-01-25T15:08:13Z", // match + "2010-02-10T15:08:25Z", // match + "2010-12-27T15:09:41Z", // match + "2011-01-12T15:09:50Z", // match + "2011-01-28T15:09:56Z", // match + "2011-11-12T15:10:06Z", + "2011-12-30T15:10:33Z"] + }; + + const userStart = "2010-01-01T00:00:00Z"; + const userEnd = "2011-11-01T00:00:00Z"; + + const numberOfDates = getNumberOfItemsWithinTimeRange(userStart, userEnd, notPeriodicDataset); + + + expect(numberOfDates).toEqual(6); + }); +}); diff --git a/app/scripts/components/analysis/define/utils.ts b/app/scripts/components/analysis/define/utils.ts new file mode 100644 index 000000000..d01cec2a4 --- /dev/null +++ b/app/scripts/components/analysis/define/utils.ts @@ -0,0 +1,45 @@ +import { + eachDayOfInterval, + eachMonthOfInterval, + eachYearOfInterval +} from 'date-fns'; + +const DATE_INTERVAL_FN = { + day: eachDayOfInterval, + month: eachMonthOfInterval, + year: eachYearOfInterval +}; + +/** + * For each collection, get the number of items within the time range, + * taking into account the time density. + * Separated the function from use-stac-collection-search for easy unit test + */ +export function getNumberOfItemsWithinTimeRange( + userStart, + userEnd, + collection +) { + const { isPeriodic, timeDensity, domain, timeseries } = collection; + if (!isPeriodic) { + const numberOfItems = timeseries.reduce((acc, t) => { + const date = +new Date(t); + if (date >= +new Date(userStart) && date <= +new Date(userEnd)) { + return acc + 1; + } else { + return acc; + } + }, 0); + return numberOfItems; // Check in with back-end team + } + const eachOf = DATE_INTERVAL_FN[timeDensity]; + const start = + +new Date(domain[0]) > +new Date(userStart) + ? new Date(domain[0]) + : new Date(userStart); + const end = + +new Date(domain[1]) < +new Date(userEnd) + ? new Date(domain[1]) + : new Date(userEnd); + return eachOf({ start, end }).length; +} diff --git a/app/scripts/components/analysis/results/analysis-head.tsx b/app/scripts/components/analysis/results/analysis-head.tsx index 19f803269..42dcf25fe 100644 --- a/app/scripts/components/analysis/results/analysis-head.tsx +++ b/app/scripts/components/analysis/results/analysis-head.tsx @@ -13,9 +13,11 @@ import { LegendSwatch, LegendLabel } from '$styles/infographics'; +import { variableGlsp } from '$styles/variable-utils'; const AnalysisFoldHeadActions = styled(FoldHeadActions)` width: 100%; + margin-top: ${variableGlsp(2)}; ${media.mediumUp` width: auto; diff --git a/app/scripts/components/analysis/results/index.tsx b/app/scripts/components/analysis/results/index.tsx index d99ad8477..82c8fa757 100644 --- a/app/scripts/components/analysis/results/index.tsx +++ b/app/scripts/components/analysis/results/index.tsx @@ -55,11 +55,7 @@ const AnalysisFold = styled(Fold)` `; const AnalysisFoldHeader = styled(FoldHeader)` - flex-flow: row wrap; - - ${media.mediumUp` - flex-flow: row nowrap; - `} + display: block; `; export default function AnalysisResults() { @@ -208,6 +204,13 @@ export default function AnalysisResults() { Results +

    + Please note: The statistics shown here can be biased towards + some regions (usually higher latitudes) due to an inaccuracy in + our methods. Please use the statistics only to get an indication + of tendencies and over smaller regions and contact us for + assistance in making accurate calculations. +

    diff --git a/app/scripts/components/analysis/results/timeseries-data.ts b/app/scripts/components/analysis/results/timeseries-data.ts index eaae0ffc9..9d8b16ba8 100644 --- a/app/scripts/components/analysis/results/timeseries-data.ts +++ b/app/scripts/components/analysis/results/timeseries-data.ts @@ -3,6 +3,7 @@ import { QueryClient } from '@tanstack/react-query'; import { FeatureCollection, Polygon } from 'geojson'; import { DatasetLayer } from 'veda'; +import { MAX_QUERY_NUM } from '../constants'; import { getFilterPayload, combineFeatureCollection } from '../utils'; import EventEmitter from './mini-events'; import { ConcurrencyManager, ConcurrencyManagerInstance } from './concurrency'; @@ -30,7 +31,7 @@ export interface TimeseriesDataUnit { percentile_98: number; } -interface TimeseriesDataResult { +export interface TimeseriesDataResult { isPeriodic: boolean; timeDensity: TimeDensity; domain: string[]; @@ -252,6 +253,11 @@ async function requestTimeseries({ const { assets, ...otherCollectionProps } = layerInfoFromSTAC; + if (assets.length > MAX_QUERY_NUM) + throw Error( + `Too many requests. We currently only allow requests up to ${MAX_QUERY_NUM} and this analysis requires ${assets.length} requests.` + ); + onData({ ...layersBase, status: 'loading', diff --git a/app/scripts/components/analysis/results/use-analysis-params.ts b/app/scripts/components/analysis/results/use-analysis-params.ts index b377be0f8..f6c91d21c 100644 --- a/app/scripts/components/analysis/results/use-analysis-params.ts +++ b/app/scripts/components/analysis/results/use-analysis-params.ts @@ -25,13 +25,14 @@ type AnyAnalysisParamsKey = keyof AnalysisParams; type AnyAnalysisParamsType = Date | DatasetLayer[] | FeatureCollection; const initialState: AnalysisParamsNull = { - start: undefined, - end: undefined, + start: new Date(2018, 0, 1), + end: new Date(2022, 11, 31), datasetsLayers: undefined, aoi: undefined, errors: null }; +const LOG = process.env.NODE_ENV !== 'production'; export class ValidationError extends Error { hints: any[]; @@ -125,7 +126,7 @@ export function useAnalysisParams(): { } catch (error) { if (error instanceof ValidationError) { /* eslint-disable no-console */ - error.hints.forEach((s) => console.log(s)); + if (LOG) error.hints.forEach((s) => console.log(s)); /* eslint-enable no-console */ setParams({ ...initialState, diff --git a/app/scripts/components/analysis/saved-analysis-control.tsx b/app/scripts/components/analysis/saved-analysis-control.tsx index 4a6463f23..b57ed22a7 100644 --- a/app/scripts/components/analysis/saved-analysis-control.tsx +++ b/app/scripts/components/analysis/saved-analysis-control.tsx @@ -4,10 +4,9 @@ import { FeatureCollection, Polygon } from 'geojson'; import styled, { useTheme } from 'styled-components'; import bbox from '@turf/bbox'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { ButtonProps } from '@devseed-ui/button'; +import { Button, ButtonProps } from '@devseed-ui/button'; import { CollecticonClockBack } from '@devseed-ui/collecticons'; -import { ToolbarIconButton } from '@devseed-ui/toolbar'; import { Dropdown, DropMenu, @@ -91,14 +90,14 @@ export default function SavedAnalysisControl({ ( - - - + Past analyses + )} > Past analyses diff --git a/app/scripts/components/common/blocks/block-map.tsx b/app/scripts/components/common/blocks/block-map.tsx index 8539e420e..9a5b88e61 100644 --- a/app/scripts/components/common/blocks/block-map.tsx +++ b/app/scripts/components/common/blocks/block-map.tsx @@ -100,7 +100,7 @@ interface MapBlockProps extends Pick { projectionCenter?: ProjectionOptions['center']; projectionParallels?: ProjectionOptions['parallels']; allowProjectionChange?: boolean; - basemapId?: BasemapId + basemapId?: BasemapId; } function MapBlock(props: MapBlockProps) { @@ -134,37 +134,37 @@ function MapBlock(props: MapBlockProps) { ? utcString2userTzDate(compareDateTime) : undefined; - const projectionStart = useMemo(() => { - if (projectionId) { - // Ensure that the default center and parallels are used if none are - // provided. - const projection = convertProjectionToMapbox({ - id: projectionId, - center: projectionCenter, - parallels: projectionParallels - }); - return { - ...projection, - id: projectionId - }; - } else { - return projectionDefault; - } - }, [projectionId, projectionCenter, projectionParallels]); - - const [projection, setProjection] = useState(projectionStart); - - useEffect(() => { - setProjection(projectionStart); - }, [projectionStart]); - - const [mapBasemapId, setMapBasemapId] = useState(basemapId); - - useEffect(() => { - if (!basemapId) return; - - setMapBasemapId(basemapId); - }, [basemapId]); + const projectionStart = useMemo(() => { + if (projectionId) { + // Ensure that the default center and parallels are used if none are + // provided. + const projection = convertProjectionToMapbox({ + id: projectionId, + center: projectionCenter, + parallels: projectionParallels + }); + return { + ...projection, + id: projectionId + }; + } else { + return projectionDefault; + } + }, [projectionId, projectionCenter, projectionParallels]); + + const [projection, setProjection] = useState(projectionStart); + + useEffect(() => { + setProjection(projectionStart); + }, [projectionStart]); + + const [mapBasemapId, setMapBasemapId] = useState(basemapId); + + useEffect(() => { + if (!basemapId) return; + + setMapBasemapId(basemapId); + }, [basemapId]); return ( @@ -176,7 +176,9 @@ function MapBlock(props: MapBlockProps) { isComparing={!!selectedCompareDatetime} compareDate={selectedCompareDatetime} compareLabel={compareLabel} - initialPosition={{ lng: center?.[0], lat: center?.[1], zoom }} + initialPosition={ + center ? { lng: center[0], lat: center[1], zoom } : undefined + } cooperativeGestures projection={projection} onProjectionChange={allowProjectionChange ? setProjection : undefined} diff --git a/app/scripts/components/common/dropdown-scrollable.tsx b/app/scripts/components/common/dropdown-scrollable.tsx index b5bb1f49f..e349a889e 100644 --- a/app/scripts/components/common/dropdown-scrollable.tsx +++ b/app/scripts/components/common/dropdown-scrollable.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, ReactNode } from 'react'; +import React, { forwardRef } from 'react'; import styled from 'styled-components'; import { glsp } from '@devseed-ui/theme-provider'; import { @@ -9,14 +9,15 @@ import { DropTitle } from '@devseed-ui/dropdown'; -import { ShadowScrollbarImproved as ShadowScrollbar } from '$components/common/shadow-scrollbar-improved'; - /** * Override Dropdown styles to play well with the shadow scrollbar. */ const DropdownWithScroll = styled(Dropdown)` padding: 0; overflow: hidden; + max-height: 320px; + overscroll-behavior: none; + overflow-y: scroll; ${DropTitle} { margin: 0; @@ -28,23 +29,12 @@ const DropdownWithScroll = styled(Dropdown)` } `; -const shadowScrollbarProps = { - autoHeight: true, - autoHeightMax: 320 -}; - -interface DropdownScrollableProps extends DropdownProps { - children?: ReactNode; -} - -export default forwardRef( +export default forwardRef( function DropdownScrollable(props, ref) { const { children, ...rest } = props; return ( - - {children} - + {children} ); } diff --git a/app/scripts/components/common/fold.tsx b/app/scripts/components/common/fold.tsx index a00f7d393..7b412b1cf 100644 --- a/app/scripts/components/common/fold.tsx +++ b/app/scripts/components/common/fold.tsx @@ -49,7 +49,9 @@ export const FoldHeader = styled.div` `; export const FoldHeadline = styled.div` - /* styled-component */ + p { + margin: 1rem 0 0 0; + } `; export const FoldHeadActions = styled.div` diff --git a/app/scripts/components/common/mapbox/layer-legend.tsx b/app/scripts/components/common/mapbox/layer-legend.tsx index 994c2b525..43e84f1ac 100644 --- a/app/scripts/components/common/mapbox/layer-legend.tsx +++ b/app/scripts/components/common/mapbox/layer-legend.tsx @@ -27,8 +27,6 @@ import { WidgetItemHGroup } from '$styles/panel'; -import { ShadowScrollbarImproved as ShadowScrollbar } from '$components/common/shadow-scrollbar-improved'; - interface LayerLegendCommonProps { id: string; title: string; @@ -198,7 +196,10 @@ const LayerLegendTitle = styled.h3` const LegendBody = styled(WidgetItemBodyInner)` padding: 0; - + min-height: 32px; + max-height: 120px; + overflow-y: auto; + overscroll-behavior: none; .scroll-inner { padding: ${variableGlsp(0.5, 0.75)}; } @@ -252,17 +253,11 @@ export function LayerLegend( )} renderBody={() => ( - +
    {description ||

    No info available for this layer.

    }
    -
    +
    )} /> diff --git a/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx b/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx index c6b7497d9..1097f8a0a 100644 --- a/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx +++ b/app/scripts/components/common/mapbox/layers/raster-timeseries.tsx @@ -33,7 +33,7 @@ import { } from '$utils/status'; // Whether or not to print the request logs. -const LOG = true; +const LOG = process.env.NODE_ENV !== 'production'; export interface MapLayerRasterTimeseriesProps { id: string; diff --git a/app/scripts/components/common/mapbox/map-options/index.tsx b/app/scripts/components/common/mapbox/map-options/index.tsx index 7895c57e6..7ffa71213 100644 --- a/app/scripts/components/common/mapbox/map-options/index.tsx +++ b/app/scripts/components/common/mapbox/map-options/index.tsx @@ -21,8 +21,6 @@ import { MapOptionsProps } from './types'; import { projectionsList } from './utils'; import { BASEMAP_STYLES } from './basemaps'; -import { ShadowScrollbarImproved as ShadowScrollbar } from '$components/common/shadow-scrollbar-improved'; - const DropHeader = styled.div` padding: ${glsp()}; box-shadow: inset 0 -1px 0 0 ${themeVal('color.base-100a')}; @@ -30,6 +28,8 @@ const DropHeader = styled.div` const DropBody = styled.div` padding: ${glsp(0, 0, 1, 0)}; + max-height: 320px; + overflow-y: auto; `; /** @@ -64,11 +64,6 @@ const SelectorButton = styled(Button)` } `; -const shadowScrollbarProps = { - autoHeight: true, - autoHeightMax: 320 -}; - const ContentGroup = styled.div` display: flex; flex-flow: column nowrap; @@ -143,7 +138,7 @@ function MapOptions(props: MapOptionsProps) { Map options - + Style @@ -258,7 +253,7 @@ function MapOptions(props: MapOptionsProps) { - + ); diff --git a/app/scripts/components/common/page-header.tsx b/app/scripts/components/common/page-header.tsx index 8274d3176..fbf57779e 100644 --- a/app/scripts/components/common/page-header.tsx +++ b/app/scripts/components/common/page-header.tsx @@ -12,7 +12,6 @@ import { } from '@devseed-ui/theme-provider'; import { reveal } from '@devseed-ui/animation'; import { Heading, Overline } from '@devseed-ui/typography'; -import { ShadowScrollbar } from '@devseed-ui/shadow-scrollbar'; import { Button } from '@devseed-ui/button'; import { CollecticonEllipsisVertical, @@ -214,6 +213,9 @@ const GlobalNavInner = styled.div` const GlobalNavHeader = styled.div` padding: ${variableGlsp(1)}; box-shadow: inset 0 -1px 0 0 ${themeVal('color.surface-200a')}; + ${media.largeUp` + display: none; + `} `; const GlobalNavTitle = styled(Heading).attrs({ @@ -225,16 +227,16 @@ const GlobalNavTitle = styled(Heading).attrs({ export const GlobalNavActions = styled.div` align-self: start; + ${media.largeUp` + display: none; + `} `; export const GlobalNavToggle = styled(Button)` z-index: 2000; `; -const GlobalNavBody = styled(ShadowScrollbar).attrs({ - topShadowVariation: 'dark', - bottomShadowVariation: 'dark' -})` +const GlobalNavBody = styled.div` display: flex; flex: 1; @@ -339,10 +341,16 @@ function PageHeader() { useEffect(() => { // Close global nav when media query changes. + // NOTE: isMediumDown is returning document.body's width, not the whole window width + // which conflicts with how mediaquery decides the width. + // JSX element susing isMediumDown is also protected with css logic because of this. + // ex. Look at GlobalNavActions if (!isMediumDown) setGlobalNavRevealed(false); }, [isMediumDown]); - const closeNavOnClick = useCallback(() => setGlobalNavRevealed(false), []); + const closeNavOnClick = useCallback(() => { + setGlobalNavRevealed(false); + }, []); return ( @@ -412,7 +420,10 @@ function PageHeader() {
  • - + {getString('stories').other}
  • diff --git a/app/scripts/components/common/page-local-nav.js b/app/scripts/components/common/page-local-nav.js index 9101f9885..3151d9d8f 100644 --- a/app/scripts/components/common/page-local-nav.js +++ b/app/scripts/components/common/page-local-nav.js @@ -17,7 +17,6 @@ import { import { reveal } from '@devseed-ui/animation'; import { Overline } from '@devseed-ui/typography'; import { DropMenu, DropMenuItem } from '@devseed-ui/dropdown'; -import { ShadowScrollbar } from '@devseed-ui/shadow-scrollbar'; import DropdownScrollable from './dropdown-scrollable'; @@ -57,7 +56,7 @@ const LocalBreadcrumb = styled.ol` } `; -const NavBlock = styled(ShadowScrollbar)` +const NavBlock = styled.div` display: flex; align-items: center; flex-flow: row nowrap; diff --git a/app/scripts/components/common/shadow-scrollbar-improved.tsx b/app/scripts/components/common/shadow-scrollbar-improved.tsx deleted file mode 100644 index e0343f305..000000000 --- a/app/scripts/components/common/shadow-scrollbar-improved.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { ShadowScrollbar } from '@devseed-ui/shadow-scrollbar'; - -export const ShadowScrollbarImproved = React.forwardRef< - HTMLDivElement, - React.ComponentProps ->(function ShadowScrollbarImprovedCmp(props, ref) { - // eslint-disable-next-line react/prop-types - const { scrollbarsProps, ...rest } = props; - const [autoHide, setAutoHide] = useState(true); - - const mouseEnter = useCallback(() => setAutoHide(false), []); - - useEffect(() => { - setAutoHide(false); - }, []); - - useEffect(() => { - if (autoHide) return; - - const timer = setTimeout(() => { - setAutoHide(true); - }, 1000); - - return () => clearTimeout(timer); - }, [autoHide]); - - const shadowScrollbarProps = { - ...(scrollbarsProps ?? {}), - autoHide - }; - - return ( - - ); -}); diff --git a/app/scripts/components/datasets/s-explore/dataset-layer-single.tsx b/app/scripts/components/datasets/s-explore/dataset-layer-single.tsx index c59727d42..65135f46d 100644 --- a/app/scripts/components/datasets/s-explore/dataset-layer-single.tsx +++ b/app/scripts/components/datasets/s-explore/dataset-layer-single.tsx @@ -109,7 +109,6 @@ export function Layer(props: LayerProps) { onToggleClick(); } }} - on > ${PanelWidget} { width: 100%; position: relative; @@ -108,14 +108,43 @@ const DatesWrapper = styled.div` } `; +const PositionPlaceHolderForScroll = styled.div` + position: absolute; + pointer-events: none; + z-index: 1000; + top: 0px; + left: 0px; + width: 1.5rem; + height: 100%; + background: linear-gradient( + to left, + rgba(255, 255, 255, 0) 0%, + rgb(255, 255, 255) 100% + ); +`; + +const ScrollArea = styled.div` + position: relative; + overflow: hidden; + width: 100%; + height: 100%; +`; + +const ScrollAreaInner = styled.div` + position: absolute; + inset: 0px; + overflow-y: auto; +`; + const isSelectedDateValid = (dateList, selectedDate) => { if (dateList) { // Since the available dates changes, check if the currently selected // one is valid. const validDate = !!dateList.find( - (d) => d.getTime() === selectedDate?.getTime() + // Only check if the date of the selected date is in the range. + // Because Dashboard can only provide daily level selection. + (d) => isSameDay(new Date(d), new Date(selectedDate)) ); - return !!validDate; } return true; @@ -175,7 +204,6 @@ const useDatePickerValue = ( }), [value] ); - return [val, onConfirm] as [typeof val, typeof onConfirm]; }; @@ -518,6 +546,7 @@ function DatasetsExplore() { selectedDatetime, setSelectedDatetime ); + const [datePickerCompareValue, datePickerCompareConfirm] = useDatePickerValue( selectedCompareDatetime, setSelectedCompareDatetime @@ -585,52 +614,57 @@ function DatasetsExplore() { - - {activeLayerTimeseries && ( - - {activeLayerCompareTimeseries && ( - setIsComparing((v) => !v)} + + + + + {activeLayerTimeseries && ( + - Toggle date comparison - + {activeLayerCompareTimeseries && ( + setIsComparing((v) => !v)} + > + Toggle date comparison + + )} + + )} + {isComparing && activeLayerCompareTimeseries && ( + )} - - )} - {isComparing && activeLayerCompareTimeseries && ( - - )} - - - - Layers - - - - - + + + + Layers + + + + + + + diff --git a/app/scripts/components/datasets/s-overview/index.tsx b/app/scripts/components/datasets/s-overview/index.tsx index dc1bcd425..eaaa26239 100644 --- a/app/scripts/components/datasets/s-overview/index.tsx +++ b/app/scripts/components/datasets/s-overview/index.tsx @@ -59,7 +59,7 @@ function DatasetsOverview() { {dataset?.data.disableExplore !== true && (