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