diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index fed724a6..2e2227bb 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -68,6 +68,7 @@ import { Subject, debounceTime, map, merge, shareReplay } from 'rxjs'; { provide: VOLUBA_APP_CONFIG, useValue: { + uploadUrl: "https://zam10143.zam.kfa-juelich.de/chumni", linearBackend: "https://voluba-backend.apps.tc.humanbrainproject.eu" } as VolubaAppConfig } diff --git a/frontend/src/const.ts b/frontend/src/const.ts index da9d5e24..92faa9c5 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -128,6 +128,7 @@ export function transCoordSpcScaling(src: CoordSpace, dst: CoordSpace): export_n } export type VolubaAppConfig = { + uploadUrl: string linearBackend: string } @@ -144,3 +145,68 @@ export type LinearXformResult = { target_point: number[] }[] } + +export const LOGIN_METHODS = [{ + name: 'ebrains keycloak', + href: '/hbp-oidc-v2/auth' +}] + +type ChumniNg = { + data_type: string + resolution: number[] + size: number[] + transform: number[][] + type: string // "image" | "segmentation" +} + +type ChumniNifti = { + affineMatrix: number[][] + niftiVersion: string // "Nifti1" | "Nifti2" + byteOrder: string // "LITTLE_ENDIAN" | "BIG_ENDIAN" + size: number[] + voxelSize: number[] + spatialUnits: string + temporalUnits: string + dataType: string // float etc + coordinateSystem: string // SCANNER_ANAT etc +} + +export type ChumniPreflightResp = { + fileName: string + neuroglancer: ChumniNg + nifti: ChumniNifti + warnings: string[] +} + +export type ChumniVolume = { + visibility: 'public' | 'private' + name: string + extra: { + data: { + minValue: 0 + maxValue: 0 + } + fileName: string + fileSize: number + fileSizeUncompressed: number + neuroglancer: ChumniNg + nifti: ChumniNifti + uploaded: string + warnings: string[] + } + links: { + /** + * Absolute path (i.e. starts with /) + */ + normalized: string + } +} + +export const SEGMENTATION_EXPLAINER_TEXT = "A segmentation nii file can be ingested differently to an image nii file" + +export function trimFilename(filename: string): string { + if (filename.length < 10) { + return filename + } + return `${filename.slice(0, 10)}...` +} diff --git a/frontend/src/io/dragDrop.directive.ts b/frontend/src/io/dragDrop.directive.ts new file mode 100644 index 00000000..a7b3be5f --- /dev/null +++ b/frontend/src/io/dragDrop.directive.ts @@ -0,0 +1,111 @@ +import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, Output, inject } from "@angular/core"; +import { fromEvent, merge, Observable, of } from "rxjs"; +import { debounceTime, distinctUntilChanged, map, scan, switchMap, takeUntil } from "rxjs/operators"; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from "src/sharedModule" +import { DestroyDirective } from "src/util/destroy.directive"; + +@Directive({ + selector: '[drag-drop-file]', + exportAs: 'dragDropFile', + hostDirectives: [ + DestroyDirective + ] +}) + +export class DragDropFileDirective { + + destroyed$ = inject(DestroyDirective).destroyed$ + + @Input() + public snackText: string|undefined + + @Output('drag-drop-file') + public dragDropOnDrop: EventEmitter = new EventEmitter() + + @HostBinding('style.transition') + public transition = `opacity 300ms ease-in` + + @HostBinding('style.opacity') + public hostOpacity: number = 0.5 + + get opacity() { + return this.hostOpacity + } + + @Input('drag-drop-file-opacity') + set opacity(val: number) { + this.hostOpacity = val + this.cdr.markForCheck() + } + + public snackbarRef: MatSnackBarRef|undefined + + private dragover$: Observable + + @HostListener('dragover', ['$event']) + public ondragover(ev: DragEvent) { + ev.preventDefault() + } + + @HostListener('drop', ['$event']) + public ondrop(ev: DragEvent) { + ev.preventDefault() + this.reset() + + this.dragDropOnDrop.emit(Array.from(ev?.dataTransfer?.files || [])) + } + + public reset() { + if (this.snackbarRef) { + this.snackbarRef.dismiss() + } + this.snackbarRef = undefined + this.opacity = 0.5 + } + + constructor(private snackBar: MatSnackBar, private el: ElementRef, private cdr: ChangeDetectorRef) { + this.dragover$ = merge( + of(null), + fromEvent(this.el.nativeElement, 'drop'), + ).pipe( + switchMap(() => merge( + fromEvent(this.el.nativeElement, 'dragenter').pipe( + map(() => 1), + ), + fromEvent(this.el.nativeElement, 'dragleave').pipe( + map(() => -1), + ), + ).pipe( + scan((acc, curr) => acc + curr, 0), + map(val => val > 0), + )), + ) + + this.dragover$.pipe( + takeUntil(this.destroyed$), + debounceTime(16), + distinctUntilChanged(), + ).subscribe(flag => { + if (flag) { + this.snackbarRef = this.snackBar.open( + this.snackText || `Drop file(s) here.`, 'Dismiss', + { + panelClass: 'sxplr-pe-none' + } + ) + + /** + * In buggy scenarios, user could at least dismiss by action + */ + this.snackbarRef.afterDismissed().subscribe(reason => { + if (reason.dismissedByAction) { + this.reset() + } + }) + this.opacity = 0.2 + } else { + this.reset() + } + }) + } +} diff --git a/frontend/src/io/module.ts b/frontend/src/io/module.ts index a0cebb18..7b885c1d 100644 --- a/frontend/src/io/module.ts +++ b/frontend/src/io/module.ts @@ -1,15 +1,22 @@ import { NgModule } from "@angular/core"; import { SaveToFileDirective } from "./saveFile.directive"; import { LoadFromFileDirective } from "./loadFile.directive"; +import { DragDropFileDirective } from "./dragDrop.directive"; +import { SharedModule } from "src/sharedModule/sharedModule"; @NgModule({ + imports: [ + SharedModule, + ], declarations: [ SaveToFileDirective, LoadFromFileDirective, + DragDropFileDirective, ], exports: [ SaveToFileDirective, LoadFromFileDirective, + DragDropFileDirective, ] }) diff --git a/frontend/src/landmarks/listview/listview.component.ts b/frontend/src/landmarks/listview/listview.component.ts index 102a5f70..d815294d 100644 --- a/frontend/src/landmarks/listview/listview.component.ts +++ b/frontend/src/landmarks/listview/listview.component.ts @@ -65,7 +65,7 @@ export class ListviewComponent { ) const position = [...landmark.position] const { vec3 } = export_nehuba - if (landmark.targetVolumeId === inc?.['@id']) { + if (landmark.targetVolumeId === inc?.id) { vec3.transformMat4(position, position, xform) } diff --git a/frontend/src/landmarks/toolbar/toolbar.component.ts b/frontend/src/landmarks/toolbar/toolbar.component.ts index a4780c03..991acf88 100644 --- a/frontend/src/landmarks/toolbar/toolbar.component.ts +++ b/frontend/src/landmarks/toolbar/toolbar.component.ts @@ -222,8 +222,8 @@ export class ToolbarComponent { return } const volId = !!purgatory - ? incomingVol['@id'] - : referenceVol['@id'] + ? incomingVol.id + : referenceVol.id this.store.dispatch( app.actions.addLandmark({ @@ -307,11 +307,11 @@ export class ToolbarComponent { id, incLm: { position: inc.coord.map(v => v*1e6) as [number, number, number], - targetVolumeId: selectedIncomingVolume['@id'] + targetVolumeId: selectedIncomingVolume.id }, tmplLm: { position: ref.coord.map(v => v*1e6) as [number, number, number], - targetVolumeId: selectedRefenceVolume['@id'] + targetVolumeId: selectedRefenceVolume.id }, name } diff --git a/frontend/src/layer-tune/layer-tune.module.ts b/frontend/src/layer-tune/layer-tune.module.ts index f41ec512..e261ac09 100644 --- a/frontend/src/layer-tune/layer-tune.module.ts +++ b/frontend/src/layer-tune/layer-tune.module.ts @@ -6,10 +6,12 @@ import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { ScrollableInput } from './scrollableInput.directive'; import { SharedModule } from 'src/sharedModule/sharedModule'; -import { RotationWidgetCmp } from './rotation-widget/rotation-widget.components'; @NgModule({ - declarations: [TuneUiComponent, ScrollableInput, RotationWidgetCmp], + declarations: [ + TuneUiComponent, + ScrollableInput, + ], imports: [ CommonModule, ReactiveFormsModule, @@ -17,6 +19,8 @@ import { RotationWidgetCmp } from './rotation-widget/rotation-widget.components' MatSlideToggleModule, SharedModule, ], - exports: [TuneUiComponent, RotationWidgetCmp], + exports: [ + TuneUiComponent + ], }) export class LayerTuneModule {} diff --git a/frontend/src/layer-tune/rotation-widget/consts.ts b/frontend/src/layer-tune/rotation-widget/consts.ts new file mode 100644 index 00000000..635aa9ec --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/consts.ts @@ -0,0 +1,6 @@ +export type SvgPath = { + path: { + type: 'M' | 'C' | 'z' + coords: number[][] + }[] +} diff --git a/frontend/src/layer-tune/rotation-widget/module.ts b/frontend/src/layer-tune/rotation-widget/module.ts new file mode 100644 index 00000000..50408de1 --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/module.ts @@ -0,0 +1,24 @@ +import { CommonModule } from "@angular/common"; +import { NO_ERRORS_SCHEMA, NgModule } from "@angular/core"; +import { GetClosestFurtherestPipe } from "./pathGetClosestFarthest.pipe"; +import { SvgPathToDPipe } from "./pathToD.pipe"; +import { RotationWidgetCmp } from "./rotation-widget.components"; + +@NgModule({ + imports: [ + CommonModule, + ], + declarations: [ + RotationWidgetCmp, + SvgPathToDPipe, + GetClosestFurtherestPipe, + ], + exports: [ + RotationWidgetCmp + ], + schemas: [ + NO_ERRORS_SCHEMA, + ] +}) + +export class RotationWidgetModule {} diff --git a/frontend/src/layer-tune/rotation-widget/pathGetClosestFarthest.pipe.ts b/frontend/src/layer-tune/rotation-widget/pathGetClosestFarthest.pipe.ts new file mode 100644 index 00000000..7b61c8ec --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/pathGetClosestFarthest.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { SvgPath } from "./consts"; + +@Pipe({ + name: 'getClosestFurtherest', + pure: true +}) + +export class GetClosestFurtherestPipe implements PipeTransform{ + transform(value: SvgPath) { + const arr = value.path.map(p => p.coords).flatMap(v => v).filter(v => !!v) + const sorted = arr.map((coord, idx) => { + return { + z: coord[2], + idx, + } + }) + sorted.sort((a, b) => a.z - b.z) + const sortedIdx = sorted[0].idx + + return { + closest: arr[sortedIdx], + furthest: arr[(sortedIdx + 7) % arr.length] + } + } +} diff --git a/frontend/src/layer-tune/rotation-widget/pathToD.pipe.ts b/frontend/src/layer-tune/rotation-widget/pathToD.pipe.ts new file mode 100644 index 00000000..75f3ca33 --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/pathToD.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { SvgPath } from "./consts"; + +@Pipe({ + name: 'pathToD', + pure: true +}) + +export class SvgPathToDPipe implements PipeTransform{ + transform(value: SvgPath): string[] { + return value.path.map(obj => { + const coordStr = obj.coords.map(coord => coord.slice(0, 2)).join(" ") + return `${obj.type}${coordStr}` + }) + } +} diff --git a/frontend/src/layer-tune/rotation-widget/rotation-widget.components.ts b/frontend/src/layer-tune/rotation-widget/rotation-widget.components.ts new file mode 100644 index 00000000..af8aaefb --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/rotation-widget.components.ts @@ -0,0 +1,167 @@ +import { Component, Input } from "@angular/core"; +import { BehaviorSubject, map } from "rxjs"; +import { SvgPath } from "./consts"; +import { GetClosestFurtherestPipe } from "./pathGetClosestFarthest.pipe"; +import { SvgPathToDPipe } from "./pathToD.pipe"; + +const getXform = (center: number[], quat: export_nehuba.quat, index: 1 | 2 | 3) => { + const { vec3 } = export_nehuba + const shuffle = (coord: number[]) => { + if (index === 1) { + return coord + } + if (index === 2) { + return coord.slice(0,1).concat(coord.slice(1).reverse()) + } + if (index === 3) { + return coord.slice(2).concat(coord.slice(0, 2)) + } + throw new Error(`Cannot shuffle index ${index}`) + } + return (coord: number[]) => { + const actualCoord = shuffle(coord.map((v, idx) => { + if (idx < 2) { + return v - center[idx] + } + return v + })) + const newV = vec3.fromValues(...actualCoord) + const disp = vec3.fromValues(...center, 0) + vec3.transformQuat(newV, newV, quat) + vec3.add(newV, newV, disp) + return Array.from(newV) + } +} + +const getGetPath = (center: number[], halfRadius: number, topRow: number, bottomRow: number, leftCol: number, rightCol: number) => { + return (xform: (coord:number[]) => number[]): SvgPath => { + return { + path: [{ + type: 'M', + coords: [[center[0], topRow, 0]].map(xform) + },{ + type: 'C', + coords: [ + [center[0] + halfRadius, topRow , 0], + [rightCol, topRow + halfRadius, 0], + [rightCol, center[1], 0] + ].map(xform) + },{ + type: 'C', + coords: [ + [rightCol, center[1] + halfRadius, 0], + [center[0] + halfRadius, bottomRow, 0], + [center[0], bottomRow, 0] + ].map(xform) + },{ + type: 'C', + coords: [ + [center[0] - halfRadius, bottomRow, 0], + [leftCol, center[1] + halfRadius, 0], + [leftCol, center[1], 0] + ].map(xform) + },{ + type: 'C', + coords: [ + [leftCol, center[1] - halfRadius, 0], + [center[0] - halfRadius, topRow, 0], + [center[0], topRow, 0] + ].map(xform) + },{ + type: 'z', + coords: [[]] + }] + } + } +} + +@Component({ + selector: 'rotation-widget', + templateUrl: './rotation-widget.template.html', + styleUrls: [ + './rotation-widget.style.scss' + ], +}) + +export class RotationWidgetCmp { + + #cfPipe = new GetClosestFurtherestPipe() + #pathToDPipe = new SvgPathToDPipe() + + #quat = new BehaviorSubject(export_nehuba.quat.fromValues(0, 0, 0, 1)) + + @Input('rotation-widget-rot-quat') + set _quat(val: export_nehuba.quat) { + this.#quat.next(val) + } + + width = 120 + height = 120 + + /** + * margin percent + * e.g. 0.1 + * === 10% total margin + * === 5% margin on left and right side respectively + */ + marginPc = 0.1 + + view$ = this.#quat.pipe( + map(quat => { + + const { width, height, marginPc } = this + const center = [ width / 2, height / 2 ] + const radius = (1 - marginPc) * width / 2 + const halfRadius = radius / 2 + + const topRow = center[0] - radius + const bottomRow = center[1] + radius + + const leftCol = center[0] - radius + const rightCol = center[0] + radius + + const blueXform = getXform(center, quat, 1) + const greenXform = getXform(center, quat, 2) + const redXform = getXform(center, quat, 3) + + const getPath = getGetPath(center, halfRadius, topRow, bottomRow, leftCol, rightCol) + + const redPath = getPath(redXform) + const greenPath = getPath(greenXform) + const bluePath = getPath(blueXform) + + console.log( + this.#cfPipe.transform(redPath) + ) + + return { + redPath, + greenPath, + bluePath, + + strokeWidth: 10, + + widgets: [ + { + path: redPath, + color: 'red', + d: this.#pathToDPipe.transform(redPath), + cf: this.#cfPipe.transform(redPath), + }, + { + path: greenPath, + color: 'green', + d: this.#pathToDPipe.transform(greenPath), + cf: this.#cfPipe.transform(greenPath), + }, + { + path: bluePath, + color: 'blue', + d: this.#pathToDPipe.transform(bluePath), + cf: this.#cfPipe.transform(bluePath), + }, + ] + } + }) + ) +} diff --git a/frontend/src/layer-tune/rotation-widget/rotation-widget.style.scss b/frontend/src/layer-tune/rotation-widget/rotation-widget.style.scss new file mode 100644 index 00000000..eb29fb4f --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/rotation-widget.style.scss @@ -0,0 +1,11 @@ +:host +{ + display: block; +} + +svg +{ + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/layer-tune/rotation-widget/rotation-widget.template.html b/frontend/src/layer-tune/rotation-widget/rotation-widget.template.html new file mode 100644 index 00000000..0de1a6a0 --- /dev/null +++ b/frontend/src/layer-tune/rotation-widget/rotation-widget.template.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/sharedModule/index.ts b/frontend/src/sharedModule/index.ts index f85acc81..a6608732 100644 --- a/frontend/src/sharedModule/index.ts +++ b/frontend/src/sharedModule/index.ts @@ -1,2 +1,2 @@ -export { MatSnackBar } from "@angular/material/snack-bar" +export { MatSnackBar, MatSnackBarRef, SimpleSnackBar, MatSnackBarConfig } from "@angular/material/snack-bar" export { MatBottomSheet, MatBottomSheetRef } from "@angular/material/bottom-sheet" \ No newline at end of file diff --git a/frontend/src/sharedModule/sharedModule.ts b/frontend/src/sharedModule/sharedModule.ts index c905be6d..62867323 100644 --- a/frontend/src/sharedModule/sharedModule.ts +++ b/frontend/src/sharedModule/sharedModule.ts @@ -13,6 +13,9 @@ import { MatTabsModule } from "@angular/material/tabs" import { MatBottomSheetModule } from "@angular/material/bottom-sheet" import { MatMenuModule } from "@angular/material/menu" import { MatProgressSpinnerModule } from "@angular/material/progress-spinner" +import { MatProgressBarModule } from "@angular/material/progress-bar" +import { MatCheckboxModule } from "@angular/material/checkbox" +import { FormsModule } from "@angular/forms"; @NgModule({ @@ -31,6 +34,9 @@ import { MatProgressSpinnerModule } from "@angular/material/progress-spinner" MatBottomSheetModule, MatMenuModule, MatProgressSpinnerModule, + MatProgressBarModule, + MatCheckboxModule, + FormsModule, ], exports: [ MatSnackBarModule, @@ -47,6 +53,9 @@ import { MatProgressSpinnerModule } from "@angular/material/progress-spinner" MatBottomSheetModule, MatMenuModule, MatProgressSpinnerModule, + MatProgressBarModule, + MatCheckboxModule, + FormsModule, ] }) diff --git a/frontend/src/state/actions.ts b/frontend/src/state/actions.ts index c812b3bf..36ff1982 100644 --- a/frontend/src/state/actions.ts +++ b/frontend/src/state/actions.ts @@ -10,4 +10,4 @@ export const error = createAction( export const info = createAction( `[${nameSpace}] info`, props<{ message: string }>() -) \ No newline at end of file +) diff --git a/frontend/src/state/app/consts.ts b/frontend/src/state/app/consts.ts index 3e80769d..8718491e 100644 --- a/frontend/src/state/app/consts.ts +++ b/frontend/src/state/app/consts.ts @@ -12,7 +12,7 @@ export const MODE = { export type Landmark = { targetVolumeId: string position: number[] -}; +} export type LandmarkPair = { tmplLm: Landmark @@ -21,7 +21,13 @@ export type LandmarkPair = { name: string } +export type User = { + fullname: string + authtoken: string +} + export type LocalState = { + user: User|null stage: keyof typeof STAGE mode: keyof typeof MODE incLocked: boolean @@ -32,24 +38,14 @@ export type LocalState = { } export const defaultState: LocalState = { - stage: STAGE.ALIGNMENT, // STAGE.SELECTION, + user: null, + stage: STAGE.SELECTION, mode: MODE.DEFAULT, addingLandmark: false, incLocked: false, - landmarkPairs: [{ - id: "foo-bar", - name: "hello my name is", - incLm: { - position: [0, 0, 0], - targetVolumeId: "waxholm" - }, - tmplLm: { - position: [0, 0, 0], - targetVolumeId: "bigbrain" - } - }], + landmarkPairs: [], purgatory: null, hoveredLandmark: null, -}; +} -export const nameSpace = `app`; +export const nameSpace = `app` diff --git a/frontend/src/state/app/effects.ts b/frontend/src/state/app/effects.ts index 80290915..b510d9bf 100644 --- a/frontend/src/state/app/effects.ts +++ b/frontend/src/state/app/effects.ts @@ -39,10 +39,10 @@ export class Effects { ), switchMap(([lm, refVol, incVol, purgatory, xform, isDefaultMode]) => { if (!purgatory) { - if (refVol?.["@id"] !== lm.landmark.targetVolumeId) { + if (refVol?.id !== lm.landmark.targetVolumeId) { return of( mainInput.error({ - message: `First landmark must target reference volume ${refVol?.["@id"]}, but instead targets ${lm.landmark.targetVolumeId}` + message: `First landmark must target reference volume ${refVol?.id}, but instead targets ${lm.landmark.targetVolumeId}` }) ) } @@ -53,10 +53,10 @@ export class Effects { ) } - if (incVol?.["@id"] !== lm.landmark.targetVolumeId) { + if (incVol?.id !== lm.landmark.targetVolumeId) { return of( mainInput.error({ - message: `Second landmark must target incoming volume ${incVol?.["@id"]}, but targets ${lm.landmark.targetVolumeId}` + message: `Second landmark must target incoming volume ${incVol?.id}, but targets ${lm.landmark.targetVolumeId}` }) ) } @@ -106,4 +106,4 @@ export class Effects { this.snackbar.open(ac.message) }) } -} \ No newline at end of file +} diff --git a/frontend/src/state/app/selectors.ts b/frontend/src/state/app/selectors.ts index b3f3931c..dc5a325a 100644 --- a/frontend/src/state/app/selectors.ts +++ b/frontend/src/state/app/selectors.ts @@ -42,3 +42,8 @@ export const hoveredLandmarkPair = createSelector( return landmarks.find(lm => lm.incLm === hoveredLandmark || lm.tmplLm === hoveredLandmark) } ) + +export const user = createSelector( + featureSelector, + state => state.user +) diff --git a/frontend/src/state/effects.ts b/frontend/src/state/effects.ts new file mode 100644 index 00000000..a93dc4d8 --- /dev/null +++ b/frontend/src/state/effects.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@angular/core"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import * as actions from "./actions" +import { MatSnackBar, MatSnackBarConfig } from "src/sharedModule" +import { map } from "rxjs"; + +@Injectable() +export class GeneralEffects { + + snackbarCfg: MatSnackBarConfig = { + duration: 5000 + } + + onInfo = createEffect(() => this.actions$.pipe( + ofType(actions.info), + map(action => { + this.snackbar.open(action.message, "Dismiss", this.snackbarCfg) + }) + ), { dispatch: false }) + + onError = createEffect(() => this.actions$.pipe( + ofType(actions.error), + map(action => { + this.snackbar.open(action.message, "Dismiss", this.snackbarCfg) + }) + ), { dispatch: false }) + + constructor(private actions$: Actions, private snackbar: MatSnackBar){ + + } +} diff --git a/frontend/src/state/index.ts b/frontend/src/state/index.ts index b13ab2ec..6f10e727 100644 --- a/frontend/src/state/index.ts +++ b/frontend/src/state/index.ts @@ -3,6 +3,7 @@ import { ActionReducer, ActionReducerMap, MetaReducer } from '@ngrx/store'; import * as inputs from './inputs'; import * as app from './app'; import * as outputs from './outputs'; +import { GeneralEffects } from "./effects" export type State = { [inputs.consts.nameSpace]: inputs.consts.LocalState; @@ -16,7 +17,7 @@ export const reducers: ActionReducerMap = { [outputs.consts.nameSpace]: outputs.state.reducer, }; -function debug(reducer: ActionReducer): ActionReducer { +function debugFn(reducer: ActionReducer): ActionReducer { return function (state, action) { console.log('state', state); console.log('action', action); @@ -25,10 +26,10 @@ function debug(reducer: ActionReducer): ActionReducer { }; } -export const effects = [outputs.effects.Effects, app.effects.Effects]; +export const effects = [outputs.effects.Effects, app.effects.Effects, GeneralEffects]; export const metaReducers: MetaReducer[] = isDevMode() ? [ - // debug + // debugFn ] : []; diff --git a/frontend/src/state/inputs/actions.ts b/frontend/src/state/inputs/actions.ts index 2ffde6c7..69f5fbe3 100644 --- a/frontend/src/state/inputs/actions.ts +++ b/frontend/src/state/inputs/actions.ts @@ -3,10 +3,17 @@ import { nameSpace, TVolume } from './consts'; export const selectTemplate = createAction( `[${nameSpace}] selecteTemplate`, - props>() + props>() ); export const selecteIncoming = createAction( `[${nameSpace}] selectedIncoming`, - props>() + props>() ); + +export const setIncoming = createAction( + `[${nameSpace}] setIncoming`, + props<{ + incomingVolumes: TVolume[] + }>() +) diff --git a/frontend/src/state/inputs/consts.ts b/frontend/src/state/inputs/consts.ts index 02d802ce..3aee7f09 100644 --- a/frontend/src/state/inputs/consts.ts +++ b/frontend/src/state/inputs/consts.ts @@ -1,5 +1,5 @@ const bigbrain: TVolume = { - '@id': 'bigbrain', + id: 'bigbrain', name: 'Big Brain (2015 Release)', volumes: [ { @@ -16,7 +16,7 @@ const bigbrain: TVolume = { }; const waxholm: TVolume = { - '@id': 'waxholm', + id: 'waxholm', name: 'Waxholm Rat', volumes: [ { @@ -31,7 +31,7 @@ const waxholm: TVolume = { } const colin: TVolume = { - '@id': 'colin27', + id: 'colin27', name: 'Colin 27', volumes: [ { @@ -55,11 +55,12 @@ type Volume = { } export type TVolume = { - '@id': string + id: string name: string volumes: Volume[] dim: number[] contentHash?: string + visibility?: 'public' | 'private' } export const nameSpace = `[inputs]` export type LocalState = { diff --git a/frontend/src/state/inputs/selectors.ts b/frontend/src/state/inputs/selectors.ts index 3dd29ed2..275e7e4f 100644 --- a/frontend/src/state/inputs/selectors.ts +++ b/frontend/src/state/inputs/selectors.ts @@ -45,7 +45,7 @@ export const inputFilesName = createSelector( export const getIncVoxelSize = () => pipe( select(selectedIncoming), - distinctUntilChanged((o, n) => o?.["@id"] === n?.["@id"]), + distinctUntilChanged((o, n) => o?.id === n?.id), switchMap(incoming => { if (!incoming) { return of(null) diff --git a/frontend/src/state/inputs/state.ts b/frontend/src/state/inputs/state.ts index bf4e703c..a80f800f 100644 --- a/frontend/src/state/inputs/state.ts +++ b/frontend/src/state/inputs/state.ts @@ -4,14 +4,18 @@ import * as actions from './actions'; export const reducer = createReducer( defaultState, - on(actions.selectTemplate, (state, { '@id': selectId }) => ({ + on(actions.selectTemplate, (state, { id: selectId }) => ({ ...state, selectedTemplate: - state.templateVolumes.find(({ '@id': id }) => id === selectId) || null, + state.templateVolumes.find(({ id }) => id === selectId) || null, })), - on(actions.selecteIncoming, (state, { '@id': selectId }) => ({ + on(actions.selecteIncoming, (state, { id: selectId }) => ({ ...state, selectedIncoming: - state.incomingVolumes.find(({ '@id': id }) => id === selectId) || null, + state.incomingVolumes.find(({ id }) => id === selectId) || null, + })), + on(actions.setIncoming, (state, { incomingVolumes }) => ({ + ...state, + incomingVolumes })) -); +) diff --git a/frontend/src/views/input-volumes/categoriseVolume.pipe.ts b/frontend/src/views/input-volumes/categoriseVolume.pipe.ts new file mode 100644 index 00000000..660af0c9 --- /dev/null +++ b/frontend/src/views/input-volumes/categoriseVolume.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { TVolume } from "src/state/inputs/consts"; + +@Pipe({ + name: 'categoriseVolume', + pure: true, +}) + +export class CategoriseVolumePipe implements PipeTransform{ + transform(volumes: TVolume[]): Record { + const returnVal: Record = {} + for (const volume of volumes){ + const label = volume.visibility || "Other" + if (!returnVal[label]) { + returnVal[label] = [] + } + returnVal[label].push(volume) + } + + return returnVal + } +} diff --git a/frontend/src/views/input-volumes/input-volumes.component.html b/frontend/src/views/input-volumes/input-volumes.component.html index 02b6a51c..5f809917 100644 --- a/frontend/src/views/input-volumes/input-volumes.component.html +++ b/frontend/src/views/input-volumes/input-volumes.component.html @@ -1,27 +1,132 @@ -
- - - Select template - - - - {{ volume.name }} - - - -

- - - Select volume to be aligned - - - - {{ volume.name }} - - - -
\ No newline at end of file + + +
+ + + Select reference volume + + + + {{ volume.name }} + + + +

+ + + Select incoming volume + + + + + + {{ option.name }} + + + + + + + + +
+ + + + + + +
diff --git a/frontend/src/views/input-volumes/input-volumes.component.scss b/frontend/src/views/input-volumes/input-volumes.component.scss index e69de29b..12dcd182 100644 --- a/frontend/src/views/input-volumes/input-volumes.component.scss +++ b/frontend/src/views/input-volumes/input-volumes.component.scss @@ -0,0 +1,39 @@ +.signin +{ + margin: 2rem; +} + +.drag-drop-file +{ + border-radius: 1rem; + border: dashed rgba(128,128,128,0.5); + + display: flex; + height: 5rem; + align-items: center; + justify-content: center; + opacity: 0.5; + + &:hover + { + cursor: pointer; + opacity: 1.0!important; + } + +} + +input[type=file] +{ + display: none; +} + +.segmentation-wrapper +{ + display: flex; + align-items: center; + + > * + { + margin: 0.5rem 0.2rem; + } +} diff --git a/frontend/src/views/input-volumes/input-volumes.component.ts b/frontend/src/views/input-volumes/input-volumes.component.ts index aad82f07..7e72ad94 100644 --- a/frontend/src/views/input-volumes/input-volumes.component.ts +++ b/frontend/src/views/input-volumes/input-volumes.component.ts @@ -1,50 +1,409 @@ -import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, inject } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { select, Store } from '@ngrx/store'; -import { Subscription } from 'rxjs'; +import { Observable, Subject, combineLatest, concat, distinctUntilChanged, filter, firstValueFrom, from, map, of, scan, switchMap, takeUntil, withLatestFrom } from 'rxjs'; import * as inputs from 'src/state/inputs'; +import * as appState from 'src/state/app' +import * as generalActions from "src/state/actions" +import { DestroyDirective } from 'src/util/destroy.directive'; +import { ChumniPreflightResp, ChumniVolume, LOGIN_METHODS, SEGMENTATION_EXPLAINER_TEXT, VOLUBA_APP_CONFIG, VolubaAppConfig, isDefined, trimFilename } from 'src/const'; +import { TVolume } from 'src/state/inputs/consts'; + +function arrayBufferToBase64String(arraybuffer: ArrayBuffer) { + const bytes = new Uint8Array( arraybuffer ) + let binary = '' + for (let i = 0; i < bytes.length; i ++){ + binary += String.fromCharCode(bytes[i]) + } + return window.btoa(binary) +} + +type UploadStatus = { + filename?: string|null + extraTexts?: string[]|null + preflight: boolean + upload: boolean +} @Component({ selector: 'voluba-input-volumes', templateUrl: './input-volumes.component.html', styleUrls: ['./input-volumes.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [ + DestroyDirective + ] }) -export class InputVolumesComponent implements OnDestroy { +export class InputVolumesComponent { + + destroyed$ = inject(DestroyDirective).destroyed$ + + isSegmentation: boolean = false + #file: File|undefined + #refreshList$ = new Subject() + #setUploadStatus$ = new Subject>() + + #uploadStatus$: Observable = concat( + of({ preflight: false, upload: false } as UploadStatus), + this.#setUploadStatus$ + ).pipe( + scan((acc, curr) => ({ + ...acc, + ...curr + }), { preflight: false, upload: false } as UploadStatus) + ) + inputCtrl = new FormGroup({ selectedTemplate: new FormControl(null), selectedIncoming: new FormControl(null), - }); - - availableTemplates$ = this.store$.pipe( - select(inputs.selectors.templateVolumes) - ); - - availableIncomings$ = this.store$.pipe( - select(inputs.selectors.incomingVolumes) - ); - - #subscriptions: Subscription[] = []; - - constructor(private store$: Store) { - this.#subscriptions.push( - this.inputCtrl.valueChanges.subscribe( - ({ selectedTemplate, selectedIncoming }) => { - if (selectedTemplate) - this.store$.dispatch( - inputs.actions.selectTemplate({ '@id': selectedTemplate }) - ); - if (selectedIncoming) - this.store$.dispatch( - inputs.actions.selecteIncoming({ '@id': selectedIncoming }) - ); + }) + + view$ = combineLatest([ + this.store$.pipe( + select(inputs.selectors.templateVolumes) + ), + this.store$.pipe( + select(inputs.selectors.incomingVolumes) + ), + this.store$.pipe( + select(appState.selectors.user) + ), + this.store$.pipe( + select(inputs.selectors.selectedIncoming) + ), + this.#uploadStatus$ + ]).pipe( + map(([ availableTemplates, availableIncomings, user, selectedIncoming, { preflight, upload, filename, extraTexts } ]) => { + return { + availableTemplates, + availableIncomings, + user, + loginMethods: LOGIN_METHODS, + selectedIncoming, + preflight, + upload, + filename, + extraTexts, + segmentationCheckboxExplainer: SEGMENTATION_EXPLAINER_TEXT, + } + }) + ) + + constructor(private store$: Store, @Inject(VOLUBA_APP_CONFIG) private appCfg: VolubaAppConfig) { + + this.store$.pipe( + select(inputs.selectors.selectedTemplate), + distinctUntilChanged((o, n) => o?.id === n?.id), + takeUntil(this.destroyed$), + ).subscribe(tmpl => { + this.inputCtrl.controls.selectedTemplate.patchValue(tmpl?.id || null) + }) + + this.store$.pipe( + select(inputs.selectors.selectedIncoming), + distinctUntilChanged((o, n) => o?.id === n?.id), + takeUntil(this.destroyed$), + ).subscribe(inc => { + this.inputCtrl.controls.selectedIncoming.patchValue(inc?.id || null) + }) + + concat( + of(null), + this.#refreshList$, + ).pipe( + takeUntil(this.destroyed$), + withLatestFrom( + this.store$.pipe( + select(appState.selectors.user), + ) + ), + switchMap(([_, user]) => { + const authHeader: { Authorization?: string } = !!user + ? { 'Authorization': `Bearer ${user.authtoken}` } + : {} + return fetch(`${this.appCfg.uploadUrl}/list`, { + headers: { + ...authHeader + } + }).then(res => res.json()) + }) + ).subscribe((chumniVolumes: ChumniVolume[]) => { + const incomingVolumes = chumniVolumes.map(vol => { + const { name, extra: { neuroglancer: { resolution, size } }, links: { normalized }, visibility } = vol + const dim = resolution.map((res, idx) => res * size[idx]) + return { + id: name, + name: name, + volumes: [ + { + "@type": "siibra/volume/v0.0.1", + providers: { + "neuroglancer/precomputed": `${this.appCfg.uploadUrl}${normalized}` + } + } + ], + dim, + visibility + } as TVolume + }) + + this.store$.dispatch( + inputs.actions.setIncoming({ + incomingVolumes + }) + ) + }) + + this.inputCtrl.valueChanges.pipe( + takeUntil(this.destroyed$) + ).subscribe(({ selectedTemplate, selectedIncoming }) => { + if (selectedTemplate) { + this.store$.dispatch( + inputs.actions.selectTemplate({ + id: selectedTemplate + }) + ) + } + if (selectedIncoming) { + this.store$.dispatch( + inputs.actions.selecteIncoming({ + id: selectedIncoming + }) + ) + } + }) + } + + async handleDragDropFile(files: File[]|FileList|null){ + if (files === null) { + return + } + if (files instanceof FileList) { + files = Array.from(files) + } + const file = files[0] + if (!file) { + return + } + + this.#file = file + const filesize = `Filesize: ${file.size} bytes` + + this.#setUploadStatus$.next({ + preflight: true, + filename: file.name, + extraTexts: [ filesize ] + }) + const result = await this.#preflight(file) + this.#setUploadStatus$.next({ + preflight: false, + extraTexts: [ + filesize, + ...result.warnings.map(v => `Warning: ${v}`) + ] + }) + + } + + async #preflight(file: File){ + + const user = await firstValueFrom( + this.store$.pipe( + select( + appState.selectors.user + ) + ) + ) + if (!user) { + throw new Error(`User must be authenticated before they can upload any files!`) + } + + const blob = file.slice(0, 2048) + const fileReader = new FileReader() + + return new Promise((rs, rj) => { + + fileReader.onload = ev => { + const result = ev?.target?.result + if (!result) { + rj(`Cannot find ev.target.result!`) + return + } + + if (!(result instanceof ArrayBuffer)) { + rj(`Expected result to be array buffer, but was not.`) + return } + + const _2048B64 = arrayBufferToBase64String(result) + const { name, size, type } = file + + /** + * or use formdata + */ + const blob = new Blob([new Uint8Array(result)]) + const slicedFile = new File([blob], name) + + const formData = new FormData() + formData.append('image', slicedFile) + + const preflightUrl = `${this.appCfg.uploadUrl}/preflight` + fetch(preflightUrl, { + method: 'POST', + headers: { + 'X-CHUNMA-FILESIZE': file.size.toString(), + 'X-CHUNMA-SEGMENTATION': 'false', + 'Authorization': `Bearer ${user.authtoken}` + }, + body: formData + }) + .then(res => res.json()) + .then((jsonResp: ChumniPreflightResp) => rs(jsonResp)) + .catch(rj) + } + fileReader.onerror = rj + fileReader.readAsArrayBuffer(blob) + }) + } + + async deleteVolume(volumeId: string|undefined) { + if (!volumeId) { + return + } + const [user, incomingVolumes] = await firstValueFrom( + combineLatest([ + this.store$.pipe( + select(appState.selectors.user) + ), + this.store$.pipe( + select(inputs.selectors.incomingVolumes) + ), + ]) + ) + if (!user) { + throw new Error(`User must be authenticated before they can upload any files!`) + } + + const authHeader: { Authorization?: string } = !!user + ? { 'Authorization': `Bearer ${user.authtoken}` } + : {} + + const deleteVolume = incomingVolumes.find(v => v.id === volumeId) + + if (!deleteVolume) { + throw new Error(`Volume with id ${volumeId} not found.`) + } + + const ngUrls = deleteVolume.volumes.map(v => v.providers["neuroglancer/precomputed"]).filter(isDefined) + if (ngUrls.length !== 1) { + throw new Error(`Expecting one and only one url to be deleted, but got ${ngUrls.length}: ${ngUrls}`) + } + const ngUrl = ngUrls[0] + + const volName = trimFilename(deleteVolume.name) + try { + const res = await fetch(ngUrl, { + method: 'DELETE', + headers: { + ...authHeader + } + }) + if (!res.ok) { + throw new Error(`Error deleting: ${res.status}`) + } + this.store$.dispatch( + generalActions.info({ + message: `Volume ${volName} deleted.` + }) + ) + + } catch (e) { + this.store$.dispatch( + generalActions.error({ + message: `Error: ${(e as Error).toString()}.\nVolume ${volName} not deleted.` + }) ) - ); + } finally { + this.#refreshList$.next(true) + } } - ngOnDestroy(): void { - while (this.#subscriptions.length > 0) - this.#subscriptions.pop()?.unsubscribe(); + clearUpload(){ + this.#setUploadStatus$.next({ + filename: null, + extraTexts: null, + }) + this.#file = undefined + } + + async upload(){ + if (!this.#file) { + return + } + const user = await firstValueFrom( + this.store$.pipe( + select( + appState.selectors.user + ) + ) + ) + if (!user) { + throw new Error(`User must be authenticated before they can upload any files!`) + } + this.#setUploadStatus$.next({ upload: true }) + + try { + + /** + * TODO + * fetch does not support progress + * if progress is required in the future, switch to xmlhttprequest in future + */ + // const xhr = new XMLHttpRequest() + + const formData = new FormData() + formData.append('image', this.#file) + const resp = await fetch(`${this.appCfg.uploadUrl}/upload`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.authtoken}` + }, + body: formData + }) + if (!resp.ok) { + throw new Error(`Error: ${resp.status}`) + } + + this.#refreshList$.next(null) + + const uploadedVolumeSelector = this.store$.pipe( + select(inputs.selectors.incomingVolumes), + map(vols => vols.find(vol => this.#file?.name.includes(vol.name))), + filter(isDefined) + ) + + const uploadedVolume = await firstValueFrom(uploadedVolumeSelector) + this.store$.dispatch( + inputs.actions.selecteIncoming({ + id: uploadedVolume.id + }) + ) + this.store$.dispatch( + generalActions.info({ + message: `File ${trimFilename(this.#file.name)} uploaded and selected.` + }) + ) + this.clearUpload() + + } catch (e) { + this.store$.dispatch( + generalActions.error({ + message: `Error: ${(e as any).toString()}` + }) + ) + } finally { + this.#setUploadStatus$.next({ upload: false }) + } + } } diff --git a/frontend/src/views/nehuba-viewer-wrapper/nehuba-viewer-wrapper.component.ts b/frontend/src/views/nehuba-viewer-wrapper/nehuba-viewer-wrapper.component.ts index d394c099..2f87372a 100644 --- a/frontend/src/views/nehuba-viewer-wrapper/nehuba-viewer-wrapper.component.ts +++ b/frontend/src/views/nehuba-viewer-wrapper/nehuba-viewer-wrapper.component.ts @@ -24,6 +24,7 @@ export type NehubaNavigation = { position: Float32Array orientation: Float32Array zoom: number + perspectiveOrientation: Float32Array } const lightmode = { @@ -222,12 +223,14 @@ export class NehubaViewerWrapperComponent implements OnInit, AfterViewInit { position: val.position, orientation: val.orientation, zoom: val.zoom, + perspectiveOrientation: val.perspectiveOrientation, }) }) posSubject.pipe( distinctUntilChanged((o, n) => ( FloatArrayEql(o.orientation, n.orientation) && FloatArrayEql(o.position, n.position) + && FloatArrayEql(o.perspectiveOrientation, n.perspectiveOrientation) && o.zoom === n.zoom )), takeUntil(this.#destroyed$) diff --git a/frontend/src/views/viewer/viewer.component.html b/frontend/src/views/viewer/viewer.component.html index 24ba769d..bc95d496 100644 --- a/frontend/src/views/viewer/viewer.component.html +++ b/frontend/src/views/viewer/viewer.component.html @@ -149,6 +149,7 @@
+
diff --git a/frontend/src/views/viewer/viewer.component.scss b/frontend/src/views/viewer/viewer.component.scss index 117ee319..d575d059 100644 --- a/frontend/src/views/viewer/viewer.component.scss +++ b/frontend/src/views/viewer/viewer.component.scss @@ -110,3 +110,10 @@ div.viewer-wrapper { margin: 1rem; } + +rotation-widget +{ + width: 4rem; + height: 4rem; + margin: 1rem; +} diff --git a/frontend/src/views/viewer/viewer.component.ts b/frontend/src/views/viewer/viewer.component.ts index ffbc668e..f9aebc57 100644 --- a/frontend/src/views/viewer/viewer.component.ts +++ b/frontend/src/views/viewer/viewer.component.ts @@ -178,6 +178,7 @@ export class ViewerComponent implements AfterViewInit { orientation: new Float32Array([0, 0, 0, 1]), position: new Float32Array([0, 0, 0]), zoom: 1e5, + perspectiveOrientation: new Float32Array([0, 0, 0, 1]), }) setPrimaryNav(nav: NehubaNavigation){ @@ -232,7 +233,7 @@ export class ViewerComponent implements AfterViewInit { ]).pipe( map(([ { landmarks, addLandmarkMode, hoveredLmp, purgatory }, incLocked, isDefaultMode, xform, mouseover, primaryNavigation, isDraggingViewer ]) => { - const { mat4 } = export_nehuba + const { mat4, quat } = export_nehuba const { getIncoming, getReference } = LandmarkSvc.GetXformToOverlay(xform, hoveredLmp) const storedLandmarks = landmarks.map(lm => { @@ -287,6 +288,12 @@ export class ViewerComponent implements AfterViewInit { const tXform = mat4.transpose(mat4.create(), xform) const atXform = Array.from(tXform) + const rotationWidgetQuat = mat4.getRotation(quat.create(), xform) + quat.invert(rotationWidgetQuat, rotationWidgetQuat) + quat.mul(rotationWidgetQuat, rotationWidgetQuat, primaryNavigation.perspectiveOrientation) + quat.normalize(rotationWidgetQuat, rotationWidgetQuat) + quat.invert(rotationWidgetQuat, rotationWidgetQuat) + return { incLocked, isDefaultMode, @@ -310,6 +317,7 @@ export class ViewerComponent implements AfterViewInit { primaryNavigation, isDraggingViewer, addLandmarkMode, + rotationWidgetQuat, } }) ); @@ -543,11 +551,11 @@ export class ViewerComponent implements AfterViewInit { } let pos: export_nehuba.vec3 - if (hoveredLandmark?.targetVolumeId === selectedTemplate?.['@id']) { + if (hoveredLandmark?.targetVolumeId === selectedTemplate?.id) { lmkey = 'tmplLm' pos = position } - if (hoveredLandmark?.targetVolumeId === selectedIncoming?.['@id']) { + if (hoveredLandmark?.targetVolumeId === selectedIncoming?.id) { lmkey = 'incLm' const { vec3, mat4 } = export_nehuba const invert = mat4.invert(mat4.create(), xform) diff --git a/frontend/src/views/views.module.ts b/frontend/src/views/views.module.ts index 9864688e..a7cce433 100644 --- a/frontend/src/views/views.module.ts +++ b/frontend/src/views/views.module.ts @@ -24,6 +24,8 @@ import { MouseInteractionDirective } from 'src/mouse-interactions/mouse-interact import { IOModule } from 'src/io/module'; import { NgLayerShaderTune } from 'src/ng-layer-shader-tune/ng-layer-shader-tune.component'; import { VolubaKeyboardShortcutDirective } from 'src/util/kbShortcut.directive'; +import { RotationWidgetModule } from 'src/layer-tune/rotation-widget/module'; +import { CategoriseVolumePipe } from './input-volumes/categoriseVolume.pipe'; @NgModule({ declarations: [ @@ -34,6 +36,7 @@ import { VolubaKeyboardShortcutDirective } from 'src/util/kbShortcut.directive'; NehubaViewerWrapperComponent, ShareExportComponent, DisplayNumArrayPipe, + CategoriseVolumePipe, ], imports: [ CommonModule, @@ -51,6 +54,7 @@ import { VolubaKeyboardShortcutDirective } from 'src/util/kbShortcut.directive'; LayerTuneModule, SharedModule, IOModule, + RotationWidgetModule, /** * standalone directives diff --git a/frontend/src/views/welcome-card/welcome-card.component.html b/frontend/src/views/welcome-card/welcome-card.component.html index 63fbbe4c..29f262e3 100644 --- a/frontend/src/views/welcome-card/welcome-card.component.html +++ b/frontend/src/views/welcome-card/welcome-card.component.html @@ -1,13 +1,17 @@ - - - - - - - - + + + + + + + + + + + + diff --git a/frontend/src/views/welcome-card/welcome-card.component.ts b/frontend/src/views/welcome-card/welcome-card.component.ts index ffd92132..d72238c6 100644 --- a/frontend/src/views/welcome-card/welcome-card.component.ts +++ b/frontend/src/views/welcome-card/welcome-card.component.ts @@ -11,15 +11,18 @@ import * as app from 'src/state/app'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class WelcomeCardComponent { - allowStart$ = combineLatest([ + + view$ = combineLatest([ this.store.pipe(select(inputs.selectors.selectedIncoming)), this.store.pipe(select(inputs.selectors.selectedTemplate)), ]).pipe( map(([incoming, template]) => { - console.log({ incoming, template }); - return !!incoming && !!template; + + return { + allowStart: !!incoming && !!template, + } }) - ); + ) start() { this.store.dispatch(