diff --git a/package.json b/package.json index c32b06f78a57865f044bc4a5e55f2aaeed1e09fa..9e45f61968a122d765cd8011217e730271c227d1 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@ngrx/effects": "^9.1.1", "@ngrx/store": "^9.1.1", "@types/node": "12.12.39", - "export-nehuba": "0.0.4", + "export-nehuba": "0.0.6", "hbp-connectivity-component": "^0.3.18", "zone.js": "^0.10.2" } diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 5d2652a506fa056e1fd4bbc828a4bec891920b99..d77129d69ba46c90ac2eee782cac01fb1948112a 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -16,6 +16,7 @@ import { import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { FRAGMENT_EMIT_RED } from "src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component"; import { IPluginManifest, PluginServices } from "src/plugin"; +import { ILoadMesh } from 'src/messaging/types' declare let window @@ -34,14 +35,6 @@ interface IGetUserSelectRegionPr{ export const CANCELLABLE_DIALOG = 'CANCELLABLE_DIALOG' -export interface ILoadMesh { - type: 'VTK' - id: string - url: string - customFragmentColor?: string -} -export const LOAD_MESH_TOKEN = new InjectionToken<(loadMeshParam: ILoadMesh) => void>('LOAD_MESH_TOKEN') - @Injectable({ providedIn : 'root' }) diff --git a/src/main.module.ts b/src/main.module.ts index cc8b838adf510afd39302d2275cf4fd1fca61cb9..f4a8fcaa08008d4069692958c6c32adf8735037d 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -14,8 +14,9 @@ import { GetNamesPipe } from "./util/pipes/getNames.pipe"; import { HttpClientModule } from "@angular/common/http"; import { EffectsModule } from "@ngrx/effects"; -import { AtlasViewerAPIServices, CANCELLABLE_DIALOG, API_SERVICE_SET_VIEWER_HANDLE_TOKEN, setViewerHandleFactory, LOAD_MESH_TOKEN, ILoadMesh } from "./atlasViewer/atlasViewer.apiService.service"; +import { AtlasViewerAPIServices, CANCELLABLE_DIALOG, API_SERVICE_SET_VIEWER_HANDLE_TOKEN, setViewerHandleFactory } from "./atlasViewer/atlasViewer.apiService.service"; import { AtlasWorkerService } from "./atlasViewer/atlasViewer.workerService.service"; +import { LOAD_MESH_TOKEN, ILoadMesh, WINDOW_MESSAGING_HANDLER_TOKEN } from 'src/messaging/types' import { ConfirmDialogComponent } from "./components/confirmDialog/confirmDialog.component"; import { DialogComponent } from "./components/dialog/dialog.component"; @@ -60,6 +61,7 @@ import { CookieModule } from './ui/cookieAgreement/module'; import { KgTosModule } from './ui/kgtos/module'; import { MouseoverModule } from './mouseoverModule/mouseover.module'; import { AtlasViewerRouterModule } from './routerModule'; +import { MessagingGlue } from './messagingGlue'; export function debug(reducer: ActionReducer<any>): ActionReducer<any> { return function(state, action) { @@ -248,6 +250,10 @@ export function debug(reducer: ActionReducer<any>): ActionReducer<any> { provide: VIEWERMODULE_DARKTHEME, useFactory: (pureConstantService: PureContantService) => pureConstantService.darktheme$, deps: [ PureContantService ] + }, + { + provide: WINDOW_MESSAGING_HANDLER_TOKEN, + useClass: MessagingGlue } ], bootstrap : [ diff --git a/src/messaging/module.spec.ts b/src/messaging/module.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0718eb33d55cec48f03ce267fcd76be3e214373f --- /dev/null +++ b/src/messaging/module.spec.ts @@ -0,0 +1,54 @@ +import { CommonModule } from "@angular/common" +import { Component } from "@angular/core" +import { TestBed } from "@angular/core/testing" +import { provideMockStore } from "@ngrx/store/testing" +import { MesssagingModule } from "./module" +import { IAV_POSTMESSAGE_NAMESPACE } from './service' + +@Component({ + template: '' +}) +class DummyCmp{} + +describe('> module.ts', () => { + describe('> MesssagingModule', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + MesssagingModule + ], + declarations: [ + DummyCmp + ], + providers: [ + provideMockStore() + ] + }).compileComponents() + }) + + describe('> service is init', () => { + let spy: jasmine.Spy + beforeEach(() => { + }) + + // TODO need to test that module result in service instantiation + it('> pong is heard', () => { + // const fixture = TestBed.createComponent(DummyCmp) + + // spy = jasmine.createSpy() + // window.addEventListener('message', ev => { + // console.log('message', ev.data) + // if (ev.data.result === 'pong') { + // spy() + // } + // }) + // window.postMessage({ + // method: `${IAV_POSTMESSAGE_NAMESPACE}ping`, + // id: '123' + // }, '*' ) + // expect(spy).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/src/messaging/module.ts b/src/messaging/module.ts index f3e496eea20bbd34cb270640d839c948f17ec6d7..e5ae4af65ba02762978da3f3129a883fff4d107b 100644 --- a/src/messaging/module.ts +++ b/src/messaging/module.ts @@ -1,174 +1,22 @@ -import { Inject, NgModule, Optional } from "@angular/core"; -import { MatDialog } from "@angular/material/dialog"; -import { MatSnackBar } from "@angular/material/snack-bar"; -import { AtlasViewerAPIServices } from "src/atlasViewer/atlasViewer.apiService.service"; -import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; -import { LOAD_MESH_TOKEN, ILoadMesh } from "src/atlasViewer/atlasViewer.apiService.service"; +import { NgModule } from "@angular/core"; import { ComponentsModule } from "src/components"; -import { ConfirmDialogComponent } from "src/components/confirmDialog/confirmDialog.component"; + import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; -import { getRandomHex } from 'common/util' +import { MessagingService } from "./service"; -const IAV_POSTMESSAGE_NAMESPACE = `ebrains:iav:` @NgModule({ imports: [ AngularMaterialModule, ComponentsModule, + ], + providers: [ + MessagingService, ] }) export class MesssagingModule{ - - private whiteListedOrigins = new Set() - private pendingRequests: Map<string, Promise<boolean>> = new Map() - private windowName: string - - constructor( - private dialog: MatDialog, - private snackbar: MatSnackBar, - private worker: AtlasWorkerService, - @Optional() private apiService: AtlasViewerAPIServices, - @Optional() @Inject(LOAD_MESH_TOKEN) private loadMesh: (loadMeshParam: ILoadMesh) => void - ){ - - if (window.opener){ - this.windowName = window.name - window.opener.postMessage({ - id: getRandomHex(), - method: `${IAV_POSTMESSAGE_NAMESPACE}onload`, - param: { - 'window.name': this.windowName - } - }, '*') - - window.addEventListener('beforeunload', () => { - window.opener.postMessage({ - id: getRandomHex(), - method: `${IAV_POSTMESSAGE_NAMESPACE}beforeunload`, - param: { - 'window.name': this.windowName - } - }, '*') - }) - } - - window.addEventListener('message', async ({ data, origin, source }) => { - const { method, id, param } = data - const src = source as Window - if (!method) return - if (method.indexOf(IAV_POSTMESSAGE_NAMESPACE) !== 0) return - const strippedMethod = method.replace(IAV_POSTMESSAGE_NAMESPACE, '') - - /** - * if ping method, respond pong method - */ - if (strippedMethod === 'ping') { - src.postMessage({ - id, - result: 'pong', - jsonrpc: '2.0' - }, origin) - return - } - - /** - * otherwise, check permission - */ - - try { - const allow = await this.checkOrigin({ origin }) - if (!allow) { - src.postMessage({ - jsonrpc: '2.0', - id, - error: { - code: 403, - message: 'User declined' - } - }, origin) - return - } - const result = await this.processMessage({ method: strippedMethod, param }) - - src.postMessage({ - jsonrpc: '2.0', - id, - result - }, origin) - - } catch (e) { - - src.postMessage({ - jsonrpc: '2.0', - id, - error: e.code - ? e - : { code: 500, message: e.toString() } - }, origin) - } - - }) - } - - async processMessage({ method, param }){ - - if (method === 'dummyMethod') { - return 'OK' - } - - if (method === 'viewerHandle:add3DLandmarks') { - this.apiService.interactiveViewer.viewerHandle.add3DLandmarks(param) - return 'OK' - } - - if (method === 'viewerHandle:remove3DLandmarks') { - this.apiService.interactiveViewer.viewerHandle.remove3DLandmarks(param) - return 'OK' - } - - if (method === '_tmp:plotly') { - const isLoadingSnack = this.snackbar.open(`Loading plotly mesh ...`) - const resp = await this.worker.sendMessage({ - method: `PROCESS_PLOTLY`, - param - }) - isLoadingSnack?.dismiss() - const meshId = 'bobby' - if (this.loadMesh) { - const { objectUrl, customFragmentColor } = resp.result || {} - this.loadMesh({ - type: 'VTK', - id: meshId, - url: objectUrl, - customFragmentColor - }) - } else { - this.snackbar.open(`Error: loadMesh method not injected.`) - } - return 'OK' - } - - throw ({ code: 404, message: 'Method not found' }) - } - - async checkOrigin({ origin }){ - if (this.whiteListedOrigins.has(origin)) return true - if (this.pendingRequests.has(origin)) return this.pendingRequests.get(origin) - const responsePromise = this.dialog.open( - ConfirmDialogComponent, - { - data: { - title: `Cross tab messaging`, - message: `${origin} would like to send data to interactive atlas viewer`, - okBtnText: `Allow` - } - } - ).afterClosed().toPromise() - this.pendingRequests.set(origin, responsePromise) - const response = await responsePromise - this.pendingRequests.delete(origin) - if (response) this.whiteListedOrigins.add(origin) - return response + // need to inject service + constructor(_mS: MessagingService){ } } diff --git a/src/messaging/native/index.ts b/src/messaging/native/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..394f5418658eb6ac1ed44621ccf1d9c024f24d33 --- /dev/null +++ b/src/messaging/native/index.ts @@ -0,0 +1,27 @@ +import { Observable, Subject } from "rxjs" +import { IMessagingActions, IMessagingActionTmpl } from "../types" + +export const TYPE = 'iav.unload' + +type TUnload = { + ['@id']: string +} + +export const processJsonLd = (json: TUnload): Observable<IMessagingActions<keyof IMessagingActionTmpl>> => { + const sub = new Subject<IMessagingActions<keyof IMessagingActionTmpl>>() + const _main = (() => { + if (!json['@id']) { + return sub.error(`@id must be defined to `) + } + sub.next({ + type: 'unloadResource', + payload: { + "@id": json['@id'] + } + }) + sub.complete() + + }) + setTimeout(_main); + return sub +} diff --git a/src/messaging/nmvSwc/index.ts b/src/messaging/nmvSwc/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7080d4193d3b2abb09b87fbc91f264731c91727a --- /dev/null +++ b/src/messaging/nmvSwc/index.ts @@ -0,0 +1,110 @@ +import { Observable, Subject } from "rxjs" +import { getUuid } from "src/util/fn" +import { IMessagingActions, IMessagingActionTmpl } from "../types" + +export const TYPE = 'bas.datasource' + +const waitFor = (condition: (...arg: any[]) => boolean) => new Promise((rs, rj) => { + const intervalRef = setInterval(() => { + if (condition()) { + clearInterval(intervalRef) + rs() + } + }, 1000) +}) + +const NM_IDS = { + AMBA_V3: 'hbp:Allen_Mouse_CCF_v3(nm)', + WAXHOLM_V1_01: 'hbp:WHS_SD_Rat_v1.01(nm)', + BIG_BRAIN: 'hbp:BigBrain_r2015(nm)', + COLIN: 'hbp:Colin27_r2008(nm)', + MNI152_2009C_ASYM: 'hbp:ICBM_Asym_r2009c(nm)', +} + +const IAV_IDS = { + AMBA_V3: 'minds/core/referencespace/v1.0.0/265d32a0-3d84-40a5-926f-bf89f68212b9', + WAXHOLM_V1_01: 'minds/core/referencespace/v1.0.0/d5717c4a-0fa1-46e6-918c-b8003069ade8', + BIG_BRAIN: 'minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588', + COLIN: 'minds/core/referencespace/v1.0.0/7f39f7be-445b-47c0-9791-e971c0b6d992', + MNI152_2009C_ASYM: 'minds/core/referencespace/v1.0.0/dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2', +} + +const translateSpace = (spaceId: string) => { + for (const key in NM_IDS){ + if (NM_IDS[key] === spaceId) return IAV_IDS[key] + } + return null +} + +export const processJsonLd = (json: { [key: string]: any }): Observable<IMessagingActions<keyof IMessagingActionTmpl>> => { + const subject = new Subject<IMessagingActions<keyof IMessagingActionTmpl>>() + const main = (async () => { + + const { + encoding, + mediaType, + data: rawData, + transformations + } = json + + if (mediaType.indexOf('model/swc') < 0) return subject.error(`mediaType of ${mediaType} cannot be parsed. Not 'model/swc'`) + + if (!Array.isArray(transformations)) { + return subject.error(`transformations must be an array!`) + } + + if (!transformations[0]) { + return subject.error(`transformations[0] must be defined`) + } + const { toSpace } = transformations[0] + + const iavSpace = translateSpace(toSpace) + if (!iavSpace) { + return subject.error(`toSpace with id ${toSpace} cannot be found.`) + } + subject.next({ + type: 'loadTemplate', + payload: { + ['@id']: iavSpace + } + }) + + await waitFor(() => !!(window as any).export_nehuba) + + const b64Encoded = encoding.indexOf('base64') >= 0 + const isGzipped = encoding.indexOf('gzip') >= 0 + let data = rawData + if (b64Encoded) { + data = atob(data) + } + if (isGzipped) { + data = (window as any).export_nehuba.pako.inflate(data) + } + let output = `` + for (let i = 0; i < data.length; i++) { + output += String.fromCharCode(data[i]) + } + + const encoder = new TextEncoder() + const tmpUrl = URL.createObjectURL( + new Blob([ encoder.encode(output) ], { type: 'application/octet-stream' }) + ) + const uuid = getUuid() + const payload: IMessagingActionTmpl['loadResource'] = { + '@id': uuid, + "@type" : 'swc', + unload: () => { + URL.revokeObjectURL(tmpUrl) + }, + url: tmpUrl + } + subject.next({ + type: 'loadResource', + payload + }) + + subject.complete() + }) + setTimeout(main); + return subject +} diff --git a/src/messaging/service.spec.ts b/src/messaging/service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcacc5aaac382ed90d8a9ba1c50ccc1f4601c673 --- /dev/null +++ b/src/messaging/service.spec.ts @@ -0,0 +1,128 @@ +import { TestBed } from "@angular/core/testing" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service" +import { viewerStateFetchedAtlasesSelector } from "src/services/state/viewerState/selectors" +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { getUuid } from "src/util/fn" +import { IAV_POSTMESSAGE_NAMESPACE, MessagingService } from "./service" +import { IWindowMessaging, WINDOW_MESSAGING_HANDLER_TOKEN } from "./types" + +describe('> service.ts', () => { + describe('> MessagingService', () => { + const windowMessagehandler = { + loadResource: jasmine.createSpy(), + loadTempladById: jasmine.createSpy(), + unloadResource: jasmine.createSpy() + } + afterEach(() => { + windowMessagehandler.loadResource.calls.reset() + windowMessagehandler.unloadResource.calls.reset() + windowMessagehandler.loadTempladById.calls.reset() + }) + beforeEach(() => { + + TestBed.configureTestingModule({ + imports: [ + AngularMaterialModule, + ], + providers: [ + provideMockStore(), + AtlasWorkerService, + { + provide: WINDOW_MESSAGING_HANDLER_TOKEN, + useValue: windowMessagehandler + } + ] + }) + + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(viewerStateFetchedAtlasesSelector, []) + }) + + it('> can be inst', () => { + const s = TestBed.inject(MessagingService) + expect(s).toBeTruthy() + }) + + describe('> on construct', () => { + describe('> if window.opener', () => { + describe('> is defined', () => { + let openerProxy = { + postMessage: jasmine.createSpy() + } + const randomWindowName = getUuid() + beforeEach(() => { + spyOnProperty(window, 'opener').and.returnValue(openerProxy) + spyOnProperty(window, 'name').and.returnValue(randomWindowName) + TestBed.inject(MessagingService) + }) + afterEach(() => { + openerProxy.postMessage.calls.reset() + }) + it('> postMessage is called on window.opener', () => { + expect(openerProxy.postMessage).toHaveBeenCalledTimes(1) + }) + describe('> args are as expected', () => { + let args: any[] + beforeEach(() => { + args = openerProxy.postMessage.calls.allArgs()[0] + }) + it('> method === {namespace}onload', () => { + expect(args[0]['method']).toEqual(`${IAV_POSTMESSAGE_NAMESPACE}onload`) + }) + it('> param[window.name] is windowname', () => { + expect(args[0]['param']['window.name']).toEqual(randomWindowName) + }) + }) + + describe('> beforeunload', () => { + beforeEach(() => { + // onload messages are called before unload + openerProxy.postMessage.calls.reset() + + // https://github.com/karma-runner/karma/issues/1062#issuecomment-42421624 + window.onbeforeunload = null + window.dispatchEvent(new Event('beforeunload')) + }) + it('> sends beforeunload event', () => { + expect(openerProxy.postMessage).toHaveBeenCalled() + }) + it('> method is {namespace}beforeunload', () => { + const args = openerProxy.postMessage.calls.allArgs()[0] + expect(args[0]['method']).toEqual(`${IAV_POSTMESSAGE_NAMESPACE}beforeunload`) + }) + }) + }) + }) + + describe('> listen to message', () => { + beforeEach(() => { + + }) + describe('> ping', () => { + it('> pong') + }) + + describe('> check permission', () => { + it('> if succeeds') + it('> if fails') + }) + + it('> if throws') + }) + + describe('> #processMessage', () => { + describe('> method === _tmp:plotly ', () => { + + }) + describe('> managedMethods', () => { + + }) + }) + + describe('> #processJsonld', () => { + + }) + }) + }) +}) diff --git a/src/messaging/service.ts b/src/messaging/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..bca0845834341cb2506a8fdb968708fd98ac4b41 --- /dev/null +++ b/src/messaging/service.ts @@ -0,0 +1,238 @@ +import { Inject, Injectable, Optional } from "@angular/core"; +import { Observable } from "rxjs"; +import { MatDialog } from "@angular/material/dialog"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +import { getUuid } from "src/util/fn"; +import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; +import { ConfirmDialogComponent } from "src/components/confirmDialog/confirmDialog.component"; + +import { IMessagingActions, IMessagingActionTmpl, ILoadMesh, LOAD_MESH_TOKEN, WINDOW_MESSAGING_HANDLER_TOKEN, IWindowMessaging } from './types' +import { TYPE as NMV_TYPE, processJsonLd as nmvProcess } from './nmvSwc/index' +import { TYPE as NATIVE_TYPE, processJsonLd as nativeProcess } from './native' + +export const IAV_POSTMESSAGE_NAMESPACE = `ebrains:iav:` + +export const MANAGED_METHODS = [ + 'openminds:nmv:loadSwc', + 'openminds:nmv:unloadSwc' +] + +@Injectable({ + providedIn: 'root' +}) + +export class MessagingService { + + private whiteListedOrigins = new Set() + private pendingRequests: Map<string, Promise<boolean>> = new Map() + private windowName: string + + private typeRegister: Map<string, (arg: any) => Observable<IMessagingActions<keyof IMessagingActionTmpl>>> = new Map() + + constructor( + private dialog: MatDialog, + private snackbar: MatSnackBar, + private worker: AtlasWorkerService, + @Optional() @Inject(WINDOW_MESSAGING_HANDLER_TOKEN) private messagingHandler: IWindowMessaging, + @Optional() @Inject(LOAD_MESH_TOKEN) private loadMesh: (loadMeshParam: ILoadMesh) => void, + ){ + + if (window.opener){ + this.windowName = window.name + window.opener.postMessage({ + id: getUuid(), + method: `${IAV_POSTMESSAGE_NAMESPACE}onload`, + param: { + 'window.name': this.windowName + } + }, '*') + + window.addEventListener('beforeunload', () => { + window.opener.postMessage({ + id: getUuid(), + method: `${IAV_POSTMESSAGE_NAMESPACE}beforeunload`, + param: { + 'window.name': this.windowName + } + }, '*') + }) + } + + window.addEventListener('message', async ({ data, origin, source }) => { + const { method, id, param } = data + const src = source as Window + if (!method) return + if (method.indexOf(IAV_POSTMESSAGE_NAMESPACE) !== 0) return + const strippedMethod = method.replace(IAV_POSTMESSAGE_NAMESPACE, '') + + /** + * if ping method, respond pong method + */ + if (strippedMethod === 'ping') { + src.postMessage({ + id, + result: 'pong', + jsonrpc: '2.0' + }, origin) + return + } + + /** + * otherwise, check permission + */ + + try { + const allow = await this.checkOrigin({ origin }) + if (!allow) { + src.postMessage({ + jsonrpc: '2.0', + id, + error: { + code: 403, + message: 'User declined' + } + }, origin) + return + } + const result = await this.processMessage({ method: strippedMethod, param }) + + src.postMessage({ + jsonrpc: '2.0', + id, + result + }, origin) + + } catch (e) { + + src.postMessage({ + jsonrpc: '2.0', + id, + error: e.code + ? e + : { code: 500, message: e.toString() } + }, origin) + } + + }) + + this.typeRegister.set( + NMV_TYPE, + nmvProcess + ) + this.typeRegister.set( + NATIVE_TYPE, + nativeProcess + ) + + } + + processJsonld(jsonLd: any){ + const { ['@type']: type } = jsonLd + const fn = this.typeRegister.get(type) + // TODO tidy this return value up + let returnValue: any + return new Promise((rs, rj) => { + + const sub = fn(jsonLd) + sub.subscribe( + ev => { + if (ev.type === 'loadTemplate') { + const payload = ev.payload as IMessagingActionTmpl['loadTemplate'] + + this.messagingHandler.loadTempladById(payload) + } + + if (ev.type === 'loadResource') { + const payload = ev.payload as IMessagingActionTmpl['loadResource'] + returnValue = { + ['@id']: payload["@id"], + ['@type']: NATIVE_TYPE + } + this.messagingHandler.loadResource(payload) + } + + if (ev.type === 'unloadResource') { + const payload = ev.payload as IMessagingActionTmpl['unloadResource'] + this.messagingHandler.unloadResource(payload) + } + }, + rj, + () => { + rs(returnValue || {}) + } + ) + }) + } + + async processMessage({ method, param }){ + + // TODO combine api service and messaging service into one + // and implement it properly + + // if (method === 'viewerHandle:add3DLandmarks') { + // this.apiService.interactiveViewer.viewerHandle.add3DLandmarks(param) + // return 'OK' + // } + + // if (method === 'viewerHandle:remove3DLandmarks') { + // this.apiService.interactiveViewer.viewerHandle.remove3DLandmarks(param) + // return 'OK' + // } + + /** + * TODO use loadResource in the future + */ + if (method === '_tmp:plotly') { + const isLoadingSnack = this.snackbar.open(`Loading plotly mesh ...`) + const resp = await this.worker.sendMessage({ + method: `PROCESS_PLOTLY`, + param + }) + isLoadingSnack?.dismiss() + const meshId = 'bobby' + if (this.loadMesh) { + const { objectUrl, customFragmentColor } = resp.result || {} + this.loadMesh({ + type: 'VTK', + id: meshId, + url: objectUrl, + customFragmentColor + }) + } else { + this.snackbar.open(`Error: loadMesh method not injected.`) + } + return 'OK' + } + + if (MANAGED_METHODS.indexOf(method) >= 0) { + try { + return await this.processJsonld(param) + } catch (e) { + throw ({ code: 401, message: e }) + } + } + + throw ({ code: 404, message: 'Method not found' }) + } + + async checkOrigin({ origin }){ + if (this.whiteListedOrigins.has(origin)) return true + if (this.pendingRequests.has(origin)) return this.pendingRequests.get(origin) + const responsePromise = this.dialog.open( + ConfirmDialogComponent, + { + data: { + title: `Cross tab messaging`, + message: `${origin} would like to send data to interactive atlas viewer`, + okBtnText: `Allow` + } + } + ).afterClosed().toPromise() + this.pendingRequests.set(origin, responsePromise) + const response = await responsePromise + this.pendingRequests.delete(origin) + if (response) this.whiteListedOrigins.add(origin) + return response + } +} diff --git a/src/messaging/types.ts b/src/messaging/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f0fb1391fa404e053f38e823cccef62214dfc71 --- /dev/null +++ b/src/messaging/types.ts @@ -0,0 +1,52 @@ +import { InjectionToken } from "@angular/core"; + +interface ILoadTemplateByIdPayload { + ['@id']: string +} + +interface IResourceType { + swc: string +} + +interface ILoadResource { + ['@id']: string + ['@type']: keyof IResourceType + url: string + unload: () => void +} + +interface IUnloadResource { + ['@id']: string +} + +interface ISetResp { + [key: string]: any +} + +export interface IMessagingActionTmpl { + setResponse: ISetResp + loadTemplate: ILoadTemplateByIdPayload + loadResource: ILoadResource + unloadResource: IUnloadResource +} + +export interface IMessagingActions<TAction extends keyof IMessagingActionTmpl> { + type: TAction + payload: IMessagingActionTmpl[TAction] +} + +export interface ILoadMesh { + type: 'VTK' + id: string + url: string + customFragmentColor?: string +} +export const LOAD_MESH_TOKEN = new InjectionToken<(loadMeshParam: ILoadMesh) => void>('LOAD_MESH_TOKEN') + +export interface IWindowMessaging { + loadTempladById(payload: IMessagingActionTmpl['loadTemplate']): void + loadResource(payload: IMessagingActionTmpl['loadResource']): void + unloadResource(payload: IMessagingActionTmpl['unloadResource']): void +} + +export const WINDOW_MESSAGING_HANDLER_TOKEN = new InjectionToken<IWindowMessaging>('WINDOW_MESSAGING_HANDLER_TOKEN') diff --git a/src/messagingGlue.ts b/src/messagingGlue.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4e6c9087a7a1ca451147e5dfcf0ffb20b846f51 --- /dev/null +++ b/src/messagingGlue.ts @@ -0,0 +1,109 @@ +import { OnDestroy } from "@angular/core"; +import { select, Store } from "@ngrx/store"; +import { IMessagingActionTmpl, IWindowMessaging } from "./messaging/types"; +import { ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer } from "./services/state/ngViewerState/actions"; +import { viewerStateSelectAtlas } from "./services/state/viewerState/actions"; +import { viewerStateFetchedAtlasesSelector } from "./services/state/viewerState/selectors"; +import { generalActionError } from "./services/stateStore.helper"; + +export class MessagingGlue implements IWindowMessaging, OnDestroy { + + private onDestroyCb: (() => void)[] = [] + private tmplSpIdToAtlasId = new Map<string, string>() + private mapIdUnload = new Map<string, () => void>() + + ngOnDestroy(){ + while(this.onDestroyCb.length > 0) this.onDestroyCb.pop()() + } + + constructor(private store: Store<any>){ + + const sub = this.store.pipe( + select(viewerStateFetchedAtlasesSelector) + ).subscribe((atlases: any[]) => { + for (const atlas of atlases) { + const { ['@id']: atlasId, templateSpaces } = atlas + for (const tmpl of templateSpaces) { + const { ['@id']: tmplId } = tmpl + this.tmplSpIdToAtlasId.set(tmplId, atlasId) + } + } + }) + + this.onDestroyCb.push(() => sub.unsubscribe()) + } + + /** + * it is important to not use select temlate by id. always go from the highest hierarchy, + * and enforce single direction flow when possible + */ + loadTempladById( payload: IMessagingActionTmpl['loadTemplate'] ){ + const atlasId = this.tmplSpIdToAtlasId.get(payload['@id']) + if (!atlasId) { + return this.store.dispatch( + generalActionError({ + message: `atlas id with the corresponding templateId ${payload['@id']} not found.` + }) + ) + } + this.store.dispatch( + viewerStateSelectAtlas({ + atlas: { + ['@id']: atlasId, + template: { + ['@id']: payload['@id'] + } + } + }) + ) + } + + loadResource(payload: IMessagingActionTmpl['loadResource']){ + const { + unload, + url, + ["@type"]: type, + ["@id"]: swcLayerUuid + } = payload + + if (type === 'swc') { + + const layer = { + name: swcLayerUuid, + id: swcLayerUuid, + source: `swc://${url}`, + mixability: 'mixable', + type: "segmentation", + "segments": [ + "1" + ], + } + + this.store.dispatch( + ngViewerActionAddNgLayer({ + layer + }) + ) + + this.mapIdUnload.set(swcLayerUuid, () => { + this.store.dispatch( + ngViewerActionRemoveNgLayer({ + layer: { + name: swcLayerUuid + } + }) + ) + unload() + }) + } + } + unloadResource(payload: IMessagingActionTmpl['unloadResource']) { + const { ["@id"]: id } = payload + const cb = this.mapIdUnload.get(id) + if (!cb) { + throw new Error(`Unload resource id ${id} does not exist.`) + } + cb() + this.mapIdUnload.delete(id) + } +} diff --git a/src/services/state/viewerState/actions.ts b/src/services/state/viewerState/actions.ts index 68787f5b45cb6053bb99557bd1b34f42c004db62..4dd72c7aa8268cb5b18b71b8ed8d5c5c17f235cd 100644 --- a/src/services/state/viewerState/actions.ts +++ b/src/services/state/viewerState/actions.ts @@ -46,7 +46,14 @@ export const viewerStateSetFetchedAtlases = createAction( export const viewerStateSelectAtlas = createAction( `[viewerState] selectAtlas`, - props<{ atlas: { ['@id']: string } }>() + props<{ + atlas: { + ['@id']: string + template?: { + ['@id']: string + } + } + }>() ) export const viewerStateHelperSelectParcellationWithId = createAction( diff --git a/src/state/effects/viewerState.useEffect.spec.ts b/src/state/effects/viewerState.useEffect.spec.ts index 8952ae3ae0d94c022e40b1f7fa21374fca0f5203..b6c0340d170d1494e97ad45de6f08e8fd9d0cfaa 100644 --- a/src/state/effects/viewerState.useEffect.spec.ts +++ b/src/state/effects/viewerState.useEffect.spec.ts @@ -386,7 +386,7 @@ describe('> viewerState.useEffect.ts', () => { ) }) - describe('> if atlas found, will try to find id of first template', () => { + describe('> if atlas found', () => { const mockParc1 = { ['@id']: 'parc-1', availableIn: [{ @@ -407,70 +407,152 @@ describe('> viewerState.useEffect.ts', () => { ['@id']: 'test-1', availableIn: [ mockParc1 ] } - it('> if fails, will return general error', () => { - - mockStore.overrideSelector(viewerStateFetchedTemplatesSelector, [ - mockTmplSpc1 - ]) - mockStore.overrideSelector(viewerStateFetchedAtlasesSelector, [{ - ['@id']: 'foo-bar', - templateSpaces: [ mockTmplSpc ] - }]) - actions$ = hot('a', { - a: viewerStateSelectAtlas({ - atlas: { + + describe('> if template key val is not provided', () => { + describe('> will try to find the id of the first tmpl', () => { + + it('> if fails, will return general error', () => { + + mockStore.overrideSelector(viewerStateFetchedTemplatesSelector, [ + mockTmplSpc1 + ]) + mockStore.overrideSelector(viewerStateFetchedAtlasesSelector, [{ ['@id']: 'foo-bar', - } + templateSpaces: [ mockTmplSpc ] + }]) + actions$ = hot('a', { + a: viewerStateSelectAtlas({ + atlas: { + ['@id']: 'foo-bar', + } + }) + }) + + const viewerSTateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerSTateCtrlEffect.onSelectAtlasSelectTmplParc$ + ).toBeObservable( + hot('a', { + a: generalActionError({ + message: CONST.TEMPLATE_NOT_FOUND + }) + }) + ) }) - }) - const viewerSTateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) - expect( - viewerSTateCtrlEffect.onSelectAtlasSelectTmplParc$ - ).toBeObservable( - hot('a', { - a: generalActionError({ - message: CONST.TEMPLATE_NOT_FOUND + it('> if succeeds, will dispatch new viewer', () => { + const completeMocktmpl = { + ...mockTmplSpc1, + parcellations: [ mockParc1 ] + } + mockStore.overrideSelector(viewerStateFetchedTemplatesSelector, [ + completeMocktmpl + ]) + mockStore.overrideSelector(viewerStateFetchedAtlasesSelector, [{ + ['@id']: 'foo-bar', + templateSpaces: [ mockTmplSpc1 ] + }]) + actions$ = hot('a', { + a: viewerStateSelectAtlas({ + atlas: { + ['@id']: 'foo-bar', + } + }) }) + + const viewerSTateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerSTateCtrlEffect.onSelectAtlasSelectTmplParc$ + ).toBeObservable( + hot('a', { + a: viewerStateNewViewer({ + selectTemplate: completeMocktmpl, + selectParcellation: mockParc1 + }) + }) + ) }) - ) - }) - it('> if succeeds, will dispatch new viewer', () => { - const completeMocktmpl = { + }) + }) + + describe('> if template key val is provided', () => { + + const completeMockTmpl = { + ...mockTmplSpc, + parcellations: [ mockParc0 ] + } + const completeMocktmpl1 = { ...mockTmplSpc1, parcellations: [ mockParc1 ] } - mockStore.overrideSelector(viewerStateFetchedTemplatesSelector, [ - completeMocktmpl - ]) - mockStore.overrideSelector(viewerStateFetchedAtlasesSelector, [{ - ['@id']: 'foo-bar', - templateSpaces: [ mockTmplSpc1 ] - }]) - actions$ = hot('a', { - a: viewerStateSelectAtlas({ - atlas: { - ['@id']: 'foo-bar', - } + beforeEach(() => { + + mockStore.overrideSelector(viewerStateFetchedTemplatesSelector, [ + completeMockTmpl, + completeMocktmpl1, + ]) + mockStore.overrideSelector(viewerStateFetchedAtlasesSelector, [{ + ['@id']: 'foo-bar', + templateSpaces: [ mockTmplSpc, mockTmplSpc1 ] + }]) + }) + it('> will select template.@id', () => { + + actions$ = hot('a', { + a: viewerStateSelectAtlas({ + atlas: { + ['@id']: 'foo-bar', + template: { + ['@id']: mockTmplSpc1['@id'] + } + } + }) }) + + const viewerSTateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerSTateCtrlEffect.onSelectAtlasSelectTmplParc$ + ).toBeObservable( + hot('a', { + a: viewerStateNewViewer({ + selectTemplate: completeMocktmpl1, + selectParcellation: mockParc1 + }) + }) + ) + }) - const viewerSTateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) - expect( - viewerSTateCtrlEffect.onSelectAtlasSelectTmplParc$ - ).toBeObservable( - hot('a', { - a: viewerStateNewViewer({ - selectTemplate: completeMocktmpl, - selectParcellation: mockParc1 + it('> if template.@id is not defined, will fallback to first template', () => { + + actions$ = hot('a', { + a: viewerStateSelectAtlas({ + atlas: { + ['@id']: 'foo-bar', + template: { + + } as any + } }) }) - ) + + const viewerSTateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerSTateCtrlEffect.onSelectAtlasSelectTmplParc$ + ).toBeObservable( + hot('a', { + a: viewerStateNewViewer({ + selectTemplate: completeMockTmpl, + selectParcellation: mockParc0 + }) + }) + ) + + }) }) }) }) - }) describe('> cvtNehubaConfigToNavigationObj', () => { diff --git a/src/state/effects/viewerState.useEffect.ts b/src/state/effects/viewerState.useEffect.ts index a26d43e2b76e9cc8256805f702e3a266041bb650..f865242c591578d6b8734b3bf28cf66abe954183 100644 --- a/src/state/effects/viewerState.useEffect.ts +++ b/src/state/effects/viewerState.useEffect.ts @@ -136,7 +136,8 @@ export class ViewerStateControllerUseEffect implements OnDestroy { ), map(([action, fetchedTemplates, fetchedAtlases ])=> { - const atlas = fetchedAtlases.find(a => a['@id'] === (action as any).atlas['@id']) + const { atlas: atlasObj } = action as any + const atlas = fetchedAtlases.find(a => a['@id'] === atlasObj['@id']) if (!atlas) { return generalActionError({ message: CONST.ATLAS_NOT_FOUND @@ -145,7 +146,12 @@ export class ViewerStateControllerUseEffect implements OnDestroy { /** * selecting atlas means selecting the first available templateSpace */ - const templateTobeSelected = atlas.templateSpaces[0] + const targetTmplSpcId = atlasObj['template']?.['@id'] + const templateTobeSelected = ( + targetTmplSpcId + && atlas.templateSpaces.find(t => t['@id'] === targetTmplSpcId) + ) || atlas.templateSpaces[0] + const templateSpaceId = templateTobeSelected['@id'] const parcellationId = (