From 159ad1553256221d1fff9f456f50775837523e0a Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 30 May 2026 14:24:58 -0400 Subject: [PATCH] Add SYNAPSE CT route - AI-assisted volumetric anatomy explorer Adds a /synapse-ct route that loads the existing Visible Human Project organ GLBs from public/ into an interactive 3D dashboard with an AI assistant. All new code is scoped under src/synapse/ and src/routes/. Only App.tsx (one route) and Header.tsx (one nav link) are modified. No new dependencies. --- PanTS-Demo/src/App.tsx | 2 + PanTS-Demo/src/components/Header.tsx | 6 + PanTS-Demo/src/routes/SynapsePage.tsx | 227 ++++++ .../synapse/components/AIAnalysisPanel.tsx | 54 ++ .../src/synapse/components/AIAssistant.tsx | 156 ++++ .../src/synapse/components/AnatomyViewer.tsx | 295 ++++++++ .../src/synapse/components/BottomTimeline.tsx | 71 ++ .../synapse/components/CTSliceExplorer.tsx | 74 ++ .../src/synapse/components/CameraRig.tsx | 59 ++ .../synapse/components/FallbackAnatomy.tsx | 37 + .../src/synapse/components/GLBAnatomy.tsx | 95 +++ .../src/synapse/components/LeftDashboard.tsx | 95 +++ .../src/synapse/components/ModelFallback.tsx | 36 + .../synapse/components/MultiplanarPreview.tsx | 95 +++ .../src/synapse/components/ScanPlatform.tsx | 31 + .../components/SelectedAnatomyCard.tsx | 54 ++ .../src/synapse/components/SlicePlanes.tsx | 56 ++ .../synapse/components/ViewPresetControls.tsx | 17 + .../src/synapse/components/ViewerControls.tsx | 62 ++ .../components/VisualizationModePanel.tsx | 30 + PanTS-Demo/src/synapse/data/anatomyData.ts | 359 +++++++++ PanTS-Demo/src/synapse/data/scanData.ts | 68 ++ .../src/synapse/hooks/useAnatomySelection.ts | 109 +++ .../src/synapse/hooks/useChatAssistant.ts | 64 ++ PanTS-Demo/src/synapse/services/aiService.ts | 57 ++ PanTS-Demo/src/synapse/synapse.css | 679 ++++++++++++++++++ .../src/synapse/utils/aiContextBuilder.ts | 101 +++ .../src/synapse/utils/mockAIResponses.ts | 238 ++++++ 28 files changed, 3227 insertions(+) create mode 100644 PanTS-Demo/src/routes/SynapsePage.tsx create mode 100644 PanTS-Demo/src/synapse/components/AIAnalysisPanel.tsx create mode 100644 PanTS-Demo/src/synapse/components/AIAssistant.tsx create mode 100644 PanTS-Demo/src/synapse/components/AnatomyViewer.tsx create mode 100644 PanTS-Demo/src/synapse/components/BottomTimeline.tsx create mode 100644 PanTS-Demo/src/synapse/components/CTSliceExplorer.tsx create mode 100644 PanTS-Demo/src/synapse/components/CameraRig.tsx create mode 100644 PanTS-Demo/src/synapse/components/FallbackAnatomy.tsx create mode 100644 PanTS-Demo/src/synapse/components/GLBAnatomy.tsx create mode 100644 PanTS-Demo/src/synapse/components/LeftDashboard.tsx create mode 100644 PanTS-Demo/src/synapse/components/ModelFallback.tsx create mode 100644 PanTS-Demo/src/synapse/components/MultiplanarPreview.tsx create mode 100644 PanTS-Demo/src/synapse/components/ScanPlatform.tsx create mode 100644 PanTS-Demo/src/synapse/components/SelectedAnatomyCard.tsx create mode 100644 PanTS-Demo/src/synapse/components/SlicePlanes.tsx create mode 100644 PanTS-Demo/src/synapse/components/ViewPresetControls.tsx create mode 100644 PanTS-Demo/src/synapse/components/ViewerControls.tsx create mode 100644 PanTS-Demo/src/synapse/components/VisualizationModePanel.tsx create mode 100644 PanTS-Demo/src/synapse/data/anatomyData.ts create mode 100644 PanTS-Demo/src/synapse/data/scanData.ts create mode 100644 PanTS-Demo/src/synapse/hooks/useAnatomySelection.ts create mode 100644 PanTS-Demo/src/synapse/hooks/useChatAssistant.ts create mode 100644 PanTS-Demo/src/synapse/services/aiService.ts create mode 100644 PanTS-Demo/src/synapse/synapse.css create mode 100644 PanTS-Demo/src/synapse/utils/aiContextBuilder.ts create mode 100644 PanTS-Demo/src/synapse/utils/mockAIResponses.ts diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 7706e5a..d57e42e 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -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"; @@ -25,6 +26,7 @@ function App() { } /> } /> } /> + } /> diff --git a/PanTS-Demo/src/components/Header.tsx b/PanTS-Demo/src/components/Header.tsx index 8b40315..f1dd20b 100644 --- a/PanTS-Demo/src/components/Header.tsx +++ b/PanTS-Demo/src/components/Header.tsx @@ -9,6 +9,12 @@ export default function Header({handleAboutClick}: Props) {
navigate("/")}>PanTS Data
+
navigate("/synapse-ct")} + > + SYNAPSE CT +
{/*
Browse Full Catalog
diff --git a/PanTS-Demo/src/routes/SynapsePage.tsx b/PanTS-Demo/src/routes/SynapsePage.tsx new file mode 100644 index 0000000..8aca4e9 --- /dev/null +++ b/PanTS-Demo/src/routes/SynapsePage.tsx @@ -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('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[2] + ) => selectMesh(meshName, organId, sub), + [selectMesh] + ); + + return ( +
+
{}} /> +
+
+ +
+
+
+
+ + + + +
+
+
SYNAPSE CT
+
VOLUMETRIC ANATOMY EXPLORER
+
+
+ + + +
+
+ + AI Online +
+
CASE A-0248
+
+
+ +
+ + +
+ + setSlice('axial', idx)} + onFallbackChange={setUsingFallback} + /> + + +
+ +
+ + + + + + setShowPlanes(!showPlanes)} + /> +
+
+ + +
+
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/AIAnalysisPanel.tsx b/PanTS-Demo/src/synapse/components/AIAnalysisPanel.tsx new file mode 100644 index 0000000..f0d19d1 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/AIAnalysisPanel.tsx @@ -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 ( +
+
setOpen((v) => !v)} + > +
AI Analysis
+ +
+
+
+
+ {ANALYSIS_STATS.map((s) => ( + + ))} +
+
+
+
+ ); +} + +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 ( +
+
+ {label} + {value}% +
+
+ +
+
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/AIAssistant.tsx b/PanTS-Demo/src/synapse/components/AIAssistant.tsx new file mode 100644 index 0000000..f183b95 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/AIAssistant.tsx @@ -0,0 +1,156 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { IconSparkles, IconSend, IconTrash } from '@tabler/icons-react'; +import { ANATOMY, type OrganId } from '../data/anatomyData'; +import { DISCLAIMER, type AssistantAction } from '../utils/mockAIResponses'; +import type { ChatMessage } from '../hooks/useChatAssistant'; + +function suggestionsFor(organId: OrganId | null): string[] { + const base = [ + 'What am I looking at?', + 'Explain Sections mode.', + 'What does segmentation confidence mean?', + 'Tell me about the Visible Human dataset.', + ]; + if (organId && ANATOMY[organId]) { + const name = ANATOMY[organId].name.toLowerCase(); + const extras: Record = { + liver: ['What are the Couinaud segments?', 'Why does the liver have so many ligaments?'], + kidney: ['Explain the renal pyramids.', 'What is the renal hilum?'], + lung: ['What are bronchopulmonary segments?', 'Compare upper and lower lobes.'], + pancreas: ['What does the uncinate process do?', 'Compare head, body, and tail of pancreas.'], + colon: ['Walk me through the colonic flow.', 'What is the ileocecal valve?'], + }; + return [ + `Tell me about the ${name}.`, + ...(extras[organId] || []), + ...base, + ].slice(0, 5); + } + return ['What are the visible organs?', ...base].slice(0, 5); +} + +interface Props { + messages: ChatMessage[]; + isTyping: boolean; + onSend: (text: string) => void; + onClear: () => void; + organId: OrganId | null; + onAction: (a: AssistantAction) => void; + aiMode: string; +} + +export default function AIAssistant({ + messages, + isTyping, + onSend, + onClear, + organId, + onAction, + aiMode, +}: Props) { + const [draft, setDraft] = useState(''); + const scrollRef = useRef(null); + + const chips = useMemo(() => suggestionsFor(organId), [organId]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, isTyping]); + + const submit = () => { + const text = draft.trim(); + if (!text) return; + setDraft(''); + onSend(text); + }; + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + return ( +
+
+
+ +
+
+
SYNAPSE AI
+
+ Imaging Assistant{aiMode === 'mock' ? ' • Demo Mode' : ' • API Mode'} +
+
+ +
+ +
+ {messages.map((m) => + m.role === 'user' ? ( +
+ {m.text} +
+ ) : ( +
+
+ + SYNAPSE AI +
+ {m.text} + {m.actions && m.actions.length > 0 && ( +
+ {m.actions.map((a) => ( + + ))} +
+ )} +
+ ) + )} + {isTyping && ( +
+
+ + SYNAPSE AI +
+
+ + + +
+
+ )} +
+ +
+ {chips.map((c) => ( + + ))} +
+ +
+ setDraft(e.target.value)} + onKeyDown={onKey} + /> + +
+ +
{DISCLAIMER}
+
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/AnatomyViewer.tsx b/PanTS-Demo/src/synapse/components/AnatomyViewer.tsx new file mode 100644 index 0000000..6b51c8a --- /dev/null +++ b/PanTS-Demo/src/synapse/components/AnatomyViewer.tsx @@ -0,0 +1,295 @@ +import { + forwardRef, + useImperativeHandle, + useRef, + useState, + useEffect, + useCallback, + Suspense, +} from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, Environment } from '@react-three/drei'; +import { + ACESFilmicToneMapping, + Group, + Mesh, + MeshStandardMaterial, + Color, +} from 'three'; +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; + +import FallbackAnatomy from './FallbackAnatomy'; +import GLBAnatomy from './GLBAnatomy'; +import ScanPlatform from './ScanPlatform'; +import SlicePlanes from './SlicePlanes'; +import CameraRig, { type CameraRigHandle } from './CameraRig'; +import { DEFAULT_CAMERA, type VisMode } from '../data/scanData'; +import { + ANATOMY, + type OrganId, + type SubStructure, + subStructureForMeshName, +} from '../data/anatomyData'; +import type { ViewerHandle, SliceState } from '../hooks/useAnatomySelection'; + +interface Props { + organId: OrganId; + mode: VisMode; + slices: SliceState; + slicePlay: boolean; + showPlanes: boolean; + autoRotate: boolean; + onPickMesh: ( + meshName: string, + organId: OrganId, + sub: { key: string; data: SubStructure } | null + ) => void; + onSlicePlayTick: (idx: number) => void; + onFallbackChange: (usingFallback: boolean) => void; +} + +// Verify a GLB is reachable (not just a SPA index.html fallback) before mounting useGLTF. +function useModelExists(url: string) { + const [state, setState] = useState<'checking' | 'present' | 'absent'>('checking'); + useEffect(() => { + let alive = true; + setState('checking'); + fetch(url, { method: 'HEAD' }) + .then((res) => { + if (!alive) return; + const type = res.headers.get('content-type') || ''; + const ok = res.ok && !type.includes('text/html'); + setState(ok ? 'present' : 'absent'); + }) + .catch(() => alive && setState('absent')); + return () => { + alive = false; + }; + }, [url]); + return state; +} + +const AnatomyViewer = forwardRef(function AnatomyViewer(props, ref) { + const { + organId, + mode, + slices, + slicePlay, + showPlanes, + autoRotate, + onPickMesh, + onSlicePlayTick, + onFallbackChange, + } = props; + + const controlsRef = useRef(null); + const rigRef = useRef(null); + const modelRoot = useRef(null); + const highlighted = useRef([]); + + const glbState = useModelExists(ANATOMY[organId].glbPath); + const usingFallback = glbState === 'absent'; + + useEffect(() => { + if (glbState !== 'checking') onFallbackChange(usingFallback); + }, [glbState, usingFallback, onFallbackChange]); + + const registerRoot = useCallback( + (g: Group | null) => { + modelRoot.current = g; + applyMode(mode); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // ---- mode engine ---- + function applyMode(m: VisMode) { + const root = modelRoot.current; + if (!root) return; + root.traverse((obj) => { + if (!(obj instanceof Mesh)) return; + const mat = obj.material as MeshStandardMaterial; + if (!mat || !mat.userData?.baseColor) return; + const base = mat.userData.baseColor as Color; + const baseE = mat.userData.baseEmissive as Color; + const baseEi = mat.userData.baseEmissiveIntensity as number; + const section = mat.userData.sectionColor as Color; + + mat.wireframe = false; + mat.color.copy(base); + mat.emissive.copy(baseE); + mat.emissiveIntensity = baseEi; + mat.transparent = true; + mat.depthWrite = true; + mat.opacity = 1; + mat.visible = true; + + if (m === 'solid') { + // defaults + } else if (m === 'translucent') { + mat.opacity = 0.45; + mat.depthWrite = false; + } else if (m === 'xray') { + mat.opacity = 0.35; + mat.depthWrite = false; + mat.emissive.copy(base); + mat.emissiveIntensity = 0.75; + } else if (m === 'sections') { + mat.color.copy(section); + mat.emissive.copy(section); + mat.emissiveIntensity = 0.25; + } else if (m === 'slices') { + mat.opacity = 0.35; + mat.depthWrite = false; + } else if (m === 'wireframe') { + mat.wireframe = true; + mat.color.copy(section); + mat.emissive.copy(section); + mat.emissiveIntensity = 0.4; + } + mat.needsUpdate = true; + }); + // re-apply any active highlight on top of the new base + highlighted.current.forEach((h) => { + const m2 = h.material as MeshStandardMaterial; + m2.emissiveIntensity = 0.95; + }); + } + + useEffect(() => { + applyMode(mode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, organId]); + + // ---- highlight ---- + const clearHighlight = useCallback(() => { + highlighted.current.forEach((m) => { + const mat = m.material as MeshStandardMaterial; + if (mat?.userData?.baseEmissiveIntensity !== undefined) { + mat.emissiveIntensity = mat.userData.baseEmissiveIntensity; + } + }); + highlighted.current = []; + }, []); + + const highlightMesh = useCallback( + (meshName: string) => { + clearHighlight(); + if (!modelRoot.current) return; + const lc = meshName.toLowerCase(); + modelRoot.current.traverse((obj) => { + if (!(obj instanceof Mesh)) return; + const mn = (obj.userData.meshName as string)?.toLowerCase() || ''; + if (mn === lc) { + const mat = obj.material as MeshStandardMaterial; + mat.emissiveIntensity = 0.95; + highlighted.current.push(obj); + } + }); + }, + [clearHighlight] + ); + + const highlightOrgan = useCallback(() => { + clearHighlight(); + if (!modelRoot.current) return; + modelRoot.current.traverse((obj) => { + if (!(obj instanceof Mesh)) return; + const mat = obj.material as MeshStandardMaterial; + mat.emissiveIntensity = 0.6; + highlighted.current.push(obj); + }); + }, [clearHighlight]); + + // pulse highlighted meshes + useEffect(() => { + let raf: number; + const loop = () => { + const t = performance.now() * 0.005; + highlighted.current.forEach((o) => { + const mat = o.material as MeshStandardMaterial; + if (mat) mat.emissiveIntensity = 0.7 + Math.sin(t) * 0.25; + }); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // expose imperative API + useImperativeHandle( + ref, + () => ({ + flyTo: (cam, target) => rigRef.current?.flyTo(cam, target), + highlightMesh, + highlightOrgan, + clearHighlight, + }), + [highlightMesh, highlightOrgan, clearHighlight] + ); + + // wrap onPickMesh so the substructure match is computed here (single source of truth) + const handlePick = useCallback( + (meshName: string, orgId: OrganId, _sub: ReturnType) => { + const sub = subStructureForMeshName(orgId, meshName); + onPickMesh(meshName, orgId, sub); + highlightMesh(meshName); + }, + [onPickMesh, highlightMesh] + ); + + return ( + { + gl.toneMapping = ACESFilmicToneMapping; + gl.toneMappingExposure = 1.15; + }} + style={{ position: 'absolute', inset: 0 }} + > + + + + + + + + + {glbState === 'present' ? ( + + ) : glbState === 'absent' ? ( + + ) : null} + + + + + + + + + ); +}); + +export default AnatomyViewer; diff --git a/PanTS-Demo/src/synapse/components/BottomTimeline.tsx b/PanTS-Demo/src/synapse/components/BottomTimeline.tsx new file mode 100644 index 0000000..ff3656b --- /dev/null +++ b/PanTS-Demo/src/synapse/components/BottomTimeline.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { IconPlayerPlayFilled, IconPlayerPauseFilled } from '@tabler/icons-react'; +import { SCAN_SUMMARY, TIMELINE_LABELS } from '../data/scanData'; +import type { SliceState } from '../hooks/useAnatomySelection'; + +const TOTAL = SCAN_SUMMARY.images; + +interface Props { + slices: SliceState; + setSlices: React.Dispatch>; + play: boolean; + setPlay: (v: boolean) => void; +} + +export default function BottomTimeline({ slices, setSlices, play, setPlay }: Props) { + // drive axial advancement when playing + useEffect(() => { + if (!play) return; + const id = setInterval(() => { + setSlices((s) => { + const next = s.axial + 1; + return { ...s, axial: next > TOTAL ? 1 : next }; + }); + }, 28); + return () => clearInterval(id); + }, [play, setSlices]); + + const fillPct = (slices.axial / TOTAL) * 100; + const currentBucket = Math.min( + TIMELINE_LABELS.length - 1, + Math.floor((slices.axial / TOTAL) * TIMELINE_LABELS.length) + ); + + return ( +
+ +
Volume Sweep
+
+ + setSlices((s) => ({ ...s, axial: Number(e.target.value) })) + } + /> +
+ {TIMELINE_LABELS.map((label, i) => ( + + {label} + + ))} +
+
+
+
+ {String(slices.axial).padStart(3, '0')}/{TOTAL} +
+
Axial Slice
+
+
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/CTSliceExplorer.tsx b/PanTS-Demo/src/synapse/components/CTSliceExplorer.tsx new file mode 100644 index 0000000..e11817a --- /dev/null +++ b/PanTS-Demo/src/synapse/components/CTSliceExplorer.tsx @@ -0,0 +1,74 @@ +import { SCAN_SUMMARY } from '../data/scanData'; +import type { SliceState, SlicePlane } from '../hooks/useAnatomySelection'; + +const TOTAL = SCAN_SUMMARY.images; +const fill = (idx: number): React.CSSProperties => ({ + ['--fill' as any]: `${(idx / TOTAL) * 100}%`, +}); + +interface Props { + slices: SliceState; + onSlice: (plane: SlicePlane, value: number) => void; + showPlanes: boolean; + onTogglePlanes: () => void; +} + +export default function CTSliceExplorer({ + slices, + onSlice, + showPlanes, + onTogglePlanes, +}: Props) { + return ( +
+
CT Slice Explorer
+ + + + + +
+ Show slice planes +
+ +
+
+
+ ); +} + +function SliceRow({ + label, + plane, + value, + onSlice, +}: { + label: string; + plane: SlicePlane; + value: number; + onSlice: (plane: SlicePlane, value: number) => void; +}) { + return ( +
+
+ {label} + + Slice {value} / {TOTAL} + +
+ onSlice(plane, Number(e.target.value))} + /> +
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/CameraRig.tsx b/PanTS-Demo/src/synapse/components/CameraRig.tsx new file mode 100644 index 0000000..d7a2b43 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/CameraRig.tsx @@ -0,0 +1,59 @@ +import { useFrame, useThree } from '@react-three/fiber'; +import { useRef, useImperativeHandle, forwardRef } from 'react'; +import { Vector3 } from 'three'; +import type { RefObject } from 'react'; +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; + +export interface CameraRigHandle { + flyTo(camPos: [number, number, number], target: [number, number, number], duration?: number): void; +} + +interface AnimState { + t: number; + duration: number; + p0: Vector3; + p1: Vector3; + g0: Vector3; + g1: Vector3; +} + +const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); + +interface Props { + controlsRef: RefObject; +} + +const CameraRig = forwardRef(function CameraRig({ controlsRef }, ref) { + const { camera } = useThree(); + const anim = useRef(null); + + useImperativeHandle(ref, () => ({ + flyTo(camPos, target, duration = 1.1) { + anim.current = { + t: 0, + duration, + p0: camera.position.clone(), + p1: new Vector3(...camPos), + g0: controlsRef.current ? controlsRef.current.target.clone() : new Vector3(), + g1: new Vector3(...target), + }; + }, + })); + + useFrame((_, dt) => { + if (!anim.current) return; + const a = anim.current; + a.t = Math.min(1, a.t + dt / a.duration); + const e = easeInOutCubic(a.t); + camera.position.lerpVectors(a.p0, a.p1, e); + if (controlsRef.current) { + controlsRef.current.target.lerpVectors(a.g0, a.g1, e); + controlsRef.current.update(); + } + if (a.t >= 1) anim.current = null; + }); + + return null; +}); + +export default CameraRig; diff --git a/PanTS-Demo/src/synapse/components/FallbackAnatomy.tsx b/PanTS-Demo/src/synapse/components/FallbackAnatomy.tsx new file mode 100644 index 0000000..4ba8408 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/FallbackAnatomy.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from 'react'; +import { Group } from 'three'; +import type { OrganId } from '../data/anatomyData'; + +interface Props { + organId: OrganId; + registerRoot: (group: Group | null) => void; +} + +// Minimal placeholder. With his GLBs in /public/ this should rarely render. +export default function FallbackAnatomy({ organId: _organId, registerRoot }: Props) { + const root = useRef(null); + useEffect(() => { + if (root.current) registerRoot(root.current); + return () => registerRoot(null); + }, [registerRoot]); + + return ( + + + + + + + + + + + ); +} diff --git a/PanTS-Demo/src/synapse/components/GLBAnatomy.tsx b/PanTS-Demo/src/synapse/components/GLBAnatomy.tsx new file mode 100644 index 0000000..8a5f10d --- /dev/null +++ b/PanTS-Demo/src/synapse/components/GLBAnatomy.tsx @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useGLTF } from '@react-three/drei'; +import { Color, Group, Mesh, MeshStandardMaterial } from 'three'; +import { + ANATOMY, + type OrganId, + organForMeshName, + subStructureForMeshName, +} from '../data/anatomyData'; + +interface Props { + organId: OrganId; + onPickMesh: ( + meshName: string, + organId: OrganId, + sub: ReturnType + ) => void; + registerRoot: (group: Group | null) => void; +} + +// Colors used in Sections mode — cycled across sub-structures of the loaded organ. +const SECTION_PALETTE = [ + '#54d6ff', '#f0b860', '#5fe3a1', '#ff6b6b', '#9fb6d4', + '#7fe3ff', '#ffd089', '#b58bff', '#ffa07a', '#86d9c6', + '#e09bff', '#ffb86c', '#90caff', '#ffd58a', '#a8e6cf', +]; + +export default function GLBAnatomy({ organId, onPickMesh, registerRoot }: Props) { + const path = ANATOMY[organId].glbPath; + const { scene } = useGLTF(path); + const root = useRef(null); + + // Clone to isolate from useGLTF's cache (HMR-safe). + const cloned = useMemo(() => scene.clone(true), [scene]); + + useEffect(() => { + // Tag every mesh with userData.anatomy + assign a section index for color modes. + let sectionIdx = 0; + cloned.traverse((obj) => { + if (!(obj instanceof Mesh)) return; + const meshName = obj.name || ''; + const orgFromMesh = organForMeshName(meshName) || organId; + obj.userData.anatomy = orgFromMesh; + obj.userData.meshName = meshName; + obj.userData.sectionColor = SECTION_PALETTE[sectionIdx % SECTION_PALETTE.length]; + sectionIdx++; + // Re-create material as MeshStandardMaterial we can manipulate freely. + const oldMat = obj.material as MeshStandardMaterial | MeshStandardMaterial[]; + const sourceColor = + Array.isArray(oldMat) ? new Color('#c08070') : + oldMat?.color instanceof Color ? oldMat.color.clone() : + new Color('#c08070'); + const newMat = new MeshStandardMaterial({ + color: sourceColor, + roughness: 0.5, + metalness: 0.08, + emissive: new Color('#3a1810'), + emissiveIntensity: 0.18, + transparent: true, + opacity: 1, + }); + newMat.userData.baseColor = sourceColor.clone(); + newMat.userData.baseEmissive = newMat.emissive.clone(); + newMat.userData.baseEmissiveIntensity = newMat.emissiveIntensity; + newMat.userData.sectionColor = new Color(obj.userData.sectionColor); + obj.material = newMat; + }); + + if (root.current) registerRoot(root.current); + return () => registerRoot(null); + }, [cloned, organId, registerRoot]); + + const handleClick = (e: any) => { + e.stopPropagation(); + const mesh = e.object as Mesh | undefined; + if (!mesh) return; + const meshName = (mesh.userData.meshName as string) || mesh.name || ''; + const orgId = (mesh.userData.anatomy as OrganId) || organId; + const sub = subStructureForMeshName(orgId, meshName); + onPickMesh(meshName, orgId, sub); + }; + + return ( + + + + ); +} + +// Preload all five so switching is instant after the first paint. +useGLTF.preload('/3d-liver.glb'); +useGLTF.preload('/3d-kidney.glb'); +useGLTF.preload('/3d-lung.glb'); +useGLTF.preload('/3d-pancreas.glb'); +useGLTF.preload('/3d-colon.glb'); diff --git a/PanTS-Demo/src/synapse/components/LeftDashboard.tsx b/PanTS-Demo/src/synapse/components/LeftDashboard.tsx new file mode 100644 index 0000000..1114489 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/LeftDashboard.tsx @@ -0,0 +1,95 @@ +import { IconPlayerPlayFilled, IconRotateClockwise, IconRefresh } from '@tabler/icons-react'; +import { SCAN_SUMMARY } from '../data/scanData'; +import { ANATOMY, ORGAN_ORDER, type OrganId } from '../data/anatomyData'; + +interface Props { + autoRotate: boolean; + activeOrgan: OrganId | null; + onStartScan: () => void; + onToggleAuto: () => void; + onReset: () => void; + onSelectOrgan: (id: OrganId) => void; +} + +const fade = (delay: number): React.CSSProperties => ({ ['--syn-delay' as any]: `${delay}s` }); + +export default function LeftDashboard({ + autoRotate, + activeOrgan, + onStartScan, + onToggleAuto, + onReset, + onSelectOrgan, +}: Props) { + return ( +
+
+
+ + SCAN ONLINE • CT VOLUME READY +
+

+ Explore the Human Body in 3D. +

+

+ AI-assisted volumetric rendering of Visible Human Project anatomical segmentations. + Click any organ on the focus rail to load its 3D model, then click a sub-structure + to inspect it. +

+
+ + + +
+
+ +
+
+ + LIVE +
+
Patient Scan Summary
+ + + + + + + +
+ +
+
Anatomical Focus
+
+ {ORGAN_ORDER.map((id) => ( + + ))} +
+
+
+ ); +} + +function Row({ k, v, cls = '' }: { k: string; v: string; cls?: string }) { + return ( +
+ {k} + {v} +
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/ModelFallback.tsx b/PanTS-Demo/src/synapse/components/ModelFallback.tsx new file mode 100644 index 0000000..f7af29c --- /dev/null +++ b/PanTS-Demo/src/synapse/components/ModelFallback.tsx @@ -0,0 +1,36 @@ +import { ANATOMY, type OrganId } from '../data/anatomyData'; + +interface Props { + state: 'loading' | 'fallback' | 'hidden'; + organId: OrganId | null; +} + +export default function ModelFallback({ state, organId }: Props) { + if (state === 'hidden') return null; + + const organName = organId ? ANATOMY[organId].name : 'organ'; + const path = organId ? ANATOMY[organId].glbPath : ''; + + return ( +
+
+ {state === 'loading' ? ( + <> +
Loading {organName} segmentation…
+
+ Streaming {path} from the PanTS-Demo assets. +
+ + ) : ( + <> +
GLB asset not found
+
+ Expected {path} in public/. Showing a procedural + placeholder. Make sure your PanTS-Demo public/ folder contains + the five Visible Human organ GLBs. +
+ + )} +
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/MultiplanarPreview.tsx b/PanTS-Demo/src/synapse/components/MultiplanarPreview.tsx new file mode 100644 index 0000000..a630e94 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/MultiplanarPreview.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react'; +import { SCAN_SUMMARY } from '../data/scanData'; +import type { SliceState } from '../hooks/useAnatomySelection'; + +const TOTAL = SCAN_SUMMARY.images; + +type Plane = 'axial' | 'sagittal' | 'coronal'; + +function CTCanvas({ plane, index }: { plane: Plane; index: number }) { + const ref = useRef(null); + useEffect(() => { + const cv = ref.current; + if (!cv) return; + const ctx = cv.getContext('2d'); + if (!ctx) return; + const W = (cv.width = 120); + const H = (cv.height = 120); + const depth = index / TOTAL; + const img = ctx.createImageData(W, H); + const cx = W / 2; + const cy = H / 2; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const dx = (x - cx) / cx; + const dy = (y - cy) / cy; + const r = Math.sqrt(dx * dx + dy * dy); + let v: number; + const bodyR = plane === 'axial' ? 0.82 : 0.7; + if (r < bodyR) { + v = 28 + 18 * Math.sin((x + depth * 40) * 0.3) * Math.cos(y * 0.25); + if (r > bodyR - 0.12) v = 150 + Math.random() * 60; + const ph = depth * 6; + const b1 = Math.hypot(dx - 0.25 * Math.sin(ph), dy + 0.1); + const b2 = Math.hypot(dx + 0.3, dy - 0.2 * Math.cos(ph)); + const b3 = Math.hypot(dx - 0.05, dy + 0.3); + if (b1 < 0.25) v = 90 + 40 * (1 - b1 / 0.25); + if (b2 < 0.22) v = 70 + 50 * (1 - b2 / 0.22); + if (b3 < 0.18) v = 110 + 30 * (1 - b3 / 0.18); + if (plane !== 'axial' && Math.abs(dx) < 0.08 && dy > 0) v = 180 + Math.random() * 40; + if (plane === 'axial' && Math.hypot(dx, dy + 0.55) < 0.1) v = 190; + v += Math.random() * 14 - 7; + } else { + v = 4 + Math.random() * 4; + } + v = Math.max(0, Math.min(255, v)); + const i = (y * W + x) * 4; + img.data[i] = img.data[i + 1] = img.data[i + 2] = v; + img.data[i + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + ctx.strokeStyle = 'rgba(84,214,255,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, cy); + ctx.lineTo(W, cy); + ctx.moveTo(cx, 0); + ctx.lineTo(cx, H); + ctx.stroke(); + }, [plane, index]); + return ; +} + +interface Props { + slices: SliceState; +} + +export default function MultiplanarPreview({ slices }: Props) { + return ( +
+
+ + LIVE +
+
Multiplanar CT Preview
+
+
+ + AXIAL + {slices.axial} +
+
+ + SAGITTAL + {slices.sagittal} +
+
+ + CORONAL + {slices.coronal} +
+
+
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/ScanPlatform.tsx b/PanTS-Demo/src/synapse/components/ScanPlatform.tsx new file mode 100644 index 0000000..bd15d81 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/ScanPlatform.tsx @@ -0,0 +1,31 @@ +import { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Mesh, DoubleSide } from 'three'; + +interface Props { + yOffset?: number; +} + +export default function ScanPlatform({ yOffset = -1.6 }: Props) { + const ring2 = useRef(null); + useFrame((_, dt) => { + if (ring2.current) ring2.current.rotation.z += dt * 0.3; + }); + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/PanTS-Demo/src/synapse/components/SelectedAnatomyCard.tsx b/PanTS-Demo/src/synapse/components/SelectedAnatomyCard.tsx new file mode 100644 index 0000000..9b99059 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/SelectedAnatomyCard.tsx @@ -0,0 +1,54 @@ +import { IconCheck } from '@tabler/icons-react'; +import { ANATOMY, type OrganId, type SubStructure } from '../data/anatomyData'; + +interface Props { + organId: OrganId | null; + subData: SubStructure | null; +} + +export default function SelectedAnatomyCard({ organId, subData }: Props) { + const a = organId ? ANATOMY[organId] : null; + + return ( +
+
+ + LIVE +
+
Selected Anatomy
+ + {!a ? ( +

+ No structure selected. Click an organ on the focus rail, then click a + sub-structure on the model to inspect it. +

+ ) : ( + <> +
{a.name}
+
+ {a.system} • {a.location} +
+ {subData && ( + <> +
{subData.displayName}
+

{subData.description}

+ + )} + {!subData &&

{a.description}

} +
+ + {a.finding} +
+
+ AI Status + No abnormalities +
+
+ Segmentation Confidence + {a.confidence}% +
+ + )} +
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/SlicePlanes.tsx b/PanTS-Demo/src/synapse/components/SlicePlanes.tsx new file mode 100644 index 0000000..f8969ae --- /dev/null +++ b/PanTS-Demo/src/synapse/components/SlicePlanes.tsx @@ -0,0 +1,56 @@ +import { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { DoubleSide } from 'three'; +import { SCAN_SUMMARY } from '../data/scanData'; +import type { SliceState } from '../hooks/useAnatomySelection'; + +const TOTAL = SCAN_SUMMARY.images; +const axialY = (idx: number) => 1.3 - (idx / TOTAL) * 2.8; +const sagX = (idx: number) => (idx / TOTAL - 0.5) * 1.4; +const corZ = (idx: number) => (idx / TOTAL - 0.5) * 1.0; + +interface Props { + visible: boolean; + slices: SliceState; + play: boolean; + onPlayTick: (idx: number) => void; +} + +export default function SlicePlanes({ visible, slices, play, onPlayTick }: Props) { + const playRef = useRef(play); + playRef.current = play; + const idxRef = useRef(slices.axial); + idxRef.current = slices.axial; + + useFrame((_, dt) => { + if (!visible) return; + if (playRef.current) { + let next = idxRef.current + dt * 90; + if (next > TOTAL) next = 1; + onPlayTick(Math.round(next)); + } + }); + + if (!visible) return null; + + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/PanTS-Demo/src/synapse/components/ViewPresetControls.tsx b/PanTS-Demo/src/synapse/components/ViewPresetControls.tsx new file mode 100644 index 0000000..53dbbb1 --- /dev/null +++ b/PanTS-Demo/src/synapse/components/ViewPresetControls.tsx @@ -0,0 +1,17 @@ +import { VIEW_PRESETS } from '../data/scanData'; + +interface Props { + onView: (id: string) => void; +} + +export default function ViewPresetControls({ onView }: Props) { + return ( +
+ {VIEW_PRESETS.map((v) => ( + + ))} +
+ ); +} diff --git a/PanTS-Demo/src/synapse/components/ViewerControls.tsx b/PanTS-Demo/src/synapse/components/ViewerControls.tsx new file mode 100644 index 0000000..54491be --- /dev/null +++ b/PanTS-Demo/src/synapse/components/ViewerControls.tsx @@ -0,0 +1,62 @@ +import { + IconRotateClockwise, + IconRefresh, + IconArrowUp, +} from '@tabler/icons-react'; +import { ANATOMY, ORGAN_ORDER, type OrganId } from '../data/anatomyData'; + +interface Props { + activeOrgan: OrganId | null; + autoRotate: boolean; + onSelectOrgan: (id: OrganId) => void; + onToggleAuto: () => void; + onReset: () => void; + onTopView: () => void; +} + +export default function ViewerControls({ + activeOrgan, + autoRotate, + onSelectOrgan, + onToggleAuto, + onReset, + onTopView, +}: Props) { + return ( + <> +
+ {ORGAN_ORDER.map((id) => ( + + ))} +
+ +
+ + + +
+ +
+ Drag to rotate • Scroll to zoom • Right drag to pan •{' '} + Click a sub-structure to inspect +
+ + ); +} diff --git a/PanTS-Demo/src/synapse/components/VisualizationModePanel.tsx b/PanTS-Demo/src/synapse/components/VisualizationModePanel.tsx new file mode 100644 index 0000000..9af11df --- /dev/null +++ b/PanTS-Demo/src/synapse/components/VisualizationModePanel.tsx @@ -0,0 +1,30 @@ +import { VIS_MODES, type VisMode } from '../data/scanData'; + +interface Props { + mode: VisMode; + onMode: (m: VisMode) => void; +} + +export default function VisualizationModePanel({ mode, onMode }: Props) { + return ( +
+
Visualization Mode
+
+ {VIS_MODES.map((m) => ( + + ))} +
+
+ ); +} diff --git a/PanTS-Demo/src/synapse/data/anatomyData.ts b/PanTS-Demo/src/synapse/data/anatomyData.ts new file mode 100644 index 0000000..06b3802 --- /dev/null +++ b/PanTS-Demo/src/synapse/data/anatomyData.ts @@ -0,0 +1,359 @@ +// Anatomy reference data, rebuilt around the real Visible Human Project +// segmentations shipped in PanTS-Demo/public/. +// +// Top-level organs (drives which GLB is loaded and the focus rail buttons): +// liver, kidney, lung, pancreas, colon +// +// Sub-structures (drives click-detail in the Selected Anatomy card and in +// the AI assistant). Keys are substring matches against the GLB mesh names. + +export type OrganId = 'liver' | 'kidney' | 'lung' | 'pancreas' | 'colon'; + +export interface SubStructure { + displayName: string; + description: string; +} + +export interface Organ { + id: OrganId; + name: string; + system: string; + location: string; + description: string; + finding: string; + confidence: number; + glbPath: string; + // camera target for fly-to. Each organ GLB centers near origin; values are + // gentle overhead-front views; tweak if a particular model looks off. + focus: { cam: [number, number, number]; target: [number, number, number] }; + // substring matchers against mesh names (case-insensitive) used to verify + // a mesh belongs to this organ. + meshAliases: string[]; + // sub-mesh detail. Substring match against the clicked mesh name. + subStructures: Record; +} + +export const ANATOMY: Record = { + liver: { + id: 'liver', + name: 'Liver', + system: 'Hepatobiliary', + location: 'Right upper quadrant', + description: + 'Hepatic parenchyma with eight Couinaud segments, ligamentous attachments, and impressions from adjacent organs. Reconstructed from Visible Human segmentation.', + finding: 'Homogeneous parenchyma, no lesions', + confidence: 97.1, + glbPath: '/3d-liver.glb', + focus: { cam: [3.2, 1.4, 4.0], target: [0, 0, 0] }, + meshAliases: ['liver', 'hepat'], + subStructures: { + right_lobe: { + displayName: 'Right Lobe of Liver', + description: + "The larger of the two main lobes, separated from the left by the falciform ligament. Contains Couinaud segments V–VIII.", + }, + left_lobe: { + displayName: 'Left Lobe of Liver', + description: + 'Smaller lobe extending across the midline to the left. Contains segments II–IV.', + }, + caudate: { + displayName: 'Caudate Lobe', + description: + 'Couinaud segment I. Sits posteriorly between the inferior vena cava and the ligamentum venosum, with independent venous drainage.', + }, + quadrate: { + displayName: 'Quadrate Lobe', + description: + 'Part of Couinaud segment IVb on the inferior surface, bordered by the gallbladder fossa and round ligament.', + }, + anterosuperior: { + displayName: 'Right Anterosuperior Segment (VIII)', + description: 'Couinaud segment VIII — superior portion of the right anterior sector.', + }, + anteroinferior: { + displayName: 'Right Anteroinferior Segment (V)', + description: 'Couinaud segment V — inferior portion of the right anterior sector.', + }, + posterosuperior: { + displayName: 'Right Posterosuperior Segment (VII)', + description: 'Couinaud segment VII — superior portion of the right posterior sector.', + }, + posteroinferior: { + displayName: 'Right Posteroinferior Segment (VI)', + description: 'Couinaud segment VI — inferior portion of the right posterior sector.', + }, + falciform: { + displayName: 'Falciform Ligament', + description: + 'Sickle-shaped peritoneal fold connecting the anterior surface of the liver to the diaphragm and anterior abdominal wall.', + }, + round_ligament: { + displayName: 'Round Ligament of Liver', + description: 'Remnant of the obliterated umbilical vein, running in the free edge of the falciform ligament.', + }, + coronary: { + displayName: 'Coronary Ligament', + description: 'Peritoneal reflection from the diaphragm onto the liver, bounding the bare area.', + }, + triangular: { + displayName: 'Triangular Ligament', + description: 'Lateral extensions of the coronary ligament fixing the liver to the diaphragm.', + }, + bare_area: { + displayName: 'Bare Area of Liver', + description: + 'Posterosuperior surface in direct contact with the diaphragm, not covered by peritoneum.', + }, + porta_hepatis: { + displayName: 'Porta Hepatis', + description: + 'Transverse fissure on the inferior surface; entry/exit point for the portal vein, hepatic artery, and bile ducts.', + }, + }, + }, + + kidney: { + id: 'kidney', + name: 'Right Kidney', + system: 'Urinary', + location: 'Retroperitoneum, right', + description: + "Right renal cortex, medulla, pyramids and hilum reconstructed at sub-millimeter resolution. Visible Human female segmentation.", + finding: 'Symmetric perfusion, no calculi', + confidence: 95.8, + glbPath: '/3d-kidney.glb', + focus: { cam: [2.4, 1.0, 3.0], target: [0, 0, 0] }, + meshAliases: ['kidney', 'renal', 'nephr', 'hilum_of_kidney'], + subStructures: { + capsule: { + displayName: 'Renal Capsule', + description: 'Tough fibrous outer covering protecting the renal parenchyma.', + }, + cortex: { + displayName: 'Renal Cortex', + description: + 'Outer layer containing glomeruli and convoluted tubules; the primary site of filtration.', + }, + column: { + displayName: 'Renal Column', + description: + 'Cortical tissue extending between renal pyramids; carries interlobar vessels.', + }, + pyramid: { + displayName: 'Renal Pyramid', + description: + 'Cone-shaped medullary structure containing the loops of Henle and collecting ducts. The kidney typically has 8–18 pyramids.', + }, + hilum: { + displayName: 'Renal Hilum', + description: + 'Medial concave fissure where the renal artery enters and the renal vein, ureter, and lymphatics exit.', + }, + }, + }, + + lung: { + id: 'lung', + name: 'Lungs', + system: 'Respiratory', + location: 'Thoracic cavity', + description: + 'Bilateral lungs with detailed bronchopulmonary segments and the complete tracheobronchial cartilage tree. Visible Human female segmentation.', + finding: 'Clear lung fields, no nodules detected', + confidence: 96.2, + glbPath: '/3d-lung.glb', + focus: { cam: [3.0, 1.6, 4.2], target: [0, 0, 0] }, + meshAliases: ['lung', 'bronch', 'pulmon', 'hilum', 'cartilage_of', 'lobar'], + subStructures: { + upper_lobe: { + displayName: 'Upper Lobe', + description: + 'Superior lobe of the lung. Three lobes on the right (upper/middle/lower) and two on the left (upper/lower).', + }, + middle_lobe: { + displayName: 'Right Middle Lobe', + description: + 'Wedge-shaped lobe present only on the right side, between the upper and lower lobes.', + }, + lower_lobe: { + displayName: 'Lower Lobe', + description: 'Inferior lobe of the lung, separated from the upper by the oblique fissure.', + }, + apical: { + displayName: 'Apical Bronchopulmonary Segment', + description: 'Most superior segment within the upper lobe.', + }, + anterior: { + displayName: 'Anterior Bronchopulmonary Segment', + description: 'Anterior segment of the upper lobe.', + }, + posterior: { + displayName: 'Posterior Bronchopulmonary Segment', + description: 'Posterior segment of the upper lobe.', + }, + lingula_superior: { + displayName: 'Superior Lingular Segment', + description: "Tongue-shaped projection from the left upper lobe; left-side analogue of the right middle lobe.", + }, + lingula_inferior: { + displayName: 'Inferior Lingular Segment', + description: 'Inferior portion of the left lingula.', + }, + superior_basal: { + displayName: 'Superior Basal Segment', + description: 'Most superior segment of the lower lobe.', + }, + basal: { + displayName: 'Basal Bronchopulmonary Segment', + description: 'Inferior segments of the lower lobe (medial, anterior, lateral, posterior basal).', + }, + hilum: { + displayName: 'Pulmonary Hilum', + description: + 'Root of the lung — entry point for the main bronchus, pulmonary artery, pulmonary veins, and lymphatics.', + }, + cartilage_of_lobar_bronchus: { + displayName: 'Cartilage of Lobar Bronchus', + description: + 'Plates of hyaline cartilage reinforcing the lobar (secondary) bronchi, keeping the airway patent during respiration.', + }, + cartilage_of_tertiary_bronchus: { + displayName: 'Cartilage of Tertiary Bronchus', + description: + 'Smaller cartilaginous plates supporting the tertiary (segmental) bronchi.', + }, + lobar_bronchus: { + displayName: 'Lobar Bronchus', + description: 'Secondary bronchi branching from the main bronchus to each lobe.', + }, + tertiary_bronchus: { + displayName: 'Tertiary (Segmental) Bronchus', + description: 'Third-generation airways supplying individual bronchopulmonary segments.', + }, + }, + }, + + pancreas: { + id: 'pancreas', + name: 'Pancreas', + system: 'Digestive / Endocrine', + location: 'Retroperitoneum, epigastrium', + description: + 'Pancreatic head, neck, body, tail, and uncinate process reconstructed from Visible Human segmentation. The pancreas is part of both the digestive system (exocrine acini producing enzymes) and the endocrine system (Islets of Langerhans producing insulin and glucagon).', + finding: 'Uniform parenchyma, no mass identified', + confidence: 94.3, + glbPath: '/3d-pancreas.glb', + focus: { cam: [2.4, 1.2, 3.0], target: [0, 0, 0] }, + meshAliases: ['pancreas', 'ucinate'], + subStructures: { + head: { + displayName: 'Head of Pancreas', + description: + 'Widest portion, sitting within the C-shaped curve of the duodenum. Closely related to the common bile duct.', + }, + neck: { + displayName: 'Neck of Pancreas', + description: 'Short constricted section connecting the head to the body, anterior to the superior mesenteric vessels.', + }, + body: { + displayName: 'Body of Pancreas', + description: 'Central portion lying transversely across the L1/L2 vertebrae, posterior to the stomach.', + }, + tail: { + displayName: 'Tail of Pancreas', + description: 'Narrow left end extending toward the splenic hilum within the splenorenal ligament.', + }, + ucinate: { + displayName: 'Uncinate Process', + description: + "Hook-shaped extension of the pancreatic head projecting posteriorly behind the superior mesenteric vessels.", + }, + }, + }, + + colon: { + id: 'colon', + name: 'Colon', + system: 'Digestive', + location: 'Abdominal cavity', + description: + 'Large intestine reconstructed from Visible Human male segmentation. Includes the caecum and vermiform appendix, ascending, transverse, descending and sigmoid colon, hepatic and splenic flexures, and rectum.', + finding: 'Normal caliber, no wall thickening', + confidence: 96.8, + glbPath: '/3d-colon.glb', + focus: { cam: [3.2, 1.4, 4.0], target: [0, 0, 0] }, + meshAliases: ['colon', 'caecum', 'rectum', 'appendix', 'flexure', 'ileocecal'], + subStructures: { + caecum: { + displayName: 'Caecum', + description: + 'Blind-ended pouch at the beginning of the large intestine, in the right iliac fossa. Receives ileal contents via the ileocecal valve.', + }, + vermiform_appendix: { + displayName: 'Vermiform Appendix', + description: + 'Narrow finger-like projection from the caecum; rich in lymphoid tissue. The site of appendicitis.', + }, + ileocecal_valve: { + displayName: 'Ileocecal Valve', + description: 'Sphincter separating the small and large intestines, regulating ileal flow into the caecum.', + }, + ascending_colon: { + displayName: 'Ascending Colon', + description: "Travels superiorly along the right side of the abdomen from caecum to hepatic flexure.", + }, + hepatic_flexure: { + displayName: 'Hepatic (Right Colic) Flexure', + description: 'Right-sided bend of the colon beneath the liver, where ascending meets transverse colon.', + }, + transverse_colon: { + displayName: 'Transverse Colon', + description: 'Crosses the abdomen from right to left between the two colic flexures; the most mobile colonic segment.', + }, + splenic_flexure: { + displayName: 'Splenic (Left Colic) Flexure', + description: 'Sharper left-sided bend near the spleen, where transverse meets descending colon.', + }, + descending_colon: { + displayName: 'Descending Colon', + description: 'Travels inferiorly along the left side of the abdomen from splenic flexure to sigmoid.', + }, + sigmoid_colon: { + displayName: 'Sigmoid Colon', + description: 'S-shaped loop in the left iliac fossa connecting descending colon to rectum.', + }, + rectum: { + displayName: 'Rectum', + description: 'Terminal straight segment of the large intestine, ending at the anal canal.', + }, + }, + }, +}; + +// Ordered list for the focus rail / model selector. +export const ORGAN_ORDER: OrganId[] = ['liver', 'kidney', 'lung', 'pancreas', 'colon']; + +// Returns the OrganId whose meshAliases match the given mesh name, or null. +export function organForMeshName(meshName: string): OrganId | null { + const n = (meshName || '').toLowerCase(); + for (const id of ORGAN_ORDER) { + if (ANATOMY[id].meshAliases.some((a) => n.includes(a.toLowerCase()))) return id; + } + return null; +} + +// Returns the sub-structure key whose substring matches the mesh name, or null. +export function subStructureForMeshName( + organId: OrganId, + meshName: string +): { key: string; data: SubStructure } | null { + const n = (meshName || '').toLowerCase(); + const subs = ANATOMY[organId].subStructures; + // Prefer the longest matching key — "lingula_superior" should win over "superior". + const keys = Object.keys(subs).sort((a, b) => b.length - a.length); + for (const k of keys) { + if (n.includes(k.toLowerCase())) return { key: k, data: subs[k] }; + } + return null; +} diff --git a/PanTS-Demo/src/synapse/data/scanData.ts b/PanTS-Demo/src/synapse/data/scanData.ts new file mode 100644 index 0000000..1102d80 --- /dev/null +++ b/PanTS-Demo/src/synapse/data/scanData.ts @@ -0,0 +1,68 @@ +export const SCAN_SUMMARY = { + caseId: 'A-0248', + study: 'Visible Human Volumetric Segmentation', + source: 'PanTS-Demo / Visible Human Project', + sliceThickness: '0.8 mm', + images: 512, + scanQuality: 98, + contrast: 'IV', + status: 'Analyzed', +}; + +export const TABS = ['Overview', '3D Volume', 'Slice Explorer', 'Findings', 'AI Assistant'] as const; +export type Tab = (typeof TABS)[number]; + +export interface ViewPreset { + id: string; + label: string; + cam: [number, number, number]; + target: [number, number, number]; +} + +export const VIEW_PRESETS: ViewPreset[] = [ + { id: 'front', label: 'Front', cam: [0, 0.4, 5], target: [0, 0, 0] }, + { id: 'back', label: 'Back', cam: [0, 0.4, -5], target: [0, 0, 0] }, + { id: 'left', label: 'Left Side', cam: [-5, 0.4, 0], target: [0, 0, 0] }, + { id: 'right', label: 'Right Side', cam: [5, 0.4, 0], target: [0, 0, 0] }, + { id: 'top', label: 'Top', cam: [0, 5, 0.4], target: [0, 0, 0] }, + { id: 'oblique', label: 'Oblique', cam: [3.2, 1.6, 4.0], target: [0, 0, 0] }, +]; + +// Visualization modes revised for organ-centric content. +export type VisMode = 'solid' | 'translucent' | 'xray' | 'sections' | 'slices' | 'wireframe'; + +export interface VisModeInfo { + id: VisMode; + label: string; + color: string; + hint: string; +} + +export const VIS_MODES: VisModeInfo[] = [ + { id: 'solid', label: 'Solid', color: '#54d6ff', hint: 'Standard opaque rendering' }, + { id: 'translucent', label: 'Translucent', color: '#9fb6d4', hint: 'See internal structures' }, + { id: 'xray', label: 'X-Ray', color: '#7fe3ff', hint: 'High-emissive transparent view' }, + { id: 'sections', label: 'Sections', color: '#f0b860', hint: 'Color-code anatomical sub-structures' }, + { id: 'slices', label: 'CT Slices', color: '#54d6ff', hint: 'Overlay axial / sagittal / coronal planes' }, + { id: 'wireframe', label: 'Wireframe', color: '#5fe3a1', hint: 'Mesh topology view' }, +]; + +export interface AnalysisStat { + id: string; + label: string; + value: number; +} + +export const ANALYSIS_STATS: AnalysisStat[] = [ + { id: 'organ', label: 'Organ Segmentation', value: 98.4 }, + { id: 'sub', label: 'Sub-structure Identification', value: 96.5 }, + { id: 'surface', label: 'Surface Reconstruction', value: 99.1 }, + { id: 'registration', label: 'Scan Registration', value: 97.8 }, +]; + +export const TIMELINE_LABELS = ['Superior', 'Thorax', 'Abdomen', 'Pelvis', 'Inferior']; + +export const DEFAULT_CAMERA = { + cam: [3.2, 1.4, 4.2] as [number, number, number], + target: [0, 0, 0] as [number, number, number], +}; diff --git a/PanTS-Demo/src/synapse/hooks/useAnatomySelection.ts b/PanTS-Demo/src/synapse/hooks/useAnatomySelection.ts new file mode 100644 index 0000000..d18aaaf --- /dev/null +++ b/PanTS-Demo/src/synapse/hooks/useAnatomySelection.ts @@ -0,0 +1,109 @@ +import { useState, useRef, useCallback } from 'react'; +import { ANATOMY, type OrganId, type SubStructure } from '../data/anatomyData'; +import { VIEW_PRESETS, DEFAULT_CAMERA, type VisMode } from '../data/scanData'; + +export interface ViewerHandle { + flyTo(camPos: [number, number, number], target: [number, number, number]): void; + highlightMesh(meshName: string): void; + highlightOrgan(): void; // highlights all loaded meshes + clearHighlight(): void; +} + +export interface SliceState { + axial: number; + sagittal: number; + coronal: number; +} + +export type SlicePlane = 'axial' | 'sagittal' | 'coronal'; + +export function useAnatomySelection() { + const viewerRef = useRef(null); + + const [selectedOrgan, setSelectedOrgan] = useState('liver'); // start on liver + const [selectedSubKey, setSelectedSubKey] = useState(null); + const [selectedSubData, setSelectedSubData] = useState(null); + const [selectedMeshName, setSelectedMeshName] = useState(null); + + const [mode, setMode] = useState('solid'); + const [autoRotate, setAutoRotate] = useState(true); + const [slices, setSlices] = useState({ axial: 248, sagittal: 256, coronal: 256 }); + const [slicePlay, setSlicePlay] = useState(false); + const [showPlanes, setShowPlanes] = useState(false); + + // Select an organ (loads its GLB) and fly the camera. + const selectOrgan = useCallback((id: OrganId) => { + if (!ANATOMY[id]) return; + setSelectedOrgan(id); + setSelectedSubKey(null); + setSelectedSubData(null); + setSelectedMeshName(null); + if (viewerRef.current) { + viewerRef.current.flyTo(ANATOMY[id].focus.cam, ANATOMY[id].focus.target); + } + }, []); + + // Called by the viewer when a specific mesh is clicked. + const selectMesh = useCallback( + (meshName: string, organId: OrganId, sub: { key: string; data: SubStructure } | null) => { + setSelectedOrgan(organId); + setSelectedMeshName(meshName); + if (sub) { + setSelectedSubKey(sub.key); + setSelectedSubData(sub.data); + } else { + setSelectedSubKey(null); + setSelectedSubData(null); + } + if (viewerRef.current) viewerRef.current.highlightMesh(meshName); + }, + [] + ); + + const setView = useCallback((viewId: string) => { + const v = VIEW_PRESETS.find((p) => p.id === viewId); + if (v && viewerRef.current) viewerRef.current.flyTo(v.cam, v.target); + }, []); + + const resetView = useCallback(() => { + if (viewerRef.current) { + viewerRef.current.flyTo(DEFAULT_CAMERA.cam, DEFAULT_CAMERA.target); + viewerRef.current.clearHighlight(); + } + setSelectedSubKey(null); + setSelectedSubData(null); + setSelectedMeshName(null); + }, []); + + const toggleAutoRotate = useCallback(() => setAutoRotate((v) => !v), []); + + const setSlice = useCallback((plane: SlicePlane, value: number) => { + setSlices((s) => ({ ...s, [plane]: value })); + }, []); + + return { + viewerRef, + selectedOrgan, + selectedSubKey, + selectedSubData, + selectedMeshName, + mode, + setMode, + autoRotate, + setAutoRotate, + toggleAutoRotate, + slices, + setSlice, + setSlices, + slicePlay, + setSlicePlay, + showPlanes, + setShowPlanes, + selectOrgan, + selectMesh, + setView, + resetView, + }; +} + +export type AnatomySelectionState = ReturnType; diff --git a/PanTS-Demo/src/synapse/hooks/useChatAssistant.ts b/PanTS-Demo/src/synapse/hooks/useChatAssistant.ts new file mode 100644 index 0000000..0d9463e --- /dev/null +++ b/PanTS-Demo/src/synapse/hooks/useChatAssistant.ts @@ -0,0 +1,64 @@ +import { useState, useCallback, useRef } from 'react'; +import { askAssistant } from '../services/aiService'; +import type { ViewerState } from '../utils/aiContextBuilder'; +import type { AssistantAction } from '../utils/mockAIResponses'; + +let idCounter = 0; +const nextId = () => `m${++idCounter}`; + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + text: string; + actions: AssistantAction[]; +} + +const WELCOME: ChatMessage = { + id: nextId(), + role: 'assistant', + text: + "Welcome to SYNAPSE AI. The 3D model is a Visible Human Project segmentation loaded from the PanTS-Demo assets. " + + "Pick an organ on the focus rail or ask me about any anatomical structure, sub-segment, or visualization mode.", + actions: [], +}; + +export function useChatAssistant(getViewerState: () => ViewerState) { + const [messages, setMessages] = useState([WELCOME]); + const [isTyping, setIsTyping] = useState(false); + const getState = useRef(getViewerState); + getState.current = getViewerState; + + const send = useCallback( + async (text: string) => { + const trimmed = (text || '').trim(); + if (!trimmed || isTyping) return; + + const userMsg: ChatMessage = { id: nextId(), role: 'user', text: trimmed, actions: [] }; + setMessages((m) => [...m, userMsg]); + setIsTyping(true); + + try { + const { text: replyText, actions } = await askAssistant(trimmed, getState.current(), []); + setMessages((m) => [ + ...m, + { id: nextId(), role: 'assistant', text: replyText, actions: actions || [] }, + ]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setMessages((m) => [ + ...m, + { id: nextId(), role: 'assistant', text: `Something went wrong: ${msg}`, actions: [] }, + ]); + } finally { + setIsTyping(false); + } + }, + [isTyping] + ); + + const clear = useCallback(() => { + setMessages([{ ...WELCOME, id: nextId() }]); + }, []); + + return { messages, isTyping, send, clear }; +} diff --git a/PanTS-Demo/src/synapse/services/aiService.ts b/PanTS-Demo/src/synapse/services/aiService.ts new file mode 100644 index 0000000..bd7c00d --- /dev/null +++ b/PanTS-Demo/src/synapse/services/aiService.ts @@ -0,0 +1,57 @@ +import { generateMockResponse, type AssistantResponse } from '../utils/mockAIResponses'; +import { buildAIContext, type ViewerState } from '../utils/aiContextBuilder'; + +// Vite injects these. They're optional — without them we default to mock mode. +const MODE: string = (import.meta.env.VITE_AI_MODE as string) || 'mock'; +const ENDPOINT: string = (import.meta.env.VITE_AI_API_ENDPOINT as string) || '/api/chat'; + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export interface ChatHistoryEntry { + role: 'user' | 'assistant'; + text: string; +} + +export async function askAssistant( + userText: string, + viewerState: ViewerState, + history: ChatHistoryEntry[] = [] +): Promise { + const context = buildAIContext(viewerState); + + if (MODE === 'api') { + try { + const res = await fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [...history, { role: 'user', text: userText }], + context, + }), + }); + if (!res.ok) throw new Error(`Backend responded ${res.status}`); + const data: { text?: string; actions?: AssistantResponse['actions'] } = await res.json(); + return { + text: data.text ?? 'No response received from the AI backend.', + actions: Array.isArray(data.actions) ? data.actions : [], + }; + } catch (err) { + const fallback = generateMockResponse(userText, context); + const msg = err instanceof Error ? err.message : String(err); + return { + text: + `⚠️ Could not reach the AI backend (${msg}). Showing a local mock response instead:\n\n` + + fallback.text, + actions: fallback.actions, + }; + } + } + + // Mock mode — small artificial delay for the typing animation. + await delay(550 + Math.random() * 500); + return generateMockResponse(userText, context); +} + +export function getAIMode(): string { + return MODE; +} diff --git a/PanTS-Demo/src/synapse/synapse.css b/PanTS-Demo/src/synapse/synapse.css new file mode 100644 index 0000000..1fae483 --- /dev/null +++ b/PanTS-Demo/src/synapse/synapse.css @@ -0,0 +1,679 @@ +/* SYNAPSE CT scoped styles. + Everything is scoped under .synapse-ct-page so these styles do NOT bleed + onto the rest of the PanTS-Demo site. No body-level rules. + Fonts use system-ui as fallback; if you want Sora/Space Mono add the import + in index.html or via the global stylesheet. */ + +/* CSS-driven entry animations (replaces framer-motion) */ +@keyframes syn-fadeup { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes syn-fadeup-strong { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} +.synapse-ct-page .syn-fadeup { + opacity: 0; + animation: syn-fadeup 0.6s ease-out forwards; + animation-delay: var(--syn-delay, 0s); +} +.synapse-ct-page .syn-fadeup-strong { + opacity: 0; + animation: syn-fadeup-strong 0.7s ease-out forwards; + animation-delay: var(--syn-delay, 0s); +} +.synapse-ct-page .syn-collapsible { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.35s ease; + overflow: hidden; +} +.synapse-ct-page .syn-collapsible.open { grid-template-rows: 1fr; } +.synapse-ct-page .syn-collapsible > div { min-height: 0; } + +.synapse-ct-page { + --syn-bg-0: #04060b; + --syn-bg-1: #070b14; + --syn-panel: rgba(14, 20, 32, 0.55); + --syn-border: rgba(120, 160, 200, 0.14); + --syn-border-strong: rgba(140, 190, 230, 0.28); + --syn-text: #e8eef7; + --syn-text-dim: #8da0b8; + --syn-text-faint: #5d6e85; + --syn-cyan: #54d6ff; + --syn-cyan-soft: #7fe3ff; + --syn-blue: #4a9eff; + --syn-amber: #f0b860; + --syn-amber-soft: #ffd089; + --syn-green: #5fe3a1; + --syn-red: #ff6b6b; + --syn-radius: 16px; + --syn-radius-sm: 11px; + + font-family: 'Sora', system-ui, -apple-system, sans-serif; + color: var(--syn-text); + background: + radial-gradient(1200px 800px at 70% 0%, rgba(40, 90, 140, 0.18), transparent 60%), + radial-gradient(900px 700px at 10% 100%, rgba(120, 80, 40, 0.12), transparent 55%), + linear-gradient(180deg, var(--syn-bg-1), var(--syn-bg-0)); + min-height: 100vh; + position: relative; + isolation: isolate; + -webkit-font-smoothing: antialiased; +} + +.synapse-ct-page * { box-sizing: border-box; } +.synapse-ct-page .mono { font-family: 'Space Mono', ui-monospace, monospace; } + +/* background grid + vignette */ +.synapse-ct-page .syn-grid { + position: absolute; inset: 0; z-index: 0; pointer-events: none; + background-image: + linear-gradient(rgba(90,140,190,0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(90,140,190,0.05) 1px, transparent 1px); + background-size: 54px 54px; + mask-image: radial-gradient(ellipse 80% 80% at 50% 45%, #000 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 45%, #000 30%, transparent 80%); +} +.synapse-ct-page .syn-vignette { + position: absolute; inset: 0; z-index: 1; pointer-events: none; + background: radial-gradient(ellipse 70% 70% at 50% 50%, transparent 55%, rgba(2,4,8,0.7) 100%); +} + +/* main shell — sits below the PanTS header */ +.synapse-ct-page .syn-shell { + position: relative; + z-index: 2; + height: calc(100vh - 76px); /* PanTS header height, approximate */ + display: flex; + flex-direction: column; + pointer-events: none; +} +.synapse-ct-page .syn-shell > * { pointer-events: auto; } + +/* sub-bar (case info + tabs) since we don't replace the existing header */ +.synapse-ct-page .syn-subbar { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 26px; gap: 20px; +} +.synapse-ct-page .syn-eyebrow-bar { + display: flex; align-items: center; gap: 13px; +} +.synapse-ct-page .syn-mark { + width: 36px; height: 36px; border-radius: 11px; + background: linear-gradient(135deg, rgba(84,214,255,0.18), rgba(240,184,96,0.14)); + border: 1px solid var(--syn-border-strong); + display: grid; place-items: center; + box-shadow: 0 0 22px rgba(84,214,255,0.18); +} +.synapse-ct-page .syn-mark svg { width: 20px; height: 20px; } +.synapse-ct-page .syn-eyebrow-bar .name { + font-weight: 700; letter-spacing: 2px; font-size: 14px; +} +.synapse-ct-page .syn-eyebrow-bar .sub { + font-size: 9px; letter-spacing: 2.5px; color: var(--syn-text-faint); font-weight: 500; +} + +.synapse-ct-page nav.syn-tabs { + display: flex; gap: 4px; padding: 5px; border-radius: 40px; + background: var(--syn-panel); backdrop-filter: blur(18px); + border: 1px solid var(--syn-border); +} +.synapse-ct-page nav.syn-tabs button { + background: none; border: none; color: var(--syn-text-dim); cursor: pointer; + font-family: inherit; font-size: 12px; letter-spacing: 1px; font-weight: 500; + padding: 8px 18px; border-radius: 30px; transition: 0.35s; text-transform: uppercase; +} +.synapse-ct-page nav.syn-tabs button.active { + color: var(--syn-text); background: rgba(120,170,220,0.12); + box-shadow: inset 0 0 0 1px var(--syn-border-strong); +} +.synapse-ct-page nav.syn-tabs button:hover:not(.active) { color: var(--syn-text); } + +.synapse-ct-page .syn-hstatus { display: flex; align-items: center; gap: 16px; } +.synapse-ct-page .syn-pill { + display: flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: 30px; + background: var(--syn-panel); backdrop-filter: blur(18px); border: 1px solid var(--syn-border); + font-size: 11px; letter-spacing: 0.5px; color: var(--syn-text-dim); +} +.synapse-ct-page .syn-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--syn-green); box-shadow: 0 0 10px var(--syn-green); + animation: syn-pulse 2s infinite; +} +@keyframes syn-pulse { 0%,100%{opacity:1;} 50%{opacity:0.45;} } +.synapse-ct-page .syn-case-chip { + font-family: 'Space Mono', ui-monospace, monospace; + color: var(--syn-amber-soft); + border: 1px solid rgba(240,184,96,0.25); + padding: 7px 13px; border-radius: 10px; + font-size: 11px; background: rgba(240,184,96,0.06); +} + +/* stage grid */ +.synapse-ct-page .syn-stage { + flex: 1; + display: grid; + grid-template-columns: 320px 1fr 360px; + min-height: 0; +} +.synapse-ct-page .syn-col { + display: flex; flex-direction: column; gap: 14px; + padding: 6px 20px 14px; overflow-y: auto; +} +.synapse-ct-page .syn-col::-webkit-scrollbar { width: 6px; } +.synapse-ct-page .syn-col::-webkit-scrollbar-thumb { + background: rgba(120,160,200,0.18); border-radius: 6px; +} +.synapse-ct-page .syn-center { + position: relative; + display: flex; flex-direction: column; + align-items: center; justify-content: flex-end; +} + +/* card */ +.synapse-ct-page .syn-card { + background: var(--syn-panel); backdrop-filter: blur(20px); + border: 1px solid var(--syn-border); border-radius: var(--syn-radius); + padding: 16px 17px; position: relative; overflow: hidden; +} +.synapse-ct-page .syn-card::before { + content: ''; position: absolute; inset: 0; border-radius: inherit; + background: linear-gradient(135deg, rgba(255,255,255,0.04), transparent 40%); + pointer-events: none; +} +.synapse-ct-page .syn-card .live { + position: absolute; top: 14px; right: 14px; + font-size: 9px; letter-spacing: 1.5px; color: var(--syn-amber-soft); + display: flex; align-items: center; gap: 5px; +} +.synapse-ct-page .syn-card .live i { + width: 5px; height: 5px; border-radius: 50%; + background: var(--syn-amber); box-shadow: 0 0 8px var(--syn-amber); + display: inline-block; animation: syn-pulse 1.8s infinite; +} +.synapse-ct-page .syn-ctitle { + font-size: 10px; letter-spacing: 2px; color: var(--syn-text-faint); + text-transform: uppercase; margin-bottom: 12px; font-weight: 600; +} +.synapse-ct-page .syn-eyebrow { + font-size: 10px; letter-spacing: 2.5px; color: var(--syn-cyan-soft); + text-transform: uppercase; display: flex; align-items: center; gap: 9px; + margin-bottom: 14px; font-weight: 600; +} +.synapse-ct-page .syn-eyebrow .syn-dot { + background: var(--syn-cyan); box-shadow: 0 0 10px var(--syn-cyan); +} +.synapse-ct-page h1.syn-hero { + font-size: 26px; line-height: 1.08; font-weight: 600; + letter-spacing: -0.5px; margin-bottom: 14px; color: var(--syn-text); +} +.synapse-ct-page h1.syn-hero span { + background: linear-gradient(100deg, var(--syn-amber-soft), var(--syn-cyan-soft)); + -webkit-background-clip: text; background-clip: text; color: transparent; +} +.synapse-ct-page p.syn-lede { + font-size: 12.5px; line-height: 1.65; color: var(--syn-text-dim); margin-bottom: 18px; +} + +.synapse-ct-page .syn-btn-row { display: flex; gap: 8px; flex-wrap: wrap; } +.synapse-ct-page .syn-btn { + font-family: inherit; cursor: pointer; border-radius: 11px; + font-size: 12px; font-weight: 500; padding: 11px 16px; + display: inline-flex; align-items: center; gap: 8px; transition: 0.3s; + background: rgba(120,170,220,0.08); border: 1px solid var(--syn-border); + color: var(--syn-text); letter-spacing: 0.3px; +} +.synapse-ct-page .syn-btn:hover { + background: rgba(120,170,220,0.16); + border-color: var(--syn-border-strong); + box-shadow: 0 0 18px rgba(84,214,255,0.15); + transform: translateY(-1px); +} +.synapse-ct-page .syn-btn.primary { + background: linear-gradient(120deg, rgba(84,214,255,0.22), rgba(74,158,255,0.18)); + border-color: rgba(84,214,255,0.4); + box-shadow: 0 0 22px rgba(84,214,255,0.18); +} +.synapse-ct-page .syn-btn.primary:hover { box-shadow: 0 0 30px rgba(84,214,255,0.32); } +.synapse-ct-page .syn-btn svg { width: 14px; height: 14px; } + +.synapse-ct-page .syn-row { + display: flex; justify-content: space-between; align-items: center; + padding: 7px 0; border-bottom: 1px solid rgba(120,160,200,0.07); + font-size: 12px; +} +.synapse-ct-page .syn-row:last-child { border-bottom: none; } +.synapse-ct-page .syn-row .k { color: var(--syn-text-faint); } +.synapse-ct-page .syn-row .v { + color: var(--syn-text); font-family: 'Space Mono', ui-monospace, monospace; font-size: 11.5px; +} +.synapse-ct-page .syn-row .v.good { color: var(--syn-green); } +.synapse-ct-page .syn-row .v.amber { color: var(--syn-amber-soft); } + +/* mode grid */ +.synapse-ct-page .syn-mode-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 7px; +} +.synapse-ct-page .syn-mode-btn { + font-family: inherit; cursor: pointer; border-radius: 10px; + font-size: 11px; font-weight: 500; padding: 11px 8px; + background: rgba(120,170,220,0.06); border: 1px solid var(--syn-border); + color: var(--syn-text-dim); transition: 0.28s; + display: flex; align-items: center; gap: 7px; justify-content: center; +} +.synapse-ct-page .syn-mode-btn:hover { color: var(--syn-text); background: rgba(120,170,220,0.13); } +.synapse-ct-page .syn-mode-btn.active { + color: var(--syn-text); background: rgba(84,214,255,0.14); + border-color: rgba(84,214,255,0.42); + box-shadow: inset 0 0 16px rgba(84,214,255,0.18); +} +.synapse-ct-page .syn-mode-btn .sw { width: 7px; height: 7px; border-radius: 2px; } + +/* CT preview windows */ +.synapse-ct-page .syn-ct-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } +.synapse-ct-page .syn-ct-win { + border-radius: 9px; overflow: hidden; + border: 1px solid var(--syn-border); + background: #05080d; position: relative; aspect-ratio: 1; +} +.synapse-ct-page .syn-ct-win canvas { width: 100%; height: 100%; display: block; } +.synapse-ct-page .syn-ct-win .lbl { + position: absolute; top: 5px; left: 6px; + font-size: 8px; letter-spacing: 1.5px; + color: var(--syn-cyan-soft); + font-family: 'Space Mono', ui-monospace, monospace; + text-shadow: 0 0 6px #000; +} +.synapse-ct-page .syn-ct-win .sl { + position: absolute; bottom: 4px; right: 6px; + font-size: 8px; color: var(--syn-text-dim); + font-family: 'Space Mono', ui-monospace, monospace; + text-shadow: 0 0 6px #000; +} + +/* confidence bars */ +.synapse-ct-page .syn-conf { margin-bottom: 11px; } +.synapse-ct-page .syn-conf .top { + display: flex; justify-content: space-between; + font-size: 11px; margin-bottom: 6px; color: var(--syn-text-dim); +} +.synapse-ct-page .syn-conf .top b { + color: var(--syn-text); font-family: 'Space Mono', ui-monospace, monospace; font-weight: 400; +} +.synapse-ct-page .syn-bar { + height: 5px; border-radius: 4px; + background: rgba(120,160,200,0.1); overflow: hidden; +} +.synapse-ct-page .syn-bar i { + display: block; height: 100%; border-radius: 4px; + background: linear-gradient(90deg, var(--syn-cyan), var(--syn-blue)); + box-shadow: 0 0 10px rgba(84,214,255,0.5); + width: 0; transition: width 1.3s cubic-bezier(0.2,0.8,0.2,1); +} + +/* selected info card */ +.synapse-ct-page .syn-selinfo .name { font-size: 18px; font-weight: 600; margin-bottom: 3px; } +.synapse-ct-page .syn-selinfo .sys { + font-size: 10px; letter-spacing: 1.5px; + color: var(--syn-cyan-soft); text-transform: uppercase; margin-bottom: 11px; +} +.synapse-ct-page .syn-selinfo .sub-name { + font-size: 13px; font-weight: 500; margin-bottom: 4px; color: var(--syn-amber-soft); +} +.synapse-ct-page .syn-selinfo p { + font-size: 11.5px; line-height: 1.6; color: var(--syn-text-dim); margin-bottom: 12px; +} +.synapse-ct-page .syn-finding { + display: flex; align-items: center; gap: 8px; + font-size: 11px; color: var(--syn-green); + background: rgba(95,227,161,0.07); + border: 1px solid rgba(95,227,161,0.2); + border-radius: 9px; padding: 9px 11px; margin-bottom: 9px; +} + +/* collapsible header */ +.synapse-ct-page .syn-collapse-head { + display: flex; justify-content: space-between; align-items: center; + cursor: pointer; user-select: none; margin-bottom: 0; +} +.synapse-ct-page .syn-collapse-head .syn-ctitle { margin-bottom: 0; } +.synapse-ct-page .syn-collapse-head .chev { color: var(--syn-text-faint); transition: 0.3s; } +.synapse-ct-page .syn-collapse-head.open .chev { transform: rotate(180deg); } +.synapse-ct-page .syn-collapse-body { margin-top: 14px; } + +/* center overlays */ +.synapse-ct-page .syn-view-presets { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + display: flex; gap: 6px; padding: 5px; border-radius: 30px; + background: var(--syn-panel); backdrop-filter: blur(18px); + border: 1px solid var(--syn-border); z-index: 5; +} +.synapse-ct-page .syn-view-presets button { + background: none; border: none; color: var(--syn-text-dim); cursor: pointer; + font-family: inherit; font-size: 11px; + padding: 7px 13px; border-radius: 24px; + transition: 0.28s; letter-spacing: 0.4px; +} +.synapse-ct-page .syn-view-presets button:hover { + color: var(--syn-text); background: rgba(120,170,220,0.12); +} + +.synapse-ct-page .syn-focus-rail { + position: absolute; left: 18px; top: 50%; transform: translateY(-50%); + display: flex; flex-direction: column; gap: 6px; z-index: 5; +} +.synapse-ct-page .syn-focus-rail button { + background: var(--syn-panel); backdrop-filter: blur(16px); + border: 1px solid var(--syn-border); color: var(--syn-text-dim); + cursor: pointer; font-family: inherit; + font-size: 11px; padding: 9px 13px; border-radius: 10px; + transition: 0.28s; text-align: left; + display: flex; align-items: center; gap: 8px; min-width: 138px; +} +.synapse-ct-page .syn-focus-rail button:hover { + color: var(--syn-text); border-color: var(--syn-border-strong); + background: rgba(120,170,220,0.13); transform: translateX(3px); +} +.synapse-ct-page .syn-focus-rail button.active { + color: var(--syn-text); border-color: rgba(84,214,255,0.4); + box-shadow: 0 0 16px rgba(84,214,255,0.15); +} +.synapse-ct-page .syn-focus-rail .ico { + width: 6px; height: 6px; border-radius: 50%; + background: var(--syn-cyan); box-shadow: 0 0 8px var(--syn-cyan); +} + +.synapse-ct-page .syn-instr { + position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); + font-size: 11px; color: var(--syn-text-faint); letter-spacing: 0.6px; + background: var(--syn-panel); backdrop-filter: blur(14px); + border: 1px solid var(--syn-border); + padding: 8px 18px; border-radius: 24px; + z-index: 5; white-space: nowrap; +} +.synapse-ct-page .syn-instr b { color: var(--syn-cyan-soft); font-weight: 400; } + +.synapse-ct-page .syn-cam-tools { + position: absolute; right: 18px; top: 50%; transform: translateY(-50%); + display: flex; flex-direction: column; gap: 7px; z-index: 5; +} +.synapse-ct-page .syn-cam-tools button { + width: 42px; height: 42px; border-radius: 12px; + background: var(--syn-panel); backdrop-filter: blur(16px); + border: 1px solid var(--syn-border); color: var(--syn-text-dim); + cursor: pointer; display: grid; place-items: center; + transition: 0.28s; +} +.synapse-ct-page .syn-cam-tools button:hover { + color: var(--syn-text); border-color: var(--syn-border-strong); + box-shadow: 0 0 16px rgba(84,214,255,0.18); +} +.synapse-ct-page .syn-cam-tools button.on { + color: var(--syn-cyan); border-color: rgba(84,214,255,0.5); + box-shadow: 0 0 18px rgba(84,214,255,0.25); +} +.synapse-ct-page .syn-cam-tools svg { width: 18px; height: 18px; } + +/* loading fallback */ +.synapse-ct-page .syn-loader { + position: absolute; inset: 0; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 16px; z-index: 6; pointer-events: none; text-align: center; +} +.synapse-ct-page .syn-loader .ring { + width: 54px; height: 54px; border-radius: 50%; + border: 2px solid rgba(84,214,255,0.15); + border-top-color: var(--syn-cyan); + animation: syn-spin 1s linear infinite; +} +@keyframes syn-spin { to { transform: rotate(360deg); } } +.synapse-ct-page .syn-loader .t { font-size: 13px; color: var(--syn-text-dim); } +.synapse-ct-page .syn-loader .s { + font-size: 10.5px; color: var(--syn-text-faint); + max-width: 300px; line-height: 1.5; +} + +/* bottom timeline */ +.synapse-ct-page .syn-timeline { + display: flex; align-items: center; gap: 18px; + margin: 10px 20px 16px; padding: 14px 20px; + background: var(--syn-panel); backdrop-filter: blur(20px); + border: 1px solid var(--syn-border); border-radius: 18px; +} +.synapse-ct-page .syn-tl-play { + width: 42px; height: 42px; border-radius: 50%; + flex: none; cursor: pointer; + background: linear-gradient(120deg, rgba(84,214,255,0.2), rgba(74,158,255,0.14)); + border: 1px solid rgba(84,214,255,0.4); color: var(--syn-text); + display: grid; place-items: center; + transition: 0.3s; box-shadow: 0 0 18px rgba(84,214,255,0.15); +} +.synapse-ct-page .syn-tl-play:hover { box-shadow: 0 0 26px rgba(84,214,255,0.3); } +.synapse-ct-page .syn-tl-play svg { width: 16px; height: 16px; } +.synapse-ct-page .syn-tl-label { + font-size: 10px; letter-spacing: 2px; + color: var(--syn-text-faint); text-transform: uppercase; flex: none; +} +.synapse-ct-page .syn-tl-track { flex: 1; position: relative; } +.synapse-ct-page .syn-tl-track input { width: 100%; } +.synapse-ct-page .syn-tl-marks { + display: flex; justify-content: space-between; + margin-top: 7px; font-size: 9px; letter-spacing: 1px; + color: var(--syn-text-faint); text-transform: uppercase; +} +.synapse-ct-page .syn-tl-marks span.cur { color: var(--syn-cyan-soft); } +.synapse-ct-page .syn-tl-readout { flex: none; text-align: right; } +.synapse-ct-page .syn-tl-readout .big { + font-family: 'Space Mono', ui-monospace, monospace; + font-size: 15px; color: var(--syn-text); +} +.synapse-ct-page .syn-tl-readout .sm { + font-size: 9px; letter-spacing: 1.5px; + color: var(--syn-text-faint); text-transform: uppercase; +} + +/* range styling */ +.synapse-ct-page input[type='range'] { + -webkit-appearance: none; appearance: none; + height: 4px; border-radius: 4px; + background: linear-gradient(90deg, var(--syn-cyan), rgba(120,160,200,0.12)); + background-size: var(--fill, 50%) 100%; + background-repeat: no-repeat; cursor: pointer; +} +.synapse-ct-page input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; height: 14px; border-radius: 50%; + background: #dff4ff; box-shadow: 0 0 12px var(--syn-cyan); + border: 2px solid var(--syn-cyan); +} +.synapse-ct-page input[type='range']::-moz-range-thumb { + width: 14px; height: 14px; + border: 2px solid var(--syn-cyan); border-radius: 50%; + background: #dff4ff; box-shadow: 0 0 12px var(--syn-cyan); +} + +.synapse-ct-page .syn-slider-block { margin-bottom: 12px; } +.synapse-ct-page .syn-slider-block .lab { + display: flex; justify-content: space-between; + font-size: 10.5px; color: var(--syn-text-dim); margin-bottom: 7px; +} +.synapse-ct-page .syn-slider-block .lab b { + font-family: 'Space Mono', ui-monospace, monospace; + color: var(--syn-cyan-soft); font-weight: 400; +} + +.synapse-ct-page .syn-toggle-row { + display: flex; align-items: center; justify-content: space-between; + font-size: 11px; color: var(--syn-text-dim); padding: 9px 0; +} +.synapse-ct-page .syn-switch { + width: 38px; height: 21px; border-radius: 20px; + background: rgba(120,160,200,0.15); + border: 1px solid var(--syn-border); + position: relative; cursor: pointer; + transition: 0.3s; flex: none; +} +.synapse-ct-page .syn-switch i { + position: absolute; top: 2px; left: 2px; + width: 15px; height: 15px; border-radius: 50%; + background: var(--syn-text-dim); transition: 0.3s; +} +.synapse-ct-page .syn-switch.on { + background: rgba(84,214,255,0.25); border-color: rgba(84,214,255,0.5); +} +.synapse-ct-page .syn-switch.on i { + left: 19px; background: var(--syn-cyan); + box-shadow: 0 0 10px var(--syn-cyan); +} + +/* AI assistant */ +.synapse-ct-page .syn-ai-card { + display: flex; flex-direction: column; + min-height: 380px; max-height: 520px; +} +.synapse-ct-page .syn-ai-head { + display: flex; align-items: center; gap: 11px; margin-bottom: 14px; +} +.synapse-ct-page .syn-ai-head .av { + width: 32px; height: 32px; border-radius: 10px; + background: linear-gradient(135deg, rgba(84,214,255,0.3), rgba(74,158,255,0.2)); + border: 1px solid rgba(84,214,255,0.4); + display: grid; place-items: center; + color: var(--syn-cyan); + box-shadow: 0 0 14px rgba(84,214,255,0.2); + flex: none; +} +.synapse-ct-page .syn-ai-head .ti { font-size: 13px; font-weight: 600; letter-spacing: 0.4px; } +.synapse-ct-page .syn-ai-head .ts { + font-size: 9px; letter-spacing: 1.8px; + color: var(--syn-text-faint); text-transform: uppercase; +} +.synapse-ct-page .syn-ai-head .clear { + margin-left: auto; background: none; + border: 1px solid var(--syn-border); color: var(--syn-text-faint); + width: 28px; height: 28px; border-radius: 8px; + cursor: pointer; display: grid; place-items: center; + transition: 0.25s; +} +.synapse-ct-page .syn-ai-head .clear:hover { + color: var(--syn-text); border-color: var(--syn-border-strong); +} +.synapse-ct-page .syn-ai-msgs { + flex: 1; overflow-y: auto; + padding: 4px 4px 4px 0; + display: flex; flex-direction: column; + gap: 9px; min-height: 160px; +} +.synapse-ct-page .syn-ai-msgs::-webkit-scrollbar { width: 5px; } +.synapse-ct-page .syn-ai-msgs::-webkit-scrollbar-thumb { + background: rgba(120,160,200,0.18); border-radius: 4px; +} +.synapse-ct-page .syn-bubble { + max-width: 88%; padding: 10px 13px; + border-radius: 13px; font-size: 12px; line-height: 1.55; + word-wrap: break-word; +} +.synapse-ct-page .syn-bubble.user { + align-self: flex-end; + background: linear-gradient(120deg, rgba(84,214,255,0.18), rgba(74,158,255,0.14)); + border: 1px solid rgba(84,214,255,0.3); + color: var(--syn-text); border-bottom-right-radius: 4px; +} +.synapse-ct-page .syn-bubble.ai { + align-self: flex-start; + background: rgba(120,170,220,0.06); + border: 1px solid var(--syn-border); + color: var(--syn-text); border-bottom-left-radius: 4px; +} +.synapse-ct-page .syn-bubble.ai .label { + font-size: 9px; letter-spacing: 1.5px; + color: var(--syn-cyan-soft); text-transform: uppercase; + margin-bottom: 6px; display: flex; align-items: center; gap: 5px; +} +.synapse-ct-page .syn-bubble.ai .label i { + width: 4px; height: 4px; border-radius: 50%; + background: var(--syn-cyan); box-shadow: 0 0 5px var(--syn-cyan); +} +.synapse-ct-page .syn-bubble.ai .actions { + margin-top: 9px; display: flex; flex-wrap: wrap; gap: 6px; +} +.synapse-ct-page .syn-bubble.ai .actions button { + font-family: inherit; cursor: pointer; + background: rgba(84,214,255,0.1); + border: 1px solid rgba(84,214,255,0.3); + color: var(--syn-cyan-soft); + padding: 5px 10px; font-size: 10.5px; + border-radius: 7px; transition: 0.25s; +} +.synapse-ct-page .syn-bubble.ai .actions button:hover { + background: rgba(84,214,255,0.2); color: var(--syn-text); +} +.synapse-ct-page .syn-typing { display: inline-flex; gap: 4px; padding: 4px 0; } +.synapse-ct-page .syn-typing span { + width: 6px; height: 6px; border-radius: 50%; + background: var(--syn-cyan-soft); opacity: 0.6; + animation: syn-blink 1.4s infinite; +} +.synapse-ct-page .syn-typing span:nth-child(2) { animation-delay: 0.2s; } +.synapse-ct-page .syn-typing span:nth-child(3) { animation-delay: 0.4s; } +@keyframes syn-blink { + 0%,60%,100% { opacity: 0.25; } + 30% { opacity: 1; } +} + +.synapse-ct-page .syn-ai-chips { + display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; +} +.synapse-ct-page .syn-ai-chips button { + font-family: inherit; background: rgba(120,170,220,0.06); + border: 1px solid var(--syn-border); color: var(--syn-text-dim); + font-size: 10.5px; padding: 6px 10px; + border-radius: 14px; cursor: pointer; transition: 0.25s; +} +.synapse-ct-page .syn-ai-chips button:hover { + color: var(--syn-text); border-color: var(--syn-border-strong); + background: rgba(120,170,220,0.14); +} + +.synapse-ct-page .syn-ai-input { + display: flex; gap: 8px; margin-top: 11px; +} +.synapse-ct-page .syn-ai-input input { + flex: 1; background: rgba(10,16,26,0.6); + border: 1px solid var(--syn-border); border-radius: 10px; + padding: 10px 12px; color: var(--syn-text); + font-family: inherit; font-size: 12px; outline: none; + transition: 0.25s; +} +.synapse-ct-page .syn-ai-input input:focus { + border-color: rgba(84,214,255,0.45); + box-shadow: 0 0 14px rgba(84,214,255,0.18); +} +.synapse-ct-page .syn-ai-input button { + width: 38px; height: 38px; border-radius: 10px; + background: linear-gradient(120deg, rgba(84,214,255,0.25), rgba(74,158,255,0.18)); + border: 1px solid rgba(84,214,255,0.42); + color: var(--syn-text); cursor: pointer; + display: grid; place-items: center; transition: 0.25s; +} +.synapse-ct-page .syn-ai-input button:hover { box-shadow: 0 0 16px rgba(84,214,255,0.28); } +.synapse-ct-page .syn-ai-input button:disabled { opacity: 0.45; cursor: not-allowed; } +.synapse-ct-page .syn-ai-disclaimer { + margin-top: 9px; font-size: 9.5px; + letter-spacing: 0.3px; color: var(--syn-text-faint); + text-align: center; line-height: 1.45; +} + +@media (max-width: 1100px) { + .synapse-ct-page .syn-stage { + grid-template-columns: 1fr; + grid-auto-rows: min-content; + overflow-y: auto; + } + .synapse-ct-page .syn-center { min-height: 62vh; order: -1; } + .synapse-ct-page .syn-col { max-height: none; } + .synapse-ct-page .syn-focus-rail { display: none; } +} diff --git a/PanTS-Demo/src/synapse/utils/aiContextBuilder.ts b/PanTS-Demo/src/synapse/utils/aiContextBuilder.ts new file mode 100644 index 0000000..2a5c8c3 --- /dev/null +++ b/PanTS-Demo/src/synapse/utils/aiContextBuilder.ts @@ -0,0 +1,101 @@ +import { ANATOMY, type OrganId, type SubStructure } from '../data/anatomyData'; +import { SCAN_SUMMARY } from '../data/scanData'; +import type { VisMode } from '../data/scanData'; + +export interface ViewerState { + selectedOrgan: OrganId | null; + selectedSubKey: string | null; + selectedSubData: SubStructure | null; + mode: VisMode; + slices: { axial: number; sagittal: number; coronal: number }; + autoRotate: boolean; +} + +export interface AIContext { + selection: { + organ: string; + system: string; + location: string; + finding: string; + confidence: number; + description: string; + subStructure?: { name: string; description: string }; + } | null; + visualizationMode: VisMode; + slices: { axial: number; sagittal: number; coronal: number; total: number }; + autoRotate: boolean; + study: { + caseId: string; + study: string; + source: string; + sliceThickness: string; + contrast: string; + scanQuality: number; + }; + disclaimer: string; +} + +export function buildAIContext(state: ViewerState): AIContext { + const organ = state.selectedOrgan ? ANATOMY[state.selectedOrgan] : null; + + return { + selection: organ + ? { + organ: organ.name, + system: organ.system, + location: organ.location, + finding: organ.finding, + confidence: organ.confidence, + description: organ.description, + subStructure: state.selectedSubData + ? { + name: state.selectedSubData.displayName, + description: state.selectedSubData.description, + } + : undefined, + } + : null, + visualizationMode: state.mode, + slices: { + axial: state.slices.axial, + sagittal: state.slices.sagittal, + coronal: state.slices.coronal, + total: SCAN_SUMMARY.images, + }, + autoRotate: state.autoRotate, + study: { + caseId: SCAN_SUMMARY.caseId, + study: SCAN_SUMMARY.study, + source: SCAN_SUMMARY.source, + sliceThickness: SCAN_SUMMARY.sliceThickness, + contrast: SCAN_SUMMARY.contrast, + scanQuality: SCAN_SUMMARY.scanQuality, + }, + disclaimer: + 'Educational visualization demo only. Not for clinical diagnosis or medical decision-making.', + }; +} + +export function renderContextAsPrompt(ctx: AIContext): string { + const sel = ctx.selection + ? `Selected organ: ${ctx.selection.organ} (${ctx.selection.system}, ${ctx.selection.location}). ` + + (ctx.selection.subStructure + ? `Specifically focused on the ${ctx.selection.subStructure.name}. ` + : '') + + `Demo AI finding: "${ctx.selection.finding}" at ${ctx.selection.confidence}% segmentation confidence.` + : 'No anatomical structure is currently selected.'; + + return [ + 'You are SYNAPSE AI, the imaging assistant inside the SYNAPSE CT volumetric explorer integrated into the PanTS-Demo / BodyMaps website.', + 'The 3D models on display are Visible Human Project anatomical segmentations.', + 'You explain anatomy, visualization modes, CT slice navigation, and AI segmentation concepts.', + 'You NEVER provide a real medical diagnosis. All findings are simulated demo data.', + '', + `Case: ${ctx.study.caseId}. Study: ${ctx.study.study}. Source: ${ctx.study.source}.`, + sel, + `Current visualization mode: ${ctx.visualizationMode}.`, + `Current slice indices — axial: ${ctx.slices.axial}/${ctx.slices.total}, sagittal: ${ctx.slices.sagittal}/${ctx.slices.total}, coronal: ${ctx.slices.coronal}/${ctx.slices.total}.`, + '', + 'Always end with the disclaimer: "Educational visualization demo only. Not for clinical diagnosis."', + ].join('\n'); +} diff --git a/PanTS-Demo/src/synapse/utils/mockAIResponses.ts b/PanTS-Demo/src/synapse/utils/mockAIResponses.ts new file mode 100644 index 0000000..316bb18 --- /dev/null +++ b/PanTS-Demo/src/synapse/utils/mockAIResponses.ts @@ -0,0 +1,238 @@ +import { ANATOMY, type OrganId } from '../data/anatomyData'; +import type { AIContext } from './aiContextBuilder'; + +export const DISCLAIMER = + 'Educational visualization demo only. Not for clinical diagnosis or medical decision-making.'; + +export type ActionType = 'focus' | 'mode' | 'view' | 'reset' | 'play_slices'; + +export interface AssistantAction { + id: string; + label: string; + type: ActionType; + payload: string | null; +} + +export interface AssistantResponse { + text: string; + actions: AssistantAction[]; +} + +function action(id: string, label: string, type: ActionType, payload: string | null = null): AssistantAction { + return { id, label, type, payload }; +} + +const focusAction = (organId: OrganId): AssistantAction => + action(`focus_${organId}`, `Focus on ${ANATOMY[organId].name}`, 'focus', organId); + +const modeAction = (modeId: string, label: string): AssistantAction => + action(`mode_${modeId}`, label, 'mode', modeId); + +function diagnosticRefusal(ctx: AIContext): AssistantResponse { + return { + text: + `I can explain what's displayed on the SYNAPSE CT viewer — anatomy, segmentation confidence, ` + + `visualization modes, and CT slice navigation — but I can't provide a medical diagnosis. ` + + `The findings shown on this case (${ctx.study.caseId}) are simulated demo data, not a real patient. ` + + `For real imaging review, a qualified radiologist must read the actual study.`, + actions: [], + }; +} + +function describeOrgan(organId: OrganId, ctx: AIContext): AssistantResponse { + const a = ANATOMY[organId]; + const subLine = ctx.selection?.subStructure + ? `\n\nYou're currently focused on the **${ctx.selection.subStructure.name}**: ${ctx.selection.subStructure.description}` + : ''; + return { + text: + `**${a.name}** — ${a.system}, located in the ${a.location.toLowerCase()}.\n\n` + + `${a.description}\n\n` + + `Demo AI finding: "${a.finding}" at ${a.confidence}% confidence.${subLine}`, + actions: [ + focusAction(a.id), + modeAction('sections', 'Color-code Sub-structures'), + modeAction('translucent', 'View Translucent'), + ], + }; +} + +interface KeywordRule { + match: string[]; + organ: OrganId; +} + +const KEYWORDS: KeywordRule[] = [ + { match: ['liver', 'hepat', 'couinaud'], organ: 'liver' }, + { match: ['kidney', 'renal', 'nephr'], organ: 'kidney' }, + { match: ['lung', 'pulmonary', 'bronch', 'respirat'], organ: 'lung' }, + { match: ['pancreas', 'pancreatic', 'islet'], organ: 'pancreas' }, + { match: ['colon', 'large intestine', 'caecum', 'appendix', 'rectum', 'sigmoid'], organ: 'colon' }, +]; + +export function generateMockResponse(userText: string, ctx: AIContext): AssistantResponse { + const q = (userText || '').toLowerCase().trim(); + + // 1. Refuse diagnoses. + if (/diagnos|prognos|treatment|cancer\?|tumor\?|is this (bad|serious)|am i ok/.test(q)) { + return diagnosticRefusal(ctx); + } + + // 2. "What am I looking at?" + if ( + /what (am i|is this|do i see|i'?m i looking at)|current selection|this structure|right now/.test(q) || + /^what'?s (this|that)\??$/.test(q) + ) { + if (ctx.selection) { + const organId = KEYWORDS.find((k) => ctx.selection!.organ.toLowerCase().includes(k.organ))?.organ ?? null; + const fromName = (Object.keys(ANATOMY) as OrganId[]).find( + (id) => ANATOMY[id].name === ctx.selection!.organ + ); + const target = organId || fromName; + if (target) return describeOrgan(target, ctx); + } + return { + text: + `Nothing is selected yet. Pick an organ from the focus rail on the left or click directly on the 3D model. ` + + `Once selected I can describe it, summarize the demo AI finding, and walk you through any sub-structure you click.`, + actions: [focusAction('liver'), focusAction('lung'), focusAction('pancreas')], + }; + } + + // 3. Keyword match against organ names. + for (const k of KEYWORDS) { + if (k.match.some((m) => q.includes(m))) { + return describeOrgan(k.organ, ctx); + } + } + + // 4. Visualization-mode questions. + if (/solid mode|opaque/.test(q)) { + return { + text: + 'Solid mode renders the organ with full opacity — the default view for inspecting surface morphology.', + actions: [modeAction('solid', 'Switch to Solid Mode')], + }; + } + if (/translucent|see through|see-through/.test(q)) { + return { + text: + 'Translucent mode drops opacity so you can see internal sub-structures (lobes, segments, vessels-impressions) through the outer surface. Useful for understanding spatial relationships.', + actions: [modeAction('translucent', 'Switch to Translucent Mode')], + }; + } + if (/x-?ray|emissive/.test(q)) { + return { + text: + 'X-Ray mode boosts emissive intensity on a heavily transparent material — gives a glowing volumetric look reminiscent of cinematic medical imaging visualizations.', + actions: [modeAction('xray', 'Switch to X-Ray Mode')], + }; + } + if (/section|lobe|segment|color.?cod|couinaud/.test(q)) { + return { + text: + "Sections mode assigns a different color to each anatomical sub-structure. " + + "For the liver that means the eight Couinaud segments, the caudate and quadrate lobes, and ligaments. " + + "For the lungs it color-codes each bronchopulmonary segment.", + actions: [modeAction('sections', 'Switch to Sections Mode')], + }; + } + if (/wireframe|mesh/.test(q)) { + return { + text: 'Wireframe mode shows the underlying triangle mesh — handy if you want to see how detailed the segmentation actually is.', + actions: [modeAction('wireframe', 'Switch to Wireframe Mode')], + }; + } + + // 5. CT slice questions. + if (/slice|axial|sagittal|coronal|multiplanar|mpr/.test(q)) { + return { + text: + `The viewer reconstructs ${ctx.study.study} as ${ctx.slices.total} axial slices at ${ctx.study.sliceThickness} thickness. ` + + `You're currently on axial ${ctx.slices.axial}, sagittal ${ctx.slices.sagittal}, coronal ${ctx.slices.coronal}. ` + + `Use the sliders on the right or play the bottom timeline to sweep superior → inferior.`, + actions: [ + modeAction('slices', 'Open CT Slice Mode'), + action('play_slices', 'Play Scan Sweep', 'play_slices', null), + ], + }; + } + + // 6. Confidence / AI segmentation. + if (/confidence|segmentation|accuracy|ai (work|do|analy)/.test(q)) { + return { + text: + "Segmentation confidence is the model's probability that a given voxel cluster belongs to the labelled structure. " + + '98% means the network is very sure of its outline; under ~85% you would usually want a human radiologist to verify the boundary. ' + + 'For this demo case overall scores are: Organ 98.4% · Sub-structure 96.5% · Surface 99.1% · Registration 97.8%.', + actions: [], + }; + } + + // 7. Region / system questions. + if (/what.*(abdomen|belly|abdominal)/.test(q)) { + return { + text: + 'In this dataset the abdomen contains the liver (right upper quadrant), pancreas (retroperitoneum across L1/L2), and most of the colon. The right kidney sits posteriorly in the retroperitoneum.', + actions: [focusAction('liver'), focusAction('pancreas'), focusAction('colon'), focusAction('kidney')], + }; + } + if (/what.*(chest|thorax|respirat)/.test(q)) { + return { + text: + 'In the thorax this dataset shows the lungs with detailed bronchopulmonary segments and the full tracheobronchial cartilage tree.', + actions: [focusAction('lung')], + }; + } + if (/visible human|panTS|pants|dataset/i.test(q)) { + return { + text: + 'The 3D models in this viewer are sub-anatomical segmentations from the Visible Human Project, shipped with the PanTS-Demo website. ' + + '"VH_M" prefixed meshes come from the Visible Human Male, "VH_F" from the Visible Human Female. ' + + 'Each organ is broken down into clinically meaningful sub-structures (e.g. Couinaud liver segments, bronchopulmonary lung segments).', + actions: [focusAction('liver'), focusAction('lung')], + }; + } + + // 8. Help. + if (/help|what can you do|capab|how do/.test(q)) { + return { + text: + 'I can help you with:\n' + + '• Explaining any of the 5 organs and their sub-structures (Couinaud segments, bronchopulmonary segments, etc.)\n' + + '• Comparing visualization modes (Solid, Translucent, X-Ray, Sections, CT Slices, Wireframe)\n' + + '• Walking you through the CT slice explorer\n' + + '• Explaining segmentation confidence and the Visible Human dataset\n\n' + + 'Try: "What are the Couinaud segments?", "Switch to Sections mode", or "What\'s in the abdomen?"', + actions: [focusAction('liver'), modeAction('sections', 'Try Sections Mode'), action('reset', 'Reset View', 'reset', null)], + }; + } + + // 9. Greeting. + if (/^(hi|hello|hey|yo|sup)\b/.test(q)) { + return { + text: + `Hi — SYNAPSE AI here. Case ${ctx.study.caseId} is loaded from the ${ctx.study.source} and AI segmentation is complete. ` + + `Pick an organ on the focus rail or ask me about any of the 5 anatomical structures, CT slices, or visualization modes.`, + actions: [focusAction('liver'), focusAction('lung'), modeAction('slices', 'Open CT Slices')], + }; + } + + // 10. Default fallback. + const selLine = ctx.selection + ? `You currently have **${ctx.selection.organ}** selected${ + ctx.selection.subStructure ? ` (focused on ${ctx.selection.subStructure.name})` : '' + }.` + : 'Nothing is selected right now.'; + return { + text: + `I'm not sure I caught that, but I can answer questions about the 5 organs (liver, kidney, lung, pancreas, colon), ` + + `visualization modes, CT slice navigation, or segmentation confidence. ${selLine}`, + actions: ctx.selection + ? [ + modeAction('sections', 'Color-code Sub-structures'), + modeAction('slices', 'Open CT Slices'), + ] + : [focusAction('liver'), focusAction('lung'), focusAction('pancreas')], + }; +}