File size: 3,471 Bytes
900a32d
 
e4daa3b
 
900a32d
 
 
e4daa3b
 
900a32d
 
 
 
 
 
 
 
 
e4daa3b
 
 
900a32d
 
e4daa3b
 
 
900a32d
e4daa3b
 
 
 
 
900a32d
 
 
e4daa3b
 
 
 
 
 
900a32d
e4daa3b
 
 
900a32d
e4daa3b
 
900a32d
 
e4daa3b
 
 
 
900a32d
 
 
e4daa3b
 
 
 
900a32d
 
e4daa3b
900a32d
e4daa3b
 
 
900a32d
e4daa3b
 
900a32d
 
e4daa3b
 
 
 
900a32d
e4daa3b
900a32d
e4daa3b
 
 
 
900a32d
 
 
 
 
 
e4daa3b
 
 
900a32d
 
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900a32d
e4daa3b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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);

  // Keep onError ref current without triggering effect re-runs
  useEffect(() => {
    onErrorRef.current = onError;
  });

  // Effect 1: Mount/unmount - instantiate and cleanup NiiVue ONCE
  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;

    // Cleanup on unmount ONLY - CRITICAL: Release WebGL context
    // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup,
    // navigating between cases will exhaust contexts and break the viewer.
    return () => {
      // Capture gl BEFORE cleanup (cleanup may null internal state)
      const gl = nv.gl;
      try {
        // NiiVue's cleanup() releases event listeners and observers
        // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup
        nv.cleanup();
        // Force WebGL context loss to free GPU memory immediately
        if (gl) {
          const ext = gl.getExtension("WEBGL_lose_context");
          ext?.loseContext();
        }
      } catch {
        // Ignore cleanup errors
      }
      nvRef.current = null;
    };
  }, []);

  // Effect 2: URL changes - reload volumes on existing NiiVue instance
  // Uses isCurrent flag to ignore stale loads when URLs change rapidly
  useEffect(() => {
    const nv = nvRef.current;
    if (!nv) return;

    let isCurrent = true;

    // Clear previous error before new load (valid pattern for async operations)
    // eslint-disable-next-line react-hooks/set-state-in-effect
    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,
      });
    }

    // Load volumes with error handling - ignore stale results
    nv.loadVolumes(volumes).catch((err: unknown) => {
      if (!isCurrent) return; // Ignore errors from stale loads
      const message =
        err instanceof Error ? err.message : "Failed to load volume";
      setLoadError(message);
      onErrorRef.current?.(message);
    });

    // Cleanup: mark this effect instance as stale
    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>
  );
}