From d22a5503c16570ee70a3ad9c4a5761db70c3dae2 Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Mon, 19 Oct 2020 18:00:16 +0200 Subject: [PATCH] bugfix: race con on spatial xform --- .../atlasViewer.constantService.service.ts | 80 +--- .../state/viewerState.store.helper.ts | 16 +- src/services/state/viewerState.store.ts | 10 +- src/services/state/viewerState/actions.ts | 15 + src/services/state/viewerState/selectors.ts | 10 + .../viewerStateController/viewerState.base.ts | 14 +- .../viewerState.useEffect.spec.ts | 284 +++++++++++--- .../viewerState.useEffect.ts | 354 ++++++++++++------ src/util/pureConstant.service.ts | 80 +++- 9 files changed, 608 insertions(+), 255 deletions(-) diff --git a/src/atlasViewer/atlasViewer.constantService.service.ts b/src/atlasViewer/atlasViewer.constantService.service.ts index 454a2c933..f435d07be 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.ts @@ -1,16 +1,12 @@ import { HttpClient, HttpHeaders } from "@angular/common/http"; import { Injectable, OnDestroy } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { merge, Observable, of, Subscription, throwError, fromEvent, forkJoin } from "rxjs"; -import { catchError, map, shareReplay, switchMap, tap, filter, take } from "rxjs/operators"; -import { LoggingService } from "src/logging"; +import { Observable, Subscription } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; import { SNACKBAR_MESSAGE } from "src/services/state/uiState.store"; import { IavRootStoreInterface } from "../services/stateStore.service"; -import { AtlasWorkerService } from "./atlasViewer.workerService.service"; import { PureContantService } from "src/util"; -const getUniqueId = () => Math.round(Math.random() * 1e16).toString(16) - @Injectable({ providedIn : 'root', }) @@ -30,77 +26,12 @@ export class AtlasViewerConstantsServices implements OnDestroy { // instead of using window.location.href, which includes query param etc public backendUrl = (BACKEND_URL && `${BACKEND_URL}/`.replace(/\/\/$/, '/')) || `${window.location.origin}${window.location.pathname}` - private fetchTemplate = (templateUrl) => this.http.get(`${this.backendUrl}${templateUrl}`, { responseType: 'json' }).pipe( - switchMap((template: any) => { - if (template.nehubaConfig) { return of(template) } - if (template.nehubaConfigURL) { return this.http.get(`${this.backendUrl}${template.nehubaConfigURL}`, { responseType: 'json' }).pipe( - map(nehubaConfig => { - return { - ...template, - nehubaConfig, - } - }), - ) - } - throwError('neither nehubaConfig nor nehubaConfigURL defined') - }), - ) - public totalTemplates = null - private workerUpdateParcellation$ = fromEvent(this.workerService.worker, 'message').pipe( - filter((message: MessageEvent) => message && message.data && message.data.type === 'UPDATE_PARCELLATION_REGIONS'), - map(({ data }) => data) - ) - - private processTemplate = template => forkJoin( - ...template.parcellations.map(parcellation => { - - const id = getUniqueId() - - this.workerService.worker.postMessage({ - type: 'PROPAGATE_PARC_REGION_ATTR', - parcellation, - inheritAttrsOpts: { - ngId: (parcellation as any ).ngId, - relatedAreas: [], - fullId: null - }, - id - }) - - return this.workerUpdateParcellation$.pipe( - filter(({ id: returnedId }) => id === returnedId), - take(1), - map(({ parcellation }) => parcellation) - ) - }) - ) - public getTemplateEndpoint$ = this.http.get(`${this.backendUrl}templates`, { responseType: 'json' }).pipe( shareReplay(1) ) - public initFetchTemplate$ = this.getTemplateEndpoint$.pipe( - tap((arr: any[]) => this.totalTemplates = arr.length), - switchMap((templates: string[]) => merge( - ...templates.map(templateName => this.fetchTemplate(templateName).pipe( - switchMap(template => this.processTemplate(template).pipe( - map(parcellations => { - return { - ...template, - parcellations - } - }) - )) - )), - )), - catchError((err) => { - this.log.warn(`fetching templates error`, err) - return of(null) - }), - ) - public templateUrls = Array(100) /* to be provided by KG in future */ @@ -258,8 +189,6 @@ Raise/track issues at github repo: <a target = "_blank" href = "${this.repoUrl}" constructor( private store$: Store<IavRootStoreInterface>, private http: HttpClient, - private log: LoggingService, - private workerService: AtlasWorkerService, private pureConstantService: PureContantService ) { @@ -291,7 +220,10 @@ Raise/track issues at github repo: <a target = "_blank" href = "${this.repoUrl}" this.dissmissUserLayerSnackbarMessage = this.dissmissUserLayerSnackbarMessageDesktop } }), - ) + ), + this.pureConstantService.getTemplateEndpoint$.subscribe(arr => { + this.totalTemplates = arr.length + }) } private subscriptions: Subscription[] = [] diff --git a/src/services/state/viewerState.store.helper.ts b/src/services/state/viewerState.store.helper.ts index 6405a55b3..de45f8a07 100644 --- a/src/services/state/viewerState.store.helper.ts +++ b/src/services/state/viewerState.store.helper.ts @@ -7,6 +7,7 @@ import { withLatestFrom, map } from "rxjs/operators"; import { Injectable } from "@angular/core"; import { + viewerStateNewViewer, viewerStateHelperSelectParcellationWithId, viewerStateNavigateToRegion, viewerStateRemoveAdditionalLayer, @@ -22,10 +23,14 @@ import { viewerStateSelectRegionWithIdDeprecated, viewerStateDblClickOnViewer, viewerStateAddUserLandmarks, - viewreStateRemoveUserLandmarks + viewreStateRemoveUserLandmarks, + viewerStateMouseOverCustomLandmark, + viewerStateMouseOverCustomLandmarkInPerspectiveView, + viewerStateSelectTemplateWithName, } from './viewerState/actions' export { + viewerStateNewViewer, viewerStateHelperSelectParcellationWithId, viewerStateNavigateToRegion, viewerStateRemoveAdditionalLayer, @@ -41,7 +46,10 @@ export { viewerStateSelectRegionWithIdDeprecated, viewerStateDblClickOnViewer, viewerStateAddUserLandmarks, - viewreStateRemoveUserLandmarks + viewreStateRemoveUserLandmarks, + viewerStateMouseOverCustomLandmark, + viewerStateMouseOverCustomLandmarkInPerspectiveView, + viewerStateSelectTemplateWithName, } import { @@ -50,6 +58,8 @@ import { viewerStateSelectedParcellationSelector, viewerStateGetSelectedAtlas, viewerStateCustomLandmarkSelector, + viewerStateFetchedTemplatesSelector, + viewerStateNavigationStateSelector, } from './viewerState/selectors' export { @@ -57,6 +67,8 @@ export { viewerStateSelectedTemplateSelector, viewerStateSelectedParcellationSelector, viewerStateCustomLandmarkSelector, + viewerStateFetchedTemplatesSelector, + viewerStateNavigationStateSelector, } interface IViewerStateHelperStore{ diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index 0060ef7b3..fa8c727a6 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -17,10 +17,14 @@ import { viewerStateSelectParcellation, viewerStateSelectRegionWithIdDeprecated, viewerStateCustomLandmarkSelector, + viewerStateDblClickOnViewer, + viewerStateAddUserLandmarks, + viewreStateRemoveUserLandmarks, + viewerStateMouseOverCustomLandmark, + viewerStateMouseOverCustomLandmarkInPerspectiveView, + viewerStateNewViewer } from './viewerState.store.helper'; -import { viewerStateDblClickOnViewer, viewerStateAddUserLandmarks, viewreStateRemoveUserLandmarks, viewerStateMouseOverCustomLandmark, viewerStateMouseOverCustomLandmarkInPerspectiveView } from './viewerState/actions'; - export interface StateInterface { fetchedTemplates: any[] @@ -258,7 +262,7 @@ export function stateStore(state, action) { export const LOAD_DEDICATED_LAYER = 'LOAD_DEDICATED_LAYER' export const UNLOAD_DEDICATED_LAYER = 'UNLOAD_DEDICATED_LAYER' -export const NEWVIEWER = 'NEWVIEWER' +export const NEWVIEWER = viewerStateNewViewer.type export const FETCHED_TEMPLATE = 'FETCHED_TEMPLATE' export const CHANGE_NAVIGATION = 'CHANGE_NAVIGATION' diff --git a/src/services/state/viewerState/actions.ts b/src/services/state/viewerState/actions.ts index 3b19de4b1..7cea8db48 100644 --- a/src/services/state/viewerState/actions.ts +++ b/src/services/state/viewerState/actions.ts @@ -1,6 +1,15 @@ import { createAction, props } from "@ngrx/store" import { IRegion } from './constants' +export const viewerStateNewViewer = createAction( + `[viewerState] newViewer`, + props<{ + selectTemplate: any + selectParcellation: any + navigation: any + }>() +) + export const viewerStateSetSelectedRegionsWithIds = createAction( `[viewerState] setSelectedRegionsWithIds`, props<{ selectRegionIds: string[] }>() @@ -46,6 +55,11 @@ export const viewerStateSelectParcellation = createAction( props<{ selectParcellation: any }>() ) +export const viewerStateSelectTemplateWithName = createAction( + `[viewerState] selectTemplateWithName`, + props<{ payload: { name: string } }>() +) + export const viewerStateSelectTemplateWithId = createAction( `[viewerState] selectTemplateWithId`, props<{ payload: { ['@id']: string }, config?: { selectParcellation: { ['@id']: string } } }>() @@ -90,3 +104,4 @@ export const viewerStateMouseOverCustomLandmarkInPerspectiveView = createAction( `[viewerState] mouseOverCustomLandmarkInPerspectiveView`, props<{ payload: { label: string } }>() ) + diff --git a/src/services/state/viewerState/selectors.ts b/src/services/state/viewerState/selectors.ts index b7a091bdc..d41d1e0aa 100644 --- a/src/services/state/viewerState/selectors.ts +++ b/src/services/state/viewerState/selectors.ts @@ -22,6 +22,11 @@ const flattenFetchedTemplatesIntoParcellationsReducer = (acc, curr) => { return acc.concat( parcelations ) } +export const viewerStateFetchedTemplatesSelector = createSelector( + state => state['viewerState'], + viewerState => viewerState['fetchedTemplates'] +) + export const viewerStateSelectedTemplateSelector = createSelector( state => state['viewerState'], viewerState => viewerState['templateSelected'] @@ -32,6 +37,11 @@ export const viewerStateSelectedParcellationSelector = createSelector( viewerState => viewerState['parcellationSelected'] ) +export const viewerStateNavigationStateSelector = createSelector( + state => state['viewerState'], + viewerState => viewerState['navigation'] +) + export const viewerStateAllRegionsFlattenedRegionSelector = createSelector( viewerStateSelectedParcellationSelector, parc => { diff --git a/src/ui/viewerStateController/viewerState.base.ts b/src/ui/viewerStateController/viewerState.base.ts index ee206f5bd..70a67c226 100644 --- a/src/ui/viewerStateController/viewerState.base.ts +++ b/src/ui/viewerStateController/viewerState.base.ts @@ -7,9 +7,9 @@ import { RegionSelection } from "src/services/state/userConfigState.store"; import { IavRootStoreInterface, SELECT_REGIONS, USER_CONFIG_ACTION_TYPES } from "src/services/stateStore.service"; import { MatSelectChange } from "@angular/material/select"; import { MatBottomSheet, MatBottomSheetRef } from "@angular/material/bottom-sheet"; +import { viewerStateSelectTemplateWithName } from "src/services/state/viewerState/actions"; const ACTION_TYPES = { - SELECT_TEMPLATE_WITH_NAME: 'SELECT_TEMPLATE_WITH_NAME', SELECT_PARCELLATION_WITH_NAME: 'SELECT_PARCELLATION_WITH_NAME', } @@ -94,13 +94,11 @@ export class ViewerStateBase implements OnInit { } public handleTemplateChange(event: MatSelectChange) { - - this.store$.dispatch({ - type: ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME, - payload: { - name: event.value, - }, - }) + this.store$.dispatch( + viewerStateSelectTemplateWithName({ + payload: { name: event.value } + }) + ) } public handleParcellationChange(event: MatSelectChange) { diff --git a/src/ui/viewerStateController/viewerState.useEffect.spec.ts b/src/ui/viewerStateController/viewerState.useEffect.spec.ts index 513854f67..e15a18fb8 100644 --- a/src/ui/viewerStateController/viewerState.useEffect.spec.ts +++ b/src/ui/viewerStateController/viewerState.useEffect.spec.ts @@ -1,19 +1,20 @@ -import { ViewerStateControllerUseEffect } from './viewerState.useEffect' +import { cvtNavigationObjToNehubaConfig, cvtNehubaConfigToNavigationObj, ViewerStateControllerUseEffect, defaultNavigationObject, defaultNehubaConfigObject } from './viewerState.useEffect' import { Observable, of } from 'rxjs' import { TestBed, async } from '@angular/core/testing' import { provideMockActions } from '@ngrx/effects/testing' -import { provideMockStore } from '@ngrx/store/testing' +import { MockStore, provideMockStore } from '@ngrx/store/testing' import { defaultRootState, NEWVIEWER } from 'src/services/stateStore.service' import { Injectable } from '@angular/core' import { TemplateCoordinatesTransformation, ITemplateCoordXformResp } from 'src/services/templateCoordinatesTransformation.service' import { hot } from 'jasmine-marbles' -import { VIEWERSTATE_CONTROLLER_ACTION_TYPES } from './viewerState.base' import { AngularMaterialModule } from '../sharedModules/angularMaterial.module' import { HttpClientModule } from '@angular/common/http' import { WidgetModule } from 'src/widget' import { PluginModule } from 'src/atlasViewer/pluginUnit/plugin.module' +import { viewerStateNavigationStateSelector, viewerStateNewViewer, viewerStateSelectTemplateWithName } from 'src/services/state/viewerState.store.helper' const bigbrainJson = require('!json-loader!src/res/ext/bigbrain.json') +const bigBrainNehubaConfig = require('!json-loader!src/res/ext/bigbrainNehubaConfig.json') const colinJson = require('!json-loader!src/res/ext/colin.json') const colinJsonNehubaConfig = require('!json-loader!src/res/ext/colinNehubaConfig.json') const reconstitutedColin = JSON.parse(JSON.stringify( @@ -22,6 +23,12 @@ const reconstitutedColin = JSON.parse(JSON.stringify( nehubaConfig: colinJsonNehubaConfig } )) +const reconstitutedBigBrain = JSON.parse(JSON.stringify( + { + ...bigbrainJson, + nehubaConfig: bigBrainNehubaConfig + } +)) let returnPosition = null @Injectable() class MockCoordXformService{ @@ -34,7 +41,7 @@ class MockCoordXformService{ const initialState = JSON.parse(JSON.stringify( defaultRootState )) initialState.viewerState.fetchedTemplates = [ - bigbrainJson, + reconstitutedBigBrain, reconstitutedColin ] initialState.viewerState.templateSelected = initialState.viewerState.fetchedTemplates[0] @@ -47,8 +54,8 @@ const currentNavigation = { } initialState.viewerState.navigation = currentNavigation -describe('viewerState.useEffect.ts', () => { - describe('ViewerStateControllerUseEffect', () => { +describe('> viewerState.useEffect.ts', () => { + describe('> ViewerStateControllerUseEffect', () => { let actions$: Observable<any> let spy: any beforeEach(async(() => { @@ -59,10 +66,7 @@ describe('viewerState.useEffect.ts', () => { actions$ = hot( 'a', { - a: { - type: VIEWERSTATE_CONTROLLER_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME, - payload: reconstitutedColin - } + a: viewerStateSelectTemplateWithName({ payload: reconstitutedColin }) } ) @@ -85,27 +89,97 @@ describe('viewerState.useEffect.ts', () => { }).compileComponents() })) - describe('selectTemplate$', () => { + describe('> selectTemplate$', () => { + describe('> when transiting from template A to template B', () => { + describe('> if the current navigation is correctly formed', () => { + it('> uses current navigation param', () => { - it('if coordXform returns error', () => { - const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) - expect( - viewerStateCtrlEffect.selectTemplate$ - ).toBeObservable( - hot( - 'a', - { - a: { - type: NEWVIEWER, - selectTemplate: reconstitutedColin, - selectParcellation: reconstitutedColin.parcellations[0] - } - } - ) - ) + const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerStateCtrlEffect.selectTemplate$ + ).toBeObservable( + hot( + 'a', + { + a: viewerStateNewViewer({ + selectTemplate: reconstitutedColin, + selectParcellation: reconstitutedColin.parcellations[0], + navigation: {} + }) + } + ) + ) + expect(spy).toHaveBeenCalledWith( + reconstitutedBigBrain.name, + reconstitutedColin.name, + initialState.viewerState.navigation.position + ) + }) + }) + + describe('> if current navigation is malformed', () => { + it('> if current navigation is undefined, use nehubaConfig of last template', () => { + + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(viewerStateNavigationStateSelector, null) + const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + + expect( + viewerStateCtrlEffect.selectTemplate$ + ).toBeObservable( + hot( + 'a', + { + a: viewerStateNewViewer({ + selectTemplate: reconstitutedColin, + selectParcellation: reconstitutedColin.parcellations[0], + navigation: {} + }) + } + ) + ) + const { position } = cvtNehubaConfigToNavigationObj(reconstitutedBigBrain.nehubaConfig.dataset.initialNgState) + + expect(spy).toHaveBeenCalledWith( + reconstitutedBigBrain.name, + reconstitutedColin.name, + position + ) + }) + + it('> if current navigation is empty object, use nehubaConfig of last template', () => { + + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(viewerStateNavigationStateSelector, {}) + const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + + expect( + viewerStateCtrlEffect.selectTemplate$ + ).toBeObservable( + hot( + 'a', + { + a: viewerStateNewViewer({ + selectTemplate: reconstitutedColin, + selectParcellation: reconstitutedColin.parcellations[0], + navigation: {} + }) + } + ) + ) + const { position } = cvtNehubaConfigToNavigationObj(reconstitutedBigBrain.nehubaConfig.dataset.initialNgState) + + expect(spy).toHaveBeenCalledWith( + reconstitutedBigBrain.name, + reconstitutedColin.name, + position + ) + }) + }) + }) - it('calls with correct param', () => { + it('> if coordXform returns error', () => { const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) expect( viewerStateCtrlEffect.selectTemplate$ @@ -113,26 +187,22 @@ describe('viewerState.useEffect.ts', () => { hot( 'a', { - a: { - type: NEWVIEWER, - selectTemplate: reconstitutedColin, - selectParcellation: reconstitutedColin.parcellations[0] - } + a: viewerStateNewViewer({ + selectTemplate: reconstitutedColin, + selectParcellation: reconstitutedColin.parcellations[0], + navigation: {} + }) } ) ) - expect(spy).toHaveBeenCalledWith( - bigbrainJson.name, - reconstitutedColin.name, - initialState.viewerState.navigation.position - ) }) - it('if coordXform returns complete', () => { + it('> if coordXform complete', () => { returnPosition = [ 1.11e6, 2.22e6, 3.33e6 ] const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) const updatedColin = JSON.parse( JSON.stringify( reconstitutedColin ) ) + const initialNgState = updatedColin.nehubaConfig.dataset.initialNgState const updatedColinNavigation = updatedColin.nehubaConfig.dataset.initialNgState.navigation const { zoom, orientation, perspectiveOrientation, position, perspectiveZoom } = currentNavigation @@ -142,6 +212,8 @@ describe('viewerState.useEffect.ts', () => { } updatedColinNavigation.zoomFactor = zoom updatedColinNavigation.pose.orientation = orientation + initialNgState.perspectiveOrientation = perspectiveOrientation + initialNgState.perspectiveZoom = perspectiveZoom expect( viewerStateCtrlEffect.selectTemplate$ @@ -149,15 +221,137 @@ describe('viewerState.useEffect.ts', () => { hot( 'a', { - a: { - type: NEWVIEWER, - selectTemplate: updatedColin, - selectParcellation: updatedColin.parcellations[0] - } + a: viewerStateNewViewer({ + selectTemplate: updatedColin, + selectParcellation: updatedColin.parcellations[0], + navigation: {} + }) } ) ) }) + + }) + }) + + describe('> cvtNehubaConfigToNavigationObj', () => { + describe('> returns default obj when input is malformed', () => { + it('> if no arg is provided', () => { + + const obj = cvtNehubaConfigToNavigationObj() + expect(obj).toEqual({ + orientation: [0, 0, 0, 1], + perspectiveOrientation: [0 , 0, 0, 1], + perspectiveZoom: 1e6, + zoom: 1e6, + position: [0, 0, 0], + positionReal: true + }) + }) + it('> if null or undefined is provided', () => { + + const obj = cvtNehubaConfigToNavigationObj(null) + expect(obj).toEqual(defaultNavigationObject) + + const obj2 = cvtNehubaConfigToNavigationObj(undefined) + expect(obj2).toEqual(defaultNavigationObject) + }) + it('> if malformed', () => { + + const obj = cvtNehubaConfigToNavigationObj(reconstitutedBigBrain) + expect(obj).toEqual(defaultNavigationObject) + + const obj2 = cvtNehubaConfigToNavigationObj({}) + expect(obj2).toEqual(defaultNavigationObject) + }) + }) + it('> converts nehubaConfig object to navigation object', () => { + + const obj = cvtNehubaConfigToNavigationObj(reconstitutedBigBrain.nehubaConfig.dataset.initialNgState) + expect(obj).toEqual({ + orientation: [0, 0, 0, 1], + perspectiveOrientation: [ + 0.3140767216682434, + -0.7418519854545593, + 0.4988985061645508, + -0.3195493221282959 + ], + perspectiveZoom: 1922235.5293810747, + zoom: 350000, + position: [ -463219.89446663484, 325772.3617553711, 601535.3736234978 ], + positionReal: true + }) + }) + }) + describe('> cvtNavigationObjToNehubaConfig', () => { + const validNehubaConfigObj = reconstitutedBigBrain.nehubaConfig.dataset.initialNgState + const validNavigationObj = currentNavigation + describe('> if inputs are malformed', () => { + describe('> if navigation object is malformed, uses navigation default object', () => { + it('> if navigation object is null', () => { + const v1 = cvtNavigationObjToNehubaConfig(null, validNehubaConfigObj) + const v2 = cvtNavigationObjToNehubaConfig(defaultNavigationObject, validNehubaConfigObj) + expect(v1).toEqual(v2) + }) + it('> if navigation object is undefined', () => { + const v1 = cvtNavigationObjToNehubaConfig(undefined, validNehubaConfigObj) + const v2 = cvtNavigationObjToNehubaConfig(defaultNavigationObject, validNehubaConfigObj) + expect(v1).toEqual(v2) + }) + + it('> if navigation object is otherwise malformed', () => { + const v1 = cvtNavigationObjToNehubaConfig(reconstitutedBigBrain, validNehubaConfigObj) + const v2 = cvtNavigationObjToNehubaConfig(defaultNavigationObject, validNehubaConfigObj) + expect(v1).toEqual(v2) + + const v3 = cvtNavigationObjToNehubaConfig({}, validNehubaConfigObj) + const v4 = cvtNavigationObjToNehubaConfig(defaultNavigationObject, validNehubaConfigObj) + expect(v3).toEqual(v4) + }) + }) + + describe('> if nehubaConfig object is malformed, use default nehubaConfig obj', () => { + it('> if nehubaConfig is null', () => { + const v1 = cvtNavigationObjToNehubaConfig(validNavigationObj, null) + const v2 = cvtNavigationObjToNehubaConfig(validNavigationObj, defaultNehubaConfigObject) + expect(v1).toEqual(v2) + }) + + it('> if nehubaConfig is undefined', () => { + const v1 = cvtNavigationObjToNehubaConfig(validNavigationObj, undefined) + const v2 = cvtNavigationObjToNehubaConfig(validNavigationObj, defaultNehubaConfigObject) + expect(v1).toEqual(v2) + }) + + it('> if nehubaConfig is otherwise malformed', () => { + const v1 = cvtNavigationObjToNehubaConfig(validNavigationObj, {}) + const v2 = cvtNavigationObjToNehubaConfig(validNavigationObj, defaultNehubaConfigObject) + expect(v1).toEqual(v2) + + const v3 = cvtNavigationObjToNehubaConfig(validNavigationObj, reconstitutedBigBrain) + const v4 = cvtNavigationObjToNehubaConfig(validNavigationObj, defaultNehubaConfigObject) + expect(v3).toEqual(v4) + }) + }) + }) + it('> converts navigation object and reference nehuba config object to navigation object', () => { + const convertedVal = cvtNavigationObjToNehubaConfig(validNavigationObj, validNehubaConfigObj) + const { perspectiveOrientation, orientation, zoom, perspectiveZoom, position } = validNavigationObj + + expect(convertedVal).toEqual({ + navigation: { + pose: { + position: { + voxelSize: validNehubaConfigObj.navigation.pose.position.voxelSize, + voxelCoordinates: [0, 1, 2].map(idx => position[idx] / validNehubaConfigObj.navigation.pose.position.voxelSize[idx]) + }, + orientation + }, + zoomFactor: zoom + }, + perspectiveOrientation: perspectiveOrientation, + perspectiveZoom: perspectiveZoom + }) }) }) -}) \ No newline at end of file +}) diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts index 8edba98ef..12ac1882e 100644 --- a/src/ui/viewerStateController/viewerState.useEffect.ts +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -2,15 +2,96 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Actions, Effect, ofType } from "@ngrx/effects"; import { Action, select, Store } from "@ngrx/store"; import { Observable, Subscription, of, merge } from "rxjs"; -import { distinctUntilChanged, filter, map, shareReplay, withLatestFrom, switchMap, mapTo } from "rxjs/operators"; -import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; +import { distinctUntilChanged, filter, map, shareReplay, withLatestFrom, switchMap, mapTo, startWith } from "rxjs/operators"; import { CHANGE_NAVIGATION, FETCHED_TEMPLATE, IavRootStoreInterface, NEWVIEWER, SELECT_PARCELLATION, SELECT_REGIONS, generalActionError } from "src/services/stateStore.service"; import { VIEWERSTATE_CONTROLLER_ACTION_TYPES } from "./viewerState.base"; import { TemplateCoordinatesTransformation } from "src/services/templateCoordinatesTransformation.service"; import { CLEAR_STANDALONE_VOLUMES } from "src/services/state/viewerState.store"; -import { viewerStateToggleRegionSelect, viewerStateHelperSelectParcellationWithId, viewerStateSelectTemplateWithId, viewerStateNavigateToRegion, viewerStateSelectedTemplateSelector } from "src/services/state/viewerState.store.helper"; +import { viewerStateToggleRegionSelect, viewerStateHelperSelectParcellationWithId, viewerStateSelectTemplateWithId, viewerStateNavigateToRegion, viewerStateSelectedTemplateSelector, viewerStateFetchedTemplatesSelector, viewerStateNewViewer, viewerStateSelectedParcellationSelector, viewerStateNavigationStateSelector, viewerStateSelectTemplateWithName } from "src/services/state/viewerState.store.helper"; import { ngViewerSelectorClearViewEntries } from "src/services/state/ngViewerState/selectors"; import { ngViewerActionClearView } from "src/services/state/ngViewerState/actions"; +import { PureContantService } from "src/util"; + +const defaultPerspectiveZoom = 1e6 +const defaultZoom = 1e6 + +export const defaultNavigationObject = { + orientation: [0, 0, 0, 1], + perspectiveOrientation: [0 , 0, 0, 1], + perspectiveZoom: defaultPerspectiveZoom, + zoom: defaultZoom, + position: [0, 0, 0], + positionReal: true +} + +export const defaultNehubaConfigObject = { + perspectiveOrientation: [0, 0, 0, 1], + perspectiveZoom: 1e6, + navigation: { + pose: { + position: { + voxelCoordinates: [0, 0, 0], + voxelSize: [1,1,1] + }, + orientation: [0, 0, 0, 1], + }, + zoomFactor: defaultZoom + } +} + +export function cvtNehubaConfigToNavigationObj(nehubaConfig?){ + const { navigation, perspectiveOrientation = [0, 0, 0, 1], perspectiveZoom = 1e6 } = nehubaConfig || {} + const { pose, zoomFactor = 1e6 } = navigation || {} + const { position, orientation = [0, 0, 0, 1] } = pose || {} + const { voxelSize = [1, 1, 1], voxelCoordinates = [0, 0, 0] } = position || {} + + return { + orientation, + perspectiveOrientation: perspectiveOrientation, + perspectiveZoom: perspectiveZoom, + zoom: zoomFactor, + position: [0, 1, 2].map(idx => voxelSize[idx] * voxelCoordinates[idx]), + positionReal: true + } +} + +export function cvtNavigationObjToNehubaConfig(navigationObj, nehubaConfigObj){ + const { + orientation = [0, 0, 0, 1], + perspectiveOrientation = [0, 0, 0, 1], + perspectiveZoom = 1e6, + zoom = 1e6, + position = [0, 0, 0], + positionReal = true, + } = navigationObj || {} + + const voxelSize = (() => { + const { + navigation = {} + } = nehubaConfigObj || {} + const { pose = {}, zoomFactor = 1e6 } = navigation + const { position = {}, orientation = [0, 0, 0, 1] } = pose + const { voxelSize = [1, 1, 1], voxelCoordinates = [0, 0, 0] } = position + return voxelSize + })() + + return { + perspectiveOrientation, + perspectiveZoom, + navigation: { + pose: { + position: { + voxelCoordinates: positionReal + ? [0, 1, 2].map(idx => position[idx] / voxelSize[idx]) + : position, + voxelSize + }, + orientation, + }, + zoomFactor: zoom + } + } +} @Injectable({ providedIn: 'root', @@ -23,7 +104,7 @@ export class ViewerStateControllerUseEffect implements OnDestroy { private selectedRegions$: Observable<any[]> @Effect() - public init$ = this.constantSerivce.initFetchTemplate$.pipe( + public init$ = this.pureService.initFetchTemplate$.pipe( map(fetchedTemplate => { return { type: FETCHED_TEMPLATE, @@ -35,8 +116,154 @@ export class ViewerStateControllerUseEffect implements OnDestroy { @Effect() public selectParcellation$: Observable<any> + private selectTemplateIntent$: Observable<any> = merge( + this.actions$.pipe( + ofType(viewerStateSelectTemplateWithId.type), + map(({ payload, config }) => { + return { + templateId: payload['@id'], + parcellationId: config && config['selectParcellation'] && config['selectParcellation']['@id'] + } + }) + ), + this.actions$.pipe( + ofType(viewerStateSelectTemplateWithName), + withLatestFrom(this.store$.pipe( + select(viewerStateFetchedTemplatesSelector) + )), + map(([ action, fetchedTemplates ]) => { + const templateName = (action as any).payload.name + const foundTemplate = fetchedTemplates.find(t => t.name === templateName) + return foundTemplate && foundTemplate['@id'] + }), + filter(v => !!v), + map(templateId => { + return { templateId, parcellationId: null } + }) + ) + ) + @Effect() - public selectTemplate$: Observable<any> + public selectTemplate$: Observable<any> = this.selectTemplateIntent$.pipe( + withLatestFrom( + this.store$.pipe( + select(viewerStateFetchedTemplatesSelector) + ), + this.store$.pipe( + select(viewerStateSelectedParcellationSelector) + ) + ), + map(([ { templateId, parcellationId }, fetchedTemplates, parcellationSelected ]) => { + /** + * find the correct template & parcellation from their IDs + */ + + /** + * for template, just look for the new id in fetched templates + */ + const newTemplateTobeSelected = fetchedTemplates.find(t => t['@id'] === templateId) + if (!newTemplateTobeSelected) { + return { + selectTemplate: null, + selectParcellation: null, + errorMessage: `Selected templateId ${templateId} not found.` + } + } + + /** + * for parcellation, + * if new parc id is defined, try to find the corresponding parcellation in the new template + * if above fails, try to find the corresponding parcellation of the currently selected parcellation + * if the above fails, select the first parcellation in the new template + */ + const selectParcellationWithTemplate = (parcellationId && newTemplateTobeSelected['parcellations'].find(p => p['@id'] === parcellationId)) + || (parcellationSelected && parcellationSelected['@id'] && newTemplateTobeSelected['parcellations'].find(p => p['@id'] === parcellationSelected['@id'])) + || newTemplateTobeSelected.parcellations[0] + + return { + selectTemplate: newTemplateTobeSelected, + selectParcellation: selectParcellationWithTemplate + } + }), + withLatestFrom( + this.store$.pipe( + select(viewerStateSelectedTemplateSelector), + startWith(<any>null), + ), + this.store$.pipe( + select(viewerStateNavigationStateSelector), + startWith(<any>null), + ) + ), + switchMap(([{ selectTemplate, selectParcellation, errorMessage }, lastSelectedTemplate, navigation]) => { + /** + * if selectTemplate is undefined (cannot find template with id) + */ + if (errorMessage) { + return of(generalActionError({ + message: errorMessage || 'Switching template error.', + })) + } + /** + * if there were no template selected last + * simply return selectTemplate object + */ + if (!lastSelectedTemplate) { + return of(viewerStateNewViewer({ + navigation: {}, + selectParcellation, + selectTemplate + })) + } + + /** + * if there were template selected last, extract navigation info + */ + const previousNavigation = (navigation && Object.keys(navigation).length > 0 && navigation) || cvtNehubaConfigToNavigationObj(lastSelectedTemplate.nehubaConfig?.dataset?.initialNgState) + return this.coordinatesTransformation.getPointCoordinatesForTemplate(lastSelectedTemplate.name, selectTemplate.name, previousNavigation.position).pipe( + map(({ status, result }) => { + + /** + * if getPointCoordinatesForTemplate returns error, simply load the temp/parc + */ + if (status === 'error') { + return viewerStateNewViewer({ + navigation: {}, + selectParcellation, + selectTemplate + }) + } + + /** + * otherwise, copy the nav state to templateSelected + * deepclone of json object is required, or it will mutate the fetchedTemplate + * setting navigation sometimes creates a race con, as creating nehubaViewer is not sync + */ + const deepCopiedState = JSON.parse(JSON.stringify(selectTemplate)) + const initialNgState = deepCopiedState.nehubaConfig.dataset.initialNgState + + const newInitialNgState = cvtNavigationObjToNehubaConfig({ + ...previousNavigation, + position: result + }, initialNgState) + + /** + * mutation of initialNgState is expected here + */ + deepCopiedState.nehubaConfig.dataset.initialNgState = { + ...initialNgState, + ...newInitialNgState + } + + return viewerStateNewViewer({ + selectTemplate: deepCopiedState, + selectParcellation, + navigation: {} + }) + }) + ) + }) + ) @Effect() public toggleRegionSelection$: Observable<any> @@ -67,7 +294,7 @@ export class ViewerStateControllerUseEffect implements OnDestroy { constructor( private actions$: Actions, private store$: Store<IavRootStoreInterface>, - private constantSerivce: AtlasViewerConstantsServices, + private pureService: PureContantService, private coordinatesTransformation: TemplateCoordinatesTransformation ) { const viewerState$ = this.store$.pipe( @@ -135,121 +362,6 @@ export class ViewerStateControllerUseEffect implements OnDestroy { }) ) - /** - * merge all sources into single stream consisting of template id's - */ - this.selectTemplate$ = merge( - this.actions$.pipe( - ofType(viewerStateSelectTemplateWithId.type), - map(({ payload, config }) => { - return { - templateId: payload['@id'], - parcellationId: config && config['selectParcellation'] && config['selectParcellation']['@id'] - } - }) - ), - this.actions$.pipe( - ofType(VIEWERSTATE_CONTROLLER_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME), - withLatestFrom(viewerState$.pipe( - select('fetchedTemplates') - )), - map(([ action, fetchedTemplates ]) => { - const templateName = (action as any).payload.name - const foundTemplate = fetchedTemplates.find(t => t.name === templateName) - return foundTemplate && foundTemplate['@id'] - }), - filter(v => !!v), - map(templateId => { - return { templateId, parcellationId: null } - }) - ) - ).pipe( - - withLatestFrom( - viewerState$ - ), - switchMap(([{ templateId: newTemplateId, parcellationId: newParcellationId }, { templateSelected, fetchedTemplates, navigation, parcellationSelected }]) => { - if (!templateSelected) { - return of({ - newTemplateId, - templateSelected: templateSelected, - fetchedTemplates, - translatedCoordinate: null, - navigation, - newParcellationId, - parcellationSelected - }) - } - const position = (navigation && navigation.position) || [0, 0, 0] - if (newTemplateId === templateSelected['@id']) return of(null) - - const newTemplateName = fetchedTemplates.find(t => t['@id'] === newTemplateId).name - - return this.coordinatesTransformation.getPointCoordinatesForTemplate(templateSelected.name, newTemplateName, position).pipe( - map(({ status, statusText, result }) => { - if (status === 'error') { - return { - newTemplateId, - templateSelected: templateSelected, - fetchedTemplates, - translatedCoordinate: null, - navigation, - newParcellationId, - parcellationSelected - } - } - return { - newTemplateId, - templateSelected: templateSelected, - fetchedTemplates, - translatedCoordinate: result, - navigation, - newParcellationId, - parcellationSelected - } - }) - ) - }), - filter(v => !!v), - map(({ newTemplateId, templateSelected, newParcellationId, fetchedTemplates, translatedCoordinate, navigation, parcellationSelected }) => { - const newTemplateTobeSelected = fetchedTemplates.find(t => t['@id'] === newTemplateId) - if (!newTemplateTobeSelected) { - return generalActionError({ - message: 'Selected template not found.' - }) - } - - const selectParcellationWithTemplate = (newParcellationId && newTemplateTobeSelected['parcellations'].find(p => p['@id'] === newParcellationId)) - || (parcellationSelected && parcellationSelected['@id'] && newTemplateTobeSelected['parcellations'].find(p => p['@id'] === parcellationSelected['@id'])) - || newTemplateTobeSelected.parcellations[0] - - if (!translatedCoordinate) { - return { - type: NEWVIEWER, - selectTemplate: newTemplateTobeSelected, - selectParcellation: selectParcellationWithTemplate, - } - } - const deepCopiedState = JSON.parse(JSON.stringify(newTemplateTobeSelected)) - const initNavigation = deepCopiedState.nehubaConfig.dataset.initialNgState.navigation - - const { zoom = null, orientation = null } = navigation || {} - if (zoom) initNavigation.zoomFactor = zoom - if (orientation) initNavigation.pose.orientation = orientation - - for (const idx of [0, 1, 2]) { - initNavigation.pose.position.voxelCoordinates[idx] = translatedCoordinate[idx] / initNavigation.pose.position.voxelSize[idx] - } - - return { - type: NEWVIEWER, - selectTemplate: deepCopiedState, - selectParcellation: selectParcellationWithTemplate, - } - }), - ) - - this.navigateToRegion$ = this.actions$.pipe( ofType(viewerStateNavigateToRegion), map(action => { diff --git a/src/util/pureConstant.service.ts b/src/util/pureConstant.service.ts index 5cdd0b0cf..2dc6b8aa9 100644 --- a/src/util/pureConstant.service.ts +++ b/src/util/pureConstant.service.ts @@ -1,11 +1,15 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Store, createSelector, select } from "@ngrx/store"; -import { Observable, merge, Subscription, of } from "rxjs"; +import { Observable, merge, Subscription, of, throwError, forkJoin, fromEvent } from "rxjs"; import { VIEWER_CONFIG_FEATURE_KEY, IViewerConfigState } from "src/services/state/viewerConfig.store.helper"; -import { shareReplay, tap, scan, catchError, filter, mergeMap, switchMapTo, switchMap } from "rxjs/operators"; +import { shareReplay, tap, scan, catchError, filter, mergeMap, switchMapTo, switchMap, map, take } from "rxjs/operators"; import { HttpClient } from "@angular/common/http"; import { BACKENDURL } from './constants' import { viewerStateSetFetchedAtlases } from "src/services/state/viewerState.store.helper"; +import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; +import { LoggingService } from "src/logging"; + +const getUniqueId = () => Math.round(Math.random() * 1e16).toString(16) @Injectable({ providedIn: 'root' @@ -24,9 +28,81 @@ export class PureContantService implements OnDestroy{ (state: IViewerConfigState) => state.useMobileUI ) + public backendUrl = (BACKEND_URL && `${BACKEND_URL}/`.replace(/\/\/$/, '/')) || `${window.location.origin}${window.location.pathname}` + + private workerUpdateParcellation$ = fromEvent(this.workerService.worker, 'message').pipe( + filter((message: MessageEvent) => message && message.data && message.data.type === 'UPDATE_PARCELLATION_REGIONS'), + map(({ data }) => data) + ) + + private fetchTemplate = (templateUrl) => this.http.get(`${this.backendUrl}${templateUrl}`, { responseType: 'json' }).pipe( + switchMap((template: any) => { + if (template.nehubaConfig) { return of(template) } + if (template.nehubaConfigURL) { return this.http.get(`${this.backendUrl}${template.nehubaConfigURL}`, { responseType: 'json' }).pipe( + map(nehubaConfig => { + return { + ...template, + nehubaConfig, + } + }), + ) + } + throwError('neither nehubaConfig nor nehubaConfigURL defined') + }), + ) + + private processTemplate = template => forkJoin( + template.parcellations.map(parcellation => { + + const id = getUniqueId() + + this.workerService.worker.postMessage({ + type: 'PROPAGATE_PARC_REGION_ATTR', + parcellation, + inheritAttrsOpts: { + ngId: (parcellation as any ).ngId, + relatedAreas: [], + fullId: null + }, + id + }) + + return this.workerUpdateParcellation$.pipe( + filter(({ id: returnedId }) => id === returnedId), + take(1), + map(({ parcellation }) => parcellation) + ) + }) + ) + + public getTemplateEndpoint$ = this.http.get<any[]>(`${this.backendUrl}templates`, { responseType: 'json' }).pipe( + shareReplay(1) + ) + + public initFetchTemplate$ = this.getTemplateEndpoint$.pipe( + switchMap((templates: string[]) => merge( + ...templates.map(templateName => this.fetchTemplate(templateName).pipe( + switchMap(template => this.processTemplate(template).pipe( + map(parcellations => { + return { + ...template, + parcellations + } + }) + )) + )), + )), + catchError((err) => { + this.log.warn(`fetching templates error`, err) + return of(null) + }), + ) + constructor( private store: Store<any>, private http: HttpClient, + private log: LoggingService, + private workerService: AtlasWorkerService, ){ this.darktheme$ = this.store.pipe( select(state => state?.viewerState?.templateSelected?.useTheme === 'dark') -- GitLab