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 17 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: 57 additions & 24 deletions src/aics-image-viewer/components/App/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// 3rd Party Imports
import {
CreateLoaderOptions,
IVolumeLoader,
LoadSpec,
PrefetchDirection,
RawArrayData,
RawArrayInfo,
RENDERMODE_PATHTRACE,
RENDERMODE_RAYMARCH,
View3d,
Expand All @@ -19,6 +20,8 @@ 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 +40,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 Down Expand Up @@ -108,17 +112,31 @@ const axisToLoaderPriority: Record<AxisName | "t", PrefetchDirection> = {
x: PrefetchDirection.X_PLUS,
};

const setIndicatorPositions = (view3d: View3d, panelOpen: boolean, hasTime: boolean): void => {
const CLIPPING_PANEL_HEIGHT = 150;
/** `true` if `p` is not an array that contains another array */
const notDoublyNested = <T,>(p: T | (T | T[])[]): p is T | T[] => !Array.isArray(p) || !p.some(Array.isArray);

const setIndicatorPositions = (
view3d: View3d,
panelOpen: boolean,
hasTime: boolean,
hasScenes: boolean,
mode3d: 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 && mode3d;
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 +186,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 | string[])[]>("");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this T | (T | T[])[] i see used a couple of places. Wondering about some readable alias like "valueOrArrayOrArray2D". even T | T[] | T[][] looks simpler to read

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I even wonder if there's a way to formalize it with a simple construct like

type Scene {
  sources: string[];
}

instead of carrying these weird arrays around.
Then the url parsing can just fill an array of Scenes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did roughly the second thing above


const [errorAlert, _showError] = useErrorAlert();
const showError = (error: unknown): void => {
Expand All @@ -187,6 +205,8 @@ const App: React.FC<AppProps> = (props) => {
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;
const singleScene = (props.rawData && props.rawDims) || notDoublyNested(props.imageUrl);
const numScenes = singleScene ? 1 : props.imageUrl.length;

// State for image loading/reloading

Expand Down Expand Up @@ -374,7 +394,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 +416,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[])[] | [{ data: RawArrayData; metadata: RawArrayInfo }];

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 (path === imageUrlRef.current) {
return;
}
imageUrlRef.current = path;

scenePaths = notDoublyNested(path) ? [path] : path;
}

setSendingQueryRequest(true);
Expand All @@ -409,10 +440,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 +448,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 +504,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 +533,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 +732,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 +861,7 @@ const App: React.FC<AppProps> = (props) => {
numSlices={numSlices}
numSlicesLoaded={numSlicesLoaded}
numTimesteps={numTimesteps}
numScenes={numScenes}
playControls={playControls}
playingAxis={playingAxis}
appHeight={props.appHeight}
Expand Down
13 changes: 11 additions & 2 deletions src/aics-image-viewer/components/App/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,23 @@ export type ControlVisibilityFlags = { [K in ControlNames]: boolean };

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[];

// The inner level of array(s), if present, groups multiple sources into a single volume with all sources' channels.
// If there is an outer array level, it groups multiple volumes into a single multi-scene collection.
// To clarify:
// - A bare string is a single volume scene with a single source.
// - An array of only strings is interpreted as a single volume with multiple sources, not a multi-scene collection.
// - An array of strings *or* string arrays is a multi-scene collection, and all strings within the top-level array
// are treated as if they were string arrays with one element (i.e. volumes with one source).
imageUrl: string | (string | string[])[];
parentImageUrl?: string | (string | string[])[];

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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { View3d, Volume } from "@aics/vole-core";
import { LoadingOutlined } from "@ant-design/icons";
import React from "react";

import { CLIPPING_PANEL_HEIGHT_DEFAULT, CLIPPING_PANEL_HEIGHT_TALL } from "../../shared/constants";
import { ViewMode } from "../../shared/enums";
import { AxisName, PerAxis, Styles } from "../../shared/types";
import PlayControls from "../../shared/utils/playControls";
Expand All @@ -24,10 +25,11 @@ type ViewerWrapperProps = {
playControls: PlayControls;
playingAxis: AxisName | "t" | null;
numTimesteps: number;
numScenes: number;
visibleControls: {
axisClipSliders: boolean;
};
onClippingPanelVisibleChange?: (panelOpen: boolean, hasTime: boolean) => void;
onClippingPanelVisibleChange?: (panelOpen: boolean, hasTime: boolean, hasScenes: boolean, mode3d: boolean) => void;
onClippingPanelVisibleChangeEnd?: (panelOpen: boolean) => void;

// From viewer state
Expand All @@ -36,6 +38,7 @@ type ViewerWrapperProps = {
region: PerAxis<[number, number]>;
slice: PerAxis<number>;
time: number;
scene: number;
changeViewerSetting: ViewerSettingUpdater;
};

Expand Down Expand Up @@ -69,28 +72,34 @@ const ViewerWrapper: React.FC<ViewerWrapperProps> = (props) => {
return noImageText || spinner;
};

const { appHeight, changeViewerSetting, visibleControls, numSlices, numTimesteps, viewMode, region, slice, time } =
const { appHeight, changeViewerSetting, visibleControls, numTimesteps, numScenes, viewMode, region, slice, time } =
props;
const clippingPanelTall = numTimesteps > 1 && numScenes > 1 && viewMode === ViewMode.threeD;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check happens in two places... For DRY, should it just be a single flag that's passed in as a prop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought here was that, since this property is derivable from other passed properties, it was better not to pass it in explicitly and risk an "invalid" value. Though this value has less of a strong notion of "invalid" than, say, a string along with an isAllCaps: boolean value. So maybe it would be better?


return (
<div className="cell-canvas" style={{ ...STYLES.viewer, height: appHeight }}>
<div ref={view3dviewerRef} style={STYLES.view3d}></div>
<BottomPanel
title="Clipping"
onVisibleChange={(visible) => props.onClippingPanelVisibleChange?.(visible, numTimesteps > 1)}
onVisibleChange={(visible) => {
props.onClippingPanelVisibleChange?.(visible, numTimesteps > 1, numScenes > 1, viewMode === ViewMode.threeD);
}}
onVisibleChangeEnd={props.onClippingPanelVisibleChangeEnd}
height={clippingPanelTall ? CLIPPING_PANEL_HEIGHT_TALL : CLIPPING_PANEL_HEIGHT_DEFAULT}
>
{visibleControls.axisClipSliders && !!props.image && (
<AxisClipSliders
mode={viewMode}
image={props.image}
changeViewerSetting={changeViewerSetting}
numSlices={numSlices}
numSlices={props.numSlices}
numSlicesLoaded={props.numSlicesLoaded}
numScenes={numScenes}
region={region}
slices={slice}
numTimesteps={numTimesteps}
time={time}
scene={props.scene}
playControls={props.playControls}
playingAxis={props.playingAxis}
/>
Expand All @@ -107,6 +116,7 @@ export default connectToViewerState(ViewerWrapper, [
"region",
"slice",
"time",
"scene",
"changeViewerSetting",
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ViewerState {
// This state is active in x,y,z single slice modes.
slice: PerAxis<number>;
time: number;
scene: number;
cameraState: Partial<CameraState> | undefined;
}

Expand Down
Loading