@@ -13,6 +13,7 @@ import {
1313 events ,
1414} from "./dicomUtils/utils" ;
1515import type { IStackViewport } from "./dicomUtils/utils" ;
16+ import { Button } from "@patternfly/react-core" ;
1617
1718export 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