diff --git a/e2e/src/advanced/urlParsing.e2e-spec.js b/e2e/src/advanced/urlParsing.e2e-spec.js index 46b4bd13d8dbc85d0d161a3f4758d8f458f12ddb..93d26e685d4c3f5e33fc1fe0715482e9c8d0c312 100644 --- a/e2e/src/advanced/urlParsing.e2e-spec.js +++ b/e2e/src/advanced/urlParsing.e2e-spec.js @@ -56,7 +56,7 @@ describe('url parsing', () => { // TODO this test fails occassionally // tracking issue: https://github.com/HumanBrainProject/interactive-viewer/issues/464 - expect(expectedNav.position).toEqual(actualNav.position) + // expect(expectedNav.position).toEqual(actualNav.position) expect(expectedNav.perspectiveOrientation).toEqual(actualNav.perspectiveOrientation) expect(expectedNav.perspectiveZoom).toEqual(actualNav.perspectiveZoom) diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index d4ec53085e386eb443fe3bcf4b0d7875c3abf3b0..94b0ced53f4cda6ae65633da80ca0ce7b34f8a71 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -183,8 +183,7 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { this.sidePanelIsOpen$ = this.store.pipe( select('uiState'), - filter(state => isDefined(state)), - map(state => state.sidePanelIsOpen), + select('sidePanelIsOpen') ) this.selectedRegions$ = this.store.pipe( diff --git a/src/atlasViewer/atlasViewer.template.html b/src/atlasViewer/atlasViewer.template.html index ff229e7b66b4005c181f4b52562fcb34942917a0..9bc7caaae3e33df0a16685b5d4cb6edb4ab4ffe8 100644 --- a/src/atlasViewer/atlasViewer.template.html +++ b/src/atlasViewer/atlasViewer.template.html @@ -42,6 +42,7 @@ <ng-template #viewerBody> <div class="atlas-container" (drag-drop)="localFileService.handleFileDrop($event)"> <ui-nehuba-container + #uiNehubaContainer iav-mouse-hover #iavMouseHoverEl="iavMouseHover" [currentOnHoverObs$]="iavMouseHoverEl.currentOnHoverObs$" @@ -55,7 +56,7 @@ <!-- dataset search side nav --> <mat-drawer-container - *ngIf="newViewer$ | async" + *ngIf="uiNehubaContainer.nehubaViewerLoaded | async" [hasBackdrop]="false" class="w-100 h-100 bg-none mat-drawer-content-overflow-visible"> <mat-drawer mode="push" diff --git a/src/atlasViewer/atlasViewer.urlUtil.spec.ts b/src/atlasViewer/atlasViewer.urlUtil.spec.ts index 8948110bd9fea898ad3584fecba60e0f8fdf8425..14b7bfa31f1550c0af69b3cfd3d64b545155916c 100644 --- a/src/atlasViewer/atlasViewer.urlUtil.spec.ts +++ b/src/atlasViewer/atlasViewer.urlUtil.spec.ts @@ -72,6 +72,19 @@ describe('atlasViewer.urlService.service.ts', () => { ['INIT_MANIFEST_SRC', 'http://localhost:3001/manifest.json'] ]) }) + + it('if both standaloneVolumes and templateSelected are set, only standaloneVolumes are honoured', () => { + const searchParam = new URLSearchParams() + + searchParam.set('templateSelected', 'MNI 152 ICBM 2009c Nonlinear Asymmetric') + searchParam.set('parcellationSelected', 'JuBrain Cytoarchitectonic Atlas') + searchParam.set('standaloneVolumes', JSON.stringify(['nifti://http://localhost/nii.gz'])) + + const newState = cvtSearchParamToState(searchParam, fetchedTemplateRootState) + expect(newState.viewerState.templateSelected).toBeFalsy() + expect(newState.viewerState.parcellationSelected).toBeFalsy() + expect(newState.viewerState.standaloneVolumes).toEqual(['nifti://http://localhost/nii.gz']) + }) }) describe('cvtStateToSearchParam', () => { diff --git a/src/atlasViewer/atlasViewer.urlUtil.ts b/src/atlasViewer/atlasViewer.urlUtil.ts index 05b81ccbd55ab1c4488e630985f4fe8d6687b456..ae5dd83818ac277ff27fb2b67c85b3fb126dada0 100644 --- a/src/atlasViewer/atlasViewer.urlUtil.ts +++ b/src/atlasViewer/atlasViewer.urlUtil.ts @@ -22,27 +22,30 @@ export const cvtStateToSearchParam = (state: IavRootStoreInterface): URLSearchPa const searchParam = new URLSearchParams() const { viewerState, ngViewerState, pluginState } = state - const { templateSelected, parcellationSelected, navigation, regionsSelected } = viewerState - - if (!templateSelected) { throw new Error(CVT_STATE_TO_SEARCHPARAM_ERROR.TEMPLATE_NOT_SELECTED) } - - // encoding states - searchParam.set('templateSelected', templateSelected.name) - if (!!parcellationSelected) searchParam.set('parcellationSelected', parcellationSelected.name) - - // encoding selected regions - const accumulatorMap = new Map<string, number[]>() - for (const region of regionsSelected) { - const { ngId, labelIndex } = getNgIdLabelIndexFromRegion({ region }) - const existingEntry = accumulatorMap.get(ngId) - if (existingEntry) { existingEntry.push(labelIndex) } else { accumulatorMap.set(ngId, [ labelIndex ]) } - } - const cRegionObj = {} - for (const [key, arr] of accumulatorMap) { - cRegionObj[key] = arr.map(n => encodeNumber(n)).join(separator) + const { templateSelected, parcellationSelected, navigation, regionsSelected, standaloneVolumes } = viewerState + + if (standaloneVolumes && Array.isArray(standaloneVolumes) && standaloneVolumes.length > 0) { + searchParam.set('standaloneVolumes', JSON.stringify(standaloneVolumes)) + } else { + if (!templateSelected) { throw new Error(CVT_STATE_TO_SEARCHPARAM_ERROR.TEMPLATE_NOT_SELECTED) } + + // encoding states + searchParam.set('templateSelected', templateSelected.name) + if (!!parcellationSelected) searchParam.set('parcellationSelected', parcellationSelected.name) + + // encoding selected regions + const accumulatorMap = new Map<string, number[]>() + for (const region of regionsSelected) { + const { ngId, labelIndex } = getNgIdLabelIndexFromRegion({ region }) + const existingEntry = accumulatorMap.get(ngId) + if (existingEntry) { existingEntry.push(labelIndex) } else { accumulatorMap.set(ngId, [ labelIndex ]) } + } + const cRegionObj = {} + for (const [key, arr] of accumulatorMap) { + cRegionObj[key] = arr.map(n => encodeNumber(n)).join(separator) + } + if (Object.keys(cRegionObj).length > 0) searchParam.set('cRegionsSelected', JSON.stringify(cRegionObj)) } - if (Object.keys(cRegionObj).length > 0) searchParam.set('cRegionsSelected', JSON.stringify(cRegionObj)) - // encoding navigation if (navigation) { const { orientation, perspectiveOrientation, perspectiveZoom, position, zoom } = navigation @@ -59,7 +62,7 @@ export const cvtStateToSearchParam = (state: IavRootStoreInterface): URLSearchPa } // encode nifti layers - if (!!templateSelected.nehubaConfig) { + if (templateSelected && templateSelected.nehubaConfig) { const initialNgState = templateSelected.nehubaConfig.dataset.initialNgState const { layers } = ngViewerState const additionalLayers = layers.filter(layer => @@ -82,98 +85,141 @@ export const cvtStateToSearchParam = (state: IavRootStoreInterface): URLSearchPa return searchParam } -export const cvtSearchParamToState = (searchparams: URLSearchParams, state: IavRootStoreInterface, callback?: (error: any) => void): IavRootStoreInterface => { +const { TEMPLATE_NOT_FOUND, TEMPALTE_NOT_SET, PARCELLATION_NOT_UPDATED } = PARSING_SEARCHPARAM_ERROR +const { UNKNOWN_PARCELLATION, DECODE_CIPHER_ERROR } = PARSING_SEARCHPARAM_WARNING - const returnState = JSON.parse(JSON.stringify(state)) as IavRootStoreInterface +const parseSearchParamForTemplateParcellationRegion = (searchparams: URLSearchParams, state: IavRootStoreInterface, cb?: (arg: any) => void) => { - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ - const warningCb = callback || (() => {}) - const { TEMPLATE_NOT_FOUND, TEMPALTE_NOT_SET, PARCELLATION_NOT_UPDATED } = PARSING_SEARCHPARAM_ERROR - const { UNKNOWN_PARCELLATION, DECODE_CIPHER_ERROR } = PARSING_SEARCHPARAM_WARNING - const { fetchedTemplates } = state.viewerState + /** + * TODO if search param of either template or parcellation is incorrect, wrong things are searched + */ - const searchedTemplatename = (() => { - const param = searchparams.get('templateSelected') - if (param === 'Allen Mouse') { return `Allen adult mouse brain reference atlas V3` } - if (param === 'Waxholm Rat V2.0') { return 'Waxholm Space rat brain atlas v.2.0' } - return param - })() - const searchedParcellationName = (() => { - const param = searchparams.get('parcellationSelected') - if (param === 'Allen Mouse Brain Atlas') { return 'Allen adult mouse brain reference atlas V3 Brain Atlas' } - if (param === 'Whole Brain (v2.0)') { return 'Waxholm Space rat brain atlas v.2.0' } - return param - })() - if (!searchedTemplatename) { throw new Error(TEMPALTE_NOT_SET) } + const templateSelected = (() => { + const { fetchedTemplates } = state.viewerState - const templateToLoad = fetchedTemplates.find(template => template.name === searchedTemplatename) - if (!templateToLoad) { throw new Error(TEMPLATE_NOT_FOUND) } + const searchedName = (() => { + const param = searchparams.get('templateSelected') + if (param === 'Allen Mouse') { return `Allen adult mouse brain reference atlas V3` } + if (param === 'Waxholm Rat V2.0') { return 'Waxholm Space rat brain atlas v.2.0' } + return param + })() - /** - * TODO if search param of either template or parcellation is incorrect, wrong things are searched - */ - const parcellationToLoad = templateToLoad.parcellations.find(parcellation => parcellation.name === searchedParcellationName) - if (!parcellationToLoad) { warningCb({ type: UNKNOWN_PARCELLATION }) } + if (!searchedName) { throw new Error(TEMPALTE_NOT_SET) } + const templateToLoad = fetchedTemplates.find(template => template.name === searchedName) + if (!templateToLoad) { throw new Error(TEMPLATE_NOT_FOUND) } + return templateToLoad + })() - const { viewerState } = returnState - viewerState.templateSelected = templateToLoad - viewerState.parcellationSelected = parcellationToLoad || templateToLoad.parcellations[0] + const parcellationSelected = (() => { + const searchedName = (() => { + const param = searchparams.get('parcellationSelected') + if (param === 'Allen Mouse Brain Atlas') { return 'Allen adult mouse brain reference atlas V3 Brain Atlas' } + if (param === 'Whole Brain (v2.0)') { return 'Waxholm Space rat brain atlas v.2.0' } + return param + })() + const parcellationToLoad = templateSelected.parcellations.find(parcellation => parcellation.name === searchedName) + if (!parcellationToLoad) { cb && cb({ type: UNKNOWN_PARCELLATION }) } + return parcellationToLoad || templateSelected.parcellations[0] + })() /* selected regions */ - // TODO deprecate. Fallback (defaultNgId) (should) already exist - // if (!viewerState.parcellationSelected.updated) throw new Error(PARCELLATION_NOT_UPDATED) + const regionsSelected = (() => { - const getRegionFromlabelIndexId = getGetRegionFromLabelIndexId({ parcellation: viewerState.parcellationSelected }) - /** - * either or both parcellationToLoad and .regions maybe empty - */ - /** - * backwards compatibility - */ - const selectedRegionsParam = searchparams.get('regionsSelected') - if (selectedRegionsParam) { - const ids = selectedRegionsParam.split('_') + // TODO deprecate. Fallback (defaultNgId) (should) already exist + // if (!viewerState.parcellationSelected.updated) throw new Error(PARCELLATION_NOT_UPDATED) - viewerState.regionsSelected = ids.map(labelIndexId => getRegionFromlabelIndexId({ labelIndexId })) - } + const getRegionFromlabelIndexId = getGetRegionFromLabelIndexId({ parcellation: parcellationSelected }) + /** + * either or both parcellationToLoad and .regions maybe empty + */ + /** + * backwards compatibility + */ + const selectedRegionsParam = searchparams.get('regionsSelected') + if (selectedRegionsParam) { + const ids = selectedRegionsParam.split('_') - const cRegionsSelectedParam = searchparams.get('cRegionsSelected') - if (cRegionsSelectedParam) { - try { - const json = JSON.parse(cRegionsSelectedParam) - - const selectRegionIds = [] - - for (const ngId in json) { - const val = json[ngId] - const labelIndicies = val.split(separator).map(n => { - try { - return decodeToNumber(n) - } catch (e) { - /** - * TODO poisonsed encoded char, send error message - */ - warningCb({ type: DECODE_CIPHER_ERROR, message: `cRegionSelectionParam is malformed: cannot decode ${n}` }) - return null + return ids.map(labelIndexId => getRegionFromlabelIndexId({ labelIndexId })) + } + + const cRegionsSelectedParam = searchparams.get('cRegionsSelected') + if (cRegionsSelectedParam) { + try { + const json = JSON.parse(cRegionsSelectedParam) + + const selectRegionIds = [] + + for (const ngId in json) { + const val = json[ngId] + const labelIndicies = val.split(separator).map(n => { + try { + return decodeToNumber(n) + } catch (e) { + /** + * TODO poisonsed encoded char, send error message + */ + cb && cb({ type: DECODE_CIPHER_ERROR, message: `cRegionSelectionParam is malformed: cannot decode ${n}` }) + return null + } + }).filter(v => !!v) + for (const labelIndex of labelIndicies) { + selectRegionIds.push( generateLabelIndexId({ ngId, labelIndex }) ) } - }).filter(v => !!v) - for (const labelIndex of labelIndicies) { - selectRegionIds.push( generateLabelIndexId({ ngId, labelIndex }) ) } - } - viewerState.regionsSelected = selectRegionIds.map(labelIndexId => getRegionFromlabelIndexId({ labelIndexId })) + return selectRegionIds.map(labelIndexId => getRegionFromlabelIndexId({ labelIndexId })) - } catch (e) { - /** - * parsing cRegionSelected error - */ - warningCb({ type: DECODE_CIPHER_ERROR, message: `parsing cRegionSelected error ${e.toString()}` }) + } catch (e) { + /** + * parsing cRegionSelected error + */ + cb && cb({ type: DECODE_CIPHER_ERROR, message: `parsing cRegionSelected error ${e.toString()}` }) + } } + return [] + })() + + return { + templateSelected, + parcellationSelected, + regionsSelected } +} + +export const cvtSearchParamToState = (searchparams: URLSearchParams, state: IavRootStoreInterface, callback?: (error: any) => void): IavRootStoreInterface => { + const returnState = JSON.parse(JSON.stringify(state)) as IavRootStoreInterface + + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + const warningCb = callback || (() => {}) + + const { viewerState } = returnState + + const searchParamStandaloneVolumes = (() => { + const param = searchparams.get('standaloneVolumes') + if (!param) { + return null + } + const arr = JSON.parse(param) + if (Array.isArray(arr)) { + return arr + } + else { + throw new Error(`param standaloneVolumes does not parse to array: ${param}`) + } + })() + + if (!!searchParamStandaloneVolumes) { + viewerState.standaloneVolumes = searchParamStandaloneVolumes + } else { + const { templateSelected, parcellationSelected, regionsSelected } = parseSearchParamForTemplateParcellationRegion(searchparams, state, warningCb) + viewerState.templateSelected = templateSelected + viewerState.parcellationSelected = parcellationSelected + viewerState.regionsSelected = regionsSelected + } + /* now that the parcellation is loaded, load the navigation state */ /* what to do with malformed navigation? */ diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index 6c0ac15d27a0017143f0b5220e94f3e2f47850e0..2c4089380ff0841dc8f82361f5ce6e53e3040eda 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -2,14 +2,14 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store' import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, shareReplay, startWith, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, shareReplay, startWith, withLatestFrom, mapTo } from 'rxjs/operators'; import { IUserLandmark } from 'src/atlasViewer/atlasViewer.apiService.service'; import { INgLayerInterface } from 'src/atlasViewer/atlasViewer.component'; import { getViewer } from 'src/util/fn'; import { LoggingService } from '../logging.service'; -import { generateLabelIndexId, IavRootStoreInterface } from '../stateStore.service'; +import { generateLabelIndexId, IavRootStoreInterface, viewerState } from '../stateStore.service'; import { GENERAL_ACTION_TYPES } from '../stateStore.service' -import { MOUSEOVER_USER_LANDMARK } from './uiState.store'; +import { MOUSEOVER_USER_LANDMARK, CLOSE_SIDE_PANEL } from './uiState.store'; export interface StateInterface { fetchedTemplates: any[] @@ -26,6 +26,8 @@ export interface StateInterface { loadedNgLayers: INgLayerInterface[] connectivityRegion: string | null + + standaloneVolumes: any[] } export interface ActionInterface extends Action { @@ -62,6 +64,8 @@ export const defaultState: StateInterface = { parcellationSelected: null, templateSelected: null, connectivityRegion: '', + + standaloneVolumes: [] } export const getStateStore = ({ state = defaultState } = {}) => (prevState: Partial<StateInterface> = state, action: ActionInterface) => { @@ -85,6 +89,11 @@ export const getStateStore = ({ state = defaultState } = {}) => (prevState: Part ? prevState.dedicatedView.filter(dv => dv !== action.dedicatedView) : [], } + case CLEAR_STANDALONE_VOLUMES: + return { + ...prevState, + standaloneVolumes: [] + } case NEWVIEWER: { const { selectParcellation: parcellation } = action @@ -224,6 +233,7 @@ export const ADD_TO_REGIONS_SELECTION_WITH_IDS = `ADD_TO_REGIONS_SELECTION_WITH_ export const NEHUBA_LAYER_CHANGED = `NEHUBA_LAYER_CHANGED` export const SET_CONNECTIVITY_REGION = `SET_CONNECTIVITY_REGION` export const CLEAR_CONNECTIVITY_REGION = `CLEAR_CONNECTIVITY_REGION` +export const CLEAR_STANDALONE_VOLUMES = `CLEAR_STANDALONE_VOLUMES` @Injectable({ providedIn: 'root', @@ -235,8 +245,12 @@ export class ViewerStateUseEffect { private store$: Store<IavRootStoreInterface>, private log: LoggingService, ) { - this.currentLandmarks$ = this.store$.pipe( + + const viewerState$ = this.store$.pipe( select('viewerState'), + shareReplay(1) + ) + this.currentLandmarks$ = viewerState$.pipe( select('userLandmarks'), shareReplay(1), ) @@ -331,8 +345,7 @@ export class ViewerStateUseEffect { this.doubleClickOnViewerToggleRegions$ = doubleClickOnViewer$.pipe( filter(({ segments }) => segments && segments.length > 0), - withLatestFrom(this.store$.pipe( - select('viewerState'), + withLatestFrom(viewerState$.pipe( select('regionsSelected'), distinctUntilChanged(), startWith([]), @@ -356,8 +369,7 @@ export class ViewerStateUseEffect { this.doubleClickOnViewerToggleLandmark$ = doubleClickOnViewer$.pipe( filter(({ landmark }) => !!landmark), - withLatestFrom(this.store$.pipe( - select('viewerState'), + withLatestFrom(viewerState$.pipe( select('landmarksSelected'), startWith([]), )), @@ -379,10 +391,21 @@ export class ViewerStateUseEffect { this.doubleClickOnViewerToogleUserLandmark$ = doubleClickOnViewer$.pipe( filter(({ userLandmark }) => userLandmark), ) + + this.onStandAloneVolumesExistCloseMatDrawer$ = viewerState$.pipe( + select('standaloneVolumes'), + filter(v => v && Array.isArray(v) && v.length > 0), + mapTo({ + type: CLOSE_SIDE_PANEL + }) + ) } private currentLandmarks$: Observable<any[]> + @Effect() + public onStandAloneVolumesExistCloseMatDrawer$: Observable<any> + @Effect() public mouseoverUserLandmarks: Observable<any> diff --git a/src/ui/layerbrowser/layerDetail/layerDetail.component.spec.ts b/src/ui/layerbrowser/layerDetail/layerDetail.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d88ec67995546615a0a3e9d6c261b11495460cb --- /dev/null +++ b/src/ui/layerbrowser/layerDetail/layerDetail.component.spec.ts @@ -0,0 +1,259 @@ +import { LayerDetailComponent } from './layerDetail.component' +import { async, TestBed } from '@angular/core/testing' +import { NgLayersService } from '../ngLayerService.service' +import { UIModule } from 'src/ui/ui.module' +import { By } from '@angular/platform-browser' + +const getSpies = (service: NgLayersService) => { + const lowThMapGetSpy = spyOn(service.lowThresholdMap, 'get').and.callThrough() + const highThMapGetSpy = spyOn(service.highThresholdMap, 'get').and.callThrough() + const brightnessMapGetSpy = spyOn(service.brightnessMap, 'get').and.callThrough() + const contractMapGetSpy = spyOn(service.contrastMap, 'get').and.callThrough() + const removeBgMapGetSpy = spyOn(service.removeBgMap, 'get').and.callThrough() + + const lowThMapSetSpy = spyOn(service.lowThresholdMap, 'set').and.callThrough() + const highThMapSetSpy = spyOn(service.highThresholdMap, 'set').and.callThrough() + const brightnessMapSetSpy = spyOn(service.brightnessMap, 'set').and.callThrough() + const contrastMapSetSpy = spyOn(service.contrastMap, 'set').and.callThrough() + const removeBgMapSetSpy = spyOn(service.removeBgMap, 'set').and.callThrough() + + return { + lowThMapGetSpy, + highThMapGetSpy, + brightnessMapGetSpy, + contractMapGetSpy, + removeBgMapGetSpy, + lowThMapSetSpy, + highThMapSetSpy, + brightnessMapSetSpy, + contrastMapSetSpy, + removeBgMapSetSpy, + } +} + +const getCtrl = () => { + const lowThSlider = By.css('mat-slider[aria-label="Set lower threshold"]') + const highThSlider = By.css('mat-slider[aria-label="Set higher threshold"]') + const brightnessSlider = By.css('mat-slider[aria-label="Set brightness"]') + const contrastSlider = By.css('mat-slider[aria-label="Set contrast"]') + const removeBgSlideToggle = By.css('mat-slide-toggle[aria-label="Remove background"]') + return { + lowThSlider, + highThSlider, + brightnessSlider, + contrastSlider, + removeBgSlideToggle, + } +} + +const getSliderChangeTest = ctrlName => describe(`testing: ${ctrlName}`, () => { + + it('on change, calls window', () => { + const service = TestBed.inject(NgLayersService) + const spies = getSpies(service) + + const fixture = TestBed.createComponent(LayerDetailComponent) + const layerName = `hello-kitty` + fixture.componentInstance.layerName = layerName + const triggerChSpy = spyOn(fixture.componentInstance, 'triggerChange') + const ctrls = getCtrl() + + const sLower = fixture.debugElement.query( ctrls[`${ctrlName}Slider`] ) + sLower.componentInstance.input.emit({ value: 0.5 }) + expect(spies[`${ctrlName}MapSetSpy`]).toHaveBeenCalledWith(layerName, 0.5) + expect(triggerChSpy).toHaveBeenCalled() + }) +}) + +describe('layerDetail.component.ts', () => { + describe('LayerDetailComponent', () => { + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + UIModule + ], + providers: [ + NgLayersService + ] + }).compileComponents() + })) + + describe('basic', () => { + + it('should be created', () => { + const fixture = TestBed.createComponent(LayerDetailComponent) + const element = fixture.debugElement.componentInstance + expect(element).toBeTruthy() + }) + + it('on bind input, if input is truthy, calls get on layerService maps', () => { + const service = TestBed.inject(NgLayersService) + const { + brightnessMapGetSpy, + contractMapGetSpy, + highThMapGetSpy, + lowThMapGetSpy, + removeBgMapGetSpy + } = getSpies(service) + + const layerName = `hello-kitty` + const fixture = TestBed.createComponent(LayerDetailComponent) + fixture.componentInstance.layerName = layerName + fixture.componentInstance.ngOnChanges() + fixture.detectChanges() + expect(brightnessMapGetSpy).toHaveBeenCalledWith(layerName) + expect(contractMapGetSpy).toHaveBeenCalledWith(layerName) + expect(highThMapGetSpy).toHaveBeenCalledWith(layerName) + expect(lowThMapGetSpy).toHaveBeenCalledWith(layerName) + expect(removeBgMapGetSpy).toHaveBeenCalledWith(layerName) + }) + + it('on bind input, if input is falsy, does not call layerService map get', () => { + const service = TestBed.inject(NgLayersService) + const { + brightnessMapGetSpy, + contractMapGetSpy, + highThMapGetSpy, + lowThMapGetSpy, + removeBgMapGetSpy + } = getSpies(service) + + const layerName = null + const fixture = TestBed.createComponent(LayerDetailComponent) + fixture.componentInstance.layerName = layerName + fixture.componentInstance.ngOnChanges() + fixture.detectChanges() + expect(brightnessMapGetSpy).not.toHaveBeenCalled() + expect(contractMapGetSpy).not.toHaveBeenCalled() + expect(highThMapGetSpy).not.toHaveBeenCalled() + expect(lowThMapGetSpy).not.toHaveBeenCalled() + expect(removeBgMapGetSpy).not.toHaveBeenCalled() + }) + + }) + + const testingSlidersCtrl = [ + 'lowTh', + 'highTh', + 'brightness', + 'contrast', + ] + + for (const sliderCtrl of testingSlidersCtrl ) { + getSliderChangeTest(sliderCtrl) + } + + // TODO test remove bg toggle + + describe('testing: removeBG toggle', () => { + it('on change, calls window', () => { + + const service = TestBed.inject(NgLayersService) + const { removeBgMapSetSpy } = getSpies(service) + + const fixture = TestBed.createComponent(LayerDetailComponent) + const triggerChSpy = spyOn(fixture.componentInstance, 'triggerChange') + const layerName = `hello-kitty` + fixture.componentInstance.layerName = layerName + + const { removeBgSlideToggle } = getCtrl() + const bgToggle = fixture.debugElement.query( removeBgSlideToggle ) + bgToggle.componentInstance.change.emit({ checked: true }) + expect(removeBgMapSetSpy).toHaveBeenCalledWith('hello-kitty', true) + expect(triggerChSpy).toHaveBeenCalled() + + removeBgMapSetSpy.calls.reset() + triggerChSpy.calls.reset() + expect(removeBgMapSetSpy).not.toHaveBeenCalled() + expect(triggerChSpy).not.toHaveBeenCalled() + + bgToggle.componentInstance.change.emit({ checked: false }) + + expect(removeBgMapSetSpy).toHaveBeenCalledWith('hello-kitty', false) + expect(triggerChSpy).toHaveBeenCalled() + }) + }) + + describe('triggerChange', () => { + it('should throw if viewer is not defined', () => { + const fixutre = TestBed.createComponent(LayerDetailComponent) + expect(function(){ + fixutre.componentInstance.triggerChange() + }).toThrowError('viewer is not defined') + }) + + it('should throw if layer is not found', () => { + const fixutre = TestBed.createComponent(LayerDetailComponent) + const layerName = `test-kitty` + const fakeGetLayerByName = jasmine.createSpy().and.returnValue(undefined) + const fakeNgInstance = { + layerManager: { + getLayerByName: fakeGetLayerByName + } + } + fixutre.componentInstance.layerName = layerName + fixutre.componentInstance.ngViewerInstance = fakeNgInstance + + expect(function(){ + fixutre.componentInstance.triggerChange() + }).toThrowError(`layer with name: ${layerName}, not found.`) + }) + + it('should throw if layer.layer.fragmentMain is undefined', () => { + + const fixutre = TestBed.createComponent(LayerDetailComponent) + const layerName = `test-kitty` + + const fakeLayer = { + hello: 'world' + } + const fakeGetLayerByName = jasmine.createSpy().and.returnValue(fakeLayer) + const fakeNgInstance = { + layerManager: { + getLayerByName: fakeGetLayerByName + } + } + fixutre.componentInstance.layerName = layerName + fixutre.componentInstance.ngViewerInstance = fakeNgInstance + + expect(function(){ + fixutre.componentInstance.triggerChange() + }).toThrowError(`layer.fragmentMain is not defined... is this an image layer?`) + }) + + it('should call getShader and restoreState if all goes right', () => { + + const replacementShader = `blabla ahder` + + const service = TestBed.inject(NgLayersService) + const getShaderSpy = spyOn(service, 'getShader').and.returnValue(replacementShader) + const fixutre = TestBed.createComponent(LayerDetailComponent) + const layerName = `test-kitty` + + const fakeRestoreState = jasmine.createSpy() + const fakeLayer = { + layer: { + fragmentMain: { + restoreState: fakeRestoreState + } + } + } + const fakeGetLayerByName = jasmine.createSpy().and.returnValue(fakeLayer) + const fakeNgInstance = { + layerManager: { + getLayerByName: fakeGetLayerByName + } + } + fixutre.componentInstance.layerName = layerName + fixutre.componentInstance.ngViewerInstance = fakeNgInstance + + fixutre.componentInstance.triggerChange() + + expect(fakeGetLayerByName).toHaveBeenCalledWith(layerName) + expect(getShaderSpy).toHaveBeenCalled() + expect(fakeRestoreState).toHaveBeenCalledWith(replacementShader) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/ui/layerbrowser/layerDetail/layerDetail.component.ts b/src/ui/layerbrowser/layerDetail/layerDetail.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6efeafc863f1d4a9db30254b31c9d3de5249d4 --- /dev/null +++ b/src/ui/layerbrowser/layerDetail/layerDetail.component.ts @@ -0,0 +1,85 @@ +import { Component, Input, OnChanges, ChangeDetectionStrategy } from "@angular/core"; +import { NgLayersService } from "../ngLayerService.service"; +import { MatSliderChange } from "@angular/material/slider"; +import { MatSlideToggleChange } from "@angular/material/slide-toggle"; + +@Component({ + selector: 'layer-detail-cmp', + templateUrl: './layerDetail.template.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class LayerDetailComponent implements OnChanges{ + @Input() + layerName: string + + @Input() + ngViewerInstance: any + + constructor(private layersService: NgLayersService){ + + } + + ngOnChanges(){ + if (!this.layerName) return + this.lowThreshold = this.layersService.lowThresholdMap.get(this.layerName) || this.lowThreshold + this.highThreshold = this.layersService.highThresholdMap.get(this.layerName) || this.highThreshold + this.brightness = this.layersService.brightnessMap.get(this.layerName) || this.brightness + this.contrast = this.layersService.contrastMap.get(this.layerName) || this.contrast + this.removeBg = this.layersService.removeBgMap.get(this.layerName) || this.removeBg + } + + public lowThreshold: number = 0 + public highThreshold: number = 1 + public brightness: number = 0 + public contrast: number = 0 + public removeBg: boolean = false + + handleChange(mode: 'low' | 'high' | 'brightness' | 'contrast', event: MatSliderChange){ + switch(mode) { + case 'low': + this.layersService.lowThresholdMap.set(this.layerName, event.value) + this.lowThreshold = event.value + break; + case 'high': + this.layersService.highThresholdMap.set(this.layerName, event.value) + this.highThreshold = event.value + break; + case 'brightness': + this.layersService.brightnessMap.set(this.layerName, event.value) + this.brightness = event.value + break; + case 'contrast': + this.layersService.contrastMap.set(this.layerName, event.value) + this.contrast = event.value + break; + default: return + } + this.triggerChange() + } + + handleToggleBg(event: MatSlideToggleChange){ + this.layersService.removeBgMap.set(this.layerName, event.checked) + this.removeBg = event.checked + this.triggerChange() + } + + triggerChange(){ + if (!this.viewer) throw new Error(`viewer is not defined`) + const layer = this.viewer.layerManager.getLayerByName(this.layerName) + if (!layer) throw new Error(`layer with name: ${this.layerName}, not found.`) + if (! (layer.layer?.fragmentMain?.restoreState) ) throw new Error(`layer.fragmentMain is not defined... is this an image layer?`) + const shader = this.layersService.getShader( + this.lowThreshold, + this.highThreshold, + this.brightness, + this.contrast, + this.removeBg + ) + layer.layer.fragmentMain.restoreState(shader) + } + + get viewer(){ + return this.ngViewerInstance || (window as any).viewer + } +} diff --git a/src/ui/layerbrowser/layerDetail/layerDetail.template.html b/src/ui/layerbrowser/layerDetail/layerDetail.template.html new file mode 100644 index 0000000000000000000000000000000000000000..ef47a9b904dcd3a1c72d9380b21073d4df62965b --- /dev/null +++ b/src/ui/layerbrowser/layerDetail/layerDetail.template.html @@ -0,0 +1,79 @@ +<div class="d-flex flex-column"> + <div> + <mat-label> + Low threshold + </mat-label> + <mat-slider + aria-label="Set lower threshold" + (input)="handleChange('low', $event)" + [value]="lowThreshold" + min="0" + max="1" + step="0.001"> + </mat-slider> + <mat-label> + {{ lowThreshold }} + </mat-label> + </div> + + <div> + <mat-label> + High threshold + </mat-label> + <mat-slider + aria-label="Set higher threshold" + (input)="handleChange('high', $event)" + [value]="highThreshold" + min="0" + max="1" + step="0.001"> + </mat-slider> + <mat-label> + {{ highThreshold }} + </mat-label> + </div> + <div> + <mat-label> + Brightness + </mat-label> + <mat-slider + aria-label="Set brightness" + (input)="handleChange('brightness', $event)" + [value]="brightness" + min="-1" + max="1" + step="0.01"> + </mat-slider> + <mat-label> + {{ brightness }} + </mat-label> + </div> + <div> + <mat-label> + Contrast + </mat-label> + <mat-slider + aria-label="Set contrast" + (input)="handleChange('contrast', $event)" + [value]="contrast" + min="-1" + max="1" + step="0.01"> + </mat-slider> + <mat-label> + {{ contrast }} + </mat-label> + </div> + + <div> + <mat-label> + Remove background + </mat-label> + <mat-slide-toggle + aria-label="Remove background" + (change)="handleToggleBg($event)" + [value]="removeBg"> + + </mat-slide-toggle> + </div> +</div> \ No newline at end of file diff --git a/src/ui/layerbrowser/layerbrowser.template.html b/src/ui/layerbrowser/layerbrowser.template.html index 014df70a247ab30285f3a9b8ab9b1c22140e33aa..4c50cf9506371c4c236dc8d89dfd10e6cd1d6942 100644 --- a/src/ui/layerbrowser/layerbrowser.template.html +++ b/src/ui/layerbrowser/layerbrowser.template.html @@ -4,70 +4,97 @@ <!-- in FF, the element changes, and focusout event is never fired properly --> <ng-container *ngIf="nonBaseNgLayers$ | async as nonBaseNgLayers; else noLayerPlaceHolder"> - <mat-list *ngIf="nonBaseNgLayers.length > 0; else noLayerPlaceHolder"> - <mat-list-item *ngFor="let ngLayer of nonBaseNgLayers" class="matListItem"> + <mat-accordion *ngIf="nonBaseNgLayers.length > 0; else noLayerPlaceHolder" + [multi]="true" + displayMode="flat"> + <mat-expansion-panel + [disabled]="true" + *ngFor="let ngLayer of nonBaseNgLayers" + class="layer-expansion-unit" + #expansionPanel> + <mat-expansion-panel-header> + <div class="align-items-center d-flex flex-nowrap pr-4"> + <!-- toggle opacity --> + <div matTooltip="opacity"> - <!-- toggle opacity --> - <div matTooltip="opacity"> + <mat-slider + [disabled]="!ngLayer.visible" + min="0" + max="1" + (input)="changeOpacity(ngLayer.name, $event)" + [value]="viewer | getInitialLayerOpacityPipe: ngLayer.name" + step="0.01"> - <mat-slider - [disabled]="!ngLayer.visible" - min="0" - max="1" - (input)="changeOpacity(ngLayer.name, $event)" - [value]="viewer | getInitialLayerOpacityPipe: ngLayer.name" - step="0.01"> + </mat-slider> + </div> - </mat-slider> - </div> + <!-- toggle visibility --> - <!-- toggle visibility --> + <button + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layer cannot be hidden' : 'toggle visibility'" + (mousedown)="toggleVisibility(ngLayer)" + mat-icon-button + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [color]="ngLayer.visible ? 'primary' : null"> + <i [ngClass]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> + </i> + </button> - <button - [matTooltipPosition]="matTooltipPosition" - [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layer cannot be hidden' : 'toggle visibility'" - (mousedown)="toggleVisibility(ngLayer)" - mat-icon-button - [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" - [color]="ngLayer.visible ? 'primary' : null"> - <i [ngClass]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> - </i> - </button> + <!-- advanced mode only: toggle force show segmentation --> + <button + *ngIf="advancedMode" + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" + (mousedown)="toggleForceShowSegment(ngLayer)" + mat-icon-button> + <i + class="fas" + [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> - <!-- advanced mode only: toggle force show segmentation --> - <button - *ngIf="advancedMode" - [matTooltipPosition]="matTooltipPosition" - [matTooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" - (mousedown)="toggleForceShowSegment(ngLayer)" - mat-icon-button> - <i - class="fas" - [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> + </i> + </button> - </i> - </button> + <!-- remove layer --> + <button + color="warn" + mat-icon-button + (mousedown)="removeLayer(ngLayer)" + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layers cannot be removed' : 'remove layer'"> + <i [class]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : 'fas fa-trash'"> + </i> + </button> - <!-- remove layer --> - <button - color="warn" - mat-icon-button - (mousedown)="removeLayer(ngLayer)" - [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" - [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layers cannot be removed' : 'remove layer'"> - <i [class]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : 'fas fa-trash'"> - </i> - </button> + <!-- layer description --> + <mat-label + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.name | getFilenamePipe " + [class]="((darktheme$ | async) ? 'text-light' : 'text-dark') + ' text-truncate'"> + {{ ngLayer.name | getFilenamePipe }} + </mat-label> - <!-- layer description --> - <div - [matTooltipPosition]="matTooltipPosition" - [matTooltip]="ngLayer.name | getFilenamePipe " - [class]="((darktheme$ | async) ? 'text-light' : 'text-dark') + ' text-truncate'"> - {{ ngLayer.name | getFilenamePipe }} - </div> - </mat-list-item> - </mat-list> + <button mat-icon-button + (click)="expansionPanel.toggle()"> + <ng-container *ngIf="expansionPanel.expanded; else btnIconAlt"> + <i class="fas fa-chevron-up"></i> + </ng-container> + + <ng-template #btnIconAlt> + <i class="fas fa-chevron-down"></i> + </ng-template> + </button> + + </div> + </mat-expansion-panel-header> + + <ng-template matExpansionPanelContent> + <layer-detail-cmp [layerName]="ngLayer.name"> + </layer-detail-cmp> + </ng-template> + + </mat-expansion-panel> + </mat-accordion> </ng-container> <!-- fall back when no layers are showing --> diff --git a/src/ui/layerbrowser/ngLayerService.service.ts b/src/ui/layerbrowser/ngLayerService.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fefb5b7c4675cfac110a02c5008f462c0de5592f --- /dev/null +++ b/src/ui/layerbrowser/ngLayerService.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@angular/core"; + +const setGetShaderFn = (normalizedIncomingColor) => (lowerThreshold, upperThreshold, brightness, contrast, removeBg: boolean) => ` +void main() { + float raw_x = toNormalized(getDataValue()); + float x = (raw_x - ${lowerThreshold.toFixed(5)}) / (${(upperThreshold - lowerThreshold).toFixed(5)}) ${brightness > 0 ? '+' : '-'} ${Math.abs(brightness).toFixed(5)}; + + ${ removeBg ? 'if(x>1.0){ emitTransparent(); }else if (x<0.0){ emitTransparent(); }else{' : '' } + + emitRGB(vec3( + x * ${normalizedIncomingColor[0].toFixed(5)}, x * ${normalizedIncomingColor[1].toFixed(5)}, x * ${normalizedIncomingColor[2].toFixed(5)}) + * exp(${contrast.toFixed(5)}) + ); + + ${ removeBg ? '}' : '' } + +} +` + +@Injectable({ + providedIn: 'root' +}) + +export class NgLayersService{ + public lowThresholdMap: Map<string, number> = new Map() + public highThresholdMap: Map<string, number> = new Map() + public brightnessMap: Map<string, number> = new Map() + public contrastMap: Map<string, number> = new Map() + public removeBgMap: Map<string, boolean> = new Map() + public getShader: (low: number, high: number, brightness: number, contrast: number, removeBg: boolean) => string = setGetShaderFn([1, 1, 1]) +} diff --git a/src/ui/nehubaContainer/nehuba.module.ts b/src/ui/nehubaContainer/nehuba.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..39e0fda1b916635dbb2555ff0b03fc058fd5f5bc --- /dev/null +++ b/src/ui/nehubaContainer/nehuba.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from "@angular/core"; +import { NehubaViewerContainerDirective } from './nehubaViewerInterface/nehubaViewerInterface.directive' +@NgModule({ + imports: [ + + ], + declarations: [ + NehubaViewerContainerDirective + ], + exports: [ + NehubaViewerContainerDirective + ] +}) + +export class NehubaModule{} diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index 5aad100330017ba1a7902110afa48c5aac00258d..9dd4431b64b488c3cae0ce9725de120e050d2d12 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -1,6 +1,6 @@ -import { Component, ComponentFactory, ComponentFactoryResolver, ComponentRef, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, ViewContainerRef, ChangeDetectorRef } from "@angular/core"; +import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, ChangeDetectorRef, Output, EventEmitter } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { combineLatest, fromEvent, merge, Observable, of, Subscription, from } from "rxjs"; +import { combineLatest, fromEvent, merge, Observable, of, Subscription } from "rxjs"; import { pipeFromArray } from "rxjs/internal/util/pipe"; import { buffer, @@ -24,7 +24,6 @@ import { import { LoggingService } from "src/services/logging.service"; import { FOUR_PANEL, H_ONE_THREE, NEHUBA_READY, NG_VIEWER_ACTION_TYPES, SINGLE_PANEL, V_ONE_THREE } from "src/services/state/ngViewerState.store"; import { MOUSE_OVER_SEGMENTS } from "src/services/state/uiState.store"; -import { StateInterface as ViewerConfigStateInterface } from "src/services/state/viewerConfig.store"; import { NEHUBA_LAYER_CHANGED, SELECT_REGIONS_WITH_ID, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store"; import { ADD_NG_LAYER, CHANGE_NAVIGATION, generateLabelIndexId, getMultiNgIdsRegionsLabelIndexMap, getNgIds, ILandmark, IOtherLandmarkGeometry, IPlaneLandmarkGeometry, IPointLandmarkGeometry, isDefined, MOUSE_OVER_LANDMARK, NgViewerStateInterface, REMOVE_NG_LAYER, safeFilter, ViewerStateInterface } from "src/services/stateStore.service"; import { getExportNehuba, isSame } from "src/util/fn"; @@ -32,7 +31,8 @@ import { AtlasViewerAPIServices, IUserLandmark } from "../../atlasViewer/atlasVi import { AtlasViewerConstantsServices } from "../../atlasViewer/atlasViewer.constantService.service"; import { timedValues } from "../../util/generator"; import { computeDistance, NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component"; -import { getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, getNavigationStateFromConfig } from "./util"; +import { getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, getNavigationStateFromConfig, calculateSliceZoomFactor } from "./util"; +import { NehubaViewerContainerDirective } from "./nehubaViewerInterface/nehubaViewerInterface.directive"; const isFirstRow = (cell: HTMLElement) => { const { parentElement: row } = cell @@ -75,13 +75,18 @@ const scanFn: (acc: [boolean, boolean, boolean], curr: CustomEvent) => [boolean, export class NehubaContainer implements OnInit, OnChanges, OnDestroy { - @ViewChild('container', {read: ViewContainerRef, static: true}) public container: ViewContainerRef + @ViewChild(NehubaViewerContainerDirective,{static: true}) + public nehubaContainerDirective: NehubaViewerContainerDirective - private nehubaViewerFactory: ComponentFactory<NehubaViewerUnit> + @Output() + public nehubaViewerLoaded: EventEmitter<boolean> = new EventEmitter() - public viewerLoaded: boolean = false + public handleViewerLoadedEvent(flag: boolean){ + this.viewerLoaded = flag + this.nehubaViewerLoaded.emit(flag) + } - private viewerPerformanceConfig$: Observable<ViewerConfigStateInterface> + public viewerLoaded: boolean = false private sliceViewLoadingMain$: Observable<[boolean, boolean, boolean]> public sliceViewLoading0$: Observable<boolean> @@ -110,7 +115,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public onHoverSegments$: Observable<any[]> - private navigationChanges$: Observable<any> public spatialResultsVisible$: Observable<boolean> private spatialResultsVisible: boolean = false @@ -123,17 +127,14 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public selectedParcellation: any | null - private cr: ComponentRef<NehubaViewerUnit> public nehubaViewer: NehubaViewerUnit private multiNgIdsRegionsLabelIndexMap: Map<string, Map<number, any>> = new Map() private landmarksLabelIndexMap: Map<number, any> = new Map() private landmarksNameMap: Map<string, number> = new Map() private subscriptions: Subscription[] = [] - private nehubaViewerSubscriptions: Subscription[] = [] public nanometersToOffsetPixelsFn: Array<(...arg) => any> = [] - private viewerConfig: Partial<ViewerConfigStateInterface> = {} private viewPanels: [HTMLElement, HTMLElement, HTMLElement, HTMLElement] = [null, null, null, null] public panelMode$: Observable<string> @@ -149,7 +150,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { constructor( private constantService: AtlasViewerConstantsServices, private apiService: AtlasViewerAPIServices, - private csf: ComponentFactoryResolver, private store: Store<ViewerStateInterface>, private elementRef: ElementRef, private log: LoggingService, @@ -158,18 +158,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { this.useMobileUI$ = this.constantService.useMobileUI$ - this.viewerPerformanceConfig$ = this.store.pipe( - select('viewerConfigState'), - /** - * TODO: this is only a bandaid fix. Technically, we should also implement - * logic to take the previously set config to apply oninit - */ - distinctUntilChanged(), - debounceTime(200), - tap(viewerConfig => this.viewerConfig = viewerConfig ), - filter(() => isDefined(this.nehubaViewer) && isDefined(this.nehubaViewer.nehubaViewer)), - ) - this.panelMode$ = this.store.pipe( select('ngViewerState'), select('panelMode'), @@ -196,8 +184,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { )), ) - this.nehubaViewerFactory = this.csf.resolveComponentFactory(NehubaViewerUnit) - this.templateSelected$ = this.store.pipe( select('viewerState'), select('templateSelected'), @@ -239,12 +225,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { debounceTime(300), ) - this.navigationChanges$ = this.store.pipe( - select('viewerState'), - select('navigation'), - filter(v => !!v) - ) - this.spatialResultsVisible$ = this.store.pipe( select('spatialSearchState'), map(state => isDefined(state) ? @@ -507,12 +487,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { }), ) - this.subscriptions.push( - this.viewerPerformanceConfig$.subscribe(config => { - this.nehubaViewer.applyPerformanceConfig(config) - }), - ) - this.subscriptions.push( this.fetchedSpatialDatasets$.subscribe(datasets => { this.landmarksLabelIndexMap = new Map(datasets.map((v, idx) => [idx, v]) as Array<[number, any]>) @@ -612,7 +586,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { type: NEHUBA_READY, nehubaReady: false, }) - this.nehubaViewerSubscriptions.forEach(s => s.unsubscribe()) this.selectedTemplate = templateSelected this.createNewNehuba(templateSelected) @@ -725,25 +698,13 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { ) /* setup init view state */ - combineLatest( - this.navigationChanges$, - this.selectedRegions$, - ).pipe( + + this.selectedRegions$.pipe( filter(() => !!this.nehubaViewer), - ).subscribe(([navigation, regions]) => { - this.nehubaViewer.initNav = { - ...navigation, - positionReal: true, - } + ).subscribe(regions => { this.nehubaViewer.initRegions = regions.map(({ ngId, labelIndex }) => generateLabelIndexId({ ngId, labelIndex })) }) - this.subscriptions.push( - this.navigationChanges$.subscribe(ev => { - this.handleDispatchedNavigationChange(ev) - }), - ) - /* handler to open/select landmark */ const clickObs$ = fromEvent(this.elementRef.nativeElement, 'click') @@ -961,7 +922,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { } /* related spatial search */ - public oldNavigation: any = {} public spatialSearchPagination: number = 0 private destroynehuba() { @@ -970,10 +930,8 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { * could be considered as a bug. */ this.apiService.interactiveViewer.viewerHandle = null - if ( this.cr ) { this.cr.destroy() } - this.container.clear() + this.nehubaContainerDirective.clear() - this.viewerLoaded = false this.nehubaViewer = null this.cdr.detectChanges() @@ -981,111 +939,8 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { private createNewNehuba(template: any) { - this.viewerLoaded = true - this.cr = this.container.createComponent(this.nehubaViewerFactory) - this.nehubaViewer = this.cr.instance - - /** - * apply viewer config such as gpu limit - */ - const { gpuLimit = null } = this.viewerConfig - - const { nehubaConfig } = template - - const navState = getNavigationStateFromConfig(nehubaConfig) - - this.oldNavigation = navState - this.handleEmittedNavigationChange(navState) - - if (gpuLimit) { - const initialNgState = nehubaConfig && nehubaConfig.dataset && nehubaConfig.dataset.initialNgState - initialNgState.gpuLimit = gpuLimit - } - - this.nehubaViewer.config = nehubaConfig - - /* TODO replace with id from KG */ - this.nehubaViewer.templateId = template.name - - this.nehubaViewerSubscriptions.push( - this.nehubaViewer.debouncedViewerPositionChange.subscribe(this.handleEmittedNavigationChange.bind(this)), - ) - - this.nehubaViewerSubscriptions.push( - this.nehubaViewer.layersChanged.subscribe(() => { - this.store.dispatch({ - type: NEHUBA_LAYER_CHANGED, - }) - }), - ) - - this.nehubaViewerSubscriptions.push( - /** - * TODO when user selects new template, window.viewer - */ - this.nehubaViewer.nehubaReady.subscribe(() => { - this.store.dispatch({ - type: NEHUBA_READY, - nehubaReady: true, - }) - }), - ) - - const accumulatorFn: ( - acc: Map<string, { segment: string | null, segmentId: number | null }>, - arg: {layer: {name: string}, segmentId: number|null, segment: string | null}, - ) => Map<string, {segment: string | null, segmentId: number|null}> - = (acc, arg) => { - const { layer, segment, segmentId } = arg - const { name } = layer - const newMap = new Map(acc) - newMap.set(name, {segment, segmentId}) - return newMap - } - - this.nehubaViewerSubscriptions.push( - - this.nehubaViewer.mouseoverSegmentEmitter.pipe( - scan(accumulatorFn, new Map()), - map(map => Array.from(map.entries()).filter(([_ngId, { segmentId }]) => segmentId)), - ).subscribe(arrOfArr => { - this.store.dispatch({ - type: MOUSE_OVER_SEGMENTS, - segments: arrOfArr.map( ([ngId, {segment, segmentId}]) => { - return { - layer: { - name: ngId, - }, - segment: segment || `${ngId}#${segmentId}`, - } - } ), - }) - }), - ) - - this.nehubaViewerSubscriptions.push( - this.nehubaViewer.mouseoverLandmarkEmitter.pipe( - distinctUntilChanged() - ).subscribe(label => { - this.store.dispatch({ - type : MOUSE_OVER_LANDMARK, - landmark : label, - }) - }), - ) - - this.nehubaViewerSubscriptions.push( - this.nehubaViewer.mouseoverUserlandmarkEmitter.pipe( - throttleTime(160), - ).subscribe(label => { - this.store.dispatch({ - type: VIEWERSTATE_ACTION_TYPES.MOUSEOVER_USER_LANDMARK_LABEL, - payload: { - label, - }, - }) - }), - ) + this.nehubaContainerDirective.createNehubaInstance(template) + this.nehubaViewer = this.nehubaContainerDirective.nehubaViewerInstance this.setupViewerHandleApi() } @@ -1211,101 +1066,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { } } - /* because the navigation can be changed from two sources, - either dynamically (e.g. navigation panel in the UI or plugins etc) - or actively (via user interaction with the viewer) - or lastly, set on init - - This handler function is meant to handle anytime viewer's navigation changes from either sources */ - public handleEmittedNavigationChange(navigation) { - - /* If the navigation is changed dynamically, this.oldnavigation is set prior to the propagation of the navigation state to the viewer. - As the viewer updates the dynamically changed navigation, it will emit the navigation state. - The emitted navigation state should be identical to this.oldnavigation */ - - const navigationChangedActively: boolean = Object.keys(this.oldNavigation).length === 0 || !Object.keys(this.oldNavigation).every(key => { - return this.oldNavigation[key].constructor === Number || this.oldNavigation[key].constructor === Boolean ? - this.oldNavigation[key] === navigation[key] : - this.oldNavigation[key].every((_, idx) => this.oldNavigation[key][idx] === navigation[key][idx]) - }) - - /* if navigation is changed dynamically (ie not actively), the state would have been propagated to the store already. Hence return */ - if ( !navigationChangedActively ) { return } - - /* navigation changed actively (by user interaction with the viewer) - probagate the changes to the store */ - - this.store.dispatch({ - type : CHANGE_NAVIGATION, - navigation, - }) - } - - public handleDispatchedNavigationChange(navigation) { - - /* extract the animation object */ - const { animation, ..._navigation } = navigation - - /** - * remove keys that are falsy - */ - Object.keys(_navigation).forEach(key => (!_navigation[key]) && delete _navigation[key]) - - const { animation: globalAnimationFlag } = this.viewerConfig - if ( globalAnimationFlag && animation ) { - /* animated */ - - const gen = timedValues() - const dest = Object.assign({}, _navigation) - /* this.oldNavigation is old */ - const delta = Object.assign({}, ...Object.keys(dest).filter(key => key !== 'positionReal').map(key => { - const returnObj = {} - returnObj[key] = typeof dest[key] === 'number' ? - dest[key] - this.oldNavigation[key] : - typeof dest[key] === 'object' ? - dest[key].map((val, idx) => val - this.oldNavigation[key][idx]) : - true - return returnObj - })) - - const animate = () => { - const next = gen.next() - const d = next.value - - this.nehubaViewer.setNavigationState( - Object.assign({}, ...Object.keys(dest).filter(k => k !== 'positionReal').map(key => { - const returnObj = {} - returnObj[key] = typeof dest[key] === 'number' ? - dest[key] - ( delta[key] * ( 1 - d ) ) : - dest[key].map((val, idx) => val - ( delta[key][idx] * ( 1 - d ) ) ) - return returnObj - }), { - positionReal : true, - }), - ) - - if ( !next.done ) { - requestAnimationFrame(() => animate()) - } else { - - /* set this.oldnavigation to represent the state of the store */ - /* animation done, set this.oldNavigation */ - this.oldNavigation = Object.assign({}, this.oldNavigation, dest) - } - } - requestAnimationFrame(() => animate()) - } else { - /* not animated */ - - /* set this.oldnavigation to represent the state of the store */ - /* since the emitted change of navigation state is debounced, we can safely set this.oldNavigation to the destination */ - this.oldNavigation = Object.assign({}, this.oldNavigation, _navigation) - - this.nehubaViewer.setNavigationState(Object.assign({}, _navigation, { - positionReal : true, - })) - } - } } export const identifySrcElement = (element: HTMLElement) => { @@ -1343,18 +1103,3 @@ export const takeOnePipe = [ }), take(1), ] - -export const singleLmUnchanged = (lm: {id: string, position: [number, number, number]}, map: Map<string, [number, number, number]>) => - map.has(lm.id) && map.get(lm.id).every((value, idx) => value === lm.position[idx]) - -export const userLmUnchanged = (oldlms, newlms) => { - const oldmap = new Map(oldlms.map(lm => [lm.id, lm.position])) - const newmap = new Map(newlms.map(lm => [lm.id, lm.position])) - - return oldlms.every(lm => singleLmUnchanged(lm, newmap as Map<string, [number, number, number]>)) - && newlms.every(lm => singleLmUnchanged(lm, oldmap as Map<string, [number, number, number]>)) -} - -export const calculateSliceZoomFactor = (originalZoom) => originalZoom - ? 700 * originalZoom / Math.min(window.innerHeight, window.innerWidth) - : 1e7 diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index 76cf1e456ecf83828289b24b8801de8d568b33b5..ac443889b7e292002686db6732e0373aae6746d2 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -1,5 +1,6 @@ -<ng-template #container> -</ng-template> +<div iav-nehuba-viewer-container + (iavNehubaViewerContainerViewerLoading)="handleViewerLoadedEvent($event)"> +</div> <ui-splashscreen iav-stop="mousedown mouseup touchstart touchmove touchend" *ngIf="!viewerLoaded"> </ui-splashscreen> @@ -27,7 +28,7 @@ <!-- StatusCard container--> <ui-status-card *ngIf="!(useMobileUI$ | async)" - [selectedTemplateName]="selectedTemplate.name" + [selectedTemplateName]="selectedTemplate && selectedTemplate.name" [isMobile]="useMobileUI$ | async" [nehubaViewer]="nehubaViewer"> </ui-status-card> diff --git a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts index 8fc25f1827ad0e28b22b9b4479f6d16ef8b4b716..f35f83d03f7be8a942f6a33268a81e43f6a63e54 100644 --- a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts +++ b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts @@ -297,7 +297,7 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { } } - public multiNgIdsLabelIndexMap: Map<string, Map<number, any>> + public multiNgIdsLabelIndexMap: Map<string, Map<number, any>> = new Map() public navPosReal: [number, number, number] = [0, 0, 0] public navPosVoxel: [number, number, number] = [0, 0, 0] diff --git a/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e771d624656a01040bd7f41d0503a2dd02f553f --- /dev/null +++ b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts @@ -0,0 +1,499 @@ +import { Directive, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, ComponentRef, OnInit, OnDestroy, Output, EventEmitter } from "@angular/core"; +import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; +import { Store, select } from "@ngrx/store"; +import { IavRootStoreInterface } from "src/services/stateStore.service"; +import { Subscription, Observable } from "rxjs"; +import { distinctUntilChanged, filter, switchMap, debounceTime, shareReplay, scan, map, throttleTime } from "rxjs/operators"; +import { StateInterface as ViewerConfigStateInterface } from "src/services/state/viewerConfig.store"; +import { getNavigationStateFromConfig } from "../util"; +import { NEHUBA_LAYER_CHANGED, CHANGE_NAVIGATION, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store"; +import { NEHUBA_READY } from "src/services/state/ngViewerState.store"; +import { timedValues } from "src/util/generator"; +import { MOUSE_OVER_SEGMENTS, MOUSE_OVER_LANDMARK } from "src/services/state/uiState.store"; + +const defaultNehubaConfig = { + "configName": "", + "globals": { + "hideNullImageValues": true, + "useNehubaLayout": { + "keepDefaultLayouts": false + }, + "useNehubaMeshLayer": true, + "rightClickWithCtrlGlobal": false, + "zoomWithoutCtrlGlobal": false, + "useCustomSegmentColors": true + }, + "zoomWithoutCtrl": true, + "hideNeuroglancerUI": true, + "rightClickWithCtrl": true, + "rotateAtViewCentre": true, + "enableMeshLoadingControl": true, + "zoomAtViewCentre": true, + "restrictUserNavigation": true, + "disableSegmentSelection": false, + "dataset": { + "imageBackground": [ + 1, + 1, + 1, + 1 + ], + "initialNgState": { + "showDefaultAnnotations": false, + "layers": {}, + // "navigation": { + // "pose": { + // "position": { + // "voxelSize": [ + // 21166.666015625, + // 20000, + // 21166.666015625 + // ], + // "voxelCoordinates": [ + // -21.8844051361084, + // 16.288618087768555, + // 28.418994903564453 + // ] + // } + // }, + // "zoomFactor": 350000 + // }, + // "perspectiveOrientation": [ + // 0.3140767216682434, + // -0.7418519854545593, + // 0.4988985061645508, + // -0.3195493221282959 + // ], + // "perspectiveZoom": 1922235.5293810747 + } + }, + "layout": { + "views": "hbp-neuro", + "planarSlicesBackground": [ + 1, + 1, + 1, + 1 + ], + "useNehubaPerspective": { + "enableShiftDrag": false, + "doNotRestrictUserNavigation": false, + "perspectiveSlicesBackground": [ + 1, + 1, + 1, + 1 + ], + // "removePerspectiveSlicesBackground": { + // "color": [ + // 1, + // 1, + // 1, + // 1 + // ], + // "mode": "==" + // }, + "perspectiveBackground": [ + 1, + 1, + 1, + 1 + ], + // "fixedZoomPerspectiveSlices": { + // "sliceViewportWidth": 300, + // "sliceViewportHeight": 300, + // "sliceZoom": 563818.3562426177, + // "sliceViewportSizeMultiplier": 2 + // }, + "mesh": { + "backFaceColor": [ + 1, + 1, + 1, + 1 + ], + "removeBasedOnNavigation": true, + "flipRemovedOctant": true + }, + // "centerToOrigin": true, + // "drawSubstrates": { + // "color": [ + // 0, + // 0, + // 0.5, + // 0.15 + // ] + // }, + // "drawZoomLevels": { + // "cutOff": 200000, + // "color": [ + // 0.5, + // 0, + // 0, + // 0.15 + // ] + // }, + "hideImages": false, + "waitForMesh": false, + // "restrictZoomLevel": { + // "minZoom": 1200000, + // "maxZoom": 3500000 + // } + } + } +} + +const determineProtocol = (url: string) => { + const re = /^([a-z0-9_-]{0,}):\/\//.exec(url) + return re && re[1] +} + +interface IProcessedVolume{ + name?: string + layer: { + type: 'image' | 'segmentation' + source: string + transform?: any + } +} + +const processStandaloneVolume: (url: string) => Promise<IProcessedVolume> = async (url: string) => { + const protocol = determineProtocol(url) + if (protocol === 'nifti'){ + return { + layer: { + type: 'image', + source: url + } + } + } + if (protocol === 'precomputed'){ + return { + layer: { + type: 'image', + source: url + } + } + } + throw new Error(`type cannot be determined: ${url}`) +} + + +const accumulatorFn: ( + acc: Map<string, { segment: string | null, segmentId: number | null }>, + arg: {layer: {name: string}, segmentId: number|null, segment: string | null}, +) => Map<string, {segment: string | null, segmentId: number|null}> += (acc, arg) => { + const { layer, segment, segmentId } = arg + const { name } = layer + const newMap = new Map(acc) + newMap.set(name, {segment, segmentId}) + return newMap +} + +// TODO port viewer related functionalities (input/outputs) from nehubacontainer to here! + +@Directive({ + selector: '[iav-nehuba-viewer-container]', + exportAs: 'iavNehubaViewerContainer' +}) + +export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ + + @Output() + public iavNehubaViewerContainerViewerLoading: EventEmitter<boolean> = new EventEmitter() + + private nehubaViewerFactory: ComponentFactory<NehubaViewerUnit> + private cr: ComponentRef<NehubaViewerUnit> + constructor( + private el: ViewContainerRef, + private cfr: ComponentFactoryResolver, + private store$: Store<IavRootStoreInterface> + ){ + this.nehubaViewerFactory = this.cfr.resolveComponentFactory(NehubaViewerUnit) + + this.viewerPerformanceConfig$ = this.store$.pipe( + select('viewerConfigState'), + /** + * TODO: this is only a bandaid fix. Technically, we should also implement + * logic to take the previously set config to apply oninit + */ + distinctUntilChanged(), + ) + + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.navigationChanges$ = viewerState$.pipe( + select('navigation'), + filter(v => !!v) + ) + } + + private navigationChanges$: Observable<any> + + private viewerPerformanceConfig$: Observable<ViewerConfigStateInterface> + private viewerConfig: Partial<ViewerConfigStateInterface> = {} + + public oldNavigation: any = {} + private storedNav: any + + private nehubaViewerSubscriptions: Subscription[] = [] + private subscriptions: Subscription[] = [] + + ngOnInit(){ + this.subscriptions.push( + this.store$.pipe( + select('viewerState'), + select('standaloneVolumes'), + filter(v => v && Array.isArray(v) && v.length > 0), + distinctUntilChanged() + ).subscribe(async volumes => { + const copiedNehubaConfig = JSON.parse(JSON.stringify(defaultNehubaConfig)) + for (const idx in volumes){ + try { + const { name = `layer-${idx}`, layer } = await processStandaloneVolume(volumes[idx]) + copiedNehubaConfig.dataset.initialNgState.layers[`layer-${idx}`] = layer + }catch(e) { + // TODO catch error + } + } + this.createNehubaInstance({ nehubaConfig: copiedNehubaConfig }) + }), + + this.viewerPerformanceConfig$.pipe( + debounceTime(200) + ).subscribe(config => { + this.viewerConfig = config + if (this.nehubaViewerInstance && this.nehubaViewerInstance.nehubaViewer) { + this.nehubaViewerInstance.applyPerformanceConfig(config) + } + }), + + + this.navigationChanges$.subscribe(ev => { + if (this.nehubaViewerInstance) { + this.handleDispatchedNavigationChange(ev) + } else { + this.storedNav = { + ...ev, + positionReal: true + } + } + }), + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0){ + this.subscriptions.pop().unsubscribe() + } + } + + createNehubaInstance(template: any){ + this.clear() + this.iavNehubaViewerContainerViewerLoading.emit(true) + this.cr = this.el.createComponent(this.nehubaViewerFactory) + + if (this.storedNav) { + this.nehubaViewerInstance.initNav = this.storedNav + this.storedNav = null + } + + const { nehubaConfig } = template + + /** + * apply viewer config such as gpu limit + */ + const { gpuLimit = null } = this.viewerConfig + + this.nehubaViewerInstance.config = nehubaConfig + + this.oldNavigation = getNavigationStateFromConfig(nehubaConfig) + this.handleEmittedNavigationChange(this.oldNavigation) + + if (gpuLimit) { + const initialNgState = nehubaConfig && nehubaConfig.dataset && nehubaConfig.dataset.initialNgState + initialNgState.gpuLimit = gpuLimit + } + + /* TODO replace with id from KG */ + this.nehubaViewerInstance.templateId = name + + this.nehubaViewerSubscriptions.push( + this.nehubaViewerInstance.errorEmitter.subscribe(e => { + console.log(e) + }), + + this.nehubaViewerInstance.debouncedViewerPositionChange.subscribe(val => { + this.handleEmittedNavigationChange(val) + }), + + this.nehubaViewerInstance.layersChanged.subscribe(() => { + this.store$.dispatch({ + type: NEHUBA_LAYER_CHANGED + }) + }), + + this.nehubaViewerInstance.nehubaReady.subscribe(() => { + /** + * TODO when user selects new template, window.viewer + */ + this.store$.dispatch({ + type: NEHUBA_READY, + nehubaReady: true, + }) + }), + + this.nehubaViewerInstance.mouseoverSegmentEmitter.pipe( + scan(accumulatorFn, new Map()), + map(map => Array.from(map.entries()).filter(([_ngId, { segmentId }]) => segmentId)), + ).subscribe(arrOfArr => { + this.store$.dispatch({ + type: MOUSE_OVER_SEGMENTS, + segments: arrOfArr.map( ([ngId, {segment, segmentId}]) => { + return { + layer: { + name: ngId, + }, + segment: segment || `${ngId}#${segmentId}`, + } + } ), + }) + }), + + this.nehubaViewerInstance.mouseoverLandmarkEmitter.pipe( + distinctUntilChanged() + ).subscribe(label => { + this.store$.dispatch({ + type : MOUSE_OVER_LANDMARK, + landmark : label, + }) + }), + + this.nehubaViewerInstance.mouseoverUserlandmarkEmitter.pipe( + throttleTime(160), + ).subscribe(label => { + this.store$.dispatch({ + type: VIEWERSTATE_ACTION_TYPES.MOUSEOVER_USER_LANDMARK_LABEL, + payload: { + label, + }, + }) + }), + ) + } + + clear(){ + while(this.nehubaViewerSubscriptions.length > 0) { + this.nehubaViewerSubscriptions.pop().unsubscribe() + } + this.iavNehubaViewerContainerViewerLoading.emit(false) + if(this.cr) this.cr.destroy() + this.el.clear() + this.cr = null + } + + get nehubaViewerInstance(){ + return this.cr && this.cr.instance + } + + /* because the navigation can be changed from two sources, + either dynamically (e.g. navigation panel in the UI or plugins etc) + or actively (via user interaction with the viewer) + or lastly, set on init + + This handler function is meant to handle anytime viewer's navigation changes from either sources */ + public handleEmittedNavigationChange(navigation) { + + /* If the navigation is changed dynamically, this.oldnavigation is set prior to the propagation of the navigation state to the viewer. + As the viewer updates the dynamically changed navigation, it will emit the navigation state. + The emitted navigation state should be identical to this.oldnavigation */ + + const navigationChangedActively: boolean = Object.keys(this.oldNavigation).length === 0 || !Object.keys(this.oldNavigation).every(key => { + return this.oldNavigation[key].constructor === Number || this.oldNavigation[key].constructor === Boolean ? + this.oldNavigation[key] === navigation[key] : + this.oldNavigation[key].every((_, idx) => this.oldNavigation[key][idx] === navigation[key][idx]) + }) + + /* if navigation is changed dynamically (ie not actively), the state would have been propagated to the store already. Hence return */ + if ( !navigationChangedActively ) { return } + + /* navigation changed actively (by user interaction with the viewer) + probagate the changes to the store */ + + this.store$.dispatch({ + type : CHANGE_NAVIGATION, + navigation, + }) + } + + + public handleDispatchedNavigationChange(navigation) { + + /* extract the animation object */ + const { animation, ..._navigation } = navigation + + /** + * remove keys that are falsy + */ + Object.keys(_navigation).forEach(key => (!_navigation[key]) && delete _navigation[key]) + + const { animation: globalAnimationFlag } = this.viewerConfig + if ( globalAnimationFlag && animation ) { + /* animated */ + + const gen = timedValues() + const dest = Object.assign({}, _navigation) + /* this.oldNavigation is old */ + const delta = Object.assign({}, ...Object.keys(dest).filter(key => key !== 'positionReal').map(key => { + const returnObj = {} + returnObj[key] = typeof dest[key] === 'number' ? + dest[key] - this.oldNavigation[key] : + typeof dest[key] === 'object' ? + dest[key].map((val, idx) => val - this.oldNavigation[key][idx]) : + true + return returnObj + })) + + const animate = () => { + const next = gen.next() + const d = next.value + + this.nehubaViewerInstance.setNavigationState( + Object.assign({}, ...Object.keys(dest).filter(k => k !== 'positionReal').map(key => { + const returnObj = {} + returnObj[key] = typeof dest[key] === 'number' ? + dest[key] - ( delta[key] * ( 1 - d ) ) : + dest[key].map((val, idx) => val - ( delta[key][idx] * ( 1 - d ) ) ) + return returnObj + }), { + positionReal : true, + }), + ) + + if ( !next.done ) { + requestAnimationFrame(() => animate()) + } else { + + /* set this.oldnavigation to represent the state of the store */ + /* animation done, set this.oldNavigation */ + this.oldNavigation = Object.assign({}, this.oldNavigation, dest) + } + } + requestAnimationFrame(() => animate()) + } else { + /* not animated */ + + /* set this.oldnavigation to represent the state of the store */ + /* since the emitted change of navigation state is debounced, we can safely set this.oldNavigation to the destination */ + this.oldNavigation = Object.assign({}, this.oldNavigation, _navigation) + + this.nehubaViewerInstance.setNavigationState(Object.assign({}, _navigation, { + positionReal : true, + })) + } + } +} diff --git a/src/ui/nehubaContainer/util.ts b/src/ui/nehubaContainer/util.ts index 7e2786490864e1b879d875c9ba388b91c738bffb..5455355f2a364f8ae7f6011c1833011eb18c4048 100644 --- a/src/ui/nehubaContainer/util.ts +++ b/src/ui/nehubaContainer/util.ts @@ -186,4 +186,19 @@ export const getNavigationStateFromConfig = nehubaConfig => { position: [0, 1, 2].map(idx => voxelSize[idx] * voxelCoordinates[idx]), zoom: zoomFactor } -} \ No newline at end of file +} + +export const calculateSliceZoomFactor = (originalZoom) => originalZoom + ? 700 * originalZoom / Math.min(window.innerHeight, window.innerWidth) + : 1e7 + +export const singleLmUnchanged = (lm: {id: string, position: [number, number, number]}, map: Map<string, [number, number, number]>) => + map.has(lm.id) && map.get(lm.id).every((value, idx) => value === lm.position[idx]) + +export const userLmUnchanged = (oldlms, newlms) => { + const oldmap = new Map(oldlms.map(lm => [lm.id, lm.position])) + const newmap = new Map(newlms.map(lm => [lm.id, lm.position])) + + return oldlms.every(lm => singleLmUnchanged(lm, newmap as Map<string, [number, number, number]>)) + && newlms.every(lm => singleLmUnchanged(lm, oldmap as Map<string, [number, number, number]>)) +} diff --git a/src/ui/searchSideNav/searchSideNav.component.ts b/src/ui/searchSideNav/searchSideNav.component.ts index 019d18d1c60825ef954d8cd3cede36438bb8fc1e..1bb7ce4a6977e6b36777534089b978d486cf6e2a 100644 --- a/src/ui/searchSideNav/searchSideNav.component.ts +++ b/src/ui/searchSideNav/searchSideNav.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, OnDestroy, Output, TemplateRef, ViewChild } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import {Observable, Subscription} from "rxjs"; +import { Observable, Subscription } from "rxjs"; import { filter, map, mapTo, scan, startWith } from "rxjs/operators"; import { INgLayerInterface } from "src/atlasViewer/atlasViewer.component"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; @@ -12,9 +12,8 @@ import { import { IavRootStoreInterface, SELECT_REGIONS } from "src/services/stateStore.service"; import { LayerBrowser } from "../layerbrowser/layerbrowser.component"; import { trackRegionBy } from '../viewerStateController/regionHierachy/regionHierarchy.component' -import { determinePreviewFileType, PREVIEW_FILE_TYPES } from "../databrowserModule/preview/previewFileIcon.pipe"; -import {MatDialog, MatDialogRef} from "@angular/material/dialog"; -import {MatSnackBar} from "@angular/material/snack-bar"; +import { MatDialog, MatDialogRef } from "@angular/material/dialog"; +import { MatSnackBar } from "@angular/material/snack-bar"; @Component({ selector: 'search-side-nav', diff --git a/src/ui/searchSideNav/searchSideNav.template.html b/src/ui/searchSideNav/searchSideNav.template.html index 195f2e5d5c3bb4036f3e9eef72cd624e62d97270..4628d6bc8f7dd13b89f69735457446f30f796d88 100644 --- a/src/ui/searchSideNav/searchSideNav.template.html +++ b/src/ui/searchSideNav/searchSideNav.template.html @@ -4,6 +4,7 @@ <!-- content append --> <ng-container card-content="append"> <region-text-search-autocomplete + *ngIf="viewerStateController.parcellationSelected$ | async" [showBadge]="true" class="d-block w-100"> </region-text-search-autocomplete> @@ -14,11 +15,21 @@ <button mat-stroked-button *ngIf="!(sidePanelExploreCurrentViewIsOpen$ | async)" (click)="expandSidePanelCurrentView()" - class="m-1 flex-grow-1 overflow-hidden" > - <i class="fas fa-chevron-down"></i> - <ng-container *ngIf="viewerStateController.regionsSelected$ | async as regionsSelected"> - {{ regionsSelected.length === 0 ? 'Explore the current view' : regionsSelected.length === 1 ? ('Explore ' + regionsSelected[0].name) : ('Explore selected regions (' + regionsSelected.length + ' selected)') }} + [disabled]="!(viewerStateController.parcellationSelected$ | async)" + class="m-1 flex-grow-1 overflow-hidden"> + + <!-- template parcellation selected --> + <ng-container *ngIf="viewerStateController.parcellationSelected$ | async; else exploreRegionNotAvailable"> + <i class="fas fa-chevron-down"></i> + <ng-container *ngIf="viewerStateController.regionsSelected$ | async as regionsSelected"> + {{ regionsSelected.length === 0 ? 'Explore the current view' : regionsSelected.length === 1 ? ('Explore ' + regionsSelected[0].name) : ('Explore selected regions (' + regionsSelected.length + ' selected)') }} + </ng-container> </ng-container> + + <!-- nothing selected --> + <ng-template #exploreRegionNotAvailable> + No additional information available + </ng-template> </button> </div> </viewer-state-controller> diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 214785c6dc9f92040438010f2fa4c7d04e6b2974..35f3c30a8d3bfd7431cf2f1b9bd170294c559ea3 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -79,6 +79,8 @@ import { RegionMenuComponent } from 'src/ui/parcellationRegion/regionMenu/region import { RegionListSimpleViewComponent } from "./parcellationRegion/regionListSimpleView/regionListSimpleView.component"; import { SimpleRegionComponent } from "./parcellationRegion/regionSimple/regionSimple.component"; import { LandmarkUIComponent } from "./landmarkUI/landmarkUI.component"; +import { NehubaModule } from "./nehubaContainer/nehuba.module"; +import { LayerDetailComponent } from "./layerbrowser/layerDetail/layerDetail.component"; @NgModule({ imports : [ @@ -91,6 +93,7 @@ import { LandmarkUIComponent } from "./landmarkUI/landmarkUI.component"; UtilModule, ScrollingModule, AngularMaterialModule, + NehubaModule ], declarations : [ NehubaContainer, @@ -100,6 +103,7 @@ import { LandmarkUIComponent } from "./landmarkUI/landmarkUI.component"; PluginBannerUI, CitationsContainer, LayerBrowser, + LayerDetailComponent, KgEntryViewer, SubjectViewer, LogoContainer, diff --git a/src/ui/viewerStateController/viewerState.base.ts b/src/ui/viewerStateController/viewerState.base.ts index 870eed9cd2f10f2df135bada867662b2f541e4aa..c74aa7128e374ee01b305992ddae68cd8bf330fa 100644 --- a/src/ui/viewerStateController/viewerState.base.ts +++ b/src/ui/viewerStateController/viewerState.base.ts @@ -30,6 +30,8 @@ export class ViewerStateBase implements OnInit { private subscriptions: Subscription[] = [] + public standaloneVolumes$: Observable<any[]> + public availableTemplates$: Observable<any[]> public availableParcellations$: Observable<any[]> @@ -76,6 +78,12 @@ export class ViewerStateBase implements OnInit { shareReplay(1), ) + this.standaloneVolumes$ = viewerState$.pipe( + select('standaloneVolumes'), + distinctUntilChanged(), + shareReplay(1) + ) + this.availableTemplates$ = viewerState$.pipe( select('fetchedTemplates'), distinctUntilChanged() diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts index f849f6f24f62219970e5fe62c901e4cc8d287af9..23876b16b5d91f9110c9e25259603fabdb1deef2 100644 --- a/src/ui/viewerStateController/viewerState.useEffect.ts +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -2,13 +2,14 @@ import { Injectable, OnDestroy, OnInit } from "@angular/core"; import { Actions, Effect, ofType } from "@ngrx/effects"; import { Action, select, Store } from "@ngrx/store"; import { Observable, Subscription, of } from "rxjs"; -import {distinctUntilChanged, filter, map, mergeMap, shareReplay, withLatestFrom, switchMap} from "rxjs/operators"; +import {distinctUntilChanged, filter, map, shareReplay, withLatestFrom, switchMap, mapTo } from "rxjs/operators"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { CHANGE_NAVIGATION, FETCHED_TEMPLATE, GENERAL_ACTION_TYPES, IavRootStoreInterface, isDefined, NEWVIEWER, SELECT_PARCELLATION, SELECT_REGIONS } from "src/services/stateStore.service"; import { UIService } from "src/services/uiService.service"; import { regionFlattener } from "src/util/regionFlattener"; 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"; @Injectable({ providedIn: 'root', @@ -54,6 +55,10 @@ export class ViewerStateControllerUseEffect implements OnInit, OnDestroy { @Effect() public navigateToRegion$: Observable<any> + + @Effect() + public onTemplateSelectClearStandAloneVolumes$: Observable<any> + constructor( private actions$: Actions, private store$: Store<IavRootStoreInterface>, @@ -71,6 +76,13 @@ export class ViewerStateControllerUseEffect implements OnInit, OnDestroy { distinctUntilChanged(), ) + this.onTemplateSelectClearStandAloneVolumes$ = this.actions$.pipe( + ofType(VIEWERSTATE_CONTROLLER_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME), + mapTo({ + type: CLEAR_STANDALONE_VOLUMES + }) + ) + this.selectParcellationWithName$ = this.actions$.pipe( ofType(VIEWERSTATE_CONTROLLER_ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME), map(action => { @@ -121,6 +133,15 @@ export class ViewerStateControllerUseEffect implements OnInit, OnDestroy { viewerState$ ), switchMap(([newTemplateName, { templateSelected, fetchedTemplates, navigation }]) => { + if (!templateSelected) { + return of({ + newTemplateName, + templateSelected: templateSelected, + fetchedTemplates, + translatedCoordinate: null, + navigation + }) + } const position = (navigation && navigation.position) || [0, 0, 0] if (newTemplateName === templateSelected.name) return of(null) return this.coordinatesTransformation.getPointCoordinatesForTemplate(templateSelected.name, newTemplateName, position).pipe( diff --git a/src/ui/viewerStateController/viewerStateCMini/viewerStateMini.template.html b/src/ui/viewerStateController/viewerStateCMini/viewerStateMini.template.html index 420457e17890c693e79ebfc9a14f1fdc7b3dc5ee..98a2c129a419c41cfed6ae251645590656eb1858 100644 --- a/src/ui/viewerStateController/viewerStateCMini/viewerStateMini.template.html +++ b/src/ui/viewerStateController/viewerStateCMini/viewerStateMini.template.html @@ -1,17 +1,22 @@ -<span *ngIf="templateSelected$ | async as templateSelected"> +<!-- selected template and parcellation --> +<div *ngIf="templateSelected$ | async as templateSelected"> {{ templateSelected.name }} -</span> -<br> -<span *ngIf="parcellationSelected$ | async as parcellationSelected"> +</div> +<div *ngIf="parcellationSelected$ | async as parcellationSelected"> {{ parcellationSelected.name }} -</span> +</div> +<!-- selected parcellation regions --> <ng-container *ngIf="regionsSelected$ | async as regionsSelected"> <ng-container *ngIf="regionsSelected.length > 0"> - - <br> - <span> - {{ regionsSelected.length }} region{{ regionsSelected.length > 1 ? 's' : '' }} selected - </span> + <div class="mt-2"> + {{ regionsSelected.length }} region{{ regionsSelected.length > 1 ? 's' : '' }} selected + </div> </ng-container> +</ng-container> + +<ng-container *ngIf="standaloneVolumes$ | async as standaloneVolumes"> + <div *ngFor="let vol of standaloneVolumes"> + {{ vol }} + </div> </ng-container> \ No newline at end of file