diff --git a/.env b/.env index a06c37492..ce7e2b852 100644 --- a/.env +++ b/.env @@ -12,6 +12,5 @@ API_STAC_ENDPOINT='https://staging.openveda.cloud/api/stac' # Google form for feedback GOOGLE_FORM = 'https://docs.google.com/forms/d/e/1FAIpQLSfGcd3FDsM3kQIOVKjzdPn4f88hX8RZ4Qef7qBsTtDqxjTSkg/viewform?embedded=true' -FEATURE_NEW_EXPLORATION = 'TRUE' -SHOW_CONFIGURABLE_COLOR_MAP = 'FALSE' +SHOW_CONFIGURABLE_COLOR_MAP = 'TRUE' diff --git a/app/scripts/components/analysis/constants.ts b/app/scripts/components/analysis/constants.ts deleted file mode 100644 index a5605fad5..000000000 --- a/app/scripts/components/analysis/constants.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 4ba1a3d87..000000000 --- a/app/scripts/components/analysis/define/aoi-selector.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import React, { - RefObject, - useCallback, - useEffect, - useMemo, - useState -} from 'react'; -import styled from 'styled-components'; -import { FeatureCollection, Polygon } from 'geojson'; -import bbox from '@turf/bbox'; - -import { themeVal } from '@devseed-ui/theme-provider'; - -import { - Toolbar, - ToolbarIconButton, - VerticalDivider -} from '@devseed-ui/toolbar'; -import { Button, ButtonGroup } from '@devseed-ui/button'; -import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; -import { - CollecticonTrashBin, - CollecticonHandPan, - CollecticonMarker, - CollecticonPencil, - CollecticonUpload2 -} from '@devseed-ui/collecticons'; -import { FeatureByRegionPreset, RegionPreset } from '../constants'; -import AoIUploadModal from './aoi-upload-modal'; -import { FoldWGuideLine, FoldTitleWOAccent } from '.'; -import { - FoldHeader, - FoldHeadline, - FoldHeadActions, - FoldBody -} from '$components/common/fold'; -import MapboxMap, { MapboxMapRef } from '$components/common/mapbox'; -import { - AoiChangeListenerOverload, - AoiState -} from '$components/common/aoi/types'; -import DropMenuItemButton from '$styles/drop-menu-item-button'; -import { makeFeatureCollection } from '$components/common/aoi/utils'; -import { variableGlsp } from '$styles/variable-utils'; - -const MapContainer = styled.div` - position: relative; - border-radius: ${themeVal('shape.rounded')}; - overflow: hidden; -`; - -const AoiMap = styled(MapboxMap)` - min-height: 24rem; -`; - -const AoiHeadActions = styled(FoldHeadActions)` - z-index: 1; - /* 2 times vertical glsp to account for paddings + 2rem which is the height of - the buttons */ - transform: translate(${variableGlsp(-1)}, calc(${variableGlsp(2)} + 2rem)); -`; - -interface AoiSelectorProps { - mapRef: RefObject; - qsAoi?: FeatureCollection; - aoiDrawState: AoiState; - onAoiEvent: AoiChangeListenerOverload; -} - -export default function AoiSelector({ - mapRef, - onAoiEvent, - qsAoi, - aoiDrawState -}: AoiSelectorProps) { - const { drawing, featureCollection } = aoiDrawState; - - // For the drawing tool, the features need an id. - const qsFc: FeatureCollection | null = useMemo(() => { - return qsAoi - ? makeFeatureCollection( - qsAoi.features.map((f, i) => ({ id: `qs-feature-${i}`, ...f })) - ) - : null; - }, [qsAoi]); - - const setFeatureCollection = useCallback( - (featureCollection: FeatureCollection) => { - onAoiEvent('aoi.set', { featureCollection }); - const fcBbox = bbox(featureCollection) as [ - number, - number, - number, - number - ]; - mapRef.current?.instance?.fitBounds(fcBbox, { padding: 32 }); - }, - [onAoiEvent] - ); - - const onRegionPresetClick = useCallback( - (preset: RegionPreset) => { - setFeatureCollection(FeatureByRegionPreset[preset]); - }, - [setFeatureCollection] - ); - - // Use the feature from the url qs or the region preset as the initial state - // to center the map. - useEffect(() => { - if (qsFc) { - setFeatureCollection(qsFc); - } else { - onAoiEvent('aoi.clear'); - mapRef.current?.instance?.flyTo({ zoom: 1, center: [0, 0] }); - } - }, [onAoiEvent, qsFc, setFeatureCollection]); - - const [aoiModalRevealed, setAoIModalRevealed] = useState(false); - - return ( - - setAoIModalRevealed(false)} - /> - - - 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} - > - - - - - - - - setAoIModalRevealed(true)} - variation='primary-fill' - > - - - ( - - - - )} - > - Select a region (BETA) - -
  • - onRegionPresetClick('world')} - > - World - -
  • -
  • - onRegionPresetClick('north-america')} - > - North America - -
  • -
    -
    -
    -
    -
    - - - - - -
    - ); -} diff --git a/app/scripts/components/analysis/define/aoi-upload-modal.tsx b/app/scripts/components/analysis/define/aoi-upload-modal.tsx deleted file mode 100644 index 4106c3b1a..000000000 --- a/app/scripts/components/analysis/define/aoi-upload-modal.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import { FeatureCollection } from 'geojson'; -import { Modal, ModalHeadline, ModalFooter } from '@devseed-ui/modal'; -import { Heading, Subtitle } from '@devseed-ui/typography'; - -import { Button } from '@devseed-ui/button'; -import { - glsp, - listReset, - themeVal, - visuallyHidden -} from '@devseed-ui/theme-provider'; -import { - CollecticonArrowUp, - CollecticonTickSmall, - CollecticonXmarkSmall, - CollecticonCircleExclamation, - CollecticonCircleTick, - CollecticonCircleInformation -} from '@devseed-ui/collecticons'; -import useCustomAoI, { acceptExtensions } from './use-custom-aoi'; -import { variableGlsp, variableProseVSpace } from '$styles/variable-utils'; - -const UploadFileModalFooter = styled(ModalFooter)` - display: flex; - justify-content: right; - flex-flow: row nowrap; - gap: ${variableGlsp(0.25)}; -`; - -const ModalBodyInner = styled.div` - display: flex; - flex-flow: column nowrap; - gap: ${variableGlsp()}; -`; - -const UploadFileIntro = styled.div` - display: flex; - flex-flow: column nowrap; - gap: ${variableProseVSpace()}; -`; - -const FileUpload = styled.div` - display: flex; - flex-flow: nowrap; - align-items: center; - gap: ${variableGlsp(0.5)}; - - ${Button} { - flex-shrink: 0; - } - - ${Subtitle} { - overflow-wrap: anywhere; - } -`; - -const FileInput = styled.input` - ${visuallyHidden()} -`; - -const UploadInformation = styled.div` - padding: ${variableGlsp()}; - background: ${themeVal('color.base-50')}; - box-shadow: ${themeVal('boxShadow.inset')}; - border-radius: ${themeVal('shape.rounded')}; -`; - -const UploadListInfo = styled.ul` - ${listReset()} - display: flex; - flex-flow: column nowrap; - gap: ${glsp(0.25)}; - - li { - display: flex; - flex-flow: row nowrap; - gap: ${glsp(0.5)}; - align-items: top; - - > svg { - flex-shrink: 0; - margin-top: ${glsp(0.25)}; - } - } -`; - -const UploadInfoItemSuccess = styled.li` - color: ${themeVal('color.success')}; -`; - -const UploadInfoItemWarnings = styled.li` - color: ${themeVal('color.info')}; -`; - -const UploadInfoItemError = styled.li` - color: ${themeVal('color.danger')}; -`; - -interface AoIUploadModalProps { - revealed: boolean; - onCloseClick: () => void; - setFeatureCollection: (featureCollection: FeatureCollection) => void; -} - -export default function AoIUploadModal({ - revealed, - onCloseClick, - setFeatureCollection -}: AoIUploadModalProps) { - const { - featureCollection, - onUploadFile, - uploadFileError, - uploadFileWarnings, - fileInfo, - reset - } = useCustomAoI(); - const fileInputRef = useRef(null); - - const onUploadClick = useCallback(() => { - if (fileInputRef.current) fileInputRef.current.click(); - }, []); - - const onConfirmClick = useCallback(() => { - if (!featureCollection) return; - setFeatureCollection(featureCollection); - onCloseClick(); - }, [featureCollection, setFeatureCollection, onCloseClick]); - - useEffect(() => { - if (revealed) reset(); - }, [revealed, reset]); - - const hasInfo = !!uploadFileWarnings.length || !!featureCollection || uploadFileError; - - return ( - ( - -

    Upload custom area

    -
    - )} - content={ - - -

    - You can upload a zipped shapefile (*.zip) or a GeoJSON file - (*.json, *.geojson) to define a custom area of interest. -

    - - - {fileInfo && ( - - File: {fileInfo.name} ({fileInfo.type}). - - )} - - -
    - - {hasInfo && ( - - - - - {uploadFileWarnings.map((w) => ( - - - {w} - - ))} - {featureCollection && ( - - - File uploaded successfully. - - )} - {uploadFileError && ( - - {uploadFileError} - - )} - - - )} -
    - } - renderFooter={() => ( - - - - - )} - /> - ); -} diff --git a/app/scripts/components/analysis/define/index.tsx b/app/scripts/components/analysis/define/index.tsx deleted file mode 100644 index 6cc845f54..000000000 --- a/app/scripts/components/analysis/define/index.tsx +++ /dev/null @@ -1,554 +0,0 @@ -import React, { - useCallback, - useEffect, - useMemo, - MouseEvent, - useRef -} from 'react'; -import styled, { css } from 'styled-components'; -import { media, multiply, themeVal } from '@devseed-ui/theme-provider'; -import { Toolbar, ToolbarLabel } from '@devseed-ui/toolbar'; -import { - Form, - FormCheckable, - FormGroupStructure, - FormInput -} from '@devseed-ui/form'; -import { - CollecticonCircleInformation, - CollecticonSignDanger -} from '@devseed-ui/collecticons'; -import { Overline } from '@devseed-ui/typography'; - -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 { useStacCollectionSearch } from './use-stac-collection-search'; -import PageFooterActions from './page-footer.actions'; -import { variableGlsp } from '$styles/variable-utils'; - -import { PageMainContent } from '$styles/page'; -import { LayoutProps } from '$components/common/layout-root'; -import PageHeroAnalysis from '$components/analysis/page-hero-analysis'; -import { - Fold, - FoldHeader, - FoldHeadline, - FoldHeadActions, - FoldTitle, - FoldBody -} from '$components/common/fold'; -import { S_FAILED, S_LOADING, S_SUCCEEDED } from '$utils/status'; -import { useAoiControls } from '$components/common/aoi/use-aoi-controls'; -import { - DateRangePreset, - dateToInputFormat, - getRangeFromPreset, - inputFormatToDate -} from '$utils/date'; - -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%; - } -`; - -const CheckableGroup = styled.div` - display: grid; - gap: ${variableGlsp(0.5)}; - grid-template-columns: repeat(2, 1fr); - background: ${themeVal('color.surface')}; - - ${media.mediumUp` - grid-template-columns: repeat(3, 1fr); - `} - - ${media.xlargeUp` - grid-template-columns: repeat(4, 1fr); - `} -`; - -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; -`; - -export const Note = styled.div` - display: flex; - flex-flow: column nowrap; - gap: ${variableGlsp(0.5)}; - justify-content: center; - align-items: center; - text-align: center; - background: ${themeVal('color.base-50')}; - border-radius: ${multiply(themeVal('shape.rounded'), 2)}; - min-height: 12rem; - padding: ${variableGlsp()}; - - [class*='Collecticon'] { - opacity: 0.32; - } -`; - -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!.data.layers.find((l) => l.id === layerId) - ); - return parentDataset?.data; -}; - -export const allAvailableDatasetsLayers: DatasetLayer[] = Object.values( - datasets -) - .map((dataset) => dataset!.data.layers) - .flat() - .filter((d) => d.type !== 'vector' && !d.analysis?.exclude); - -export default function Analysis() { - const { params, setAnalysisParam } = useAnalysisParams(); - const { start, end, datasetsLayers, aoi, errors } = params; - - const mapRef = useRef(null); - const { aoi: aoiDrawState, onAoiEvent } = useAoiControls(mapRef, { - drawing: true - }); - - // If there are errors in the url parameters it means that this should be - // treated as a new analysis. If the parameters are all there and correct, the - // user is refining the analysis. - const isNewAnalysis = !!errors?.length; - - const onStartDateChange = useCallback( - (e) => { - if (!e.target.value || e.target.value === '') { - setAnalysisParam('start', null); - return; - } - setAnalysisParam('start', inputFormatToDate(e.target.value)); - }, - [setAnalysisParam] - ); - - const onEndDateChange = useCallback( - (e) => { - if (!e.target.value || e.target.value === '') { - setAnalysisParam('end', null); - return; - } - setAnalysisParam('end', inputFormatToDate(e.target.value)); - }, - [setAnalysisParam] - ); - - const onDatePresetClick = useCallback( - (e: MouseEvent, preset: DateRangePreset) => { - e.preventDefault(); - const { start, end } = getRangeFromPreset(preset); - setAnalysisParam('start', start); - setAnalysisParam('end', end); - }, - [setAnalysisParam] - ); - - const selectedDatasetLayerIds = datasetsLayers?.map((layer) => layer.id); - - const onDatasetLayerChange = useCallback( - (e) => { - const id = e.target.id; - let newDatasetsLayers = [...(datasetsLayers || [])]; - if (e.target.checked) { - const newDatasetLayer = allAvailableDatasetsLayers.find( - (l) => l.id === id - ); - if (newDatasetLayer) { - newDatasetsLayers = [...newDatasetsLayers, newDatasetLayer]; - } - } else { - newDatasetsLayers = newDatasetsLayers.filter((l) => l.id !== id); - } - setAnalysisParam('datasetsLayers', newDatasetsLayers); - }, - [setAnalysisParam, datasetsLayers] - ); - - 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 - useEffect(() => { - if (!datasetsLayers) return; - const selectableDatasetLayersIds = selectableDatasetLayers.map( - (layer) => layer.id - ); - const cleanedDatasetsLayers = datasetsLayers.filter((l) => - selectableDatasetLayersIds.includes(l.id) - ); - - setAnalysisParam('datasetsLayers', cleanedDatasetsLayers); - // Only update when stac search gets updated to avoid triggering an infinite - // read/set state loop - }, [selectableDatasetLayers, setAnalysisParam]); - - const notReady = !readyToLoadDatasets || !datasetsLayers?.length; - - const infoboxMessage = useMemo(() => { - if ( - readyToLoadDatasets && - stacSearchStatus === S_SUCCEEDED && - selectableDatasetLayers.length - ) { - return; - } - - if (!readyToLoadDatasets) { - return 'To select datasets, please define an area and a date first.'; - } else { - if (stacSearchStatus === S_LOADING) { - return 'Loading...'; - } else if (stacSearchStatus === S_FAILED) { - return 'Error loading datasets.'; - } else if (!selectableDatasetLayers.length) { - return 'No datasets available for the currently selected dates and area.'; - } - } - }, [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 ( - <> - - - ( - - )} - /> - - - - - - Pick a date period -

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

    -
    - -
    - -
    - - - - - - - - - -
    - - 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} - - - {'numberOfItems' in datasetLayer ? `${datasetLayer.numberOfItems} data points`: 'Data temporarily unavailable'} - - - ))} - -
    - - )} - - ) : ( - - -

    {infoboxMessage}

    -
    - )} -
    -
    -
    - - - - - - - - - ); -} diff --git a/app/scripts/components/analysis/define/page-footer.actions.tsx b/app/scripts/components/analysis/define/page-footer.actions.tsx deleted file mode 100644 index 8c41a2e69..000000000 --- a/app/scripts/components/analysis/define/page-footer.actions.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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/use-custom-aoi.ts b/app/scripts/components/analysis/define/use-custom-aoi.ts deleted file mode 100644 index b3c84af8e..000000000 --- a/app/scripts/components/analysis/define/use-custom-aoi.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import shp from 'shpjs'; -import simplify from '@turf/simplify'; -import { multiPolygonToPolygons } from '../utils'; - -import { makeFeatureCollection } from '$components/common/aoi/utils'; -import { round } from '$utils/format'; - -const extensions = ['geojson', 'json', 'zip']; -export const acceptExtensions = extensions.map((ext) => `.${ext}`).join(', '); - -export interface FileInfo { - name: string; - extension: string; - type: 'Shapefile' | 'GeoJSON'; -} - -function getNumPoints(feature: Feature): number { - return feature.geometry.coordinates.reduce((acc, current) => { - return acc + current.length; - }, 0); -} - -function useCustomAoI() { - const [fileInfo, setFileInfo] = useState(null); - const [uploadFileError, setUploadFileError] = useState(null); - const [uploadFileWarnings, setUploadFileWarnings] = useState([]); - const reader = useRef(); - const [featureCollection, setFeatureCollection] = - useState(null); - - useEffect(() => { - reader.current = new FileReader(); - - const setError = (error: string) => { - setUploadFileError(error); - setFeatureCollection(null); - setUploadFileWarnings([]); - }; - - const onLoad = async () => { - if (!reader.current) return; - - let geojson; - if (typeof reader.current.result === 'string') { - const rawGeoJSON = reader.current.result; - if (!rawGeoJSON) { - setError('Error uploading file.'); - return; - } - try { - geojson = JSON.parse(rawGeoJSON as string); - } catch (e) { - setError('Error uploading file: invalid JSON'); - return; - } - } else { - try { - geojson = await shp(reader.current.result); - } catch (e) { - setError(`Error uploading file: invalid Shapefile (${e.message})`); - return; - } - } - - if (!geojson?.features?.length) { - setError('Error uploading file: Invalid GeoJSON'); - return; - } - - let warnings: string[] = []; - - if ( - geojson.features.some( - (feature) => - !['MultiPolygon', 'Polygon'].includes(feature.geometry.type) - ) - ) { - setError( - 'Wrong geometry type. Only polygons or multi polygons are accepted.' - ); - return; - } - - const features: Feature[] = geojson.features.reduce( - (acc, feature: Feature) => { - if (feature.geometry.type === 'MultiPolygon') { - return acc.concat( - multiPolygonToPolygons(feature as Feature) - ); - } - - return acc.concat(feature); - }, - [] - ); - - if (features.length > 200) { - setError('Only files with up to 200 polygons are accepted.'); - return; - } - - // Simplify features; - const originalTotalFeaturePoints = features.reduce( - (acc, f) => acc + getNumPoints(f), - 0 - ); - let numPoints = originalTotalFeaturePoints; - let tolerance = 0.001; - - // Remove holes from polygons as they're not supported. - let polygonHasRings = false; - let simplifiedFeatures = features.map>((feature) => { - if (feature.geometry.coordinates.length > 1) { - polygonHasRings = true; - return { - ...feature, - geometry: { - type: 'Polygon', - coordinates: [feature.geometry.coordinates[0]] - } - }; - } - - return feature; - }); - - if (polygonHasRings) { - warnings = [ - ...warnings, - 'Polygons with rings are not supported and were simplified to remove them' - ]; - } - - // If we allow up to 200 polygons and each polygon needs 4 points, we need - // at least 800, give an additional buffer and we get 1000. - while (numPoints > 1000 && tolerance < 5) { - simplifiedFeatures = simplifiedFeatures.map((feature) => - simplify(feature, { tolerance }) - ); - numPoints = simplifiedFeatures.reduce( - (acc, f) => acc + getNumPoints(f), - 0 - ); - tolerance = Math.min(tolerance * 1.8, 5); - } - - if (originalTotalFeaturePoints !== numPoints) { - warnings = [ - ...warnings, - `The geometry has been simplified (${round( - (1 - numPoints / originalTotalFeaturePoints) * 100 - )} % less).` - ]; - } - - setUploadFileWarnings(warnings); - setUploadFileError(null); - setFeatureCollection( - makeFeatureCollection( - simplifiedFeatures.map((feat, i) => ({ - id: `aoi-upload-${i}`, - ...feat - })) - ) - ); - }; - - const onError = () => { - setError('Error uploading file'); - }; - - reader.current.addEventListener('load', onLoad); - reader.current.addEventListener('error', onError); - - return () => { - if (!reader.current) return; - reader.current.removeEventListener('load', onLoad); - reader.current.removeEventListener('error', onError); - }; - }, []); - - const onUploadFile = useCallback((event) => { - if (!reader.current) return; - - const file = event.target.files[0]; - if (!file) return; - - const [, extension] = file.name.match(/^.*\.(json|geojson|zip)$/i) ?? []; - - if (!extensions.includes(extension)) { - setUploadFileError( - 'Wrong file type. Only zipped shapefiles and geojson files are accepted.' - ); - return; - } - - setFileInfo({ - name: file.name, - extension, - type: extension === 'zip' ? 'Shapefile' : 'GeoJSON' - }); - - if (extension === 'zip') { - reader.current.readAsArrayBuffer(file); - } else if (extension === 'json' || extension === 'geojson') { - reader.current.readAsText(file); - } - }, []); - - const reset = useCallback(() => { - setFeatureCollection(null); - setUploadFileWarnings([]); - setUploadFileError(null); - setFileInfo(null); - }, []); - - return { - featureCollection, - onUploadFile, - uploadFileError, - uploadFileWarnings, - fileInfo, - reset - }; -} - -export default useCustomAoI; diff --git a/app/scripts/components/analysis/define/use-stac-collection-search.ts b/app/scripts/components/analysis/define/use-stac-collection-search.ts deleted file mode 100644 index 6814ae978..000000000 --- a/app/scripts/components/analysis/define/use-stac-collection-search.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { useMemo } from 'react'; -import { FeatureCollection, Polygon } from 'geojson'; -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 { DatasetLayer } from 'veda'; - -import { MAX_QUERY_NUM } from '../constants'; -import { TimeseriesMissingSummaries, TimeseriesDataResult } from '../results/timeseries-data'; -import { getNumberOfItemsWithinTimeRange } from './utils'; -import { allAvailableDatasetsLayers } from '.'; - -import { utcString2userTzDate } from '$utils/date'; - -interface UseStacSearchProps { - start?: Date; - end?: Date; - aoi?: FeatureCollection | null; -} - -export type DatasetWithCollections = TimeseriesDataResult & DatasetLayer; -type DatasetMissingSummaries = TimeseriesMissingSummaries & DatasetLayer; -export type DatasetWithTimeseriesData = DatasetWithCollections & { numberOfItems: number }; -export type InvalidDatasets = DatasetMissingSummaries | DatasetWithTimeseriesData; - -const collectionEndpointSuffix = '/collections'; - -export function useStacCollectionSearch({ - start, - end, - aoi -}: UseStacSearchProps) { - const readyToLoadDatasets = !!(start && end && aoi); - - const result = useQuery({ - queryKey: ['stacCollection'], - queryFn: async ({ signal }) => { - const collectionUrlsFromDataSets = allAvailableDatasetsLayers.reduce( - (filtered, { stacApiEndpoint }) => { - return stacApiEndpoint - ? [...filtered, `${stacApiEndpoint}${collectionEndpointSuffix}`] - : filtered; - }, - [] - ); - // Get unique values of stac api endpoints from layer data, concat with api_stac_endpoiint from env var - const collectionUrls = Array.from( - new Set(collectionUrlsFromDataSets) - ).concat(`${process.env.API_STAC_ENDPOINT}${collectionEndpointSuffix}`); - - const collectionRequests = collectionUrls.map((url: string) => - axios.get(url, { signal }).then((response) => { - return response.data.collections.map((col) => ({ - ...col, - stacApiEndpoint: url.replace(collectionEndpointSuffix, '') - })); - }) - ); - return axios.all(collectionRequests).then( - // Merge all responses into one array - axios.spread((...responses) => [].concat(...responses)) - ); - }, - enabled: readyToLoadDatasets - }); - - const datasetLayersInRange = useMemo(() => { - try { - return getInTemporalAndSpatialExtent(result.data, aoi, { - start, - end - }); - } catch (e) { - return []; - } - }, [result.data, aoi, start, end]); - - const [datasetsWithSummaries, invalidDatasets]: [DatasetWithCollections[], DatasetMissingSummaries[]] = datasetLayersInRange.reduce((result: [DatasetWithCollections[], DatasetMissingSummaries[]], d) => { - /* eslint-disable-next-line fp/no-mutating-methods */ - d.timeseries ? result[0].push(d as DatasetWithCollections) : result[1].push(d as DatasetMissingSummaries); - return result; - },[[], []]); - - const datasetLayersInRangeWithNumberOfItems: DatasetWithTimeseriesData[] = - useMemo(() => { - return datasetsWithSummaries.map((l) => { - const numberOfItems = getNumberOfItemsWithinTimeRange(start, end, l); - return { ...l, numberOfItems }; - }); - }, [datasetsWithSummaries, start, end]); - - const selectableDatasetLayers = useMemo(() => { - return datasetLayersInRangeWithNumberOfItems.filter( - (l) => l.numberOfItems <= MAX_QUERY_NUM - ); - }, [datasetLayersInRangeWithNumberOfItems]); - - const datasetsWithTooManyRequests: DatasetWithTimeseriesData[] = useMemo(() => { - return datasetLayersInRangeWithNumberOfItems.filter( - (l) => l.numberOfItems > MAX_QUERY_NUM - ); - }, [datasetLayersInRangeWithNumberOfItems]); - - const unselectableDatasetLayers:InvalidDatasets[] = [...datasetsWithTooManyRequests, ...invalidDatasets]; - - return { - selectableDatasetLayers, - unselectableDatasetLayers, - stacSearchStatus: result.status, - readyToLoadDatasets - }; -} - -function getInTemporalAndSpatialExtent(collectionData, aoi, timeRange) { - const matchingCollectionIds = collectionData.reduce((acc, col) => { - try { - const { id, stacApiEndpoint } = col; - - // Is is a dataset defined in the app? - // If not, skip other calculations. - const isAppDataset = allAvailableDatasetsLayers.some((l) => { - const stacApiEndpointUsed = - l.stacApiEndpoint ?? process.env.API_STAC_ENDPOINT; - return l.stacCol === id && stacApiEndpointUsed === stacApiEndpoint; - }); - - if ( - !isAppDataset || - !col.extent.spatial.bbox || - !col.extent.temporal.interval - ) { - return acc; - } - - const bbox = col.extent.spatial.bbox[0]; - const start = utcString2userTzDate(col.extent.temporal.interval[0][0]); - const end = utcString2userTzDate(col.extent.temporal.interval[0][1]); - - const isInAOI = aoi.features.some((feature) => - booleanIntersects(feature, bboxPolygon(bbox)) - ); - - const isInTOI = areIntervalsOverlapping( - { start: new Date(timeRange.start), end: new Date(timeRange.end) }, - { - start: new Date(start), - end: new Date(end) - } - ); - - if (isInAOI && isInTOI) { - return [...acc, id]; - } else { - return acc; - } - } catch (e) { - // If somehow the data is not in the shape we want, just skip it - return acc; - } - - }, []); - - const filteredDatasets = allAvailableDatasetsLayers.filter((l) => - matchingCollectionIds.includes(l.stacCol) - ); - - const filteredDatasetsWithCollections = filteredDatasets.map((l) => { - const stacApiEndpointUsed = - l.stacApiEndpoint ?? process.env.API_STAC_ENDPOINT; - const collection = collectionData.find( - (c) => c.id === l.stacCol && stacApiEndpointUsed === c.stacApiEndpoint - ); - - 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 deleted file mode 100644 index d4f6a2cb6..000000000 --- a/app/scripts/components/analysis/define/utils.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index d01cec2a4..000000000 --- a/app/scripts/components/analysis/define/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/page-hero-analysis.tsx b/app/scripts/components/analysis/page-hero-analysis.tsx deleted file mode 100644 index e89fb19f0..000000000 --- a/app/scripts/components/analysis/page-hero-analysis.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React, { ReactNode, useEffect, useState } from 'react'; -import styled, { css } from 'styled-components'; -import { - glsp, - media, - themeVal, - truncated, - visuallyHidden -} from '@devseed-ui/theme-provider'; -import { reveal } from '@devseed-ui/animation'; -import { ButtonProps } from '@devseed-ui/button'; - -import { FeatureCollection, Polygon } from 'geojson'; -import { useSlidingStickyHeaderProps } from '../common/layout-root/useSlidingStickyHeaderProps'; -import PageHeroMedia from './page-hero-media'; -import { PageLead, PageMainTitle } from '$styles/page'; -import Constrainer from '$styles/constrainer'; - -import { variableGlsp } from '$styles/variable-utils'; -import { useMediaQuery } from '$utils/use-media-query'; -import { HEADER_TRANSITION_DURATION } from '$utils/use-sliding-sticky-header'; - -const PageHeroBlockAlpha = styled.div` - display: flex; - flex-direction: column; - gap: ${variableGlsp()}; - min-width: 0; - - grid-column: 1 / span 4; - - ${media.mediumUp` - grid-column: 1 / span 6; - `} - - ${media.largeUp` - grid-column: 1 / span 6; - `} -`; - -const PageHeroBlockBeta = styled.div` - grid-column: 1 / span 4; - grid-row: 2; - display: flex; - flex-direction: column; - gap: ${variableGlsp()}; - - ${media.mediumUp` - grid-column: 1 / span 6; - grid-row: 2; - `} - - ${media.largeUp` - grid-column: 7 / span 6; - grid-row: 1; - `} -`; - -interface PageHeroSelfProps { - isStuck: boolean; - isHidden: boolean; - shouldSlideHeader: boolean; - minTop: number; - maxTop: number; -} - -const PageHeroSelf = styled.div` - position: sticky; - z-index: 10; - display: flex; - flex-flow: column nowrap; - gap: ${glsp()}; - justify-content: flex-end; - background: ${themeVal('color.primary')}; - color: ${themeVal('color.surface')}; - min-height: 14rem; - animation: ${reveal} ${HEADER_TRANSITION_DURATION}ms ease 0s 1; - transition: top ${HEADER_TRANSITION_DURATION}ms ease-out, - min-height ${HEADER_TRANSITION_DURATION}ms ease-out; - - ${({ isHidden }) => isHidden && visuallyHidden()} - - ${({ shouldSlideHeader, minTop, maxTop }) => { - const topVal = shouldSlideHeader ? minTop : maxTop; - return css` - top: ${topVal}px; - `; - }} - - ${({ isStuck }) => - isStuck && - css` - min-height: 0; - `} - - ${PageHeroMedia} { - transition: opacity ${HEADER_TRANSITION_DURATION}ms ease-out; - opacity: ${({ isStuck }) => Number(!isStuck)}; - } - - ${PageLead} { - transition: font-size ${HEADER_TRANSITION_DURATION}ms ease-out; - ${({ isStuck }) => - isStuck && - css` - ${truncated()} - font-size: ${themeVal('type.base.size')}; - `} - } - - ${PageHeroBlockAlpha} { - transition: gap ${HEADER_TRANSITION_DURATION}ms ease-out; - ${({ isStuck }) => - isStuck && - css` - gap: 0; - `} - } - - ${PageHeroBlockBeta} { - ${({ isStuck }) => - isStuck && - css` - margin-left: auto; - `} - } -`; - -const PageHeroInner = styled(Constrainer)<{ isStuck: boolean }>` - align-items: end; - transition: padding ${HEADER_TRANSITION_DURATION}ms ease-out; - - ${({ isStuck }) => - isStuck - ? css` - display: flex; - flex-flow: row nowrap; - padding-top: ${variableGlsp()}; - padding-bottom: ${variableGlsp()}; - ` - : css` - padding-top: ${variableGlsp(4)}; - padding-bottom: ${variableGlsp(2)}; - `} -`; - -export const PageHeroHGroup = styled.div` - display: flex; - flex-flow: column; - gap: ${variableGlsp(0.125)}; -`; - -const PageHeroActions = styled.div` - display: flex; - flex-flow: row nowrap; - gap: ${variableGlsp(0.25)}; - align-items: center; - - ${media.largeUp` - justify-content: end; - `} -`; - -interface PageHeroAnalysisProps { - title: string; - description: ReactNode; - isHidden?: boolean; - isResults?: boolean; - aoi?: FeatureCollection; - renderActions?: ({ size }: { size: ButtonProps['size'] }) => ReactNode; -} - -function PageHeroAnalysis(props: PageHeroAnalysisProps) { - const { - title, - description, - isHidden, - isResults = false, - aoi, - renderActions - } = props; - - const { isHeaderHidden, headerHeight, wrapperHeight } = - useSlidingStickyHeaderProps(); - - // The page hero must be sticky at a certain distance from the top which is - // equal to the NavWrapper's height. - const maxTop = wrapperHeight; - // Except when the header get hidden by sliding out of the viewport. When this - // happens the header height must be removed from the equation. - const minTop = wrapperHeight - headerHeight; - - const isStuck = useIsStuck(headerHeight); - - const { isLargeUp } = useMediaQuery(); - - return ( - - - - - - {title} - - - {description && {description}} - - - - {renderActions?.({ - size: isStuck ? 'medium' : isLargeUp ? 'large' : 'medium' - })} - - - - {isResults && aoi && ( - - )} - - ); -} - -export default PageHeroAnalysis; - -const OBSERVER_PIXEL_ID = 'page-hero-pixel'; - -function useIsStuck(threshold: number) { - const [isStuck, setStuck] = useState(window.scrollY > threshold); - - useEffect(() => { - // Check for the observer pixel. - // https://mediatemple.net/blog/web-development-tech/using-intersectionobserver-to-check-if-page-scrolled-past-certain-point-2/ - const pixel = document.createElement('div'); - pixel.id = OBSERVER_PIXEL_ID; - - const style = { - position: 'absolute', - width: '1px', - height: '1px', - left: 0, - pointerEvents: 'none' - }; - - for (const [key, value] of Object.entries(style)) { - pixel.style[key] = value; - } - - document.body.appendChild(pixel); - - const observer = new IntersectionObserver( - ([e]) => { - setStuck(e.intersectionRatio < 1); - }, - { threshold: 1 } - ); - - observer.observe(pixel); - - return () => { - observer.unobserve(pixel); - pixel.remove(); - }; - }, []); - - useEffect(() => { - const el = document.getElementById(OBSERVER_PIXEL_ID); - if (el) { - el.style.top = `${threshold}px`; - } - }, [threshold]); - - return isStuck; -} diff --git a/app/scripts/components/analysis/page-hero-media.tsx b/app/scripts/components/analysis/page-hero-media.tsx deleted file mode 100644 index 89335973f..000000000 --- a/app/scripts/components/analysis/page-hero-media.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import styled, { useTheme } from 'styled-components'; -import { GeoJSONSource, MapboxOptions, Map as MapboxMap } from 'mapbox-gl'; -import { FeatureCollection, Polygon } from 'geojson'; -import bbox from '@turf/bbox'; -import { shade } from 'polished'; -import { rgba, themeVal } from '@devseed-ui/theme-provider'; -import { reveal } from '@devseed-ui/animation'; - -import { combineFeatureCollection } from './utils'; - -import { SimpleMap } from '$components/common/mapbox/map'; -import { useEffectPrevious } from '$utils/use-effect-previous'; -import { HEADER_TRANSITION_DURATION } from '$utils/use-sliding-sticky-header'; -import { DEFAULT_MAP_STYLE_URL } from '$components/common/mapbox/map-options/basemaps'; - -const WORLD_POLYGON = [ - [180, 90], - [-180, 90], - [-180, -90], - [180, -90], - [180, 90] -]; - -const mapOptions: Partial = { - style: DEFAULT_MAP_STYLE_URL, - logoPosition: 'bottom-right', - interactive: false, - center: [0, 0], - zoom: 3 -}; - -interface PageHeroMediaProps { - aoi: FeatureCollection; - isHeaderStuck: boolean; -} - -function PageHeroMedia(props: PageHeroMediaProps) { - const { aoi, isHeaderStuck, ...rest } = props; - const mapContainer = useRef(null); - const mapRef = useRef(null); - const [isMapLoaded, setMapLoaded] = useState(false); - - const theme = useTheme(); - - // Delay the mount of the hero media to avoid the map showing with the wrong - // size. See: https://github.com/NASA-IMPACT/veda-ui/issues/330 - // The issue: - // If on the analysis define page we press the generate button when the header - // is stuck (this happens if we scrolled down the page) then the analysis - // results page will be loaded with the header also stuck. This causes the map - // to mount and read the parent height when it is contracted. The header will - // then expand but the map will not. - // The solution: - // If the header starts as stuck we delay the mounting of the map so by the - // time the map mounts, the parent already has its final size. In the case - // that the header is already unstuck when the page loads, this is not needed. - const [shouldMount, setShouldMount] = useState(!isHeaderStuck); - useEffectPrevious( - ([wasStuck]) => { - if (!shouldMount && wasStuck && !isHeaderStuck) { - const tid = setTimeout( - () => setShouldMount(true), - HEADER_TRANSITION_DURATION - ); - - return () => { - clearTimeout(tid); - }; - } - }, - [isHeaderStuck, shouldMount] - ); - - useEffect(() => { - if (!shouldMount || !isMapLoaded || !mapRef.current) return; - - const aoiSource = mapRef.current.getSource('aoi') as - | GeoJSONSource - | undefined; - - // Convert to multipolygon to use the inverse shading trick. - const aoiInverse = combineFeatureCollection(aoi); - // Add a full polygon to reverse the feature. - aoiInverse.geometry.coordinates[0] = [ - WORLD_POLYGON, - ...aoiInverse.geometry.coordinates[0] - ]; - - // Contrary to mapbox types getSource can return null. - if (!aoiSource) { - mapRef.current.addSource('aoi-inverse', { - type: 'geojson', - data: aoiInverse - }); - - mapRef.current.addLayer({ - id: 'aoi-fill', - type: 'fill', - source: 'aoi-inverse', - paint: { - 'fill-color': shade(0.8, theme.color!.primary!), - 'fill-opacity': 0.8 - } - }); - mapRef.current.addSource('aoi', { - type: 'geojson', - data: aoi - }); - - mapRef.current.addLayer({ - id: 'aoi-stroke', - type: 'line', - source: 'aoi', - paint: { 'line-color': '#fff', 'line-width': 1 }, - layout: { - 'line-cap': 'round', - 'line-join': 'round' - } - }); - } else { - const aoiSource = mapRef.current.getSource( - 'aoi' - ) as GeoJSONSource; - const aoiInverseSource = mapRef.current.getSource( - 'aoi-inverse' - ) as GeoJSONSource; - aoiSource.setData(aoi); - aoiInverseSource.setData(aoiInverse); - } - - mapRef.current.fitBounds(bbox(aoi) as [number, number, number, number], { - padding: { - top: 32, - bottom: 32, - right: 32, - left: 12 * 16 // 12rems - } - }); - /* - theme.color.primary will never change. Having it being set once is enough - */ - }, [shouldMount, isMapLoaded, aoi]); - - return shouldMount ? ( -
    - setMapLoaded(true)} - mapOptions={mapOptions} - attributionPosition='top-left' - /> -
    - ) : null; -} - -const rgbaFixed = rgba as any; - -/** - * Page Hero media map component - * - */ -export default styled(PageHeroMedia)` - /* Convert to styled-component: https://styled-components.com/docs/advanced#caveat */ - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 64%; - z-index: -1; - pointer-events: none; - animation: ${reveal} 2s ease 0s 1; - - &::after { - position: absolute; - top: 0; - bottom: 0; - width: 32%; - z-index: 1; - background: linear-gradient( - 90deg, - ${rgbaFixed(themeVal('color.primary-900'), 1)} 0%, - ${rgbaFixed(themeVal('color.primary-900'), 0)} 100% - ); - content: ''; - } - - &::before { - position: absolute; - top: 0; - right: 100%; - bottom: 0; - width: 100vw; - z-index: 1; - background: ${themeVal('color.primary-900')}; - content: ''; - pointer-events: none; - } - - .mapboxgl-ctrl-attrib { - opacity: 0; - } -`; diff --git a/app/scripts/components/analysis/results/analysis-head.tsx b/app/scripts/components/analysis/results/analysis-head.tsx deleted file mode 100644 index 42dcf25fe..000000000 --- a/app/scripts/components/analysis/results/analysis-head.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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'; -import { variableGlsp } from '$styles/variable-utils'; - -const AnalysisFoldHeadActions = styled(FoldHeadActions)` - width: 100%; - margin-top: ${variableGlsp(2)}; - - ${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 deleted file mode 100644 index dcd8132f4..000000000 --- a/app/scripts/components/analysis/results/analysis-metrics-dropdown.tsx +++ /dev/null @@ -1,146 +0,0 @@ -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' - | 'infographicF'; -} - -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' - }, - { - id: 'sum', - label: 'Sum', - chartLabel: 'Sum', - themeColor: 'infographicF' - } -]; - -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-message.tsx b/app/scripts/components/analysis/results/chart-card-message.tsx deleted file mode 100644 index d7c348572..000000000 --- a/app/scripts/components/analysis/results/chart-card-message.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { ReactNode } from 'react'; -import styled, { css } from 'styled-components'; -import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { - CollecticonChartLine, - CollecticonCircleXmark -} from '@devseed-ui/collecticons'; - -import { variableGlsp } from '$styles/variable-utils'; - -const MessageWrapper = styled.div<{ isError?: boolean }>` - display: flex; - align-items: center; - justify-content: center; - flex-flow: column; - text-align: center; - padding: ${variableGlsp()}; - gap: ${glsp()}; - aspect-ratio: 16/9; - - ${({ isError }) => - isError && - css` - color: ${themeVal('color.danger')}; - `} -`; - -export function ChartCardNoData() { - return ( - - -

    - There is no data available for this dataset with the given parameters. -

    -
    - ); -} - -export function ChartCardNoMetric() { - return ( - - -

    There are no metrics selected to display.

    -
    - ); -} - -export function ChartCardAlert(props: { message: ReactNode }) { - return ( - - -

    - Chart failed loading: -
    - {props.message} -

    -
    - ); -} diff --git a/app/scripts/components/analysis/results/chart-card.tsx b/app/scripts/components/analysis/results/chart-card.tsx deleted file mode 100644 index 14dcaa4b3..000000000 --- a/app/scripts/components/analysis/results/chart-card.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import React, { - useCallback, - useRef, - useMemo, - MouseEvent, - ReactNode, - useState -} from 'react'; -import { DatasetLayer } from 'veda'; -import { reverse } from 'd3'; -import styled, { useTheme } from 'styled-components'; -import { Link } from 'react-router-dom'; -import { glsp } from '@devseed-ui/theme-provider'; -import { - Toolbar, - ToolbarIconButton, - VerticalDivider -} from '@devseed-ui/toolbar'; -import { - CollecticonCircleInformation, - CollecticonDownload2, - CollecticonExpandTopRight -} from '@devseed-ui/collecticons'; -import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; -import { Button } from '@devseed-ui/button'; - -import { getDateRangeFormatted } from '../utils'; -import { TimeseriesData } from './timeseries-data'; -import { - ChartCardAlert, - ChartCardNoData, - ChartCardNoMetric -} from './chart-card-message'; -import AnalysisMetricsDropdown, { - DataMetric, - DATA_METRICS -} from './analysis-metrics-dropdown'; - -import { - CardItem -} from '$components/common/card'; -import { CardHeader, CardHeadline, CardActions, CardTitle, CardBody } from '$components/common/card/styles'; -import Chart, { AnalysisChartRef } from '$components/common/chart/analysis'; -import { ChartLoading } from '$components/common/loading-skeleton'; -import { dateFormatter } from '$components/common/chart/utils'; -import { Tip } from '$components/common/tip'; -import { composeVisuallyDisabled } from '$utils/utils'; -import { - exportCsv, - getTimeDensityFormat -} from '$components/common/chart/analysis/utils'; -import DropMenuItemButton from '$styles/drop-menu-item-button'; -import { getDatasetPath } from '$utils/routes'; -import { veda_faux_module_datasets } from '$data-layer/datasets'; -import { getAllDatasetsProps } from '$utils/veda-data'; - -const InfoTipContent = styled.div` - padding: ${glsp(0.25)}; - display: flex; - flex-flow: column; - gap: ${glsp(0.5)}; - - ${Button} { - align-self: flex-start; - } -`; - -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; -} - -interface ChartCardProps { - title: ReactNode; - chartData: TimeseriesData; - availableDomain: [Date, Date]; - brushRange: [Date, Date]; - onBrushRangeChange: (range: [Date, Date]) => void; -} - -const ChartDownloadButton = composeVisuallyDisabled(ToolbarIconButton); - -const getNoDownloadReason = ({ status, data }: TimeseriesData) => { - if (status === 'errored') { - return 'Data loading errored. Download is not available.'; - } - if (status === 'loading') { - return 'Download will be available once the data finishes loading.'; - } - if (!data.timeseries.length) { - return 'There is no data to download.'; - } - return ''; -}; - -/** - * Get the Dataset overview path from a given dataset layer. - * - * The analysis charts refer to a dataset layer and not the dataset itself. - * Since each dataset layer is analyzed individually (relating to a STAC - * dataset), there is no information on the layer data about the parent dataset. - * To find the corresponding dataset we look through the layers of the datasets - * use the found match. - * - * @param layerId Id of the dataset layer - * - * @returns Internal path for Link - */ -const getDatasetOverviewPath = (layerId: string) => { - const allDatasetsProps = getAllDatasetsProps(veda_faux_module_datasets); - const dataset = allDatasetsProps.find((d) => - d.layers.find((l) => l.id === layerId) - ); - - return dataset ? getDatasetPath(dataset) : '/'; -}; - -export default function ChartCard(props: ChartCardProps) { - 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); - - const timeDensityFormat = getTimeDensityFormat(data?.timeDensity); - - const onExportClick = useCallback( - (e: MouseEvent, type: 'image' | 'text') => { - e.preventDefault(); - if (!chartData.data?.timeseries.length) { - return; - } - - const [startDate, endDate] = brushRange; - // 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 - )}`; - - if (type === 'image') { - chartRef.current?.saveAsImage(filename); - } else { - exportCsv(filename, data, startDate, endDate, activeMetrics); - } - }, - [id, chartData.data, brushRange, activeMetrics] - ); - - const theme = useTheme(); - - const { uniqueKeys, colors } = useMemo(() => { - return { - uniqueKeys: activeMetrics.map((metric) => ({ - label: metric.chartLabel, - value: metric.id, - active: true - })), - colors: activeMetrics.map((metric) => theme.color?.[metric.themeColor]) - }; - }, [activeMetrics, theme]); - - const chartDates = useMemo( - () => - data?.timeseries.map((e) => - dateFormatter(new Date(e.date), timeDensityFormat) - ) ?? [], - [data?.timeseries, timeDensityFormat] - ); - - return ( - - - - {title} - - - - ( - - - - - - )} - > - Select a file format - -
  • - onExportClick(e, 'image')} - > - Image (PNG) - -
  • -
  • - onExportClick(e, 'text')}> - Text (CSV) - -
  • -
    -
    - - - -

    {layer.description}

    - - - } - trigger='click' - interactive - > - - - -
    -
    -
    -
    - - {status === 'errored' && } - - {status === 'loading' ? ( - meta.total ? ( - - ) : ( - - ) - ) : null} - - {status === 'succeeded' ? ( - data.timeseries.length ? ( - !activeMetrics.length ? ( - - ) : ( - - ) - ) : ( - - ) - ) : null} - -
    - ); -} diff --git a/app/scripts/components/analysis/results/concurrency.ts b/app/scripts/components/analysis/results/concurrency.ts deleted file mode 100644 index b236a424b..000000000 --- a/app/scripts/components/analysis/results/concurrency.ts +++ /dev/null @@ -1,44 +0,0 @@ -export function ConcurrencyManager(concurrentRequests = 15) { - let queue: (() => Promise)[] = []; - let running = 0; - - const run = async () => { - if (!queue.length || running > concurrentRequests) return; - - const task = queue.shift(); - if (!task) return; - running++; - await task(); - running--; - run(); - }; - - return { - clear: () => { - queue = []; - }, - queue: (taskFn: () => Promise): Promise => { - let resolve; - let reject; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - queue.push(async () => { - try { - const result = await taskFn(); - resolve(result); - } catch (error) { - reject(error); - } - }); - - run(); - - return promise; - } - }; -} - -export type ConcurrencyManagerInstance = ReturnType; diff --git a/app/scripts/components/analysis/results/index.tsx b/app/scripts/components/analysis/results/index.tsx deleted file mode 100644 index 423363530..000000000 --- a/app/scripts/components/analysis/results/index.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import { useQueryClient } from '@tanstack/react-query'; -import { Navigate } from 'react-router'; -import { Link } from 'react-router-dom'; -import { max, min } from 'd3'; -import { media } from '@devseed-ui/theme-provider'; -import { Button } from '@devseed-ui/button'; -import { CollecticonPencil } from '@devseed-ui/collecticons'; -import { VerticalDivider } from '@devseed-ui/toolbar'; - -import SavedAnalysisControl from '../saved-analysis-control'; -import { - analysisParams2QueryString, - useAnalysisParams -} from './use-analysis-params'; -import { - requestStacDatasetsTimeseries, - TimeseriesData -} from './timeseries-data'; -import ChartCard from './chart-card'; -import AnalysisHead from './analysis-head'; -import { LayoutProps } from '$components/common/layout-root'; -import { CardListGrid } from '$components/common/card/styles'; -import { - Fold, - FoldHeader, - FoldHeadline, - FoldTitle, - FoldBody -} from '$components/common/fold'; -import PageHeroAnalysis from '$components/analysis/page-hero-analysis'; -import { PageMainContent } from '$styles/page'; -import { formatDateRange } from '$utils/date'; -import { pluralize } from '$utils/pluralize'; -import { calcFeatCollArea } from '$components/common/aoi/utils'; -import { ANALYSIS_PATH, ANALYSIS_RESULTS_PATH } from '$utils/routes'; - -const ChartCardList = styled(CardListGrid)` - > li { - min-width: 0; - } - - ${media.largeUp` - grid-template-columns: repeat(2, 1fr); - `} -`; - -const AnalysisFold = styled(Fold)` - /* When the page is too small, the shrinking header causes itself to become - unstuck (because the page stops having an overflow). Since this happens only - under specific screen sizes, this small hack solves the problem with minimal - visual impact. */ - min-height: calc(100vh - 190px); -`; - -const AnalysisFoldHeader = styled(FoldHeader)` - display: block; -`; - -export default function AnalysisResults() { - const queryClient = useQueryClient(); - const [requestStatus, setRequestStatus] = useState([]); - const { params } = useAnalysisParams(); - const { start, end, datasetsLayers, aoi, errors } = params; - - useEffect(() => { - if (!start || !end || !datasetsLayers || !aoi) return; - - const controller = new AbortController(); - - setRequestStatus([]); - const requester = requestStacDatasetsTimeseries({ - start, - end, - aoi, - layers: datasetsLayers, - queryClient, - signal: controller.signal - }); - - requester.on('data', (data, index) => { - setRequestStatus((dataStore) => - Object.assign([], dataStore, { - [index]: data - }) - ); - }); - - return () => { - controller.abort(); - }; - }, [queryClient, start, end, datasetsLayers, aoi]); - - // Textual description for the meta tags and element for the page hero. - const descriptions = useMemo(() => { - if (!start || !end || !datasetsLayers || !aoi) { - return { meta: '', page: '' }; - } - - const dateLabel = formatDateRange(start, end); - const area = calcFeatCollArea(aoi); - const datasetCount = pluralize({ - singular: 'dataset', - count: datasetsLayers.length, - showCount: true - }); - - return { - meta: `Covering ${datasetCount} over a ${area} km2 area from ${dateLabel}.`, - page: ( - <> - Covering {datasetCount} over a{' '} - - {area} km2 - {' '} - area from {dateLabel}. - - ) - }; - }, [start, end, datasetsLayers, aoi]); - - const analysisParamsQs = analysisParams2QueryString({ - start, - end, - datasetsLayers, - aoi - }); - - const availableDomain: [Date, Date] | null = useMemo(() => { - if (!start || !end) return null; - const onlySingleValues = requestStatus.every( - (rs) => rs.data?.timeseries.length === 1 - ); - - const { minDate, maxDate } = requestStatus.reduce( - (acc, item) => { - if (item.data?.timeseries) { - const itemDates = item.data.timeseries.map((t) => +new Date(t.date)); - const itemMin = min(itemDates) ?? Number.POSITIVE_INFINITY; - const itemMax = max(itemDates) ?? Number.NEGATIVE_INFINITY; - - return { - minDate: itemMin < acc.minDate ? itemMin : acc.minDate, - maxDate: itemMax > acc.maxDate ? itemMax : acc.maxDate - }; - } - return acc; - }, - { - minDate: onlySingleValues ? Number.POSITIVE_INFINITY : +start, - maxDate: onlySingleValues ? Number.NEGATIVE_INFINITY : +end - } - ); - - if (!onlySingleValues) { - return [new Date(minDate), new Date(maxDate)]; - } - - // When all data only contain one value, we need to pad the domain to make sure the single value is shown in the center of the chart - // substract/add one day - return [new Date(minDate - 86400000), new Date(minDate + 86400000)]; - }, [start, end, requestStatus]); - - const [brushRange, setBrushRange] = useState<[Date, Date] | null>(null); - - useEffect(() => { - if (availableDomain) { - // TODO auto fit to available data? For now taking the whole user-defined range - setBrushRange(availableDomain); - } - }, [availableDomain]); - - if (errors?.length) { - return ; - } - - return ( - - - ( - <> - - - - - - - )} - /> - - - - 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. -

    -
    - -
    - - {!!requestStatus.length && availableDomain && brushRange && ( - - {requestStatus.map((l) => ( -
  • - setBrushRange(range)} - /> -
  • - ))} -
    - )} -
    -
    -
    - ); -} diff --git a/app/scripts/components/analysis/results/mini-events.ts b/app/scripts/components/analysis/results/mini-events.ts deleted file mode 100644 index 498f4982a..000000000 --- a/app/scripts/components/analysis/results/mini-events.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export default function EventEmitter() { - const _events = {}; - return { - fire(event: string, ...args: any[]) { - if (!_events[event]) return; - _events[event].forEach((callback) => callback(...args)); - }, - on(event: string, callback: (...args: any[]) => any) { - if (!_events[event]) _events[event] = []; - /* eslint-disable-next-line fp/no-mutating-methods */ - _events[event].push(callback); - }, - off(event: string) { - if (!_events[event]) return; - _events[event] = undefined; - } - }; -} diff --git a/app/scripts/components/analysis/results/timeseries-data.ts b/app/scripts/components/analysis/results/timeseries-data.ts deleted file mode 100644 index 095d3e292..000000000 --- a/app/scripts/components/analysis/results/timeseries-data.ts +++ /dev/null @@ -1,350 +0,0 @@ -import axios, { AxiosRequestConfig } from 'axios'; -import { QueryClient } from '@tanstack/react-query'; -import { FeatureCollection, Polygon } from 'geojson'; -import { MAX_QUERY_NUM } from '../constants'; -import { getFilterPayload, combineFeatureCollection } from '../utils'; -import { ConcurrencyManager, ConcurrencyManagerInstance } from './concurrency'; -import EventEmitter from './mini-events'; -import { DatasetLayer } from '$types/veda'; -import { TimeDensity } from '$context/layer-data'; - -export const TIMESERIES_DATA_BASE_ID = 'analysis'; - -export interface TimeseriesDataUnit { - date: string; - 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; -} - -export interface TimeseriesDataResult { - isPeriodic: boolean; - timeDensity: TimeDensity; - domain: string[]; - timeseries: TimeseriesDataUnit[]; -} - -export interface TimeseriesMissingSummaries { - isPeriodic: boolean; - timeDensity: TimeDensity; - domain: string[]; - timeseries?: unknown; -} - -// Different options based on status. -export type TimeseriesData = - | { - id: string; - name: string; - status: 'loading'; - layer: DatasetLayer; - meta: { - total: null | number; - loaded: null | number; - }; - data: null; - error: null; - } - | { - id: string; - name: string; - status: 'succeeded'; - layer: DatasetLayer; - meta: { - total: number; - loaded: number; - }; - data: TimeseriesDataResult; - error: null; - } - | { - id: string; - name: string; - status: 'errored'; - layer: DatasetLayer; - meta: { - total: null | number; - loaded: null | number; - }; - data: null; - error: Error; - }; - -// Restrict events for the requester. -interface StacDatasetsTimeseriesEvented { - on: ( - event: 'data', - callback: (data: TimeseriesData, index: number) => void - ) => void; - off: (event: 'data') => void; -} - -export function requestStacDatasetsTimeseries({ - start, - end, - aoi, - layers, - queryClient, - signal -}: { - start: Date; - end: Date; - aoi: FeatureCollection; - layers: DatasetLayer[]; - queryClient: QueryClient; - signal: AbortSignal; -}) { - const eventEmitter = EventEmitter(); - - const concurrencyManager = ConcurrencyManager(); - - // On abort clear the queue. - signal.addEventListener( - 'abort', - () => { - queryClient.cancelQueries([TIMESERIES_DATA_BASE_ID]); - concurrencyManager.clear(); - }, - { once: true } - ); - - // Start the request for each layer. - layers.forEach(async (layer, index) => { - requestTimeseries({ - start, - end, - aoi, - layer, - queryClient, - eventEmitter, - index, - concurrencyManager - }); - }); - - return { - on: eventEmitter.on, - off: eventEmitter.off - } as StacDatasetsTimeseriesEvented; -} - -interface DatasetAssetsRequestParams { - stacCol: string; - stacApiEndpoint?: string; - assets: string; - dateStart: Date; - dateEnd: Date; - aoi: FeatureCollection; -} - -async function getDatasetAssets( - { - dateStart, - dateEnd, - stacApiEndpoint, - stacCol, - assets, - aoi - }: DatasetAssetsRequestParams, - opts: AxiosRequestConfig, - concurrencyManager: ConcurrencyManagerInstance -) { - const stacApiEndpointToUse = stacApiEndpoint ?? process.env.API_STAC_ENDPOINT; - const data = await concurrencyManager.queue(async () => { - const collectionReqRes = await axios.get( - `${stacApiEndpointToUse}/collections/${stacCol}` - ); - - const searchReqRes = await axios.post( - `${stacApiEndpointToUse}/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 { - isPeriodic: collectionReqRes.data['dashboard:is_periodic'], - timeDensity: collectionReqRes.data['dashboard:time_density'], - domain: collectionReqRes.data.summaries.datetime, - assets: searchReqRes.data.features.map((o) => ({ - date: o.properties.start_datetime || o.properties.datetime, - url: o.assets[assets].href - })) - }; - }); - - return data; -} - -interface TimeseriesRequesterParams { - start: Date; - end: Date; - aoi: FeatureCollection; - layer: DatasetLayer; - queryClient: QueryClient; - eventEmitter: ReturnType; - index: number; - concurrencyManager: ConcurrencyManagerInstance; -} - -// Make requests and emit events. -async function requestTimeseries({ - start, - end, - aoi, - layer, - queryClient, - eventEmitter, - index, - concurrencyManager -}: TimeseriesRequesterParams) { - const id = layer.id; - - let layersBase: TimeseriesData = { - id, - name: layer.name, - status: 'loading', - layer, - meta: { - total: null, - loaded: null - }, - data: null, - error: null - }; - - const onData = (data: TimeseriesData) => { - layersBase = data; - eventEmitter.fire('data', layersBase, index); - }; - - // Initial status. Defer to next tick otherwise the listener will not be - // attached yet. - setTimeout(() => onData(layersBase), 0); - - try { - const layerInfoFromSTAC = await queryClient.fetchQuery( - [TIMESERIES_DATA_BASE_ID, 'dataset', id, aoi, start, end], - ({ signal }) => - getDatasetAssets( - { - stacCol: layer.stacCol, - stacApiEndpoint: layer.stacApiEndpoint, - assets: layer.sourceParams?.assets || 'cog_default', - aoi, - dateStart: start, - dateEnd: end - }, - { signal }, - concurrencyManager - ), - { - staleTime: Infinity - } - ); - - 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', - meta: { - total: assets.length, - loaded: 0 - } - }); - - const tileEndpointToUse = - layer.tileApiEndpoint ?? process.env.API_RASTER_ENDPOINT; - - const analysisParams = layersBase.layer.analysis?.sourceParams ?? {}; - - const layerStatistics = await Promise.all( - assets.map(async ({ date, url }) => { - const statistics = await queryClient.fetchQuery( - [TIMESERIES_DATA_BASE_ID, 'asset', url], - async ({ signal }) => { - return concurrencyManager.queue(async () => { - const { data } = await axios.post( - `${tileEndpointToUse}/cog/statistics?url=${url}`, - // Making a request with a FC causes a 500 (as of 2023/01/20) - combineFeatureCollection(aoi), - { params: { ...analysisParams, url }, signal } - ); - return { - date, - // Remove 1 when https://github.com/NASA-IMPACT/veda-ui/issues/572 is fixed. - ...(data.properties.statistics.b1 || - data.properties.statistics['1']) - }; - }); - }, - { - staleTime: Infinity - } - ); - - onData({ - ...layersBase, - meta: { - total: assets.length, - loaded: (layersBase.meta.loaded ?? 0) + 1 - } - }); - - return statistics; - }) - ); - - onData({ - ...layersBase, - status: 'succeeded', - meta: { - total: assets.length, - loaded: assets.length - }, - data: { - ...otherCollectionProps, - timeseries: layerStatistics - } - }); - } catch (error) { - // Discard abort related errors. - if (error.revert) return; - - onData({ - ...layersBase, - status: 'errored', - error - }); - } -} diff --git a/app/scripts/components/analysis/results/use-analysis-params.ts b/app/scripts/components/analysis/results/use-analysis-params.ts deleted file mode 100644 index f6c91d21c..000000000 --- a/app/scripts/components/analysis/results/use-analysis-params.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import qs from 'qs'; -import { useLocation } from 'react-router'; -import { FeatureCollection, Polygon } from 'geojson'; -import { DatasetLayer, datasets as vedaDatasets } from 'veda'; - -import { userTzDate2utcString, utcString2userTzDate } from '$utils/date'; -import { polygonUrlDecode, polygonUrlEncode } from '$utils/polygon-url'; - -// ?start=2020-01-01T00%3A00%3A00.000Z&end=2020-03-01T00%3A00%3A00.000Z&datasetsLayers=no2-monthly&aoi=ngtcAqjcyEqihCg_%60%7C%40c%7CbyAr%60tJftcApyb~%40%7C%7C%60g~vA%7DlirHc%7BhyBf%60wAngtcA%7D_qaA - -export interface AnalysisParams { - start: Date; - end: Date; - datasetsLayers: DatasetLayer[]; - aoi: FeatureCollection; - errors: any[] | null; -} - -type AnalysisParamsNull = Omit, 'errors'> & { - errors: any[] | null; -}; - -type AnyAnalysisParamsKey = keyof AnalysisParams; -type AnyAnalysisParamsType = Date | DatasetLayer[] | FeatureCollection; - -const initialState: AnalysisParamsNull = { - 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[]; - - constructor(hints: any[]) { - super('Invalid parameters'); - this.hints = hints; - } -} - -export function useAnalysisParams(): { - params: AnalysisParams | AnalysisParamsNull; - setAnalysisParam: ( - param: AnyAnalysisParamsKey, - value: AnyAnalysisParamsType | null - ) => void; -} { - const location = useLocation(); - - const [params, setParams] = useState( - initialState - ); - - useEffect(() => { - const { start, end, datasetsLayers, aoi } = qs.parse(location.search, { - ignoreQueryPrefix: true - }); - - try { - if (!start || !end || !datasetsLayers || !aoi) { - throw new ValidationError([ - 'Missing required value from URL:', - { - start, - end, - datasetsLayers, - aoi - } - ]); - } - - const startDate = utcString2userTzDate(start); - const endDate = utcString2userTzDate(end); - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - throw new ValidationError([ - 'Invalid start or end date:', - { - start, - end - } - ]); - } - - // Create an array with all the dataset layers. - const allDatasetLayers = Object.values(vedaDatasets).flatMap( - // 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) - ); - - if (!datasetsLayers.length || layers.includes(undefined)) { - const sentences = ['Invalid dataset layer ids found:']; - layers.forEach((l, i) => { - if (!l) { - /* eslint-disable-next-line fp/no-mutating-methods */ - sentences.push(`- ${datasetsLayers.split('|')[i]}`); - } - }); - - throw new ValidationError(sentences); - } - - // polyline-encoding;polyline-encoding - // ; separates polygons - const { geojson, errors: gjvErrors } = polygonUrlDecode(aoi); - - if (gjvErrors.length) { - throw new ValidationError(['Invalid AOI string:', ...gjvErrors]); - } - - setParams({ - start: startDate, - end: endDate, - datasetsLayers: layers, - aoi: geojson, - errors: null - }); - } catch (error) { - if (error instanceof ValidationError) { - /* eslint-disable no-console */ - if (LOG) error.hints.forEach((s) => console.log(s)); - /* eslint-enable no-console */ - setParams({ - ...initialState, - errors: error.hints - }); - } else { - throw error; - } - } - }, [location.search]); - - const setAnalysisParam = useCallback( - (param: AnyAnalysisParamsKey, value: AnyAnalysisParamsType) => { - setParams((oldParams) => ({ - ...oldParams, - [param]: value - })); - }, - [] - ); - - return { params, setAnalysisParam }; -} - -export function analysisParams2QueryString( - params: Omit -) { - const urlParams = qs.stringify({ - start: params.start ? userTzDate2utcString(params.start) : undefined, - end: params.end ? userTzDate2utcString(params.end) : undefined, - datasetsLayers: params.datasetsLayers - ? params.datasetsLayers.map((d) => d.id).join('|') - : undefined, - aoi: params.aoi ? polygonUrlEncode(params.aoi) : undefined - }); - - return urlParams ? `?${urlParams}` : ''; -} diff --git a/app/scripts/components/analysis/saved-analysis-control.tsx b/app/scripts/components/analysis/saved-analysis-control.tsx deleted file mode 100644 index b57ed22a7..000000000 --- a/app/scripts/components/analysis/saved-analysis-control.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -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 { Button, ButtonProps } from '@devseed-ui/button'; -import { CollecticonClockBack } from '@devseed-ui/collecticons'; - -import { - Dropdown, - DropMenu, - DropMenuItem, - DropTitle -} from '@devseed-ui/dropdown'; -import { Subtitle } from '@devseed-ui/typography'; - -import useSavedSettings from './use-saved-settings'; - -import { VarHeading } from '$styles/variable-components'; -import { formatDateRange } from '$utils/date'; -import ItemTruncateCount from '$components/common/item-truncate-count'; - -const PastAnalysesDropdown = styled(Dropdown)` - max-width: 22rem; -`; - -const PastAnalysesMenu = styled(DropMenu)` - /* styled-component */ -`; - -const PastAnalysisItem = styled(DropMenuItem)` - /* styled-component */ -`; - -const PastAnalysisMedia = styled.figure` - position: relative; - order: -1; - height: 3rem; - overflow: hidden; - border-radius: ${themeVal('shape.rounded')}; - flex-shrink: 0; - aspect-ratio: 1.5 / 1; - background: ${themeVal('color.base-50')}; - - &::before { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 2; - content: ''; - box-shadow: inset 0 0 0 1px ${themeVal('color.base-100a')}; - border-radius: ${themeVal('shape.rounded')}; - pointer-events: none; - } -`; - -const PastAnalysisHeadline = styled.div` - display: flex; - flex-flow: column nowrap; - gap: ${glsp(0.25)}; - - ${Subtitle} { - font-weight: initial; - } -`; - -const PastAnalysisTitle = styled(VarHeading).attrs({ - as: 'h4', - size: 'xxsmall' -})` - /* styled-component */ -`; - -interface SavedAnalysisControlProps { - size: ButtonProps['size']; - urlBase: string; -} - -export default function SavedAnalysisControl({ - size, - urlBase -}: SavedAnalysisControlProps) { - - const { savedSettingsList } = useSavedSettings(); - - return ( - ( - - )} - > - Past analyses - - {savedSettingsList.map((savedSettings) => { - const { start, end, aoi, datasets } = savedSettings.params; - return ( -
  • -
    - - - - {formatDateRange( - new Date(start), - new Date(end), - ' ā€” ', - false - )} - - - - - - - - - -
    -
  • - ); - })} -
    -
    - ); -} - -function SavedAnalysisThumbnail(props: { aoi: FeatureCollection }) { - const { aoi } = props; - - const theme = useTheme(); - - const styledFeatures = { - type: 'FeatureCollection', - features: aoi.features.map(({ geometry }) => ({ - type: 'Feature', - properties: { - fill: theme.color?.primary, - 'stroke-width': 2, - stroke: theme.color?.primary - }, - geometry - })) - }; - - let encodedGeoJson = encodeURIComponent(JSON.stringify(styledFeatures)); - const encodedGeoJsonChars = encodedGeoJson.length; - - // If more than 8000 chars the request will fail. - // In this case simplify and show a bounding box. - const MAX_MAPBOX_API_CHARS = 8000; - if (encodedGeoJsonChars > MAX_MAPBOX_API_CHARS) { - const [w, s, e, n] = bbox(styledFeatures); - // We want the corners length to be 1/4 of the distance between - // W & E / N & S - const lonSide = (w * -1 + e) * 0.25; - const latSide = (n * -1 + s) * 0.25; - - const makeCorner = (p1, p2, p3) => ({ - type: 'Feature', - properties: { - 'stroke-width': 8, - stroke: theme.color?.primary - }, - geometry: { - type: 'LineString', - coordinates: [p1, p2, p3] - } - }); - - const fc = { - type: 'FeatureCollection', - features: [ - makeCorner([w + lonSide, n], [w, n], [w, n + latSide]), - makeCorner([e - lonSide, n], [e, n], [e, n + latSide]), - makeCorner([e - lonSide, s], [e, s], [e, s - latSide]), - makeCorner([w + lonSide, s], [w, s], [w, s - latSide]) - ] - }; - - encodedGeoJson = encodeURIComponent(JSON.stringify(fc)); - } - - const src = `https://api.mapbox.com/styles/v1/covid-nasa/cldac5c2c003k01oebmavw4q3/static/geojson(${encodedGeoJson})/auto/480x320?padding=32&access_token=${process.env.MAPBOX_TOKEN}`; - - return Thumbnail showing AOI; -} diff --git a/app/scripts/components/analysis/use-saved-settings.ts b/app/scripts/components/analysis/use-saved-settings.ts deleted file mode 100644 index 2d8ce0ab9..000000000 --- a/app/scripts/components/analysis/use-saved-settings.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { FeatureCollection, Polygon } from 'geojson'; -import { useCallback, useEffect, useState } from 'react'; - -interface AnalysisParams { - start?: Date; - end?: Date; - datasets?: string[]; - aoi?: FeatureCollection; -} - -interface SavedSettings { - url: string; - params: Required; -} - -interface useSavedSettingsParams { - analysisParamsQs?: string; - params?: AnalysisParams; -} - -const SAVED_SETTINGS_KEY = 'analysisSavedSettings'; -const MAX_SAVED_SETTINGS = 5; - -function useSavedSettings(opts: useSavedSettingsParams = {}) { - const { analysisParamsQs, params = {} } = opts; - - // Only need to read localStorage at component mount, because whenever the - // localStorage item is updated, this components gets unmounted anyways - // (navigating from the page using the 'Generate' button) - const [savedSettingsList, setSavedSettingsList] = useState( - [] - ); - useEffect(() => { - const savedSettingsRaw = localStorage.getItem(SAVED_SETTINGS_KEY); - try { - if (savedSettingsRaw) { - setSavedSettingsList(JSON.parse(savedSettingsRaw)); - } - } catch (e) { - /* eslint-disable-next-line no-console */ - console.error(e); - } - }, []); - - const onGenerateClick = useCallback(() => { - if (Object.values(params).some((v) => !v) || !analysisParamsQs) { - /* eslint-disable-next-line no-console */ - console.log( - 'Analysis parameters missing. Can not save to localstorage', - params - ); - return; - } - - try { - if (!savedSettingsList.find((s) => s.url === analysisParamsQs)) { - const newSettings = [ - { - // At this point the params and url are required. - url: analysisParamsQs!, - params: params as SavedSettings['params'] - }, - ...savedSettingsList.slice(0, MAX_SAVED_SETTINGS - 1) - ]; - - localStorage.setItem(SAVED_SETTINGS_KEY, JSON.stringify(newSettings)); - setSavedSettingsList(newSettings); - } - } catch (e) { - /* eslint-disable-next-line no-console */ - console.error(e); - } - // params will be left out of the dependency array since it is an object and - // changes on every render. analysisParamsQs is the url version of params, - // so when params change, analysisParamsQs is guaranteed to change as well. - }, [savedSettingsList, analysisParamsQs]); - - return { - onGenerateClick, - savedSettingsList - }; -} - -export default useSavedSettings; diff --git a/app/scripts/components/analysis/utils.ts b/app/scripts/components/analysis/utils.ts deleted file mode 100644 index d22c05fdb..000000000 --- a/app/scripts/components/analysis/utils.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { endOfDay, startOfDay, format } from 'date-fns'; -import { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; -import combine from '@turf/combine'; -import { userTzDate2utcString } from '$utils/date'; -import { fixAntimeridian } from '$utils/antimeridian'; - -/** - * Creates the appropriate filter object to send to STAC. - * - * @param {Date} start Start date to request - * @param {Date} end End date to request - * @param {string} collection STAC collection to request - * @returns Object - */ -export function getFilterPayload( - start: Date, - end: Date, - aoi: FeatureCollection, - collections: string[] -) { - const aoiMultiPolygon = fixAoiFcForStacSearch(aoi); - - const filterPayload = { - op: 'and', - args: [ - { - op: 't_intersects', - args: [ - { property: 'datetime' }, - { - interval: [ - userTzDate2utcString(startOfDay(start)), - userTzDate2utcString(endOfDay(end)) - ] - } - ] - }, - { - op: 's_intersects', - args: [{ property: 'geometry' }, aoiMultiPolygon.geometry] - }, - { - op: 'in', - args: [{ property: 'collection' }, collections] - } - ] - }; - return filterPayload; -} - -/** - * Converts a MultiPolygon to a Feature Collection of polygons. - * - * @param feature MultiPolygon feature - * - * @see combineFeatureCollection() for opposite - * - * @returns Feature Collection of Polygons - */ -export function multiPolygonToPolygons(feature: Feature) { - const polygons = feature.geometry.coordinates.map( - (coordinates) => - ({ - type: 'Feature', - properties: { ...feature.properties }, - geometry: { - type: 'Polygon', - coordinates: coordinates - } - } as Feature) - ); - - return polygons; -} - -/** - * Converts a Feature Collection of polygons into a MultiPolygon - * - * @param featureCollection Feature Collection of Polygons - * - * @see multiPolygonToPolygons() for opposite - * - * @returns MultiPolygon Feature - */ -export function combineFeatureCollection( - featureCollection: FeatureCollection -): Feature { - const combined = combine(featureCollection); - return { - type: 'Feature', - properties: {}, - geometry: combined.features[0].geometry as MultiPolygon - }; -} - -/** - * Fixes the AOI feature collection for a STAC search by converting all polygons - * to a single multipolygon and ensuring that every polygon is inside the - * -180/180 range. - * @param aoi The AOI feature collection - * @returns AOI as a multipolygon with every polygon inside the -180/180 range - */ -export function fixAoiFcForStacSearch(aoi: FeatureCollection) { - // Stac search spatial intersect needs to be done on a single feature. - // Using a Multipolygon - const singleMultiPolygon = combineFeatureCollection(aoi); - // And every polygon must be inside the -180/180 range. - // See: https://github.com/NASA-IMPACT/veda-ui/issues/732 - const aoiMultiPolygon = fixAntimeridian(singleMultiPolygon); - return aoiMultiPolygon; -} - -export function getDateRangeFormatted(startDate, endDate) { - const dFormat = 'yyyy-MM-dd'; - const startDateFormatted = format(startDate, dFormat); - const endDateFormatted = format(endDate, dFormat); - return [startDateFormatted, endDateFormatted].join('-'); -} diff --git a/app/scripts/components/common/aoi/use-aoi-controls.ts b/app/scripts/components/common/aoi/use-aoi-controls.ts index de4f7dba0..33729a406 100644 --- a/app/scripts/components/common/aoi/use-aoi-controls.ts +++ b/app/scripts/components/common/aoi/use-aoi-controls.ts @@ -1,7 +1,7 @@ import { RefObject, useCallback, useState } from 'react'; import { useDeepCompareEffect } from 'use-deep-compare'; +import { Map as MapboxMap } from 'mapbox-gl'; -import { MapboxMapRef } from '../mapbox'; import { AoiChangeListenerOverload, AoiState } from './types'; import { makeFeatureCollection } from './utils'; @@ -13,6 +13,12 @@ const DEFAULT_PARAMETERS = { actionOrigin: null }; +export interface MapboxMapRef { + resize: () => void; + instance: MapboxMap | null; + compareInstance: MapboxMap | null; +} + export function useAoiControls( mapRef: RefObject, initialState: Partial = {} diff --git a/app/scripts/components/common/chart/analysis/control.tsx b/app/scripts/components/common/chart/analysis/control.tsx deleted file mode 100644 index e4d903dbd..000000000 --- a/app/scripts/components/common/chart/analysis/control.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { themeVal } from '@devseed-ui/theme-provider'; - -import { LegendWrapper, LegendItem } from '$components/common/chart/legend'; -import { ListItem } from '$components/common/chart/tooltip'; - -interface LegendEntryUnit { - label: string; - color: string; - value: string; - active: boolean; - onClick: (arg: string) => void; -} - -interface AnalysisLegendComponentProps { - payload?: LegendEntryUnit[]; -} - -const ClickableLegendItem = styled(LegendItem)` - cursor: pointer; -`; - -const TogglableListItem = styled(ListItem)<{ active: boolean; color: string }>` - background-color: ${({ active, color }) => - active ? color : themeVal('color.base-400')}; -`; - -export const AnalysisLegendComponent = ( - props: AnalysisLegendComponentProps -) => { - const { payload } = props; - - if (payload) { - return ( - - {payload.map((entry) => ( - { - event.preventDefault(); - entry.onClick(entry.value); - }} - > - - {entry.label} - - ))} - - ); - } - return null; -}; diff --git a/app/scripts/components/common/chart/analysis/index.tsx b/app/scripts/components/common/chart/analysis/index.tsx deleted file mode 100644 index 14fdf65a4..000000000 --- a/app/scripts/components/common/chart/analysis/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { useRef, useMemo, useImperativeHandle, MutableRefObject, forwardRef } from 'react'; -import styled from 'styled-components'; -import FileSaver from 'file-saver'; - -import { ChartWrapperRef, exportImage } from './utils'; -import { getLegendStringForScreenshot } from './svg-legend'; -import Chart, { CommonLineChartProps } from '$components/common/chart'; -import { formatTimeSeriesData } from '$components/common/chart/utils'; - -const Wrapper = styled.div` - width: 100%; - grid-column: 1/-1; -`; - -interface AnalysisChartProps extends CommonLineChartProps { - timeSeriesData: object[]; - dates: string[]; -} - -export interface AnalysisChartRef { - instanceRef: MutableRefObject; - saveAsImage: (name?: string) => Promise; -} - -const syncId = 'analysis'; - -export default forwardRef( - function AnalysisChart(props, ref) { - const { timeSeriesData, dates, uniqueKeys, dateFormat, altTitle } = - props; - - const chartRef = useRef(null); - - const chartData = useMemo(() => { - return formatTimeSeriesData({ - timeSeriesData, - dates, - uniqueKeys, - dateFormat, - }); - }, [timeSeriesData, dates, uniqueKeys, dateFormat]); - - useImperativeHandle( - ref, - () => ({ - instanceRef: chartRef, - saveAsImage: async (name = 'chart') => { - if (!chartRef.current) return; - - const chartImageUrl = await exportImage({ - title: altTitle, - svgWrapperRef: chartRef, - legendSvgString: getLegendStringForScreenshot({ - uniqueKeys, - lineColors: props.colors - }) - }); - FileSaver.saveAs(chartImageUrl, `${name}.png`); - } - }), - [uniqueKeys, props.colors, altTitle] - ); - - return ( - - - - ); - } -); diff --git a/app/scripts/components/common/chart/analysis/svg-legend.tsx b/app/scripts/components/common/chart/analysis/svg-legend.tsx deleted file mode 100644 index 377d0b1c7..000000000 --- a/app/scripts/components/common/chart/analysis/svg-legend.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; - -// The legend on dashboard is consist of HTML elements to make the alignment easy -// However, SVG needs to be 'pure' to be exportable -// Creating SVG version of legend for exporting purpose -export function getLegendStringForScreenshot({ uniqueKeys, lineColors }) { - const legendWidth = 80; - const legendHeight = 16; - - return renderToStaticMarkup( - - {uniqueKeys - .filter((k) => k.active) - .map((entry, idx) => ( - - - - {entry.label} - - - ))} - - ); -} diff --git a/app/scripts/components/common/chart/analysis/utils.ts b/app/scripts/components/common/chart/analysis/utils.ts deleted file mode 100644 index 8e4c2c453..000000000 --- a/app/scripts/components/common/chart/analysis/utils.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { RefObject, Component } from 'react'; -import FileSaver from 'file-saver'; -import { unparse } from 'papaparse'; -import { - chartAspectRatio -} from '$components/common/chart/constant'; -import { TimeseriesDataUnit } from '$components/analysis/results/timeseries-data'; -import { TimeDensity } from '$context/layer-data'; -import { DataMetric } from '$components/analysis/results/analysis-metrics-dropdown'; - -const URL = window.URL; - -const chartPNGPadding = 20; -// Export in full HD. -const PNGWidth = 1920 - chartPNGPadding * 2; - -const titleAreaHeight = 64; -const chartExportHeight = PNGWidth / chartAspectRatio; -const chartExportWidth = PNGWidth; - -// Rechart does not export the type for wrapper component (CategoricalChartWrapper) -// Working around -export interface ChartWrapperRef extends Component { - container: HTMLElement; -} - -export interface ChartToImageProps { - title: string; - svgWrapperRef: RefObject; - legendSvgString: string; -} - -function getFontStyle() { - // font url needs to be encoded to be embedded into svg - const encodedFontUrl = window.btoa('/open-sans.woff2'); - const fontStyleAsString = `@font-face { font-family: "Open Sans",sans-serif; - src: url("data:application/font-woff;charset=utf-8;base64,${encodedFontUrl}") format('woff2');}`; - - const style = document.createElement('style'); - style.type = 'text/css'; - style.appendChild(document.createTextNode(fontStyleAsString)); - return style; -} - -function getLegendSVG(legendsString: string) { - const wrapperDiv = document.createElement('div'); - wrapperDiv.innerHTML = legendsString; - return wrapperDiv.firstChild; -} - -function getDataURLFromSVG(svgElement: SVGElement) { - // Inject font styles to SVG - const fontNode = getFontStyle(); - svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - svgElement.setAttribute( - 'style', - `font-family:"Open Sans",sans-serif;font-size:14px;font-style:normal;` - ); - svgElement.appendChild(fontNode); - - const blob = new Blob([svgElement.outerHTML], { - type: 'image/svg+xml;charset=utf-8' - }); - const blobURL = URL.createObjectURL(blob); - return blobURL; -} - -interface DrawOnCanvasParams { - chartImage: HTMLImageElement; - legendImage: HTMLImageElement; - zoomRatio: number; - title: string; -} - -function drawOnCanvas({ - chartImage, - legendImage, - zoomRatio, - title -}: DrawOnCanvasParams) { - const c = document.createElement('canvas'); - const legendWidth = legendImage.width * zoomRatio; - const legendHeight = legendImage.height * zoomRatio; - - // Height of all elements and the padding between them. - const PNGHeight = - titleAreaHeight + chartExportHeight + legendHeight + chartPNGPadding * 2; - - const canvasWidth = PNGWidth + chartPNGPadding * 2; - const canvasHeight = PNGHeight + chartPNGPadding * 2; - c.width = canvasWidth; - c.height = canvasHeight; - - // Current Y coord where to start drawing - let currentY = chartPNGPadding; - - const ctx = c.getContext('2d'); - - if (!ctx) throw new Error('Failed to get canvas context to export chart.'); - - // Draw white background - ctx.fillStyle = 'white'; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - - // DEBUG AREAS - // šŸŽÆ Uncomment to view areas. - // ctx.fillStyle = 'rgba(0, 0, 0, 0.32)'; - // ctx.fillRect(0, 0, canvasWidth, canvasHeight); - - // ctx.fillStyle = 'rgba(0, 0, 0, 0.16)'; - // // - title area - // ctx.fillRect(chartPNGPadding, currentY, PNGWidth, titleAreaHeight); - // currentY += titleAreaHeight + chartPNGPadding; - // // - chart area - // ctx.fillRect(chartPNGPadding, currentY, PNGWidth, chartExportHeight); - // currentY += chartExportHeight + chartPNGPadding; - // // - legend area - // ctx.fillRect(chartPNGPadding, currentY, PNGWidth, legendHeight); - // currentY = chartPNGPadding; - // END DEBUG AREAS - - // Draw the title. - ctx.fillStyle = 'black'; - ctx.font = 'bold 48px "Open Sans"'; - ctx.textAlign = 'center'; - - ctx.fillText(title, canvasWidth / 2, currentY + 48); - - currentY += titleAreaHeight + chartPNGPadding; - - // Draw chart (crop out brush part) - ctx.drawImage( - chartImage, - 0, - 0, - chartImage.width, - chartImage.height, - 0, - currentY, - chartExportWidth, - chartExportHeight - ); - - currentY += chartExportHeight + chartPNGPadding; - - // Draw legend - ctx.drawImage( - legendImage, - 0, - 0, - legendImage.width, - legendImage.height, - PNGWidth - legendWidth, - currentY, - legendWidth, - legendHeight - ); - - const png = c.toDataURL(); - return png; -} - -function loadImageWithPromise(url: string) { - return new Promise((resolve) => { - const image = new Image(); - image.addEventListener('load', () => { - resolve(image); - }); - image.src = url; - }); -} - -export async function exportImage({ - title, - svgWrapperRef, - legendSvgString -}: ChartToImageProps) { - const svgWrapper = svgWrapperRef.current; - - // Extract SVG element from svgRef (div wrapper around chart SVG) - const svg = svgWrapper?.container.getElementsByTagName('svg')[0] as - | SVGSVGElement - | undefined; - - // Scale up/down the chart to make it width 800px - if (svg) { - const originalSVGWidth = parseInt(svg.getAttribute('width') ?? '800'); - const zoomRatio = PNGWidth / originalSVGWidth; - - const clonedSvgElement = svg.cloneNode(true) as SVGElement; - clonedSvgElement.setAttribute('width', chartExportWidth.toString()); - clonedSvgElement.setAttribute('height', chartExportHeight.toString()); - clonedSvgElement.querySelector('g.recharts-brush')?.remove(); - - const legendSVG = getLegendSVG(legendSvgString) as SVGElement; - - const chartUrl = getDataURLFromSVG(clonedSvgElement); - const legendUrl = getDataURLFromSVG(legendSVG); - - const chartImage = await loadImageWithPromise(chartUrl); - const legendImage = await loadImageWithPromise(legendUrl); - - return drawOnCanvas({ chartImage, legendImage, zoomRatio, title }); - } else { - throw Error('No SVG specified'); - } -} - -export function exportCsv( - filename: string, - data: TimeseriesDataUnit[], - startDate: Date, - endDate: Date, - activeMetrics: DataMetric[] -) { - const startTimestamp = +startDate; - const endTimestamp = +endDate; - const filtered = data.filter((row) => { - const timestamp = +new Date(row.date); - return timestamp >= startTimestamp && timestamp <= endTimestamp; - }); - const csv = unparse(filtered, { - columns: ['date', ...activeMetrics.map((m) => m.id)] - }); - FileSaver.saveAs( - new Blob([csv], { type: 'text/csv;charset=utf-8' }), - `${filename}.csv` - ); -} - -const dateFormatsPerDensity = { - day: '%Y/%m/%d', - month: '%Y/%m', - year: '%Y', - default: '%Y/%m' -}; - -export function getTimeDensityFormat(td?: TimeDensity) { - if (td) return dateFormatsPerDensity[td]; - return dateFormatsPerDensity.default; -} \ No newline at end of file diff --git a/app/scripts/components/common/chart/analysis/brush.tsx b/app/scripts/components/common/chart/brush.tsx similarity index 97% rename from app/scripts/components/common/chart/analysis/brush.tsx rename to app/scripts/components/common/chart/brush.tsx index aa4b1853b..d4e935aeb 100644 --- a/app/scripts/components/common/chart/analysis/brush.tsx +++ b/app/scripts/components/common/chart/brush.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { brushHeight } from '../constant'; +import { brushHeight } from './constant'; import useBrush from './useBrush'; const BrushWrapper = styled.div` diff --git a/app/scripts/components/common/chart/index.tsx b/app/scripts/components/common/chart/index.tsx index 1eafd6a0e..e230fb20d 100644 --- a/app/scripts/components/common/chart/index.tsx +++ b/app/scripts/components/common/chart/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, forwardRef } from 'react'; +import React, { Component, useState, useMemo, useEffect, forwardRef } from 'react'; import styled from 'styled-components'; import { LineChart, @@ -36,10 +36,15 @@ import { chartYAxisWidth, chartLabelOffset } from './constant'; -import { ChartWrapperRef } from './analysis/utils'; -import BrushCustom from './analysis/brush'; +import BrushCustom from './brush'; import { useMediaQuery } from '$utils/use-media-query'; +// Rechart does not export the type for wrapper component (CategoricalChartWrapper) +// Working around +export interface ChartWrapperRef extends Component { + container: HTMLElement; +} + const LineChartWithFont = styled(LineChart)` font-size: 0.8rem; `; diff --git a/app/scripts/components/common/chart/analysis/useBrush.ts b/app/scripts/components/common/chart/useBrush.ts similarity index 100% rename from app/scripts/components/common/chart/analysis/useBrush.ts rename to app/scripts/components/common/chart/useBrush.ts diff --git a/app/scripts/components/common/dateslider/constants.ts b/app/scripts/components/common/dateslider/constants.ts deleted file mode 100644 index 73481931e..000000000 --- a/app/scripts/components/common/dateslider/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface DateSliderDataItem { - index: number; - date: Date; - hasData: boolean; - breakLength?: number; -} - -export type DateSliderTimeDensity = 'day' | 'month' | 'year'; - -export const DATA_POINT_WIDTH = 32; - -export const RANGE_PADDING = 24; - -export const CHART_HEIGHT = 48; diff --git a/app/scripts/components/common/dateslider/data-points.tsx b/app/scripts/components/common/dateslider/data-points.tsx deleted file mode 100644 index 8f22151e4..000000000 --- a/app/scripts/components/common/dateslider/data-points.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import { ScaleLinear, select, Selection } from 'd3'; -import { themeVal } from '@devseed-ui/theme-provider'; - -import { findDate } from './utils'; -import { DateSliderDataItem, DateSliderTimeDensity } from './constants'; - -import useReducedMotion from '$utils/use-prefers-reduced-motion'; - -const StyledG = styled.g` - .data-point { - fill: #fff; - stroke-width: 1px; - stroke: ${themeVal('color.base-100')}; - transition: fill 160ms ease-in; - - &.over { - fill: ${themeVal('color.base')}; - } - } - - .data-point-valid { - fill: ${themeVal('color.base')}; - } - - .select-highlight { - fill: none; - stroke-width: 2px; - stroke: ${themeVal('color.base')}; - } - - .data-point-break { - fill: none; - stroke: ${themeVal('color.base-200')}; - } -`; - -const DataLineSelf = styled.line` - stroke: ${themeVal('color.base-100')}; -`; - -type HighlightCircle = Selection< - SVGCircleElement, - DateSliderDataItem, - SVGGElement | null, - unknown ->; - -function animateHighlight(circle: HighlightCircle) { - return circle - .attr('r', 1) - .style('opacity', 1) - .transition() - .duration(1500) - .style('opacity', 0) - .attr('r', 10) - .on('end', () => animateHighlight(circle)); -} - -interface DataPointsProps { - data: DateSliderDataItem[]; - hoveringDataPoint: Date | null; - value?: Date; - x: ScaleLinear; - zoomXTranslation: number; - timeDensity: DateSliderTimeDensity; -} - -export function DataPoints(props: DataPointsProps) { - const { data, value, timeDensity, x, zoomXTranslation, hoveringDataPoint } = - props; - const container = useRef(null); - - const reduceMotion = useReducedMotion(); - - useEffect(() => { - const rootG = select(container.current); - - const classAttr = (d, c) => { - return hoveringDataPoint === d.index ? `${c} over` : c; - }; - - rootG - .selectAll('circle.data-point') - .data(data.filter((d) => d.hasData)) - .join('circle') - .attr('class', (d) => classAttr(d, 'data-point')) - .attr('cx', (d) => x(d.index)) - .attr('cy', 12) - .attr('r', 4); - - rootG - .selectAll('circle.data-point-valid') - .data(data.filter((d) => d.hasData)) - .join('circle') - .attr('class', (d) => classAttr(d, 'data-point-valid')) - .attr('cx', (d) => x(d.index)) - .attr('cy', 12) - .attr('r', 2); - - // Add a squiggly line to indicate there is a data break. - rootG - .selectAll('path.data-point-break') - .data(data.filter((d) => d.breakLength)) - .join('path') - .attr('class', (d) => classAttr(d, 'data-point-break')) - .attr('d', (d) => { - // Center point on the horizontal line. We draw a bit left and right. - const h = x(d.index); - return `M${h - 12} 12 - L${h - 9} 8 - L${h - 3} 16 - L${h + 3} 8 - L${h + 9} 16 - L${h + 12} 12`; - }); - }, [data, x, hoveringDataPoint]); - - useEffect(() => { - const rootG = select(container.current); - - const item = findDate(data, value, timeDensity); - - if (item) { - let circle = rootG.select('.select-highlight') as HighlightCircle; - - if (circle.empty()) { - circle = rootG - .append('circle') - .lower() - .datum(item) - .attr('class', 'select-highlight') - .attr('cy', 12); - } - - circle.attr('cx', (d) => x(d.index)); - - // Animate or not. - if (reduceMotion) { - circle.attr('r', 6).style('opacity', 1); - } else { - circle.call(animateHighlight); - } - } - - return () => { - if (item) { - rootG.select('.select-highlight').remove(); - } - }; - }, [data, value, timeDensity, x, reduceMotion]); - - return ( - - ); -} - -interface DataLineProps { - x: ScaleLinear; - zoomXTranslation: number; -} - -export function DataLine(props: DataLineProps) { - const { x, zoomXTranslation } = props; - - // The line should occupy the full scale. - const [x1, x2] = x.range(); - - return ( - - ); -} diff --git a/app/scripts/components/common/dateslider/date-axis.tsx b/app/scripts/components/common/dateslider/date-axis.tsx deleted file mode 100644 index ea6f31cbe..000000000 --- a/app/scripts/components/common/dateslider/date-axis.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import { ScaleLinear, select } from 'd3'; -import { format } from 'date-fns'; -import { themeVal } from '@devseed-ui/theme-provider'; -import { createSubtitleStyles } from '@devseed-ui/typography'; - -import { DateSliderDataItem, DateSliderTimeDensity } from './constants'; - -const timeFormat = { - day: 'dd', - month: 'MMM', - year: 'yyyy' -}; - -const parentTimeFormat = { - day: 'MMM yyyy', - month: 'yyyy' -}; - -const parentSearchFormat = { - day: 'yyyy-MM', - month: 'yyyy' -}; - -const StyledG = styled.g` - .date-value, - .date-parent-value { - ${createSubtitleStyles()} - fill: ${themeVal('color.base-400')}; - font-size: 0.75rem; - line-height: 1rem; - } -`; - -interface DateAxisProps { - data: DateSliderDataItem[]; - x: ScaleLinear; - zoomXTranslation: number; - timeDensity: DateSliderTimeDensity; -} - -export function DateAxis(props: DateAxisProps) { - const { data, x, zoomXTranslation, timeDensity } = props; - const dateGRef = useRef(null); - - useEffect(() => { - const dateG = select(dateGRef.current); - - dateG - .selectAll('text.date-value') - .data(data.filter((d) => !d.breakLength)) - .join('text') - .attr('class', 'date-value') - .attr('x', (d) => x(d.index)) - .attr('y', 16) - .attr('dy', '1em') - .attr('text-anchor', 'middle') - .text((d) => format(d.date, timeFormat[timeDensity])); - }, [data, x, timeDensity]); - - return ( - - ); -} - -interface DateAxisParentProps { - data: DateSliderDataItem[]; - x: ScaleLinear; - zoomXTranslation: number; - timeDensity: DateSliderTimeDensity; -} - -export function DateAxisParent(props: DateAxisParentProps) { - const { data, x, zoomXTranslation, timeDensity } = props; - const parentGref = useRef(null); - - useEffect(() => { - const parentG = select(parentGref.current); - // There's no parent for the year time unit. - if (timeDensity === 'year') { - parentG.selectAll('text.date-parent-value').remove(); - } else { - const uniqueParent = data.reduce((acc, item) => { - const { date } = item; - const formatStr = parentSearchFormat[timeDensity]; - - const exists = acc.find( - (d) => format(d.date, formatStr) === format(date, formatStr) - ); - - return exists ? acc : acc.concat(item); - }, []); - - parentG - .selectAll('text.date-parent-value') - .data(uniqueParent) - .join('text') - .attr('class', 'date-parent-value') - .attr('y', 30) - .attr('dy', '1em') - .attr('text-anchor', d => { - const isLastElement = d.index === x.domain()[1]; - return isLastElement ? 'end' : 'middle'; - }) - .attr('dx', d => { - const isLastElement = d.index === x.domain()[1]; - return isLastElement ? '1em' : ''; - }) - .text((d) => format(d.date, parentTimeFormat[timeDensity])); - } - }, [data, x, timeDensity]); - - useEffect(() => { - select(parentGref.current) - .selectAll('text.date-parent-value') - .each((d, i, n) => { - // Expected position of this node. - const expectedPosition = x(d.index); - const expectedAfterTrans = expectedPosition + zoomXTranslation; - - const nextNode = n[i + 1] as SVGTextElement | undefined; - - let maxPos = Infinity; - // If there's a node after this one, that node will push on this one, so - // the max "x" position for this node will be start of the next. - if (nextNode) { - // Width of current node. - const { width: nextNodeW } = nextNode.getBBox(); - // Position of the next item. - const nextItemData = select( - nextNode - ).datum(); - const nextItemPos = x(nextItemData.index) + zoomXTranslation; - - maxPos = nextItemPos - nextNodeW; - } - - // The node should stay close to the left, so we get the width / 2 - // because of text anchor middle. Add 4px for spacing. - const leftPadding = n[i].getBBox().width / 2 + 4; - - const xTrans = Math.min( - Math.max(expectedAfterTrans, leftPadding), - maxPos - ); - select(n[i]).attr('x', xTrans); - }); - }, [zoomXTranslation, x]); - - return ; -} diff --git a/app/scripts/components/common/dateslider/faders.tsx b/app/scripts/components/common/dateslider/faders.tsx deleted file mode 100644 index 8caafb056..000000000 --- a/app/scripts/components/common/dateslider/faders.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { Fragment } from 'react'; -import { ScaleLinear } from 'd3'; - -import { DateSliderDataItem } from './constants'; -import { getZoomTranslateExtent } from './utils'; - -export const MASK_ID = 'gradient-mask'; -const FADE_ID = 'fade-gradient'; - -interface FaderDefinitionProps { - data: DateSliderDataItem[]; - x: ScaleLinear; - zoomXTranslation: number; - width: number; - height: number; - getUID: (base: string) => string; -} - -export function FaderDefinition(props: FaderDefinitionProps) { - const { zoomXTranslation, width, height, data, x, getUID } = props; - - const [[xMinExtent], [xMaxExtent]] = getZoomTranslateExtent(data, x); - - // Invert the value. - const xTranslation = zoomXTranslation * -1; - - // Fade the masks. - // Fade in 5px (1/5) - const fadePx = 1 / 5; - const maxX = xMaxExtent - width; - const minX = xMinExtent; - - // Decreasing straight line equation. - // y = -mx + b - const b = 1 + fadePx * minX; - const leftOpc = Math.max(-fadePx * xTranslation + b, 0); - - // Increasing straight line equation. - // y = mx + b - const b2 = 1 - fadePx * maxX; - const rightOpc = Math.max(fadePx * xTranslation + b2, 0); - - return ( - - - - - - - - - - - - ); -} diff --git a/app/scripts/components/common/dateslider/index.tsx b/app/scripts/components/common/dateslider/index.tsx deleted file mode 100644 index 76f151a24..000000000 --- a/app/scripts/components/common/dateslider/index.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import styled from 'styled-components'; -import { debounce } from 'lodash'; -import { interpolate, scaleLinear, select, zoom } from 'd3'; -import { themeVal } from '@devseed-ui/theme-provider'; - -import { findDate, getZoomTranslateExtent, useChartDimensions } from './utils'; -import { DataPoints, DataLine } from './data-points'; -import TriggerRect from './trigger-rect'; -import { FaderDefinition, MASK_ID } from './faders'; -import { DateAxis, DateAxisParent } from './date-axis'; -import { - DATA_POINT_WIDTH, - DateSliderDataItem, - DateSliderTimeDensity, - RANGE_PADDING -} from './constants'; - -import { useEffectPrevious } from '$utils/use-effect-previous'; -import useReducedMotion from '$utils/use-prefers-reduced-motion'; - -const StyledSvg = styled.svg` - display: block; - width: 100%; - font-family: ${themeVal('type.base.family')}; -`; - -interface DateSliderControlProps { - id?: string; - data: DateSliderDataItem[]; - value?: Date; - timeDensity: DateSliderTimeDensity; - onChange: (value: { date: Date }) => void; -} - -export default function DateSliderControl(props: DateSliderControlProps) { - const { id, data, value, timeDensity, onChange } = props; - const { observe, width, height, outerWidth, outerHeight, margin } = - useChartDimensions(); - const svgRef = useRef(null); - const [hoveringDataPoint, setHoveringDataPoint] = useState(null); - - // Unique id creator - const getUID = useMemo(() => { - const rand = `ts-${Math.random().toString(36).substring(2, 8)}`; - return (base) => `${id ?? rand}-${base}`; - }, [id]); - - const x = useMemo(() => { - const dataWidth = data.length * DATA_POINT_WIDTH; - return scaleLinear() - .domain([data[0].index, data.last.index]) - .range([RANGE_PADDING, Math.max(dataWidth, width) - RANGE_PADDING]); - }, [data, width]); - - const [zoomXTranslation, setZoomXTranslation] = useState(0); - const zoomBehavior = useMemo( - () => - zoom() - .translateExtent(getZoomTranslateExtent(data, x)) - // Remove the zoom interpolation so it doesn't zoom back and forth. - .interpolate(interpolate) - .on('zoom', (event) => { - setZoomXTranslation(event.transform.x); - }), - [data, x] - ); - - const onDataOverOut = useCallback(({ hover, date }) => { - setHoveringDataPoint(date || null); - - if (svgRef.current) { - svgRef.current.style.cursor = hover ? 'pointer' : 'ew-resize'; - } - }, []); - - // Limit the data that is rendered so that performance is not hindered by very - // large datasets. - // Lower and Upper pixel bounds of the data that should be rendered taking the - // horizontal drag window into account. - const minX = zoomXTranslation * -1; - const maxX = width - zoomXTranslation; - // Using the scale, get the indices of the data that would be rendered. - const indices = [ - Math.max(Math.floor(x.invert(minX)), 0), - // Add one to account for when there's a single data point. - Math.ceil(x.invert(maxX)) + 1 - ]; - - const dataToRender = useMemo( - () => data.slice(...indices), - [data, ...indices] - ); - - // Recenter the slider to the selected date when data changes or when the - // chart gets resized. - const item = findDate(data, value, timeDensity); - useRecenterSlider({ value: item?.index, width, x, zoomBehavior, svgRef }); - - return ( -
    - - - - - - - - - - - - - - -
    - ); -} - -function useRecenterSlider({ value, width, x, zoomBehavior, svgRef }) { - const reduceMotion = useReducedMotion(); - - // The recenter function must be debounced because if it is triggered while - // another animation is already running, the X translation extent gets messed - // up. Debouncing a function with React is tricky. Since the function must - // access "fresh" parameters it is defined through a ref and then invoked in - // the debounced function created through useMemo. - - const recenterFnRef = useRef<() => void>(); - recenterFnRef.current = () => { - if (isNaN(value)) return; - - select(svgRef.current) - .select('.trigger-rect') - .transition() - .duration(500) - .call(zoomBehavior.translateTo, x(value), 0); - }; - - const debouncedRecenter = useMemo( - () => - debounce(() => { - recenterFnRef.current?.(); - }, 400), - [] - ); - - useEffectPrevious( - (deps, mounted) => { - if (isNaN(value) || !mounted) return; - - // No animation if reduce motion. - if (reduceMotion) { - zoomBehavior.translateTo( - select(svgRef.current).select('.trigger-rect'), - x(value), - 0 - ); - return; - } - - debouncedRecenter(); - - return () => { - debouncedRecenter.cancel(); - }; - }, - [value, width, x, zoomBehavior] - ); -} diff --git a/app/scripts/components/common/dateslider/trigger-rect.tsx b/app/scripts/components/common/dateslider/trigger-rect.tsx deleted file mode 100644 index d82af253d..000000000 --- a/app/scripts/components/common/dateslider/trigger-rect.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { ScaleLinear, select, ZoomBehavior } from 'd3'; - -import { DateSliderDataItem } from './constants'; - -interface TriggerRectProps { - onDataOverOut: (result: { hover: boolean; date: Date | null }) => void; - onDataClick: (result: { date: Date }) => void; - data: DateSliderDataItem[]; - x: ScaleLinear; - zoomXTranslation: number; - width: number; - height: number; - zoomBehavior: ZoomBehavior; -} - -interface Point { - x: number; - y: number; -} - -interface HotZone extends Point { - date: Date; - radius: number; -} - -function dist(p1: Point, p2: Point) { - return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); -} - -function getHotZone(zones: HotZone[], mouse) { - return zones.find( - (z) => dist(z, { x: mouse.layerX, y: mouse.layerY }) <= z.radius - ); -} - -export default function TriggerRect(props: TriggerRectProps) { - const { - onDataOverOut, - onDataClick, - width, - height, - data, - x, - zoomXTranslation, - zoomBehavior - } = props; - - const elRef = useRef(null); - const hoverDataRef = useRef(false); - - // Since we're applying the zoom behavior on an invisible trigger rect, it - // swallows all mouse events. To be able to handle a hover state or even a - // click on a data point we create a list of coordinates for where the data - // is, and then check if a mouse event is within a given radius of them. - const dataHotZones = useMemo(() => { - const dataList = data.filter((d) => d.hasData); - return dataList.map((d) => ({ - date: d.date, - x: x(d.index) + zoomXTranslation, - y: 12, - radius: 8 - })); - }, [data, x, zoomXTranslation]); - - useEffect(() => { - select(elRef.current) - .call(zoomBehavior) - .on('dblclick.zoom', null) - .on('wheel.zoom', (event) => { - event.preventDefault(); - zoomBehavior.translateBy(select(event.target), event.wheelDelta, 0); - }); - }, [zoomBehavior, x]); - - useEffect(() => { - select(elRef.current) - .on('click', (event) => { - const zone = getHotZone(dataHotZones, event); - if (zone) { - onDataClick({ date: zone.date }); - } - }) - .on('mousemove', (event) => { - const zone = getHotZone(dataHotZones, event); - const currHover = !!zone; - const prevHover = hoverDataRef.current; - - if (currHover !== prevHover) { - hoverDataRef.current = currHover; - onDataOverOut({ - hover: currHover, - date: currHover ? zone.date : null - }); - } - }) - .on('mouseout', () => { - onDataOverOut({ - hover: false, - date: null - }); - }); - }, [dataHotZones, onDataClick, onDataOverOut]); - - return ( - - ); -} diff --git a/app/scripts/components/common/dateslider/utils.tsx b/app/scripts/components/common/dateslider/utils.tsx deleted file mode 100644 index 21b650545..000000000 --- a/app/scripts/components/common/dateslider/utils.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import useDimensions from 'react-cool-dimensions'; -import { extent } from 'd3'; -import { - eachDayOfInterval, - eachMonthOfInterval, - eachYearOfInterval, - format -} from 'date-fns'; - -import { - CHART_HEIGHT, - DateSliderDataItem, - DateSliderTimeDensity -} from './constants'; - -const margin = { top: 0, right: 0, bottom: 0, left: 0 }; - -export function useChartDimensions() { - const { observe, width: elementWidth } = useDimensions(); - - const outerWidth = elementWidth > 0 ? elementWidth : 100; - const outerHeight = CHART_HEIGHT - margin.top - margin.bottom; - - return { - observe, - // Dimensions of the wrapper. - outerWidth, - outerHeight, - // Dimensions of the visualization. - width: outerWidth - margin.left - margin.right, - height: outerHeight - margin.top - margin.bottom, - margin - }; -} - -const dateFormatByTimeDensity = { - day: 'yyyy-MM-dd', - month: 'yyyy-MM', - year: 'yyyy' -}; - -/** - * Creates and array of date entries based on the extent of the provided list of - * dates, and the time density. - * For example, if the time density is month it will return a list of monthly - * dates between the min and max of the given list. - * For each date in the extent interval we check if there is data for that date. - * If there is we set the `hasData` property to true and continue. If there is - * no data, we start grouping the dates with no data. - * - If the group exceeds the threshold we group them in an entry with 'hasData' - * set to false and 'breakLength' set to the length of the group. - * - If it doesn't exceed we keep all dates with 'hasData' set to false. - * - * The resulting array of objects has an 'index' property added to each object, - * starting from 0 which is needed to correctly position the elements after a - * .filter operation where we can no longer rely on the array index. - * - * @param dates List of dates that have data - * @param timeDensity Time density of the dates. One of day | month | year - * @returns Data for the date slider - */ -export function prepareDateSliderData( - dates: Date[], - timeDensity: DateSliderTimeDensity -) { - const domain = extent(dates, (d) => d) as Date[]; - - const dateFormat = dateFormatByTimeDensity[timeDensity]; - - const intervalFn = { - day: eachDayOfInterval, - month: eachMonthOfInterval, - year: eachYearOfInterval - }[timeDensity]; - - const searchStrs = dates.map((d) => format(d, dateFormat)); - - const allDates = intervalFn({ - start: domain[0], - end: domain.last - }); - - let data: Omit[] = []; - let noDataGroup: Omit[] = []; - - for (const date of allDates) { - const hasData = searchStrs.includes(format(date, dateFormat)); - - if (!hasData) { - noDataGroup = noDataGroup.concat({ date, hasData }); - } else { - // If we find a date with data and it has been less than 20 entries - // without data we keep them all. - if (noDataGroup.length < 20) { - data = data.concat(noDataGroup, { date, hasData }); - } else { - // Otherwise we add a break group with the first date that doesn't have - // data and store how many timesteps don't have data (breakLength) - data = data.concat( - { - date: noDataGroup[0].date, - hasData: false, - breakLength: noDataGroup.length - }, - { date, hasData } - ); - } - noDataGroup = []; - } - } - - // Add an index property which is needed to correctly position the elements - // after a .filter operation where we can no longer rely on the array index. - return data.map((d, i) => ({ ...d, index: i })); -} - -export function getZoomTranslateExtent( - data, - xScale -): [[number, number], [number, number]] { - // The translation extent should always at least encompass the full chart. - // This handle problems coming from having only 1 data point. - const end = Math.max(xScale(data.last.index), xScale.range()[1]); - return [ - [0, 0], - [end + 16, 0] - ]; -} - -export function findDate( - data: DateSliderDataItem[], - date: Date | undefined | null, - timeDensity: DateSliderTimeDensity -) { - if (!date) return undefined; - - const dateFormat = dateFormatByTimeDensity[timeDensity]; - const item = data.find( - (d) => format(d.date, dateFormat) === format(date, dateFormat) - ); - - return item; -} diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index 32f0f7eaa..745c64bcc 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -4,15 +4,15 @@ import { Map as MapboxMap } from 'mapbox-gl'; import { MapRef } from 'react-map-gl'; import startOfDay from 'date-fns/startOfDay'; import endOfDay from 'date-fns/endOfDay'; -import { Feature, MultiPolygon, Polygon } from 'geojson'; import { BBox } from '@turf/helpers'; +import { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; +import combine from '@turf/combine'; import { StacFeature } from './types'; import { DatasetDatumFn, DatasetDatumFnResolverBag, DatasetDatumReturnType } from '$types/veda'; -import { TimeDensity } from '$context/layer-data'; import { userTzDate2utcString } from '$utils/date'; import { validateRangeNum } from '$utils/utils'; import { @@ -20,9 +20,12 @@ import { DatasetData, VizDataset } from '$components/exploration/types.d.ts'; +import { fixAntimeridian } from '$utils/antimeridian'; export const FIT_BOUNDS_PADDING = 32; +export type TimeDensity = 'day' | 'month' | 'year' | null; + export const validateLon = validateRangeNum(-180, 180); export const validateLat = validateRangeNum(-90, 90); @@ -290,3 +293,84 @@ export function reconcileVizDataset(dataset: DatasetData): VizDataset { } }; } + +/** + * Converts a Feature Collection of polygons into a MultiPolygon + * + * @param featureCollection Feature Collection of Polygons + * + * @see multiPolygonToPolygons() for opposite + * + * @returns MultiPolygon Feature + */ +export function combineFeatureCollection( + featureCollection: FeatureCollection +): Feature { + const combined = combine(featureCollection); + return { + type: 'Feature', + properties: {}, + geometry: combined.features[0].geometry as MultiPolygon + }; +} + +/** + * Fixes the AOI feature collection for a STAC search by converting all polygons + * to a single multipolygon and ensuring that every polygon is inside the + * -180/180 range. + * @param aoi The AOI feature collection + * @returns AOI as a multipolygon with every polygon inside the -180/180 range + */ +export function fixAoiFcForStacSearch(aoi: FeatureCollection) { + // Stac search spatial intersect needs to be done on a single feature. + // Using a Multipolygon + const singleMultiPolygon = combineFeatureCollection(aoi); + // And every polygon must be inside the -180/180 range. + // See: https://github.com/NASA-IMPACT/veda-ui/issues/732 + const aoiMultiPolygon = fixAntimeridian(singleMultiPolygon); + return aoiMultiPolygon; +} + +/** + * Creates the appropriate filter object to send to STAC. + * + * @param {Date} start Start date to request + * @param {Date} end End date to request + * @param {string} collection STAC collection to request + * @returns Object + */ +export function getFilterPayloadWithAOI( + start: Date, + end: Date, + aoi: FeatureCollection, + collections: string[] +) { + const aoiMultiPolygon = fixAoiFcForStacSearch(aoi); + + const filterPayload = { + op: 'and', + args: [ + { + op: 't_intersects', + args: [ + { property: 'datetime' }, + { + interval: [ + userTzDate2utcString(startOfDay(start)), + userTzDate2utcString(endOfDay(end)) + ] + } + ] + }, + { + op: 's_intersects', + args: [{ property: 'geometry' }, aoiMultiPolygon.geometry] + }, + { + op: 'in', + args: [{ property: 'collection' }, collections] + } + ] + }; + return filterPayload; +} diff --git a/app/scripts/components/common/mapbox/README.md b/app/scripts/components/common/mapbox/README.md deleted file mode 100644 index 941d4294b..000000000 --- a/app/scripts/components/common/mapbox/README.md +++ /dev/null @@ -1,4 +0,0 @@ -> :warning: **This component was deprecated** - -The `/mapbox` component has been used for the previous Explorer view of the dashboard. We had a different requirement for exploring the datasets at that time so this component was written in a way that only one additional layer was allowed on top of the basemap. -As the dashboard moves towards [the new E&A page](https://www.earthdata.nasa.gov/dashboard/exploration) with multiple dataset layers, this component was deprecated in favor of the `/map` component. diff --git a/app/scripts/components/common/mapbox/aoi/mb-aoi-draw.d.ts b/app/scripts/components/common/mapbox/aoi/mb-aoi-draw.d.ts deleted file mode 100644 index 777fe58fc..000000000 --- a/app/scripts/components/common/mapbox/aoi/mb-aoi-draw.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MutableRefObject } from 'react'; -import { Map as MapboxMap } from 'mapbox-gl'; -import { - DefaultTheme, - FlattenInterpolation, - ThemeProps -} from 'styled-components'; -import { AoiChangeListener, AoiState } from '$components/common/aoi/types'; - -export const aoiCursorStyles: FlattenInterpolation>; - -type useMbDrawParams = { - mapRef: MutableRefObject; - theme: DefaultTheme; - onChange?: AoiChangeListener; -} & Partial>; - -export const useMbDraw: (params: useMbDrawParams) => void; diff --git a/app/scripts/components/common/mapbox/aoi/mb-aoi-draw.js b/app/scripts/components/common/mapbox/aoi/mb-aoi-draw.js deleted file mode 100644 index 0e191e72e..000000000 --- a/app/scripts/components/common/mapbox/aoi/mb-aoi-draw.js +++ /dev/null @@ -1,123 +0,0 @@ -import { useEffect, useRef } from 'react'; -import MapboxDraw from '@mapbox/mapbox-gl-draw'; -import { css } from 'styled-components'; - -import { computeDrawStyles } from './style'; - -export const aoiCursorStyles = css` - &.mouse-add .mapboxgl-canvas-container { - cursor: crosshair; - } - &.mouse-pointer .mapboxgl-canvas-container { - cursor: pointer; - } - &.mouse-move .mapboxgl-canvas-container { - cursor: move; - } -`; - -export function useMbDraw({ - mapRef, - theme, - onChange, - drawing, - featureCollection -}) { - const mbDrawRef = useRef(); - - useEffect(() => { - const mbMap = mapRef.current; - if (!mbMap || !onChange || !mbMap._interactive) return; - - const newMbDraw = new MapboxDraw({ - modes: MapboxDraw.modes, - displayControlsDefault: false, - styles: computeDrawStyles(theme) - }); - - mbDrawRef.current = newMbDraw; - - mbMap.addControl(newMbDraw, 'top-left'); - - // Store control for later retrieval and imperative method use. - mbMap._drawControl = newMbDraw; - - const drawCreateListener = (e) => - onChange?.('aoi.draw-finish', { feature: e.features[0] }); - - const drawSelectionListener = (e) => { - const mode = newMbDraw.getMode(); - const features = e.features; - const points = e.points; - - // A feature is only selected if in simple_select mode. When a feature is - // selected with direct_select mode we don't count it because it can't be - // deleted. This is how the plugin works. Go figure. - const isSelected = - points.length || (features.length && mode === 'simple_select'); - - onChange?.('aoi.selection', { - selected: isSelected, - context: isSelected - ? { - features, - points - } - : undefined - }); - }; - - const drawUpdateListener = (e) => { - // If the user deletes points from a polygon leaving it with just 2 - // points, it is no longer a polygon and the coordinates array will be - // empty. In this case don't emit the update event as mbDraw will emit a - // delete event right after. - e.features[0].geometry.coordinates.length && - onChange?.('aoi.update', { feature: e.features[0] }); - }; - - const drawModeListener = (e) => - e.mode === 'simple_select' && - onChange?.('aoi.selection', { selected: false }); - - const drawDeleteListener = (e) => - onChange?.('aoi.delete', { ids: e.features.map((f) => f.id) }); - - mbMap - .on('draw.create', drawCreateListener) - .on('draw.selectionchange', drawSelectionListener) - .on('draw.modechange', drawModeListener) - .on('draw.delete', drawDeleteListener) - .on('draw.update', drawUpdateListener); - - return () => { - if (!mbMap || !newMbDraw) return; - - mbMap - .off('draw.create', drawCreateListener) - .off('draw.selectionchange', drawSelectionListener) - .off('draw.update', drawUpdateListener); - - mbMap.hasControl(newMbDraw) && mbMap.removeControl(newMbDraw); - }; - }, [mapRef, theme, onChange]); - - // Set / delete the feature. - useEffect(() => { - const mbDraw = mbDrawRef.current; - if (!mbDraw) return; - - if (featureCollection) { - mbDraw.set(featureCollection); - } else { - mbDraw.deleteAll(); - } - }, [featureCollection]); - - // Start/stop the drawing. - useEffect(() => { - const mbDraw = mbDrawRef.current; - if (!mbDraw) return; - mbDraw.changeMode(drawing ? 'draw_polygon' : 'simple_select'); - }, [drawing]); -} diff --git a/app/scripts/components/common/mapbox/aoi/mb-draw-popover.tsx b/app/scripts/components/common/mapbox/aoi/mb-draw-popover.tsx deleted file mode 100644 index 77515d49b..000000000 --- a/app/scripts/components/common/mapbox/aoi/mb-draw-popover.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { MutableRefObject, useMemo } from 'react'; -import { Map as MapboxMap } from 'mapbox-gl'; -import styled from 'styled-components'; -import centroid from '@turf/centroid'; -import { CollecticonTrashBin } from '@devseed-ui/collecticons'; -import { Button } from '@devseed-ui/button'; - -import ReactPopoverGl from '../mb-popover'; -import { AoiChangeListener, AoiState } from '$components/common/aoi/types'; -import { makeFeatureCollection } from '$components/common/aoi/utils'; - -const ActionPopoverInner = styled.div` - padding: 0.25rem; -`; - -interface MbDrawPopoverProps { - mapRef: MutableRefObject; - onChange?: AoiChangeListener; - selectedContext: AoiState['selectedContext']; -} - -function getCenterCoords(selectedContext) { - if (!selectedContext) return null; - - const items = selectedContext.points.length - ? selectedContext.points - : selectedContext.features; - - const centerPoint = centroid(makeFeatureCollection(items)); - - return centerPoint.geometry.coordinates as [number, number]; -} - -export default function MbDrawPopover(props: MbDrawPopoverProps) { - const { mapRef, onChange, selectedContext } = props; - - const lngLat = useMemo( - () => getCenterCoords(selectedContext), - [selectedContext] - ); - - if (!mapRef.current || !onChange) return null; - - return ( - { - return ( - - - - ); - }} - /> - ); -} diff --git a/app/scripts/components/common/mapbox/aoi/style.ts b/app/scripts/components/common/mapbox/aoi/style.ts deleted file mode 100644 index 9a44d6c3f..000000000 --- a/app/scripts/components/common/mapbox/aoi/style.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { DefaultTheme } from 'styled-components'; - -export const computeDrawStyles = (theme: DefaultTheme) => [ - { - id: 'gl-draw-polygon-fill-inactive', - type: 'fill', - filter: [ - 'all', - ['==', 'active', 'false'], - ['==', '$type', 'Polygon'], - ['!=', 'mode', 'static'] - ], - paint: { - 'fill-color': theme.color?.primary, - 'fill-outline-color': theme.color?.primary, - 'fill-opacity': 0.16 - } - }, - { - id: 'gl-draw-polygon-stroke-inactive', - type: 'line', - filter: [ - 'all', - ['==', 'active', 'false'], - ['==', '$type', 'Polygon'], - ['!=', 'mode', 'static'] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, - paint: { - 'line-color': theme.color?.primary, - 'line-width': 2 - } - }, - { - id: 'gl-draw-polygon-fill-active', - type: 'fill', - filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], - paint: { - 'fill-color': theme.color?.primary, - 'fill-outline-color': theme.color?.primary, - 'fill-opacity': 0.16 - } - }, - { - id: 'gl-draw-polygon-stroke-active', - type: 'line', - filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, - paint: { - 'line-color': theme.color?.primary, - 'line-dasharray': [0.64, 2], - 'line-width': 2 - } - }, - { - id: 'gl-draw-line-active', - type: 'line', - filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']], - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, - paint: { - 'line-color': theme.color?.primary, - 'line-dasharray': [0.64, 2], - 'line-width': 2 - } - }, - { - id: 'gl-draw-polygon-and-line-vertex-stroke-inactive', - type: 'circle', - filter: [ - 'all', - ['==', 'meta', 'vertex'], - ['==', '$type', 'Point'], - ['!=', 'mode', 'static'] - ], - paint: { - 'circle-radius': 6, - 'circle-color': '#fff' - } - }, - { - id: 'gl-draw-polygon-and-line-vertex-inactive', - type: 'circle', - filter: [ - 'all', - ['==', 'meta', 'vertex'], - ['==', '$type', 'Point'], - ['!=', 'mode', 'static'] - ], - paint: { - 'circle-radius': 4, - 'circle-color': theme.color?.primary - } - }, - { - id: 'gl-draw-point-stroke-active', - type: 'circle', - filter: [ - 'all', - ['==', '$type', 'Point'], - ['==', 'active', 'true'], - ['!=', 'meta', 'midpoint'] - ], - paint: { - 'circle-radius': 8, - 'circle-color': '#fff' - } - }, - { - id: 'gl-draw-point-active', - type: 'circle', - filter: [ - 'all', - ['==', '$type', 'Point'], - ['!=', 'meta', 'midpoint'], - ['==', 'active', 'true'] - ], - paint: { - 'circle-radius': 6, - 'circle-color': theme.color?.primary - } - }, - { - id: 'gl-draw-polygon-midpoint', - type: 'circle', - filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], - paint: { - 'circle-radius': 3, - 'circle-color': '#fff' - } - } -]; diff --git a/app/scripts/components/common/mapbox/index.tsx b/app/scripts/components/common/mapbox/index.tsx deleted file mode 100644 index 83cdace64..000000000 --- a/app/scripts/components/common/mapbox/index.tsx +++ /dev/null @@ -1,577 +0,0 @@ -import React, { - MutableRefObject, - ReactNode, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState -} from 'react'; -import styled from 'styled-components'; -import { Map as MapboxMap, MapboxOptions } from 'mapbox-gl'; -import 'mapbox-gl/dist/mapbox-gl.css'; -import CompareMbGL from 'mapbox-gl-compare'; -import 'mapbox-gl-compare/dist/mapbox-gl-compare.css'; -// Avoid error: node_modules/date-fns/esm/index.js does not export 'default' -import * as dateFns from 'date-fns'; -import { - CollecticonCircleXmark, - CollecticonChevronRightSmall, - CollecticonChevronLeftSmall, - iconDataURI -} from '@devseed-ui/collecticons'; -import { themeVal } from '@devseed-ui/theme-provider'; -import { DatasetDatumFnResolverBag, ProjectionOptions, datasets } from 'veda'; - -import { AoiChangeListenerOverload, AoiState } from '../aoi/types'; -import MapMessage from '../map/map-message'; -import { LayerLegendContainer, LayerLegend } from '../map/layer-legend'; -import { getLayerComponent, resolveConfigFunctions } from './layers/utils'; -import { SimpleMap } from './map'; -import { useBasemap } from './map-options/use-basemap'; -import { BasemapId, DEFAULT_MAP_STYLE_URL } from './map-options/basemaps'; -import { ExtendedStyle, Styles } from './layers/styles'; -import { Basemap } from './layers/basemap'; -import { formatCompareDate, formatSingleDate } from './utils'; -import { MapLoading } from '$components/common/loading-skeleton'; -import { useDatasetAsyncLayer } from '$context/layer-data'; -import { - ActionStatus, - S_FAILED, - S_IDLE, - S_LOADING, - S_SUCCEEDED -} from '$utils/status'; -import { calcFeatCollArea } from '$components/common/aoi/utils'; - -// @DEPRECATED: This file is to be DELETED as we are moving to use the map components under "/common/map" - -const chevronRightURI = () => - iconDataURI(CollecticonChevronRightSmall, { - color: 'white' - }); - -const chevronLeftURI = () => - iconDataURI(CollecticonChevronLeftSmall, { - color: 'white' - }); - -const MapsContainer = styled.div` - position: relative; - - .mapboxgl-compare .compare-swiper-vertical { - background: ${themeVal('color.primary')}; - display: flex; - align-items: center; - justify-content: center; - - &::before, - &::after { - display: inline-block; - content: ''; - background-repeat: no-repeat; - background-size: 1rem 1rem; - width: 1rem; - height: 1rem; - } - - &::before { - background-image: url('${chevronLeftURI()}'); - } - &::after { - background-image: url('${chevronRightURI()}'); - } - } -`; - -const mapOptions: Partial = { - style: DEFAULT_MAP_STYLE_URL, - logoPosition: 'bottom-left', - trackResize: true, - pitchWithRotate: false, - dragRotate: false, - zoom: 1 -}; - -const getMapPositionOptions = (position) => { - const opts = {} as Pick; - if (position?.lng !== undefined && position?.lat !== undefined) { - opts.center = [position.lng, position.lat]; - } - - if (position?.zoom) { - opts.zoom = position.zoom; - } - - return opts; -}; - -function MapboxMapComponent( - props: MapboxMapProps, - ref: MutableRefObject -) { - /* eslint-disable react/prop-types */ - const { - className, - id, - as, - datasetId, - layerId, - date, - compareDate, - compareLabel, - isComparing, - cooperativeGestures, - onPositionChange, - initialPosition, - withGeocoder, - withScale, - aoi, - onAoiChange, - projection, - onProjectionChange, - basemapStyleId, - onBasemapStyleIdChange, - isDatasetLayerHidden, - onStyleChange - } = props; - /* eslint-enable react/prop-types */ - - const mapContainer = useRef(null); - const mapRef = useRef(null); - - const mapCompareContainer = useRef(null); - const mapCompareRef = useRef(null); - - const [isMapLoaded, setMapLoaded] = useState(false); - const [isMapCompareLoaded, setMapCompareLoaded] = useState(false); - - const { labelsOption, boundariesOption, onOptionChange } = useBasemap(); - - // This baseLayerStatus is for BaseLayerComponent - // ex. RasterTimeSeries uses this variable to track the status of - // registering mosaic. - const [baseLayerStatus, setBaseLayerStatus] = useState(S_IDLE); - - const onBaseLayerStatusChange = useCallback( - ({ status }) => setBaseLayerStatus(status), - [] - ); - const [compareLayerStatus, setCompareLayerStatus] = - useState(S_IDLE); - const onCompareLayerStatusChange = useCallback( - (status) => setCompareLayerStatus(status), - [] - ); - - // Add ref control operations to allow map to be controlled by the parent. - useImperativeHandle(ref, () => ({ - resize: () => { - mapRef.current?.resize(); - mapCompareRef.current?.resize(); - }, - instance: mapRef.current, - compareInstance: mapCompareRef.current - })); - - const { baseLayer, compareLayer } = useDatasetAsyncLayer(datasets, datasetId, layerId); - - const shouldRenderCompare = isMapLoaded && isComparing; - - // Compare control - useEffect(() => { - if (!isMapLoaded || !isComparing || !isMapCompareLoaded) return; - - const compareControl = new CompareMbGL( - mapRef.current, - mapCompareRef.current, - `#${id ?? 'mapbox-container'}`, - { - mousemove: false, - orientation: 'vertical' - } - ); - - return () => { - compareControl.remove(); - }; - }, [id, isComparing, isMapCompareLoaded, isMapLoaded]); - - // Some properties defined in the dataset layer config may be functions that - // need to be resolved before rendering them. These functions accept data to - // return the correct value. - const resolverBag = useMemo( - () => ({ datetime: date, compareDatetime: compareDate, dateFns }), - [date, compareDate] - ); - - // Resolve data needed for the base layer once the layer is loaded - const [baseLayerResolvedData, BaseLayerComponent] = useMemo(() => { - if (baseLayer?.status !== S_SUCCEEDED || !baseLayer.data) - return [null, null]; - - // Include access to raw data. - const bag = { ...resolverBag, raw: baseLayer.data }; - const data = resolveConfigFunctions(baseLayer.data, bag); - - return [data, getLayerComponent(!!data.timeseries, data.type)]; - }, [baseLayer, resolverBag]); - - // Resolve data needed for the compare layer once it is loaded. - const [compareLayerResolvedData, CompareLayerComponent] = useMemo(() => { - if (compareLayer?.status !== S_SUCCEEDED || !compareLayer.data) - return [null, null]; - - // Include access to raw data. - const bag = { ...resolverBag, raw: compareLayer.data }; - const data = resolveConfigFunctions(compareLayer.data, bag); - - return [data, getLayerComponent(!!data.timeseries, data.type)]; - }, [compareLayer, resolverBag]); - - // Get the compare to date. - // The compare date is specified by the user. - // If no date is specified anywhere we just use the same. - const compareToDate = useMemo(() => { - const theDate = compareDate ?? date; - return theDate instanceof Date && !isNaN(theDate.getTime()) - ? theDate - : null; - }, [compareDate, date]); - - const baseTimeDensity = baseLayerResolvedData?.timeseries.timeDensity; - const compareTimeDensity = compareLayerResolvedData?.timeseries.timeDensity; - - const computedCompareLabel = useMemo(() => { - // Use a provided label if it exist. - const providedLabel = compareLabel ?? compareLayerResolvedData?.mapLabel; - if (providedLabel) return providedLabel as string; - - // Default to date comparison. - return date && compareToDate - ? formatCompareDate( - date, - compareToDate, - baseTimeDensity, - compareTimeDensity - ) - : null; - }, [ - compareLabel, - compareLayerResolvedData?.mapLabel, - date, - compareToDate, - baseTimeDensity, - compareTimeDensity - ]); - return ( - <> - {/* - Normally we only need 1 loading which is centered. If we're comparing we - need to render a loading for each layer, but instead of centering them, - we show them on top of their respective map. - */} - {baseLayerStatus === S_LOADING && ( - - )} - {shouldRenderCompare && compareLayerStatus === S_LOADING && ( - - )} - - {/* - Normally we only need 1 error which is centered. If we're comparing we - need to render an error for each layer, but instead of centering them, - we show them on top of their respective map. - */} - - Failed to load layer {baseLayer?.data?.id} - - - Failed to load compare layer{' '} - {compareLayer?.data?.id} - - - {/* - Map overlay element - Message shown when the map is is not in compare mode. It displays the - date being visualized if there is one. - */} - - {date && - formatSingleDate(date, baseLayerResolvedData?.timeseries.timeDensity)} - - - {/* - Map overlay element - Message shown when the map is in compare mode to indicate what's - being compared. - If the user provided an override value (compareLabel), use that. - */} - - {computedCompareLabel} - - - {/* - Map overlay element - Message shown when the aoi is being used. The message shown depends on - what is being done to the AOI. - - No area defined - - Drawing area shape - - XXkm2 - */} - -

    - {aoi?.drawing ? ( - 'Drawing area shape' - ) : !aoi?.featureCollection?.features.length ? ( - 'No area defined' - ) : ( - <> - {calcFeatCollArea(aoi.featureCollection)} km2 area - - )} -

    -
    - - {/* - Map overlay element - Layer legend for the active layer. - */} - {baseLayerResolvedData?.legend && ( - - - {compareLayerResolvedData?.legend && - isComparing && - baseLayerResolvedData.id !== compareLayerResolvedData.id && ( - - )} - - )} - - {/* - Maps container - */} - - - {/* - Each layer type is added to the map through a component. This component - has all the logic needed to add/update/remove the layer. - Which component to use will depend on the characteristics of the layer - and dataset. - The function getLayerComponent() should be used to get the correct - component. - */} - - {mapRef.current && - isMapLoaded && - baseLayerResolvedData && - BaseLayerComponent && ( - - )} - setMapLoaded(true)} - onMoveEnd={onPositionChange} - mapOptions={{ - ...mapOptions, - ...getMapPositionOptions(initialPosition), - cooperativeGestures - }} - withGeocoder={withGeocoder} - withScale={withScale} - aoi={aoi} - onAoiChange={onAoiChange} - projection={projection} - onProjectionChange={onProjectionChange} - basemapStyleId={basemapStyleId} - onBasemapStyleIdChange={onBasemapStyleIdChange} - labelsOption={labelsOption} - boundariesOption={boundariesOption} - onOptionChange={onOptionChange} - /> - - - {shouldRenderCompare && ( - - {/* - Adding a layer to the comparison map is also done through a component, - which is this case targets a different map instance. - */} - - {mapCompareRef.current && - isMapCompareLoaded && - compareLayerResolvedData && - CompareLayerComponent && ( - - )} - setMapCompareLoaded(true)} - onUnmount={() => setMapCompareLoaded(false)} - mapOptions={{ - ...mapOptions, - cooperativeGestures, - center: mapRef.current?.getCenter(), - zoom: mapRef.current?.getZoom() - }} - withGeocoder={withGeocoder} - withScale={withScale} - aoi={aoi} - onAoiChange={onAoiChange} - projection={projection} - onProjectionChange={onProjectionChange} - basemapStyleId={basemapStyleId} - onBasemapStyleIdChange={onBasemapStyleIdChange} - labelsOption={labelsOption} - boundariesOption={boundariesOption} - onOptionChange={onOptionChange} - /> - - )} - - - ); -} - -interface MapPosition { - lng: number; - lat: number; - zoom: number; -} - -export interface MapboxMapProps { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - as?: any; - className?: string; - id?: string; - datasetId?: string; - layerId?: string; - date?: Date; - compareDate?: Date; - compareLabel?: string; - isComparing?: boolean; - cooperativeGestures?: boolean; - initialPosition?: Partial; - onPositionChange?: ( - result: MapPosition & { - userInitiated: boolean; - } - ) => void; - withGeocoder?: boolean; - withScale?: boolean; - children?: ReactNode; - aoi?: AoiState; - onAoiChange?: AoiChangeListenerOverload; - projection?: ProjectionOptions; - onProjectionChange?: (projection: ProjectionOptions) => void; - basemapStyleId?: BasemapId; - onBasemapStyleIdChange?: (id: BasemapId) => void; - isDatasetLayerHidden?: boolean; - onStyleChange?: (style: ExtendedStyle) => void; -} - -export interface MapboxMapRef { - resize: () => void; - instance: MapboxMap | null; - compareInstance: MapboxMap | null; -} - -const MapboxMapComponentFwd = forwardRef( - MapboxMapComponent -); - -/** - * Mapbox map component - * - * @param {string} id Id to apply to the map wrapper. - * Defaults to mapbox-container - * @param {string} className Css class for styling - */ -export default styled(MapboxMapComponentFwd)` - /* Convert to styled-component: https://styled-components.com/docs/advanced#caveat */ -`; diff --git a/app/scripts/components/common/mapbox/layers/basemap.tsx b/app/scripts/components/common/mapbox/layers/basemap.tsx deleted file mode 100644 index 952f6be36..000000000 --- a/app/scripts/components/common/mapbox/layers/basemap.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { AnySourceImpl, Layer, Style } from 'mapbox-gl'; -import { useEffect, useState } from 'react'; -import { - BasemapId, - BASEMAP_STYLES, - getStyleUrl, - GROUPS_BY_OPTION -} from '../map-options/basemaps'; -import { ExtendedLayer, useMapStyle } from './styles'; - -interface BasemapProps { - basemapStyleId?: BasemapId; - labelsOption?: boolean; - boundariesOption?: boolean; -} - -function mapGroupNameToGroupId( - groupNames: string[], - mapboxGroups: Record -) { - const groupsAsArray = Object.entries(mapboxGroups); - - return groupNames.map((groupName) => { - return groupsAsArray.find(([, group]) => group.name === groupName)?.[0]; - }); -} - -export function Basemap({ - basemapStyleId = 'satellite', - labelsOption = true, - boundariesOption = true -}: BasemapProps) { - const { updateStyle } = useMapStyle(); - - const [baseStyle, setBaseStyle] = useState