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"