diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000000000000000000000000000000000000..a7056f5b61bb51dccbbf299a27254d217fefcf6b
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,3 @@
+{
+  "semi": false
+}
\ No newline at end of file
diff --git a/docs/releases/v2.8.0.md b/docs/releases/v2.8.0.md
index 6b7f6716800f2483a322c64377fab203c969c483..c8f58a7317679a8c7e8999b4de246e95417e1b7a 100644
--- a/docs/releases/v2.8.0.md
+++ b/docs/releases/v2.8.0.md
@@ -4,6 +4,7 @@
 
 - Added detail filter for connectivity
 - Added minimap picture-in-picture in single panel mode
+- Supports arbitrary layer based on URL parameter
 
 ## Behind the scenes
 
diff --git a/src/dragDropFile/index.ts b/src/dragDropFile/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f83141bad17b50cf7ae58d72850d7b44c66b4fa1
--- /dev/null
+++ b/src/dragDropFile/index.ts
@@ -0,0 +1,2 @@
+export { DragDropFileModule } from "./module"
+export { DragDropFileDirective } from "./dragDrop.directive"
diff --git a/src/dragDropFile/module.ts b/src/dragDropFile/module.ts
index e3b77d44dc37541f55f62beed2ab213af43c4d77..9aa8a1201dd1b006218759e5752f0cb082273058 100644
--- a/src/dragDropFile/module.ts
+++ b/src/dragDropFile/module.ts
@@ -3,15 +3,8 @@ import { NgModule } from "@angular/core";
 import { DragDropFileDirective } from "./dragDrop.directive";
 
 @NgModule({
-  imports: [
-    CommonModule
-  ],
-  declarations: [
-    DragDropFileDirective
-  ],
-  exports: [
-    DragDropFileDirective
-  ]
+  imports: [CommonModule],
+  declarations: [DragDropFileDirective],
+  exports: [DragDropFileDirective],
 })
-
-export class DragDropFileModule{}
\ No newline at end of file
+export class DragDropFileModule {}
diff --git a/src/routerModule/routeStateTransform.service.ts b/src/routerModule/routeStateTransform.service.ts
index dc7da45d8a9eed4d3b8c4edbe31c409864d09741..d464b6eeaf5b90f400be66b1f100ee292b94d8ac 100644
--- a/src/routerModule/routeStateTransform.service.ts
+++ b/src/routerModule/routeStateTransform.service.ts
@@ -217,7 +217,7 @@ export class RouteStateTransformSvc {
     const standaloneVolumes = atlasSelection.selectors.standaloneVolumes(state)
     const navigation = atlasSelection.selectors.navigation(state)
     const selectedFeature = userInteraction.selectors.selectedFeature(state)
-  
+
     const searchParam = new URLSearchParams()
   
     let cNavString: string
@@ -270,7 +270,7 @@ export class RouteStateTransformSvc {
         ['@']: cNavString,
       } as TUrlPathObj<string, TUrlStandaloneVolume<string>>
     }
-  
+
     const routesArr: string[] = []
     for (const key in routes) {
       if (!!routes[key]) {
diff --git a/src/routerModule/router.service.ts b/src/routerModule/router.service.ts
index 3359055f7b70b395772c5d27842dffee46b1b374..3a060971d4f2d51a4223f16c86cb218362e75095 100644
--- a/src/routerModule/router.service.ts
+++ b/src/routerModule/router.service.ts
@@ -167,6 +167,11 @@ export class RouterService {
         routeFromState += `/${customStatePath}`
       }
       if ( fullPath !== `/${routeFromState}`) {
+        /**
+         * TODO buggy edge case:
+         * if the route changes on viewer load, the already added baselayer/nglayer will be cleared
+         * This will result in a white screen (nehuba viewer not showing)
+         */
         store$.dispatch(
           generalActions.generalApplyState({
             state: stateFromRoute
diff --git a/src/routerModule/util.spec.ts b/src/routerModule/util.spec.ts
index 95971bb00bf7f5d69249f9392d35def82d80d484..66d6ca1b71b187039f20e43a1b0f3dac4e2bbfe7 100644
--- a/src/routerModule/util.spec.ts
+++ b/src/routerModule/util.spec.ts
@@ -30,6 +30,9 @@ describe('> util.ts', () => {
       it('> encodes correctly', () => {
         expect(encodeCustomState('x-test', 'foo-bar')).toEqual('x-test:foo-bar')
       })
+      it("> encodes /", () => {
+        expect(encodeCustomState("x-test", "http://local.dev/foo")).toEqual(`x-test:http:%2F%2Flocal.dev%2Ffoo`)
+      })
     })
   })
 })
diff --git a/src/routerModule/util.ts b/src/routerModule/util.ts
index ee8f181f0431509a7b6512a762df034ec56982ab..cd0709ea8fc1a30e4d11eab738db77af0ebd4ad2 100644
--- a/src/routerModule/util.ts
+++ b/src/routerModule/util.ts
@@ -46,17 +46,17 @@ export const decodeCustomState = (fullPath: UrlTree) => {
     if (!verifyCustomState(f.path)) continue
     const { key, val } = decodePath(f.path) || {}
     if (!key || !val) continue
-    returnObj[key] = val[0]
+    returnObj[key] = val[0].replace("%2F", '/')
   }
   return returnObj
 }
 
-export const encodeCustomState = (key: string, value: string) => {
+export const encodeCustomState = (key: string, value: string|string[]) => {
   if (!verifyCustomState(key)) {
     throw new Error(`custom state must start with x-`)
   }
   if (!value) return null
-  return endcodePath(key, value)
+  return endcodePath(key, value).replace(/\//g, '%2F')
 }
 
 @Component({
diff --git a/src/state/index.ts b/src/state/index.ts
index 3eba9c31857ad2db855c3c9c8e4c461c8fd488ee..4d63d64e5d893fae4008bc9bad0f906471385f65 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -22,7 +22,7 @@ export {
 
 export * as generalActions from "./actions"
 
-function debug(reducer: ActionReducer<any>): ActionReducer<any> {
+function debug(reducer: ActionReducer<MainState>): ActionReducer<MainState> {
   return function(state, action) {
     console.log('state', state);
     console.log('action', action);
diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts
index 6886634b6e6c02a6556dcaa8a18c3f4729b7b71a..c052d1109674137db6e5fa941167c6af2283f21e 100644
--- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts
+++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts
@@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
 import { createEffect } from "@ngrx/effects";
 import { select, Store } from "@ngrx/store";
 import { forkJoin, of } from "rxjs";
-import { mapTo, switchMap, withLatestFrom, filter, catchError, map, debounceTime, shareReplay, distinctUntilChanged, startWith, pairwise } from "rxjs/operators";
+import { mapTo, switchMap, withLatestFrom, filter, catchError, map, debounceTime, shareReplay, distinctUntilChanged, startWith, pairwise, tap } from "rxjs/operators";
 import { SAPI, SapiAtlasModel, SapiFeatureModel, SapiParcellationModel, SapiSpaceModel, SapiRegionModel } from "src/atlasComponents/sapi";
 import { SapiVOIDataResponse, SapiVolumeModel } from "src/atlasComponents/sapi/type";
 import { atlasAppearance, atlasSelection, userInteraction } from "src/state";
@@ -67,7 +67,7 @@ export class LayerCtrlEffects {
 
   onATP$ = this.store.pipe(
     atlasSelection.fromRootStore.distinctATP(),
-    map(val => val as { atlas: SapiAtlasModel, parcellation: SapiParcellationModel, template: SapiSpaceModel })
+    map(val => val as { atlas: SapiAtlasModel, parcellation: SapiParcellationModel, template: SapiSpaceModel }),
   )
 
   onShownFeature = createEffect(() => this.store.pipe(
@@ -286,11 +286,11 @@ export class LayerCtrlEffects {
 
   onATPDebounceNgLayers$ = this.onATP$.pipe(
     debounceTime(16),
-    switchMap(({ atlas, parcellation, template }) => 
-      this.getNgLayers(atlas, parcellation, template).pipe(
+    switchMap(({ atlas, parcellation, template }) => {
+      return this.getNgLayers(atlas, parcellation, template).pipe(
         map(volumes => getNgLayersFromVolumesATP(volumes, { atlas, parcellation, template }))
       )
-    ),
+    }),
     shareReplay(1)
   )
 
@@ -322,10 +322,5 @@ export class LayerCtrlEffects {
     })
   ))
 
-  constructor(
-    private store: Store<any>,
-    private sapi: SAPI,  
-  ){
-
-  }
+  constructor(private store: Store<any>,private sapi: SAPI){}
 }
\ No newline at end of file
diff --git a/src/viewerModule/nehuba/module.ts b/src/viewerModule/nehuba/module.ts
index c9666e5aa1878c27d553ebf9ce15ef2aa836863d..e3cc3aeb2bdead46e359b35d21ad773849411301 100644
--- a/src/viewerModule/nehuba/module.ts
+++ b/src/viewerModule/nehuba/module.ts
@@ -1,4 +1,4 @@
-import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core";
+import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core";
 import { NehubaViewerContainerDirective } from './nehubaViewerInterface/nehubaViewerInterface.directive'
 import { IMPORT_NEHUBA_INJECT_TOKEN, NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component";
 import { CommonModule } from "@angular/common";
@@ -21,7 +21,6 @@ import { StateModule } from "src/state";
 import { AuthModule } from "src/auth";
 import {QuickTourModule} from "src/ui/quickTour/module";
 import { WindowResizeModule } from "src/util/windowResize";
-import { DragDropFileModule } from "src/dragDropFile/module";
 import { NgLayerCtrlCmp } from "./ngLayerCtl/ngLayerCtrl.component";
 import { EffectsModule } from "@ngrx/effects";
 import { MeshEffects } from "./mesh.effects/mesh.effects";
@@ -29,6 +28,7 @@ import { NehubaLayoutOverlayModule } from "./layoutOverlay";
 import { NgAnnotationService } from "./annotation/service";
 import { NgAnnotationEffects } from "./annotation/effects";
 import { NehubaViewerContainer } from "./nehubaViewerInterface/nehubaViewerContainer.component";
+import { NehubaUserLayerModule } from "./userLayers";
 
 @NgModule({
   imports: [
@@ -41,7 +41,7 @@ import { NehubaViewerContainer } from "./nehubaViewerInterface/nehubaViewerConta
     MouseoverModule,
     ShareModule,
     WindowResizeModule,
-    DragDropFileModule,
+    NehubaUserLayerModule,
 
     /**
      * should probably break this into its own...
@@ -87,6 +87,12 @@ import { NehubaViewerContainer } from "./nehubaViewerInterface/nehubaViewerConta
       provide: NEHUBA_INSTANCE_INJTKN,
       useValue: new BehaviorSubject(null)
     },
+    {
+      provide: APP_INITIALIZER,
+      multi: true,
+      useFactory: (_svc: NgAnnotationService) => () => Promise.resolve(),
+      deps: [ NgAnnotationService ]
+    },
     NgAnnotationService
   ],
   schemas: [
@@ -94,8 +100,4 @@ import { NehubaViewerContainer } from "./nehubaViewerInterface/nehubaViewerConta
   ]
 })
 
-export class NehubaModule{
-
-  // eslint-disable-next-line  @typescript-eslint/no-empty-function
-  constructor(_svc: NgAnnotationService){}
-}
+export class NehubaModule{}
diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts
index df06e08ba9ffbe21ffe2ae10d10d70e2d2e57247..703da432248b444f26edcdc15c3bb0a511db27ad 100644
--- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts
+++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts
@@ -249,121 +249,4 @@ describe('> nehubaViewerGlue.component.ts', () => {
     })
   })
 
-  describe('> handleFileDrop', () => {
-    let dispatchSpy: jasmine.Spy
-    let workerSendMessageSpy: jasmine.Spy
-    let dummyFile1: File
-    let dummyFile2: File
-    let input: File[]
-
-    beforeEach(() => {
-      dispatchSpy = spyOn(mockStore, 'dispatch')
-      dummyFile1 = (() => {
-        const bl: any = new Blob([], { type: 'text' })
-        bl.name = 'filename1.txt'
-        bl.lastModifiedDate = new Date()
-        return bl as File
-      })()
-
-      dummyFile2 = (() => {
-        const bl: any = new Blob([], { type: 'text' })
-        bl.name = 'filename2.txt'
-        bl.lastModifiedDate = new Date()
-        return bl as File
-      })()
-
-      fixture = TestBed.createComponent(NehubaGlueCmp)
-      fixture.detectChanges()
-
-      workerSendMessageSpy = spyOn(fixture.componentInstance['worker'], 'sendMessage').and.callFake(async () => {
-        return {
-          result: {
-            meta: {}, buffer: null
-          }
-        }
-      })
-    })
-    afterEach(() => {
-      dispatchSpy.calls.reset()
-      workerSendMessageSpy.calls.reset()
-    })
-
-    describe('> malformed input', () => {
-      const scenarios = [{
-        desc: 'too few files',
-        inp: []
-      }, {
-        desc: 'too many files',
-        inp: [dummyFile1, dummyFile2]
-      }]
-
-      for (const { desc, inp } of scenarios) {
-        describe(`> ${desc}`, () => {
-          beforeEach(() => {
-            input = inp
-
-            const cmp = fixture.componentInstance
-            cmp.handleFileDrop(input)
-          })
-
-          it('> should not call addnglayer', () => {
-            expect(dispatchSpy).not.toHaveBeenCalled()
-          })
-
-          // TODO having a difficult time getting snackbar harness
-          // it('> snackbar should show error message', async () => {
-          //   console.log('get harness')
-
-          //   rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture)
-          //   const loader = TestbedHarnessEnvironment.loader(fixture)
-          //   fixture.detectChanges()
-          //   const snackbarHarness = await rootLoader.getHarness(MatSnackBarHarness)
-          //   console.log('got harness', snackbarHarness)
-          //   // const message = await snackbarHarness.getMessage()
-          //   // console.log('got message')
-          //   // expect(message).toEqual(INVALID_FILE_INPUT)
-          // })
-        })
-      }
-    })
-
-    describe('> correct input', () => {
-      beforeEach(async () => {
-        input = [dummyFile1]
-
-        const cmp = fixture.componentInstance
-        await cmp.handleFileDrop(input)
-      })
-
-      afterEach(() => {
-        // remove remove all urls
-        fixture.componentInstance['dismissAllAddedLayers']()
-      })
-
-      it('> should call addNgLayer', () => {
-        expect(dispatchSpy).toHaveBeenCalledTimes(1)
-        const arg = dispatchSpy.calls.argsFor(0)
-        expect(arg.length).toEqual(1)
-        expect(arg[0].type).toBe(atlasAppearance.actions.addCustomLayer.type)
-      })
-      it('> on repeated input, both remove nglayer and remove ng layer called', async () => {
-        const cmp = fixture.componentInstance
-        await cmp.handleFileDrop(input)
-
-        expect(dispatchSpy).toHaveBeenCalledTimes(3)
-        
-        const arg0 = dispatchSpy.calls.argsFor(0)
-        expect(arg0.length).toEqual(1)
-        expect(arg0[0].type).toBe(atlasAppearance.actions.addCustomLayer.type)
-
-        const arg1 = dispatchSpy.calls.argsFor(1)
-        expect(arg1.length).toEqual(1)
-        expect(arg1[0].type).toBe(atlasAppearance.actions.removeCustomLayer.type)
-
-        const arg2 = dispatchSpy.calls.argsFor(2)
-        expect(arg2.length).toEqual(1)
-        expect(arg2[0].type).toBe(atlasAppearance.actions.addCustomLayer.type)
-      })
-    })
-  })
 })
diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts
index 88d05fbee161a4f871b51c72c8b92e586a4f3f0a..e570bcd5f01ea7b0ef4d19556773aa11285c4e4d 100644
--- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts
+++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts
@@ -2,24 +2,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Inject, OnDestroy, Op
 import { select, Store } from "@ngrx/store";
 import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util";
 import { distinctUntilChanged } from "rxjs/operators";
-import { ARIA_LABELS, CONST } from 'common/constants'
 import { IViewer, TViewerEvent } from "../../viewer.interface";
 import { NehubaMeshService } from "../mesh.service";
 import { NehubaLayerControlService, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service";
-import { getExportNehuba, getUuid } from "src/util/fn";
 import { NG_LAYER_CONTROL, SET_SEGMENT_VISIBILITY } from "../layerCtrl.service/layerCtrl.util";
-import { MatSnackBar } from "@angular/material/snack-bar";
-import { getShader } from "src/util/constants";
-import { EnumColorMapName } from "src/util/colorMaps";
-import { MatDialog } from "@angular/material/dialog";
-import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service";
 import { SapiRegionModel } from "src/atlasComponents/sapi";
 import { NehubaConfig } from "../config.service";
 import { SET_MESHES_TO_LOAD } from "../constants";
-import { atlasAppearance, atlasSelection, userInteraction } from "src/state";
-import { linearTransform, TVALID_LINEAR_XFORM_DST, TVALID_LINEAR_XFORM_SRC } from "src/atlasComponents/sapi/core/space/interspaceLinearXform";
+import { atlasSelection, userInteraction } from "src/state";
 
-export const INVALID_FILE_INPUT = `Exactly one (1) file is required!`
 
 @Component({
   selector: 'iav-cmp-viewer-nehuba-glue',
@@ -63,12 +54,6 @@ export const INVALID_FILE_INPUT = `Exactly one (1) file is required!`
 
 export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy {
 
-  @ViewChild('layerCtrlTmpl', { static: true })
-  layerCtrlTmpl: TemplateRef<any>
-
-  public ARIA_LABELS = ARIA_LABELS
-  public CONST = CONST
-
   private onhoverSegments: SapiRegionModel[] = []
   private onDestroyCb: (() => void)[] = []
 
@@ -83,9 +68,6 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy {
 
   constructor(
     private store$: Store<any>,
-    private snackbar: MatSnackBar,
-    private dialog: MatDialog,
-    private worker: AtlasWorkerService,
     @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor,
   ){
 
@@ -124,147 +106,4 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy {
     )
     return true
   }
-
-  private droppedLayerNames: {
-    layerName: string
-    resourceUrl: string
-  }[] = []
-  private dismissAllAddedLayers(){
-    while (this.droppedLayerNames.length) {
-      const { resourceUrl, layerName } = this.droppedLayerNames.pop()
-      this.store$.dispatch(
-        atlasAppearance.actions.removeCustomLayer({
-          id: layerName
-        })
-      )
-      
-      URL.revokeObjectURL(resourceUrl)
-    }
-  }
-  public async handleFileDrop(files: File[]): Promise<void> {
-    if (files.length !== 1) {
-      this.snackbar.open(INVALID_FILE_INPUT, 'Dismiss', {
-        duration: 5000
-      })
-      return
-    }
-    const randomUuid = getUuid()
-    const file = files[0]
-
-    /**
-     * TODO check extension?
-     */
-    this.dismissAllAddedLayers()
-
-    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.`
-      }
-
-      const xform = await linearTransform(src, dst)
-      
-      const url = URL.createObjectURL(file)
-      this.droppedLayerNames.push({
-        layerName: randomUuid,
-        resourceUrl: url
-      })
-      this.store$.dispatch(
-        atlasAppearance.actions.addCustomLayer({
-          customLayer: {
-            id: randomUuid,
-            source: `swc://${url}`,
-            segments: ["1"],
-            transform: xform,
-            clType: 'customlayer/nglayer' as const
-          }
-        })
-      )
-      this.snackbar.open(message, "Dismiss", {
-        duration: 10000
-      })
-      return
-    }
-     
-    
-    // Get file, try to inflate, if files, use original array buffer
-    const buf = await file.arrayBuffer()
-    let outbuf
-    try {
-      outbuf = getExportNehuba().pako.inflate(buf).buffer
-    } catch (e) {
-      console.log('unpack error', e)
-      outbuf = buf
-    }
-
-    try {
-      const { result } = await this.worker.sendMessage({
-        method: 'PROCESS_NIFTI',
-        param: {
-          nifti: outbuf
-        },
-        transfers: [ outbuf ]
-      })
-      
-      const { meta, buffer } = result
-
-      const url = URL.createObjectURL(new Blob([ buffer ]))
-      this.droppedLayerNames.push({
-        layerName: randomUuid,
-        resourceUrl: url
-      })
-
-      this.store$.dispatch(
-        atlasAppearance.actions.addCustomLayer({
-          customLayer: {
-            id: randomUuid,
-            source: `nifti://${url}`,
-            shader: getShader({
-              colormap: EnumColorMapName.MAGMA,
-              lowThreshold: meta.min || 0,
-              highThreshold: meta.max || 1
-            }),
-            clType: 'customlayer/nglayer'
-          }
-        })
-      )
-      this.dialog.open(
-        this.layerCtrlTmpl,
-        {
-          data: {
-            layerName: randomUuid,
-            filename: file.name,
-            moreInfoFlag: false,
-            min: meta.min || 0,
-            max: meta.max || 1,
-            warning: meta.warning || []
-          },
-          hasBackdrop: false,
-          disableClose: true,
-          position: {
-            top: '0em'
-          },
-          autoFocus: false,
-          panelClass: [
-            'no-padding-dialog',
-            'w-100'
-          ]
-        }
-      ).afterClosed().subscribe(
-        () => this.dismissAllAddedLayers()
-      )
-    } catch (e) {
-      console.error(e)
-      this.snackbar.open(`Error loading nifti: ${e.toString()}`, 'Dismiss', {
-        duration: 5000
-      })
-    }
-  }
 }
diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html
index 595ee6a14bd31629df752a60b92f3dc858b4c82a..f06f6339fe9f6bc7af3d29e03adfc6f41badab29 100644
--- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html
+++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html
@@ -1,8 +1,7 @@
 <div class="nehuba-viewer-container-parent"
   iav-viewer-touch-interface
   (touchmove)="$event.preventDefault()"
-  [snackText]="CONST.NEHUBA_DRAG_DROP_TEXT"
-  (drag-drop-file)="handleFileDrop($event)">
+  sxplr-nehuba-drag-drop>
 
   <sxplr-nehuba-viewer-container
     class="sxplr-w-100 sxplr-h-100 sxplr-d-block"
@@ -13,43 +12,3 @@
 
 <nehuba-layout-overlay></nehuba-layout-overlay>
 
-<!-- user dropped NIFTI overlay -->
-<ng-template #layerCtrlTmpl let-data>
-  <div class="grid grid-col-3">
-
-    <span class="ml-2 text-truncate v-center-text-span">
-      <i class="fas fa-file"></i>
-      {{ data.filename }}
-    </span>
-    
-    <button
-      [matTooltip]="ARIA_LABELS.VOLUME_TUNING_EXPAND"
-      mat-icon-button
-      [color]="data.moreInfoFlag ? 'primary' : 'basic'"
-      (click)="data.moreInfoFlag = !data.moreInfoFlag">
-      <i class="fas fa-sliders-h"></i>
-    </button>
-
-    <button
-      [matTooltip]="ARIA_LABELS.CLOSE"
-      color="warn"
-      mat-icon-button
-      mat-dialog-close>
-      <i class="fas fa-trash"></i>
-    </button>
-
-    <div *ngIf="data.moreInfoFlag"
-      class="sxplr-custom-cmp darker-bg overflow-hidden grid-wide-3">
-      <ng-layer-tune
-        advanced-control="true"
-        [ngLayerName]="data.layerName"
-        [thresholdMin]="data.min"
-        [thresholdMax]="data.max">
-      </ng-layer-tune>
-      <ul>
-        <li *ngFor="let warn of data.warning">{{ warn }}</li>
-      </ul>
-    </div>
-
-  </div>
-</ng-template>
diff --git a/src/viewerModule/nehuba/userLayers/index.ts b/src/viewerModule/nehuba/userLayers/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4b8545e0fd088bf7d9366d31346877842fcc9a5c
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/index.ts
@@ -0,0 +1,2 @@
+export { NehubaUserLayerModule } from "./module"
+export { UserLayerDragDropDirective } from "./userlayerDragdrop.directive"
diff --git a/src/viewerModule/nehuba/userLayers/module.ts b/src/viewerModule/nehuba/userLayers/module.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6de5343d80b664d3c7949eccc231829b644f59f5
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/module.ts
@@ -0,0 +1,26 @@
+import { CommonModule } from "@angular/common"
+import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"
+import { MatDialogModule } from "@angular/material/dialog"
+import { MatSnackBarModule } from "@angular/material/snack-bar"
+import { DragDropFileModule } from "src/dragDropFile"
+import { UserLayerDragDropDirective } from "./userlayerDragdrop.directive"
+import { UserLayerService } from "./service"
+import { MatButtonModule } from "@angular/material/button"
+import { MatTooltipModule } from "@angular/material/tooltip"
+import { UserLayerInfoCmp } from "./userlayerInfo/userlayerInfo.component"
+
+@NgModule({
+  imports: [
+    CommonModule,
+    DragDropFileModule,
+    MatSnackBarModule,
+    MatDialogModule,
+    MatButtonModule,
+    MatTooltipModule,
+  ],
+  declarations: [UserLayerDragDropDirective, UserLayerInfoCmp],
+  exports: [UserLayerDragDropDirective],
+  providers: [UserLayerService],
+  schemas: [CUSTOM_ELEMENTS_SCHEMA],
+})
+export class NehubaUserLayerModule {}
diff --git a/src/viewerModule/nehuba/userLayers/service.ts b/src/viewerModule/nehuba/userLayers/service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..607f01558d663ad9c5502ad4a25b7d926b40cc32
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/service.ts
@@ -0,0 +1,230 @@
+import { Injectable, OnDestroy } from "@angular/core"
+import { MatDialog } from "@angular/material/dialog"
+import { select, Store } from "@ngrx/store"
+import { concat, of, Subscription } from "rxjs"
+import { pairwise } from "rxjs/operators"
+import {
+  linearTransform,
+  TVALID_LINEAR_XFORM_DST,
+  TVALID_LINEAR_XFORM_SRC,
+} from "src/atlasComponents/sapi/core/space/interspaceLinearXform"
+import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"
+import { RouterService } from "src/routerModule/router.service"
+import { atlasAppearance } from "src/state"
+import { NgLayerCustomLayer } from "src/state/atlasAppearance"
+import { EnumColorMapName } from "src/util/colorMaps"
+import { getShader } from "src/util/constants"
+import { getExportNehuba, getUuid } from "src/util/fn"
+import { UserLayerInfoCmp } from "./userlayerInfo/userlayerInfo.component"
+
+type OmitKeys = "clType" | "id" | "source"
+type Meta = {
+  min?: number
+  max?: number
+  message?: string
+  filename: string
+}
+
+const OVERLAY_LAYER_KEY = "x-overlay-layer"
+
+@Injectable()
+export class UserLayerService implements OnDestroy {
+  private userLayerUrlToIdMap = new Map<string, string>()
+  private createdUrlRes = new Set<string>()
+
+  private supportedPrefix = ["nifti://", "precomputed://", "swc://"]
+
+  private verifyUrl(url: string) {
+    for (const prefix of this.supportedPrefix) {
+      if (url.includes(prefix)) return
+    }
+    throw new Error(
+      `url: ${url} does not start with supported prefixes ${this.supportedPrefix}`
+    )
+  }
+
+  async getCvtFileToUrl(file: File): Promise<{
+    url: string
+    meta: Meta
+    options?: Omit<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.`
+      }
+
+      const xform = await linearTransform(src, dst)
+
+      const url = URL.createObjectURL(file)
+      this.createdUrlRes.add(url)
+
+      return {
+        url: `swc://${url}`,
+        meta: {
+          filename: file.name,
+          message,
+        },
+        options: {
+          segments: ["1"],
+          transform: xform,
+        },
+      }
+    }
+
+    /**
+     * process as if nifti
+     */
+
+    // Get file, try to inflate, if files, use original array buffer
+    const buf = await file.arrayBuffer()
+    let outbuf
+    try {
+      outbuf = getExportNehuba().pako.inflate(buf).buffer
+    } catch (e) {
+      console.log("unpack error", e)
+      outbuf = buf
+    }
+
+    const { result } = await this.worker.sendMessage({
+      method: "PROCESS_NIFTI",
+      param: {
+        nifti: outbuf,
+      },
+      transfers: [outbuf],
+    })
+
+    const { meta, buffer } = result
+
+    const url = URL.createObjectURL(new Blob([buffer]))
+    return {
+      url: `nifti://${url}`,
+      meta: {
+        filename: file.name,
+        min: meta.min || 0,
+        max: meta.max || 1,
+        message: meta.message,
+      },
+      options: {
+        shader: getShader({
+          colormap: EnumColorMapName.MAGMA,
+          lowThreshold: meta.min || 0,
+          highThreshold: meta.max || 1,
+        }),
+      },
+    }
+  }
+
+  addUserLayer(
+    url: string,
+    meta: Meta,
+    options: Omit<NgLayerCustomLayer, OmitKeys> = {}
+  ) {
+    this.verifyUrl(url)
+    if (this.userLayerUrlToIdMap.has(url)) {
+      throw new Error(`url ${url} already added`)
+    }
+    const id = getUuid()
+    const layer: NgLayerCustomLayer = {
+      id,
+      clType: "customlayer/nglayer",
+      source: url,
+      ...options,
+    }
+    this.store$.dispatch(
+      atlasAppearance.actions.addCustomLayer({
+        customLayer: layer,
+      })
+    )
+
+    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.removeUserLayer(url))
+  }
+
+  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)
+  }
+
+  #subscription: Subscription[] = []
+  constructor(
+    private store$: Store,
+    private dialog: MatDialog,
+    private worker: AtlasWorkerService,
+    private routerSvc: RouterService
+  ) {
+    this.#subscription.push(
+      concat(
+        of(null),
+        this.routerSvc.customRoute$.pipe(select((v) => v[OVERLAY_LAYER_KEY]))
+      )
+        .pipe(pairwise())
+        .subscribe(([prev, curr]) => {
+          if (prev) {
+            this.removeUserLayer(prev)
+          }
+          if (curr) {
+            this.addUserLayer(
+              curr,
+              {
+                filename: curr,
+                message: `Overlay layer populated in URL`,
+              },
+              {
+                shader: getShader({
+                  colormap: EnumColorMapName.MAGMA,
+                }),
+              }
+            )
+          }
+        })
+    )
+  }
+
+  ngOnDestroy(): void {
+    while (this.#subscription.length > 0) this.#subscription.pop().unsubscribe()
+  }
+}
diff --git a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..be2c332759205abf28aa9d844244a325a7a5a106
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts
@@ -0,0 +1,145 @@
+import { Component, ViewChild } from "@angular/core"
+import { ComponentFixture, TestBed } from "@angular/core/testing"
+import { UserLayerDragDropDirective } from "./userlayerDragdrop.directive"
+import { UserLayerService } from "./service"
+import { NehubaUserLayerModule } from "./module"
+import { CommonModule } from "@angular/common"
+import { provideMockStore } from "@ngrx/store/testing"
+import { NoopAnimationsModule } from "@angular/platform-browser/animations"
+
+@Component({
+  template: `<div sxplr-nehuba-drag-drop></div>`,
+})
+class TestCmp {
+  @ViewChild(UserLayerDragDropDirective)
+  directive: UserLayerDragDropDirective
+}
+
+describe("dragdrop.directive.spec.ts", () => {
+  let fixture: ComponentFixture<TestCmp>
+
+  let addUserLayerSpy: jasmine.Spy
+  let removeUserLayerSpy: jasmine.Spy
+  let getCvtFileToUrlSpy: jasmine.Spy
+
+  let dummyFile1: File
+  let dummyFile2: File
+  let input: File[]
+
+  const meta = {}
+  const url = ""
+  const options = {}
+
+  describe("UserLayerDragDropDirective", () => {
+    beforeEach(() => {
+      TestBed.configureTestingModule({
+        imports: [CommonModule, NehubaUserLayerModule, NoopAnimationsModule],
+        declarations: [TestCmp],
+        providers: [
+          provideMockStore(),
+          {
+            provide: UserLayerService,
+            useValue: {
+              addUserLayer: () => {},
+              removeUserLayer: () => {},
+              getCvtFileToUrl: () => 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 })
+
+      fixture = TestBed.createComponent(TestCmp)
+      fixture.detectChanges()
+
+      dummyFile1 = (() => {
+        const bl: any = new Blob([], { type: "text" })
+        bl.name = "filename1.txt"
+        bl.lastModifiedDate = new Date()
+        return bl as File
+      })()
+
+      dummyFile2 = (() => {
+        const bl: any = new Blob([], { type: "text" })
+        bl.name = "filename2.txt"
+        bl.lastModifiedDate = new Date()
+        return bl as File
+      })()
+    })
+    afterEach(() => {
+      addUserLayerSpy.calls.reset()
+      removeUserLayerSpy.calls.reset()
+      getCvtFileToUrlSpy.calls.reset()
+    })
+
+    describe("> malformed input", () => {
+      const scenarios = [
+        {
+          desc: "too few files",
+          inp: [],
+        },
+        {
+          desc: "too many files",
+          inp: [dummyFile1, dummyFile2],
+        },
+      ]
+
+      for (const { desc, inp } of scenarios) {
+        describe(`> ${desc}`, () => {
+          beforeEach(async () => {
+            input = inp
+            const cmp = fixture.componentInstance
+            await cmp.directive.handleFileDrop(input)
+          })
+
+          it("> should not call addnglayer", () => {
+            expect(getCvtFileToUrlSpy).not.toHaveBeenCalled()
+          })
+
+          // TODO having a difficult time getting snackbar harness
+          // it('> snackbar should show error message', async () => {
+          //   console.log('get harness')
+
+          //   rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture)
+          //   const loader = TestbedHarnessEnvironment.loader(fixture)
+          //   fixture.detectChanges()
+          //   const snackbarHarness = await rootLoader.getHarness(MatSnackBarHarness)
+          //   console.log('got harness', snackbarHarness)
+          //   // const message = await snackbarHarness.getMessage()
+          //   // console.log('got message')
+          //   // expect(message).toEqual(INVALID_FILE_INPUT)
+          // })
+        })
+      }
+    })
+
+    describe("> correct input", () => {
+      beforeEach(async () => {
+        input = [dummyFile1]
+
+        const cmp = fixture.componentInstance
+        await cmp.directive.handleFileDrop(input)
+      })
+
+      it("> should call addNgLayer", () => {
+        expect(getCvtFileToUrlSpy).toHaveBeenCalledTimes(1)
+        const arg = getCvtFileToUrlSpy.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
new file mode 100644
index 0000000000000000000000000000000000000000..a56d677fd8b77ecce210dc7c2940ea7b1836472b
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts
@@ -0,0 +1,61 @@
+import {
+  ChangeDetectorRef,
+  Directive,
+  ElementRef,
+  OnDestroy,
+  OnInit,
+} from "@angular/core"
+import { MatSnackBar } from "@angular/material/snack-bar"
+import { Subscription } from "rxjs"
+import { DragDropFileDirective } from "src/dragDropFile/dragDrop.directive"
+import { UserLayerService } from "./service"
+import { CONST } from "common/constants"
+
+export const INVALID_FILE_INPUT = `Exactly one (1) file is required!`
+
+@Directive({
+  selector: "[sxplr-nehuba-drag-drop]",
+})
+export class UserLayerDragDropDirective
+  extends DragDropFileDirective
+  implements OnInit, OnDestroy
+{
+  public CONST = CONST
+  #subscription: Subscription[] = []
+
+  ngOnInit() {
+    this.snackText = CONST.NEHUBA_DRAG_DROP_TEXT
+    this.#subscription.push(
+      this.dragDropOnDrop.subscribe((event) => {
+        this.handleFileDrop(event)
+      })
+    )
+  }
+  ngOnDestroy() {
+    super.ngOnDestroy()
+    while (this.#subscription.length > 0) this.#subscription.pop().unsubscribe()
+  }
+
+  constructor(
+    private snackbar: MatSnackBar,
+    el: ElementRef,
+    cdr: ChangeDetectorRef,
+    private svc: UserLayerService
+  ) {
+    super(snackbar, el, cdr)
+  }
+
+  public async handleFileDrop(files: File[]): Promise<void> {
+    if (files.length !== 1) {
+      this.snackbar.open(INVALID_FILE_INPUT, "Dismiss", {
+        duration: 5000,
+      })
+      return
+    }
+    const file = files[0]
+
+    const { meta, url, options } = await this.svc.getCvtFileToUrl(file)
+
+    this.svc.addUserLayer(url, meta, options)
+  }
+}
diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fb6603223551791853fd9825aea84168a4bd48ce
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts
@@ -0,0 +1,30 @@
+import { Component, Inject } from "@angular/core";
+import { MAT_DIALOG_DATA } from "@angular/material/dialog";
+import { ARIA_LABELS, CONST } from 'common/constants'
+
+export type UserLayerInfoData = {
+  layerName: string
+  filename: string
+  min: number
+  max: number
+  warning: string[]
+}
+
+@Component({
+  selector: `sxplr-userlayer-info`,
+  templateUrl: './userlayerInfo.template.html',
+  styleUrls: [
+    './userlayerInfo.style.css'
+  ]
+})
+
+export class UserLayerInfoCmp {
+  ARIA_LABELS = ARIA_LABELS
+  CONST = CONST
+  constructor(
+    @Inject(MAT_DIALOG_DATA) public data: UserLayerInfoData
+  ){
+
+  }
+  public showMoreInfo = false
+}
diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.style.css b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.style.css
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html
new file mode 100644
index 0000000000000000000000000000000000000000..0f878b732221bfd13c24feb573e51d9013fea03a
--- /dev/null
+++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html
@@ -0,0 +1,37 @@
+<div class="grid grid-col-3">
+
+  <span class="ml-2 text-truncate v-center-text-span">
+    <i class="fas fa-file"></i>
+    {{ data.filename }}
+  </span>
+  
+  <button
+    [matTooltip]="ARIA_LABELS.VOLUME_TUNING_EXPAND"
+    mat-icon-button
+    [color]="showMoreInfo ? 'primary' : 'basic'"
+    (click)="showMoreInfo = !showMoreInfo">
+    <i class="fas fa-sliders-h"></i>
+  </button>
+
+  <button
+    [matTooltip]="ARIA_LABELS.CLOSE"
+    color="warn"
+    mat-icon-button
+    mat-dialog-close>
+    <i class="fas fa-trash"></i>
+  </button>
+
+  <div *ngIf="showMoreInfo"
+    class="sxplr-custom-cmp darker-bg overflow-hidden grid-wide-3">
+    <ng-layer-tune
+      advanced-control="true"
+      [ngLayerName]="data.layerName"
+      [thresholdMin]="data.min"
+      [thresholdMax]="data.max">
+    </ng-layer-tune>
+    <ul>
+      <li *ngFor="let warn of data.warning">{{ warn }}</li>
+    </ul>
+  </div>
+
+</div>
\ No newline at end of file