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