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
2 changes: 2 additions & 0 deletions PanTS-Demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { default as RotatingHeartLoader } from "./components/Loading";
import { AnnotationProvider } from "./contexts/annotationContexts";
import { FileProvider } from "./contexts/fileContexts";
import Homepage from "./routes/Homepage";
import SynapsePage from "./routes/SynapsePage";
import UploadPage from "./routes/UploadPage";
import VisualizationPage from "./routes/VisualizationPage";

Expand All @@ -25,6 +26,7 @@ function App() {
<Route path="/reconstruction/:reconstructionId" element={<VisualizationPage />} />
<Route path="/test" element={<RotatingHeartLoader />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/synapse-ct" element={<SynapsePage />} />
</Routes>
</BrowserRouter>
</div>
Expand Down
6 changes: 6 additions & 0 deletions PanTS-Demo/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export default function Header({handleAboutClick}: Props) {
<header className="flex items-center pl-8 justify-start gap-20 w-screen p-4 bg-black relative">
<div className="text-4xl cursor-pointer" onClick={() => navigate("/")}>PanTS Data</div>
<div className="flex items-center gap-8 justify-center">
<div
className="text-lg cursor-pointer"
onClick={() => navigate("/synapse-ct")}
>
SYNAPSE CT
</div>
{/* <div className="text-lg cursor-pointer group relative">
Browse Full Catalog
<div className="scale-0 flex flex-col rounded gap-2 p-2 w-full transition-all bg-gray-800 absolute top-8 origin-top group-hover:scale-100 duration-100">
Expand Down
227 changes: 227 additions & 0 deletions PanTS-Demo/src/routes/SynapsePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import Header from '../components/Header';

import LeftDashboard from '../synapse/components/LeftDashboard';
import AnatomyViewer from '../synapse/components/AnatomyViewer';
import ViewPresetControls from '../synapse/components/ViewPresetControls';
import ViewerControls from '../synapse/components/ViewerControls';
import VisualizationModePanel from '../synapse/components/VisualizationModePanel';
import MultiplanarPreview from '../synapse/components/MultiplanarPreview';
import SelectedAnatomyCard from '../synapse/components/SelectedAnatomyCard';
import AIAnalysisPanel from '../synapse/components/AIAnalysisPanel';
import AIAssistant from '../synapse/components/AIAssistant';
import CTSliceExplorer from '../synapse/components/CTSliceExplorer';
import BottomTimeline from '../synapse/components/BottomTimeline';
import ModelFallback from '../synapse/components/ModelFallback';

import { useAnatomySelection } from '../synapse/hooks/useAnatomySelection';
import { useChatAssistant } from '../synapse/hooks/useChatAssistant';
import { ANATOMY, type OrganId } from '../synapse/data/anatomyData';
import { TABS, type Tab, type VisMode } from '../synapse/data/scanData';
import type { ViewerState } from '../synapse/utils/aiContextBuilder';
import { getAIMode } from '../synapse/services/aiService';
import type { AssistantAction } from '../synapse/utils/mockAIResponses';

import '../synapse/synapse.css';

export default function SynapsePage() {
const sel = useAnatomySelection();
const {
viewerRef,
selectedOrgan,
selectedSubData,
mode,
setMode,
autoRotate,
toggleAutoRotate,
slices,
setSlices,
setSlice,
slicePlay,
setSlicePlay,
showPlanes,
setShowPlanes,
selectOrgan,
selectMesh,
setView,
resetView,
} = sel;

const [activeTab, setActiveTab] = useState<Tab>('3D Volume');
const [usingFallback, setUsingFallback] = useState(false);
const [glbLoaded, setGlbLoaded] = useState(false);
const aiMode = useMemo(() => getAIMode(), []);

// Reset the load state when the organ changes; AnatomyViewer's HEAD check
// will set glbLoaded back to true once it resolves.
useEffect(() => {
setGlbLoaded(false);
setUsingFallback(false);
const t = setTimeout(() => setGlbLoaded(true), 600);
return () => clearTimeout(t);
}, [selectedOrgan]);

// Build the AI context (a snapshot of viewer state) on demand.
const getViewerState = useCallback<() => ViewerState>(
() => ({
selectedOrgan,
selectedSubKey: sel.selectedSubKey,
selectedSubData,
mode,
slices,
autoRotate,
}),
[selectedOrgan, sel.selectedSubKey, selectedSubData, mode, slices, autoRotate]
);
const chat = useChatAssistant(getViewerState);

// Dispatch an assistant action button (focus an organ, switch mode, etc.).
const onAction = useCallback(
(a: AssistantAction) => {
if (a.type === 'focus' && a.payload && a.payload in ANATOMY) {
selectOrgan(a.payload as OrganId);
} else if (a.type === 'mode' && a.payload) {
setMode(a.payload as VisMode);
} else if (a.type === 'view' && a.payload) {
setView(a.payload);
} else if (a.type === 'reset') {
resetView();
} else if (a.type === 'play_slices') {
setMode('slices');
setShowPlanes(true);
setSlicePlay(true);
}
},
[selectOrgan, setMode, setView, resetView, setShowPlanes, setSlicePlay]
);

const onStartScan = useCallback(() => {
setMode('slices');
setShowPlanes(true);
setSlicePlay(true);
}, [setMode, setShowPlanes, setSlicePlay]);

const onTopView = useCallback(() => setView('top'), [setView]);

const onPickMesh = useCallback(
(
meshName: string,
organId: OrganId,
sub: Parameters<typeof selectMesh>[2]
) => selectMesh(meshName, organId, sub),
[selectMesh]
);

return (
<div className="synapse-ct-page">
<Header handleAboutClick={() => {}} />
<div className="syn-grid" />
<div className="syn-vignette" />

<div className="syn-shell">
<div className="syn-subbar">
<div className="syn-eyebrow-bar">
<div className="syn-mark">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="#54d6ff" strokeWidth="1.4" />
<circle cx="12" cy="12" r="3" fill="#f0b860" />
</svg>
</div>
<div>
<div className="name">SYNAPSE CT</div>
<div className="sub">VOLUMETRIC ANATOMY EXPLORER</div>
</div>
</div>

<nav className="syn-tabs">
{TABS.map((t) => (
<button
key={t}
className={t === activeTab ? 'active' : ''}
onClick={() => setActiveTab(t)}
>
{t}
</button>
))}
</nav>

<div className="syn-hstatus">
<div className="syn-pill">
<span className="syn-dot" />
AI Online
</div>
<div className="syn-case-chip">CASE A-0248</div>
</div>
</div>

<div className="syn-stage">
<LeftDashboard
autoRotate={autoRotate}
activeOrgan={selectedOrgan}
onStartScan={onStartScan}
onToggleAuto={toggleAutoRotate}
onReset={resetView}
onSelectOrgan={selectOrgan}
/>

<div className="syn-center">
<ViewPresetControls onView={setView} />
<AnatomyViewer
ref={viewerRef}
organId={selectedOrgan ?? 'liver'}
mode={mode}
slices={slices}
slicePlay={slicePlay}
showPlanes={showPlanes}
autoRotate={autoRotate}
onPickMesh={onPickMesh}
onSlicePlayTick={(idx: number) => setSlice('axial', idx)}
onFallbackChange={setUsingFallback}
/>
<ViewerControls
activeOrgan={selectedOrgan}
autoRotate={autoRotate}
onSelectOrgan={selectOrgan}
onToggleAuto={toggleAutoRotate}
onReset={resetView}
onTopView={onTopView}
/>
<ModelFallback
state={!glbLoaded ? 'loading' : usingFallback ? 'fallback' : 'hidden'}
organId={selectedOrgan}
/>
</div>

<div className="syn-col">
<VisualizationModePanel mode={mode} onMode={setMode} />
<MultiplanarPreview slices={slices} />
<SelectedAnatomyCard organId={selectedOrgan} subData={selectedSubData} />
<AIAnalysisPanel />
<AIAssistant
messages={chat.messages}
isTyping={chat.isTyping}
onSend={chat.send}
onClear={chat.clear}
organId={selectedOrgan}
onAction={onAction}
aiMode={aiMode}
/>
<CTSliceExplorer
slices={slices}
onSlice={setSlice}
showPlanes={showPlanes}
onTogglePlanes={() => setShowPlanes(!showPlanes)}
/>
</div>
</div>

<BottomTimeline
slices={slices}
setSlices={setSlices}
play={slicePlay}
setPlay={setSlicePlay}
/>
</div>
</div>
);
}
54 changes: 54 additions & 0 deletions PanTS-Demo/src/synapse/components/AIAnalysisPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import { IconChevronDown } from '@tabler/icons-react';
import { ANALYSIS_STATS } from '../data/scanData';

interface Props {
defaultOpen?: boolean;
}

export default function AIAnalysisPanel({ defaultOpen = false }: Props) {
const [open, setOpen] = useState(defaultOpen);
return (
<div
className="syn-card syn-fadeup"
style={{ ['--syn-delay' as any]: '0.18s' }}
>
<div
className={'syn-collapse-head' + (open ? ' open' : '')}
onClick={() => setOpen((v) => !v)}
>
<div className="syn-ctitle">AI Analysis</div>
<IconChevronDown className="chev" size={16} />
</div>
<div className={'syn-collapsible' + (open ? ' open' : '')}>
<div>
<div className="syn-collapse-body">
{ANALYSIS_STATS.map((s) => (
<Conf key={s.id} label={s.label} value={s.value} active={open} />
))}
</div>
</div>
</div>
</div>
);
}

function Conf({ label, value, active }: { label: string; value: number; active: boolean }) {
const [w, setW] = useState(0);
useEffect(() => {
if (!active) { setW(0); return; }
const t = setTimeout(() => setW(value), 200);
return () => clearTimeout(t);
}, [value, active]);
return (
<div className="syn-conf">
<div className="top">
<span>{label}</span>
<b>{value}%</b>
</div>
<div className="syn-bar">
<i style={{ width: `${w}%` }} />
</div>
</div>
);
}
Loading