Skip to content

Commit be7e78a

Browse files
committed
Maintain camera zoom when switching dataset
1 parent 6af9e45 commit be7e78a

File tree

14 files changed

+163
-32
lines changed

14 files changed

+163
-32
lines changed

packages/app/src/App.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import '@h5web/lib'; // make sure lib styles come first in CSS bundle
1+
import '@h5web/lib'; // eslint-disable-line import/no-duplicates -- make sure lib styles come first in CSS bundle
22

3+
import { KeepZoomProvider } from '@h5web/lib'; // eslint-disable-line import/no-duplicates
34
import { useToggle } from '@react-hookz/web';
45
import { Suspense, useState } from 'react';
56
import { ErrorBoundary } from 'react-error-boundary';
@@ -85,23 +86,25 @@ function App(props: Props) {
8586
/>
8687
<VisConfigProvider>
8788
<DimMappingProvider>
88-
<ErrorBoundary
89-
resetKeys={[selectedPath, isInspecting]}
90-
FallbackComponent={ErrorFallback}
91-
>
92-
<Suspense
93-
fallback={<EntityLoader isInspecting={isInspecting} />}
89+
<KeepZoomProvider>
90+
<ErrorBoundary
91+
resetKeys={[selectedPath, isInspecting]}
92+
FallbackComponent={ErrorFallback}
9493
>
95-
{isInspecting ? (
96-
<MetadataViewer
97-
path={selectedPath}
98-
onSelectPath={onSelectPath}
99-
/>
100-
) : (
101-
<Visualizer path={selectedPath} />
102-
)}
103-
</Suspense>
104-
</ErrorBoundary>
94+
<Suspense
95+
fallback={<EntityLoader isInspecting={isInspecting} />}
96+
>
97+
{isInspecting ? (
98+
<MetadataViewer
99+
path={selectedPath}
100+
onSelectPath={onSelectPath}
101+
/>
102+
) : (
103+
<Visualizer path={selectedPath} />
104+
)}
105+
</Suspense>
106+
</ErrorBoundary>
107+
</KeepZoomProvider>
105108
</DimMappingProvider>
106109
</VisConfigProvider>
107110
</ReflexElement>

packages/app/src/vis-packs/core/complex/MappedComplexHeatmapVis.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
type DimensionMapping,
33
HeatmapVis,
4+
KeepZoom,
45
useDomain,
56
useSafeDomain,
67
useSlicedDimsAndMapping,
@@ -20,6 +21,7 @@ import { type HeatmapConfig } from '../heatmap/config';
2021
import HeatmapToolbar from '../heatmap/HeatmapToolbar';
2122
import { useMappedArray, useToNumArrays } from '../hooks';
2223
import { DEFAULT_DOMAIN } from '../utils';
24+
import { Vis } from '../visualizations';
2325
import { usePhaseAmplitude } from './hooks';
2426
import { COMPLEX_VIS_TYPE_LABELS } from './utils';
2527

@@ -115,7 +117,9 @@ function MappedComplexHeatmapVis(props: Props) {
115117
}
116118
: undefined
117119
}
118-
/>
120+
>
121+
<KeepZoom visKey={Vis.Heatmap} />
122+
</HeatmapVis>
119123
</>
120124
);
121125
}

packages/app/src/vis-packs/core/complex/MappedComplexLineVis.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
ComplexVisType,
33
type DimensionMapping,
4+
KeepZoom,
45
LineVis,
56
useCombinedDomain,
67
useDomains,
@@ -22,6 +23,7 @@ import { useMappedArrays, useToNumArrays } from '../hooks';
2223
import { type LineConfig } from '../line/config';
2324
import LineToolbar from '../line/LineToolbar';
2425
import { DEFAULT_DOMAIN } from '../utils';
26+
import { Vis } from '../visualizations';
2527
import { usePhaseAmplitudeArrays } from './hooks';
2628
import { COMPLEX_VIS_TYPE_LABELS } from './utils';
2729

@@ -140,7 +142,9 @@ function MappedComplexLineVis(props: Props) {
140142
visible={valueVisible}
141143
interpolation={interpolation}
142144
testid={dimMapping.toString()}
143-
/>
145+
>
146+
<KeepZoom visKey={Vis.Line} />
147+
</LineVis>
144148
</>
145149
);
146150
}

packages/app/src/vis-packs/core/heatmap/MappedHeatmapVis.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getSliceSelection,
44
HeatmapVis,
55
type IgnoreValue,
6+
KeepZoom,
67
useDomain,
78
useSafeDomain,
89
useSlicedDimsAndMapping,
@@ -26,6 +27,7 @@ import {
2627
useToNumArrays,
2728
} from '../hooks';
2829
import { DEFAULT_DOMAIN, formatNumLikeType } from '../utils';
30+
import { Vis } from '../visualizations';
2931
import { type HeatmapConfig } from './config';
3032
import HeatmapToolbar from './HeatmapToolbar';
3133

@@ -118,7 +120,9 @@ function MappedHeatmapVis(props: Props) {
118120
flipXAxis={flipXAxis}
119121
flipYAxis={flipYAxis}
120122
ignoreValue={ignoreValue}
121-
/>
123+
>
124+
<KeepZoom visKey={Vis.Heatmap} />
125+
</HeatmapVis>
122126
</>
123127
);
124128
}

packages/app/src/vis-packs/core/line/MappedLineVis.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type DimensionMapping,
33
getSliceSelection,
44
type IgnoreValue,
5+
KeepZoom,
56
LineVis,
67
useCombinedDomain,
78
useDomain,
@@ -32,6 +33,7 @@ import {
3233
useToNumArrays,
3334
} from '../hooks';
3435
import { DEFAULT_DOMAIN, formatNumLikeType, toNumArray } from '../utils';
36+
import { Vis } from '../visualizations';
3537
import { type LineConfig } from './config';
3638
import LineToolbar from './LineToolbar';
3739
import { generateCsv } from './utils';
@@ -191,7 +193,9 @@ function MappedLineVis(props: Props) {
191193
interpolation={interpolation}
192194
visible={valueVisible}
193195
testid={dimMapping.toString()}
194-
/>
196+
>
197+
<KeepZoom visKey={Vis.Line} />
198+
</LineVis>
195199
</>
196200
);
197201
}

packages/app/src/vis-packs/core/rgb/MappedRgbVis.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type DimensionMapping,
3+
KeepZoom,
34
RgbVis,
45
useSlicedDimsAndMapping,
56
} from '@h5web/lib';
@@ -14,6 +15,7 @@ import { createPortal } from 'react-dom';
1415

1516
import visualizerStyles from '../../../visualizer/Visualizer.module.css';
1617
import { useMappedArray, useToNumArray, useToNumArrays } from '../hooks';
18+
import { Vis } from '../visualizations';
1719
import { type RgbVisConfig } from './config';
1820
import RgbToolbar from './RgbToolbar';
1921

@@ -73,7 +75,9 @@ function MappedRgbVis(props: Props) {
7375
}}
7476
flipXAxis={flipXAxis}
7577
flipYAxis={flipYAxis}
76-
/>
78+
>
79+
<KeepZoom visKey={Vis.RGB} />
80+
</RgbVis>
7781
</>
7882
);
7983
}

packages/app/src/vis-packs/core/scatter/MappedScatterVis.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import { ScatterVis, useDomain, useSafeDomain, useVisDomain } from '@h5web/lib';
1+
import {
2+
KeepZoom,
3+
ScatterVis,
4+
useDomain,
5+
useSafeDomain,
6+
useVisDomain,
7+
} from '@h5web/lib';
28
import { assertDefined } from '@h5web/shared/guards';
39
import { type ArrayValue, type NumericType } from '@h5web/shared/hdf5-models';
410
import { type AxisMapping } from '@h5web/shared/nexus-models';
511
import { createPortal } from 'react-dom';
612

713
import visualizerStyles from '../../../visualizer/Visualizer.module.css';
14+
import { NxDataVis } from '../../nexus/visualizations';
815
import { useBaseArray, useToNumArray, useToNumArrays } from '../hooks';
916
import { DEFAULT_DOMAIN } from '../utils';
17+
import { Vis } from '../visualizations';
1018
import { type ScatterConfig } from './config';
1119
import ScatterToolbar from './ScatterToolbar';
1220

@@ -64,7 +72,9 @@ function MappedScatterVis(props: Props) {
6472
scaleType={scaleType}
6573
showGrid={showGrid}
6674
title={title}
67-
/>
75+
>
76+
<KeepZoom visKey={NxDataVis.NxScatter} />
77+
</ScatterVis>
6878
</>
6979
);
7080
}

packages/lib/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export { default as AxialSelectToZoom } from './interactions/AxialSelectToZoom';
8383
export { default as SelectionTool } from './interactions/SelectionTool';
8484
export { default as AxialSelectionTool } from './interactions/AxialSelectionTool';
8585
export { default as PreventDefaultContextMenu } from './interactions/PreventDefaultContextMenu';
86+
export { default as KeepZoom } from './interactions/KeepZoom';
87+
export { KeepZoomProvider } from './interactions/keep-zoom-store';
8688
export type { PanProps } from './interactions/Pan';
8789
export type { ZoomProps } from './interactions/Zoom';
8890
export type { XAxisZoomProps } from './interactions/XAxisZoom';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useDebouncedCallback, useSyncedRef } from '@react-hookz/web';
2+
import { useFrame, useThree } from '@react-three/fiber';
3+
import { useLayoutEffect } from 'react';
4+
import { useStore } from 'zustand';
5+
6+
import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider';
7+
import { useMoveCameraTo } from './hooks';
8+
import { useKeepZoomStore } from './keep-zoom-store';
9+
10+
interface Props {
11+
visKey: string;
12+
}
13+
14+
function KeepZoom(props: Props) {
15+
const { visKey } = props;
16+
const { abscissaConfig, ordinateConfig } = useVisCanvasContext();
17+
const keyRef = useSyncedRef(
18+
`${visKey}_${abscissaConfig.visDomain.toString()}_${ordinateConfig.visDomain.toString()}`,
19+
);
20+
21+
const store = useKeepZoomStore();
22+
const setState = useStore(store, (state) => state.setState);
23+
const setStateDebounced = useDebouncedCallback(setState, [setState], 100);
24+
25+
const camera = useThree((state) => state.camera);
26+
const moveCameraToRef = useSyncedRef(useMoveCameraTo());
27+
28+
/* Restore camera position and scale on mount if current key matches persisted key from store.
29+
* Synced refs are to ensure that the effect runs only on mount and not when the axis domains change. */
30+
useLayoutEffect(() => {
31+
const { key, scale, position } = store.getState(); // non-reactive state to avoid render loop
32+
33+
if (keyRef.current === key) {
34+
camera.scale.copy(scale);
35+
moveCameraToRef.current(position);
36+
}
37+
}, [keyRef, moveCameraToRef, camera, store]);
38+
39+
// Save camera position and scale on change
40+
useFrame(() => {
41+
setStateDebounced(keyRef.current, camera.position, camera.scale);
42+
});
43+
44+
return null;
45+
}
46+
47+
export default KeepZoom;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { type NoProps } from '@h5web/shared/vis-models';
2+
import {
3+
createContext,
4+
type PropsWithChildren,
5+
useContext,
6+
useState,
7+
} from 'react';
8+
import { Vector3 } from 'three';
9+
import { createStore, type StoreApi } from 'zustand';
10+
11+
import { CAMERA_Z } from '../vis/utils';
12+
13+
interface KeepZoomState {
14+
key: string | undefined;
15+
position: Vector3;
16+
scale: Vector3;
17+
setState: (key: string, position: Vector3, scale: Vector3) => void;
18+
}
19+
20+
function createKeepZoomStore() {
21+
return createStore<KeepZoomState>()(
22+
(set): KeepZoomState => ({
23+
key: undefined,
24+
position: new Vector3(0, 0, CAMERA_Z),
25+
scale: new Vector3(1, 1, 1),
26+
27+
setState: (key, position, scale) => {
28+
set(() => ({
29+
key,
30+
position: position.clone(),
31+
scale: scale.clone(),
32+
}));
33+
},
34+
}),
35+
);
36+
}
37+
38+
const StoreContext = createContext({} as StoreApi<KeepZoomState>);
39+
40+
export function KeepZoomProvider(props: PropsWithChildren<NoProps>) {
41+
const { children } = props;
42+
43+
const [store] = useState(createKeepZoomStore);
44+
45+
return (
46+
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
47+
);
48+
}
49+
50+
export function useKeepZoomStore() {
51+
return useContext(StoreContext);
52+
}

0 commit comments

Comments
 (0)