From 80608a00deaa5f7588005070a0ee898e95600c49 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 15 Jul 2026 14:19:11 -0300 Subject: [PATCH 1/3] fix: size slide-list overview previews from container and matrix Compute OverviewImageViewer resizeFactor from preview tile dimensions and TotalPixelMatrix size so THUMBNAIL/OVERVIEW extent matches the rendered PNG. Restores fit after #340 removed the fixed 0.3 thumbnail heuristic (#399). --- src/components/SlideItem.tsx | 112 ++++++++++++------ ...computeOverviewPreviewResizeFactor.test.ts | 51 ++++++++ .../computeOverviewPreviewResizeFactor.ts | 47 ++++++++ types/dicom-microscopy-viewer/index.d.ts | 4 + 4 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts create mode 100644 src/utils/computeOverviewPreviewResizeFactor.ts diff --git a/src/components/SlideItem.tsx b/src/components/SlideItem.tsx index 3d4fb91f..6f81261c 100644 --- a/src/components/SlideItem.tsx +++ b/src/components/SlideItem.tsx @@ -11,6 +11,10 @@ import NotificationMiddleware, { NotificationMiddlewareContext, } from '../services/NotificationMiddleware' import type { CustomError } from '../utils/CustomError' +import { + computeOverviewPreviewResizeFactor, + SLIDE_PREVIEW_HEIGHT_PX, +} from '../utils/computeOverviewPreviewResizeFactor' import Description from './Description' import ValidationWarning from './ValidationWarning' @@ -37,6 +41,8 @@ class SlideItem extends React.Component { private overviewViewer?: dmv.viewer.OverviewImageViewer + private overviewResizeObserver?: ResizeObserver + constructor(props: SlideItemProps) { super(props) this.overviewViewer = undefined @@ -44,47 +50,85 @@ class SlideItem extends React.Component { componentDidMount(): void { this.setState({ isLoading: true }) + this.scheduleOverviewViewerMount() + this.setState({ isLoading: false }) + } + + componentWillUnmount(): void { + this.overviewResizeObserver?.disconnect() + this.overviewResizeObserver = undefined + this.overviewViewer?.cleanup() + this.overviewViewer = undefined + } + + /** + * Wait until the preview tile has non-zero layout, then mount the overview + * viewer with a resize factor that matches container pixels to matrix extent. + */ + private scheduleOverviewViewerMount(): void { + const tryMount = (): void => { + const container = this.overviewViewportRef.current + if (container == null) { + return + } + const { clientWidth, clientHeight } = container + if (clientWidth <= 0 || clientHeight <= 0) { + requestAnimationFrame(tryMount) + return + } + this.mountOverviewViewer(container) + } + requestAnimationFrame(tryMount) + } - /* Use OVERVIEW if available, otherwise fall back to THUMBNAIL */ + private mountOverviewViewer(container: HTMLDivElement): void { + /** Use OVERVIEW if available, otherwise fall back to THUMBNAIL */ const previewImages = this.props.slide.overviewImages.length > 0 ? this.props.slide.overviewImages : this.props.slide.thumbnailImages - if (previewImages.length > 0) { - const metadata = previewImages[0] - if ( - this.overviewViewportRef.current !== null && - this.overviewViewportRef.current !== undefined - ) { - this.overviewViewportRef.current.innerHTML = '' - const imageType = - this.props.slide.overviewImages.length > 0 ? 'OVERVIEW' : 'THUMBNAIL' - console.info( - `instantiate viewer for ${imageType} image of slide ` + - `"${metadata.ContainerIdentifier}"`, - ) - const resizeFactor = 1 - this.overviewViewer = new dmv.viewer.OverviewImageViewer({ - client: - this.props.clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE], - disableInteractions: true, - metadata, - resizeFactor, - errorInterceptor: (error: CustomError) => { - NotificationMiddleware.onError( - NotificationMiddlewareContext.DMV, - error, - ) - }, - }) - this.overviewViewer.render({ - container: this.overviewViewportRef.current, - }) - } + if (previewImages.length === 0) { + return } - this.setState({ isLoading: false }) + const metadata = previewImages[0] + container.innerHTML = '' + const imageType = + this.props.slide.overviewImages.length > 0 ? 'OVERVIEW' : 'THUMBNAIL' + console.info( + `instantiate viewer for ${imageType} image of slide ` + + `"${metadata.ContainerIdentifier}"`, + ) + + const resizeFactor = computeOverviewPreviewResizeFactor( + metadata, + container.clientWidth, + container.clientHeight, + ) + + this.overviewViewer?.cleanup() + this.overviewViewer = new dmv.viewer.OverviewImageViewer({ + client: + this.props.clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE], + disableInteractions: true, + metadata, + resizeFactor, + errorInterceptor: (error: CustomError) => { + NotificationMiddleware.onError(NotificationMiddlewareContext.DMV, error) + }, + }) + this.overviewViewer.render({ container }) + + requestAnimationFrame(() => { + this.overviewViewer?.resize() + }) + + this.overviewResizeObserver?.disconnect() + this.overviewResizeObserver = new ResizeObserver(() => { + this.overviewViewer?.resize() + }) + this.overviewResizeObserver.observe(container) } render(): React.ReactNode { @@ -112,7 +156,7 @@ class SlideItem extends React.Component { attributes={attributes} selectable > -
+
{this.props.slide.overviewImages.length > 0 || this.props.slide.thumbnailImages.length > 0 ? (
diff --git a/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts b/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts new file mode 100644 index 00000000..3ba402bc --- /dev/null +++ b/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts @@ -0,0 +1,51 @@ +import { + computeOverviewPreviewResizeFactor, + SLIDE_PREVIEW_FALLBACK_WIDTH_PX, + SLIDE_PREVIEW_HEIGHT_PX, +} from '../computeOverviewPreviewResizeFactor' + +describe('computeOverviewPreviewResizeFactor', () => { + it('scales down large matrices to fit the preview container', () => { + const factor = computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: 50_000, TotalPixelMatrixRows: 40_000 }, + 280, + 100, + ) + expect(factor).toBeCloseTo(100 / 40_000, 6) + }) + + 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 factor = computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: 10_000, TotalPixelMatrixRows: 8_000 }, + 0, + 0, + ) + expect(factor).toBeCloseTo( + SLIDE_PREVIEW_HEIGHT_PX / 8_000, + 6, + ) + expect(factor).toBeLessThan( + SLIDE_PREVIEW_FALLBACK_WIDTH_PX / 10_000, + ) + }) + + it('returns 1 for invalid matrix metadata', () => { + expect( + computeOverviewPreviewResizeFactor( + { TotalPixelMatrixColumns: 0, TotalPixelMatrixRows: 100 }, + 280, + 100, + ), + ).toBe(1) + }) +}) diff --git a/src/utils/computeOverviewPreviewResizeFactor.ts b/src/utils/computeOverviewPreviewResizeFactor.ts new file mode 100644 index 00000000..ab729b34 --- /dev/null +++ b/src/utils/computeOverviewPreviewResizeFactor.ts @@ -0,0 +1,47 @@ +/** 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 +} + +/** + * 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). + */ +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 = + containerWidth > 0 ? containerWidth : SLIDE_PREVIEW_FALLBACK_WIDTH_PX + const height = containerHeight > 0 ? containerHeight : SLIDE_PREVIEW_HEIGHT_PX + + const scale = Math.min(width / cols, height / rows, 1) + if (!Number.isFinite(scale) || scale <= 0) { + return 1 + } + /** Keep the DICOMweb viewport request at least one pixel per axis. */ + return Math.max(scale, 1 / cols, 1 / rows) +} diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 49f8432b..5d34ab7c 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -550,6 +550,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 From fa881bf21941731e6878ef7e9433e873ed8931c2 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 15 Jul 2026 14:26:24 -0300 Subject: [PATCH 2/3] fix: use integer DICOMweb viewport sizes for overview previews Google Healthcare rejects fractional viewport=w,h (HTTP 400). Derive resizeFactor so cols*factor and rows*factor are integers; fall back to factor 1 when no integer downscale fits the slide-list tile. --- ...computeOverviewPreviewResizeFactor.test.ts | 38 ++++++++++---- .../computeOverviewPreviewResizeFactor.ts | 49 ++++++++++++++++--- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts b/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts index 3ba402bc..0ddc8449 100644 --- a/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts +++ b/src/utils/__tests__/computeOverviewPreviewResizeFactor.test.ts @@ -5,13 +5,17 @@ import { } from '../computeOverviewPreviewResizeFactor' describe('computeOverviewPreviewResizeFactor', () => { - it('scales down large matrices to fit the preview container', () => { + it('scales down large matrices to integer viewport dimensions', () => { + const cols = 50_000 + const rows = 40_000 const factor = computeOverviewPreviewResizeFactor( - { TotalPixelMatrixColumns: 50_000, TotalPixelMatrixRows: 40_000 }, + { TotalPixelMatrixColumns: cols, TotalPixelMatrixRows: rows }, 280, 100, ) - expect(factor).toBeCloseTo(100 / 40_000, 6) + expect(factor).toBe(100 / rows) + expect(cols * factor).toBe(125) + expect(rows * factor).toBe(100) }) it('does not upscale small matrices', () => { @@ -25,18 +29,32 @@ describe('computeOverviewPreviewResizeFactor', () => { }) it('uses fallback dimensions when the container is not yet measured', () => { + const cols = 10_000 + const rows = 8_000 const factor = computeOverviewPreviewResizeFactor( - { TotalPixelMatrixColumns: 10_000, TotalPixelMatrixRows: 8_000 }, + { TotalPixelMatrixColumns: cols, TotalPixelMatrixRows: rows }, 0, 0, ) - expect(factor).toBeCloseTo( - SLIDE_PREVIEW_HEIGHT_PX / 8_000, - 6, - ) - expect(factor).toBeLessThan( - SLIDE_PREVIEW_FALLBACK_WIDTH_PX / 10_000, + 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', () => { diff --git a/src/utils/computeOverviewPreviewResizeFactor.ts b/src/utils/computeOverviewPreviewResizeFactor.ts index ab729b34..eb30fb5c 100644 --- a/src/utils/computeOverviewPreviewResizeFactor.ts +++ b/src/utils/computeOverviewPreviewResizeFactor.ts @@ -9,6 +9,17 @@ export type OverviewPreviewMatrixSize = { 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 @@ -17,6 +28,11 @@ export type OverviewPreviewMatrixSize = { * 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, @@ -34,14 +50,33 @@ export function computeOverviewPreviewResizeFactor( return 1 } - const width = - containerWidth > 0 ? containerWidth : SLIDE_PREVIEW_FALLBACK_WIDTH_PX - const height = containerHeight > 0 ? containerHeight : SLIDE_PREVIEW_HEIGHT_PX + 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 + } - const scale = Math.min(width / cols, height / rows, 1) - if (!Number.isFinite(scale) || scale <= 0) { + /** + * `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 } - /** Keep the DICOMweb viewport request at least one pixel per axis. */ - return Math.max(scale, 1 / cols, 1 / rows) + return targetH / rows } From b7a37175aa7c5d29994d2c8c77d2678ca4fd78a3 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 15 Jul 2026 15:51:45 -0300 Subject: [PATCH 3/3] Fix overview map --- src/App.dark.less | 9 + src/App.light.less | 9 + src/App.tsx | 24 +- src/components/CaseViewer.tsx | 257 ++++++++++++------ src/components/SlideItem.tsx | 25 +- src/components/SlideList.tsx | 98 +++++-- src/components/SlideViewer.tsx | 21 ++ .../__tests__/fitOverviewMapSize.test.ts | 35 +++ .../recoverSeriesInstanceUID.test.ts | 69 +++++ src/utils/clampOverviewMapInViewport.ts | 192 +++++++++++++ src/utils/fitOverviewMapSize.ts | 108 ++++++++ src/utils/recoverSeriesInstanceUID.ts | 77 ++++++ 12 files changed, 799 insertions(+), 125 deletions(-) create mode 100644 src/utils/__tests__/fitOverviewMapSize.test.ts create mode 100644 src/utils/__tests__/recoverSeriesInstanceUID.test.ts create mode 100644 src/utils/clampOverviewMapInViewport.ts create mode 100644 src/utils/fitOverviewMapSize.ts create mode 100644 src/utils/recoverSeriesInstanceUID.ts diff --git a/src/App.dark.less b/src/App.dark.less index fa4e6bf4..b315c6ca 100644 --- a/src/App.dark.less +++ b/src/App.dark.less @@ -69,6 +69,15 @@ border: 2px solid @primary-color !important; } +/** + * Mini-map: same inset from bottom as from left (px). Height clamping for tall + * overviews is done in JS ({@link clampOverviewMapInViewport}). + */ +.ol-overviewmap { + left: 8px; + bottom: 8px; +} + img { object-fit: contain; max-height: 100%; diff --git a/src/App.light.less b/src/App.light.less index 8ccddf87..70734ade 100644 --- a/src/App.light.less +++ b/src/App.light.less @@ -69,6 +69,15 @@ border: 2px solid @primary-color !important; } +/** + * Mini-map: same inset from bottom as from left (px). Height clamping for tall + * overviews is done in JS ({@link clampOverviewMapInViewport}). + */ +.ol-overviewmap { + left: 8px; + bottom: 8px; +} + img { object-fit: contain; max-height: 100%; diff --git a/src/App.tsx b/src/App.tsx index 70f0cde4..3d2d09c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -504,7 +504,21 @@ class App extends React.Component { } const layoutStyle = { height: '100vh' } - const layoutContentStyle = { height: '100%' } + /** Default fill when there is no MemoryFooter below Content. */ + const layoutContentStyle = { height: '100%' as const } + /** + * Fill space between Header and Footer. `height: 100%` made Content as tall + * as the full Layout and left a larger gap above the memory footer than the + * overview mini-map's left inset. Only used on routes that render + * MemoryFooter when monitoring is enabled. + */ + const layoutContentWithFooterStyle = enableMemoryMonitoring + ? { + flex: 1, + minHeight: 0, + overflow: 'hidden' as const, + } + : layoutContentStyle if (this.state.redirectTo !== undefined) { return ( @@ -553,7 +567,7 @@ class App extends React.Component { clients={this.state.clients} defaultClients={this.state.defaultClients} /> - + {worklist} {enableMemoryMonitoring && ( @@ -577,7 +591,7 @@ class App extends React.Component { clients={this.state.clients} defaultClients={this.state.defaultClients} /> - + { clients={this.state.clients} defaultClients={this.state.defaultClients} /> - + { clients={this.state.clients} defaultClients={this.state.defaultClients} /> - + Logged out {enableMemoryMonitoring && ( diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index c9ca3738..ba062f4a 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 ( @@ -286,32 +369,32 @@ function Viewer(props: ViewerProps): JSX.Element | null { height: '100%', borderRight: 'solid', borderRightWidth: 0.25, - overflow: 'hidden', + overflow: 'auto', background: 'none', }} > +
- - - - - - - {clinicalTrialMenu} - - - -
+ Slides +
+ diff --git a/src/components/SlideItem.tsx b/src/components/SlideItem.tsx index 6f81261c..579e9b6b 100644 --- a/src/components/SlideItem.tsx +++ b/src/components/SlideItem.tsx @@ -21,6 +21,8 @@ import ValidationWarning from './ValidationWarning' interface SlideItemProps { clients: { [key: string]: DicomWebManager } slide: Slide + /** When true, parent is a native button — omit hoverable Card styling. */ + disableCardHover?: boolean } interface SlideItemState { @@ -43,18 +45,28 @@ class SlideItem extends React.Component { private overviewResizeObserver?: ResizeObserver + private mountFrameId: number | undefined + + private isMountAborted = false + constructor(props: SlideItemProps) { super(props) this.overviewViewer = undefined } componentDidMount(): void { + this.isMountAborted = false this.setState({ isLoading: true }) this.scheduleOverviewViewerMount() this.setState({ isLoading: false }) } componentWillUnmount(): void { + this.isMountAborted = true + if (this.mountFrameId !== undefined) { + cancelAnimationFrame(this.mountFrameId) + this.mountFrameId = undefined + } this.overviewResizeObserver?.disconnect() this.overviewResizeObserver = undefined this.overviewViewer?.cleanup() @@ -67,21 +79,28 @@ class SlideItem extends React.Component { */ private scheduleOverviewViewerMount(): void { const tryMount = (): void => { + this.mountFrameId = undefined + if (this.isMountAborted) { + return + } const container = this.overviewViewportRef.current if (container == null) { return } const { clientWidth, clientHeight } = container if (clientWidth <= 0 || clientHeight <= 0) { - requestAnimationFrame(tryMount) + this.mountFrameId = requestAnimationFrame(tryMount) return } this.mountOverviewViewer(container) } - requestAnimationFrame(tryMount) + this.mountFrameId = requestAnimationFrame(tryMount) } private mountOverviewViewer(container: HTMLDivElement): void { + if (this.isMountAborted) { + return + } /** Use OVERVIEW if available, otherwise fall back to THUMBNAIL */ const previewImages = this.props.slide.overviewImages.length > 0 @@ -154,7 +173,7 @@ class SlideItem extends React.Component {
{this.props.slide.overviewImages.length > 0 || diff --git a/src/components/SlideList.tsx b/src/components/SlideList.tsx index 4c3aa482..a7113991 100644 --- a/src/components/SlideList.tsx +++ b/src/components/SlideList.tsx @@ -1,5 +1,3 @@ -import type { MenuProps } from 'antd' -import { Menu } from 'antd' import React from 'react' import type DicomWebManager from '../DicomWebManager' @@ -21,8 +19,16 @@ interface SlideListState { selectedSeriesInstanceUID: string } +function seriesUidForSlide(slide: Slide): string { + return slide.seriesInstanceUIDs[0] +} + /** * React component representing a list of DICOM Series Information Entities. + * + * Intentionally not an antd Menu: nesting Menu inside the case sider Menu is + * invalid HTML (ul>ul) and DICOM UIDs as Menu keys have caused mangled routes + * (e.g. series UID + ".0" → 404 metadata requests). */ class SlideList extends React.Component { state = { @@ -35,39 +41,71 @@ class SlideList extends React.Component { }) } - render(): React.ReactNode { - const items: MenuProps['items'] = this.props.metadata.map((slide) => { - const key = slide.seriesInstanceUIDs[0] - return { - key, - style: { height: '100%' }, - label: , - } - }) - - const handleMenuItemSelection: MenuProps['onSelect'] = ({ key }) => { - console.info(`select slide "${key}"`) - this.setState({ selectedSeriesInstanceUID: key.toString() }) - this.props.onSeriesSelection({ seriesInstanceUID: key.toString() }) - } - - let selectedKeys: string[] = [] + componentDidUpdate(prevProps: SlideListProps): void { if ( - this.state.selectedSeriesInstanceUID !== null && - this.state.selectedSeriesInstanceUID !== undefined + prevProps.selectedSeriesInstanceUID !== + this.props.selectedSeriesInstanceUID ) { - selectedKeys = [this.state.selectedSeriesInstanceUID] + this.setState({ + selectedSeriesInstanceUID: this.props.selectedSeriesInstanceUID, + }) } + } + private handleSlideClick = (seriesInstanceUID: string): void => { + console.info(`select slide "${seriesInstanceUID}"`) + this.setState({ selectedSeriesInstanceUID: seriesInstanceUID }) + this.props.onSeriesSelection({ seriesInstanceUID }) + } + + render(): React.ReactNode { return ( - +
    + {this.props.metadata.map((slide) => { + const seriesInstanceUID = seriesUidForSlide(slide) + const isSelected = + this.state.selectedSeriesInstanceUID === seriesInstanceUID || + slide.seriesInstanceUIDs.includes( + this.state.selectedSeriesInstanceUID, + ) + return ( +
  • + +
  • + ) + })} +
) } } diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index ff766773..a06830f1 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 @@ -1494,6 +1500,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 && @@ -1543,6 +1554,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 => { @@ -2309,6 +2325,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() @@ -2371,6 +2390,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) { diff --git a/src/utils/__tests__/fitOverviewMapSize.test.ts b/src/utils/__tests__/fitOverviewMapSize.test.ts new file mode 100644 index 00000000..146e582c --- /dev/null +++ b/src/utils/__tests__/fitOverviewMapSize.test.ts @@ -0,0 +1,35 @@ +import { + fitOverviewMapSize, + MIN_OVERVIEW_HEIGHT_PX, + overviewMapSizeBounds, +} from '../fitOverviewMapSize' + +describe('fitOverviewMapSize', () => { + it('raises short wide maps to the minimum height when aspect allows', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(80, 40, bounds) + expect(fitted.height).toBe(MIN_OVERVIEW_HEIGHT_PX) + expect(fitted.width).toBeCloseTo(MIN_OVERVIEW_HEIGHT_PX * 2) + }) + + it('spills toward full width to approach the minimum height', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(400, 40, bounds) + expect(fitted.height).toBeGreaterThanOrEqual(MIN_OVERVIEW_HEIGHT_PX) + expect(fitted.width / fitted.height).toBeCloseTo(10) + }) + + it('shrinks tall maps to the max height', () => { + const bounds = overviewMapSizeBounds(400, 200) + const fitted = fitOverviewMapSize(100, 500, bounds) + expect(fitted.height).toBeLessThanOrEqual(bounds.maxMapHeight + 0.01) + expect(fitted.width / fitted.height).toBeCloseTo(100 / 500) + }) + + it('keeps aspect ratio when clamping to max width', () => { + const bounds = overviewMapSizeBounds(300, 800) + const fitted = fitOverviewMapSize(2000, 100, bounds) + expect(fitted.width).toBeLessThanOrEqual(bounds.maxMapWidth + 0.01) + expect(fitted.width / fitted.height).toBeCloseTo(20) + }) +}) 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..a836eba3 --- /dev/null +++ b/src/utils/clampOverviewMapInViewport.ts @@ -0,0 +1,192 @@ +import type OlMap from 'ol/Map' + +import { + fitOverviewMapSize, + OVERVIEW_EDGE_INSET_PX, + overviewMapSizeBounds, +} from './fitOverviewMapSize' + +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. + */ +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() + const projection = view?.getProjection() + const extent = projection?.getExtent() + const size = overviewOlMap.getSize() + if (extent != null && size != null) { + view.fit(extent, { size }) + } + return + } +} + +export type ClampOverviewMapOptions = { + /** + * VolumeImageViewer instance. When provided, calls overview `updateSize()` + * and `view.fit` after CSS size changes (DOM `resize` events do not do this). + */ + volumeViewer?: object +} + +/** + * Fit overview map size into the viewport: grow short (wide) maps, shrink tall + * ones, keep left/bottom insets equal. + * + * Runtime note: Slim applies this because craco loads the published DMV min + * bundle; local `dicom-microscopy-viewer/src/viewer.js` sizing changes do not + * ship until that package is published and bumped. + */ +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.margin = '0' + mapEl.style.margin = '0' + + 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` + if (options.volumeViewer != null) { + syncOverviewOpenLayersMap(options.volumeViewer) + } + } + + /** + * Match bottom gap to left gap using the visible map border vs the volume + * container. + */ + const containerRect = container.getBoundingClientRect() + const mapRect = mapEl.getBoundingClientRect() + const leftGap = mapRect.left - containerRect.left + const bottomGap = containerRect.bottom - mapRect.bottom + if (leftGap >= 0 && bottomGap - leftGap > 0.5) { + overview.style.bottom = `${Math.max(0, OVERVIEW_EDGE_INSET_PX - (bottomGap - leftGap))}px` + } else if (leftGap >= 0) { + overview.style.bottom = `${leftGap}px` + } +} + +/** + * Re-run {@link clampOverviewMapInViewport} when DMV rebuilds or resizes the + * overview control (it sets inline width/height asynchronously). + */ +export function observeOverviewMapClamp( + container: HTMLElement, + options: ClampOverviewMapOptions = {}, +): () => void { + let scheduled = false + let isClamping = false + + const clamp = (): void => { + if (scheduled || isClamping) { + return + } + scheduled = true + requestAnimationFrame(() => { + scheduled = false + isClamping = true + try { + clampOverviewMapInViewport(container, options) + } finally { + isClamping = false + } + }) + } + + const mutationObserver = new MutationObserver(() => { + if (isClamping) { + return + } + clamp() + }) + mutationObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'], + }) + + const resizeObserver = new ResizeObserver(clamp) + resizeObserver.observe(container) + + clamp() + + return () => { + mutationObserver.disconnect() + resizeObserver.disconnect() + } +} diff --git a/src/utils/fitOverviewMapSize.ts b/src/utils/fitOverviewMapSize.ts new file mode 100644 index 00000000..e0493572 --- /dev/null +++ b/src/utils/fitOverviewMapSize.ts @@ -0,0 +1,108 @@ +/** + * Shared overview mini-map sizing (kept in sync with DMV's + * `_updateOverviewMapSize` intent). 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. + */ + +/** 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 mini-map height so wide/thin slides stay usable (width-first + * sizing otherwise collapses height with the slide aspect ratio). + */ +export const MIN_OVERVIEW_HEIGHT_PX = 80 + +/** + * Prefer this fraction of the viewport width before spilling to nearly-full + * width to satisfy {@link MIN_OVERVIEW_HEIGHT_PX}. + */ +export const PREFERRED_OVERVIEW_WIDTH_FRACTION = 0.45 + +export type OverviewMapSizeBounds = { + maxMapWidth: number + maxMapHeight: number + preferredMaxWidth: number + minMapHeight: number +} + +export type OverviewMapSize = { + width: number + height: number +} + +export function overviewMapSizeBounds( + containerWidth: number, + containerHeight: number, + chromeX = 0, + chromeY = 0, +): OverviewMapSizeBounds { + const maxMapHeight = Math.max( + 0, + containerHeight - + OVERVIEW_EDGE_INSET_PX - + OVERVIEW_TOP_HEADROOM_PX - + chromeY, + ) + const maxMapWidth = Math.max( + 0, + containerWidth - 2 * OVERVIEW_EDGE_INSET_PX - chromeX, + ) + const preferredMaxWidth = Math.min( + maxMapWidth, + containerWidth * PREFERRED_OVERVIEW_WIDTH_FRACTION, + ) + const minMapHeight = Math.min(MIN_OVERVIEW_HEIGHT_PX, maxMapHeight) + return { maxMapWidth, maxMapHeight, preferredMaxWidth, minMapHeight } +} + +/** + * Fit overview map size into the viewport: grow short (wide) maps, shrink tall + * ones, keep aspect ratio. + */ +export function fitOverviewMapSize( + width: number, + height: number, + bounds: OverviewMapSizeBounds, +): OverviewMapSize { + if (!(width > 0) || !(height > 0)) { + return { width, height } + } + + const aspect = width / height + let nextWidth = width + let nextHeight = height + const { maxMapWidth, maxMapHeight, preferredMaxWidth, minMapHeight } = bounds + + /** Wide/thin slides: raise height to the floor and let width grow. */ + if (nextHeight < minMapHeight - 0.5) { + nextHeight = minMapHeight + nextWidth = nextHeight * aspect + } + + /** Prefer staying within preferred width; if still too short, use full width. */ + if (nextWidth > preferredMaxWidth + 0.5) { + nextWidth = preferredMaxWidth + nextHeight = nextWidth / aspect + if (nextHeight < minMapHeight - 0.5 && maxMapWidth > preferredMaxWidth) { + nextWidth = maxMapWidth + nextHeight = nextWidth / aspect + } + } + + if (nextHeight > maxMapHeight + 0.5) { + nextHeight = maxMapHeight + nextWidth = nextHeight * aspect + } + + if (nextWidth > maxMapWidth + 0.5) { + nextWidth = maxMapWidth + nextHeight = nextWidth / aspect + } + + 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] +}