diff --git a/docs/releases/v2.2.0.md b/docs/releases/v2.2.0.md
index 3db6c411eb5a39d176a35a22109accf96b58e349..0ac4117cc17eaecba0a735745ed120ebd492a955 100644
--- a/docs/releases/v2.2.0.md
+++ b/docs/releases/v2.2.0.md
@@ -2,4 +2,5 @@
 
 ## New features:
 
-- [sane url sharing](../usage/sharing.md)
\ No newline at end of file
+- [sane url sharing](../usage/sharing.md)
+- allow `pinch rotate` motion to be used for oblique rotation on touch enabled devices
diff --git a/docs/usage/navigating.md b/docs/usage/navigating.md
index ff6d085a04c6f6896df91b496000ac54002dcb9b..4f812f362d9a02d27d464d1fe6cc1f118f0ee2b7 100644
--- a/docs/usage/navigating.md
+++ b/docs/usage/navigating.md
@@ -7,7 +7,7 @@ The interactive atlas viewer can be accessed from either a desktop or an Android
 | | Desktop | Mobile |
 | --- | --- | --- |
 | Translating / Panning | `click drag` on any _slice views_ | `touchmove` on any _slice views_ |
-| Oblique rotation | `shift` + `click drag` on any _slice views_ | hold `🌏` + `drag up/down` to switch rotation mode<br> hold 🌏 + `drag left/right` to rotate |
+| Oblique rotation | `shift` + `click drag` on any _slice views_ | `pinch rotate` |
 | Zooming (_slice view_, _3d view_) | `mouse wheel` | `pinch zoom` |
 | Next slice (_slice view_) | `ctrl` + `mouse wheel` | |
 | Next 10 slice (_slice view_) | `ctrl` + `shift` + `mouse wheel` | |
diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts
index c1d8dbee4211896d56cbee45983a5fb0f1e04132..ebe8ffe713ed59ca1d2f58881b78301818dc61a8 100644
--- a/src/atlasViewer/atlasViewer.component.ts
+++ b/src/atlasViewer/atlasViewer.component.ts
@@ -291,6 +291,7 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit {
 
     /**
      * TODO deprecated
+     * TODO what the??? is this?
      */
     this.subscriptions.push(
       this.ngLayerNames$.pipe(
diff --git a/src/atlasViewer/atlasViewer.template.html b/src/atlasViewer/atlasViewer.template.html
index 9bc7caaae3e33df0a16685b5d4cb6edb4ab4ffe8..9c276d266d39ecfc6559250f1b281e08181be643 100644
--- a/src/atlasViewer/atlasViewer.template.html
+++ b/src/atlasViewer/atlasViewer.template.html
@@ -42,8 +42,15 @@
 <ng-template #viewerBody>
   <div class="atlas-container" (drag-drop)="localFileService.handleFileDrop($event)">
     <ui-nehuba-container
-      #uiNehubaContainer
-      iav-mouse-hover 
+      iav-viewer-touch-interface
+      [iav-viewer-touch-interface-v-panels]="uiNehubaContainer.viewPanels"
+      [iav-viewer-touch-interface-vp-to-data]="uiNehubaContainer.nehubaViewer?.viewportToDatas"
+      [iav-viewer-touch-interface-ngviewer]="uiNehubaContainer.nehubaViewer?.nehubaViewer?.ngviewer"
+      [iav-viewer-touch-interface-nehuba-config]="uiNehubaContainer.selectedTemplate?.nehubaConfig"
+      #iavNehubaViewerTouch="iavNehubaViewerTouch"
+
+      #uiNehubaContainer="uiNehubaContainer"
+      iav-mouse-hover
       #iavMouseHoverEl="iavMouseHover"
       [currentOnHoverObs$]="iavMouseHoverEl.currentOnHoverObs$"
       [currentOnHover]="iavMouseHoverEl.currentOnHoverObs$ | async"
@@ -89,6 +96,7 @@
 
           </button>
 
+          <!-- visible status card when mat drawer is closed -->
           <mat-card
             *ngIf="!sideNavDrawer.opened"
             (click)="toggleSideNavMenu(false)"
diff --git a/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.component.ts b/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.component.ts
index df18cdd160fa371af8df8a9d4ac229898b199358..4948b3a38be2f43149491447ec4c79a33237ae36 100644
--- a/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.component.ts
+++ b/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.component.ts
@@ -1,7 +1,20 @@
-import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from "@angular/core";
-import { combineLatest, concat, fromEvent, merge, Observable, of, Subject } from "rxjs";
-import { filter, map, scan, switchMap, takeUntil } from "rxjs/operators";
+import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, Inject, TemplateRef, ViewChildren, QueryList } from "@angular/core";
+import { combineLatest, concat, fromEvent, merge, Observable, of, Subject, BehaviorSubject } from "rxjs";
+import { filter, map, scan, switchMap, takeUntil, startWith, pairwise, tap, shareReplay, distinctUntilChanged, switchMapTo, reduce } from "rxjs/operators";
 import { clamp } from "src/util/generator";
+import { DOCUMENT } from "@angular/common";
+
+const TOUCHMOVE_THRESHOLD = 50
+const SUBMENU_IXOBS_CONFIG = {
+
+}
+
+export interface ITunableProp{
+  name: string
+  displayName?: string
+  values: string[]
+  selected?: any
+}
 
 @Component({
   selector : 'mobile-overlay',
@@ -29,15 +42,31 @@ div:not(.active) > span:before
 })
 
 export class MobileOverlay implements OnInit, OnDestroy {
-  @Input() public tunableProperties: string [] = []
-  @Output() public deltaValue: EventEmitter<{delta: number, selectedProp: string}> = new EventEmitter()
+
+  @Input('iav-mobile-overlay-guide-tmpl')
+  public guideTmpl: TemplateRef<any>
+
+  @Input('iav-mobile-overlay-hide-ctrl-btn')
+  public hideCtrlBtn: boolean = false
+
+  @Input('iav-mobile-overlay-ctrl-btn-pos')
+  public ctrlBtnPosition: { left: string, top: string } = { left: '50%', top: '50%' }
+  
+  @Input() public tunableProperties: ITunableProp[] = []
+  
+  @Output() public tunablePropertySelected: EventEmitter<ITunableProp> = new EventEmitter()
+  @Output() public deltaValue: EventEmitter<{delta: number, selectedProp: ITunableProp}> = new EventEmitter()
+  @Output() public valueSelected: EventEmitter<{ value: string, selectedProp: ITunableProp }> = new EventEmitter()
+
   @ViewChild('initiator', {read: ElementRef, static: true}) public initiator: ElementRef
   @ViewChild('mobileMenuContainer', {read: ElementRef, static: true}) public menuContainer: ElementRef
   @ViewChild('intersector', {read: ElementRef, static: true}) public intersector: ElementRef
+  @ViewChild('subMenuObserver', {read: ElementRef, static: false}) public subMenuIx: ElementRef
+  @ViewChild('setValueContainer', { read: ElementRef, static: false }) public setValueContainer?: ElementRef
 
   private _onDestroySubject: Subject<boolean> = new Subject()
 
-  private _focusedProperties: string
+  private _focusedProperties: ITunableProp
   get focusedProperty() {
     return this._focusedProperties
       ? this._focusedProperties
@@ -49,20 +78,37 @@ export class MobileOverlay implements OnInit, OnDestroy {
       : 0
   }
 
+  private initiatorSingleTouchStart$: Observable<any>
+
   public showScreen$: Observable<boolean>
   public showProperties$: Observable<boolean>
   public showDelta$: Observable<boolean>
   public showInitiator$: Observable<boolean>
   private _drag$: Observable<any>
+  private _thresholdDrag$: Observable<any>
   private intersectionObserver: IntersectionObserver
+  private subMenuIxObs: IntersectionObserver
+
+  constructor(
+    @Inject(DOCUMENT) private document: Document
+  ){
+
+  }
 
   public ngOnDestroy() {
     this._onDestroySubject.next(true)
     this._onDestroySubject.complete()
+    this.intersectionObserver && this.intersectionObserver.disconnect()
+    this.subMenuIxObs && this.subMenuIxObs.disconnect()
   }
 
   public ngOnInit() {
 
+    this.initiatorSingleTouchStart$ = fromEvent(this.initiator.nativeElement, 'touchstart').pipe(
+      filter((ev: TouchEvent) => ev.touches.length === 1),
+      shareReplay(1)
+    )
+
     const itemCount = this.tunableProperties.length
 
     const config = {
@@ -79,52 +125,55 @@ export class MobileOverlay implements OnInit, OnDestroy {
 
     this.intersectionObserver.observe(this.menuContainer.nativeElement)
 
-    const scanDragScanAccumulator: (acc: TouchEvent[], item: TouchEvent, idx: number) => TouchEvent[] = (acc, curr) => acc.length < 2
-      ? acc.concat(curr)
-      : acc.slice(1).concat(curr)
-
-    this._drag$ = fromEvent(this.initiator.nativeElement, 'touchmove').pipe(
-      takeUntil(fromEvent(this.initiator.nativeElement, 'touchend').pipe(
+    this._drag$ = fromEvent(this.document, 'touchmove').pipe(
+      filter((ev: TouchEvent) => ev.touches.length === 1),
+      takeUntil(fromEvent(this.document, 'touchend').pipe(
         filter((ev: TouchEvent) => ev.touches.length === 0),
       )),
-      map((ev: TouchEvent) => (ev.preventDefault(), ev.stopPropagation(), ev)),
-      filter((ev: TouchEvent) => ev.touches.length === 1),
-      scan(scanDragScanAccumulator, []),
-      filter(ev => ev.length === 2),
     )
 
-    this.showProperties$ = concat(
-      of(false),
-      fromEvent(this.initiator.nativeElement, 'touchstart').pipe(
-        switchMap(() => concat(
-          this._drag$.pipe(
-            map(double => ({
-              deltaX : double[1].touches[0].screenX - double[0].touches[0].screenX,
-              deltaY : double[1].touches[0].screenY - double[0].touches[0].screenY,
-            })),
-            scan((acc, _curr) => acc),
-            map(v => v.deltaY ** 2 > v.deltaX ** 2),
-          ),
-          of(false),
-        )),
-      ),
+    this._thresholdDrag$ = this._drag$.pipe(
+      distinctUntilChanged((o, n) => {
+        const deltaX = o.touches[0].screenX - n.touches[0].screenX
+        const deltaY = o.touches[0].screenY - n.touches[0].screenY
+        return (deltaX ** 2 + deltaY ** 2) < TOUCHMOVE_THRESHOLD
+      }),
     )
 
-    this.showDelta$ = concat(
-      of(false),
-      fromEvent(this.initiator.nativeElement, 'touchstart').pipe(
-        switchMap(() => concat(
-          this._drag$.pipe(
-            map(double => ({
-              deltaX : double[1].touches[0].screenX - double[0].touches[0].screenX,
-              deltaY : double[1].touches[0].screenY - double[0].touches[0].screenY,
-            })),
-            scan((acc, _curr) => acc),
-            map(v => v.deltaX ** 2 > v.deltaY ** 2),
-          ),
-          of(false),
-        )),
-      ),
+    this.showProperties$ = this.initiatorSingleTouchStart$.pipe(
+      switchMap(() => concat(
+        this._thresholdDrag$.pipe(
+          pairwise(),
+          map(double => ({
+            deltaX : double[1].touches[0].screenX - double[0].touches[0].screenX,
+            deltaY : double[1].touches[0].screenY - double[0].touches[0].screenY,
+          })),
+          map(v => v.deltaY ** 2 > v.deltaX ** 2),
+          scan((acc, _curr) => acc),
+        ),
+        of(false)
+      )),
+      startWith(false),
+      distinctUntilChanged(),
+      shareReplay(1)
+    )
+
+    this.showDelta$ = this.initiatorSingleTouchStart$.pipe(
+      switchMap(() => concat(
+        this._thresholdDrag$.pipe(
+          pairwise(),
+          map(double => ({
+            deltaX : double[1].touches[0].screenX - double[0].touches[0].screenX,
+            deltaY : double[1].touches[0].screenY - double[0].touches[0].screenY,
+          })),
+          scan((acc, _curr) => acc),
+          map(v => v.deltaX ** 2 > v.deltaY ** 2),
+        ),
+        of(false),
+      )),
+      startWith(false),
+      distinctUntilChanged(),
+      shareReplay(1)
     )
 
     this.showInitiator$ = combineLatest(
@@ -144,50 +193,44 @@ export class MobileOverlay implements OnInit, OnDestroy {
       map(([ev, showInitiator]: [TouchEvent, boolean]) => showInitiator && ev.touches.length === 1),
     )
 
-    fromEvent(this.initiator.nativeElement, 'touchstart').pipe(
-      switchMap(() => this._drag$.pipe(
-        map(double => ({
-          deltaX : double[1].touches[0].screenX - double[0].touches[0].screenX,
-          deltaY : double[1].touches[0].screenY - double[0].touches[0].screenY,
-        })),
-        scan((acc, curr: any) => ({
-          pass: acc.pass === null
-            ? curr.deltaX ** 2 > curr.deltaY ** 2
-            : acc.pass,
-          delta: curr.deltaX,
-        }), {
-          pass: null,
-          delta : null,
-        }),
-        filter(ev => ev.pass),
-        map(ev => ev.delta),
+    this.showDelta$.pipe(
+      filter(flag => flag),
+      switchMapTo(this._thresholdDrag$.pipe(
+        pairwise(),
+        map(double => double[1].touches[0].screenX - double[0].touches[0].screenX)
       )),
-      takeUntil(this._onDestroySubject),
-    ).subscribe(ev => this.deltaValue.emit({
-      delta : ev,
-      selectedProp : this.focusedProperty,
-    }))
+      takeUntil(this._onDestroySubject)
+    ).subscribe(ev => {
+      this.deltaValue.emit({
+        delta: ev,
+        selectedProp: this.focusedProperty
+      })
+    })
 
-    const offsetObs$ = fromEvent(this.initiator.nativeElement, 'touchstart').pipe(
-      switchMap(() => concat(
-        this._drag$.pipe(
-          scan((acc, curr) => [acc[0], curr[1]]),
-          map(double => ({
-            deltaX : double[1].touches[0].screenX - double[0].touches[0].screenX,
-            deltaY : double[1].touches[0].screenY - double[0].touches[0].screenY,
-          })),
-        ),
-      )),
+    const offsetObs$ = this.initiatorSingleTouchStart$.pipe(
+      switchMap(() => this._drag$)
     )
+
     combineLatest(
       this.showProperties$,
       offsetObs$,
     ).pipe(
       filter(v => v[0]),
       map(v => v[1]),
+      scan((acc, curr) => {
+        const { startY } = acc
+        const { screenY } = curr.touches[0]
+        return {
+          startY: startY || screenY,
+          totalDeltaY: screenY - (startY || 0)
+        }
+      }, {
+        startY: null,
+        totalDeltaY: 0
+      }),
       takeUntil(this._onDestroySubject),
     ).subscribe(v => {
-      const deltaY = v.deltaY
+      const deltaY = v.totalDeltaY
       const cellHeight = this.menuContainer && this.tunableProperties && this.tunableProperties.length > 0 && this.menuContainer.nativeElement.offsetHeight / this.tunableProperties.length
       const adjHeight = - this.focusedIndex * cellHeight - cellHeight * 0.5
 
@@ -206,8 +249,82 @@ export class MobileOverlay implements OnInit, OnDestroy {
       }
     })
 
+    this.showDelta$.pipe(
+      tap(flag => {
+        this.highlightedSubmenu$.next(this.focusedProperty.values[0])
+        if (!flag && !!this.subMenuIxObs) {
+          this.subMenuIxObs.disconnect()
+          this.subMenuIxObs = null
+        }
+      }),
+      filter(v => !!v),
+      // when options show again, options may have changed, so need to recalculate
+      tap(() => {
+        this.setValueContainerClampStart = null
+        this.setValueContainerWidth = null
+        this.setValueContainerClampEnd = null
+        this.setValueContainerOffset = null
+      }), 
+      switchMapTo(this._drag$.pipe(
+        scan((acc, curr) => {
+          const { startX } = acc
+          const { screenX } = curr.touches[0]
+          return {
+            startX: startX || screenX,
+            totalDeltaX: screenX - (startX  || 0)
+          }
+        }, {
+          startX: null,
+          totalDeltaX: 0
+        })
+      )),
+      takeUntil(this._onDestroySubject)
+    ).subscribe(({ totalDeltaX }) => {
+      if (!this.subMenuIxObs && this.subMenuIx) {
+        this.subMenuIxObs = new IntersectionObserver(ixs => {
+          const ix = ixs.find(({ intersectionRatio }) => intersectionRatio < 0.7)
+          if (!ix) return console.log(ixs)
+          const value = ix.target.getAttribute('data-submenu-value')
+          this.highlightedSubmenu$.next(value)
+        }, {
+          root: this.subMenuIx.nativeElement,
+          threshold: [ 0.1, 0.3, 0.5, 0.7, 0.9 ]
+        })
+
+        for (const btn of this.setValueContainer.nativeElement.children) {
+          this.subMenuIxObs.observe(btn)
+        }
+      }
+      if (!this.setValueContainerWidth) {
+        if (!this.setValueContainer) return
+        if (this.setValueContainer.nativeElement.children.length === 0) return
+        const { children, clientWidth } = this.setValueContainer.nativeElement
+
+        this.setValueContainerWidth = clientWidth
+        const firstChildWidth = children[0].clientWidth
+        const lastChildWidth = children[children.length - 1].clientWidth
+
+        this.setValueContainerOffset = firstChildWidth / -2
+        this.setValueContainerClampStart = firstChildWidth / -2
+        this.setValueContainerClampEnd = lastChildWidth / 2 - clientWidth
+      }
+      const actualDeltaX = clamp(totalDeltaX + this.setValueContainerOffset, this.setValueContainerClampStart, this.setValueContainerClampEnd)
+      this.subMenuTransform = `translate(${actualDeltaX}px , 0px)`
+    })
+
+    this.showDelta$.pipe(
+      takeUntil(this._onDestroySubject),
+      filter(v => !v)
+    ).subscribe(() => this.valueSelected.emit({ selectedProp: this.focusedProperty, value: this.highlightedSubmenu$.value }))
   }
 
+  public highlightedSubmenu$: BehaviorSubject<string> = new BehaviorSubject(null)
+
+  private setValueContainerOffset = null
+  private setValueContainerClampEnd = null
+  private setValueContainerClampStart = null
+  private setValueContainerWidth = null
+  public subMenuTransform = `translate(0px, 0px)`
   public menuTransform = `translate(0px, 0px)`
 
   public focusItemIndex: number = 0
diff --git a/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.style.css b/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.style.css
index a37da10afb7571d0f5a2010aff618cb2b4f223bf..8d548523eaefd725bb5baa490dfc38e3e7abdc2e 100644
--- a/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.style.css
+++ b/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.style.css
@@ -17,11 +17,13 @@
   top: 0;
   left: 0;
   position: absolute;
+  z-index: 99999;
 
   color : black;
   background-color: rgba(255, 255, 255, 0.5);
-}
 
+  transition: all 200ms linear;
+}
 
 :host-context([darktheme="true"]) [screen]
 {
@@ -65,7 +67,44 @@
   background-color: rgba(128, 128, 200, 0.2);
 }
 
-[guide]
+.base-container
+{
+  position: relative;
+  width: 100%;
+  left: 0;
+  top: 50%;
+  height: 0;
+  z-index: 9999;
+}
+
+div[delta]
+{
+  white-space: nowrap
+}
+
+.popup
 {
-  z-index:9999;
+  transition: all 120ms linear;
+  transform-origin: 50% 100%;
 }
+
+.scale-y-0
+{
+  transform: scale(0.5, 0);
+  opacity: 0;
+}
+
+.subMenu
+{
+  bottom: 15px;
+}
+
+.w-50
+{
+  width: 50%;
+}
+
+.sliver
+{
+  width: 1px;
+}
\ No newline at end of file
diff --git a/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.template.html b/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.template.html
index 9770b46d5a6473606f49bd94c70893e50db71c61..791edddf1b9dda7ae39043f7746afa8703f87bef 100644
--- a/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.template.html
+++ b/src/ui/nehubaContainer/mobileOverlay/mobileOverlay.template.html
@@ -7,20 +7,76 @@
         class = "btn btn-default theme-controlled property scrollFocus">
         <!-- scrollFocus class -->
         <span>
-          {{ p }}
+          {{ p.displayName || p.name }}
         </span>
       </div>
     </div>
   </div>
 </div>
 
-<ng-content *ngIf="showDelta$ | async" select="[delta]" guide>
-</ng-content>
+<!-- container class -->
+<div class="d-flex flex-column-reverse flex-nowrap align-items-center base-container position-relative">
 
-<ng-content *ngIf="showScreen$ | async" select="[guide]" guide>
-</ng-content>
+  <!-- ctrl nub -->
+  <div class="h-0 d-inline-flex align-items-center" [hidden]="!(showInitiator$ | async)" #initiator>
+    <div (contextmenu)="$event.stopPropagation(); $event.preventDefault();"
+      [ngStyle]="ctrlBtnPosition"
+      class="pe-all"
+      initiator>
+      <button mat-mini-fab color="primary">
+        <i class="fas fa-globe"></i>
+      </button>
+    </div>
+  </div>
+
+  <!-- guide text -->
+  <mat-card [ngClass]="{ 'scale-y-0': !(showScreen$ | async)  }"
+    class="mb-4 popup muted position-absolute subMenu">
+    <mat-card-content>
+      <ng-container *ngTemplateOutlet="guideTmpl">
+      </ng-container>
+    </mat-card-content>
+  </mat-card>
+
+  <!-- mobile set value -->
+  <div *ngIf="showDelta$ | async" class="position-absolute h-0 w-100 d-flex flex-row justify-content-end align-items-end">
+
+    <!-- intersection observer -->
+    <div class="w-50 d-flex flex-row flex-nowrap" #subMenuObserver>
+    
+      <!-- value selection container -->
+      <div class="position-relative mb-4" [style.transform]="subMenuTransform" #setValueContainer>
+        <!-- value selections -->
+        <ng-container *ngFor="let value of focusedProperty.values">
+          <!-- selected button -->
+          <ng-template
+            [ngIf]="focusedProperty.selected === value"
+            [ngIfElse]="notSelectedTmpl">
+            <button
+              [attr.data-submenu-value]="value"
+              mat-flat-button
+              [ngClass]="{ 'muted': (highlightedSubmenu$ | async) !== value }"
+              color="primary"
+              class="mr-2">
+              {{ value }}
+            </button>
+          </ng-template>
+
+          <!-- not selected button -->
+          <ng-template #notSelectedTmpl>
+            <button
+              [attr.data-submenu-value]="value"
+              mat-flat-button
+              [ngClass]="{ 'muted': (highlightedSubmenu$ | async) !== value }"
+              color="default"
+              class="mr-2">
+              {{ value }}
+            </button>
+          </ng-template>
 
-<div [hidden]="!(showInitiator$ | async)" #initiator>
-  <ng-content select="[initiator]">
-  </ng-content>
-</div>
\ No newline at end of file
+        </ng-container>
+      </div>
+    </div>
+  </div>
+
+</div>
diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts
index 0ddb3de683a2764633c34c412e2262322a7968cf..1e3a5df8048b4fd229045ded55a012fb3e523685 100644
--- a/src/ui/nehubaContainer/nehubaContainer.component.ts
+++ b/src/ui/nehubaContainer/nehubaContainer.component.ts
@@ -16,7 +16,6 @@ import {
   switchMap,
   switchMapTo,
   take,
-  takeUntil,
   tap,
   withLatestFrom
 } from "rxjs/operators";
@@ -25,11 +24,12 @@ import { FOUR_PANEL, H_ONE_THREE, NEHUBA_READY, NG_VIEWER_ACTION_TYPES, SINGLE_P
 import { SELECT_REGIONS_WITH_ID, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store";
 import { ADD_NG_LAYER, generateLabelIndexId, getMultiNgIdsRegionsLabelIndexMap, getNgIds, ILandmark, IOtherLandmarkGeometry, IPlaneLandmarkGeometry, IPointLandmarkGeometry, isDefined, MOUSE_OVER_LANDMARK, NgViewerStateInterface, REMOVE_NG_LAYER, safeFilter, ViewerStateInterface } from "src/services/stateStore.service";
 import { getExportNehuba, isSame } from "src/util/fn";
-import { AtlasViewerAPIServices, IUserLandmark } from "../../atlasViewer/atlasViewer.apiService.service";
-import { AtlasViewerConstantsServices } from "../../atlasViewer/atlasViewer.constantService.service";
-import { computeDistance, NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component";
+import { AtlasViewerAPIServices, IUserLandmark } from "src/atlasViewer/atlasViewer.apiService.service";
+import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service";
+import { NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component";
 import { getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, calculateSliceZoomFactor } from "./util";
 import { NehubaViewerContainerDirective } from "./nehubaViewerInterface/nehubaViewerInterface.directive";
+import { ITunableProp } from "./mobileOverlay/mobileOverlay.component";
 
 const isFirstRow = (cell: HTMLElement) => {
   const { parentElement: row } = cell
@@ -68,6 +68,7 @@ const scanFn: (acc: [boolean, boolean, boolean], curr: CustomEvent) => [boolean,
   styleUrls : [
     `./nehubaContainer.style.css`,
   ],
+  exportAs: 'uiNehubaContainer',
 })
 
 export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
@@ -133,7 +134,7 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
 
   public nanometersToOffsetPixelsFn: Array<(...arg) => any> = []
 
-  private viewPanels: [HTMLElement, HTMLElement, HTMLElement, HTMLElement] = [null, null, null, null]
+  public viewPanels: [HTMLElement, HTMLElement, HTMLElement, HTMLElement] = [null, null, null, null]
   public panelMode$: Observable<string>
 
   private panelOrder: string
@@ -142,8 +143,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
 
   public hoveredPanelIndices$: Observable<number>
 
-  private ngPanelTouchMove$: Observable<any>
-
   constructor(
     private constantService: AtlasViewerConstantsServices,
     private apiService: AtlasViewerAPIServices,
@@ -291,22 +290,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
         ? state.layers.findIndex(l => l.mixability === 'nonmixable') >= 0
         : false),
     )
-
-    this.ngPanelTouchMove$ = fromEvent(this.elementRef.nativeElement, 'touchstart').pipe(
-      switchMap((touchStartEv: TouchEvent) => fromEvent(this.elementRef.nativeElement, 'touchmove').pipe(
-        tap((ev: TouchEvent) => ev.preventDefault()),
-        scan((acc, curr: TouchEvent) => [curr, ...acc.slice(0, 1)], []),
-        map((touchMoveEvs: TouchEvent[]) => {
-          return {
-            touchStartEv,
-            touchMoveEvs,
-          }
-        }),
-        takeUntil(fromEvent(this.elementRef.nativeElement, 'touchend').pipe(
-          filter((ev: TouchEvent) => ev.touches.length === 0)),
-        ),
-      )),
-    )
   }
 
   public useMobileUI$: Observable<boolean>
@@ -331,75 +314,6 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
 
   public ngOnInit() {
 
-    // translation on mobile
-    this.subscriptions.push(
-      this.ngPanelTouchMove$.pipe(
-        filter(({ touchMoveEvs }) => touchMoveEvs.length > 1 && (touchMoveEvs as TouchEvent[]).every(ev => ev.touches.length === 1)),
-      ).subscribe(({ touchMoveEvs, touchStartEv }) => {
-
-        // get deltaX and deltaY of touchmove
-        const deltaX = touchMoveEvs[1].touches[0].screenX - touchMoveEvs[0].touches[0].screenX
-        const deltaY = touchMoveEvs[1].touches[0].screenY - touchMoveEvs[0].touches[0].screenY
-
-        // figure out the target of touch start
-        const panelIdx = this.findPanelIndex(touchStartEv.target as HTMLElement)
-
-        // translate if panelIdx < 3
-        if (panelIdx >= 0 && panelIdx < 3) {
-          const { position } = this.nehubaViewer.nehubaViewer.ngviewer.navigationState
-          const pos = position.spatialCoordinates
-          this.exportNehuba.vec3.set(pos, deltaX, deltaY, 0)
-          this.exportNehuba.vec3.transformMat4(pos, pos, this.nehubaViewer.viewportToDatas[panelIdx])
-          position.changed.dispatch()
-        } else if (panelIdx === 3) {
-          const {perspectiveNavigationState} = this.nehubaViewer.nehubaViewer.ngviewer
-          const { vec3 } = this.exportNehuba
-          perspectiveNavigationState.pose.rotateRelative(vec3.fromValues(0, 1, 0), -deltaX / 4.0 * Math.PI / 180.0)
-          perspectiveNavigationState.pose.rotateRelative(vec3.fromValues(1, 0, 0), deltaY / 4.0 * Math.PI / 180.0)
-          this.nehubaViewer.nehubaViewer.ngviewer.perspectiveNavigationState.changed.dispatch()
-        } else {
-          this.log.warn(`panelIdx not found`)
-        }
-      }),
-    )
-
-    // perspective reorientation on mobile
-    this.subscriptions.push(
-      this.ngPanelTouchMove$.pipe(
-        filter(({ touchMoveEvs }) => touchMoveEvs.length > 1 && (touchMoveEvs as TouchEvent[]).every(ev => ev.touches.length === 2)),
-      ).subscribe(({ touchMoveEvs, touchStartEv }) => {
-
-        const d1 = computeDistance(
-          [touchMoveEvs[1].touches[0].screenX, touchMoveEvs[1].touches[0].screenY],
-          [touchMoveEvs[1].touches[1].screenX, touchMoveEvs[1].touches[1].screenY],
-        )
-        const d2 = computeDistance(
-          [touchMoveEvs[0].touches[0].screenX, touchMoveEvs[0].touches[0].screenY],
-          [touchMoveEvs[0].touches[1].screenX, touchMoveEvs[0].touches[1].screenY],
-        )
-        const factor = d1 / d2
-
-        // figure out the target of touch start
-        const panelIdx = this.findPanelIndex(touchStartEv.target as HTMLElement)
-
-        // zoom slice view if slice
-        if (panelIdx >= 0 && panelIdx < 3) {
-          this.nehubaViewer.nehubaViewer.ngviewer.navigationState.zoomBy(factor)
-        } else if (panelIdx === 3) {
-          const { minZoom = null, maxZoom = null } = (this.selectedTemplate.nehubaConfig
-            && this.selectedTemplate.nehubaConfig.layout
-            && this.selectedTemplate.nehubaConfig.layout.useNehubaPerspective
-            && this.selectedTemplate.nehubaConfig.layout.useNehubaPerspective.restrictZoomLevel)
-            || {}
-
-          const { zoomFactor } = this.nehubaViewer.nehubaViewer.ngviewer.perspectiveNavigationState
-          if (!!minZoom && zoomFactor.value * factor < minZoom) { return }
-          if (!!maxZoom && zoomFactor.value * factor > maxZoom) { return }
-          zoomFactor.zoomBy(factor)
-        }
-      }),
-    )
-
     this.hoveredPanelIndices$ = fromEvent(this.elementRef.nativeElement, 'mouseover').pipe(
       switchMap((ev: MouseEvent) => merge(
         of(this.findPanelIndex(ev.target as HTMLElement)),
@@ -826,6 +740,10 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
     this.subscriptions.forEach(s => s.unsubscribe())
   }
 
+  public test(){
+    console.log('test')
+  }
+
   public toggleMaximiseMinimise(index: number) {
     this.store.dispatch({
       type: NG_VIEWER_ACTION_TYPES.TOGGLE_MAXIMISE,
@@ -835,26 +753,10 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy {
     })
   }
 
-  public tunableMobileProperties = ['Oblique Rotate X', 'Oblique Rotate Y', 'Oblique Rotate Z', 'Remove extra layers']
-  public selectedProp = null
-
-  public handleMobileOverlayTouchEnd(focusItemIndex) {
-    if (this.tunableMobileProperties[focusItemIndex] === 'Remove extra layers') {
-      this.store.dispatch({
-        type: NG_VIEWER_ACTION_TYPES.REMOVE_ALL_NONBASE_LAYERS,
-      })
-    }
-  }
+  public tunableMobileProperties: ITunableProp[] = []
 
-  public handleMobileOverlayEvent(obj: any) {
-    const {delta, selectedProp} = obj
-    this.selectedProp = selectedProp
-
-    const idx = this.tunableMobileProperties.findIndex(p => p === selectedProp)
-    if (idx === 0) { this.nehubaViewer.obliqueRotateX(delta) }
-    if (idx === 1) { this.nehubaViewer.obliqueRotateY(delta) }
-    if (idx === 2) { this.nehubaViewer.obliqueRotateZ(delta) }
-  }
+  
+  public selectedProp = null
 
   public returnTruePos(quadrant: number, data: any) {
     const pos = quadrant > 2 ?
diff --git a/src/ui/nehubaContainer/nehubaContainer.style.css b/src/ui/nehubaContainer/nehubaContainer.style.css
index 9737b28faebcf1d5c24d7e3ff7be187d708031c5..795c4084b41770f293712671d0709ccb87c23938 100644
--- a/src/ui/nehubaContainer/nehubaContainer.style.css
+++ b/src/ui/nehubaContainer/nehubaContainer.style.css
@@ -101,67 +101,6 @@ div.loadingIndicator div.spinnerAnimationCircle
   color:rgba(255,255,255,0.8);
 }
 
-div[mobileObliqueCtrl]
-{
-  font-size: 200%;
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  width: 0;
-  height: 0;
-
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  pointer-events: all;
-}
-
-div[mobileObliqueScreen]
-{
-  position: absolute;
-  top: 0; 
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background-color:rgba(128,128,128,0.2);
-  transition: all 0.5s linear;
-  pointer-events: all;
-}
-
-div.base
-{
-  position : absolute;
-  top: 50%;
-  left: 50%;
-  width: 0;
-  height: 0;
-  display:flex;
-  flex-direction: column-reverse;
-  align-items: center;
-}
-
-div[delta]
-{
-  white-space: nowrap
-}
-
-div[mobileObliqueGuide]
-{
-  background-color: rgba(250,250,250,0.8);
-}
-
-div[mobileObliqueGuide] > *
-{
-  white-space: nowrap;
-}
-
-:host-context([darktheme="true"]) div[mobileObliqueGuide]
-{
-
-  background-color: rgba(50,50,50,0.8);
-  color: white;
-}
-
 div#scratch-pad
 {
   position: absolute;
diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html
index 175c62c6372131e4e3dfe8541e9c1897d1181d2f..b6ecacc3753003334269b2c610ad29a022859704 100644
--- a/src/ui/nehubaContainer/nehubaContainer.template.html
+++ b/src/ui/nehubaContainer/nehubaContainer.template.html
@@ -39,59 +39,52 @@
 <div id="scratch-pad">
 </div>
 
-<!-- mobile nub, allowing for ooblique slicing in mobile -->
+<!-- mobile nub. may be required when more advanced control is required on mobile. for now, disabled -->
 <mobile-overlay
-  *ngIf="(useMobileUI$ | async) && viewerLoaded"
-  (touchend)="handleMobileOverlayTouchEnd(mobileOverlayEl.focusItemIndex)"
+  *ngIf="false && (useMobileUI$ | async) && viewerLoaded"
+  [iav-mobile-overlay-guide-tmpl]="mobileOverlayGuide"
   [tunableProperties]="tunableMobileProperties"
-  (deltaValue)="handleMobileOverlayEvent($event)"
-  #mobileOverlayEl>
-  <div class="base" delta>
-    <div mobileObliqueGuide class="p-2 mb-4 shadow">
-      {{ selectedProp }}
-    </div>
-  </div>
-  <div class="base" guide>
-    <div
-      mobileObliqueGuide
-      class="p-2 mb-4 shadow">
-      <div>
-        <i class="fas fa-arrows-alt-v"></i> oblique mode
-      </div>
-      <div>
-        <i class="fas fa-arrows-alt-h"></i> rotate slice
-      </div>
-    </div>
+  [iav-mobile-overlay-hide-ctrl-btn]="(panelMode$ | async) !== 'SINGLE_PANEL'"
+  [iav-mobile-overlay-ctrl-btn-pos]="panelMode$ | async | mobileControlNubStylePipe">
+
+</mobile-overlay>
+
+<ng-template #mobileOverlayGuide>
+  <div>
+    <i class="fas fa-arrows-alt-v"></i>
+    <span>
+      Select item
+    </span>
   </div>
-  <div
-    (contextmenu)="$event.stopPropagation(); $event.preventDefault();"
-    [ngStyle]="panelMode$ | async | mobileControlNubStylePipe"
-    *ngIf="(panelMode$ | async) !== 'SINGLE_PANEL'"
-    mobileObliqueCtrl
-    initiator>
-    <button mat-mini-fab color="primary">
-      <i class="fas fa-globe"></i>
-    </button>
+  <div>
+    <i class="fas fa-arrows-alt-h"></i>
+    <span>
+      Modify item value
+    </span>
   </div>
-</mobile-overlay>
+</ng-template>
 
 <!-- overlay templates -->
 <!-- inserted using ngTemplateOutlet -->
 <ng-template #overlayi>
   <layout-floating-container pos00 landmarkContainer>
     <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)"
-      (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)"
+      (mouseenter)="handleMouseEnterLandmark(spatialData)"
+      (mouseleave)="handleMouseLeaveLandmark(spatialData)"
       [highlight]="spatialData.highlight ? spatialData.highlight : false"
       [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'"
-      [positionX]="getPositionX(0,spatialData)" [positionY]="getPositionY(0,spatialData)"
+      [positionX]="getPositionX(0,spatialData)"
+      [positionY]="getPositionY(0,spatialData)"
       [positionZ]="getPositionZ(0,spatialData)">
     </nehuba-2dlandmark-unit>
 
     <!-- maximise/minimise button -->
     <maximise-panel-button
+      (touchend)="toggleMaximiseMinimise(0)"
       (click)="toggleMaximiseMinimise(0)"
       [ngClass]="{ onHover: (panelOrder$ | async | reorderPanelIndexPipe : ( hoveredPanelIndices$ | async  )) === 0 }"
-      [touch-side-class]="0 " class="pe-all">
+      [touch-side-class]="0"
+      class="pe-all">
     </maximise-panel-button>
     
     <div *ngIf="sliceViewLoading0$ | async" class="loadingIndicator">
@@ -104,44 +97,52 @@
 
 <ng-template #overlayii>
   <layout-floating-container pos01 landmarkContainer>
-      <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)"
-        (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)"
-        [highlight]="spatialData.highlight ? spatialData.highlight : false"
-        [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'"
-        [positionX]="getPositionX(1,spatialData)" [positionY]="getPositionY(1,spatialData)"
-        [positionZ]="getPositionZ(1,spatialData)">
-      </nehuba-2dlandmark-unit>
-
-      <!-- maximise/minimise button -->
-      <maximise-panel-button
-        (click)="toggleMaximiseMinimise(1)"
-        [ngClass]="{ onHover: (panelOrder$ | async | reorderPanelIndexPipe : ( hoveredPanelIndices$ | async  )) === 1 }"
-        [touch-side-class]="1 " class="pe-all">
-      </maximise-panel-button>
-
-      <div *ngIf="sliceViewLoading1$ | async" class="loadingIndicator">
-        <div class="spinnerAnimationCircle">
-
-        </div>
+    <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)"
+      (mouseenter)="handleMouseEnterLandmark(spatialData)"
+      (mouseleave)="handleMouseLeaveLandmark(spatialData)"
+      [highlight]="spatialData.highlight ? spatialData.highlight : false"
+      [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'"
+      [positionX]="getPositionX(1,spatialData)"
+      [positionY]="getPositionY(1,spatialData)"
+      [positionZ]="getPositionZ(1,spatialData)">
+    </nehuba-2dlandmark-unit>
+
+    <!-- maximise/minimise button -->
+    <maximise-panel-button
+      (touchend)="toggleMaximiseMinimise(1)"
+      (click)="toggleMaximiseMinimise(1)"
+      [ngClass]="{ onHover: (panelOrder$ | async | reorderPanelIndexPipe : ( hoveredPanelIndices$ | async  )) === 1 }"
+      [touch-side-class]="1"
+      class="pe-all">
+    </maximise-panel-button>
+
+    <div *ngIf="sliceViewLoading1$ | async" class="loadingIndicator">
+      <div class="spinnerAnimationCircle">
+
       </div>
-    </layout-floating-container>
+    </div>
+  </layout-floating-container>
 </ng-template>
 
 <ng-template #overlayiii>
   <layout-floating-container pos10 landmarkContainer>
     <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)"
-      (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)"
+      (mouseenter)="handleMouseEnterLandmark(spatialData)"
+      (mouseleave)="handleMouseLeaveLandmark(spatialData)"
       [highlight]="spatialData.highlight ? spatialData.highlight : false"
       [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'"
-      [positionX]="getPositionX(2,spatialData)" [positionY]="getPositionY(2,spatialData)"
+      [positionX]="getPositionX(2,spatialData)"
+      [positionY]="getPositionY(2,spatialData)"
       [positionZ]="getPositionZ(2,spatialData)">
     </nehuba-2dlandmark-unit>
 
     <!-- maximise/minimise button -->
     <maximise-panel-button
+      (touchend)="toggleMaximiseMinimise(2)"
       (click)="toggleMaximiseMinimise(2)"
       [ngClass]="{ onHover: (panelOrder$ | async | reorderPanelIndexPipe : ( hoveredPanelIndices$ | async  )) === 2 }"
-      [touch-side-class]="2 " class="pe-all">
+      [touch-side-class]="2"
+      class="pe-all">
     </maximise-panel-button>
 
     <div *ngIf="sliceViewLoading2$ | async" class="loadingIndicator">
@@ -157,9 +158,11 @@
 
     <!-- maximise/minimise button -->
     <maximise-panel-button
+      (touchend)="toggleMaximiseMinimise(3)"
       (click)="toggleMaximiseMinimise(3)"
       [ngClass]="{ onHover: (panelOrder$ | async | reorderPanelIndexPipe : ( hoveredPanelIndices$ | async  )) === 3 }"
-      [touch-side-class]="3 " class="pe-all">
+      [touch-side-class]="3"
+      class="pe-all">
     </maximise-panel-button>
     
     <div *ngIf="perspectiveViewLoading$ | async" class="loadingIndicator">
diff --git a/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts
index 7e771d624656a01040bd7f41d0503a2dd02f553f..35d07e84051f7597b6ac657cf61e39780d4522d4 100644
--- a/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts
+++ b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerInterface.directive.ts
@@ -3,7 +3,7 @@ import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component";
 import { Store, select } from "@ngrx/store";
 import { IavRootStoreInterface } from "src/services/stateStore.service";
 import { Subscription, Observable } from "rxjs";
-import { distinctUntilChanged, filter, switchMap, debounceTime, shareReplay, scan, map, throttleTime } from "rxjs/operators";
+import { distinctUntilChanged, filter, debounceTime, shareReplay, scan, map, throttleTime } from "rxjs/operators";
 import { StateInterface as ViewerConfigStateInterface } from "src/services/state/viewerConfig.store";
 import { getNavigationStateFromConfig } from "../util";
 import { NEHUBA_LAYER_CHANGED, CHANGE_NAVIGATION, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store";
diff --git a/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerTouch.directive.ts b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerTouch.directive.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fe3591052eaee13878de6cb8fe6d6f5a83c8a615
--- /dev/null
+++ b/src/ui/nehubaContainer/nehubaViewerInterface/nehubaViewerTouch.directive.ts
@@ -0,0 +1,260 @@
+import { Directive, ElementRef, Input, HostListener, Output, OnDestroy } from "@angular/core";
+import { Observable, fromEvent, merge, Subscription } from "rxjs";
+import { map, filter, shareReplay, switchMap, pairwise, takeUntil, tap, switchMapTo } from "rxjs/operators";
+import { getExportNehuba } from 'src/util/fn'
+import { computeDistance } from "../nehubaViewer/nehubaViewer.component";
+
+@Directive({
+  selector: '[iav-viewer-touch-interface]',
+  exportAs: 'iavNehubaViewerTouch'
+})
+
+export class NehubaViewerTouchDirective implements OnDestroy{
+
+  @Input('iav-viewer-touch-interface-v-panels')
+  viewerPanels: [HTMLElement, HTMLElement, HTMLElement, HTMLElement]
+
+  @Input('iav-viewer-touch-interface-vp-to-data')
+  viewportToData: [any, any, any, any]
+
+  @Input('iav-viewer-touch-interface-ngviewer')
+  ngViewer: any
+
+  @Input('iav-viewer-touch-interface-nehuba-config')
+  nehubaConfig: any
+
+  private touchMove$: Observable<any>
+  private singleTouchStart$: Observable<TouchEvent>
+  private touchEnd$: Observable<TouchEvent>
+  private multiTouchStart$: Observable<any>
+
+  public translate$: Observable<any>
+
+  private findPanelIndex = (panel: HTMLElement) => this.viewerPanels.indexOf(panel)
+
+  private _exportNehuba: any
+  private get exportNehuba(){
+    if (!this._exportNehuba) {
+      this._exportNehuba = getExportNehuba()
+    }
+    return this._exportNehuba
+  }
+
+  private s: Subscription[] = []
+
+  constructor(
+    private el: ElementRef,
+  ){
+
+    /**
+     * Touchend also needs to be listened to, as user could start
+     * with multitouch, and end up as single touch
+     */
+    const touchStart$ = fromEvent(this.el.nativeElement, 'touchstart').pipe(
+      tap((ev: TouchEvent) => ev.preventDefault()),
+      shareReplay(1),
+    )
+    this.singleTouchStart$ = merge(
+      touchStart$,
+      fromEvent(this.el.nativeElement, 'touchend')
+    ).pipe(
+      filter((ev: TouchEvent) => ev.touches.length === 1),
+      shareReplay(1),
+    )
+
+    this.multiTouchStart$ = touchStart$.pipe(
+      filter((ev: TouchEvent) => ev.touches.length > 1),
+    )
+
+    this.touchEnd$ = fromEvent(this.el.nativeElement, 'touchend').pipe(
+      map(ev => ev as TouchEvent),
+    )
+
+    this.touchMove$ = fromEvent(this.el.nativeElement, 'touchmove')
+
+    const multiTouch$ = this.multiTouchStart$.pipe(
+      // only tracks first 2 touches
+      map((ev: TouchEvent) => [ this.findPanelIndex(ev.touches[0].target as HTMLElement), this.findPanelIndex(ev.touches[0].target as HTMLElement) ]),
+      filter(indicies => indicies[0] >= 0 && indicies[0] === indicies[1]),
+      map(indicies => indicies[0]),
+      switchMap(panelIndex => fromEvent(this.el.nativeElement, 'touchmove').pipe(
+        filter((ev: TouchEvent) => ev.touches.length > 1),
+        pairwise(),
+        map(([ev0, ev1]) => {
+          return {
+            panelIndex,
+            ev0,
+            ev1
+          }
+        }),
+        takeUntil(this.touchEnd$.pipe(
+          filter(ev => ev.touches.length < 2)
+        ))
+      )),
+      shareReplay(1)
+    )
+
+    const multitouchSliceView$ = multiTouch$.pipe(
+      filter(({ panelIndex }) => panelIndex < 3)
+    )
+
+    const multitouchPerspective$ = multiTouch$.pipe(
+      filter(({ panelIndex }) => panelIndex === 3)
+    )
+
+    const rotationByMultiTouch$ = multitouchSliceView$
+
+    const zoomByMultiTouch$ = multitouchSliceView$.pipe(
+      map(({ ev1, ev0 }) => {
+        const d1 = computeDistance(
+          [ev0.touches[0].screenX, ev0.touches[0].screenY],
+          [ev0.touches[1].screenX, ev0.touches[1].screenY],
+        )
+        const d2 = computeDistance(
+          [ev1.touches[0].screenX, ev1.touches[0].screenY],
+          [ev1.touches[1].screenX, ev1.touches[1].screenY],
+        )
+        const factor = d1 / d2
+        return factor
+      })
+    )
+
+    const translateByMultiTouch$ = multitouchSliceView$.pipe(
+      map(({ ev0, ev1, panelIndex }) => {
+
+        const av0X = (ev0.touches[0].screenX + ev0.touches[1].screenX) / 2
+        const av0Y = (ev0.touches[0].screenY + ev0.touches[1].screenY) / 2
+
+        const av1X = (ev1.touches[0].screenX + ev1.touches[1].screenX) / 2
+        const av1Y = (ev1.touches[0].screenY + ev1.touches[1].screenY) / 2
+
+        const deltaX = av0X - av1X
+        const deltaY = av0Y - av1Y
+        return {
+          panelIndex,
+          deltaX,
+          deltaY
+        }
+      }),
+    )
+
+    const translateBySingleTouch$ = this.singleTouchStart$.pipe(
+      map(ev => this.findPanelIndex(ev.target as HTMLElement)),
+      filter(panelIndex => !!this.ngViewer && panelIndex >= 0 && panelIndex < 3),
+      switchMap(panelIndex => this.touchMove$.pipe(
+        pairwise(),
+        map(([ ev0, ev1 ]: [TouchEvent, TouchEvent]) => {
+          const deltaX = ev0.touches[0].screenX - ev1.touches[0].screenX
+          const deltaY = ev0.touches[0].screenY - ev1.touches[0].screenY
+          return {
+            panelIndex,
+            deltaX,
+            deltaY
+          }
+        }),
+        takeUntil(
+          merge(
+            this.touchEnd$,
+            this.multiTouchStart$
+          )
+        )
+      ))
+    )
+
+    const changePerspectiveView$ = this.singleTouchStart$.pipe(
+      map(ev => this.findPanelIndex(ev.target as HTMLElement)),
+      filter(panelIndex => panelIndex === 3 ),
+      switchMapTo(this.touchMove$.pipe(
+        pairwise(),
+        map(([ev0, ev1]) => {
+          return { ev0, ev1 }
+        }),
+        takeUntil(
+          merge(
+            this.touchEnd$,
+            this.multiTouchStart$
+          )
+        ),
+      )),
+    )
+
+    this.s.push(
+      changePerspectiveView$.subscribe(({ ev1, ev0 }) => {
+        const { perspectiveNavigationState } = this.ngViewer
+
+        const { vec3 } = this.exportNehuba
+
+        const deltaX = ev0.touches[0].screenX - ev1.touches[0].screenX
+        const deltaY = ev0.touches[0].screenY - ev1.touches[0].screenY
+        perspectiveNavigationState.pose.rotateRelative(vec3.fromValues(0, 1, 0), -deltaX / 4.0 * Math.PI / 180.0)
+        perspectiveNavigationState.pose.rotateRelative(vec3.fromValues(1, 0, 0), deltaY / 4.0 * Math.PI / 180.0)
+        perspectiveNavigationState.changed.dispatch()
+      }),
+      multitouchPerspective$.subscribe(({ ev1, ev0 }) => {
+        const d1 = computeDistance(
+          [ev0.touches[0].screenX, ev0.touches[0].screenY],
+          [ev0.touches[1].screenX, ev0.touches[1].screenY],
+        )
+        const d2 = computeDistance(
+          [ev1.touches[0].screenX, ev1.touches[0].screenY],
+          [ev1.touches[1].screenX, ev1.touches[1].screenY],
+        )
+        const factor = d1 / d2
+        const { minZoom = null, maxZoom = null } = this.nehubaConfig?.layout?.useNehubaPerspective?.restrictZoomLevel || {}
+        const { zoomFactor } = this.ngViewer.perspectiveNavigationState
+        if (!!minZoom && zoomFactor.value * factor < minZoom) { return }
+        if (!!maxZoom && zoomFactor.value * factor > maxZoom) { return }
+        zoomFactor.zoomBy(factor)
+      }),
+      rotationByMultiTouch$.subscribe(({ panelIndex, ev0, ev1 }) => {
+        
+        const dY0 = ev0.touches[1].screenY - ev0.touches[0].screenY
+        const dX0 = ev0.touches[1].screenX - ev0.touches[0].screenX
+        const m0 = dY0 / dX0
+
+        const dY1 = ev1.touches[1].screenY - ev1.touches[0].screenY
+        const dX1 = ev1.touches[1].screenX - ev1.touches[0].screenX
+        const m1 = dY1 / dX1
+
+        const theta = Math.atan( (m1 - m0) / ( 1 + m1 * m0 ) )
+        if (isNaN(theta)) return
+        
+        const { vec3 } = this.exportNehuba
+
+        const axis = vec3.fromValues(
+          ...[
+            [0, -1, 0],
+            [1, 0, 0],
+            [0, 0, 1]
+          ][panelIndex]
+        )
+        const ori = this.ngViewer.navigationState.pose.orientation.orientation
+        vec3.transformQuat(axis, axis, ori)
+
+        this.ngViewer.navigationState.pose.rotateRelative(axis, theta)
+      }),
+      zoomByMultiTouch$.subscribe(factor => {
+        if (isNaN(factor)) return
+        this.ngViewer.navigationState.zoomBy(factor)
+      }),
+      merge(
+        translateBySingleTouch$,
+        translateByMultiTouch$,
+      ).subscribe(({ panelIndex, deltaX, deltaY }) => {
+        if (isNaN(deltaX) || isNaN(deltaX)) return
+        const { position } = this.ngViewer.navigationState
+        const pos = position.spatialCoordinates
+        this.exportNehuba.vec3.set(pos, deltaX, deltaY, 0)
+        this.exportNehuba.vec3.transformMat4(pos, pos, this.viewportToData[panelIndex])
+
+        position.changed.dispatch()
+      })
+    )
+  }
+
+  ngOnDestroy(){
+    while(this.s.length > 0){
+      this.s.pop().unsubscribe()
+    }
+  }
+}
diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts
index 8703dcbda670840759a482e6dd6a7db3bf618394..4c2ef9b1c0ffe225583cf88052d82b2609242885 100644
--- a/src/ui/ui.module.ts
+++ b/src/ui/ui.module.ts
@@ -86,6 +86,7 @@ import { StateModule } from "src/state";
 import { AuthModule } from "src/auth";
 import { FabSpeedDialModule } from "src/components/fabSpeedDial";
 import { ActionDialog } from "./actionDialog/actionDialog.component";
+import { NehubaViewerTouchDirective } from "./nehubaContainer/nehubaViewerInterface/nehubaViewerTouch.directive";
 
 @NgModule({
   imports : [
@@ -179,6 +180,7 @@ import { ActionDialog } from "./actionDialog/actionDialog.component";
     TouchSideClass,
     ElementOutClickDirective,
     FixedMouseContextualContainerDirective,
+    NehubaViewerTouchDirective,
   ],
   entryComponents : [
 
@@ -211,7 +213,8 @@ import { ActionDialog } from "./actionDialog/actionDialog.component";
     ViewerStateMini,
     RegionMenuComponent,
     FixedMouseContextualContainerDirective,
-    LandmarkUIComponent
+    LandmarkUIComponent,
+    NehubaViewerTouchDirective,
   ],
   schemas: [
     CUSTOM_ELEMENTS_SCHEMA,