From 3f6ce6e1997d273a71c0f0933d19016793051949 Mon Sep 17 00:00:00 2001 From: Daniel da Silva <danielfdsilva@gmail.com> Date: Tue, 31 Oct 2023 15:31:20 +0000 Subject: [PATCH 1/3] Fix url update cadence --- .../components/exploration/atoms/atoms.ts | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index fe52dd9d1..079f8b2cf 100644 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ b/app/scripts/components/exploration/atoms/atoms.ts @@ -1,5 +1,6 @@ import { atom } from 'jotai'; import { atomWithLocation } from 'jotai-location'; +import { debounce } from 'lodash'; import { HEADER_COLUMN_WIDTH, RIGHT_AXIS_SPACE } from '../constants'; import { @@ -15,8 +16,40 @@ import { ZoomTransformPlain } from '../types.d.ts'; -// This is the atom acting as a single source of truth for the AOIs. +interface JotaiLocation { + pathname?: string; + searchParams?: URLSearchParams; +} + +// 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<JotaiLocation | null>(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 locAtomDebounced = atom( + (get): JotaiLocation => { + return get(locStorageAtom) ?? get(locAtom); + }, + (get, set, updates) => { + const newData = + typeof updates === 'function' ? updates(get(locAtomDebounced)) : updates; + + if (!setDebounced) { + setDebounced = debounce(set, 320); + } + + setDebounced(locAtom, newData); + set(locStorageAtom, newData); + } +); const setUrlParam = (name: string, value: string) => (prev) => { const searchParams = prev.searchParams ?? new URLSearchParams(); @@ -41,7 +74,8 @@ type ValueUpdater<T> = T | ((prev: T) => T); const datasetsUrlConfig = atom( (get): TimelineDatasetForUrl[] => { try { - const serialized = get(locAtom).searchParams?.get('datasets') ?? '[]'; + const serialized = + get(locAtomDebounced).searchParams?.get('datasets') ?? '[]'; return urlDatasetsHydrate(serialized); } catch (error) { return []; @@ -50,7 +84,7 @@ const datasetsUrlConfig = atom( (get, set, datasets: TimelineDataset[]) => { // Extract need properties from the datasets and encode them. const encoded = urlDatasetsDehydrate(datasets); - set(locAtom, setUrlParam('datasets', encoded)); + set(locAtomDebounced, setUrlParam('datasets', encoded)); } ); @@ -95,7 +129,7 @@ export const timelineDatasetsAtom = atom( // Main timeline date. This date defines the datasets shown on the map. export const selectedDateAtom = atom( (get) => { - const txtDate = get(locAtom).searchParams?.get('date'); + const txtDate = get(locAtomDebounced).searchParams?.get('date'); return getValidDateOrNull(txtDate); }, (get, set, updates: ValueUpdater<Date | null>) => { @@ -104,14 +138,14 @@ export const selectedDateAtom = atom( ? updates(get(selectedCompareDateAtom)) : updates; - set(locAtom, setUrlParam('date', newData?.toISOString() ?? '')); + set(locAtomDebounced, 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'); + const txtDate = get(locAtomDebounced).searchParams?.get('dateCompare'); return getValidDateOrNull(txtDate); }, (get, set, updates: ValueUpdater<Date | null>) => { @@ -120,14 +154,17 @@ export const selectedCompareDateAtom = atom( ? updates(get(selectedCompareDateAtom)) : updates; - set(locAtom, setUrlParam('dateCompare', newData?.toISOString() ?? '')); + set( + locAtomDebounced, + setUrlParam('dateCompare', newData?.toISOString() ?? '') + ); } ); // Date range for L&R playheads. export const selectedIntervalAtom = atom( (get) => { - const txtDate = get(locAtom).searchParams?.get('dateRange'); + const txtDate = get(locAtomDebounced).searchParams?.get('dateRange'); const [start, end] = txtDate?.split('|') ?? []; const dateStart = getValidDateOrNull(start); @@ -147,7 +184,7 @@ export const selectedIntervalAtom = atom( ? `${newData.start.toISOString()}|${newData.end.toISOString()}` : ''; - set(locAtom, setUrlParam('dateRange', value)); + set(locAtomDebounced, setUrlParam('dateRange', value)); } ); From 881fb4886b57a0b91c4b5ddfffeb14689ed67a02 Mon Sep 17 00:00:00 2001 From: Daniel da Silva <danielfdsilva@gmail.com> Date: Thu, 2 Nov 2023 19:14:39 +0000 Subject: [PATCH 2/3] Ensure that parsed values from location are stable --- .../components/exploration/atoms/README.md | 65 +++++ .../components/exploration/atoms/analysis.ts | 8 + .../components/exploration/atoms/atoms.ts | 224 ------------------ .../components/exploration/atoms/cache.ts | 27 +++ .../components/exploration/atoms/datasets.ts | 87 +++++++ .../components/exploration/atoms/dates.ts | 76 ++++++ .../components/exploration/atoms/hooks.ts | 3 +- .../components/exploration/atoms/timeline.ts | 34 +++ .../components/exploration/atoms/types.d.ts | 1 + .../components/exploration/atoms/url.ts | 62 +++++ .../exploration/components/chart-popover.tsx | 2 +- .../components/dataset-selector-modal.tsx | 2 +- .../components/datasets/dataset-chart.tsx | 2 +- .../components/datasets/dataset-list-item.tsx | 2 +- .../components/datasets/dataset-list.tsx | 2 +- .../components/datasets/dataset-options.tsx | 2 +- .../map/analysis-message-control.tsx | 3 +- .../exploration/components/map/index.tsx | 7 +- .../components/timeline/timeline-controls.tsx | 7 +- .../components/timeline/timeline.tsx | 8 +- .../components/exploration/data-utils.ts | 20 +- .../components/exploration/datasets-mock.tsx | 8 +- .../exploration/hooks/scales-hooks.ts | 2 +- .../hooks/use-analysis-data-request.ts | 8 +- .../hooks/use-stac-metadata-datasets.ts | 2 +- app/scripts/components/exploration/index.tsx | 2 +- 26 files changed, 392 insertions(+), 274 deletions(-) create mode 100644 app/scripts/components/exploration/atoms/README.md create mode 100644 app/scripts/components/exploration/atoms/analysis.ts delete mode 100644 app/scripts/components/exploration/atoms/atoms.ts create mode 100644 app/scripts/components/exploration/atoms/cache.ts create mode 100644 app/scripts/components/exploration/atoms/datasets.ts create mode 100644 app/scripts/components/exploration/atoms/dates.ts create mode 100644 app/scripts/components/exploration/atoms/timeline.ts create mode 100644 app/scripts/components/exploration/atoms/types.d.ts create mode 100644 app/scripts/components/exploration/atoms/url.ts diff --git a/app/scripts/components/exploration/atoms/README.md b/app/scripts/components/exploration/atoms/README.md new file mode 100644 index 000000000..7709a3d51 --- /dev/null +++ b/app/scripts/components/exploration/atoms/README.md @@ -0,0 +1,65 @@ +# 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 external cache that is checked before returning the value from the atom. This way we can return the same object if it is already in the cache. + +The previous example would become +```ts +const valueAtom = atom( + (get) => { + const value = get(urlAtom).searchParams?.get('value'); + const parsedValue = value ? JSON.parse(value) : null; + return getStableValue('value-key', parsedValue); + }, + (get, set, updates) => { + set(urlAtom, JSON.stringify(updates)); + } +); +``` + +The cache is pretty simple and it is in [./cache.ts](./cache.ts). \ 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<string, number | undefined>, + isObsolete: false +}); diff --git a/app/scripts/components/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts deleted file mode 100644 index 079f8b2cf..000000000 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { atom } from 'jotai'; -import { atomWithLocation } from 'jotai-location'; -import { debounce } from 'lodash'; - -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'; - -interface JotaiLocation { - pathname?: string; - searchParams?: URLSearchParams; -} - -// 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<JotaiLocation | null>(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 locAtomDebounced = atom( - (get): JotaiLocation => { - return get(locStorageAtom) ?? get(locAtom); - }, - (get, set, updates) => { - const newData = - typeof updates === 'function' ? updates(get(locAtomDebounced)) : updates; - - if (!setDebounced) { - setDebounced = debounce(set, 320); - } - - setDebounced(locAtom, newData); - set(locStorageAtom, newData); - } -); - -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> = 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(locAtomDebounced).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(locAtomDebounced, setUrlParam('datasets', encoded)); - } -); - -const timelineDatasetsStorageAtom = atom<TimelineDataset[]>([]); - -// 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<TimelineDataset[]>) => { - 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(locAtomDebounced).searchParams?.get('date'); - return getValidDateOrNull(txtDate); - }, - (get, set, updates: ValueUpdater<Date | null>) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedCompareDateAtom)) - : updates; - - set(locAtomDebounced, 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(locAtomDebounced).searchParams?.get('dateCompare'); - return getValidDateOrNull(txtDate); - }, - (get, set, updates: ValueUpdater<Date | null>) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedCompareDateAtom)) - : updates; - - set( - locAtomDebounced, - setUrlParam('dateCompare', newData?.toISOString() ?? '') - ); - } -); - -// Date range for L&R playheads. -export const selectedIntervalAtom = atom( - (get) => { - const txtDate = get(locAtomDebounced).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<DateRange | null>) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedIntervalAtom)) - : updates; - - const value = newData - ? `${newData.start.toISOString()}|${newData.end.toISOString()}` - : ''; - - set(locAtomDebounced, setUrlParam('dateRange', value)); - } -); - -// Zoom transform for the timeline. Values as object instead of d3.ZoomTransform -export const zoomTransformAtom = atom<ZoomTransformPlain>({ - 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<number | undefined>(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<boolean>(false); - -// Analysis controller. Stores high level state about the analysis process. -export const analysisControllerAtom = atom({ - isAnalyzing: false, - runIds: {} as Record<string, number | undefined>, - isObsolete: false -}); diff --git a/app/scripts/components/exploration/atoms/cache.ts b/app/scripts/components/exploration/atoms/cache.ts new file mode 100644 index 000000000..9560f5f77 --- /dev/null +++ b/app/scripts/components/exploration/atoms/cache.ts @@ -0,0 +1,27 @@ +const dataCache = new Map(); + +/** + * Returns a stable value for a given key, either from the cache or the provided + * value. If the provided value is an object, it is compared to the cached value + * using JSON.stringify. + * @param key - The key to use for the cache lookup. + * @param value - The value to use if the key is not found in the cache. + * @returns The cached value if it exists and is equal to the provided value, + * otherwise the provided value (after caching it). + */ +export function getStableValue<T>(key: string, value: T): T { + const prev = dataCache.get(key); + + if (typeof value === 'object') { + const valueString = JSON.stringify(value); + const prevString = JSON.stringify(prev); + if (prevString === valueString) { + return prev; + } + } else if (prev === value) { + return prev; + } + + dataCache.set(key, value); + return value; +} diff --git a/app/scripts/components/exploration/atoms/datasets.ts b/app/scripts/components/exploration/atoms/datasets.ts new file mode 100644 index 000000000..e4cee6a4e --- /dev/null +++ b/app/scripts/components/exploration/atoms/datasets.ts @@ -0,0 +1,87 @@ +import { atom } from 'jotai'; + +import { datasetLayers, reconcileDatasets } from '../data-utils'; +import { TimelineDataset, TimelineDatasetForUrl } from '../types.d.ts'; +import { getStableValue } from './cache'; +import { AtomValueUpdater } from './types'; +import { setUrlParam, urlAtom } from './url'; + +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; +} + +// 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 datasetsUrlAtom = atom( + (get): TimelineDatasetForUrl[] => { + try { + const serialized = get(urlAtom).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(urlAtom, setUrlParam('datasets', encoded)); + } +); + +// Atom to hold all the data about the datasets. +const timelineDatasetsStorageAtom = atom<TimelineDataset[]>([]); + +// Atom from where to get the dataset information. This reconciles the storage +// data with the url data. +export const timelineDatasetsAtom = atom( + (get) => { + const urlDatasets = get(datasetsUrlAtom); + const datasets = get(timelineDatasetsStorageAtom); + + // 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 = 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; + }); + + return getStableValue('datasets', reconciledDatasets); + }, + (get, set, updates: AtomValueUpdater<TimelineDataset[]>) => { + const newData = + typeof updates === 'function' + ? updates(get(timelineDatasetsStorageAtom)) + : updates; + + set(datasetsUrlAtom, newData); + set(timelineDatasetsStorageAtom, newData); + } +); diff --git a/app/scripts/components/exploration/atoms/dates.ts b/app/scripts/components/exploration/atoms/dates.ts new file mode 100644 index 000000000..28e072434 --- /dev/null +++ b/app/scripts/components/exploration/atoms/dates.ts @@ -0,0 +1,76 @@ +import { atom } from 'jotai'; + +import { DateRange } from '../types.d.ts'; +import { getStableValue } from './cache'; +import { AtomValueUpdater } from './types'; +import { setUrlParam, urlAtom } from './url'; + +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 = atom( + (get) => { + const txtDate = get(urlAtom).searchParams?.get('date'); + const date = getValidDateOrNull(txtDate); + return getStableValue('selectedDate', date); + }, + (get, set, updates: AtomValueUpdater<Date | null>) => { + const newData = + typeof updates === 'function' + ? updates(get(selectedCompareDateAtom)) + : updates; + + set(urlAtom, 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(urlAtom).searchParams?.get('dateCompare'); + const date = getValidDateOrNull(txtDate); + return getStableValue('selectedCompareDate', date); + }, + (get, set, updates: AtomValueUpdater<Date | null>) => { + const newData = + typeof updates === 'function' + ? updates(get(selectedCompareDateAtom)) + : updates; + + set(urlAtom, setUrlParam('dateCompare', newData?.toISOString() ?? '')); + } +); + +// Date range for L&R playheads. +export const selectedIntervalAtom = atom( + (get) => { + const txtDate = get(urlAtom).searchParams?.get('dateRange'); + const [start, end] = txtDate?.split('|') ?? []; + + const dateStart = getValidDateOrNull(start); + const dateEnd = getValidDateOrNull(end); + + if (!dateStart || !dateEnd) return null; + + const range = { start: dateStart, end: dateEnd }; + return getStableValue('selectedInterval', range); + }, + (get, set, updates: AtomValueUpdater<DateRange | null>) => { + const newData = + typeof updates === 'function' + ? updates(get(selectedIntervalAtom)) + : updates; + + const value = newData + ? `${newData.start.toISOString()}|${newData.end.toISOString()}` + : ''; + + set(urlAtom, setUrlParam('dateRange', value)); + } +); 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<ZoomTransformPlain>({ + 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<number | undefined>(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<boolean>(false); diff --git a/app/scripts/components/exploration/atoms/types.d.ts b/app/scripts/components/exploration/atoms/types.d.ts new file mode 100644 index 000000000..db94eabe7 --- /dev/null +++ b/app/scripts/components/exploration/atoms/types.d.ts @@ -0,0 +1 @@ +export type AtomValueUpdater<T> = T | ((prev: T) => T); diff --git a/app/scripts/components/exploration/atoms/url.ts b/app/scripts/components/exploration/atoms/url.ts new file mode 100644 index 000000000..b641dcf55 --- /dev/null +++ b/app/scripts/components/exploration/atoms/url.ts @@ -0,0 +1,62 @@ +import { atom } from 'jotai'; +import { atomWithLocation } from 'jotai-location'; +import { debounce } from 'lodash'; + +interface JotaiLocation { + pathname?: string; + searchParams?: URLSearchParams; +} + +// 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<JotaiLocation | null>(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; +export 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); + } +); + +/** + * 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) { <Button onClick={onCompareClick} variation='primary-outline'> Toggle compare ({comparing.toString()}) </Button> - <div style={{ border: '1px solid teal'}}> + <div style={{ border: '1px solid teal' }}> {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'; From b15530b81ad50540beb625dfe2b794a024f5f94b Mon Sep 17 00:00:00 2001 From: Daniel da Silva <danielfdsilva@gmail.com> Date: Wed, 8 Nov 2023 11:51:17 +0000 Subject: [PATCH 3/3] Simplify atoms related to url storage --- .../components/exploration/atoms/README.md | 43 ++++-- .../atoms/atom-with-url-value-stability.ts | 122 ++++++++++++++++++ .../components/exploration/atoms/cache.ts | 27 ---- .../components/exploration/atoms/datasets.ts | 57 +++----- .../components/exploration/atoms/dates.ts | 92 ++++++------- .../components/exploration/atoms/types.d.ts | 1 - .../components/exploration/atoms/url.ts | 61 +++++---- 7 files changed, 239 insertions(+), 164 deletions(-) create mode 100644 app/scripts/components/exploration/atoms/atom-with-url-value-stability.ts delete mode 100644 app/scripts/components/exploration/atoms/cache.ts delete mode 100644 app/scripts/components/exploration/atoms/types.d.ts diff --git a/app/scripts/components/exploration/atoms/README.md b/app/scripts/components/exploration/atoms/README.md index 7709a3d51..ccc649cf4 100644 --- a/app/scripts/components/exploration/atoms/README.md +++ b/app/scripts/components/exploration/atoms/README.md @@ -46,20 +46,37 @@ const valueAtom = atom( 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 external cache that is checked before returning the value from the atom. This way we can return the same object if it is already in the cache. +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). -The previous example would become +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 valueAtom = atom( - (get) => { - const value = get(urlAtom).searchParams?.get('value'); - const parsedValue = value ? JSON.parse(value) : null; - return getStableValue('value-key', parsedValue); +const atom = atomWithUrlValueStability({ + initialValue: { a: 0 }, + urlParam: 'someKey', + hydrate: (serialized) => { + return JSON.parse(serialized); }, - (get, set, updates) => { - set(urlAtom, JSON.stringify(updates)); - } -); -``` + dehydrate: (value) => { + return JSON.stringify(value); + }, + reconcile: (urlValue, storageValue) => { + return someReconcileFunction(urlValue, storageValue); + }, + areEqual: (a, b) => { + return a.a === b.a; + }, +}); -The cache is pretty simple and it is in [./cache.ts](./cache.ts). \ No newline at end of file +``` \ No newline at end of file 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<Value>( + 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<Value, ValueUrl> { + /** + * 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<T, TUrl = T>( + options: StableAtomOptions<T, TUrl> +) { + 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<T>(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<T>) => { + // 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/cache.ts b/app/scripts/components/exploration/atoms/cache.ts deleted file mode 100644 index 9560f5f77..000000000 --- a/app/scripts/components/exploration/atoms/cache.ts +++ /dev/null @@ -1,27 +0,0 @@ -const dataCache = new Map(); - -/** - * Returns a stable value for a given key, either from the cache or the provided - * value. If the provided value is an object, it is compared to the cached value - * using JSON.stringify. - * @param key - The key to use for the cache lookup. - * @param value - The value to use if the key is not found in the cache. - * @returns The cached value if it exists and is equal to the provided value, - * otherwise the provided value (after caching it). - */ -export function getStableValue<T>(key: string, value: T): T { - const prev = dataCache.get(key); - - if (typeof value === 'object') { - const valueString = JSON.stringify(value); - const prevString = JSON.stringify(prev); - if (prevString === valueString) { - return prev; - } - } else if (prev === value) { - return prev; - } - - dataCache.set(key, value); - return value; -} diff --git a/app/scripts/components/exploration/atoms/datasets.ts b/app/scripts/components/exploration/atoms/datasets.ts index e4cee6a4e..6ab7f420f 100644 --- a/app/scripts/components/exploration/atoms/datasets.ts +++ b/app/scripts/components/exploration/atoms/datasets.ts @@ -1,10 +1,6 @@ -import { atom } from 'jotai'; - import { datasetLayers, reconcileDatasets } from '../data-utils'; import { TimelineDataset, TimelineDatasetForUrl } from '../types.d.ts'; -import { getStableValue } from './cache'; -import { AtomValueUpdater } from './types'; -import { setUrlParam, urlAtom } from './url'; +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 @@ -25,42 +21,30 @@ function urlDatasetsHydrate( return parsed; } -// 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 datasetsUrlAtom = atom( - (get): TimelineDatasetForUrl[] => { +export const timelineDatasetsAtom = atomWithUrlValueStability< + TimelineDataset[], + TimelineDatasetForUrl[] +>({ + initialValue: [], + urlParam: 'datasets', + hydrate: (serialized) => { try { - const serialized = get(urlAtom).searchParams?.get('datasets') ?? '[]'; - return urlDatasetsHydrate(serialized); + return urlDatasetsHydrate(serialized ?? '[]'); } catch (error) { return []; } }, - (get, set, datasets: TimelineDataset[]) => { - // Extract need properties from the datasets and encode them. - const encoded = urlDatasetsDehydrate(datasets); - set(urlAtom, setUrlParam('datasets', encoded)); - } -); - -// Atom to hold all the data about the datasets. -const timelineDatasetsStorageAtom = atom<TimelineDataset[]>([]); - -// Atom from where to get the dataset information. This reconciles the storage -// data with the url data. -export const timelineDatasetsAtom = atom( - (get) => { - const urlDatasets = get(datasetsUrlAtom); - const datasets = get(timelineDatasetsStorageAtom); - + 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 = datasets.find((d) => d.data.id === enc.id); + const readyDataset = storageDatasets.find((d) => d.data.id === enc.id); if (readyDataset) { return readyDataset; } @@ -73,15 +57,6 @@ export const timelineDatasetsAtom = atom( return reconciled; }); - return getStableValue('datasets', reconciledDatasets); - }, - (get, set, updates: AtomValueUpdater<TimelineDataset[]>) => { - const newData = - typeof updates === 'function' - ? updates(get(timelineDatasetsStorageAtom)) - : updates; - - set(datasetsUrlAtom, newData); - set(timelineDatasetsStorageAtom, newData); + return reconciledDatasets; } -); +}); diff --git a/app/scripts/components/exploration/atoms/dates.ts b/app/scripts/components/exploration/atoms/dates.ts index 28e072434..3813304a7 100644 --- a/app/scripts/components/exploration/atoms/dates.ts +++ b/app/scripts/components/exploration/atoms/dates.ts @@ -1,9 +1,5 @@ -import { atom } from 'jotai'; - import { DateRange } from '../types.d.ts'; -import { getStableValue } from './cache'; -import { AtomValueUpdater } from './types'; -import { setUrlParam, urlAtom } from './url'; +import { atomWithUrlValueStability } from './atom-with-url-value-stability'; const getValidDateOrNull = (value: any) => { if (!value) { @@ -14,63 +10,49 @@ const getValidDateOrNull = (value: any) => { }; // Main timeline date. This date defines the datasets shown on the map. -export const selectedDateAtom = atom( - (get) => { - const txtDate = get(urlAtom).searchParams?.get('date'); - const date = getValidDateOrNull(txtDate); - return getStableValue('selectedDate', date); +export const selectedDateAtom = atomWithUrlValueStability<Date | null>({ + initialValue: null, + urlParam: 'date', + hydrate: (serialized) => { + return getValidDateOrNull(serialized); }, - (get, set, updates: AtomValueUpdater<Date | null>) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedCompareDateAtom)) - : updates; - - set(urlAtom, setUrlParam('date', newData?.toISOString() ?? '')); + dehydrate: (date) => { + return date?.toISOString() ?? ''; } -); +}); // Compare date. This is the compare date for the datasets shown on the map. -export const selectedCompareDateAtom = atom( - (get) => { - const txtDate = get(urlAtom).searchParams?.get('dateCompare'); - const date = getValidDateOrNull(txtDate); - return getStableValue('selectedCompareDate', date); +export const selectedCompareDateAtom = atomWithUrlValueStability<Date | null>({ + initialValue: null, + urlParam: 'dateCompare', + hydrate: (serialized) => { + return getValidDateOrNull(serialized); }, - (get, set, updates: AtomValueUpdater<Date | null>) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedCompareDateAtom)) - : updates; - - set(urlAtom, setUrlParam('dateCompare', newData?.toISOString() ?? '')); + dehydrate: (date) => { + return date?.toISOString() ?? ''; } -); +}); // Date range for L&R playheads. -export const selectedIntervalAtom = atom( - (get) => { - const txtDate = get(urlAtom).searchParams?.get('dateRange'); - const [start, end] = txtDate?.split('|') ?? []; - - const dateStart = getValidDateOrNull(start); - const dateEnd = getValidDateOrNull(end); - - if (!dateStart || !dateEnd) return null; - - const range = { start: dateStart, end: dateEnd }; - return getStableValue('selectedInterval', range); - }, - (get, set, updates: AtomValueUpdater<DateRange | null>) => { - const newData = - typeof updates === 'function' - ? updates(get(selectedIntervalAtom)) - : updates; - - const value = newData - ? `${newData.start.toISOString()}|${newData.end.toISOString()}` - : ''; - - set(urlAtom, setUrlParam('dateRange', value)); +export const selectedIntervalAtom = atomWithUrlValueStability<DateRange | null>( + { + 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/types.d.ts b/app/scripts/components/exploration/atoms/types.d.ts deleted file mode 100644 index db94eabe7..000000000 --- a/app/scripts/components/exploration/atoms/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -export type AtomValueUpdater<T> = T | ((prev: T) => T); diff --git a/app/scripts/components/exploration/atoms/url.ts b/app/scripts/components/exploration/atoms/url.ts index b641dcf55..6ce569ff2 100644 --- a/app/scripts/components/exploration/atoms/url.ts +++ b/app/scripts/components/exploration/atoms/url.ts @@ -7,35 +7,42 @@ interface JotaiLocation { searchParams?: URLSearchParams; } -// 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<JotaiLocation | null>(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; -export 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); +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<JotaiLocation | null>(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; +} + - setDebounced(locAtom, newData); - set(locStorageAtom, newData); - } -); +export const urlAtom = atomWithDebouncedLocation(); /** * Creates an updater function that sets a given value for the search params of