pathology/viewer/src/components/image-viewer-page/image-viewer-page.component.ts (277 lines of code) (raw):

/** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {CommonModule} from '@angular/common'; import {Component, OnInit} from '@angular/core'; import {MatDividerModule} from '@angular/material/divider'; import {MatIconModule} from '@angular/material/icon'; import {ActivatedRoute, Router} from '@angular/router'; import ol from 'ol'; import {EventsKey} from 'ol/events'; import {unByKey} from 'ol/Observable'; import {combineLatest, ReplaySubject} from 'rxjs'; import {distinctUntilChanged, takeUntil, tap} from 'rxjs/operators'; import {ImageViewerPageParams} from '../../app/app.routes'; import {SlideDescriptor, SlideInfo} from '../../interfaces/slide_descriptor'; import {GetSlideInfoPipe} from '../../pipes/get-slide-info.pipe'; import {ImageViewerPageStore} from '../../stores/image-viewer-page.store'; import {ImageViewerQuickViewComponent} from '../image-viewer-quick-view/image-viewer-quick-view.component'; import {ImageViewerSideNavComponent} from '../image-viewer-side-nav/image-viewer-side-nav.component'; import {ImageViewerSlidesComponent} from '../image-viewer-slides/image-viewer-slides.component'; import {OlTileViewerComponent} from '../ol-tile-viewer/ol-tile-viewer.component'; /** * Image viewer component */ @Component({ selector: 'image-viewer-page', standalone: true, imports: [ CommonModule, OlTileViewerComponent, GetSlideInfoPipe, MatIconModule, ImageViewerSideNavComponent, ImageViewerQuickViewComponent, MatDividerModule, ImageViewerSlidesComponent ], templateUrl: './image-viewer-page.component.html', styleUrl: './image-viewer-page.component.scss' }) export class ImageViewerPageComponent implements OnInit { seriesId = ''; selectedSlideDescriptor?: SlideDescriptor; selectedSlideInfo?: SlideInfo; selectedOlMap: ol.Map|undefined = undefined; slideDescriptors: SlideDescriptor[] = []; splitViewSlideDescriptors: Array<SlideDescriptor|undefined> = []; splitViewFirstOlMap: ol.Map|undefined = undefined; slideInfoBySlideDescriptorId = new Map<string, SlideInfo>(); isMenuOpen = false; syncLock = false; syncLockListeners: EventsKey[] = []; activatedRouteParams: ImageViewerPageParams = {}; isMultiViewScreenPicker = false; private mapAnimationSyncLock = false; multiViewScreenSelectedIndex = 0; private readonly destroy$ = new ReplaySubject(); constructor( private readonly activatedRoute: ActivatedRoute, private readonly router: Router, readonly imageViewerPageStore: ImageViewerPageStore, ) { this.activatedRoute.queryParams .pipe( distinctUntilChanged(), tap((params: ImageViewerPageParams) => { this.activatedRouteParams = params; }), ) .subscribe(); } ngOnDestroy() { this.destroy$.next(''); this.destroy$.complete(); } ngOnInit() { this.imageViewerPageStore.multiViewScreens$ .pipe(takeUntil(this.destroy$), tap((multiViewScreens) => { this.multiViewScreens = multiViewScreens; })) .subscribe(); this.imageViewerPageStore.isMultiViewSlidePicker$ .pipe( takeUntil(this.destroy$), tap((isMultiViewSlidePicker) => { this.isMultiViewScreenPicker = isMultiViewSlidePicker; }), ) .subscribe(); this.imageViewerPageStore.syncLock$ .pipe(takeUntil(this.destroy$), tap((syncLock) => { this.syncLock = syncLock; })) .subscribe(); this.imageViewerPageStore.splitViewSlideDescriptors$ .pipe( takeUntil(this.destroy$), tap((splitViewSlideDescriptors) => { this.splitViewSlideDescriptors = splitViewSlideDescriptors; }), ) .subscribe(); this.imageViewerPageStore.slideInfoBySlideDescriptorId$ .pipe( takeUntil(this.destroy$), tap((slideInfoBySlideDescriptorId) => { this.slideInfoBySlideDescriptorId = new Map(slideInfoBySlideDescriptorId); }), ) .subscribe(); this.imageViewerPageStore.multiViewScreenSelectedIndex$ .pipe( takeUntil(this.destroy$), tap((multiViewScreenSelectedIndex) => { this.multiViewScreenSelectedIndex = multiViewScreenSelectedIndex; }), ) .subscribe(); combineLatest([ this.imageViewerPageStore.selectedSplitViewSlideDescriptor$, this.imageViewerPageStore.olMapBySlideDescriptorId$, ]) .subscribe( ([selectedSplitViewSlideDescriptor, olMapBySlideDescriptorId]) => { if (!selectedSplitViewSlideDescriptor || !olMapBySlideDescriptorId || !olMapBySlideDescriptorId.has( selectedSplitViewSlideDescriptor.id as string)) { return; } this.selectedOlMap = olMapBySlideDescriptorId.get( selectedSplitViewSlideDescriptor.id as string); }); } olMapLoaded(olMap: ol.Map, slideDescriptor: SlideDescriptor) { if (!olMap) return; const olMapBySlideDescriptor = this.imageViewerPageStore.olMapBySlideDescriptorId$.value; const olMapOriginalViewBySlideDescriptorId = this.imageViewerPageStore.olMapOriginalViewBySlideDescriptorId$.value; olMapBySlideDescriptor.set(slideDescriptor.id as string, olMap); olMapOriginalViewBySlideDescriptorId.set( slideDescriptor.id as string, olMap.getView()); this.imageViewerPageStore.olMapBySlideDescriptorId$.next( olMapBySlideDescriptor); this.imageViewerPageStore.olMapOriginalViewBySlideDescriptorId$.next( olMapOriginalViewBySlideDescriptorId); this.splitViewFirstOlMap = olMap; } setupViewDiffInOlMaps(olMap1: ol.Map, olMap2: ol.Map) { const center1 = olMap1.getView().getCenter() ?? [0, 0]; const center2 = olMap2.getView().getCenter() ?? [0, 0]; const zoom1 = olMap1.getView().getZoom() ?? 0; const zoom2 = olMap2.getView().getZoom() ?? 0; const rotation1 = olMap1.getView().getRotation(); const rotation2 = olMap2.getView().getRotation(); const dz = zoom2 - zoom1; const dx = center2[0] - center1[0]; const dy = center2[1] - center1[1]; const dr = rotation2 - rotation1; return {dx, dy, dz, dr}; } selectSlideDescriptor(slideDescriptor: SlideDescriptor) { if (this.imageViewerPageStore.selectedSplitViewSlideDescriptor$.value ?.id === slideDescriptor.id) { return; } this.imageViewerPageStore.selectedSplitViewSlideDescriptor$.next( slideDescriptor); } trackBySlideDescriptor( index: number, slideDescriptor: SlideDescriptor|undefined) { return `${index}-${slideDescriptor?.id}`; } toggleSyncLock() { const syncLock = !this.syncLock; this.imageViewerPageStore.syncLock$.next(syncLock); this.mapAnimationSyncLock = syncLock; if (this.mapAnimationSyncLock) { this.handleSyncLock(); } if (!this.mapAnimationSyncLock) { this.syncLockListeners.forEach((listenr) => { unByKey(listenr); }); this.syncLockListeners = []; } } private handleSyncLock() { const splitViewSlideDescriptors = this.imageViewerPageStore.splitViewSlideDescriptors$.value.filter( (a): a is SlideDescriptor => !!a?.id); const olMapBySlideDescriptorId = this.imageViewerPageStore.olMapBySlideDescriptorId$.value; const splitViewOlMaps = splitViewSlideDescriptors .map(slideDescriptor => { return olMapBySlideDescriptorId.get(slideDescriptor.id as string); }) .filter((map): map is ol.Map => map !== undefined); const listners = splitViewOlMaps .map((mapToListen) => { const mapsToAnimateWithDiffs = splitViewOlMaps .filter((mapToAnimate) => { return mapToAnimate !== mapToListen; }) .map((mapToAnimate) => { return { mapToAnimate, mapDiff: this.computeDiffBetweenTwoMaps( mapToListen, mapToAnimate), }; }); const changeEventsToListen = mapToListen.getView().on( ['change:center', 'change:resolution', 'change:rotation'], () => { if (!this.mapAnimationSyncLock) return; this.mapAnimationSyncLock = false; mapsToAnimateWithDiffs.forEach( ({mapToAnimate, mapDiff}) => { const animationParam = this.computeMapAnimationParmas( mapToListen, mapDiff); mapToAnimate.getView().animate(animationParam, () => { this.mapAnimationSyncLock = true; }); }); }); return changeEventsToListen; }) .flat(); this.syncLockListeners.push(...listners); } private computeMapAnimationParmas( map: ol.Map, mapDiff: {dx: number, dy: number, dz: number, dr: number}) { const mapView = map.getView(); const center = mapView.getCenter() ?? [0, 0]; const zoom = mapView.getZoom() ?? 0; const rotation = mapView.getRotation() ?? 0; return { center: [center[0] + mapDiff.dx, center[1] + mapDiff.dy], zoom: zoom + mapDiff.dz, rotation: rotation + mapDiff.dr, duration: 0 }; } private computeDiffBetweenTwoMaps(olMap1: ol.Map, olMap2: ol.Map) { const center1 = olMap1.getView().getCenter() ?? [0, 0]; const center2 = olMap2.getView().getCenter() ?? [0, 0]; const dx = center2[0] - center1[0]; const dy = center2[1] - center1[1]; const zoom1 = olMap1.getView().getZoom() ?? 0; const zoom2 = olMap2.getView().getZoom() ?? 0; const dz = zoom2 - zoom1; const rotation1 = olMap1.getView().getRotation() ?? 0; const rotation2 = olMap2.getView().getRotation() ?? 0; const dr = rotation2 - rotation1; return {dx, dy, dz, dr}; } multiViewScreens = 1; toggleMultiView(multiViewScreens: 1|2|4) { let slideDescriptor = this.splitViewSlideDescriptors[0]; if (!slideDescriptor) { slideDescriptor = this.splitViewSlideDescriptors.filter( (slideDescriptor): slideDescriptor is SlideDescriptor => !!slideDescriptor?.id)[0]; } const viewerParams: ImageViewerPageParams = { series: slideDescriptor?.id as string + ','.repeat(multiViewScreens - 1), cohortName: this.activatedRouteParams.cohortName, }; this.router.navigate(['/viewer'], { queryParams: viewerParams, replaceUrl: true, }); this.imageViewerPageStore.isMultiViewSlidePicker$.next(false); } selectMultiViewScreenSelected(index: number) { this.multiViewScreenSelectedIndex = index; this.imageViewerPageStore.multiViewScreenSelectedIndex$.next(index); const slideDescriptor = this.splitViewSlideDescriptors[index]; if (slideDescriptor?.id) { this.selectSlideDescriptor(slideDescriptor); } } }