Files
ohif-viewer/extensions/usAnnotation/src/panels/USAnnotationPanel.tsx
T

426 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<any[]>([]);
const [imageIdsToObserve, setImageIdsToObserve] = useState<string[]>([]);
const [labels, setLabels] = useState<string[]>([]);
/** ──────────────────────────────────────────────────────
* 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 = () => (
<PanelSection.Content>
<div className="space-y-3 p-2 text-sm text-white">
<div className="flex items-center">
<Switch
id="depth-guide-switch"
className="mr-3"
checked={depthGuide}
onCheckedChange={() => setDepthGuideCommand(!depthGuide)}
/>
<label
htmlFor="depth-guide-switch"
className="cursor-pointer"
onClick={() => setDepthGuideCommand(!depthGuide)}
>
Depth guide toggle
</label>
</div>
{/* <div className="flex items-center">
<Switch
id="auto-add-switch"
className="mr-3"
checked={autoAdd}
onCheckedChange={() => setAutoAddCommand(!autoAdd)}
/>
<label
htmlFor="auto-add-switch"
className="cursor-pointer"
onClick={() => setAutoAddCommand(!autoAdd)}
>
Auto-add annotations
</label>
</div> */}
<div className="flex items-center">
<Switch
id="pleura-percentage-switch"
className="mr-3"
checked={showPleuraPct}
onCheckedChange={() => setShowPleuraPercentageCommand(!showPleuraPct)}
/>
<label
htmlFor="pleura-percentage-switch"
className="cursor-pointer"
onClick={() => setShowPleuraPercentageCommand(!showPleuraPct)}
>
Show pleura percentage
</label>
</div>
</div>
</PanelSection.Content>
);
const renderSectorAnnotations = () => (
<PanelSection.Content>
<div className="flex flex-col gap-4 p-2">
<Label>Sector Annotations</Label>
<div className="flex items-center gap-2">
<Tabs
defaultValue={UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE}
onValueChange={newValue => switchAnnotation(newValue)}
>
<TabsList>
<TabsTrigger value={UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA}>
<Icons.Plus /> Pleura line
</TabsTrigger>
<TabsTrigger value={UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE}>
<Icons.Plus /> B-line
</TabsTrigger>
<Separator orientation="vertical" />
<Separator orientation="vertical" />
</TabsList>
</Tabs>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-auto">
<Icons.More />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
deleteLast(UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE)
}
>
<Icons.Delete className="text-foreground" />
<span className="pl-2">B-line annotation</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
deleteLast(UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA)
}
>
<Icons.Delete className="text-foreground" />
<span className="pl-2">Pleura annotation</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-2 flex items-center gap-2">
<Switch
id="show-overlay-switch"
checked={showOverlay}
onCheckedChange={() => setShowOverlayCommand(!showOverlay)}
className="data-[state=checked]:bg-blue-500"
/>
<label htmlFor="show-overlay-switch" className="cursor-pointer text-blue-300">
Show Overlay
</label>
</div>
{/* Divider */}
<hr className="border-t border-gray-800" />
</div>
</PanelSection.Content>
);
const renderAnnotatedFrames = () => (
<ScrollArea className="h-full">
<PanelSection.Content>
<div className="mb-4 flex items-center justify-between">
{/* <Button
variant="ghost"
size="sm"
className="text-blue-300"
disabled={autoAdd}
onClick={addCurrentImageId}
>
<Icons.Plus className="mr-2" /> Add current frame
</Button> */}
<Button variant="ghost" onClick={() => downloadJSON()}>
<Icons.Download className="h-5 w-5" />
<span>JSON</span>
</Button>
<Button variant="ghost" onClick={() => setShowOverlayCommand(!showOverlay)}>
{showOverlay ? <Icons.Hide className="h-5 w-5" /> : <Icons.Show className="h-5 w-5" />}
</Button>
</div>
<div className="w-full overflow-hidden">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-blue-900 text-blue-300">
<th></th>
<th className="py-2 px-3 text-left">Frame</th>
<th className="py-2 px-3 text-center">Pleura lines</th>
<th className="py-2 px-3 text-center">B-lines</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{annotatedFrames.map(item => (
<tr
key={item.frame}
className={`border-b border-blue-900 ${
item.frame === 5 ? 'bg-cyan-800 bg-opacity-30' : ''
}`}
onClick={() => handleRowClick(item)}
style={{ cursor: 'pointer' }}
>
<td className="py-3 px-3">{item.index}</td>
<td className="py-3 px-3">{item.frame + 1}</td>
<td className="py-3 px-3 text-center">{item.pleura}</td>
<td className="py-3 px-3 text-center">{item.bLine}</td>
<td className="py-3 px-3 text-right">
{item.frame === 5 && (
<div className="flex items-center justify-end">
<Button variant="ghost" className="p-0 text-blue-300">
<Icons.EyeVisible />
</Button>
<Button variant="ghost" className="ml-2 p-0 text-blue-300">
<Icons.More />
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</PanelSection.Content>
</ScrollArea>
);
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 (
<div
className="h-full bg-black text-white"
style={{ minWidth: 240, maxWidth: 480, width: '100%' }}
>
{/* Workflow */}
<PanelSection>
<PanelSection.Header>Workflow</PanelSection.Header>
{renderWorkflowToggles()}
</PanelSection>
{/* Progress
<PanelSection>
<SectionHeader title="Workflow Progress" actionLabel="Source Folder" />
{renderWorkflowProgress()}
</PanelSection> */}
{/* Annotations */}
<PanelSection>
<PanelSection.Header> Annotations </PanelSection.Header>
{renderSectorAnnotations()}
</PanelSection>
{/* Annotated frames */}
<PanelSection className="flex-1">
<PanelSection.Header> Annotated Frames </PanelSection.Header>
{renderAnnotatedFrames()}
</PanelSection>
</div>
);
}