Skip to content

Commit 6d0f9d1

Browse files
authored
Merge pull request #25 from joris-gentinetta/feat/scroll_z_and_t
Adding scrolling and arrow navigation through time and z axis
2 parents e670f36 + 1f35d0f commit 6d0f9d1

3 files changed

Lines changed: 338 additions & 14 deletions

File tree

main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import debounce from "just-debounce-it";
22
import * as vizarr from "./src/index";
33

44
async function main() {
5-
console.log(`vizarr v${vizarr.version}: https://github.com/hms-dbmi/vizarr`);
5+
console.log(`vizarr v${vizarr.version}: https://github.com/BioNGFF/vizarr`);
66
// biome-ignore lint/style/noNonNullAssertion: We know the element exists
77
const viewer = await vizarr.createViewer(document.querySelector("#root")!);
88
const url = new URL(window.location.href);

src/components/Viewer.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { OrthographicView } from "deck.gl";
44
import { useAtom, useAtomValue } from "jotai";
55
import * as React from "react";
66
import { useViewState } from "../hooks";
7+
import { useAxisNavigation } from "../hooks/useAxisNavigation";
78
import { layerAtoms, viewportAtom } from "../state";
89
import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils";
910

@@ -18,6 +19,8 @@ export default function Viewer() {
1819
const layers = useAtomValue(layerAtoms);
1920
const firstLayer = layers[0] as VizarrLayer;
2021

22+
const axisNavigationSnackbar = useAxisNavigation(deckRef, viewport);
23+
2124
const resetViewState = React.useCallback(
2225
(layer: VizarrLayer) => {
2326
const { deck } = deckRef.current || {};
@@ -135,18 +138,22 @@ export default function Viewer() {
135138
}, [layers, firstLayer]);
136139

137140
return (
138-
<DeckGL
139-
ref={deckRef}
140-
layers={deckLayers}
141-
viewState={viewState && { ortho: viewState }}
142-
onViewStateChange={(e: { viewState: OrthographicViewState }) =>
143-
// @ts-expect-error - deck doesn't know this should be ok
144-
setViewState(e.viewState)
145-
}
146-
views={[new OrthographicView({ id: "ortho", controller: true, near, far })]}
147-
glOptions={glOptions}
148-
getTooltip={getTooltip}
149-
onDeviceInitialized={() => setViewport(deckRef.current?.deck || null)}
150-
/>
141+
<>
142+
<DeckGL
143+
ref={deckRef}
144+
layers={deckLayers}
145+
viewState={viewState && { ortho: viewState }}
146+
controller={{ keyboard: true }}
147+
onViewStateChange={(e: { viewState: OrthographicViewState }) =>
148+
// @ts-expect-error - deck doesn't know this should be ok
149+
setViewState(e.viewState)
150+
}
151+
views={[new OrthographicView({ id: "ortho", controller: true, near, far })]}
152+
glOptions={glOptions}
153+
getTooltip={getTooltip}
154+
onDeviceInitialized={() => setViewport(deckRef.current?.deck || null)}
155+
/>
156+
{axisNavigationSnackbar}
157+
</>
151158
);
152159
}

src/hooks/useAxisNavigation.tsx

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { Snackbar } from "@mui/material";
2+
import Fade from "@mui/material/Fade";
3+
import type { DeckGLRef, PickingInfo } from "deck.gl";
4+
import { useAtomCallback } from "jotai/utils";
5+
import * as React from "react";
6+
import { layerFamilyAtom, sourceInfoAtom } from "../state";
7+
8+
type DeckInstance = DeckGLRef["deck"] | null;
9+
10+
type Axis = "z" | "t";
11+
type AdjustArgs = {
12+
axis: Axis;
13+
delta: number;
14+
pointer?: { x: number; y: number };
15+
};
16+
17+
const AXIS_SCROLL_STEP_DELTA = 40;
18+
19+
function AxisNavigationSnackbar({ open, axis }: { open: boolean; axis: string }) {
20+
return (
21+
<Snackbar
22+
open={open}
23+
message={`Hold down key ${axis.toUpperCase()} + scroll or left/right arrows to navigate ${axis.toUpperCase()} axis`}
24+
slots={{ transition: Fade }}
25+
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
26+
sx={{
27+
"& .MuiSnackbarContent-root": {
28+
backgroundColor: "rgba(0, 0, 0, 0.5)",
29+
color: "white",
30+
},
31+
}}
32+
/>
33+
);
34+
}
35+
36+
export function useAxisNavigation(deckRef: React.RefObject<DeckGLRef>, viewport: DeckInstance) {
37+
const [axisScrollKey, setAxisScrollKey] = React.useState<Axis | null>(null);
38+
const axisScrollKeyRef = React.useRef<Axis | null>(null);
39+
const axisScrollAccumulatorRef = React.useRef(0);
40+
const lastPointerRef = React.useRef<{ x: number; y: number } | undefined>(undefined);
41+
const lastTargetSourceIdRef = React.useRef<string | undefined>(undefined);
42+
43+
const updateAxisScrollKey = React.useCallback((nextKey: Axis | null) => {
44+
axisScrollKeyRef.current = nextKey;
45+
setAxisScrollKey(nextKey);
46+
}, []);
47+
48+
const adjustAxis = useAtomCallback(
49+
React.useCallback(
50+
(get, set, { axis, delta, pointer }: AdjustArgs) => {
51+
if (delta === 0) {
52+
return;
53+
}
54+
55+
const deckInstance = viewport ?? deckRef.current?.deck ?? null;
56+
const canvas = (deckInstance as { canvas?: HTMLCanvasElement } | null)?.canvas;
57+
if (!deckInstance || !canvas) {
58+
return; // no deck instance or canvas
59+
}
60+
61+
const rect = canvas.getBoundingClientRect();
62+
if (pointer) {
63+
lastPointerRef.current = pointer;
64+
}
65+
66+
const sources = get(sourceInfoAtom);
67+
if (sources.length === 0) {
68+
return;
69+
}
70+
71+
const getAxisIndex = (source: (typeof sources)[number]) =>
72+
(source.axis_labels ?? []).findIndex((label) => label.toLowerCase() === axis);
73+
74+
const pointerToUse = pointer ?? lastPointerRef.current;
75+
76+
let targetSource: (typeof sources)[number] | undefined;
77+
let axisIndex = -1;
78+
79+
if (pointerToUse) {
80+
const { x, y } = pointerToUse;
81+
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
82+
const picks = (deckInstance.pickMultipleObjects({ x, y, depth: 1 }) ?? []) as PickingInfo[];
83+
const pickedLayerId = (() => {
84+
const pick = picks.find((info: PickingInfo) => info.layer && typeof info.layer.props?.id === "string");
85+
if (!pick || !pick.layer?.props?.id) {
86+
return undefined;
87+
}
88+
return String(pick.layer.props.id);
89+
})();
90+
91+
if (pickedLayerId) {
92+
targetSource = sources.find(
93+
(item) =>
94+
pickedLayerId === item.id ||
95+
pickedLayerId.startsWith(`${item.id}_`) ||
96+
pickedLayerId.startsWith(`${item.id}-`),
97+
);
98+
if (targetSource) {
99+
axisIndex = getAxisIndex(targetSource);
100+
}
101+
}
102+
}
103+
}
104+
105+
if ((!targetSource || axisIndex === -1) && lastTargetSourceIdRef.current) {
106+
targetSource = sources.find((item) => item.id === lastTargetSourceIdRef.current);
107+
if (targetSource) {
108+
axisIndex = getAxisIndex(targetSource);
109+
}
110+
}
111+
112+
if (!targetSource) {
113+
targetSource = sources[0];
114+
axisIndex = targetSource ? getAxisIndex(targetSource) : -1;
115+
}
116+
117+
if (!targetSource || axisIndex === -1) {
118+
return;
119+
}
120+
121+
lastTargetSourceIdRef.current = targetSource.id;
122+
123+
const baseLoader = targetSource.loader?.[0];
124+
const shape = baseLoader?.shape;
125+
if (!shape || axisIndex >= shape.length) {
126+
return;
127+
}
128+
129+
const maxIndex = shape[axisIndex] - 1;
130+
if (maxIndex <= 0) {
131+
return;
132+
}
133+
134+
const layerAtom = layerFamilyAtom(targetSource);
135+
const layerState = get(layerAtom);
136+
if (!layerState) {
137+
return;
138+
}
139+
140+
const { layerProps } = layerState;
141+
const selections = layerProps.selections;
142+
if (selections.length === 0) {
143+
return;
144+
}
145+
146+
const currentIndex = selections[0]?.[axisIndex] ?? 0;
147+
const nextIndex = Math.min(Math.max(currentIndex + delta, 0), maxIndex);
148+
if (nextIndex === currentIndex) {
149+
return;
150+
}
151+
152+
const nextSelections = selections.map((selection: number[]) => {
153+
const next = [...selection];
154+
next[axisIndex] = nextIndex;
155+
return next;
156+
});
157+
158+
set(layerAtom, {
159+
...layerState,
160+
layerProps: {
161+
...layerProps,
162+
selections: nextSelections,
163+
},
164+
});
165+
166+
const defaultSelection = nextSelections[0] ? [...nextSelections[0]] : undefined;
167+
if (!defaultSelection) {
168+
return;
169+
}
170+
171+
set(sourceInfoAtom, (prev: typeof sources) =>
172+
prev.map((item) => {
173+
if (item.id !== targetSource.id) {
174+
return item;
175+
}
176+
const prevSelection = item.defaults.selection;
177+
const isSame =
178+
prevSelection.length === defaultSelection.length &&
179+
prevSelection.every((value: number, index: number) => value === defaultSelection[index]);
180+
181+
return isSame
182+
? item
183+
: {
184+
...item,
185+
defaults: {
186+
...item.defaults,
187+
selection: defaultSelection,
188+
},
189+
};
190+
}),
191+
);
192+
},
193+
[viewport, deckRef],
194+
),
195+
);
196+
197+
React.useEffect(() => {
198+
const handleKeyDown = (event: KeyboardEvent) => {
199+
const lower = event.key.toLowerCase();
200+
if (lower === "z" || lower === "t") {
201+
event.preventDefault();
202+
event.stopPropagation();
203+
updateAxisScrollKey(lower as Axis);
204+
return; // set when pressing the key
205+
}
206+
207+
if (
208+
event.key === "ArrowUp" ||
209+
event.key === "ArrowDown" ||
210+
event.key === "ArrowLeft" ||
211+
event.key === "ArrowRight"
212+
) {
213+
const axis = axisScrollKeyRef.current;
214+
if (!axis) {
215+
return; // only respond when an axis key is active
216+
}
217+
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
218+
event.preventDefault();
219+
event.stopPropagation();
220+
return; // suppress vertical arrows when an axis key is active
221+
}
222+
const delta = event.key === "ArrowLeft" ? -1 : 1;
223+
event.preventDefault();
224+
event.stopPropagation();
225+
adjustAxis({ axis, delta });
226+
}
227+
};
228+
229+
const handleKeyUp = (event: KeyboardEvent) => {
230+
const lower = event.key.toLowerCase();
231+
if (lower === "z" || lower === "t") {
232+
event.preventDefault();
233+
event.stopPropagation();
234+
if (axisScrollKeyRef.current === lower) {
235+
updateAxisScrollKey(null);
236+
}
237+
} // reset when letting go of the key
238+
};
239+
240+
const handleBlur = () => {
241+
// reset when switching windows
242+
updateAxisScrollKey(null);
243+
};
244+
245+
window.addEventListener("keydown", handleKeyDown, true);
246+
window.addEventListener("keyup", handleKeyUp, true);
247+
window.addEventListener("blur", handleBlur);
248+
return () => {
249+
window.removeEventListener("keydown", handleKeyDown, true);
250+
window.removeEventListener("keyup", handleKeyUp, true);
251+
window.removeEventListener("blur", handleBlur);
252+
};
253+
}, [adjustAxis, updateAxisScrollKey]);
254+
255+
React.useEffect(() => {
256+
// reset accumulator when axis key changes
257+
axisScrollAccumulatorRef.current = 0;
258+
void axisScrollKey;
259+
}, [axisScrollKey]);
260+
261+
const handleWheel = React.useCallback(
262+
(event: WheelEvent) => {
263+
if (!axisScrollKey) {
264+
return; // ignore if no axis key is set, fall back to default zoom behavior
265+
}
266+
267+
const deckInstance = viewport ?? deckRef.current?.deck ?? null;
268+
const canvas = (deckInstance as { canvas?: HTMLCanvasElement } | null)?.canvas;
269+
if (!deckInstance || !canvas) {
270+
return; // no deck instance or canvas
271+
}
272+
273+
const rect = canvas.getBoundingClientRect();
274+
const x = event.clientX - rect.left;
275+
const y = event.clientY - rect.top;
276+
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
277+
return; // only consider events within the canvas
278+
}
279+
280+
event.preventDefault();
281+
event.stopPropagation();
282+
283+
axisScrollAccumulatorRef.current += event.deltaY;
284+
const steps = Math.trunc(axisScrollAccumulatorRef.current / AXIS_SCROLL_STEP_DELTA);
285+
if (steps === 0) {
286+
return;
287+
}
288+
289+
axisScrollAccumulatorRef.current -= steps * AXIS_SCROLL_STEP_DELTA;
290+
291+
const pointer = { x, y };
292+
adjustAxis({ axis: axisScrollKey, delta: -steps, pointer });
293+
},
294+
[axisScrollKey, viewport, deckRef, adjustAxis],
295+
);
296+
297+
React.useEffect(() => {
298+
// attach wheel listener to deck canvas
299+
const deckInstance = (viewport ?? deckRef.current?.deck ?? null) as { canvas?: HTMLCanvasElement } | null;
300+
const element = deckInstance?.canvas;
301+
if (!element) {
302+
return;
303+
}
304+
305+
const listener = (event: WheelEvent) => {
306+
handleWheel(event);
307+
};
308+
309+
element.addEventListener("wheel", listener, { passive: false });
310+
return () => {
311+
element.removeEventListener("wheel", listener);
312+
};
313+
}, [viewport, handleWheel, deckRef]);
314+
315+
// @TODO: check axis is present
316+
return axisScrollKey !== null && <AxisNavigationSnackbar open={true} axis={axisScrollKey} />;
317+
}

0 commit comments

Comments
 (0)