diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index b16572938..9c0fe1f1f 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -8,6 +8,7 @@ import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
import styles from './App.module.css';
import BreadcrumbsBar from './breadcrumbs/BreadcrumbsBar';
import type { FeedbackContext } from './breadcrumbs/models';
+import { DimMappingProvider } from './dimension-mapper/store';
import EntityLoader from './EntityLoader';
import ErrorFallback from './ErrorFallback';
import MetadataViewer from './metadata-viewer/MetadataViewer';
@@ -84,21 +85,25 @@ function App(props: Props) {
getFeedbackURL={getFeedbackURL}
/>
-
- }>
- {isInspecting ? (
-
- ) : (
-
- )}
-
-
+
+
+ }
+ >
+ {isInspecting ? (
+
+ ) : (
+
+ )}
+
+
+
diff --git a/packages/app/src/__tests__/DimensionMapper.test.tsx b/packages/app/src/__tests__/DimensionMapper.test.tsx
index 45d8f2355..d043f54cf 100644
--- a/packages/app/src/__tests__/DimensionMapper.test.tsx
+++ b/packages/app/src/__tests__/DimensionMapper.test.tsx
@@ -1,7 +1,7 @@
import { screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
-import { renderApp, waitForAllLoaders } from '../test-utils';
+import { getDimMappingBtn, renderApp } from '../test-utils';
import { Vis } from '../vis-packs/core/visualizations';
test('control mapping for X axis when visualizing 2D dataset as Line', async () => {
@@ -100,14 +100,92 @@ test('slice through 2D dataset', async () => {
const { user } = await renderApp({
initialPath: '/nD_datasets/twoD',
preferredVis: Vis.Line,
- withFakeTimers: true, // required since React 18 upgrade (along with `waitForAllLoaders` below)
});
- await waitForAllLoaders();
-
// Move to next slice with keyboard
const d0Slider = screen.getByRole('slider', { name: 'D0' });
await user.type(d0Slider, '{ArrowUp}');
expect(d0Slider).toHaveAttribute('aria-valuenow', '1');
});
+
+test('maintain mapping when switching to inspect mode and back', async () => {
+ const { user } = await renderApp({
+ initialPath: '/nD_datasets/twoD',
+ preferredVis: Vis.Heatmap,
+ });
+
+ // Swap axes for D0 and D1
+ await user.click(getDimMappingBtn('x', 0));
+
+ // Toggle inspect mode
+ await user.click(screen.getByRole('tab', { name: 'Inspect' }));
+ await user.click(screen.getByRole('tab', { name: 'Display' }));
+
+ expect(getDimMappingBtn('x', 0)).toBeChecked();
+ expect(getDimMappingBtn('x', 1)).not.toBeChecked();
+});
+
+test('maintain mapping when switching to visualization with same axes count', async () => {
+ const { user, selectVisTab } = await renderApp({
+ initialPath: '/nD_datasets/twoD',
+ preferredVis: Vis.Heatmap,
+ });
+
+ // Swap axes for D0 and D1
+ await user.click(getDimMappingBtn('x', 0));
+
+ // Switch to Matrix visualization
+ await selectVisTab(Vis.Matrix);
+
+ expect(getDimMappingBtn('x', 0)).toBeChecked();
+ expect(getDimMappingBtn('x', 1)).not.toBeChecked();
+});
+
+test('maintain mapping when switching to dataset with same dimensions', async () => {
+ const { user, selectExplorerNode } = await renderApp({
+ initialPath: '/nD_datasets/twoD_bool',
+ preferredVis: Vis.Line,
+ });
+
+ // Swap axes for D0 and D1
+ await user.click(getDimMappingBtn('x', 0));
+
+ // Switch to dataset with same dimensions
+ await selectExplorerNode('twoD_enum');
+
+ expect(getDimMappingBtn('x', 0)).toBeChecked();
+ expect(getDimMappingBtn('x', 1)).not.toBeChecked();
+});
+
+test('reset mapping when switching to visualization with different axes count', async () => {
+ const { user, selectVisTab } = await renderApp({
+ initialPath: '/nD_datasets/twoD',
+ preferredVis: Vis.Heatmap,
+ });
+
+ // Swap axes for D0 and D1
+ await user.click(getDimMappingBtn('x', 0));
+
+ // Switch to Line visualization
+ await selectVisTab(Vis.Line);
+
+ expect(getDimMappingBtn('x', 0)).not.toBeChecked();
+ expect(getDimMappingBtn('x', 1)).toBeChecked();
+});
+
+test('reset mapping when switching to dataset with different dimensions', async () => {
+ const { user, selectExplorerNode } = await renderApp({
+ initialPath: '/nD_datasets/twoD',
+ preferredVis: Vis.Heatmap,
+ });
+
+ // Swap axes for D0 and D1
+ await user.click(getDimMappingBtn('x', 0));
+
+ // Switch to dataset with different dimensions
+ await selectExplorerNode('twoD_cplx');
+
+ expect(getDimMappingBtn('x', 0)).not.toBeChecked();
+ expect(getDimMappingBtn('x', 1)).toBeChecked();
+});
diff --git a/packages/app/src/dimension-mapper/hooks.ts b/packages/app/src/dimension-mapper/hooks.ts
deleted file mode 100644
index 0dc2ad0be..000000000
--- a/packages/app/src/dimension-mapper/hooks.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { useState } from 'react';
-
-import type { DimensionMapping } from './models';
-
-export function useDimMappingState(dims: number[], axesCount: number) {
- return useState([
- ...Array.from({ length: dims.length - axesCount }, () => 0),
- ...(dims.length > 0 ? ['y' as const, 'x' as const].slice(-axesCount) : []),
- ]);
-}
diff --git a/packages/app/src/dimension-mapper/store.tsx b/packages/app/src/dimension-mapper/store.tsx
new file mode 100644
index 000000000..02ad5bc65
--- /dev/null
+++ b/packages/app/src/dimension-mapper/store.tsx
@@ -0,0 +1,72 @@
+import type { ArrayShape } from '@h5web/shared/hdf5-models';
+import type { PropsWithChildren } from 'react';
+import { createContext, useContext, useEffect, useState } from 'react';
+import type { StoreApi } from 'zustand';
+import { createStore, useStore } from 'zustand';
+
+import { areSameDims } from '../vis-packs/nexus/utils';
+import type { DimensionMapping } from './models';
+
+interface DimMappingState {
+ dims: ArrayShape;
+ axesCount: number;
+ mapping: DimensionMapping;
+ setMapping: (mapping: DimensionMapping) => void;
+ reset: (
+ dims: ArrayShape,
+ axesCount: number,
+ mapping: DimensionMapping,
+ ) => void;
+}
+
+function createLineConfigStore() {
+ return createStore((set) => ({
+ dims: [],
+ axesCount: 0,
+ mapping: [],
+ setMapping: (mapping) => set({ mapping }),
+ reset: (dims, axesCount, mapping) => {
+ set({ dims, axesCount, mapping });
+ },
+ }));
+}
+
+const StoreContext = createContext({} as StoreApi);
+
+interface Props {}
+export function DimMappingProvider(props: PropsWithChildren) {
+ const { children } = props;
+
+ const [store] = useState(createLineConfigStore);
+
+ return (
+ {children}
+ );
+}
+
+export function useDimMappingState(
+ dims: number[],
+ axesCount: number,
+): [DimensionMapping, (mapping: DimensionMapping) => void] {
+ const state = useStore(useContext(StoreContext));
+
+ /* If current mapping was initialised with different axes count and dimensions,
+ * need to compute new mapping and reset state. */
+ const isStale =
+ axesCount !== state.axesCount || !areSameDims(dims, state.dims);
+
+ const mapping = isStale
+ ? [
+ ...Array.from({ length: dims.length - axesCount }, () => 0),
+ ...(dims.length > 0
+ ? ['y' as const, 'x' as const].slice(-axesCount)
+ : []),
+ ]
+ : state.mapping;
+
+ useEffect(() => {
+ state.reset(dims, axesCount, mapping);
+ }, [isStale]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return [mapping, state.setMapping];
+}
diff --git a/packages/app/src/test-utils.tsx b/packages/app/src/test-utils.tsx
index 6ab738af5..f966c6687 100644
--- a/packages/app/src/test-utils.tsx
+++ b/packages/app/src/test-utils.tsx
@@ -118,6 +118,11 @@ export function getSelectedVisTab(): string {
return selectedTab.textContent;
}
+export function getDimMappingBtn(axis: 'x' | 'y', dim: number): HTMLElement {
+ const radioGroup = screen.getByLabelText(`Dimension as ${axis} axis`);
+ return within(radioGroup).getByRole('radio', { name: `D${dim}` });
+}
+
/**
* Mock a console method.
* Mocks are automatically cleared and restored after every test but you
diff --git a/packages/app/src/vis-packs/core/complex/ComplexLineVisContainer.tsx b/packages/app/src/vis-packs/core/complex/ComplexLineVisContainer.tsx
index e7e30f368..6eaeeaaaa 100644
--- a/packages/app/src/vis-packs/core/complex/ComplexLineVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/complex/ComplexLineVisContainer.tsx
@@ -5,7 +5,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useValuesInCache } from '../hooks';
diff --git a/packages/app/src/vis-packs/core/complex/ComplexVisContainer.tsx b/packages/app/src/vis-packs/core/complex/ComplexVisContainer.tsx
index 2cc7f932e..61daaeb81 100644
--- a/packages/app/src/vis-packs/core/complex/ComplexVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/complex/ComplexVisContainer.tsx
@@ -6,7 +6,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useHeatmapConfig } from '../heatmap/config';
diff --git a/packages/app/src/vis-packs/core/compound/CompoundVisContainer.tsx b/packages/app/src/vis-packs/core/compound/CompoundVisContainer.tsx
index 8493df832..e5b65efe5 100644
--- a/packages/app/src/vis-packs/core/compound/CompoundVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/compound/CompoundVisContainer.tsx
@@ -6,7 +6,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useValuesInCache } from '../hooks';
diff --git a/packages/app/src/vis-packs/core/heatmap/HeatmapVisContainer.tsx b/packages/app/src/vis-packs/core/heatmap/HeatmapVisContainer.tsx
index fa4854bd6..ad38e9db6 100644
--- a/packages/app/src/vis-packs/core/heatmap/HeatmapVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/heatmap/HeatmapVisContainer.tsx
@@ -6,7 +6,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useIgnoreFillValue, useValuesInCache } from '../hooks';
diff --git a/packages/app/src/vis-packs/core/line/LineVisContainer.tsx b/packages/app/src/vis-packs/core/line/LineVisContainer.tsx
index 6ca86e451..9745954e7 100644
--- a/packages/app/src/vis-packs/core/line/LineVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/line/LineVisContainer.tsx
@@ -5,7 +5,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useIgnoreFillValue, useValuesInCache } from '../hooks';
diff --git a/packages/app/src/vis-packs/core/matrix/MatrixVisContainer.tsx b/packages/app/src/vis-packs/core/matrix/MatrixVisContainer.tsx
index b5afc3f9f..c8861d998 100644
--- a/packages/app/src/vis-packs/core/matrix/MatrixVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/matrix/MatrixVisContainer.tsx
@@ -5,7 +5,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useValuesInCache } from '../hooks';
diff --git a/packages/app/src/vis-packs/core/rgb/RgbVisContainer.tsx b/packages/app/src/vis-packs/core/rgb/RgbVisContainer.tsx
index f2fbbab63..3ab6b147c 100644
--- a/packages/app/src/vis-packs/core/rgb/RgbVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/rgb/RgbVisContainer.tsx
@@ -6,7 +6,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import { useDataContext } from '../../../providers/DataProvider';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
diff --git a/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx b/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx
index f510fc1fe..e1e8f1c3a 100644
--- a/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx
+++ b/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx
@@ -6,7 +6,7 @@ import {
} from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import type { VisContainerProps } from '../../models';
import VisBoundary from '../../VisBoundary';
import { useValuesInCache } from '../hooks';
diff --git a/packages/app/src/vis-packs/nexus/containers/NxComplexImageContainer.tsx b/packages/app/src/vis-packs/nexus/containers/NxComplexImageContainer.tsx
index e258553d3..b41ad03fc 100644
--- a/packages/app/src/vis-packs/nexus/containers/NxComplexImageContainer.tsx
+++ b/packages/app/src/vis-packs/nexus/containers/NxComplexImageContainer.tsx
@@ -2,7 +2,7 @@ import { assertGroup, assertMinDims } from '@h5web/shared/guards';
import { useState } from 'react';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import { useComplexConfig } from '../../core/complex/config';
import MappedComplexVis from '../../core/complex/MappedComplexVis';
import { useHeatmapConfig } from '../../core/heatmap/config';
diff --git a/packages/app/src/vis-packs/nexus/containers/NxComplexSpectrumContainer.tsx b/packages/app/src/vis-packs/nexus/containers/NxComplexSpectrumContainer.tsx
index 9199cd1ea..cd1176bdf 100644
--- a/packages/app/src/vis-packs/nexus/containers/NxComplexSpectrumContainer.tsx
+++ b/packages/app/src/vis-packs/nexus/containers/NxComplexSpectrumContainer.tsx
@@ -2,7 +2,7 @@ import { ScaleType } from '@h5web/lib';
import { assertGroup, isAxisScaleType } from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import { useComplexLineConfig } from '../../core/complex/lineConfig';
import MappedComplexLineVis from '../../core/complex/MappedComplexLineVis';
import { useLineConfig } from '../../core/line/config';
diff --git a/packages/app/src/vis-packs/nexus/containers/NxImageContainer.tsx b/packages/app/src/vis-packs/nexus/containers/NxImageContainer.tsx
index e85f52095..e7e83823d 100644
--- a/packages/app/src/vis-packs/nexus/containers/NxImageContainer.tsx
+++ b/packages/app/src/vis-packs/nexus/containers/NxImageContainer.tsx
@@ -2,7 +2,7 @@ import { assertGroup, assertMinDims } from '@h5web/shared/guards';
import { useState } from 'react';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import { useHeatmapConfig } from '../../core/heatmap/config';
import MappedHeatmapVis from '../../core/heatmap/MappedHeatmapVis';
import { getSliceSelection } from '../../core/utils';
diff --git a/packages/app/src/vis-packs/nexus/containers/NxRgbContainer.tsx b/packages/app/src/vis-packs/nexus/containers/NxRgbContainer.tsx
index 72f332b4d..a70b81afa 100644
--- a/packages/app/src/vis-packs/nexus/containers/NxRgbContainer.tsx
+++ b/packages/app/src/vis-packs/nexus/containers/NxRgbContainer.tsx
@@ -1,7 +1,7 @@
import { assertGroup, assertMinDims } from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import { useRgbConfig } from '../../core/rgb/config';
import MappedRgbVis from '../../core/rgb/MappedRgbVis';
import { getSliceSelection } from '../../core/utils';
diff --git a/packages/app/src/vis-packs/nexus/containers/NxSpectrumContainer.tsx b/packages/app/src/vis-packs/nexus/containers/NxSpectrumContainer.tsx
index 5366abee9..d33b78c1a 100644
--- a/packages/app/src/vis-packs/nexus/containers/NxSpectrumContainer.tsx
+++ b/packages/app/src/vis-packs/nexus/containers/NxSpectrumContainer.tsx
@@ -2,7 +2,7 @@ import { ScaleType } from '@h5web/lib';
import { assertGroup, isAxisScaleType } from '@h5web/shared/guards';
import DimensionMapper from '../../../dimension-mapper/DimensionMapper';
-import { useDimMappingState } from '../../../dimension-mapper/hooks';
+import { useDimMappingState } from '../../../dimension-mapper/store';
import { useLineConfig } from '../../core/line/config';
import MappedLineVis from '../../core/line/MappedLineVis';
import { getSliceSelection } from '../../core/utils';