diff --git a/src/App.dark.less b/src/App.dark.less index 23e47234..5346afc4 100644 --- a/src/App.dark.less +++ b/src/App.dark.less @@ -20,6 +20,14 @@ height: 100%; } +.slim-multiline-menu-item.ant-menu-item, +.slim-multiline-menu-item.ant-menu-item .ant-menu-title-content { + height: auto; + white-space: normal; + line-height: 1.2; + overflow: visible; +} + .ant-menu-submenu-title { font-size: 'medium'; } diff --git a/src/App.light.less b/src/App.light.less index 5c35683e..59e618ff 100644 --- a/src/App.light.less +++ b/src/App.light.less @@ -20,6 +20,14 @@ height: 100%; } +.slim-multiline-menu-item.ant-menu-item, +.slim-multiline-menu-item.ant-menu-item .ant-menu-title-content { + height: auto; + white-space: normal; + line-height: 1.2; + overflow: visible; +} + .ant-menu-submenu-title { font-size: 'medium'; } diff --git a/src/components/AnnotationProgress.tsx b/src/components/AnnotationProgress.tsx new file mode 100644 index 00000000..8eee6294 --- /dev/null +++ b/src/components/AnnotationProgress.tsx @@ -0,0 +1,186 @@ +import React from 'react' +import { Progress, message } from 'antd' + +interface AnnotationProgressProps { + annotationGroupUID: string + processed: number + total: number + percentage: number +} + +interface AnnotationProgressState { + progressMap: Map +} + +/** + * Component to display progress of annotation retrieval and processing + * Listens to annotation retrieval and processing progress events from the viewer + */ +class AnnotationProgress extends React.Component { + private processingHandler?: (event: CustomEvent) => void + private retrievalHandler?: (event: CustomEvent) => void + + constructor (props: AnnotationProgressProps) { + super(props) + this.state = { + progressMap: new Map() + } + } + + componentDidMount (): void { + const handleProcessingProgress = (event: CustomEvent): void => { + const detail = event.detail?.payload ?? event.detail + const { annotationGroupUID = '', processed = 0, total = 0, percentage = 0 } = (detail != null) ? detail : {} + + if (annotationGroupUID === undefined || annotationGroupUID === null || annotationGroupUID === '') { + return + } + + this.setState(prevState => { + const newMap = new Map(prevState.progressMap) + const current = (newMap.get(annotationGroupUID) != null) ? newMap.get(annotationGroupUID) : {} + + if (processed === total && percentage === 100) { + // Show completion message and remove after delay + void message.success(`Finished processing ${String(total ?? 0)} annotations`, 2) + setTimeout(() => { + this.setState(prevState => { + const updatedMap = new Map(prevState.progressMap) + const updated = updatedMap.get(annotationGroupUID) + if (updated != null) { + // Remove processing, keep retrieval if it exists + if (updated.retrieval != null) { + updatedMap.set(annotationGroupUID, { retrieval: updated.retrieval }) + } else { + updatedMap.delete(annotationGroupUID) + } + } + return { progressMap: updatedMap } + }) + }, 2000) + } else { + newMap.set(annotationGroupUID, { ...current, processing: { processed, total, percentage } }) + } + return { progressMap: newMap } + }) + } + + const handleRetrievalProgress = (event: CustomEvent): void => { + const detail = event.detail?.payload ?? event.detail + const { annotationGroupUID = '', isLoading = false, description = '' } = (detail != null) ? detail : {} + + if (annotationGroupUID === undefined || annotationGroupUID === null || annotationGroupUID === '') { + return + } + + this.setState(prevState => { + const newMap = new Map(prevState.progressMap) + const current = (newMap.get(annotationGroupUID) != null) ? newMap.get(annotationGroupUID) : {} + + if (isLoading === false) { + /** + * Retrieval complete, remove after a short delay if processing hasn't started + */ + setTimeout(() => { + this.setState(prevState => { + const updatedMap = new Map(prevState.progressMap) + const updated = updatedMap.get(annotationGroupUID) + if ((updated != null) && (updated.processing == null)) { + updatedMap.delete(annotationGroupUID) + } else if (updated != null) { + /** + * Keep processing, remove retrieval + */ + updatedMap.set(annotationGroupUID, { processing: updated.processing }) + } + return { progressMap: updatedMap } + }) + }, 1000) + } else { + newMap.set(annotationGroupUID, { ...current, retrieval: { isLoading, description } }) + } + return { progressMap: newMap } + }) + } + + window.addEventListener('dicommicroscopyviewer_annotation_processing_progress', handleProcessingProgress as EventListener) + window.addEventListener('dicommicroscopyviewer_annotation_retrieval_progress', handleRetrievalProgress as EventListener) + this.processingHandler = handleProcessingProgress + this.retrievalHandler = handleRetrievalProgress + } + + componentWillUnmount (): void { + if (this.processingHandler != null) { + window.removeEventListener('dicommicroscopyviewer_annotation_processing_progress', this.processingHandler as EventListener) + } + if (this.retrievalHandler != null) { + window.removeEventListener('dicommicroscopyviewer_annotation_retrieval_progress', this.retrievalHandler as EventListener) + } + } + + render (): React.ReactNode { + if (this.state.progressMap.size === 0) { + return null + } + + return ( +
+ {Array.from(this.state.progressMap.entries()).map(([uid, progress]) => ( +
+ {progress.retrieval?.isLoading === true && ( +
+
+ {(progress.retrieval.description !== undefined && progress.retrieval.description !== null && progress.retrieval.description !== '') ? progress.retrieval.description : 'Retrieving annotations...'} +
+ +
+ )} + {(progress.processing != null) && ( +
+
+ Processing annotations: {progress.processing.processed} / {progress.processing.total} +
+ +
+ )} +
+ ))} +
+ ) + } +} + +export default AnnotationProgress diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index 0b9aecf4..d028b044 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -1,7 +1,7 @@ import React from 'react' import debounce from 'lodash/debounce' import type { DebouncedFunc } from 'lodash' -import { Layout, Space, Checkbox, Descriptions, Divider, Select, Tooltip, message, Menu, Row } from 'antd' +import { Layout, Space, Checkbox, Descriptions, Divider, Select, Tooltip, message, Menu, Row, InputNumber, Col, Switch } from 'antd' import { CheckboxChangeEvent } from 'antd/es/checkbox' import { UndoOutlined } from '@ant-design/icons' import { @@ -76,6 +76,7 @@ import SegmentList from './SegmentList' import MappingList from './MappingList' import Btn from './Button' import { logger } from '../utils/logger' +import AnnotationProgress from './AnnotationProgress' /** * React component for interactive viewing of an individual digital slide, @@ -203,7 +204,8 @@ class SlideViewer extends React.Component { const { volumeViewer, labelViewer } = constructViewers({ clients: this.props.clients, slide: this.props.slide, - preload: this.props.preload + preload: this.props.preload, + clusteringPixelSizeThreshold: 0.001 // Default: 0.001 mm (1 micrometer) - enabled by default }) this.volumeViewer = volumeViewer this.labelViewer = labelViewer @@ -261,7 +263,9 @@ class SlideViewer extends React.Component { isICCProfilesEnabled: true, isSegmentationInterpolationEnabled: false, isParametricMapInterpolationEnabled: true, - customizedSegmentColors: {} + customizedSegmentColors: {}, + clusteringPixelSizeThreshold: 0.001, // Default: 0.001 mm (1 micrometer) + isClusteringEnabled: true // Clustering enabled by default } this.handlePointerMoveDebounced = debounce( @@ -295,6 +299,51 @@ class SlideViewer extends React.Component { return palette } + shouldComponentUpdate ( + nextProps: SlideViewerProps, + nextState: SlideViewerState + ): boolean { + // Only re-render if relevant props or state changed + // Skip re-render for frequent state updates that don't affect UI + if ( + this.props.location.pathname !== nextProps.location.pathname || + this.props.studyInstanceUID !== nextProps.studyInstanceUID || + this.props.seriesInstanceUID !== nextProps.seriesInstanceUID || + this.props.slide !== nextProps.slide || + this.props.clients !== nextProps.clients || + this.state.isLoading !== nextState.isLoading || + this.state.isAnnotationModalVisible !== nextState.isAnnotationModalVisible || + this.state.isSelectedRoiModalVisible !== nextState.isSelectedRoiModalVisible || + this.state.isReportModalVisible !== nextState.isReportModalVisible || + this.state.isGoToModalVisible !== nextState.isGoToModalVisible || + this.state.isHoveredRoiTooltipVisible !== nextState.isHoveredRoiTooltipVisible || + this.state.hoveredRoiAttributes.length !== nextState.hoveredRoiAttributes.length || + this.state.visibleRoiUIDs.size !== nextState.visibleRoiUIDs.size || + this.state.visibleSegmentUIDs.size !== nextState.visibleSegmentUIDs.size || + this.state.visibleMappingUIDs.size !== nextState.visibleMappingUIDs.size || + this.state.visibleAnnotationGroupUIDs.size !== nextState.visibleAnnotationGroupUIDs.size || + this.state.selectedRoiUIDs.size !== nextState.selectedRoiUIDs.size || + this.state.selectedRoi !== nextState.selectedRoi || + this.state.selectedFinding !== nextState.selectedFinding || + this.state.selectedEvaluations.length !== nextState.selectedEvaluations.length || + this.state.selectedGeometryType !== nextState.selectedGeometryType || + this.state.selectedMarkup !== nextState.selectedMarkup || + this.state.selectedPresentationStateUID !== nextState.selectedPresentationStateUID || + this.state.areRoisHidden !== nextState.areRoisHidden || + this.state.isICCProfilesEnabled !== nextState.isICCProfilesEnabled || + this.state.isSegmentationInterpolationEnabled !== nextState.isSegmentationInterpolationEnabled || + this.state.isParametricMapInterpolationEnabled !== nextState.isParametricMapInterpolationEnabled || + this.state.isClusteringEnabled !== nextState.isClusteringEnabled || + this.state.clusteringPixelSizeThreshold !== nextState.clusteringPixelSizeThreshold + ) { + return true + } + + // Don't re-render for loadingFrames changes (too frequent) + // Don't re-render for pixelDataStatistics changes (computed, not directly displayed) + return false + } + componentDidUpdate ( previousProps: SlideViewerProps, previousState: SlideViewerState @@ -322,7 +371,10 @@ class SlideViewer extends React.Component { const { volumeViewer, labelViewer } = constructViewers({ clients: this.props.clients, slide: this.props.slide, - preload: this.props.preload + preload: this.props.preload, + clusteringPixelSizeThreshold: this.state.isClusteringEnabled + ? this.state.clusteringPixelSizeThreshold + : undefined }) this.volumeViewer = volumeViewer this.labelViewer = labelViewer @@ -3075,6 +3127,53 @@ class SlideViewer extends React.Component { ;(this.volumeViewer as any).toggleParametricMapInterpolation() } + /** + * Handler that toggles clustering on/off. + */ + handleClusteringToggle = (checked: boolean): void => { + /** Ensure checked is a boolean */ + const newValue = !!checked + + /** Use functional setState to ensure we have the latest state */ + this.setState((prevState) => { + /** Don't update if the value hasn't actually changed */ + if (prevState.isClusteringEnabled === newValue) { + return null + } + + const threshold = newValue ? prevState.clusteringPixelSizeThreshold : undefined + + /** + * Update viewer options immediately with the new state + * Check if viewer exists and has the method before calling + */ + if (this.volumeViewer !== null && this.volumeViewer !== undefined && typeof (this.volumeViewer as any).setAnnotationOptions === 'function') { + try { + ;(this.volumeViewer as any).setAnnotationOptions({ + clusteringPixelSizeThreshold: threshold + }) + } catch (error) { + console.error('Failed to update annotation options:', error) + } + } + + return { isClusteringEnabled: newValue } + }) + } + + /** + * Handler that updates the global clustering pixel size threshold. + */ + handleClusteringPixelSizeThresholdChange = (value: number | null): void => { + const threshold = value !== null ? value : 0.001 // Default fallback + this.setState({ clusteringPixelSizeThreshold: threshold }) + if (this.state.isClusteringEnabled) { + ;(this.volumeViewer as any).setAnnotationOptions?.({ + clusteringPixelSizeThreshold: threshold + }) + } + } + formatAnnotation = (annotation: AnnotationCategoryAndType): void => { const roi = this.volumeViewer.getROI(annotation.uid) const key = getRoiKey(roi) as string @@ -3615,6 +3714,58 @@ class SlideViewer extends React.Component { onAnnotationGroupStyleChange={this.handleAnnotationGroupStyleChange} /> )} + + {/* Clustering Settings */} + + + +
+ Enable Clustering + +
+ +
+
+ {this.state.isClusteringEnabled && ( + + + +
+ Clustering Pixel Size Threshold (mm) +
+ + + + + +
+ When pixel size ≤ threshold, clustering is disabled. Leave empty for zoom-based detection. +
+ +
+
+ )} ) } @@ -3995,6 +4146,13 @@ class SlideViewer extends React.Component { /> ) : null} + + ) } diff --git a/src/components/SlideViewer/types.ts b/src/components/SlideViewer/types.ts index 24e2a0ef..deda1db0 100644 --- a/src/components/SlideViewer/types.ts +++ b/src/components/SlideViewer/types.ts @@ -120,4 +120,6 @@ export interface SlideViewerState { isSegmentationInterpolationEnabled: boolean isParametricMapInterpolationEnabled: boolean customizedSegmentColors: { [segmentUID: string]: number[] } + clusteringPixelSizeThreshold: number + isClusteringEnabled: boolean } diff --git a/src/components/SlideViewer/utils/viewerUtils.ts b/src/components/SlideViewer/utils/viewerUtils.ts index 8af03c13..c2eedc8c 100644 --- a/src/components/SlideViewer/utils/viewerUtils.ts +++ b/src/components/SlideViewer/utils/viewerUtils.ts @@ -15,10 +15,11 @@ import NotificationMiddleware, { /** * Constructs volume and label viewers for the slide */ -export const constructViewers = ({ clients, slide, preload }: { +export const constructViewers = ({ clients, slide, preload, clusteringPixelSizeThreshold }: { clients: { [key: string]: dwc.api.DICOMwebClient } slide: Slide preload?: boolean + clusteringPixelSizeThreshold?: number }): { volumeViewer: dmv.viewer.VolumeImageViewer labelViewer?: dmv.viewer.LabelImageViewer @@ -34,6 +35,9 @@ export const constructViewers = ({ clients, slide, preload }: { controls: ['overview', 'position'], skipThumbnails: true, preload, + annotationOptions: clusteringPixelSizeThreshold !== undefined + ? { clusteringPixelSizeThreshold } + : undefined, errorInterceptor: (error: CustomError) => { NotificationMiddleware.onError( NotificationMiddlewareContext.DMV, error