Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b59f865
add scrolling on t and y axis when pressing t / z
joris-gentinetta Nov 13, 2025
3614d90
refactor: axis scroll out of viewer
joris-gentinetta Nov 13, 2025
7ce79d7
add: arrow key navigation in t and z
joris-gentinetta Nov 13, 2025
cf07ed4
add: pyramid loading in plate view
joris-gentinetta Nov 13, 2025
3042b45
fix: only update pyramid in wells visible on screen
joris-gentinetta Nov 13, 2025
11e8405
fix: arrow keybindings in plate view
joris-gentinetta Nov 13, 2025
9bce28c
fix: smoother scrolling
joris-gentinetta Nov 13, 2025
b8fcb7c
Merge remote-tracking branch 'upstream/dev' into feat/scroll_z_and_t
joris-gentinetta Nov 14, 2025
743b932
Merge branch 'dev' into feat/scroll_z_and_t
davehorsfall Nov 14, 2025
9a6b03c
add: only load tiles in screen
joris-gentinetta Nov 14, 2025
59f8dd5
Merge remote-tracking branch 'upstream/dev' into feat/pyramid_in_plate
joris-gentinetta Nov 14, 2025
74c0ca3
Merge branch 'dev' into feat/scroll_z_and_t
davehorsfall Nov 14, 2025
13ed460
fix: only use arrow keys for z / t axis scrolling when z / t keys are…
joris-gentinetta Nov 14, 2025
ee44f17
Merge remote-tracking branch 'origin/dev' into feat/pyramid_in_plate
joris-gentinetta Nov 14, 2025
c052cf8
fix: removed launch.json
joris-gentinetta Nov 14, 2025
b6d36a5
cleanup
joris-gentinetta Nov 14, 2025
c205019
cleanup / linting
joris-gentinetta Nov 14, 2025
c7df876
Merge branch 'feat/scroll_z_and_t' into feat/pyramid_in_plate
joris-gentinetta Nov 14, 2025
160a75c
fix: invert scroll direction for z / t
joris-gentinetta Nov 14, 2025
d79bb22
Merge branch 'feat/scroll_z_and_t' into feat/pyramid_in_plate
joris-gentinetta Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import debounce from "just-debounce-it";
import * as vizarr from "./src/index";

async function main() {
console.log(`vizarr v${vizarr.version}: https://github.com/hms-dbmi/vizarr`);
console.log(`vizarr v${vizarr.version}: https://github.com/BioNGFF/vizarr`);
// biome-ignore lint/style/noNonNullAssertion: We know the element exists
const viewer = await vizarr.createViewer(document.querySelector("#root")!);
const url = new URL(window.location.href);
Expand Down
25 changes: 23 additions & 2 deletions src/ZarrPixelSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,15 @@ export class ZarrPixelSource implements viv.PixelSource<Array<string>> {
async getRaster(options: {
selection: viv.PixelSourceSelection<Array<string>> | Array<number>;
signal?: AbortSignal;
window?: { x: [number, number]; y: [number, number] };
}): Promise<viv.PixelData> {
const { selection, signal } = options;
const { selection, signal, window } = options;
const xSlice = makeSlice(window?.x, this.#width);
const ySlice = makeSlice(window?.y, this.#height);
return this.#fetchData({
selection: buildZarrSelection(selection, {
labels: this.labels,
slices: { x: zarr.slice(null), y: zarr.slice(null) },
slices: { x: xSlice, y: ySlice },
}),
signal,
});
Expand Down Expand Up @@ -180,3 +183,21 @@ function capitalize<T extends string>(s: T): Capitalize<T> {
// @ts-expect-error - TypeScript can't verify that the return type is correct
return s[0].toUpperCase() + s.slice(1);
}

function makeSlice(range: [number, number] | undefined, limit: number) {
if (!range) {
return zarr.slice(null);
}
const [rawStart, rawStop] = range;
if (!Number.isFinite(rawStart) || !Number.isFinite(rawStop)) {
return zarr.slice(null);
}
if (limit <= 0) {
return zarr.slice(null);
}
const maxStart = Math.max(0, limit - 1);
const start = Math.min(Math.max(0, Math.floor(rawStart)), maxStart);
const roundedStop = Math.ceil(rawStop);
const stop = Math.min(limit, Math.max(start + 1, roundedStop));
return zarr.slice(start, stop);
}
145 changes: 138 additions & 7 deletions src/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,123 @@ import { OrthographicView } from "deck.gl";
import { useAtom, useAtomValue } from "jotai";
import * as React from "react";
import { useViewState } from "../hooks";
import { useAxisNavigation } from "../hooks/useAxisNavigation";
import { layerAtoms, viewportAtom } from "../state";
import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils";

import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl";
import type { GrayscaleBitmapLayerPickingInfo } from "../layers/label-layer";
import type { ViewState, VizarrLayer } from "../state";

const VIEWSTATE_EPSILON = 1e-3;

function mapDeckToViewState(next: OrthographicViewState, prev?: ViewState | null): ViewState {
const targetCandidate = (Array.isArray(next.target) ? next.target : (prev?.target ?? [])) as number[];
const resolvedTarget: [number, number] =
targetCandidate.length >= 2
? [Number(targetCandidate[0] ?? 0), Number(targetCandidate[1] ?? 0)]
: (prev?.target ?? [0, 0]);
const zoom = typeof next.zoom === "number" ? next.zoom : (prev?.zoom ?? 0);
const width =
typeof (next as { width?: unknown }).width === "number" ? (next as { width: number }).width : prev?.width;
const height =
typeof (next as { height?: unknown }).height === "number" ? (next as { height: number }).height : prev?.height;
return {
zoom,
target: resolvedTarget,
width,
height,
};
}

function hasViewportDimensions(state: unknown): state is ViewState & { width: number; height: number } {
if (!state || typeof state !== "object") {
return false;
}
const maybe = state as { width?: unknown; height?: unknown };
return typeof maybe.width === "number" && typeof maybe.height === "number";
}

function viewStatesApproximatelyEqual(
a: OrthographicViewState | null,
b: (OrthographicViewState | ViewState) | null,
): boolean {
if (!a || !b) {
return a === (b as OrthographicViewState | null);
}
const nextTarget = Array.isArray(a.target) ? a.target.map((value) => Number(value)) : [];
const rawPrevTarget = Array.isArray((b as OrthographicViewState).target)
? (b as OrthographicViewState).target
: ((b as ViewState).target ?? []);
const prevTarget = (rawPrevTarget as number[]).map((value) => Number(value));
const length = Math.min(nextTarget.length, prevTarget.length);
for (let i = 0; i < length; i += 1) {
if (Math.abs(nextTarget[i] - prevTarget[i]) > VIEWSTATE_EPSILON) {
return false;
}
}
const zoomA = typeof a.zoom === "number" ? a.zoom : 0;
const zoomCandidate = (b as OrthographicViewState).zoom ?? (b as ViewState).zoom;
const zoomB = typeof zoomCandidate === "number" ? zoomCandidate : 0;
return Math.abs(zoomA - zoomB) <= VIEWSTATE_EPSILON;
}

export default function Viewer() {
const deckRef = React.useRef<DeckGLRef>(null);
const [viewport, setViewport] = useAtom(viewportAtom);
const [viewState, setViewState] = useViewState();
const [localViewState, setLocalViewState] = React.useState<OrthographicViewState | null>(null);
const layers = useAtomValue(layerAtoms);
const firstLayer = layers[0] as VizarrLayer;

useAxisNavigation(deckRef, viewport);

const pendingViewStateRef = React.useRef<OrthographicViewState | null>(null);
const pendingFrameRef = React.useRef<number>();
const interactionStateRef = React.useRef({ isActive: false });

const cancelPendingFrame = React.useCallback(() => {
if (pendingFrameRef.current !== undefined) {
window.cancelAnimationFrame(pendingFrameRef.current);
pendingFrameRef.current = undefined;
}
}, []);

const flushPendingViewState = React.useCallback(() => {
cancelPendingFrame();
const next = pendingViewStateRef.current;
pendingViewStateRef.current = null;
if (next) {
setViewState((prev) => mapDeckToViewState(next, prev));
}
}, [cancelPendingFrame, setViewState]);

const scheduleViewStateCommit = React.useCallback(
(next: OrthographicViewState, immediate = false) => {
pendingViewStateRef.current = next;
if (immediate) {
flushPendingViewState();
return;
}
if (pendingFrameRef.current !== undefined) {
return;
}
pendingFrameRef.current = window.requestAnimationFrame(() => {
pendingFrameRef.current = undefined;
flushPendingViewState();
});
},
[flushPendingViewState],
);

React.useEffect(
() => () => {
cancelPendingFrame();
pendingViewStateRef.current = null;
},
[cancelPendingFrame],
);

const resetViewState = React.useCallback(
(layer: VizarrLayer) => {
const { deck } = deckRef.current || {};
Expand Down Expand Up @@ -54,8 +157,23 @@ export default function Viewer() {
}
}, [viewport, setViewport, firstLayer, resetViewState, viewState, setViewState]);

React.useEffect(() => {
if (!viewState) {
cancelPendingFrame();
pendingViewStateRef.current = null;
setLocalViewState(null);
return;
}
if (!viewStatesApproximatelyEqual(pendingViewStateRef.current, viewState)) {
pendingViewStateRef.current = null;
}
setLocalViewState((prev) =>
viewStatesApproximatelyEqual(prev, viewState) ? prev : (viewState as OrthographicViewState),
);
}, [cancelPendingFrame, viewState]);

const deckLayers = React.useMemo(() => {
if (!firstLayer || !(viewState?.width && viewState?.height)) {
if (!firstLayer || !hasViewportDimensions(viewState)) {
return layers;
}
const loader = resolveLoaderFromLayerProps(firstLayer.props);
Expand All @@ -65,7 +183,7 @@ export default function Viewer() {
id: "scalebar",
size: size / firstLayer.props.modelMatrix[0],
unit: unit,
viewState: viewState,
viewState: viewState as unknown as OrthographicViewState,
snap: false,
});
return [...layers, scalebar];
Expand Down Expand Up @@ -138,11 +256,24 @@ export default function Viewer() {
<DeckGL
ref={deckRef}
layers={deckLayers}
viewState={viewState && { ortho: viewState }}
onViewStateChange={(e: { viewState: OrthographicViewState }) =>
// @ts-expect-error - deck doesn't know this should be ok
setViewState(e.viewState)
}
viewState={localViewState ? { ortho: localViewState } : undefined}
controller={{ keyboard: true }}
onViewStateChange={(event: {
viewState: OrthographicViewState;
interactionState?: { inTransition?: boolean };
}) => {
const { viewState: next, interactionState } = event;
setLocalViewState((prev) => (viewStatesApproximatelyEqual(prev, next) ? prev : next));
const immediate = !(interactionState?.inTransition ?? false);
scheduleViewStateCommit(next, immediate);
}}
onInteractionStateChange={(state) => {
const isActive = Boolean(state.isDragging || state.isZooming || state.isRotating || state.isPanning);
if (interactionStateRef.current.isActive && !isActive) {
flushPendingViewState();
}
interactionStateRef.current = { isActive };
}}
views={[new OrthographicView({ id: "ortho", controller: true, near, far })]}
glOptions={glOptions}
getTooltip={getTooltip}
Expand Down
Loading
Loading