From 2f0c0331806d134803f1c4e0e966ba43fcbc813f Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Fri, 16 Aug 2019 16:41:32 +0200 Subject: [PATCH] experimental: redesigned viewer state controller --- src/components/components.module.ts | 6 + .../confirmDialog/confirmDialog.component.ts | 24 ++ .../confirmDialog/confirmDialog.style.css | 0 .../confirmDialog/confirmDialog.template.html | 16 + src/components/dialog/dialog.component.ts | 70 ++++ src/components/dialog/dialog.style.css | 0 src/components/dialog/dialog.template.html | 38 ++ src/components/flatTree/flatTree.component.ts | 6 + .../flatTree/flatTree.template.html | 4 +- src/components/sleightOfHand/soh.component.ts | 26 +- src/components/sleightOfHand/soh.style.css | 10 +- src/main.module.ts | 16 +- src/res/css/extra_styles.css | 23 +- src/res/ext/colinNehubaConfig.json | 180 +++++++++- src/services/dialogService.service.ts | 62 ++++ src/services/effect/effect.ts | 124 +++++-- src/services/state/userConfigState.store.ts | 340 ++++++++++++++++++ src/services/state/viewerState.store.ts | 11 +- src/services/stateStore.service.ts | 5 + src/ui/layerbrowser/layerbrowser.component.ts | 59 ++- .../layerbrowser/layerbrowser.template.html | 96 +++-- src/ui/menuicons/menuicons.component.ts | 106 +----- src/ui/menuicons/menuicons.style.css | 37 +- src/ui/menuicons/menuicons.template.html | 116 ++---- .../regionHierarchy.component.ts | 67 +--- .../sharedModules/angularMaterial.module.ts | 49 ++- .../signinBanner/signinBanner.components.ts | 249 +------------ .../signinBanner/signinBanner.template.html | 56 --- src/ui/ui.module.ts | 26 +- .../regionHierachy/filterNameBySearch.pipe.ts | 0 .../regionHierarchy.component.ts | 214 +++++++++++ .../regionHierachy/regionHierarchy.style.css | 17 +- .../regionHierarchy.template.html | 21 +- .../regionSearch/regionSearch.component.ts | 149 ++++++++ .../regionSearch/regionSearch.style.css | 4 + .../regionSearch/regionSearch.template.html | 45 +++ .../viewerState.component.ts | 304 ++++++++++++++++ .../viewerState.pipes.ts | 38 ++ .../viewerState.style.css | 35 ++ .../viewerState.template.html | 202 +++++++++++ .../viewerState.useEffect.ts | 180 ++++++++++ src/util/pipes/filterNgLayer.pipe.ts | 18 - src/util/pipes/getFileExt.pipe.ts | 36 ++ .../pipes/getFileNameFromPathName.pipe.ts | 12 - src/util/pipes/getFilename.pipe.ts | 14 + 45 files changed, 2375 insertions(+), 736 deletions(-) create mode 100644 src/components/confirmDialog/confirmDialog.component.ts create mode 100644 src/components/confirmDialog/confirmDialog.style.css create mode 100644 src/components/confirmDialog/confirmDialog.template.html create mode 100644 src/components/dialog/dialog.component.ts create mode 100644 src/components/dialog/dialog.style.css create mode 100644 src/components/dialog/dialog.template.html create mode 100644 src/services/dialogService.service.ts create mode 100644 src/services/state/userConfigState.store.ts rename src/ui/{ => viewerStateController}/regionHierachy/filterNameBySearch.pipe.ts (100%) create mode 100644 src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts rename src/ui/{ => viewerStateController}/regionHierachy/regionHierarchy.style.css (86%) rename src/ui/{ => viewerStateController}/regionHierachy/regionHierarchy.template.html (72%) create mode 100644 src/ui/viewerStateController/regionSearch/regionSearch.component.ts create mode 100644 src/ui/viewerStateController/regionSearch/regionSearch.style.css create mode 100644 src/ui/viewerStateController/regionSearch/regionSearch.template.html create mode 100644 src/ui/viewerStateController/viewerState.component.ts create mode 100644 src/ui/viewerStateController/viewerState.pipes.ts create mode 100644 src/ui/viewerStateController/viewerState.style.css create mode 100644 src/ui/viewerStateController/viewerState.template.html create mode 100644 src/ui/viewerStateController/viewerState.useEffect.ts delete mode 100644 src/util/pipes/filterNgLayer.pipe.ts create mode 100644 src/util/pipes/getFileExt.pipe.ts delete mode 100644 src/util/pipes/getFileNameFromPathName.pipe.ts create mode 100644 src/util/pipes/getFilename.pipe.ts diff --git a/src/components/components.module.ts b/src/components/components.module.ts index f6114ed86..d944ca40e 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -30,6 +30,8 @@ import { RadioList } from './radiolist/radiolist.component'; import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module'; import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; import { SleightOfHand } from './sleightOfHand/soh.component'; +import { DialogComponent } from './dialog/dialog.component'; +import { ConfirmDialogComponent } from './confirmDialog/confirmDialog.component'; @NgModule({ @@ -54,6 +56,8 @@ import { SleightOfHand } from './sleightOfHand/soh.component'; PillComponent, RadioList, SleightOfHand, + DialogComponent, + ConfirmDialogComponent, /* directive */ HoverableBlockDirective, @@ -86,6 +90,8 @@ import { SleightOfHand } from './sleightOfHand/soh.component'; PillComponent, RadioList, SleightOfHand, + DialogComponent, + ConfirmDialogComponent, SearchResultPaginationPipe, TreeSearchPipe, diff --git a/src/components/confirmDialog/confirmDialog.component.ts b/src/components/confirmDialog/confirmDialog.component.ts new file mode 100644 index 000000000..6c16434ac --- /dev/null +++ b/src/components/confirmDialog/confirmDialog.component.ts @@ -0,0 +1,24 @@ +import { Component, Inject, Input } from "@angular/core"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material"; + +@Component({ + selector: 'confirm-dialog-component', + templateUrl: './confirmDialog.template.html', + styleUrls: [ + './confirmDialog.style.css' + ] +}) +export class ConfirmDialogComponent{ + + @Input() + public title: string = 'Confirm' + + @Input() + public message: string = 'Would you like to proceed?' + + constructor(@Inject(MAT_DIALOG_DATA) data: any){ + const { title = null, message = null} = data || {} + if (title) this.title = title + if (message) this.message = message + } +} \ No newline at end of file diff --git a/src/components/confirmDialog/confirmDialog.style.css b/src/components/confirmDialog/confirmDialog.style.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/confirmDialog/confirmDialog.template.html b/src/components/confirmDialog/confirmDialog.template.html new file mode 100644 index 000000000..eb0c76fff --- /dev/null +++ b/src/components/confirmDialog/confirmDialog.template.html @@ -0,0 +1,16 @@ +<h1 mat-dialog-title> + {{ title }} +</h1> + +<mat-dialog-content> + <p> + {{ message }} + </p> +</mat-dialog-content> + +<mat-divider></mat-divider> + +<mat-dialog-actions class="justify-content-start flex-row-reverse"> + <button [mat-dialog-close]="true" mat-raised-button color="primary">OK</button> + <button [mat-dialog-close]="false" mat-button>Cancel</button> +</mat-dialog-actions> \ No newline at end of file diff --git a/src/components/dialog/dialog.component.ts b/src/components/dialog/dialog.component.ts new file mode 100644 index 000000000..ac9607865 --- /dev/null +++ b/src/components/dialog/dialog.component.ts @@ -0,0 +1,70 @@ +import { Component, Input, ChangeDetectionStrategy, ViewChild, ElementRef, OnInit, OnDestroy, Inject } from "@angular/core"; +import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material"; +import { Subscription, Observable, fromEvent } from "rxjs"; +import { filter, share } from "rxjs/operators"; + +@Component({ + selector: 'dialog-component', + templateUrl: './dialog.template.html', + styleUrls: [ + './dialog.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class DialogComponent implements OnInit, OnDestroy { + + private subscrptions: Subscription[] = [] + + @Input() title: string = 'Message' + @Input() placeholder: string = "Type your response here" + @Input() defaultValue: string = '' + @Input() message: string = '' + @ViewChild('inputField', {read: ElementRef}) private inputField: ElementRef + + private value: string = '' + private keyListener$: Observable<any> + + constructor( + @Inject(MAT_DIALOG_DATA) public data:any, + private dialogRef: MatDialogRef<DialogComponent> + ){ + const { title, placeholder, defaultValue, message } = this.data + if (title) this.title = title + if (placeholder) this.placeholder = placeholder + if (defaultValue) this.value = defaultValue + if (message) this.message = message + } + + ngOnInit(){ + + this.keyListener$ = fromEvent(this.inputField.nativeElement, 'keyup').pipe( + filter((ev: KeyboardEvent) => ev.key === 'Enter' || ev.key === 'Esc' || ev.key === 'Escape'), + share() + ) + this.subscrptions.push( + this.keyListener$.subscribe(ev => { + if (ev.key === 'Enter') { + this.dialogRef.close(this.value) + } + if (ev.key === 'Esc' || ev.key === 'Escape') { + this.dialogRef.close(null) + } + }) + ) + } + + confirm(){ + this.dialogRef.close(this.value) + } + + cancel(){ + this.dialogRef.close(null) + } + + ngOnDestroy(){ + while(this.subscrptions.length > 0) { + this.subscrptions.pop().unsubscribe() + } + } +} \ No newline at end of file diff --git a/src/components/dialog/dialog.style.css b/src/components/dialog/dialog.style.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/dialog/dialog.template.html b/src/components/dialog/dialog.template.html new file mode 100644 index 000000000..311b6f30f --- /dev/null +++ b/src/components/dialog/dialog.template.html @@ -0,0 +1,38 @@ +<h1 mat-dialog-title> + {{ title }} +</h1> + +<div> + {{ message }} +</div> + +<div mat-dialog-content> + <mat-form-field> + <input + tabindex="0" + [(ngModel)]="value" + matInput + [placeholder]="placeholder" + #inputField> + </mat-form-field> +</div> + +<mat-divider></mat-divider> + +<div class="mt-2 d-flex flex-row justify-content-end"> + <button + (click)="cancel()" + color="primary" + mat-button> + Cancel + </button> + + <button + (click)="confirm()" + class="ml-1" + mat-raised-button + color="primary"> + <i class="fas fa-save mr-1"></i> + Confirm + </button> +</div> \ No newline at end of file diff --git a/src/components/flatTree/flatTree.component.ts b/src/components/flatTree/flatTree.component.ts index d8cd1d832..5d565cd48 100644 --- a/src/components/flatTree/flatTree.component.ts +++ b/src/components/flatTree/flatTree.component.ts @@ -92,4 +92,10 @@ export class FlatTreeComponent implements AfterViewChecked { .some(id => this.isCollapsedById(id)) } + handleTreeNodeClick(event:MouseEvent, inputItem: any){ + this.treeNodeClick.emit({ + event, + inputItem + }) + } } \ No newline at end of file diff --git a/src/components/flatTree/flatTree.template.html b/src/components/flatTree/flatTree.template.html index 48ff87a25..893c539a9 100644 --- a/src/components/flatTree/flatTree.template.html +++ b/src/components/flatTree/flatTree.template.html @@ -27,7 +27,7 @@ <i [ngClass]="isCollapsed(flattenedItem) ? 'r-270' : ''" class="fas fa-chevron-down"></i> </span> <span - (click)="treeNodeClick.emit({event:$event,inputItem:flattenedItem})" + (click)="handleTreeNodeClick($event, flattenedItem)" class="render-node-text" [innerHtml]="flattenedItem | renderPipe : renderNode "> </span> @@ -63,7 +63,7 @@ <i [ngClass]="isCollapsed(flattenedItem) ? 'r-270' : ''" class="fas fa-chevron-down"></i> </span> <span - (click)="treeNodeClick.emit({event:$event,inputItem:flattenedItem})" + (click)="handleTreeNodeClick($event, flattenedItem)" class="render-node-text" [innerHtml]="flattenedItem | renderPipe : renderNode "> </span> diff --git a/src/components/sleightOfHand/soh.component.ts b/src/components/sleightOfHand/soh.component.ts index ed7d8cfab..4b6fbe5fd 100644 --- a/src/components/sleightOfHand/soh.component.ts +++ b/src/components/sleightOfHand/soh.component.ts @@ -1,13 +1,33 @@ -import { Component } from "@angular/core"; +import { Component, Input, HostBinding, ChangeDetectionStrategy, HostListener } from "@angular/core"; @Component({ selector: 'sleight-of-hand', templateUrl: './soh.template.html', styleUrls: [ './soh.style.css' - ] + ], + changeDetection: ChangeDetectionStrategy.OnPush }) export class SleightOfHand{ - + + @HostBinding('class.do-not-close') + get doNotCloseClass(){ + return this.doNotClose || this.focusInStatus + } + + @HostListener('focusin') + focusInHandler(){ + this.focusInStatus = true + } + + @HostListener('focusout') + focusOutHandler(){ + this.focusInStatus = false + } + + private focusInStatus: boolean = false + + @Input() + doNotClose: boolean = false } \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.style.css b/src/components/sleightOfHand/soh.style.css index b862eb721..2c4f61aa2 100644 --- a/src/components/sleightOfHand/soh.style.css +++ b/src/components/sleightOfHand/soh.style.css @@ -1,10 +1,6 @@ -:host:not(:hover) > .sleight-of-hand-back -{ - opacity: 0; - pointer-events: none; -} - -:host:hover > .sleight-of-hand-front +:host:not(.do-not-close):not(:hover) > .sleight-of-hand-back, +:host:not(.do-not-close):hover > .sleight-of-hand-front, +:host-context(.do-not-close) > .sleight-of-hand-front { opacity: 0; pointer-events: none; diff --git a/src/main.module.ts b/src/main.module.ts index 1fb66d55c..3820e2b00 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -5,7 +5,7 @@ import { UIModule } from "./ui/ui.module"; import { LayoutModule } from "./layouts/layout.module"; import { AtlasViewer } from "./atlasViewer/atlasViewer.component"; import { StoreModule, Store, select } from "@ngrx/store"; -import { viewerState, dataStore,spatialSearchState,uiState, ngViewerState, pluginState, viewerConfigState } from "./services/stateStore.service"; +import { viewerState, dataStore,spatialSearchState,uiState, ngViewerState, pluginState, viewerConfigState, userConfigState, UserConfigStateUseEffect } from "./services/stateStore.service"; import { GetNamesPipe } from "./util/pipes/getNames.pipe"; import { CommonModule } from "@angular/common"; import { GetNamePipe } from "./util/pipes/getName.pipe"; @@ -22,7 +22,6 @@ import { ModalModule } from 'ngx-bootstrap/modal' import { ModalUnit } from "./atlasViewer/modalUnit/modalUnit.component"; import { AtlasViewerURLService } from "./atlasViewer/atlasViewer.urlService.service"; import { ToastComponent } from "./components/toast/toast.component"; -import { GetFilenameFromPathnamePipe } from "./util/pipes/getFileNameFromPathName.pipe"; import { AtlasViewerAPIServices } from "./atlasViewer/atlasViewer.apiService.service"; import { PluginUnit } from "./atlasViewer/pluginUnit/pluginUnit.component"; import { NewViewerDisctinctViewToLayer } from "./util/pipes/newViewerDistinctViewToLayer.pipe"; @@ -42,6 +41,10 @@ import { TransformOnhoverSegmentPipe } from "./atlasViewer/onhoverSegment.pipe"; import {HttpClientModule} from "@angular/common/http"; import { EffectsModule } from "@ngrx/effects"; import { UseEffects } from "./services/effect/effect"; +import { DialogService } from "./services/dialogService.service"; +import { DialogComponent } from "./components/dialog/dialog.component"; +import { ViewerStateControllerUseEffect } from "./ui/viewerStateController/viewerState.useEffect"; +import { ConfirmDialogComponent } from "./components/confirmDialog/confirmDialog.component"; @NgModule({ imports : [ @@ -57,7 +60,9 @@ import { UseEffects } from "./services/effect/effect"; TooltipModule.forRoot(), TabsModule.forRoot(), EffectsModule.forRoot([ - UseEffects + UseEffects, + UserConfigStateUseEffect, + ViewerStateControllerUseEffect ]), StoreModule.forRoot({ pluginState, @@ -67,6 +72,7 @@ import { UseEffects } from "./services/effect/effect"; dataStore, spatialSearchState, uiState, + userConfigState }), HttpClientModule ], @@ -96,7 +102,6 @@ import { UseEffects } from "./services/effect/effect"; GetNamesPipe, GetNamePipe, TransformOnhoverSegmentPipe, - GetFilenameFromPathnamePipe, NewViewerDisctinctViewToLayer ], entryComponents : [ @@ -104,6 +109,8 @@ import { UseEffects } from "./services/effect/effect"; ModalUnit, ToastComponent, PluginUnit, + DialogComponent, + ConfirmDialogComponent, ], providers : [ AtlasViewerDataService, @@ -113,6 +120,7 @@ import { UseEffects } from "./services/effect/effect"; ToastService, AtlasWorkerService, AuthService, + DialogService, /** * TODO diff --git a/src/res/css/extra_styles.css b/src/res/css/extra_styles.css index bef52b792..c197b4d65 100644 --- a/src/res/css/extra_styles.css +++ b/src/res/css/extra_styles.css @@ -301,11 +301,26 @@ markdown-dom pre code max-width: 60%!important; } +.mw-20em +{ + max-width: 20em!important; +} + +.w-20em +{ + width: 20em!important; +} + .mh-20em { max-height: 20em; } +.mh-10em +{ + max-height: 10em; +} + .pe-all { pointer-events: all; @@ -333,7 +348,7 @@ markdown-dom pre code .overflow-x-hidden { - overflow-x:hidden; + overflow-x:hidden!important; } .muted @@ -360,4 +375,10 @@ markdown-dom pre code .bs-content-box { box-sizing: content-box; +} + +/* required to hide */ +.cdk-global-scrollblock +{ + overflow-y:hidden !important; } \ No newline at end of file diff --git a/src/res/ext/colinNehubaConfig.json b/src/res/ext/colinNehubaConfig.json index 7b40174f1..6028ab6b9 100644 --- a/src/res/ext/colinNehubaConfig.json +++ b/src/res/ext/colinNehubaConfig.json @@ -1 +1,179 @@ -{"globals":{"hideNullImageValues":true,"useNehubaLayout":true,"useNehubaMeshLayer":true,"useCustomSegmentColors":true},"zoomWithoutCtrl":true,"hideNeuroglancerUI":true,"rightClickWithCtrl":true,"rotateAtViewCentre":true,"zoomAtViewCentre":true,"enableMeshLoadingControl":true,"layout":{"useNehubaPerspective":{"fixedZoomPerspectiveSlices":{"sliceViewportWidth":300,"sliceViewportHeight":300,"sliceZoom":724698.1843689409,"sliceViewportSizeMultiplier":2},"centerToOrigin":true,"mesh":{"removeBasedOnNavigation":true,"flipRemovedOctant":true,"surfaceParcellation":false},"removePerspectiveSlicesBackground":{"mode":"=="},"waitForMesh":false,"drawSubstrates":{"color":[0.5,0.5,1,0.2]},"drawZoomLevels":{"cutOff":150000},"restrictZoomLevel":{"minZoom":2500000,"maxZoom":3500000}}},"dataset":{"imageBackground":[0,0,0,1],"initialNgState":{"showDefaultAnnotations":false,"layers":{"colin":{"type":"image","visible":true,"source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/v2.2c/colin27_seg","transform":[[1,0,0,-75500000],[0,1,0,-111500000],[0,0,1,-67500000],[0,0,0,1]]},"jubrain v2_2c":{"type":"segmentation","source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/v2.2c/MPM","transform":[[1,0,0,-75500000],[0,1,0,-111500000],[0,0,1,-67500000],[0,0,0,1]]},"jubrain colin v17 left":{"type":"segmentation","visible":true,"source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/left","transform":[[1,0,0,-128500000],[0,1,0,-148500000],[0,0,1,-110500000],[0,0,0,1]]},"jubrain colin v17 right":{"type":"segmentation","visible":true,"source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/right","transform":[[1,0,0,-128500000],[0,1,0,-148500000],[0,0,1,-110500000],[0,0,0,1]]}},"navigation":{"pose":{"position":{"voxelSize":[1000000,1000000,1000000],"voxelCoordinates":[0,-32,0]}},"zoomFactor":1000000},"perspectiveOrientation":[-0.2753947079181671,0.6631333827972412,-0.6360703706741333,0.2825356423854828],"perspectiveZoom":3000000}}} \ No newline at end of file +{ + "globals": { + "hideNullImageValues": true, + "useNehubaLayout": true, + "useNehubaMeshLayer": true, + "useCustomSegmentColors": true + }, + "zoomWithoutCtrl": true, + "hideNeuroglancerUI": true, + "rightClickWithCtrl": true, + "rotateAtViewCentre": true, + "zoomAtViewCentre": true, + "enableMeshLoadingControl": true, + "layout": { + "useNehubaPerspective": { + "fixedZoomPerspectiveSlices": { + "sliceViewportWidth": 300, + "sliceViewportHeight": 300, + "sliceZoom": 724698.1843689409, + "sliceViewportSizeMultiplier": 2 + }, + "centerToOrigin": true, + "mesh": { + "removeBasedOnNavigation": true, + "flipRemovedOctant": true, + "surfaceParcellation": false + }, + "removePerspectiveSlicesBackground": { + "mode": "==" + }, + "waitForMesh": false, + "drawSubstrates": { + "color": [ + 0.5, + 0.5, + 1, + 0.2 + ] + }, + "drawZoomLevels": { + "cutOff": 150000 + }, + "restrictZoomLevel": { + "minZoom": 2500000, + "maxZoom": 3500000 + } + } + }, + "dataset": { + "imageBackground": [ + 0, + 0, + 0, + 1 + ], + "initialNgState": { + "showDefaultAnnotations": false, + "layers": { + "colin": { + "type": "image", + "visible": true, + "source": "precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/v2.2c/colin27_seg", + "transform": [ + [ + 1, + 0, + 0, + -75500000 + ], + [ + 0, + 1, + 0, + -111500000 + ], + [ + 0, + 0, + 1, + -67500000 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "jubrain colin v17 left": { + "type": "segmentation", + "visible": true, + "source": "precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/left", + "transform": [ + [ + 1, + 0, + 0, + -128500000 + ], + [ + 0, + 1, + 0, + -148500000 + ], + [ + 0, + 0, + 1, + -110500000 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "jubrain colin v17 right": { + "type": "segmentation", + "visible": true, + "source": "precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/right", + "transform": [ + [ + 1, + 0, + 0, + -128500000 + ], + [ + 0, + 1, + 0, + -148500000 + ], + [ + 0, + 0, + 1, + -110500000 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + }, + "navigation": { + "pose": { + "position": { + "voxelSize": [ + 1000000, + 1000000, + 1000000 + ], + "voxelCoordinates": [ + 0, + -32, + 0 + ] + } + }, + "zoomFactor": 1000000 + }, + "perspectiveOrientation": [ + -0.2753947079181671, + 0.6631333827972412, + -0.6360703706741333, + 0.2825356423854828 + ], + "perspectiveZoom": 3000000 + } + } +} \ No newline at end of file diff --git a/src/services/dialogService.service.ts b/src/services/dialogService.service.ts new file mode 100644 index 000000000..110edf666 --- /dev/null +++ b/src/services/dialogService.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@angular/core"; +import { MatDialog, MatDialogRef } from "@angular/material"; +import { DialogComponent } from "src/components/dialog/dialog.component"; +import { ConfirmDialogComponent } from "src/components/confirmDialog/confirmDialog.component"; + + +@Injectable({ + providedIn: 'root' +}) + +export class DialogService{ + + private dialogRef: MatDialogRef<DialogComponent> + private confirmDialogRef: MatDialogRef<ConfirmDialogComponent> + + constructor(private dialog:MatDialog){ + + } + + public getUserConfirm(config: Partial<DialogConfig> = {}): Promise<string>{ + this.confirmDialogRef = this.dialog.open(ConfirmDialogComponent, { + data: config + }) + return new Promise((resolve, reject) => this.confirmDialogRef.afterClosed() + .subscribe(val => { + if (val) resolve() + else reject('User cancelled') + }, + reject, + () => this.confirmDialogRef = null)) + } + + public getUserInput(config: Partial<DialogConfig> = {}):Promise<string>{ + const { defaultValue = '', placeholder = 'Type your response here', title = 'Message', message = '' } = config + this.dialogRef = this.dialog.open(DialogComponent, { + data: { + title, + placeholder, + defaultValue, + message + } + }) + return new Promise((resolve, reject) => { + /** + * nb: one one value is ever emitted, then the subscription ends + * Should not result in leak + */ + this.dialogRef.afterClosed().subscribe(value => { + if (value) resolve(value) + else reject('User cancelled input') + this.dialogRef = null + }) + }) + } +} + +export interface DialogConfig{ + title: string + placeholder: string + defaultValue: string + message: string +} \ No newline at end of file diff --git a/src/services/effect/effect.ts b/src/services/effect/effect.ts index 7ab1195be..603ed7be8 100644 --- a/src/services/effect/effect.ts +++ b/src/services/effect/effect.ts @@ -1,9 +1,9 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Effect, Actions, ofType } from "@ngrx/effects"; import { Subscription, merge, fromEvent, combineLatest, Observable } from "rxjs"; -import { withLatestFrom, map, filter, shareReplay } from "rxjs/operators"; +import { withLatestFrom, map, filter, shareReplay, tap, switchMap, take } from "rxjs/operators"; import { Store, select } from "@ngrx/store"; -import { SELECT_PARCELLATION, SELECT_REGIONS, NEWVIEWER, UPDATE_PARCELLATION, SELECT_REGIONS_WITH_ID, DESELECT_REGIONS } from "../state/viewerState.store"; +import { SELECT_PARCELLATION, SELECT_REGIONS, NEWVIEWER, UPDATE_PARCELLATION, SELECT_REGIONS_WITH_ID, DESELECT_REGIONS, ADD_TO_REGIONS_SELECTION_WITH_IDS } from "../state/viewerState.store"; import { worker } from 'src/atlasViewer/atlasViewer.workerService.service' import { getNgIdLabelIndexFromId, generateLabelIndexId, recursiveFindRegionWithLabelIndexId } from '../stateStore.service'; @@ -44,6 +44,27 @@ export class UseEffects implements OnDestroy{ } }) ) + + this.addToSelectedRegions$ = this.actions$.pipe( + ofType(ADD_TO_REGIONS_SELECTION_WITH_IDS), + map(action => { + const { selectRegionIds } = action + return selectRegionIds + }), + switchMap(selectRegionIds => this.updatedParcellation$.pipe( + filter(p => !!p), + take(1), + map(p => [selectRegionIds, p]) + )), + map(this.convertRegionIdsToRegion), + withLatestFrom(this.regionsSelected$), + map(([ selectedRegions, alreadySelectedRegions ]) => { + return { + type: SELECT_REGIONS, + selectRegions: this.removeDuplicatedRegions(selectedRegions, alreadySelectedRegions) + } + }) + ) } private regionsSelected$: Observable<any[]> @@ -75,48 +96,76 @@ export class UseEffects implements OnDestroy{ private updatedParcellation$ = this.store$.pipe( select('viewerState'), select('parcellationSelected'), - filter(p => !!p && !!p.regions) + map(p => p.updated ? p : null), + shareReplay(1) ) @Effect() onDeselectRegions: Observable<any> + private convertRegionIdsToRegion = ([selectRegionIds, parcellation]) => { + const { ngId: defaultNgId } = parcellation + return (<any[]>selectRegionIds) + .map(labelIndexId => getNgIdLabelIndexFromId({ labelIndexId })) + .map(({ ngId, labelIndex }) => { + return { + labelIndexId: generateLabelIndexId({ + ngId: ngId || defaultNgId, + labelIndex + }) + } + }) + .map(({ labelIndexId }) => { + return recursiveFindRegionWithLabelIndexId({ + regions: parcellation.regions, + labelIndexId, + inheritedNgId: defaultNgId + }) + }) + .filter(v => { + if (!v) { + console.log(`SELECT_REGIONS_WITH_ID, some ids cannot be parsed intto label index`) + } + return !!v + }) + } + + private removeDuplicatedRegions = (...args) => { + const set = new Set() + const returnArr = [] + for (const regions of args){ + for (const region of regions){ + if (!set.has(region.name)) { + returnArr.push(region) + set.add(region.name) + } + } + } + return returnArr + } + + @Effect() + addToSelectedRegions$: Observable<any> + + /** * for backwards compatibility. * older versions of atlas viewer may only have labelIndex as region identifier */ @Effect() - onSelectRegionWithId = combineLatest( - this.actions$.pipe( - ofType(SELECT_REGIONS_WITH_ID) - ), - this.updatedParcellation$ - ).pipe( - map(([action, parcellation]) => { + onSelectRegionWithId = this.actions$.pipe( + ofType(SELECT_REGIONS_WITH_ID), + map(action => { const { selectRegionIds } = action - const { ngId: defaultNgId } = parcellation - - const selectRegions = (<any[]>selectRegionIds) - .map(labelIndexId => getNgIdLabelIndexFromId({ labelIndexId })) - .map(({ ngId, labelIndex }) => { - return { - labelIndexId: generateLabelIndexId({ - ngId: ngId || defaultNgId, - labelIndex - }) - } - }) - .map(({ labelIndexId }) => { - return recursiveFindRegionWithLabelIndexId({ - regions: parcellation.regions, - labelIndexId, - inheritedNgId: defaultNgId - }) - }) - .filter(v => { - if (!v) console.log(`SELECT_REGIONS_WITH_ID, some ids cannot be parsed intto label index`) - return !!v - }) + return selectRegionIds + }), + switchMap(selectRegionIds => this.updatedParcellation$.pipe( + filter(p => !!p), + take(1), + map(parcellation => [selectRegionIds, parcellation]) + )), + map(this.convertRegionIdsToRegion), + map(selectRegions => { return { type: SELECT_REGIONS, selectRegions @@ -143,7 +192,14 @@ export class UseEffects implements OnDestroy{ filter((message: MessageEvent) => message && message.data && message.data.type === 'UPDATE_PARCELLATION_REGIONS'), map(({data}) => data.parcellation), withLatestFrom(this.newParcellationSelected$), - filter(([ propagatedP, selectedP ] : [any, any]) => propagatedP.name === selectedP.name), + filter(([ propagatedP, selectedP ] : [any, any]) => { + /** + * TODO + * use id + * but jubrain may have same id for different template spaces + */ + return propagatedP.name === selectedP.name + }), map(([ propagatedP, _ ]) => propagatedP), map(parcellation => ({ type: UPDATE_PARCELLATION, diff --git a/src/services/state/userConfigState.store.ts b/src/services/state/userConfigState.store.ts new file mode 100644 index 000000000..a4af6c342 --- /dev/null +++ b/src/services/state/userConfigState.store.ts @@ -0,0 +1,340 @@ +import { Action, Store, select } from "@ngrx/store"; +import { Injectable, OnDestroy } from "@angular/core"; +import { Actions, Effect, ofType } from "@ngrx/effects"; +import { Observable, combineLatest, Subscription, from, of } from "rxjs"; +import { shareReplay, withLatestFrom, map, distinctUntilChanged, filter, take, tap, switchMap, catchError, share } from "rxjs/operators"; +import { generateLabelIndexId, recursiveFindRegionWithLabelIndexId } from "../stateStore.service"; +import { SELECT_REGIONS, NEWVIEWER, SELECT_PARCELLATION } from "./viewerState.store"; +import { DialogService } from "../dialogService.service"; + +interface UserConfigState{ + savedRegionsSelection: RegionSelection[] +} + +export interface RegionSelection{ + templateSelected: any + parcellationSelected: any + regionsSelected: any[] + name: string + id: string +} + +/** + * for serialisation into local storage/database + */ +interface SimpleRegionSelection{ + id: string, + name: string, + tName: string, + pName: string, + rSelected: string[] +} + +interface UserConfigAction extends Action{ + config?: Partial<UserConfigState> + payload?: any +} + +const defaultUserConfigState: UserConfigState = { + savedRegionsSelection: [] +} + +const ACTION_TYPES = { + UPDATE_REGIONS_SELECTIONS: `UPDATE_REGIONS_SELECTIONS`, + UPDATE_REGIONS_SELECTION:'UPDATE_REGIONS_SELECTION', + SAVE_REGIONS_SELECTION: `SAVE_REGIONS_SELECTIONN`, + DELETE_REGIONS_SELECTION: 'DELETE_REGIONS_SELECTION', + + LOAD_REGIONS_SELECTION: 'LOAD_REGIONS_SELECTION' +} + +export const USER_CONFIG_ACTION_TYPES = ACTION_TYPES + +export function userConfigState(prevState: UserConfigState = defaultUserConfigState, action: UserConfigAction) { + switch(action.type) { + case ACTION_TYPES.UPDATE_REGIONS_SELECTIONS: + const { config = {} } = action + const { savedRegionsSelection } = config + return { + ...prevState, + savedRegionsSelection + } + default: + return { + ...prevState + } + } +} + +@Injectable({ + providedIn: 'root' +}) +export class UserConfigStateUseEffect implements OnDestroy{ + + private subscriptions: Subscription[] = [] + + constructor( + private actions$: Actions, + private store$: Store<any>, + private dialogService: DialogService + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + share() + ) + + this.tprSelected$ = combineLatest( + viewerState$.pipe( + select('templateSelected'), + distinctUntilChanged() + ), + this.parcellationSelected$, + viewerState$.pipe( + select('regionsSelected'), + /** + * TODO + * distinct selectedRegions + */ + ) + ).pipe( + map(([ templateSelected, parcellationSelected, regionsSelected ]) => { + return { + templateSelected, parcellationSelected, regionsSelected + } + }), + shareReplay(1) + ) + + this.savedRegionsSelections$ = this.store$.pipe( + select('userConfigState'), + select('savedRegionsSelection'), + shareReplay(1) + ) + + this.onSaveRegionsSelection$ = this.actions$.pipe( + ofType(ACTION_TYPES.SAVE_REGIONS_SELECTION), + withLatestFrom(this.tprSelected$), + withLatestFrom(this.savedRegionsSelections$), + + map(([[action, tprSelected], savedRegionsSelection]) => { + const { payload = {} } = action as UserConfigAction + const { name = 'Untitled' } = payload + + const { templateSelected, parcellationSelected, regionsSelected } = tprSelected + const newSavedRegionSelection: RegionSelection = { + id: Date.now().toString(), + name, + templateSelected, + parcellationSelected, + regionsSelected + } + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { + savedRegionsSelection: savedRegionsSelection.concat([newSavedRegionSelection]) + } + } as UserConfigAction + }) + ) + + this.onDeleteRegionsSelection$ = this.actions$.pipe( + ofType(ACTION_TYPES.DELETE_REGIONS_SELECTION), + withLatestFrom(this.savedRegionsSelections$), + map(([ action, savedRegionsSelection ]) => { + const { payload = {} } = action as UserConfigAction + const { id } = payload + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { + savedRegionsSelection: savedRegionsSelection.filter(srs => srs.id !== id) + } + } + }) + ) + + this.onUpdateRegionsSelection$ = this.actions$.pipe( + ofType(ACTION_TYPES.UPDATE_REGIONS_SELECTION), + withLatestFrom(this.savedRegionsSelections$), + map(([ action, savedRegionsSelection]) => { + const { payload = {} } = action as UserConfigAction + const { id, ...rest } = payload + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { + savedRegionsSelection: savedRegionsSelection + .map(srs => srs.id === id + ? { ...srs, ...rest } + : { ...srs }) + } + } + }) + ) + + this.subscriptions.push( + this.actions$.pipe( + ofType(ACTION_TYPES.LOAD_REGIONS_SELECTION), + map(action => { + const { payload = {}} = action as UserConfigAction + const { savedRegionsSelection } : {savedRegionsSelection : RegionSelection} = payload + return savedRegionsSelection + }), + filter(val => !!val), + withLatestFrom(this.tprSelected$), + switchMap(([savedRegionsSelection, { parcellationSelected, templateSelected, regionsSelected }]) => + from(this.dialogService.getUserConfirm({ + title: `Load region selection: ${savedRegionsSelection.name}`, + message: `This action would cause the viewer to navigate away from the current view. Proceed?` + })).pipe( + catchError((e, obs) => of(null)), + map(() => { + return { + savedRegionsSelection, + parcellationSelected, + templateSelected, + regionsSelected + } + }), + filter(val => !!val) + ) + ), + switchMap(({ savedRegionsSelection, parcellationSelected, templateSelected, regionsSelected }) => { + if (templateSelected.name !== savedRegionsSelection.templateSelected.name ) { + /** + * template different, dispatch NEWVIEWER + */ + this.store$.dispatch({ + type: NEWVIEWER, + selectParcellation: savedRegionsSelection.parcellationSelected, + selectTemplate: savedRegionsSelection.templateSelected + }) + return this.parcellationSelected$.pipe( + filter(p => p.updated), + take(1), + map(() => { + return { + regionsSelected: savedRegionsSelection.regionsSelected + } + }) + ) + } + + if (parcellationSelected.name !== savedRegionsSelection.parcellationSelected.name) { + /** + * parcellation different, dispatch SELECT_PARCELLATION + */ + + this.store$.dispatch({ + type: SELECT_PARCELLATION, + selectParcellation: savedRegionsSelection.parcellationSelected + }) + return this.parcellationSelected$.pipe( + filter(p => p.updated), + take(1), + map(() => { + return { + regionsSelected: savedRegionsSelection.regionsSelected + } + }) + ) + } + + return of({ + regionsSelected: savedRegionsSelection.regionsSelected + }) + }) + ).subscribe(({ regionsSelected }) => { + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: regionsSelected + }) + }) + ) + + this.subscriptions.push( + this.actions$.pipe( + ofType(ACTION_TYPES.UPDATE_REGIONS_SELECTIONS) + ).subscribe(action => { + const { config = {} } = action as UserConfigAction + const { savedRegionsSelection } = config + const simpleSRSs = savedRegionsSelection.map(({ id, name, templateSelected, parcellationSelected, regionsSelected }) => { + return { + id, + name, + tName: templateSelected.name, + pName: parcellationSelected.name, + rSelected: regionsSelected.map(({ ngId, labelIndex }) => generateLabelIndexId({ ngId, labelIndex })) + } as SimpleRegionSelection + }) + + /** + * TODO save server side on per user basis + */ + window.localStorage.setItem(LOCAL_STORAGE_KEY.SAVED_REGION_SELECTIONS, JSON.stringify(simpleSRSs)) + }) + ) + + const savedSRSsString = window.localStorage.getItem(LOCAL_STORAGE_KEY.SAVED_REGION_SELECTIONS) + const savedSRSs:SimpleRegionSelection[] = savedSRSsString && JSON.parse(savedSRSsString) + + this.restoreSRSsFromStorage$ = viewerState$.pipe( + filter(() => !!savedSRSs), + select('fetchedTemplates'), + distinctUntilChanged(), + map(fetchedTemplates => savedSRSs.map(({ id, name, tName, pName, rSelected }) => { + const templateSelected = fetchedTemplates.find(t => t.name === tName) + const parcellationSelected = templateSelected && templateSelected.parcellations.find(p => p.name === pName) + const regionsSelected = parcellationSelected && rSelected.map(labelIndexId => recursiveFindRegionWithLabelIndexId({ regions: parcellationSelected.regions, labelIndexId, inheritedNgId: parcellationSelected.ngId })) + return { + templateSelected, + parcellationSelected, + id, + name, + regionsSelected + } as RegionSelection + })), + filter(RSs => RSs.every(rs => rs.regionsSelected && rs.regionsSelected.every(r => !!r))), + take(1), + map(savedRegionsSelection => { + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { savedRegionsSelection } + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } + + /** + * Temmplate Parcellation Regions selected + */ + private tprSelected$: Observable<{templateSelected:any, parcellationSelected: any, regionsSelected: any[]}> + private savedRegionsSelections$: Observable<any[]> + private parcellationSelected$: Observable<any> + + @Effect() + public onSaveRegionsSelection$: Observable<any> + + @Effect() + public onDeleteRegionsSelection$: Observable<any> + + @Effect() + public onUpdateRegionsSelection$: Observable<any> + + @Effect() + public restoreSRSsFromStorage$: Observable<any> +} + +const LOCAL_STORAGE_KEY = { + SAVED_REGION_SELECTIONS: 'fzj.xg.iv.SAVED_REGION_SELECTIONS' +} \ No newline at end of file diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index 874b8e311..2448818ec 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -107,7 +107,10 @@ export function viewerState( const { updatedParcellation } = action return { ...state, - parcellationSelected: updatedParcellation + parcellationSelected: { + ...updatedParcellation, + updated: true + } } } case SELECT_REGIONS: @@ -134,6 +137,10 @@ export function viewerState( userLandmarks: action.landmarks } } + /** + * TODO + * duplicated with ngViewerState.layers ? + */ case NEHUBA_LAYER_CHANGED: { if (!window['viewer']) { return { @@ -175,4 +182,6 @@ export const SELECT_LANDMARKS = `SELECT_LANDMARKS` export const DESELECT_LANDMARKS = `DESELECT_LANDMARKS` export const USER_LANDMARKS = `USER_LANDMARKS` +export const ADD_TO_REGIONS_SELECTION_WITH_IDS = `ADD_TO_REGIONS_SELECTION_WITH_IDS` + export const NEHUBA_LAYER_CHANGED = `NEHUBA_LAYER_CHANGED` diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts index 96048ec21..842499c18 100644 --- a/src/services/stateStore.service.ts +++ b/src/services/stateStore.service.ts @@ -7,6 +7,11 @@ export { CHANGE_NAVIGATION, AtlasAction, DESELECT_LANDMARKS, FETCHED_TEMPLATE, N export { DataEntry, ParcellationRegion, DataStateInterface, DatasetAction, FETCHED_DATAENTRIES, FETCHED_SPATIAL_DATA, Landmark, OtherLandmarkGeometry, PlaneLandmarkGeometry, PointLandmarkGeometry, Property, Publication, ReferenceSpace, dataStore, File, FileSupplementData } from './state/dataStore.store' export { CLOSE_SIDE_PANEL, MOUSE_OVER_LANDMARK, MOUSE_OVER_SEGMENT, OPEN_SIDE_PANEL, TOGGLE_SIDE_PANEL, UIAction, UIStateInterface, uiState } from './state/uiState.store' export { SPATIAL_GOTO_PAGE, SpatialDataEntries, SpatialDataStateInterface, UPDATE_SPATIAL_DATA, spatialSearchState } from './state/spatialSearchState.store' +export { userConfigState, UserConfigStateUseEffect, USER_CONFIG_ACTION_TYPES } from './state/userConfigState.store' + +export const GENERAL_ACTION_TYPES = { + ERROR: 'ERROR' +} export function safeFilter(key:string){ return filter((state:any)=> diff --git a/src/ui/layerbrowser/layerbrowser.component.ts b/src/ui/layerbrowser/layerbrowser.component.ts index 18c2cb75b..35178d86e 100644 --- a/src/ui/layerbrowser/layerbrowser.component.ts +++ b/src/ui/layerbrowser/layerbrowser.component.ts @@ -1,9 +1,9 @@ -import { Component, OnDestroy } from "@angular/core"; +import { Component, OnDestroy, Input, Pipe, PipeTransform } from "@angular/core"; import { NgLayerInterface } from "../../atlasViewer/atlasViewer.component"; import { Store, select } from "@ngrx/store"; import { ViewerStateInterface, isDefined, REMOVE_NG_LAYER, FORCE_SHOW_SEGMENT, safeFilter, getNgIds } from "../../services/stateStore.service"; -import { Subscription, Observable } from "rxjs"; -import { filter, map } from "rxjs/operators"; +import { Subscription, Observable, combineLatest } from "rxjs"; +import { filter, map, shareReplay, tap } from "rxjs/operators"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; @Component({ @@ -20,13 +20,15 @@ export class LayerBrowser implements OnDestroy{ /** * TODO make untangle nglayernames and its dependency on ng */ - loadedNgLayers$: Observable<NgLayerInterface[]> - lockedLayers : string[] = [] + public loadedNgLayers$: Observable<NgLayerInterface[]> + public lockedLayers : string[] = [] + + public nonBaseNgLayers$: Observable<NgLayerInterface[]> public forceShowSegmentCurrentState : boolean | null = null public forceShowSegment$ : Observable<boolean|null> - public ngLayers$: Observable<any> + public ngLayers$: Observable<string[]> public advancedMode: boolean = false private subscriptions : Subscription[] = [] @@ -35,6 +37,11 @@ export class LayerBrowser implements OnDestroy{ /* TODO temporary measure. when datasetID can be used, will use */ public fetchedDataEntries$ : Observable<any> + @Input() + showPlaceholder: boolean = true + + darktheme$: Observable<boolean> + constructor( private store : Store<ViewerStateInterface>, private constantsService: AtlasViewerConstantsServices){ @@ -64,6 +71,22 @@ export class LayerBrowser implements OnDestroy{ */ map(arr => arr.filter(v => !!v)) ) + + this.loadedNgLayers$ = this.store.pipe( + select('viewerState'), + select('loadedNgLayers') + ) + + this.nonBaseNgLayers$ = combineLatest( + this.ngLayers$, + this.loadedNgLayers$ + ).pipe( + map(([baseNgLayerNames, loadedNgLayers]) => { + const baseNameSet = new Set(baseNgLayerNames) + return loadedNgLayers.filter(l => !baseNameSet.has(l.name)) + }) + ) + /** * TODO * this is no longer populated @@ -80,9 +103,9 @@ export class LayerBrowser implements OnDestroy{ map(state => state.forceShowSegment) ) - this.loadedNgLayers$ = this.store.pipe( - select('viewerState'), - select('loadedNgLayers') + + this.darktheme$ = this.constantsService.darktheme$.pipe( + shareReplay(1) ) this.subscriptions.push( @@ -128,6 +151,9 @@ export class LayerBrowser implements OnDestroy{ return } + /** + * TODO perhaps useEffects ? + */ this.store.dispatch({ type : FORCE_SHOW_SEGMENT, forceShowSegment : this.forceShowSegmentCurrentState === null @@ -151,6 +177,9 @@ export class LayerBrowser implements OnDestroy{ }) } + /** + * TODO use observable and pipe to make this more perf + */ segmentationTooltip(){ return `toggle segments visibility: ${this.forceShowSegmentCurrentState === true ? 'always show' : this.forceShowSegmentCurrentState === false ? 'always hide' : 'auto'}` @@ -169,4 +198,16 @@ export class LayerBrowser implements OnDestroy{ get isMobile(){ return this.constantsService.mobile } + + public matTooltipPosition: string = 'below' } + +@Pipe({ + name: 'lockedLayerBtnClsPipe' +}) + +export class LockedLayerBtnClsPipe implements PipeTransform{ + public transform(ngLayer:NgLayerInterface, lockedLayers?: string[]): boolean{ + return (lockedLayers && new Set(lockedLayers).has(ngLayer.name)) || false + } +} \ No newline at end of file diff --git a/src/ui/layerbrowser/layerbrowser.template.html b/src/ui/layerbrowser/layerbrowser.template.html index 6ac015c16..37e52eb05 100644 --- a/src/ui/layerbrowser/layerbrowser.template.html +++ b/src/ui/layerbrowser/layerbrowser.template.html @@ -1,71 +1,61 @@ -<ng-container *ngIf="ngLayers$ | async | filterNgLayer : (loadedNgLayers$ | async) as filteredNgLayers; else noLayerPlaceHolder"> - <ng-container *ngIf="filteredNgLayers.length > 0; else noLayerPlaceHolder"> +<ng-container *ngIf="nonBaseNgLayers$ | async as nonBaseNgLayers; else noLayerPlaceHolder"> + <ng-container *ngIf="nonBaseNgLayers.length > 0; else noLayerPlaceHolder"> <div class="layerContainer overflow-hidden" - *ngFor = "let ngLayer of filteredNgLayers"> + *ngFor = "let ngLayer of nonBaseNgLayers"> <!-- toggle visibility --> - <div class="btnWrapper"> - <div - container = "body" - placement = "bottom" - [tooltip] = "checkLocked(ngLayer) ? 'base layer cannot be hidden' : 'toggle visibility'" - (click) = "checkLocked(ngLayer) ? null : toggleVisibility(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i [ngClass] = "checkLocked(ngLayer) ? 'fas fa-lock muted' :ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> - </i> - </div> - </div> + + <button + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layer cannot be hidden' : 'toggle visibility'" + (click)="toggleVisibility(ngLayer)" + mat-icon-button + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [color]="ngLayer.visible ? 'primary' : null"> + <i [ngClass]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> + </i> + </button> <!-- advanced mode only: toggle force show segmentation --> - <div class="btnWrapper"> - <div - *ngIf="advancedMode" - container="body" - placement="bottom" - [tooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" - #forceSegment="bs-tooltip" - (click)="forceSegment.hide();toggleForceShowSegment(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i - class="fas" - [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> - - </i> - </div> - </div> + <button + *ngIf="advancedMode" + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" + (click)="toggleForceShowSegment(ngLayer)" + mat-icon-button> + <i + class="fas" + [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> + + </i> + </button> <!-- remove layer --> - <div class="btnWrapper"> - <div - container="body" - placement="bottom" - [tooltip]="checkLocked(ngLayer) ? 'base layers cannot be removed' : 'remove layer'" - (click)="removeLayer(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i [ngClass]="checkLocked(ngLayer) ? 'fas fa-lock muted' : 'far fa-times-circle'"> - </i> - </div> - </div> + <button + color="warn" + mat-icon-button + (click)="removeLayer(ngLayer)" + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layers cannot be removed' : 'remove layer'"> + <i [class]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : 'fas fa-trash'"> + </i> + </button> <!-- layer description --> - <panel-component [ngClass]="{'muted-text muted' : !classVisible(ngLayer)}"> - - <div heading> - {{ ngLayer.name | getLayerNameFromDatasets : (fetchedDataEntries$ | async) }} - </div> - - <div bodyy> - {{ ngLayer.source }} - </div> - </panel-component> + <div + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.name | getFilenamePipe " + [class]="((darktheme$ | async) ? 'text-light' : 'text-dark') + ' text-truncate'"> + {{ ngLayer.name | getFilenamePipe | getFileExtension }} + </div> </div> </ng-container> </ng-container> <!-- fall back when no layers are showing --> <ng-template #noLayerPlaceHolder> - <h5 class="noLayerPlaceHolder text-muted"> + <small *ngIf="showPlaceholder" class="noLayerPlaceHolder text-muted"> No additional layers added. - </h5> + </small> </ng-template> \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.component.ts b/src/ui/menuicons/menuicons.component.ts index 1ccf9a21b..8acb577d7 100644 --- a/src/ui/menuicons/menuicons.component.ts +++ b/src/ui/menuicons/menuicons.component.ts @@ -2,17 +2,14 @@ import { Component, ComponentRef, Injector, ComponentFactory, ComponentFactoryRe import { WidgetServices } from "src/atlasViewer/widgetUnit/widgetService.service"; import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; -import { LayerBrowser } from "src/ui/layerbrowser/layerbrowser.component"; import { DataBrowser } from "src/ui/databrowserModule/databrowser/databrowser.component"; import { PluginBannerUI } from "../pluginBanner/pluginBanner.component"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { DatabrowserService } from "../databrowserModule/databrowser.service"; import { PluginServices, PluginManifest } from "src/atlasViewer/atlasViewer.pluginService.service"; import { Store, select } from "@ngrx/store"; -import { Observable, BehaviorSubject, combineLatest, merge, of } from "rxjs"; -import { map, shareReplay } from "rxjs/operators"; -import { DESELECT_REGIONS, SELECT_REGIONS, CHANGE_NAVIGATION } from "src/services/state/viewerState.store"; -import { MatSnackBar } from "@angular/material"; +import { Observable, combineLatest, merge, of } from "rxjs"; +import { map, shareReplay, startWith } from "rxjs/operators"; import { ToastService } from "src/services/toastService.service"; @Component({ @@ -35,13 +32,6 @@ export class MenuIconsBar{ dataBrowser: ComponentRef<DataBrowser> = null dbWidget: ComponentRef<WidgetUnit> = null - /** - * layerBrowser - */ - lbcf: ComponentFactory<LayerBrowser> - layerBrowser: ComponentRef<LayerBrowser> = null - lbWidget: ComponentRef<WidgetUnit> = null - /** * pluginBrowser */ @@ -57,9 +47,6 @@ export class MenuIconsBar{ public themedBtnClass$: Observable<string> public skeletonBtnClass$: Observable<string> - - private layerBrowserExists$: BehaviorSubject<boolean> = new BehaviorSubject(false) - public layerBrowserBtnClass$: Observable<string> public toolBtnClass$: Observable<string> public getKgSearchBtnCls$: Observable<[Set<WidgetUnit>, string]> @@ -88,7 +75,6 @@ export class MenuIconsBar{ this.dbService.createDatabrowser = this.clickSearch.bind(this) this.dbcf = cfr.resolveComponentFactory(DataBrowser) - this.lbcf = cfr.resolveComponentFactory(LayerBrowser) this.pbcf = cfr.resolveComponentFactory(PluginBannerUI) this.selectedTemplate$ = store.pipe( @@ -96,13 +82,10 @@ export class MenuIconsBar{ select('templateSelected') ) - this.selectedRegions$ = merge( - of([]), - store.pipe( - select('viewerState'), - select('regionsSelected') - ) - ).pipe( + this.selectedRegions$ = store.pipe( + select('viewerState'), + select('regionsSelected'), + startWith([]), shareReplay(1) ) @@ -116,13 +99,6 @@ export class MenuIconsBar{ shareReplay(1) ) - this.layerBrowserBtnClass$ = combineLatest( - this.layerBrowserExists$, - this.themedBtnClass$ - ).pipe( - map(([flag,themedBtnClass]) => `${this.mobileRespBtnClass} ${flag ? 'btn-primary' : themedBtnClass}`) - ) - this.launchedPlugins$ = this.pluginServices.launchedPlugins$.pipe( map(set => Array.from(set)) ) @@ -170,37 +146,6 @@ export class MenuIconsBar{ public catchError(e) { this.constantService.catchError(e) } - - public clickLayer(event: MouseEvent){ - - if (this.lbWidget) { - this.lbWidget.destroy() - this.lbWidget = null - return - } - this.layerBrowser = this.lbcf.create(this.injector) - this.lbWidget = this.widgetServices.addNewWidget(this.layerBrowser, { - exitable: true, - persistency: true, - state: 'floating', - title: 'Layer Browser', - titleHTML: '<i class="fas fa-layer-group"></i> Layer Browser' - }) - - this.layerBrowserExists$.next(true) - - this.lbWidget.onDestroy(() => { - this.layerBrowserExists$.next(false) - this.layerBrowser = null - this.lbWidget = null - }) - - const el = event.currentTarget as HTMLElement - const top = el.offsetTop - const left = el.offsetLeft + 50 - this.lbWidget.instance.position = [left, top] - } - public clickPlugins(event: MouseEvent){ if(this.pbWidget) { this.pbWidget.destroy() @@ -245,45 +190,6 @@ export class MenuIconsBar{ this.widgetServices.exitWidget(wu) } - public deselectRegion(event: MouseEvent, region: any){ - event.stopPropagation() - - this.store.dispatch({ - type: DESELECT_REGIONS, - deselectRegions: [region] - }) - } - - public deselectAllRegions(event: MouseEvent){ - event.stopPropagation() - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: [] - }) - } - - public gotoRegion(event: MouseEvent, region:any){ - event.stopPropagation() - - if (region.position) { - this.store.dispatch({ - type: CHANGE_NAVIGATION, - navigation: { - position: region.position, - animation: {} - } - }) - } else { - /** - * TODO convert to snack bar - */ - this.toastService.showToast(`${region.name} does not have a position defined`, { - timeout: 5000, - dismissable: true - }) - } - } - public renameKgSearchWidget(event:MouseEvent, wu: WidgetUnit) { event.stopPropagation() } diff --git a/src/ui/menuicons/menuicons.style.css b/src/ui/menuicons/menuicons.style.css index 1873b453d..c76c706c7 100644 --- a/src/ui/menuicons/menuicons.style.css +++ b/src/ui/menuicons/menuicons.style.css @@ -32,38 +32,7 @@ margin-top: 0.1em; } -.virtual-scroll-viewport-container +layer-browser { - height: 20em; - width: 20em; - overflow: hidden; -} - -.virtual-scroll-viewport-container > cdk-virtual-scroll-viewport -{ - width: 100%; - height: 100%; - box-sizing: content-box; - padding-right: 3em; -} - -.virtual-scroll-row -{ - width: 20em; -} - -/* required to match virtual scroll itemSize property */ -.virtual-scroll-unit -{ - height: 26px -} - -.selected-region-container -{ - flex: 1 1 auto; -} - -.selected-region-actionbtn -{ - flex: 0 0 auto; -} + max-width: 20em; +} \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.template.html b/src/ui/menuicons/menuicons.template.html index 796fe0890..7e3140dbe 100644 --- a/src/ui/menuicons/menuicons.template.html +++ b/src/ui/menuicons/menuicons.template.html @@ -5,16 +5,31 @@ <ng-template [ngIf]="selectedTemplate$ | async"> <!-- layer browser --> - <div - matTooltip="Layer" - matTooltipPosition="right" - (click)="clickLayer($event)" - [class]="layerBrowserBtnClass$ | async" - class="btn"> - <i class="fas fa-layer-group"> - </i> - </div> + <sleight-of-hand> + <div sleight-of-hand-front> + <div [ngClass]="(skeletonBtnClass$ | async) + ' btn'"> + <i class="fas fa-layer-group"></i> + </div> + </div> + <div class="d-flex flex-row align-items-start soh-row pe-none" + sleight-of-hand-back> + <div [ngClass]="(skeletonBtnClass$ | async) + ' btn muted pe-all'"> + <i class="fas fa-layer-group"></i> + </div> + + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light') + ' card pe-all'"> + <layer-browser #layerBrowser [showPlaceholder]="false"> + </layer-browser> + </div> + <ng-container *ngIf="(layerBrowser.nonBaseNgLayers$ | async).length === 0" #noNonBaseNgLayerTemplate> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + No additional layers added + </small> + </ng-container> + </div> + </sleight-of-hand> + <!-- tools --> <sleight-of-hand> @@ -29,7 +44,7 @@ <!-- shown after mouse over --> <div - class="d-flex flex-row soh-row" + class="d-flex flex-row soh-row align-items-start" sleight-of-hand-back> <!-- placeholder icon --> @@ -77,11 +92,11 @@ <!-- shown after mouse over --> <div sleight-of-hand-back - class="d-flex flex-row align-items-center soh-row"> + class="d-flex flex-row align-items-center soh-row pe-none"> <!-- placeholder icon --> <div - [ngClass]="(skeletonBtnClass$ | async) + ' btn muted'" + [ngClass]="(skeletonBtnClass$ | async) + ' btn muted pe-all'" [matBadgePosition]="badgetPosition" [matBadge]="dbService.instantiatedWidgetUnits.length > 0 ? dbService.instantiatedWidgetUnits.length : null"> <i class="fas fa-search"></i> @@ -90,7 +105,7 @@ <!-- only renders if there is at least one search result --> <div *ngIf="dbService.instantiatedWidgetUnits.length > 0; else noKgSearchTemplate" - class="position-relative"> + class="position-relative pe-all"> <div class="position-absolute d-flex flex-column soh-column"> @@ -194,7 +209,8 @@ </sleight-of-hand> <!-- selected regions --> - <sleight-of-hand> + <sleight-of-hand + [doNotClose]="viewerStateController.focused"> <!-- shown prior to mouse over --> <div @@ -220,76 +236,12 @@ <i class="fas fa-brain"></i> </div> - <div - *ngIf="(selectedRegions$ | async).length > 0; else noBrainRegionSelected" - class="position-relative"> + <div class="position-relative"> - <!-- rendering all the selected regions --> - <div [class]="((darktheme$ | async) ? 'text-light bg-dark' : 'text-dark bg-light') + ' position-absolute'"> - <div class="m-1 position-relative"> - <button - mat-raised-button - (click)="deselectAllRegions($event)" - class="virtual-scroll-row" - color="warn"> - <i class="fas fa-trash"></i> - Deselect all {{ (selectedRegions$ | async).length }} region{{ (selectedRegions$ | async).length > 1 ? 's' : '' }} - </button> - </div> - <mat-divider></mat-divider> - <div class="virtual-scroll-viewport-container"> - <cdk-virtual-scroll-viewport - class="d-flex-flex-column soh-column" - itemSize="26"> - <div - *cdkVirtualFor="let region of (selectedRegions$ | async)" - class="virtual-scroll-unit"> - <sleight-of-hand> - <!-- prior to mouseover --> - <div - [class]="((darktheme$ | async) ? 'text-light bg-dark' : 'text-dark bg-light') + ' d-flex flex-row align-items-center virtual-scroll-row'" - sleight-of-hand-front> - <div class="ml-2 cursor-default text-nowrap"> - {{ region.name }} - </div> - - <!-- place holder icon to support height --> - <div [class]="(skeletonBtnClass$ | async) + ' invisible w-0 pe-none'"> - <i class="fas fa-trash"></i> - </div> - </div> - - <!-- on mouse over --> - <div - [class]="((darktheme$ | async) ? 'text-light bg-dark' : 'text-dark bg-light') + ' d-flex flex-row align-items-center virtual-scroll-row'" - sleight-of-hand-back> - <div class="text-truncate selected-region-container ml-2 cursor-default text-nowrap"> - {{ region.name }} - </div> - <div - *ngIf="region.position" - (click)="gotoRegion($event, region)" - matTooltip="Goto" - matTooltipPosition="below" - [class]="(skeletonBtnClass$ | async) + ' selected-region-actionbtn'"> - <i class="fas fa-map-marked-alt"></i> - </div> - <div - #trashBtn="matTooltip" - (mousedown)="trashBtn.hide()" - (click)="deselectRegion($event, region)" - matTooltip="Deselect" - matTooltipPosition="below" - [class]="(skeletonBtnClass$ | async) + ' selected-region-actionbtn'"> - <i class="fas fa-trash"></i> - </div> - </div> - </sleight-of-hand> - </div> - </cdk-virtual-scroll-viewport> - </div> + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light') + ' position-absolute card'"> + <viewer-state-controller #viewerStateController></viewer-state-controller> </div> - + <!-- invisible icon to keep height of the otherwise unstable flex block --> <div class="invisible pe-none"> <i class="fas fa-brain"></i> diff --git a/src/ui/regionHierachy/regionHierarchy.component.ts b/src/ui/regionHierachy/regionHierarchy.component.ts index 397d87c94..3c64434d2 100644 --- a/src/ui/regionHierachy/regionHierarchy.component.ts +++ b/src/ui/regionHierachy/regionHierarchy.component.ts @@ -38,9 +38,7 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ public selectedRegions: any[] = [] @Input() - public selectedParcellation: any - - @Input() isMobile: boolean; + public parcellationSelected: any private _showRegionTree: boolean = false @@ -54,7 +52,7 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ private doubleClickRegion: EventEmitter<any> = new EventEmitter() @Output() - private clearAllRegions: EventEmitter<null> = new EventEmitter() + private clearAllRegions: EventEmitter<MouseEvent> = new EventEmitter() public searchTerm: string = '' private subscriptions: Subscription[] = [] @@ -62,6 +60,8 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ @ViewChild('searchTermInput', {read: ElementRef}) private searchTermInput: ElementRef + public placeHolderText: string = `Start by selecting a template and a parcellation.` + /** * set the height to max, bound by max-height */ @@ -95,17 +95,19 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ } ngOnChanges(){ - this.aggregatedRegionTree = { - name: this.selectedParcellation.name, - children: this.selectedParcellation.regions + if (this.parcellationSelected) { + this.placeHolderText = `Search region in ${this.parcellationSelected.name}` + this.aggregatedRegionTree = { + name: this.parcellationSelected.name, + children: this.parcellationSelected.regions + } } this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) } clearRegions(event:MouseEvent){ - event.stopPropagation() - this.clearAllRegions.emit() + this.clearAllRegions.emit(event) } get showRegionTree(){ @@ -133,20 +135,6 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ } ngAfterViewInit(){ - /** - * TODO - * bandaid fix on - * when region search loses focus, the searchTerm is cleared, - * but hierarchy filter does not reset - */ - this.subscriptions.push( - fromEvent(this.searchTermInput.nativeElement, 'focus').pipe( - - ).subscribe(() => { - this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) - this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) - }) - ) this.subscriptions.push( fromEvent(this.searchTermInput.nativeElement, 'input').pipe( debounceTime(200) @@ -156,13 +144,6 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ ) } - getInputPlaceholder(parcellation:any) { - if (parcellation) - return `Search region in ${parcellation.name}` - else - return `Start by selecting a template and a parcellation.` - } - escape(event:KeyboardEvent){ this.showRegionTree = false this.searchTerm = ''; @@ -182,27 +163,12 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ }) } - focusInput(event?:MouseEvent){ - if (event) { - /** - * need to stop propagation, or @closeRegion will be triggered - */ - event.stopPropagation() - } - this.searchTermInput.nativeElement.focus() - this.showRegionTree = true - } - /* NB need to bind two way data binding like this. Or else, on searchInput blur, the flat tree will be rebuilt, resulting in first click to be ignored */ changeSearchTerm(event: any) { - if (event.target.value === this.searchTerm) - return + if (event.target.value === this.searchTerm) return this.searchTerm = event.target.value - /** - * TODO maybe introduce debounce - */ this.ngOnChanges() this.cdr.markForCheck() } @@ -214,18 +180,17 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ /** * TODO figure out why @closeRegion gets triggered, but also, contains returns false */ - if (event) + if (event) { event.stopPropagation() + } this.handleRegionTreeClickSubject.next(obj) } /* single click selects/deselects region(s) */ private singleClick(obj: any) { - if (!obj) - return + if (!obj) return const { inputItem : region } = obj - if (!region) - return + if (!region) return this.singleClickRegion.emit(region) } diff --git a/src/ui/sharedModules/angularMaterial.module.ts b/src/ui/sharedModules/angularMaterial.module.ts index a1b13144f..cd9848a04 100644 --- a/src/ui/sharedModules/angularMaterial.module.ts +++ b/src/ui/sharedModules/angularMaterial.module.ts @@ -6,12 +6,55 @@ import { MatTabsModule, MatTooltipModule, MatBadgeModule, - MatDividerModule + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule } from '@angular/material'; import { NgModule } from '@angular/core'; +/** + * TODO should probably be in src/util + */ + @NgModule({ - imports: [MatDividerModule, MatBadgeModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], - exports: [MatDividerModule, MatBadgeModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], + imports: [ + MatDividerModule, + MatBadgeModule, + MatButtonModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule + ], + exports: [ + MatDividerModule, + MatBadgeModule, + MatButtonModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule + ], }) export class AngularMaterialModule { } \ No newline at end of file diff --git a/src/ui/signinBanner/signinBanner.components.ts b/src/ui/signinBanner/signinBanner.components.ts index 07428cb0c..f559248fe 100644 --- a/src/ui/signinBanner/signinBanner.components.ts +++ b/src/ui/signinBanner/signinBanner.components.ts @@ -1,18 +1,9 @@ import {Component, ChangeDetectionStrategy, OnDestroy, OnInit, Input, ViewChild, TemplateRef } from "@angular/core"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { AuthService, User } from "src/services/auth.service"; -import { Store, select } from "@ngrx/store"; +import { Store} from "@ngrx/store"; import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; -import { Subscription, Observable, merge, Subject, combineLatest } from "rxjs"; import { safeFilter, isDefined, NEWVIEWER, SELECT_REGIONS, SELECT_PARCELLATION, CHANGE_NAVIGATION } from "src/services/stateStore.service"; -import { map, filter, distinctUntilChanged, bufferTime, delay, share, tap, withLatestFrom } from "rxjs/operators"; -import { regionFlattener } from "src/util/regionFlattener"; -import { ToastService } from "src/services/toastService.service"; -import { getSchemaIdFromName } from "src/util/pipes/templateParcellationDecoration.pipe"; - -const compareParcellation = (o, n) => !o || !n - ? false - : o.name === n.name @Component({ selector: 'signin-banner', @@ -24,199 +15,18 @@ const compareParcellation = (o, n) => !o || !n changeDetection: ChangeDetectionStrategy.OnPush }) -export class SigninBanner implements OnInit, OnDestroy{ - - public compareParcellation = compareParcellation - - private subscriptions: Subscription[] = [] - - public loadedTemplates$: Observable<any[]> +export class SigninBanner{ - public selectedTemplate$: Observable<any> - public selectedParcellation$: Observable<any> - - public selectedRegions$: Observable<any[]> - private selectedRegions: any[] = [] @Input() darktheme: boolean - @ViewChild('publicationTemplate', {read:TemplateRef}) publicationTemplate: TemplateRef<any> - - public focusedDatasets$: Observable<any[]> - private userFocusedDataset$: Subject<any> = new Subject() - public focusedDatasets: any[] = [] - private dismissToastHandler: () => void + public isMobile: boolean constructor( private constantService: AtlasViewerConstantsServices, private authService: AuthService, - private store: Store<ViewerConfiguration>, - private toastService: ToastService + private store: Store<ViewerConfiguration> ){ - this.loadedTemplates$ = this.store.pipe( - select('viewerState'), - safeFilter('fetchedTemplates'), - map(state => state.fetchedTemplates) - ) - - this.selectedTemplate$ = this.store.pipe( - select('viewerState'), - select('templateSelected'), - filter(v => !!v), - distinctUntilChanged((o, n) => o.name === n.name), - ) - - this.selectedParcellation$ = this.store.pipe( - select('viewerState'), - select('parcellationSelected'), - filter(v => !!v) - ) - - this.selectedRegions$ = this.store.pipe( - select('viewerState'), - safeFilter('regionsSelected'), - map(state => state.regionsSelected), - distinctUntilChanged((arr1, arr2) => arr1.length === arr2.length && (arr1 as any[]).every((item, index) => item.name === arr2[index].name)) - ) - - this.focusedDatasets$ = this.userFocusedDataset$.pipe( - filter(v => !!v), - withLatestFrom( - combineLatest(this.selectedTemplate$, this.selectedParcellation$) - ), - ).pipe( - map(([userFocusedDataset, [selectedTemplate, selectedParcellation]]) => { - const { type, ...rest } = userFocusedDataset - if (type === 'template') return { ...selectedTemplate, ...rest} - if (type === 'parcellation') return { ...selectedParcellation, ...rest } - return { ...rest } - }), - bufferTime(100), - filter(arr => arr.length > 0), - /** - * merge properties field with the root level - * with the prop in properties taking priority - */ - map(arr => arr.map(item => { - const { properties } = item - return { - ...item, - ...properties - } - })), - share() - ) - } - - ngOnInit(){ - - this.subscriptions.push( - this.selectedRegions$.subscribe(regions => { - this.selectedRegions = regions - }) - ) - - this.subscriptions.push( - this.focusedDatasets$.subscribe(() => { - if (this.dismissToastHandler) this.dismissToastHandler() - }) - ) - - this.subscriptions.push( - this.focusedDatasets$.pipe( - /** - * creates the illusion that the toast complete disappears before reappearing - */ - delay(100) - ).subscribe(arr => { - this.focusedDatasets = arr - this.dismissToastHandler = this.toastService.showToast(this.publicationTemplate, { - dismissable: true, - timeout:7000 - }) - }) - ) - } - - ngOnDestroy(){ - this.subscriptions.forEach(s => s.unsubscribe()) - } - - changeTemplate({ current, previous }){ - if (previous && current && current.name === previous.name) return - this.store.dispatch({ - type: NEWVIEWER, - selectTemplate: current, - selectParcellation: current.parcellations[0] - }) - } - - changeParcellation({ current, previous }){ - const { ngId: prevNgId} = previous - const { ngId: currNgId} = current - if (prevNgId === currNgId) return - this.store.dispatch({ - type: SELECT_PARCELLATION, - selectParcellation: current - }) - } - - // TODO handle mobile - handleRegionClick({ mode = 'single', region }){ - if (!region) return - - /** - * single click on region hierarchy => toggle selection - */ - if (mode === 'single') { - const flattenedRegion = regionFlattener(region).filter(r => isDefined(r.labelIndex)) - const flattenedRegionNames = new Set(flattenedRegion.map(r => r.name)) - const selectedRegionNames = new Set(this.selectedRegions.map(r => r.name)) - const selectAll = flattenedRegion.every(r => !selectedRegionNames.has(r.name)) - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: selectAll - ? this.selectedRegions.concat(flattenedRegion) - : this.selectedRegions.filter(r => !flattenedRegionNames.has(r.name)) - }) - } - - /** - * double click on region hierarchy => navigate to region area if it exists - */ - if (mode === 'double') { - - /** - * if position is defined, go to position (in nm) - * if not, show error messagea s toast - * - * nb: currently, only supports a single triplet - */ - if (region.position) { - this.store.dispatch({ - type: CHANGE_NAVIGATION, - navigation: { - position: region.position, - animation: {} - } - }) - } else { - /** - * TODO convert to snack bar - */ - this.toastService.showToast(`${region.name} does not have a position defined`, { - timeout: 5000, - dismissable: true - }) - } - } - } - - displayActiveParcellation(parcellation:any){ - return `<div class="d-flex"><small>Parcellation</small> <small class = "flex-grow-1 mute-text">${parcellation ? '(' + parcellation.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` - } - - displayActiveTemplate(template: any) { - return `<div class="d-flex"><small>Template</small> <small class = "flex-grow-1 mute-text">${template ? '(' + template.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + this.isMobile = this.constantService.mobile } showHelp() { @@ -227,56 +37,7 @@ export class SigninBanner implements OnInit, OnDestroy{ this.constantService.showSigninSubject$.next(this.user) } - clearAllRegions(){ - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: [] - }) - } - - handleActiveDisplayBtnClicked(event, type: 'parcellation' | 'template'){ - const { - extraBtn, - event: extraBtnClickEvent - } = event - - const { name } = extraBtn - const { kgSchema, kgId } = getSchemaIdFromName(name) - - this.userFocusedDataset$.next({ - kgSchema, - kgId, - type - }) - } - - handleExtraBtnClicked(event, toastType: 'parcellation' | 'template'){ - const { - extraBtn, - inputItem, - event: extraBtnClickEvent - } = event - - const { name } = extraBtn - const { kgSchema, kgId } = getSchemaIdFromName(name) - - this.userFocusedDataset$.next({ - ...inputItem, - kgSchema, - kgId - }) - - extraBtnClickEvent.stopPropagation() - } - - get isMobile(){ - return this.constantService.mobile - } - get user() : User | null { return this.authService.user } - - public flexItemIsMobileClass = 'mt-2' - public flexItemIsDesktopClass = 'mr-2' } \ No newline at end of file diff --git a/src/ui/signinBanner/signinBanner.template.html b/src/ui/signinBanner/signinBanner.template.html index c3d288680..14f80d223 100644 --- a/src/ui/signinBanner/signinBanner.template.html +++ b/src/ui/signinBanner/signinBanner.template.html @@ -2,48 +2,6 @@ class="d-flex" [ngClass]="{ 'flex-column w-100 align-items-stretch' : isMobile}" > - <dropdown-component - (itemSelected)="changeTemplate($event)" - [checkSelected]="compareParcellation" - [activeDisplay]="displayActiveTemplate" - [selectedItem]="selectedTemplate$ | async" - [inputArray]="loadedTemplates$ | async | filterNull | appendTooltipTextPipe" - [ngClass]="isMobile ? flexItemIsMobileClass : flexItemIsDesktopClass" - (extraBtnClicked)="handleExtraBtnClicked($event, 'template')" - [activeDisplayBtns]="(selectedTemplate$ | async | templateParcellationsDecorationPipe)?.extraButtons" - (activeDisplayBtnClicked)="handleActiveDisplayBtnClicked($event, 'template')" - (listItemButtonClicked)="handleExtraBtnClicked($event, 'template')"> - </dropdown-component> - - <ng-container *ngIf="selectedTemplate$ | async as selectedTemplate"> - <dropdown-component - *ngIf="selectedParcellation$ | async as selectedParcellation" - (itemSelected)="changeParcellation($event)" - [checkSelected]="compareParcellation" - [activeDisplay]="displayActiveParcellation" - [selectedItem]="selectedParcellation" - [inputArray]="selectedTemplate.parcellations | appendTooltipTextPipe" - [ngClass]="isMobile ? flexItemIsMobileClass : flexItemIsDesktopClass" - (extraBtnClicked)="handleExtraBtnClicked($event, 'parcellation')" - [activeDisplayBtns]="(selectedParcellation | templateParcellationsDecorationPipe)?.extraButtons" - (activeDisplayBtnClicked)="handleActiveDisplayBtnClicked($event, 'parcellation')" - (listItemButtonClicked)="handleExtraBtnClicked($event, 'parcellation')"> - - </dropdown-component> - <region-hierarchy - [selectedRegions]="selectedRegions$ | async | filterNull" - (singleClickRegion)="handleRegionClick({ mode: 'single', region: $event })" - (doubleClickRegion)="handleRegionClick({ mode: 'double', region: $event })" - (clearAllRegions)="clearAllRegions()" - [isMobile] = "isMobile" - *ngIf="selectedParcellation$ | async as selectedParcellation" - class="h-0" - [selectedParcellation]="selectedParcellation" - [ngClass]="isMobile ? flexItemIsMobileClass : flexItemIsDesktopClass"> - - </region-hierarchy> - </ng-container> - <!-- help btn --> <div class="btnWrapper"> <div @@ -81,17 +39,3 @@ </div> </div> - -<!-- TODO somehow, using async pipe here does not work --> -<!-- maybe have something to do with bufferTime, and that it replays from the beginning? --> -<ng-template #publicationTemplate> - <single-dataset-view - *ngFor="let focusedDataset of focusedDatasets" - [name]="focusedDataset.name" - [description]="focusedDataset.description" - [publications]="focusedDataset.publications" - [kgSchema]="focusedDataset.kgSchema" - [kgId]="focusedDataset.kgId"> - - </single-dataset-view> -</ng-template> \ No newline at end of file diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 694000b8e..6606c25ac 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -5,7 +5,7 @@ import { NehubaViewerUnit } from "./nehubaContainer/nehubaViewer/nehubaViewer.co import { NehubaContainer } from "./nehubaContainer/nehubaContainer.component"; import { SplashScreen } from "./nehubaContainer/splashScreen/splashScreen.component"; import { LayoutModule } from "../layouts/layout.module"; -import { FormsModule } from "@angular/forms"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { GroupDatasetByRegion } from "../util/pipes/groupDataEntriesByRegion.pipe"; import { filterRegionDataEntries } from "../util/pipes/filterRegionDataEntries.pipe"; @@ -17,7 +17,7 @@ import { LandmarkUnit } from "./nehubaContainer/landmarkUnit/landmarkUnit.compon import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; import { PluginBannerUI } from "./pluginBanner/pluginBanner.component"; import { CitationsContainer } from "./citation/citations.component"; -import { LayerBrowser } from "./layerbrowser/layerbrowser.component"; +import { LayerBrowser, LockedLayerBtnClsPipe } from "./layerbrowser/layerbrowser.component"; import { TooltipModule } from "ngx-bootstrap/tooltip"; import { KgEntryViewer } from "./kgEntryViewer/kgentry.component"; import { SubjectViewer } from "./kgEntryViewer/subjectViewer/subjectViewer.component"; @@ -38,10 +38,9 @@ import { PopoverModule } from 'ngx-bootstrap/popover' import { DatabrowserModule } from "./databrowserModule/databrowser.module"; import { SigninBanner } from "./signinBanner/signinBanner.components"; import { SigninModal } from "./signinModal/signinModal.component"; -import { FilterNgLayer } from "src/util/pipes/filterNgLayer.pipe"; import { UtilModule } from "src/util/util.module"; -import { RegionHierarchy } from "./regionHierachy/regionHierarchy.component"; -import { FilterNameBySearch } from "./regionHierachy/filterNameBySearch.pipe"; +import { RegionHierarchy } from "./viewerStateController/regionHierachy/regionHierarchy.component"; +import { FilterNameBySearch } from "./viewerStateController/regionHierachy/filterNameBySearch.pipe"; import { StatusCardComponent } from "./nehubaContainer/statusCard/statusCard.component"; import { CookieAgreement } from "./cookieAgreement/cookieAgreement.component"; import { KGToS } from "./kgtos/kgtos.component"; @@ -51,10 +50,17 @@ import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.pipe"; import { MenuIconPluginBtnClsPipe } from "src/util/pipes/menuIconPluginBtnCls.pipe"; import { MenuIconKgSearchBtnClsPipe } from "src/util/pipes/menuIconKgSearchBtnCls.pipe"; import { ScrollingModule } from "@angular/cdk/scrolling" +import { GetFilenamePipe } from "src/util/pipes/getFilename.pipe"; +import { GetFileExtension } from "src/util/pipes/getFileExt.pipe"; +import { ViewerStateController } from "./viewerStateController/viewerState.component"; +import { BinSavedRegionsSelectionPipe, SavedRegionsSelectionBtnDisabledPipe } from "./viewerStateController/viewerState.pipes"; +import { RegionTextSearchAutocomplete } from "./viewerStateController/regionSearch/regionSearch.component"; + @NgModule({ imports : [ FormsModule, + ReactiveFormsModule, LayoutModule, ComponentsModule, DatabrowserModule, @@ -87,6 +93,8 @@ import { ScrollingModule } from "@angular/cdk/scrolling" StatusCardComponent, CookieAgreement, KGToS, + ViewerStateController, + RegionTextSearchAutocomplete, /* pipes */ GroupDatasetByRegion, @@ -98,12 +106,16 @@ import { ScrollingModule } from "@angular/cdk/scrolling" SortDataEntriesToRegion, SpatialLandmarksToDataBrowserItemPipe, FilterNullPipe, - FilterNgLayer, FilterNameBySearch, TemplateParcellationsDecorationPipe, AppendtooltipTextPipe, MenuIconPluginBtnClsPipe, MenuIconKgSearchBtnClsPipe, + LockedLayerBtnClsPipe, + GetFilenamePipe, + GetFileExtension, + BinSavedRegionsSelectionPipe, + SavedRegionsSelectionBtnDisabledPipe, /* directive */ DownloadDirective, @@ -114,7 +126,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling" /* dynamically created components needs to be declared here */ NehubaViewerUnit, LayerBrowser, - PluginBannerUI + PluginBannerUI, ], exports : [ SubjectViewer, diff --git a/src/ui/regionHierachy/filterNameBySearch.pipe.ts b/src/ui/viewerStateController/regionHierachy/filterNameBySearch.pipe.ts similarity index 100% rename from src/ui/regionHierachy/filterNameBySearch.pipe.ts rename to src/ui/viewerStateController/regionHierachy/filterNameBySearch.pipe.ts diff --git a/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts b/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts new file mode 100644 index 000000000..2fd52a746 --- /dev/null +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts @@ -0,0 +1,214 @@ +import { EventEmitter, Component, ElementRef, ViewChild, HostListener, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, Input, Output, AfterViewInit } from "@angular/core"; +import { Subscription, Subject, fromEvent } from "rxjs"; +import { buffer, debounceTime, tap } from "rxjs/operators"; +import { FilterNameBySearch } from "./filterNameBySearch.pipe"; +import { generateLabelIndexId } from "src/services/stateStore.service"; + +const insertHighlight :(name:string, searchTerm:string) => string = (name:string, searchTerm:string = '') => { + const regex = new RegExp(searchTerm, 'gi') + return searchTerm === '' ? + name : + name.replace(regex, (s) => `<span class = "highlight">${s}</span>`) +} + +const getDisplayTreeNode : (searchTerm:string, selectedRegions:any[]) => (item:any) => string = (searchTerm:string = '', selectedRegions:any[] = []) => ({ ngId, name, status, labelIndex }) => { + return !!labelIndex + && !!ngId + && selectedRegions.findIndex(re => + generateLabelIndexId({ labelIndex: re.labelIndex, ngId: re.ngId }) === generateLabelIndexId({ ngId, labelIndex }) + ) >= 0 + ? `<span class="cursor-default regionSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) + : `<span class="cursor-default regionNotSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) +} + +const getFilterTreeBySearch = (pipe:FilterNameBySearch, searchTerm:string) => (node:any) => pipe.transform([node.name, node.status], searchTerm) + +@Component({ + selector: 'region-hierarchy', + templateUrl: './regionHierarchy.template.html', + styleUrls: [ + './regionHierarchy.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class RegionHierarchy implements OnInit, AfterViewInit{ + + @Input() + public selectedRegions: any[] = [] + + @Input() + public parcellationSelected: any + + private _showRegionTree: boolean = false + + @Output() + private showRegionFlagChanged: EventEmitter<boolean> = new EventEmitter() + + @Output() + private singleClickRegion: EventEmitter<any> = new EventEmitter() + + @Output() + private doubleClickRegion: EventEmitter<any> = new EventEmitter() + + @Output() + private clearAllRegions: EventEmitter<MouseEvent> = new EventEmitter() + + public searchTerm: string = '' + private subscriptions: Subscription[] = [] + + @ViewChild('searchTermInput', {read: ElementRef}) + private searchTermInput: ElementRef + + public placeHolderText: string = `Start by selecting a template and a parcellation.` + + /** + * set the height to max, bound by max-height + */ + numTotalRenderedRegions: number = 999 + windowHeight: number + + @HostListener('document:click', ['$event']) + closeRegion(event: MouseEvent) { + const contains = this.el.nativeElement.contains(event.target) + this.showRegionTree = contains + if (!this.showRegionTree){ + this.searchTerm = '' + this.numTotalRenderedRegions = 999 + } + } + + @HostListener('window:resize', ['$event']) + onResize(event) { + this.windowHeight = event.target.innerHeight; + } + + get regionsLabelIndexMap() { + return null + } + + constructor( + private cdr:ChangeDetectorRef, + private el:ElementRef + ){ + this.windowHeight = window.innerHeight; + } + + ngOnChanges(){ + if (this.parcellationSelected) { + this.placeHolderText = `Search region in ${this.parcellationSelected.name}` + this.aggregatedRegionTree = { + name: this.parcellationSelected.name, + children: this.parcellationSelected.regions + } + } + this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) + this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) + } + + clearRegions(event:MouseEvent){ + this.clearAllRegions.emit(event) + } + + get showRegionTree(){ + return this._showRegionTree + } + + set showRegionTree(flag: boolean){ + this._showRegionTree = flag + this.showRegionFlagChanged.emit(this._showRegionTree) + } + + ngOnInit(){ + this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) + this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) + + this.subscriptions.push( + this.handleRegionTreeClickSubject.pipe( + buffer( + this.handleRegionTreeClickSubject.pipe( + debounceTime(200) + ) + ) + ).subscribe(arr => arr.length > 1 ? this.doubleClick(arr[0]) : this.singleClick(arr[0])) + ) + } + + ngAfterViewInit(){ + this.subscriptions.push( + fromEvent(this.searchTermInput.nativeElement, 'input').pipe( + debounceTime(200) + ).subscribe(ev => { + this.changeSearchTerm(ev) + }) + ) + } + + escape(event:KeyboardEvent){ + this.showRegionTree = false + this.searchTerm = ''; + (event.target as HTMLInputElement).blur() + + } + + handleTotalRenderedListChanged(changeEvent: {previous: number, current: number}){ + const { current } = changeEvent + this.numTotalRenderedRegions = current + } + + regionHierarchyHeight(){ + return({ + 'height' : (this.numTotalRenderedRegions * 15 + 60).toString() + 'px', + 'max-height': (this.windowHeight - 100) + 'px' + }) + } + + /* NB need to bind two way data binding like this. Or else, on searchInput blur, the flat tree will be rebuilt, + resulting in first click to be ignored */ + + changeSearchTerm(event: any) { + if (event.target.value === this.searchTerm) return + this.searchTerm = event.target.value + this.ngOnChanges() + this.cdr.markForCheck() + } + + private handleRegionTreeClickSubject: Subject<any> = new Subject() + + handleClickRegion(obj: any) { + const {event} = obj + /** + * TODO figure out why @closeRegion gets triggered, but also, contains returns false + */ + if (event) { + event.stopPropagation() + } + this.handleRegionTreeClickSubject.next(obj) + } + + /* single click selects/deselects region(s) */ + private singleClick(obj: any) { + if (!obj) return + const { inputItem : region } = obj + if (!region) return + this.singleClickRegion.emit(region) + } + + /* double click navigate to the interested area */ + private doubleClick(obj: any) { + if (!obj) + return + const { inputItem : region } = obj + if (!region) + return + this.doubleClickRegion.emit(region) + } + + public displayTreeNode: (item:any) => string + + private filterNameBySearchPipe = new FilterNameBySearch() + public filterTreeBySearch: (node:any) => boolean + + public aggregatedRegionTree: any + +} \ No newline at end of file diff --git a/src/ui/regionHierachy/regionHierarchy.style.css b/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css similarity index 86% rename from src/ui/regionHierachy/regionHierarchy.style.css rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css index a4aa507f4..8fbf9e702 100644 --- a/src/ui/regionHierachy/regionHierarchy.style.css +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css @@ -26,7 +26,6 @@ div[treeContainer] div[hideScrollbarcontainer] { - width: 20em; overflow:hidden; margin-top:2px; } @@ -62,4 +61,20 @@ input[type="text"] .tree-body { flex: 1 1 auto; +} + +:host +{ + display: flex; + flex-direction: column; +} + +:host > mat-form-field +{ + flex: 0 0 auto; +} + +:host > [hideScrollbarContainer] +{ + flex: 1 1 0; } \ No newline at end of file diff --git a/src/ui/regionHierachy/regionHierarchy.template.html b/src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html similarity index 72% rename from src/ui/regionHierachy/regionHierarchy.template.html rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html index 91d9f552c..48e0540fe 100644 --- a/src/ui/regionHierachy/regionHierarchy.template.html +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html @@ -1,24 +1,19 @@ -<div class="input-group regionSearch"> +<mat-form-field class="w-100"> <input #searchTermInput - tabindex="0" + matInput (keydown.esc)="escape($event)" - (focus)="showRegionTree = true && !isMobile" + (focus)="showRegionTree = true" [value]="searchTerm" class="form-control form-control-sm" type="text" autocomplete="off" - [placeholder]="getInputPlaceholder(selectedParcellation)"/> - -</div> + [placeholder]="placeHolderText"/> +</mat-form-field> -<div - *ngIf="showRegionTree" - hideScrollbarContainer> - +<div hideScrollbarContainer> <div - [ngStyle]="regionHierarchyHeight()" - class="d-flex flex-column" + class="d-flex flex-column h-100" treeContainer #treeContainer> <div class="tree-header d-inline-flex align-items-center"> @@ -34,7 +29,7 @@ </div> <div - *ngIf="selectedParcellation && selectedParcellation.regions as regions" + *ngIf="parcellationSelected && parcellationSelected.regions as regions" class="tree-body"> <flat-tree-component (treeNodeClick)="handleClickRegion($event)" diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.component.ts b/src/ui/viewerStateController/regionSearch/regionSearch.component.ts new file mode 100644 index 000000000..c0e178323 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.component.ts @@ -0,0 +1,149 @@ +import { Component, EventEmitter, Output, ViewChild, ElementRef, TemplateRef } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable } from "rxjs"; +import { map, distinctUntilChanged, startWith, withLatestFrom, filter, debounceTime, tap, share, shareReplay, take } from "rxjs/operators"; +import { getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId } from "src/services/stateStore.service"; +import { FormControl } from "@angular/forms"; +import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger, MatDialog } from "@angular/material"; +import { ADD_TO_REGIONS_SELECTION_WITH_IDS, SELECT_REGIONS } from "src/services/state/viewerState.store"; +import { VIEWERSTATE_ACTION_TYPES } from "../viewerState.component"; + +const filterRegionBasedOnText = searchTerm => region => region.name.toLowerCase().includes(searchTerm.toLowerCase()) + +@Component({ + selector: 'region-text-search-autocomplete', + templateUrl: './regionSearch.template.html', + styleUrls: [ + './regionSearch.style.css' + ] +}) + +export class RegionTextSearchAutocomplete{ + + @ViewChild('autoTrigger', {read: ElementRef}) autoTrigger: ElementRef + @ViewChild('regionHierarchy', {read:TemplateRef}) regionHierarchyTemplate: TemplateRef<any> + constructor( + private store$: Store<any>, + private dialog: MatDialog, + ){ + + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.regionsWithLabelIndex$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + map(parcellationSelected => { + const returnArray = [] + const ngIdMap = getMultiNgIdsRegionsLabelIndexMap(parcellationSelected) + for (const [ngId, labelIndexMap] of ngIdMap) { + for (const [labelIndex, region] of labelIndexMap){ + returnArray.push({ + ...region, + ngId, + labelIndex, + labelIndexId: generateLabelIndexId({ ngId, labelIndex }) + }) + } + } + return returnArray + }) + ) + + this.autocompleteList$ = this.formControl.valueChanges.pipe( + startWith(''), + debounceTime(200), + withLatestFrom(this.regionsWithLabelIndex$.pipe( + startWith([]) + )), + map(([searchTerm, regionsWithLabelIndex]) => regionsWithLabelIndex.filter(filterRegionBasedOnText(searchTerm))), + map(arr => arr.slice(0, 5)) + ) + + this.regionsSelected$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + } + + public optionSelected(ev: MatAutocompleteSelectedEvent){ + const id = ev.option.value + this.store$.dispatch({ + type: ADD_TO_REGIONS_SELECTION_WITH_IDS, + selectRegionIds : [id] + }) + + this.autoTrigger.nativeElement.value = '' + this.autoTrigger.nativeElement.focus() + } + + private regionsWithLabelIndex$: Observable<any[]> + public autocompleteList$: Observable<any[]> + public formControl = new FormControl() + + public regionsSelected$: Observable<any> + public parcellationSelected$: Observable<any> + + + @Output() + public focusedStateChanged: EventEmitter<boolean> = new EventEmitter() + + private _focused: boolean = false + set focused(val: boolean){ + this._focused = val + this.focusedStateChanged.emit(val) + } + get focused(){ + return this._focused + } + + public deselectAllRegions(event: MouseEvent){ + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: [] + }) + } + + // TODO handle mobile + handleRegionClick({ mode = null, region = null } = {}){ + const type = mode === 'single' + ? VIEWERSTATE_ACTION_TYPES.SINGLE_CLICK_ON_REGIONHIERARCHY + : mode === 'double' + ? VIEWERSTATE_ACTION_TYPES.DOUBLE_CLICK_ON_REGIONHIERARCHY + : '' + this.store$.dispatch({ + type, + payload: { region } + }) + } + + showHierarchy(event:MouseEvent){ + const dialog = this.dialog.open(this.regionHierarchyTemplate, { + height: '90vh', + width: '90vw' + }) + + /** + * keep sleight of hand shown while modal is shown + * + */ + this.focused = true + + /** + * take 1 to avoid memory leak + */ + dialog.afterClosed().pipe( + take(1) + ).subscribe(() => this.focused = false) + } + +} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.style.css b/src/ui/viewerStateController/regionSearch/regionSearch.style.css new file mode 100644 index 000000000..17cda15a4 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.style.css @@ -0,0 +1,4 @@ +region-hierarchy +{ + height: 100%; +} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.template.html b/src/ui/viewerStateController/regionSearch/regionSearch.template.html new file mode 100644 index 000000000..ef828ac2e --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.template.html @@ -0,0 +1,45 @@ +<div class="d-flex flex-row align-items-center"> + + <form class="flex-grow-1 flex-shrink-1"> + <mat-form-field class="w-100"> + <input + placeholder="Regions" + #autoTrigger + #trigger="matAutocompleteTrigger" + type="text" + matInput + [formControl]="formControl" + [matAutocomplete]="auto"> + </mat-form-field> + <mat-autocomplete + (opened)="focused = true" + (closed)="focused = false" + (optionSelected)="optionSelected($event)" + autoActiveFirstOption + #auto="matAutocomplete"> + <mat-option + *ngFor="let region of autocompleteList$ | async" + [value]="region.labelIndexId"> + {{ region.name }} + </mat-option> + </mat-autocomplete> + </form> + + <button + class="flex-grow-0 flex-shrink-0" + (click)="showHierarchy($event)" + mat-icon-button color="primary"> + <i class="fas fa-sitemap"></i> + </button> +</div> + +<ng-template #regionHierarchy> + <region-hierarchy + [selectedRegions]="regionsSelected$ | async | filterNull" + (singleClickRegion)="handleRegionClick({ mode: 'single', region: $event })" + (doubleClickRegion)="handleRegionClick({ mode: 'double', region: $event })" + (clearAllRegions)="deselectAllRegions($event)" + [parcellationSelected]="parcellationSelected$ | async"> + + </region-hierarchy> +</ng-template> diff --git a/src/ui/viewerStateController/viewerState.component.ts b/src/ui/viewerStateController/viewerState.component.ts new file mode 100644 index 000000000..726c1e895 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.component.ts @@ -0,0 +1,304 @@ +import { Component, ViewChild, TemplateRef, OnInit } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable, Subject, combineLatest, Subscription } from "rxjs"; +import { distinctUntilChanged, shareReplay, bufferTime, filter, map, withLatestFrom, delay, take, tap } from "rxjs/operators"; +import { SELECT_REGIONS, USER_CONFIG_ACTION_TYPES } from "src/services/stateStore.service"; +import { DESELECT_REGIONS, CHANGE_NAVIGATION } from "src/services/state/viewerState.store"; +import { ToastService } from "src/services/toastService.service"; +import { getSchemaIdFromName } from "src/util/pipes/templateParcellationDecoration.pipe"; +import { MatDialog, MatSelectChange, MatBottomSheet, MatBottomSheetRef } from "@angular/material"; +import { ExtraButton } from "src/components/radiolist/radiolist.component"; +import { DialogService } from "src/services/dialogService.service"; +import { RegionSelection } from "src/services/state/userConfigState.store"; + +const compareWith = (o, n) => !o || !n + ? false + : o.name === n.name + +@Component({ + selector: 'viewer-state-controller', + templateUrl: './viewerState.template.html', + styleUrls: [ + './viewerState.style.css' + ] +}) + +export class ViewerStateController implements OnInit{ + + @ViewChild('publicationTemplate', {read:TemplateRef}) publicationTemplate: TemplateRef<any> + @ViewChild('savedRegionBottomSheetTemplate', {read:TemplateRef}) savedRegionBottomSheetTemplate: TemplateRef<any> + + public focused: boolean = false + + private subscriptions: Subscription[] = [] + + public availableTemplates$: Observable<any[]> + public availableParcellations$: Observable<any[]> + + public templateSelected$: Observable<any> + public parcellationSelected$: Observable<any> + public regionsSelected$: Observable<any> + + public savedRegionsSelections$: Observable<any[]> + + public focusedDatasets$: Observable<any[]> + private userFocusedDataset$: Subject<any> = new Subject() + private dismissToastHandler: () => void + + public compareWith = compareWith + + private savedRegionBottomSheetRef: MatBottomSheetRef + + constructor( + private store$: Store<any>, + private toastService: ToastService, + private dialogService: DialogService, + private bottomSheet: MatBottomSheet + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.savedRegionsSelections$ = this.store$.pipe( + select('userConfigState'), + select('savedRegionsSelection'), + shareReplay(1) + ) + + this.templateSelected$ = viewerState$.pipe( + select('templateSelected'), + distinctUntilChanged() + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.regionsSelected$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.availableTemplates$ = viewerState$.pipe( + select('fetchedTemplates'), + distinctUntilChanged() + ) + + this.availableParcellations$ = this.templateSelected$.pipe( + select('parcellations') + ) + + this.focusedDatasets$ = this.userFocusedDataset$.pipe( + filter(v => !!v), + withLatestFrom( + combineLatest(this.templateSelected$, this.parcellationSelected$) + ), + ).pipe( + map(([userFocusedDataset, [selectedTemplate, selectedParcellation]]) => { + const { type, ...rest } = userFocusedDataset + if (type === 'template') return { ...selectedTemplate, ...rest} + if (type === 'parcellation') return { ...selectedParcellation, ...rest } + return { ...rest } + }), + bufferTime(100), + filter(arr => arr.length > 0), + /** + * merge properties field with the root level + * with the prop in properties taking priority + */ + map(arr => arr.map(item => { + const { properties } = item + return { + ...item, + ...properties + } + })), + shareReplay(1) + ) + } + + ngOnInit(){ + this.subscriptions.push( + this.savedRegionsSelections$.pipe( + filter(srs => srs.length === 0) + ).subscribe(() => this.savedRegionBottomSheetRef && this.savedRegionBottomSheetRef.dismiss()) + ) + this.subscriptions.push( + this.focusedDatasets$.subscribe(() => this.dismissToastHandler && this.dismissToastHandler()) + ) + this.subscriptions.push( + this.focusedDatasets$.pipe( + /** + * creates the illusion that the toast complete disappears before reappearing + */ + delay(100) + ).subscribe(() => this.dismissToastHandler = this.toastService.showToast(this.publicationTemplate, { + dismissable: true, + timeout:7000 + })) + ) + } + + handleActiveDisplayBtnClicked(event: MouseEvent, type: 'parcellation' | 'template', extraBtn: ExtraButton, inputItem:any = {}){ + const { name } = extraBtn + const { kgSchema, kgId } = getSchemaIdFromName(name) + this.userFocusedDataset$.next({ + ...inputItem, + kgSchema, + kgId + }) + } + + handleTemplateChange(event:MatSelectChange){ + + this.store$.dispatch({ + type: ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME, + payload: { + name: event.value + } + }) + } + + handleParcellationChange(event:MatSelectChange){ + if (!event.value) return + this.store$.dispatch({ + type: ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME, + payload: { + name: event.value + } + }) + } + + loadSavedRegion(event:MouseEvent, savedRegionsSelection:RegionSelection){ + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.LOAD_REGIONS_SELECTION, + payload: { + savedRegionsSelection + } + }) + } + + public editSavedRegion(event: MouseEvent, savedRegionsSelection: RegionSelection){ + event.preventDefault() + event.stopPropagation() + this.dialogService.getUserInput({ + defaultValue: savedRegionsSelection.name, + placeholder: `Enter new name`, + title: 'Edit name' + }).then(name => { + if (!name) throw new Error('user cancelled') + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.UPDATE_REGIONS_SELECTION, + payload: { + ...savedRegionsSelection, + name + } + }) + }).catch(e => { + // TODO catch user cancel + }) + } + public removeSavedRegion(event: MouseEvent, savedRegionsSelection: RegionSelection){ + event.preventDefault() + event.stopPropagation() + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.DELETE_REGIONS_SELECTION, + payload: { + ...savedRegionsSelection + } + }) + } + + + displayActiveParcellation(parcellation:any){ + return `<div class="d-flex"><small>Parcellation</small> <small class = "flex-grow-1 mute-text">${parcellation ? '(' + parcellation.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + } + + displayActiveTemplate(template: any) { + return `<div class="d-flex"><small>Template</small> <small class = "flex-grow-1 mute-text">${template ? '(' + template.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + } + + public loadSelection(event: MouseEvent){ + this.focused = true + + this.savedRegionBottomSheetRef = this.bottomSheet.open(this.savedRegionBottomSheetTemplate) + this.savedRegionBottomSheetRef.afterDismissed() + .subscribe(val => { + + }, error => { + + }, () => { + this.focused = false + this.savedRegionBottomSheetRef = null + }) + } + + public saveSelection(event: MouseEvent){ + this.focused = true + this.dialogService.getUserInput({ + defaultValue: `Saved Region`, + placeholder: `Name the selection`, + title: 'Save region selection' + }) + .then(name => { + if (!name) throw new Error('User cancelled') + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.SAVE_REGIONS_SELECTION, + payload: { name } + }) + }) + .catch(e => { + /** + * USER CANCELLED, HANDLE + */ + }) + .finally(() => this.focused = false) + } + + public deselectAllRegions(event: MouseEvent){ + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: [] + }) + } + + public deselectRegion(event: MouseEvent, region: any){ + this.store$.dispatch({ + type: DESELECT_REGIONS, + deselectRegions: [region] + }) + } + + public gotoRegion(event: MouseEvent, region:any){ + if (region.position) { + this.store$.dispatch({ + type: CHANGE_NAVIGATION, + navigation: { + position: region.position, + animation: {} + } + }) + } else { + /** + * TODO convert to snack bar + */ + this.toastService.showToast(`${region.name} does not have a position defined`, { + timeout: 5000, + dismissable: true + }) + } + } +} + +const ACTION_TYPES = { + SINGLE_CLICK_ON_REGIONHIERARCHY: 'SINGLE_CLICK_ON_REGIONHIERARCHY', + DOUBLE_CLICK_ON_REGIONHIERARCHY: 'DOUBLE_CLICK_ON_REGIONHIERARCHY', + SELECT_TEMPLATE_WITH_NAME: 'SELECT_TEMPLATE_WITH_NAME', + SELECT_PARCELLATION_WITH_NAME: 'SELECT_PARCELLATION_WITH_NAME', +} + +export const VIEWERSTATE_ACTION_TYPES = ACTION_TYPES diff --git a/src/ui/viewerStateController/viewerState.pipes.ts b/src/ui/viewerStateController/viewerState.pipes.ts new file mode 100644 index 000000000..659d35778 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.pipes.ts @@ -0,0 +1,38 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { RegionSelection } from "src/services/state/userConfigState.store"; + +@Pipe({ + name: 'binSavedRegionsSelectionPipe' +}) + +export class BinSavedRegionsSelectionPipe implements PipeTransform{ + public transform(regionSelections:RegionSelection[]):{parcellationSelected:any, templateSelected:any, regionSelections: RegionSelection[]}[]{ + const returnMap = new Map() + for (let regionSelection of regionSelections){ + const key = `${regionSelection.templateSelected.name}\n${regionSelection.parcellationSelected.name}` + const existing = returnMap.get(key) + if (existing) existing.push(regionSelection) + else returnMap.set(key, [regionSelection]) + } + return Array.from(returnMap) + .map(([_, regionSelections]) => { + const {parcellationSelected = null, templateSelected = null} = regionSelections[0] || {} + return { + regionSelections, + parcellationSelected, + templateSelected + } + }) + } +} + +@Pipe({ + name: 'savedRegionsSelectionBtnDisabledPipe' +}) + +export class SavedRegionsSelectionBtnDisabledPipe implements PipeTransform{ + public transform(regionSelection: RegionSelection, templateSelected: any, parcellationSelected: any): boolean{ + return regionSelection.parcellationSelected.name !== parcellationSelected.name + || regionSelection.templateSelected.name !== templateSelected.name + } +} \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.style.css b/src/ui/viewerStateController/viewerState.style.css new file mode 100644 index 000000000..0da6d0ea5 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.style.css @@ -0,0 +1,35 @@ +.virtual-scroll-viewport-container +{ + height: 20em; + width: 20em; + overflow: hidden; +} + +.virtual-scroll-viewport-container > cdk-virtual-scroll-viewport +{ + width: 100%; + height: 100%; + box-sizing: content-box; + padding-right: 3em; +} + +.virtual-scroll-row +{ + width: 20em; +} + +/* required to match virtual scroll itemSize property */ +.virtual-scroll-unit +{ + height: 26px +} + +.selected-region-container +{ + flex: 1 1 auto; +} + +.selected-region-actionbtn +{ + flex: 0 0 auto; +} diff --git a/src/ui/viewerStateController/viewerState.template.html b/src/ui/viewerStateController/viewerState.template.html new file mode 100644 index 000000000..9e1718231 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.template.html @@ -0,0 +1,202 @@ +<mat-card> + + <!-- template selection --> + <mat-form-field> + <mat-label> + Template + </mat-label> + <mat-select + [value]="(templateSelected$ | async)?.name" + (selectionChange)="handleTemplateChange($event)" + (openedChange)="focused = $event"> + <mat-option + *ngFor="let template of (availableTemplates$ | async)" + [value]="template.name"> + {{ template.name }} + </mat-option> + </mat-select> + </mat-form-field> + <ng-container *ngIf="templateSelected$ | async as templateSelected"> + <ng-container *ngIf="(templateSelected | templateParcellationsDecorationPipe)?.extraButtons as extraButtons"> + <button + *ngFor="let extraBtn of extraButtons" + (click)="handleActiveDisplayBtnClicked($event, 'template', extraBtn, templateSelected)" + mat-icon-button> + <i [class]="extraBtn.faIcon"></i> + </button> + </ng-container> + </ng-container> + + <!-- parcellation selection --> + <mat-form-field *ngIf="templateSelected$ | async as templateSelected"> + <mat-label> + Parcellation + </mat-label> + <mat-select + (selectionChange)="handleParcellationChange($event)" + [value]="(parcellationSelected$ | async)?.name" + (openedChange)="focused = $event"> + <mat-option + *ngFor="let parcellation of (templateSelected.parcellations | appendTooltipTextPipe)" + [value]="parcellation.name"> + {{ parcellation.name }} + </mat-option> + </mat-select> + </mat-form-field> + + <ng-container *ngIf="parcellationSelected$ | async as parcellationSelected"> + <ng-container *ngIf="(parcellationSelected | templateParcellationsDecorationPipe)?.extraButtons as extraButtons"> + <button + *ngFor="let extraBtn of extraButtons" + (click)="handleActiveDisplayBtnClicked($event, 'parcellation', extraBtn, parcellationSelected)" + mat-icon-button> + <i [class]="extraBtn.faIcon"></i> + </button> + </ng-container> + </ng-container> + + <!-- divider --> + <mat-divider></mat-divider> + + <!-- selected regions --> + + <div class="d-flex"> + <region-text-search-autocomplete + (focusedStateChanged)="focused = $event"> + </region-text-search-autocomplete> + </div> + + <!-- chips --> + <mat-card class="w-20em mh-10em overflow-auto overflow-x-hidden"> + <mat-chip-list class="mat-chip-list-stacked" #selectedRegionsChipList> + <mat-chip class="w-100" *ngFor="let region of (regionsSelected$ | async)"> + <span class="flex-grow-1 flex-shrink-1 text-truncate"> + {{ region.name }} + </span> + <button + *ngIf="region.position" + (click)="gotoRegion($event, region)" + mat-icon-button> + <i class="fas fa-map-marked-alt"></i> + </button> + <button + (click)="deselectRegion($event, region)" + mat-icon-button> + <i class="fas fa-trash"></i> + </button> + </mat-chip> + </mat-chip-list> + + <!-- place holder when no regions has been selected --> + <span class="muted" *ngIf="(regionsSelected$ | async).length === 0"> + No regions selected. Double click on any regions in the viewer, or use the search tool to select regions of interest. + </span> + </mat-card> + + <!-- control btns --> + <div class="mt-2 mb-2 d-flex justify-content-between"> + <div class="d-flex"> + + <!-- save --> + <button + matTooltip="Save this selection of regions" + matTooltipPosition="below" + mat-button + (click)="saveSelection($event)" + color="primary"> + <i class="fas fa-save"></i> + + </button> + + <!-- load --> + <button + (click)="loadSelection($event)" + matTooltip="Load a selection of regions" + matTooltipPosition="below" + mat-button + color="primary" + [disabled]="(savedRegionsSelections$ | async)?.length === 0"> + <i + matBadgeColor="accent" + [matBadgeOverlap]="false" + [matBadge]="(savedRegionsSelections$ | async)?.length > 0 ? (savedRegionsSelections$ | async)?.length : null" + class="fas fa-folder-open"></i> + + </button> + </div> + + <!-- deselect all --> + <button + (click)="deselectAllRegions($event)" + matTooltip="Deselect all selected regions" + matTooltipPosition="below" + mat-raised-button + color="warn" + [disabled]="(regionsSelected$ | async)?.length === 0"> + <i class="fas fa-trash"></i> + </button> + </div> +</mat-card> + +<ng-template #publicationTemplate> + <single-dataset-view + *ngFor="let focusedDataset of (focusedDatasets$ | async)" + [name]="focusedDataset.name" + [description]="focusedDataset.description" + [publications]="focusedDataset.publications" + [kgSchema]="focusedDataset.kgSchema" + [kgId]="focusedDataset.kgId"> + + </single-dataset-view> +</ng-template> + +<!-- bottom sheet for saved regions --> +<ng-template #savedRegionBottomSheetTemplate> + <mat-action-list> + + <!-- separated (binned) by template/parcellation --> + <ng-container *ngFor="let binnedRS of (savedRegionsSelections$ | async | binSavedRegionsSelectionPipe); let index = index"> + + <!-- only render divider if it is not the leading element --> + <mat-divider *ngIf="index !== 0"></mat-divider> + + <!-- header --> + <h3 mat-subheader> + {{ binnedRS.templateSelected.name }} / {{ binnedRS.parcellationSelected.name }} + </h3> + + <!-- ng for all saved regions --> + <button + *ngFor="let savedRegionsSelection of binnedRS.regionSelections" + (click)="loadSavedRegion($event, savedRegionsSelection)" + mat-list-item> + <!-- [class]="savedRegionsSelection | savedRegionsSelectionBtnDisabledPipe : (templateSelected$ | async) : (parcellationSelected$ | async) ? 'text-muted' : ''" --> + <!-- [disabled]="savedRegionsSelection | savedRegionsSelectionBtnDisabledPipe : (templateSelected$ | async) : (parcellationSelected$ | async)" --> + <!-- main content --> + <span class="flex-grow-0 flex-shrink-1"> + {{ savedRegionsSelection.name }} + </span> + <small class="ml-1 mr-1 text-muted flex-grow-1 flex-shrink-0"> + ({{ savedRegionsSelection.regionsSelected.length }} selected regions) + </small> + + <!-- edit btn --> + <button + (mousedown)="$event.stopPropagation()" + (click)="editSavedRegion($event, savedRegionsSelection)" + mat-icon-button> + <i class="fas fa-edit"></i> + </button> + + <!-- trash btn --> + <button + (mousedown)="$event.stopPropagation()" + (click)="removeSavedRegion($event, savedRegionsSelection)" + mat-icon-button + color="warn"> + <i class="fas fa-trash"></i> + </button> + </button> + </ng-container> + </mat-action-list> +</ng-template> \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts new file mode 100644 index 000000000..4c8232e1c --- /dev/null +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -0,0 +1,180 @@ +import { Subscription, Observable } from "rxjs"; +import { Injectable, OnInit, OnDestroy } from "@angular/core"; +import { Actions, ofType, Effect } from "@ngrx/effects"; +import { Store, select, Action } from "@ngrx/store"; +import { ToastService } from "src/services/toastService.service"; +import { shareReplay, distinctUntilChanged, map, withLatestFrom, filter } from "rxjs/operators"; +import { VIEWERSTATE_ACTION_TYPES } from "./viewerState.component"; +import { CHANGE_NAVIGATION, SELECT_REGIONS, NEWVIEWER, GENERAL_ACTION_TYPES, SELECT_PARCELLATION, isDefined } from "src/services/stateStore.service"; +import { regionFlattener } from "src/util/regionFlattener"; + +@Injectable({ + providedIn: 'root' +}) + +export class ViewerStateControllerUseEffect implements OnInit, OnDestroy{ + + private subscriptions: Subscription[] = [] + + private selectedRegions$: Observable<any[]> + + @Effect() + singleClickOnHierarchy$: Observable<any> + + @Effect() + selectTemplateWithName$: Observable<any> + + @Effect() + selectParcellationWithName$: Observable<any> + + doubleClickOnHierarchy$: Observable<any> + + constructor( + private actions$: Actions, + private store$: Store<any>, + private toastService: ToastService + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.selectedRegions$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged() + ) + + this.selectParcellationWithName$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME), + map(action => { + const { payload = {} } = action as ViewerStateAction + const { name } = payload + return name + }), + filter(name => !!name), + withLatestFrom(viewerState$.pipe( + select('parcellationSelected') + )), + filter(([name, parcellationSelected]) => { + if (parcellationSelected && parcellationSelected.name === name) return false + return true + }), + map(([name, _]) => name), + withLatestFrom(viewerState$.pipe( + select('templateSelected') + )), + map(([name, templateSelected]) => { + + const { parcellations: availableParcellations } = templateSelected + const newParcellation = availableParcellations.find(t => t.name === name) + if (!newParcellation) { + return { + type: GENERAL_ACTION_TYPES.ERROR, + payload: { + message: 'Selected parcellation not found.' + } + } + } + return { + type: SELECT_PARCELLATION, + selectParcellation: newParcellation + } + }) + ) + + this.selectTemplateWithName$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME), + map(action => { + const { payload = {} } = action as ViewerStateAction + const { name } = payload + return name + }), + filter(name => !!name), + withLatestFrom(viewerState$.pipe( + select('templateSelected') + )), + filter(([name, templateSelected]) => { + if (templateSelected && templateSelected.name === name) return false + return true + }), + map(([name, templateSelected]) => name), + withLatestFrom(viewerState$.pipe( + select('fetchedTemplates') + )), + map(([name, availableTemplates]) => { + const newTemplateTobeSelected = availableTemplates.find(t => t.name === name) + if (!newTemplateTobeSelected) { + return { + type: GENERAL_ACTION_TYPES.ERROR, + payload: { + message: 'Selected template not found.' + } + } + } + return { + type: NEWVIEWER, + selectTemplate: newTemplateTobeSelected, + selectParcellation: newTemplateTobeSelected.parcellations[0] + } + }) + ) + + this.doubleClickOnHierarchy$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.DOUBLE_CLICK_ON_REGIONHIERARCHY) + ) + + this.singleClickOnHierarchy$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SINGLE_CLICK_ON_REGIONHIERARCHY), + withLatestFrom(this.selectedRegions$), + map(([action, regionsSelected]) => { + + const {payload = {}} = action as ViewerStateAction + const { region } = payload + + const flattenedRegion = regionFlattener(region).filter(r => isDefined(r.labelIndex)) + const flattenedRegionNames = new Set(flattenedRegion.map(r => r.name)) + const selectedRegionNames = new Set(regionsSelected.map(r => r.name)) + const selectAll = flattenedRegion.every(r => !selectedRegionNames.has(r.name)) + return { + type: SELECT_REGIONS, + selectRegions: selectAll + ? regionsSelected.concat(flattenedRegion) + : regionsSelected.filter(r => !flattenedRegionNames.has(r.name)) + } + }) + ) + } + + ngOnInit(){ + this.subscriptions.push( + this.doubleClickOnHierarchy$.subscribe(({ region } = {}) => { + const { position } = region + if (position) { + this.store$.dispatch({ + type: CHANGE_NAVIGATION, + navigation: { + position, + animation: {} + } + }) + } else { + this.toastService.showToast(`${region.name} does not have a position defined`, { + timeout: 5000, + dismissable: true + }) + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } +} + +interface ViewerStateAction extends Action{ + payload: any + config: any +} \ No newline at end of file diff --git a/src/util/pipes/filterNgLayer.pipe.ts b/src/util/pipes/filterNgLayer.pipe.ts deleted file mode 100644 index 798075159..000000000 --- a/src/util/pipes/filterNgLayer.pipe.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; -import { NgLayerInterface } from "src/atlasViewer/atlasViewer.component"; - -/** - * TODO deprecate - * use regular pipe to achieve the same effect - */ - -@Pipe({ - name: 'filterNgLayer' -}) - -export class FilterNgLayer implements PipeTransform{ - public transform(excludedLayers: string[] = [], ngLayers: NgLayerInterface[]): NgLayerInterface[] { - const set = new Set(excludedLayers) - return ngLayers.filter(l => !set.has(l.name)) - } -} \ No newline at end of file diff --git a/src/util/pipes/getFileExt.pipe.ts b/src/util/pipes/getFileExt.pipe.ts new file mode 100644 index 000000000..aea77ceba --- /dev/null +++ b/src/util/pipes/getFileExt.pipe.ts @@ -0,0 +1,36 @@ +import { PipeTransform, Pipe } from "@angular/core"; + +const NIFTI = `NIFTI Volume` +const VTK = `VTK Mesh` + +const extMap = new Map([ + ['.nii', NIFTI], + ['.nii.gz', NIFTI], + ['.vtk', VTK] +]) + +@Pipe({ + name: 'getFileExtension' +}) + +export class GetFileExtension implements PipeTransform{ + private regex: RegExp = new RegExp('(\\.[\\w\\.]*?)$') + + private getRegexp(ext){ + return new RegExp(`${ext.replace(/\./g, '\\.')}$`, 'i') + } + + private detFileExt(filename:string):string{ + for (let [key, val] of extMap){ + if(this.getRegexp(key).test(filename)){ + return val + } + } + return filename + } + + public transform(filename:string):string{ + return this.detFileExt(filename) + } +} + diff --git a/src/util/pipes/getFileNameFromPathName.pipe.ts b/src/util/pipes/getFileNameFromPathName.pipe.ts deleted file mode 100644 index d64a96c1c..000000000 --- a/src/util/pipes/getFileNameFromPathName.pipe.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; - - -@Pipe({ - name : 'getFilenameFromPathname' -}) - -export class GetFilenameFromPathnamePipe implements PipeTransform{ - public transform(pathname:string):string{ - return pathname.split('/')[pathname.split('/').length - 1] - } -} \ No newline at end of file diff --git a/src/util/pipes/getFilename.pipe.ts b/src/util/pipes/getFilename.pipe.ts new file mode 100644 index 000000000..76afea562 --- /dev/null +++ b/src/util/pipes/getFilename.pipe.ts @@ -0,0 +1,14 @@ +import { PipeTransform, Pipe } from "@angular/core"; + +@Pipe({ + name: 'getFilenamePipe' +}) + +export class GetFilenamePipe implements PipeTransform{ + private regex: RegExp = new RegExp('[\\/\\\\]([\\w\\.]*?)$') + public transform(fullname: string): string{ + return this.regex.test(fullname) + ? this.regex.exec(fullname)[1] + : fullname + } +} \ No newline at end of file -- GitLab