diff --git a/app/scripts/components/common/loading-skeleton.tsx b/app/scripts/components/common/loading-skeleton.tsx index fc3e4a46d..06ace02e1 100644 --- a/app/scripts/components/common/loading-skeleton.tsx +++ b/app/scripts/components/common/loading-skeleton.tsx @@ -15,7 +15,7 @@ const pulse = keyframes` } `; -const pulsingAnimation = css` +export const pulsingAnimation = css` animation: ${pulse} 0.8s ease 0s infinite alternate; `; diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index 2991eba2a..3b624ba54 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -13,12 +13,20 @@ export enum TimeDensity { DAY = 'day' } +export enum TimelineDatasetStatus { + IDLE = 'idle', + LOADING = 'loading', + SUCCEEDED = 'succeeded', + ERRORED = 'errored' +} + export interface TimelineDataset { - status: 'idle' | 'loading' | 'succeeded' | 'errored'; + status: TimelineDatasetStatus; data: any; error: any; settings: { // user defined settings like visibility, opacity + isVisible?: boolean; }; } diff --git a/app/scripts/components/exploration/dataset-list-item-status.tsx b/app/scripts/components/exploration/dataset-list-item-status.tsx new file mode 100644 index 000000000..d63260a80 --- /dev/null +++ b/app/scripts/components/exploration/dataset-list-item-status.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import styled from 'styled-components'; +import { themeVal } from '@devseed-ui/theme-provider'; + +import { DATASET_TRACK_BLOCK_HEIGHT } from './constants'; + +import { pulsingAnimation } from '$components/common/loading-skeleton'; + +const loadingPattern = '.-.. --- .- -.. .. -. --.' + .split(' ') + .map((c) => c.split('')); + +const errorPattern = '. .-. .-. --- .-. . -..' + .split(' ') + .map((c) => c.split('')); + +const Track = styled.div` + display: flex; + gap: 1.5rem; + margin: auto; + padding: 0 1rem; +`; + +const Item = styled.div<{ code: string }>` + height: ${DATASET_TRACK_BLOCK_HEIGHT}px; + width: ${({ code }) => (code === '.' ? '1rem' : '2rem')}; + border-radius: 4px; +`; + +const TrackBlock = styled.div` + display: flex; + gap: 0.25rem; +`; + +const TrackLoading = styled(Track)` + ${pulsingAnimation} + + ${Item} { + background: ${themeVal('color.base-200')}; + } +`; + +const TrackError = styled(Track)` + ${Item} { + background: ${themeVal('color.danger-200')}; + } +`; + +export function DatasetTrackLoading() { + /* eslint-disable react/no-array-index-key */ + return ( + + {loadingPattern.map((letter, i) => ( + + {letter.map((s, i2) => ( + + ))} + + ))} + + ); + /* eslint-enable react/no-array-index-key */ +} + +export function DatasetTrackError() { + /* eslint-disable react/no-array-index-key */ + return ( + + {errorPattern.map((letter, i) => ( + + {letter.map((s, i2) => ( + + ))} + + ))} + + ); + /* eslint-enable react/no-array-index-key */ +} diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index 5bfccc3a7..1ca4c5841 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { Reorder, useDragControls } from 'framer-motion'; import styled, { useTheme } from 'styled-components'; import { @@ -14,6 +15,7 @@ import { } from 'date-fns'; import { ScaleTime } from 'd3'; import { + CollecticonArrowSpinCw, CollecticonEye, CollecticonEyeDisabled, CollecticonGripVertical @@ -26,8 +28,14 @@ import { DATASET_TRACK_BLOCK_HEIGHT, HEADER_COLUMN_WIDTH, TimeDensity, - TimelineDataset + TimelineDataset, + TimelineDatasetStatus } from './constants'; +import { useTimelineDatasetAtom, useTimelineDatasetVisibility } from './hooks'; +import { + DatasetTrackError, + DatasetTrackLoading +} from './dataset-list-item-status'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -103,21 +111,27 @@ const DatasetData = styled.div` padding: ${glsp(0.25, 0)}; display: flex; align-items: center; + flex-grow: 1; `; interface DatasetListItemProps { - dataset: TimelineDataset; + datasetId: string; width: number; xScaled: ScaleTime; } export function DatasetListItem(props: DatasetListItemProps) { - const { dataset, width, xScaled } = props; + const { datasetId, width, xScaled } = props; - const [isVisible, setVisible] = useState(true); + const datasetAtom = useTimelineDatasetAtom(datasetId); + const dataset = useAtomValue(datasetAtom); + + const [isVisible, setVisible] = useTimelineDatasetVisibility(datasetAtom); const controls = useDragControls(); + const isError = dataset.status === TimelineDatasetStatus.ERRORED; + return ( @@ -125,23 +139,36 @@ export function DatasetListItem(props: DatasetListItemProps) { controls.start(e)} /> - + {dataset.data.title} - setVisible((v) => !v)}> - {isVisible ? ( - setVisible((v) => !v)}> + {isVisible ? ( + + ) : ( + + )} + + ) : ( + + - ) : ( - - )} - + + )} - + {dataset.status === TimelineDatasetStatus.LOADING && ( + + )} + {isError && } + {dataset.status === TimelineDatasetStatus.SUCCEEDED && ( + + )} diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index 91403925f..d7eebeb54 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -5,9 +5,10 @@ import styled from 'styled-components'; import { Button } from '@devseed-ui/button'; import { timelineDatasetsAtom } from './atoms'; -import { TimelineDataset } from './constants'; +import { TimelineDataset, TimelineDatasetStatus } from './constants'; const extraDataset = { + id: 'infinity', title: 'Daily infinity!', timeDensity: 'day', domain: eachDayOfInterval({ @@ -18,6 +19,7 @@ const extraDataset = { const datasets = [ { + id: 'monthly', title: 'Monthly dataset', timeDensity: 'month', domain: [ @@ -29,6 +31,7 @@ const datasets = [ ] }, { + id: 'daily', title: 'Daily dataset', timeDensity: 'day', domain: [ @@ -96,6 +99,7 @@ const datasets = [ ] }, { + id: 'daily2', title: 'Daily 2', timeDensity: 'day', domain: [ @@ -107,6 +111,7 @@ const datasets = [ ] }, { + id: 'daily3', title: 'Daily 3', timeDensity: 'day', domain: eachDayOfInterval({ @@ -114,19 +119,38 @@ const datasets = [ end: new Date('2021-01-01') }) } -].map(makeDataset); +].map((d) => makeDataset(d)); -function makeDataset(data): TimelineDataset { +function makeDataset( + data, + status = TimelineDatasetStatus.SUCCEEDED, + settings: Record = {} +): TimelineDataset { return { - status: 'succeeded', + status, data, error: null, - settings: {} + settings: { + ...settings, + isVisible: settings.isVisible === undefined ? true : settings.isVisible + } + }; +} + +function toggleDataset(dataset) { + return (d) => { + if (d.find((dd) => dd.data.id === dataset.data.id)) { + return d.filter((dd) => dd.data.id !== dataset.data.id); + } + return [...d, dataset]; }; } const MockPanel = styled.div` + display: flex; + flex-direction: row wrap; padding: 1rem; + gap: 1rem; `; export function MockControls() { @@ -134,22 +158,56 @@ export function MockControls() { return ( + + + ); } diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 4098e1787..510f8e2c6 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { extent, scaleTime } from 'd3'; -import { useAtomValue } from 'jotai'; +import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; +import { focusAtom } from 'jotai-optics'; import { differenceInCalendarDays } from 'date-fns'; import { @@ -9,6 +10,7 @@ import { zoomTransformAtom } from './atoms'; import { rescaleX } from './utils'; +import { TimelineDataset } from './constants'; /** * Calculates the date domain of the datasets, if any are selected. @@ -77,3 +79,36 @@ export function useScales() { // In the first run the scaled and main scales are the same. return { main, scaled: scaled ?? main }; } + +/** + * Creates a focus atom for a dataset with the given id. + * + * @param id The dataset id for which to create the atom. + * @returns Focus atom for the dataset with the given id. + */ +export function useTimelineDatasetAtom(id: string) { + const datasetAtom = useMemo(() => { + return focusAtom(timelineDatasetsAtom, (optic) => + optic.find((d) => d.data.id === id) + ); + }, [id]); + + return datasetAtom as PrimitiveAtom; +} + +/** + * Hook to get/set the visibility of a dataset. + * @param datasetAtom Single dataset atom. + * @returns State getter/setter for the dataset visibility. + */ +export function useTimelineDatasetVisibility( + datasetAtom: PrimitiveAtom +) { + const visibilityAtom = useMemo(() => { + return focusAtom(datasetAtom, (optic) => + optic.prop('settings').prop('isVisible') + ); + }, [datasetAtom]); + + return useAtom(visibilityAtom); +} diff --git a/app/scripts/components/exploration/timeline-controls.tsx b/app/scripts/components/exploration/timeline-controls.tsx new file mode 100644 index 000000000..bca10ca84 --- /dev/null +++ b/app/scripts/components/exploration/timeline-controls.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import styled from 'styled-components'; +import { format } from 'date-fns'; +import { ScaleTime } from 'd3'; + +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; +import { Button } from '@devseed-ui/button'; +import { DatePicker } from '@devseed-ui/date-picker'; + +import { DateAxis } from './date-axis'; +import { DateRange, emptyDateRange } from './constants'; + +const TimelineControlsSelf = styled.div` + width: 100%; + display: flex; + flex-flow: column; + min-width: 0; + + .date-axis { + margin-top: auto; + } +`; + +const ControlsToolbar = styled.div` + display: flex; + justify-content: space-between; + padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; +`; + +const DatePickerButton = styled(Button)` + gap: ${glsp(0.5)}; + + .head-reference { + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; + font-size: 0.875rem; + } +`; + +interface TimelineControlsProps { + selectedDay: Date | null; + selectedInterval: DateRange | null; + xScaled: ScaleTime; + width: number; + onDayChange: (d: Date) => void; + onIntervalChange: (d: DateRange) => void; +} + +export function TimelineControls(props: TimelineControlsProps) { + const { + selectedDay, + selectedInterval, + xScaled, + width, + onDayChange, + onIntervalChange + } = props; + + return ( + + + { + onDayChange(d.start!); + }} + renderTriggerElement={(props, label) => ( + + P + {label} + + + )} + /> + { + onIntervalChange({ + start: d.start!, + end: d.end! + }); + }} + isClearable={false} + isRange + alignment='right' + renderTriggerElement={(props) => ( + + L + + {selectedInterval + ? format(selectedInterval.start, 'MMM do, yyyy') + : 'Date'} + + R + + {selectedInterval + ? format(selectedInterval.end, 'MMM do, yyyy') + : 'Date'} + + + + )} + /> + + + + + ); +} diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index a09c33344..3c8ae0e1a 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -1,18 +1,14 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useAtomValue, useSetAtom, useAtom } from 'jotai'; import styled from 'styled-components'; import useDimensions from 'react-cool-dimensions'; import { Reorder } from 'framer-motion'; import { ZoomTransform, select, zoom } from 'd3'; -import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; +import { add, isAfter, isBefore, startOfDay, sub } from 'date-fns'; import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { - CollecticonChevronDownSmall, - CollecticonPlusSmall -} from '@devseed-ui/collecticons'; +import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; import { Heading } from '@devseed-ui/typography'; -import { DatePicker } from '@devseed-ui/date-picker'; import { selectedDateAtom, @@ -29,8 +25,9 @@ import { TimelineHeadR, TimelineRangeTrack } from './timeline-head'; -import { DateAxis, DateGrid } from './date-axis'; -import { emptyDateRange, RIGHT_AXIS_SPACE } from './constants'; +import { TimelineControls } from './timeline-controls'; +import { DateGrid } from './date-axis'; +import { RIGHT_AXIS_SPACE } from './constants'; import { applyTransform, isEqualTransform, rescaleX } from './utils'; import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; @@ -90,33 +87,6 @@ const Headline = styled.div` align-items: center; `; -const TimelineControls = styled.div` - width: 100%; - display: flex; - flex-flow: column; - min-width: 0; - - .date-axis { - margin-top: auto; - } -`; - -const ControlsToolbar = styled.div` - display: flex; - justify-content: space-between; - padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; -`; - -const DatePickerButton = styled(Button)` - gap: ${glsp(0.5)}; - - .head-reference { - font-weight: ${themeVal('type.base.regular')}; - color: ${themeVal('color.base-400')}; - font-size: 0.875rem; - } -`; - const TimelineContent = styled.div` height: 100%; min-height: 0; @@ -140,7 +110,7 @@ const DatasetListSelf = styled.ul` width: 100%; `; -function Timeline() { +export default function Timeline() { // Refs for non react based interactions. // The interaction rect is used to capture the different d3 events for the // zoom. @@ -149,7 +119,7 @@ function Timeline() { // container to propagate the needed events to it, like scroll. const datasetsContainerRef = useRef(null); - const datasets = useAtomValue(timelineDatasetsAtom); + const [datasets, setDatasets] = useAtom(timelineDatasetsAtom); const dataDomain = useTimelineDatasetsDomain(); @@ -328,56 +298,14 @@ function Timeline() {

X of Y

- - - { - setSelectedDay(d.start); - }} - renderTriggerElement={(props, label) => ( - - P - {label} - - - )} - /> - { - setSelectedInterval({ - start: d.start!, - end: d.end! - }); - }} - isClearable={false} - isRange - alignment='right' - renderTriggerElement={(props) => ( - - L - - {selectedInterval - ? format(selectedInterval.start, 'MMM do, yyyy') - : 'Date'} - - R - - {selectedInterval - ? format(selectedInterval.end, 'MMM do, yyyy') - : 'Date'} - - - - )} - /> - - - - + {selectedDay ? ( @@ -434,39 +362,23 @@ function Timeline() { - + + {datasets.map((dataset) => ( + + ))} + ); } - -export default Timeline; - -function DatasetList(props: any) { - const { datasets, ...rest } = props; - - const [orderedDatasets, setOrderDatasets] = useState(datasets); - - useEffect(() => { - setOrderDatasets(datasets); - }, [datasets]); - - return ( - - {orderedDatasets.map((dataset) => ( - - ))} - - ); -} diff --git a/package.json b/package.json index aee284bf6..1468ce5c7 100644 --- a/package.json +++ b/package.json @@ -138,11 +138,13 @@ "intersection-observer": "^0.12.0", "jest-environment-jsdom": "^28.1.3", "jotai": "^2.2.3", + "jotai-optics": "^0.3.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mapbox-gl": "^2.11.0", "mapbox-gl-compare": "^0.4.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", + "optics-ts": "^2.4.1", "papaparse": "^5.3.2", "polished": "^4.1.3", "prop-types": "^15.7.2", diff --git a/yarn.lock b/yarn.lock index a968fd63f..5503683f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8088,6 +8088,11 @@ jest@^28.1.3: import-local "^3.0.2" jest-cli "^28.1.3" +jotai-optics@^0.3.1: + version "0.3.1" + resolved "http://verdaccio.ds.io:4873/jotai-optics/-/jotai-optics-0.3.1.tgz#7ff38470551429460cc41d9cd1320193665354e0" + integrity sha512-KibUx9IneM2hGWGIYGs/v0KCxU985lg7W2c6dt5RodJCB2XPbmok8rkkLmdVk9+fKsn2shkPMi+AG8XzHgB3+w== + jotai@^2.2.3: version "2.2.3" resolved "http://verdaccio.ds.io:4873/jotai/-/jotai-2.2.3.tgz#4dd9f429e9cd23d81f08a6b1492931db05ccf79f" @@ -9760,6 +9765,11 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +optics-ts@^2.4.1: + version "2.4.1" + resolved "http://verdaccio.ds.io:4873/optics-ts/-/optics-ts-2.4.1.tgz#de94bda2b0ed7fde5b7631283031b9699459d40d" + integrity sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ== + optionator@^0.8.1: version "0.8.3" resolved "http://verdaccio.ds.io:4873/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"