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

Feature/thumbnail loading placeholder #268

Merged
merged 6 commits into from
Oct 17, 2024
Merged
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
11 changes: 8 additions & 3 deletions packages/core/components/FileDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function resizeHandleOnMouseDown(mouseDownEvent: React.MouseEvent<HTMLDivElement

if (mouseMoveEvent.buttons === 1 && newWidth >= 175) {
// If primary button (left-click) is still pressed and newWidth is still greater
// than the minimum width we want for the pane
// than the minimum width we want for the pane
rootElement.style.setProperty(FILE_DETAILS_WIDTH_ATTRIBUTE, `${newWidth}px`);
} else {
// Remove this listener if user releases the primary button
Expand Down Expand Up @@ -73,11 +73,16 @@ export default function FileDetails(props: Props) {
const dispatch = useDispatch();
const [fileDetails, isLoading] = useFileDetails();
const [thumbnailPath, setThumbnailPath] = React.useState<string | undefined>();
const [isThumbnailLoading, setIsThumbnailLoading] = React.useState(true);
const stackTokens: IStackTokens = { childrenGap: 12 + " " + 20 };

React.useEffect(() => {
if (fileDetails) {
fileDetails.getPathToThumbnail().then(setThumbnailPath);
setIsThumbnailLoading(true);
fileDetails.getPathToThumbnail().then((path) => {
setThumbnailPath(path);
setIsThumbnailLoading(false);
});
}
}, [fileDetails]);

Expand Down Expand Up @@ -125,8 +130,8 @@ export default function FileDetails(props: Props) {
<FileThumbnail
className={styles.thumbnail}
width="100%"
// height={thumbnailHeight}
uri={thumbnailPath}
loading={isThumbnailLoading}
/>
</div>
<Stack
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@
margin: auto;
text-align: center;
width: 100%;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr
}, [fileSelection, fileSet, overallIndex]);

const [thumbnailPath, setThumbnailPath] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);

React.useEffect(() => {
if (file) {
file.getPathToThumbnail().then(setThumbnailPath);
setIsLoading(true);
file.getPathToThumbnail().then((path) => {
setThumbnailPath(path);
setIsLoading(false);
});
}
}, [file]);

Expand Down Expand Up @@ -112,6 +118,7 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr
height={thumbnailSize}
width={thumbnailSize}
uri={thumbnailPath}
loading={isLoading}
/>
<div
className={classNames({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@
.no-thumbnail {
fill: var(--border-color);
}

.thumbnailPlaceholder {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--border-color);
border: 5px var(--primary-background-color);
aswallace marked this conversation as resolved.
Show resolved Hide resolved
margin: auto;
}
22 changes: 20 additions & 2 deletions packages/core/components/FileThumbnail/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classNames from "classnames";
import * as React from "react";
import { Spinner, SpinnerSize } from "@fluentui/react";

import { NO_IMAGE_ICON_PATH_DATA } from "../../icons";
import SvgIcon from "../SvgIcon";
Expand All @@ -11,12 +12,29 @@ interface Props {
uri?: string;
height?: number | string;
width?: number | string;
loading?: boolean;
}

/**
* Displays the thumbnail for a file.
* Displays the thumbnail for a file, or a spinner if loading, or a placeholder if missing.
*/
export default function FileThumbnail(props: Props) {
// If loading, render the spinner instead of the image
if (props.loading) {
return (
<div
className={classNames(
props.className,
styles.fileThumbnail,
styles.thumbnailPlaceholder
)}
style={{ height: props.height || props.width, width: props.width || props.height }}
>
<Spinner size={SpinnerSize.large} />
</div>
);
}

// Render no thumbnail icon if no URI is provided
if (!props.uri) {
return (
Expand All @@ -34,7 +52,7 @@ export default function FileThumbnail(props: Props) {
<img
className={classNames(props.className, styles.fileThumbnail)}
src={props.uri}
style={{ height: props.height, maxWidth: props.width }}
style={{ height: props.height || props.width, maxWidth: props.width || props.height }}
/>
);
}
184 changes: 112 additions & 72 deletions packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,89 +38,129 @@ function transformAxes(originalArray: AxisData[]): TransformedAxes[] {
value: item.type !== "space" ? 0 : null,
}));
}
/**
* Helper function to handle retry logic with timeout for async operations.
* It retries the operation up to the specified number of times and aborts if it takes too long.
*/
async function retryWithTimeout<T>(fn: () => Promise<T>, retries = 3, timeout = 5000): Promise<T> {
let attempt = 0;
while (attempt < retries) {
try {
return await withTimeout(fn(), timeout);
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the purpose of the timeout here. Does it help the callstack avoid getting overloaded somehow?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From what i can tell some async requests in the zarr render dont register as failures, This just says if its taking too long count it as a failure.

} catch (error) {
attempt++;
console.warn(`Attempt ${attempt} failed. Retrying...`, error);
if (attempt >= retries) {
throw new Error(`Operation failed after ${retries} attempts: ${error}`);
}
}
}
throw new Error("Unexpected error in retry logic");
}

/**
* The purpose of this function is to attempt to render a usable thumbnail using the lowest
* resolution present in a zarr image's metadata. This function looks at the available dimensions
* and determines a slice that would be appropriate to display as a thumbnail and creates a DataURL
* of that slice to provide to users.
* Helper function to enforce a timeout on async operations.
*/
export async function renderZarrThumbnailURL(zarrUrl: string): Promise<string> {
// read base image into store
const store = new zarr.FetchStore(zarrUrl);
const root = zarr.root(store);
const group = await zarr.open(root, { kind: "group" });
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}

// Check that image has readable metadata structure.
// Because zarr images are just directories there is variation here.
if (
!group.attrs ||
!Array.isArray(group.attrs.multiscales) ||
group.attrs.multiscales.length === 0
) {
throw new Error("Invalid multiscales attribute structure");
}
/**
* Main function to attempt to render a usable thumbnail using the lowest
* resolution present in a zarr image's metadata.
*/
export async function renderZarrThumbnailURL(zarrUrl: string): Promise<string | undefined> {
try {
return await retryWithTimeout(
async () => {
// read base image into store
const store = new zarr.FetchStore(zarrUrl);
const root = zarr.root(store);
const group = await zarr.open(root, { kind: "group" });

// Access the image metadata and find lowest resolution.
const { multiscales } = group.attrs;
const datasets = multiscales[0].datasets;
const lowestResolutionDataset = datasets[datasets.length - 1];
const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path);
const lowestResolution = await zarr.open(lowestResolutionLocation, { kind: "array" });
// Check that image has readable metadata structure.
// Because zarr images are just directories there is variation here.
if (
!group.attrs ||
!Array.isArray(group.attrs.multiscales) ||
group.attrs.multiscales.length === 0
) {
throw new Error("Invalid multiscales attribute structure");
}

// Determine a slice of the image that has the best chance at being a good thumbnail.
// X and Y will have full range, while Z will be the middle integer.
// Non-spatial dims will default to None.
const axes = transformAxes(multiscales[0].axes);
const zIndex = axes.findIndex((item) => item.name === "z");
if (zIndex !== -1) {
const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2);
axes[zIndex].value = zSliceIndex;
}
// Access the image metadata and find lowest resolution.
const { multiscales } = group.attrs;
const datasets = multiscales[0].datasets;
const lowestResolutionDataset = datasets[datasets.length - 1];
const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path);
const lowestResolution = await zarr.open(lowestResolutionLocation, {
kind: "array",
});

// Create a view (get data) of the determined lowest resolution using the
// choices made for each axes.
const lowestResolutionView = await zarr.get(
lowestResolution,
axes.map((item) => item.value)
);
const u16data = lowestResolutionView.data as Uint16Array;
// Determine a slice of the image that has the best chance at being a good thumbnail.
// X and Y will have full range, while Z will be the middle integer.
// Non-spatial dims will default to None.
const axes = transformAxes(multiscales[0].axes);
const zIndex = axes.findIndex((item) => item.name === "z");
if (zIndex !== -1) {
const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2);
axes[zIndex].value = zSliceIndex;
}

// Normalize Data to improve image visability.
const min = Math.min(...u16data);
const max = Math.max(...u16data);
const normalizedData = new Uint8Array(u16data.length);
for (let i = 0; i < u16data.length; i++) {
normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min));
}
// Create a view (get data) of the determined lowest resolution using the
// choices made for each axes.
const lowestResolutionView = await zarr.get(
lowestResolution,
axes.map((item) => item.value)
);
const u16data = lowestResolutionView.data as Uint16Array;

// Build a canvas to put image data onto.
const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")];
const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")];
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
// Normalize Data to improve image visibility.
const min = Math.min(...u16data);
const max = Math.max(...u16data);
const normalizedData = new Uint8Array(u16data.length);
for (let i = 0; i < u16data.length; i++) {
normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min));
}

if (!context) {
throw new Error("Failed to get canvas context");
}
// Build a canvas to put image data onto.
const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")];
const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")];
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");

// Draw data to canvas in grayscale.
const imageData = context.createImageData(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const value = normalizedData[y * width + x];
imageData.data[idx] = value; // Red
imageData.data[idx + 1] = value; // Green
imageData.data[idx + 2] = value; // Blue
imageData.data[idx + 3] = 255; // Alpha
}
}
if (!context) {
throw new Error("Failed to get canvas context");
}

context.putImageData(imageData, 0, 0);
// Draw data to canvas in grayscale.
const imageData = context.createImageData(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const value = normalizedData[y * width + x];
imageData.data[idx] = value; // Red
imageData.data[idx + 1] = value; // Green
imageData.data[idx + 2] = value; // Blue
imageData.data[idx + 3] = 255; // Alpha
}
}

// Convert data to data URL
return canvas.toDataURL("image/png");
context.putImageData(imageData, 0, 0);

// Convert data to data URL
return canvas.toDataURL("image/png");
},
3,
5000
);
} catch (error) {
console.error("Failed to render Zarr thumbnail after 3 attempts:", error);
return undefined; // Return undefined if all attempts fail
}
}
Loading