diff --git a/.gitignore b/.gitignore index 7f77de64..e68e7de5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ public/config/* # misc .DS_Store +# Local pnpm store / link artifacts (do not commit) +.pnpm-store/ .env.local .env.development.local .env.test.local diff --git a/src/App.dark.less b/src/App.dark.less index fa4e6bf4..bcb486e1 100644 --- a/src/App.dark.less +++ b/src/App.dark.less @@ -69,6 +69,36 @@ border: 2px solid @primary-color !important; } +/** + * Mini-map / scale insets (px). Slim clamp may reinforce these as inline styles + * after DMV render; keep values aligned with OVERVIEW_EDGE_INSET_PX. + */ +.ol-overviewmap { + left: 8px; + bottom: 8px; + margin: 0; + padding: 0; +} + +.ol-overviewmap:not(.ol-collapsed) button { + position: absolute; + bottom: 0; + left: 0; + margin: 0; +} + +.ol-overviewmap .ol-overviewmap-map { + margin: 0; + padding: 0; +} + +.ol-scale-line { + right: 8px; + bottom: 8px; + left: auto; + margin: 0; +} + img { object-fit: contain; max-height: 100%; diff --git a/src/App.light.less b/src/App.light.less index 8ccddf87..3ae29378 100644 --- a/src/App.light.less +++ b/src/App.light.less @@ -69,6 +69,36 @@ border: 2px solid @primary-color !important; } +/** + * Mini-map / scale insets (px). Slim clamp may reinforce these as inline styles + * after DMV render; keep values aligned with OVERVIEW_EDGE_INSET_PX. + */ +.ol-overviewmap { + left: 8px; + bottom: 8px; + margin: 0; + padding: 0; +} + +.ol-overviewmap:not(.ol-collapsed) button { + position: absolute; + bottom: 0; + left: 0; + margin: 0; +} + +.ol-overviewmap .ol-overviewmap-map { + margin: 0; + padding: 0; +} + +.ol-scale-line { + right: 8px; + bottom: 8px; + left: auto; + margin: 0; +} + img { object-fit: contain; max-height: 100%; diff --git a/src/App.tsx b/src/App.tsx index 70f0cde4..004c2775 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,10 @@ import type AppConfig from './AppConfig' import type { ErrorMessageSettings, ServerSettings } from './AppConfig' import type { AuthManager, User } from './auth' import OidcManager from './auth/OidcManager' +import AppShell from './components/AppShell' import CaseViewer from './components/CaseViewer' import Header from './components/Header' import InfoPage from './components/InfoPage' -import MemoryFooter from './components/MemoryFooter' import Worklist from './components/Worklist' import { SettingsProvider } from './contexts/SettingsContext' import { ValidationProvider } from './contexts/ValidationContext' @@ -503,8 +503,20 @@ class App extends React.Component { isLogoutPossible = false } - const layoutStyle = { height: '100vh' } - const layoutContentStyle = { height: '100%' } + /** + * Fill AppShell's main pane. flex + minHeight:0 keeps ant-layout from + * sizing to content and spilling into the in-flow MemoryFooter. + */ + const layoutStyle: React.CSSProperties = { + flex: '1 1 0%', + minHeight: 0, + overflow: 'hidden', + } + const layoutContentStyle: React.CSSProperties = { + flex: 1, + minHeight: 0, + overflow: 'hidden', + } if (this.state.redirectTo !== undefined) { return ( @@ -515,20 +527,22 @@ class App extends React.Component { } else if (this.state.isLoading) { return ( - -
- - - - + + +
+ + + + + ) } else if (!this.state.wasAuthSuccessful) { @@ -542,35 +556,12 @@ class App extends React.Component { -
- - {worklist} - - {enableMemoryMonitoring && ( - - )} - - } - /> - +
{ defaultClients={this.state.defaultClients} /> - + {worklist} - {enableMemoryMonitoring && ( - - )} + + } + /> + + + +
+ + + + + } /> @@ -596,11 +608,40 @@ class App extends React.Component { path="/projects/:project/locations/:location/datasets/:dataset/dicomStores/:dicomStore/study/:studyInstanceUID/*" element={ + + +
+ + + + + + + } + /> +
{ defaultClients={this.state.defaultClients} /> - + Logged out - {enableMemoryMonitoring && ( - - )} - - } - /> - -
- - Logged out - - {enableMemoryMonitoring && ( - - )} - + } /> diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 00000000..e0226d2a --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,40 @@ +import type React from 'react' +import MemoryFooter from './MemoryFooter' + +const shellStyle: React.CSSProperties = { + height: '100vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +} + +const mainStyle: React.CSSProperties = { + flex: '1 1 0%', + minHeight: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +} + +interface AppShellProps { + children: React.ReactNode + enableMemoryMonitoring: boolean +} + +/** + * Column shell: main pane fills leftover height; MemoryFooter stays in normal + * document flow underneath so the map cannot extend under the bar. + */ +const AppShell: React.FC = ({ + children, + enableMemoryMonitoring, +}) => { + return ( +
+
{children}
+ +
+ ) +} + +export default AppShell diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index c9ca3738..8fbc6985 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -1,8 +1,15 @@ +import type { MenuProps } from 'antd' import { Layout, Menu } from 'antd' // skipcq: JS-C1003 import * as dcmjs from 'dcmjs' import { useEffect, useState } from 'react' -import { Route, Routes, useLocation, useParams } from 'react-router-dom' +import { + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from 'react-router-dom' import type { AnnotationSettings } from '../AppConfig' import type { User } from '../auth' @@ -10,6 +17,10 @@ import type DicomWebManager from '../DicomWebManager' import type { Slide } from '../data/slides' import { StorageClasses } from '../data/uids' import { useSlides } from '../hooks/useSlides' +import { + findSlideBySeriesInstanceUID, + seriesUidFromSlide, +} from '../utils/recoverSeriesInstanceUID' import { type RouteComponentProps, withRouter } from '../utils/router' import ClinicalTrial from './ClinicalTrial' import Patient from './Patient' @@ -44,13 +55,7 @@ interface NaturalizedInstance { const findSeriesSlide = ( slides: Slide[], seriesInstanceUID: string, -): Slide | undefined => { - return slides.find((slide: Slide) => { - return slide.seriesInstanceUIDs.find((uid: string) => { - return uid === seriesInstanceUID - }) - }) -} +): Slide | undefined => findSlideBySeriesInstanceUID(slides, seriesInstanceUID) function ParametrizedSlideViewer({ clients, @@ -79,6 +84,7 @@ function ParametrizedSlideViewer({ seriesInstanceUID: string }>() const location = useLocation() + const navigate = useNavigate() const [selectedSlide, setSelectedSlide] = useState( findSeriesSlide(slides, seriesInstanceUID), @@ -88,9 +94,10 @@ function ParametrizedSlideViewer({ useEffect(() => { const currentSlideMatchesSeries = - selectedSlide?.seriesInstanceUIDs.some( - (uid: string) => uid === seriesInstanceUID, - ) ?? false + selectedSlide !== null && + selectedSlide !== undefined && + findSlideBySeriesInstanceUID([selectedSlide], seriesInstanceUID) === + selectedSlide if ( selectedSlide === null || @@ -99,68 +106,104 @@ function ParametrizedSlideViewer({ ) { const imageSlide = findSeriesSlide(slides, seriesInstanceUID) if (imageSlide !== null && imageSlide !== undefined) { + const resolvedSeriesUID = seriesUidFromSlide( + imageSlide, + seriesInstanceUID, + ) setSelectedSlide(imageSlide) setDerivedDataset(null) + if (resolvedSeriesUID !== seriesInstanceUID) { + console.warn( + `Corrected mangled series UID in route: "${seriesInstanceUID}" → "${resolvedSeriesUID}"`, + ) + navigate( + { + pathname: location.pathname.replace( + `/series/${seriesInstanceUID}`, + `/series/${resolvedSeriesUID}`, + ), + search: location.search, + }, + { replace: true }, + ) + } return } const findReferencedSlide = async (): Promise => { - const client = clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE] - const derivedSeriesMetadata = await client.retrieveSeriesMetadata({ - studyInstanceUID, - seriesInstanceUID, - }) - const naturalizedDerivedMetadata = naturalizeDataset( - derivedSeriesMetadata[0], - ) as NaturalizedInstance - if ( - naturalizedDerivedMetadata.ReferencedSeriesSequence != null && - naturalizedDerivedMetadata.ReferencedSeriesSequence.length > 0 - ) { - for (const referencedSeries of naturalizedDerivedMetadata.ReferencedSeriesSequence) { - const referencedImageSeriesUID = referencedSeries.SeriesInstanceUID + try { + const client = clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE] + const derivedSeriesMetadata = await client.retrieveSeriesMetadata({ + studyInstanceUID, + seriesInstanceUID, + }) + const naturalizedDerivedMetadata = naturalizeDataset( + derivedSeriesMetadata[0], + ) as NaturalizedInstance + if ( + naturalizedDerivedMetadata.ReferencedSeriesSequence != null && + naturalizedDerivedMetadata.ReferencedSeriesSequence.length > 0 + ) { + for (const referencedSeries of naturalizedDerivedMetadata.ReferencedSeriesSequence) { + const referencedImageSeriesUID = + referencedSeries.SeriesInstanceUID + const referencedSlide = slides.find((slide: Slide) => { + return slide.seriesInstanceUIDs.some( + (uid: string) => uid === referencedImageSeriesUID, + ) + }) + if (referencedSlide !== null && referencedSlide !== undefined) { + setSelectedSlide(referencedSlide) + setDerivedDataset(naturalizedDerivedMetadata) + return + } + } + } + const IMAGE_LIBRARY_CONCEPT_NAME_CODE = '111028' + const imageLibrary = naturalizedDerivedMetadata.ContentSequence?.find( + (contentItem) => + contentItem.ConceptNameCodeSequence[0].CodeValue === + IMAGE_LIBRARY_CONCEPT_NAME_CODE, + ) + if ( + imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] + ?.ReferencedSOPSequence?.[0] !== undefined && + imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] + ?.ReferencedSOPSequence?.[0] !== null + ) { + const referencedSOPInstanceUID = + imageLibrary.ContentSequence[0].ContentSequence[0] + .ReferencedSOPSequence[0].ReferencedSOPInstanceUID const referencedSlide = slides.find((slide: Slide) => { - return slide.seriesInstanceUIDs.some( - (uid: string) => uid === referencedImageSeriesUID, + return slide.volumeImages.find( + (image: { SOPInstanceUID: string }) => { + return image.SOPInstanceUID === referencedSOPInstanceUID + }, ) }) - if (referencedSlide !== null && referencedSlide !== undefined) { - setSelectedSlide(referencedSlide) - setDerivedDataset(naturalizedDerivedMetadata) - return - } + setSelectedSlide(referencedSlide) + setDerivedDataset(naturalizedDerivedMetadata) } - } - const IMAGE_LIBRARY_CONCEPT_NAME_CODE = '111028' - const imageLibrary = naturalizedDerivedMetadata.ContentSequence?.find( - (contentItem) => - contentItem.ConceptNameCodeSequence[0].CodeValue === - IMAGE_LIBRARY_CONCEPT_NAME_CODE, - ) - if ( - imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] - ?.ReferencedSOPSequence?.[0] !== undefined && - imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] - ?.ReferencedSOPSequence?.[0] !== null - ) { - const referencedSOPInstanceUID = - imageLibrary.ContentSequence[0].ContentSequence[0] - .ReferencedSOPSequence[0].ReferencedSOPInstanceUID - const referencedSlide = slides.find((slide: Slide) => { - return slide.volumeImages.find( - (image: { SOPInstanceUID: string }) => { - return image.SOPInstanceUID === referencedSOPInstanceUID - }, - ) - }) - setSelectedSlide(referencedSlide) - setDerivedDataset(naturalizedDerivedMetadata) + } catch (error) { + console.warn( + `Failed to resolve referenced slide for series "${seriesInstanceUID}"`, + error, + ) } } void findReferencedSlide() } - }, [slides, clients, studyInstanceUID, seriesInstanceUID, selectedSlide]) + }, [ + slides, + clients, + studyInstanceUID, + seriesInstanceUID, + selectedSlide, + navigate, + location.pathname, + location.search, + ]) const searchParams = new URLSearchParams(location.search) let presentationStateUID: string | undefined @@ -171,11 +214,15 @@ function ParametrizedSlideViewer({ let viewer = null if (selectedSlide != null && selectedSlide !== undefined) { + const resolvedSeriesInstanceUID = seriesUidFromSlide( + selectedSlide, + seriesInstanceUID, + ) viewer = ( - - - ) - } + const siderMenuItems: MenuProps['items'] = [ + { + key: 'patient', + label: 'Patient', + children: [ + { + key: 'patient-info', + style: { cursor: 'default', height: 'auto' }, + label: , + }, + ], + }, + { + key: 'study', + label: 'Study', + children: [ + { + key: 'study-info', + style: { cursor: 'default', height: 'auto' }, + label: , + }, + ], + }, + ...(refImage.ClinicalTrialSponsorName != null + ? [ + { + key: 'clinical-trial', + label: 'Clinical Trial', + children: [ + { + key: 'clinical-trial-info', + style: { cursor: 'default', height: 'auto' }, + label: , + }, + ], + }, + ] + : []), + ] return ( - + +
- - - - - - - {clinicalTrialMenu} - - - -
+ Slides + +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1bc9a225..918c663a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -88,42 +88,60 @@ const aboutModalStyles: Record = { /** * Static count pill that avoids antd Badge → rc-motion `findDOMNode` * (deprecated under React Strict Mode). + * + * Layout/CSS mirrors antd Badge (compact): wrapper `line-height: 1` so the + * header's 64px line-height cannot inflate the positioning context, and the + * count uses `top/right: 0` + `translate(50%, -50%)` to sit on the corner. + * Measured repro: without `line-height: 1`, a `top: -4` pill pins to y=0 and + * AppShell `overflow: hidden` crops it. */ function HeaderCountBadge({ count, color = '#ff4d4f', zIndex, + /** Same meaning as antd Badge `offset`: [offsetX, offsetY] in px. */ + offset = [0, 0], children, }: { count: number color?: string zIndex?: number + offset?: [number, number] children?: React.ReactNode }): JSX.Element { + const [offsetX, offsetY] = offset const pill = count > 0 ? ( - {count > 99 ? '99+' : count} - + ) : null if (children == null) { @@ -131,7 +149,14 @@ function HeaderCountBadge({ } return ( - + {children} {pill} @@ -667,63 +692,20 @@ class Header extends React.Component { ) const debugButton = ( - - + + ) + })} + ) } } diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index ba2f4239..f7cf431e 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -48,6 +48,10 @@ import type { AnnotationSettings, } from '../types/annotations' import { CustomError, errorTypes } from '../utils/CustomError' +import { + clampOverviewMapInViewport, + observeOverviewMapClamp, +} from '../utils/clampOverviewMapInViewport' import { applyDistinctFractionalSegmentPalettes, applyDistinctParametricMapPalettes, @@ -120,6 +124,8 @@ class SlideViewer extends React.Component { private readonly labelViewportRef: React.RefObject + private stopOverviewMapClamp: (() => void) | undefined + private volumeViewer: dmv.viewer.VolumeImageViewer private labelViewer?: dmv.viewer.LabelImageViewer @@ -1551,6 +1557,11 @@ class SlideViewer extends React.Component { if (this.volumeViewportRef.current !== null) { this.volumeViewer.render({ container: this.volumeViewportRef.current }) + this.stopOverviewMapClamp?.() + this.stopOverviewMapClamp = observeOverviewMapClamp( + this.volumeViewportRef.current, + { volumeViewer: this.volumeViewer }, + ) } if ( this.labelViewportRef.current !== null && @@ -1600,6 +1611,11 @@ class SlideViewer extends React.Component { if (this.labelViewer !== null && this.labelViewer !== undefined) { this.labelViewer.resize() } + if (this.volumeViewportRef.current !== null) { + clampOverviewMapInViewport(this.volumeViewportRef.current, { + volumeViewer: this.volumeViewer, + }) + } } onRoiDrawn = (event: CustomEventInit): void => { @@ -2366,6 +2382,9 @@ class SlideViewer extends React.Component { document.body.removeEventListener('keyup', this.onKeyDown) window.removeEventListener('resize', this.onWindowResize) + this.stopOverviewMapClamp?.() + this.stopOverviewMapClamp = undefined + this.volumeViewer.cleanup() if (this.labelViewer !== null && this.labelViewer !== undefined) { this.labelViewer.cleanup() @@ -2428,6 +2447,8 @@ class SlideViewer extends React.Component { } componentWillUnmount = (): void => { + this.stopOverviewMapClamp?.() + this.stopOverviewMapClamp = undefined ActiveSeriesService.clear() this.volumeViewer.cleanup() if (this.labelViewer !== null && this.labelViewer !== undefined) { @@ -4899,7 +4920,7 @@ class SlideViewer extends React.Component { annotations?.forEach?.(this.formatAnnotation) return ( - + this.setState({ isSettingsDrawerOpen: true })} /> diff --git a/src/components/SlideViewer/SlideViewerContent.tsx b/src/components/SlideViewer/SlideViewerContent.tsx index 5768dc8c..ed3ebb8a 100644 --- a/src/components/SlideViewer/SlideViewerContent.tsx +++ b/src/components/SlideViewer/SlideViewerContent.tsx @@ -3,30 +3,42 @@ import type React from 'react' interface SlideViewerContentProps { toolbar: React.ReactNode - toolbarHeight: string + /** Kept for call-site compatibility; height is flex-based now. */ + toolbarHeight?: string cursor: string volumeViewportRef: React.RefObject children: React.ReactNode } /** - * Main content area component for the SlideViewer + * Main content area for the SlideViewer. Viewport flex-fills under the toolbar + * so a mismatched toolbarHeight cannot leave empty space below the map (that + * gap sat under the minimap/scale and looked like uneven bottom inset). */ const SlideViewerContent: React.FC = ({ toolbar, - toolbarHeight, cursor, volumeViewportRef, children, }) => { return ( - + {toolbar}
{ + it('scales down large matrices to integer viewport dimensions', () => { + const cols = 50_000 + const rows = 40_000 + const factor = computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: cols, TotalPixelMatrixRows: rows }, + 280, + 100, + ) + expect(factor).toBe(100 / rows) + expect(cols * factor).toBe(125) + expect(rows * factor).toBe(100) + }) + + it('does not upscale small matrices', () => { + expect( + computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: 200, TotalPixelMatrixRows: 100 }, + 280, + 100, + ), + ).toBe(1) + }) + + it('uses fallback dimensions when the container is not yet measured', () => { + const cols = 10_000 + const rows = 8_000 + const factor = computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: cols, TotalPixelMatrixRows: rows }, + 0, + 0, + ) + expect(factor).toBe(SLIDE_PREVIEW_HEIGHT_PX / rows) + expect(cols * factor).toBe( + (cols * SLIDE_PREVIEW_HEIGHT_PX) / rows, + ) + expect(factor).toBeLessThan(SLIDE_PREVIEW_FALLBACK_WIDTH_PX / cols) + }) + + it('falls back to 1 when no integer downscale fits the tile', () => { + /** + * Coprime matrix sizes: only multiples of `rows` keep both viewport axes + * integer, so a 100px-tall tile cannot downscale via viewport. + */ + expect( + computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: 48_001, TotalPixelMatrixRows: 38_300 }, + 280, + 100, + ), + ).toBe(1) + }) + + it('returns 1 for invalid matrix metadata', () => { + expect( + computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: 0, TotalPixelMatrixRows: 100 }, + 280, + 100, + ), + ).toBe(1) + }) +}) diff --git a/src/utils/__tests__/fitOverviewMapSize.test.ts b/src/utils/__tests__/fitOverviewMapSize.test.ts new file mode 100644 index 00000000..244fd52a --- /dev/null +++ b/src/utils/__tests__/fitOverviewMapSize.test.ts @@ -0,0 +1,93 @@ +import { + fitOverviewMapSize, + MAX_OVERVIEW_BOX_PX, + MAX_OVERVIEW_FRACTION, + overviewMapSizeBounds, + PREFERRED_OVERVIEW_BOX_PX, + PREFERRED_OVERVIEW_FRACTION, +} from '../fitOverviewMapSize' + +describe('fitOverviewMapSize', () => { + it('fits a normal aspect ratio inside the OL-sized preferred box', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(400, 300, bounds) + expect(fitted.width).toBeLessThanOrEqual(bounds.preferredMaxWidth + 0.01) + expect(fitted.height).toBeLessThanOrEqual(bounds.preferredMaxHeight + 0.01) + expect(fitted.width / fitted.height).toBeCloseTo(400 / 300) + expect(Math.max(fitted.width, fitted.height)).toBeLessThanOrEqual( + PREFERRED_OVERVIEW_BOX_PX + 0.01, + ) + }) + + it('grows wide maps toward the min side then respects the max box', () => { + const bounds = overviewMapSizeBounds(1000, 800) + /** Aspect 6: min-side growth wants 48×288, then max width 200 scales it down. */ + const fitted = fitOverviewMapSize(600, 100, bounds) + expect(fitted.width).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(fitted.height).toBeCloseTo(MAX_OVERVIEW_BOX_PX / 6) + expect(fitted.width / fitted.height).toBeCloseTo(6) + }) + + it('caps extremely wide maps at the absolute OL-inspired max box', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(4000, 40, bounds) + expect(fitted.width).toBeLessThanOrEqual(bounds.maxMapWidth + 0.01) + expect(fitted.width).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(fitted.width / fitted.height).toBeCloseTo(100) + expect(fitted.width).toBeLessThanOrEqual(1000 * MAX_OVERVIEW_FRACTION) + }) + + it('grows tall maps toward the min side then respects the max box', () => { + const bounds = overviewMapSizeBounds(1000, 800) + /** Aspect 1/5: min-side growth wants 48×240, then max height 200 scales it down. */ + const fitted = fitOverviewMapSize(100, 500, bounds) + expect(fitted.height).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(fitted.width).toBeCloseTo(MAX_OVERVIEW_BOX_PX / 5) + expect(fitted.width / fitted.height).toBeCloseTo(1 / 5) + }) + + it('caps extremely tall maps at the absolute OL-inspired max box', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(40, 4000, bounds) + expect(fitted.height).toBeLessThanOrEqual(bounds.maxMapHeight + 0.01) + expect(fitted.height).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(fitted.width / fitted.height).toBeCloseTo(40 / 4000) + }) + + it('treats wide and tall extremes with matching absolute box caps', () => { + const bounds = overviewMapSizeBounds(1000, 1000) + const wide = fitOverviewMapSize(5000, 50, bounds) + const tall = fitOverviewMapSize(50, 5000, bounds) + expect(wide.width).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(tall.height).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(wide.width).toBeCloseTo(tall.height) + expect(wide.height).toBeCloseTo(tall.width) + }) + + it('exposes preferred bounds at the OpenLayers default 150px box', () => { + const bounds = overviewMapSizeBounds(1000, 800) + expect(bounds.preferredMaxWidth).toBeCloseTo(PREFERRED_OVERVIEW_BOX_PX) + expect(bounds.preferredMaxHeight).toBeCloseTo(PREFERRED_OVERVIEW_BOX_PX) + expect(bounds.maxMapWidth).toBeCloseTo(MAX_OVERVIEW_BOX_PX) + expect(bounds.maxMapHeight).toBeCloseTo( + Math.min(800 * MAX_OVERVIEW_FRACTION, MAX_OVERVIEW_BOX_PX), + ) + expect(bounds.preferredMaxWidth).toBeLessThanOrEqual( + 1000 * PREFERRED_OVERVIEW_FRACTION + 0.01, + ) + }) + + it('stays near the OL default size on large viewports', () => { + const bounds = overviewMapSizeBounds(2400, 1600) + const square = fitOverviewMapSize(1000, 1000, bounds) + const tall = fitOverviewMapSize(80, 4000, bounds) + const wide = fitOverviewMapSize(4000, 80, bounds) + expect(Math.max(square.width, square.height)).toBeCloseTo( + PREFERRED_OVERVIEW_BOX_PX, + ) + expect(tall.height).toBeLessThanOrEqual(MAX_OVERVIEW_BOX_PX + 0.01) + expect(wide.width).toBeLessThanOrEqual(MAX_OVERVIEW_BOX_PX + 0.01) + expect(tall.height).toBeLessThan(1600 * 0.25) + expect(wide.width).toBeLessThan(2400 * 0.25) + }) +}) diff --git a/src/utils/__tests__/recoverSeriesInstanceUID.test.ts b/src/utils/__tests__/recoverSeriesInstanceUID.test.ts new file mode 100644 index 00000000..10bcc208 --- /dev/null +++ b/src/utils/__tests__/recoverSeriesInstanceUID.test.ts @@ -0,0 +1,69 @@ +import { + findSlideBySeriesInstanceUID, + recoverSeriesInstanceUID, + seriesUidFromSlide, +} from '../recoverSeriesInstanceUID' + +describe('recoverSeriesInstanceUID', () => { + const uids = [ + '1.2.3.4.5.6.7.8.9.2', + '1.2.3.4.5.6.7.8.9.2.0', + '1.2.3.4.5.6.7.8.9.2.0.1', + ] + + it('returns an exact match', () => { + expect(recoverSeriesInstanceUID(uids[1], uids)).toBe(uids[1]) + }) + + it('strips trailing .0 from antd Menu key mangling', () => { + expect(recoverSeriesInstanceUID(`${uids[2]}.0`, uids)).toBe(uids[2]) + }) + + it('stops stripping at the first existing UID', () => { + // "...2.0.0" → "...2.0" exists in candidates, do not strip further to "...2" + expect(recoverSeriesInstanceUID(`${uids[0]}.0.0`, uids)).toBe(uids[1]) + }) + + it('strips multiple trailing .0 when intermediates are absent', () => { + expect(recoverSeriesInstanceUID(`${uids[0]}.0.0`, [uids[0]])).toBe(uids[0]) + }) + + it('prefers the longest prefix when strip does not match', () => { + expect( + recoverSeriesInstanceUID('1.2.3.4.5.6.7.8.9.2.0.1.9', uids), + ).toBe(uids[2]) + }) + + it('returns undefined when nothing matches', () => { + expect(recoverSeriesInstanceUID('9.9.9', uids)).toBeUndefined() + }) +}) + +describe('findSlideBySeriesInstanceUID', () => { + const slides = [ + { id: 'a', seriesInstanceUIDs: ['1.2.3.2', '1.2.3.2.0'] }, + { id: 'b', seriesInstanceUIDs: ['1.2.3.2.0.1'] }, + ] + + it('finds an exact slide', () => { + expect(findSlideBySeriesInstanceUID(slides, '1.2.3.2.0.1')?.id).toBe('b') + }) + + it('does not bind a mangled longer UID to a shorter sibling prefix', () => { + expect(findSlideBySeriesInstanceUID(slides, '1.2.3.2.0.1.0')?.id).toBe('b') + }) +}) + +describe('seriesUidFromSlide', () => { + const slide = { + seriesInstanceUIDs: ['1.2.3.2', '1.2.3.2.0.1'], + } + + it('recovers a mangled preferred UID', () => { + expect(seriesUidFromSlide(slide, '1.2.3.2.0.1.0')).toBe('1.2.3.2.0.1') + }) + + it('falls back to the first UID', () => { + expect(seriesUidFromSlide(slide)).toBe('1.2.3.2') + }) +}) diff --git a/src/utils/clampOverviewMapInViewport.ts b/src/utils/clampOverviewMapInViewport.ts new file mode 100644 index 00000000..2e897a93 --- /dev/null +++ b/src/utils/clampOverviewMapInViewport.ts @@ -0,0 +1,281 @@ +import { getCenter, getHeight, getWidth } from 'ol/extent' +import type OlMap from 'ol/Map' +import type View from 'ol/View' + +import { + fitOverviewMapSize, + OVERVIEW_EDGE_INSET_PX, + overviewMapSizeBounds, +} from './fitOverviewMapSize' + +/** OpenLayers View internals used to retarget locked overview resolutions. */ +type OverviewViewInternals = View & { + applyOptions_: (options: Record) => void + getUpdatedOptions_: ( + options: Record, + ) => Record +} + +function verticalChromePx(mapEl: HTMLElement): number { + const style = window.getComputedStyle(mapEl) + const read = (prop: string): number => + Number.parseFloat(style.getPropertyValue(prop)) || 0 + return ( + read('margin-top') + + read('margin-bottom') + + read('padding-top') + + read('padding-bottom') + + read('border-top-width') + + read('border-bottom-width') + ) +} + +function horizontalChromePx(mapEl: HTMLElement): number { + const style = window.getComputedStyle(mapEl) + const read = (prop: string): number => + Number.parseFloat(style.getPropertyValue(prop)) || 0 + return ( + read('margin-left') + + read('margin-right') + + read('padding-left') + + read('padding-right') + + read('border-left-width') + + read('border-right-width') + ) +} + +/** + * Locate DMV's OverviewMap control via Symbol-keyed private fields (no public + * API on the published package), then sync OL size + view after CSS resize. + * + * DMV locks overview `minResolution === maxResolution` and pins the center via + * a point `extent` + `constrainOnlyCenter` so OpenLayers' OverviewMap cannot + * rezoom/recenter when the main-map box shrinks on zoom (`resetExtent_`). + * After Slim shrinks the map for chrome / max-fraction, retarget that locked + * resolution to the post-resize map size — and re-apply the center pin. + * + * Do not `setView(new View)`: DMV bundles its own `ol`, so a Slim `View` fails + * `instanceof` and OL treats it as a Promise (`view.then`). + */ +function syncOverviewOpenLayersMap(volumeViewer: object): void { + for (const symbol of Object.getOwnPropertySymbols(volumeViewer)) { + const value = (volumeViewer as Record)[symbol] + if ( + value == null || + typeof value !== 'object' || + typeof (value as { getOverviewMap?: unknown }).getOverviewMap !== + 'function' + ) { + continue + } + const overviewOlMap = ( + value as { getOverviewMap: () => OlMap } + ).getOverviewMap() + overviewOlMap.updateSize() + const view = overviewOlMap.getView() as OverviewViewInternals | undefined + const projection = view?.getProjection() + const extent = projection?.getExtent() + const size = overviewOlMap.getSize() + if ( + view == null || + extent == null || + size == null || + !(size[0] > 0) || + !(size[1] > 0) || + typeof view.applyOptions_ !== 'function' || + typeof view.getUpdatedOptions_ !== 'function' + ) { + return + } + + const rotation = view.getRotation() + const degrees = (rotation / Math.PI) * 180 + const isRotated = !( + Math.abs(degrees - 180) < 0.01 || Math.abs(degrees - 0) < 0.01 + ) + /** Same formula as DMV `_updateOverviewMapSize` (height-driven). */ + const resolution = isRotated + ? getWidth(extent) / size[1] + : getHeight(extent) / size[1] + if (!(resolution > 0) || !Number.isFinite(resolution)) { + return + } + + const center = getCenter(extent) + view.applyOptions_( + view.getUpdatedOptions_({ + minResolution: resolution, + maxResolution: resolution, + resolution, + center, + /** Keep the overview pinned to the full-slide center on zoom. */ + extent: center.concat(center), + constrainOnlyCenter: true, + showFullExtent: true, + }), + ) + return + } +} + +function syncCollapseButtonLayout(overview: HTMLElement): void { + const collapseButton = overview.querySelector(':scope > button') + if (!(collapseButton instanceof HTMLElement)) { + return + } + collapseButton.style.margin = '0' + if (overview.classList.contains('ol-collapsed')) { + collapseButton.style.position = '' + collapseButton.style.bottom = '' + collapseButton.style.left = '' + } else { + collapseButton.style.position = 'absolute' + collapseButton.style.bottom = '0' + collapseButton.style.left = '0' + } +} + +export type ClampOverviewMapOptions = { + /** + * VolumeImageViewer instance. When provided, retargets the overview view's + * locked resolution after CSS size changes (DOM `resize` events do not). + */ + volumeViewer?: object +} + +/** + * Fit overview map size into the viewport; keep left/bottom insets equal. + * + * Slim owns runtime inset/size because craco loads the published DMV bundle; + * keep constants in sync with DMV `_updateOverviewMapSize` / + * {@link fitOverviewMapSize}. + */ +export function clampOverviewMapInViewport( + container: HTMLElement, + options: ClampOverviewMapOptions = {}, +): void { + const overview = container.querySelector('.ol-overviewmap') + const mapEl = container.querySelector('.ol-overviewmap-map') + if (!(overview instanceof HTMLElement) || !(mapEl instanceof HTMLElement)) { + return + } + + const chromeY = verticalChromePx(mapEl) + const chromeX = horizontalChromePx(mapEl) + const bounds = overviewMapSizeBounds( + container.clientWidth, + container.clientHeight, + chromeX, + chromeY, + ) + + overview.style.left = `${OVERVIEW_EDGE_INSET_PX}px` + overview.style.bottom = `${OVERVIEW_EDGE_INSET_PX}px` + overview.style.top = 'auto' + overview.style.right = 'auto' + overview.style.margin = '0' + overview.style.padding = '0' + mapEl.style.margin = '0' + mapEl.style.padding = '0' + + const scale = container.querySelector('.ol-scale-line') + if (scale instanceof HTMLElement) { + scale.style.bottom = `${OVERVIEW_EDGE_INSET_PX}px` + scale.style.right = `${OVERVIEW_EDGE_INSET_PX}px` + scale.style.margin = '0' + } + + syncCollapseButtonLayout(overview) + + const height = + Number.parseFloat(mapEl.style.height || '') || mapEl.clientHeight + const width = Number.parseFloat(mapEl.style.width || '') || mapEl.clientWidth + if (!(height > 0) || !(width > 0)) { + return + } + + const fitted = fitOverviewMapSize(width, height, bounds) + const sizeChanged = + Math.abs(fitted.width - width) > 0.5 || + Math.abs(fitted.height - height) > 0.5 + + if (sizeChanged) { + mapEl.style.width = `${fitted.width}px` + mapEl.style.height = `${fitted.height}px` + /** + * Only retarget the locked overview resolution when the CSS size changed. + * Zoom updates the overview *box* styles and would otherwise re-enter here + * via MutationObserver; repeatedly rewriting view options on every box + * paint is unnecessary once size (and thus resolution) is stable. + */ + if (options.volumeViewer != null) { + syncOverviewOpenLayersMap(options.volumeViewer) + } + } +} + +/** + * Re-run {@link clampOverviewMapInViewport} when DMV rebuilds or resizes the + * overview control. Volume `resize()` runs only from ResizeObserver so mutation + * clamping cannot feedback through OL style updates. + */ +export function observeOverviewMapClamp( + container: HTMLElement, + options: ClampOverviewMapOptions = {}, +): () => void { + let scheduled = false + let isClamping = false + let resizeScheduled = false + + const clamp = (): void => { + if (scheduled || isClamping) { + return + } + scheduled = true + requestAnimationFrame(() => { + scheduled = false + isClamping = true + try { + clampOverviewMapInViewport(container, options) + } finally { + isClamping = false + } + }) + } + + const onContainerResize = (): void => { + if (resizeScheduled) { + return + } + resizeScheduled = true + requestAnimationFrame(() => { + resizeScheduled = false + const viewer = options.volumeViewer as { resize?: () => void } | undefined + viewer?.resize?.() + clamp() + }) + } + + const mutationObserver = new MutationObserver(() => { + if (isClamping) { + return + } + clamp() + }) + mutationObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'], + }) + + const resizeObserver = new ResizeObserver(onContainerResize) + resizeObserver.observe(container) + + clamp() + + return () => { + mutationObserver.disconnect() + resizeObserver.disconnect() + } +} diff --git a/src/utils/computeOverviewPreviewResizeFactor.ts b/src/utils/computeOverviewPreviewResizeFactor.ts new file mode 100644 index 00000000..eb30fb5c --- /dev/null +++ b/src/utils/computeOverviewPreviewResizeFactor.ts @@ -0,0 +1,82 @@ +/** Default slide-list preview height (see {@link SlideItem}). */ +export const SLIDE_PREVIEW_HEIGHT_PX = 100 + +/** Fallback width when the container has not been laid out yet. */ +export const SLIDE_PREVIEW_FALLBACK_WIDTH_PX = 280 + +export type OverviewPreviewMatrixSize = { + TotalPixelMatrixColumns: number + TotalPixelMatrixRows: number +} + +function gcd(a: number, b: number): number { + let x = Math.abs(Math.trunc(a)) + let y = Math.abs(Math.trunc(b)) + while (y !== 0) { + const t = y + y = x % y + x = t + } + return x === 0 ? 1 : x +} + +/** + * Scale factor for {@link OverviewImageViewer}'s `resizeFactor` so the DICOMweb + * rendered preview extent matches the slide-list tile and the server returns a + * reasonably sized PNG (via the viewport query param when factor < 1). + * + * Without this, THUMBNAIL / large OVERVIEW instances keep the full-slide + * TotalPixelMatrix extent while the rendered image is much smaller — the preview + * shows a tiny image in a huge canvas or fails to fit (#399). + * + * Google Healthcare DICOMweb rejects non-integer `viewport` values (HTTP 400). + * DMV builds `viewport` as `cols*factor,rows*factor`, so the factor must yield + * integer pixel sizes on both axes. When no such downscale fits the tile, return + * `1` (omit viewport; OL fits the full rendered instance). + */ +export function computeOverviewPreviewResizeFactor( + metadata: OverviewPreviewMatrixSize, + containerWidth: number, + containerHeight: number, +): number { + const cols = Number(metadata.TotalPixelMatrixColumns) + const rows = Number(metadata.TotalPixelMatrixRows) + if ( + !Number.isFinite(cols) || + !Number.isFinite(rows) || + cols <= 0 || + rows <= 0 + ) { + return 1 + } + + const width = Math.floor( + containerWidth > 0 ? containerWidth : SLIDE_PREVIEW_FALLBACK_WIDTH_PX, + ) + const height = Math.floor( + containerHeight > 0 ? containerHeight : SLIDE_PREVIEW_HEIGHT_PX, + ) + if (width <= 0 || height <= 0) { + return 1 + } + + const fitScale = Math.min(width / cols, height / rows, 1) + if (!Number.isFinite(fitScale) || fitScale <= 0) { + return 1 + } + if (fitScale >= 1) { + return 1 + } + + /** + * `cols * h / rows` is an integer iff `h` is a multiple of `rows / gcd(cols, rows)`. + * Pick the largest such `h` that still fits the container. + */ + const maxTargetH = Math.max(1, Math.floor(rows * fitScale)) + const step = rows / gcd(cols, rows) + const targetH = Math.floor(maxTargetH / step) * step + if (targetH < 1) { + return 1 + } + return targetH / rows +} diff --git a/src/utils/fitOverviewMapSize.ts b/src/utils/fitOverviewMapSize.ts new file mode 100644 index 00000000..74cddc1b --- /dev/null +++ b/src/utils/fitOverviewMapSize.ts @@ -0,0 +1,191 @@ +/** + * Shared overview mini-map sizing (kept in sync with DMV's + * `_updateOverviewMapSize` in dicom-microscopy-viewer/src/viewer.js). + * Slim applies this client-side because the published + * `dicom-microscopy-viewer` bundle may not yet include the same fix; + * local DMV `viewer.js` edits are out of band until that package is bumped. + * + * Keep these constants aligned with DMV when changing either side: + * edgeInsetPx=8, topHeadroomPx=12, minOverviewSidePx=48, + * preferredBoxPx=150, maxBoxPx=200, preferredFraction=0.25, maxFraction=0.3. + * + * OpenLayers' native OverviewMap does **not** size by viewport fraction: its + * default CSS is a fixed 150×150px box (`.ol-overviewmap-map` in `ol.css`), and + * the official custom example uses ~300px width. We follow that model: contain + * the slide aspect ratio in a fixed pixel box, with a hard absolute cap so + * extreme aspects / large monitors cannot dominate the viewport. + */ + +/** Matching inset from the left and bottom edges of the map viewport (px). */ +export const OVERVIEW_EDGE_INSET_PX = 8 + +/** Extra top clearance so a tall mini-map does not cover the toolbar. */ +export const OVERVIEW_TOP_HEADROOM_PX = 12 + +/** + * Floor for each mini-map side so ultra-thin slides stay clickable. Growth to + * meet this still respects {@link MAX_OVERVIEW_BOX_PX}. + */ +export const MIN_OVERVIEW_SIDE_PX = 48 + +/** @deprecated Use {@link MIN_OVERVIEW_SIDE_PX}. */ +export const MIN_OVERVIEW_HEIGHT_PX = MIN_OVERVIEW_SIDE_PX + +/** + * Preferred contain box — OpenLayers default `.ol-overviewmap-map` size. + * Tall and wide slides share this budget so the footprint stays consistent. + */ +export const PREFERRED_OVERVIEW_BOX_PX = 150 + +/** + * Hard absolute contain box (px). Slightly above the OL default so min-side + * growth on extreme aspects has a little room without approaching the OL + * custom-example 300px size. + */ +export const MAX_OVERVIEW_BOX_PX = 200 + +/** + * @deprecated Viewport fractions are no longer the primary budget; kept so + * older imports keep resolving. Prefer {@link PREFERRED_OVERVIEW_BOX_PX}. + */ +export const PREFERRED_OVERVIEW_FRACTION = 0.25 + +/** @deprecated Use {@link PREFERRED_OVERVIEW_FRACTION}. */ +export const PREFERRED_OVERVIEW_WIDTH_FRACTION = PREFERRED_OVERVIEW_FRACTION + +/** + * @deprecated Viewport fractions are no longer the primary budget; kept so + * older imports keep resolving. Prefer {@link MAX_OVERVIEW_BOX_PX}. + */ +export const MAX_OVERVIEW_FRACTION = 0.3 + +/** @deprecated Use {@link PREFERRED_OVERVIEW_BOX_PX}. */ +export const PREFERRED_OVERVIEW_LONG_SIDE_PX = PREFERRED_OVERVIEW_BOX_PX + +/** @deprecated Use {@link MAX_OVERVIEW_BOX_PX}. */ +export const MAX_OVERVIEW_LONG_SIDE_PX = MAX_OVERVIEW_BOX_PX + +export type OverviewMapSizeBounds = { + maxMapWidth: number + maxMapHeight: number + preferredMaxWidth: number + preferredMaxHeight: number + minMapWidth: number + minMapHeight: number +} + +export type OverviewMapSize = { + width: number + height: number +} + +export function overviewMapSizeBounds( + containerWidth: number, + containerHeight: number, + chromeX = 0, + chromeY = 0, +): OverviewMapSizeBounds { + const insetMaxWidth = Math.max( + 0, + containerWidth - 2 * OVERVIEW_EDGE_INSET_PX - chromeX, + ) + const insetMaxHeight = Math.max( + 0, + containerHeight - + OVERVIEW_EDGE_INSET_PX - + OVERVIEW_TOP_HEADROOM_PX - + chromeY, + ) + /** + * Primary budget is the fixed OL-style box; fractions only shrink further on + * tiny viewports so the mini-map cannot overflow the slide area. + */ + const maxMapWidth = Math.min( + insetMaxWidth, + containerWidth * MAX_OVERVIEW_FRACTION, + MAX_OVERVIEW_BOX_PX, + ) + const maxMapHeight = Math.min( + insetMaxHeight, + containerHeight * MAX_OVERVIEW_FRACTION, + MAX_OVERVIEW_BOX_PX, + ) + const preferredMaxWidth = Math.min( + maxMapWidth, + containerWidth * PREFERRED_OVERVIEW_FRACTION, + PREFERRED_OVERVIEW_BOX_PX, + ) + const preferredMaxHeight = Math.min( + maxMapHeight, + containerHeight * PREFERRED_OVERVIEW_FRACTION, + PREFERRED_OVERVIEW_BOX_PX, + ) + const minMapWidth = Math.min(MIN_OVERVIEW_SIDE_PX, maxMapWidth) + const minMapHeight = Math.min(MIN_OVERVIEW_SIDE_PX, maxMapHeight) + return { + maxMapWidth, + maxMapHeight, + preferredMaxWidth, + preferredMaxHeight, + minMapWidth, + minMapHeight, + } +} + +/** + * Fit overview map size into a fixed OL-style box: contain in the preferred + * box, grow toward the max box only to meet minimum side length, then contain + * in the max box. Aspect ratio is preserved. + */ +export function fitOverviewMapSize( + width: number, + height: number, + bounds: OverviewMapSizeBounds, +): OverviewMapSize { + if (!(width > 0) || !(height > 0)) { + return { width, height } + } + + const aspect = width / height + const { + maxMapWidth, + maxMapHeight, + preferredMaxWidth, + preferredMaxHeight, + minMapWidth, + minMapHeight, + } = bounds + + if ( + !(preferredMaxWidth > 0) || + !(preferredMaxHeight > 0) || + !(maxMapWidth > 0) || + !(maxMapHeight > 0) + ) { + return { width: 0, height: 0 } + } + + /** Contain in preferred box. */ + let nextHeight = Math.min(preferredMaxHeight, preferredMaxWidth / aspect) + let nextWidth = nextHeight * aspect + + /** Grow toward max box to meet minimum side lengths. */ + const scaleUp = Math.max( + 1, + minMapHeight > 0 ? minMapHeight / nextHeight : 1, + minMapWidth > 0 ? minMapWidth / nextWidth : 1, + ) + nextWidth *= scaleUp + nextHeight *= scaleUp + + /** Contain in max box (never dominate the main viewport). */ + const scaleDown = Math.min( + 1, + maxMapWidth / nextWidth, + maxMapHeight / nextHeight, + ) + nextWidth *= scaleDown + nextHeight *= scaleDown + + return { width: nextWidth, height: nextHeight } +} diff --git a/src/utils/recoverSeriesInstanceUID.ts b/src/utils/recoverSeriesInstanceUID.ts new file mode 100644 index 00000000..628c17e9 --- /dev/null +++ b/src/utils/recoverSeriesInstanceUID.ts @@ -0,0 +1,77 @@ +/** + * Resolve a route series UID that may have been mangled by nested antd Menus + * (they append ".0" to keys). Prefer exact match, then strip trailing ".0" + * segments, then the longest strict prefix among known UIDs. + */ +export function recoverSeriesInstanceUID( + seriesInstanceUID: string, + candidateUIDs: readonly string[], +): string | undefined { + if (seriesInstanceUID === '' || candidateUIDs.length === 0) { + return undefined + } + + if (candidateUIDs.includes(seriesInstanceUID)) { + return seriesInstanceUID + } + + let stripped = seriesInstanceUID + while (/\.0$/.test(stripped)) { + stripped = stripped.slice(0, -2) + if (candidateUIDs.includes(stripped)) { + return stripped + } + } + + let longestPrefix: string | undefined + for (const uid of candidateUIDs) { + if ( + seriesInstanceUID.startsWith(`${uid}.`) && + seriesInstanceUID.length > uid.length && + (longestPrefix === undefined || uid.length > longestPrefix.length) + ) { + longestPrefix = uid + } + } + return longestPrefix +} + +export function findSlideBySeriesInstanceUID< + T extends { seriesInstanceUIDs: string[] }, +>(slides: readonly T[], seriesInstanceUID: string): T | undefined { + const exact = slides.find((slide) => + slide.seriesInstanceUIDs.includes(seriesInstanceUID), + ) + if (exact !== undefined) { + return exact + } + + const allUIDs = slides.flatMap((slide) => slide.seriesInstanceUIDs) + const recovered = recoverSeriesInstanceUID(seriesInstanceUID, allUIDs) + if (recovered === undefined) { + return undefined + } + return slides.find((slide) => slide.seriesInstanceUIDs.includes(recovered)) +} + +/** + * Pick the series UID to use for a slide, recovering mangled route params. + */ +export function seriesUidFromSlide( + slide: { seriesInstanceUIDs: string[] }, + preferredSeriesInstanceUID?: string, +): string { + if ( + preferredSeriesInstanceUID !== undefined && + preferredSeriesInstanceUID !== '' + ) { + const recovered = recoverSeriesInstanceUID( + preferredSeriesInstanceUID, + slide.seriesInstanceUIDs, + ) + if (recovered !== undefined) { + return recovered + } + } + return slide.seriesInstanceUIDs[0] +} diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 7b16e153..540d47c9 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -559,6 +559,10 @@ declare module 'dicom-microscopy-viewer' { ImageType: string[] SamplesPerPixel: number PhotometricInterpretation: string + TotalPixelMatrixColumns: number + TotalPixelMatrixRows: number + Columns: number + Rows: number // Acquisition AcquisitionUID?: string // Multi-Resolution Pyramid