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 4 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
26 changes: 11 additions & 15 deletions src/aics-image-viewer/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import {
CreateLoaderOptions,
LoadSpec,
PrefetchDirection,
RawArrayData,
RawArrayInfo,
RawArrayLoaderOptions,
RENDERMODE_PATHTRACE,
RENDERMODE_RAYMARCH,
View3d,
Expand All @@ -13,7 +12,7 @@ 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";

Expand Down Expand Up @@ -50,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 @@ -112,15 +111,12 @@ const axisToLoaderPriority: Record<AxisName | "t", PrefetchDirection> = {
x: PrefetchDirection.X_PLUS,
};

/** `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
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;
Expand All @@ -131,7 +127,7 @@ const setIndicatorPositions = (
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 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
Expand Down Expand Up @@ -188,7 +184,7 @@ const App: React.FC<AppProps> = (props) => {

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

const [errorAlert, _showError] = useErrorAlert();
const showError = (error: unknown): void => {
Expand All @@ -202,11 +198,11 @@ 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;
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 @@ -417,20 +413,20 @@ const App: React.FC<AppProps> = (props) => {
const openImage = async (): Promise<void> => {
const { imageUrl, parentImageUrl, rawData, rawDims } = props;
const scene = viewerState.current.scene;
let scenePaths: (string | string[])[] | [{ data: RawArrayData; metadata: RawArrayInfo }];
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 (path === imageUrlRef.current) {
if (isEqual(path, imageUrlRef.current)) {
return;
}
imageUrlRef.current = path;

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

setSendingQueryRequest(true);
Expand Down
19 changes: 10 additions & 9 deletions src/aics-image-viewer/components/App/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ 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

Expand All @@ -39,15 +41,14 @@ export interface AppProps {

// SECOND WAY TO GET DATA INTO THE VIEWER: (if `rawData`/`rawDims` isn't present) pass in URL(s) to fetch volume data

// 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[])[];
/**
* 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
16 changes: 6 additions & 10 deletions website/components/AppWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { ReactElement, useEffect, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";

import { ImageViewerApp, ViewerStateProvider } from "../../src";
import { MultisceneUrls } from "../../src/aics-image-viewer/components/App/types";
import { ViewerState } from "../../src/aics-image-viewer/components/ViewerStateProvider/types";
import { getDefaultViewerChannelSettings } from "../../src/aics-image-viewer/shared/constants";
import { AppDataProps } from "../types";
Expand Down Expand Up @@ -68,16 +69,11 @@ export default function AppWrapper(): ReactElement {
// Force a page reload when loading new data. This prevents a bug where a desync in the number
// of channels in the viewer can cause a crash. The root cause is React immediately forcing a
// re-render every time `setState` is called in an async function.
const url = appProps.imageUrl;
if (Array.isArray(url)) {
navigation(`/viewer?url=${encodeURIComponent(url.join(","))}`, {
state: appProps,
});
} else {
navigation(`/viewer?url=${encodeURIComponent(url)}`, {
state: appProps,
});
}
const urls = (appProps.imageUrl as MultisceneUrls).scenes ?? [appProps.imageUrl];
const sceneUrlsUnencoded = urls.map((scene) => (Array.isArray(scene) ? scene.join(",") : scene));
navigation(`/viewer?url=${encodeURIComponent(sceneUrlsUnencoded.join(" "))}`, {
state: appProps,
});
navigation(0);
};

Expand Down
16 changes: 6 additions & 10 deletions website/components/LandingPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useNavigate } from "react-router";
import { useSearchParams } from "react-router-dom";
import styled from "styled-components";

import { MultisceneUrls } from "../../../src/aics-image-viewer/components/App/types";
import { BannerVideo } from "../../assets/videos";
import { AppDataProps, DatasetEntry, ProjectEntry } from "../../types";
import { parseViewerUrlParams } from "../../utils/url_utils";
Expand Down Expand Up @@ -253,16 +254,11 @@ export default function LandingPage(): ReactElement {
const onClickLoad = (appProps: AppDataProps): void => {
// TODO: Make URL search params from the appProps and append it to the viewer URL so the URL can be shared directly.
// Alternatively, AppWrapper should manage syncing URL and viewer props.
const url = appProps.imageUrl;
if (Array.isArray(url)) {
navigation(`/viewer?url=${encodeURIComponent(url.join(","))}`, {
state: appProps,
});
} else {
navigation(`/viewer?url=${encodeURIComponent(url)}`, {
state: appProps,
});
}
const urls = (appProps.imageUrl as MultisceneUrls).scenes ?? [appProps.imageUrl];
const sceneUrlsUnencoded = urls.map((scene) => (Array.isArray(scene) ? scene.join(",") : scene));
navigation(`/viewer?url=${encodeURIComponent(sceneUrlsUnencoded.join(" "))}`, {
Comment on lines +257 to +259
Copy link
Contributor

Choose a reason for hiding this comment

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

If this code is copied with AppWrapper maybe it's worth putting in a function?

state: appProps,
});
};

// TODO: Should the load buttons be link elements or buttons?
Expand Down
6 changes: 3 additions & 3 deletions website/components/Modals/ShareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { Button, Input, Modal, notification } from "antd";
import React, { useRef, useState } from "react";
import styled from "styled-components";

import { MultisceneUrls } from "../../../src/aics-image-viewer/components/App/types";
import { ViewerStateContextType } from "../../../src/aics-image-viewer/components/ViewerStateProvider/types";
import { AppDataProps } from "../../types";
import { notDoublyNested } from "../../utils/datatype_utils";
import { ENCODED_COLON_REGEX, ENCODED_COMMA_REGEX, serializeViewerUrlParams } from "../../utils/url_utils";
import { FlexRow } from "../LandingPage/utils";

Expand Down Expand Up @@ -44,8 +44,8 @@ const ShareModal: React.FC<ShareModalProps> = (props: ShareModalProps) => {
const { imageUrl } = props.appProps;

if (imageUrl) {
const url = notDoublyNested(imageUrl) ? [imageUrl] : imageUrl;
const serializedUrl = url
const urls = (imageUrl as MultisceneUrls).scenes ?? [imageUrl];
const serializedUrl = urls
.map((scene) => {
if (Array.isArray(scene)) {
return scene.map((url) => encodeURIComponent(url)).join(",");
Expand Down
3 changes: 0 additions & 3 deletions website/utils/datatype_utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { isEqual } from "lodash";

/** `true` if `p` is not an array that contains another array */
export const notDoublyNested = <T>(p: T | (T | T[])[]): p is T | T[] => !Array.isArray(p) || !p.some(Array.isArray);

/**
* Returns a (shallow) copy of an object with all properties that are
* `undefined` removed.
Expand Down
13 changes: 9 additions & 4 deletions website/utils/url_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CameraState, ControlPoint } from "@aics/vole-core";
import { isEqual } from "lodash";

import FirebaseRequest, { DatasetMetaData } from "../../public/firebase";
import type { AppProps } from "../../src/aics-image-viewer/components/App/types";
import type { AppProps, MultisceneUrls } from "../../src/aics-image-viewer/components/App/types";
import type {
ChannelState,
ViewerState,
Expand Down Expand Up @@ -960,12 +960,17 @@ export async function parseViewerUrlParams(urlSearchParams: URLSearchParams): Pr

// Parse data sources (URL or dataset/id pair)
if (params.url) {
// split encoded url into a list of one or more scenes...
const sceneUrls = tryDecodeURLList(params.url, " ") ?? [params.url];
const sourceUrls = sceneUrls.map((scene) => tryDecodeURLList(scene) ?? [decodeURL(scene)]);
// ...and each scene into a list of multiple sources, if any.
const scenes = sceneUrls.map((scene) => tryDecodeURLList(scene) ?? decodeURL(scene));

const firstScene = sourceUrls[0];
const firstScene = scenes[0];
// Get the very first URL for the download button
const firstUrl = Array.isArray(firstScene) ? firstScene[0] : firstScene;
const imageUrls = sourceUrls.length === 1 ? (sourceUrls[0].length === 1 ? firstScene[0] : firstScene) : sourceUrls;
// Strip away unneeded structure from final prop (don't represent a multiscene or multi-source image unless needed)
const imageUrls: string | string[] | MultisceneUrls =
scenes.length > 1 ? { scenes } : firstScene.length > 1 ? firstScene : firstScene[0];

args.cellId = "1";
args.imageUrl = imageUrls;
Expand Down