diff --git a/app/scripts/components/exploration/atoms/README.md b/app/scripts/components/exploration/atoms/README.md new file mode 100644 index 000000000..ccc649cf4 --- /dev/null +++ b/app/scripts/components/exploration/atoms/README.md @@ -0,0 +1,82 @@ +# Atom values and url + +Jotai and its atoms are used to store the state for the exploration page. +Since this page's state needs to be shareable between users, some of the state needs to end up in the url, which is achieved using jotai-location. + +Its usage is, in principle, simple: We create an atom that communicates with the url, so every time a value is set, the url is updated and vice versa. + +### Problem 1 - URL update limits +The browser has a limit to how many updates can be made to the url in a given amount of time. When this limit is reached an error is thrown that crashes the app. + +This was resolved by creating an atom that holds the same value as the url but without updating it. In this way we can make all the updates we want. We then debounce the url update so it is not frequent enough to reach the limit. + +See the code in [./url.ts](./url.ts). + +### Problem 2 - URL as source of truth +Using the url as a source of truth means that all data that goes to the url is converted to and from string. I.e. every time we want to read a value we parse the url string value. + +Example for a simple value +```ts +const valueAtom = atom( + (get) => { + const value = get(urlAtom).searchParams?.get('value'); + return value; + }, + (get, set, updates) => { + set(urlAtom, updates); + } +); +``` + +This is not a problem for most values, but it is for objects because the resulting value will always be a new object, even if the source string is the same. + +Example for an object +```ts +const valueAtom = atom( + (get) => { + const value = get(urlAtom).searchParams?.get('value'); + return value ? JSON.parse(value) : null; + }, + (get, set, updates) => { + set(urlAtom, JSON.stringify(updates)); + } +); +``` + +The result is that these values cannot be used dependencies for hooks because they will always change. +For example, the selectedDate is converted to a ISO string when it goes to the url, and when we create a Date from the string it will be different. + +The solution used here was to create an atom that ensures url value stability at the same time that interfaces with with the url atom. +The code for the atom can be found in [./atom-with-url-value-stability.ts](./atom-with-url-value-stability.ts). + +This atom is still a little complex because it needs to do a number of things: +- To get the value: + - Get the value from the url + - Convert the value from string to the desired type (hydrate step) + - Reconcile the value from the url with the internal stored value. This is important for cases where the atom value is so complex that the information that goes to the url needs to be simplified, as with datasets (reconcile step) + - Check if the stored atom and the reconciled value are the same. (areEqual step) +- To store the value: + - Convert the value to string (dehydrate step) + - Update the url with the new value by writing to the url atom + - Update the internal stored value using the atom compare that only updates if the value is different (areEqual step) + +An example code to instantiate such atom would be: +```ts +const atom = atomWithUrlValueStability({ + initialValue: { a: 0 }, + urlParam: 'someKey', + hydrate: (serialized) => { + return JSON.parse(serialized); + }, + dehydrate: (value) => { + return JSON.stringify(value); + }, + reconcile: (urlValue, storageValue) => { + return someReconcileFunction(urlValue, storageValue); + }, + areEqual: (a, b) => { + return a.a === b.a; + }, +}); + +``` \ No newline at end of file diff --git a/app/scripts/components/exploration/atoms/analysis.ts b/app/scripts/components/exploration/atoms/analysis.ts new file mode 100644 index 000000000..f75449398 --- /dev/null +++ b/app/scripts/components/exploration/atoms/analysis.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; + +// Analysis controller. Stores high level state about the analysis process. +export const analysisControllerAtom = atom({ + isAnalyzing: false, + runIds: {} as Record, + isObsolete: false +}); diff --git a/app/scripts/components/exploration/atoms/atom-with-url-value-stability.ts b/app/scripts/components/exploration/atoms/atom-with-url-value-stability.ts new file mode 100644 index 000000000..73aa9f88e --- /dev/null +++ b/app/scripts/components/exploration/atoms/atom-with-url-value-stability.ts @@ -0,0 +1,122 @@ +import { atom, SetStateAction } from 'jotai'; +import { atomWithReducer } from 'jotai/utils'; +import { setUrlParam, urlAtom } from './url'; + +export function atomWithCompare( + initialValue: Value, + areEqual: (prev: Value, next: Value) => boolean +) { + return atomWithReducer(initialValue, (prev: Value, next: Value) => { + if (areEqual(prev, next)) { + return prev; + } + + return next; + }); +} + +function isEqual(prev, next) { + if (typeof next === 'object') { + const nextString = JSON.stringify(next); + const prevString = JSON.stringify(prev); + return prevString === nextString; + } + + return prev === next; +} + +/** + * Options for creating a stable atom with a value that is synced to a URL parameter. + * @template Value The type of the value being stored in the atom. + * @template ValueUrl The type of the value being that is dehydrated/hydrated to/from the URL. + */ +interface StableAtomOptions { + /** + * The initial value of the atom. + */ + initialValue: Value; + /** + * The name of the URL parameter to sync the atom value to. + */ + urlParam: string; + /** + * A function to convert the serialized URL parameter value to the atom value. + * @param serialized The serialized URL parameter value. + * @returns The deserialized atom value. + */ + hydrate: (serialized: string | null | undefined) => ValueUrl; + /** + * A function to convert the atom value to a serialized URL parameter value. + * @param value The atom value. + * @returns The serialized URL parameter value. + */ + dehydrate: (value: Value) => string; + /** + * An optional function to reconcile the URL parameter value with the atom + * value. This is important for cases where the atom value is so complex that + * the information that goes to the url needs to be simplified. + * @param urlValue The value stored in the URL parameter after hydration. + * @param storageValue The value stored in the atom. + * @returns The reconciled value. If the function is not provided the urlValue + * is considered the reconciled value. + */ + reconcile?: (urlValue: ValueUrl, storageValue: Value) => Value; + /** + * An optional function to compare two atom values for equality. + * @param prev The previous atom value. + * @param next The next atom value. + * @returns Whether the two values are equal. + */ + areEqual?: (prev: Value, next: Value) => boolean; +} + +export function atomWithUrlValueStability( + options: StableAtomOptions +) { + const { + initialValue, + urlParam, + hydrate, + dehydrate, + reconcile = (h) => h as unknown as T, + areEqual = isEqual + } = options; + // Store the value in an atom that only updates if the value is different. + const storage = atomWithCompare(initialValue, areEqual); + + const stableAtom = atom( + (get) => { + // Get value from the url according to the urlParam. + const serialized = get(urlAtom).searchParams?.get(urlParam); + // Hydrate the value from the url. + const hydrated = hydrate(serialized); + const storageValue = get(storage); + + // Reconcile the hydrated value with the storage value. + const reconciled = reconcile(hydrated, storageValue); + + // If the reconciled value is equal to the storage value, return the + // storage value to ensure equality. + return areEqual(storageValue, reconciled) + ? (storageValue as T) + : reconciled; + }, + (get, set, updates: SetStateAction) => { + // Since updates can be a function, we need to get the correct new value. + const newData = + typeof updates === 'function' + ? (updates as (prev: T) => T)(get(stableAtom)) + : updates; + + // Dehydrate the new value to a string for the url. + const dehydrated = dehydrate(newData); + // The url atom will take care of debouncing the url updates. + set(urlAtom, setUrlParam(urlParam, dehydrated)); + + // Store value as provided by the user. + set(storage, newData); + } + ); + + return stableAtom; +} diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts deleted file mode 100644 index fe52dd9d1..000000000 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { atom } from 'jotai'; -import { atomWithLocation } from 'jotai-location'; - -import { HEADER_COLUMN_WIDTH, RIGHT_AXIS_SPACE } from '../constants'; -import { - datasetLayers, - reconcileDatasets, - urlDatasetsDehydrate, - urlDatasetsHydrate -} from '../data-utils'; -import { - DateRange, - TimelineDataset, - TimelineDatasetForUrl, - ZoomTransformPlain -} from '../types.d.ts'; - -// This is the atom acting as a single source of truth for the AOIs. -const locAtom = atomWithLocation(); - -const setUrlParam = (name: string, value: string) => (prev) => { - const searchParams = prev.searchParams ?? new URLSearchParams(); - searchParams.set(name, value); - - return { ...prev, searchParams }; -}; - -const getValidDateOrNull = (value: any) => { - if (!value) { - return null; - } - const date = new Date(value); - return isNaN(date.getTime()) ? null : date; -}; - -type ValueUpdater = T | ((prev: T) => T); - -// Dataset data that is serialized to the url. Only the data needed to -// reconstruct the dataset (and user interaction data like settings) is stored -// in the url, otherwise it would be too long. -const datasetsUrlConfig = atom( - (get): TimelineDatasetForUrl[] => { - try { - const serialized = get(locAtom).searchParams?.get('datasets') ?? '[]'; - return urlDatasetsHydrate(serialized); - } catch (error) { - return []; - } - }, - (get, set, datasets: TimelineDataset[]) => { - // Extract need properties from the datasets and encode them. - const encoded = urlDatasetsDehydrate(datasets); - set(locAtom, setUrlParam('datasets', encoded)); - } -); - -const timelineDatasetsStorageAtom = atom([]); - -// Datasets to show on the timeline and their settings. -export const timelineDatasetsAtom = atom( - (get) => { - const urlDatasets = get(datasetsUrlConfig); - const datasets = get(timelineDatasetsStorageAtom); - - // Reconcile what needs to be reconciled. - return urlDatasets.map((enc) => { - // We only want to do this on load. If the dataset was already - // initialized, skip. - // WARNING: This means that changing settings directly in the url without - // a page refresh will do nothing. - const readyDataset = datasets.find((d) => d.data.id === enc.id); - if (readyDataset) { - return readyDataset; - } - // Reconcile the dataset with the internal data (from VEDA config files) - // and then add the url stored settings. - const [reconciled] = reconcileDatasets([enc.id], datasetLayers, []); - if (enc.settings) { - reconciled.settings = enc.settings; - } - return reconciled; - }); - }, - (get, set, updates: ValueUpdater) => { - const newData = - typeof updates === 'function' - ? updates(get(timelineDatasetsStorageAtom)) - : updates; - - set(datasetsUrlConfig, newData); - set(timelineDatasetsStorageAtom, newData); - } -); - -// Main timeline date. This date defines the datasets shown on the map. -export const selectedDateAtom = atom( - (get) => { - const txtDate = get(locAtom).searchParams?.get('date'); - return getValidDateOrNull(txtDate); - }, - (get, set, updates: ValueUpdater) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedCompareDateAtom)) - : updates; - - set(locAtom, setUrlParam('date', newData?.toISOString() ?? '')); - } -); - -// Compare date. This is the compare date for the datasets shown on the map. -export const selectedCompareDateAtom = atom( - (get) => { - const txtDate = get(locAtom).searchParams?.get('dateCompare'); - return getValidDateOrNull(txtDate); - }, - (get, set, updates: ValueUpdater) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedCompareDateAtom)) - : updates; - - set(locAtom, setUrlParam('dateCompare', newData?.toISOString() ?? '')); - } -); - -// Date range for L&R playheads. -export const selectedIntervalAtom = atom( - (get) => { - const txtDate = get(locAtom).searchParams?.get('dateRange'); - const [start, end] = txtDate?.split('|') ?? []; - - const dateStart = getValidDateOrNull(start); - const dateEnd = getValidDateOrNull(end); - - if (!dateStart || !dateEnd) return null; - - return { start: dateStart, end: dateEnd }; - }, - (get, set, updates: ValueUpdater) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedIntervalAtom)) - : updates; - - const value = newData - ? `${newData.start.toISOString()}|${newData.end.toISOString()}` - : ''; - - set(locAtom, setUrlParam('dateRange', value)); - } -); - -// Zoom transform for the timeline. Values as object instead of d3.ZoomTransform -export const zoomTransformAtom = atom({ - x: 0, - y: 0, - k: 1 -}); - -// Width of the whole timeline item. Set via a size observer and then used to -// compute the different element sizes. -export const timelineWidthAtom = atom(undefined); - -// Derived atom with the different sizes of the timeline elements. -export const timelineSizesAtom = atom((get) => { - const totalWidth = get(timelineWidthAtom); - - return { - headerColumnWidth: HEADER_COLUMN_WIDTH, - rightAxisWidth: RIGHT_AXIS_SPACE, - contentWidth: Math.max( - 1, - (totalWidth ?? 0) - HEADER_COLUMN_WIDTH - RIGHT_AXIS_SPACE - ) - }; -}); - -// Whether or not the dataset rows are expanded. -export const isExpandedAtom = atom(false); - -// Analysis controller. Stores high level state about the analysis process. -export const analysisControllerAtom = atom({ - isAnalyzing: false, - runIds: {} as Record, - isObsolete: false -}); diff --git a/app/scripts/components/exploration/atoms/datasets.ts b/app/scripts/components/exploration/atoms/datasets.ts new file mode 100644 index 000000000..6ab7f420f --- /dev/null +++ b/app/scripts/components/exploration/atoms/datasets.ts @@ -0,0 +1,62 @@ +import { datasetLayers, reconcileDatasets } from '../data-utils'; +import { TimelineDataset, TimelineDatasetForUrl } from '../types.d.ts'; +import { atomWithUrlValueStability } from './atom-with-url-value-stability'; + +function urlDatasetsDehydrate(datasets: TimelineDataset[]) { + // Only keep what we need to reconstruct the dataset, which is the id and + // whatever the user has changed. + return JSON.stringify( + datasets.map((d) => ({ + id: d.data.id, + settings: d.settings + })) + ); +} + +function urlDatasetsHydrate( + encoded: string | null | undefined +): TimelineDatasetForUrl[] { + if (!encoded) return []; + const parsed = JSON.parse(encoded); + return parsed; +} + +export const timelineDatasetsAtom = atomWithUrlValueStability< + TimelineDataset[], + TimelineDatasetForUrl[] +>({ + initialValue: [], + urlParam: 'datasets', + hydrate: (serialized) => { + try { + return urlDatasetsHydrate(serialized ?? '[]'); + } catch (error) { + return []; + } + }, + dehydrate: (datasets) => { + return urlDatasetsDehydrate(datasets); + }, + reconcile: (urlDatasets, storageDatasets) => { + // Reconcile what needs to be reconciled. + const reconciledDatasets = urlDatasets.map((enc) => { + // We only want to do this on load. If the dataset was already + // initialized, skip. + // WARNING: This means that changing settings directly in the url without + // a page refresh will do nothing. + const readyDataset = storageDatasets.find((d) => d.data.id === enc.id); + if (readyDataset) { + return readyDataset; + } + // Reconcile the dataset with the internal data (from VEDA config files) + // and then add the url stored settings. + const [reconciled] = reconcileDatasets([enc.id], datasetLayers, []); + if (enc.settings) { + reconciled.settings = enc.settings; + } + return reconciled; + }); + + return reconciledDatasets; + } +}); diff --git a/app/scripts/components/exploration/atoms/dates.ts b/app/scripts/components/exploration/atoms/dates.ts new file mode 100644 index 000000000..3813304a7 --- /dev/null +++ b/app/scripts/components/exploration/atoms/dates.ts @@ -0,0 +1,58 @@ +import { DateRange } from '../types.d.ts'; +import { atomWithUrlValueStability } from './atom-with-url-value-stability'; + +const getValidDateOrNull = (value: any) => { + if (!value) { + return null; + } + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; +}; + +// Main timeline date. This date defines the datasets shown on the map. +export const selectedDateAtom = atomWithUrlValueStability({ + initialValue: null, + urlParam: 'date', + hydrate: (serialized) => { + return getValidDateOrNull(serialized); + }, + dehydrate: (date) => { + return date?.toISOString() ?? ''; + } +}); + +// Compare date. This is the compare date for the datasets shown on the map. +export const selectedCompareDateAtom = atomWithUrlValueStability({ + initialValue: null, + urlParam: 'dateCompare', + hydrate: (serialized) => { + return getValidDateOrNull(serialized); + }, + dehydrate: (date) => { + return date?.toISOString() ?? ''; + } +}); + +// Date range for L&R playheads. +export const selectedIntervalAtom = atomWithUrlValueStability( + { + initialValue: null, + urlParam: 'dateRange', + hydrate: (serialized) => { + const [start, end] = serialized?.split('|') ?? []; + + const dateStart = getValidDateOrNull(start); + const dateEnd = getValidDateOrNull(end); + + if (!dateStart || !dateEnd) return null; + + const range: DateRange = { start: dateStart, end: dateEnd }; + return range; + }, + dehydrate: (range) => { + return range + ? `${range.start.toISOString()}|${range.end.toISOString()}` + : ''; + } + } +); diff --git a/app/scripts/components/exploration/atoms/hooks.ts b/app/scripts/components/exploration/atoms/hooks.ts index 865f6e9c0..6b858efcd 100644 --- a/app/scripts/components/exploration/atoms/hooks.ts +++ b/app/scripts/components/exploration/atoms/hooks.ts @@ -11,7 +11,8 @@ import { TimelineDatasetStatus, TimelineDatasetSuccess } from '../types.d.ts'; -import { timelineDatasetsAtom, timelineSizesAtom } from './atoms'; +import { timelineSizesAtom } from './timeline'; +import { timelineDatasetsAtom } from './datasets'; function addDurationToDate(date, timeDensity: TimeDensity) { const duration = { diff --git a/app/scripts/components/exploration/atoms/timeline.ts b/app/scripts/components/exploration/atoms/timeline.ts new file mode 100644 index 000000000..e5a94bd75 --- /dev/null +++ b/app/scripts/components/exploration/atoms/timeline.ts @@ -0,0 +1,34 @@ +import { atom } from 'jotai'; + +import { HEADER_COLUMN_WIDTH, RIGHT_AXIS_SPACE } from '../constants'; +import { + ZoomTransformPlain +} from '../types.d.ts'; + +// Zoom transform for the timeline. Values as object instead of d3.ZoomTransform +export const zoomTransformAtom = atom({ + x: 0, + y: 0, + k: 1 +}); + +// Width of the whole timeline item. Set via a size observer and then used to +// compute the different element sizes. +export const timelineWidthAtom = atom(undefined); + +// Derived atom with the different sizes of the timeline elements. +export const timelineSizesAtom = atom((get) => { + const totalWidth = get(timelineWidthAtom); + + return { + headerColumnWidth: HEADER_COLUMN_WIDTH, + rightAxisWidth: RIGHT_AXIS_SPACE, + contentWidth: Math.max( + 1, + (totalWidth ?? 0) - HEADER_COLUMN_WIDTH - RIGHT_AXIS_SPACE + ) + }; +}); + +// Whether or not the dataset rows are expanded. +export const isExpandedAtom = atom(false); diff --git a/app/scripts/components/exploration/atoms/url.ts b/app/scripts/components/exploration/atoms/url.ts new file mode 100644 index 000000000..6ce569ff2 --- /dev/null +++ b/app/scripts/components/exploration/atoms/url.ts @@ -0,0 +1,69 @@ +import { atom } from 'jotai'; +import { atomWithLocation } from 'jotai-location'; +import { debounce } from 'lodash'; + +interface JotaiLocation { + pathname?: string; + searchParams?: URLSearchParams; +} + +function atomWithDebouncedLocation() { + // The locAtom is used to store the data in the url. However it can't be used as + // the source of truth because there is a limit to how many url changes can be + // made in a specific amount of time. (This is a browser restriction). + // The solution is to have a local location storage atom (locStorageAtom) that + // can have all the updates we want. + const locAtom = atomWithLocation(); + const locStorageAtom = atom(null); + // The locAtomDebounced is the read/write atom that then updates the other two. + // It updates the locStorageAtom immediately and the locAtom after a debounce. + // In summary: + // The data is set in locAtomDebounced which updates locStorageAtom and after + // a debounce it updates locAtom. + let setDebounced; + const urlAtom = atom( + (get): JotaiLocation => { + return get(locStorageAtom) ?? get(locAtom); + }, + (get, set, updates) => { + const newData = + typeof updates === 'function' ? updates(get(urlAtom)) : updates; + + if (!setDebounced) { + setDebounced = debounce(set, 320); + } + + setDebounced(locAtom, newData); + set(locStorageAtom, newData); + } + ); + + return urlAtom; +} + + +export const urlAtom = atomWithDebouncedLocation(); + +/** + * Creates an updater function that sets a given value for the search params of + * a location. This is to be used to write to a location atom, so we can update + * a given search parameter without deleting the others. + * + * @code + * ``` + * const locAtom = atomWithLocation(); + * const setter = useSetAtom(locAtom); + * setter(setUrlParam('page', '2')); + * ``` + * + * @param name Name of the url parameter + * @param value Value for the parameter + */ +export function setUrlParam(name: string, value: string) { + return (prev) => { + const searchParams = prev.searchParams ?? new URLSearchParams(); + searchParams.set(name, value); + + return { ...prev, searchParams }; + }; +} diff --git a/app/scripts/components/exploration/components/chart-popover.tsx b/app/scripts/components/exploration/components/chart-popover.tsx index aabcb4c27..38b4d43e0 100644 --- a/app/scripts/components/exploration/components/chart-popover.tsx +++ b/app/scripts/components/exploration/components/chart-popover.tsx @@ -19,7 +19,7 @@ import { format } from 'date-fns'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { AnalysisTimeseriesEntry, TimeDensity } from '../types.d.ts'; -import { isExpandedAtom } from '../atoms/atoms'; +import { isExpandedAtom } from '../atoms/timeline'; import { DataMetric } from './datasets/analysis-metrics'; import { getNumForChart } from '$components/common/chart/utils'; diff --git a/app/scripts/components/exploration/components/dataset-selector-modal.tsx b/app/scripts/components/exploration/components/dataset-selector-modal.tsx index f74d5d354..6de35e8a1 100644 --- a/app/scripts/components/exploration/components/dataset-selector-modal.tsx +++ b/app/scripts/components/exploration/components/dataset-selector-modal.tsx @@ -19,7 +19,7 @@ import { iconDataURI } from '@devseed-ui/collecticons'; -import { timelineDatasetsAtom } from '../atoms/atoms'; +import { timelineDatasetsAtom } from '../atoms/datasets'; import { allDatasets, datasetLayers, diff --git a/app/scripts/components/exploration/components/datasets/dataset-chart.tsx b/app/scripts/components/exploration/components/datasets/dataset-chart.tsx index 1bb1b3361..cfb89f1f2 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-chart.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-chart.tsx @@ -4,7 +4,7 @@ import { extent, scaleLinear, ScaleTime, line, ScaleLinear } from 'd3'; import { useAtomValue } from 'jotai'; import { AnimatePresence, motion } from 'framer-motion'; -import { isExpandedAtom } from '../../atoms/atoms'; +import { isExpandedAtom } from '../../atoms/timeline'; import { RIGHT_AXIS_SPACE } from '../../constants'; import { DatasetTrackMessage } from './dataset-track-message'; import { DataMetric } from './analysis-metrics'; diff --git a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index 51e25b051..2332400a7 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -44,7 +44,7 @@ import { useTimelineDatasetAtom, useTimelineDatasetVisibility } from '$components/exploration/atoms/hooks'; -import { analysisControllerAtom } from '$components/exploration/atoms/atoms'; +import { analysisControllerAtom } from '$components/exploration/atoms/analysis'; import { useAnalysisController, useAnalysisDataRequest diff --git a/app/scripts/components/exploration/components/datasets/dataset-list.tsx b/app/scripts/components/exploration/components/datasets/dataset-list.tsx index 7ac9b282e..b0c7b538d 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list.tsx @@ -6,7 +6,7 @@ import styled from 'styled-components'; import { listReset } from '@devseed-ui/theme-provider'; import { DatasetListItem } from './dataset-list-item'; -import { timelineDatasetsAtom } from '$components/exploration/atoms/atoms'; +import { timelineDatasetsAtom } from '$components/exploration/atoms/datasets'; const DatasetListSelf = styled.ul` ${listReset()} diff --git a/app/scripts/components/exploration/components/datasets/dataset-options.tsx b/app/scripts/components/exploration/components/datasets/dataset-options.tsx index 905fabc3f..d69cfaeac 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-options.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-options.tsx @@ -15,7 +15,7 @@ import { SliderInput, SliderInputProps } from '$styles/range-slider'; import { composeVisuallyDisabled } from '$utils/utils'; import { Tip } from '$components/common/tip'; import { TimelineDataset } from '$components/exploration/types.d.ts'; -import { timelineDatasetsAtom } from '$components/exploration/atoms/atoms'; +import { timelineDatasetsAtom } from '$components/exploration/atoms/datasets'; import { useTimelineDatasetSettings } from '$components/exploration/atoms/hooks'; const RemoveButton = composeVisuallyDisabled(DropMenuItemButton); diff --git a/app/scripts/components/exploration/components/map/analysis-message-control.tsx b/app/scripts/components/exploration/components/map/analysis-message-control.tsx index 2b7c15597..2d6ad3c21 100644 --- a/app/scripts/components/exploration/components/map/analysis-message-control.tsx +++ b/app/scripts/components/exploration/components/map/analysis-message-control.tsx @@ -12,7 +12,8 @@ import { CollecticonXmarkSmall } from '@devseed-ui/collecticons'; -import { selectedIntervalAtom, timelineDatasetsAtom } from '../../atoms/atoms'; +import { timelineDatasetsAtom } from '../../atoms/datasets'; +import { selectedIntervalAtom } from '../../atoms/dates'; import useAois from '$components/common/map/controls/hooks/use-aois'; import { calcFeatCollArea } from '$components/common/aoi/utils'; diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index dea90a63e..f5ab9165b 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -2,11 +2,8 @@ import React, { useState } from 'react'; import { useAtomValue } from 'jotai'; import { useStacMetadataOnDatasets } from '../../hooks/use-stac-metadata-datasets'; -import { - selectedCompareDateAtom, - selectedDateAtom, - timelineDatasetsAtom -} from '../../atoms/atoms'; +import { selectedCompareDateAtom, selectedDateAtom } from '../../atoms/dates'; +import { timelineDatasetsAtom } from '../../atoms/datasets'; import { TimelineDatasetStatus, TimelineDatasetSuccess diff --git a/app/scripts/components/exploration/components/timeline/timeline-controls.tsx b/app/scripts/components/exploration/components/timeline/timeline-controls.tsx index 0af9b24e1..595f44921 100644 --- a/app/scripts/components/exploration/components/timeline/timeline-controls.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline-controls.tsx @@ -22,14 +22,13 @@ import { } from '@devseed-ui/toolbar'; import { DateAxis } from './date-axis'; - +import { analysisControllerAtom } from '$components/exploration/atoms/analysis'; +import { isExpandedAtom } from '$components/exploration/atoms/timeline'; import { - analysisControllerAtom, - isExpandedAtom, selectedCompareDateAtom, selectedDateAtom, selectedIntervalAtom -} from '$components/exploration/atoms/atoms'; +} from '$components/exploration/atoms/dates'; import { emptyDateRange } from '$components/exploration/constants'; const TimelineControlsSelf = styled.div` diff --git a/app/scripts/components/exploration/components/timeline/timeline.tsx b/app/scripts/components/exploration/components/timeline/timeline.tsx index c4a46d1e7..935b9f5b9 100644 --- a/app/scripts/components/exploration/components/timeline/timeline.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline.tsx @@ -31,12 +31,14 @@ import { DateGrid } from './date-axis'; import { selectedCompareDateAtom, selectedDateAtom, - selectedIntervalAtom, - timelineDatasetsAtom, + selectedIntervalAtom +} from '$components/exploration/atoms/dates'; +import { timelineDatasetsAtom } from '$components/exploration/atoms/datasets'; +import { timelineSizesAtom, timelineWidthAtom, zoomTransformAtom -} from '$components/exploration/atoms/atoms'; +} from '$components/exploration/atoms/timeline'; import { useTimelineDatasetsDomain } from '$components/exploration/atoms/hooks'; import { RIGHT_AXIS_SPACE } from '$components/exploration/constants'; import { diff --git a/app/scripts/components/exploration/data-utils.ts b/app/scripts/components/exploration/data-utils.ts index 0ed3939fd..0415ca9ff 100644 --- a/app/scripts/components/exploration/data-utils.ts +++ b/app/scripts/components/exploration/data-utils.ts @@ -11,7 +11,6 @@ import { StacDatasetData, TimeDensity, TimelineDataset, - TimelineDatasetForUrl, TimelineDatasetStatus } from './types.d.ts'; import { @@ -151,23 +150,6 @@ export function getTimeDensityStartDate(date: Date, timeDensity: TimeDensity) { return startOfDay(date); } -export function urlDatasetsDehydrate(datasets: TimelineDataset[]) { - return JSON.stringify( - datasets.map((d) => ({ - id: d.data.id, - settings: d.settings - })) - ); -} - -export function urlDatasetsHydrate( - encoded: string | null | undefined -): TimelineDatasetForUrl[] { - if (!encoded) return []; - const parsed = JSON.parse(encoded); - return parsed; -} - export class ExtendedError extends Error { code: string; details?: any; @@ -176,4 +158,4 @@ export class ExtendedError extends Error { super(message); this.code = code; } -} \ No newline at end of file +} diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index ae9c6d1a9..a874d9638 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -5,10 +5,8 @@ import styled from 'styled-components'; import { themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; -import { - isExpandedAtom, - timelineDatasetsAtom -} from './atoms/atoms'; +import { isExpandedAtom } from './atoms/timeline'; +import { timelineDatasetsAtom } from './atoms/datasets'; import { TimelineDataset, TimelineDatasetAnalysis, @@ -608,7 +606,7 @@ export function MockControls({ onCompareClick, comparing }: any) { -
+
{isAnalyzing ? ( <> In Analysis (obsolete: {isObsolete.toString()}) diff --git a/app/scripts/components/exploration/hooks/scales-hooks.ts b/app/scripts/components/exploration/hooks/scales-hooks.ts index 012b34d0d..31811cb3d 100644 --- a/app/scripts/components/exploration/hooks/scales-hooks.ts +++ b/app/scripts/components/exploration/hooks/scales-hooks.ts @@ -3,7 +3,7 @@ import { scaleTime } from 'd3'; import { useAtomValue } from 'jotai'; import { differenceInCalendarDays } from 'date-fns'; -import { timelineSizesAtom, zoomTransformAtom } from '../atoms/atoms'; +import { timelineSizesAtom, zoomTransformAtom } from '../atoms/timeline'; import { DAY_SIZE_MAX } from '../constants'; import { useTimelineDatasetsDomain } from '../atoms/hooks'; import { rescaleX } from '../components/timeline/timeline-utils'; diff --git a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts index d7343f034..ad0c0a3e6 100644 --- a/app/scripts/components/exploration/hooks/use-analysis-data-request.ts +++ b/app/scripts/components/exploration/hooks/use-analysis-data-request.ts @@ -4,7 +4,8 @@ import { FeatureCollection, Polygon } from 'geojson'; import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; import { requestDatasetTimeseriesData } from '../analysis-data'; -import { analysisControllerAtom, selectedIntervalAtom } from '../atoms/atoms'; +import { analysisControllerAtom } from '../atoms/analysis'; +import { selectedIntervalAtom } from '../atoms/dates'; import { useTimelineDatasetAnalysis } from '../atoms/hooks'; import { analysisConcurrencyManager } from '../concurrency'; import { TimelineDataset, TimelineDatasetStatus } from '../types.d.ts'; @@ -46,7 +47,7 @@ export function useAnalysisController() { })), [] // eslint-disable-line react-hooks/exhaustive-deps -- setController is stable ), - getRunId: (id: string) => controller.runIds[id] ?? 0, + getRunId: (id: string) => controller.runIds[id] ?? 0 }; } @@ -84,7 +85,8 @@ export function useAnalysisDataRequest({ if ( datasetStatus !== TimelineDatasetStatus.SUCCESS || !selectedInterval || - !selectedFeatures.length + !selectedFeatures.length || + analysisRunId === 0 // Avoid running the analysis on the first render ) { return; } diff --git a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts index 6f7fba9e7..9f2bb5d91 100644 --- a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts +++ b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts @@ -6,7 +6,7 @@ import { import axios from 'axios'; import { useAtom } from 'jotai'; -import { timelineDatasetsAtom } from '../atoms/atoms'; +import { timelineDatasetsAtom } from '../atoms/datasets'; import { StacDatasetData, TimeDensity, diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index 07b874b4e..0670237a0 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -8,7 +8,7 @@ import { MockControls } from './datasets-mock'; import Timeline from './components/timeline/timeline'; import { ExplorationMap } from './components/map'; import { DatasetSelectorModal } from './components/dataset-selector-modal'; -import { timelineDatasetsAtom } from './atoms/atoms'; +import { timelineDatasetsAtom } from './atoms/datasets'; import { LayoutProps } from '$components/common/layout-root'; import PageHero from '$components/common/page-hero';