Skip to content
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
2 changes: 1 addition & 1 deletion PanTS-Demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<link href="/src/index.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<title>BodyMaps — CT Segmentation Platform</title>
</head>
<body>
Expand Down
74 changes: 53 additions & 21 deletions PanTS-Demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,71 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router";
import "./App.css";
import { default as RotatingHeartLoader } from "./components/Loading";
import { AnnotationProvider } from "./contexts/annotationContexts";
import { FileProvider } from "./contexts/fileContexts";
import LandingPage from "./pages/LandingPage";
import Homepage from "./routes/Homepage";
import UploadPage from "./routes/UploadPage";
import VisualizationPage from "./routes/VisualizationPage";

// The viewer routes pull in the WebGL stack (NiiVue + Cornerstone + three.js), which
// is the bulk of the JS bundle. Code-split them so the landing + dataset pages don't
// download the viewer up front — they only load it when a case is actually opened.
const VisualizationPage = lazy(() => import("./routes/VisualizationPage"));
const UploadPage = lazy(() => import("./routes/UploadPage"));
const RotatingHeartLoader = lazy(() => import("./components/Loading"));

const BASENAME = import.meta.env.VITE_BASENAME;

// Lightweight fallback shown while a lazy route chunk loads (intentionally avoids the
// three.js loader so the fallback itself stays out of the main bundle).
function RouteFallback() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#08090b",
}}
>
<div
className="animate-spin"
style={{
width: 28,
height: 28,
borderRadius: "50%",
border: "2px solid rgba(255,255,255,0.15)",
borderTopColor: "rgba(255,255,255,0.6)",
}}
/>
</div>
);
}

function App() {
return (
<>
<FileProvider>
<AnnotationProvider>
<FileProvider>
<AnnotationProvider>
<div className="App">
<BrowserRouter basename={BASENAME}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Homepage />} />
{/* <Route path="/data" element={<DataPage />} /> */}
{/* <Route path="/:type/:page" element={<Homepage />} /> */}
<Route path="/case/:caseId" element={<VisualizationPage />} />
<Route path="/session/:sessionId" element={<VisualizationPage />} />
<Route path="/reconstruction/:reconstructionId" element={<VisualizationPage />} />
<Route path="/test" element={<RotatingHeartLoader />} />
<Route path="/upload" element={<UploadPage />} />
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Homepage />} />
{/* <Route path="/data" element={<DataPage />} /> */}
{/* <Route path="/:type/:page" element={<Homepage />} /> */}
<Route path="/case/:caseId" element={<VisualizationPage />} />
<Route path="/session/:sessionId" element={<VisualizationPage />} />
<Route path="/reconstruction/:reconstructionId" element={<VisualizationPage />} />
<Route path="/test" element={<RotatingHeartLoader />} />
<Route path="/upload" element={<UploadPage />} />
</Routes>
</Suspense>
</BrowserRouter>
</div>
</AnnotationProvider>
</FileProvider>
</>
</AnnotationProvider>
</FileProvider>
);
}
}

export default App;
56 changes: 56 additions & 0 deletions PanTS-Demo/src/components/CtPreview/CtPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Niivue, NVImage, SLICE_TYPE } from "@niivue/niivue";
import { useEffect, useRef, useState } from "react";

// Lightweight, client-side preview of a locally-selected CT (.nii/.nii.gz) — loaded
// straight into NiiVue from the File object, no upload/server round-trip. Lets users
// verify the right file + slice orientation before running inference. Lazy-loaded by
// the upload page so NiiVue isn't pulled into that bundle until a file is chosen.
export default function CtPreview({ file }: { file: File }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [error, setError] = useState(false);
const [ready, setReady] = useState(false);

useEffect(() => {
let cancelled = false;
setError(false);
setReady(false);
const nv = new Niivue({
sliceType: SLICE_TYPE.MULTIPLANAR,
backColor: [0.03, 0.035, 0.04, 1],
show3Dcrosshair: true,
});
const load = async () => {
if (!canvasRef.current) return;
try {
nv.attachToCanvas(canvasRef.current);
const nvImage = await NVImage.loadFromFile({ file });
if (cancelled) return;
nv.addVolume(nvImage);
nv.setSliceType(SLICE_TYPE.MULTIPLANAR);
setReady(true);
} catch (e) {
console.error("CT preview failed to load", e);
if (!cancelled) setError(true);
}
};
load();
return () => {
cancelled = true;
};
}, [file]);

if (error) {
return (
<div className="ct-preview ct-preview--msg">
Couldn't preview this file — it will still upload for inference.
</div>
);
}

return (
<div className="ct-preview">
<canvas ref={canvasRef} className="ct-preview-canvas" />
{!ready && <div className="ct-preview-loading">Loading preview…</div>}
</div>
);
}
23 changes: 23 additions & 0 deletions PanTS-Demo/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Component, type ReactNode } from "react";

type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };

// Minimal error boundary: if a child throws while rendering (e.g. the three.js
// loader fails to get a WebGL context, or a lazy chunk fails to load), show the
// fallback instead of crashing the subtree to a blank/white canvas.
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };

static getDerivedStateFromError(): State {
return { hasError: true };
}

componentDidCatch(error: unknown) {
console.error("ErrorBoundary caught:", error);
}

render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
2 changes: 1 addition & 1 deletion PanTS-Demo/src/components/Loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const Model = ({ organ }: Props) => {
const RotatingModelLoader: React.FC = () => {
const ref = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [organ, setOrgan] = useState<number>(Math.round(Math.random() * 4));
const [organ, setOrgan] = useState<number>(Math.floor(Math.random() * 4));
const organ_arr = ["pancreas", "kidney", "liver", "colon"];

useEffect(() => {
Expand Down
52 changes: 25 additions & 27 deletions PanTS-Demo/src/components/OpacitySlider/OpacitySlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,35 +40,33 @@ export default function OpacitySlider({


return (
<div className="windowing-slider w-full flex flex-col gap-2 border-2 rounded-sm bg-gray-900 shadow-md">
<div className="bg-gray-600 w-full h-8 flex items-center justify-center text-center rounded-t-sm text-white">Label Settings</div>
<div className="pb-2 pl-4 pr-4 flex flex-col gap-2">

<div className="flex gap-1 flex-col justify-center items-center">
<div className="flex justify-between w-full items-center">
<div className="text-white">Label Opacity</div>
</div>
<input
type="range"
min="0"
max="100"
step="1"
aria-label="s"
className="w-full"
value={opacityValue}
onChange={handleOpacityOnSliderChange}
/>
<div className="vp-panel">
<div className="vp-panel__title">Label Settings</div>
<div className="flex flex-col gap-2">
<div className="vp-row">
<span className="vp-label">Label Opacity</span>
<span className="vp-readout">{Math.round(opacityValue)}%</span>
</div>
<button
className="text-white relative !p-1 text-2xs !bg-gray-700 hover:!border-white"
onClick={() => {
setShowOrganDetails((prev) => !prev);
setShowTaskDetails((prev) => !prev);
}}
>
Class Map
</button>
<input
type="range"
min="0"
max="100"
step="1"
aria-label="Label opacity"
className="vp-range"
value={opacityValue}
onChange={handleOpacityOnSliderChange}
/>
</div>
<button
className="vp-btn"
onClick={() => {
setShowOrganDetails((prev) => !prev);
setShowTaskDetails((prev) => !prev);
}}
>
Class Map
</button>
</div>
);
}
43 changes: 22 additions & 21 deletions PanTS-Demo/src/components/OrganCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Color } from "@cornerstonejs/core/types";
import { IconArrowLeft, IconChevronRight } from "@tabler/icons-react";
import { IconArrowLeft, IconCheck, IconChevronRight } from "@tabler/icons-react";
import React, { useEffect, useState } from "react";
import {
MiscColorMap, OrganSystems,
Expand Down Expand Up @@ -105,8 +105,8 @@ function Checked({
onClick={() => setCollapsed((prev) => !prev)}
>
<IconChevronRight
className={`cursor-pointer text-white hover:bg-gray-700 rounded-md flex items-center justify-center transition-all duration origin-center ${
collapsed ? "rotate-90" : ""
className={`vp-organs__chevron ${
collapsed ? "is-open" : ""
}`}
/>
<div
Expand All @@ -115,15 +115,16 @@ function Checked({
{system}
</div>
</div>
<input
type="checkbox"
className="w-4 h-4 text-blue-600 !bg-gray-700 border-gray-600 !rounded-sm focus:ring-blue-600 ring-offset-gray-800 focus:ring-2"
aria-label="s"
checked={partialToggled}
onChange={() => {
updateToggle(!partialToggled);
}}
/>
<button
type="button"
role="checkbox"
aria-checked={partialToggled}
aria-label={`Toggle ${system}`}
className={`vp-checkbox ${partialToggled ? "vp-checkbox--on" : ""}`}
onClick={() => updateToggle(!partialToggled)}
>
{partialToggled && <IconCheck size={13} stroke={3} />}
</button>
</>
) : (
<>
Expand All @@ -132,8 +133,8 @@ function Checked({
onClick={() => setCollapsed((prev) => !prev)}
>
<IconChevronRight
className={`cursor-pointer text-white hover:bg-gray-700 rounded-md flex items-center justify-center transition-all duration origin-center ${
collapsed ? "rotate-90" : ""
className={`vp-organs__chevron ${
collapsed ? "is-open" : ""
}`}
/>
<div
Expand Down Expand Up @@ -168,7 +169,7 @@ function Checked({
if (organ == "pancreas") return null;
return (
<div className={`flex items-center gap-2 ${level == 0 ? "pl-8" : "pl-9"} `} key={idx}>
<div className="cursor-pointer text-white hover:bg-gray-700 rounded-md flex items-center justify-center transition-all duration origin-center" />
<div className="vp-organs__chevron" />
<div
className={`text-white text-md rounded-md p-1 cursor-pointer hover:border-2 ${
!checkState[getOrganIdx(organ) + 1]
Expand Down Expand Up @@ -237,27 +238,27 @@ function OrganCheckbox({

return (
<div
className={`flex w-2xs h-screen flex-col gap-4 p-3 z-5 absolute top-0 left-0 bg-[#0f0824] duration-200 transition-all ${
className={`vp-organs flex flex-col gap-4 w-72 h-screen pt-16 px-4 pb-4 z-40 fixed top-0 left-0 duration-200 transition-all ${
showOrganDetails ? "translate-x-0" : "-translate-x-full"
} origin-left`}
>
<div className="flex justify-between items-center w-full">

<div className="flex gap-4 items-center justify-start">
<div className="flex gap-2 items-center justify-start">
<IconArrowLeft
className="cursor-pointer text-white hover:bg-gray-700 rounded-md flex items-center justify-center"
className="vp-organs__back"
onClick={() => {
setShowTaskDetails(false);
setShowOrganDetails(false);
}}
/>
<div className="text-white text-2xl">Organs</div>
<div className="vp-organs__title">Organs</div>
</div>
<button className="!p-1.5 !bg-gray-700" onClick={() => toggleAll()}>
<button className="vp-btn" onClick={() => toggleAll()}>
Toggle all
</button>
</div>
<div className="flex flex-col gap-2 overflow-scroll">
<div className="vp-organs__list flex flex-col gap-1 overflow-y-auto">
{OrganSystemsArray.map((system: Systems, idx) => {
return (
<Checked
Expand Down
7 changes: 6 additions & 1 deletion PanTS-Demo/src/components/Preview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { API_BASE } from "../helpers/constants";
import { prefetchViewer } from "../helpers/prefetchViewer";
import type { PreviewType } from "../types";

type Props = {
Expand Down Expand Up @@ -47,7 +48,10 @@ export default function Preview({ id, previewMetadata }: Props) {
}
: {}
}
onMouseEnter={() => setHovered(true)}
onMouseEnter={() => {
setHovered(true);
prefetchViewer(); // warm the viewer JS chunk so clicking feels instant
}}
onMouseLeave={() => setHovered(false)}
onClick={() => navigate(`/case/${id}`)}
>
Expand All @@ -71,6 +75,7 @@ export default function Preview({ id, previewMetadata }: Props) {
<img
src={thumbUrl}
alt={`Case ${id} CT scan`}
loading="lazy"
decoding="async"
onLoad={() => setImgLoaded(true)}
onError={handleImgError}
Expand Down
Loading
Loading