|
|
import { useRef, useEffect, useState } from "react"; |
|
|
import { Niivue } from "@niivue/niivue"; |
|
|
|
|
|
interface NiiVueViewerProps { |
|
|
backgroundUrl: string; |
|
|
overlayUrl?: string; |
|
|
onError?: (error: string) => void; |
|
|
} |
|
|
|
|
|
export function NiiVueViewer({ |
|
|
backgroundUrl, |
|
|
overlayUrl, |
|
|
onError, |
|
|
}: NiiVueViewerProps) { |
|
|
const canvasRef = useRef<HTMLCanvasElement>(null); |
|
|
const nvRef = useRef<Niivue | null>(null); |
|
|
const onErrorRef = useRef(onError); |
|
|
const [loadError, setLoadError] = useState<string | null>(null); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
onErrorRef.current = onError; |
|
|
}); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!canvasRef.current) return; |
|
|
|
|
|
const nv = new Niivue({ |
|
|
backColor: [0.05, 0.05, 0.05, 1], |
|
|
show3Dcrosshair: true, |
|
|
crosshairColor: [1, 0, 0, 0.5], |
|
|
}); |
|
|
nv.attachToCanvas(canvasRef.current); |
|
|
nvRef.current = nv; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return () => { |
|
|
|
|
|
const gl = nv.gl; |
|
|
try { |
|
|
|
|
|
|
|
|
nv.cleanup(); |
|
|
|
|
|
if (gl) { |
|
|
const ext = gl.getExtension("WEBGL_lose_context"); |
|
|
ext?.loseContext(); |
|
|
} |
|
|
} catch { |
|
|
|
|
|
} |
|
|
nvRef.current = null; |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const nv = nvRef.current; |
|
|
if (!nv) return; |
|
|
|
|
|
let isCurrent = true; |
|
|
|
|
|
|
|
|
|
|
|
setLoadError(null); |
|
|
|
|
|
const volumes: Array<{ url: string; colormap: string; opacity: number }> = [ |
|
|
{ url: backgroundUrl, colormap: "gray", opacity: 1 }, |
|
|
]; |
|
|
|
|
|
if (overlayUrl) { |
|
|
volumes.push({ |
|
|
url: overlayUrl, |
|
|
colormap: "red", |
|
|
opacity: 0.5, |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
nv.loadVolumes(volumes).catch((err: unknown) => { |
|
|
if (!isCurrent) return; |
|
|
const message = |
|
|
err instanceof Error ? err.message : "Failed to load volume"; |
|
|
setLoadError(message); |
|
|
onErrorRef.current?.(message); |
|
|
}); |
|
|
|
|
|
|
|
|
return () => { |
|
|
isCurrent = false; |
|
|
}; |
|
|
}, [backgroundUrl, overlayUrl]); |
|
|
|
|
|
return ( |
|
|
<div className="bg-gray-900 rounded-lg p-2"> |
|
|
<canvas ref={canvasRef} className="w-full h-[500px] rounded" /> |
|
|
{loadError && ( |
|
|
<div className="mt-2 p-2 bg-red-900/50 rounded text-red-300 text-sm"> |
|
|
Failed to load volume: {loadError} |
|
|
</div> |
|
|
)} |
|
|
<div className="flex gap-4 mt-2 text-xs text-gray-400"> |
|
|
<span>Scroll: Navigate slices</span> |
|
|
<span>Drag: Adjust contrast</span> |
|
|
<span>Right-click: Pan</span> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|