diff --git a/docs/advanced/datasets.md b/docs/advanced/datasets.md
deleted file mode 100644
index 5850b315165a29e21ecb7c237fe1c5e5999f3914..0000000000000000000000000000000000000000
--- a/docs/advanced/datasets.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Fetching datasets from Knowledge Graph
-
-Human Brain Project Knowledge Graph is a metadata database consisting of datasets contributed by collaborators of the Human Brain Project and curated by human curoators in order to ensure the highest standards. 
-
-!!! note
-    v2.3.0 changed the way datasets are fetched from the knowledge graph.
-
-Datasets are now fetched from the knowledge graph on a region of interest basis. The fetched datasets are then filtered only on the reference space basis.
-
-Currently, only parcellation regions are supported.
diff --git a/docs/advanced/keyboard.md b/docs/advanced/keyboard.md
index d331cb87b092483f4d9fb98e1d8ffa4bd1ebfe4b..734072c77cf20cf08ca995301e255d824c424f07 100644
--- a/docs/advanced/keyboard.md
+++ b/docs/advanced/keyboard.md
@@ -1,10 +1,18 @@
 # Keyboard shortcuts
 
-Please note that the keyboard shortcuts may alter the behaviour irreversibly. 
+| Action | Desktop | Touch devices |
+| --- | --- | --- |
+| Translate / Pan | `[drag]` any of the slice views | `[drag]` any of the slice views |
+| Oblique rotation | `<shift>` + `[drag]` any of the slice views | `[rotate]` any of the slice views |
+| Zoom | `[mousewheel]` | `[pinch zoom]` |
+| Zoom | `[hover]` on any slice views > `[click]` magnifier | `[tap]` on magnifier |
+| Next slice | `<ctrl>` + `[mousewheel]` / `[p]`revious / `[n]`ext | - |
+| Next 10 slice | `<shift>` + `[mousewheel]` | - |
+| Toggle delineation | `[q]` | - |
+| Toggle cross hair | `[a]` | - |
+| Multiple region select | `<ctrl>` + `[click]` on region | - |
+| Context menu | `[right click]` | - |
 
-|Key|Description|
-|---|---|
-|[0-9]|Toggle layer visibility|
-|[h] [?]|Show help|
-|[o]|Toggle orthographic/perspective _3d view_ |
-|[a]|Toggle axis visibility |
+---
+
+You can enable touch interface by `[Portrait]` > `Settings` > `Enable Mobile UI`
diff --git a/docs/advanced/otherVolumes.md b/docs/advanced/otherVolumes.md
deleted file mode 100644
index b38f05425259ccdfd47e1f2e43086ec9055aa8a4..0000000000000000000000000000000000000000
--- a/docs/advanced/otherVolumes.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# Displaying non-atlas volumes
-
-!!! warning
-    This section is still been developed, and the content/API may change in future versions.
-
-Interactive atlas can allow for arbitary volumes to be viewed, either in the context of a reference template or without. 
-
-## Viewing standalone volumes
-
-`standaloneVolumes` query param is parsed, and parsed as JSON. They are passed directly to be rendered in nehuba. 
-
-If both `standaloneVolumes` and `templateSelected` are present, the latter is ignored.
-
-### Query param
-
-standaloneVolumes
-
-### Example
-```
-/?standaloneVolumes=%5B%22nifti%3A%2F%2Fhttp%3A%2F%2Flocalhost%3A1234%2Fnii.gz%22%2C%22precomputed%3A%2F%2Fhttp%3A%2F%2Flocalhost%3A4321%2Fvolume%22%5D
-```
-
-decoding and parsing as JSON:
-
-```json
-[
-  "nifti://http://localhost:1234/nii.gz",
-  "precomputed://http://localhost:4321/volume"
-]
-```
-
-## Viewing registered volumes
-
-`previewingDatasetFiles` query param is parsed, and parsed as JSON. Then, relevant volume information is retrieved, and displayed with `templateSelected` and `parcellationSelected`
-
-### Query param
-
-previewingDatasetFiles
-
-### Example
-
-```
-/?templateSelected=Big+Brain+%28Histology%29&parcellationSelected=Grey%2FWhite+matter&previewingDatasetFiles=%5B%7B%22datasetId%22%3A%22minds%2Fcore%2Fdataset%2Fv1.0.0%2Fb08a7dbc-7c75-4ce7-905b-690b2b1e8957%22%2C%22filename%22%3A%22Overlay%20of%20data%20modalities%22%7D%5D
-```
-
-decoding and parsing as JSON:
-
-```json
-[
-  {
-    "datasetId":"minds/core/dataset/v1.0.0/b08a7dbc-7c75-4ce7-905b-690b2b1e8957",
-    "filename":"Overlay of data modalities"
-  }
-]
-```
-
-The metadata fetched from these ID [^1] is as follows. 
-
-[^1]: Currently, `kg-dataset-previewer` is used to resolve the preview URL. This is very likely going to change in the future.
-
-```json
-{
-  "name": "Overlay of data modalities",
-  "filename": "Overlay of data modalities",
-  "mimetype": "application/json",
-  "data": {
-    "iav-registered-volumes": {
-      "volumes": [
-        {
-          "name": "PLI Fiber Orientation Red Channel",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/8b970e20de0e31b1b78ec9dba13d20319111189711983cb03ddbb7cc/BI-FOM-HSV_R",
-          "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(1.0 * x, x * 0., 0. * x )); } }",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 1.0
-        },
-        {
-          "name": "PLI Fiber Orientation Green Channel",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/8b970e20de0e31b1b78ec9dba13d20319111189711983cb03ddbb7cc/BI-FOM-HSV_G",
-          "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0. * x, x * 1., 0. * x )); } }",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 0.5
-        },
-        {
-          "name": "PLI Fiber Orientation Blue Channel",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/8b970e20de0e31b1b78ec9dba13d20319111189711983cb03ddbb7cc/BI-FOM-HSV_B",
-          "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0. * x, x * 0., 1.0 * x )); } }",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 0.25
-        },
-        {
-          "name": "Blockface Image",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI",
-          "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0.8 * x, x * 1., 0.8 * x )); } }",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 1.0
-        },
-        {
-          "name": "PLI Transmittance",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI-TIM",
-          "shader": "void main(){ float x = toNormalized(getDataValue()); if (x > 0.9) { emitTransparent(); } else { emitRGB(vec3(x * 1., x * 0.8, x * 0.8 )); } }",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 1.0
-        },
-        {
-          "name": "T2w MRI",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI-MRI",
-          "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0.8 * x, 0.8 * x, x * 1. )); } }",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 1.0
-        },
-        {
-          "name": "MRI Labels",
-          "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI-MRS",
-          "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]],
-          "opacity": 1.0
-        }
-      ]
-    }
-  },
-  "referenceSpaces": [
-    {
-      "name": "Big Brain (Histology)",
-      "fullId": "minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588"
-    }
-  ]
-}
-```
diff --git a/docs/advanced/url.md b/docs/advanced/url.md
deleted file mode 100644
index d5353f6d8ccf22af54cfeb5a67e38e80aed4a3ff..0000000000000000000000000000000000000000
--- a/docs/advanced/url.md
+++ /dev/null
@@ -1,225 +0,0 @@
-# URL parsing
-
-!!! note
-    Since [version 2.0.0](../releases/v2.0.0.md), navigation state and region(s) selected has been significantly redesigned.
-
-    While the the URL parsing engine should still be backwards compatible, users should update their bookmarks/links. 
-
-The interactive atlas viewer uses query parameters to store some of the viewer state. As a result, users can share or bookmark the URL, easily collaborating with other users in an interactive environment.
-
-
-```
-https://interactive-viewer.apps.hbp.eu/?templateSelected=Big+Brain+%28Histology%29&parcellationSelected=Cytoarchitectonic+Maps&cRegionsSelected=%7B%22interpolated%22%3A%224.5.6.7.O.P%22%7D&cNavigation=0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2
-```
-
-However, expert users may want to generate custom state URLs. 
-
-This document explains how the URL parsing in the Interactive Atlas Viewer work.
-
-## Query Parameters
-
-| Query param | 
-| --- | 
-| [`templateSelected`](#templateselected) | 
-| [`parcellationSelected`](#parcellationselected) |
-| [`cNavigation`](#cnavigation) | 
-| [`cRegionsSelected`](#cregionsselected) | 
-
-### `templateSelected`
-
-Describes the selected template. URI encoded value of the name of the selected template.
-
-If unset, loads homepage.
-
-__Example__
-
-```
-templateSelected=Big+Brain+%28Histology%29
-```
-
-
-### `parcellationSelected`
-
-Describes the parcellation selected. Depends on `templateSelected`. URI encoded value of the name of the selected parcellation.
-
-If unset, or not a subset of parcellations supported by the selected template, the first parcellation of the selected template will be loaded instead
-
-__Example__
-
-```
-parcellationSelected=Cytoarchitectonic+Maps
-```
-
-### `cNavigation`
-
-Describes the navigation state of the viewer.
-
-Uses `..` as a delimiter for key value, `.` as a delimiter for value and [hash function](#hash-function) to encode signed float to base64 string.
-
-If unset, loads the default orientation.
-
-__Example__
-
-```
-cNavigation=0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2
-```
-
-```javascript
-// cNavigation=0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2
-
-const cNavigation = `0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2`
-
-// First, separate with key value delimiter
-const [
-  orientationStr,
-  perspectiveOrientationStr,
-  perspectiveZoomStr,
-  positionStr,
-  zoomStr  
-] = cNavigation.split('..')
-
-// For entries that are Array:
-
-const orientationArr = orientationStr.split('.')
-const perspectiveOrientationArr = perspectiveOrientationStr.split('.')
-const positionArr = positionStr.split('.')
-
-
-// check hash function for decodeToNumber
-// To get values back:
-const orientation = orientationArr.map(v => decodeToNumber(v, { float: true }))
-// [ 0, 0, 0, 1 ]
-
-
-const perspectiveOrientation = perspectiveOrientationArr.map(v => decodeToNumber(v, { float: true }))
-// [ 0.7971121072769165,  -0.14286760985851288,  0.17759324610233307,  -0.5591617226600647 ]
-
-const zoom = decodeToNumber(zoomStr, { float: false })
-// 264578
-
-const perspectiveZoom = decodeToNumber(perspectiveZoomStr, { float: false })
-// 1922235
-
-const position = positionArr.map(v => decodeToNumber(v, { float: false }))
-// [ -11860944, -3841071, 5798192 ]
-
-```
-
-
-### `cRegionsSelected`
-
-Describe the regions selected.
-
-Query value is an URI encoded JSON object. Upon decoding, keys represent the name of the segmentation layer (which is often different to _selected parcellation_). Value is a `.` delimited array of integer [hashed](#hash-function) to base64 string.
-
-If unset, or if unable to decode, does not select region.
-
-__Example__
-
-```
-cRegionsSelected=%7B%22interpolated%22%3A%224.5.6.7.O.P%22%7D
-```
-
-```javascript
-const cRegionSelected = `%7B%22interpolated%22%3A%224.5.6.7.O.P%22%7D`
-const decoded = decodeURIComponent(cRegionSelected)
-
-const parsed = JSON.parse(decoded)
-const returnObj = {}
-for (const key in parsed){
-  const seg = parsed[key].split('.').map(v => decodeToNumber(v, { float: false }))
-  returnObj[key] = seg
-}
-
-// { interpolated: [ 4, 5, 6, 7, 24, 25 ] }
-
-```
-
-## hash function
-
-```javascript
-
-/**
- * First attempt at encoding int (e.g. selected region, navigation location) from number (loc info density) to b64 (higher info density)
- * The constraint is that the cipher needs to be commpatible with URI encoding
- * and a URI compatible separator is required. 
- * 
- * While a faster solution exist in the same post, this operation is expected to be done:
- * - once per 1 sec frequency
- * - on < 1000 numbers
- * 
- * So performance is not really that important (Also, need to learn bitwise operation)
- */
-
-const cipher = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-'
-export const separator = "."
-const negString = '~'
-
-const encodeInt = number => {
-  if (number % 1 !== 0) { throw new Error('cannot encodeInt on a float. Ensure float flag is set') }
-  if (isNaN(Number(number)) || number === null || number === Number.POSITIVE_INFINITY) { throw new Error('The input is not valid') }
-
-  let residual
-  let result = ''
-
-  if (number < 0) {
-    result += negString
-    residual = Math.floor(number * -1)
-  } else {
-    residual = Math.floor(number)
-  }
-
-  while (true) {
-    result = cipher.charAt(residual % 64) + result
-    residual = Math.floor(residual / 64)
-
-    if (residual === 0) {
-      break
-    }
-  }
-  return result
-}
-
-const defaultB64EncodingOption = {
-  float: false
-}
-
-export const encodeNumber = (number, option = defaultB64EncodingOption) => {
-  const { float } = option
-  if (!float) return encodeInt(number)
-  else {
-    const floatArray = new Float32Array(1)
-    floatArray[0] = number
-    const intArray = new Uint32Array(floatArray.buffer)
-    const castedInt = intArray[0]
-    return encodeInt(castedInt)
-  }
-}
-
-const decodetoInt = encodedString => {
-  let _encodedString, negFlag = false
-  if (encodedString.slice(-1) === negString) {
-    negFlag = true
-    _encodedString = encodedString.slice(0, -1)
-  } else {
-    _encodedString = encodedString
-  }
-  return (negFlag ? -1 : 1) * [..._encodedString].reduce((acc,curr) => {
-    const index = cipher.indexOf(curr)
-    if (index < 0) throw new Error(`Poisoned b64 encoding ${encodedString}`)
-    return acc * 64 + index
-  }, 0)
-}
-
-export const decodeToNumber = (encodedString, {float = false} = defaultB64EncodingOption) => {
-  if (!float) return decodetoInt(encodedString)
-  else {
-    const _int = decodetoInt(encodedString)
-    const intArray = new Uint32Array(1)
-    intArray[0] = _int
-    const castedFloat = new Float32Array(intArray.buffer)
-    return castedFloat[0]
-  }
-}
-
-```
diff --git a/mkdocs.yml b/mkdocs.yml
index 683054e07f3097a9c1b3cc699afc0485aff729fb..22a04cd407599d49da3fc3401cec38cf4450fc80 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -29,9 +29,6 @@ nav:
     - Sharing: 'usage/sharing.md'
   - Advanced usage:
     - Keyboard shortcuts: 'advanced/keyboard.md'
-    - URL parsing: 'advanced/url.md'
-    - Fetching datasets: 'advanced/datasets.md'
-    - Display non-atlas volumes: 'advanced/otherVolumes.md'
   - Release notes:
     - v2.14.0: 'releases/v2.14.0.md'
     - v2.13.5: 'releases/v2.13.5.md'
diff --git a/package-lock.json b/package-lock.json
index 988893d143b1661bf6bca7685e244e46e87c2898..b91a2cc43f2dedefcb87b50ad762cd74162b5b57 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "siibra-explorer",
-  "version": "2.12.1",
+  "version": "2.14.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "siibra-explorer",
-      "version": "2.12.0",
+      "version": "2.14.0",
       "license": "apache-2.0",
       "dependencies": {
         "@angular/animations": "^14.2.12",
@@ -23,7 +23,7 @@
         "@ngrx/effects": "^14.3.2",
         "@ngrx/store": "^14.3.2",
         "acorn": "^8.4.1",
-        "export-nehuba": "^0.1.0",
+        "export-nehuba": "^0.1.2",
         "file-loader": "^6.2.0",
         "jszip": "^3.6.0",
         "postcss": "^8.3.6",
@@ -26966,9 +26966,9 @@
       "dev": true
     },
     "node_modules/export-nehuba": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/export-nehuba/-/export-nehuba-0.1.0.tgz",
-      "integrity": "sha512-49mp9MiR6n+1zzeoVOfYTmr1g9CWBXrCtXK6PxwnRj+VBFrmjbp5PzBjVsGr5HsODrhwBWCLInK7zXmXaDnE/Q==",
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/export-nehuba/-/export-nehuba-0.1.2.tgz",
+      "integrity": "sha512-rzydWAaa9QUKZqbYQcAuwnGsMGBlEQFD5URkEi5IGTG8LS4eH/xqc97ol0ZpUExa6jyn6nLtAjFJQmKL1rdV0w==",
       "dependencies": {
         "pako": "^1.0.6"
       }
diff --git a/package.json b/package.json
index 101d49c59e5b92380e94010ac86ee15d81cea2fe..2aa0ea7ab4af53dcf0a1bfb695cc6c31f38f0748 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,7 @@
     "@ngrx/effects": "^14.3.2",
     "@ngrx/store": "^14.3.2",
     "acorn": "^8.4.1",
-    "export-nehuba": "^0.1.0",
+    "export-nehuba": "^0.1.2",
     "file-loader": "^6.2.0",
     "jszip": "^3.6.0",
     "postcss": "^8.3.6",
diff --git a/src/viewerModule/nehuba/userLayers/service.ts b/src/viewerModule/nehuba/userLayers/service.ts
index 5f9c1e130ce2ea589b26293292ccefe2be0ae915..5937a106ed53e1fa3682f2900446bba171ffcdb5 100644
--- a/src/viewerModule/nehuba/userLayers/service.ts
+++ b/src/viewerModule/nehuba/userLayers/service.ts
@@ -1,8 +1,8 @@
 import { Injectable, OnDestroy } from "@angular/core"
 import { MatDialog } from "@angular/material/dialog"
 import { select, Store } from "@ngrx/store"
-import { concat, forkJoin, from, of, Subscription } from "rxjs"
-import { map, pairwise, switchMap } from "rxjs/operators"
+import { forkJoin, from, Subscription } from "rxjs"
+import { distinctUntilChanged, filter } from "rxjs/operators"
 import {
   linearTransform,
   TVALID_LINEAR_XFORM_DST,
@@ -19,130 +19,241 @@ import { translateV3Entities } from "src/atlasComponents/sapi/translateV3"
 import { MetaV1Schema } from "src/atlasComponents/sapi/typeV3"
 
 type OmitKeys = "clType" | "id" | "source"
+type LayerOption = Omit<atlasAppearance.const.NgLayerCustomLayer, OmitKeys>
 type Meta = {
-  min?: number
-  max?: number
   message?: string
   filename: string
 }
 
 const OVERLAY_LAYER_KEY = "x-overlay-layer"
+const OVERLAY_LAYER_PROTOCOL = `${OVERLAY_LAYER_KEY}://`
+const SUPPORTED_PREFIX = ["nifti://", "precomputed://", "swc://", "deepzoom://"] as const
+
+type ValidProtocol = typeof SUPPORTED_PREFIX[number]
+type ValidInputTypes = File|string
+
+type ProcessorOutput = {option: LayerOption, url: string, protocol: ValidProtocol, meta: Meta, cleanup: () => void}
+type ProcessResource = {
+  matcher: (input: ValidInputTypes) => boolean
+  processor: (input: ValidInputTypes) => Promise<ProcessorOutput>
+}
+
+const SOURCE_PROCESSOR: ProcessResource[] = []
+function RegisterSource(matcher: ProcessResource['matcher']) {
+  return (target: Record<string, any>, propertyKey: string, _descriptor: PropertyDescriptor) => {
+    SOURCE_PROCESSOR.push({
+      matcher,
+      processor: target[propertyKey],
+    })
+  }
+}
+
 
 @Injectable()
 export class UserLayerService implements OnDestroy {
-  private userLayerUrlToIdMap = new Map<string, string>()
-  private createdUrlRes = new Set<string>()
-
-  private supportedPrefix = ["nifti://", "precomputed://", "swc://"]
+  #idToCleanup = new Map<string, () => void>()
 
-  private verifyUrl(url: string) {
-    for (const prefix of this.supportedPrefix) {
-      if (url.includes(prefix)) return
+  static VerifyUrl(source: string) {
+    for (const prefix of SUPPORTED_PREFIX) {
+      if (source.includes(prefix)) return
     }
     throw new Error(
-      `url: ${url} does not start with supported prefixes ${this.supportedPrefix}`
+      `source: ${source} does not start with supported prefixes ${SUPPORTED_PREFIX}`
     )
   }
 
-  async getCvtFileToUrl(file: File): Promise<{
-    url: string
-    meta: Meta
-    options?: Omit<atlasAppearance.const.NgLayerCustomLayer, OmitKeys>
-  }> {
-    /**
-     * if extension is .swc, process as if swc
-     */
-    if (/\.swc$/i.test(file.name)) {
-      let message = `The swc rendering is experimental. Please contact us on any feedbacks. `
-      const swcText = await file.text()
-      let src: TVALID_LINEAR_XFORM_SRC
-      const dst: TVALID_LINEAR_XFORM_DST = "NEHUBA"
-      if (/ccf/i.test(swcText)) {
-        src = "CCF"
-        message += `CCF detected, applying known transformation.`
-      }
-      if (!src) {
-        message += `no known space detected. Applying default transformation.`
-      }
+  @RegisterSource(
+    input => input instanceof File && input.name.endsWith(".swc")
+  )
+  async processSwc(file: File): ReturnType<ProcessResource['processor']> {
+    let message = `The swc rendering is experimental. Please contact us on any feedbacks. `
+    const swcText = await file.text()
+    let src: TVALID_LINEAR_XFORM_SRC
+    const dst: TVALID_LINEAR_XFORM_DST = "NEHUBA"
+    if (/ccf/i.test(swcText)) {
+      src = "CCF"
+      message += `CCF detected, applying known transformation.`
+    }
+    if (!src) {
+      message += `no known space detected. Applying default transformation.`
+    }
 
-      const xform = await linearTransform(src, dst)
+    const xform = await linearTransform(src, dst)
+    const url = URL.createObjectURL(file)
 
-      const url = URL.createObjectURL(file)
-      this.createdUrlRes.add(url)
+    return {
+      option: {
+        type: 'segmentation',
+        transform: xform,
+        segments: ["1"]
+      },
+      url,
+      protocol: "swc://",
+      meta: {
+        filename: file.name
+      },
+      cleanup: () => URL.revokeObjectURL(url)
+    }
+  }
 
-      return {
-        url: `swc://${url}`,
-        meta: {
-          filename: file.name,
-          message,
-        },
-        options: {
-          segments: ["1"],
-          transform: xform,
-          type: "segmentation"
-        },
-      }
+  async #processUnpackedNiiBuf(buf: ArrayBuffer): ReturnType<ProcessResource['processor']> {
+    const { result } = await this.worker.sendMessage({
+      method: "PROCESS_NIFTI",
+      param: {
+        nifti: buf,
+      },
+      transfers: [buf],
+    })
+    const { buffer, meta } = result
+    const url = URL.createObjectURL(new Blob([buffer]))
+    return {
+      protocol: 'nifti://',
+      url,
+      option: {
+        type: 'image',
+        shader: getShader({
+          colormap: EnumColorMapName.MAGMA,
+          lowThreshold: meta.min || 0,
+          highThreshold: meta.max || 1,
+        })
+      },
+      meta,
+      cleanup: () => URL.revokeObjectURL(url)
     }
+  }
 
-    /**
-     * process as if nifti
-     */
+  @RegisterSource(
+    input => input instanceof File && input.name.endsWith(".nii")
+  )
+  async processNifti(file: File){
+    const buf = await file.arrayBuffer()
+    return await this.#processUnpackedNiiBuf(buf)
+  }
 
-    // Get file, try to inflate, if files, use original array buffer
+  @RegisterSource(
+    input => input instanceof File && input.name.endsWith(".nii.gz")
+  )
+  async processNiiGz(file: File) {
     const buf = await file.arrayBuffer()
-    let outbuf
     try {
       const { pako } = await getExportNehuba()
-      outbuf = pako.inflate(buf).buffer
+      const outbuf = pako.inflate(buf).buffer
+      return await this.#processUnpackedNiiBuf(outbuf)
     } catch (e) {
       console.log("unpack error", e)
-      outbuf = buf
+      throw e
     }
+  }
 
-    const { result } = await this.worker.sendMessage({
-      method: "PROCESS_NIFTI",
-      param: {
-        nifti: outbuf,
-      },
-      transfers: [outbuf],
-    })
+  @RegisterSource(
+    input => typeof input === "string" && input.startsWith(OVERLAY_LAYER_PROTOCOL)
+  )
+  async processOverlayPath(source: string) {
+    const strippedSrc = source.replace(OVERLAY_LAYER_PROTOCOL, "")
+    const { cleanup, ...rest } = await this.#processInput(strippedSrc)
+    return {
+      ...rest,
+      cleanup: () => {
+        this.routerSvc.setCustomRoute(OVERLAY_LAYER_KEY, null)
+        cleanup()
+      }
+    }
+  }
 
-    const { meta, buffer } = result
+  @RegisterSource(
+    input => typeof input === "string" && input.startsWith("precomputed://")
+  )
+  async processPrecomputed(source: string): Promise<ProcessorOutput>{
+    const url = source.replace("precomputed://", "")
+    const { transform, meta } = await forkJoin({
+      transform: fetch(`${url}/transform.json`)
+        .then(res => res.json() as Promise<MetaV1Schema["transform"]>)
+        .catch(_e => null as MetaV1Schema["transform"]),
+      meta: from(
+        translateV3Entities.fetchMeta(url)
+        .catch(_e => null as MetaV1Schema)  
+      )
+    }).toPromise()
+    
+    return {
+      cleanup: () => {},
+      meta: {
+        filename: url
+      },
+      option: {
+        transform: meta?.transform || transform,
+        shader: getShaderFromMeta(meta),
+      },
+      protocol: "precomputed://",
+      url
+    }
+  }
 
-    const url = URL.createObjectURL(new Blob([buffer]))
+  @RegisterSource(
+    input => typeof input === "string" && input.startsWith("deepzoom://")
+  )
+  async processDzi(source: string): Promise<ProcessorOutput> {
+    const url = source.replace("deepzoom://", "")
+    const scaleFactor = 1e2
     return {
-      url: `nifti://${url}`,
+      cleanup: () => {},
       meta: {
-        filename: file.name,
-        min: meta.min || 0,
-        max: meta.max || 1,
-        message: meta.message,
+        filename: `deepzoom://${url}`
       },
-      options: {
-        shader: getShader({
-          colormap: EnumColorMapName.MAGMA,
-          lowThreshold: meta.min || 0,
-          highThreshold: meta.max || 1,
-        }),
-        type: 'image'
+      option: {
+        transform: [
+          [ scaleFactor, 0, 0, 0 ],
+          [ 0, scaleFactor, 0, 0 ],
+          [ 0, 0, 1, 0 ],
+          [ 0, 0, 0, 1 ],
+        ],
+        shader: `void main(){emitRGB(vec3(toNormalized(getDataValue(0)),toNormalized(getDataValue(1)),toNormalized(getDataValue(2))));}`,
       },
+      protocol: "deepzoom://",
+      url
     }
   }
 
+  async #processInput(input: ValidInputTypes): Promise<ProcessorOutput> {
+    for (const { matcher, processor } of SOURCE_PROCESSOR) {
+      if (matcher(input)) {
+        return await processor.apply(this, [input])
+      }
+    }
+    debugger
+    const inputStr = input instanceof File
+      ? input.name
+      : input
+    throw new Error(`Could not find a processor for ${inputStr}`)
+  }
+
+  async handleUserInput(input: ValidInputTypes){
+    const id = getUuid()
+    const { option, protocol, url, meta, cleanup } = await this.#processInput(input)
+    if (this.#idToCleanup.has(id)) {
+      throw new Error(`${url} was already registered`)
+    }
+    this.#idToCleanup.set(id, cleanup)
+    this.addUserLayer(
+      id,
+      `${protocol}${url}`,
+      meta,
+      option,
+    )
+    return
+  }
+
   addUserLayer(
-    url: string,
+    id: string,
+    source: string,
     meta: Meta,
-    options: Omit<atlasAppearance.const.NgLayerCustomLayer, OmitKeys> = {}
+    options: LayerOption = {}
   ) {
-    this.verifyUrl(url)
-    if (this.userLayerUrlToIdMap.has(url)) {
-      throw new Error(`url ${url} already added`)
-    }
-    const id = getUuid()
-    const layer: atlasAppearance.const.NgLayerCustomLayer = {
+    UserLayerService.VerifyUrl(source)
+    const layer = {
       id,
-      clType: "customlayer/nglayer",
-      source: url,
+      clType: "customlayer/nglayer" as const,
+      source,
       ...options,
     }
     this.store$.dispatch(
@@ -151,48 +262,30 @@ export class UserLayerService implements OnDestroy {
       })
     )
 
-    this.userLayerUrlToIdMap.set(url, id)
-
-    this.dialog
-      .open(UserLayerInfoCmp, {
-        data: {
-          layerName: id,
-          filename: meta.filename,
-          min: meta.min || 0,
-          max: meta.max || 1,
-          warning: [meta.message] || [],
-        },
-        hasBackdrop: false,
-        disableClose: true,
-        position: {
-          top: "0em",
-        },
-        autoFocus: false,
-        panelClass: ["no-padding-dialog", "w-100"],
-      })
-      .afterClosed()
-      .subscribe(() => {
-        this.routerSvc.setCustomRoute(OVERLAY_LAYER_KEY, null)
-      })
-  }
-
-  removeUserLayer(url: string) {
-    if (!this.userLayerUrlToIdMap.has(url)) {
-      throw new Error(`${url} has not yet been added.`)
-    }
-
-    /**
-     * if the url to be removed is a url resource, revoke the resource
-     */
-    const matched = /http.*$/.exec(url)
-    if (matched && this.createdUrlRes.has(matched[0])) {
-      URL.revokeObjectURL(matched[0])
-      this.createdUrlRes.delete(matched[0])
-    }
-
-    const id = this.userLayerUrlToIdMap.get(url)
-    this.store$.dispatch(atlasAppearance.actions.removeCustomLayer({ id }))
-    this.userLayerUrlToIdMap.delete(url)
+    this.dialog.open(UserLayerInfoCmp, {
+      data: {
+        layerName: id,
+        filename: meta.filename,
+        warning: [meta.message] || [],
+      },
+      hasBackdrop: false,
+      disableClose: true,
+      position: {
+        top: "0em",
+      },
+      autoFocus: false,
+      panelClass: ["no-padding-dialog", "w-100"],
+    })
+    .afterClosed()
+    .subscribe(() => {
+      this.store$.dispatch(atlasAppearance.actions.removeCustomLayer({ id }))
+      const cleanup = this.#idToCleanup.get(id)
+      if (!cleanup) {
+        console.warn(`idToCleanup ${id} could not be found! ${meta.filename}`)
+        return
+      }
+      cleanup()
+    })
   }
 
   #subscription: Subscription[] = []
@@ -203,66 +296,12 @@ export class UserLayerService implements OnDestroy {
     private routerSvc: RouterService
   ) {
     this.#subscription.push(
-      concat(
-        of(null as string),
-        this.routerSvc.customRoute$.pipe(
-          select(v => v[OVERLAY_LAYER_KEY])
-        )
+      this.routerSvc.customRoute$.pipe(
+        select(v => v[OVERLAY_LAYER_KEY])
       ).pipe(
-        pairwise(),
-        switchMap(([prev, curr]) => {
-          /**
-           * for precomputed sources, check if transform.json exists.
-           * if so, try to fetch it, and set it as transform
-           */
-          if (!curr) {
-            return of({ prev, curr, meta: null as MetaV1Schema })
-          }
-          if (!curr.startsWith("precomputed://")) {
-            return of({ prev, curr, meta: null as MetaV1Schema })
-          }
-          const url = curr.replace("precomputed://", "")
-          
-          
-          return forkJoin({
-            transform: fetch(`${url}/transform.json`)
-              .then(res => res.json() as Promise<MetaV1Schema["transform"]>)
-              .catch(_e => null as MetaV1Schema["transform"]),
-            meta: from(
-              translateV3Entities.fetchMeta(url)
-              .catch(_e => null as MetaV1Schema)  
-            )
-          }).pipe(
-            map(({ transform, meta }) => {
-              return {
-                prev,
-                curr,
-                meta: {
-                  ...meta,
-                  transform: meta?.transform || transform
-                }
-              }
-            }),
-          )
-        })
-      ).subscribe(({ prev, curr, meta }) => {
-        if (prev) {
-          this.removeUserLayer(prev)
-        }
-        if (curr) {
-          this.addUserLayer(
-            curr,
-            {
-              filename: curr,
-              message: `Overlay layer populated in URL`,
-            },
-            {
-              shader: getShaderFromMeta(meta),
-              transform: meta.transform
-            }
-          )
-        }
-      })
+        distinctUntilChanged(),
+        filter(url => !!url)
+      ).subscribe(url => this.handleUserInput(url))
     )
   }
 
diff --git a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts
index be2c332759205abf28aa9d844244a325a7a5a106..d3724c1e2a6134973661e30578df71d3a9deafa2 100644
--- a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts
+++ b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts
@@ -17,10 +17,7 @@ class TestCmp {
 
 describe("dragdrop.directive.spec.ts", () => {
   let fixture: ComponentFixture<TestCmp>
-
-  let addUserLayerSpy: jasmine.Spy
-  let removeUserLayerSpy: jasmine.Spy
-  let getCvtFileToUrlSpy: jasmine.Spy
+  let handleUserInputSpy: jasmine.Spy
 
   let dummyFile1: File
   let dummyFile2: File
@@ -42,18 +39,14 @@ describe("dragdrop.directive.spec.ts", () => {
             useValue: {
               addUserLayer: () => {},
               removeUserLayer: () => {},
-              getCvtFileToUrl: () => Promise.resolve(),
+              handleUserInput: () => Promise.resolve(),
             },
           },
         ],
       })
       const svc = TestBed.inject(UserLayerService)
-
-      addUserLayerSpy = spyOn(svc, "addUserLayer")
-      removeUserLayerSpy = spyOn(svc, "removeUserLayer")
-      getCvtFileToUrlSpy = spyOn(svc, "getCvtFileToUrl")
-
-      getCvtFileToUrlSpy.and.resolveTo({ meta, url, options })
+      handleUserInputSpy = spyOn(svc, "handleUserInput")
+      handleUserInputSpy.and.resolveTo(null)
 
       fixture = TestBed.createComponent(TestCmp)
       fixture.detectChanges()
@@ -73,9 +66,7 @@ describe("dragdrop.directive.spec.ts", () => {
       })()
     })
     afterEach(() => {
-      addUserLayerSpy.calls.reset()
-      removeUserLayerSpy.calls.reset()
-      getCvtFileToUrlSpy.calls.reset()
+      handleUserInputSpy.calls.reset()
     })
 
     describe("> malformed input", () => {
@@ -99,7 +90,7 @@ describe("dragdrop.directive.spec.ts", () => {
           })
 
           it("> should not call addnglayer", () => {
-            expect(getCvtFileToUrlSpy).not.toHaveBeenCalled()
+            expect(handleUserInputSpy).not.toHaveBeenCalled()
           })
 
           // TODO having a difficult time getting snackbar harness
@@ -128,17 +119,11 @@ describe("dragdrop.directive.spec.ts", () => {
       })
 
       it("> should call addNgLayer", () => {
-        expect(getCvtFileToUrlSpy).toHaveBeenCalledTimes(1)
-        const arg = getCvtFileToUrlSpy.calls.argsFor(0)
+        expect(handleUserInputSpy).toHaveBeenCalledTimes(1)
+        const arg = handleUserInputSpy.calls.argsFor(0)
         expect(arg.length).toEqual(1)
         expect(arg[0]).toEqual(dummyFile1)
 
-        expect(addUserLayerSpy).toHaveBeenCalledTimes(1)
-        const args1 = addUserLayerSpy.calls.argsFor(0)
-
-        expect(args1[0]).toBe(url)
-        expect(args1[1]).toBe(meta)
-        expect(args1[2]).toBe(options)
       })
     })
   })
diff --git a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts
index a56d677fd8b77ecce210dc7c2940ea7b1836472b..2b049c7f70b4f011f45d99b7f09bbb808e088d5a 100644
--- a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts
+++ b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts
@@ -53,9 +53,6 @@ export class UserLayerDragDropDirective
       return
     }
     const file = files[0]
-
-    const { meta, url, options } = await this.svc.getCvtFileToUrl(file)
-
-    this.svc.addUserLayer(url, meta, options)
+    await this.svc.handleUserInput(file)
   }
 }
diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts
index 204da49bf5170ca9998ed52f26fc31ad1df05471..eb4f6fac154934c88b3c83e595430046b2e2b770 100644
--- a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts
+++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts
@@ -8,8 +8,6 @@ import { MediaQueryDirective } from "src/util/directives/mediaQuery.directive";
 export type UserLayerInfoData = {
   layerName: string
   filename: string
-  min: number
-  max: number
   warning: string[]
 }
 
diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html
index c999b1c1befd8d650096bf38c6b23a8aadf63192..84b908d46b466d47be99763d6bd3147a1423260e 100644
--- a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html
+++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html
@@ -55,8 +55,6 @@
   <ng-layer-tune
     [hideCtrl]="onlyOpacity ? HIDE_NG_TUNE_CTRL.ONLY_SHOW_OPACITY : ''"
     advanced-control="true"
-    [ngLayerName]="data.layerName"
-    [thresholdMin]="data.min"
-    [thresholdMax]="data.max">
+    [ngLayerName]="data.layerName">
   </ng-layer-tune>
 </ng-template>