Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 0 additions & 6 deletions fixtures/generic/c4904df5-0005-4a80-953e-62b1ede447b4.yaml

This file was deleted.

1 change: 1 addition & 0 deletions fixtures/local/only_zarr_json.zarr/zarr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
9 changes: 9 additions & 0 deletions viewer/src/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ import {
import theme from "./theme";
import { defer, typedEmitter } from "./utils";

export type LogFunction = (message: string) => void;

export interface Logger {
debug: LogFunction;
info: LogFunction;
warn: LogFunction;
error: LogFunction;
}

type Events = {
viewStateChange: ViewState;
};
Expand Down
11 changes: 0 additions & 11 deletions viewer/src/components/LayerController/ChannelOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { MoreHoriz, Remove } from "@mui/icons-material";
import { Divider, IconButton, Input, NativeSelect, Paper, Popover, Typography } from "@mui/material";
import { styled } from "@mui/material/styles";
import { useSetAtom } from "jotai";
import React, { useState } from "react";
import type { ChangeEvent, MouseEvent } from "react";
import { useLayerState, useSourceData } from "../../hooks";
import { sourceWarningAtom } from "../../state";
import { arraysIdentical, getDefaultChannelLabels } from "../../utils";
import ColorPalette from "./ColorPalette";

const DenseInput = styled(Input)`
Expand All @@ -23,14 +20,6 @@ function ChannelOptions({ channelIndex }: Props) {
const [layer, setLayer] = useLayerState();
const [anchorEl, setAnchorEl] = useState<null | Element>(null);
const { channel_axis, names } = sourceData;
const setSourceWarning = useSetAtom(sourceWarningAtom);
const defaultNames = getDefaultChannelLabels(names.length);

React.useEffect(() => {
if (arraysIdentical(names, defaultNames)) {
setSourceWarning((prev) => [...prev, "Channel metadata either does not exist or was not loaded correctly."]);
}
}, [setSourceWarning, names, defaultNames]);

const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
Expand Down
32 changes: 0 additions & 32 deletions viewer/src/components/Snackbar.tsx

This file was deleted.

66 changes: 54 additions & 12 deletions viewer/src/components/VizarrViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Box, Link, ThemeProvider, Typography } from "@mui/material";
import type { Layer } from "deck.gl";
import { type PrimitiveAtom, Provider, atom, useAtomValue, useSetAtom } from "jotai";
import React, { useId } from "react";
import { getSourceDataError, sourceDataValid, writeUserErrorMessage } from "../error";
import { type SnackbarKey, SnackbarProvider, closeSnackbar, enqueueSnackbar } from "notistack";
import React from "react";
import type { Logger } from "../api";
import {
getSourceDataError,
getSourceDataWarnings,
handleError,
sourceDataValid,
writeUserErrorMessage,
} from "../error";
import { ViewStateContext, useViewState } from "../hooks";
import { createSourceData } from "../io";
import {
Expand All @@ -17,13 +25,11 @@ import {
setZSliceAtom,
sourceErrorAtom,
sourceInfoAtom,
sourceWarningAtom,
viewStateAtom,
viewportAtom,
} from "../state";
import theme from "../theme";
import Menu from "./Menu";
import { InfoSnackbar } from "./Snackbar";
import Viewer from "./Viewer";

/** Viewer state snapshot exposed to the host application via onViewerStateChange. */
Expand Down Expand Up @@ -51,6 +57,7 @@ export interface VizarrViewerProps {
onPluginClick?: (coordinate: [number, number]) => boolean;
onPluginHover?: (coordinate: [number, number] | null) => void;
children?: React.ReactNode;
logger?: Logger;
}

/**
Expand Down Expand Up @@ -131,13 +138,15 @@ function VizarrViewerComponent({
onPluginClick,
onPluginHover,
children,
logger = console,
}: VizarrViewerProps) {
Comment thread
Copilot marked this conversation as resolved.
const setSourceInfo = useSetAtom(sourceInfoAtom);
const setViewStateAtom = useSetAtom(viewStateAtom);
const sourceError = useAtomValue(sourceErrorAtom);
const redirectObj = useAtomValue(redirectObjAtom);
const setSourceError = useSetAtom(sourceErrorAtom);
const sourceWarning = useAtomValue(sourceWarningAtom);
const [snackbarId, setSnackbarId] = React.useState<number | string | undefined>();

React.useEffect(() => {
if (initialViewState) {
setViewStateAtom(initialViewState);
Expand Down Expand Up @@ -166,9 +175,9 @@ function VizarrViewerComponent({
return config;
}),
);

React.useEffect(() => {
async function loadSources() {
logger.debug("Loading sources");
const results = await Promise.allSettled(
configs.map(async (config, index) => {
const sourceData = await createSourceData(config);
Expand All @@ -183,7 +192,9 @@ function VizarrViewerComponent({
);
let sourceDatas = [];
if (!sourceDataValid(results)) {
setSourceError(writeUserErrorMessage(getSourceDataError(results)));
const error = getSourceDataError(results);
setSourceError(writeUserErrorMessage(error));
handleError(error, logger);
}
Comment on lines 194 to 198

for (const res of results) {
Expand All @@ -195,13 +206,48 @@ function VizarrViewerComponent({
}
sourceDatas = sourceDatas.filter((s) => s !== null);
sourceDatas = sourceDatas.flat();

for (const sourceData of sourceDatas) {
const warnings = getSourceDataWarnings(sourceData);
warnings.map((warning: string) => {
handleWarning(warning);
});
}

setSourceInfo(sourceDatas);
}

loadSources();
}, [configs, setSourceInfo, setSourceError]);
}, [configs, setSourceInfo, setSourceError, logger]);

const hideSnackbar = (snackbarId: SnackbarKey) => (
<>
<button
type={"button"}
onClick={() => {
closeSnackbar(snackbarId);
}}
>
Dismiss
</button>
</>
);

function handleWarning(warning: string): void {
logger.warn(warning);
setSnackbarId(enqueueSnackbar(warning, { variant: "warning", action: hideSnackbar }));
}

return (
<>
<div>
<SnackbarProvider
anchorOrigin={{ horizontal: "right", vertical: "top" }}
autoHideDuration={null}
variant={"warning"}
preventDuplicate={true}
/>
</div>
{redirectObj === null && (
<ViewStateContext.Provider value={viewStateAtomWithEffect}>
<ViewerBridge
Expand Down Expand Up @@ -241,10 +287,6 @@ function VizarrViewerComponent({
</p>
</Box>
)}
{sourceWarning.length &&
sourceWarning.map((warning, index) => {
return <InfoSnackbar message={warning} key={useId()} />;
})}
{redirectObj !== null && (
<Box
sx={{
Expand Down
33 changes: 16 additions & 17 deletions viewer/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,9 @@
import type { Logger } from "./api";
import type { SourceData } from "./state";
import { AssertionError } from "./utils";

export const errorToMessageMapping: Record<string, string> = {
"Store does not support range requests": "Sharded .ozx files are not currently supported.",
"Failed to fetch": "An error occurred while trying to fetch the file from the server - this is likely a CORs issue.",
"Node not found: v3 array or group":
"No valid .zattrs, .zarray, .zgroup, or zarr.json was found at this URL - please check that the file exists and is correctly formatted.",
};

import { arraysIdentical, getDefaultChannelLabels } from "./utils";
export function writeUserErrorMessage(error: Error) {
if (error instanceof AssertionError) {
//Error message is raised by this application
return error.message;
}
//Error raised externally
if (Object.keys(errorToMessageMapping).includes(error.message)) {
return errorToMessageMapping[error.message];
}
return "An unknown error occurred.";
return error.message;
}

export function sourceDataValid(sourceData: Array<PromiseSettledResult<SourceData[]>>): boolean {
Expand All @@ -33,3 +19,16 @@ export function getSourceDataError(sourceData: Array<PromiseSettledResult<Source
}
return Error("An unknown error occurred.");
}

export function getSourceDataWarnings(sourceData: SourceData): string[] {
const warnings = [];
if (arraysIdentical(sourceData.names, getDefaultChannelLabels(sourceData.names.length))) {
warnings.push("Using default channel names because no valid channel names were found in the metadata.");
}
return warnings;
}

export function handleError(error: Error, logger: Logger) {
logger.error(error.message);
throw error;
}
Comment on lines +31 to +34
9 changes: 7 additions & 2 deletions viewer/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { DEFAULT_LABEL_OPACITY } from "./layers/label-layer";
import type { BaseLayerProps } from "./layers/viv-layers";
import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from "./state";

import { openZarrRoot } from "./services/http";

async function loadSingleChannel(config: SingleChannelConfig, data: Array<ZarrPixelSource>): Promise<SourceData> {
const { color, contrast_limits, visibility, name, colormap = "", opacity = 1 } = config;
const lowres = data[data.length - 1];
Expand Down Expand Up @@ -74,7 +76,8 @@ async function loadMultiChannel(
}

export async function createSourceData(config: ImageLayerConfig): Promise<SourceData[]> {
const node = await utils.open(config.source);
const node = await openZarrRoot(config.source);

let data: zarr.Array<zarr.DataType, zarr.Readable>[];
let axes: Ome.Axis[] | undefined;
if (node instanceof zarr.Group) {
Expand Down Expand Up @@ -269,7 +272,9 @@ function getSourceSelectionTransform(
);
utils.assert(
labels.labels.every((label) => source.labels.includes(label)),
`Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify(labels.labels)}`,
`Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify(
labels.labels,
)}`,
);
// Identify labels that should always map to 0, regardless of the source selection.
const excludeFromTransformedSelection = new Set(
Expand Down
98 changes: 98 additions & 0 deletions viewer/src/services/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as zarr from "zarrita";
import { normalizeStore } from "../utils";

const MAYBE_CORS_ERROR_MESSAGE = "Failed to fetch";

export class HttpError extends Error {
message: string;
cause: string;
code?: number;
constructor(message: string, cause: string, code?: number) {
super(message);
this.name = "HttpError";
this.message = message;
this.cause = cause;
this.code = code;
Object.setPrototypeOf(this, HttpError.prototype);
}
}

export class MetadataError extends Error {
message: string;
cause: string;
constructor(message: string, cause: string) {
super(message);
this.name = "MetadataError";
this.message = message;
this.cause = cause;
Object.setPrototypeOf(this, MetadataError.prototype);
}
}

export class MetadataNotFoundError extends MetadataError {
constructor(message: string, cause: string) {
super(message, cause);
this.name = "MetadataNotFoundError";
this.message = message;
this.cause = cause;
Object.setPrototypeOf(this, MetadataNotFoundError.prototype);
}
}

export async function openZarrRoot(source: string | zarr.Readable): Promise<zarr.Group<zarr.Readable<unknown>>> {
let url: string;

if (typeof source === "string") {
url = source;
} else {
url = zarr.root(source).path;
}
try {
const { statusText, status } = await fetch(url, { method: "GET" });
if (status === 400) {
throw new HttpError(
`400: The server could not process the request to access the resource at ${source}. The request was invalid.`,
statusText,
status,
);
}

if (status === 403) {
throw new HttpError(
`403: Unauthorized to access resource at ${source}. Please check the specified URL is correct and that permission to access it is not restricted.`,
statusText,
status,
);
}
Comment on lines +60 to +66

if (status === 401) {
throw new HttpError(
`401: Unauthorized to access resource at ${source}. Please check the specified URL is correct and that permission to access it is not restricted.`,
statusText,
status,
);
}

const store = await normalizeStore(source);
const location = await zarr.open(store, { kind: "group" });
return location;
Comment on lines +76 to +78
} catch (error) {
if (error instanceof TypeError) {
if (error.message === MAYBE_CORS_ERROR_MESSAGE) {
throw new HttpError(
`An unknown error occurred while trying to fetch the resource ${source} from the server - this is most likely a CORs issue.`,
error.message,
);
Comment on lines +80 to +85
}
}

if (error instanceof zarr.NodeNotFoundError) {
throw new MetadataNotFoundError(
`404: No valid metadata file found at zarr group ${source}, please check the specified URL is correct.`,
error.message,
);
}

throw error;
}
}
Loading
Loading