diff --git a/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts b/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts index 072cbabf90ec1d731fe4a767dcd78ee76ba7d4b9..eb9e3c32e9d0ad71e1cd57c5ab1c33157eeae450 100644 --- a/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts +++ b/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts @@ -150,18 +150,18 @@ export class IEEGRecordingsCmp extends RegionFeatureBase implements ISingleFeatu }, []) ) - private clickIntp(ev: any, next: Function) { + private clickIntp(ev: any): boolean { let hoveredLandmark = null this.regionFeatureService.onHoverLandmarks$.pipe( take(1) ).subscribe(val => { hoveredLandmark = val }) - if (!hoveredLandmark) return next() + if (!hoveredLandmark) return true const isOne = this.landmarksLoaded.some(lm => { return lm['_']['electrodeId'] === hoveredLandmark['_']['electrodeId'] }) - if (!isOne) return next() + if (!isOne) return true this.exploreElectrode$.next(hoveredLandmark['_']['electrodeId']) } } diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 5b3fe24f2717e0df81e7bb2951ac799165b17e1f..e7253b284cfa9a4ebba5c4f53d61e7247c89037d 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -83,7 +83,7 @@ export class AtlasViewerAPIServices implements OnDestroy{ private s: Subscription[] = [] - private onMouseClick(ev: any, next){ + private onMouseClick(ev: any): boolean{ const { rs, spec } = this.getNextUserRegionSelectHandler() || {} if (!!rs) { @@ -112,10 +112,11 @@ export class AtlasViewerAPIServices implements OnDestroy{ mousePositionReal = floatArr && Array.from(floatArr).map((val: number) => val / 1e6) }) } - return rs({ + rs({ type: spec.type, payload: mousePositionReal }) + return false } /** @@ -125,10 +126,11 @@ export class AtlasViewerAPIServices implements OnDestroy{ if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { this.popUserRegionSelectHandler() - return rs({ + rs({ type: spec.type, payload: moSegments }) + return false } } } else { @@ -138,11 +140,12 @@ export class AtlasViewerAPIServices implements OnDestroy{ */ if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { this.popUserRegionSelectHandler() - return rs(moSegments[0]) + rs(moSegments[0]) + return false } } } - next() + return true } constructor( diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 7a2af6e9ed3a85bac69a58b7ae6422232377180e..346cc6c126115e9d5e4627d4d8ef2cef01e11498 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -329,8 +329,8 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { this.subscriptions.forEach(s => s.unsubscribe()) } - public mouseClickDocument(_event: MouseEvent) { - this.clickIntService.run(_event) + public mouseClickDocument(event: MouseEvent) { + this.clickIntService.callRegFns(event) } /** diff --git a/src/contextMenuModule/ctxMenuHost.directive.ts b/src/contextMenuModule/ctxMenuHost.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..b403b359bceece402b9be5594825f8e15a872371 --- /dev/null +++ b/src/contextMenuModule/ctxMenuHost.directive.ts @@ -0,0 +1,30 @@ +import { AfterViewInit, Directive, HostListener, Input, OnDestroy, TemplateRef, ViewContainerRef } from "@angular/core"; +import { ContextMenuService } from "./service"; + +@Directive({ + selector: '[ctx-menu-host]' +}) + +export class CtxMenuHost implements OnDestroy, AfterViewInit{ + + @Input('ctx-menu-host-tmpl') + tmplRef: TemplateRef<any> + + @HostListener('contextmenu', ['$event']) + onClickListener(ev: MouseEvent){ + this.svc.showCtxMenu(ev, this.tmplRef) + } + + constructor( + private vcr: ViewContainerRef, + private svc: ContextMenuService + ){ + } + + ngAfterViewInit(){ + this.svc.vcr = this.vcr + } + ngOnDestroy(){ + this.svc.vcr = null + } +} diff --git a/src/contextMenuModule/dismissCtxMenu.directive.ts b/src/contextMenuModule/dismissCtxMenu.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ee7e048fff9b6dd486c874dcf984770b687d63c --- /dev/null +++ b/src/contextMenuModule/dismissCtxMenu.directive.ts @@ -0,0 +1,19 @@ +import { Directive, HostListener } from "@angular/core"; +import { ContextMenuService } from "./service"; + +@Directive({ + selector: '[ctx-menu-dismiss]' +}) + +export class DismissCtxMenuDirective{ + @HostListener('click', ['$event']) + onClickListener(ev: MouseEvent) { + this.svc.dismissCtxMenu() + } + + constructor( + private svc: ContextMenuService + ){ + + } +} diff --git a/src/contextMenuModule/index.ts b/src/contextMenuModule/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b04167fec1094c0d95e4a7fc175f450444d3572 --- /dev/null +++ b/src/contextMenuModule/index.ts @@ -0,0 +1,3 @@ +export { ContextMenuModule } from './module' +export { ContextMenuService } from './service' +export { DismissCtxMenuDirective } from './dismissCtxMenu.directive' diff --git a/src/contextMenuModule/module.ts b/src/contextMenuModule/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3529c0b58e9099b46a6733ca3af91905f3d5538 --- /dev/null +++ b/src/contextMenuModule/module.ts @@ -0,0 +1,25 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; +import { CtxMenuHost } from "./ctxMenuHost.directive"; +import { DismissCtxMenuDirective } from "./dismissCtxMenu.directive"; + +@NgModule({ + imports: [ + AngularMaterialModule, + CommonModule, + ], + declarations: [ + DismissCtxMenuDirective, + CtxMenuHost, + ], + exports: [ + DismissCtxMenuDirective, + CtxMenuHost, + ], + providers: [ + + ] +}) + +export class ContextMenuModule{} diff --git a/src/contextMenuModule/service.ts b/src/contextMenuModule/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3ae40b0f0a2ee8306e327264fa546581b867e4e --- /dev/null +++ b/src/contextMenuModule/service.ts @@ -0,0 +1,91 @@ +import { Overlay, OverlayRef } from "@angular/cdk/overlay" +import { TemplatePortal } from "@angular/cdk/portal" +import { Injectable, TemplateRef, ViewContainerRef } from "@angular/core" +import { ReplaySubject, Subject, Subscription } from "rxjs" +import { RegDeregController } from "src/util/regDereg.base" + +type TTmplRef = { + tmpl: TemplateRef<any>, + data: any, +} + +@Injectable({ + providedIn: 'root' +}) +export class ContextMenuService extends RegDeregController<unknown, { tmpl: TemplateRef<any>, data: any}>{ + + public vcr: ViewContainerRef + private overlayRef: OverlayRef + + private subs: Subscription[] = [] + + public context$ = new Subject() + public context: any + + public tmplRefs$ = new ReplaySubject<TTmplRef[]>(1) + public tmplRefs: TTmplRef[] = [] + + constructor( + private overlay: Overlay, + ){ + super() + this.subs.push( + this.context$.subscribe(v => this.context = v) + ) + } + + callRegFns(){ + const tmplRefs: TTmplRef[] = [] + for (const fn of this.callbacks){ + const resp = fn(this.context) + if (resp) { + const { tmpl, data } = resp + tmplRefs.push({ tmpl, data }) + } + } + this.tmplRefs = tmplRefs + this.tmplRefs$.next(tmplRefs) + } + + dismissCtxMenu(){ + if (this.overlayRef) { + this.overlayRef.dispose() + this.overlayRef = null + } + } + + showCtxMenu(ev: MouseEvent, tmplRef: TemplateRef<any>){ + if (!this.vcr) { + console.warn(`[ctx-menu-host] not attached to any component!`) + return + } + this.dismissCtxMenu() + this.callRegFns() + + const { x, y } = ev + const positionStrategy = this.overlay.position() + .flexibleConnectedTo({ x, y }) + .withPositions([ + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + } + ]) + + this.overlayRef = this.overlay.create({ + positionStrategy, + }) + + this.overlayRef.attach( + new TemplatePortal( + tmplRef, + this.vcr, + { + tmplRefs: this.tmplRefs + } + ) + ) + } +} diff --git a/src/glue.spec.ts b/src/glue.spec.ts index 92466c534536e6df36120aa565b7355639cbf7da..79f82db2535f0ab4defa6c9ad669a7ea8beb34e7 100644 --- a/src/glue.spec.ts +++ b/src/glue.spec.ts @@ -1217,94 +1217,44 @@ describe('> glue.ts', () => { interceptorService = new ClickInterceptorService() }) - describe('> #addInterceptor', () => { - it('> adds interceptor fn', () => { - const fn = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - }) - it('> when config not supplied, or last not present, will add fn to the first of the queue', () => { - - const dummy = (ev: any, next: Function) => {} - interceptorService.addInterceptor(dummy) - - const fn = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toEqual(0) - - const fn2 = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn2, {}) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toEqual(1) - expect(interceptorService['clickInterceptorStack'].indexOf(fn2)).toEqual(0) - }) - it('> when last is supplied as a config param, will add the fn at the end', () => { - - const dummy = (ev: any, next: Function) => {} - interceptorService.addInterceptor(dummy) - - const fn = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn, { last: true }) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toEqual(1) - - }) - }) - - describe('> deregister', () => { - it('> if the fn exist in the register, it will be removed', () => { - - const fn = (ev: any, next: Function) => {} - const fn2 = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(1) - - interceptorService.removeInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeLessThan(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(0) + describe('> # callRegFns', () => { + let spy1: jasmine.Spy, + spy2: jasmine.Spy, + spy3: jasmine.Spy + beforeEach(() => { + spy1 = jasmine.createSpy('spy1') + spy2 = jasmine.createSpy('spy2') + spy3 = jasmine.createSpy('spy3') + interceptorService['callbacks'] = [ + spy1, + spy2, + spy3, + ] + spy1.and.returnValue(true) + spy2.and.returnValue(true) + spy3.and.returnValue(true) }) + it('> fns are all called', () => { - it('> if fn does not exist in register, it will not be removed', () => { - - const fn = (ev: any, next: Function) => {} - const fn2 = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(1) - - interceptorService.removeInterceptor(fn2) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(1) + interceptorService.callRegFns('stuff') + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spy3).toHaveBeenCalled() }) - }) - - describe('> # run', () => { it('> will run fns from first idx to last idx', () => { - const callNext = (ev: any, next: Function) => next() - const fn = jasmine.createSpy().and.callFake(callNext) - const fn2 = jasmine.createSpy().and.callFake(callNext) - interceptorService.addInterceptor(fn) - interceptorService.addInterceptor(fn2) - interceptorService.run({}) - - expect(fn2).toHaveBeenCalledBefore(fn) + interceptorService.callRegFns('stuff') + expect(spy1).toHaveBeenCalledBefore(spy2) + expect(spy2).toHaveBeenCalledBefore(spy3) }) it('> will stop at when next is not called', () => { - const callNext = (ev: any, next: Function) => next() - const halt = (ev: any, next: Function) => {} - const fn = jasmine.createSpy().and.callFake(callNext) - const fn2 = jasmine.createSpy().and.callFake(halt) - const fn3 = jasmine.createSpy().and.callFake(callNext) - - interceptorService.addInterceptor(fn) - interceptorService.addInterceptor(fn2) - interceptorService.addInterceptor(fn3) - interceptorService.run({}) + spy2.and.returnValue(false) + interceptorService.callRegFns('stuff') - expect(fn3).toHaveBeenCalled() - expect(fn2).toHaveBeenCalled() - expect(fn).not.toHaveBeenCalled() + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spy3).not.toHaveBeenCalled() }) }) }) diff --git a/src/glue.ts b/src/glue.ts index 451e16b0968e76c0b5c0d0d677fc2c5cf012374e..a2c1652af870a4b992b9392b131da237447a15ef 100644 --- a/src/glue.ts +++ b/src/glue.ts @@ -18,7 +18,7 @@ import { viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector import { ngViewerSelectorClearView } from "./services/state/ngViewerState/selectors" import { ngViewerActionClearView } from './services/state/ngViewerState/actions' import { generalActionError } from "./services/stateStore.helper" -import { TClickInterceptorConfig } from "./util/injectionTokens" +import { RegDeregController } from "./util/regDereg.base" const PREVIEW_FILE_TYPES_NO_UI = [ EnumPreviewFileTypes.NIFTI, @@ -726,34 +726,13 @@ export const gluActionSetFavDataset = createAction( providedIn: 'root' }) -export class ClickInterceptorService{ - private clickInterceptorStack: Function[] = [] - - removeInterceptor(fn: Function) { - const idx = this.clickInterceptorStack.findIndex(int => int === fn) - if (idx < 0) { - console.warn(`clickInterceptorService could not remove the function. Did you pass the EXACT reference? - Anonymouse functions such as () => {} or .bind will create a new reference! - You may want to assign .bind to a ref, and pass it to register and unregister functions`) - } else { - this.clickInterceptorStack.splice(idx, 1) - } - } - addInterceptor(fn: Function, config?: TClickInterceptorConfig) { - if (config?.last) { - this.clickInterceptorStack.push(fn) - } else { - this.clickInterceptorStack.unshift(fn) - } - } +export class ClickInterceptorService extends RegDeregController<any, boolean>{ - run(ev: any){ + callRegFns(ev: any){ let intercepted = false - for (const clickInc of this.clickInterceptorStack) { - let runNext = false - clickInc(ev, () => { - runNext = true - }) + for (const clickInc of this.callbacks) { + + const runNext = clickInc(ev) if (!runNext) { intercepted = true break diff --git a/src/main.module.ts b/src/main.module.ts index f4a8fcaa08008d4069692958c6c32adf8735037d..90c7080be8366e2e1e3ecc7f4784c77f6321fbb1 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -229,8 +229,16 @@ export function debug(reducer: ActionReducer<any>): ActionReducer<any> { provide: CLICK_INTERCEPTOR_INJECTOR, useFactory: (clickIntService: ClickInterceptorService) => { return { - deregister: clickIntService.removeInterceptor.bind(clickIntService), - register: clickIntService.addInterceptor.bind(clickIntService) + deregister: clickIntService.deregister.bind(clickIntService), + register: (fn: (arg: any) => boolean, config?) => { + if (config?.last) { + clickIntService.register(fn) + } else { + clickIntService.register(fn, { + first: true + }) + } + } } as ClickInterceptor }, deps: [ diff --git a/src/util/injectionTokens.ts b/src/util/injectionTokens.ts index acd5142e83ad88f25f76622c2b53bf282b53cce1..1c0795c53d019a7c5574e9f85033fa1313c49997 100644 --- a/src/util/injectionTokens.ts +++ b/src/util/injectionTokens.ts @@ -7,6 +7,6 @@ export type TClickInterceptorConfig = { } export interface ClickInterceptor{ - register: (interceptorFunction: (ev: any, next: Function) => void, config?: TClickInterceptorConfig) => void + register: (interceptorFunction: (ev: any) => boolean, config?: TClickInterceptorConfig) => void deregister: (interceptorFunction: Function) => void } diff --git a/src/util/regDereg.base.spec.ts b/src/util/regDereg.base.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0534070fe768c00b011e0a69ea972722add1b239 --- /dev/null +++ b/src/util/regDereg.base.spec.ts @@ -0,0 +1,81 @@ +import { RegDereg } from "./regDereg.base" + +describe('> regDereg.base.ts', () => { + describe('> RegDereg', () => { + let regDereg: RegDereg<string, boolean> + beforeEach(() => { + regDereg = new RegDereg() + }) + + describe('> #register', () => { + it('> adds interceptor fn', () => { + let nextReturnVal = false + const fn = (ev: any) => nextReturnVal + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + }) + it('> when config not supplied, or first not present, will add fn to the last of the queue', () => { + let dummyReturn = false + const dummy = (ev: any) => dummyReturn + regDereg.register(dummy) + + let fnReturn = false + const fn = (ev: any) => fnReturn + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toEqual(1) + + let fn2Return = false + const fn2 = (ev: any) => fn2Return + regDereg.register(fn2, {}) + expect(regDereg['callbacks'].indexOf(fn)).toEqual(1) + expect(regDereg['callbacks'].indexOf(fn2)).toEqual(2) + }) + it('> when first is supplied as a config param, will add the fn at the front', () => { + + let dummyReturn = false + const dummy = (ev: any) => dummyReturn + regDereg.register(dummy) + + let fnReturn = false + const fn = (ev: any) => fnReturn + regDereg.register(fn, { + first: true + }) + expect(regDereg['callbacks'].indexOf(fn)).toEqual(0) + + }) + }) + + describe('> deregister', () => { + it('> if the fn exist in the register, it will be removed', () => { + + let fnReturn = false + let fn2Return = false + const fn = (ev: any) => fnReturn + const fn2 = (ev: any) => fn2Return + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + expect(regDereg['callbacks'].length).toEqual(1) + + regDereg.deregister(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeLessThan(0) + expect(regDereg['callbacks'].length).toEqual(0) + }) + + it('> if fn does not exist in register, it will not be removed', () => { + + let fnReturn = false + let fn2Return = false + const fn = (ev: any) => fnReturn + const fn2 = (ev: any) => fn2Return + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + expect(regDereg['callbacks'].length).toEqual(1) + + regDereg.deregister(fn2) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + expect(regDereg['callbacks'].length).toEqual(1) + }) + }) + }) +}) diff --git a/src/util/regDereg.base.ts b/src/util/regDereg.base.ts new file mode 100644 index 0000000000000000000000000000000000000000..eea305f3c9bcdaae65aa9de5d73dab403208b326 --- /dev/null +++ b/src/util/regDereg.base.ts @@ -0,0 +1,45 @@ +type TRegDeregConfig = { + first?: boolean +} + +/** + * this is base register/dregister class + * a pattern which is observed very frequently + */ +export class RegDereg<T, Y = void> { + constructor(){ + + } + public allowDuplicate = false + protected callbacks: ((allArg: T) => Y)[] = [] + register(fn: (allArg: T) => Y, config?: TRegDeregConfig) { + if (!this.allowDuplicate) { + if (this.callbacks.indexOf(fn) >= 0) { + console.warn(`[RegDereg] #register: function has already been regsitered`) + return + } + } + if (config?.first) { + this.callbacks.unshift(fn) + } else { + this.callbacks.push(fn) + } + } + deregister(fn: (allArg: T) => Y){ + this.callbacks = this.callbacks.filter(f => f !== fn ) + } +} + +export class RegDeregController<T, Y = void> extends RegDereg<T, Y>{ + constructor(){ + super() + } + /** + * Can be overwritten by inherited class + */ + callRegFns(arg: T) { + for (const fn of this.callbacks) { + fn(arg) + } + } +} diff --git a/src/viewerModule/constants.ts b/src/viewerModule/constants.ts index f7fe2681a531f5830db8807bb0409ec06bb17105..aa974a56e592618f67ae248f9b1224c15466ff2c 100644 --- a/src/viewerModule/constants.ts +++ b/src/viewerModule/constants.ts @@ -1,8 +1,6 @@ import { InjectionToken } from "@angular/core"; import { Observable } from "rxjs"; -export type TSupportedViewer = 'notsupported' | 'nehuba' | 'threeSurfer' | null - export const VIEWERMODULE_DARKTHEME = new InjectionToken<Observable<boolean>>('VIEWERMODULE_DARKTHEME') export interface IViewerCmpUiState { diff --git a/src/viewerModule/module.ts b/src/viewerModule/module.ts index 7bfc1b341467602de9ddcdcdefa5d75c889e00a8..d31ef339fad75c9ab61802e3c6a5dfb7c607ed85 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -9,6 +9,7 @@ import { BSFeatureModule, BS_DARKTHEME, } from "src/atlasComponents/regionalFea import { SplashUiModule } from "src/atlasComponents/splashScreen"; import { AtlasCmpUiSelectorsModule } from "src/atlasComponents/uiSelectors"; import { ComponentsModule } from "src/components"; +import { ContextMenuModule } from "src/contextMenuModule"; import { LayoutModule } from "src/layouts/layout.module"; import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; import { TopMenuModule } from "src/ui/topMenu/module"; @@ -38,6 +39,7 @@ import {QuickTourModule} from "src/ui/quickTour/module"; ComponentsModule, BSFeatureModule, QuickTourModule, + ContextMenuModule, ], declarations: [ ViewerCmp, diff --git a/src/viewerModule/nehuba/navigation.service/navigation.service.ts b/src/viewerModule/nehuba/navigation.service/navigation.service.ts index 6d6e16b83ad5201ffe61770f93ce3db6806f7e73..9344da35e6024e63a7c50d23bfd13a76a9eba203 100644 --- a/src/viewerModule/nehuba/navigation.service/navigation.service.ts +++ b/src/viewerModule/nehuba/navigation.service/navigation.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, OnDestroy, Optional } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { Observable, Subscription } from "rxjs"; +import { Observable, ReplaySubject, Subscription } from "rxjs"; import { debounceTime } from "rxjs/operators"; import { selectViewerConfigAnimationFlag } from "src/services/state/viewerConfig/selectors"; import { viewerStateChangeNavigation } from "src/services/state/viewerState/actions"; @@ -19,6 +19,7 @@ export class NehubaNavigationService implements OnDestroy{ private nehubaViewerInstance: NehubaViewerUnit public storeNav: INavObj public viewerNav: INavObj + public viewerNav$ = new ReplaySubject<INavObj>(1) // if set, ignores store attempt to update nav private viewerNavLock: boolean = false @@ -105,6 +106,7 @@ export class NehubaNavigationService implements OnDestroy{ this.nehubaViewerInstance.viewerPositionChange.subscribe( (val: INavObj) => { this.viewerNav = val + this.viewerNav$.next(val) this.viewerNavLock = true } ), diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts index 9865869b2083f56907e8d79c0570b866ab23a5c1..1459d4e14021804607718b0d13cffa6c1262fdc9 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts @@ -26,8 +26,8 @@ describe('> nehubaViewerGlue.component.ts', () => { provide: CLICK_INTERCEPTOR_INJECTOR, useFactory: (clickIntService: ClickInterceptorService) => { return { - deregister: clickIntService.removeInterceptor.bind(clickIntService), - register: clickIntService.addInterceptor.bind(clickIntService) + deregister: clickIntService.deregister.bind(clickIntService), + register: arg => clickIntService.register(arg) } as ClickInterceptor }, deps: [ @@ -72,7 +72,7 @@ describe('> nehubaViewerGlue.component.ts', () => { beforeEach(() => { fallbackSpy = spyOn(clickIntServ, 'fallback') TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) }) it('> dispatch not called', () => { expect(dispatchSpy).not.toHaveBeenCalled() @@ -92,7 +92,7 @@ describe('> nehubaViewerGlue.component.ts', () => { fallbackSpy = spyOn(clickIntServ, 'fallback') mockStore.overrideSelector(uiStateMouseOverSegmentsSelector, ['hello world', testObj0]) TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) }) it('> dispatch not called', () => { expect(dispatchSpy).not.toHaveBeenCalled() @@ -127,7 +127,7 @@ describe('> nehubaViewerGlue.component.ts', () => { }) it('> dispatch called with obj1', () => { TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) const { segment } = testObj1 expect(dispatchSpy).toHaveBeenCalledWith( viewerStateSetSelectedRegions({ @@ -137,7 +137,7 @@ describe('> nehubaViewerGlue.component.ts', () => { }) it('> fallback called (does not intercept)', () => { TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) expect(fallbackSpy).toHaveBeenCalled() }) }) diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index 0d8466e55ab61d2cab646e291873793a81e42688..99b59c3dcbe0aff2e73d636dbe51cef164b43ae0 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -1,6 +1,6 @@ -import { Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, TemplateRef, ViewChild } from "@angular/core"; +import { AfterViewInit, Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, ViewChild } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { asyncScheduler, combineLatest, fromEvent, merge, Observable, of, Subject } from "rxjs"; +import { asyncScheduler, combineLatest, fromEvent, merge, Observable, of, Subject, Subscription } from "rxjs"; import { ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer, ngViewerActionToggleMax } from "src/services/state/ngViewerState/actions"; import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors"; @@ -14,9 +14,9 @@ import { PANELS } from "src/services/state/ngViewerState/constants"; import { LoggingService } from "src/logging"; import { getMultiNgIdsRegionsLabelIndexMap, SET_MESHES_TO_LOAD } from "../constants"; -import { IViewer, TViewerEvent } from "../../viewer.interface"; +import { EnumViewerEvt, IViewer, TViewerEvent } from "../../viewer.interface"; import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; -import { NehubaViewerContainerDirective } from "../nehubaViewerInterface/nehubaViewerInterface.directive"; +import { NehubaViewerContainerDirective, TMouseoverEvent } from "../nehubaViewerInterface/nehubaViewerInterface.directive"; import { cvtNavigationObjToNehubaConfig, getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, scanSliceViewRenderFn, takeOnePipe } from "../util"; import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, TSetViewerHandle } from "src/atlasViewer/atlasViewer.apiService.service"; import { MouseHoverDirective } from "src/mouseoverModule"; @@ -24,6 +24,7 @@ import { NehubaMeshService } from "../mesh.service"; import { IQuickTourData } from "src/ui/quickTour/constrants"; import { NehubaLayerControlService, IColorMap, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service"; import { switchMapWaitFor } from "src/util/fn"; +import { INavObj } from "../navigation.service"; interface INgLayerInterface { name: string // displayName @@ -64,7 +65,7 @@ interface INgLayerInterface { ] }) -export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ +export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, AfterViewInit { public ARIA_LABELS = ARIA_LABELS public IDS = IDS @@ -130,10 +131,6 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ }))), ) - ngAfterViewInit(){ - this.setQuickTourPos() - } - public panelOrder$ = this.store$.pipe( select(ngViewerSelectorPanelOrder), distinctUntilChanged(), @@ -160,6 +157,43 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ } } + ngAfterViewInit(){ + this.setQuickTourPos() + + const { + mouseOverSegments, + navigationEmitter, + mousePosEmitter, + } = this.nehubaContainerDirective + const sub = combineLatest([ + mouseOverSegments, + navigationEmitter, + mousePosEmitter, + ]).pipe( + throttleTime(16, asyncScheduler, { trailing: true }) + ).subscribe(([ seg, nav, mouse ]: [ TMouseoverEvent [], INavObj, { real: number[], voxel: number[] } ]) => { + this.viewerEvent.emit({ + type: EnumViewerEvt.VIEWER_CTX, + data: { + viewerType: 'nehuba', + payload: { + nav, + mouse, + nehuba: seg.map(v => { + return { + layerName: v.layer.name, + labelIndices: [ Number(v.segmentId) ] + } + }) + } + } + }) + }) + this.onDestroyCb.push( + () => sub.unsubscribe() + ) + } + ngOnDestroy() { while (this.onDestroyCb.length) this.onDestroyCb.pop()() } @@ -272,7 +306,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ } @Output() - public viewerEvent = new EventEmitter<TViewerEvent>() + public viewerEvent = new EventEmitter<TViewerEvent<'nehuba'>>() constructor( private store$: Store<any>, @@ -282,10 +316,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, @Optional() private layerCtrlService: NehubaLayerControlService, ){ - this.viewerEvent.emit({ - type: 'MOUSEOVER_ANNOTATION', - data: {} - }) + /** * define onclick behaviour */ @@ -714,24 +745,24 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ handleViewerLoadedEvent(flag: boolean) { this.viewerEvent.emit({ - type: 'VIEWERLOADED', + type: EnumViewerEvt.VIEWERLOADED, data: flag }) this.viewerLoaded = flag } - private selectHoveredRegion(_ev: any, next: Function){ + private selectHoveredRegion(_ev: any): boolean{ /** * If label indicies are not defined by the ontology, it will be a string in the format of `{ngId}#{labelIndex}` */ const trueOnhoverSegments = this.onhoverSegments && this.onhoverSegments.filter(v => typeof v === 'object') - if (!trueOnhoverSegments || (trueOnhoverSegments.length === 0)) return next() + if (!trueOnhoverSegments || (trueOnhoverSegments.length === 0)) return true this.store$.dispatch( viewerStateSetSelectedRegions({ selectRegions: trueOnhoverSegments.slice(0, 1) }) ) - next() + return true } private waitForNehuba = switchMapWaitFor({ diff --git a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts index 33891485ba4928a4a4f5a8f0e817fda7acd67dcd..eaaab40c01f5ba4fd2845c900cb917cf5443b3bc 100644 --- a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts +++ b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts @@ -1,7 +1,7 @@ import { Directive, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, ComponentRef, OnInit, OnDestroy, Output, EventEmitter, Optional } from "@angular/core"; import { NehubaViewerUnit, INehubaLifecycleHook } from "../nehubaViewer/nehubaViewer.component"; import { Store, select } from "@ngrx/store"; -import { Subscription, Observable, fromEvent, asyncScheduler } from "rxjs"; +import { Subscription, Observable, fromEvent, asyncScheduler, combineLatest } from "rxjs"; import { distinctUntilChanged, filter, debounceTime, scan, map, throttleTime, switchMapTo } from "rxjs/operators"; import { takeOnePipe } from "../util"; import { ngViewerActionNehubaReady } from "src/services/state/ngViewerState/actions"; @@ -12,7 +12,7 @@ import { LoggingService } from "src/logging"; import { uiActionMouseoverLandmark, uiActionMouseoverSegments } from "src/services/state/uiState/actions"; import { IViewerConfigState } from "src/services/state/viewerConfig.store.helper"; import { arrayOfPrimitiveEqual } from 'src/util/fn' -import { NehubaNavigationService } from "../navigation.service"; +import { INavObj, NehubaNavigationService } from "../navigation.service"; const defaultNehubaConfig = { "configName": "", @@ -111,6 +111,14 @@ interface IProcessedVolume{ } } +export type TMouseoverEvent = { + layer: { + name: string + }, + segment: any | string + segmentId: string +} + const processStandaloneVolume: (url: string) => Promise<IProcessedVolume> = async (url: string) => { const protocol = determineProtocol(url) if (protocol === 'nifti'){ @@ -202,6 +210,15 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ public viewportToDatas: [any, any, any] = [null, null, null] + @Output('iav-nehuba-viewer-container-mouseover') + public mouseOverSegments = new EventEmitter<TMouseoverEvent[]>() + + @Output('iav-nehuba-viewer-container-navigation') + public navigationEmitter = new EventEmitter<INavObj>() + + @Output('iav-nehuba-viewer-container-mouse-pos') + public mousePosEmitter = new EventEmitter<{ voxel: number[], real: number[] }>() + @Output() public iavNehubaViewerContainerViewerLoading: EventEmitter<boolean> = new EventEmitter() @@ -279,7 +296,9 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ this.nehubaViewerInstance.applyPerformanceConfig(config) } }), - + this.navService.viewerNav$.subscribe(v => { + this.navigationEmitter.emit(v) + }) ) } @@ -353,20 +372,7 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ this.nehubaViewerInstance.mouseoverSegmentEmitter.pipe( scan(accumulatorFn, new Map()), map((map: Map<string, any>) => Array.from(map.entries()).filter(([_ngId, { segmentId }]) => segmentId)), - ).subscribe(arrOfArr => { - this.store$.dispatch( - uiActionMouseoverSegments({ - segments: arrOfArr.map( ([ngId, {segment, segmentId}]) => { - return { - layer: { - name: ngId, - }, - segment: segment || `${ngId}#${segmentId}`, - } - } ) - }) - ) - }), + ).subscribe(val => this.handleMouseoverSegments(val)), this.nehubaViewerInstance.mouseoverLandmarkEmitter.pipe( distinctUntilChanged() @@ -394,6 +400,16 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ ).subscribe((events: CustomEvent[]) => { [0, 1, 2].forEach(idx => this.viewportToDatas[idx] = events[idx].detail.viewportToData) }), + + combineLatest([ + this.nehubaViewerInstance.mousePosInVoxel$, + this.nehubaViewerInstance.mousePosInReal$ + ]).subscribe(([ voxel, real ]) => { + this.mousePosEmitter.emit({ + voxel, + real + }) + }) ) } @@ -421,4 +437,22 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ isReady() { return !!(this.cr?.instance?.nehubaViewer?.ngviewer) } + + handleMouseoverSegments(arrOfArr: [string, any][]) { + const payload = arrOfArr.map( ([ngId, {segment, segmentId}]) => { + return { + layer: { + name: ngId, + }, + segment: segment || `${ngId}#${segmentId}`, + segmentId + } + }) + this.mouseOverSegments.emit(payload) + this.store$.dispatch( + uiActionMouseoverSegments({ + segments: payload + }) + ) + } } diff --git a/src/viewerModule/nehuba/types.ts b/src/viewerModule/nehuba/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ab8a11c50d3dcb1a4539a96e58854ae76cc300e --- /dev/null +++ b/src/viewerModule/nehuba/types.ts @@ -0,0 +1,13 @@ +import { INavObj } from "./navigation.service"; + +export type TNehubaContextInfo = { + nav: INavObj + mouse: { + real: number[] + voxel: number[] + } + nehuba: { + layerName: string, + labelIndices: number[] + }[] +} diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index b68770f6675d0f7bec017b2ed0e22663427fb44b..96ba31a146366dffa8868c69f0ef4b01abec1c49 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -1,9 +1,18 @@ import { Component, Input, Output, EventEmitter, ElementRef, OnChanges, OnDestroy, AfterViewInit } from "@angular/core"; -import { IViewer, TViewerEvent } from "src/viewerModule/viewer.interface"; +import { EnumViewerEvt, IViewer, TViewerEvent } from "src/viewerModule/viewer.interface"; import { TThreeSurferConfig, TThreeSurferMode } from "../types"; import { parseContext } from "../util"; import { retry } from 'common/util' +type THandlingCustomEv = { + regions: ({ name?: string, error?: string })[] + event: CustomEvent + evMesh?: { + faceIndex: number + verticesIndicies: number[] + } +} + @Component({ selector: 'three-surfer-glue-cmp', templateUrl: './threeSurfer.template.html', @@ -12,7 +21,7 @@ import { retry } from 'common/util' ] }) -export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, OnDestroy { +export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, OnChanges, AfterViewInit, OnDestroy { @Input() selectedTemplate: any @@ -21,7 +30,7 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On selectedParcellation: any @Output() - viewerEvent = new EventEmitter<TViewerEvent>() + viewerEvent = new EventEmitter<TViewerEvent<'threeSurfer'>>() private domEl: HTMLElement private config: TThreeSurferConfig @@ -129,7 +138,7 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On this.loadMode(this.config.modes[0]) this.viewerEvent.emit({ - type: 'VIEWERLOADED', + type: EnumViewerEvt.VIEWERLOADED, data: true }) } @@ -137,21 +146,31 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On ngAfterViewInit(){ const customEvHandler = (ev: CustomEvent) => { + const evMesh = ev.detail?.mesh && { + faceIndex: ev.detail.mesh.faceIndex, + // typo in three-surfer + verticesIndicies: ev.detail.mesh.verticesIdicies + } + const custEv: THandlingCustomEv = { + event: ev, + regions: [], + evMesh + } if (!ev.detail.mesh) { - return this.handleMouseoverEvent([]) + return this.handleMouseoverEvent(custEv) } const evGeom = ev.detail.mesh.geometry const evVertIdx = ev.detail.mesh.verticesIdicies const found = this.loadedMeshes.find(({ threeSurfer }) => threeSurfer === evGeom) - if (!found) return this.handleMouseoverEvent([]) + if (!found) return this.handleMouseoverEvent(custEv) const { hemisphere: key, vIdxArr } = found if (!key || !evVertIdx) { - return this.handleMouseoverEvent([]) + return this.handleMouseoverEvent(custEv) } const labelIdxSet = new Set<number>() @@ -162,30 +181,32 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On ) } if (labelIdxSet.size === 0) { - return this.handleMouseoverEvent([]) + return this.handleMouseoverEvent(custEv) } const foundRegion = this.selectedParcellation.regions.find(({ name }) => name === key) if (!foundRegion) { - return this.handleMouseoverEvent( - Array.from(labelIdxSet).map(v => { - return `unknown#${v}` - }) - ) + custEv.regions = Array.from(labelIdxSet).map(v => { + return { + error: `unknown#${v}` + } + }) + return this.handleMouseoverEvent(custEv) } - return this.handleMouseoverEvent( - Array.from(labelIdxSet) - .map(lblIdx => { - const ontoR = foundRegion.children.find(ontR => Number(ontR.grayvalue) === lblIdx) - if (ontoR) { - return ontoR.name - } else { - return `unkonwn#${lblIdx}` + custEv.regions = Array.from(labelIdxSet) + .map(lblIdx => { + const ontoR = foundRegion.children.find(ontR => Number(ontR.grayvalue) === lblIdx) + if (ontoR) { + return ontoR + } else { + return { + error: `unkonwn#${lblIdx}` } - }) - ) + } + }) + return this.handleMouseoverEvent(custEv) } @@ -196,8 +217,26 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On } public mouseoverText: string - private handleMouseoverEvent(mouseover: any[]){ - this.mouseoverText = mouseover.length === 0 ? null : mouseover.join(' / ') + private handleMouseoverEvent(ev: THandlingCustomEv){ + const { regions: mouseover, evMesh } = ev + this.viewerEvent.emit({ + type: EnumViewerEvt.VIEWER_CTX, + data: { + viewerType: 'threeSurfer', + payload: { + fsversion: this.selectedMode, + faceIndex: evMesh?.faceIndex, + vertexIndices: evMesh?.verticesIndicies, + position: [], + _mouseoverRegion: mouseover.filter(el => !el.error) + } + } + }) + this.mouseoverText = mouseover.length === 0 ? + null : + mouseover.map( + el => el.name || el.error + ).join(' / ') } private onDestroyCb: (() => void) [] = [] diff --git a/src/viewerModule/threeSurfer/types.ts b/src/viewerModule/threeSurfer/types.ts index eb06df0b2c4cc95ef2c748489396cef48c37c172..d6f987870b2f1f6a628f69a0742c8e4177b4b38a 100644 --- a/src/viewerModule/threeSurfer/types.ts +++ b/src/viewerModule/threeSurfer/types.ts @@ -15,3 +15,11 @@ export type TThreeSurferConfig = { ['@context']: IContext modes: TThreeSurferMode[] } + +export type TThreeSurferContextInfo = { + position: number[] + faceIndex: number + vertexIndices: number[] + fsversion: string + _mouseoverRegion?: any[] +} diff --git a/src/viewerModule/viewer.interface.ts b/src/viewerModule/viewer.interface.ts index fdd5600b3a67aafb26c06335c4ec93d5742c0bb5..e93cb8f3a763d69164aa1ea0e22ae5e7ed064638 100644 --- a/src/viewerModule/viewer.interface.ts +++ b/src/viewerModule/viewer.interface.ts @@ -1,4 +1,6 @@ import { EventEmitter } from "@angular/core"; +import { TNehubaContextInfo } from "./nehuba/types"; +import { TThreeSurferContextInfo } from "./threeSurfer/types"; type TLayersColorMap = Map<string, Map<number, { red: number, green: number, blue: number }>> @@ -27,22 +29,43 @@ interface IViewerCtrl { getLayersColourMap(): TLayersColorMap } -type TViewerEventMOAnno = { - type: "MOUSEOVER_ANNOTATION" - data: any +export interface IViewerCtx { + 'nehuba': TNehubaContextInfo + 'threeSurfer': TThreeSurferContextInfo +} + +export type TContextArg<K extends keyof IViewerCtx> = ({ + viewerType: K + payload: IViewerCtx[K] +}) + +export enum EnumViewerEvt { + VIEWERLOADED, + VIEWER_CTX, } type TViewerEventViewerLoaded = { - type: "VIEWERLOADED" + type: EnumViewerEvt.VIEWERLOADED data: boolean } -export type TViewerEvent = TViewerEventMOAnno | TViewerEventViewerLoaded +export type TViewerEvent<T extends keyof IViewerCtx> = TViewerEventViewerLoaded | + { + type: EnumViewerEvt.VIEWER_CTX, + data: TContextArg<T> + } + +export type TSupportedViewers = keyof IViewerCtx -export type IViewer = { +export interface IViewer<K extends keyof IViewerCtx> { selectedTemplate: any selectedParcellation: any viewerCtrlHandler?: IViewerCtrl - viewerEvent: EventEmitter<TViewerEvent> -} \ No newline at end of file + viewerEvent: EventEmitter<TViewerEvent<K>> +} + +export interface IGetContextInjArg { + register: (fn: (contextArg: TContextArg<TSupportedViewers>) => void) => void + deregister: (fn: (contextArg: TContextArg<TSupportedViewers>) => void) => void +} diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 3a7183062394728fdf901aaf1ccd4d966294d4c6..aa6d2af847e6cb6570e982bdc096d7cfaaa3f10a 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, Inject, Input, OnDestroy, Optional, ViewChild } from "@angular/core"; +import { Component, ElementRef, Inject, Input, OnDestroy, Optional, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core"; import { select, Store } from "@ngrx/store"; import { combineLatest, Observable, Subject, Subscription } from "rxjs"; import { distinctUntilChanged, filter, map, startWith } from "rxjs/operators"; @@ -11,10 +11,13 @@ import { uiActionHideAllDatasets, uiActionHideDatasetWithId } from "src/services import { REGION_OF_INTEREST } from "src/util/interfaces"; import { animate, state, style, transition, trigger } from "@angular/animations"; import { SwitchDirective } from "src/util/directives/switch.directive"; -import { IViewerCmpUiState, TSupportedViewer } from "../constants"; +import { IViewerCmpUiState } from "../constants"; import { QuickTourThis, IQuickTourData } from "src/ui/quickTour"; import { MatDrawer } from "@angular/material/sidenav"; import { ComponentStore } from "../componentStore"; +import { EnumViewerEvt, TContextArg, TSupportedViewers, TViewerEvent } from "../viewer.interface"; +import { getGetRegionFromLabelIndexId } from "src/util/fn"; +import { ContextMenuService } from "src/contextMenuModule"; @Component({ selector: 'iav-cmp-viewer-container', @@ -102,6 +105,7 @@ export class ViewerCmp implements OnDestroy { @Input() ismobile = false private subscriptions: Subscription[] = [] + private onDestroyCb: (() => void)[] = [] public viewerLoaded: boolean = false public templateSelected$ = this.store$.pipe( @@ -123,7 +127,7 @@ export class ViewerCmp implements OnDestroy { map(v => v.length > 0) ) - public useViewer$: Observable<TSupportedViewer> = combineLatest([ + public useViewer$: Observable<TSupportedViewers | 'notsupported'> = combineLatest([ this.templateSelected$, this.isStandaloneVolumes$, ]).pipe( @@ -175,9 +179,17 @@ export class ViewerCmp implements OnDestroy { map(([ regions, layers ]) => regions.length === 0 && layers.length === 0) ) + @ViewChild('viewerStatusCtxMenu', { read: TemplateRef }) + private viewerStatusCtxMenu: TemplateRef<any> + + public context: TContextArg<TSupportedViewers> + private templateSelected: any + private getRegionFromlabelIndexId: Function + constructor( private store$: Store<any>, private viewerCmpLocalUiStore: ComponentStore<IViewerCmpUiState>, + private viewerModuleSvc: ContextMenuService, @Optional() @Inject(REGION_OF_INTEREST) public regionOfInterest$: Observable<any> ){ this.viewerCmpLocalUiStore.setState({ @@ -201,12 +213,70 @@ export class ViewerCmp implements OnDestroy { filter(flag => !flag), ).subscribe(() => { this.openSideNavs() - }) + }), + this.viewerModuleSvc.context$.subscribe( + (ctx: any) => this.context = ctx + ), + this.templateSelected$.subscribe( + t => this.templateSelected = t + ), + this.parcellationSelected$.subscribe( + p => { + this.getRegionFromlabelIndexId = !!p + ? getGetRegionFromLabelIndexId({ parcellation: p }) + : null + } + ) + ) + } + + ngAfterViewInit(){ + const cb = (context: TContextArg<'nehuba' | 'threeSurfer'>) => { + let hoveredRegions = [] + + if (context.viewerType === 'nehuba') { + hoveredRegions = (context as TContextArg<'nehuba'>).payload.nehuba.reduce( + (acc, curr) => acc.concat( + curr.labelIndices.map( + lblIdx => { + const labelIndexId = `${curr.layerName}#${lblIdx}` + if (!!this.getRegionFromlabelIndexId) { + return this.getRegionFromlabelIndexId({ + labelIndexId: `${curr.layerName}#${lblIdx}` + }) + } + return labelIndexId + } + ) + ), + [] + ) + } + + if (context.viewerType === 'threeSurfer') { + hoveredRegions = (context as TContextArg<'threeSurfer'>).payload._mouseoverRegion + } + + return { + tmpl: this.viewerStatusCtxMenu, + data: { + context, + metadata: { + template: this.templateSelected, + hoveredRegions + } + } + } + } + this.viewerModuleSvc.register(cb) + this.onDestroyCb.push( + () => this.viewerModuleSvc.deregister(cb) ) } ngOnDestroy() { while (this.subscriptions.length) this.subscriptions.pop().unsubscribe() + while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()() } public activePanelTitles$: Observable<string[]> @@ -246,6 +316,14 @@ export class ViewerCmp implements OnDestroy { ) } + public selectRoi(roi: any) { + this.store$.dispatch( + viewerStateSetSelectedRegions({ + selectRegions: [ roi ] + }) + ) + } + public clearSelectedRegions(){ this.store$.dispatch( viewerStateSetSelectedRegions({ @@ -303,4 +381,20 @@ export class ViewerCmp implements OnDestroy { !sideNavExpanded ? null : this.regionSelRef ) } + + public handleViewerEvent(event: TViewerEvent<'nehuba' | 'threeSurfer'>){ + switch(event.type) { + case EnumViewerEvt.VIEWERLOADED: + this.viewerLoaded = event.data + break + case EnumViewerEvt.VIEWER_CTX: + this.viewerModuleSvc.context$.next(event.data) + break + default: + } + } + + public disposeCtxMenu(){ + this.viewerModuleSvc.dismissCtxMenu() + } } diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index c401208018a6e89b00075de69239b8d976084111..22b8b3f220a4f2394af393bfcb50621d5f94b359 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -245,14 +245,16 @@ <iav-layout-fourcorners> <div iavLayoutFourCornersContent class="w-100 h-100 position-absolute"> - <div class="h-100 w-100 overflow-hidden position-relative"> + <div class="h-100 w-100 overflow-hidden position-relative" + ctx-menu-host + [ctx-menu-host-tmpl]="viewerCtxMenuTmpl"> <ng-container [ngSwitch]="useViewer$ | async"> <!-- nehuba viewer --> <iav-cmp-viewer-nehuba-glue class="d-block w-100 h-100 position-absolute left-0 top-0" *ngSwitchCase="'nehuba'" - (viewerEvent)="viewerLoaded = $event.data" + (viewerEvent)="handleViewerEvent($event)" [selectedTemplate]="templateSelected$ | async" [selectedParcellation]="parcellationSelected$ | async" #iavCmpViewerNehubaGlue="iavCmpViewerNehubaGlue"> @@ -261,7 +263,7 @@ <!-- three surfer (free surfer viewer) --> <three-surfer-glue-cmp class="d-block w-100 h-100 position-absolute left-0 top-0" *ngSwitchCase="'threeSurfer'" - (viewerEvent)="viewerLoaded = $event.data" + (viewerEvent)="handleViewerEvent($event)" [selectedTemplate]="templateSelected$ | async" [selectedParcellation]="parcellationSelected$ | async"> </three-surfer-glue-cmp> @@ -1014,3 +1016,87 @@ </span> </div> </ng-template> + +<!-- context menu template --> +<ng-template #viewerCtxMenuTmpl let-tmplRefs="tmplRefs"> + <mat-card class="p-0" (iav-outsideClick)="disposeCtxMenu()"> + <mat-card-content *ngFor="let tmplRef of tmplRefs"> + <ng-container *ngTemplateOutlet="tmplRef.tmpl; context: { $implicit: tmplRef.data } "> + </ng-container> + </mat-card-content> + </mat-card> +</ng-template> + +<!-- viewer status ctx menu --> +<ng-template #viewerStatusCtxMenu let-data> + <mat-list> + + <!-- ref space & position --> + <ng-container [ngSwitch]="data.context.viewerType"> + + <!-- volumetric i.e. nehuba --> + <ng-container *ngSwitchCase="'nehuba'"> + <mat-list-item> + <span mat-line> + {{ data.context.payload.mouse.real | nmToMm | addUnitAndJoin : '' }} (mm) + </span> + <span mat-line class="text-muted"> + <i class="fas fa-map"></i> + <span> + {{ data.metadata.template.displayName || data.metadata.template.name }} + </span> + </span> + + <button mat-icon-button> + <i class="fas fa-thumbtack"></i> + </button> + </mat-list-item> + </ng-container> + + <ng-container *ngSwitchCase="'threeSurfer'"> + <mat-list-item> + <span mat-line> + face#{{ data.context.payload.faceIndex }} + </span> + <span mat-line> + vertices#{{ data.context.payload.vertexIndices | addUnitAndJoin : '' }} + </span> + <span mat-line class="text-muted"> + <i class="fas fa-map"></i> + <span> + {{ data.context.payload.fsversion }} + </span> + </span> + </mat-list-item> + </ng-container> + + <ng-container *ngSwitchDefault> + DEFAULT + </ng-container> + </ng-container> + + <!-- hovered ROIs --> + <ng-template [ngIf]="data.metadata.hoveredRegions.length > 0"> + <mat-divider></mat-divider> + + <mat-list-item *ngFor="let hoveredR of data.metadata.hoveredRegions"> + <span mat-line> + {{ hoveredR.displayName || hoveredR.name }} + </span> + <span mat-line class="text-muted"> + <i class="fas fa-brain"></i> + <span> + Brain region + </span> + </span> + + <!-- lookup region --> + <button mat-icon-button + (click)="selectRoi(hoveredR)" + ctx-menu-dismiss> + <i class="fas fa-search"></i> + </button> + </mat-list-item> + </ng-template> + </mat-list> +</ng-template>