diff --git a/package.json b/package.json index fb08407f448f9bb11b7ade82697b1272147f0cda..04ddb7088000c63d48ff074d7893b1f927500d91 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build-min": "webpack --config webpack.prod.js", "build": "webpack --config webpack.dev.js", "dev-server": "webpack-dev-server --config webpack.dev.js --mode development", + "serve-plugins" : "node src/plugin_examples/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0d0c59842c00c652c5507bdb1ee901bde00015e --- /dev/null +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -0,0 +1,118 @@ +import { Injectable, Renderer2 } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { ViewerStateInterface, safeFilter } from "../services/stateStore.service"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +declare var window + +@Injectable({ + providedIn : 'root' +}) + +export class AtlasViewerAPIServices{ + + private loadedTemplates$ : Observable<any> + public interactiveViewer : InteractiveViewerInterface + + public loadedLibraries : Map<string,{counter:number,src:HTMLElement|null}> = new Map() + + constructor( + private store : Store<ViewerStateInterface> + ){ + + this.loadedTemplates$ = this.store.pipe( + select('viewerState'), + safeFilter('fetchedTemplates'), + map(state=>state.fetchedTemplates) + ) + + this.interactiveViewer = { + metadata : { + selectedTemplateBSubject : this.store.pipe( + select('viewerState'), + safeFilter('templateSelected'), + map(state=>state.templateSelected)), + + selectedParcellationBSubject : this.store.pipe( + select('viewerState'), + safeFilter('parcellationSelected'), + map(state=>state.parcellationSelected)), + + selectedRegionsBSubject : this.store.pipe( + select('viewerState'), + safeFilter('regionsSelected'), + map(state=>state.regionsSelected)), + + loadedTemplates : [], + + regionsLabelIndexMap : new Map(), + + datasetsBSubject : this.store.pipe( + select('dataStore'), + safeFilter('fetchedDataEntries'), + map(state=>state.fetchedDataEntries) + ) + }, + uiHandle : {}, + pluginControl : { + loadExternalLibraries : ()=>Promise.reject('load External Library method not over written') + , + unloadExternalLibraries : ()=>{ + console.warn('unloadExternalLibrary method not overwritten by atlasviewer') + } + } + } + window['interactiveViewer'] = this.interactiveViewer + this.init() + } + + private init(){ + this.loadedTemplates$.subscribe(templates=>this.interactiveViewer.metadata.loadedTemplates = templates) + } +} + +export interface InteractiveViewerInterface{ + + metadata : { + selectedTemplateBSubject : Observable<any|null> + selectedParcellationBSubject : Observable<any|null> + selectedRegionsBSubject : Observable<any[]|null> + loadedTemplates : any[] + regionsLabelIndexMap : Map<number,any> | null + datasetsBSubject : Observable<any[]> + }, + + viewerHandle? : { + setNavigationLoc : (coordinates:[number,number,number],realSpace?:boolean)=>void + moveToNavigationLoc : (coordinates:[number,number,number],realSpace?:boolean)=>void + setNavigationOri : (quat:[number,number,number,number])=>void + moveToNavigationOri : (quat:[number,number,number,number])=>void + showSegment : (labelIndex : number)=>void + hideSegment : (labelIndex : number)=>void + showAllSegments : ()=>void + hideAllSegments : ()=>void + segmentColourMap : Map<number,{red:number,green:number,blue:number}> + applyColourMap : (newColourMap : Map<number,{red:number,green:number,blue:number}>)=>void + loadLayer : (layerobj:NGLayerObj)=>NGLayerObj + removeLayer : (condition:{name : string | RegExp})=>string[] + setLayerVisibility : (condition:{name : string|RegExp},visible:boolean)=>void + + mouseEvent : Observable<{eventName:string,event:MouseEvent}> + mouseOverNehuba : Observable<{labelIndex : number, foundRegion : any | null}> + } + + uiHandle : { + + } + + pluginControl : { + loadExternalLibraries : (libraries:string[])=>Promise<void> + unloadExternalLibraries : (libraries:string[])=>void + [key:string] : any + } +} + +export interface NGLayerObj{ + +} \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index ef92e3e159f41a86e8b9bc75fadffe50bac2cca2..f7b94e6fef819da278d6f4084dd592f634b4feaa 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -1,133 +1,194 @@ -import { Component, HostBinding, ViewChild, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, OnDestroy, ElementRef, Injector, ComponentRef, AfterViewInit, OnInit, TemplateRef, HostListener } from "@angular/core"; +import { Component, HostBinding, ViewChild, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, OnDestroy, ElementRef, Injector, ComponentRef, AfterViewInit, OnInit, TemplateRef, HostListener, Renderer2 } from "@angular/core"; import { Store, select } from "@ngrx/store"; -import { ViewerStateInterface, safeFilter, OPEN_SIDE_PANEL, CLOSE_SIDE_PANEL, isDefined, NEWVIEWER, viewerState, CHANGE_NAVIGATION, SELECT_REGIONS, getLabelIndexMap, LOAD_DEDICATED_LAYER, UNLOAD_DEDICATED_LAYER } from "../services/stateStore.service"; -import { Observable, Subscription, combineLatest, merge } from "rxjs"; -import { map, filter, scan, take, distinctUntilChanged } from "rxjs/operators"; +import { ViewerStateInterface, safeFilter, OPEN_SIDE_PANEL, CLOSE_SIDE_PANEL, isDefined,UNLOAD_DEDICATED_LAYER } from "../services/stateStore.service"; +import { Observable, Subscription } from "rxjs"; +import { map, filter, distinctUntilChanged } from "rxjs/operators"; import { AtlasViewerDataService } from "./atlasViewer.dataService.service"; import { WidgetServices } from "./widgetUnit/widgetService.service"; import { DataBrowserUI } from "../ui/databrowser/databrowser.component"; import { LayoutMainSide } from "../layouts/mainside/mainside.component"; import { Chart } from 'chart.js' -import { AtlasViewerConstantsServices } from "./atlasViewer.constantService.service"; +import { AtlasViewerConstantsServices, SUPPORT_LIBRARY_MAP } from "./atlasViewer.constantService.service"; import { BsModalService } from "ngx-bootstrap/modal"; import { ModalUnit } from "./modalUnit/modalUnit.component"; import { AtlasViewerURLService } from "./atlasViewer.urlService.service"; import { ToastComponent } from "../components/toast/toast.component"; import { WidgetUnit } from "./widgetUnit/widgetUnit.component"; +import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; +import { PluginServices } from "./atlasViewer.pluginService.service"; +import '../res/css/extra_styles.css' @Component({ - selector : 'atlas-viewer', - templateUrl : './atlasViewer.template.html', - styleUrls : [ + selector: 'atlas-viewer', + templateUrl: './atlasViewer.template.html', + styleUrls: [ `./atlasViewer.style.css` ] }) -export class AtlasViewer implements OnDestroy,OnInit,AfterViewInit{ +export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { - @ViewChild('dockedContainer',{read:ViewContainerRef}) dockedContainer : ViewContainerRef - @ViewChild('floatingContainer',{read:ViewContainerRef}) floatingContainer : ViewContainerRef - @ViewChild('databrowser',{read:ElementRef}) databrowser : ElementRef - @ViewChild('temporaryContainer',{read:ViewContainerRef}) temporaryContainer : ViewContainerRef + @ViewChild('dockedContainer', { read: ViewContainerRef }) dockedContainer: ViewContainerRef + @ViewChild('floatingContainer', { read: ViewContainerRef }) floatingContainer: ViewContainerRef + @ViewChild('databrowser', { read: ElementRef }) databrowser: ElementRef + @ViewChild('temporaryContainer', { read: ViewContainerRef }) temporaryContainer: ViewContainerRef - @ViewChild('toastContainer',{read:ViewContainerRef}) toastContainer : ViewContainerRef - @ViewChild('dedicatedViewerToast',{read:TemplateRef}) dedicatedViewerToast : TemplateRef<any> + @ViewChild('toastContainer', { read: ViewContainerRef }) toastContainer: ViewContainerRef + @ViewChild('dedicatedViewerToast', { read: TemplateRef }) dedicatedViewerToast: TemplateRef<any> - @ViewChild('floatingMouseContextualContainer', {read:ViewContainerRef}) floatingMouseContextualContainer : ViewContainerRef + @ViewChild('floatingMouseContextualContainer', { read: ViewContainerRef }) floatingMouseContextualContainer: ViewContainerRef - @ViewChild(LayoutMainSide) layoutMainSide : LayoutMainSide + @ViewChild('pluginFactory', { read: ViewContainerRef }) pluginViewContainerRef: ViewContainerRef - @HostBinding('attr.darktheme') - darktheme : boolean = false + @ViewChild(LayoutMainSide) layoutMainSide: LayoutMainSide - meetsRequirement : boolean = true + @HostBinding('attr.darktheme') + darktheme: boolean = false - toastComponentFactory : ComponentFactory<ToastComponent> - databrowserComponentFactory : ComponentFactory<DataBrowserUI> - databrowserComponentRef : ComponentRef<DataBrowserUI> - private databrowserHostComponentRef :ComponentRef<WidgetUnit> - private dedicatedViewComponentRef : ComponentRef<ToastComponent> + meetsRequirement: boolean = true - private newViewer$ : Observable<any> - public dedicatedView$ : Observable<string|null> - public onhoverSegment$ : Observable<string> - private subscriptions : Subscription[] = [] + toastComponentFactory: ComponentFactory<ToastComponent> + databrowserComponentFactory: ComponentFactory<DataBrowserUI> + databrowserComponentRef: ComponentRef<DataBrowserUI> + private databrowserHostComponentRef: ComponentRef<WidgetUnit> + private dedicatedViewComponentRef: ComponentRef<ToastComponent> + + private newViewer$: Observable<any> + public dedicatedView$: Observable<string | null> + public onhoverSegment$: Observable<string> + private subscriptions: Subscription[] = [] constructor( - private store : Store<ViewerStateInterface>, - public dataService : AtlasViewerDataService, - private cfr : ComponentFactoryResolver, - private widgetServices : WidgetServices, - private constantsService : AtlasViewerConstantsServices, - public urlService : AtlasViewerURLService, - private modalService:BsModalService, - private injector : Injector - ){ + private pluginService: PluginServices, + private rd2: Renderer2, + private store: Store<ViewerStateInterface>, + public dataService: AtlasViewerDataService, + private cfr: ComponentFactoryResolver, + private widgetServices: WidgetServices, + private constantsService: AtlasViewerConstantsServices, + public urlService: AtlasViewerURLService, + public apiService: AtlasViewerAPIServices, + private modalService: BsModalService, + private injector: Injector + ) { this.toastComponentFactory = this.cfr.resolveComponentFactory(ToastComponent) this.databrowserComponentFactory = this.cfr.resolveComponentFactory(DataBrowserUI) - this.databrowserComponentRef = this.databrowserComponentFactory.create( this.injector ) + this.databrowserComponentRef = this.databrowserComponentFactory.create(this.injector) this.newViewer$ = this.store.pipe( select('viewerState'), - filter(state=>isDefined(state) && isDefined(state.templateSelected)), - map(state=>state.templateSelected), - distinctUntilChanged((t1,t2)=>t1.name === t2.name) + filter(state => isDefined(state) && isDefined(state.templateSelected)), + map(state => state.templateSelected), + distinctUntilChanged((t1, t2) => t1.name === t2.name) ) this.dedicatedView$ = this.store.pipe( select('viewerState'), - filter(state=>isDefined(state)&& typeof state.dedicatedView !== 'undefined'), - map(state=>state.dedicatedView), + filter(state => isDefined(state) && typeof state.dedicatedView !== 'undefined'), + map(state => state.dedicatedView), distinctUntilChanged() ) - + this.onhoverSegment$ = this.store.pipe( select('uiState'), - filter(state=>isDefined(state)), - map(state=>state.mouseOverSegment ? - state.mouseOverSegment.constructor === Number ? - state.mouseOverSegment.toString() : - state.mouseOverSegment.name : - '' ), + /* cannot filter by state, as the template expects a default value, or it will throw ExpressionChangedAfterItHasBeenCheckedError */ + map(state => isDefined(state) ? + state.mouseOverSegment ? + state.mouseOverSegment.constructor === Number ? + state.mouseOverSegment.toString() : + state.mouseOverSegment.name : + '' : + ''), distinctUntilChanged() ) - } - ngOnInit(){ + ngOnInit() { + + this.apiService.interactiveViewer.pluginControl.loadExternalLibraries = (libraries: string[]) => new Promise((resolve, reject) => { + const srcHTMLElement = libraries.map(libraryName => ({ + name: libraryName, + srcEl: SUPPORT_LIBRARY_MAP.get(libraryName) + })) + + const rejected = srcHTMLElement.filter(scriptObj => scriptObj.srcEl === null) + if (rejected.length > 0) + return reject(`Some library names cannot be recognised. No libraries were loaded: ${rejected.map(srcObj => srcObj.name).join(', ')}`) + + Promise.all(srcHTMLElement.map(scriptObj => new Promise((rs, rj) => { + if('customElements' in window && scriptObj.name === 'webcomponentsLite'){ + return rs() + } + const existingEntry = this.apiService.loadedLibraries.get(scriptObj.name) + if (existingEntry) { + this.apiService.loadedLibraries.set(scriptObj.name, { counter: existingEntry.counter + 1, src: existingEntry.src }) + rs() + } else { + const srcEl = scriptObj.srcEl + srcEl.onload = () => rs() + srcEl.onerror = (e: any) => rj(e) + this.rd2.appendChild(document.head, srcEl) + this.apiService.loadedLibraries.set(scriptObj.name, { counter: 1, src: srcEl }) + } + }))) + .then(() => resolve()) + .catch(e => (console.warn(e), reject(e))) + }) + + this.apiService.interactiveViewer.pluginControl.unloadExternalLibraries = (libraries: string[]) => + libraries + .filter((stringname) => SUPPORT_LIBRARY_MAP.get(stringname) !== null) + .forEach(libname => { + const ledger = this.apiService.loadedLibraries.get(libname!) + if (!ledger) { + console.warn('unload external libraries error. cannot find ledger entry...', libname, this.apiService.loadedLibraries) + return + } + if (ledger.src === null) { + console.log('webcomponents is native supported. no library needs to be unloaded') + return + } + + if (ledger.counter - 1 == 0) { + this.rd2.removeChild(document.head, ledger.src) + this.apiService.loadedLibraries.delete(libname!) + } else { + this.apiService.loadedLibraries.set(libname!, { counter: ledger.counter - 1, src: ledger.src }) + } + }) this.meetsRequirement = this.meetsRequirements() this.subscriptions.push( - this.dedicatedView$.subscribe(string=>{ - if(string === null){ - if(this.dedicatedViewComponentRef) + this.dedicatedView$.subscribe(string => { + if (string === null) { + if (this.dedicatedViewComponentRef) this.dedicatedViewComponentRef.destroy() return } - this.dedicatedViewComponentRef = this.toastContainer.createComponent( this.toastComponentFactory ) - this.dedicatedViewComponentRef.instance.messageContainer.createEmbeddedView( this.dedicatedViewerToast ) + this.dedicatedViewComponentRef = this.toastContainer.createComponent(this.toastComponentFactory) + this.dedicatedViewComponentRef.instance.messageContainer.createEmbeddedView(this.dedicatedViewerToast) this.dedicatedViewComponentRef.instance.dismissable = false }) ) this.subscriptions.push( - this.newViewer$.subscribe(template=>{ - this.darktheme = this.meetsRequirement ? + this.newViewer$.subscribe(template => { + this.darktheme = this.meetsRequirement ? template.useTheme === 'dark' : false - if(this.databrowserHostComponentRef){ + if (this.databrowserHostComponentRef) { this.databrowserHostComponentRef.instance.container.detach(0) - this.temporaryContainer.insert( this.databrowserComponentRef.hostView ) + this.temporaryContainer.insert(this.databrowserComponentRef.hostView) } this.widgetServices.clearAllWidgets() - this.databrowserHostComponentRef = - this.widgetServices.addNewWidget(this.databrowserComponentRef,{ - title : 'Data Browser', - exitable :false, - state : 'docked' + this.databrowserHostComponentRef = + this.widgetServices.addNewWidget(this.databrowserComponentRef, { + title: 'Data Browser', + exitable: false, + state: 'docked' }) }) ) @@ -136,9 +197,9 @@ export class AtlasViewer implements OnDestroy,OnInit,AfterViewInit{ this.store.pipe( select('uiState'), safeFilter('sidePanelOpen'), - map(state=>state.sidePanelOpen) - ).subscribe(show=> - this.layoutMainSide.showSide=show) + map(state => state.sidePanelOpen) + ).subscribe(show => + this.layoutMainSide.showSide = show) ) /** @@ -147,55 +208,55 @@ export class AtlasViewer implements OnDestroy,OnInit,AfterViewInit{ Chart.pluginService.register({ /* patching background color fill, so saved images do not look completely white */ - beforeDraw: (chart)=>{ + beforeDraw: (chart) => { const ctx = chart.ctx as CanvasRenderingContext2D; - ctx.fillStyle = this.darktheme ? + ctx.fillStyle = this.darktheme ? `rgba(50,50,50,0.8)` : `rgba(255,255,255,0.8)` - - if(chart.canvas)ctx.fillRect(0,0,chart.canvas.width,chart.canvas.height) - + + if (chart.canvas) ctx.fillRect(0, 0, chart.canvas.width, chart.canvas.height) + }, /* patching standard deviation for polar (potentially also line/bar etc) graph */ - afterInit : (chart)=>{ - if(chart.config.options && chart.config.options.tooltips){ - + afterInit: (chart) => { + if (chart.config.options && chart.config.options.tooltips) { + chart.config.options.tooltips.callbacks = { - label : function(tooltipItem,data){ + label: function (tooltipItem, data) { let sdValue - if( data.datasets && typeof tooltipItem.datasetIndex != 'undefined' && data.datasets[tooltipItem.datasetIndex].label ){ - const sdLabel = data.datasets[tooltipItem.datasetIndex].label+'_sd' - const sd = data.datasets.find(dataset=> typeof dataset.label != 'undefined' && dataset.label == sdLabel) - if(sd && sd.data && typeof tooltipItem.index != 'undefined' && typeof tooltipItem.yLabel != 'undefined') sdValue = Number(sd.data[tooltipItem.index]) - Number(tooltipItem.yLabel) + if (data.datasets && typeof tooltipItem.datasetIndex != 'undefined' && data.datasets[tooltipItem.datasetIndex].label) { + const sdLabel = data.datasets[tooltipItem.datasetIndex].label + '_sd' + const sd = data.datasets.find(dataset => typeof dataset.label != 'undefined' && dataset.label == sdLabel) + if (sd && sd.data && typeof tooltipItem.index != 'undefined' && typeof tooltipItem.yLabel != 'undefined') sdValue = Number(sd.data[tooltipItem.index]) - Number(tooltipItem.yLabel) } - return `${tooltipItem.yLabel} ${sdValue ? '('+ sdValue +')' : ''}` + return `${tooltipItem.yLabel} ${sdValue ? '(' + sdValue + ')' : ''}` } } } - if(chart.data.datasets){ + if (chart.data.datasets) { chart.data.datasets = chart.data.datasets - .map(dataset=>{ - if(dataset.label && /\_sd$/.test(dataset.label)){ - const originalDS = chart.data.datasets!.find(baseDS=>typeof baseDS.label!== 'undefined' && (baseDS.label == dataset.label!.replace(/_sd$/,''))) - if(originalDS){ - return Object.assign({},dataset,{ - data : (originalDS.data as number[]).map((datapoint,idx)=>(Number(datapoint) + Number((dataset.data as number[])[idx]))), + .map(dataset => { + if (dataset.label && /\_sd$/.test(dataset.label)) { + const originalDS = chart.data.datasets!.find(baseDS => typeof baseDS.label !== 'undefined' && (baseDS.label == dataset.label!.replace(/_sd$/, ''))) + if (originalDS) { + return Object.assign({}, dataset, { + data: (originalDS.data as number[]).map((datapoint, idx) => (Number(datapoint) + Number((dataset.data as number[])[idx]))), ... this.constantsService.chartSdStyle }) - }else{ + } else { return dataset } - }else if (dataset.label){ - const sdDS = chart.data.datasets!.find(sdDS=>typeof sdDS.label !=='undefined' && (sdDS.label == dataset.label + '_sd')) - if(sdDS){ - return Object.assign({},dataset,{ + } else if (dataset.label) { + const sdDS = chart.data.datasets!.find(sdDS => typeof sdDS.label !== 'undefined' && (sdDS.label == dataset.label + '_sd')) + if (sdDS) { + return Object.assign({}, dataset, { ...this.constantsService.chartBaseStyle }) - }else{ + } else { return dataset } - }else{ + } else { return dataset } }) @@ -204,39 +265,42 @@ export class AtlasViewer implements OnDestroy,OnInit,AfterViewInit{ }) } - ngOnDestroy(){ - this.subscriptions.forEach(s=>s.unsubscribe()) + ngOnDestroy() { + this.subscriptions.forEach(s => s.unsubscribe()) } - ngAfterViewInit(){ + ngAfterViewInit() { this.widgetServices.floatingContainer = this.floatingContainer this.widgetServices.dockedContainer = this.dockedContainer + + this.pluginService.pluginViewContainerRef = this.pluginViewContainerRef + this.pluginService.appendSrc = (src: HTMLElement) => this.rd2.appendChild(document.head, src) } - meetsRequirements(){ - + meetsRequirements() { + const canvas = document.createElement('canvas') const gl = canvas.getContext('webgl') - const message:any = { - Error:'Your browser does not meet the minimum requirements to run neuroglancer.' + const message: any = { + Error: 'Your browser does not meet the minimum requirements to run neuroglancer.' } - if(!gl){ + if (!gl) { message['Detail'] = 'Your browser does not support WebGL.' - - this.modalService.show(ModalUnit,{ - initialState : { - title : message.Error, - body : message.Detail + + this.modalService.show(ModalUnit, { + initialState: { + title: message.Error, + body: message.Detail } }) return false } - + const drawbuffer = gl.getExtension('WEBGL_draw_buffers') const texturefloat = gl.getExtension('OES_texture_float') const indexuint = gl.getExtension('OES_element_index_uint') - if( !(drawbuffer && texturefloat && indexuint) ){ + if (!(drawbuffer && texturefloat && indexuint)) { const detail = `Your browser does not support ${ !drawbuffer ? 'WEBGL_draw_buffers' : ''} @@ -244,10 +308,10 @@ export class AtlasViewer implements OnDestroy,OnInit,AfterViewInit{ ${ !indexuint ? 'OES_element_index_uint' : ''} ` message['Detail'] = [detail] - this.modalService.show(ModalUnit,{ - initialState : { - title : message.Error, - body : message.Detail + this.modalService.show(ModalUnit, { + initialState: { + title: message.Error, + body: message.Detail } }) return false @@ -255,26 +319,26 @@ export class AtlasViewer implements OnDestroy,OnInit,AfterViewInit{ return true } - manualPanelToggle(show:boolean){ + manualPanelToggle(show: boolean) { this.store.dispatch({ - type : show ? OPEN_SIDE_PANEL : CLOSE_SIDE_PANEL + type: show ? OPEN_SIDE_PANEL : CLOSE_SIDE_PANEL }) } - clearDedicatedView(){ + clearDedicatedView() { this.store.dispatch({ - type : UNLOAD_DEDICATED_LAYER + type: UNLOAD_DEDICATED_LAYER }) } - mousePos : [number,number] = [0,0] + mousePos: [number, number] = [0, 0] - @HostListener('mousemove',['$event']) - mousemove(event:MouseEvent){ - this.mousePos = [event.clientX,event.clientY] + @HostListener('mousemove', ['$event']) + mousemove(event: MouseEvent) { + this.mousePos = [event.clientX, event.clientY] } - get floatingMouseContextualContainerTransform(){ + get floatingMouseContextualContainerTransform() { return `translate(${this.mousePos[0]}px,${this.mousePos[1]}px)` } } \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.dataService.service.ts b/src/atlasViewer/atlasViewer.dataService.service.ts index 524eb69f2f16c8a820ba743ad7fc161ec7dc8720..a9f0423179d256375ba1bbbaf92cfb6e65267804 100644 --- a/src/atlasViewer/atlasViewer.dataService.service.ts +++ b/src/atlasViewer/atlasViewer.dataService.service.ts @@ -4,6 +4,7 @@ import { ViewerStateInterface, FETCHED_TEMPLATES, DataEntry, FETCHED_DATAENTRIES import { map, distinctUntilChanged } from "rxjs/operators"; import { Subscription } from "rxjs"; import { AtlasViewerConstantsServices } from "./atlasViewer.constantService.service"; +import { PluginManifest } from "./atlasViewer.pluginService.service"; @Injectable({ providedIn : 'root' @@ -11,22 +12,38 @@ import { AtlasViewerConstantsServices } from "./atlasViewer.constantService.serv export class AtlasViewerDataService implements OnDestroy{ private subscriptions : Subscription[] = [] + + public promiseFetchedPluginManifests : Promise<PluginManifest[]> = new Promise((resolve,reject)=>{ + // fetch('http://medpc055.ime.kfa-juelich.de:5080/collectPlugins') + // .then(res=>res.json()) + // .then(json=>resolve(json)) + // .catch(err=>reject(err)) + + Promise.all([ + fetch('http://localhost:10080/jugex/manifest.json').then(res=>res.json()), + fetch('http://localhost:10080/testPlugin/manifest.json').then(res=>res.json()) + ]) + .then(arr=>resolve(arr)) + .catch(e=>reject(e)) + }) + + public promiseFetchedTemplates : Promise<any[]> = Promise.all(this.constantService.templateUrls.map(url=> + fetch(url) + .then(res=> + res.json()) + .then(json=>json.nehubaConfig && !json.nehubaConfigURL ? + Promise.resolve(json) : + fetch(json.nehubaConfigURL) + .then(r=>r.json()) + .then(nehubaConfig=>Promise.resolve(Object.assign({},json,{ nehubaConfig }))) + ))) constructor( private store : Store<ViewerStateInterface>, private constantService : AtlasViewerConstantsServices ){ - Promise.all(this.constantService.templateUrls.map(url=> - fetch(url) - .then(res=> - res.json()) - .then(json=>json.nehubaConfig && !json.nehubaConfigURL ? - Promise.resolve(json) : - fetch(json.nehubaConfigURL) - .then(r=>r.json()) - .then(nehubaConfig=>Promise.resolve(Object.assign({},json,{ nehubaConfig }))) - ))) + this.promiseFetchedTemplates .then(arrJson=> this.store.dispatch({ type : FETCHED_TEMPLATES, diff --git a/src/atlasViewer/atlasViewer.pluginService.service.ts b/src/atlasViewer/atlasViewer.pluginService.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..81f6f926d7f946e46f63b28b0ad71aefa01290c9 --- /dev/null +++ b/src/atlasViewer/atlasViewer.pluginService.service.ts @@ -0,0 +1,153 @@ +import { Injectable, ViewContainerRef, ComponentFactoryResolver, ComponentFactory } from "@angular/core"; +import { AtlasViewerDataService } from "./atlasViewer.dataService.service"; +import { isDefined } from "../services/stateStore.service"; +import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; +import { PluginUnit } from "./pluginUnit/pluginUnit.component"; +import { WidgetServices } from "./widgetUnit/widgetService.service"; + +import '../res/css/plugin_styles.css' +import { interval } from "rxjs"; +import { take, takeUntil } from "rxjs/operators"; + +@Injectable({ + providedIn : 'root' +}) + +export class PluginServices{ + + public fetchedPluginManifests : PluginManifest[] = [] + public pluginViewContainerRef : ViewContainerRef + public appendSrc : (script:HTMLElement)=>void + private pluginUnitFactory : ComponentFactory<PluginUnit> + + constructor( + private apiService : AtlasViewerAPIServices, + private atlasDataService : AtlasViewerDataService, + private widgetService : WidgetServices, + private cfr : ComponentFactoryResolver + ){ + + this.pluginUnitFactory = this.cfr.resolveComponentFactory( PluginUnit ) + + this.atlasDataService.promiseFetchedPluginManifests + .then(arr=> + this.fetchedPluginManifests = arr) + .catch(console.error) + } + + readyPlugin(plugin:PluginManifest):Promise<any>{ + return Promise.all([ + isDefined(plugin.template) ? + Promise.resolve('template already provided') : + isDefined(plugin.templateURL) ? + fetch(plugin.templateURL) + .then(res=>res.text()) + .then(template=>plugin.template = template) : + Promise.reject('both template and templateURL are not defined') , + isDefined(plugin.script) ? + Promise.resolve('script already provided') : + isDefined(plugin.scriptURL) ? + fetch(plugin.scriptURL) + .then(res=>res.text()) + .then(script=>plugin.script = script) : + Promise.reject('both template and templateURL are not defined') + ]) + } + + launchPlugin(plugin:PluginManifest){ + if(this.apiService.interactiveViewer.pluginControl[plugin.name]) + { + console.warn('plugin already launched. blinking for 10s.') + this.apiService.interactiveViewer.pluginControl[plugin.name].blink(10) + return + } + this.readyPlugin(plugin) + .then(()=>{ + const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) + /* TODO in v0.2, I used: + + const template = document.createElement('div') + template.insertAdjacentHTML('afterbegin',template) + + // reason was: + // changed from innerHTML to insertadjacenthtml to accomodate angular elements ... not too sure about the actual ramification + + */ + const script = document.createElement('script') + script.innerHTML = plugin.script + this.appendSrc(script) + + const template = document.createElement('div') + template.insertAdjacentHTML('afterbegin',plugin.template) + pluginUnit.instance.elementRef.nativeElement.append( template ) + + const widgetCompRef = this.widgetService.addNewWidget(pluginUnit,{ + state : 'floating', + exitable : true, + title : plugin.name + }) + + const handler = new PluginHandler() + this.apiService.interactiveViewer.pluginControl[plugin.name] = handler + + const unsubscribeOnPluginDestroy = [] + const shutdownCB = [] + + handler.onShutdown = (cb)=>{ + if(typeof cb !== 'function'){ + console.warn('onShutdown requires the argument to be a function') + return + } + shutdownCB.push(cb) + } + + handler.blink = (sec?:number)=>{ + if(typeof sec !== 'number') + console.warn(`sec is not a number, default blink interval used`) + widgetCompRef.instance.containerClass = '' + interval(typeof sec === 'number' ? sec * 1000 : 500).pipe( + take(11), + takeUntil(widgetCompRef.instance.clickedEmitter) + ).subscribe(()=> + widgetCompRef.instance.containerClass = widgetCompRef.instance.containerClass === 'panel-success' ? + '' : + 'panel-success') + } + + unsubscribeOnPluginDestroy.push( + widgetCompRef.instance.clickedEmitter.subscribe(()=> + widgetCompRef.instance.containerClass = '') + ) + + handler.shutdown = ()=>{ + widgetCompRef.instance.exit() + } + + handler.onShutdown(()=>{ + unsubscribeOnPluginDestroy.forEach(s=>s.unsubscribe()) + delete this.apiService.interactiveViewer.pluginControl[plugin.name] + }) + + pluginUnit.onDestroy(()=>{ + while(shutdownCB.length > 0){ + shutdownCB.pop()() + } + }) + }) + .catch(console.error) + } +} + +export class PluginHandler{ + onShutdown : (callback:()=>void)=>void + blink : (sec?:number)=>void + shutdown : ()=>void +} + +export interface PluginManifest{ + name? : string + templateURL? : string + template? : string + scriptURL? : string + script? : string +} \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.template.html b/src/atlasViewer/atlasViewer.template.html index 877b85986a57e0489f0a00d3ae64daf665545f71..6c1d721e5e401df10ee528e1ebe76c6e100f79fe 100644 --- a/src/atlasViewer/atlasViewer.template.html +++ b/src/atlasViewer/atlasViewer.template.html @@ -24,13 +24,10 @@ </ng-template> <div [style.transform] = "floatingMouseContextualContainerTransform" floatingMouseContextualContainer> - <div contextualInnerContainer> - <div *ngIf = "(onhoverSegment$ | async) !== ''" contextualBlock> - {{ onhoverSegment$ | async }} + <div *ngIf = "onhoverSegment$ | async as onhoverSegment" contextualInnerContainer> + <div *ngIf = "onhoverSegment !== ''" contextualBlock> + {{ onhoverSegment }} </div> - <ng-template #floatingContextContainer> - - </ng-template> </div> </div> <div toastContainer> @@ -40,6 +37,9 @@ </layout-floating-container> <ng-template #temporaryContainer> + </ng-template> + + <ng-template #pluginFactory> </ng-template> @@ -57,7 +57,3 @@ no special data is being displayed right now </div> </ng-template> - -<ng-template #mouseoverSegment> - {{ onhoverSegment$ | async }} -</ng-template> \ No newline at end of file diff --git a/src/atlasViewer/pluginUnit/pluginUnit.component.ts b/src/atlasViewer/pluginUnit/pluginUnit.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..15e4624040df7c1c6545ba9c9d32b96d82bf1f23 --- /dev/null +++ b/src/atlasViewer/pluginUnit/pluginUnit.component.ts @@ -0,0 +1,17 @@ +import { Component, ElementRef, ViewChild, OnDestroy } from "@angular/core"; + + +@Component({ + templateUrl : `./pluginUnit.template.html` +}) + +export class PluginUnit implements OnDestroy{ + + @ViewChild('pluginContainer',{read:ElementRef}) + elementRef:ElementRef + + ngOnDestroy(){ + console.log('plugin being destroyed') + } + +} \ No newline at end of file diff --git a/src/atlasViewer/pluginUnit/pluginUnit.template.html b/src/atlasViewer/pluginUnit/pluginUnit.template.html new file mode 100644 index 0000000000000000000000000000000000000000..4896a25f9103b94552db10dd920b9f200a1225ec --- /dev/null +++ b/src/atlasViewer/pluginUnit/pluginUnit.template.html @@ -0,0 +1,2 @@ +<div pluginContainer #pluginContainer> +</div> \ No newline at end of file diff --git a/src/atlasViewer/widgetUnit/widgetService.service.ts b/src/atlasViewer/widgetUnit/widgetService.service.ts index f3e4bdfe7720436ca3a328d4f6ba953312a82d2f..2c45a005887c49a65c099c41a374858557396740 100644 --- a/src/atlasViewer/widgetUnit/widgetService.service.ts +++ b/src/atlasViewer/widgetUnit/widgetService.service.ts @@ -33,7 +33,7 @@ export class WidgetServices{ this.clickedListener.forEach(s=>s.unsubscribe()) } - addNewWidget(guestComponentRef:ComponentRef<any>,options?:any){ + addNewWidget(guestComponentRef:ComponentRef<any>,options?:any):ComponentRef<WidgetUnit>{ const _option = getOption(options) const component = _option.state === 'floating' ? this.floatingContainer.createComponent(this.widgetUnitFactory) : diff --git a/src/atlasViewer/widgetUnit/widgetUnit.component.ts b/src/atlasViewer/widgetUnit/widgetUnit.component.ts index 5c15dd4a3301661565a17872ed20d54efd389afe..e9500f6ebca59be5a17d130d0b6434d397d3be84 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.component.ts +++ b/src/atlasViewer/widgetUnit/widgetUnit.component.ts @@ -28,6 +28,8 @@ export class WidgetUnit { @Input() title : string = 'Untitled' + @Input() containerClass : string = '' + @Output() clickedEmitter : EventEmitter<WidgetUnit> = new EventEmitter() @@ -37,10 +39,12 @@ export class WidgetUnit { public cf : ComponentRef<WidgetUnit> public widgetServices:WidgetServices - undock(event:Event){ - event.stopPropagation() - event.preventDefault() - + undock(event?:Event){ + if(event){ + event.stopPropagation() + event.preventDefault() + } + this.widgetServices.changeState(this,{ title : this.title, state:'floating', @@ -48,10 +52,12 @@ export class WidgetUnit { }) } - dock(event:Event){ - event.stopPropagation() - event.preventDefault() - + dock(event?:Event){ + if(event){ + event.stopPropagation() + event.preventDefault() + } + this.widgetServices.changeState(this,{ title : this.title, state:'docked', @@ -59,9 +65,11 @@ export class WidgetUnit { }) } - exit(event:Event){ - event.stopPropagation() - event.preventDefault() + exit(event?:Event){ + if(event){ + event.stopPropagation() + event.preventDefault() + } this.widgetServices.exitWidget(this) } diff --git a/src/atlasViewer/widgetUnit/widgetUnit.template.html b/src/atlasViewer/widgetUnit/widgetUnit.template.html index c9a35c58479075d83493109a59804cc7d89c4c4f..eb5222c030e44d3528b0ad152f1bfe1a5a13b6cf 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.template.html +++ b/src/atlasViewer/widgetUnit/widgetUnit.template.html @@ -1,5 +1,6 @@ <panel [style.transform] = "transform" + [containerClass] = "containerClass" widgetUnitPanel [bodyCollapsable] = "state === 'docked'" > diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 6eaf6d29caffc918c15d7c4e2e2f40eebe4f2241..9f8c480e387b7fc582f0244ba700db77e897e8cc 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -16,6 +16,7 @@ import { PanelComponent } from './panel/panel.component'; import { PaginationComponent } from './pagination/pagination.component'; import { SearchResultPaginationPipe } from '../util/pipes/pagination.pipe'; import { ToastComponent } from './toast/toast.component'; +import { TreeSearchPipe } from '../util/pipes/treeSearch.pipe'; @NgModule({ imports : [ @@ -39,7 +40,8 @@ import { ToastComponent } from './toast/toast.component'; /* pipes */ SafeHtmlPipe, - SearchResultPaginationPipe + SearchResultPaginationPipe, + TreeSearchPipe ], exports : [ MarkdownDom, @@ -51,6 +53,7 @@ import { ToastComponent } from './toast/toast.component'; ToastComponent, SearchResultPaginationPipe, + TreeSearchPipe, HoverableBlockDirective, diff --git a/src/components/panel/panel.component.ts b/src/components/panel/panel.component.ts index ed81129782de8882d58f58b17a2d4bd90731bf88..5f7220081300be309bf1089413d8de2d102cdbef 100644 --- a/src/components/panel/panel.component.ts +++ b/src/components/panel/panel.component.ts @@ -17,6 +17,8 @@ export class PanelComponent{ @Input() collapseBody : boolean = false @Input() bodyCollapsable : boolean = false + @Input() containerClass : string = '' + toggleCollapseBody(event:Event){ if(this.bodyCollapsable){ this.collapseBody = !this.collapseBody diff --git a/src/components/panel/panel.style.css b/src/components/panel/panel.style.css index 6a50ea914eb18c8510bce1d27eae5e5d96d93713..1802a29a767f050073e17900faec0ef047053c50 100644 --- a/src/components/panel/panel.style.css +++ b/src/components/panel/panel.style.css @@ -2,13 +2,13 @@ { font-size:92%; overflow:hidden; + box-shadow: 0px 4px 16px -4px rgba(0,0,0,0.2); } .panel { border-radius : 0; border:none; - box-shadow: 0px 4px 16px -4px rgba(0,0,0,0.2); margin:0; } @@ -36,14 +36,25 @@ div.panel background:none; } -:host-context([darktheme="true"]) div.panel-heading +div.panel-body +{ + background-color:rgba(245,245,245,0.8); +} + +:host-context([darktheme="true"]) div.panel-default div.panel-heading { background-color:rgba(45,45,45,0.9); - color:rgba(255,255,255,0.9); + color:rgba(250,250,250,0.9); +} + +:host-context([darktheme="true"]) div.panel-success div.panel-heading +{ + background-color:rgba(60,118,61,0.9); + color:rgba(223,240,216 ,0.9); } :host-context([darktheme="true"]) div.panel-body { color:rgba(255,255,255,0.9); - background-color:rgba(45,45,45,0.65); + background-color:rgba(45,45,45,0.8); } \ No newline at end of file diff --git a/src/components/panel/panel.template.html b/src/components/panel/panel.template.html index 853c6ee7031daada8ced86f5586b27432f327eeb..b571a62aefa107f97e60689c35e694208fccaa73 100644 --- a/src/components/panel/panel.template.html +++ b/src/components/panel/panel.template.html @@ -1,4 +1,5 @@ -<div class = "panel panel-default"> +<div class = "panel" + [ngClass] = "containerClass === '' ? 'panel-default' : containerClass"> <div *ngIf = "showHeading" class = "panel-heading" diff --git a/src/components/tree/tree.component.ts b/src/components/tree/tree.component.ts index 081376b31581ed1bafb148bb94cdd6ece39fe056..22d7698dd3d60778989f3d78d9b2c5c4334bf1f8 100644 --- a/src/components/tree/tree.component.ts +++ b/src/components/tree/tree.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, Output, EventEmitter, ViewChildren, QueryList, HostBinding } from "@angular/core"; +import { Component, Input, Output, EventEmitter, ViewChildren, QueryList, HostBinding, ChangeDetectionStrategy } from "@angular/core"; @Component({ @@ -6,7 +6,8 @@ import { Component, Input, Output, EventEmitter, ViewChildren, QueryList, HostBi templateUrl : './tree.template.html', styleUrls : [ './tree.style.css' - ] + ], + changeDetection:ChangeDetectionStrategy.OnPush }) export class TreeComponent{ @@ -18,7 +19,7 @@ export class TreeComponent{ @Input() findChildren : (item:any)=>any[] = (item)=>item.children @Input() childrenExpanded : boolean = true - @Input() searchFilter : (item:any)=>boolean | null = null + @Input() searchFilter : (item:any)=>boolean | null = ()=>true @Output() mouseentertree : EventEmitter<any> = new EventEmitter() @Output() mouseleavetree : EventEmitter<any> = new EventEmitter() @@ -53,8 +54,8 @@ export class TreeComponent{ @HostBinding('attr.filterHidden') get visibilityOnFilter():boolean{ - return (this.searchFilter ? - this.searchFilter(this.inputItem) || (this.treeChildren.some(tree=>tree.visibilityOnFilter) ) : - true) + return this.searchFilter ? + this.searchFilter(this.inputItem) : + false } } \ No newline at end of file diff --git a/src/components/tree/tree.style.css b/src/components/tree/tree.style.css index fec01230a858b1d61f88a16715c99a96375979a1..22cd7ce11ed5845e590c01a0a4f3f5da278c4b53 100644 --- a/src/components/tree/tree.style.css +++ b/src/components/tree/tree.style.css @@ -1,4 +1,4 @@ -tree:not([filterHidden="false"]):not([hidden]) +tree { display:block; margin-left:1em; @@ -31,7 +31,7 @@ div[itemContainer] > span[itemName]:hover /* dashed guiding line */ -tree:not([filterHidden="false"]):not(:last-child) > div[itemMasterContainer]:before +tree:not(:last-child) > div[itemMasterContainer]:before { pointer-events: none; @@ -43,13 +43,13 @@ tree:not([filterHidden="false"]):not(:last-child) > div[itemMasterContainer]:bef border-left: rgba(255,255,255,1) 1px dashed; } -tree:not([filterHidden="false"]):not(:last-child) div[itemMasterContainer] > [itemContainer] +tree:not(:last-child) div[itemMasterContainer] > [itemContainer] { position:relative; } -tree:not([filterHidden="false"]):not(:last-child) div[itemMasterContainer] > [itemContainer]:before +tree:not(:last-child) div[itemMasterContainer] > [itemContainer]:before { pointer-events: none; content : ''; @@ -61,12 +61,12 @@ tree:not([filterHidden="false"]):not(:last-child) div[itemMasterContainer] > [it z-index: 0; } -tree:not([filterHidden="false"]):last-child div[itemMasterContainer] > [itemContainer] +tree:last-child div[itemMasterContainer] > [itemContainer] { position:relative; } -tree:not([filterHidden="false"]):last-child div[itemMasterContainer] > [itemContainer]:before +tree:last-child div[itemMasterContainer] > [itemContainer]:before { pointer-events: none; content : ''; @@ -78,17 +78,17 @@ tree:not([filterHidden="false"]):last-child div[itemMasterContainer] > [itemCont z-index: 0; } -tree:not([filterHidden="false"]):not(:last-child) div[itemMasterContainer]:before +tree:not(:last-child) div[itemMasterContainer]:before { border-left: rgba(128,128,128,0.6) 1px dashed; } -tree:not([filterHidden="false"]):not(:last-child) div[itemMasterContainer] > [itemContainer]:before +tree:not(:last-child) div[itemMasterContainer] > [itemContainer]:before { border-bottom: rgba(128,128,128,0.6) 1px dashed; } -tree:not([filterHidden="false"]) div[itemMasterContainer]:last-child > [itemContainer]:before +tree div[itemMasterContainer]:last-child > [itemContainer]:before { border-bottom: rgba(128,128,128,0.6) 1px dashed; border-left : rgba(128,128,128,0.6) 1px dashed; diff --git a/src/components/tree/tree.template.html b/src/components/tree/tree.template.html index c0d71c6a22f72e3e3d123a2df707ba90a84d5adf..0b7192da4ef22a9000b7e04f0e342054c0c89490 100644 --- a/src/components/tree/tree.template.html +++ b/src/components/tree/tree.template.html @@ -1,5 +1,4 @@ <div - [hidden] = "!visibilityOnFilter" (mouseleave)="mouseleavetree.emit({inputItem:inputItem,node:this});handleEv($event)" (mouseenter)="mouseentertree.emit({inputItem:inputItem,node:this});handleEv($event)" (click)="mouseclicktree.emit({inputItem:inputItem,node:this});handleEv($event)" @@ -17,12 +16,13 @@ </span> </div> <tree - *ngFor = "let child of findChildren(inputItem)" + *ngFor = "let child of (findChildren(inputItem) | treeSearch : searchFilter : findChildren )" [hidden] = "!childrenExpanded" [childrenExpanded] = "childrenExpanded" [inputItem] = "child" [renderNode]="renderNode" [searchFilter]="searchFilter" + [findChildren] = "findChildren" (mouseentertree)="mouseentertree.emit($event)" (mouseleavetree)="mouseleavetree.emit($event)" (mouseclicktree)="mouseclicktree.emit($event)"> diff --git a/src/index.html b/src/index.html index d24b57232a80f827db473d8fa9b813d4345cd970..ec4d02f37491659b0cfa03110309b0073a4388d9 100644 --- a/src/index.html +++ b/src/index.html @@ -1,131 +1,10 @@ <!doctype html> <html> <head> - <style> - markdown-dom pre code - { - white-space:pre; - } - </style> - <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> - <!-- needed for nehuba container only --> - <style> - html - { - width:100%; - height:100%; - } - body - { - width:100%; - height:100%; - margin:0; - border:0; + <link rel = "stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> + <link rel = "stylesheet" href = "extra_styles.css"> + <link rel = "stylesheet" href = "plugin_styles.css"> - /* required for glyphicon tooltip directives */ - overflow:hidden; - } - div.scale-bar-container - { - text-align: center; - background-color: rgba(0,0,0,.3); - position: absolute; - left: 1em; - bottom: 1em; - padding: 2px; - font-weight: 700; - pointer-events: none; - } - - div.scale-bar - { - min-height: 1ex; - background-color: #fff; - padding: 0; - margin: 0; - margin-top: 2px; - } - div.neuroglancer-rendered-data-panel - { - position:relative; - } - - ul#statusContainer - { - display:none; - } - - .inputSearchContainer - { - background:none; - box-shadow:none; - border:none; - /* width:25em; */ - max-width:999999px; - } - .inputSearchContainer .popover-arrow - { - display:none; - } - - .inputSearchContainer .popover-content.popover-body - { - padding:0; - max-width:999999px; - } - - .mute-text - { - opacity:0.8; - } - - div.scale-bar-container - { - font-weight:500; - color: #1a1a1a; - background-color:hsla(0,0%,80%,0.5); - } - - label.perspective-panel-show-slice-views - { - visibility: hidden; - } - - label.perspective-panel-show-slice-views:hover - { - text-decoration: underline - } - - [darktheme="false"] .neuroglancer-panel - { - border:2px solid rgba(255,255,255,0.9); - } - - [darktheme="true"] .neuroglancer-panel - { - border:2px solid rgba(30,30,30,0.9); - } - - label.perspective-panel-show-slice-views:before - { - margin-left: .2em; - content: "show / hide frontal octant"; - visibility: visible; - pointer-events: all; - color: #337ab7; - } - - [darktheme="true"] .scale-bar-container - { - color:#f2f2f2; - background-color:hsla(0,0%,60%,0.2); - } - - span.regionSelected - { - color : #dbb556 - } - </style> <!-- <link rel = "stylesheet" href = "styles.css" /> --> </head> <body> diff --git a/src/main.module.ts b/src/main.module.ts index cfa7c2680a32241cb3171243c9745cb48c80a151..e873454c5700dfbeb543dbc3f2eab5873ab6ba37 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -5,13 +5,11 @@ import { LayoutModule } from "./layouts/layout.module"; import { AtlasViewer } from "./atlasViewer/atlasViewer.component"; import { StoreModule } from "@ngrx/store"; import { viewerState, dataStore,spatialSearchState,uiState } from "./services/stateStore.service"; -import { AtlasBanner } from "./ui/banner/banner.component"; import { GetNamesPipe } from "./util/pipes/getNames.pipe"; import { CommonModule } from "@angular/common"; import { GetNamePipe } from "./util/pipes/getName.pipe"; import { FormsModule } from "@angular/forms"; -import { PopoverModule } from 'ngx-bootstrap/popover' import { AtlasViewerDataService } from "./atlasViewer/atlasViewer.dataService.service"; import { WidgetUnit } from "./atlasViewer/widgetUnit/widgetUnit.component"; import { WidgetServices } from './atlasViewer/widgetUnit/widgetService.service' @@ -23,6 +21,8 @@ import { AtlasViewerURLService } from "./atlasViewer/atlasViewer.urlService.serv import { ToastComponent } from "./components/toast/toast.component"; import { GetFilenameFromPathnamePipe } from "./util/pipes/getFileNameFromPathName.pipe"; import { FilterNameBySearch } from "./util/pipes/filterNameBySearch.pipe"; +import { AtlasViewerAPIServices } from "./atlasViewer/atlasViewer.apiService.service"; +import { PluginUnit } from "./atlasViewer/pluginUnit/pluginUnit.component"; @NgModule({ imports : [ @@ -34,7 +34,6 @@ import { FilterNameBySearch } from "./util/pipes/filterNameBySearch.pipe"; ModalModule.forRoot(), TooltipModule.forRoot(), - PopoverModule.forRoot(), StoreModule.forRoot({ viewerState , dataStore , @@ -44,9 +43,9 @@ import { FilterNameBySearch } from "./util/pipes/filterNameBySearch.pipe"; ], declarations : [ AtlasViewer, - AtlasBanner, WidgetUnit, ModalUnit, + PluginUnit, /* directives */ GlyphiconTooltipScreenshotDirective, @@ -61,18 +60,20 @@ import { FilterNameBySearch } from "./util/pipes/filterNameBySearch.pipe"; GetNamesPipe, GetNamePipe, GetFilenameFromPathnamePipe, - FilterNameBySearch + FilterNameBySearch, ], entryComponents : [ WidgetUnit, ModalUnit, ToastComponent, + PluginUnit, ], providers : [ AtlasViewerDataService, AtlasViewerDataService, WidgetServices, - AtlasViewerURLService + AtlasViewerURLService, + AtlasViewerAPIServices ], bootstrap : [ AtlasViewer diff --git a/src/plugin_examples/jugex/manifest.json b/src/plugin_examples/jugex/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..05ead3b046bc2b1a039bc149dad4a98d8bf64119 --- /dev/null +++ b/src/plugin_examples/jugex/manifest.json @@ -0,0 +1 @@ +{"name":"fzj.hb.jugex","type":"plugin","templateURL":"http://localhost:10080/jugex/template.html","scriptURL":"http://localhost:10080/jugex/script.js"} diff --git a/src/plugin_examples/jugex/newscript.js b/src/plugin_examples/jugex/newscript.js new file mode 100644 index 0000000000000000000000000000000000000000..22a6ab15d44eca451412ab9a38985860d5bbca60 --- /dev/null +++ b/src/plugin_examples/jugex/newscript.js @@ -0,0 +1,803 @@ +(()=>{ + const PLUGIN_NAME = `fzj.hb.JuGEx` + const DOM_PARSER = new DOMParser() + const MIN_CHAR = 3 + const URL_BASE = 'http://medpc055.ime.kfa-juelich.de:8003' + + class HoverRegionSelectorComponent extends HTMLElement{ + constructor(){ + super() + + this.template = + ` + <div class = "input-group"> + <input value = "${this.selectedRegion ? this.selectedRegion.name : ''}" class = "form-control" placeholder = "" readonly = "readonly" type = "text" region /> + <span class = "input-group-btn"> + <div class = "btn btn-default" editRegion> + <span class = "glyphicon glyphicon-edit"></span> + </div> + </span> + </div> + ` + + this.elTemplate = DOM_PARSER.parseFromString(this.template,'text/html') // or use innerHTML... whichever suits you + + this.elTemplate2 = document.createElement('div') + this.elTemplate2.innerHTML = this.template + + this.renderedFlag = false + this.listening = true + this.selectedRegion = null + this.shutdownHooks = [] + + this.rootChild = document.createElement('div') + this.appendChild(this.rootChild) + this.firstrender = true + + this.init() + window.pluginControl[PLUGIN_NAME].onShutdown((this.onShutdown).bind(this)) + } + + /* class method */ + /* connectedCallback can get called multiple times during the lifetime of a widget. + most prominently, when the user chooses to dock the widget, or minimise the widget. */ + connectedCallback(){ + this.render() + this.attachEventListeners() + } + + /* class method */ + /* ditto, see above */ + disconnectedCallback(){ + this.unattachEventListeners() + } + + /* in this example, the init funciton attaches any permanent listeners, such as, in this + case, mouseovernehuba event stream */ + init(){ + this.mouseOverNehuba = window.viewerHandle.mouseOverNehuba.filter(()=>this.listening).subscribe(ev=>{ + this.selectedRegion = ev.foundRegion + this.render() + this.attachEventListeners() + }) + } + + /* cleaning up when the user permanently closes the widget */ + onShutdown(){ + this.mouseOverNehuba.unsubscribe() + } + + render(){ + if(!this.firstrender){ + this.unattachEventListeners() + this.firstrender = false + } + while(this.rootChild.lastChild){ + this.rootChild.removeChild(this.rootChild.lastChild) + } + + this.template = + ` + <div class = "input-group"> + <input value = "${this.selectedRegion ? this.selectedRegion.name : ''}" class = "form-control" placeholder = "" readonly = "readonly" type = "text" region /> + <span class = "input-group-btn"> + <div class = "btn btn-default" editRegion> + <span class = "glyphicon glyphicon-edit"></span> + </div> + </span> + </div> + ` + this.elTemplate2.innerHTML = this.template + this.rootChild.appendChild(this.elTemplate2) + } + + clearAndRelisten(ev){ + this.listening = true + this.selectedRegion = null + this.render() + this.attachEventListeners() + } + + attachEventListeners(){ + this.rootChild.querySelector('div[editRegion]').addEventListener('click',(this.clearAndRelisten).bind(this)) + } + + unattachEventListeners(){ + this.rootChild.querySelector('div[editRegion]').addEventListener('click',(this.clearAndRelisten).bind(this)) + } + } + + class DismissablePill extends HTMLElement{ + constructor(){ + super() + this.name = `` + this.template = + ` + <span class = "label label-default"> + <span pillName>${this.name}</span> + <span class = "glyphicon glyphicon-remove" pillRemove></span> + </span> + ` + } + + render(){ + this.template = + ` + <span class = "label label-default"> + <span pillName>${this.name}</span> + <span class = "glyphicon glyphicon-remove" pillRemove></span> + </span> + ` + this.elTemplate = document.createElement('span') + // this.elTemplate = DOM_PARSER.parseFromString(this.template,'text/html') + this.elTemplate.innerHTML = this.template + + while(this.lastChild){ + this.removeChild(this.lastChild) + } + this.appendChild(this.elTemplate) + } + + connectedCallback(){ + this.render() + this.attachEventListeners() + } + + disconnectedCallback(){ + this.unattachEventListeners() + } + + attachEventListeners(){ + this.querySelector('span[pillRemove]').addEventListener('click',(this.dismissPill).bind(this)) + } + + unattachEventListeners(){ + this.querySelector('span[pillRemove]').removeEventListener('click',(this.dismissPill).bind(this)) + } + + dismissPill(){ + this.onRemove(this.name) + this.remove() + } + + /* needs to be overwritten by parent, if parent needs to listen to the on remove event */ + onRemove(){ + + } + } + + class WebjugexGeneComponent extends HTMLElement{ + constructor(){ + super() + this.arrDict = [] + this.autocompletesuggestion = [] + this.selectedGenes = [] + this.template = + ` + <div class = "input-group"> + <input geneInputBox type = "text" class = "form-control" placeholder = "Enter gene of interest ..." /> + <input geneImportInput class = "hidden" type = "file" /> + <span class = "input-group-btn"> + <div geneAdd class = "btn btn-default" title = "Add a gene">Add</div> + <div geneImport class = "btn btn-default" title = "Import a CSV file">Import</div> + <div geneExport class = "btn btn-default" title = "Export selected genes into a CSV file">Export</div> + </span> + </div> + ` + + window.pluginControl[PLUGIN_NAME].onShutdown((this.unloadExternalResources).bind(this)) + this.firstrender = true + } + + connectedCallback(){ + if(this.firstrender){ + + this.elTemplate = document.createElement('div') + this.elTemplate.innerHTML = this.template + this.rootChild = document.createElement('div') + this.appendChild(this.rootChild) + this.rootChild.appendChild(this.elTemplate) + this.init() + this.firstrender = false + } + // this.render() + // this.attachEventListeners() + /* see below */ + } + + disconnectedCallback(){ + // this.unattachEventListeners() + /* see below */ + } + + // render(){ + // while(this.lastChild){ + // this.removeChild(lastChild) + // } + // this.appendChild(this.elTemplate) + // } + + /* in special circumstances, where the view does not change too much, but there are numerous + eventlisteners, it maybe more efficient to only attach event listener once, */ + init(){ + + this.elGeneInputBox = this.rootChild.querySelector('input[geneInputBox]') + this.elGeneImportInput = this.rootChild.querySelector('input[geneImportInput]') + this.elGeneAdd = this.rootChild.querySelector('div[geneAdd]') + this.elGeneImport = this.rootChild.querySelector('div[geneImport]') + this.elGeneExport = this.rootChild.querySelector('div[geneExport]') + + const importGeneList = (file) => { + const csvReader = new FileReader() + csvReader.onload = (ev)=>{ + const csvRaw = ev.target.result + this.selectedGenes.splice(0,this.selectedGenes.length) + csvRaw.split(/\r|\r\n|\n|\t|\,|\;/).forEach(gene=>{ + if(gene.length > 0) + this.addGene(gene) + }) + } + csvReader.readAsText(file,'utf-8') + } + + this.elGeneImportInput.addEventListener('change',(ev)=>{ + importGeneList(ev.target.files[0]) + }) + + this.elGeneImport.addEventListener('click',()=>{ + this.elGeneImportInput.click() + }) + this.elGeneExport.addEventListener('click',()=>{ + const exportGeneList = 'data:text/csv;charset=utf-8,'+this.selectedGenes.join(',') + const exportGeneListURI = encodeURI(exportGeneList) + const dlExportGeneList = document.createElement('a') + dlExportGeneList.setAttribute('href',exportGeneListURI) + document.body.appendChild(dlExportGeneList) + const date = new Date() + dlExportGeneList.setAttribute('download',`exported_genelist_${''+date.getFullYear()+(date.getMonth()+1)+date.getDate()+'_'+date.getHours()+date.getMinutes()}.csv`) + dlExportGeneList.click() + document.body.removeChild(dlExportGeneList) + }) + this.elGeneAdd.addEventListener('click',()=>{ + if(this.autocompleteSuggestions.length > 0 && this.elGeneInputBox.value.length >= MIN_CHAR) + this.addGene(this.autocompleteSuggestions[0]) + }) + + this.elGeneInputBox.addEventListener('dragenter',(ev)=>{ + this.elGeneInputBox.setAttribute('placeholder','Drop file here to be uploaded') + }) + + this.elGeneInputBox.addEventListener('dragleave',(ev)=>{ + this.elGeneInputBox.setAttribute('placeholder','Enter gene of interest ... ') + }) + + this.elGeneInputBox.addEventListener('drop',(ev)=>{ + ev.preventDefault() + ev.stopPropagation() + ev.stopImmediatePropagation() + this.elGeneInputBox.setAttribute('placeholder','Enter gene of interest ... ') + //ev.dataTransfer.files[0] + }) + + this.elGeneInputBox.addEventListener('dragover',(ev)=>{ + ev.preventDefault() + ev.stopPropagation() + ev.stopImmediatePropagation() + }) + + this.elGeneInputBox.addEventListener('keydown',(ev)=>{ + ev.stopPropagation() + ev.stopImmediatePropagation() + if(ev.key=='Enter') this.elGeneAdd.click() + }) + + Promise.all([ + this.loadExternalResources(), + fetch(URL_BASE).then(txt=>txt.json()) + ]) + .then(arr=>{ + this.arrDict = arr[1] + + console.log('attaching autocomplete') + + this.autocompleteInput = new autoComplete({ + selector : this.elGeneInputBox, + delay : 0, + minChars : MIN_CHAR, + cache : false, + source : (term,suggest)=>{ + const searchTerm = new RegExp('^'+term,'gi') + this.autocompleteSuggestions = this.arrDict.filter(dict=>searchTerm.test(dict)) + suggest(this.autocompleteSuggestions) + }, + onSelect : (e,term,item)=>{ + this.addGene(term) + } + }) + }) + .catch(err=>{ + console.error('loading external resources failed ... ',err) + // console.log('failed to fetch full list of genes... using limited list of genes instead ...',e) + // this.arrDict = ["ADRA2A", "AVPR1B", "CHRM2", "CNR1", "CREB1", "CRH", "CRHR1", "CRHR2", "GAD2", "HTR1A", "HTR1B", "HTR1D", "HTR2A", "HTR3A", "HTR5A", "MAOA", "PDE1A", "SLC6A2", "SLC6A4", "SST", "TAC1", "TPH1", "GPR50", "CUX2", "TPH2"] + }) + } + + loadExternalResources(){ + return new Promise((rs,rj)=>Promise.all([ + new Promise((resolve,reject)=>{ + this.autoCompleteCss = document.createElement('link') + this.autoCompleteCss.type = 'text/css' + this.autoCompleteCss.rel = 'stylesheet' + this.autoCompleteCss.onload = () => resolve() + this.autoCompleteCss.onerror = (e) => reject(e) + this.autoCompleteCss.href = '/res/css/js-autocomplete.min.css' + document.head.appendChild(this.autoCompleteCss) + }), + new Promise((resolve,reject)=>{ + this.autoCompleteJs = document.createElement('script') + this.autoCompleteJs.onload = () => resolve() + this.autoCompleteJs.onerror = (e) => reject(e) + this.autoCompleteJs.src = '/res/js/js-autocomplete.min.js' + document.head.appendChild(this.autoCompleteJs) + }) + ]) + .then(()=>rs()) + .catch(e=>rj(e)) + )} + + unloadExternalResources(){ + document.head.removeChild(this.autoCompleteJs) + document.head.removeChild(this.autoCompleteCss) + } + + addGene(gene){ + const pill = document.createElement('dismissable-pill-card') + pill.onRemove = (name) => + this.selectedGenes.splice(this.selectedGenes.indexOf(name),1) + + pill.name = gene + this.rootChild.appendChild(pill) + this.selectedGenes.push(gene) + this.elGeneInputBox.value = '' + this.elGeneInputBox.blur() + this.elGeneInputBox.focus() + } + } + + class WebjugexSearchComponent extends HTMLElement{ + constructor(){ + super() + this.template = + ` + + <div class = "row"> + <div class = "col-md-12"> + <small> + Find a set of differentially expressed genes between two user defined volumes of interest based on JuBrain maps. + The tool downloads expression values of user specified sets of genes from Allen Brain API. + Then, it uses zscores to find which genes are expressed differentially between the user specified regions of interests. + After the analysis is finished, the genes and their calculated p values are displayed. There is also an option of downloading the gene names and their p values + and the roi coordinates used in the analysis. + Please select two regions of interest, and at least one gene : + </small> + </div> + <div class = "col-md-12"> + <hover-region-selector-card area1></hover-region-selector-card> + </div> + <div class = "col-md-12"> + <hover-region-selector-card area2></hover-region-selector-card> + </div> + <div class = "col-md-12"> + <div class = "input-group"> + <span class = "input-group-addon"> + Threshold + </span> + <input value = "0.20" class = "form-control" type = "range" min = "0" max = "1" step = "0.01" threshold /> + <span class = "input-group-addon" thresholdValue> + 0.20 + </span> + </div> + <div class="input-group"> + <input type="checkbox" probemode /> Single Probe Mode + </div> + </div> + <div class = "row"> + <div class = "col-md-12"> + <fzj-xg-webjugex-gene-card> + </fzj-xg-webjugex-gene-card> + </div> + </div> + <div class = "row"> + <div class = "col-md-12"> + <div class = "btn btn-default btn-block" analysisSubmit> + Start differential analysis + </div> + </div> + </div> + ` + this.mouseEventSubscription = this.rootChild = this.threshold = this.elArea1 = this.elArea2 = null + this.selectedGenes = [] + this.firstrender = true + + } + + connectedCallback(){ + if(this.firstrender){ + this.init() + this.firstrender = false + } + } + + init(){ + // this.elTemplate = DOM_PARSER.parseFromString(this.template,'text/html') + this.elTemplate = document.createElement('div') + this.elTemplate.innerHTML = this.template + this.appendChild(this.elTemplate) + + this.elArea1 = this.querySelector('hover-region-selector-card[area1]') + this.elArea2 = this.querySelector('hover-region-selector-card[area2]') + this.elArea1.listening = true + this.elArea2.listening = false + this.probemodeval = false + + this.elGenesInput = this.querySelector('fzj-xg-webjugex-gene-card') + + this.elAnalysisSubmit = this.querySelector('div[analysisSubmit]') + this.elAnalysisSubmit.style.marginBottom = '20px' + this.elAnalysisSubmit.addEventListener('click',()=>this.analysisGo()) + + this.elThreshold = this.querySelector('input[threshold]') + const elThresholdValue = this.querySelector('span[thresholdValue]') + this.elThreshold.addEventListener('input',(ev)=> elThresholdValue.innerHTML = parseFloat(this.elThreshold.value).toFixed(2) ) + + this.onViewerClick() + + window.pluginControl[PLUGIN_NAME].onShutdown(()=>{ + this.mouseEventSubscription.unsubscribe() + }) + } + + onViewerClick(){ + this.mouseEventSubscription = window.viewerHandle.mouseEvent.filter(ev=>ev.eventName=='click').subscribe(ev=>{ + if(this.elArea1.listening && this.elArea2.listening){ + this.elArea1.listening = false + } + else if(this.elArea2.listening){ + this.elArea2.listening = false + } + else if(this.elArea1.listening){ + if(this.elArea2.selectedRegion == null){ + this.elArea1.listening = false + this.elArea2.listening = true + } + else if(this.elArea2.selectedRegion != null){ + this.elArea1.listening = false + } + } + }) + } + + analysisGo(){ + /* test for submit conditions */ + if(this.elArea1.selectedRegion == null || this.elArea2.selectedRegion == null || this.elGenesInput.selectedGenes.length < 1){ + const resultCard = document.createElement('fzj-xg-webjugex-result-failure-card') + container.appendChild(resultCard) + let e = 'Error: We need ' + if(this.elArea1.selectedRegion == null || this.elArea2.selectedRegion == null) e += 'both areas to be defined and ' + if(this.elGenesInput.selectedGenes.length < 1) e += 'atleast one gene' + else e = e.substr(0, 40) + e += '.' + resultCard.panelBody.innerHTML = e + return + } + console.log(this.elArea1.selectedRegion,this.elArea2.selectedRegion,this.elArea1.selectedRegion.PMapURL,this.elArea2.selectedRegion.PMapURL,this.elThreshold.value,this.elGenesInput.selectedGenes) + const region1 = Object.assign({},this.elArea1.selectedRegion,{url:this.elArea1.selectedRegion.PMapURL}) + const region2 = Object.assign({},this.elArea2.selectedRegion,{url:this.elArea2.selectedRegion.PMapURL}) + this.sendAnalysis({ + area1 : region1, + area2 : region2, + threshold : this.elThreshold.value, + selectedGenes : this.elGenesInput.selectedGenes, + mode : this.querySelector('input[probemode]').checked + }) + } + + sendAnalysis(analysisInfo){ + /* to be overwritten by parent class */ + } + } + + /* custom class for analysis-card */ + class WebJuGExAnalysisComponent extends HTMLElement{ + constructor(){ + super() + this.template = `` + this.analysisObj = {} + this.status = 'pending' + } + + connectedCallback(){ + this.render() + this.panelHeader = this.querySelector('[panelHeader]') + } + + render(){ + + this.template = + ` + <div class = "row"> + <div class="progress"> + <div class="progress-bar progress-bar-striped active" style="width:100%"></div> + </div> + </div> + ` + this.innerHTML = this.template + } + } + + + const searchCard = document.querySelector('fzj-xg-webjugex-search-card') + const container = document.getElementById('fzj.xg.webjugex.container') + const parseContentToCsv = (content)=>{ + const CSVContent = 'data:text/csv;charset=utf-8,'+content + const CSVURI = encodeURI(CSVContent) + const domDownload = document.createElement('a') + domDownload.setAttribute('href',CSVURI) + return domDownload + } + const createRow = ()=>{ + const domDownload = document.createElement('div') + domDownload.style.display = 'flex' + domDownload.style.flexDirection = 'row' + const col1 = document.createElement('div') + const col2 = document.createElement('div') + col2.style.flex = col1.style.flex = '0 0 50%' + domDownload.appendChild(col1) + domDownload.appendChild(col2) + return [domDownload,col1,col2] + } + /* custom class for analysis-card */ + + + class WebJuGExResultSuccessComponent extends HTMLElement{ + constructor(){ + super() + this.template = `` + this.resultObj = {} + this.pvalString = '' + this.areaString = '' + this.status = 'pending' + this.firstrender = true + } + + connectedCallback(){ + + if(this.firstrender){ + this.childRoot = document.createElement('div') + this.appendChild(this.childRoot) + this.render() + + this.panelHeader = this.childRoot.querySelector('[panelHeader]') + this.panelBody = this.childRoot.querySelector('[panelBody]') + this.panelHeader.addEventListener('click',()=>{ + this.uiTogglePanelBody() + }) + this.firstrender = false + } + } + + uiTogglePanelBody(){ + if(/hidden/.test(this.panelBody.className)){ + this.panelBody.classList.remove('hidden') + }else{ + this.panelBody.classList.add('hidden') + } + } + + render(){ + this.template = + ` + <div class = "row"> + <div class = "panel panel-success"> + <div class = "btn btn-default btn-block panel-heading" panelHeader> + <span class="glyphicon glyphicon-ok"></span> Request completed! <u> Details below.</u> + </div> + <div class = "panel-body hidden" panelBody> + </div> + <div class = "panel-footer hidden" panelFooter> + </div> + </div> + </div> + ` + this.childRoot.innerHTML = this.template + } + } + + class WebJuGExResultFailureComponent extends HTMLElement{ + constructor(){ + super() + this.template = `` + this.resultObj = {} + this.pvalString = '' + this.areaString = '' + this.status = 'pending' + this.firstrender = true + } + + connectedCallback(){ + // const shadowRoot = this.attachShadow({mode:'open'}) + if(this.firstrender){ + + this.childRoot = document.createElement('div') + this.appendChild(this.childRoot) + this.render() + + this.panelHeader = this.childRoot.querySelector('[panelHeader]') + this.panelBody = this.childRoot.querySelector('[panelBody]') + this.panelHeader.addEventListener('click',()=>{ + this.uiTogglePanelBody() + }) + this.firstrender = false + } + } + + uiTogglePanelBody(){ + if(/hidden/.test(this.panelBody.className)){ + this.panelBody.classList.remove('hidden') + }else{ + this.panelBody.classList.add('hidden') + } + } + + render(){ + this.template = + ` + <div class = "row"> + <div class = "panel panel-danger"> + <div class = "btn btn-default btn-block panel-heading" panelHeader> + <span class="glyphicon glyphicon-remove"></span> Error. Check below. + </div> + <div class = "panel-body hidden" panelBody> + </div> + <div class = "panel-footer hidden" panelFooter> + </div> + </div> + </div> + ` + this.childRoot.innerHTML = this.template + } + } + + searchCard.sendAnalysis = (analysisInfo) => { + + console.log(analysisInfo) + + const analysisCard = document.createElement('fzj-xg-webjugex-analysis-card') + analysisCard.analysisObj = analysisInfo + container.appendChild(analysisCard) + const headers = new Headers() + headers.append('Content-Type','application/json') + const request = new Request(`${URL_BASE}/jugex`,{ + method : 'POST', + headers : headers, + mode : 'cors', + body : JSON.stringify(analysisInfo) + }) + fetch(request) + .then(resp => { + if (resp.ok){ + return Promise.resolve(resp) + } + else { + return new Promise((resolve,reject)=>{ + resp.text() + .then(text=>reject(text)) + }) + } + }) + .then(resp=>resp.text()) + .then(text=>{ + container.removeChild(analysisCard) + const resultCard = document.createElement('fzj-xg-webjugex-result-success-card') + container.appendChild(resultCard) + const date = new Date() + const dateDownload = ''+date.getFullYear()+(date.getMonth()+1)+date.getDate()+'_'+date.getHours()+':'+date.getMinutes() + resultCard.panelHeader.innerHTML += '('+dateDownload+')' + resultCard.resultObj = JSON.parse(text) + extension = createRow() + extension[0].style.order = -1 + if(resultCard.resultObj.length == 3){ + extension[1].innerHTML = 'Probe ids' + } + else if(resultCard.resultObj.length == 2){ + extension[1].innerHTML = 'Gene Symbol' + } + extension[1].style.fontWeight = 900 + extension[2].innerHTML = 'Pval' + extension[2].style.fontWeight = 900 + resultCard.panelBody.style.maxHeight = '400px' + resultCard.panelBody.style.overflowY = 'scroll' + resultCard.panelBody.appendChild(extension[0]) + let count = 0 + for(let key in resultCard.resultObj[1]){ + count = count+1 + } + for(let key in resultCard.resultObj[1]){ + resultCard.pvalString += [key, resultCard.resultObj[1][key]].join(',') + '\n' + } + if (count < 2){ + for (let key in resultCard.resultObj[1]){ + extension = createRow() + extension[0].style.order = Number(resultCard.resultObj[1][key]) ? Math.round(Number(resultCard.resultObj[1][key])*1000) : 1000 + extension[1].innerHTML = key + extension[2].innerHTML = resultCard.resultObj[1][key] + resultCard.panelBody.appendChild(extension[0]) + } + } + else{ + let v = 0 + for(let key in resultCard.resultObj[1]){ + extension = createRow() + extension[0].style.order = Number(resultCard.resultObj[1][key]) ? Math.round(Number(resultCard.resultObj[1][key])*1000) : 1000 + if(v == 0 || v == count-1){ + extension[1].innerHTML = key + extension[2].innerHTML = resultCard.resultObj[1][key] + } + else if (v == 1 || v == 2){ + extension[1].innerHTML = '...' + extension[2].innerHTML = '...' + } + v = v+1 + resultCard.panelBody.appendChild(extension[0]) + } + } + resultCard.areaString = 'ROI, x, y, z, ' + if(resultCard.resultObj.length == 3){ + resultCard.areaString += resultCard.resultObj[2]+'\n' + } + else{ + for(let key in resultCard.resultObj[1]){ + resultCard.areaString += key+',' + } + resultCard.areaString = resultCard.areaString.slice(0, -1) + resultCard.areaString += '\n' + } + for(let key in resultCard.resultObj[0]){ + for(let i in resultCard.resultObj[0][key]){ + resultCard.areaString += key+','+resultCard.resultObj[0][key][i]['xyz'].join(',')+','+resultCard.resultObj[0][key][i]['winsorzed_mean']+'\n' + } + } + + const domDownloadPVal = parseContentToCsv(resultCard.pvalString) + domDownloadPVal.innerHTML = 'Download Pvals of genes ('+dateDownload+')' + domDownloadPVal.setAttribute('download','PVal.csv') + resultCard.panelBody.append(domDownloadPVal) + linebreak = document.createElement("br") + resultCard.panelBody.append(linebreak) + const domDownloadArea = parseContentToCsv(resultCard.areaString) + domDownloadArea.innerHTML = 'Download sample coordinates ('+dateDownload+')' + domDownloadArea.setAttribute('download',`SampleCoordinates.csv`) + domDownloadArea.style.order = -3 + resultCard.panelBody.append(domDownloadArea) + }) + .catch(e=>{ + console.log('Here 2') + container.removeChild(analysisCard) + const resultCard = document.createElement('fzj-xg-webjugex-result-failure-card') + container.appendChild(resultCard) + console.log('error',e) + resultCard.panelBody.innerHTML = e + }) + } + + customElements.define('hover-region-selector-card', HoverRegionSelectorComponent) + customElements.define('fzj-xg-webjugex-analysis-card',WebJuGExAnalysisComponent) + + customElements.define('fzj-xg-webjugex-result-success-card',WebJuGExResultSuccessComponent) + customElements.define('fzj-xg-webjugex-result-failure-card',WebJuGExResultFailureComponent) + + customElements.define('dismissable-pill-card',DismissablePill) + + customElements.define('fzj-xg-webjugex-gene-card',WebjugexGeneComponent) + customElements.define('fzj-xg-webjugex-search-card',WebjugexSearchComponent) +})() diff --git a/src/plugin_examples/jugex/script.js b/src/plugin_examples/jugex/script.js new file mode 100644 index 0000000000000000000000000000000000000000..6a88f9f2c1f9077d6e715967258fad7159554e6d --- /dev/null +++ b/src/plugin_examples/jugex/script.js @@ -0,0 +1,840 @@ +(() => { + // // const landmarkService = interactiveViewer.experimental.landmarkService + const code = () => { + + const register = (tag,classname)=>{ + + try{ + customElements.define(tag, classname) + }catch(e){ + console.warn(tag + ' already registered',e) + } + } + + + const basePath = 'http://medpc055.ime.kfa-juelich.de:5080/plugins/webjugex/' + + const backendBasePath = 'http://medpc055.ime.kfa-juelich.de:8005/' + + /* components like this are reusable. */ + class HoverRegionSelectorComponent extends HTMLElement { + + constructor() { + super() + + this.template = + ` + <div class = "input-group"> + <input class = "form-control" placeholder = "" readonly = "readonly" type = "text" region> + <span class = "input-group-btn"> + <div class = "btn btn-default" editRegion> + <span class = "glyphicon glyphicon-edit"></span> + </div> + </span> + </div> + ` + this.listening = true + this.selectedRegion = null + this.shutdownHooks = [] + } + + connectNehubaHooks() { + const mouseOverNehuba = window.interactiveViewer.viewerHandle.mouseOverNehuba + .subscribe(ev => { + if(!this.listening) + return + + this.selectedRegion = ev ? ev : null + this.render() + }) + + this.shutdownHooks.push(() => mouseOverNehuba.unsubscribe()) + } + + disconnectedCallback() { + // disconnected call back gets called multiple times, each time user chooses to + this.shutdownHooks.forEach(fn => fn()) + } + + connectedCallback() { + // const shadowRoot = this.attachShadow({mode:'open'}) + while(this.lastChild){ + this.removeChild(this.lastChild) + } + + this.rootChild = document.createElement('div') + this.appendChild(this.rootChild) + this.connectNehubaHooks() + this.render() + } + + render() { + this.rootChild.innerHTML = this.template + console.log(this.selectedRegion) + this.rootChild.querySelector('input[region]').value = this.selectedRegion ? this.selectedRegion.name : '' + this.rootChild.querySelector('div[editRegion]').addEventListener('click', () => { + this.rootChild.querySelector('input[region]').value = '' + this.selectedRegion = null + this.listening = true + }) + } + } + + register('hover-region-selector-card',HoverRegionSelectorComponent) + + /* reusable pill components */ + class DismissablePill extends HTMLElement { + constructor() { + super() + this.name = '' + this.template = `` + } + + render() { + this.template = + ` + <span class = "label label-default"> + <span pillName>${this.name}</span> + <span class = "glyphicon glyphicon-remove" pillRemove></span> + </span> + ` + } + + connectedCallback() { + // const shadowRoot = this.attachShadow({mode:'open'}) + + while(this.lastChild){ + this.removeChild(this.lastChild) + } + + this.render() + this.innerHTML = this.template + const removePill = this.querySelector('span[pillRemove]') + removePill.addEventListener('click', () => { + this.onRemove(this.name) + this.remove() + }) + } + + onRemove(name) { } + } + register('dismissable-pill-card', DismissablePill) + + class WebJuGExGeneComponent extends HTMLElement { + constructor() { + super() + this.selectedGenes = [] + this.arrDict = [] + this.autocompleteSuggestions = [] + this.template = + ` + <div class = "input-group"> + <input geneInputBox type = "text" class = "form-control" placeholder = "Enter gene of interest ... "> + <input geneImportInput class="hidden" type="file"> + <span class = "input-group-btn"> + <div geneAdd class = "btn btn-default" title = "Add a gene">Add</div> + <div geneImport class = "btn btn-default" title = "Import a CSV file">Import</div> + <div geneExport class = "btn btn-default" title = "Export selected genes into a csv file">Export</div> + </span> + </div> + ` + } + + connectedCallback() { + // const shadowRoot = this.attachShadow({mode:'open'}) + this.rootChild = document.createElement('div') + this.rootChild.innerHTML = this.template + this.appendChild(this.rootChild) + + this.config() + this.init() + } + + config() { + this.MINCHAR = 1 + } + + init() { + this.elGeneInputBox = this.rootChild.querySelector('input[geneInputBox]') + this.elGeneImportInput = this.rootChild.querySelector('input[geneImportInput]') + this.elGeneAdd = this.rootChild.querySelector('div[geneAdd]') + this.elGeneImport = this.rootChild.querySelector('div[geneImport]') + this.elGeneExport = this.rootChild.querySelector('div[geneExport]') + + const importGeneList = (file) => { + const csvReader = new FileReader() + csvReader.onload = (ev) => { + const csvRaw = ev.target.result + this.selectedGenes.splice(0, this.selectedGenes.length) + csvRaw.split(/\r|\r\n|\n|\t|\,|\;/).forEach(gene => { + if (gene.length > 0) + this.addGene(gene) + }) + } + csvReader.readAsText(file, 'utf-8') + } + this.elGeneImportInput.addEventListener('change', (ev) => { + importGeneList(ev.target.files[0]) + }) + this.elGeneImport.addEventListener('click', () => { + this.elGeneImportInput.click() + }) + this.elGeneExport.addEventListener('click', () => { + const exportGeneList = 'data:text/csv;charset=utf-8,' + this.selectedGenes.join(',') + const exportGeneListURI = encodeURI(exportGeneList) + const dlExportGeneList = document.createElement('a') + dlExportGeneList.setAttribute('href', exportGeneListURI) + document.body.appendChild(dlExportGeneList) + const date = new Date() + dlExportGeneList.setAttribute('download', `exported_genelist_${'' + date.getFullYear() + (date.getMonth() + 1) + date.getDate() + '_' + date.getHours() + date.getMinutes()}.csv`) + dlExportGeneList.click() + document.body.removeChild(dlExportGeneList) + }) + this.elGeneAdd.addEventListener('click', () => { + if (this.autocompleteSuggestions.length > 0 && this.elGeneInputBox.value.length >= this.MINCHAR) + this.addGene(this.autocompleteSuggestions[0]) + }) + + this.elGeneInputBox.addEventListener('dragenter', (ev) => { + this.elGeneInputBox.setAttribute('placeholder', 'Drop file here to be uploaded') + }) + + this.elGeneInputBox.addEventListener('dragleave', (ev) => { + this.elGeneInputBox.setAttribute('placeholder', 'Enter gene of interest ... ') + }) + + this.elGeneInputBox.addEventListener('drop', (ev) => { + ev.preventDefault() + ev.stopPropagation() + ev.stopImmediatePropagation() + this.elGeneInputBox.setAttribute('placeholder', 'Enter gene of interest ... ') + //ev.dataTransfer.files[0] + }) + + this.elGeneInputBox.addEventListener('dragover', (ev) => { + ev.preventDefault() + ev.stopPropagation() + ev.stopImmediatePropagation() + }) + + this.elGeneInputBox.addEventListener('keydown', (ev) => { + ev.stopPropagation() + ev.stopImmediatePropagation() + if (ev.key == 'Enter') this.elGeneAdd.click() + }) + + this.loadExternalResources() + fetch(backendBasePath).then(txt => txt.json()) + .then(json => { + this.arrDict = json + }) + .catch(err => { + console.log('failed to fetch full list of genes... using limited list of genes instead ...', e) + this.arrDict = ["ADRA2A", "AVPR1B", "CHRM2", "CNR1", "CREB1", "CRH", "CRHR1", "CRHR2", "GAD2", "HTR1A", "HTR1B", "HTR1D", "HTR2A", "HTR3A", "HTR5A", "MAOA", "PDE1A", "SLC6A2", "SLC6A4", "SST", "TAC1", "TPH1", "GPR50", "CUX2", "TPH2"] + }) + } + + loadExternalResources() { + this.autoCompleteCss = document.createElement('link') + this.autoCompleteCss.type = 'text/css' + this.autoCompleteCss.rel = 'stylesheet' + this.autoCompleteCss.href = basePath + 'js-autocomplete.min.css' + + this.autoCompleteJs = document.createElement('script') + this.autoCompleteJs.onload = () => { + /* append autocomplete here */ + this.autocompleteInput = new autoComplete({ + selector: this.elGeneInputBox, + delay: 0, + minChars: this.MINCHAR, + cache: false, + source: (term, suggest) => { + const searchTerm = new RegExp('^' + term, 'gi') + this.autocompleteSuggestions = this.arrDict.filter(dict => searchTerm.test(dict)) + suggest(this.autocompleteSuggestions) + }, + onSelect: (e, term, item) => { + this.addGene(term) + } + }) + } + this.autoCompleteJs.src = basePath + 'js-autocomplete.min.js' + + document.head.appendChild(this.autoCompleteJs) + document.head.appendChild(this.autoCompleteCss) + } + + addGene(gene) { + const pill = document.createElement('dismissable-pill-card') + pill.onRemove = (name) => + this.selectedGenes.splice(this.selectedGenes.indexOf(name), 1) + pill.name = gene + this.rootChild.appendChild(pill) + this.selectedGenes.push(gene) + this.elGeneInputBox.value = '' + this.elGeneInputBox.blur() + this.elGeneInputBox.focus() + } + } + + register('fzj-xg-webjugex-gene-card', WebJuGExGeneComponent) + + class WebJuGExSearchComponent extends HTMLElement { + constructor() { + super() + this.template = ` + <div> + <div class = "col-md-12"> + <small> + Find a set of differentially expressed genes between two user defined volumes of interest based on JuBrain maps. + The tool downloads expression values of user specified sets of genes from Allen Brain API. + Then, it uses zscores to find which genes are expressed differentially between the user specified regions of interests. + After the analysis is finished, the genes and their calculated p values are displayed. There is also an option of downloading the gene names and their p values + and the roi coordinates used in the analysis. + Please select two regions of interest, and at least one gene : + </small> + </div> + <div class = "col-md-12"> + <hover-region-selector-card area1> + </hover-region-selector-card> + </div> + <div class = "col-md-12"> + <hover-region-selector-card area2> + </hover-region-selector-card> + </div> + <div class = "col-md-12"> + <div class = "input-group"> + <span class = "input-group-addon"> + Threshold + </span> + <input value = "0.20" class = "form-control" type = "range" min = "0" max = "1" step = "0.01" threshold \> + <span class = "input-group-addon" thresholdValue> + 0.20 + </span> + </div> + </div> + <div class = "col-md-12"> + <div class="input-group"> + <input id = "fzj-hb-jugex-singleprobe" name = "fzj-hb-jugex-singleprobe" type="checkbox" probemode> + <label for = "fzj-hb-jugex-singleprobe">Single Probe Mode</label> + </div> + </div> + <div class = "col-md-12"> + <div class = "input-group"> + <input name = "fzj-hb-jugex-hemisphere" type = "radio" id = "fzj-hb-jugex-hemisphere-lh" value = "left-hemisphere" checked/> + <label for = "fzj-hb-jugex-hemisphere-lh">Left Hemisphere</label> + </div> + <div class = "input-group"> + <input name = "fzj-hb-jugex-hemisphere" type = "radio" id = "fzj-hb-jugex-hemisphere-rh" value = "right-hemisphere"/> + <label for = "fzj-hb-jugex-hemisphere-rh">Right Hemisphere</label> + </div> + </div> + <div> + <div class = "col-md-12"> + <fzj-xg-webjugex-gene-card> + </fzj-xg-webjugex-gene-card> + </div> + </div> + <div> + <div class = "col-md-12"> + <div class = "btn btn-default btn-block" analysisSubmit> + Start differential analysis + </div> + </div> + </div> + ` + this.mouseEventSubscription = this.rootChild = this.threshold = this.elArea1 = this.elArea2 = null + this.selectedGenes = [] + + this.datasets = [] + + this.datasetSub = window.interactiveViewer.metadata.datasetsBSubject.subscribe(datasets=>{ + this.datasets = datasets + }) + + + // const createsth = ()=>{ + // const div1 = document.createElement('div') + // const child1 = document.createElement('div') + // const child2 = document.createElement('div') + + // child1.innerHTML = 'helo' + // child2.innerHTML = 'world' + // div1.appendChild(child1) + // div1.appendChild(child2) + + // return [div1,child1,child2] + // } + // const container = document.getElementById('fzj.xg.webjugex.container') + + // const div2 = createsth() + // container.appendChild(div2[0]) + + } + + connectedCallback() { + + while(this.lastChild){ + this.removeChild(this.lastChild) + } + + // const shadowRoot = this.attachShadow({mode:'open'}) + this.rootChild = document.createElement('div') + this.rootChild.innerHTML = this.template + this.appendChild(this.rootChild) + + /* init */ + this.init() + + /* attach click listeners */ + this.onViewerClick() + + } + + init() { + this.elArea1 = this.rootChild.querySelector('hover-region-selector-card[area1]') + this.elArea2 = this.rootChild.querySelector('hover-region-selector-card[area2]') + this.elArea1.listening = true + this.elArea2.listening = false + this.probemodeval = false + + this.elGenesInput = this.rootChild.querySelector('fzj-xg-webjugex-gene-card') + + this.elAnalysisSubmit = this.rootChild.querySelector('div[analysisSubmit]') + this.elAnalysisSubmit.style.marginBottom = '20px' + this.elAnalysisSubmit.addEventListener('click', () => { + this.analysisGo() + }) + + this.elThreshold = this.rootChild.querySelector('input[threshold]') + const elThresholdValue = this.rootChild.querySelector('span[thresholdValue]') + this.elThreshold.addEventListener('input', (ev) => { + elThresholdValue.innerHTML = parseFloat(this.elThreshold.value).toFixed(2) + }) + } + + onViewerClick() { + this.mouseEventSubscription = window.interactiveViewer.viewerHandle.mouseEvent + .subscribe(ev => { + if(ev.eventName !== 'click') return + if (this.elArea1.listening && this.elArea2.listening) { + this.elArea1.listening = false + } + else if (this.elArea2.listening) { + this.elArea2.listening = false + } + else if (this.elArea1.listening) { + if (this.elArea2.selectedRegion == null) { + this.elArea1.listening = false + this.elArea2.listening = true + } + else if (this.elArea2.selectedRegion != null) { + this.elArea1.listening = false + } + } + }) + + } + + analysisGo() { + /* test for submit conditions */ + const hemisphere = this.rootChild.querySelector('input[name="fzj-hb-jugex-hemisphere"]:checked').value + + if (this.elArea1.selectedRegion == null || this.elArea2.selectedRegion == null || this.elGenesInput.selectedGenes.length < 1) { + const resultCard = document.createElement('fzj-xg-webjugex-result-failure-card') + + const container = document.getElementById('fzj.xg.webjugex.container') + + container.appendChild(resultCard) + let e = 'Error: We need ' + if (this.elArea1.selectedRegion == null || this.elArea2.selectedRegion == null) e += 'both areas to be defined and ' + if (this.elGenesInput.selectedGenes.length < 1) e += 'atleast one gene' + else e = e.substr(0, 40) + e += '.' + resultCard.panelBody.innerHTML = e + return + } + + + console.log(this.elArea1.selectedRegion.name, + this.elArea2.selectedRegion.name, + this.elArea1.selectedRegion.PMapURL, + this.elArea2.selectedRegion.PMapURL, + this.elThreshold.value, + this.elGenesInput.selectedGenes, + hemisphere) + + const getPmap = (name) => { + if(name == 'AStr (Amygdala)') throw Error('AStr (Amygdala) has not yet been implemented in MNI152') + + + + const dataset = this.datasets.find(dataset => dataset.type === 'Cytoarchitectonic Probabilistic Map' && dataset.regionName[0].regionName === name) + const url = dataset.files[0].url + console.log('getpmap', url) + const host = 'https://neuroglancer-dev.humanbrainproject.org' + // const host = 'http://offline-neuroglancer:80' + const mni152url = `${host}/precomputed/JuBrain/v2.2c/PMaps/MNI152/${url.substring(url.lastIndexOf('/') + 1).replace('.nii',hemisphere == 'left-hemisphere' ? '_l.nii' : '_r.nii')}` + console.log('MNI152 PMap',mni152url) + if (dataset) { return mni152url } else { throw new Error('could not find PMap') } + } + + const newArea1 = { + name: this.elArea1.selectedRegion.name, + PMapURL: getPmap(this.elArea1.selectedRegion.name) + } + const newArea2 = { + name: this.elArea2.selectedRegion.name, + PMapURL: getPmap(this.elArea2.selectedRegion.name) + } + console.log('fixed loop reference', + newArea1.name, + newArea2.name, + newArea1.PMapURL, + newArea2.PMapURL, + this.elThreshold.value, + this.elGenesInput.selectedGenes) + + this.sendAnalysis({ + area1: newArea1, + area2: newArea2, + threshold: this.elThreshold.value, + selectedGenes: this.elGenesInput.selectedGenes, + mode: this.rootChild.querySelector('input[probemode]').checked + }) + } + + sendAnalysis(analysisInfo) { + + const analysisCard = document.createElement('fzj-xg-webjugex-analysis-card') + analysisCard.analysisObj = analysisInfo + + const container = document.getElementById('fzj.xg.webjugex.container') + + container.appendChild(analysisCard) + const headers = new Headers() + headers.append('Content-Type', 'application/json') + const request = new Request(backendBasePath + 'jugex', { + method: 'POST', + headers: headers, + mode: 'cors', + body: JSON.stringify(analysisInfo) + }) + fetch(request) + .then(resp => { + if (resp.ok) { + return Promise.resolve(resp) + } + else { + return new Promise((resolve, reject) => { + resp.text() + .then(text => reject(text)) + }) + } + }) + .then(resp => resp.text()) + .then(text => { + + const createRow = () => { + const domDownload = document.createElement('div') + domDownload.style.display = 'flex' + domDownload.style.flexDirection = 'row' + const col1 = document.createElement('div') + const col2 = document.createElement('div') + col2.style.flex = col1.style.flex = '0 0 50%' + domDownload.appendChild(col1) + domDownload.appendChild(col2) + return [domDownload, col1, col2] + } + + debugger + + container.removeChild(analysisCard) + const resultCard = document.createElement('fzj-xg-webjugex-result-success-card') + container.appendChild(resultCard) + const date = new Date() + const dateDownload = '' + date.getFullYear() + (date.getMonth() + 1) + date.getDate() + '_' + date.getHours() + ':' + date.getMinutes() + resultCard.panelHeader.innerHTML += '(' + dateDownload + ')' + resultCard.resultObj = JSON.parse(text) + const extension = createRow() + extension[0].style.order = -1 + if (resultCard.resultObj.length == 3) { + extension[1].innerHTML = 'Probe ids' + } + else if (resultCard.resultObj.length == 2) { + extension[1].innerHTML = 'Gene Symbol' + } + extension[1].style.fontWeight = 900 + extension[2].innerHTML = 'Pval' + extension[2].style.fontWeight = 900 + resultCard.panelBody.style.maxHeight = '400px' + resultCard.panelBody.style.overflowY = 'scroll' + resultCard.panelBody.appendChild(extension[0]) + let count = 0 + for (let key in resultCard.resultObj[1]) { + count = count + 1 + } + for (let key in resultCard.resultObj[1]) { + resultCard.pvalString += [key, resultCard.resultObj[1][key]].join(',') + '\n' + } + if (count < 2) { + for (let key in resultCard.resultObj[1]) { + const extension1 = createRow() + extension1[0].style.order = Number(resultCard.resultObj[1][key]) ? Math.round(Number(resultCard.resultObj[1][key]) * 1000) : 1000 + extension1[1].innerHTML = key + extension1[2].innerHTML = resultCard.resultObj[1][key] + resultCard.panelBody.appendChild(extension[0]) + } + } + else { + let v = 0 + for (let key in resultCard.resultObj[1]) { + const extension2 = createRow() + extension2[0].style.order = Number(resultCard.resultObj[1][key]) ? Math.round(Number(resultCard.resultObj[1][key]) * 1000) : 1000 + if (v == 0 || v == count - 1) { + extension2[1].innerHTML = key + extension2[2].innerHTML = resultCard.resultObj[1][key] + } + else if (v == 1 || v == 2) { + extension2[1].innerHTML = '...' + extension2[2].innerHTML = '...' + } + v = v + 1 + resultCard.panelBody.appendChild(extension2[0]) + } + } + resultCard.areaString = 'ROI, x, y, z, ' + if (resultCard.resultObj.length == 3) { + resultCard.areaString += resultCard.resultObj[2] + '\n' + } + else { + for (let key in resultCard.resultObj[1]) { + resultCard.areaString += key + ',' + } + resultCard.areaString = resultCard.areaString.slice(0, -1) + resultCard.areaString += '\n' + } + + /* injected to add landmarks */ + const newLandmarks = [] + + for (let key in resultCard.resultObj[0]) { + for (let i in resultCard.resultObj[0][key]) { + resultCard.areaString += key + ',' + resultCard.resultObj[0][key][i]['xyz'].join(',') + ',' + resultCard.resultObj[0][key][i]['winsorzed_mean'] + '\n' + + const pos = resultCard.resultObj[0][key][i]['xyz'] + const newLandmark = { + pos : pos, + id : pos.join('_'), + properties : pos.join('_'), + hover:false + } + newLandmarks.push(newLandmark) + // landmarkService.addLandmark(newLandmark) + } + } + + // newLandmarks.forEach((lm,idx)=>landmarkService.TEMP_parseLandmarkToVtk(lm,idx)) + + /* end */ + + const domDownloadPVal = parseContentToCsv(resultCard.pvalString) + domDownloadPVal.innerHTML = 'Download Pvals of genes (' + dateDownload + ')' + domDownloadPVal.setAttribute('download', 'PVal.csv') + resultCard.panelBody.append(domDownloadPVal) + const linebreak = document.createElement("br") + resultCard.panelBody.append(linebreak) + const domDownloadArea = parseContentToCsv(resultCard.areaString) + domDownloadArea.innerHTML = 'Download sample coordinates (' + dateDownload + ')' + domDownloadArea.setAttribute('download', `SampleCoordinates.csv`) + domDownloadArea.style.order = -3 + resultCard.panelBody.append(domDownloadArea) + }) + .catch(e => { + console.log('Here 2') + container.removeChild(analysisCard) + const resultCard = document.createElement('fzj-xg-webjugex-result-failure-card') + container.appendChild(resultCard) + console.log('error', e) + resultCard.panelBody.innerHTML = e + }) + + }; + } + + register('fzj-xg-webjugex-search-card', WebJuGExSearchComponent) + + /* custom class for analysis-card */ + class WebJuGExAnalysisComponent extends HTMLElement { + constructor() { + super() + this.template = `` + this.analysisObj = {} + this.status = 'pending' + } + + connectedCallback() { + + while(this.lastChild){ + this.removeChild(this.lastChild) + } + + // const shadowRoot = this.attachShadow({mode:'open'}) + this.childRoot = document.createElement('div') + this.appendChild(this.childRoot) + this.render() + this.panelHeader = this.childRoot.querySelector('[panelHeader]') + } + + render() { + + this.template = + ` + <div> + <div class="progress"> + <div class="progress-bar progress-bar-striped active" style="width:100%"></div> + </div> + </div> + ` + this.childRoot.innerHTML = this.template + } + } + + register('fzj-xg-webjugex-analysis-card', WebJuGExAnalysisComponent) + + const parseContentToCsv = (content) => { + const CSVContent = 'data:text/csv;charset=utf-8,' + content + const CSVURI = encodeURI(CSVContent) + const domDownload = document.createElement('a') + domDownload.setAttribute('href', CSVURI) + return domDownload + } + /* custom class for analysis-card */ + + + class WebJuGExResultSuccessComponent extends HTMLElement { + constructor() { + super() + this.template = `` + this.resultObj = {} + this.pvalString = '' + this.areaString = '' + this.status = 'pending' + } + + connectedCallback() { + + while(this.lastChild){ + this.removeChild(this.lastChild) + } + + // const shadowRoot = this.attachShadow({mode:'open'}) + this.childRoot = document.createElement('div') + this.appendChild(this.childRoot) + this.render() + + this.panelHeader = this.childRoot.querySelector('[panelHeader]') + this.panelBody = this.childRoot.querySelector('[panelBody]') + this.panelHeader.addEventListener('click', () => { + this.uiTogglePanelBody() + }) + } + + uiTogglePanelBody() { + if (/hidden/.test(this.panelBody.className)) { + this.panelBody.classList.remove('hidden') + } else { + this.panelBody.classList.add('hidden') + } + } + + render() { + this.template = + ` + <div> + <div class = "panel panel-success"> + <div class = "btn btn-default btn-block panel-heading" panelHeader> + <span class="glyphicon glyphicon-ok"></span> Request completed! <u> Details below.</u> + </div> + <div class = "panel-body hidden" panelBody> + </div> + <div class = "panel-footer hidden" panelFooter> + </div> + </div> + </div> + ` + this.childRoot.innerHTML = this.template + } + } + + class WebJuGExResultFailureComponent extends HTMLElement { + constructor() { + super() + this.template = `` + this.resultObj = {} + this.pvalString = '' + this.areaString = '' + this.status = 'pending' + } + + connectedCallback() { + + while(this.lastChild){ + this.removeChild(this.lastChild) + } + + // const shadowRoot = this.attachShadow({mode:'open'}) + this.childRoot = document.createElement('div') + this.appendChild(this.childRoot) + this.render() + + this.panelHeader = this.childRoot.querySelector('[panelHeader]') + this.panelBody = this.childRoot.querySelector('[panelBody]') + this.panelHeader.addEventListener('click', () => { + this.uiTogglePanelBody() + }) + } + + uiTogglePanelBody() { + if (/hidden/.test(this.panelBody.className)) { + this.panelBody.classList.remove('hidden') + } else { + this.panelBody.classList.add('hidden') + } + } + + render() { + this.template = + ` + <div> + <div class = "panel panel-danger"> + <div class = "btn btn-default btn-block panel-heading" panelHeader> + <span class="glyphicon glyphicon-remove"></span> Error. Check below. + </div> + <div class = "panel-body hidden" panelBody> + </div> + <div class = "panel-footer hidden" panelFooter> + </div> + </div> + </div> + ` + this.childRoot.innerHTML = this.template + } + } + + register('fzj-xg-webjugex-result-success-card', WebJuGExResultSuccessComponent) + register('fzj-xg-webjugex-result-failure-card', WebJuGExResultFailureComponent) + + interactiveViewer.pluginControl['fzj.hb.jugex'].onShutdown(() => { + console.log('shutting down fzj jugex') + // landmarkService.TEMP_clearVtkLayers() + interactiveViewer.pluginControl.unloadExternalLibraries(['webcomponentsLite']) + }) + + } + interactiveViewer.pluginControl.loadExternalLibraries(['webcomponentsLite']) + .then(() => code()) + .catch(console.warn) +})() + + diff --git a/src/plugin_examples/jugex/template.html b/src/plugin_examples/jugex/template.html new file mode 100644 index 0000000000000000000000000000000000000000..5ab759f6278401a09cf337d7630b076175f92a68 --- /dev/null +++ b/src/plugin_examples/jugex/template.html @@ -0,0 +1,10 @@ +<div id = "fzj.xg.webjugex.container"> + <fzj-xg-webjugex-search-card> + </fzj-xg-webjugex-search-card> +</div> +<style> +fzj-xg-webjugex-search-card +{ + display:inline-block; +} +</style> \ No newline at end of file diff --git a/src/plugin_examples/migrationGuide.md b/src/plugin_examples/migrationGuide.md new file mode 100644 index 0000000000000000000000000000000000000000..1489d5b848ab70382b63575190309020b0f8c148 --- /dev/null +++ b/src/plugin_examples/migrationGuide.md @@ -0,0 +1,64 @@ +Plugin Migration Guide (v0.1.0 => v0.2.0) +====== +Plugin APIs have changed drastically from v0.1.0 to v0.2.0. Here is a list of plugin API from v0.1.0, and how it has changed moving to v0.2.0. + +**n.b.** `webcomponents-lite.js` is no longer included by default. You will need to request it explicitly with `window.interactiveViewer.pluginControl.loadExternalLibraries()` and unload it once you are done. + +--- + +- ~~*window.nehubaUI*~~ removed + - ~~*metadata*~~ => **window.interactiveViewer.metadata** + - ~~*selectedTemplate* : nullable Object~~ removed. use **window.interactiveViewer.metadata.selectedTemplateBSubject** instead + - ~~*availableTemplates* : Array of TemplateDescriptors (empty array if no templates are available)~~ => **window.interactiveViewer.metadata.loadedTemplates** + - ~~*selectedParcellation* : nullable Object~~ removed. use **window.interactiveViewer.metadata.selectedParcellationBSubject** instead + - ~~*selectedRegions* : Array of Object (empty array if no regions are selected)~~ removed. use **window.interactiveViewer.metadata.selectedRegionsBSubject** instead + +- ~~window.pluginControl['YOURPLUGINNAME'] *nb: may be undefined if yourpluginname is incorrect*~~ => **window.interactiveViewer.pluginControl[YOURPLUGINNAME]** + - blink(sec?:number) : Function that causes the floating widget to blink, attempt to grab user attention + - ~~pushMessage(message:string) : Function that pushes a message that are displayed as a popover if the widget is minimised. No effect if the widget is not miniminised.~~ removed + - shutdown() : Function that causes the widget to shutdown dynamically. (triggers onShutdown callback) + - onShutdown(callback) : Attaches a callback function, which is called when the plugin is shutdown. + +- ~~*window.viewerHandle*~~ => **window.interactiveViewer.viewerHandle** + - ~~*loadTemplate(TemplateDescriptor)* : Function that loads a new template~~ removed. use **window.interactiveViewer.metadata.selectedTemplateBSubject** instead + - ~~*onViewerInit(callback)* : Functional that allows a callback function to be called just before a nehuba viewer is initialised~~ removed + - ~~*afterViewerInit(callback)* : Function that allows a callback function to be called just after a nehuba viewer is initialised~~ removed + - ~~*onViewerDestroy(callback)* : Function that allows a callback function be called just before a nehuba viewer is destroyed~~ removed + - ~~*onParcellationLoading(callback)* : Function that allows a callback function to be called just before a parcellation is selected~~ removed + - ~~*afterParcellationLoading(callback)* : Function that allows a callback function to be called just after a parcellation is selected~~ removed + - *setNavigationLoc(loc,realSpace?)* : Function that teleports to loc : number[3]. Optional argument to determine if the loc is in realspace (default) or voxelspace. + - ~~*setNavigationOrientation(ori)* : Function that teleports to ori : number[4]. (Does not work currently)~~ => **setNavigationOri(ori)** (still non-functional) + - *moveToNavigationLoc(loc,realSpace?)* : same as *setNavigationLoc(loc,realSpace?)*, except moves to target location over 500ms. + - *showSegment(id)* : Function that selectes a segment in the viewer and UI. + - *hideSegment(id)* : Function that deselects a segment in the viewer and UI. + - *showAllSegments()* : Function that selects all segments. + - *hideAllSegments()* : Function that deselects all segments. + - *loadLayer(layerObject)* : Function that loads a custom neuroglancer compatible layer into the viewer (e.g. precomputed, NIFTI, etc). Does not influence UI. + - ~~*reapplyNehubaMeshFix()* Function that reapplies the cosmetic change to NehubaViewer (such as custom colour map, if defined)~~ removed. use **applyColourMap(colourMap)** instead + - *mouseEvent* RxJs Observable. Read more at [rxjs doc](http://reactivex.io/rxjs/) + - *mouseEvent.filter(filterFn:({eventName : String, event: Event})=>boolean)* returns an Observable. Filters the event stream according to the filter function. + - *mouseEvent.map(mapFn:({eventName : String, event: Event})=>any)* returns an Observable. Map the event stream according to the map function. + - *mouseEvent.subscribe(callback:({eventName : String , event : Event})=>void)* returns an Subscriber instance. Call *Subscriber.unsubscribe()* when done to avoid memory leak. + - *mouseOverNehuba* RxJs Observable. Read more at [rxjs doc](http://reactivex.io/rxjs) + - *mouseOverNehuba.filter* && *mouseOvernehuba.map* see above + - *mouseOverNehuba.subscribe(callback:({nehubaOutput : any, foundRegion : any})=>void)* + +- ~~*window.uiHandle*~~ => **window.interactiveViewer.uiHandle** + - ~~*onTemplateSelection(callback)* : Function that allows a callback function to be called just after user clicks to navigate to a new template, before *selectedTemplate* is updated~~ removed. use **window.interactiveViewer.metadata.selectedTemplateBSubject** instead + - ~~*afterTemplateSelection(callback)* : Function that allows a callback function to be called after the template selection process is complete, and *selectedTemplate* is updated~~ removed + - ~~*onParcellationSelection(callback)* : Function that attach a callback function to user selecting a different parcellation~~ removed. use **window.interactiveViewer.metadata.selectedParcellationBSubject** instead. + - ~~*afterParcellationSelection(callback)* : Function that attach a callback function to be called after the parcellation selection process is complete and *selectedParcellation* is updated.~~ removed + - *modalControl* + - *getModalHandler()* : Function returning a handler to change/show/hide/listen to a Modal. + - *modalHander* methods: + - *hide()* : Dynamically hides the modal + - *show()* : Shows the modal + - *onHide(callback(reason)=>void)* : Attaches an onHide callback. + - *onHidden(callback(reason)=>void)* : Attaches an onHidden callback. + - *onShow(callback(reason)=>void)* : Attaches an onShow callback. + - *onShown(callback(reason)=>void)* : Attaches an onShown callback. + - *modalHandler* properties: + - title : title of the modal (String) + - body : body of the modal shown (JSON, Array, String) + - footer : footer of the modal (String) + - config : config of the modal \ No newline at end of file diff --git a/src/plugin_examples/plugin_README.md b/src/plugin_examples/plugin_README.md new file mode 100644 index 0000000000000000000000000000000000000000..6a76357bf8122f52de712b0f5857327bcc3b8f45 --- /dev/null +++ b/src/plugin_examples/plugin_README.md @@ -0,0 +1,92 @@ +Plugin README +====== +A plugin needs to contain three files. +- Manifest JSON +- template HTML +- script JS. + +These files need to be served by GET requests over HTTP with appropriate CORS header. If your application requires a backend, it is strongly recommended to host these three files with your backend. + +--- +Manifest JSON +------ +The manifest JSON file describes the metadata associated with the plugin. + +```json +{ + "name":"fzj.xg.helloWorld", + "templateURL":"http://LINK-TO-YOUR-PLUGIN-TEMPLATE/template.html", + "scriptURL":"http://LINK-TO-YOUR-PLUGIN-SCRIPT/script.js" +} +``` +*NB* +- Plugin name must be unique globally. To prevent plugin name clashing, please adhere to the convention of naming your package **AFFILIATION.AUTHORNAME.PACKAGENAME**. + + +--- +Template HTML +------ +The template HTML file describes the HTML view that will be rendered in the widget. + + +```html +<form> + <div class = "input-group"> + <span class = "input-group-addon">Area 1</span> + <input type = "text" id = "fzj.xg.helloWorld.area1" name = "fzj.xg.helloWorld.area1" class = "form-control" placeholder="Select a region" value = ""> + </div> + + <div class = "input-group"> + <span class = "input-group-addon">Area 2</span> + <input type = "text" id = "fzj.xg.helloWorld.area2" name = "fzj.xg.helloWorld.area2" class = "form-control" placeholder="Select a region" value = ""> + </div> + + <hr class = "col-md-10"> + + <div class = "col-md-12"> + Select genes of interest: + </div> + <div class = "input-group"> + <input type = "text" id = "fzj.xg.helloWorld.genes" name = "fzj.xg.helloWorld.genes" class = "form-control" placeholder = "Genes of interest ..."> + <span class = "input-group-btn"> + <button id = "fzj.xg.helloWorld.addgenes" name = "fzj.xg.helloWorld.addgenes" class = "btn btn-default" type = "button">Add</button> + </span> + </div> + + <hr class = "col-md-10"> + + <button id = "fzj.xg.helloWorld.submit" name = "fzj.xg.helloWorld.submit" type = "button" class = "btn btn-default btn-block">Submit</button> + + <hr class = "col-md-10"> + + <div class = "col-md-12" id = "fzj.xg.helloWorld.result"> + + </div> +</form> +``` +*NB* +- *bootstrap 3.6* css is already included for templating. +- keep in mind of the widget width restriction (400px) when crafting the template +- whilst there are no vertical limits on the widget, contents can be rendered outside the viewport. Consider setting the *max-height* attribute. +- your template and script will interact with each other likely via *element id*. As a result, it is highly recommended that unique id's are used. Please adhere to the convention: **AFFILIATION.AUTHOR.PACKAGENAME.ELEMENTID** +--- +Script JS +------ +The script will always be appended **after** the rendering of the template. + +```javascript +(()=>{ + /* your code here */ + const submitButton = document.getElemenById('fzj.xg.helloWorld.submit') + submitButton.addEventListener('click',(ev)=>{ + console.log('submit button was clicked') + }) +})() +``` +*NB* +- ensure the script is scoped locally, instead of poisoning the global scope +- for every observable subscription, call *unsubscribe()* in the *onShutdown* callback +- some frameworks such as *jquery2*, *jquery3*, *react/reactdom* and *webcomponents* can be loaded via *interactiveViewer.pluinControl.loadExternalLibraries([LIBRARY_NAME_1, LIBRARY_NAME_2])*. if the libraries are loaded, remember to hook *interactiveViewer.pluginControl.unloadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2])* in the *onShutdown* callback +- when/if using webcomponents, please be aware that the `connectedCallback()` and `disconnectedCallback()` will be called everytime user toggle between *floating* and *docked* modes. +- when user navigate to a new template all existing widgets will be destroyed. +- for a list of APIs, see [plugin_api.md](plugin_api.md) diff --git a/src/plugin_examples/plugin_api.md b/src/plugin_examples/plugin_api.md new file mode 100644 index 0000000000000000000000000000000000000000..1ea94a6449132d111c69fe4b337732c0c2becf44 --- /dev/null +++ b/src/plugin_examples/plugin_api.md @@ -0,0 +1,171 @@ +Plugin APIs +====== + +[plugin migration guide](migrationGuide.md) + +window.interactiveViewer +--- +- metadata + + - *selectedTemplateBSubject* : BehaviourSubject that emits a TemplateDescriptor object whenever a template is selected. Emits null onInit. + + - *selectedParcellationBSubject* : BehaviourSubject that emits a ParcellationDescriptor object whenever a parcellation is selected. n.b. selecting a new template automatically select the first available parcellation. Emits null onInit. + + - *selectedRegionsBSubject* BehaviourSubject that emits an Array of RegionDescriptor objects whenever the list of selected regions changes. Emits empty array onInit. + + - *loadedTemplates* : Array of TemplateDescriptor objects. Loaded asynchronously onInit. + + - *regionsLabelIndexMap* Map of labelIndex (used by neuroglancer and nehuba) to the corresponding RegionDescriptor object. + +- viewerHandle + + - *setNavigationLoc(coordinates,realspace?:boolean)* Function that teleports the navigation state to coordinates : [x:number,y:number,z:number]. Optional arg determine if the set of coordinates is in realspace (default) or voxelspace. + + - *moveToNavigationLoc(coordinates,realspace?:boolean)* + same as *setNavigationLoc(coordinates,realspace?)*, except the action is carried out over 500ms. + + - *setNavigationOri(ori)* (not yet live) Function that sets the orientation state of the viewer. + + - *moveToNavigationOri(ori)* (not yet live) same as *setNavigationOri*, except the action is carried out over 500ms. + + - *showSegment(labelIndex)* Function that shows a specific segment. Will trigger *selectedRegionsBSubject*. + + - *hideSegment(labelIndex)* Function that hides a specific segment. Will trigger *selectRegionsBSubject* + + - *showAllSegments()* Function that shows all segments. Will trigger *selectRegionsBSubject* + + - *hideAllSegments()* Function that hides all segments. Will trigger *selectRegionBSubject* + + - *segmentColourMap* : Map of *labelIndex* to an object with the shape of `{red: number, green: number, blue: number}`. + + - *applyColourMap(colourMap)* Function that applies a custom colour map (Map of number to and object with the shape of `{red: number , green: number , blue: number}`) + + - *loadLayer(layerObject)* Function that loads *ManagedLayersWithSpecification* directly to neuroglancer. Returns the values of the object successfully added. **n.b.** advanced feature, will likely break other functionalities. **n.b.** if the layer name is already taken, the layer will not be added. + + ```javascript + const obj = { + 'advanced layer' : { + type : 'image', + source : 'nifti://http://example.com/data/nifti.nii', + }, + 'advanced layer 2' : { + type : 'mesh', + source : 'vtk://http://example.com/data/vtk.vtk' + } + } + const returnValue = window.interactiveViewer.viewerHandle.loadLayer(obj) + /* loads two layers, an image nifti layer and a mesh vtk layer */ + + console.log(returnValue) + /* prints + + [{ + type : 'image', + source : 'nifti...' + }, + { + type : 'mesh', + source : 'vtk...' + }] + */ + ``` + + - *removeLayer(layerObject)* Function that removes *ManagedLayersWithSpecification*, returns an array of the names of the layers removed. **n.b.** advanced feature. may break other functionalities. + ```js + const obj = { + 'name' : /^PMap/ + } + const returnValue = window.interactiveViewer.viewerHandle.removeLayer(obj) + + console.log(returnValue) + /* prints + ['PMap 001','PMap 002'] + */ + ``` + - *setLayerVisibility(layerObject,visible)* Function that sets the visibility of a layer. Returns the names of all the layers that are affected as an Array of string. + + ```js + const obj = { + 'type' : 'segmentation' + } + + window.interactiveViewer.viewerHandle.setLayerVisibility(obj,false) + + /* turns off all the segmentation layers */ + ``` + + - *mouseEvent* Subject that emits an object shaped `{ eventName : string, event: event }` when a user triggers a mouse event on the viewer. + + - *mouseOverNehuba* BehaviourSubject that emits an object shaped `{ nehubaOutput : number | null, foundRegion : RegionDescriptor | null }` + +- uiHandle + + - modalControl + + - *getModalHandler()* returns a modalHandlerObject + + - *modalHandler* + + - *hide()* : Dynamically hides the modal + - *show()* : Shows the modal + - *onHide(callback(reason)=>void)* : Attaches an onHide callback. + - *onHidden(callback(reason)=>void)* : Attaches an onHidden callback. + - *onShow(callback(reason)=>void)* : Attaches an onShow callback. + - *onShown(callback(reason)=>void)* : Attaches an onShown callback. + - title : title of the modal (String) + - body : body of the modal shown (JSON, Array, String) + - footer : footer of the modal (String) + - config : config of the modal + + - *viewingModeBSubject* BehaviourSubject emitting strings when viewing mode has changed. + +- pluginControl + + - *loadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2])* Function that loads external libraries. Pass the name of the libraries as an Array of string, and returns a Promise. When promise resolves, the libraries are loaded. **n.b.** while unlikely, there is a possibility that multiple requests to load external libraries in quick succession can cause the promise to resolve before the library is actually loaded. + + ```js + const currentlySupportedLibraries = ['jquery2','jquery3','webcomponentsLite','react16','reactdom16'] + + window.interactivewViewer.loadExternalLibraries(currentlySupportedLibraries) + .then(()=>{ + /* loaded */ + }) + .catch(e=>console.warn(e)) + + ``` + + - *unloadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2])* unloading the libraries (should be called on shutdown). + + - **[PLUGINNAME]** returns a plugin handler. This would be how to interface with the plugins. + + + - *blink(sec?:number)* : Function that causes the floating widget to blink, attempt to grab user attention + - *shutdown()* : Function that causes the widget to shutdown dynamically. (triggers onShutdown callback) + - *onShutdown(callback)* : Attaches a callback function, which is called when the plugin is shutdown. + + ```js + const pluginHandler = window.interactiveViewer.pluginControl[PLUGINNAME] + + const subscription = window.interactiveViewer.metadata.selectedTemplateBSubject.subscribe(template=>console.log(template)) + + fetch(`http://YOUR_BACKEND.com/API_ENDPOINT`) + .then(data=>pluginHandler.blink(20)) + + pluginHandler.onShutdown(()=>{ + subscription.unsubscribe() + }) + ``` + +------ + +window.nehubaViewer +--- + +nehuba object, exposed if user would like to use it + +------- + +window.viewer +--- + +neuroglancer object, exposed if user would like to use it \ No newline at end of file diff --git a/src/plugin_examples/server.js b/src/plugin_examples/server.js new file mode 100644 index 0000000000000000000000000000000000000000..ce14bf6949c65e7d7ebbabe6885371957f4785d5 --- /dev/null +++ b/src/plugin_examples/server.js @@ -0,0 +1,14 @@ +const express = require('express') + +const app = express() + +const cors = (req,res,next)=>{ + res.setHeader('Access-Control-Allow-Origin','*') + next() +} + +app.use(cors,express.static(__dirname)) + +app.listen(10080,()=>{ + console.log(`listening on 10080, serving ${__dirname}`) +}) \ No newline at end of file diff --git a/src/plugin_examples/testPlugin/manifest.json b/src/plugin_examples/testPlugin/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..f59a172f48d5743665ef42219d84da73292e2bca --- /dev/null +++ b/src/plugin_examples/testPlugin/manifest.json @@ -0,0 +1 @@ +{"name":"fzj.xg.test","type":"plugin","templateURL":"http://localhost:10080/testPlugin/template.html","scriptURL":"http://localhost:10080/testPlugin/script.js"} diff --git a/src/plugin_examples/testPlugin/script.js b/src/plugin_examples/testPlugin/script.js new file mode 100644 index 0000000000000000000000000000000000000000..e805a1924e712fdd43e1bd4d8d98bf2267c76893 --- /dev/null +++ b/src/plugin_examples/testPlugin/script.js @@ -0,0 +1,9 @@ +(()=>{ + setTimeout(()=>{ + const el = document.getElementById('testplugin-id') + const newel = document.createElement('div') + newel.innerHTML = `hello new owrld` + el.appendChild(newel) + + },100) +})() \ No newline at end of file diff --git a/src/plugin_examples/testPlugin/template.html b/src/plugin_examples/testPlugin/template.html new file mode 100644 index 0000000000000000000000000000000000000000..14e0ebf75e7171960e9d2830e6cfe720dc650819 --- /dev/null +++ b/src/plugin_examples/testPlugin/template.html @@ -0,0 +1,3 @@ +<div id = "testplugin-id"> + hello world +</div> diff --git a/src/res/css/extra_styles.css b/src/res/css/extra_styles.css new file mode 100644 index 0000000000000000000000000000000000000000..aa4a5e77b82afdc6f987fdebd63cdb56e061aabb --- /dev/null +++ b/src/res/css/extra_styles.css @@ -0,0 +1,120 @@ +html +{ + width:100%; + height:100%; +} +body +{ + width:100%; + height:100%; + margin:0; + border:0; + + /* required for glyphicon tooltip directives */ + overflow:hidden; +} +div.scale-bar-container +{ + text-align: center; + background-color: rgba(0,0,0,.3); + position: absolute; + left: 1em; + bottom: 1em; + padding: 2px; + font-weight: 700; + pointer-events: none; +} + +div.scale-bar +{ + min-height: 1ex; + background-color: #fff; + padding: 0; + margin: 0; + margin-top: 2px; +} +div.neuroglancer-rendered-data-panel +{ + position:relative; +} + +ul#statusContainer +{ + display:none; +} + +.inputSearchContainer +{ + background:none; + box-shadow:none; + border:none; + /* width:25em; */ + max-width:999999px; +} +.inputSearchContainer .popover-arrow +{ + display:none; +} + +.inputSearchContainer .popover-content.popover-body +{ + padding:0; + max-width:999999px; +} + +.mute-text +{ + opacity:0.8; +} + +div.scale-bar-container +{ + font-weight:500; + color: #1a1a1a; + background-color:hsla(0,0%,80%,0.5); +} + +label.perspective-panel-show-slice-views +{ + visibility: hidden; +} + +label.perspective-panel-show-slice-views:hover +{ + text-decoration: underline +} + +[darktheme="false"] .neuroglancer-panel +{ + border:2px solid rgba(255,255,255,0.9); +} + +[darktheme="true"] .neuroglancer-panel +{ + border:2px solid rgba(30,30,30,0.9); +} + +label.perspective-panel-show-slice-views:before +{ + margin-left: .2em; + content: "show / hide frontal octant"; + visibility: visible; + pointer-events: all; + color: #337ab7; +} + +[darktheme="true"] .scale-bar-container +{ + color:#f2f2f2; + background-color:hsla(0,0%,60%,0.2); +} + +span.regionSelected +{ + color : #dbb556 +} + +markdown-dom pre code +{ + white-space:pre; +} \ No newline at end of file diff --git a/src/res/css/plugin_styles.css b/src/res/css/plugin_styles.css new file mode 100644 index 0000000000000000000000000000000000000000..3fbdf34578aa805573416ae02de95cb2d3b472dd --- /dev/null +++ b/src/res/css/plugin_styles.css @@ -0,0 +1,35 @@ +/* layout */ + +[plugincontainer] .btn, +[plugincontainer] .input-group-addon, +[plugincontainer] input[type="text"], +[plugincontainer] .panel +{ + border-radius:0px; + border:none; +} + +[plugincontainer] .btn +{ + opacity : 0.9; + transition: opacity 0.3s ease, transform 0.3s ease; + box-shadow : rgba(5, 5, 5, 0.1) 0px 4px 6px 0px; +} + +[plugincontainer] .btn:hover +{ + opacity:1.0; + transform:translateY(-5%); + box-shadow : rgba(5, 5, 5, 0.25) 0px 4px 6px 0px; +} + +/* colour */ +[darktheme="true"] [plugincontainer] .btn, +[darktheme="true"] [plugincontainer] .input-group-addon, +[darktheme="true"] [plugincontainer] input[type="text"], +[darktheme="true"] [plugincontainer] .panel +{ + background-color:rgba(80,80,80,0.8); + color:white; +} + diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts index 421aa33b821d40685133abc5a3eae4e283b045c2..67348cba88ae85204489c985308b07a6a0a17ac2 100644 --- a/src/services/stateStore.service.ts +++ b/src/services/stateStore.service.ts @@ -26,6 +26,9 @@ export const OPEN_SIDE_PANEL = `OPEN_SIDE_PANEL` export const MOUSE_OVER_SEGMENT = `MOUSE_OVER_SEGMENT` +export const FETCHED_PLUGIN_MANIFESTS = `FETCHED_PLUGIN_MANIFESTS` +export const LAUNCH_PLUGIN = `LAUNCH_PLUGIN` + export interface ViewerStateInterface{ fetchedTemplates : any[] @@ -113,7 +116,8 @@ export function viewerState(state:ViewerStateInterface,action:AtlasAction){ dedicatedView : null }) case FETCHED_TEMPLATES : { - return Object.assign({},state,{fetchedTemplates:action.fetchedTemplate}) + return Object.assign({},state,{ + fetchedTemplates:action.fetchedTemplate}) } case CHANGE_NAVIGATION : { return Object.assign({},state,{navigation : action.navigation}) diff --git a/src/ui/banner/banner.component.ts b/src/ui/banner/banner.component.ts index 4947e11115011942aefa58efb7c4f956cc3af49f..78ac4ccfc5b0072d04517d32c690bdc761aa5a0f 100644 --- a/src/ui/banner/banner.component.ts +++ b/src/ui/banner/banner.component.ts @@ -134,6 +134,10 @@ export class AtlasBanner implements OnDestroy{ return '' } + getChildren(item:any){ + return item.children + } + filterTreeBySearch(node:any):boolean{ return this.filterNameBySearchPipe.transform([node.name],this.searchTerm) } diff --git a/src/ui/banner/banner.template.html b/src/ui/banner/banner.template.html index bd06b9581b9a47632cc777c87f7c74d5270448eb..6b0e30b92996bfa04e4ece94d0dd737357f3211e 100644 --- a/src/ui/banner/banner.template.html +++ b/src/ui/banner/banner.template.html @@ -34,6 +34,8 @@ placeholder="Regions"/> </div> + <plugin-banner> + </plugin-banner> </div> <ng-template #searchRegionTemplate> @@ -44,7 +46,7 @@ <span (click) = "clearRegions($event)" *ngIf = "selectedRegions.length > 0" class = "btn btn-link">clear all</span> </div> <tree - *ngFor = "let child of selectedParcellation.regions" + *ngFor = "let child of (selectedParcellation.regions | treeSearch : filterTreeBySearch.bind(this) : getChildren )" (mouseclicktree) = "handleClickRegion($event)" [renderNode]="(displayTreeNode).bind(this)" [searchFilter]="(filterTreeBySearch).bind(this)" diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index 294c232785a9d3a5f7ca67ddbffeeff67ae3ad7e..5f25d436aad8d6540baee01b968f0a7ddd1a61a9 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -1,11 +1,11 @@ import { Component, ViewChild, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, ComponentRef, OnInit, OnDestroy, ElementRef, AfterViewInit } from "@angular/core"; import { NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component"; import { Store, select } from "@ngrx/store"; -import { ViewerStateInterface, safeFilter, SELECT_REGIONS, getLabelIndexMap, DataEntry, CHANGE_NAVIGATION, isDefined, SPATIAL_GOTO_PAGE, MOUSE_OVER_SEGMENT } from "../../services/stateStore.service"; -import { Observable, Subscription, fromEvent, combineLatest } from "rxjs"; +import { ViewerStateInterface, safeFilter, SELECT_REGIONS, getLabelIndexMap, DataEntry, CHANGE_NAVIGATION, isDefined, MOUSE_OVER_SEGMENT } from "../../services/stateStore.service"; +import { Observable, Subscription, fromEvent, combineLatest, merge } from "rxjs"; import { filter,map, take, scan, debounceTime, distinctUntilChanged } from "rxjs/operators"; import * as export_nehuba from 'export_nehuba' -import { AtlasViewerDataService } from "../../atlasViewer/atlasViewer.dataService.service"; +import { AtlasViewerAPIServices } from "../../atlasViewer/atlasViewer.apiService.service"; @Component({ selector : 'ui-nehuba-container', @@ -32,7 +32,8 @@ export class NehubaContainer implements OnInit,OnDestroy,AfterViewInit{ private selectedRegions$ : Observable<any[]> private dedicatedView$ : Observable<string|null> private fetchedSpatialDatasets$ : Observable<any[]> - public onHoverSegment$ : Observable<string> + public onHoverSegmentName$ : Observable<string> + public onHoverSegment$ : Observable<any> private navigationChanges$ : Observable<any> private redrawObservable$ : Observable<any> @@ -53,6 +54,7 @@ export class NehubaContainer implements OnInit,OnDestroy,AfterViewInit{ constructor( + private apiService :AtlasViewerAPIServices, private csf:ComponentFactoryResolver, private store : Store<ViewerStateInterface>, private elementRef : ElementRef @@ -111,7 +113,28 @@ export class NehubaContainer implements OnInit,OnDestroy,AfterViewInit{ distinctUntilChanged() ) + const segmentsUnchangedChanged = (s1,s2)=> + !(typeof s1 === typeof s2 ? + typeof s2 === 'undefined' ? + false : + typeof s2 === 'number' ? + s2 !== s1 : + s1 === s2 ? + false : + s1 === null || s2 === null ? + true : + s2.name !== s1.name : + true) + + this.onHoverSegment$ = this.store.pipe( + select('uiState'), + filter(state=>isDefined(state)), + map(state=>state.mouseOverSegment), + distinctUntilChanged(segmentsUnchangedChanged) + ) + + this.onHoverSegmentName$ = this.store.pipe( select('uiState'), filter(state=>isDefined(state)), map(state=>state.mouseOverSegment ? @@ -237,12 +260,10 @@ export class NehubaContainer implements OnInit,OnDestroy,AfterViewInit{ handleMouseEnterLandmark(spatialData:any){ spatialData.highlight = true - // console.log('mouseover',spatialData) } handleMouseLeaveLandmark(spatialData:any){ spatialData.highlight = false - // console.log('mouseleave') } private getNMToOffsetPixelFn(){ @@ -344,6 +365,9 @@ export class NehubaContainer implements OnInit,OnDestroy,AfterViewInit{ spatialSearchPagination : number = 0 private createNewNehuba(template:any){ + + this.apiService.interactiveViewer.viewerHandle = null + this.viewerLoaded = true this.container.clear() this.cr = this.container.createComponent(this.nehubaViewerFactory) @@ -357,6 +381,78 @@ export class NehubaContainer implements OnInit,OnDestroy,AfterViewInit{ this.nehubaViewerSubscriptions.push( this.nehubaViewer.mouseoverSegmentEmitter.subscribe(this.handleEmittedMouseoverSegment.bind(this)) ) + + this.setupViewerHandleApi() + } + + private setupViewerHandleApi(){ + this.apiService.interactiveViewer.viewerHandle = { + setNavigationLoc : (coord,realSpace?)=>this.nehubaViewer.setNavigationState({ + position : coord, + positionReal : typeof realSpace !== 'undefined' ? realSpace : true + }), + /* TODO introduce animation */ + moveToNavigationLoc : (coord,realSpace?)=>this.nehubaViewer.setNavigationState({ + position : coord, + positionReal : typeof realSpace !== 'undefined' ? realSpace : true + }), + setNavigationOri : (quat)=>this.nehubaViewer.setNavigationState({ + orientation : quat + }), + /* TODO introduce animation */ + moveToNavigationOri : (quat)=>this.nehubaViewer.setNavigationState({ + orientation : quat + }), + showSegment : (labelIndex) => { + if(!this.selectedRegionIndexSet.has(labelIndex)) + this.store.dispatch({ + type : SELECT_REGIONS, + selectRegions : [labelIndex, ...this.selectedRegionIndexSet] + }) + }, + hideSegment : (labelIndex) => { + if(this.selectedRegionIndexSet.has(labelIndex)){ + this.store.dispatch({ + type :SELECT_REGIONS, + selectRegions : [...this.selectedRegionIndexSet].filter(num=>num!==labelIndex) + }) + } + }, + showAllSegments : () => { + this.store.dispatch({ + type : SELECT_REGIONS, + selectRegions : this.regionsLabelIndexMap.keys() + }) + }, + hideAllSegments : ()=>{ + this.store.dispatch({ + type : SELECT_REGIONS, + selectRegions : [] + }) + }, + segmentColourMap : new Map(), + applyColourMap : (map)=>{ + /* TODO to be implemented */ + }, + loadLayer : (layerObj)=>this.nehubaViewer.loadLayer(layerObj), + removeLayer : (condition)=>this.nehubaViewer.removeLayer(condition), + setLayerVisibility : (condition,visible)=>this.nehubaViewer.setLayerVisibility(condition,visible), + mouseEvent : merge( + fromEvent(this.nehubaViewer.elementRef.nativeElement,'click').pipe( + map((ev:MouseEvent)=>({eventName :'click',event:ev})) + ), + fromEvent(this.nehubaViewer.elementRef.nativeElement,'mousemove').pipe( + map((ev:MouseEvent)=>({eventName :'mousemove',event:ev})) + ), + fromEvent(this.nehubaViewer.elementRef.nativeElement,'mousedown').pipe( + map((ev:MouseEvent)=>({eventName :'mousedown',event:ev})) + ), + fromEvent(this.nehubaViewer.elementRef.nativeElement,'mouseup').pipe( + map((ev:MouseEvent)=>({eventName :'mouseup',event:ev})) + ), + ) , + mouseOverNehuba : this.onHoverSegment$ + } } handleEmittedMouseoverSegment(emitted : any | number | null){ diff --git a/src/ui/nehubaContainer/nehubaContainer.style.css b/src/ui/nehubaContainer/nehubaContainer.style.css index 1daed932e5ae9ec9cc79eb212904f62be1cb568b..1adeb5bccea9afe544dfe94606782dc3e78d2c31 100644 --- a/src/ui/nehubaContainer/nehubaContainer.style.css +++ b/src/ui/nehubaContainer/nehubaContainer.style.css @@ -86,3 +86,7 @@ div[landmarkMasterContainer] > div > [landmarkContainer] > div margin-top:-1em; } +small[onHoverSegment] +{ + margin-left:2em; +} \ No newline at end of file diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index 6f01a65b45dd8df5584eb5715690bb5c557acc7b..5e810777cfa0d8dd7ff07a0d5312fd06ec9533d5 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -89,8 +89,8 @@ {{ mouseCoord }} </small> <br /> - <small> - {{ onHoverSegment$ | async }} + <small onHoverSegment> + {{ onHoverSegment$ | async }} </small> </div> </div> diff --git a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts index d69483896f8ac1a707e9889287e64c3e17928dab..340949cfa0cdc9ac196a588950b2659aa0c02bc5 100644 --- a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts +++ b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts @@ -1,4 +1,4 @@ -import { Component, AfterViewInit, OnDestroy, Output, EventEmitter } from "@angular/core"; +import { Component, AfterViewInit, OnDestroy, Output, EventEmitter, ElementRef } from "@angular/core"; import * as export_nehuba from 'export_nehuba' import 'export_nehuba/dist/min/chunk_worker.bundle.js' @@ -45,6 +45,10 @@ export class NehubaViewerUnit implements AfterViewInit,OnDestroy{ this._s8$ ] + constructor(public elementRef:ElementRef){ + + } + private _parcellationId : string get parcellationId(){ @@ -130,6 +134,13 @@ export class NehubaViewerUnit implements AfterViewInit,OnDestroy{ this.loadLayer(_) } + public setLayerVisibility(condition:{name:string|RegExp},visible:boolean){ + const viewer = this.nehubaViewer.ngviewer + viewer.layerManager.managedLayers + .filter(l=>this.filterLayers(l,condition)) + .map(layer=>layer.setVisible(visible)) + } + public remove3DLandmarks(){ this.removeLayer({ name : /vtk-[0-9]/ diff --git a/src/ui/pluginBanner/pluginBanner.component.ts b/src/ui/pluginBanner/pluginBanner.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..4081d39d2ccde043eed5641ba165a52a6754abeb --- /dev/null +++ b/src/ui/pluginBanner/pluginBanner.component.ts @@ -0,0 +1,18 @@ +import { Component } from "@angular/core"; +import { PluginServices } from "../../atlasViewer/atlasViewer.pluginService.service"; + + +@Component({ + selector : 'plugin-banner', + templateUrl : './pluginBanner.template.html', + styleUrls : [ + `./pluginBanner.style.css` + ] +}) + +export class PluginBannerUI{ + + constructor(public pluginServices:PluginServices){ + + } +} \ No newline at end of file diff --git a/src/ui/pluginBanner/pluginBanner.style.css b/src/ui/pluginBanner/pluginBanner.style.css new file mode 100644 index 0000000000000000000000000000000000000000..0f51586a46488a894fe1cf5846b071b17fc81e98 --- /dev/null +++ b/src/ui/pluginBanner/pluginBanner.style.css @@ -0,0 +1,36 @@ +:host +{ + margin-left:1em; +} + +.btn +{ + border-radius: 0px; + border:none; +} + +.btn +{ + opacity : 0.9; + transition: opacity 0.3s ease, transform 0.3s ease; + box-shadow : rgba(5, 5, 5, 0.1) 0px 4px 6px 0px; +} + +.btn:hover +{ + cursor:default; + opacity:1.0; + transform:translateY(-5%); + box-shadow : rgba(5, 5, 5, 0.25) 0px 4px 6px 0px; +} + +.btn-default +{ + background-color:rgba(255,255,255,0.8); +} + +:host-context([darktheme="true"]) .btn-default +{ + background-color:rgba(80,80,80,0.8); + color:white; +} \ No newline at end of file diff --git a/src/ui/pluginBanner/pluginBanner.template.html b/src/ui/pluginBanner/pluginBanner.template.html new file mode 100644 index 0000000000000000000000000000000000000000..849bc291b6b62972a6bcbf120e5fd85141592e1c --- /dev/null +++ b/src/ui/pluginBanner/pluginBanner.template.html @@ -0,0 +1,6 @@ +<div + *ngFor = "let plugin of pluginServices.fetchedPluginManifests" + (click) = "pluginServices.launchPlugin(plugin)" + class = "btn btn-default"> + {{ plugin.name }} +</div> \ No newline at end of file diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 15f0e02678b32abaaf4b083fb57d58fdcbd922a5..5fbbf3def24dc128e789455a0a63ab973c60fadc 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -22,6 +22,9 @@ import { FilterDataEntriesbyType } from "../util/pipes/filterDataEntriesByType.p import { DedicatedViewer } from "./fileviewer/dedicated/dedicated.component"; import { LandmarkUnit } from "./nehubaContainer/landmarkUnit/landmarkUnit.component"; import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; +import { PluginBannerUI } from "./pluginBanner/pluginBanner.component"; +import { AtlasBanner } from "./banner/banner.component"; +import { PopoverModule } from "ngx-bootstrap/popover"; @NgModule({ @@ -30,7 +33,9 @@ import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; FormsModule, BrowserModule, LayoutModule, - ComponentsModule + ComponentsModule, + + PopoverModule.forRoot(), ], declarations : [ NehubaContainer, @@ -42,6 +47,8 @@ import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; LineChart, DedicatedViewer, LandmarkUnit, + AtlasBanner, + PluginBannerUI, /* pipes */ GroupDatasetByRegion, @@ -61,6 +68,8 @@ import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; DataBrowserUI ], exports : [ + AtlasBanner, + PluginBannerUI, NehubaContainer, NehubaViewerUnit, DataBrowserUI, diff --git a/src/util/pipes/treeSearch.pipe.ts b/src/util/pipes/treeSearch.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f26c05c662b0caae9b8a0d2c5736a0aa0413f6b --- /dev/null +++ b/src/util/pipes/treeSearch.pipe.ts @@ -0,0 +1,15 @@ +import { PipeTransform, Pipe } from "@angular/core"; + + +@Pipe({ + name : 'treeSearch' +}) + +export class TreeSearchPipe implements PipeTransform{ + public transform(array:any[],filterFn:(item:any)=>boolean,getChildren:(item:any)=>any[]):any[]{ + const transformSingle = (item:any):boolean=> + filterFn(item) || + getChildren(item).some(transformSingle) + return array.filter(transformSingle) + } +} \ No newline at end of file