Skip to content

Commit 8c922cb

Browse files
committed
feat: cine player for a dicom display
1 parent 75b1eb5 commit 8c922cb

File tree

1 file changed

+159
-28
lines changed

1 file changed

+159
-28
lines changed

src/components/Preview/displays/DcmDisplay.tsx

Lines changed: 159 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
events,
1414
} from "./dicomUtils/utils";
1515
import type { IStackViewport } from "./dicomUtils/utils";
16+
import { Button } from "@patternfly/react-core";
1617

1718
export type DcmImageProps = {
1819
selectedFile: IFileBlob;
@@ -41,25 +42,33 @@ const DcmDisplay = (props: DcmImageProps) => {
4142
filesLoading,
4243
} = props;
4344

45+
// State variables
4446
const [currentImageIndex, setCurrentImageIndex] = useState(0);
4547
const [imageStack, setImageStack] = useState<ImageStackType>({});
46-
const [multiFrameDisplay, setMultiframeDisplay] = useState(false);
48+
const [multiFrameDisplay, setMultiFrameDisplay] = useState(false);
4749
const [isLoadingMore, setIsLoadingMore] = useState(false);
4850
const [lastLoadedIndex, setLastLoadedIndex] = useState(0);
51+
const [isPlaying, setIsPlaying] = useState(false);
52+
const [playbackSpeed, setPlaybackSpeed] = useState(24); // frames per second
4953

54+
// Refs
5055
const dicomImageRef = useRef<HTMLDivElement>(null);
5156
const elementRef = useRef<HTMLDivElement>(null);
5257
const renderingEngineRef = useRef<RenderingEngine | null>(null);
5358
const activeViewportRef = useRef<IStackViewport | null>(null);
5459
const cacheRef = useRef<{ [key: string]: ImageStackType }>({
5560
[CACHE_KEY]: {},
5661
});
62+
const cineIntervalIdRef = useRef<NodeJS.Timeout | null>(null);
5763

64+
// Derived values
5865
const size = useSize(dicomImageRef);
5966
const cacheStack = cacheRef.current[CACHE_KEY];
6067
const fname = selectedFile.data.fname;
6168

62-
// Handle resizing of the DICOM image viewer
69+
/**
70+
* Handle resizing of the DICOM image viewer when the container size changes.
71+
*/
6372
const handleResize = useCallback(() => {
6473
if (dicomImageRef.current && size && elementRef.current) {
6574
const { width, height } = size;
@@ -70,6 +79,7 @@ const DcmDisplay = (props: DcmImageProps) => {
7079
}
7180
}, [size]);
7281

82+
// Set up resize event listener
7383
useEffect(() => {
7484
window.addEventListener("resize", handleResize);
7585
handleResize(); // Initial resize
@@ -78,7 +88,9 @@ const DcmDisplay = (props: DcmImageProps) => {
7888
};
7989
}, [handleResize]);
8090

81-
// Initialize Cornerstone
91+
/**
92+
* Initialize Cornerstone library and set up tooling.
93+
*/
8294
useEffect(() => {
8395
const setupCornerstone = async () => {
8496
await basicInit();
@@ -87,33 +99,40 @@ const DcmDisplay = (props: DcmImageProps) => {
8799
setupCornerstone();
88100
}, []);
89101

90-
// Filter the list to include only DICOM files
102+
/**
103+
* Filter the file list to include only DICOM files.
104+
*/
91105
const filteredList = useMemo(
92106
() =>
93107
list?.filter((file) => getFileExtension(file.data.fname) === "dcm") || [],
94108
[list],
95109
);
96110

97-
// Preview the selected DICOM file
111+
/**
112+
* Preview the selected DICOM file.
113+
* If the image is already cached, it uses the cached data.
114+
* Otherwise, it loads the image and caches it.
115+
*/
98116
const previewFile = useCallback(async () => {
99117
if (!elementRef.current) return {};
100118

101119
const existingImageEntry = cacheStack?.[fname];
102120

103121
if (existingImageEntry && activeViewportRef.current) {
122+
// Image is already cached
104123
let imageIDs: string[];
105124
let index: number;
106125

107126
if (Array.isArray(existingImageEntry)) {
108127
// Multi-frame image
109128
imageIDs = existingImageEntry;
110129
index = currentImageIndex;
111-
setMultiframeDisplay(true);
130+
setMultiFrameDisplay(true);
112131
} else {
113132
// Single-frame images
114133
imageIDs = Object.values(cacheStack).flat() as string[];
115134
index = imageIDs.findIndex((id) => id === existingImageEntry);
116-
setMultiframeDisplay(false);
135+
setMultiFrameDisplay(false);
117136
}
118137

119138
await activeViewportRef.current.setStack(
@@ -146,7 +165,7 @@ const DcmDisplay = (props: DcmImageProps) => {
146165
elementId,
147166
);
148167

149-
setMultiframeDisplay(framesCount > 1);
168+
setMultiFrameDisplay(framesCount > 1);
150169
activeViewportRef.current = viewport;
151170
renderingEngineRef.current = renderingEngine;
152171

@@ -155,13 +174,27 @@ const DcmDisplay = (props: DcmImageProps) => {
155174
return newImageStack;
156175
}, [selectedFile, cacheStack, fname, currentImageIndex]);
157176

177+
// Use React Query to fetch and cache the preview data
158178
const { isLoading, data } = useQuery({
159179
queryKey: ["cornerstone-preview", fname],
160180
queryFn: previewFile,
161181
enabled: !!selectedFile && !!elementRef.current,
162182
});
163183

164-
// Clean up when the component unmounts
184+
/**
185+
* Stop cine playback.
186+
*/
187+
const stopCinePlay = useCallback(() => {
188+
if (cineIntervalIdRef.current) {
189+
clearInterval(cineIntervalIdRef.current);
190+
cineIntervalIdRef.current = null;
191+
}
192+
}, []);
193+
194+
/**
195+
* Clean up when the component unmounts.
196+
* Destroys the rendering engine and clears caches and intervals.
197+
*/
165198
useEffect(() => {
166199
return () => {
167200
renderingEngineRef.current?.destroy();
@@ -175,10 +208,13 @@ const DcmDisplay = (props: DcmImageProps) => {
175208
handleImageRendered,
176209
);
177210
}
211+
stopCinePlay();
178212
};
179-
}, []);
213+
}, [stopCinePlay]);
180214

181-
// Load multi-frame images
215+
/**
216+
* Load multi-frame images into the viewport.
217+
*/
182218
const loadMultiFrames = useCallback(async () => {
183219
if (activeViewportRef.current) {
184220
const currentIndex = activeViewportRef.current.getCurrentImageIdIndex();
@@ -188,17 +224,23 @@ const DcmDisplay = (props: DcmImageProps) => {
188224
}
189225
}, [imageStack, fname]);
190226

191-
// Generator for loading image files
227+
/**
228+
* Generator function to yield image files starting from a specific index.
229+
*/
192230
function* imageFileGenerator(list: IFileBlob[], startIndex: number) {
193231
for (let i = startIndex; i < list.length; i++) {
194232
yield list[i];
195233
}
196234
}
197235

198-
// Load more images when needed
236+
/**
237+
* Load more images when needed.
238+
* This function loads additional images into the cache and updates the viewport.
239+
*/
199240
const loadMoreImages = useCallback(
200241
async (filteredList: IFileBlob[]) => {
201242
if (Object.keys(cacheStack).length === filteredList.length) {
243+
// All images are already loaded
202244
setImageStack(cacheStack);
203245
if (activeViewportRef.current) {
204246
const currentIndex =
@@ -214,18 +256,15 @@ const DcmDisplay = (props: DcmImageProps) => {
214256
const generator = imageFileGenerator(filteredList, lastLoadedIndex);
215257
const newImages: ImageStackType = {};
216258

217-
let next = generator.next();
218-
while (!next.done) {
259+
for (let next = generator.next(); !next.done; next = generator.next()) {
219260
const file = next.value;
220261
if (cacheStack[file.data.fname]) {
221-
next = generator.next();
222-
continue;
262+
continue; // Skip if already in cache
223263
}
224264

225265
const blob = await file.getFileBlob();
226266
const imageData = await loadDicomImage(blob);
227267
newImages[file.data.fname] = imageData.imageID;
228-
next = generator.next();
229268
}
230269

231270
const updatedImageStack = { ...cacheStack, ...newImages };
@@ -245,9 +284,13 @@ const DcmDisplay = (props: DcmImageProps) => {
245284
[cacheStack, lastLoadedIndex],
246285
);
247286

287+
// Check if the first frame is still loading
248288
const loadingFirstFrame = isLoading || !data;
249289

250-
// Load more images when scrolling near the end
290+
/**
291+
* Load more images when scrolling near the end.
292+
* Also handles loading multi-frame images.
293+
*/
251294
useEffect(() => {
252295
if (
253296
!loadingFirstFrame &&
@@ -274,7 +317,10 @@ const DcmDisplay = (props: DcmImageProps) => {
274317
selectedFile,
275318
]);
276319

277-
// Handle image rendering event
320+
/**
321+
* Handle image rendered event.
322+
* Updates the current image index and triggers pagination if needed.
323+
*/
278324
const handleImageRendered = useCallback(() => {
279325
if (activeViewportRef.current) {
280326
const newIndex = activeViewportRef.current.getCurrentImageIdIndex();
@@ -302,7 +348,7 @@ const DcmDisplay = (props: DcmImageProps) => {
302348
handlePagination,
303349
]);
304350

305-
// Add event listener for image rendering
351+
// Add event listener for image rendered event
306352
useEffect(() => {
307353
if (elementRef.current) {
308354
elementRef.current.addEventListener(
@@ -320,9 +366,58 @@ const DcmDisplay = (props: DcmImageProps) => {
320366
};
321367
}, [handleImageRendered]);
322368

323-
const imageCount = multiFrameDisplay
324-
? (imageStack[fname] as string[]).length
325-
: Object.values(imageStack).flat().length;
369+
/**
370+
* Calculate the total number of images.
371+
*/
372+
const imageCount = useMemo(() => {
373+
return multiFrameDisplay
374+
? (imageStack[fname] as string[]).length
375+
: Object.values(imageStack).flat().length;
376+
}, [multiFrameDisplay, imageStack, fname]);
377+
378+
const totalDigits = imageCount.toString().length;
379+
const currentIndexDisplay = (currentImageIndex + 1)
380+
.toString()
381+
.padStart(totalDigits, "0");
382+
const imageCountDisplay = imageCount.toString().padStart(totalDigits, "0");
383+
384+
/**
385+
* Start cine playback.
386+
*/
387+
const startCinePlay = useCallback(() => {
388+
if (
389+
cineIntervalIdRef.current ||
390+
!activeViewportRef.current ||
391+
!imageStack[fname]
392+
)
393+
return;
394+
395+
const frameDuration = 1000 / playbackSpeed; // Calculate frame duration in milliseconds
396+
397+
cineIntervalIdRef.current = setInterval(() => {
398+
if (activeViewportRef.current) {
399+
let currentIndex = activeViewportRef.current.getCurrentImageIdIndex();
400+
const totalImages = imageCount;
401+
currentIndex = (currentIndex + 1) % totalImages;
402+
activeViewportRef.current.setImageIdIndex(currentIndex);
403+
setCurrentImageIndex(currentIndex);
404+
}
405+
}, frameDuration);
406+
}, [playbackSpeed, imageStack, fname, imageCount]);
407+
408+
/**
409+
* Manage cine playback based on `isPlaying` state.
410+
*/
411+
useEffect(() => {
412+
if (isPlaying) {
413+
startCinePlay();
414+
} else {
415+
stopCinePlay();
416+
}
417+
return () => {
418+
stopCinePlay();
419+
};
420+
}, [isPlaying, startCinePlay, stopCinePlay]);
326421

327422
return (
328423
<>
@@ -332,26 +427,62 @@ const DcmDisplay = (props: DcmImageProps) => {
332427
ref={dicomImageRef}
333428
className={preview === "large" ? "dcm-preview" : ""}
334429
>
430+
{/* Overlay Controls */}
335431
<div
336432
style={{
337433
position: "absolute",
338434
top: "0.25em",
339435
right: "0.25em",
340436
zIndex: 99999,
437+
width: "200px", // Set a fixed width
341438
}}
342439
>
343-
<div style={{ color: "#fff" }}>
344-
{`Current Index: ${currentImageIndex + 1} (${
345-
currentImageIndex + 1
346-
}/${imageCount})`}
440+
{/* Current Index Display */}
441+
<div
442+
style={{
443+
color: "#fff",
444+
marginBottom: "0.5em",
445+
fontFamily: "monospace", // Use monospaced font
446+
}}
447+
>
448+
{`Current Index: ${currentIndexDisplay}/${imageCountDisplay}`}
347449
</div>
450+
451+
{/* Play/Pause Button */}
452+
<div style={{ marginBottom: "0.5em" }}>
453+
<Button
454+
variant="control"
455+
size="sm"
456+
onClick={() => setIsPlaying(!isPlaying)}
457+
>
458+
{isPlaying ? "Pause" : "Play"}
459+
</Button>
460+
</div>
461+
462+
{/* Playback Speed Control */}
463+
<div style={{ color: "#fff", marginBottom: "0.5em" }}>
464+
<label>
465+
Speed:
466+
<input
467+
type="number"
468+
value={playbackSpeed}
469+
min="1"
470+
max="60"
471+
onChange={(e) => setPlaybackSpeed(Number(e.target.value))}
472+
/>
473+
fps
474+
</label>
475+
</div>
476+
477+
{/* Loading More Indicator */}
348478
{isLoadingMore && (
349479
<div style={{ color: "#fff" }}>
350480
<i>Loading more...</i>
351481
</div>
352482
)}
353483
</div>
354484

485+
{/* DICOM Image Display */}
355486
<div
356487
id={`cornerstone-element-${fname}`}
357488
ref={elementRef}

0 commit comments

Comments
 (0)