From 7d0288088ee9ce14fad07a6740e19b314d8ae63d Mon Sep 17 00:00:00 2001 From: xgui3783 <xgui3783@gmail.com> Date: Fri, 4 Sep 2020 20:26:21 +0200 Subject: [PATCH] feat: 3d landmarks now can inherit colours (#656) feat: interactiveViewer.viewerHandle.mouseOverNehubaUI now allows ... ... customLandmarks enter/leave state to be monitored --- .../atlasViewer.apiService.service.ts | 8 +- src/atlasViewer/mouseOver.directive.ts | 20 +- src/plugin_examples/plugin_api.md | 635 +++++++++++------- src/services/state/uiState.store.ts | 15 +- .../state/viewerState.store.helper.ts | 2 + src/services/state/viewerState.store.ts | 59 +- src/services/state/viewerState/actions.ts | 10 + src/services/state/viewerState/selectors.ts | 5 + .../landmarkUnit/landmarkUnit.component.ts | 160 +++-- .../landmarkUnit/landmarkUnit.template.html | 33 +- .../nehubaContainer.component.spec.ts | 13 + .../nehubaContainer.component.ts | 84 ++- .../nehubaContainer.template.html | 12 + .../nehubaViewer/nehubaViewer.component.ts | 29 +- .../nehubaViewerInterface.directive.ts | 12 +- 15 files changed, 684 insertions(+), 413 deletions(-) diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 606409490..26ab547fa 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -346,11 +346,8 @@ export interface IInteractiveViewerInterface { mouseEvent: Observable<{eventName: string, event: MouseEvent}> mouseOverNehuba: Observable<{labelIndex: number, foundRegion: any | null}> - /** - * TODO add to documentation - */ mouseOverNehubaLayers: Observable<Array<{layer: {name: string}, segment: any | number }>> - + mouseOverNehubaUI: Observable<{ segments: any, landmark: any, customLandmark: any }> getNgHash: () => string } @@ -386,7 +383,8 @@ export interface IUserLandmark { name: string position: [number, number, number] id: string /* probably use the it to track and remove user landmarks */ - color: [ number, number, number ] + highlight: boolean + color?: [number, number, number] } export enum EnumCustomRegion{ diff --git a/src/atlasViewer/mouseOver.directive.ts b/src/atlasViewer/mouseOver.directive.ts index c3eccd363..0d31a3107 100644 --- a/src/atlasViewer/mouseOver.directive.ts +++ b/src/atlasViewer/mouseOver.directive.ts @@ -5,7 +5,7 @@ import { combineLatest, merge, Observable } from "rxjs"; import { distinctUntilChanged, filter, map, scan, shareReplay, startWith, withLatestFrom } from "rxjs/operators"; import { TransformOnhoverSegmentPipe } from "src/atlasViewer/onhoverSegment.pipe"; import { LoggingService } from "src/logging"; -import { getNgIdLabelIndexFromId, IavRootStoreInterface } from "src/services/stateStore.service"; +import { getNgIdLabelIndexFromId } from "src/services/stateStore.service"; /** * Scan function which prepends newest positive (i.e. defined) value @@ -44,7 +44,7 @@ export class MouseHoverDirective { public currentOnHoverObs$: Observable<{segments: any, landmark: any, userLandmark: any}> constructor( - private store$: Store<IavRootStoreInterface>, + private store$: Store<any>, private log: LoggingService, ) { @@ -144,14 +144,22 @@ export class MouseHoverDirective { this.currentOnHoverObs$ = mergeObs.pipe( scan(temporalPositveScanFn, []), - map(arr => arr[0]), - map(val => { - return { + map(arr => { + + let returnObj = { segments: null, landmark: null, userLandmark: null, - ...val, } + + for (const val of arr) { + returnObj = { + ...returnObj, + ...val + } + } + + return returnObj }), shareReplay(1), ) diff --git a/src/plugin_examples/plugin_api.md b/src/plugin_examples/plugin_api.md index 425daa034..a8b29b30f 100644 --- a/src/plugin_examples/plugin_api.md +++ b/src/plugin_examples/plugin_api.md @@ -1,303 +1,442 @@ -Plugin APIs -====== +# Plugin APIs -[plugin migration guide](migrationGuide.md) +## window.interactiveViewer -window.interactiveViewer ---- -- metadata +### metadata - - *selectedTemplateBSubject* : BehaviourSubject that emits a TemplateDescriptor object whenever a template is selected. Emits null onInit. +#### selectedTemplateBSubject - - *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. +BehaviourSubject that emits a TemplateDescriptor object whenever a template is selected. Emits null onInit. - - *selectedRegionsBSubject* BehaviourSubject that emits an Array of RegionDescriptor objects whenever the list of selected regions changes. Emits empty array onInit. +#### selectedParcellationBSubject - - *loadedTemplates* : Array of TemplateDescriptor objects. Loaded asynchronously onInit. +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. - - **Deprecated** ~~*regionsLabelIndexMap* Map of labelIndex (used by neuroglancer and nehuba) to the corresponding RegionDescriptor object.~~ +#### selectedRegionsBSubject - - *layersRegionLabelIndexMap* Map of layer name to Map of labelIndex (used by neuroglancer and nehuba) to the corresponding RegionDescriptor object. +BehaviourSubject that emits an Array of RegionDescriptor objects whenever the list of selected regions changes. Emits empty array onInit. -- viewerHandle +#### loadedTemplates - - *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. +Array of TemplateDescriptor objects. Loaded asynchronously onInit. - - *moveToNavigationLoc(coordinates,realspace?:boolean)* - same as *setNavigationLoc(coordinates,realspace?)*, except the action is carried out over 500ms. +#### layersRegionLabelIndexMap - - *setNavigationOri(ori)* (not yet live) Function that sets the orientation state of the viewer. +Map of layer name to Map of labelIndex (used by neuroglancer and nehuba) to the corresponding RegionDescriptor object. - - *moveToNavigationOri(ori)* (not yet live) same as *setNavigationOri*, except the action is carried out over 500ms. +### viewerHandle - - *showSegment(labelIndex)* Function that shows a specific segment. Will trigger *selectedRegionsBSubject*. +#### setNavigationLoc(coordinates, realspace?:boolean) - - *hideSegment(labelIndex)* Function that hides a specific segment. Will trigger *selectRegionsBSubject* - - - *showAllSegments()* Function that shows all segments. Will trigger *selectRegionsBSubject* +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. - - *hideAllSegments()* Function that hides all segments. Will trigger *selectRegionBSubject* +#### moveToNavigationLoc(coordinates,realspace?:boolean) - - **Deprecated** ~~*segmentColourMap* : Map of *labelIndex* to an object with the shape of `{red: number, green: number, blue: number}`.~~ +same as *setNavigationLoc(coordinates,realspace?)*, except the action is carried out over 500ms. - - *getLayersSegmentColourMap* : Call to get Map of layer name to Map of label index to colour map +#### setNavigationOri(ori) - - **Deprecated** ~~*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}`)~~ +(NYI) Function that sets the orientation state of the viewer. - - *applyLayersColourMap* Function that applies a custom colour map. +#### moveToNavigationOri(ori) - - *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 */ +(NYI) 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() - console.log(returnValue) - /* prints +Function that shows all segments. Will trigger *selectRegionsBSubject* + +#### hideAllSegments() +Function that hides all segments. Will trigger *selectRegionBSubject* + +#### getLayersSegmentColourMap() + +Call to get Map of layer name to Map of label index to colour map + +#### applyLayersColourMap + +Function that applies a custom colour map. + +#### 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. - [{ - type : 'image', - source : 'nifti...' +```javascript +const obj = { + 'advanced layer' : { + type : 'image', + source : 'nifti://http://example.com/data/nifti.nii', }, - { + 'advanced layer 2' : { 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'] - */ - ``` - - *add3DLandmarks(landmarks)* adds landmarks to both the perspective view and slice view. - - ```js - const landmarks = [{ - id : `fzj-xg-jugex-1`, - position : [0,0,0] - },{ - id : `fzj-xg-jugex-2`, - position : [22,27,-1] - }] - window.interactiveViewer.viewerHandle.add3DLandmarks(landmarks) - - /* adds landmarks in perspective view and slice view */ - ``` - - - *remove3DLandmarks(IDs)* removes the landmarks by their IDs - ```js - window.interactiveViewer.viewerHandle - .remove3DLandmarks(['fzj-xg-jugex-1', 'fzj-xg-jugex-2']) - /* removes the landmarks added above */ - ``` - - - *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' + 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 */ - window.interactiveViewer.viewerHandle.setLayerVisibility(obj,false) +console.log(returnValue) +/* prints - /* turns off all the segmentation layers */ - ``` +[{ + type : 'image', + source : 'nifti...' +}, +{ + type : 'mesh', + source : 'vtk...' +}] +*/ +``` - - *mouseEvent* Subject that emits an object shaped `{ eventName : string, event: event }` when a user triggers a mouse event on the viewer. +#### removeLayer(layerObject) - - *mouseOverNehuba* BehaviourSubject that emits an object shaped `{ nehubaOutput : number | null, foundRegion : RegionDescriptor | null }` +Function that removes *ManagedLayersWithSpecification*, returns an array of the names of the layers removed. -- uiHandle +**n.b.** advanced feature. may break other functionalities. - - *getModalHandler()* returns a modalHandler object, which has the following methods/properties: +```js +const obj = { + 'name' : /^PMap/ +} +const returnValue = window.interactiveViewer.viewerHandle.removeLayer(obj) - - *hide()* : Dynamically hides the modal - - *show()* : Shows the modal - - title : title of the modal (String) - - body : body of the modal shown (String) - - footer : footer of the modal (String) - - dismissable : whether the modal is dismissable on click backdrop/esc key (Boolean) *n.b. if true, users will not be able to interact with the viewer unless you specifically call `handler.hide()`* +console.log(returnValue) +/* prints +['PMap 001','PMap 002'] +*/ +``` - - *getToastHandler()* returns a toastHandler objectm, which has the following methods/properties: +#### add3DLandmarks(landmarks) - - *show()* : Show the toast - - *hide()* : Dynamically hides the toast - - message : message on the toast - - htmlMessage : HTML message. If used to display user content, beware of script injection. Angular strips `style` attribute, so use `class` and bootstrap for styling. - - dismissable : allow user dismiss the toast via x - - timeout : auto hide (in ms). set to 0 for not auto hide. +adds landmarks to both the perspective view and slice view. - - *launchNewWidget(manifest)* returns a Promise. expects a JSON object, with the same key value as a plugin manifest. the *name* key must be unique, or the promise will be rejected. +_input_ - - *getUserInput(config)* returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: - ```javascript - const config = { - "title": "Title of the modal", // default: "Message" - "message":"Message to be seen by the user.", // default: "" - "placeholder": "Start typing here", // default: "Type your response here" - "defaultValue": "42" // default: "" - "iconClass":"fas fa-save" // default fas fa-save, set to falsy value to disable - } - ``` - - *getUserConfirmation(config)* returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: - ```javascript - const config = { - "title": "Title of the modal", // default: "Message" - "message":"Message to be seen by the user." // default: "" - } - ``` - - *getUserToSelectARegion(message)* returns a `Promise` - - **To be deprecated** - - _input_ - - | input | type | desc | - | --- | --- | --- | - | message | `string` | human readable message displayed to the user | - | spec.type | `'POINT'` `'PARCELLATION_REGION'` **default** | type of region to be returned. | - - _returns_ - - `Promise`, resolves to return array of region clicked, rejects with error object `{ userInitiated: boolean }` - - Requests user to select a region of interest. Resolving to the region selected by the user. Rejects if either user cancels by pressing `Esc` or `Cancel`, or by developer calling `cancelPromise` - - - *getUserToSelectRoi(message, spec)* returns a `Promise` - - _input_ - - | input | type | desc | - | --- | --- | --- | - | message | `string` | human readable message displayed to the user | - | spec.type | `POINT` `PARCELLATION_REGION` | type of ROI to be returned. | - - _returns_ - - `Promise` - - **resolves**: return `{ type, payload }`. `type` is the same as `spec.type`, and `payload` differs depend on the type requested: - - | type | payload | example | - | --- | --- | --- | - | `POINT` | array of number in mm | `[12.2, 10.1, -0.3]` | - | `PARCELLATION_REGOIN` | non empty array of region selected | `[{ "layer": { "name" : " viewer specific layer name " }, "segment": {} }]` | - - **rejects**: with error object `{ userInitiated: boolean }` - - Requests user to select a region of interest. If the `spec.type` input is missing, it is assumed to be `'PARCELLATION_REGION'`. Resolving to the region selected by the user. Rejects if either user cancels by pressing `Esc` or `Cancel`, or by developer calling `cancelPromise` - - - *cancelPromise(promise)* returns `void` - - _input_ - - | input | type | desc | - | --- | --- | --- | - | promise | `Promise` | Reference to the __exact__ promise returned by `uiHnandle` methods | - - Cancel the request to select a parcellation region. - - _usage example_ - - ```javascript - - (() => { - const pr = interactive.uiHandle.getUserToSelectARegion(`webJuGEx would like you to select a region`) - - pr.then(region => { }) - .catch(console.warn) - - /* - * do NOT do - * - * const pr = interactive.uiHandle.getUserToSelectARegion(`webJuGEx would like you to select a region`) - * .then(region => { }) - * -catch(console.warn) - * - * the promise passed to `cancelPromise` must be the exact promise returned. - * by chaining then/catch, a new reference is returned - */ - - setTimeout(() => { - try { - interactive.uiHandle.cancelPromise(pr) - } catch (e) { - // if the promise has been fulfilled (by resolving or user cancel), cancelPromise will throw - } - }, 5000) - })() - ``` +| input | type | desc | +| --- | --- | --- | +| landmarks | array | an array of `landmarks` to be rendered by the viewer | + +A landmark object consist of the following keys: + +| key | type | desc | required | +| --- | --- | --- | --- | +| id | string | id of the landmark | yes | +| name | string | name of the landmark | | +| position | [number, number, number] | position (in mm) | yes | +| color | [number, number, number] | rgb of the landmark | | + + +```js +const landmarks = [{ + id : `fzj-xg-jugex-1`, + position : [0,0,0] +},{ + id : `fzj-xg-jugex-2`, + position : [22,27,-1], + color: [255, 0, 255] +}] +window.interactiveViewer.viewerHandle.add3DLandmarks(landmarks) + +/* adds landmarks in perspective view and slice view */ +``` + +#### remove3DLandmarks(IDs) + +removes the landmarks by their IDs + +```js +window.interactiveViewer.viewerHandle + .remove3DLandmarks(['fzj-xg-jugex-1', 'fzj-xg-jugex-2']) +/* removes the landmarks added above */ +``` + +#### 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 + +**deprecating** use mouseOverNehubaUI instead + +BehaviourSubject that emits an object shaped `{ nehubaOutput : number | null, foundRegion : RegionDescriptor | null }` + +#### mouseOverNehubaUI + +`Observable<{ landmark, segments, customLandmark }>`. + +**nb** it is a known issue that if customLandmarks are destroyed/created while user mouse over the custom landmark this observable will emit `{ customLandmark: null }` + +### uiHandle + +#### getModalHandler() + +returns a modalHandler object, which has the following methods/properties: + +##### hide() + +Dynamically hides the modal + +##### show() + +Shows the modal + +##### title + +title of the modal (String) + +##### body + +body of the modal shown (String) + +##### footer + +footer of the modal (String) + +##### dismissable + +whether the modal is dismissable on click backdrop/esc key (Boolean) + +*n.b. if true, users will not be able to interact with the viewer unless you specifically call `handler.hide()`* + +#### getToastHandler() + +returns a toastHandler objectm, which has the following methods/properties: + +##### show() + +Show the toast + +##### hide() + +Dynamically hides the toast + +##### message + +message on the toast + +##### htmlMessage + +HTML message. If used to display user content, beware of script injection. Angular strips `style` attribute, so use `class` and bootstrap for styling. + +##### dismissable + +allow user dismiss the toast via x + +##### timeout + +auto hide (in ms). set to 0 for not auto hide. + +#### launchNewWidget(manifest) + +returns a Promise. expects a JSON object, with the same key value as a plugin manifest. the *name* key must be unique, or the promise will be rejected. + +#### getUserInput(config) + +returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: + +```javascript +const config = { + "title": "Title of the modal", // default: "Message" + "message":"Message to be seen by the user.", // default: "" + "placeholder": "Start typing here", // default: "Type your response here" + "defaultValue": "42" // default: "" + "iconClass":"fas fa-save" // default fas fa-save, set to falsy value to disable +} +``` + +#### getUserConfirmation(config) + +returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: + +```javascript +const config = { + "title": "Title of the modal", // default: "Message" + "message":"Message to be seen by the user." // default: "" +} +``` + +#### getUserToSelectARegion(message) + +**To be deprecated** + +_input_ + +| input | type | desc | +| --- | --- | --- | +| message | `string` | human readable message displayed to the user | +| spec.type | `'POINT'` `'PARCELLATION_REGION'` **default** | type of region to be returned. | + +_returns_ + +`Promise`, resolves to return array of region clicked, rejects with error object `{ userInitiated: boolean }` + +Requests user to select a region of interest. Resolving to the region selected by the user. Rejects if either user cancels by pressing `Esc` or `Cancel`, or by developer calling `cancelPromise` + +#### getUserToSelectRoi(message, spec) + +_input_ + +| input | type | desc | +| --- | --- | --- | +| message | `string` | human readable message displayed to the user | +| spec.type | `POINT` `PARCELLATION_REGION` | type of ROI to be returned. | + +_returns_ + +`Promise` + +**resolves**: return `{ type, payload }`. `type` is the same as `spec.type`, and `payload` differs depend on the type requested: + +| type | payload | example | +| --- | --- | --- | +| `POINT` | array of number in mm | `[12.2, 10.1, -0.3]` | +| `PARCELLATION_REGOIN` | non empty array of region selected | `[{ "layer": { "name" : " viewer specific layer name " }, "segment": {} }]` | + +**rejects**: with error object `{ userInitiated: boolean }` + +Requests user to select a region of interest. If the `spec.type` input is missing, it is assumed to be `'PARCELLATION_REGION'`. Resolving to the region selected by the user. Rejects if either user cancels by pressing `Esc` or `Cancel`, or by developer calling `cancelPromise` + +#### cancelPromise(promise) + +returns `void` -- pluginControl +_input_ - - *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. +| input | type | desc | +| --- | --- | --- | +| promise | `Promise` | Reference to the __exact__ promise returned by `uiHnandle` methods | - ```js - const currentlySupportedLibraries = ['jquery@2','jquery@3','webcomponentsLite@1.1.0','react@16','reactdom@16','vue@2.5.16'] +Cancel the request to select a parcellation region. - window.interactivewViewer.loadExternalLibraries(currentlySupportedLibraries) - .then(() => { - /* loaded */ - }) - .catch(e=>console.warn(e)) +_usage example_ - ``` +```javascript - - *unloadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2])* unloading the libraries (should be called on shutdown). +(() => { + const pr = interactive.uiHandle.getUserToSelectARegion(`webJuGEx would like you to select a region`) - - **[PLUGINNAME]** returns a plugin handler. This would be how to interface with the plugins. + pr.then(region => { }) + .catch(console.warn) - - - *blink()* : Function that causes the floating widget to blink, attempt to grab user attention (silently fails if called on startup). - - *setProgressIndicator(val:number|null)* : Set the progress of the plugin. Useful for giving user feedbacks on the progress of a long running process. Call the function with null to unset the progress. - - *shutdown()* : Function that causes the widget to shutdown dynamically. (triggers onShutdown callback, silently fails if called on startup) - - *onShutdown(callback)* : Attaches a callback function, which is called when the plugin is shutdown. - - *initState* : passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). - - *initStateUrl* : passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). - - *setInitManifestUrl(url|null)* set/unset the url for a manifest json that will be fetched on atlas viewer startup. the argument should be a valid URL, has necessary CORS header, and returns a valid manifest json file. null will unset the search param. Useful for passing/preserving state. If called multiple times, the last one will take effect. + /* + * do NOT do + * + * const pr = interactive.uiHandle.getUserToSelectARegion(`webJuGEx would like you to select a region`) + * .then(region => { }) + * -catch(console.warn) + * + * the promise passed to `cancelPromise` must be the exact promise returned. + * by chaining then/catch, a new reference is returned + */ - ```js - const pluginHandler = window.interactiveViewer.pluginControl[PLUGINNAME] + setTimeout(() => { + try { + interactive.uiHandle.cancelPromise(pr) + } catch (e) { + // if the promise has been fulfilled (by resolving or user cancel), cancelPromise will throw + } + }, 5000) +})() +``` - const subscription = window.interactiveViewer.metadata.selectedTemplateBSubject.subscribe(template=>console.log(template)) +### pluginControl - fetch(`http://YOUR_BACKEND.com/API_ENDPOINT`) - .then(data=>pluginHandler.blink(20)) +#### loadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2]) - pluginHandler.onShutdown(()=>{ - subscription.unsubscribe() - }) - ``` +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 = ['jquery@2','jquery@3','webcomponentsLite@1.1.0','react@16','reactdom@16','vue@2.5.16'] + +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() + +Function that causes the floating widget to blink, attempt to grab user attention (silently fails if called on startup). + +##### setProgressIndicator(val:number|null) -window.nehubaViewer ---- +Set the progress of the plugin. Useful for giving user feedbacks on the progress of a long running process. Call the function with null to unset the progress. + +##### shutdown() + +Function that causes the widget to shutdown dynamically. (triggers onShutdown callback, silently fails if called on startup) + +##### onShutdown(callback) + +Attaches a callback function, which is called when the plugin is shutdown. + +##### initState + +passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). + +##### initStateUrl + +passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). + +##### setInitManifestUrl(url|null) + +set/unset the url for a manifest json that will be fetched on atlas viewer startup. the argument should be a valid URL, has necessary CORS header, and returns a valid manifest json file. null will unset the search param. Useful for passing/preserving state. If called multiple times, the last one will take effect. + +```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() +}) +``` + +------ -nehuba object, exposed if user would like to use it +## window.nehubaViewer -------- +nehuba object, exposed if developer would like to use it -window.viewer ---- +## window.viewer -neuroglancer object, exposed if user would like to use it \ No newline at end of file +neuroglancer object, exposed if developer would like to use it \ No newline at end of file diff --git a/src/services/state/uiState.store.ts b/src/services/state/uiState.store.ts index a3969dd32..d050b1a52 100644 --- a/src/services/state/uiState.store.ts +++ b/src/services/state/uiState.store.ts @@ -1,5 +1,5 @@ import { Injectable, TemplateRef, OnDestroy } from '@angular/core'; -import { Action, select, Store, createAction, props } from '@ngrx/store' +import { Action, select, Store } from '@ngrx/store' import { Effect, Actions, ofType } from "@ngrx/effects"; import { Observable, Subscription } from "rxjs"; @@ -8,6 +8,7 @@ import { COOKIE_VERSION, KG_TOS_VERSION, LOCAL_STORAGE_CONST } from 'src/util/co import { IavRootStoreInterface, GENERAL_ACTION_TYPES } from '../stateStore.service' import { MatBottomSheetRef, MatBottomSheet } from '@angular/material/bottom-sheet'; import { uiStateCloseSidePanel, uiStateOpenSidePanel, uiStateCollapseSidePanel, uiStateExpandSidePanel, uiActionSetPreviewingDatasetFiles, uiStateShowBottomSheet, uiActionShowSidePanelConnectivity } from './uiState.store.helper'; +import { viewerStateMouseOverCustomLandmark } from './viewerState/actions'; export const defaultState: StateInterface = { previewingDatasetFiles: [], @@ -20,7 +21,6 @@ export const defaultState: StateInterface = { focusedSidePanel: null, sidePanelIsOpen: false, - sidePanelCurrentViewContent: 'Dataset', sidePanelExploreCurrentViewIsOpen: false, snackbarMessage: null, @@ -54,7 +54,7 @@ export const getStateStore = ({ state = defaultState } = {}) => (prevState: Stat ...prevState, mouseOverSegment : action.segment, } - case MOUSEOVER_USER_LANDMARK: { + case viewerStateMouseOverCustomLandmark.type: { const { payload = {} } = action const { userLandmark: mouseOverUserLandmark = null } = payload return { @@ -102,12 +102,6 @@ export const getStateStore = ({ state = defaultState } = {}) => (prevState: Stat sidePanelExploreCurrentViewIsOpen: false, } - case SHOW_SIDE_PANEL_DATASET_LIST: - return { - ...prevState, - sidePanelCurrentViewContent: 'Dataset', - } - case uiActionShowSidePanelConnectivity.type: case AGREE_COOKIE: { /** @@ -160,7 +154,6 @@ export interface StateInterface { segment: any | null }> sidePanelIsOpen: boolean - sidePanelCurrentViewContent: 'Connectivity' | 'Dataset' | null sidePanelExploreCurrentViewIsOpen: boolean mouseOverSegment: any | number @@ -266,11 +259,9 @@ export class UiStateUseEffect implements OnDestroy{ export const MOUSE_OVER_SEGMENT = `MOUSE_OVER_SEGMENT` export const MOUSE_OVER_SEGMENTS = `MOUSE_OVER_SEGMENTS` export const MOUSE_OVER_LANDMARK = `MOUSE_OVER_LANDMARK` -export const MOUSEOVER_USER_LANDMARK = `MOUSEOVER_USER_LANDMARK` export const CLOSE_SIDE_PANEL = `CLOSE_SIDE_PANEL` export const OPEN_SIDE_PANEL = `OPEN_SIDE_PANEL` -export const SHOW_SIDE_PANEL_DATASET_LIST = `SHOW_SIDE_PANEL_DATASET_LIST` export const COLLAPSE_SIDE_PANEL_CURRENT_VIEW = `COLLAPSE_SIDE_PANEL_CURRENT_VIEW` export const EXPAND_SIDE_PANEL_CURRENT_VIEW = `EXPAND_SIDE_PANEL_CURRENT_VIEW` diff --git a/src/services/state/viewerState.store.helper.ts b/src/services/state/viewerState.store.helper.ts index eaf14e842..6405a55b3 100644 --- a/src/services/state/viewerState.store.helper.ts +++ b/src/services/state/viewerState.store.helper.ts @@ -49,12 +49,14 @@ import { viewerStateSelectedTemplateSelector, viewerStateSelectedParcellationSelector, viewerStateGetSelectedAtlas, + viewerStateCustomLandmarkSelector, } from './viewerState/selectors' export { viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector, viewerStateSelectedParcellationSelector, + viewerStateCustomLandmarkSelector, } interface IViewerStateHelperStore{ diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index eb1005074..0060ef7b3 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -9,8 +9,17 @@ import { getViewer } from 'src/util/fn'; import { LoggingService } from 'src/logging'; import { generateLabelIndexId, IavRootStoreInterface } from '../stateStore.service'; import { GENERAL_ACTION_TYPES } from '../stateStore.service' -import { MOUSEOVER_USER_LANDMARK, CLOSE_SIDE_PANEL } from './uiState.store'; -import { viewerStateSetSelectedRegions, viewerStateSetConnectivityRegion, viewerStateSelectAtlas, viewerStateSelectParcellation } from './viewerState.store.helper'; +import { CLOSE_SIDE_PANEL } from './uiState.store'; +import { + viewerStateSetSelectedRegions, + viewerStateSetConnectivityRegion, + viewerStateSelectAtlas, + viewerStateSelectParcellation, + viewerStateSelectRegionWithIdDeprecated, + viewerStateCustomLandmarkSelector, +} from './viewerState.store.helper'; + +import { viewerStateDblClickOnViewer, viewerStateAddUserLandmarks, viewreStateRemoveUserLandmarks, viewerStateMouseOverCustomLandmark, viewerStateMouseOverCustomLandmarkInPerspectiveView } from './viewerState/actions'; export interface StateInterface { fetchedTemplates: any[] @@ -246,11 +255,6 @@ export function stateStore(state, action) { return defaultStateStore(state, action) } -import { - viewerStateSelectRegionWithIdDeprecated -} from './viewerState.store.helper' -import { viewerStateDblClickOnViewer, viewerStateAddUserLandmarks, viewreStateRemoveUserLandmarks } from './viewerState/actions'; - export const LOAD_DEDICATED_LAYER = 'LOAD_DEDICATED_LAYER' export const UNLOAD_DEDICATED_LAYER = 'UNLOAD_DEDICATED_LAYER' @@ -291,8 +295,8 @@ export class ViewerStateUseEffect { select('viewerState'), shareReplay(1) ) - this.currentLandmarks$ = viewerState$.pipe( - select('userLandmarks'), + this.currentLandmarks$ = this.store$.pipe( + select(viewerStateCustomLandmarkSelector), shareReplay(1), ) @@ -314,7 +318,7 @@ export class ViewerStateUseEffect { ) this.addUserLandmarks$ = this.actions$.pipe( - ofType(ACTION_TYPES.ADD_USERLANDMARKS), + ofType(viewerStateAddUserLandmarks.type), withLatestFrom(this.currentLandmarks$), map(([action, currentLandmarks]) => { const { landmarks } = action as ActionInterface @@ -340,36 +344,31 @@ export class ViewerStateUseEffect { ) this.mouseoverUserLandmarks = this.actions$.pipe( - ofType(ACTION_TYPES.MOUSEOVER_USER_LANDMARK_LABEL), + ofType(viewerStateMouseOverCustomLandmarkInPerspectiveView.type), withLatestFrom(this.currentLandmarks$), map(([ action, currentLandmarks ]) => { const { payload } = action as any const { label } = payload - if (!label) { return { - type: MOUSEOVER_USER_LANDMARK, - payload: { - userLandmark: null, - }, - } + if (!label) { + return viewerStateMouseOverCustomLandmark({ + payload: { + userLandmark: null + } + }) } const idx = Number(label.replace('label=', '')) if (isNaN(idx)) { this.log.warn(`Landmark index could not be parsed as a number: ${idx}`) - return { - type: MOUSEOVER_USER_LANDMARK, - payload: { - userLandmark: null, - }, - } + return viewerStateMouseOverCustomLandmark({ + payload: { userLandmark: null } + }) } - - return { - type: MOUSEOVER_USER_LANDMARK, + return viewerStateMouseOverCustomLandmark({ payload: { - userLandmark: currentLandmarks[idx], - }, - } + userLandmark: currentLandmarks[idx] + } + }) }), ) @@ -467,9 +466,7 @@ export class ViewerStateUseEffect { } const ACTION_TYPES = { - ADD_USERLANDMARKS: viewerStateAddUserLandmarks.type, REMOVE_USER_LANDMARKS: viewreStateRemoveUserLandmarks.type, - MOUSEOVER_USER_LANDMARK_LABEL: 'MOUSEOVER_USER_LANDMARK_LABEL', SINGLE_CLICK_ON_VIEWER: 'SINGLE_CLICK_ON_VIEWER', DOUBLE_CLICK_ON_VIEWER: viewerStateDblClickOnViewer.type diff --git a/src/services/state/viewerState/actions.ts b/src/services/state/viewerState/actions.ts index c0fba6b04..3b19de4b1 100644 --- a/src/services/state/viewerState/actions.ts +++ b/src/services/state/viewerState/actions.ts @@ -80,3 +80,13 @@ export const viewreStateRemoveUserLandmarks = createAction( `[viewerState] removeUserLandmarks`, props<{ payload: { landmarkIds: string[] } }>() ) + +export const viewerStateMouseOverCustomLandmark = createAction( + '[viewerState] mouseOverCustomLandmark', + props<{ payload: { userLandmark: any } }>() +) + +export const viewerStateMouseOverCustomLandmarkInPerspectiveView = createAction( + `[viewerState] mouseOverCustomLandmarkInPerspectiveView`, + props<{ payload: { label: string } }>() +) diff --git a/src/services/state/viewerState/selectors.ts b/src/services/state/viewerState/selectors.ts index 60ad71c1a..6a2a7e912 100644 --- a/src/services/state/viewerState/selectors.ts +++ b/src/services/state/viewerState/selectors.ts @@ -6,6 +6,11 @@ export const viewerStateSelectedRegionsSelector = createSelector( viewerState => viewerState['regionsSelected'] ) +export const viewerStateCustomLandmarkSelector = createSelector( + state => state['viewerState'], + viewerState => viewerState['userLandmarks'] +) + const flattenFetchedTemplatesIntoParcellationsReducer = (acc, curr) => { const parcelations = (curr['parcellations'] || []).map(p => { return { diff --git a/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.component.ts b/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.component.ts index 7b3f4e555..7c18f10b3 100644 --- a/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.component.ts +++ b/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, HostBinding, Input, OnChanges } from "@angular/core"; +import { ChangeDetectionStrategy, Component, HostBinding, Input, OnChanges, SimpleChanges } from "@angular/core"; @Component({ selector : 'nehuba-2dlandmark-unit', @@ -14,6 +14,8 @@ export class LandmarkUnit implements OnChanges { @Input() public positionY: number = 0 @Input() public positionZ: number = 0 + @Input() public color: [number, number, number] + @Input() public highlight: boolean = false @Input() public flatProjection: boolean = false @@ -22,68 +24,120 @@ export class LandmarkUnit implements OnChanges { @HostBinding('style.transform') public transform: string = `translate(${this.positionX}px, ${this.positionY}px)` - get className() { - return `fas ${this.fasClass}` - } - public styleNode() { - return({ - 'color' : `rgb(${this.highlight ? HOVER_COLOR : NORMAL_COLOR})`, - 'z-index' : this.positionZ >= 0 ? 0 : -2, - }) - } + public className = `fas fa-map-marker` - public ngOnChanges() { - this.transform = `translate(${this.positionX}px, ${this.positionY}px)` + public nodeStyle = { + color: `rgb(${NORMAL_COLOR.join(',')})`, + 'z-index': 0 } - public calcOpacity(): number { - return this.flatProjection ? - this.calcOpacityFlatMode() : - this.positionZ >= 0 ? - 1 : - 0.4 + public shadowStyle = { + background: `radial-gradient( + circle at center, + rgba(${NORMAL_COLOR.join(',')},0.3) 10%, + rgba(${NORMAL_COLOR.join(',')},0.8) 30%, + rgba(0,0,0,0.8))`, + transform : `scale(3,3)`, } - public calcOpacityFlatMode(): number { - return this.highlight ? 1.0 : 10 / (Math.abs(this.positionZ) + 10) + public beamColorInner = { + "transform" : `scale(1.0,1.0)`, + 'border-top-color' : `rgba(${NORMAL_COLOR.join(',')},0.8)`, } - public styleShadow() { - - return ({ - background: `radial-gradient( - circle at center, - rgba(${this.highlight ? HOVER_COLOR + ',0.3' : NORMAL_COLOR + ',0.3'}) 10%, - rgba(${this.highlight ? HOVER_COLOR + ',0.8' : NORMAL_COLOR + ',0.8'}) 30%, - rgba(0,0,0,0.8))`, - transform : `scale(3,3)`, - }) + public beamColorOuter = { + "transform" : `scale(1.5,1.0)`, + 'border-top-color' : 'rgb(0,0,0)', } - get markerTransform() { - return `translate(0px, ${-1 * this.positionZ}px)` + public markerTransform = `translate(0px, 0px)` + public beamTransform = `translate(0px, 0px) scale(1,0)` + public beamDashedColor = { + 'border-left-color': `rgba(${NORMAL_COLOR.join(',')},0.8)` } - - get beamTransform() { - return `translate(0px, ${-1 * this.positionZ / 2}px) scale(1,${Math.abs(this.positionZ)})` - } - - public styleBeamDashedColor() { - return({ - 'border-left-color' : `rgba(${this.highlight ? HOVER_COLOR + ',0.8' : NORMAL_COLOR + ',0.8'})`, - }) - } - - public styleBeamColor(inner: boolean) { - return inner ? ({ - "transform" : `scale(1.0,1.0)`, - 'border-top-color' : `rgba(${this.highlight ? HOVER_COLOR + ',0.8' : NORMAL_COLOR + ',0.8'})`, - }) : ({ - "transform" : `scale(1.5,1.0)`, - 'border-top-color' : 'rgb(0,0,0)', - }) + public opacity: number = 1 + + public ngOnChanges(simpleChanges: SimpleChanges) { + const { + positionX, + positionY, + positionZ, + color, + highlight, + flatProjection, + fasClass, + } = simpleChanges + + if (fasClass) { + this.className = `fas ${fasClass.currentValue}` + } + + if (positionX || positionY) { + this.transform = `translate(${positionX?.currentValue || this.positionX}px, ${positionY?.currentValue || this.positionY}px)` + } + + if (color || positionZ || highlight) { + const zIndex = (positionZ.currentValue || this.positionZ) >= 0 ? 0 : -2 + const nColor = color?.currentValue + || this.color + || (highlight?.currentValue || this.highlight) + ? HOVER_COLOR + : NORMAL_COLOR + + this.nodeStyle = { + color: `rgb(${nColor.join(',')})`, + 'z-index': zIndex + } + + + const shadowStyleBackground = `radial-gradient( + circle at center, + rgba(${nColor.join(',')},0.3) 10%, + rgba(${nColor.join(',')},0.8) 30%, + rgba(0,0,0,0.8))` + + this.shadowStyle = { + ...this.shadowStyle, + background: shadowStyleBackground, + } + } + + if (flatProjection || highlight || positionZ) { + + const flatProjectionVal = typeof flatProjection === 'undefined' + ? this.flatProjection + : flatProjection.currentValue + + const highlightVal = typeof highlight === 'undefined' + ? this.highlight + : flatProjection.currentValue + + const positionZVal = typeof positionZ === 'undefined' + ? this.positionZ + : positionZ.currentValue + + this.opacity = flatProjectionVal + ? highlightVal + ? 1.0 + : 10 / (Math.abs(positionZVal)) + : positionZVal >= 0 + ? 1.0 + : 0.4 + } + + if (positionZ) { + this.markerTransform = `translate(0px, ${-1 * positionZ.currentValue}px)` + this.beamTransform = `translate(0px, ${-1 * positionZ.currentValue / 2}px) scale(1,${Math.abs(positionZ.currentValue)})` + } + + if (highlight) { + console.log('highlight change') + this.beamDashedColor = { + 'border-left-color' : `rgba(${highlight.currentValue ? HOVER_COLOR.join(',') : NORMAL_COLOR.join(',')}, 0.8)`, + } + } } } -const NORMAL_COLOR: string = '201,54,38' -const HOVER_COLOR: string = '250,150,80' +const NORMAL_COLOR: number[] = [201,54,38] +const HOVER_COLOR: number[] = [250,150,80] diff --git a/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.template.html b/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.template.html index 915cb8b31..b28146e8f 100644 --- a/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.template.html +++ b/src/ui/nehubaContainer/landmarkUnit/landmarkUnit.template.html @@ -1,41 +1,40 @@ <div - [ngStyle] = "{opacity:calcOpacity()}" + [ngStyle]="{ opacity: opacity }" landmarkContainer> <div [style.transform] = "markerTransform | safeStyle" - [ngStyle] = "styleNode()" + [ngStyle]="nodeStyle" nodeView #nodeView> - <span - [class] = "className"> + <span [class]="className"> </span> </div> <div - [style.transform] = "beamTransform | safeStyle" - class = "pos-beam"> + [style.transform]="beamTransform | safeStyle" + class="pos-beam"> <div - *ngIf = "positionZ >= 0" - [ngStyle] = "styleBeamColor(false)" - class = "pos-beam-outer"> + *ngIf="positionZ >= 0" + [ngStyle]="beamColorOuter" + class="pos-beam-outer"> </div> <div - *ngIf = "positionZ >= 0" - [ngStyle] = "styleBeamColor(true)" - class = "pos-beam-inner"> + *ngIf="positionZ >= 0" + [ngStyle]="beamColorInner" + class="pos-beam-inner"> </div> <div - *ngIf = "positionZ < 0" - [ngStyle] = "styleBeamDashedColor()" - class = "pos-beam-dashed"> + *ngIf="positionZ < 0" + [ngStyle]="beamDashedColor" + class="pos-beam-dashed"> </div> </div> <div - [ngStyle]="styleShadow()" - class = "pos-shadow"> + [ngStyle]="shadowStyle" + class="pos-shadow"> </div> </div> \ No newline at end of file diff --git a/src/ui/nehubaContainer/nehubaContainer.component.spec.ts b/src/ui/nehubaContainer/nehubaContainer.component.spec.ts index 250171ffe..d1bf6c272 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.spec.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.spec.ts @@ -38,6 +38,7 @@ import { By } from '@angular/platform-browser' import { ARIA_LABELS } from 'common/constants' import { NoopAnimationsModule } from '@angular/platform-browser/animations' import { RegionAccordionTooltipTextPipe } from '../util' +import { hot } from 'jasmine-marbles' const { TOGGLE_SIDE_PANEL, @@ -116,6 +117,7 @@ describe('> nehubaContainer.component.ts', () => { it('> component can be created', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') const el = fixture.debugElement.componentInstance expect(el).toBeTruthy() }) @@ -124,6 +126,7 @@ describe('> nehubaContainer.component.ts', () => { it('> calls importNehubaPr', async () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') const mockStore = TestBed.inject(MockStore) const newState = { @@ -155,6 +158,7 @@ describe('> nehubaContainer.component.ts', () => { it('> should set ngId of nehubaViewer', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') const el = fixture.debugElement.componentInstance as NehubaContainer const mockStore = TestBed.inject(MockStore) const newState = { @@ -221,6 +225,7 @@ describe('> nehubaContainer.component.ts', () => { it('> both should be shut', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() expect( fixture.componentInstance.matDrawerMain.opened @@ -233,6 +238,7 @@ describe('> nehubaContainer.component.ts', () => { it('> opening via tab should result in only top drawer open', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const toggleBtn = fixture.debugElement.query( By.css(`[aria-label="${TOGGLE_SIDE_PANEL}"]`) ) toggleBtn.triggerEventHandler('click', null) @@ -249,6 +255,7 @@ describe('> nehubaContainer.component.ts', () => { it('> on opening top drawer, explore features should not be present', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const toggleBtn = fixture.debugElement.query( By.css(`[aria-label="${TOGGLE_SIDE_PANEL}"]`) ) toggleBtn.triggerEventHandler('click', null) @@ -259,6 +266,7 @@ describe('> nehubaContainer.component.ts', () => { it('> collapse btn should not be visible', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const toggleBtn = fixture.debugElement.query( By.css(`[aria-label="${TOGGLE_SIDE_PANEL}"]`) ) toggleBtn.triggerEventHandler('click', null) @@ -292,6 +300,7 @@ describe('> nehubaContainer.component.ts', () => { }) it('> both should be open', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() expect( fixture.componentInstance.matDrawerMain.opened @@ -310,6 +319,7 @@ describe('> nehubaContainer.component.ts', () => { it('> closing main drawer via tag should close both', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const toggleBtn = fixture.debugElement.query( By.css(`[aria-label="${TOGGLE_SIDE_PANEL}"]`) ) toggleBtn.triggerEventHandler('click', null) @@ -335,6 +345,7 @@ describe('> nehubaContainer.component.ts', () => { it('> collapse btn should be visible', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const collapseRegionFeatureBtn = fixture.debugElement.query( By.css(`mat-drawer[data-mat-drawer-open="true"] [aria-label="${COLLAPSE}"]`) ) expect(collapseRegionFeatureBtn).not.toBeNull() @@ -342,6 +353,7 @@ describe('> nehubaContainer.component.ts', () => { it('> clicking on collapse btn should minimize 1 drawer', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const collapseRegionFeatureBtn = fixture.debugElement.query( By.css(`mat-drawer[data-mat-drawer-open="true"] [aria-label="${COLLAPSE}"]`) ) collapseRegionFeatureBtn.triggerEventHandler('click', null) @@ -367,6 +379,7 @@ describe('> nehubaContainer.component.ts', () => { it('> on minimize drawer, clicking expand btn should expand everything', () => { const fixture = TestBed.createComponent(NehubaContainer) + fixture.componentInstance.currentOnHoverObs$ = hot('') fixture.detectChanges() const collapseRegionFeatureBtn = fixture.debugElement.query( By.css(`mat-drawer[data-mat-drawer-open="true"] [aria-label="${COLLAPSE}"]`) ) collapseRegionFeatureBtn.triggerEventHandler('click', null) diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index ac456e0db..5311900fa 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -15,7 +15,7 @@ import { compareLandmarksChanged } from "src/util/constants"; import { PureContantService } from "src/util"; import { ARIA_LABELS, IDS } from 'common/constants' import { ngViewerActionSetPerspOctantRemoval, PANELS, ngViewerActionToggleMax, ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer } from "src/services/state/ngViewerState.store.helper"; -import { viewerStateSelectRegionWithIdDeprecated, viewerStateAddUserLandmarks, viewreStateRemoveUserLandmarks } from 'src/services/state/viewerState.store.helper' +import { viewerStateSelectRegionWithIdDeprecated, viewerStateAddUserLandmarks, viewreStateRemoveUserLandmarks, viewerStateCustomLandmarkSelector, viewerStateSelectedParcellationSelector, viewerStateSelectedTemplateSelector } from 'src/services/state/viewerState.store.helper' import { SwitchDirective } from "src/util/directives/switch.directive"; import { viewerStateDblClickOnViewer, @@ -25,6 +25,7 @@ import { getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThre import { NehubaViewerContainerDirective } from "./nehubaViewerInterface/nehubaViewerInterface.directive"; import { ITunableProp } from "./mobileOverlay/mobileOverlay.component"; import {ConnectivityBrowserComponent} from "src/ui/connectivityBrowser/connectivityBrowser.component"; +import { viewerStateMouseOverCustomLandmark } from "src/services/state/viewerState/actions"; const { MESH_LOADING_STATUS } = IDS @@ -168,9 +169,20 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public perspectiveViewLoading$: Observable<string|null> public showPerpsectiveScreen$: Observable<string> - public templateSelected$: Observable<any> - private newViewer$: Observable<any> - private selectedParcellation$: Observable<any> + public templateSelected$: Observable<any> = this.store.pipe( + select(viewerStateSelectedTemplateSelector), + distinctUntilChanged(isSame), + ) + + private newViewer$: Observable<any> = this.templateSelected$.pipe( + filter(v => !!v), + ) + + private selectedParcellation$: Observable<any> = this.store.pipe( + select(viewerStateSelectedParcellationSelector), + distinctUntilChanged(), + filter(v => !!v) + ) public selectedRegions: any[] = [] public selectedRegions$: Observable<any[]> = this.store.pipe( select('viewerState'), @@ -180,6 +192,15 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public selectedLandmarks$: Observable<any[]> public selectedPtLandmarks$: Observable<any[]> + public customLandmarks$: Observable<any> = this.store.pipe( + select(viewerStateCustomLandmarkSelector), + map(lms => lms.map(lm => ({ + ...lm, + geometry: { + position: lm.position + } + }))) + ) private hideSegmentations$: Observable<boolean> private fetchedSpatialDatasets$: Observable<ILandmark[]> @@ -191,7 +212,7 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { private currentOnHover: {segments: any, landmark: any, userLandmark: any} @Input() - private currentOnHoverObs$: Observable<{segments: any, landmark: any, userLandmark: any}> + currentOnHoverObs$: Observable<{segments: any, landmark: any, userLandmark: any}> public iavAdditionalLayers$ = new Subject<any[]>() @@ -292,23 +313,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { )), ) - this.templateSelected$ = this.store.pipe( - select('viewerState'), - select('templateSelected'), - distinctUntilChanged(isSame), - ) - - this.newViewer$ = this.templateSelected$.pipe( - filter(v => !!v), - ) - - this.selectedParcellation$ = this.store.pipe( - select('viewerState'), - select('parcellationSelected'), - distinctUntilChanged(), - filter(v => !!v) - ) - this.selectedLandmarks$ = this.store.pipe( select('viewerState'), select('landmarksSelected'), @@ -328,8 +332,7 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { ) this.userLandmarks$ = this.store.pipe( - select('viewerState'), - select('userLandmarks'), + select(viewerStateCustomLandmarkSelector), distinctUntilChanged(), ) @@ -429,7 +432,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { } public ngOnInit() { - this.hoveredPanelIndices$ = fromEvent(this.elementRef.nativeElement, 'mouseover').pipe( switchMap((ev: MouseEvent) => merge( of(this.findPanelIndex(ev.target as HTMLElement)), @@ -819,11 +821,11 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public selectedProp = null public returnTruePos(quadrant: number, data: any) { - const pos = quadrant > 2 ? - [0, 0, 0] : - this.nanometersToOffsetPixelsFn && this.nanometersToOffsetPixelsFn[quadrant] ? - this.nanometersToOffsetPixelsFn[quadrant](data.geometry.position.map(n => n * 1e6)) : - [0, 0, 0] + const pos = quadrant > 2 + ? [0, 0, 0] + : this.nanometersToOffsetPixelsFn && this.nanometersToOffsetPixelsFn[quadrant] + ? this.nanometersToOffsetPixelsFn[quadrant](data.geometry.position.map(n => n * 1e6)) + : [0, 0, 0] return pos } @@ -837,6 +839,22 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { return this.returnTruePos(quadrant, data)[2] } + public handleMouseEnterCustomLandmark(lm) { + this.store.dispatch( + viewerStateMouseOverCustomLandmark({ + payload: { userLandmark: lm } + }) + ) + } + + public handleMouseLeaveCustomLandmark(lm) { + this.store.dispatch( + viewerStateMouseOverCustomLandmark({ + payload: { userLandmark: null } + }) + ) + } + // handles mouse enter/leave landmarks in 2D public handleMouseEnterLandmark(spatialData: any) { spatialData.highlight = true @@ -1024,9 +1042,13 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { ), ) , mouseOverNehuba : this.onHoverSegment$.pipe( - tap(() => this.log.warn('mouseOverNehuba observable is becoming deprecated. use mouseOverNehubaLayers instead.')), + tap(() => console.warn('mouseOverNehuba observable is becoming deprecated. use mouseOverNehubaLayers instead.')), ), mouseOverNehubaLayers: this.onHoverSegments$, + mouseOverNehubaUI: this.currentOnHoverObs$.pipe( + map(({ landmark, segments, userLandmark: customLandmark }) => ({ segments, landmark, customLandmark })), + shareReplay(1), + ), getNgHash : this.nehubaViewer.getNgHash, } } diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index a9296fa60..b23d4cf6d 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -471,6 +471,18 @@ <!-- nb this slice view is not suitable for perspective view! --> <layout-floating-container *ngIf="panelIndex < 3" landmarkContainer> + <!-- customLandmarks --> + <nehuba-2dlandmark-unit *ngFor="let lm of (customLandmarks$ | async)" + (mouseenter)="handleMouseEnterCustomLandmark(lm)" + (mouseleave)="handleMouseLeaveCustomLandmark(lm)" + fasClass="fa-chevron-down" + [color]="lm.color || [255, 255, 255]" + [positionX]="getPositionX(panelIndex, lm)" + [positionY]="getPositionY(panelIndex, lm)" + [positionZ]="getPositionZ(panelIndex, lm)"> + + </nehuba-2dlandmark-unit> + <!-- only show landmarks in slice views --> <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" diff --git a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts index aa70b00af..7c2dbe4bb 100644 --- a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts +++ b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts @@ -99,7 +99,7 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { } }> = new EventEmitter() @Output() public mouseoverLandmarkEmitter: EventEmitter<number | null> = new EventEmitter() - @Output() public mouseoverUserlandmarkEmitter: EventEmitter<number | null> = new EventEmitter() + @Output() public mouseoverUserlandmarkEmitter: EventEmitter<string> = new EventEmitter() @Output() public regionSelectionEmitter: EventEmitter<{segment: number, layer: {name?: string, url?: string}}> = new EventEmitter() @Output() public errorEmitter: EventEmitter<any> = new EventEmitter() @@ -253,9 +253,9 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { if (!url) { return } const _ = {} _[NG_USER_LANDMARK_LAYER_NAME] = { - type : 'mesh', - source : `vtk://${url}`, - shader : FRAGMENT_MAIN_WHITE, + type: 'mesh', + source: `vtk://${url}`, + shader: this.userLandmarkShader, } this.loadLayer(_) }), @@ -500,16 +500,37 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { ) } + private userLandmarkShader: string = FRAGMENT_MAIN_WHITE + // TODO single landmark for user landmark public updateUserLandmarks(landmarks: any[]) { if (!this.nehubaViewer) { return } + this.workerService.worker.postMessage({ type : 'GET_USERLANDMARKS_VTK', scale: Math.min(...this.dim.map(v => v * NG_LANDMARK_CONSTANT)), landmarks : landmarks.map(lm => lm.position.map(coord => coord * 1e6)), }) + + const parseLmColor = lm => { + if (!lm) return null + const { color } = lm + if (!color) return null + if (!Array.isArray(color)) return null + if (color.length !== 3) return null + const parseNum = num => (num >= 0 && num <= 255 ? num / 255 : 1).toFixed(3) + return `emitRGB(vec3(${color.map(parseNum).join(',')}));` + } + + const appendConditional = (frag, idx) => frag && `if (label > ${idx - 0.01} && label < ${idx + 0.01}) { ${frag} }` + + if (landmarks.some(parseLmColor)) { + this.userLandmarkShader = `void main(){ ${landmarks.map(parseLmColor).map(appendConditional).filter(v => !!v).join('else ')} else {${FRAGMENT_EMIT_RED}} }` + } else { + this.userLandmarkShader = FRAGMENT_MAIN_WHITE + } } public removeSpatialSearch3DLandmarks() { diff --git a/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts index 7965bedee..613bc6621 100644 --- a/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts +++ b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts @@ -11,6 +11,7 @@ import { timedValues } from "src/util/generator"; import { MOUSE_OVER_SEGMENTS, MOUSE_OVER_LANDMARK } from "src/services/state/uiState.store"; import { takeOnePipe } from "../nehubaContainer.component"; import { ngViewerActionNehubaReady } from "src/services/state/ngViewerState/actions"; +import { viewerStateMouseOverCustomLandmarkInPerspectiveView } from "src/services/state/viewerState/actions"; const defaultNehubaConfig = { "configName": "", @@ -461,12 +462,11 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ this.nehubaViewerInstance.mouseoverUserlandmarkEmitter.pipe( throttleTime(160), ).subscribe(label => { - this.store$.dispatch({ - type: VIEWERSTATE_ACTION_TYPES.MOUSEOVER_USER_LANDMARK_LABEL, - payload: { - label, - }, - }) + this.store$.dispatch( + viewerStateMouseOverCustomLandmarkInPerspectiveView({ + payload: { label } + }) + ) }), this.nehubaViewerInstance.nehubaReady.pipe( -- GitLab