import React, { useEffect, useState } from 'react'; import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools'; import { eventTarget, utilities } from '@cornerstonejs/core'; import { useSystem } from '@ohif/core'; import { /* Layout */ PanelSection, ScrollArea, /* Controls */ Label, Button, Icons, Switch, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, Tabs, TabsList, TabsTrigger, Separator, } from '@ohif/ui-next'; /** * A side panel that drives the ultrasound annotation workflow. * It provides controls for managing annotations, toggling display options, * and downloading annotations as JSON. * @returns The USAnnotationPanel component */ export default function USAnnotationPanel() { const { servicesManager, commandsManager } = useSystem(); /** ────────────────────────────────────────────────────── * Local state – purely UI related (no business logic). */ const { viewportGridService, cornerstoneViewportService, measurementService } = servicesManager.services as AppTypes.Services; // UI state variables const [depthGuide, setDepthGuide] = useState(true); const [autoAdd, setAutoAdd] = useState(true); const [showPleuraPct, setShowPleuraPct] = useState(true); const [showOverlay, setShowOverlay] = useState(true); // Data state variables const [annotatedFrames, setAnnotatedFrames] = useState([]); const [imageIdsToObserve, setImageIdsToObserve] = useState([]); const [labels, setLabels] = useState([]); /** ────────────────────────────────────────────────────── * Helper – commands bridging back to OHIF services. */ /** * Switches the active annotation type (pleura or B-line) * @param type - The annotation type to switch to */ const switchAnnotation = (type: string) => { commandsManager.runCommand('setToolActive', { toolName: UltrasoundPleuraBLineTool.toolName }); commandsManager.runCommand('switchUSAnnotation', { annotationType: type }); }; /** * Deletes the last annotation of the specified type * @param type - The annotation type to delete */ const deleteLast = (type: string) => { commandsManager.runCommand('deleteLastAnnotation', { annotationType: type }); updateAnnotatedFrames(); }; /** * Sets the depth guide display state * @param value - Boolean indicating whether to show the depth guide */ const setDepthGuideCommand = (value: boolean) => { commandsManager.runCommand('setDepthGuide', { value }); setDepthGuide(value); }; /** * Sets the auto-add annotations state * When enabled, all frames are monitored for annotations * When disabled, only manually added frames are monitored * @param value - Boolean indicating whether to auto-add annotations */ const setAutoAddCommand = (value: boolean) => { if (value) { setImageIdsToObserve([]); } else { const imageIds = annotatedFrames.map(item => item.imageId); if (imageIds.length > 0) { setImageIdsToObserve(imageIds); } else { setImageIdsToObserve(['Manual']); } } setAutoAdd(value); }; /** * Sets whether to show the pleura percentage in the viewport overlay * @param value - Boolean indicating whether to show the percentage */ const setShowPleuraPercentageCommand = (value: boolean) => { commandsManager.runCommand('setShowPleuraPercentage', { value }); setShowPleuraPct(value); }; /** * Sets whether to show the fan overlay in the viewport * @param value - Boolean indicating whether to show the overlay */ const setShowOverlayCommand = (value: boolean) => { commandsManager.runCommand('setDisplayFanAnnotation', { value }); commandsManager.runCommand('setShowPleuraPercentage', { value }); setShowOverlay(value); }; /** * Downloads the annotations as a JSON file * Uses the labels and imageIdsToObserve state variables */ const downloadJSON = () => { commandsManager.runCommand('downloadJSON', { labels, imageIds: imageIdsToObserve }); }; /** * Adds the current image ID to the list of monitored image IDs * Only works when auto-add is disabled */ const addCurrentImageId = () => { if (!autoAdd) { const activeViewportId = viewportGridService.getActiveViewportId(); const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId); const currentImageId = viewport.getCurrentImageId(); const imageIds = [...imageIdsToObserve]; if (!imageIds.includes(currentImageId)) { imageIds.push(currentImageId); } setImageIdsToObserve(imageIds); } }; /** * Handles clicking on a row in the annotated frames table * Scrolls the viewport to the selected frame * @param item - The annotated frame item that was clicked */ const handleRowClick = item => { const activeViewportId = viewportGridService.getActiveViewportId(); const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId); utilities.scroll(viewport, { delta: item.frame - viewport.getCurrentImageIdIndex() }); }; /** * Render helpers so the JSX doesn’t become spaghetti. */ const renderWorkflowToggles = () => (
setDepthGuideCommand(!depthGuide)} />
{/*
setAutoAddCommand(!autoAdd)} />
*/}
setShowPleuraPercentageCommand(!showPleuraPct)} />
); const renderSectorAnnotations = () => (
switchAnnotation(newValue)} > Pleura line B-line deleteLast(UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE) } > B-line annotation deleteLast(UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA) } > Pleura annotation
setShowOverlayCommand(!showOverlay)} className="data-[state=checked]:bg-blue-500" />
{/* Divider */}
); const renderAnnotatedFrames = () => (
{/* */}
{annotatedFrames.map(item => ( handleRowClick(item)} style={{ cursor: 'pointer' }} > ))}
Frame Pleura lines B-lines
{item.index} {item.frame + 1} {item.pleura} {item.bLine} {item.frame === 5 && (
)}
); const updateAnnotatedFrames = () => { const activeViewportId = viewportGridService.getActiveViewportId(); const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId); // copying to avoid mutating the original array const imageIdsMonitored = [...imageIdsToObserve]; const imageIdFilter = (imageId: string) => { if (imageIdsMonitored.length === 0) { return true; } return imageIdsMonitored.includes(imageId); }; const mapping = UltrasoundPleuraBLineTool.countAnnotations(viewport.element, imageIdFilter); if (!mapping) { setAnnotatedFrames([]); return; } const keys = Array.from(mapping.keys()); const updatedFrames = keys.map((key, index) => { const { pleura, bLine, frame } = mapping.get(key) || { pleura: 0, bLine: 0, frame: 0 }; return { imageId: key, index: index + 1, frame, pleura, bLine }; }); setAnnotatedFrames(updatedFrames); }; /** * Callback function that is called when an annotation is modified * Updates the annotatedFrames state with the latest annotation data */ const annotationModified = React.useCallback( event => { if (event.detail.annotation.metadata.toolName === UltrasoundPleuraBLineTool.toolName) { updateAnnotatedFrames(); } }, [viewportGridService, cornerstoneViewportService, imageIdsToObserve] ); useEffect(() => { eventTarget.addEventListener(csToolsEnums.Events.ANNOTATION_MODIFIED, annotationModified); const { unsubscribe } = measurementService.subscribe( measurementService.EVENTS.MEASUREMENT_REMOVED, () => { updateAnnotatedFrames(); } ); return () => { eventTarget.removeEventListener(csToolsEnums.Events.ANNOTATION_MODIFIED, annotationModified); unsubscribe(); }; }, [annotationModified, measurementService]); /** * ────────────────────────────────────────────────────── * 🖼 Final Render */ return (
{/* Workflow */} Workflow {renderWorkflowToggles()} {/* Progress {renderWorkflowProgress()} */} {/* Annotations */} Annotations {renderSectorAnnotations()} {/* Annotated frames */} Annotated Frames {renderAnnotatedFrames()}
); }