diff --git a/package-lock.json b/package-lock.json index 262b5f95..82a6ae8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "lodash": "^4.17.20", "nouislider-react": "^3.4.0", "react-color": "^2.19.3", + "react-router-dom": "^6.22.3", "styled-components": "^6.1.8" }, "devDependencies": { @@ -3631,6 +3632,14 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "dev": true }, + "node_modules/@remix-run/router": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", + "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", @@ -17050,6 +17059,36 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "dev": true }, + "node_modules/react-router": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", + "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "dependencies": { + "@remix-run/router": "1.15.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", + "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "dependencies": { + "@remix-run/router": "1.15.3", + "react-router": "6.22.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-slick": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.25.2.tgz", diff --git a/package.json b/package.json index 115983a3..1667cc9e 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "lodash": "^4.17.20", "nouislider-react": "^3.4.0", "react-color": "^2.19.3", + "react-router-dom": "^6.22.3", "styled-components": "^6.1.8" }, "peerDependencies": { diff --git a/public/index.tsx b/public/index.tsx index 54bceb88..e0a4c958 100644 --- a/public/index.tsx +++ b/public/index.tsx @@ -1,262 +1,47 @@ import React from "react"; import ReactDOM from "react-dom"; +import { createBrowserRouter, createHashRouter, RouterProvider } from "react-router-dom"; +import { Router } from "@remix-run/router"; import "antd/dist/antd.less"; // Components +import AppWrapper from "../website/components/AppWrapper"; +import LandingPage from "../website/components/LandingPage"; +import ErrorPage from "../website/components/ErrorPage"; +import StyleProvider from "../src/aics-image-viewer/components/StyleProvider"; import "../src/aics-image-viewer/assets/styles/typography.css"; import "./App.css"; -import { ImageViewerApp, RenderMode, ViewerChannelSettings, ViewMode } from "../src"; -import FirebaseRequest, { DatasetMetaData } from "./firebase"; -import { AppProps, GlobalViewerSettings } from "../src/aics-image-viewer/components/App/types"; - // vars filled at build time using webpack DefinePlugin console.log(`website-3d-cell-viewer ${WEBSITE3DCELLVIEWER_BUILD_ENVIRONMENT} build`); console.log(`website-3d-cell-viewer Version ${WEBSITE3DCELLVIEWER_VERSION}`); console.log(`volume-viewer Version ${VOLUMEVIEWER_VERSION}`); -export const VIEWER_3D_SETTINGS: ViewerChannelSettings = { - groups: [ - { - name: "Observed channels", - channels: [ - { name: "Membrane", match: ["(CMDRP)"], color: "E2CDB3", enabled: true, lut: ["p50", "p98"] }, - { - name: "Labeled structure", - match: ["(EGFP)|(RFPT)"], - color: "6FBA11", - enabled: true, - lut: ["p50", "p98"], - }, - { name: "DNA", match: ["(H3342)"], color: "8DA3C0", enabled: true, lut: ["p50", "p98"] }, - { name: "Bright field", match: ["(100)|(Bright)"], color: "F5F1CB", enabled: false, lut: ["p50", "p98"] }, - ], - }, - { - name: "Segmentation channels", - channels: [ - { - name: "Labeled structure", - match: ["(SEG_STRUCT)"], - color: "E0E3D1", - enabled: false, - lut: ["p50", "p98"], - }, - { name: "Membrane", match: ["(SEG_Memb)"], color: "DD9BF5", enabled: false, lut: ["p50", "p98"] }, - { name: "DNA", match: ["(SEG_DNA)"], color: "E3F4F5", enabled: false, lut: ["p50", "p98"] }, - ], - }, - { - name: "Contour channels", - channels: [ - { name: "Membrane", match: ["(CON_Memb)"], color: "FF6200", enabled: false, lut: ["p50", "p98"] }, - { name: "DNA", match: ["(CON_DNA)"], color: "F7DB78", enabled: false, lut: ["p50", "p98"] }, - ], - }, - ], - // must be the true channel name in the volume data - maskChannelName: "SEG_Memb", -}; - -type ParamKeys = "mask" | "ch" | "luts" | "colors" | "url" | "file" | "dataset" | "id" | "view"; -type Params = { [_ in ParamKeys]?: string }; - -function parseQueryString(): Params { - const pairs = location.search.slice(1).split("&"); - const result = {}; - pairs.forEach((pairString) => { - const pair = pairString.split("="); - result[pair[0]] = decodeURIComponent(pair[1] || ""); - }); - return JSON.parse(JSON.stringify(result)); -} -const params = parseQueryString(); - -const decodeURL = (url: string): string => { - const decodedUrl = decodeURIComponent(url); - return decodedUrl.endsWith("/") ? decodedUrl.slice(0, -1) : decodedUrl; -}; - -/** Try to parse a `string` as a list of 2 or more URLs. Returns `undefined` if the string is not a valid URL list. */ -const tryDecodeURLList = (url: string, delim = ","): string[] | undefined => { - if (!url.includes(delim)) { - return undefined; - } - - const urls = url.split(delim).map((u) => decodeURL(u)); - - // Verify that all urls are valid - for (const u of urls) { - try { - new URL(u); - } catch (_e) { - return undefined; - } - } - - return urls; -}; - -const BASE_URL = "https://s3-us-west-2.amazonaws.com/bisque.allencell.org/v1.4.0/Cell-Viewer_Thumbnails/"; -const args: Omit = { - cellId: "2025", - imageUrl: BASE_URL + "AICS-22/AICS-22_8319_2025_atlas.json", - parentImageUrl: BASE_URL + "AICS-22/AICS-22_8319_atlas.json", - parentImageDownloadHref: "https://files.allencell.org/api/2.0/file/download?collection=cellviewer-1-4/?id=F8319", - imageDownloadHref: "https://files.allencell.org/api/2.0/file/download?collection=cellviewer-1-4/?id=C2025", - viewerChannelSettings: VIEWER_3D_SETTINGS, -}; -const viewerSettings: Partial = { - showAxes: false, - showBoundingBox: false, - autorotate: false, - viewMode: ViewMode.threeD, - renderMode: RenderMode.volumetric, - maskAlpha: 50, - brightness: 70, - density: 50, - levels: [0, 128, 255] as [number, number, number], - backgroundColor: [0, 0, 0] as [number, number, number], - boundingBoxColor: [255, 255, 255] as [number, number, number], -}; - -async function loadDataset(dataset: string, id: string) { - const db = new FirebaseRequest(); - - const datasets = await db.getAvailableDatasets(); - - let datasetMeta: DatasetMetaData | undefined = undefined; - for (const d of datasets) { - const innerDatasets = d.datasets!; - const names = Object.keys(innerDatasets); - const matchingName = names.find((name) => name === dataset); - if (matchingName) { - datasetMeta = innerDatasets[matchingName]; - break; - } - } - if (datasetMeta === undefined) { - console.error(`No matching dataset: ${dataset}`); - return; - } - - const datasetData = await db.selectDataset(datasetMeta.manifest!); - const baseUrl = datasetData.volumeViewerDataRoot + "/"; - args.imageDownloadHref = datasetData.downloadRoot + "/" + id; - // args.fovDownloadHref = datasetData.downloadRoot + "/" + id; - - const fileInfo = await db.getFileInfoByCellId(id); - args.imageUrl = baseUrl + fileInfo!.volumeviewerPath; - args.parentImageUrl = baseUrl + fileInfo!.fovVolumeviewerPath; - runApp(); - - // only now do we have all the data needed -} - -if (params) { - if (params.mask) { - viewerSettings.maskAlpha = parseInt(params.mask, 10); - } - if (params.view) { - const mapping = { - "3D": ViewMode.threeD, - Z: ViewMode.xy, - Y: ViewMode.xz, - X: ViewMode.yz, - }; - const allowedViews = Object.keys(mapping); - if (!allowedViews.includes(params.view)) { - params.view = "3D"; - } - viewerSettings.viewMode = mapping[params.view]; - } - if (params.ch) { - // ?ch=1,2 - // ?luts=0,255,0,255 - // ?colors=ff0000,00ff00 - const initialChannelSettings: ViewerChannelSettings = { - groups: [{ name: "Channels", channels: [] }], - }; - const ch = initialChannelSettings.groups[0].channels; - - const channelsOn = params.ch.split(",").map((numstr) => parseInt(numstr, 10)); - for (let i = 0; i < channelsOn.length; ++i) { - ch.push({ match: channelsOn[i], enabled: true }); - } - // look for luts or color - if (params.luts) { - const luts = params.luts.split(","); - if (luts.length !== ch.length * 2) { - console.log("ILL-FORMED QUERYSTRING: luts must have a min/max for each ch"); - } - for (let i = 0; i < ch.length; ++i) { - ch[i]["lut"] = [luts[i * 2], luts[i * 2 + 1]]; - } - } - if (params.colors) { - const colors = params.colors.split(","); - if (colors.length !== ch.length) { - console.log("ILL-FORMED QUERYSTRING: if colors specified, must have a color for each ch"); - } - for (let i = 0; i < ch.length; ++i) { - ch[i]["color"] = colors[i]; - } - } - args.viewerChannelSettings = initialChannelSettings; - } - if (params.url) { - const imageUrls = tryDecodeURLList(params.url) ?? decodeURL(params.url); - const firstUrl = Array.isArray(imageUrls) ? imageUrls[0] : imageUrls; - - args.cellId = "1"; - args.imageUrl = imageUrls; - // this is invalid for zarr? - args.imageDownloadHref = firstUrl; - args.parentImageUrl = ""; - args.parentImageDownloadHref = ""; - // if json, then use the CFE settings for now. - // (See VIEWER_3D_SETTINGS) - // otherwise turn the first 3 channels on and group them - if (!firstUrl.endsWith("json") && !params.ch) { - args.viewerChannelSettings = { - groups: [ - // first 3 channels on by default! - { - name: "Channels", - channels: [ - { match: [0, 1, 2], enabled: true }, - { match: "(.+)", enabled: false }, - ], - }, - ], - }; - } - runApp(); - } else if (params.file) { - // quick way to load a atlas.json from a special directory. - // - // ?file=relative-path-to-atlas-on-isilon - args.cellId = "1"; - const baseUrl = "http://dev-aics-dtp-001.corp.alleninstitute.org/dan-data/"; - args.imageUrl = baseUrl + params.file; - args.parentImageUrl = baseUrl + params.file; - args.parentImageDownloadHref = ""; - args.imageDownloadHref = ""; - runApp(); - } else if (params.dataset && params.id) { - // ?dataset=aics_hipsc_v2020.1&id=232265 - loadDataset(params.dataset, params.id); - } else { - runApp(); - } +const routes = [ + { + path: "/", + element: , + errorElement: , + }, + { + path: "viewer", + element: , + }, +]; + +let router: Router; +if (WEBSITE3DCELLVIEWER_BUILD_ENVIRONMENT === "dev") { + router = createBrowserRouter(routes); } else { - runApp(); + // Production mode. + // TODO: Use createBrowserRouter when building to S3. + router = createHashRouter(routes); } -function runApp() { - ReactDOM.render( - , - document.getElementById("cell-viewer") - ); -} +ReactDOM.render( + + + , + document.getElementById("cell-viewer") +); diff --git a/src/aics-image-viewer/components/StyleProvider/styles.css b/src/aics-image-viewer/components/StyleProvider/styles.css index cce74e9a..85f39ab7 100644 --- a/src/aics-image-viewer/components/StyleProvider/styles.css +++ b/src/aics-image-viewer/components/StyleProvider/styles.css @@ -95,6 +95,14 @@ font-weight: 400; } + a { + color: var(--color-text-link); + &:focus, + &:focus-visible { + text-decoration: underline; + } + } + & *::selection { /** * Override Ant + Less styling, since it uses a very light purple that's diff --git a/src/aics-image-viewer/components/Toolbar/index.tsx b/src/aics-image-viewer/components/Toolbar/index.tsx index 854ed242..19d24a66 100644 --- a/src/aics-image-viewer/components/Toolbar/index.tsx +++ b/src/aics-image-viewer/components/Toolbar/index.tsx @@ -69,9 +69,12 @@ export default class Toolbar extends React.Component checkSize = debounce((): void => { const { leftRef, centerRef, rightRef, barRef } = this; - const leftRect = leftRef.current!.getBoundingClientRect(); - const centerRect = centerRef.current!.getBoundingClientRect(); - const rightRect = rightRef.current!.getBoundingClientRect(); + if (!leftRef.current || !centerRef.current || !rightRef.current || !barRef.current) { + return; + } + const leftRect = leftRef.current.getBoundingClientRect(); + const centerRect = centerRef.current.getBoundingClientRect(); + const rightRect = rightRef.current.getBoundingClientRect(); // when calculating width required to leave scroll mode, add a bit of extra width to ensure that triggers // for entering and leaving scroll mode never overlap (causing toolbar to rapidly switch when resizing) diff --git a/src/landing-page/components/LandingPage/content.ts b/src/landing-page/components/LandingPage/content.ts deleted file mode 100644 index 614f9eee..00000000 --- a/src/landing-page/components/LandingPage/content.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ProjectEntry } from "../../types"; - -export const landingPageContent: ProjectEntry[] = [ - { - name: "Large colony hiPSC nuclei time series, tagged lamin-B", - description: - "This image was segmented and analyzed for our Nuclear Morphogenesis publication and consists of 500 time samples at 1000x800x65.", - publicationLink: new URL("https://google.com"), - publicationName: "This is the name of the associated publication that the user can click to open in a new tab", - inReview: true, - loadParams: { - baseurl: "", - cellid: 0, - cellPath: "", - fovPath: "", - fovDownloadHref: "", - cellDownloadHref: "", - viewerSettings: { groups: [] }, - }, - }, -]; diff --git a/src/landing-page/types.ts b/src/landing-page/types.ts deleted file mode 100644 index 3aac4c35..00000000 --- a/src/landing-page/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ViewerChannelSettings } from "../aics-image-viewer/shared/utils/viewerChannelSettings"; - -// TBD what URL parameters to include here -export type ViewerArgs = { - baseurl: string; - cellid: number; - cellPath: string; - fovPath: string; - fovDownloadHref: string; - cellDownloadHref: string; - viewerSettings: ViewerChannelSettings; -}; - -export type DatasetEntry = { - name: string; - description: string; - loadParams: ViewerArgs; -}; - -export type ProjectEntry = { - name: string; - description: string; - publicationLink?: URL; - publicationName?: string; - loadParams?: ViewerArgs; - datasets?: DatasetEntry[]; - inReview?: boolean; -}; diff --git a/tsconfig.json b/tsconfig.json index 32b40cd6..98996d3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.base", - "include": ["src/**/*"], + "include": ["src/**/*", "website/**/*"], "compilerOptions": { - "module": "ES6" + "module": "ES6", + "outDir": "./dist" } } diff --git a/webpack.dev.js b/webpack.dev.js index 70670afb..9ed5abca 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -6,6 +6,8 @@ module.exports = (env) => { mode: "development", devtool: "eval-source-map", devServer: { + // Allows the dev server to handle routes + historyApiFallback: true, open: ["/"], port: 9020, allowedHosts: "all", diff --git a/website/components/AppWrapper.tsx b/website/components/AppWrapper.tsx new file mode 100644 index 00000000..fd6b5d47 --- /dev/null +++ b/website/components/AppWrapper.tsx @@ -0,0 +1,61 @@ +import React, { ReactElement, useEffect, useState } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; +import { GlobalViewerSettings } from "../../src/aics-image-viewer/components/App/types"; +import { ImageViewerApp, RenderMode, ViewMode } from "../../src"; +import { getArgsFromParams } from "../utils/url_utils"; +import { AppDataProps } from "../types"; + +type AppWrapperProps = { + viewerSettings?: Partial; + viewerArgs?: AppDataProps; +}; + +const DEFAULT_VIEWER_SETTINGS: Partial = { + showAxes: false, + showBoundingBox: false, + autorotate: false, + viewMode: ViewMode.threeD, + renderMode: RenderMode.volumetric, + maskAlpha: 50, + brightness: 70, + density: 50, + levels: [0, 128, 255] as [number, number, number], + backgroundColor: [0, 0, 0] as [number, number, number], + boundingBoxColor: [255, 255, 255] as [number, number, number], +}; + +const DEFAULT_APP_PROPS: AppDataProps = { + imageUrl: "", + cellId: "", + imageDownloadHref: "", + parentImageDownloadHref: "", +}; + +const defaultAppWrapperProps = { + viewerSettings: DEFAULT_VIEWER_SETTINGS, + viewerArgs: DEFAULT_APP_PROPS, +}; + +/** + * Wrapper around the main ImageViewer component. Handles the collection of parameters from the + * URL and location state (from routing) to pass to the viewer. + */ +export default function AppWrapper(inputProps: AppWrapperProps): ReactElement { + const props = { ...defaultAppWrapperProps, ...inputProps }; + const location = useLocation(); + + const [viewerSettings, setViewerSettings] = useState>(props.viewerSettings); + const [viewerArgs, setViewerArgs] = useState(props.viewerArgs); + const [searchParams] = useSearchParams(); + + useEffect(() => { + // On load, fetch parameters from the URL and location state, then merge. + const locationArgs = location.state as AppDataProps; + getArgsFromParams(searchParams).then(({ args: urlArgs, viewerSettings: urlViewerSettings }) => { + setViewerArgs({ ...DEFAULT_APP_PROPS, ...locationArgs, ...urlArgs }); + setViewerSettings({ ...DEFAULT_VIEWER_SETTINGS, ...urlViewerSettings }); + }); + }, []); + + return ; +} diff --git a/website/components/ErrorPage.tsx b/website/components/ErrorPage.tsx new file mode 100644 index 00000000..60296177 --- /dev/null +++ b/website/components/ErrorPage.tsx @@ -0,0 +1,62 @@ +import React, { ReactElement } from "react"; +import { FlexColumnAlignCenter } from "./LandingPage/utils"; +import { ErrorResponse, Link, useRouteError } from "react-router-dom"; +import { Button } from "antd"; +import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type ErrorPageProps = {}; + +const isErrorResponse = (error: unknown): error is ErrorResponse => { + return typeof (error as ErrorResponse).status === "number" && typeof (error as ErrorResponse).statusText === "string"; +}; + +export default function ErrorPage(props: ErrorPageProps): ReactElement { + const error = useRouteError() as unknown; + let errorMessage = ""; + + if (isErrorResponse(error)) { + errorMessage = error.status + " " + error.statusText; + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = "Unknown error"; + } + + return ( +
+ +

Sorry, something went wrong.

+ +

We encountered the following error:

+ +

{errorMessage}

+

+ Check the browser console for more details. +

+
+

+ If the issue persists after a refresh,{" "} + + please click here to report it. + + +

+
+
+ {/* TODO: Bad practice to wrap a button inside a link, since it's confusing for tab navigation. */} + + + +
+
+ ); +} diff --git a/website/components/LandingPage/content.ts b/website/components/LandingPage/content.ts new file mode 100644 index 00000000..d1a06319 --- /dev/null +++ b/website/components/LandingPage/content.ts @@ -0,0 +1,32 @@ +import { ProjectEntry } from "../../types"; + +const BASE_URL = "https://s3-us-west-2.amazonaws.com/bisque.allencell.org/v1.4.0/Cell-Viewer_Thumbnails/"; +export const landingPageContent: ProjectEntry[] = [ + { + name: "Large colony hiPSC nuclei time series, tagged lamin-B", + description: + "This image was segmented and analyzed for our Nuclear Morphogenesis publication and consists of 500 time samples at 1000x800x65.", + publicationLink: new URL("https://google.com"), + publicationName: "This is the name of the associated publication that the user can click to open in a new tab", + inReview: true, + loadParams: { + cellId: "2025", + imageUrl: BASE_URL + "AICS-22/AICS-22_8319_2025_atlas.json", + parentImageUrl: BASE_URL + "AICS-22/AICS-22_8319_atlas.json", + parentImageDownloadHref: "https://files.allencell.org/api/2.0/file/download?collection=cellviewer-1-4/?id=F8319", + imageDownloadHref: "https://files.allencell.org/api/2.0/file/download?collection=cellviewer-1-4/?id=C2025", + viewerChannelSettings: { + groups: [ + // first 3 channels on by default! + { + name: "Channels", + channels: [ + { match: [0, 1, 2], enabled: true }, + { match: "(.+)", enabled: false }, + ], + }, + ], + }, + }, + }, +]; diff --git a/src/landing-page/components/LandingPage/index.tsx b/website/components/LandingPage/index.tsx similarity index 86% rename from src/landing-page/components/LandingPage/index.tsx rename to website/components/LandingPage/index.tsx index d3606d8e..10b1ecbe 100644 --- a/src/landing-page/components/LandingPage/index.tsx +++ b/website/components/LandingPage/index.tsx @@ -2,11 +2,14 @@ import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button, Tooltip } from "antd"; import React, { ReactElement, useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { useSearchParams } from "react-router-dom"; +import styled from "styled-components"; import { landingPageContent } from "./content"; -import { DatasetEntry, ProjectEntry, ViewerArgs } from "../../types"; -import styled from "styled-components"; +import { AppDataProps, DatasetEntry, ProjectEntry } from "../../types"; import { FlexColumnAlignCenter, FlexColumn, FlexRowAlignCenter, VisuallyHidden, FlexRow } from "./utils"; +import { getArgsFromParams } from "../../utils/url_utils"; const MAX_CONTENT_WIDTH_PX = 1060; @@ -44,7 +47,7 @@ const BannerVideoContainer = styled.div` right: 0; width: 100%; height: 100%; - background-color: #ded9ef; + background-color: #000; z-index: -1; & > video { @@ -177,12 +180,33 @@ const InReviewFlag = styled(FlexRowAlignCenter)` } `; -type LandingPageProps = { - load: (args: ViewerArgs) => void; -}; - -export default function LandingPage(props: LandingPageProps): ReactElement { +export default function LandingPage(): ReactElement { // Rendering + const navigation = useNavigate(); + const [searchParams] = useSearchParams(); + + useEffect(() => { + // Check if the URL used to open the landing page has arguments; + // if so, assume that this is an old URL intended to go to the viewer. + // Navigate to the viewer while preserving URL arguments. + getArgsFromParams(searchParams).then(({ args }) => { + if (Object.keys(args).length > 0) { + console.log("Detected URL parameters. Redirecting from landing page to viewer."); + navigation("viewer" + "?" + searchParams.toString(), { + state: args, + replace: true, + }); + } + }); + }, []); + + 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. + navigation("viewer", { + state: appProps, + }); + }; // TODO: Should the load buttons be link elements or buttons? // Currently both the link and the button inside can be tab-selected. @@ -191,7 +215,7 @@ export default function LandingPage(props: LandingPageProps): ReactElement {

{dataset.name}

{dataset.description}

-
@@ -232,7 +256,7 @@ export default function LandingPage(props: LandingPageProps): ReactElement { const loadParams = project.loadParams; const loadButton = loadParams ? (
-
diff --git a/src/landing-page/components/LandingPage/utils.tsx b/website/components/LandingPage/utils.tsx similarity index 100% rename from src/landing-page/components/LandingPage/utils.tsx rename to website/components/LandingPage/utils.tsx diff --git a/website/types.ts b/website/types.ts new file mode 100644 index 00000000..6b27d411 --- /dev/null +++ b/website/types.ts @@ -0,0 +1,19 @@ +import { AppProps } from "../src/aics-image-viewer/components/App/types"; + +export type AppDataProps = Omit; + +export type DatasetEntry = { + name: string; + description: string; + loadParams: AppDataProps; +}; + +export type ProjectEntry = { + name: string; + description: string; + publicationLink?: URL; + publicationName?: string; + loadParams?: AppDataProps; + datasets?: DatasetEntry[]; + inReview?: boolean; +}; diff --git a/website/utils/url_utils.ts b/website/utils/url_utils.ts new file mode 100644 index 00000000..98886242 --- /dev/null +++ b/website/utils/url_utils.ts @@ -0,0 +1,185 @@ +import FirebaseRequest, { DatasetMetaData } from "../../public/firebase"; + +import { AppProps, GlobalViewerSettings } from "../../src/aics-image-viewer/components/App/types"; +import { ViewMode } from "../../src/aics-image-viewer/shared/enums"; +import { ViewerChannelSettings } from "../../src/aics-image-viewer/shared/utils/viewerChannelSettings"; + +const paramKeys = ["mask", "ch", "luts", "colors", "url", "file", "dataset", "id", "view"]; +type ParamKeys = (typeof paramKeys)[number]; +type Params = { [_ in ParamKeys]?: string }; + +function urlSearchParamsToParams(searchParams: URLSearchParams): Params { + const result: Params = {}; + for (const [key, value] of searchParams.entries()) { + if (paramKeys.includes(key)) { + result[key] = value; + } + } + return result; +} + +const decodeURL = (url: string): string => { + const decodedUrl = decodeURIComponent(url); + return decodedUrl.endsWith("/") ? decodedUrl.slice(0, -1) : decodedUrl; +}; + +/** Try to parse a `string` as a list of 2 or more URLs. Returns `undefined` if the string is not a valid URL list. */ +const tryDecodeURLList = (url: string, delim = ","): string[] | undefined => { + if (!url.includes(delim)) { + return undefined; + } + + const urls = url.split(delim).map((u) => decodeURL(u)); + + // Verify that all urls are valid + for (const u of urls) { + try { + new URL(u); + } catch (_e) { + return undefined; + } + } + + return urls; +}; + +async function loadDataset(dataset: string, id: string): Promise> { + const db = new FirebaseRequest(); + const args: Partial = {}; + + const datasets = await db.getAvailableDatasets(); + + let datasetMeta: DatasetMetaData | undefined = undefined; + for (const d of datasets) { + const innerDatasets = d.datasets!; + const names = Object.keys(innerDatasets); + const matchingName = names.find((name) => name === dataset); + if (matchingName) { + datasetMeta = innerDatasets[matchingName]; + break; + } + } + if (datasetMeta === undefined) { + console.error(`No matching dataset: ${dataset}`); + return {}; + } + + const datasetData = await db.selectDataset(datasetMeta.manifest!); + const baseUrl = datasetData.volumeViewerDataRoot + "/"; + args.imageDownloadHref = datasetData.downloadRoot + "/" + id; + // args.fovDownloadHref = datasetData.downloadRoot + "/" + id; + + const fileInfo = await db.getFileInfoByCellId(id); + args.imageUrl = baseUrl + fileInfo!.volumeviewerPath; + args.parentImageUrl = baseUrl + fileInfo!.fovVolumeviewerPath; + + return args; +} + +export async function getArgsFromParams(urlSearchParams: URLSearchParams): Promise<{ + args: Partial; + viewerSettings: Partial; +}> { + const params = urlSearchParamsToParams(urlSearchParams); + let args: Partial = {}; + const viewerSettings: Partial = {}; + + if (params.mask) { + viewerSettings.maskAlpha = parseInt(params.mask, 10); + } + if (params.view) { + const mapping = { + "3D": ViewMode.threeD, + Z: ViewMode.xy, + Y: ViewMode.xz, + X: ViewMode.yz, + }; + const allowedViews = Object.keys(mapping); + let view: "3D" | "X" | "Y" | "Z"; + if (allowedViews.includes(params.view)) { + view = params.view as "3D" | "X" | "Y" | "Z"; + } else { + view = "3D"; + } + viewerSettings.viewMode = mapping[view]; + } + if (params.ch) { + // ?ch=1,2 + // ?luts=0,255,0,255 + // ?colors=ff0000,00ff00 + const initialChannelSettings: ViewerChannelSettings = { + groups: [{ name: "Channels", channels: [] }], + }; + const ch = initialChannelSettings.groups[0].channels; + + const channelsOn = params.ch.split(",").map((numstr) => parseInt(numstr, 10)); + for (let i = 0; i < channelsOn.length; ++i) { + ch.push({ match: channelsOn[i], enabled: true }); + } + // look for luts or color + if (params.luts) { + const luts = params.luts.split(","); + if (luts.length !== ch.length * 2) { + console.warn("ILL-FORMED QUERYSTRING: luts must have a min/max for each ch"); + } else { + for (let i = 0; i < ch.length; ++i) { + ch[i]["lut"] = [luts[i * 2], luts[i * 2 + 1]]; + } + } + } + if (params.colors) { + const colors = params.colors.split(","); + if (colors.length !== ch.length) { + console.warn("ILL-FORMED QUERYSTRING: if colors specified, must have a color for each ch"); + } else { + for (let i = 0; i < ch.length; ++i) { + ch[i]["color"] = colors[i]; + } + } + } + args.viewerChannelSettings = initialChannelSettings; + } + if (params.url) { + const imageUrls = tryDecodeURLList(params.url) ?? decodeURL(params.url); + const firstUrl = Array.isArray(imageUrls) ? imageUrls[0] : imageUrls; + + args.cellId = "1"; + args.imageUrl = imageUrls; + // this is invalid for zarr? + args.imageDownloadHref = firstUrl; + args.parentImageUrl = ""; + args.parentImageDownloadHref = ""; + // if json, will not override channel settings. + // otherwise turn the first 3 channels on by default and group them + if (!firstUrl.endsWith("json") && !params.ch) { + args.viewerChannelSettings = { + groups: [ + // first 3 channels on by default! + { + name: "Channels", + channels: [ + { match: [0, 1, 2], enabled: true }, + { match: "(.+)", enabled: false }, + ], + }, + ], + }; + } + } else if (params.file) { + // quick way to load a atlas.json from a special directory. + // + // ?file=relative-path-to-atlas-on-isilon + args.cellId = "1"; + const baseUrl = "http://dev-aics-dtp-001.corp.alleninstitute.org/dan-data/"; + args.imageUrl = baseUrl + params.file; + args.parentImageUrl = baseUrl + params.file; + args.parentImageDownloadHref = ""; + args.imageDownloadHref = ""; + } else if (params.dataset && params.id) { + // ?dataset=aics_hipsc_v2020.1&id=232265 + const datasetArgs = await loadDataset(params.dataset, params.id); + args = { ...args, ...datasetArgs }; + } + + return { args, viewerSettings }; +}