From 80608a00deaa5f7588005070a0ee898e95600c49 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 15 Jul 2026 14:19:11 -0300 Subject: [PATCH 1/7] 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/7] 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/7] 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] +} From 2681183601e1c53ac1352a3d2787af46efd5bd61 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Thu, 6 Aug 2026 19:36:22 -0300 Subject: [PATCH 4/7] fix: size overview mini-map symmetrically for wide and tall slides Prefer a shared viewport fraction on both axes and cap growth at 60% so wide slides no longer spill to full width while tall slides get matching height treatment. --- .../__tests__/fitOverviewMapSize.test.ts | 70 +++++++--- src/utils/clampOverviewMapInViewport.ts | 4 +- src/utils/fitOverviewMapSize.ts | 125 ++++++++++++------ 3 files changed, 140 insertions(+), 59 deletions(-) diff --git a/src/utils/__tests__/fitOverviewMapSize.test.ts b/src/utils/__tests__/fitOverviewMapSize.test.ts index 146e582c..2df0aa22 100644 --- a/src/utils/__tests__/fitOverviewMapSize.test.ts +++ b/src/utils/__tests__/fitOverviewMapSize.test.ts @@ -1,35 +1,71 @@ import { fitOverviewMapSize, - MIN_OVERVIEW_HEIGHT_PX, + MAX_OVERVIEW_FRACTION, + MIN_OVERVIEW_SIDE_PX, overviewMapSizeBounds, + PREFERRED_OVERVIEW_FRACTION, } from '../fitOverviewMapSize' describe('fitOverviewMapSize', () => { - it('raises short wide maps to the minimum height when aspect allows', () => { + it('fits a normal aspect ratio inside the preferred box', () => { 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) + 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) }) - it('spills toward full width to approach the minimum height', () => { + it('grows wide maps that undershoot the preferred box up to the min side', () => { 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) + /** Aspect 6: preferred height is 75px; scale up to 80px stays under max width. */ + const fitted = fitOverviewMapSize(600, 100, bounds) + expect(fitted.height).toBeCloseTo(MIN_OVERVIEW_SIDE_PX) + expect(fitted.width).toBeCloseTo(MIN_OVERVIEW_SIDE_PX * 6) + expect(fitted.width).toBeLessThan(bounds.maxMapWidth) }) - it('shrinks tall maps to the max height', () => { - const bounds = overviewMapSizeBounds(400, 200) + it('caps extremely wide maps at the max width fraction (not full viewport)', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(4000, 40, bounds) + expect(fitted.width).toBeLessThanOrEqual(bounds.maxMapWidth + 0.01) + expect(fitted.width).toBeCloseTo(1000 * MAX_OVERVIEW_FRACTION) + expect(fitted.width / fitted.height).toBeCloseTo(100) + expect(fitted.width).toBeLessThan(1000 - 16) + }) + + it('grows tall maps that undershoot the preferred box up to the min side', () => { + const bounds = overviewMapSizeBounds(1000, 800) + /** Aspect 1/5: preferred width is 72px; scale up to 80px stays under max height. */ const fitted = fitOverviewMapSize(100, 500, bounds) + expect(fitted.width).toBeCloseTo(MIN_OVERVIEW_SIDE_PX) + expect(fitted.height).toBeCloseTo(MIN_OVERVIEW_SIDE_PX * 5) + expect(fitted.height).toBeLessThan(bounds.maxMapHeight) + }) + + it('caps extremely tall maps at the max height fraction (not full viewport)', () => { + const bounds = overviewMapSizeBounds(1000, 800) + const fitted = fitOverviewMapSize(40, 4000, bounds) expect(fitted.height).toBeLessThanOrEqual(bounds.maxMapHeight + 0.01) - expect(fitted.width / fitted.height).toBeCloseTo(100 / 500) + expect(fitted.height).toBeCloseTo(800 * MAX_OVERVIEW_FRACTION) + expect(fitted.width / fitted.height).toBeCloseTo(40 / 4000) + expect(fitted.height).toBeLessThan(800 - 20) }) - 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) + it('treats wide and tall extremes with matching max fractions', () => { + const bounds = overviewMapSizeBounds(1000, 1000) + const wide = fitOverviewMapSize(5000, 50, bounds) + const tall = fitOverviewMapSize(50, 5000, bounds) + expect(wide.width / 1000).toBeCloseTo(MAX_OVERVIEW_FRACTION) + expect(tall.height / 1000).toBeCloseTo(MAX_OVERVIEW_FRACTION) + expect(wide.width).toBeCloseTo(tall.height) + expect(wide.height).toBeCloseTo(tall.width) + }) + + it('exposes preferred bounds below the max cap', () => { + const bounds = overviewMapSizeBounds(1000, 800) + expect(bounds.preferredMaxWidth).toBeCloseTo(1000 * PREFERRED_OVERVIEW_FRACTION) + expect(bounds.preferredMaxHeight).toBeCloseTo(800 * PREFERRED_OVERVIEW_FRACTION) + expect(bounds.maxMapWidth).toBeCloseTo(1000 * MAX_OVERVIEW_FRACTION) + expect(bounds.maxMapHeight).toBeCloseTo(800 * MAX_OVERVIEW_FRACTION) }) }) diff --git a/src/utils/clampOverviewMapInViewport.ts b/src/utils/clampOverviewMapInViewport.ts index a836eba3..af85d41c 100644 --- a/src/utils/clampOverviewMapInViewport.ts +++ b/src/utils/clampOverviewMapInViewport.ts @@ -73,8 +73,8 @@ export type ClampOverviewMapOptions = { } /** - * Fit overview map size into the viewport: grow short (wide) maps, shrink tall - * ones, keep left/bottom insets equal. + * Fit overview map size into the viewport symmetrically for wide and tall + * slides, 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 diff --git a/src/utils/fitOverviewMapSize.ts b/src/utils/fitOverviewMapSize.ts index e0493572..63c9d52b 100644 --- a/src/utils/fitOverviewMapSize.ts +++ b/src/utils/fitOverviewMapSize.ts @@ -12,21 +12,35 @@ export const OVERVIEW_EDGE_INSET_PX = 8 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). + * Floor for each mini-map side so thin slides stay usable after preferred-box + * sizing. Growth to meet this still respects {@link MAX_OVERVIEW_FRACTION}. */ -export const MIN_OVERVIEW_HEIGHT_PX = 80 +export const MIN_OVERVIEW_SIDE_PX = 80 + +/** @deprecated Use {@link MIN_OVERVIEW_SIDE_PX}. */ +export const MIN_OVERVIEW_HEIGHT_PX = MIN_OVERVIEW_SIDE_PX + +/** + * Prefer fitting inside this fraction of the viewport (both axes) before + * growing toward the max box to meet {@link MIN_OVERVIEW_SIDE_PX}. + */ +export const PREFERRED_OVERVIEW_FRACTION = 0.45 + +/** @deprecated Use {@link PREFERRED_OVERVIEW_FRACTION}. */ +export const PREFERRED_OVERVIEW_WIDTH_FRACTION = PREFERRED_OVERVIEW_FRACTION /** - * Prefer this fraction of the viewport width before spilling to nearly-full - * width to satisfy {@link MIN_OVERVIEW_HEIGHT_PX}. + * Hard cap so the mini-map cannot approach the size of the main image (wide + * slides used to spill to nearly full viewport width). */ -export const PREFERRED_OVERVIEW_WIDTH_FRACTION = 0.45 +export const MAX_OVERVIEW_FRACTION = 0.6 export type OverviewMapSizeBounds = { maxMapWidth: number maxMapHeight: number preferredMaxWidth: number + preferredMaxHeight: number + minMapWidth: number minMapHeight: number } @@ -41,28 +55,49 @@ export function overviewMapSizeBounds( chromeX = 0, chromeY = 0, ): OverviewMapSizeBounds { - const maxMapHeight = Math.max( + 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, ) - const maxMapWidth = Math.max( - 0, - containerWidth - 2 * OVERVIEW_EDGE_INSET_PX - chromeX, + const maxMapWidth = Math.min( + insetMaxWidth, + containerWidth * MAX_OVERVIEW_FRACTION, + ) + const maxMapHeight = Math.min( + insetMaxHeight, + containerHeight * MAX_OVERVIEW_FRACTION, ) const preferredMaxWidth = Math.min( maxMapWidth, - containerWidth * PREFERRED_OVERVIEW_WIDTH_FRACTION, + containerWidth * PREFERRED_OVERVIEW_FRACTION, ) - const minMapHeight = Math.min(MIN_OVERVIEW_HEIGHT_PX, maxMapHeight) - return { maxMapWidth, maxMapHeight, preferredMaxWidth, minMapHeight } + const preferredMaxHeight = Math.min( + maxMapHeight, + containerHeight * PREFERRED_OVERVIEW_FRACTION, + ) + 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 the viewport: grow short (wide) maps, shrink tall - * ones, keep aspect ratio. + * Fit overview map size into the viewport symmetrically for wide and tall + * slides: 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, @@ -74,35 +109,45 @@ export function fitOverviewMapSize( } 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 - } + const { + maxMapWidth, + maxMapHeight, + preferredMaxWidth, + preferredMaxHeight, + minMapWidth, + minMapHeight, + } = bounds - /** 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 ( + !(preferredMaxWidth > 0) || + !(preferredMaxHeight > 0) || + !(maxMapWidth > 0) || + !(maxMapHeight > 0) + ) { + return { width: 0, height: 0 } } - if (nextHeight > maxMapHeight + 0.5) { - nextHeight = maxMapHeight - nextWidth = nextHeight * aspect - } + /** Contain in preferred box. */ + let nextHeight = Math.min(preferredMaxHeight, preferredMaxWidth / aspect) + let nextWidth = nextHeight * aspect - if (nextWidth > maxMapWidth + 0.5) { - nextWidth = maxMapWidth - nextHeight = nextWidth / 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 } } From c430fe9fba0cc14d8c2732b863b8b492fc1030f2 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 10 Aug 2026 16:01:29 -0300 Subject: [PATCH 5/7] fix: keep full-slide overview after clamp and zoom Retarget DMV's locked overview resolution in place after Slim resizes the mini-map, re-pinning the slide center so OverviewMap resetExtent cannot crop the view when zooming. --- src/utils/clampOverviewMapInViewport.ts | 70 +++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/utils/clampOverviewMapInViewport.ts b/src/utils/clampOverviewMapInViewport.ts index af85d41c..ce768afb 100644 --- a/src/utils/clampOverviewMapInViewport.ts +++ b/src/utils/clampOverviewMapInViewport.ts @@ -1,4 +1,6 @@ +import { getCenter, getHeight, getWidth } from 'ol/extent' import type OlMap from 'ol/Map' +import type View from 'ol/View' import { fitOverviewMapSize, @@ -6,6 +8,14 @@ import { 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 => @@ -37,6 +47,15 @@ function horizontalChromePx(mapEl: HTMLElement): number { /** * 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)) { @@ -53,21 +72,56 @@ function syncOverviewOpenLayersMap(volumeViewer: object): void { value as { getOverviewMap: () => OlMap } ).getOverviewMap() overviewOlMap.updateSize() - const view = overviewOlMap.getView() + const view = overviewOlMap.getView() as OverviewViewInternals | undefined const projection = view?.getProjection() const extent = projection?.getExtent() const size = overviewOlMap.getSize() - if (extent != null && size != null) { - view.fit(extent, { size }) + 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 } } export type ClampOverviewMapOptions = { /** - * VolumeImageViewer instance. When provided, calls overview `updateSize()` - * and `view.fit` after CSS size changes (DOM `resize` events do not do this). + * VolumeImageViewer instance. When provided, retargets the overview view's + * locked resolution after CSS size changes (DOM `resize` events do not). */ volumeViewer?: object } @@ -120,6 +174,12 @@ export function clampOverviewMapInViewport( 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) } From d2c6f5f93e3a8b5e70536e237405a6531ed6cf9a Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 10 Aug 2026 16:48:56 -0300 Subject: [PATCH 6/7] fix: equalize overview overlay insets and keep map above memory footer Put MemoryFooter in an AppShell column below the viewer, size the mini-map with an OL-style fixed box, and keep overlay left/bottom insets aligned without the collapse control adding layout chrome. --- .gitignore | 2 + src/App.dark.less | 25 ++- src/App.light.less | 25 ++- src/App.tsx | 194 +++++++++--------- src/components/AppShell.tsx | 40 ++++ src/components/CaseViewer.tsx | 2 +- src/components/MemoryFooter.tsx | 31 ++- src/components/SlideViewer.tsx | 2 +- .../SlideViewer/SlideViewerContent.tsx | 22 +- .../__tests__/fitOverviewMapSize.test.ts | 74 ++++--- src/utils/clampOverviewMapInViewport.ts | 71 +++++-- src/utils/fitOverviewMapSize.ts | 66 ++++-- 12 files changed, 378 insertions(+), 176 deletions(-) create mode 100644 src/components/AppShell.tsx 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 b315c6ca..bcb486e1 100644 --- a/src/App.dark.less +++ b/src/App.dark.less @@ -70,12 +70,33 @@ } /** - * Mini-map: same inset from bottom as from left (px). Height clamping for tall - * overviews is done in JS ({@link clampOverviewMapInViewport}). + * 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 { diff --git a/src/App.light.less b/src/App.light.less index 70734ade..3ae29378 100644 --- a/src/App.light.less +++ b/src/App.light.less @@ -70,12 +70,33 @@ } /** - * Mini-map: same inset from bottom as from left (px). Height clamping for tall - * overviews is done in JS ({@link clampOverviewMapInViewport}). + * 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 { diff --git a/src/App.tsx b/src/App.tsx index 3d2d09c1..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,22 +503,20 @@ class App extends React.Component { isLogoutPossible = false } - const layoutStyle = { height: '100vh' } - /** 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. + * Fill AppShell's main pane. flex + minHeight:0 keeps ant-layout from + * sizing to content and spilling into the in-flow MemoryFooter. */ - const layoutContentWithFooterStyle = enableMemoryMonitoring - ? { - flex: 1, - minHeight: 0, - overflow: 'hidden' as const, - } - : layoutContentStyle + 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 ( @@ -529,20 +527,22 @@ class App extends React.Component { } else if (this.state.isLoading) { return ( - -
- - - - + + +
+ + + + + ) } else if (!this.state.wasAuthSuccessful) { @@ -556,53 +556,51 @@ class App extends React.Component { -
- - {worklist} - - {enableMemoryMonitoring && ( - - )} - - } - /> - +
- - + + {worklist} - {enableMemoryMonitoring && ( - - )} + + } + /> + + + +
+ + + + + } /> @@ -610,53 +608,51 @@ class App extends React.Component { path="/projects/:project/locations/:location/datasets/:dataset/dicomStores/:dicomStore/study/:studyInstanceUID/*" element={ + + +
+ + + + + + + } + /> +
- - + + 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 ba062f4a..8fbc6985 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -362,7 +362,7 @@ function Viewer(props: ViewerProps): JSX.Element | null { ] return ( - + , + prevState: Readonly, + ): void { + const wasHidden = !MemoryFooter.isVisible(prevState.memoryInfo) + const isVisible = MemoryFooter.isVisible(this.state.memoryInfo) + if ((wasHidden && isVisible) || (!wasHidden && !isVisible)) { + window.dispatchEvent(new Event('resize')) + } + } + componentWillUnmount(): void { if ( this.unsubscribeMemory !== null && @@ -89,6 +101,10 @@ class MemoryFooter extends React.Component< } } + private static isVisible(memoryInfo: MemoryInfo | null): boolean { + return memoryInfo !== null && memoryInfo.apiMethod !== 'unavailable' + } + render(): React.ReactNode { if (this.props.enabled !== true) { return null @@ -96,7 +112,7 @@ class MemoryFooter extends React.Component< const { memoryInfo } = this.state - if (memoryInfo === null || memoryInfo.apiMethod === 'unavailable') { + if (!MemoryFooter.isVisible(memoryInfo) || memoryInfo === null) { return null } @@ -112,8 +128,13 @@ class MemoryFooter extends React.Component< } return ( - )} - +
) } } diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index f1b50e8d..f7cf431e 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -4920,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('fits a normal aspect ratio inside the preferred box', () => { + 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 that undershoot the preferred box up to the min side', () => { + it('grows wide maps toward the min side then respects the max box', () => { const bounds = overviewMapSizeBounds(1000, 800) - /** Aspect 6: preferred height is 75px; scale up to 80px stays under max width. */ + /** Aspect 6: min-side growth wants 48×288, then max width 200 scales it down. */ const fitted = fitOverviewMapSize(600, 100, bounds) - expect(fitted.height).toBeCloseTo(MIN_OVERVIEW_SIDE_PX) - expect(fitted.width).toBeCloseTo(MIN_OVERVIEW_SIDE_PX * 6) - expect(fitted.width).toBeLessThan(bounds.maxMapWidth) + 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 max width fraction (not full viewport)', () => { + 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(1000 * MAX_OVERVIEW_FRACTION) + expect(fitted.width).toBeCloseTo(MAX_OVERVIEW_BOX_PX) expect(fitted.width / fitted.height).toBeCloseTo(100) - expect(fitted.width).toBeLessThan(1000 - 16) + expect(fitted.width).toBeLessThanOrEqual(1000 * MAX_OVERVIEW_FRACTION) }) - it('grows tall maps that undershoot the preferred box up to the min side', () => { + it('grows tall maps toward the min side then respects the max box', () => { const bounds = overviewMapSizeBounds(1000, 800) - /** Aspect 1/5: preferred width is 72px; scale up to 80px stays under max height. */ + /** Aspect 1/5: min-side growth wants 48×240, then max height 200 scales it down. */ const fitted = fitOverviewMapSize(100, 500, bounds) - expect(fitted.width).toBeCloseTo(MIN_OVERVIEW_SIDE_PX) - expect(fitted.height).toBeCloseTo(MIN_OVERVIEW_SIDE_PX * 5) - expect(fitted.height).toBeLessThan(bounds.maxMapHeight) + 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 max height fraction (not full viewport)', () => { + 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(800 * MAX_OVERVIEW_FRACTION) + expect(fitted.height).toBeCloseTo(MAX_OVERVIEW_BOX_PX) expect(fitted.width / fitted.height).toBeCloseTo(40 / 4000) - expect(fitted.height).toBeLessThan(800 - 20) }) - it('treats wide and tall extremes with matching max fractions', () => { + 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 / 1000).toBeCloseTo(MAX_OVERVIEW_FRACTION) - expect(tall.height / 1000).toBeCloseTo(MAX_OVERVIEW_FRACTION) + 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 below the max cap', () => { + it('exposes preferred bounds at the OpenLayers default 150px box', () => { const bounds = overviewMapSizeBounds(1000, 800) - expect(bounds.preferredMaxWidth).toBeCloseTo(1000 * PREFERRED_OVERVIEW_FRACTION) - expect(bounds.preferredMaxHeight).toBeCloseTo(800 * PREFERRED_OVERVIEW_FRACTION) - expect(bounds.maxMapWidth).toBeCloseTo(1000 * MAX_OVERVIEW_FRACTION) - expect(bounds.maxMapHeight).toBeCloseTo(800 * MAX_OVERVIEW_FRACTION) + 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/clampOverviewMapInViewport.ts b/src/utils/clampOverviewMapInViewport.ts index ce768afb..2e897a93 100644 --- a/src/utils/clampOverviewMapInViewport.ts +++ b/src/utils/clampOverviewMapInViewport.ts @@ -118,6 +118,23 @@ function syncOverviewOpenLayersMap(volumeViewer: object): void { } } +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 @@ -127,12 +144,11 @@ export type ClampOverviewMapOptions = { } /** - * Fit overview map size into the viewport symmetrically for wide and tall - * slides, keep left/bottom insets equal. + * Fit overview map size into the viewport; 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. + * 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, @@ -156,8 +172,20 @@ export function clampOverviewMapInViewport( 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 @@ -184,25 +212,12 @@ export function clampOverviewMapInViewport( 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). + * overview control. Volume `resize()` runs only from ResizeObserver so mutation + * clamping cannot feedback through OL style updates. */ export function observeOverviewMapClamp( container: HTMLElement, @@ -210,6 +225,7 @@ export function observeOverviewMapClamp( ): () => void { let scheduled = false let isClamping = false + let resizeScheduled = false const clamp = (): void => { if (scheduled || isClamping) { @@ -227,6 +243,19 @@ export function observeOverviewMapClamp( }) } + 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 @@ -240,7 +269,7 @@ export function observeOverviewMapClamp( attributeFilter: ['style', 'class'], }) - const resizeObserver = new ResizeObserver(clamp) + const resizeObserver = new ResizeObserver(onContainerResize) resizeObserver.observe(container) clamp() diff --git a/src/utils/fitOverviewMapSize.ts b/src/utils/fitOverviewMapSize.ts index 63c9d52b..74cddc1b 100644 --- a/src/utils/fitOverviewMapSize.ts +++ b/src/utils/fitOverviewMapSize.ts @@ -1,8 +1,19 @@ /** * 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; + * `_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). */ @@ -12,28 +23,47 @@ export const OVERVIEW_EDGE_INSET_PX = 8 export const OVERVIEW_TOP_HEADROOM_PX = 12 /** - * Floor for each mini-map side so thin slides stay usable after preferred-box - * sizing. Growth to meet this still respects {@link MAX_OVERVIEW_FRACTION}. + * 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 = 80 +export const MIN_OVERVIEW_SIDE_PX = 48 /** @deprecated Use {@link MIN_OVERVIEW_SIDE_PX}. */ export const MIN_OVERVIEW_HEIGHT_PX = MIN_OVERVIEW_SIDE_PX /** - * Prefer fitting inside this fraction of the viewport (both axes) before - * growing toward the max box to meet {@link 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_FRACTION = 0.45 +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 /** - * Hard cap so the mini-map cannot approach the size of the main image (wide - * slides used to spill to nearly full viewport width). + * @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.6 +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 @@ -66,21 +96,29 @@ export function overviewMapSizeBounds( 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) @@ -95,9 +133,9 @@ export function overviewMapSizeBounds( } /** - * Fit overview map size into the viewport symmetrically for wide and tall - * slides: 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. + * 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, From ef2edc48e3b5ee2a5eeff5660bc1790ec826ea20 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 10 Aug 2026 17:49:56 -0300 Subject: [PATCH 7/7] fix: keep debug count badges on the bug button corner Mirror antd Badge layout (line-height: 1 + translate(50%, -50%)) so AppShell overflow no longer crops pills pinned by the header line-height. --- src/components/Header.tsx | 118 ++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 68 deletions(-) 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 = ( - -