diff --git a/.gitignore b/.gitignore index 56eafb3d..7f77de64 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ /coverage .env +.cursorrules + # production /build diff --git a/README.md b/README.md index 00ba29b6..43a6d866 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It relies on [DICOMweb](https://www.dicomstandard.org/dicomweb/) RESTful service - [Display of images](#display-of-images) - [Display of image annotations and analysis results](#display-of-image-annotations-and-analysis-results) - [Annotation of images](#annotation-of-images) + - [Memory Monitoring](#memory-monitoring) - [Authentication and Authorization](#autentication-and-authorization) - [Configuration](#configuration) - [Server Configuration](#server-configuration) @@ -105,6 +106,22 @@ ROIs are stored as 3D spatial coordinates (SCOORD3D) in millimeter unit accordin Specifically, [Image Region](http://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_A.html#para_b68aa0a9-d0b1-475c-9630-fbbd48dc581d) is used to store the vector graphic data and [Finding](http://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_A.html#para_c4ac1cac-ee86-4a86-865a-8137ebe1bd95) is used to describe what has been annotated using a standard medical terminology such as [SNOMED CT](https://www.snomed.org/). The terms that can be chosen by a user can be configured (see [AppConfig.d.ts](src/AppConfig.d.ts)). +### Memory Monitoring + +_Slim_ includes automatic memory monitoring to help track browser memory usage when viewing large whole slide images. The memory monitor: + +- Displays real-time memory usage in the footer (used memory, heap limit, usage percentage, remaining memory) +- Automatically monitors memory every 5 seconds using modern browser APIs when available +- Shows color-coded status indicators (green/orange/red) based on usage levels +- Issues warnings when memory usage exceeds 80% (high) or 90% (critical) +- Falls back to Chrome-specific APIs when modern APIs aren't available + +The memory footer appears at the bottom of all pages and updates automatically. When memory usage is high, users receive notifications with recommendations to refresh the page or close other tabs. + +Memory monitoring is enabled by default and can be disabled via configuration by setting `enableMemoryMonitoring: false` in the application config. + +For technical details, see [Memory Monitoring Documentation](docs/MEMORY_MONITORING.md). + ## Autentication and authorization Users can authenticate and authorize the application to access data via [OpenID Connect (OIDC)](https://openid.net/connect/) based on the [OAuth 2.0](https://oauth.net/2/) protocol using either the [authorization code grant type](https://oauth.net/2/grant-types/authorization-code/) (with [Proof Key for Code Exchange (PKCE)](https://oauth.net/2/pkce/) extension) or the legacy [implicit grant type](https://oauth.net/2/grant-types/implicit/). @@ -198,6 +215,22 @@ Default values if not specified: - `duration`: 5 seconds - `top`: 100 pixels +### Memory Monitoring Configuration + +Memory monitoring can be enabled or disabled through configuration: + +```javascript +window.config = { + // ... other config options ... + enableMemoryMonitoring: false, // Set to false to disable memory monitoring footer +}; +``` + +- **Default**: Memory monitoring is enabled (`enableMemoryMonitoring: true` or undefined) +- **Disable**: Set `enableMemoryMonitoring: false` to hide the memory footer and stop monitoring + +When enabled, the memory footer appears at the bottom of all pages and monitors memory usage every 5 seconds. + ## Deployment Download the latest release from [github.com/imagingdatacommons/slim/releases](https://github.com/imagingdatacommons/slim/releases) and then run the following commands to install build dependencies and build the app: diff --git a/docs/MEMORY_MONITORING.md b/docs/MEMORY_MONITORING.md new file mode 100644 index 00000000..b7efeda6 --- /dev/null +++ b/docs/MEMORY_MONITORING.md @@ -0,0 +1,157 @@ +# Memory Monitoring in Slim + +## Overview + +Slim includes memory monitoring capabilities to help track and manage browser memory usage, which is particularly important when viewing large DICOM whole slide images (WSI) that can consume significant amounts of memory. + +## Features + +- **Automatic memory monitoring**: Monitors memory usage every 5 seconds +- **Multiple API support**: + - Modern API (`performance.measureUserAgentSpecificMemory()`) when cross-origin isolation is enabled + - Chrome-specific fallback (`performance.memory`) in Chrome/Edge browsers +- **Visual indicators**: Memory status shown in the footer with color-coded tags +- **Automatic warnings**: Notifications when memory usage is high (>80%) or critical (>90%) +- **Real-time updates**: Memory information updates automatically as usage changes + +## Accessing Memory Information + +The memory monitor appears in the footer at the bottom of all pages. It displays: + +- Used memory +- Heap limit +- Usage percentage +- Remaining memory +- Color-coded status (green/orange/red) + +## Memory Warnings + +The application automatically shows warnings when: + +- **High usage** (>80%): A warning notification appears +- **Critical usage** (>90%): A critical warning notification appears with recommendations to refresh the page + +Warnings are only shown when the status changes to avoid spamming users with repeated notifications. + +## API Methods + +### Modern API (Recommended) + +Uses `performance.measureUserAgentSpecificMemory()` which provides accurate memory measurements including breakdowns by context (main thread, workers, etc.). + +**Requirements**: +- Browser support (Chrome 89+, Edge 89+) +- Cross-origin isolation enabled via HTTP headers: + - `Cross-Origin-Opener-Policy: same-origin` + - `Cross-Origin-Embedder-Policy: require-corp` + +**Status**: Already configured in `firebase.json` for production deployments. + +### Chrome Fallback API + +Uses the deprecated but still functional `performance.memory` API available in Chrome/Edge browsers. This provides basic memory statistics without requiring cross-origin isolation. + +**Limitations**: +- Only shows JavaScript heap usage +- Doesn't include WebGL or WebAssembly memory +- Deprecated (may be removed in future browser versions) + +### Unavailable + +If neither API is available, memory monitoring will be disabled and the footer will not display memory information. + +## Configuration + +### Enable/Disable Memory Monitoring + +Memory monitoring can be enabled or disabled through the application configuration: + +```javascript +window.config = { + // ... other config options ... + enableMemoryMonitoring: false, // Set to false to disable memory monitoring footer +}; +``` + +- **Default**: Memory monitoring is enabled by default (`enableMemoryMonitoring: true` or undefined) +- **Disable**: Set `enableMemoryMonitoring: false` to hide the memory footer and stop monitoring + +The memory footer appears at the bottom of all pages and monitors memory usage every 5 seconds. When disabled, the memory footer will not appear and memory monitoring will not start, reducing overhead. + +### Cross-Origin Isolation Setup + +For local development with the modern API, you need to configure HTTP headers. The nginx configuration in `etc/nginx/conf.d/local.conf` should include: + +```nginx +add_header Cross-Origin-Opener-Policy "same-origin" always; +add_header Cross-Origin-Embedder-Policy "require-corp" always; +``` + +**Note**: Cross-origin isolation may break some third-party integrations or embeds. Test thoroughly if enabling locally. + +### Monitoring Interval + +The default monitoring interval is 5 seconds. This can be changed by modifying the `updateInterval` in `MemoryMonitor.ts` or when calling `memoryMonitor.startMonitoring(interval)`. + +### Thresholds + +Memory warning thresholds can be adjusted in `MemoryMonitor.ts`: +- `highUsageThreshold`: 0.80 (80%) +- `criticalUsageThreshold`: 0.90 (90%) + +## Technical Details + +### Memory Monitor Service + +Located in `src/services/MemoryMonitor.ts`, this service provides: + +- Singleton pattern for application-wide memory monitoring +- Subscription-based updates for components +- Automatic API detection and fallback +- Utility functions for formatting and status messages + +### Integration Points + +1. **MemoryFooter Component**: + - Displays memory info in the footer + - Subscribes to memory updates + - Shows warnings when memory is high + +2. **Notification Middleware**: + - Publishes memory warnings as toast notifications + - Integrated with existing error/warning system + +## Best Practices + +1. **Monitor during development**: Check memory usage when testing with large images +2. **Watch for leaks**: If memory steadily increases without user interaction, investigate potential memory leaks +3. **Consider cleanup**: The viewer's `cleanup()` method can be called to explicitly free memory +4. **Browser DevTools**: Use Chrome DevTools Memory profiler for detailed analysis + +## Troubleshooting + +### Memory monitoring shows "unavailable" + +**Chrome/Edge**: The Chrome fallback API should work. Check that you're using a supported browser version. + +**Other browsers**: The modern API requires cross-origin isolation. Ensure your server is sending the correct headers. + +### Cross-origin isolation breaks my app + +If enabling cross-origin isolation causes issues: +- Check console for blocked resources +- Ensure all third-party scripts are compatible +- Consider using the Chrome fallback API instead (works without isolation) + +### Memory keeps increasing + +This could indicate a memory leak: +1. Check if tiles are being properly disposed +2. Verify web workers are being terminated when not needed +3. Ensure image blobs are being revoked after use +4. Use Chrome DevTools Memory profiler to identify leaks + +## References + +- [MDN: measureUserAgentSpecificMemory()](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory) +- [Chrome Memory API](https://developer.chrome.com/docs/devtools/memory-problems/) diff --git a/package.json b/package.json index 09c5d73b..a02151a2 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "dcmjs": "^0.35.0", "detect-browser": "^5.2.1", "dicom-microscopy-viewer": "^0.48.16", - "dicomweb-client": "^0.10.3", + "dicomweb-client": "0.10.3", "gh-pages": "^5.0.0", "oidc-client": "^1.11.5", "react": "^18.2.0", 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/App.tsx b/src/App.tsx index 478594a6..85a17adf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import CaseViewer from './components/CaseViewer' import Header from './components/Header' import InfoPage from './components/InfoPage' import Worklist from './components/Worklist' +import MemoryFooter from './components/MemoryFooter' import { ValidationProvider } from './contexts/ValidationContext' import { User, AuthManager } from './auth' @@ -436,6 +437,9 @@ class App extends React.Component { const enableServerSelection = ( this.props.config.enableServerSelection ?? false ) + const enableMemoryMonitoring = ( + this.props.config.enableMemoryMonitoring ?? true + ) let worklist if (enableWorklist) { @@ -520,6 +524,7 @@ class App extends React.Component { {worklist} + {enableMemoryMonitoring && } } /> @@ -545,6 +550,7 @@ class App extends React.Component { app={appInfo} /> + {enableMemoryMonitoring && } } /> @@ -570,6 +576,7 @@ class App extends React.Component { app={appInfo} /> + {enableMemoryMonitoring && } } /> @@ -587,7 +594,10 @@ class App extends React.Component { clients={this.state.clients} defaultClients={this.state.defaultClients} /> - Logged out + + Logged out + + {enableMemoryMonitoring && } } /> diff --git a/src/AppConfig.d.ts b/src/AppConfig.d.ts index abcae66d..9fd4a9f0 100644 --- a/src/AppConfig.d.ts +++ b/src/AppConfig.d.ts @@ -106,4 +106,5 @@ export default interface AppConfig { enableInProduction?: boolean enableInDevelopment?: boolean } + enableMemoryMonitoring?: boolean } diff --git a/src/components/DicomTagBrowser/DicomTagBrowser.tsx b/src/components/DicomTagBrowser/DicomTagBrowser.tsx index 66b6c763..e6973db5 100644 --- a/src/components/DicomTagBrowser/DicomTagBrowser.tsx +++ b/src/components/DicomTagBrowser/DicomTagBrowser.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { Select, Input, Slider, Typography, Table } from 'antd' import { SearchOutlined } from '@ant-design/icons' @@ -35,9 +35,10 @@ interface TableDataItem { interface DicomTagBrowserProps { clients: { [key: string]: DicomWebManager } studyInstanceUID: string + seriesInstanceUID?: string } -const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): JSX.Element => { +const DicomTagBrowser = ({ clients, studyInstanceUID, seriesInstanceUID = '' }: DicomTagBrowserProps): JSX.Element => { const { slides, isLoading } = useSlides({ clients, studyInstanceUID }) const [study, setStudy] = useState(undefined) @@ -47,6 +48,7 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J const [filterValue, setFilterValue] = useState('') const [expandedKeys, setExpandedKeys] = useState([]) const [searchInput, setSearchInput] = useState('') + const needsInstanceResetRef = useRef(false) const debouncedSearchValue = useDebounce(searchInput, 300) @@ -153,9 +155,12 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J setDisplaySets([...displaySets, ...derivedDisplaySets]) }, [slides, study]) + const sortedDisplaySets = useMemo(() => { + return [...displaySets].sort((a, b) => Number(a.SeriesNumber) - Number(b.SeriesNumber)) + }, [displaySets]) + const displaySetList = useMemo(() => { - displaySets.sort((a, b) => Number(a.SeriesNumber) - Number(b.SeriesNumber)) - return displaySets.map((displaySet, index) => { + return sortedDisplaySets.map((displaySet, index) => { const { SeriesDate = '', SeriesTime = '', @@ -173,24 +178,48 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J description: displayDate } }) - }, [displaySets]) + }, [sortedDisplaySets]) + + useEffect(() => { + if (sortedDisplaySets.length === 0) return + + if (seriesInstanceUID) { + const matchingIndex = sortedDisplaySets.findIndex( + (displaySet) => displaySet.SeriesInstanceUID === seriesInstanceUID + ) + if (matchingIndex !== -1) { + setSelectedDisplaySetInstanceUID(matchingIndex) + setInstanceNumber(1) + return + } + } + + needsInstanceResetRef.current = false + setSelectedDisplaySetInstanceUID((currentIndex) => { + const needsReset = currentIndex >= sortedDisplaySets.length || currentIndex < 0 + needsInstanceResetRef.current = needsReset + return needsReset ? 0 : currentIndex + }) + if (needsInstanceResetRef.current) { + setInstanceNumber(1) + } + }, [seriesInstanceUID, sortedDisplaySets]) const showInstanceList = - displaySets[selectedDisplaySetInstanceUID]?.images.length > 1 + sortedDisplaySets[selectedDisplaySetInstanceUID]?.images.length > 1 const instanceSliderMarks = useMemo(() => { - if (displaySets[selectedDisplaySetInstanceUID] === undefined) return {} - const totalInstances = displaySets[selectedDisplaySetInstanceUID].images.length + if (sortedDisplaySets[selectedDisplaySetInstanceUID] === undefined) return {} + const totalInstances = sortedDisplaySets[selectedDisplaySetInstanceUID].images.length - // Create marks for first, middle, and last instances const marks: Record = { - 1: '1', // First - [Math.ceil(totalInstances / 2)]: String(Math.ceil(totalInstances / 2)), // Middle - [totalInstances]: String(totalInstances) // Last + 1: '1', + [Math.ceil(totalInstances / 2)]: String(Math.ceil(totalInstances / 2)), + [totalInstances]: String(totalInstances) } return marks - }, [selectedDisplaySetInstanceUID, displaySets]) + }, [selectedDisplaySetInstanceUID, sortedDisplaySets]) const columns = [ { @@ -242,8 +271,8 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J }) } - if (displaySets[selectedDisplaySetInstanceUID] === undefined) return [] - const images = displaySets[selectedDisplaySetInstanceUID]?.images + if (sortedDisplaySets[selectedDisplaySetInstanceUID] === undefined) return [] + const images = sortedDisplaySets[selectedDisplaySetInstanceUID]?.images const sortedMetadata = Array.isArray(images) ? [...images].sort((a, b) => { if (a.InstanceNumber !== undefined && b.InstanceNumber !== undefined) { @@ -255,7 +284,7 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J const metadata = sortedMetadata[instanceNumber - 1] const tags = getSortedTags(metadata) return transformTagsToTableData(tags) - }, [instanceNumber, selectedDisplaySetInstanceUID, displaySets]) + }, [instanceNumber, selectedDisplaySetInstanceUID, sortedDisplaySets]) const filteredData = useMemo(() => { if (filterValue === undefined || filterValue === '') return tableData @@ -272,7 +301,6 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J ) } - // First pass: find all matching nodes and their parent paths const findMatchingPaths = ( node: TableDataItem, parentPath: TableDataItem[] = [] @@ -294,10 +322,8 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J return matchingPaths } - // Find all paths that contain matches const matchingPaths = tableData.flatMap(node => findMatchingPaths(node)) - // Second pass: reconstruct the tree with matching paths const reconstructTree = ( paths: TableDataItem[][], level = 0 @@ -384,7 +410,7 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J setInstanceNumber(value)} marks={instanceSliderMarks} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 143bc5a7..74aca4b8 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -295,12 +295,20 @@ class Header extends React.Component { handleDicomTagBrowserButtonClick = (): void => { const width = window.innerWidth - 200 + + let seriesInstanceUID = '' + if (this.props.location.pathname.includes('series/')) { + const seriesFragment = this.props.location.pathname.split('series/')[1] + seriesInstanceUID = seriesFragment.includes('/') ? seriesFragment.split('/')[0] : seriesFragment + } + Modal.info({ title: 'DICOM Tag Browser', width, content: , onOk (): void {} }) diff --git a/src/components/MemoryFooter.tsx b/src/components/MemoryFooter.tsx new file mode 100644 index 00000000..d76c5c2f --- /dev/null +++ b/src/components/MemoryFooter.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { Layout, Typography, Space, Tag } from 'antd' +import { memoryMonitor, type MemoryInfo } from '../services/MemoryMonitor' +import NotificationMiddleware, { NotificationMiddlewareEvents } from '../services/NotificationMiddleware' + +const { Text } = Typography + +interface MemoryFooterProps { + enabled?: boolean +} + +interface MemoryFooterState { + memoryInfo: MemoryInfo | null +} + +/** + * React component for displaying memory usage information in the footer. + */ +class MemoryFooter extends React.Component { + private unsubscribeMemory?: () => void + private lastWarningLevel: 'none' | 'high' | 'critical' = 'none' + + constructor (props: {}) { + super(props) + this.state = { + memoryInfo: null + } + } + + componentDidMount (): void { + if (!this.props.enabled) { + return + } + + this.unsubscribeMemory = memoryMonitor.subscribe((memory: MemoryInfo) => { + this.setState({ memoryInfo: memory }) + + const warningLevel = memoryMonitor.getWarningLevel(memory) + + // Re-warn for critical to ensure user sees repeated alerts + if (warningLevel !== this.lastWarningLevel || warningLevel === 'critical') { + this.lastWarningLevel = warningLevel + + if (warningLevel === 'critical' && memory.usagePercentage !== null) { + NotificationMiddleware.publish( + NotificationMiddlewareEvents.OnWarning, + `Critical memory usage: ${memory.usagePercentage.toFixed(1)}% used. ` + + `Only ${memoryMonitor.formatBytes(memory.remainingBytes)} remaining. ` + + `Consider refreshing the page or closing other tabs.` + ) + } else if (warningLevel === 'high' && memory.usagePercentage !== null) { + NotificationMiddleware.publish( + NotificationMiddlewareEvents.OnWarning, + `High memory usage: ${memory.usagePercentage.toFixed(1)}% used. ` + + `${memoryMonitor.formatBytes(memory.remainingBytes)} remaining.` + ) + } + } + }) + + memoryMonitor.startMonitoring() + memoryMonitor.measure().catch(error => { + console.warn('Failed to measure memory:', error) + }) + } + + componentWillUnmount (): void { + if (this.unsubscribeMemory) { + this.unsubscribeMemory() + } + memoryMonitor.stopMonitoring() + } + + render (): React.ReactNode { + if (!this.props.enabled) { + return null + } + + const { memoryInfo } = this.state + + if (memoryInfo === null || memoryInfo.apiMethod === 'unavailable') { + return null + } + + const warningLevel = memoryMonitor.getWarningLevel(memoryInfo) + + let statusColor: string = 'default' + if (warningLevel === 'critical') { + statusColor = 'red' + } else if (warningLevel === 'high') { + statusColor = 'orange' + } else { + statusColor = 'green' + } + + return ( + + |} size='small' wrap> + + Memory: + + + {memoryMonitor.formatBytes(memoryInfo.usedJSHeapSize)} + + + of + + + {memoryMonitor.formatBytes(memoryInfo.jsHeapSizeLimit)} + + + ({memoryInfo.usagePercentage !== null ? `${memoryInfo.usagePercentage.toFixed(1)}%` : 'N/A'}) + + {memoryInfo.remainingBytes !== null && ( + <> + + Remaining: + + + {memoryMonitor.formatBytes(memoryInfo.remainingBytes)} + + + )} + + + ) + } +} + +export default MemoryFooter diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index 0b9aecf4..0d4ef6e0 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 { @@ -203,7 +203,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 +262,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 +298,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 +370,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 +3126,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 +3713,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. +
+ +
+
+ )} ) } 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 diff --git a/src/services/MemoryMonitor.ts b/src/services/MemoryMonitor.ts new file mode 100644 index 00000000..d3bf2995 --- /dev/null +++ b/src/services/MemoryMonitor.ts @@ -0,0 +1,345 @@ +/** + * Memory monitoring service for tracking browser memory usage. + * + * Uses modern APIs when available: + * - performance.measureUserAgentSpecificMemory() (Chrome 89+, requires cross-origin isolation) + * - performance.memory (Chrome-specific, deprecated but still useful) + */ + +export interface MemoryInfo { + /** + * Total memory used in bytes (JS heap size) + */ + usedJSHeapSize: number | null + + /** + * Maximum JS heap size limit in bytes + */ + jsHeapSizeLimit: number | null + + /** + * Total JS heap size allocated in bytes + */ + totalJSHeapSize: number | null + + /** + * Memory usage as percentage of limit (0-100) + */ + usagePercentage: number | null + + /** + * Estimated remaining memory in bytes + */ + remainingBytes: number | null + + /** + * Whether memory usage is considered high (>80% of limit) + */ + isHighUsage: boolean + + /** + * Whether memory usage is considered critical (>90% of limit) + */ + isCriticalUsage: boolean + + /** + * API method used: 'modern', 'chrome', or 'unavailable' + */ + apiMethod: 'modern' | 'chrome' | 'unavailable' + + /** + * Timestamp of measurement + */ + timestamp: number +} + +export interface MemoryMeasureResult { + /** + * Memory information + */ + memory: MemoryInfo + + /** + * Breakdown by context (main thread, workers, etc.) + * Only available with modern API + */ + breakdown?: Array<{ + bytes: number + userAgentSpecificTypes: string[] + }> +} + +type MemoryUpdateCallback = (memory: MemoryInfo) => void + +/** + * Memory monitoring service + */ +class MemoryMonitor { + private updateCallbacks: Set = new Set() + private monitoringInterval: ReturnType | null = null + private readonly updateInterval: number = 5000 // 5 seconds + private lastMeasurement: MemoryInfo | null = null + private readonly highUsageThreshold = 0.80 // 80% + private readonly criticalUsageThreshold = 0.90 // 90% + + /** + * Check if modern memory API is available + */ + private isModernAPIAvailable (): boolean { + return typeof performance !== 'undefined' && + typeof performance.measureUserAgentSpecificMemory === 'function' && + (window.crossOriginIsolated === true) + } + + /** + * Check if Chrome-specific memory API is available + */ + private isChromeAPIAvailable (): boolean { + return typeof performance !== 'undefined' && + performance.memory !== undefined && + typeof performance.memory.usedJSHeapSize === 'number' + } + + /** + * Format bytes to human-readable string + */ + formatBytes (bytes: number | null): string { + if (bytes === null || bytes === 0) { + return 'N/A' + } + + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` + } + + /** + * Get memory info using modern API + */ + private async getMemoryModern (): Promise { + try { + if (!performance.measureUserAgentSpecificMemory) { + throw new Error('measureUserAgentSpecificMemory not available') + } + const result = await performance.measureUserAgentSpecificMemory() + const bytes = result.bytes || 0 + + // Estimate limit (typically 2-4GB for 32-bit browsers, 4-8GB+ for 64-bit) + // We use a conservative estimate since the API doesn't provide a direct limit + const estimatedLimit = Math.max( + bytes * 2, // At least 2x current usage + 4 * 1024 * 1024 * 1024 // Minimum 4GB for modern browsers + ) + + const usagePercentage = (bytes / estimatedLimit) * 100 + + return { + usedJSHeapSize: bytes, + jsHeapSizeLimit: estimatedLimit, + totalJSHeapSize: bytes, + usagePercentage: Math.min(usagePercentage, 100), + remainingBytes: Math.max(0, estimatedLimit - bytes), + isHighUsage: usagePercentage > this.highUsageThreshold * 100, + isCriticalUsage: usagePercentage > this.criticalUsageThreshold * 100, + apiMethod: 'modern', + timestamp: Date.now() + } + } catch (error) { + console.warn('Failed to measure memory with modern API:', error) + throw error + } + } + + /** + * Get memory info using Chrome-specific API + */ + private getMemoryChrome (): MemoryInfo { + const memory = performance.memory + if (!memory) { + throw new Error('performance.memory not available') + } + const usedJSHeapSize = memory.usedJSHeapSize + const totalJSHeapSize = memory.totalJSHeapSize + const jsHeapSizeLimit = memory.jsHeapSizeLimit + + const usagePercentage = (usedJSHeapSize / jsHeapSizeLimit) * 100 + const remainingBytes = jsHeapSizeLimit - usedJSHeapSize + + return { + usedJSHeapSize, + jsHeapSizeLimit, + totalJSHeapSize, + usagePercentage, + remainingBytes: Math.max(0, remainingBytes), + isHighUsage: usagePercentage > this.highUsageThreshold * 100, + isCriticalUsage: usagePercentage > this.criticalUsageThreshold * 100, + apiMethod: 'chrome', + timestamp: Date.now() + } + } + + /** + * Get memory info (unavailable) + */ + private getMemoryUnavailable (): MemoryInfo { + return { + usedJSHeapSize: null, + jsHeapSizeLimit: null, + totalJSHeapSize: null, + usagePercentage: null, + remainingBytes: null, + isHighUsage: false, + isCriticalUsage: false, + apiMethod: 'unavailable', + timestamp: Date.now() + } + } + + /** + * Measure current memory usage + */ + async measure (): Promise { + let memory: MemoryInfo + let breakdown: Array<{ bytes: number, userAgentSpecificTypes: string[] }> | undefined + + if (this.isModernAPIAvailable()) { + try { + if (!performance.measureUserAgentSpecificMemory) { + throw new Error('measureUserAgentSpecificMemory not available') + } + const result = await performance.measureUserAgentSpecificMemory() + memory = await this.getMemoryModern() + + if (result.breakdown) { + breakdown = result.breakdown.map(item => ({ + bytes: item.bytes, + userAgentSpecificTypes: item.userAgentSpecificTypes || [] + })) + } + } catch (error) { + // Modern API failed, try Chrome fallback + if (this.isChromeAPIAvailable()) { + memory = this.getMemoryChrome() + } else { + memory = this.getMemoryUnavailable() + } + } + } else if (this.isChromeAPIAvailable()) { + memory = this.getMemoryChrome() + } else { + memory = this.getMemoryUnavailable() + } + + this.lastMeasurement = memory + + this.updateCallbacks.forEach(callback => { + try { + callback(memory) + } catch (error) { + console.error('Error in memory update callback:', error) + } + }) + + return { memory, breakdown } + } + + /** + * Get last measured memory info (synchronous) + */ + getLastMeasurement (): MemoryInfo | null { + return this.lastMeasurement + } + + /** + * Subscribe to memory updates + */ + subscribe (callback: MemoryUpdateCallback): () => void { + this.updateCallbacks.add(callback) + + return () => { + this.updateCallbacks.delete(callback) + } + } + + /** + * Start periodic memory monitoring + */ + startMonitoring (interval: number = this.updateInterval): void { + if (this.monitoringInterval !== null) { + this.stopMonitoring() + } + + this.measure().catch(error => { + console.error('Error in initial memory measurement:', error) + }) + + this.monitoringInterval = setInterval(() => { + this.measure().catch(error => { + console.error('Error in periodic memory measurement:', error) + }) + }, interval) + } + + /** + * Stop periodic memory monitoring + */ + stopMonitoring (): void { + if (this.monitoringInterval !== null) { + clearInterval(this.monitoringInterval) + this.monitoringInterval = null + } + } + + /** + * Check if monitoring is active + */ + isMonitoring (): boolean { + return this.monitoringInterval !== null + } + + /** + * Get status message for current memory usage + */ + getStatusMessage (memory: MemoryInfo | null): string { + if (memory === null || memory.apiMethod === 'unavailable') { + return 'Memory monitoring unavailable' + } + + if (memory.isCriticalUsage) { + return `Critical: ${memory.usagePercentage?.toFixed(1)}% used (${this.formatBytes(memory.remainingBytes)} remaining)` + } + + if (memory.isHighUsage) { + return `High: ${memory.usagePercentage?.toFixed(1)}% used (${this.formatBytes(memory.remainingBytes)} remaining)` + } + + return `Memory: ${memory.usagePercentage?.toFixed(1)}% used (${this.formatBytes(memory.remainingBytes)} remaining)` + } + + /** + * Get warning level for memory usage + */ + getWarningLevel (memory: MemoryInfo | null): 'none' | 'high' | 'critical' { + if (memory === null || memory.apiMethod === 'unavailable') { + return 'none' + } + + if (memory.isCriticalUsage) { + return 'critical' + } + + if (memory.isHighUsage) { + return 'high' + } + + return 'none' + } +} + +// Export singleton instance +export const memoryMonitor = new MemoryMonitor() + +// Auto-start monitoring when module loads (optional - can be controlled by app) +// memoryMonitor.startMonitoring() diff --git a/src/types/performance.d.ts b/src/types/performance.d.ts new file mode 100644 index 00000000..901879ec --- /dev/null +++ b/src/types/performance.d.ts @@ -0,0 +1,26 @@ +/** + * Type declarations for experimental Performance API methods + */ + +interface PerformanceMemory { + usedJSHeapSize: number + totalJSHeapSize: number + jsHeapSizeLimit: number +} + +interface PerformanceMemoryInfo { + bytes: number + breakdown?: Array<{ + bytes: number + userAgentSpecificTypes: string[] + }> +} + +interface Performance { + memory?: PerformanceMemory + measureUserAgentSpecificMemory?(): Promise +} + +interface Window { + crossOriginIsolated?: boolean +} diff --git a/yarn.lock b/yarn.lock index f4cb036c..2e34977f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5632,7 +5632,7 @@ dicomicc@^0.1: resolved "https://registry.yarnpkg.com/dicomicc/-/dicomicc-0.1.0.tgz#c73acc60a8e2d73a20f462c8c7d0e1e0d977c486" integrity sha512-kZejPGjLQ9NsgovSyVsiAuCpq6LofNR9Erc8Tt/vQAYGYCoQnTyWDlg5D0TJJQATKul7cSr9k/q0TF8G9qdDkQ== -dicomweb-client@^0.10.3: +dicomweb-client@0.10.3, dicomweb-client@^0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/dicomweb-client/-/dicomweb-client-0.10.3.tgz#b4fe550037166dff5d8afd88db3800eb579c91f4" integrity sha512-/fHNEAYiz8j+9TNOrNJ0k+hYqirbOT85B7vM7I4VkY8DeDQb4BDUeL3RX6huDVtn6ZQlR91dI+2tejLc5c99wA==