diff --git a/docs/releases/v2.14.5.md b/docs/releases/v2.14.5.md index a1c15104ac8d92556681f6bf5be939a3f8f31151..6b645d432029d5c37eea56f45ff2abad7a182e58 100644 --- a/docs/releases/v2.14.5.md +++ b/docs/releases/v2.14.5.md @@ -8,6 +8,7 @@ - Reworded point assignment UI - Allow multi selected region names to be copied - Added legend to region hierarchy +- (experimental) allow addition of custom linear coordinate space ## Bugfix diff --git a/src/components/coordTextBox/coordTextBox.component.ts b/src/components/coordTextBox/coordTextBox.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..e15e9b04b2cd3aff9eea4762561128278e7a2634 --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.component.ts @@ -0,0 +1,139 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, HostListener, Input, Output, ViewChild, inject } from "@angular/core"; +import { BehaviorSubject, combineLatest } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; +import { AngularMaterialModule, MatInput } from "src/sharedModules"; +import { DestroyDirective } from "src/util/directives/destroy.directive"; + +type TTriplet = [number, number, number] +type TVec4 = [number, number, number, number] +export type TAffine = [TVec4, TVec4, TVec4, TVec4] +export type Render = (v: TTriplet) => string + +export function isTVec4(val: unknown): val is TAffine { + if (!Array.isArray(val)) { + return false + } + if (val.length !== 4) { + return false + } + return val.every(v => typeof v === "number") +} + +export function isAffine(val: unknown): val is TAffine { + if (!Array.isArray(val)) { + return false + } + if (val.length !== 4) { + return false + } + return val.every(v => isTVec4(v)) +} + +export function isTriplet(val: unknown): val is TTriplet{ + if (!Array.isArray(val)) { + return false + } + if (val.some(v => typeof v !== "number")) { + return false + } + return val.length === 3 +} + +@Component({ + selector: 'coordinate-text-input', + templateUrl: './coordTextBox.template.html', + styleUrls: [ + './coordTextBox.style.css' + ], + standalone: true, + imports: [ + CommonModule, + AngularMaterialModule + ], + hostDirectives: [ + DestroyDirective + ] +}) + +export class CoordTextBox { + + #destroyed$ = inject(DestroyDirective).destroyed$ + + @ViewChild(MatInput) + input: MatInput + + @Output('enter') + enter = new EventEmitter() + + @HostListener('keydown.enter') + @HostListener('keydown.tab') + enterHandler() { + this.enter.emit() + } + + #coordinates = new BehaviorSubject<TTriplet>([0, 0, 0]) + + @Input() + set coordinates(val: unknown) { + if (!isTriplet(val)) { + console.error(`${val} is not TTriplet`) + return + } + this.#coordinates.next(val) + } + + + #affine = new BehaviorSubject<TAffine>([ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]) + + @Input() + set affine(val: unknown) { + if (!isAffine(val)) { + console.error(`${val} is not TAffine!`) + return + } + this.#affine.next(val) + } + + #render = new BehaviorSubject<Render>(v => v.join(`, `)) + + @Input() + set render(val: Render) { + this.#render.next(val) + } + + inputValue$ = combineLatest([ + this.#coordinates, + this.#affine, + this.#render, + ]).pipe( + map(([ coord, flattenedAffine, render ]) => { + const [ + [m00, m10, m20, m30], + [m01, m11, m21, m31], + [m02, m12, m22, m32], + // [m03, m13, m23, m33], + ] = flattenedAffine + + const newCoord: TTriplet = [ + coord[0] * m00 + coord[1] * m10 + coord[2] * m20 + 1 * m30, + coord[0] * m01 + coord[1] * m11 + coord[2] * m21 + 1 * m31, + coord[0] * m02 + coord[1] * m12 + coord[2] * m22 + 1 * m32 + ] + return render(newCoord) + }), + shareReplay(1), + ) + + @Input() + label: string = "Coordinates" + + get inputValue(){ + return this.input?.value + } +} diff --git a/src/components/coordTextBox/coordTextBox.style.css b/src/components/coordTextBox/coordTextBox.style.css new file mode 100644 index 0000000000000000000000000000000000000000..e0db9f988ab4342413fa9dc02c1df442ac63c10c --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.style.css @@ -0,0 +1,15 @@ +:host +{ + display: flex; + width: 100%; +} + +mat-form-field +{ + flex: 1 1 auto; +} + +.suffix +{ + flex: 0 0 auto; +} diff --git a/src/components/coordTextBox/coordTextBox.template.html b/src/components/coordTextBox/coordTextBox.template.html new file mode 100644 index 0000000000000000000000000000000000000000..ddf0c59a1d4f8d5a90eac540651acb0b9f4d6251 --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.template.html @@ -0,0 +1,13 @@ +<mat-form-field> + <mat-label> + {{ label }} + </mat-label> + + <input type="text" matInput + [value]="inputValue$ | async"> + +</mat-form-field> + +<div class="suffix"> + <ng-content select="[suffix]"></ng-content> +</div> diff --git a/src/components/coordTextBox/index.ts b/src/components/coordTextBox/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe8e63ce12dec965848966a2e5ca41dd810fb49f --- /dev/null +++ b/src/components/coordTextBox/index.ts @@ -0,0 +1 @@ +export * from "./coordTextBox.component" diff --git a/src/sharedModules/angularMaterial.exports.ts b/src/sharedModules/angularMaterial.exports.ts index 9769df1c2a4dafc8a3d8f9aefed9d561c25e4ebe..09611d70f3f6c8afdcacd927a0cbe3be40339fae 100644 --- a/src/sharedModules/angularMaterial.exports.ts +++ b/src/sharedModules/angularMaterial.exports.ts @@ -11,5 +11,6 @@ export { UntypedFormControl } from "@angular/forms"; export { MatTreeFlatDataSource, MatTreeFlattener } from "@angular/material/tree" export { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete"; export { MatPaginator } from "@angular/material/paginator"; +export { MatInput } from "@angular/material/input"; export { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing' diff --git a/src/theme.scss b/src/theme.scss index 4898089b853c95dc60f71a39fcbdaa7b7d0eb615..d184be4f42fe0557569f03c05d5600f4073cf63d 100644 --- a/src/theme.scss +++ b/src/theme.scss @@ -133,7 +133,7 @@ $sxplr-dark-theme: mat.define-dark-theme(( { @include mat.all-component-themes($sxplr-dark-theme); @include custom-cmp($sxplr-dark-theme); - input[type="text"] + input[type="text"],textarea { caret-color: white!important; } diff --git a/src/viewerModule/nehuba/module.ts b/src/viewerModule/nehuba/module.ts index 485c478b8078df06a5d660dd1838f6aac68c32f8..f7e7026c186bf4aa75d663587ffc1c25053ed1f6 100644 --- a/src/viewerModule/nehuba/module.ts +++ b/src/viewerModule/nehuba/module.ts @@ -29,6 +29,8 @@ import { NgAnnotationEffects } from "./annotation/effects"; import { NehubaViewerContainer } from "./nehubaViewerInterface/nehubaViewerContainer.component"; import { NehubaUserLayerModule } from "./userLayers"; import { DialogModule } from "src/ui/dialogInfo"; +import { CoordTextBox } from "src/components/coordTextBox"; +import { ExperimentalFlagDirective } from "src/experimental/experimental-flag.directive"; @NgModule({ imports: [ @@ -60,6 +62,9 @@ import { DialogModule } from "src/ui/dialogInfo"; QuickTourModule, NehubaLayoutOverlayModule, DialogModule, + + CoordTextBox, + ExperimentalFlagDirective ], declarations: [ NehubaViewerContainerDirective, diff --git a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts index 0c4b2ca09fd7a427ca3d999e99623b86384c20e9..6e2f8251fa76b4d8910b3d3f7d012542a95024fb 100644 --- a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts +++ b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts @@ -67,7 +67,7 @@ export class NehubaViewerUnit implements OnDestroy { public viewerPosInVoxel$ = new BehaviorSubject<number[]>(null) public viewerPosInReal$ = new BehaviorSubject<[number, number, number]>(null) public mousePosInVoxel$ = new BehaviorSubject<[number, number, number]>(null) - public mousePosInReal$ = new BehaviorSubject(null) + public mousePosInReal$ = new BehaviorSubject<[number, number, number]>(null) private exportNehuba: any @@ -869,7 +869,7 @@ export class NehubaViewerUnit implements OnDestroy { if (this.#translateVoxelToReal) { const coordInReal = this.#translateVoxelToReal(coordInVoxel) - this.mousePosInReal$.next( coordInReal ) + this.mousePosInReal$.next( coordInReal as [number, number, number] ) } }), diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.ts index 7d9a0886b2dd00c27ef17bb8a3ceb99d55997358..7dc07eb9a27de9fc9f78a6814becbad955449716 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.ts @@ -9,7 +9,7 @@ import { select, Store } from "@ngrx/store"; import { LoggingService } from "src/logging"; import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; import { Observable, Subject, concat, of } from "rxjs"; -import { map, filter, takeUntil, switchMap, shareReplay, debounceTime } from "rxjs/operators"; +import { map, filter, takeUntil, switchMap, shareReplay, debounceTime, scan } from "rxjs/operators"; import { Clipboard, MatBottomSheet, MatSnackBar } from "src/sharedModules/angularMaterial.exports" import { ARIA_LABELS, QUICKTOUR_DESC } from 'common/constants' import { FormControl, FormGroup } from "@angular/forms"; @@ -22,6 +22,12 @@ import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { NEHUBA_CONFIG_SERVICE_TOKEN, NehubaConfigSvc } from "../config.service"; import { DestroyDirective } from "src/util/directives/destroy.directive"; import { getUuid } from "src/util/fn"; +import { Render, TAffine, isAffine } from "src/components/coordTextBox" + +type TSpace = { + label: string + affine: TAffine +} @Component({ selector : 'iav-cmp-viewer-nehuba-status', @@ -33,6 +39,41 @@ import { getUuid } from "src/util/fn"; }) export class StatusCardComponent { + #newSpace = new Subject<TSpace>() + additionalSpace$ = this.#newSpace.pipe( + scan((acc, v) => acc.concat(v), [] as TSpace[]) + ) + readonly idAffStr = `[ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] +] +` + readonly defaultLabel = `New Space` + reset(label: HTMLInputElement, affine: HTMLTextAreaElement){ + label.value = this.defaultLabel + affine.value = this.idAffStr + } + add(label: HTMLInputElement, affine: HTMLTextAreaElement) { + try { + const aff = JSON.parse(affine.value) + if (!isAffine(aff)) { + throw new Error(`${affine.value} cannot be parsed into 4x4 affine`) + } + this.#newSpace.next({ + label: label.value, + affine: aff + }) + } catch (e) { + console.error(`Error: ${e.toString()}`) + } + + } + + readonly renderMm: Render = v => v.map(i => `${i}mm`).join(", ") + readonly renderDefault: Render = v => v.map(i => i.toFixed(3)).join(", ") + readonly #destroy$ = inject(DestroyDirective).destroyed$ public nehubaViewer: NehubaViewerUnit @@ -57,30 +98,25 @@ export class StatusCardComponent { zoom: number perspectiveOrientation: number[] perspectiveZoom: number -} + } - public readonly navVal$ = this.nehubaViewer$.pipe( + readonly navigation$ = this.nehubaViewer$.pipe( filter(v => !!v), - switchMap(nehubaViewer => - concat( - of(`nehubaViewer initialising`), - nehubaViewer.viewerPosInReal$.pipe( - filter(v => !!v), - map(real => real.map(v => `${ (v / 1e6).toFixed(3) }mm`).join(', ')) - ) - ) - ), + switchMap(nv => nv.viewerPosInReal$.pipe( + map(vals => (vals || [0, 0, 0]).map(v => Number((v / 1e6).toFixed(3)))) + )), shareReplay(1), ) - public readonly mouseVal$ = this.nehubaViewer$.pipe( + + readonly navVal$ = this.navigation$.pipe( + map(v => v.map(v => `${v}mm`).join(", ")) + ) + readonly mouseVal$ = this.nehubaViewer$.pipe( filter(v => !!v), switchMap(nehubaViewer => - concat( - of(``), - nehubaViewer.mousePosInReal$.pipe( - filter(v => !!v), - map(real => real.map(v => `${ (v/1e6).toFixed(3) }mm`).join(', ')) - ) + nehubaViewer.mousePosInReal$.pipe( + filter(v => !!v), + map(real => real.map(v => Number((v/1e6).toFixed(3)))) ), ) ) diff --git a/src/viewerModule/nehuba/statusCard/statusCard.template.html b/src/viewerModule/nehuba/statusCard/statusCard.template.html index 57da3b774db7221682614c74c76e1f7d6569d23f..2b17c8e751bf2220ca576e772f96eaea3a9114ea 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.template.html +++ b/src/viewerModule/nehuba/statusCard/statusCard.template.html @@ -47,48 +47,67 @@ <!-- coord --> <div class="d-flex"> + <coordinate-text-input + [coordinates]="navigation$ | async" + [render]="renderMm" + (enter)="textNavigateTo(physCoordInput.inputValue)" + label="Physical Coord" + #physCoordInput> + + <ng-container ngProjectAs="[suffix]"> + <button mat-icon-button + iav-stop="click" + [attr.aria-label]="COPY_NAVIGATION_STRING" + (click)="copyString(physCoordInput.inputValue)"> + <i class="fas fa-copy"></i> + </button> + + <button mat-icon-button + iav-stop="click" + sxplr-share-view + [attr.aria-label]="SHARE_BTN_ARIA_LABEL"> + <i class="fas fa-share-square"></i> + </button> + </ng-container> + </coordinate-text-input> + </div> - <mat-form-field class="flex-grow-1"> - <mat-label> - Physical Coord - </mat-label> - <input type="text" - matInput - (keydown.enter)="textNavigateTo(navInput.value)" - (keydown.tab)="textNavigateTo(navInput.value)" - [value]="navVal$ | async" - #navInput="matInput"> - - <button mat-icon-button - iav-stop="click" - matSuffix - [attr.aria-label]="COPY_NAVIGATION_STRING" - (click)="copyString(navInput.value)"> - <i class="fas fa-copy"></i> - </button> - - <button mat-icon-button - iav-stop="click" - matSuffix - sxplr-share-view - [attr.aria-label]="SHARE_BTN_ARIA_LABEL"> - <i class="fas fa-share-square"></i> - </button> - </mat-form-field> - + <!-- custom coord --> + <div class="d-flex" *ngFor="let f of additionalSpace$ | async"> + <coordinate-text-input + [coordinates]="navigation$ | async" + [affine]="f.affine" + [label]="f.label" + [render]="renderDefault" + #customInput> + + <ng-container ngProjectAs="[suffix]"> + <button mat-icon-button + iav-stop="click" + [attr.aria-label]="COPY_NAVIGATION_STRING" + (click)="copyString(customInput.inputValue)"> + <i class="fas fa-copy"></i> + </button> + </ng-container> + </coordinate-text-input> </div> + <ng-template sxplrExperimentalFlag [experimental]="true"> + <button mat-button + [sxplr-dialog]="enterNewCoordTmpl" + [sxplr-dialog-size]="null"> + Add Coord Space + </button> + </ng-template> + <!-- cursor pos --> - <mat-form-field - class="w-100"> - <mat-label> - Cursor Position - </mat-label> - <input type="text" - matInput - [readonly]="true" - [value]="mouseVal$ | async"> - </mat-form-field> + <div class="d-flex"> + <coordinate-text-input + [coordinates]="mouseVal$ | async" + [render]="renderMm" + label="Cursor Position"> + </coordinate-text-input> + </div> </mat-card-content> </mat-card> @@ -153,4 +172,39 @@ </button> </div> </form> -</ng-template> \ No newline at end of file +</ng-template> + +<ng-template #enterNewCoordTmpl> + <h2 mat-dialog-title> + Add a new coordinate space + </h2> + <mat-dialog-content> + <mat-form-field class="d-block"> + <mat-label> + Label + </mat-label> + <input type="text" matInput [value]="defaultLabel" #labelInput> + </mat-form-field> + + + <mat-form-field class="d-block"> + <mat-label> + Affine + </mat-label> + <textarea matInput rows="7" #affineInput>{{ idAffStr }}</textarea> + </mat-form-field> + + <mat-dialog-actions> + <button mat-button color="primary" + (click)="add(labelInput, affineInput)"> + Add + </button> + <button mat-button + (click)="reset(labelInput, affineInput)"> + Reset + </button> + </mat-dialog-actions> + + + </mat-dialog-content> +</ng-template>