Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-scene collections #362

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
81 changes: 55 additions & 26 deletions src/aics-image-viewer/components/App/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// 3rd Party Imports
import {
CreateLoaderOptions,
IVolumeLoader,
LoadSpec,
PrefetchDirection,
RawArrayLoaderOptions,
RENDERMODE_PATHTRACE,
RENDERMODE_RAYMARCH,
View3d,
Expand All @@ -12,13 +12,15 @@ import {
VolumeLoaderContext,
} from "@aics/vole-core";
import { Layout } from "antd";
import { debounce } from "lodash";
import { debounce, isEqual } from "lodash";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Box3, Vector3 } from "three";

import {
AXIS_MARGIN_DEFAULT,
CACHE_MAX_SIZE,
CLIPPING_PANEL_HEIGHT_DEFAULT,
CLIPPING_PANEL_HEIGHT_TALL,
CONTROL_PANEL_CLOSE_WIDTH,
getDefaultChannelColor,
getDefaultViewerState,
Expand All @@ -37,6 +39,7 @@ import {
} from "../../shared/utils/controlPointsToLut";
import { useConstructor, useStateWithGetter } from "../../shared/utils/hooks";
import PlayControls from "../../shared/utils/playControls";
import SceneStore from "../../shared/utils/sceneStore";
import {
alphaSliderToImageValue,
brightnessSliderToImageValue,
Expand All @@ -46,7 +49,7 @@ import {
import { ChannelGrouping, getDisplayName, makeChannelIndexGrouping } from "../../shared/utils/viewerChannelSettings";
import { initializeOneChannelSetting } from "../../shared/utils/viewerState";
import type { ChannelState } from "../ViewerStateProvider/types";
import type { AppProps, ControlVisibilityFlags, UseImageEffectType } from "./types";
import type { AppProps, ControlVisibilityFlags, MultisceneUrls, UseImageEffectType } from "./types";

import CellViewerCanvasWrapper from "../CellViewerCanvasWrapper";
import ControlPanel from "../ControlPanel";
Expand Down Expand Up @@ -108,17 +111,28 @@ const axisToLoaderPriority: Record<AxisName | "t", PrefetchDirection> = {
x: PrefetchDirection.X_PLUS,
};

const setIndicatorPositions = (view3d: View3d, panelOpen: boolean, hasTime: boolean): void => {
const CLIPPING_PANEL_HEIGHT = 150;
const setIndicatorPositions = (
view3d: View3d,
panelOpen: boolean,
hasTime: boolean,
hasScenes: boolean,
isMode3d: boolean
): void => {
// The height of the clipping panel includes the button, but we're trying to put these elements next to the button
const CLIPPING_PANEL_BUTTON_HEIGHT = 40;
// Move scale bars this far to the left when showing time series, to make room for timestep indicator
const SCALE_BAR_TIME_SERIES_OFFSET = 120;

let axisY = AXIS_MARGIN_DEFAULT[1];
let [scaleBarX, scaleBarY] = SCALE_BAR_MARGIN_DEFAULT;
if (panelOpen) {
// If we have Time, Scene, X, Y, and Z sliders, the drawer will need to be a bit taller
let isTall = hasTime && hasScenes && isMode3d;
let clippingPanelFullHeight = isTall ? CLIPPING_PANEL_HEIGHT_TALL : CLIPPING_PANEL_HEIGHT_DEFAULT;
let clippingPanelHeight = clippingPanelFullHeight - CLIPPING_PANEL_BUTTON_HEIGHT;
// Move indicators up out of the way of the clipping panel
axisY += CLIPPING_PANEL_HEIGHT;
scaleBarY += CLIPPING_PANEL_HEIGHT;
axisY += clippingPanelHeight;
scaleBarY += clippingPanelHeight;
}
if (hasTime) {
// Move scale bar left out of the way of timestep indicator
Expand Down Expand Up @@ -168,9 +182,9 @@ const App: React.FC<AppProps> = (props) => {
// install loadContext into view3d
view3d.loaderContext = loadContext;

const loader = useRef<IVolumeLoader>();
const loader = useRef<SceneStore>();
const [image, setImage] = useState<Volume | null>(null);
const imageUrlRef = useRef<string | string[]>("");
const imageUrlRef = useRef<string | string[] | MultisceneUrls>("");

const [errorAlert, _showError] = useErrorAlert();
const showError = (error: unknown): void => {
Expand All @@ -184,6 +198,8 @@ const App: React.FC<AppProps> = (props) => {
return () => view3d.setLoadErrorHandler(undefined);
}, [view3d]);

const hasRawImage = !!(props.rawData && props.rawDims);
const numScenes = hasRawImage ? 1 : ((props.imageUrl as MultisceneUrls).scenes?.length ?? 1);
const numSlices: PerAxis<number> = image?.imageInfo.volumeSize ?? { x: 0, y: 0, z: 0 };
const numSlicesLoaded: PerAxis<number> = image?.imageInfo.subregionSize ?? { x: 0, y: 0, z: 0 };
const numTimesteps = image?.imageInfo.times ?? 1;
Expand Down Expand Up @@ -374,7 +390,8 @@ const App: React.FC<AppProps> = (props) => {
}),
});

setIndicatorPositions(view3d, clippingPanelOpenRef.current, aimg.imageInfo.times > 1);
const mode3d = viewerSettings.viewMode === ViewMode.threeD;
setIndicatorPositions(view3d, clippingPanelOpenRef.current, aimg.imageInfo.times > 1, numScenes > 1, mode3d);
imageLoadHandlers.current.forEach((effect) => effect(aimg));

playControls.stepAxis = (axis: AxisName | "t") => {
Expand All @@ -395,11 +412,21 @@ const App: React.FC<AppProps> = (props) => {

const openImage = async (): Promise<void> => {
const { imageUrl, parentImageUrl, rawData, rawDims } = props;
const showParentImage = viewerState.current.imageType === ImageType.fullField && parentImageUrl !== undefined;
const path = showParentImage ? parentImageUrl : imageUrl;
// Don't reload if we're already looking at this image
if (path === imageUrlRef.current && !rawData && !rawDims) {
return;
const scene = viewerState.current.scene;
let scenePaths: (string | string[])[] | [RawArrayLoaderOptions];

if (rawData && rawDims) {
scenePaths = [{ data: rawData, metadata: rawDims }];
} else {
const showParentImage = viewerState.current.imageType === ImageType.fullField && parentImageUrl !== undefined;
const path = showParentImage ? parentImageUrl : imageUrl;
// Don't reload if we're already looking at this image
if (isEqual(path, imageUrlRef.current)) {
return;
}
imageUrlRef.current = path;

scenePaths = (path as MultisceneUrls).scenes ?? [path];
}

setSendingQueryRequest(true);
Expand All @@ -409,10 +436,6 @@ const App: React.FC<AppProps> = (props) => {
const loadSpec = new LoadSpec();
loadSpec.time = viewerState.current.time;

// if this does NOT end with tif or json,
// then we assume it's zarr.
await loadContext.onOpen();

const options: Partial<CreateLoaderOptions> = {};
if (rawData && rawDims) {
options.fileType = VolumeFileFormat.DATA;
Expand All @@ -421,9 +444,9 @@ const App: React.FC<AppProps> = (props) => {

let aimg: Volume;
try {
loader.current = await loadContext.createLoader(path, { ...options });
loader.current = new SceneStore(loadContext, scenePaths);

aimg = await loader.current.createVolume(loadSpec, (v, channelIndex) => {
aimg = await loader.current.createVolume(scene, loadSpec, (v, channelIndex) => {
// NOTE: this callback runs *after* `onNewVolumeCreated` below, for every loaded channel
// TODO is this search by name necessary or will the `channelIndex` passed to the callback always match state?
const thisChannelSettings = viewerState.current.channelSettings[channelIndex];
Expand Down Expand Up @@ -477,12 +500,10 @@ const App: React.FC<AppProps> = (props) => {

// initiate loading only after setting up new channel settings,
// in case the loader callback fires before the state is set
loader.current.loadVolumeData(aimg, requiredLoadSpec).catch((e) => {
loader.current.loadScene(scene, aimg, requiredLoadSpec).catch((e) => {
showError(e);
throw e;
});

imageUrlRef.current = path;
};

// Imperative callbacks /////////////////////////////////////////////////////
Expand All @@ -508,9 +529,9 @@ const App: React.FC<AppProps> = (props) => {
const resetCamera = useCallback((): void => view3d.resetCamera(), []);

const onClippingPanelVisibleChange = useCallback(
(panelOpen: boolean, hasTime: boolean): void => {
(panelOpen: boolean, hasTime: boolean, hasScenes: boolean, mode3d: boolean): void => {
clippingPanelOpenRef.current = panelOpen;
setIndicatorPositions(view3d, panelOpen, hasTime);
setIndicatorPositions(view3d, panelOpen, hasTime, hasScenes, mode3d);

// Hide indicators while clipping panel is in motion - otherwise they pop to the right place prematurely
view3d.setShowScaleBar(false);
Expand Down Expand Up @@ -707,6 +728,13 @@ const App: React.FC<AppProps> = (props) => {
}
}, [viewerSettings.time]);

useEffect(() => {
if (image) {
setSendingQueryRequest(true);
loader.current?.loadScene(viewerSettings.scene, image).catch((e) => showError(e));
}
}, [viewerSettings.scene]);

useImageLoadEffect(
(currentImage) => view3d.setInterpolationEnabled(currentImage, viewerSettings.interpolationEnabled),
[viewerSettings.interpolationEnabled]
Expand Down Expand Up @@ -829,6 +857,7 @@ const App: React.FC<AppProps> = (props) => {
numSlices={numSlices}
numSlicesLoaded={numSlicesLoaded}
numTimesteps={numTimesteps}
numScenes={numScenes}
playControls={playControls}
playingAxis={playingAxis}
appHeight={props.appHeight}
Expand Down
14 changes: 12 additions & 2 deletions src/aics-image-viewer/components/App/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,26 @@ type ControlNames =
/** Show/hide different elements of the UI */
export type ControlVisibilityFlags = { [K in ControlNames]: boolean };

export type MultisceneUrls = { scenes: (string | string[])[] };

export interface AppProps {
// FIRST WAY TO GET DATA INTO THE VIEWER: pass in volume data directly

// rawData has a "dtype" which is expected to be "uint8", a "shape":[c,z,y,x] and a "buffer" which is a DataView
rawData?: RawArrayData;
// rawDims is a small amount of metadata (e.g. dimensions and channel names) to be converted internally to an ImageInfo
rawDims?: RawArrayInfo;

// SECOND WAY TO GET DATA INTO THE VIEWER: (if `rawData`/`rawDims` isn't present) pass in URL(s) to fetch volume data
imageUrl: string | string[];
parentImageUrl?: string | string[];

/**
* URL(s) from which to fetch the image. You can pass a `string` to load from a single data source, or get fancier:
* - Pass an array of strings to assemble a single volume with all sources' channels, in order.
* - Pass an object with a key `scenes: (string | string[])[]` to load multiple volumes as a *multi-scene collection*.
* Each string or string array within the `scenes` array is treated as a single volume with one or more sources.
*/
imageUrl: string | string[] | MultisceneUrls;
parentImageUrl?: string | string[] | MultisceneUrls;

viewerChannelSettings?: ViewerChannelSettings;

Expand Down
18 changes: 18 additions & 0 deletions src/aics-image-viewer/components/AxisClipSliders/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,12 @@ type AxisClipSlidersProps = {
changeViewerSetting: ViewerSettingUpdater;
numSlices: PerAxis<number>;
numSlicesLoaded: PerAxis<number>;
numScenes: number;
region: PerAxis<[number, number]>;
slices: PerAxis<number>;
numTimesteps: number;
time: number;
scene: number;
playingAxis: AxisName | "t" | null;
playControls: PlayControls;
};
Expand Down Expand Up @@ -288,6 +290,22 @@ const AxisClipSliders: React.FC<AxisClipSlidersProps> = (props) => {
</span>
</span>
)}

{props.numScenes > 1 && (
<span className="slider-group">
<h4 className="slider-group-title">Scene</h4>
<span className="slider-group-rows">
<div className="slider-row slider-scene">
<SliderRow
label={""}
vals={[props.scene]}
max={props.numScenes}
onChange={([scene]) => props.changeViewerSetting("scene", scene)}
/>
</div>
</span>
</span>
)}
</div>
);
};
Expand Down
15 changes: 12 additions & 3 deletions src/aics-image-viewer/components/BottomPanel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import React, { useState, useEffect } from "react";
import { Drawer, Button } from "antd";
import { Button, Drawer } from "antd";
import React, { useEffect, useState } from "react";

import ViewerIcon from "../shared/ViewerIcon";

import "./styles.css";

type BottomPanelProps = {
title?: string;
onVisibleChange?: (visible: boolean) => void;
onVisibleChangeEnd?: (visible: boolean) => void;
children?: React.ReactNode;
height?: number;
};

const BottomPanel: React.FC<BottomPanelProps> = ({ children, title, onVisibleChange, onVisibleChangeEnd }) => {
const BottomPanel: React.FC<BottomPanelProps> = ({ children, title, height, onVisibleChange, onVisibleChangeEnd }) => {
const [isVisible, setIsVisible] = useState(true);

// Call `onVisibleChange` on mount
Expand All @@ -22,6 +24,12 @@ const BottomPanel: React.FC<BottomPanelProps> = ({ children, title, onVisibleCha
}
}, []);

// Treat changes in height as a change in visibility if the panel is open
useEffect(() => {
onVisibleChange?.(isVisible);
window.setTimeout(() => onVisibleChangeEnd?.(isVisible), 300);
}, [height]);

const toggleDrawer = (): void => {
setIsVisible(!isVisible);
if (onVisibleChange) {
Expand All @@ -47,6 +55,7 @@ const BottomPanel: React.FC<BottomPanelProps> = ({ children, title, onVisibleCha
mask={false}
title={optionsButton}
afterOpenChange={onVisibleChangeEnd}
height={height ?? 190}
>
<div className="drawer-body-wrapper">{children}</div>
</Drawer>
Expand Down
3 changes: 1 addition & 2 deletions src/aics-image-viewer/components/BottomPanel/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
width: 100%;

.ant-drawer .ant-drawer-content-wrapper {
transform: translateY(69%);
transform: translateY(calc(100% - 55px));
}

.ant-drawer-content-wrapper {
display: flex;
flex-direction: column;
justify-content: flex-end;
height: 190px !important;
transition: all 0.3s, max-width 0s 0s;
opacity: 85%;
box-shadow: none;
Expand Down
Loading