diff --git a/.github/workflows/docker_img.yml b/.github/workflows/docker_img.yml index 176821e86ca0e7be8250b8490dc6a6dd494bf6b9..3470644fb386722065358045c226d8989a208b34 100644 --- a/.github/workflows/docker_img.yml +++ b/.github/workflows/docker_img.yml @@ -43,7 +43,7 @@ jobs: echo "BS_REST_URL=${{ env.SIIBRA_API_LATEST }}" >> $GITHUB_ENV fi - - name: 'Set version variable' + - name: 'Set version variable & expmt feature flag' run: | if [[ "$GITHUB_REF" == 'refs/heads/master' ]] || [[ "$GITHUB_REF" == 'refs/heads/staging' ]] then @@ -52,6 +52,7 @@ jobs: else echo "Using git hash" VERSION=$(git rev-parse --short HEAD) + echo "EXPERIMENTAL_FEATURE_FLAG=true" >> $GITHUB_ENV fi echo "VERSION=$VERSION" >> $GITHUB_ENV - name: 'Build docker image' @@ -63,6 +64,7 @@ jobs: --build-arg MATOMO_URL=$MATOMO_URL \ --build-arg MATOMO_ID=$MATOMO_ID \ --build-arg BS_REST_URL=$BS_REST_URL \ + --build-arg EXPERIMENTAL_FEATURE_FLAG=$EXPERIMENTAL_FEATURE_FLAG \ -t $DOCKER_BUILT_TAG \ . echo "Successfully built $DOCKER_BUILT_TAG" diff --git a/Dockerfile b/Dockerfile index 2754e365f581600c4d7f2432e125b71c216150f7..23dea08fc6aa3b0cbc1e837707d7d6765b761e62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,9 @@ ENV MATOMO_URL=${MATOMO_URL} ARG MATOMO_ID ENV MATOMO_ID=${MATOMO_ID} +ARG EXPERIMENTAL_FEATURE_FLAG +ENV EXPERIMENTAL_FEATURE_FLAG=${EXPERIMENTAL_FEATURE_FLAG:-false} + COPY . /iv WORKDIR /iv diff --git a/common/constants.js b/common/constants.js index 00fd20a33a36df6b725bbc3735c4fc0d9d52dd32..ceeb3d027330bb5167bd469abe812569d8be6da0 100644 --- a/common/constants.js +++ b/common/constants.js @@ -60,6 +60,7 @@ //Viewer mode VIEWER_MODE_ANNOTATING: 'annotating', + VIEWER_MODE_KEYFRAME: 'key frame', // Annotations USER_ANNOTATION_LIST: 'user annotations footer', diff --git a/package-lock.json b/package-lock.json index bc71ed5ff69c23f514eeef154188e888ea3b63f4..ddebd64c07e83e9ea2fcfe80eb0efa2b9cdc910a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "interactive-viewer", - "version": "2.4.0", + "version": "2.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index caa678cc44df957b127679abf2ac3421f88f1f60..f2c3057d8c6514d6b5b3a6d5c51bb1d27a683aca 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -14,4 +14,7 @@ export const environment = { // invite user to touch/interact after 5 min of inactivity KIOSK_MODE: false, + + // experimental feature flag + EXPERIMENTAL_FEATURE_FLAG: false, } diff --git a/src/environments/parseEnv.js b/src/environments/parseEnv.js index 293c50cbcc171ae98ef3c186fa7437d4b58b98c5..2b3052d932dd2be8ca0f738ec2f4e26c91ef1801 100644 --- a/src/environments/parseEnv.js +++ b/src/environments/parseEnv.js @@ -13,7 +13,8 @@ const main = async () => { MATOMO_ID, BS_REST_URL, VERSION, - GIT_HASH + GIT_HASH, + EXPERIMENTAL_FEATURE_FLAG } = process.env const version = JSON.stringify( VERSION || GIT_HASH || 'unspecificied hash' @@ -30,6 +31,7 @@ export const environment = { STRICT_LOCAL: ${JSON.stringify(STRICT_LOCAL)}, MATOMO_URL: ${JSON.stringify(MATOMO_URL)}, MATOMO_ID: ${JSON.stringify(MATOMO_ID)}, + EXPERIMENTAL_FEATURE_FLAG: ${EXPERIMENTAL_FEATURE_FLAG} } ` await asyncWrite(pathToEnvFile, outputTxt, 'utf-8') diff --git a/src/keyframesModule/constants.ts b/src/keyframesModule/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d716c05f4abaa7a9a6a98909a806c5ff03c63f2 --- /dev/null +++ b/src/keyframesModule/constants.ts @@ -0,0 +1,2 @@ +import { ARIA_LABELS } from 'common/constants' +export const KEYFRAME_VIEWMODE = ARIA_LABELS.VIEWER_MODE_KEYFRAME \ No newline at end of file diff --git a/src/keyframesModule/index.ts b/src/keyframesModule/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/keyframesModule/keyframe.directive.ts b/src/keyframesModule/keyframe.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..50708e85062ba075edf28ca9a41506d492694419 --- /dev/null +++ b/src/keyframesModule/keyframe.directive.ts @@ -0,0 +1,42 @@ +import { Directive, HostListener, Input } from "@angular/core"; +import { KeyFrameService } from "./service"; + +@Directive({ + selector: '[key-frame-play-now]' +}) + +export class KeyFrameDirective{ + @HostListener('click') + onClick(){ + if (this._mode === 'on') { + this.svc.startKeyFrameSession() + return + } + if (this._mode === 'off') { + this.svc.endKeyFrameSession() + return + } + if (this.svc.inSession) { + this.svc.endKeyFrameSession() + } else { + this.svc.startKeyFrameSession() + } + } + + private _mode: 'toggle' | 'off' | 'on' = 'on' + @Input('key-frame-play-now') + set mode(val: string){ + if (val === 'off') { + this._mode = val + return + } + if (val === 'toggle') { + this._mode = val + return + } + this._mode = 'on' + } + + constructor(private svc: KeyFrameService){ + } +} diff --git a/src/keyframesModule/keyframeCtrl/keyframeCtrl.component.ts b/src/keyframesModule/keyframeCtrl/keyframeCtrl.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f909d5280166c3544c8f3ec81ed0610db49fa88 --- /dev/null +++ b/src/keyframesModule/keyframeCtrl/keyframeCtrl.component.ts @@ -0,0 +1,258 @@ +import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop"; +import { Component, OnDestroy, Optional } from "@angular/core"; +import { MatSnackBar } from "@angular/material/snack-bar"; +import { Subscription } from "rxjs"; +import { getUuid } from "src/util/fn"; +import { timedValues } from "src/util/generator"; +import { AUTO_ROTATE, TAutoRotatePayload, ViewerInternalStateSvc } from "src/viewerModule/viewerInternalState.service"; + +type TStoredState = { + name: string + duration: number + viewerType: string + payload: any +} + +@Component({ + selector: 'key-frame-controller', + templateUrl: './keyframeCtrl.template.html', + styleUrls: [ + './keyframeCtrl.style.css' + ] +}) + +export class KeyFrameCtrlCmp implements OnDestroy { + + public loopFlag = false + public linearFlag = false + public currState: any + private currViewerType: string + public internalStates: TStoredState[] = [] + + private subs: Subscription[] = [] + + ngOnDestroy(){ + while(this.subs.length) this.subs.pop().unsubscribe() + } + + constructor( + private snackbar: MatSnackBar, + @Optional() private viewerInternalSvc: ViewerInternalStateSvc + ){ + if (!viewerInternalSvc) { + this.snackbar.open(`error: ViewerInternalStateSvc not injected.`) + return + } + + this.subs.push( + viewerInternalSvc.viewerInternalState$.pipe( + ).subscribe(state => { + this.currState = state.payload + this.currViewerType = state.viewerType + }) + ) + } + + addKeyFrame(){ + this.internalStates = [ + ...this.internalStates, + { + name: `Frame ${this.internalStates.length + 1}`, + duration: 1000, + viewerType: this.currViewerType, + payload: this.currState + } + ] + } + + private _autoRotateSpeed = 2 + get autoRotateSpeed(){ + return this._autoRotateSpeed + } + set autoRotateSpeed(val: number){ + this._autoRotateSpeed = val + this.updateAutoRotate() + } + + private _autoRotateReverse = false + get autoRotateReverse(){ + return this._autoRotateReverse + } + set autoRotateReverse(val: boolean){ + this._autoRotateReverse = val + this.updateAutoRotate() + } + + private _autoRotateFlag = false + get autoRotateFlag(){ + return this._autoRotateFlag + } + set autoRotateFlag(val: boolean){ + this._autoRotateFlag = val + this.updateAutoRotate() + } + + private updateAutoRotate(){ + this.viewerInternalSvc.applyInternalState<TAutoRotatePayload>({ + "@id": getUuid(), + "@type": 'TViewerInternalStateEmitterEvent', + payload: { + play: this._autoRotateFlag, + speed: this._autoRotateSpeed, + reverse: this._autoRotateReverse + }, + viewerType: AUTO_ROTATE + }) + } + + private raf: number + + public isPlaying = false + async togglePlay(){ + if (this.isPlaying) { + this.isPlaying = false + return + } + + if (this.internalStates.length === 0) { + return + } + + this.isPlaying = true + + let idx = 0 + this.gotoFrame(this.internalStates[0]) + + // eslint-disable-next-line no-constant-condition + while (true) { + if (!this.isPlaying) break + try { + await this.animateFrame( + this.internalStates[idx % this.internalStates.length], + this.internalStates[(idx + 1) % this.internalStates.length] + ) + idx ++ + if (idx >= this.internalStates.length-1 && !this.loopFlag) break + } catch (e) { + // user interrupted + console.log(e) + break + } + } + this.isPlaying = false + } + private async animateFrame(fromFrame: TStoredState, toFrame: TStoredState) { + const toPayloadCamera = (toFrame.payload as any).camera + const fromPayloadCamera = (fromFrame.payload as any).camera + + const delta = { + x: toPayloadCamera.x - fromPayloadCamera.x, + y: toPayloadCamera.y - fromPayloadCamera.y, + z: toPayloadCamera.z - fromPayloadCamera.z, + } + + const applyDelta = (() => { + if (this.linearFlag) { + return (d: number) => { + return { + x: delta.x * d + fromPayloadCamera.x, + y: delta.y * d + fromPayloadCamera.y, + z: delta.z * d + fromPayloadCamera.z, + } + } + } else { + const THREE = (window as any).ThreeSurfer.THREE + const idQ = new THREE.Quaternion() + const targetQ = new THREE.Quaternion() + const vec1 = new THREE.Vector3( + fromPayloadCamera.x, + fromPayloadCamera.y, + fromPayloadCamera.z, + ) + + const startVec = vec1.clone() + const vec1Length = vec1.length() + const vec2 = new THREE.Vector3( + toPayloadCamera.x, + toPayloadCamera.y, + toPayloadCamera.z, + ) + const vec2Length = vec2.length() + vec1.normalize() + vec2.normalize() + + targetQ.setFromUnitVectors(vec1, vec2) + + return (d: number) => { + const deltaQ = idQ.clone() + deltaQ.slerp(targetQ, d) + const v = startVec.clone() + v.applyQuaternion(deltaQ) + return { + x: v.x, + y: v.y, + z: v.z + } + } + } + })() + + const gen = timedValues(toFrame.duration) + + return new Promise((rs, rj) => { + + const animate = () => { + if (!this.isPlaying) { + this.raf = null + return rj('User interrupted') + } + const next = gen.next() + const d = next.value + + if (this.viewerInternalSvc) { + const camera = applyDelta(d) + this.viewerInternalSvc.applyInternalState({ + "@id": getUuid(), + "@type": "TViewerInternalStateEmitterEvent", + viewerType: fromFrame.viewerType, + payload: { + camera + } + }) + } + + if (next.done) { + this.raf = null + + rs('') + } else { + this.raf = requestAnimationFrame(() => { + animate() + }) + } + } + this.raf = requestAnimationFrame(() => { + animate() + }) + }) + } + + gotoFrame(item: TStoredState) { + if (this.viewerInternalSvc) { + this.viewerInternalSvc.applyInternalState({ + "@id": getUuid(), + "@type": "TViewerInternalStateEmitterEvent", + viewerType: item.viewerType, + payload: item.payload + }) + } + } + + removeFrame(item: TStoredState){ + this.internalStates = this.internalStates.filter(v => item !== v) + } + + drop(event: CdkDragDrop<string[]>) { + moveItemInArray(this.internalStates, event.previousIndex, event.currentIndex); + } +} diff --git a/src/keyframesModule/keyframeCtrl/keyframeCtrl.style.css b/src/keyframesModule/keyframeCtrl/keyframeCtrl.style.css new file mode 100644 index 0000000000000000000000000000000000000000..b2f6be6b044c7edb50fd6ebefa6f1a2656547c24 --- /dev/null +++ b/src/keyframesModule/keyframeCtrl/keyframeCtrl.style.css @@ -0,0 +1,26 @@ +.cdk-drop-list-dragging :not(.cdk-drag-placeholder) +{ + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} +.cdk-drag-placeholder { + opacity: 0.2; +} +[cdkDragHandle] +{ + cursor:ns-resize; +} + +mat-form-field +{ + margin-right: 0.25rem; +} + +.duration-container +{ + width: 3.5rem; +} + +.controller-container > * +{ + display: block; +} diff --git a/src/keyframesModule/keyframeCtrl/keyframeCtrl.template.html b/src/keyframesModule/keyframeCtrl/keyframeCtrl.template.html new file mode 100644 index 0000000000000000000000000000000000000000..d1b9382a39ac5656727b8779233b88a9a3c3faca --- /dev/null +++ b/src/keyframesModule/keyframeCtrl/keyframeCtrl.template.html @@ -0,0 +1,85 @@ +<ng-template #noCurrStateTmpl> + No current state recorded +</ng-template> + +<div *ngIf="currState; else noCurrStateTmpl" + class="controller-container m-4"> + + <mat-slide-toggle [(ngModel)]="autoRotateFlag">Auto Rotate</mat-slide-toggle> + + <div> + <mat-slider + min="1" + max="10" + step="0.2" + [disabled]="!autoRotateFlag" + [(ngModel)]="autoRotateSpeed"> + </mat-slider> + <span>Speed</span> + </div> + + <mat-slide-toggle + [(ngModel)]="autoRotateReverse" + [disabled]="!autoRotateFlag"> + Reverse + </mat-slide-toggle> + + <mat-divider class="m-2"></mat-divider> + + <mat-slide-toggle [(ngModel)]="loopFlag">Loop</mat-slide-toggle> + <mat-slide-toggle [(ngModel)]="linearFlag">Linear Camera</mat-slide-toggle> + + <button mat-button + (click)="togglePlay()"> + <ng-template [ngIf]="isPlaying" [ngIfElse]="isNotPlayingTmpl"> + <i class="fas fa-stop"></i> + Stop + </ng-template> + <ng-template #isNotPlayingTmpl> + <i class="fas fa-play"></i> + Play + </ng-template> + </button> + + <mat-divider class="m-2"></mat-divider> + + <button + (click)="addKeyFrame()" + matTooltip="Add key frame. Shortcut: [a]" + mat-button + color="primary"> + <i class="fas fa-plus"></i> + Add Key Frame + </button> +</div> + +<mat-list cdkDropList (cdkDropListDropped)="drop($event)"> + <mat-list-item *ngFor="let state of internalStates" cdkDrag> + <button mat-icon-button + cdkDragHandle> + <i class="fas fa-grip-vertical"></i> + </button> + + <mat-form-field> + <mat-label>name</mat-label> + <input type="text" matInput [(ngModel)]="state.name"> + </mat-form-field> + + <mat-form-field class="duration-container"> + <mat-label>ms</mat-label> + <input type="number" matInput [(ngModel)]="state.duration"> + </mat-form-field> + + <button mat-icon-button + (click)="gotoFrame(state)"> + <i class="fas fa-map-marker-alt"></i> + </button> + + <button mat-icon-button + color="warn" + (click)="removeFrame(state)"> + <i class="fas fa-trash"></i> + </button> + + </mat-list-item> +</mat-list> \ No newline at end of file diff --git a/src/keyframesModule/module.ts b/src/keyframesModule/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..0042fdbf4a8dd60efc0f048897950236fba2e3b8 --- /dev/null +++ b/src/keyframesModule/module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { ComponentsModule } from "src/components"; +import { AngularMaterialModule } from "src/sharedModules"; +import { KeyFrameDirective } from "./keyframe.directive"; +import { KeyFrameCtrlCmp } from "./keyframeCtrl/keyframeCtrl.component"; +import { KeyFrameService } from "./service"; + +@NgModule({ + imports: [ + CommonModule, + AngularMaterialModule, + ComponentsModule, + FormsModule, + ], + declarations: [ + KeyFrameCtrlCmp, + KeyFrameDirective, + ], + exports: [ + KeyFrameCtrlCmp, + KeyFrameDirective, + ], + providers: [ + KeyFrameService, + ] +}) + +export class KeyFrameModule{} \ No newline at end of file diff --git a/src/keyframesModule/service.ts b/src/keyframesModule/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b807aa15ba8414c823bf48ab132fb01c29c19590 --- /dev/null +++ b/src/keyframesModule/service.ts @@ -0,0 +1,51 @@ +import { Injectable, OnDestroy } from "@angular/core"; +import { MatDialog } from "@angular/material/dialog"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject, Subscription } from "rxjs"; +import { distinctUntilChanged } from "rxjs/operators"; +import { viewerStateSetViewerMode } from "src/services/state/viewerState.store.helper"; +import { KEYFRAME_VIEWMODE } from "./constants"; +import { KeyFrameCtrlCmp } from "./keyframeCtrl/keyframeCtrl.component"; + +@Injectable() +export class KeyFrameService implements OnDestroy { + + inSession$ = new BehaviorSubject(false) + private subs: Subscription[] = [] + private _inSession = false + get inSession(){ + return this._inSession + } + set inSession(val){ + this._inSession = val + this.inSession$.next(val) + } + + ngOnDestroy(){ + while(this.subs.length) this.subs.pop().unsubscribe() + } + + startKeyFrameSession(){ + this.inSession = true + } + endKeyFrameSession(){ + this.inSession = false + } + + constructor( + // private dialog: MatDialog, + private store: Store<any> + ){ + this.inSession$.pipe( + distinctUntilChanged() + ).subscribe(flag => { + + // TODO enable side bar when ready + this.store.dispatch( + viewerStateSetViewerMode({ + payload: flag && KEYFRAME_VIEWMODE + }) + ) + }) + } +} \ No newline at end of file diff --git a/src/ui/topMenu/module.ts b/src/ui/topMenu/module.ts index 9b4d781bb97bc6e21fa27a4453025e327a72727a..241a0e3d27543f3968c81d4964834b3f7dce2fa8 100644 --- a/src/ui/topMenu/module.ts +++ b/src/ui/topMenu/module.ts @@ -14,6 +14,7 @@ import { AngularMaterialModule } from "src/sharedModules"; import { TopMenuCmp } from "./topMenuCmp/topMenu.components"; import { UserAnnotationsModule } from "src/atlasComponents/userAnnotations"; import { QuickTourModule } from "src/ui/quickTour/module"; +import { KeyFrameModule } from "src/keyframesModule/module"; @NgModule({ imports: [ @@ -30,6 +31,7 @@ import { QuickTourModule } from "src/ui/quickTour/module"; AuthModule, ScreenshotModule, UserAnnotationsModule, + KeyFrameModule, QuickTourModule, ], declarations: [ diff --git a/src/ui/topMenu/topMenuCmp/topMenu.components.ts b/src/ui/topMenu/topMenuCmp/topMenu.components.ts index d9adf9d8a823e893329e57832c411cc652464f1d..3b4ecb5f4aa8bd0446c5bcf11a5eee79b27c582b 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.components.ts +++ b/src/ui/topMenu/topMenuCmp/topMenu.components.ts @@ -11,6 +11,7 @@ import { MatDialog, MatDialogConfig, MatDialogRef } from "@angular/material/dial import { MatBottomSheet } from "@angular/material/bottom-sheet"; import { CONST, QUICKTOUR_DESC } from 'common/constants' import { IQuickTourData } from "src/ui/quickTour/constrants"; +import { environment } from 'src/environments/environment' @Component({ selector: 'top-menu-cmp', @@ -23,6 +24,8 @@ import { IQuickTourData } from "src/ui/quickTour/constrants"; export class TopMenuCmp { + public EXPERIMENTAL_FEATURE_FLAG = environment.EXPERIMENTAL_FEATURE_FLAG + public PINNED_DATASETS_BADGE_DESC = CONST.PINNED_DATASETS_BADGE_DESC public matBtnStyle = 'mat-icon-button' @@ -49,7 +52,8 @@ export class TopMenuCmp { public pluginTooltipText: string = `Plugins and Tools` public screenshotTooltipText: string = 'Take screenshot' public annotateTooltipText: string = 'Start annotating' - + public keyFrameText = `Start KeyFrames` + public quickTourData: IQuickTourData = { description: QUICKTOUR_DESC.TOP_MENU, order: 8, diff --git a/src/ui/topMenu/topMenuCmp/topMenu.template.html b/src/ui/topMenu/topMenuCmp/topMenu.template.html index 6982b1ad149a20377d938d8f0b9ebe5a752aac7d..dc889adc9b46ee6ddd826bd4c76452d55beb743f 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.template.html +++ b/src/ui/topMenu/topMenuCmp/topMenu.template.html @@ -163,6 +163,18 @@ Annotation mode </span> </button> + + <button mat-menu-item + *ngIf="EXPERIMENTAL_FEATURE_FLAG" + [disabled]="!viewerLoaded" + key-frame-play-now + [matTooltip]="keyFrameText"> + <mat-icon fontSet="fas" fontIcon="fa-play"></mat-icon> + <span> + KeyFrames + </span> + </button> + <plugin-banner></plugin-banner> </mat-menu> diff --git a/src/util/pureConstant.service.ts b/src/util/pureConstant.service.ts index 16258cba419a3bef23721f6a7143737a946dbbbd..b7962a9fe0ed9622e7237cb2b7cd7245ac9539ad 100644 --- a/src/util/pureConstant.service.ts +++ b/src/util/pureConstant.service.ts @@ -448,6 +448,17 @@ Raise/track issues at github repo: <a target = "_blank" href = "${this.repoUrl}" return forkJoin( templateSpaces.map( tmpl => { + // hardcode + // see https://github.com/FZJ-INM1-BDA/siibra-python/issues/98 + if ( + tmpl.id === 'minds/core/referencespace/v1.0.0/tmp-fsaverage' + && !tmpl.availableParcellations.find(p => p.id === 'minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290') + ) { + tmpl.availableParcellations.push({ + id: 'minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290', + name: 'Julich-Brain Probabilistic Cytoarchitectonic Maps (v2.9)' + }) + } ngLayerObj[tmpl.id] = {} return tmpl.availableParcellations.map( parc => this.getRegions(atlas['@id'], parc.id, tmpl.id).pipe( diff --git a/src/viewerModule/module.ts b/src/viewerModule/module.ts index eb6302174da652e6328def8c94531b160894e9aa..53d48a2ecc114144ba740b3f25231f2030882df9 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -26,6 +26,8 @@ import { ViewerStateBreadCrumbModule } from "./viewerStateBreadCrumb/module"; import { KgRegionalFeatureModule } from "src/atlasComponents/regionalFeatures/bsFeatures/kgRegionalFeature"; import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, AtlasViewerAPIServices, setViewerHandleFactory } from "src/atlasViewer/atlasViewer.apiService.service"; import { ILoadMesh, LOAD_MESH_TOKEN } from "src/messaging/types"; +import { KeyFrameModule } from "src/keyframesModule/module"; +import { ViewerInternalStateSvc } from "./viewerInternalState.service"; @NgModule({ imports: [ @@ -47,6 +49,7 @@ import { ILoadMesh, LOAD_MESH_TOKEN } from "src/messaging/types"; ContextMenuModule, ViewerStateBreadCrumbModule, KgRegionalFeatureModule, + KeyFrameModule, ], declarations: [ ViewerCmp, @@ -95,6 +98,7 @@ import { ILoadMesh, LOAD_MESH_TOKEN } from "src/messaging/types"; ] }, AtlasViewerAPIServices, + ViewerInternalStateSvc, ], exports: [ ViewerCmp, diff --git a/src/viewerModule/threeSurfer/module.ts b/src/viewerModule/threeSurfer/module.ts index 5c58247b69ecaecfc39ea7e2625b4537705cbccb..21f11580befb07553f6f613b3b0f86fa2366e8d6 100644 --- a/src/viewerModule/threeSurfer/module.ts +++ b/src/viewerModule/threeSurfer/module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; +import { ComponentsModule } from "src/components"; import { AngularMaterialModule } from "src/sharedModules"; import { UtilModule } from "src/util"; import { ThreeSurferGlueCmp } from "./threeSurferGlue/threeSurfer.component"; @@ -12,6 +13,7 @@ import { ThreeSurferViewerConfig } from "./tsViewerConfig/tsViewerConfig.compone AngularMaterialModule, UtilModule, FormsModule, + ComponentsModule, ], declarations: [ ThreeSurferGlueCmp, diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index 852a10faa56829c7ab8da8b87cbb749a19e04cc4..551bec7a55fc14645db6470dc3920ddaef092a8c 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -14,8 +14,19 @@ import { REGION_OF_INTEREST } from "src/util/interfaces"; import { MatSnackBar } from "@angular/material/snack-bar"; import { CONST } from 'common/constants' import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, TSetViewerHandle } from "src/atlasViewer/atlasViewer.apiService.service"; -import { switchMapWaitFor } from "src/util/fn"; - +import { getUuid, switchMapWaitFor } from "src/util/fn"; +import { AUTO_ROTATE, TInteralStatePayload, ViewerInternalStateSvc } from "src/viewerModule/viewerInternalState.service"; + +const viewerType = 'ThreeSurfer' +type TInternalState = { + camera: { + x: number + y: number + z: number + } + mode: string + hemisphere: 'left' | 'right' | 'both' +} const pZoomFactor = 5e3 type THandlingCustomEv = { @@ -88,18 +99,49 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, OnChanges, Af private localCameraNav: TCameraOrientation = null public allKeys: {name: string, checked: boolean}[] = [] + private internalStateNext: (arg: TInteralStatePayload<TInternalState>) => void private regionMap: Map<string, Map<number, any>> = new Map() private mouseoverRegions = [] + + private raf: number constructor( private el: ElementRef, private store$: Store<any>, private navStateStoreRelay: ComponentStore<{ perspectiveOrientation: [number, number, number, number], perspectiveZoom: number }>, private snackbar: MatSnackBar, + @Optional() intViewerStateSvc: ViewerInternalStateSvc, @Optional() @Inject(REGION_OF_INTEREST) private roi$: Observable<any>, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, ){ + if (intViewerStateSvc) { + const { + done, + next, + } = intViewerStateSvc.registerEmitter({ + "@type": 'TViewerInternalStateEmitter', + viewerType, + applyState: arg => { + if (arg.viewerType === AUTO_ROTATE) { + const autoPlayFlag = (arg.payload as any).play + const reverseFlag = (arg.payload as any).reverse + const autoplaySpeed = (arg.payload as any).speed + this.toTsRef(tsRef => { + tsRef.control.autoRotate = autoPlayFlag + tsRef.control.autoRotateSpeed = autoplaySpeed * (reverseFlag ? -1 : 1) + }) + return + } + if (arg.viewerType !== viewerType) return + this.toTsRef(tsRef => { + tsRef.camera.position.copy((arg.payload as any).camera) + }) + } + }) + this.internalStateNext = next + this.onDestroyCb.push(() => done()) + } // set viewer handle // the API won't be 100% compatible with ngviewer @@ -474,7 +516,7 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, OnChanges, Af this.tsRef.dispose() this.tsRef = null } - ); + ) this.tsRef.control.enablePan = false while (this.tsRefInitCb.length > 0) this.tsRefInitCb.pop()(this.tsRef) } @@ -574,6 +616,18 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, OnChanges, Af private cameraEv$ = new Subject<{ position: { x: number, y: number, z: number }, zoom: number }>() private handleCustomCameraEvent(detail: any){ + if (this.internalStateNext) { + this.internalStateNext({ + "@id": getUuid(), + "@type": 'TViewerInternalStateEmitterEvent', + viewerType, + payload: { + mode: this.selectedMode, + camera: detail.position, + hemisphere: 'both' + } + }) + } this.cameraEv$.next(detail) } @@ -634,4 +688,10 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, OnChanges, Af ngOnDestroy() { while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()() } + + toggleMode(){ + const currIdx = this.modes.findIndex(m => m.name === this.selectedMode) + const newIdx = (currIdx + 1) % this.modes.length + this.loadMode(this.modes[newIdx]) + } } diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html index 56a25de0502ffc08a17a2a560cc83be60d7e7bcc..c1981ce4f144361f24e86026bf15de4d063f89fc 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html @@ -7,6 +7,8 @@ <!-- selector & configurator --> <button mat-icon-button + [iav-key-listener]="[{ type: 'keydown', key: 'q', target: 'document' }]" + (iav-key-event)="toggleMode()" color="primary" class="pe-all" [matMenuTriggerFor]="fsModeSelMenu"> @@ -39,5 +41,9 @@ <span> {{ mode.name }} </span> + <markdown-dom *ngIf="mode.name === selectedMode" + class="d-inline-block"> + `[q]` + </markdown-dom> </button> </mat-menu> diff --git a/src/viewerModule/viewerCmp/viewerCmp.style.css b/src/viewerModule/viewerCmp/viewerCmp.style.css index 735eba1520dca238a3688a96a4e35bbe68e14332..19b64d6b88116e5954dd0fb2226e6fc79b3dcc01 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.style.css +++ b/src/viewerModule/viewerCmp/viewerCmp.style.css @@ -20,6 +20,10 @@ margin-top: 1.5rem; } +.tab-toggle-container > * +{ + display: block; +} .tab-toggle { diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index aa2004d86a9df45e61b54719dbd291f627df569e..30d2c0411ce039d73beba463374fe6a3e2bed884 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -6,34 +6,42 @@ <layout-floating-container [zIndex]="10"> <!-- Annotation mode --> - <div *ngIf="(viewerMode$ | async) === ARIA_LABELS.VIEWER_MODE_ANNOTATING"> - <mat-drawer-container class="mat-drawer-content-overflow-visible w-100 h-100 position-absolute invisible" + <mat-drawer-container *ngIf="viewerMode$ | async as viewerMode" + class="mat-drawer-content-overflow-visible w-100 h-100 position-absolute invisible" [hasBackdrop]="false"> - <mat-drawer #annotationDrawer="matDrawer" + + <mat-drawer #viewerModeDrawer="matDrawer" mode="side" - (annotation-event-directive)="annotationDrawer.open()" + (annotation-event-directive)="viewerModeDrawer.open()" [annotation-event-directive-filter]="['showList']" [autoFocus]="false" [disableClose]="true" class="p-0 pe-all col-10 col-sm-10 col-md-5 col-lg-4 col-xl-3 col-xxl-2"> - <annotation-list></annotation-list> - </mat-drawer> - <mat-drawer-content class="visible position-relative pe-none"> + <!-- annotation --> + <ng-template [ngIf]="viewerMode === ARIA_LABELS.VIEWER_MODE_ANNOTATING"> + <annotation-list></annotation-list> + </ng-template> - <iav-layout-fourcorners> + <ng-template [ngIf]="viewerMode === ARIA_LABELS.VIEWER_MODE_KEYFRAME"> + <key-frame-controller></key-frame-controller> + </ng-template> + + </mat-drawer> + <mat-drawer-content class="visible position-relative pe-none"> + <!-- annotation specific --> + <iav-layout-fourcorners *ngIf="viewerMode === ARIA_LABELS.VIEWER_MODE_ANNOTATING"> <!-- pullable tab top right corner --> <div iavLayoutFourCornersTopLeft class="tab-toggle-container"> - <div> - <ng-container *ngTemplateOutlet="tabTmpl_defaultTmpl; context: { - matColor: 'primary', - fontIcon: 'fa-list', - tooltip: 'Annotation list', - click: annotationDrawer.toggle.bind(annotationDrawer) - }"> - </ng-container> - </div> + + <ng-container *ngTemplateOutlet="tabTmpl_defaultTmpl; context: { + matColor: 'primary', + fontIcon: 'fa-list', + tooltip: 'Annotation list', + click: viewerModeDrawer.toggle.bind(viewerModeDrawer) + }"> + </ng-container> <annotating-tools-panel class="z-index-10"> </annotating-tools-panel> @@ -53,13 +61,42 @@ </button> </mat-card> </div> + </iav-layout-fourcorners> + <!-- key frame specific --> + <iav-layout-fourcorners *ngIf="viewerMode === ARIA_LABELS.VIEWER_MODE_KEYFRAME"> + <!-- pullable tab top right corner --> + <div iavLayoutFourCornersTopLeft class="tab-toggle-container"> + + <ng-container *ngTemplateOutlet="tabTmpl_defaultTmpl; context: { + matColor: 'primary', + fontIcon: 'fa-play', + tooltip: 'Annotation list', + click: viewerModeDrawer.toggle.bind(viewerModeDrawer) + }"> + </ng-container> + </div> + + <div iavLayoutFourCornersTopRight> + <mat-card class="mat-card-sm pe-all m-4"> + <span> + Key Frame + </span> + <button mat-icon-button + color="warn" + key-frame-play-now="off"> + <i class="fas fa-times"></i> + </button> + </mat-card> + </div> + + </iav-layout-fourcorners> </mat-drawer-content> + </mat-drawer-container> - <!-- <annotation-message></annotation-message> --> - </div> + <!-- top drawer --> <mat-drawer-container diff --git a/src/viewerModule/viewerInternalState.service.ts b/src/viewerModule/viewerInternalState.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..3985a2f65e45663166adfa73e0d1a500adeafcf9 --- /dev/null +++ b/src/viewerModule/viewerInternalState.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; + +export const AUTO_ROTATE = `[special] autoRotate` +export type TAutoRotatePayload = { + play: boolean + speed?: number + reverse?: boolean +} + +export type TInteralStatePayload<TPayloadShape> = { + '@type': 'TViewerInternalStateEmitterEvent' + '@id': string + viewerType: string + payload: TPayloadShape +} + +type TViewerInternalStateEmitter = { + '@type': 'TViewerInternalStateEmitter' + viewerType: string + applyState: <T>(arg: TInteralStatePayload<T>) => void +} + +type TEmitterCallbacks<TPayloadShape> = { + next: (arg: TInteralStatePayload<TPayloadShape>) => void + done: () => void +} + +@Injectable() +export class ViewerInternalStateSvc{ + + public viewerInternalState$ = new BehaviorSubject<TInteralStatePayload<any>>(null) + + private registeredEmitter: TViewerInternalStateEmitter + + applyInternalState<T>(arg: TInteralStatePayload<T>){ + if (!this.registeredEmitter) { + throw new Error(`No emitter registered. Aborting.`) + } + this.registeredEmitter.applyState(arg) + } + + registerEmitter<T>(emitter: TViewerInternalStateEmitter): TEmitterCallbacks<T>{ + this.registeredEmitter = emitter + return { + next: arg => { + this.viewerInternalState$.next(arg) + }, + done: () => this.deregisterEmitter(emitter) + } + } + deregisterEmitter(emitter: TViewerInternalStateEmitter){ + if (emitter === this.registeredEmitter) { + this.viewerInternalState$.next(null) + this.registeredEmitter = null + } + } +} \ No newline at end of file