numerous fixes
This commit is contained in:
+709
-25
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react"
|
||||
import createImageIdsAndCacheMetaData from "./lib/createImageIdsAndCacheMetaData"
|
||||
import createImageIds from "./lib/createImageIds.ts"
|
||||
import {
|
||||
RenderingEngine,
|
||||
Enums,
|
||||
@@ -13,7 +13,10 @@ import { StackScrollOutOfBoundsEvent } from 'core/src/types/EventTypes';
|
||||
import { segmentation, Enums as csEnums } from '@cornerstonejs/tools';
|
||||
import { voi } from "@cornerstonejs/tools/utilities"
|
||||
import { readDicomElementExplicit } from "dicom-parser"
|
||||
|
||||
import { volumeLoader } from '@cornerstonejs/core';
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
import * as dicomParser from "dicom-parser";
|
||||
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
|
||||
|
||||
const { MouseBindings, KeyboardBindings } = csToolsEnums;
|
||||
|
||||
@@ -81,6 +84,34 @@ function App() {
|
||||
// Store imageIds globally after loading so they can be reused
|
||||
const [loadedImageIds, setLoadedImageIds] = useState<string[] | null>(null);
|
||||
|
||||
// Wrap setLoadedImageIds to also cache DICOM metadata
|
||||
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => {
|
||||
for (const imageId of imageIds) {
|
||||
// Only cache if it's a local file (wadouri:blob:) or a file URL
|
||||
if (imageId.startsWith("wadouri:")) {
|
||||
try {
|
||||
// Extract the URL after "wadouri:"
|
||||
const url = imageId.slice(8);
|
||||
// Only cache if it's a blob or file URL
|
||||
if (url.startsWith("blob:") || url.startsWith("/")) {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
|
||||
cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url);
|
||||
|
||||
} else {
|
||||
console.warn("MetaDataManager not found. Unable to cache metadata.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to cache metadata for ${imageId}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoadedImageIds(imageIds);
|
||||
};
|
||||
|
||||
|
||||
const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 })
|
||||
const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
|
||||
@@ -92,6 +123,9 @@ function App() {
|
||||
const prevViewportProps = useRef<Record<number, any>>({});
|
||||
const prevScrollIndices = useRef<Record<number, number>>({});
|
||||
|
||||
const [orderBySliceLocation, setOrderBySliceLocation] = useState(true);
|
||||
|
||||
|
||||
// Helper to generate grid options
|
||||
const gridOptions = []
|
||||
for (let rows = 1; rows <= 3; rows++) {
|
||||
@@ -115,6 +149,10 @@ function App() {
|
||||
})
|
||||
);
|
||||
|
||||
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
|
||||
() => Array(MAX_VIEWPORTS).fill('stack')
|
||||
);
|
||||
|
||||
// State for preset menu open/close per viewport
|
||||
const [presetsOpenArr, setPresetsOpenArr] = useState<boolean[]>(
|
||||
() => Array(viewportGrid.rows * viewportGrid.cols).fill(false)
|
||||
@@ -122,9 +160,9 @@ function App() {
|
||||
|
||||
// State for selected tool per mouse button
|
||||
const [mouseToolBindings, setMouseToolBindings] = useState({
|
||||
Primary: WindowLevelTool.toolName,
|
||||
Primary: StackScrollTool.toolName,
|
||||
Auxiliary: PanTool.toolName,
|
||||
Secondary: ZoomTool.toolName,
|
||||
Secondary: WindowLevelTool.toolName,
|
||||
})
|
||||
const [ctrlMouseToolBindings, setCtrlMouseToolBindings] = useState({
|
||||
Primary: LengthTool.toolName,
|
||||
@@ -149,6 +187,65 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [metaModalOpen, setMetaModalOpen] = useState(false);
|
||||
const [metaModalContent, setMetaModalContent] = useState<any>(null);
|
||||
|
||||
// Add this function to gather and format metadata:
|
||||
const handleShowMetadata = () => {
|
||||
if (!loadedImageIds || loadedImageIds.length === 0) {
|
||||
setMetaModalContent("No images loaded.");
|
||||
setMetaModalOpen(true);
|
||||
return;
|
||||
}
|
||||
const imageId = loadedImageIds[0];
|
||||
|
||||
// Gather all available metadata modules for this imageId
|
||||
const modules = [
|
||||
"imagePlaneModule",
|
||||
"generalSeriesModule",
|
||||
"generalStudyModule",
|
||||
"patientModule",
|
||||
"imagePixelModule",
|
||||
"voiLutModule",
|
||||
"modalityLutModule",
|
||||
"sopCommonModule",
|
||||
"petIsotopeModule",
|
||||
"multiframeModule",
|
||||
"cineModule",
|
||||
"overlayPlaneModule",
|
||||
"generalImageModule",
|
||||
"stackModule",
|
||||
"voiLutModule",
|
||||
"modalityLutModule",
|
||||
"petSeriesModule",
|
||||
"petImageModule",
|
||||
"petIsotopeModule",
|
||||
"petSeriesModule",
|
||||
"petImageModule",
|
||||
"petIsotopeModule",
|
||||
"petSeriesModule",
|
||||
"petImageModule",
|
||||
"petIsotopeModule",
|
||||
"petSeriesModule",
|
||||
"petImageModule",
|
||||
"petIsotopeModule",
|
||||
// Add more if your app or cornerstone supports them
|
||||
];
|
||||
|
||||
const allMeta: Record<string, any> = {};
|
||||
modules.forEach((mod) => {
|
||||
const val = metaData.get(mod, imageId);
|
||||
if (val !== undefined) allMeta[mod] = val;
|
||||
});
|
||||
|
||||
setMetaModalContent(
|
||||
<pre style={{ maxHeight: 600, overflow: "auto", fontSize: 13, color: "#fff" }}>
|
||||
{JSON.stringify(allMeta, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
setMetaModalOpen(true);
|
||||
};
|
||||
|
||||
// Helper to apply all mouse tool bindings (normal or ctrl)
|
||||
const applyMouseToolBindings = useCallback(() => {
|
||||
const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupId)
|
||||
@@ -188,6 +285,30 @@ function App() {
|
||||
}
|
||||
}, [mouseToolBindings, ctrlMouseToolBindings])
|
||||
|
||||
const [sideMenuOpen, setSideMenuOpen] = useState(false);
|
||||
|
||||
// Add a ref to store selected files
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Handler for file input change
|
||||
const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Reset order by slice location when new files are loaded
|
||||
setOrderBySliceLocation(false);
|
||||
|
||||
// Convert FileList to Array and sort by name (optional, for series order)
|
||||
const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Create imageIds for each file using the cornerstone-wado-image-loader file API
|
||||
// (Assumes cornerstone-wado-image-loader is configured for file loading)
|
||||
const imageIds = fileArray.map(
|
||||
file => `wadouri:${URL.createObjectURL(file)}`
|
||||
);
|
||||
|
||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
||||
};
|
||||
|
||||
|
||||
const handleToolChange = (mouseButton: string, toolName: string) => {
|
||||
@@ -257,8 +378,8 @@ function App() {
|
||||
// 2. Load image IDs if not already loaded
|
||||
let imageIds = loadedImageIds;
|
||||
if (!imageIds) {
|
||||
imageIds = await createImageIdsAndCacheMetaData();
|
||||
setLoadedImageIds(imageIds);
|
||||
imageIds = await createImageIds();
|
||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
||||
}
|
||||
|
||||
// 3. Get or create the rendering engine
|
||||
@@ -277,27 +398,98 @@ function App() {
|
||||
const element = viewportRefs.current[i];
|
||||
if (!element) continue;
|
||||
|
||||
// Enable element only once
|
||||
if (!element._cornerstoneEnabled) {
|
||||
if (!renderingEngine.getViewport(viewportId)) {
|
||||
renderingEngine.enableElement({
|
||||
viewportId,
|
||||
type: Enums.ViewportType.STACK,
|
||||
element,
|
||||
});
|
||||
element._cornerstoneEnabled = true;
|
||||
const viewportType =
|
||||
viewportModes[i] === 'volume'
|
||||
? Enums.ViewportType.ORTHOGRAPHIC // or Enums.ViewportType.VOLUME if supported
|
||||
: Enums.ViewportType.STACK;
|
||||
|
||||
if (!element._cornerstoneEnabled || element._lastViewportType !== viewportType) {
|
||||
if (renderingEngine.getViewport(viewportId)) {
|
||||
renderingEngine.disableElement(viewportId);
|
||||
}
|
||||
// Add tool group only once
|
||||
renderingEngine.enableElement({
|
||||
viewportId,
|
||||
type: viewportType,
|
||||
element,
|
||||
});
|
||||
element._cornerstoneEnabled = true;
|
||||
element._lastViewportType = viewportType;
|
||||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||
}
|
||||
|
||||
// Add tool group only once
|
||||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
if (!viewport) continue;
|
||||
|
||||
// Set stack if images are loaded
|
||||
if (imageIds) {
|
||||
viewport.setStack(imageIds);
|
||||
if (viewportModes[i] === 'stack') {
|
||||
if (imageIds) {
|
||||
viewport.setStack(imageIds);
|
||||
}
|
||||
// Restore scroll, properties, etc.
|
||||
} else if (viewportModes[i] === 'volume') {
|
||||
//// Ensure all images are loaded before proceeding
|
||||
//const notLoaded = imageIds.filter(id => !metaData.get('imagePlaneModule', id));
|
||||
//if (notLoaded.length > 0) {
|
||||
// // Force metadata loading for missing images
|
||||
// console.log("Forcing metadata loading for missing images:", notLoaded);
|
||||
// await Promise.all(
|
||||
// notLoaded.map(async id => {
|
||||
// try {
|
||||
// // Remove "wadouri:" prefix to get the URL
|
||||
// const url = id.startsWith("wadouri:") ? id.slice(8) : id;
|
||||
// if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
|
||||
// cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url)
|
||||
// } else {
|
||||
// console.warn("MetaDataManager not found. Unable to cache metadata.");
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.warn(`Failed to force-load metadata for ${id}:`, e);
|
||||
// }
|
||||
// })
|
||||
// );
|
||||
//}
|
||||
//// After forcing, check again
|
||||
//const stillNotLoaded = imageIds.filter(id => !metaData.get('imagePlaneModule', id));
|
||||
//if (stillNotLoaded.length > 0) {
|
||||
// alert("Some images could not be loaded for volume rendering.");
|
||||
// setViewportModes(prev => {
|
||||
// const next = [...prev];
|
||||
// next[i] = 'stack';
|
||||
// return next;
|
||||
// });
|
||||
// continue;
|
||||
//}
|
||||
//// Check metadata for all imageIds
|
||||
//const missingMeta = imageIds.filter(id => {
|
||||
// const meta = metaData.get('imagePlaneModule', id);
|
||||
// return !(meta && meta.imagePositionPatient && meta.imageOrientationPatient && meta.pixelSpacing);
|
||||
//});
|
||||
//if (missingMeta.length > 0) {
|
||||
// console.warn(
|
||||
// "The following imageIds are missing required DICOM metadata for volume rendering:",
|
||||
// missingMeta
|
||||
// );
|
||||
// setViewportModes(prev => {
|
||||
// const next = [...prev];
|
||||
// next[i] = 'stack';
|
||||
// return next;
|
||||
// });
|
||||
// alert("Some images are missing required DICOM metadata for volume rendering.");
|
||||
// continue;
|
||||
//}
|
||||
// Example: create and set a volume (you may need to adapt this for your data)
|
||||
const volumeId = 'myVolumeId';
|
||||
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds });
|
||||
volume.load();
|
||||
//await volumeLoader.loadVolume(volumeId);
|
||||
viewport.setVolumes([{ volumeId }]);
|
||||
// Optionally restore properties for volume
|
||||
viewport.render();
|
||||
return
|
||||
}
|
||||
viewport.render();
|
||||
|
||||
// Restore previous scroll position for existing viewports only
|
||||
if (
|
||||
@@ -352,11 +544,14 @@ function App() {
|
||||
element._resizeObserver = resizeObserver;
|
||||
}
|
||||
|
||||
// Attach overlay update for each viewport (only once)
|
||||
if (!element._overlayListener) {
|
||||
viewport.element.addEventListener(IMAGE_RENDERED, () => updateOverlay(i, viewport));
|
||||
element._overlayListener = true;
|
||||
// Always (re-)attach overlay update for each viewport
|
||||
if (element._overlayListener) {
|
||||
// Remove previous listener if present
|
||||
viewport.element.removeEventListener(IMAGE_RENDERED, element._overlayListener);
|
||||
}
|
||||
const overlayListener = () => updateOverlay(i, viewport);
|
||||
viewport.element.addEventListener(IMAGE_RENDERED, overlayListener);
|
||||
element._overlayListener = overlayListener;
|
||||
updateOverlay(i, viewport);
|
||||
}
|
||||
|
||||
@@ -379,7 +574,8 @@ function App() {
|
||||
});
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [elementRef, updateOverlay, viewportGrid, loadedImageIds]);
|
||||
}, [elementRef, updateOverlay, viewportGrid, loadedImageIds, viewportModes]);
|
||||
|
||||
|
||||
// Handler to reset the view (camera and window/level)
|
||||
const handleDoubleClick = useCallback((viewportIdx: numebr) => {
|
||||
@@ -457,6 +653,85 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add this effect to re-order images by Slice Location after images are loaded and when the option changes:
|
||||
useEffect(() => {
|
||||
if (!loadedImageIds || loadedImageIds.length === 0) return;
|
||||
if (!orderBySliceLocation) return;
|
||||
|
||||
// Helper to get slice location from metadata
|
||||
const getSliceLocation = (imageId: string) => {
|
||||
const meta = metaData.get('imagePlaneModule', imageId);
|
||||
return meta && meta.sliceLocation !== undefined ? Number(meta.sliceLocation) : null;
|
||||
};
|
||||
|
||||
// Build array of { imageId, sliceLocation }
|
||||
const withSliceLoc = loadedImageIds.map(id => ({
|
||||
imageId: id,
|
||||
sliceLoc: getSliceLocation(id),
|
||||
}));
|
||||
|
||||
// Sort by slice location, fallback to name if missing
|
||||
const sorted = [...withSliceLoc].sort((a, b) => {
|
||||
if (a.sliceLoc !== null && b.sliceLoc !== null) {
|
||||
return a.sliceLoc - b.sliceLoc;
|
||||
}
|
||||
return a.imageId.localeCompare(b.imageId);
|
||||
});
|
||||
|
||||
// Only update if the order actually changed
|
||||
const sortedIds = sorted.map(obj => obj.imageId);
|
||||
const isSame =
|
||||
loadedImageIds.length === sortedIds.length &&
|
||||
loadedImageIds.every((id, idx) => id === sortedIds[idx]);
|
||||
|
||||
if (!isSame) {
|
||||
setLoadedImageIds(sortedIds);
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [orderBySliceLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedImageIds || loadedImageIds.length === 0) return;
|
||||
// Only trigger if orderBySliceLocation is enabled
|
||||
if (!orderBySliceLocation) return;
|
||||
|
||||
// Check if all images have metadata loaded
|
||||
const allLoaded = loadedImageIds.every(
|
||||
id => !!metaData.get("imagePlaneModule", id)
|
||||
);
|
||||
if (!allLoaded) return;
|
||||
|
||||
// Helper to get slice location from metadata
|
||||
const getSliceLocation = (imageId: string) => {
|
||||
const meta = metaData.get('imagePlaneModule', imageId);
|
||||
return meta && meta.sliceLocation !== undefined ? Number(meta.sliceLocation) : null;
|
||||
};
|
||||
|
||||
// Build array of { imageId, sliceLocation }
|
||||
const withSliceLoc = loadedImageIds.map(id => ({
|
||||
imageId: id,
|
||||
sliceLoc: getSliceLocation(id),
|
||||
}));
|
||||
|
||||
// Sort by slice location, fallback to name if missing
|
||||
const sorted = [...withSliceLoc].sort((a, b) => {
|
||||
if (a.sliceLoc !== null && b.sliceLoc !== null) {
|
||||
return a.sliceLoc - b.sliceLoc;
|
||||
}
|
||||
return a.imageId.localeCompare(b.imageId);
|
||||
});
|
||||
|
||||
// Only update if the order actually changed
|
||||
const sortedIds = sorted.map(obj => obj.imageId);
|
||||
const isSame =
|
||||
loadedImageIds.length === sortedIds.length &&
|
||||
loadedImageIds.every((id, idx) => id === sortedIds[idx]);
|
||||
|
||||
if (!isSame) {
|
||||
setLoadedImageIds(sortedIds);
|
||||
}
|
||||
}, [orderBySliceLocation, loadedImageIds]);
|
||||
|
||||
|
||||
const viewports = [];
|
||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||
@@ -485,6 +760,113 @@ function App() {
|
||||
}}
|
||||
onDoubleClick={() => handleDoubleClick(i)}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 20,
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
opacity: 0.85,
|
||||
}}
|
||||
onClick={() => {
|
||||
setViewportModes(prev => {
|
||||
const next = [...prev];
|
||||
next[i] = prev[i] === 'stack' ? 'volume' : 'stack';
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{viewportModes[i] === 'stack' ? 'Volume' : 'Stack'}
|
||||
</button>
|
||||
{viewportModes[i] === 'volume' && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 70,
|
||||
zIndex: 21,
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
background: "rgba(34,34,34,0.85)",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
background: "#444",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
padding: "2px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
marginRight: "2px",
|
||||
}}
|
||||
onClick={() => {
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||||
if (viewport && viewport.setOrientation) {
|
||||
viewport.setOrientation("axial");
|
||||
viewport.render();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Axial
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
background: "#444",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
padding: "2px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
marginRight: "2px",
|
||||
}}
|
||||
onClick={() => {
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||||
if (viewport && viewport.setOrientation) {
|
||||
viewport.setOrientation("sagittal");
|
||||
viewport.render();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sagittal
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
background: "#444",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
padding: "2px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
onClick={() => {
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||||
if (viewport && viewport.setOrientation) {
|
||||
viewport.setOrientation("coronal");
|
||||
viewport.render();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Coronal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay info (bottom left of viewport) */}
|
||||
<div
|
||||
style={{
|
||||
@@ -604,19 +986,158 @@ function App() {
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100vw", height: "100vh", background: "#111" }}>
|
||||
<div style={{
|
||||
position: "relative", width: "99vw", height: "99vh", background: "#111",
|
||||
margin: 0, padding: 0,
|
||||
overflow: "hidden", // Add this line
|
||||
}}>
|
||||
<div>
|
||||
{/* Side menu toggle button */}
|
||||
<button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
left: 20,
|
||||
zIndex: 200,
|
||||
width: 40,
|
||||
height: 40,
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
fontSize: "22px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onClick={() => setSideMenuOpen(open => !open)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
{/* Side menu drawer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: sideMenuOpen ? 0 : -220,
|
||||
width: 220,
|
||||
height: "100%",
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
zIndex: 199,
|
||||
boxShadow: sideMenuOpen ? "2px 0 12px rgba(0,0,0,0.3)" : "none",
|
||||
transition: "left 0.25s cubic-bezier(.4,2,.6,1)",
|
||||
flexDirection: "column",
|
||||
padding: "24px 16px 16px 16px",
|
||||
display: sideMenuOpen ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: "bold", fontSize: 18, marginBottom: 24 }}>
|
||||
Menu
|
||||
<button
|
||||
style={{
|
||||
float: "right",
|
||||
background: "transparent",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
fontSize: "22px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setSideMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
fontSize: "15px",
|
||||
marginBottom: "18px",
|
||||
width: "100%",
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Load DICOM Folder
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
multiple
|
||||
webkitdirectory="true"
|
||||
directory="true"
|
||||
onChange={handleFilesSelected}
|
||||
accept=".dcm,application/dicom"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="orderBySliceLocation"
|
||||
checked={orderBySliceLocation}
|
||||
onChange={e => setOrderBySliceLocation(e.target.checked)}
|
||||
style={{ accentColor: "#666" }}
|
||||
/>
|
||||
<label htmlFor="orderBySliceLocation" style={{ fontSize: "15px", cursor: "pointer" }}>
|
||||
Order by Slice Location
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
fontSize: "15px",
|
||||
marginBottom: "18px",
|
||||
width: "100%",
|
||||
}}
|
||||
onClick={handleShowMetadata}
|
||||
>
|
||||
Show DICOM Metadata
|
||||
</button>
|
||||
{/* Add more menu items here if needed */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Viewport grid menu at top center */}
|
||||
<div
|
||||
className="grid-menu-hover-container"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 100,
|
||||
// Always present for hover
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="grid-menu-btn"
|
||||
style={{
|
||||
opacity: 0,
|
||||
transition: "opacity 0.2s",
|
||||
pointerEvents: "auto",
|
||||
padding: "6px 16px",
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
@@ -632,6 +1153,7 @@ function App() {
|
||||
Grid: {viewportGrid.rows}x{viewportGrid.cols} ▼
|
||||
</button>
|
||||
{viewportMenuOpen && (
|
||||
// ...your grid menu dropdown...
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -692,6 +1214,113 @@ function App() {
|
||||
}}
|
||||
>
|
||||
{viewports}
|
||||
|
||||
{/* Per-viewport status bars, positioned inside each viewport */}
|
||||
{Array.from({ length: viewportGrid.rows * viewportGrid.cols }).map((_, vpIdx) => {
|
||||
// Only show for visible viewports
|
||||
if (vpIdx >= loadedImageIds?.length) return null;
|
||||
// Get imageIds for this viewport (all viewports share the same stack, adapt if needed)
|
||||
const imageIds = loadedImageIds || [];
|
||||
// Get current image index for this viewport
|
||||
let currentIdx = -1;
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
if (
|
||||
renderingEngine &&
|
||||
typeof renderingEngine.getViewport === "function" &&
|
||||
viewportModes[vpIdx] === "stack"
|
||||
) {
|
||||
const viewport = renderingEngine.getViewport(`CT_${vpIdx}`);
|
||||
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
|
||||
currentIdx = viewport.getCurrentImageIdIndex();
|
||||
}
|
||||
}
|
||||
// Position the bar absolutely inside the viewport grid cell
|
||||
// Calculate row and column for this viewport
|
||||
const row = Math.floor(vpIdx / viewportGrid.cols) + 1;
|
||||
const col = (vpIdx % viewportGrid.cols) + 1;
|
||||
return (
|
||||
<div
|
||||
key={`statusbar-${vpIdx}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
// These CSS grid calculations align the bar with the viewport cell
|
||||
top: `calc((100% / ${viewportGrid.rows}) * (${row - 1}) + 10%)`,
|
||||
left: `calc((100% / ${viewportGrid.cols}) * ${col} - 24px)`,
|
||||
width: "16px",
|
||||
height: "80vh",
|
||||
zIndex: 50,
|
||||
display: imageIds.length > 0 && viewportModes[vpIdx] === "stack" ? "flex" : "none",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
flexDirection: "column",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "100%",
|
||||
borderRadius: "6px",
|
||||
background: "#222",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
boxShadow: "0 0 8px #2196f3cc",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{imageIds.map((id, idx) => {
|
||||
const loaded = !!metaData.get("imagePlaneModule", id);
|
||||
const isCurrent = idx === currentIdx;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: `${100 / imageIds.length}%`,
|
||||
background: isCurrent
|
||||
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
|
||||
: loaded
|
||||
? "linear-gradient(to right, #4caf50 60%, #2196f3 100%)"
|
||||
: "#444",
|
||||
opacity: loaded ? 0.95 : 0.4,
|
||||
transition: "background 0.3s, opacity 0.3s",
|
||||
borderBottom:
|
||||
idx < imageIds.length - 1
|
||||
? "1px solid #222"
|
||||
: "none",
|
||||
cursor: loaded ? "pointer" : "default",
|
||||
pointerEvents: loaded ? "auto" : "none",
|
||||
}}
|
||||
title={
|
||||
isCurrent
|
||||
? `Current image #${idx + 1}`
|
||||
: loaded
|
||||
? `Go to image #${idx + 1}`
|
||||
: `Loading...`
|
||||
}
|
||||
onClick={() => {
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewport = renderingEngine?.getViewport(`CT_${vpIdx}`);
|
||||
if (
|
||||
viewport &&
|
||||
typeof viewport.setImageIdIndex === "function" &&
|
||||
viewportModes[vpIdx] === "stack"
|
||||
) {
|
||||
viewport.setImageIdIndex(idx);
|
||||
viewport.render();
|
||||
updateOverlay(vpIdx, viewport);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -815,7 +1444,62 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metaModalOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
zIndex: 2000,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => setMetaModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
borderRadius: "8px",
|
||||
minWidth: "320px",
|
||||
maxWidth: "80vw",
|
||||
maxHeight: "80vh",
|
||||
padding: "24px",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
|
||||
position: "relative",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ fontSize: "20px", marginBottom: "16px" }}>
|
||||
DICOM Metadata
|
||||
</div>
|
||||
{metaModalContent}
|
||||
<button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
background: "transparent",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
fontSize: "20px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setMetaModalOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* ...rest of your UI... */}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
/* global.css */
|
||||
html, body, #root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-menu-hover-container .grid-menu-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.grid-menu-hover-container:hover .grid-menu-btn,
|
||||
.grid-menu-btn:focus {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { api } from "dicomweb-client"
|
||||
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
|
||||
|
||||
import { readFile } from "dicom-parser"
|
||||
/**
|
||||
/**
|
||||
* Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone,
|
||||
* and return a list of imageIds for the frames.
|
||||
*
|
||||
* Uses the app config to choose which study to fetch, and which
|
||||
* dicom-web server to fetch it from.
|
||||
*
|
||||
* @returns {string[]} An array of imageIds for instances in the study.
|
||||
*/
|
||||
|
||||
/* export default async function createImageIdsAndCacheMetaData({
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID = null,
|
||||
wadoRsRoot,
|
||||
client = null,
|
||||
}) {
|
||||
const SOP_INSTANCE_UID = "00080018"
|
||||
const SERIES_INSTANCE_UID = "0020000E"
|
||||
|
||||
const studySearchOptions = {
|
||||
studyInstanceUID: StudyInstanceUID,
|
||||
seriesInstanceUID: SeriesInstanceUID,
|
||||
}
|
||||
|
||||
client =
|
||||
client ||
|
||||
new api.DICOMwebClient({ url: wadoRsRoot as string, singlepart: true })
|
||||
const instances = await client.retrieveSeriesMetadata(studySearchOptions)
|
||||
const imageIds = instances.map((instanceMetaData) => {
|
||||
const SeriesInstanceUID = instanceMetaData[SERIES_INSTANCE_UID].Value[0]
|
||||
const SOPInstanceUIDToUse =
|
||||
SOPInstanceUID || instanceMetaData[SOP_INSTANCE_UID].Value[0]
|
||||
|
||||
const prefix = "wadors:"
|
||||
|
||||
const imageId =
|
||||
prefix +
|
||||
wadoRsRoot +
|
||||
"/studies/" +
|
||||
StudyInstanceUID +
|
||||
"/series/" +
|
||||
SeriesInstanceUID +
|
||||
"/instances/" +
|
||||
SOPInstanceUIDToUse +
|
||||
"/frames/1"
|
||||
|
||||
cornerstoneDICOMImageLoader.wadors.metaDataManager.add(
|
||||
imageId,
|
||||
instanceMetaData
|
||||
)
|
||||
return imageId
|
||||
})
|
||||
|
||||
// we don't want to add non-pet
|
||||
// Note: for 99% of scanners SUV calculation is consistent bw slices
|
||||
|
||||
return imageIds
|
||||
} */
|
||||
|
||||
const NUM_IMAGES = 152 // set to however many images you have
|
||||
|
||||
export default async function createImageIds() {
|
||||
const imageIds = []
|
||||
for (let i = 1; i <= NUM_IMAGES; i++) {
|
||||
const fileName = `IMG${String(i)}.dcm`
|
||||
imageIds.push(`wadouri:/test_images/stack4/${fileName}`)
|
||||
}
|
||||
return imageIds
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "dicomweb-client"
|
||||
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
|
||||
|
||||
import { readFile } from "dicom-parser"
|
||||
/**
|
||||
/**
|
||||
* Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone,
|
||||
@@ -62,13 +63,13 @@ import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
|
||||
return imageIds
|
||||
} */
|
||||
|
||||
const NUM_IMAGES = 20 // set to however many images you have
|
||||
const NUM_IMAGES = 26 // set to however many images you have
|
||||
|
||||
export default async function createImageIdsAndCacheMetaData() {
|
||||
const imageIds = []
|
||||
for (let i = 1; i <= NUM_IMAGES; i++) {
|
||||
const fileName = `IMG${String(i)}.dcm`
|
||||
imageIds.push(`wadouri:/test_images/stack/${fileName}`)
|
||||
imageIds.push(`wadouri:/test_images/stack3/${fileName}`)
|
||||
}
|
||||
return imageIds
|
||||
}
|
||||
Reference in New Issue
Block a user