more improvements
This commit is contained in:
+210
-75
@@ -24,6 +24,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StackEntry = { imageIds: string[], studyInstanceUID?: string };
|
||||||
|
|
||||||
// Add this type for clarity
|
// Add this type for clarity
|
||||||
declare global {
|
declare global {
|
||||||
@@ -109,6 +110,7 @@ function App() {
|
|||||||
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||||
// State for overlay info
|
// State for overlay info
|
||||||
const [imageInfo, setImageInfo] = useState({
|
const [imageInfo, setImageInfo] = useState({
|
||||||
index: 0,
|
index: 0,
|
||||||
@@ -121,7 +123,9 @@ function App() {
|
|||||||
const renderingEngineId = "mainRenderingEngine";
|
const renderingEngineId = "mainRenderingEngine";
|
||||||
const renderingEngineRef = useRef<any>(null);
|
const renderingEngineRef = useRef<any>(null);
|
||||||
|
|
||||||
const [availableStacks, setAvailableStacks] = useState<string[][]>([]);
|
|
||||||
|
|
||||||
|
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
||||||
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
||||||
const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
|
const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
|
||||||
() => Array(MAX_VIEWPORTS).fill([])
|
() => Array(MAX_VIEWPORTS).fill([])
|
||||||
@@ -131,19 +135,47 @@ function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Load available stacks on mount
|
// Load available stacks on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
availableImageIds().then(stacks => {
|
availableImageIds().then(async stacks => {
|
||||||
setAvailableStacks(stacks);
|
// Try to extract StudyInstanceUID for each stack
|
||||||
// Optionally initialize each viewport with the first stack
|
const stackEntries: StackEntry[] = await Promise.all(
|
||||||
setLoadedImageIdsAndCacheMeta(stacks[0]);
|
stacks.map(async imageIds => {
|
||||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || []));
|
let studyInstanceUID: string | undefined = undefined;
|
||||||
});
|
try {
|
||||||
}, []);
|
// Try to fetch the first image as a blob and parse it
|
||||||
|
const firstId = imageIds[0];
|
||||||
|
if (firstId && firstId.startsWith("wadouri:")) {
|
||||||
|
const url = firstId.slice(8);
|
||||||
|
const response = await fetch(url);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||||
|
studyInstanceUID = dataSet.string('x0020000d');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors, fallback to undefined
|
||||||
|
}
|
||||||
|
return { imageIds, studyInstanceUID };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setAvailableStacks(stackEntries);
|
||||||
|
//setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined })));
|
||||||
|
setLoadedImageIdsAndCacheMeta(stacks[0]);
|
||||||
|
setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || []));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
||||||
const stack = availableStacks[stackIdx];
|
const stackObj = availableStacks[stackIdx];
|
||||||
|
if (!stackObj) return;
|
||||||
|
const stack = stackObj.imageIds;
|
||||||
|
|
||||||
|
// Always revert to stack mode when loading a new stack
|
||||||
|
setViewportModes(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[viewportIdx] = 'stack';
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
if (!stack) return;
|
|
||||||
setViewportImageIds(prev => {
|
setViewportImageIds(prev => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[viewportIdx] = stack;
|
next[viewportIdx] = stack;
|
||||||
@@ -383,20 +415,20 @@ function App() {
|
|||||||
setMetaModalOpen(true);
|
setMetaModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inside your App component:
|
// Inside your App component:
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
|
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
|
||||||
// Convert FileList to array if needed
|
// Convert FileList to array if needed
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
// Your logic to handle the files and load them as a stack
|
// Your logic to handle the files and load them as a stack
|
||||||
// For example, call your existing handler:
|
// For example, call your existing handler:
|
||||||
handleFilesSelected({ target: { files: fileArray } } as any);
|
handleFilesSelected({ target: { files: fileArray } } as any);
|
||||||
};
|
};
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
window.loadDicomStackFromFiles = undefined;
|
window.loadDicomStackFromFiles = undefined;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [altPressed, setAltPressed] = useState(false);
|
const [altPressed, setAltPressed] = useState(false);
|
||||||
|
|
||||||
@@ -509,18 +541,19 @@ const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> |
|
|||||||
|
|
||||||
setOrderBySliceLocation(false);
|
setOrderBySliceLocation(false);
|
||||||
|
|
||||||
// Convert FileList to Array and sort by name (optional)
|
|
||||||
const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
|
const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
let studyInstanceUID: string | undefined = undefined;
|
||||||
|
const arrayBuffer = await fileArray[0].arrayBuffer();
|
||||||
|
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||||
|
studyInstanceUID = dataSet.string('x0020000d');
|
||||||
|
console.log("StudyInstanceUID:", studyInstanceUID);
|
||||||
|
|
||||||
// Create imageIds for each file
|
|
||||||
const imageIds = fileArray.map(
|
const imageIds = fileArray.map(
|
||||||
file => `wadouri:${URL.createObjectURL(file)}`
|
file => `wadouri:${URL.createObjectURL(file)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add as a new stack
|
|
||||||
setAvailableStacks(prev => {
|
setAvailableStacks(prev => {
|
||||||
const next = [...prev, imageIds];
|
const next = [...prev, { imageIds, studyInstanceUID }];
|
||||||
// Optionally, set the new stack as active in the first viewport:
|
|
||||||
setViewportImageIds(vpPrev => {
|
setViewportImageIds(vpPrev => {
|
||||||
const vpNext = [...vpPrev];
|
const vpNext = [...vpPrev];
|
||||||
vpNext[0] = imageIds;
|
vpNext[0] = imageIds;
|
||||||
@@ -531,7 +564,6 @@ const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> |
|
|||||||
selNext[0] = next.length - 1;
|
selNext[0] = next.length - 1;
|
||||||
return selNext;
|
return selNext;
|
||||||
});
|
});
|
||||||
// Cache imageIds and load metadata for this stack
|
|
||||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
setLoadedImageIdsAndCacheMeta(imageIds);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -969,6 +1001,11 @@ const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference stack and UID for study comparison (use active viewport)
|
||||||
|
const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0;
|
||||||
|
const referenceStackObj = availableStacks[referenceStackIdx];
|
||||||
|
const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||||
|
|
||||||
const viewports = [];
|
const viewports = [];
|
||||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||||
// Determine if this viewport should be visible in the current grid
|
// Determine if this viewport should be visible in the current grid
|
||||||
@@ -1109,6 +1146,40 @@ const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> |
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Fullscreen button below the Volume/Stack toggle */}
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 40, // below the toggle button (which is at top: 8, height: ~32)
|
||||||
|
left: 8,
|
||||||
|
zIndex: 20,
|
||||||
|
background: "#111",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
opacity: 0.85,
|
||||||
|
marginTop: "4px",
|
||||||
|
}}
|
||||||
|
title="Toggle Fullscreen"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = viewportRefs.current[i];
|
||||||
|
if (document.fullscreenElement === el) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else if (el?.requestFullscreen) {
|
||||||
|
el.requestFullscreen();
|
||||||
|
} else if ((el as any)?.webkitRequestFullscreen) {
|
||||||
|
(el as any).webkitRequestFullscreen();
|
||||||
|
} else if ((el as any)?.msRequestFullscreen) {
|
||||||
|
(el as any).msRequestFullscreen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.fullscreenElement === viewportRefs.current[i] ? "Exit Fullscreen" : "⛶ Fullscreen"}
|
||||||
|
</button>
|
||||||
{/* Overlay info (bottom left of viewport) */}
|
{/* Overlay info (bottom left of viewport) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -1328,57 +1399,121 @@ const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> |
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
zIndex: 20,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: "8px",
|
||||||
|
background: "rgba(34,34,34,0.85)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "2px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Custom Stack Dropdown */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
background: "#222",
|
||||||
|
color: "#fff",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
marginRight: "6px",
|
||||||
|
minWidth: 120,
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setOpenDropdownIdx(openDropdownIdx === i ? null : i)}
|
||||||
|
onBlur={() => setTimeout(() => setOpenDropdownIdx(null), 150)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={openDropdownIdx === i}
|
||||||
|
>
|
||||||
|
{/* Show current stack with indicator */}
|
||||||
|
{(() => {
|
||||||
|
const stackObj = availableStacks[selectedStackIdx[i] || 0];
|
||||||
|
const thisUID = stackObj?.studyInstanceUID;
|
||||||
|
const sameStudy = referenceUID && thisUID && referenceUID === thisUID;
|
||||||
|
const dotColor = sameStudy ? "#4caf50" : "#888";
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span style={{
|
||||||
|
color: dotColor,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginRight: 6,
|
||||||
|
fontSize: "16px",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}>●</span>
|
||||||
|
Stack {selectedStackIdx[i] + 1} ({stackObj?.imageIds.length || 0} images)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span style={{ float: "right", fontSize: "12px" }}>▼</span>
|
||||||
|
</button>
|
||||||
|
{openDropdownIdx === i && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
bottom: "110%", // <-- changed from top: "110%" to bottom: "110%"
|
||||||
|
background: "#222",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 100,
|
||||||
|
minWidth: 180,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{availableStacks.map((stackObj, idx) => {
|
||||||
|
const thisUID = stackObj.studyInstanceUID;
|
||||||
|
const sameStudy = referenceUID && thisUID && referenceUID === thisUID;
|
||||||
|
const dotColor = sameStudy ? "#4caf50" : "#888";
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={idx}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedStackIdx[i] === idx}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
padding: "6px 12px",
|
||||||
left: 8,
|
background: selectedStackIdx[i] === idx ? "#444" : "#222",
|
||||||
right: 8,
|
color: "#fff",
|
||||||
bottom: 8,
|
cursor: "pointer",
|
||||||
zIndex: 20,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
fontWeight: selectedStackIdx[i] === idx ? "bold" : "normal",
|
||||||
gap: "8px",
|
borderBottom: "1px solid #333",
|
||||||
background: "rgba(34,34,34,0.85)",
|
}}
|
||||||
borderRadius: "4px",
|
onMouseDown={e => {
|
||||||
padding: "2px 8px",
|
e.preventDefault();
|
||||||
|
handleLoadStack(i, idx);
|
||||||
|
setOpenDropdownIdx(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<select
|
<span style={{
|
||||||
value={selectedStackIdx[i] || 0}
|
color: dotColor,
|
||||||
onChange={e => handleLoadStack(i, Number(e.target.value))}
|
fontWeight: "bold",
|
||||||
style={{
|
marginRight: 8,
|
||||||
background: "#222",
|
fontSize: "16px",
|
||||||
color: "#fff",
|
verticalAlign: "middle",
|
||||||
border: "1px solid #444",
|
}}>●</span>
|
||||||
borderRadius: "4px",
|
Stack {idx + 1} ({stackObj.imageIds.length} images)
|
||||||
padding: "2px 8px",
|
|
||||||
fontSize: "13px",
|
|
||||||
marginRight: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{availableStacks.map((stack, idx) => (
|
|
||||||
<option key={idx} value={idx}>
|
|
||||||
Stack {idx + 1} ({stack.length} images)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: "#1976d2",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "3px",
|
|
||||||
padding: "2px 12px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
onClick={() => handleLoadStack(i, selectedStackIdx[i] || 0)}
|
|
||||||
disabled={!availableStacks.length}
|
|
||||||
>
|
|
||||||
Load Stack
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user