diff --git a/docs/releases/v2.11.0.md b/docs/releases/v2.11.0.md index a205f109cb7f3a78543c7bc908a30dc70294fd58..23ea566b39a15d649fce4e09fc55758479914e3c 100644 --- a/docs/releases/v2.11.0.md +++ b/docs/releases/v2.11.0.md @@ -4,3 +4,5 @@ - Automatically selects human multilevel atlas on startup, if no atlases are selected - Allow multiple region selection for visualization purposes (hold ctrl & select region) +- Allow point to be selected (right click) and enabling map assignment + diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index c5ac1ecd6501d24d6d731ee5077d06b7f9d95b4d..faf55163bb5607a3b9214cee6e4b6089e75cfc40 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -22,15 +22,15 @@ export const useViewer = { } as const export const SIIBRA_API_VERSION_HEADER_KEY='x-siibra-api-version' -export const EXPECTED_SIIBRA_API_VERSION = '0.3.2' +export const EXPECTED_SIIBRA_API_VERSION = '0.3.3' let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null type PaginatedResponse<T> = { items: T[] total: number - page: number - size: number + page?: number + size?: number pages?: number } diff --git a/src/atlasComponents/sapi/schemaV3.ts b/src/atlasComponents/sapi/schemaV3.ts index c6740c619bf9de58fed30778b672d8996cb46ea6..41ea4e33a0d995c722403fb5d0027b2289c91d1f 100644 --- a/src/atlasComponents/sapi/schemaV3.ts +++ b/src/atlasComponents/sapi/schemaV3.ts @@ -33,6 +33,10 @@ export interface paths { /** Get All Regions */ get: operations["get_all_regions_regions_get"] } + "/regions/{region_id}/features": { + /** Get All Regions */ + get: operations["get_all_regions_regions__region_id__features_get"] + } "/regions/{region_id}": { /** Get All Regions */ get: operations["get_all_regions_regions__region_id__get"] @@ -65,6 +69,10 @@ export interface paths { /** Route Get Region Statistical Map */ get: operations["route_get_region_statistical_map_map_statistical_map_info_json_get"] } + "/map/assign": { + /** Router Assign Point */ + get: operations["router_assign_point_map_assign_get"] + } "/feature/_types": { /** Get All Feature Types */ get: operations["get_all_feature_types_feature__types_get"] @@ -497,7 +505,7 @@ export interface components { /** Ndim */ ndim: number /** Data */ - data: ((number | string | (number)[])[])[] + data?: (any[])[] } /** EbrainsDatasetModel */ EbrainsDatasetModel: { @@ -641,7 +649,7 @@ export interface components { * @description An enumeration. * @enum {unknown} */ - ImageTypes: "BlockfaceVolumeOfInterest" | "CellBodyStainedVolumeOfInterest" | "CellbodyStainedSection" | "MRIVolumeOfInterest" | "PLIVolumeOfInterest" | "SegmentedVolumeOfInterest" + ImageTypes: "BlockfaceVolumeOfInterest" | "CellBodyStainedVolumeOfInterest" | "CellbodyStainedSection" | "MRIVolumeOfInterest" | "PLIVolumeOfInterest" | "SegmentedVolumeOfInterest" | "XPCTVolumeOfInterest" /** LocationModel */ LocationModel: { /** @Type */ @@ -719,9 +727,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -732,9 +740,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -745,9 +753,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -758,9 +766,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -771,9 +779,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -784,9 +792,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -797,9 +805,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -810,9 +818,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -823,9 +831,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -836,9 +844,9 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -849,9 +857,22 @@ export interface components { /** Total */ total: number /** Page */ - page: number + page?: number + /** Size */ + size?: number + /** Pages */ + pages?: number + } + /** Page[Union[SiibraVoiModel, SiibraCorticalProfileModel, SiibraRegionalConnectivityModel, SiibraReceptorDensityFp, SiibraTabularModel, SiibraEbrainsDataFeatureModel]] */ + Page_Union_SiibraVoiModel__SiibraCorticalProfileModel__SiibraRegionalConnectivityModel__SiibraReceptorDensityFp__SiibraTabularModel__SiibraEbrainsDataFeatureModel__: { + /** Items */ + items: (components["schemas"]["SiibraVoiModel"] | components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraRegionalConnectivityModel"] | components["schemas"]["SiibraReceptorDensityFp"] | components["schemas"]["SiibraTabularModel"] | components["schemas"]["SiibraEbrainsDataFeatureModel"])[] + /** Total */ + total: number + /** Page */ + page?: number /** Size */ - size: number + size?: number /** Pages */ pages?: number } @@ -1223,7 +1244,7 @@ export interface components { * @description An enumeration. * @enum {unknown} */ - TabularTypes: "ReceptorDensityFingerprint" | "LayerwiseBigBrainIntensities" | "LayerwiseCellDensity" + TabularTypes: "ReceptorDensityFingerprint" | "LayerwiseBigBrainIntensities" | "LayerwiseCellDensity" | "RegionalBOLD" /** ValidationError */ ValidationError: { /** Location */ @@ -1478,6 +1499,7 @@ export interface operations { parameters: { query: { parcellation_id: string + find?: string page?: number size?: number } @@ -1497,12 +1519,39 @@ export interface operations { } } } + get_all_regions_regions__region_id__features_get: { + /** Get All Regions */ + parameters: { + query: { + parcellation_id: string + page?: number + size?: number + } + path: { + region_id: string + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["Page_Union_SiibraVoiModel__SiibraCorticalProfileModel__SiibraRegionalConnectivityModel__SiibraReceptorDensityFp__SiibraTabularModel__SiibraEbrainsDataFeatureModel__"] + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } get_all_regions_regions__region_id__get: { /** Get All Regions */ parameters: { query: { parcellation_id: string - space_id: string + space_id?: string } path: { region_id: string @@ -1623,6 +1672,31 @@ export interface operations { } } } + router_assign_point_map_assign_get: { + /** Router Assign Point */ + parameters: { + query: { + parcellation_id: string + space_id: string + point: string + sigma_mm?: number + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["DataFrameModel"] + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } get_all_feature_types_feature__types_get: { /** Get All Feature Types */ parameters?: { @@ -1894,6 +1968,8 @@ export interface operations { parcellation_id: string region_id: string gene: string + page?: number + size?: number } path: { feature_id: string @@ -1903,7 +1979,7 @@ export interface operations { /** @description Successful Response */ 200: { content: { - "application/json": components["schemas"]["SiibraTabularModel"] + "application/json": components["schemas"]["Page_SiibraTabularModel_"] } } /** @description Validation Error */ @@ -1945,6 +2021,8 @@ export interface operations { query: { parcellation_id: string region_id: string + page?: number + size?: number } path: { feature_id: string @@ -1954,7 +2032,7 @@ export interface operations { /** @description Successful Response */ 200: { content: { - "application/json": components["schemas"]["SiibraEbrainsDataFeatureModel"] + "application/json": components["schemas"]["Page_SiibraEbrainsDataFeatureModel_"] } } /** @description Validation Error */ diff --git a/src/atlasComponents/sapiViews/module.ts b/src/atlasComponents/sapiViews/module.ts index aa5faf15b444b6a3158c09c0af2d05ac2b52a4de..6927dbfdfc6f49e33b927960c8790234c3ab5e8d 100644 --- a/src/atlasComponents/sapiViews/module.ts +++ b/src/atlasComponents/sapiViews/module.ts @@ -1,12 +1,15 @@ import { NgModule } from "@angular/core"; import { SapiViewsCoreModule } from "./core"; +import { VolumesModule } from "./volumes/volumes.module"; @NgModule({ imports: [ SapiViewsCoreModule, + VolumesModule, ], exports: [ SapiViewsCoreModule, + VolumesModule, ] }) -export class SapiViewsModule{} \ No newline at end of file +export class SapiViewsModule{} diff --git a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html new file mode 100644 index 0000000000000000000000000000000000000000..9b5e1c0b7c14b617454b79842c64dbc989635258 --- /dev/null +++ b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html @@ -0,0 +1,37 @@ +<div class="sxplr-m-2" *ngIf="busy$ | async"> + <spinner-cmp class="sxplr-d-inline-block"></spinner-cmp> + <span> + Loading assignment ... + </span> +</div> + +<ng-template [ngIf]="df$ | async" let-df> + <button mat-raised-button + class="sxplr-m-2" + (click)="openDialog(datatableTmpl)"> + Show Assignment ({{ df.data.length }}) + </button> +</ng-template> + +<ng-template #datatableTmpl> + <h2 mat-dialog-title>Assignment</h2> + <mat-dialog-content> + <table mat-table [dataSource]="df$ | async | dfToDs"> + <ng-container *ngFor="let column of columns$ | async" + [matColumnDef]="column"> + <th mat-header-cell *matHeaderCellDef> + {{ column }} + </th> + <td mat-cell *matCellDef="let element"> + {{ element[column] | prettyPresent }} + </td> + </ng-container> + + <tr mat-header-row *matHeaderRowDef="columns$ | async"></tr> + <tr mat-row *matRowDef="let row; columns: columns$ | async;"></tr> + </table> + </mat-dialog-content> + <mat-dialog-actions align="center"> + <button mat-button mat-dialog-close>Close</button> + </mat-dialog-actions> +</ng-template> diff --git a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.scss b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..ef0991853ea9c614011a91bfafc6ab483fa577d2 --- /dev/null +++ b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.scss @@ -0,0 +1,10 @@ +td[mat-cell], +th[mat-header-cell] +{ + padding: 0.25rem; +} + +.show-assignment-button +{ + margin: 2rem; +} diff --git a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.spec.ts b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..00e57ca0a847addd15cbdfa102e8bd357d7c8b2c --- /dev/null +++ b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PointAssignmentComponent } from './point-assignment.component'; + +describe('PointAssignmentComponent', () => { + let component: PointAssignmentComponent; + let fixture: ComponentFixture<PointAssignmentComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ PointAssignmentComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PointAssignmentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..9948c1183f0e67c6cced527011d116986f1cb08b --- /dev/null +++ b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts @@ -0,0 +1,80 @@ +import { Component, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { BehaviorSubject, EMPTY, Subscription, combineLatest } from 'rxjs'; +import { map, shareReplay, switchMap, tap } from 'rxjs/operators'; +import { SAPI } from 'src/atlasComponents/sapi/sapi.service'; +import { SxplrParcellation, SxplrTemplate } from 'src/atlasComponents/sapi/sxplrTypes'; +import { TSandsPoint } from 'src/util/types'; + +@Component({ + selector: 'sxplr-point-assignment', + templateUrl: './point-assignment.component.html', + styleUrls: ['./point-assignment.component.scss'] +}) +export class PointAssignmentComponent implements OnDestroy { + + #busy$ = new BehaviorSubject(false) + busy$ = this.#busy$.asObservable() + + #point = new BehaviorSubject<TSandsPoint>(null) + @Input() + set point(val: TSandsPoint) { + this.#point.next(val) + } + + #template = new BehaviorSubject<SxplrTemplate>(null) + @Input() + set template(val: SxplrTemplate) { + this.#template.next(val) + } + + #parcellation = new BehaviorSubject<SxplrParcellation>(null) + @Input() + set parcellation(val: SxplrParcellation) { + this.#parcellation.next(val) + } + + df$ = combineLatest([ + this.#point, + this.#parcellation, + this.#template, + ]).pipe( + switchMap(([ point, parcellation, template ]) => { + if (!point || !parcellation || !template) { + return EMPTY + } + const { ['@id']: ptSpaceId} = point.coordinateSpace + if (ptSpaceId !== template.id) { + console.warn(`point coordination space id ${ptSpaceId} is not the same as template id ${template.id}.`) + return EMPTY + } + this.#busy$.next(true) + return this.sapi.v3Get("/map/assign", { + query: { + parcellation_id: parcellation.id, + point: point.coordinates.map(v => `${v.value/1e6}mm`).join(','), + space_id: template.id, + sigma_mm: 3.0 + } + }).pipe( + tap(() => this.#busy$.next(false)), + shareReplay(1), + ) + }) + ) + + columns$ = this.df$.pipe( + map(df => df.columns as string[]) + ) + + constructor(private sapi: SAPI, private dialog: MatDialog) {} + + openDialog(tmpl: TemplateRef<unknown>){ + this.dialog.open(tmpl) + } + + #sub: Subscription[] = [] + ngOnDestroy(): void { + while (this.#sub.length > 0) this.#sub.pop().unsubscribe() + } +} diff --git a/src/atlasComponents/sapiViews/volumes/volumes.module.ts b/src/atlasComponents/sapiViews/volumes/volumes.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..eee2503b61afec1b5533c4d265ce178669ee492c --- /dev/null +++ b/src/atlasComponents/sapiViews/volumes/volumes.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { PointAssignmentComponent } from './point-assignment/point-assignment.component'; +import { MatTableModule } from '@angular/material/table'; +import { UtilModule } from 'src/util'; +import { SpinnerModule } from 'src/components/spinner'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + + + +@NgModule({ + declarations: [ + PointAssignmentComponent + ], + imports: [ + CommonModule, + MatTableModule, + UtilModule, + SpinnerModule, + MatDialogModule, + MatButtonModule, + ], + exports: [ + PointAssignmentComponent + ] +}) +export class VolumesModule { } diff --git a/src/atlasComponents/userAnnotations/tools/type.ts b/src/atlasComponents/userAnnotations/tools/type.ts index eb6f15882be83d17ed7ff30870a879e39ca8101f..e2abb6f9cf4b745180c5d971ccd8a36178637388 100644 --- a/src/atlasComponents/userAnnotations/tools/type.ts +++ b/src/atlasComponents/userAnnotations/tools/type.ts @@ -5,6 +5,9 @@ import { getUuid } from "src/util/fn" import { TLineJsonSpec } from "./line" import { TPointJsonSpec } from "./point" import { TPolyJsonSpec } from "./poly" +import { TSandsCoord, TSandsPoint } from "src/util/types" + +export { getCoord, TSandsPoint } from "src/util/types" type TRecord = Record<string, unknown> @@ -233,28 +236,6 @@ export type TBaseAnnotationGeomtrySpec = { desc?: string } -export function getCoord(value: number): TSandsQValue { - return { - '@id': getUuid(), - '@type': "https://openminds.ebrains.eu/core/QuantitativeValue", - value, - unit: { - "@id": 'id.link/mm' - } - } -} - -type TSandsQValue = { - '@id': string - '@type': 'https://openminds.ebrains.eu/core/QuantitativeValue' - uncertainty?: [number, number] - value: number - unit: { - '@id': 'id.link/mm' - } -} -type TSandsCoord = [TSandsQValue, TSandsQValue] | [TSandsQValue, TSandsQValue, TSandsQValue] - export type TGeometryJson = TPointJsonSpec | TLineJsonSpec | TPolyJsonSpec export type TSands = TSandsPolyLine | TSandsLine | TSandsPoint @@ -278,14 +259,6 @@ export type TSandsLine = { '@id': string } -export type TSandsPoint = { - coordinates: TSandsCoord - coordinateSpace: { - '@id': string - } - '@type': 'https://openminds.ebrains.eu/sands/CoordinatePoint' - '@id': string -} export interface ISandsAnnotation { point: TSandsPoint diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 5f510e7fda57199da499e2922ead4451b772bb84..9255c9065120627b7998d0c9c297972a69f1c0ff 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -168,7 +168,12 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { } public mouseClickDocument(event: MouseEvent) { - this.clickIntService.callRegFns(event) + /** + * only trigger on primary mouse click + */ + if (event.button === 0) { + this.clickIntService.callRegFns(event) + } } /** diff --git a/src/contextMenuModule/service.ts b/src/contextMenuModule/service.ts index a35798d79ecbaa790598842007bc10fc002d44df..7cba30e61e0b576feef3c63a7493c78652c23ad1 100644 --- a/src/contextMenuModule/service.ts +++ b/src/contextMenuModule/service.ts @@ -2,6 +2,7 @@ import { Overlay, OverlayRef } from "@angular/cdk/overlay" import { TemplatePortal } from "@angular/cdk/portal" import { Injectable, TemplateRef, ViewContainerRef } from "@angular/core" import { ReplaySubject, Subject, Subscription } from "rxjs" +import { mutateDeepMerge } from "src/util/fn" import { RegDeregController } from "src/util/regDereg.base" type TTmpl = { @@ -111,6 +112,16 @@ export class ContextMenuService<T> extends RegDeregController<CtxMenuInterArg<T> ) ) } + + setState(state: T){ + this.context$.next(state) + } + deepMerge(pState: Partial<T>) { + const newState: T = structuredClone(this.context || {}) + this.context$.next( + mutateDeepMerge(newState, pState) + ) + } } export type TContextMenuReg<T> = (arg: CtxMenuInterArg<T>) => boolean diff --git a/src/state/atlasSelection/actions.ts b/src/state/atlasSelection/actions.ts index 0b9f5d3c980f18f6440f7e50ff6364e6b710b596..a4d044ae4d3b2e1b09fa2745f9045cb48b2729d5 100644 --- a/src/state/atlasSelection/actions.ts +++ b/src/state/atlasSelection/actions.ts @@ -1,6 +1,7 @@ import { createAction, props } from "@ngrx/store"; import { SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { BreadCrumb, nameSpace, ViewerMode, AtlasSelectionState } from "./const" +import { TFace, TSandsPoint } from "src/util/types"; export const selectAtlas = createAction( `${nameSpace} selectAtlas`, @@ -187,3 +188,14 @@ export const viewSelRegionInNewSpace = createAction( template: SxplrTemplate }>() ) + +export const selectPoint = createAction( + `${nameSpace} selectPoint`, + props<{ + point: TSandsPoint|TFace + }>() +) + +export const clearSelectedPoint = createAction( + `${nameSpace} clearPoint` +) diff --git a/src/state/atlasSelection/const.ts b/src/state/atlasSelection/const.ts index 9eacc31e232b91fd79c5df84e70c15b08a5ecbe4..bc8fa3156c229ea5ade5c4dad9171270be334a57 100644 --- a/src/state/atlasSelection/const.ts +++ b/src/state/atlasSelection/const.ts @@ -1,4 +1,5 @@ import { SxplrAtlas, SxplrTemplate, SxplrParcellation, SxplrRegion } from "src/atlasComponents/sapi/sxplrTypes" +import { TSandsPoint, TFace } from "src/util/types" export const nameSpace = `[state.atlasSelection]` export type ViewerMode = 'annotating' | 'key frame' @@ -30,4 +31,6 @@ export type AtlasSelectionState = { viewerMode: ViewerMode breadcrumbs: BreadCrumb[] + + selectedPoint: TSandsPoint|TFace } diff --git a/src/state/atlasSelection/effects.ts b/src/state/atlasSelection/effects.ts index cf5dcdf1b2ec753291f699413e0a6d4cb92bd1b1..816c23c2439b953c511d15bc6168348b1a00c2db 100644 --- a/src/state/atlasSelection/effects.ts +++ b/src/state/atlasSelection/effects.ts @@ -447,6 +447,16 @@ export class Effect { ) )) + onRegionSelectionClearPointSelection = createEffect(() => this.action.pipe( + ofType(actions.selectRegion), + map(() => actions.clearSelectedPoint()) + )) + + onPointSelectionClearRegionSelection = createEffect(() => this.action.pipe( + ofType(actions.selectPoint), + map(() => actions.clearSelectedRegions()) + )) + constructor( private action: Actions, private sapiSvc: SAPI, diff --git a/src/state/atlasSelection/selectors.ts b/src/state/atlasSelection/selectors.ts index ad3079a560f2d12eb3cf5655279122b8cf21cd75..44f122b362d28da0335db6c726e5b49f207a0613 100644 --- a/src/state/atlasSelection/selectors.ts +++ b/src/state/atlasSelection/selectors.ts @@ -48,4 +48,24 @@ export const viewerMode = createSelector( export const breadCrumbs = createSelector( selectStore, state => state.breadcrumbs -) \ No newline at end of file +) + +export const selectedPoint = createSelector( + selectStore, + state => state.selectedPoint +) + +export const relevantSelectedPoint = createSelector( + selectedTemplate, + selectedPoint, + (tmpl, point) => { + if (!tmpl || !point) { + return null + } + const { ['@id']: spcId } = point.coordinateSpace + if (spcId === tmpl.id) { + return point + } + return null + } +) diff --git a/src/state/atlasSelection/store.ts b/src/state/atlasSelection/store.ts index 5fcf2e1820a936bc605d18fac8717b6d347629b1..036fae219ba525dbb6afaadcb5acc20c154b6e5d 100644 --- a/src/state/atlasSelection/store.ts +++ b/src/state/atlasSelection/store.ts @@ -11,7 +11,8 @@ export const defaultState: AtlasSelectionState = { standAloneVolumes: [], navigation: null, viewerMode: null, - breadcrumbs: [] + breadcrumbs: [], + selectedPoint: null, } const reducer = createReducer( @@ -126,6 +127,24 @@ const reducer = createReducer( breadcrumbs: state.breadcrumbs.filter(bc => bc.id !== id) } } + ), + on( + actions.selectPoint, + (state, { point }) => { + return { + ...state, + selectedPoint: point + } + } + ), + on( + actions.clearSelectedPoint, + state => { + return { + ...state, + selectedPoint: null + } + } ) ) diff --git a/src/util/df-to-ds.pipe.spec.ts b/src/util/df-to-ds.pipe.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2b424943be89d7818c7f140e06fc730f3f67cb2 --- /dev/null +++ b/src/util/df-to-ds.pipe.spec.ts @@ -0,0 +1,8 @@ +import { DfToDsPipe } from './df-to-ds.pipe'; + +describe('DfToDsPipe', () => { + it('create an instance', () => { + const pipe = new DfToDsPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/util/df-to-ds.pipe.ts b/src/util/df-to-ds.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..612b8b48220cb6463458d21bd63f5f5c2eb60e7b --- /dev/null +++ b/src/util/df-to-ds.pipe.ts @@ -0,0 +1,42 @@ +import { CdkTableDataSourceInput } from '@angular/cdk/table'; +import { Pipe, PipeTransform } from '@angular/core'; +import { components } from "src/atlasComponents/sapi/schemaV3" +type DF = components["schemas"]["DataFrameModel"] + +function isDf(val: object): val is DF { + if (!val) return false + const keys = [ + "columns", + "ndim", + "data", + ] + return keys.every(key => key in val) +} + +@Pipe({ + name: 'dfToDs', + pure: true +}) +export class DfToDsPipe implements PipeTransform { + + transform(df: object): CdkTableDataSourceInput<unknown> { + if (!isDf(df)) { + return null + } + return df.data.map((arr, idx) => { + const val = df.index[idx] as any + const returnVal: Record<string, string|number|number[]> = { + index: val, + } + arr.forEach((val, colIdx) => { + const key = df.columns[colIdx] + if (!(typeof key === "string")) { + throw new Error(`Expected key to be of type string, number or number[], but was not`) + } + returnVal[key] = val + }) + return returnVal + }) + } + +} diff --git a/src/util/fn.ts b/src/util/fn.ts index fd9316ab4d139cbf07d9bc17f4c8283ade502695..713bc7cbe70fff8c149fdc128ada1de3671936cf 100644 --- a/src/util/fn.ts +++ b/src/util/fn.ts @@ -358,16 +358,20 @@ export function mutateDeepMerge(toObj: any, fromObj: any){ continue } if (Array.isArray(toObj[key])) { - const objToAppend = Array.isArray(fromObj[key]) - ? fromObj[key] - : [fromObj[key]] - toObj[key].push(...objToAppend) + toObj[key] = fromObj[key] continue } - if (typeof toObj[key] === typeof fromObj[key] && typeof toObj[key] === 'object') { + const toObjType = typeof toObj[key] + if (toObjType === typeof fromObj[key] && toObjType === 'object') { mutateDeepMerge(toObj[key], fromObj[key]) continue } + + if (["boolean", "string", "number"].includes(toObjType)) { + toObj[key] = fromObj[key] + continue + } + throw new Error(`cannot mutate ${key} typeof ${typeof fromObj[key]}`) } diff --git a/src/util/pretty-present.pipe.spec.ts b/src/util/pretty-present.pipe.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fa31213ba802ea6dcc0edc18c715354aff0a49c --- /dev/null +++ b/src/util/pretty-present.pipe.spec.ts @@ -0,0 +1,8 @@ +import { PrettyPresentPipe } from './pretty-present.pipe'; + +describe('PrettyPresentPipe', () => { + it('create an instance', () => { + const pipe = new PrettyPresentPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/util/pretty-present.pipe.ts b/src/util/pretty-present.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..90f68362a6d4719d82d5b985222db5e398af0ff4 --- /dev/null +++ b/src/util/pretty-present.pipe.ts @@ -0,0 +1,31 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'prettyPresent', + pure: true +}) +export class PrettyPresentPipe implements PipeTransform { + + transform(value: unknown, toFixed: number=4): unknown { + if (value === null) { + return null + } + if (typeof value === "string") { + return value + } + if (Array.isArray(value)) { + return value.map(v => this.transform(v)).join(", ") + } + if (typeof value === "number") { + return value.toFixed(toFixed) + } + if (typeof value === "object") { + if (value['name']) { + return value['name'] + } + return 'Unknown' + } + return null; + } + +} diff --git a/src/util/types.ts b/src/util/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..70f6e67a2e4c1a0f48e4edcde8faedaa1100c775 --- /dev/null +++ b/src/util/types.ts @@ -0,0 +1,43 @@ +import { getUuid } from "./fn" + +type TSandsQValue = { + '@id': string + '@type': 'https://openminds.ebrains.eu/core/QuantitativeValue' + uncertainty?: [number, number] + value: number + unit: { + '@id': 'id.link/mm' + } +} + +export type TSandsCoord = TSandsQValue[] + +export type TSandsPoint = { + coordinates: TSandsCoord + coordinateSpace: { + '@id': string + } + '@type': 'https://openminds.ebrains.eu/sands/CoordinatePoint' + '@id': string +} + +export type TFace = { + face: number + vertices: number [] + coordinateSpace: { + '@id': string + } + '@type': 'siibra-explorer/surface/face' + '@id': string +} + +export function getCoord(value: number): TSandsQValue { + return { + '@id': getUuid(), + '@type': "https://openminds.ebrains.eu/core/QuantitativeValue", + value, + unit: { + "@id": 'id.link/mm' + } + } +} diff --git a/src/util/util.module.ts b/src/util/util.module.ts index 4e483fe9b6621b48cbdda0180b7f7060a1b155ad..d40b143f716b382721cd8620cfd3760021088786 100644 --- a/src/util/util.module.ts +++ b/src/util/util.module.ts @@ -22,6 +22,8 @@ import { IncludesPipe } from "./includes.pipe"; import { SidePanelComponent } from './side-panel/side-panel.component'; import { MatCardModule } from "@angular/material/card"; import { CommonModule } from "@angular/common"; +import { DfToDsPipe } from './df-to-ds.pipe'; +import { PrettyPresentPipe } from './pretty-present.pipe'; @NgModule({ imports:[ @@ -50,6 +52,8 @@ import { CommonModule } from "@angular/common"; MergeObjPipe, IncludesPipe, SidePanelComponent, + DfToDsPipe, + PrettyPresentPipe, ], exports: [ StopPropagationDirective, @@ -71,7 +75,9 @@ import { CommonModule } from "@angular/common"; CombineFnPipe, MergeObjPipe, IncludesPipe, - SidePanelComponent + SidePanelComponent, + DfToDsPipe, + PrettyPresentPipe ] }) diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index cf057611b6cbb5ba9d3876a38179bc3be3263aa6..b05ee4f2100bbf543a7d2488562103996ef461be 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -14,6 +14,7 @@ import { atlasAppearance, atlasSelection, userInteraction } from "src/state"; import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { FormControl } from "@angular/forms"; import { EntryComponent } from "src/features/entry/entry.component"; +import { TFace, TSandsPoint, getCoord } from "src/util/types"; @Component({ selector: 'iav-cmp-viewer-container', @@ -94,11 +95,19 @@ export class ViewerCmp implements OnDestroy { public templateSelected$ = this.selectedATP.pipe( map(({ template }) => template) ) + + #templateSelected$ = this.store$.pipe( + select(atlasSelection.selectors.selectedTemplate) + ) + public parcellationSelected$ = this.selectedATP.pipe( map(({ parcellation }) => parcellation) ) + #parcellationSelected$ = this.store$.pipe( + select(atlasSelection.selectors.selectedParcellation) + ) - public selectedRegions$ = this.store$.pipe( + #selectedRegions$ = this.store$.pipe( select(atlasSelection.selectors.selectedRegions), ) @@ -106,9 +115,8 @@ export class ViewerCmp implements OnDestroy { select(atlasSelection.selectors.selectedParcAllRegions) ) - public viewerMode$: Observable<string> = this.store$.pipe( + #viewerMode$: Observable<string> = this.store$.pipe( select(atlasSelection.selectors.viewerMode), - shareReplay(1), ) public useViewer$: Observable<TSupportedViewers | 'notsupported'> = this.store$.pipe( @@ -123,24 +131,61 @@ export class ViewerCmp implements OnDestroy { public viewerCtx$ = this.ctxMenuSvc.context$ - public selectedFeature$: Observable<Feature> = this.store$.pipe( + #selectedFeature$: Observable<Feature> = this.store$.pipe( select(userInteraction.selectors.selectedFeature) ) - /** - * if no regions are selected, nor any additional layers (being deprecated) - * then the "explore" btn should not show - * and the full left side bar should not be expandable - * if it is already expanded, it should collapse - */ - public onlyShowMiniTray$: Observable<boolean> = combineLatest([ - this.selectedRegions$, - this.viewerMode$.pipe( - startWith(null as string) - ), - this.selectedFeature$, + #selectedPoint$ = this.store$.pipe( + select(atlasSelection.selectors.relevantSelectedPoint) + ) + + public view$ = combineLatest([ + this.#selectedRegions$, + this.#viewerMode$, + this.#selectedFeature$, + this.#selectedPoint$, + this.#templateSelected$, + this.#parcellationSelected$ ]).pipe( - map(([ regions, viewerMode, selectedFeature ]) => regions.length === 0 && !viewerMode && !selectedFeature) + map(([ selectedRegions, viewerMode, selectedFeature, selectedPoint, selectedTemplate, selectedParcellation ]) => { + let spatialObjectTitle: string + let spatialObjectSubtitle: string + if (selectedPoint) { + const { ['@type']: selectedPtType } = selectedPoint + if (selectedPtType === "https://openminds.ebrains.eu/sands/CoordinatePoint") { + spatialObjectTitle = `Point: ${selectedPoint.coordinates.map(v => (v.value / 1e6).toFixed(2))} (mm)` + } + if (selectedPtType === "siibra-explorer/surface/face") { + spatialObjectTitle = `Face: #${selectedPoint.face}` + } + } + if (!!selectedTemplate) { + spatialObjectSubtitle = selectedTemplate.name + } + return { + viewerMode, + selectedRegions, + selectedFeature, + selectedPoint, + selectedTemplate, + selectedParcellation, + + /** + * Selected Spatial Object + */ + spatialObjectTitle, + spatialObjectSubtitle, + + /** + * if no regions are selected, nor any additional layers (being deprecated) + * then the "explore" btn should not show + * and the full left side bar should not be expandable + * if it is already expanded, it should collapse + */ + onlyShowMiniTray: selectedRegions.length === 0 && !viewerMode && !selectedFeature && !selectedPoint + } + }), + shareReplay(1), ) @ViewChild('viewerStatusCtxMenu', { read: TemplateRef }) @@ -149,7 +194,6 @@ export class ViewerCmp implements OnDestroy { @ViewChild('viewerStatusRegionCtxMenu', { read: TemplateRef }) private viewerStatusRegionCtxMenu: TemplateRef<any> - public context: TContextArg<TSupportedViewers> private templateSelected: SxplrTemplate constructor( @@ -161,9 +205,6 @@ export class ViewerCmp implements OnDestroy { ){ this.subscriptions.push( - this.ctxMenuSvc.context$.subscribe( - (ctx: any) => this.context = ctx - ), this.templateSelected$.subscribe( t => this.templateSelected = t ), @@ -223,12 +264,16 @@ export class ViewerCmp implements OnDestroy { order: 0 }) + if (!context) { + return true + } + /** * check hovered region */ let hoveredRegions = [] if (context.viewerType === 'nehuba') { - hoveredRegions = (context as TContextArg<'nehuba'>).payload.nehuba.reduce( + hoveredRegions = ((context as TContextArg<'nehuba'>).payload.nehuba || []).reduce( (acc, curr) => acc.concat(...curr.regions), [] ) @@ -302,6 +347,47 @@ export class ViewerCmp implements OnDestroy { ) } + public selectPoint(pointSpec: {point?: number[], face?: number, vertices?: number[]}, template: SxplrTemplate){ + const { point, face, vertices } = pointSpec + const id = `${template.id}-${point ? point.join(',') : face}` + let pointOfInterest: TFace | TSandsPoint + + if (point) { + pointOfInterest = { + "@id": `${template.id}-${point.join(',')}`, + "@type": "https://openminds.ebrains.eu/sands/CoordinatePoint" as const, + coordinateSpace: { + "@id": template.id + }, + coordinates: point.map(v => getCoord(v)) + } + } + if ((face === 0 || !!face) && vertices) { + pointOfInterest = { + "@id": id, + "@type": "siibra-explorer/surface/face" as const, + coordinateSpace: { + "@id": template.id + }, + face, + vertices, + } + } + if (pointOfInterest) { + this.store$.dispatch( + atlasSelection.actions.selectPoint({ + point: pointOfInterest + }) + ) + } + } + + public clearPoint(){ + this.store$.dispatch( + atlasSelection.actions.clearSelectedPoint() + ) + } + public exitSpecialViewMode(): void{ this.store$.dispatch( atlasSelection.actions.clearViewerMode() @@ -315,7 +401,7 @@ export class ViewerCmp implements OnDestroy { this.cdr.detectChanges() break case EnumViewerEvt.VIEWER_CTX: - this.ctxMenuSvc.context$.next(event.data) + this.ctxMenuSvc.deepMerge(event.data) if (event.data.viewerType === "nehuba") { const { nehuba, nav } = (event.data as TContextArg<"nehuba">).payload if (nehuba) { diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index 2f1de6761f77673e51cd2fc54865c56a15bcb2dd..c9b03854e09030982cd8f626832d0dfe436cb1b3 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -33,34 +33,27 @@ <!-- TODO Potentially implementing plugin contextual info --> </div> - <!-- mouse on click context menu, currently not used --> - <!-- <div class="floating-container" - [attr.aria-label]="CONTEXT_MENU_ARIA_LABEL" - fixedMouseContextualContainerDirective - #fixedContainer="iavFixedMouseCtxContainer"> - - - - </div> --> </div> </div> <!-- master draw container --> +<ng-template [ngIf]="view$ | async" let-view> + <mat-drawer-container *ngIf="viewerLoaded" iav-switch - [iav-switch-state]="!(onlyShowMiniTray$ | async)" + [iav-switch-state]="!(view.onlyShowMiniTray)" #showFullSidenavSwitch="iavSwitch" class="position-absolute w-100 h-100 mat-drawer-content-overflow-visible invisible" [hasBackdrop]="false"> - + <!-- master drawer --> <mat-drawer mode="side" #drawer="matDrawer" - [opened]="!(onlyShowMiniTray$ | async)" + [opened]="!(view.onlyShowMiniTray)" [@openClose]="showFullSidenavSwitch && (showFullSidenavSwitch.switchState$ | async) ? 'open' : 'closed'" (@openClose.start)="$event.toState === 'open' && drawer.open()" (@openClose.done)="$event.toState === 'closed' && drawer.close()" @@ -69,7 +62,7 @@ class="sxplr-custom-cmp darker-bg sxplr-p-0 pe-all col-10 col-sm-10 col-md-5 col-lg-4 col-xl-3 col-xxl-2 z-index-10"> <!-- entry template --> - <ng-template [ngIf]="viewerMode$ | async" let-mode [ngIfElse]="regularTmpl"> + <ng-template [ngIf]="view.viewerMode" let-mode [ngIfElse]="regularTmpl"> <ng-template [ngTemplateOutlet]="alternateModeDrawerTmpl" [ngTemplateOutletContext]="{ mode: mode @@ -96,7 +89,7 @@ <div iavLayoutFourCornersTopLeft class="ws-no-wrap"> <!-- special mode --> - <ng-template [ngIf]="viewerMode$ | async" let-mode [ngIfElse]="defaultTopLeftTmpl"> + <ng-template [ngIf]="view.viewerMode" let-mode [ngIfElse]="defaultTopLeftTmpl"> <ng-template [ngTemplateOutlet]="specialModeTopLeftTmpl" [ngTemplateOutletContext]="{ mode: mode, @@ -121,7 +114,7 @@ <div iavLayoutFourCornersTopRight class="ws-no-wrap"> <!-- exit special mode --> - <ng-template [ngIf]="viewerMode$ | async" let-mode [ngIfElse]="defaultTopRightTmpl"> + <ng-template [ngIf]="view.viewerMode" let-mode [ngIfElse]="defaultTopRightTmpl"> <ng-template [ngTemplateOutlet]="specialTopRightTmpl" [ngTemplateOutletContext]="{ mode: mode @@ -141,7 +134,7 @@ <div iavLayoutFourCornersBottomLeft class="ws-no-wrap d-inline-flex w-100vw sxplr-pe-none align-items-center mb-4"> <!-- special bottom left --> - <ng-template [ngIf]="viewerMode$ | async" let-mode [ngIfElse]="localBottomLeftTmpl"></ng-template> + <ng-template [ngIf]="view.viewerMode" let-mode [ngIfElse]="localBottomLeftTmpl"></ng-template> <!-- default mode bottom left tmpl --> <ng-template #localBottomLeftTmpl> @@ -168,7 +161,9 @@ </div> </iav-layout-fourcorners> </mat-drawer-content> -</mat-drawer-container> + </mat-drawer-container> + +</ng-template> <!-- alternate mode drawer tmpl --> <ng-template #alternateModeDrawerTmpl let-mode="mode"> @@ -188,15 +183,37 @@ let-showFullSidenavSwitch="showFullSidenavSwitch"> <!-- selectedFeature || selectedRegion --> - <ng-template - [ngTemplateOutlet]="(selectedFeature$ | async) - ? selectedFeatureTmpl - : sidenavRegionTmpl" - [ngTemplateOutletContext]="{ - drawer: drawer, - showFullSidenavSwitch: showFullSidenavSwitch, - feature: selectedFeature$ | async - }"> + <ng-template [ngIf]="view$ | async" let-view> + + <!-- if selected feature is not null, show selected feature --> + <ng-template [ngIf]="view.selectedFeature"> + <ng-template + [ngTemplateOutlet]="selectedFeatureTmpl" + [ngTemplateOutletContext]="{ + feature: view.selectedFeature + }"> + </ng-template> + </ng-template> + + <!-- if selected point is not null, show selected point --> + <ng-template [ngIf]="view.selectedPoint"> + <ng-template + [ngTemplateOutlet]="selectedPointTmpl" + [ngTemplateOutletContext]="{ + view: view + }"> + </ng-template> + </ng-template> + + <!-- if selected feature and selected point are both null, show default (selected region) --> + <ng-template [ngIf]="!view.selectedFeature && !view.selectedPoint" + [ngTemplateOutlet]="sidenavRegionTmpl" + [ngTemplateOutletContext]="{ + view: view, + showFullSidenavSwitch: showFullSidenavSwitch + }"> + + </ng-template> </ng-template> </ng-template> @@ -220,38 +237,49 @@ <ng-container *ngTemplateOutlet="autocompleteTmpl; context: { showTour: true }"> </ng-container> - <div *ngIf="!((selectedRegions$ | async)[0])" class="sxplr-p-1 w-100"> - <ng-container *ngTemplateOutlet="spatialFeatureListTmpl"></ng-container> - </div> + <ng-template [ngIf]="view$ | async" let-view> + <!-- if no selected regions, show spatial search --> + <div *ngIf="(view.selectedRegions || []).length === 0" class="sxplr-p-1 w-100"> + <ng-template + [ngTemplateOutlet]="spatialFeatureListTmpl" + [ngTemplateOutletContext]="{ + view: view + }"> + </ng-template> + </div> + </ng-template> </div> <!-- such a gross implementation --> <!-- TODO fix this --> - <div class="min-tray-explr-btn" - sxplr-sapiviews-core-region - [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" - [sxplr-sapiviews-core-region-template]="templateSelected$ | async" - [sxplr-sapiviews-core-region-parcellation]="parcellationSelected$ | async" - [sxplr-sapiviews-core-region-region]="(selectedRegions$ | async)[0]" - [sxplr-sapiviews-core-region-detail-flag]="true" - #sapiRegion="sapiViewsCoreRegion"> - - <!-- TODO use sapiViews/core/region/base and fix the rest --> - <button mat-raised-button - *ngIf="!(onlyShowMiniTray$ | async)" - [attr.aria-label]="ARIA_LABELS.EXPAND" - (click)="showFullSidenav()" - class="sxplr-mt-9 sxplr-pe-all w-100" - [ngClass]="{ - 'darktheme': sapiRegion.regionDarkmode, - 'lighttheme': !sapiRegion.regionDarkmode - }" - [style.backgroundColor]="sapiRegion.regionRgbString"> - <span class="text sxplr-custom-cmp"> - Explore - </span> - </button> - </div> + <ng-template [ngIf]="view$ | async" let-view> + + <div class="min-tray-explr-btn" + sxplr-sapiviews-core-region + [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" + [sxplr-sapiviews-core-region-template]="view.selectedTemplate" + [sxplr-sapiviews-core-region-parcellation]="view.selectedParcellation" + [sxplr-sapiviews-core-region-region]="view.selectedRegions.length === 1 ? view.selectedRegions[0] : null" + [sxplr-sapiviews-core-region-detail-flag]="true" + #sapiRegion="sapiViewsCoreRegion"> + + <!-- TODO use sapiViews/core/region/base and fix the rest --> + <button mat-raised-button + *ngIf="!(view$ | async | getProperty : 'onlyShowMiniTray')" + [attr.aria-label]="ARIA_LABELS.EXPAND" + (click)="showFullSidenav()" + class="sxplr-mt-9 sxplr-pe-all w-100" + [ngClass]="{ + 'darktheme': sapiRegion.regionDarkmode, + 'lighttheme': !sapiRegion.regionDarkmode + }" + [style.backgroundColor]="sapiRegion.regionRgbString"> + <span class="text sxplr-custom-cmp"> + Explore + </span> + </button> + </div> + </ng-template> </div> @@ -277,18 +305,22 @@ </ng-template> <!-- pullable tab top left corner --> - <div *ngIf="showFullSidenavSwitch.switchState$ | async" - class="v-align-top pe-all tab-toggle-container d-inline-block" - (click)="drawer.toggle()" - quick-tour - [quick-tour-description]="quickTourRegionSearch.description" - [quick-tour-order]="quickTourRegionSearch.order"> - <ng-container *ngTemplateOutlet="tabTmpl; context: { - isOpen: isOpen, - regionSelected: selectedRegions$ | async - }"> - </ng-container> - </div> + + <ng-template [ngIf]="view$ | async" let-view> + + <div *ngIf="showFullSidenavSwitch.switchState$ | async" + class="v-align-top pe-all tab-toggle-container d-inline-block" + (click)="drawer.toggle()" + quick-tour + [quick-tour-description]="quickTourRegionSearch.description" + [quick-tour-order]="quickTourRegionSearch.order"> + <ng-container *ngTemplateOutlet="tabTmpl; context: { + isOpen: isOpen, + view: view + }"> + </ng-container> + </div> + </ng-template> <!-- status panel for (for nehuba viewer) --> <iav-cmp-viewer-nehuba-status *ngIf="(useViewer$ | async) === 'nehuba'" @@ -373,85 +405,87 @@ <!-- bottom left --> <ng-template #bottomLeftTmpl let-showFullSideNav="showFullSideNav"> - <!-- scroll container --> - <div class="sxplr-d-inline-flex - sxplr-flex-wrap-nowrap - sxplr-mxw-80vw - sxplr-pe-all - sxplr-of-x-auto - sxplr-of-y-hidden - sxplr-align-items-stretch"> - - <sxplr-wrapper-atp-selector class="sxplr-z-2"> - </sxplr-wrapper-atp-selector> - - <!-- selected region chip --> - <ng-template [ngIf]="selectedRegions$ | async" let-regions> - - <!-- single region --> - <ng-template [ngIf]="regions.length === 1"> - <sxplr-smart-chip - *ngFor="let region of regions" - [noMenu]="true" - [color]="sapiViewsCoreRegion.regionRgbString" - (click)="showFullSideNav()" - sxplr-sapiviews-core-region - [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" - [sxplr-sapiviews-core-region-template]="templateSelected$ | async" - [sxplr-sapiviews-core-region-parcellation]="parcellationSelected$ | async" - [sxplr-sapiviews-core-region-region]="region" - [sxplr-sapiviews-core-region-detail-flag]="true" - #sapiViewsCoreRegion="sapiViewsCoreRegion"> - <ng-template sxplrSmartChipContent> - <span class="regionname"> - {{ region.name }} - </span> - <button class="sxplr-mr-n3" - mat-icon-button - (click)="clearRoi()"> - <i class="fas fa-times"></i> - </button> - </ng-template> - </sxplr-smart-chip> - </ng-template> + <ng-template [ngIf]="view$ | async" let-view> + <!-- scroll container --> + <div class="sxplr-d-inline-flex + sxplr-flex-wrap-nowrap + sxplr-mxw-80vw + sxplr-pe-all + sxplr-of-x-auto + sxplr-of-y-hidden + sxplr-align-items-stretch"> - <!-- multiple-regions --> - <ng-template [ngIf]="regions.length > 1"> - <sxplr-smart-chip - [noMenu]="true" - - (click)="showFullSideNav()" - sxplr-sapiviews-core-region - [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" - [sxplr-sapiviews-core-region-template]="templateSelected$ | async" - [sxplr-sapiviews-core-region-parcellation]="parcellationSelected$ | async"> - <ng-template sxplrSmartChipContent> - <span class="regionname"> - {{ regions.length }} regions selected - </span> - <button class="sxplr-mr-n3" - mat-icon-button - (click)="clearRoi()"> - <i class="fas fa-times"></i> - </button> - </ng-template> - </sxplr-smart-chip> + <sxplr-wrapper-atp-selector class="sxplr-z-2"> + </sxplr-wrapper-atp-selector> + + <!-- selected region chip --> + <ng-template [ngIf]="view.selectedRegions" let-regions> + + <!-- single region --> + <ng-template [ngIf]="regions.length === 1"> + <sxplr-smart-chip + *ngFor="let region of regions" + [noMenu]="true" + [color]="sapiViewsCoreRegion.regionRgbString" + (click)="showFullSideNav()" + sxplr-sapiviews-core-region + [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" + [sxplr-sapiviews-core-region-template]="view.selectedTemplate" + [sxplr-sapiviews-core-region-parcellation]="view.selectedParcellation" + [sxplr-sapiviews-core-region-region]="region" + [sxplr-sapiviews-core-region-detail-flag]="true" + #sapiViewsCoreRegion="sapiViewsCoreRegion"> + <ng-template sxplrSmartChipContent> + <span class="regionname"> + {{ region.name }} + </span> + <button class="sxplr-mr-n3" + mat-icon-button + (click)="clearRoi()"> + <i class="fas fa-times"></i> + </button> + </ng-template> + </sxplr-smart-chip> + </ng-template> - </ng-template> + + <!-- multiple-regions --> + <ng-template [ngIf]="regions.length > 1"> + <sxplr-smart-chip + [noMenu]="true" + + (click)="showFullSideNav()" + sxplr-sapiviews-core-region + [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" + [sxplr-sapiviews-core-region-template]="view.selectedTemplate" + [sxplr-sapiviews-core-region-parcellation]="view.selectedParcellation"> + <ng-template sxplrSmartChipContent> + <span class="regionname"> + {{ regions.length }} regions selected + </span> + <button class="sxplr-mr-n3" + mat-icon-button + (click)="clearRoi()"> + <i class="fas fa-times"></i> + </button> + </ng-template> + </sxplr-smart-chip> - </ng-template> - <ng-template ngFor [ngForOf]="selectedRegions$ | async" let-region> + </ng-template> - </ng-template> - </div> + </ng-template> + </div> + </ng-template> </ng-template> <!-- viewer tmpl --> <ng-template #viewerTmpl> - <div class="position-absolute w-100 h-100 z-index-1"> + <div class="position-absolute w-100 h-100 z-index-1" + ctx-menu-host + [ctx-menu-host-tmpl]="viewerCtxMenuTmpl"> <ng-container [ngSwitch]="useViewer$ | async"> @@ -491,82 +525,84 @@ </div> </ng-container> - <!-- <div class="h-100 w-100 overflow-hidden position-relative" - ctx-menu-host - [ctx-menu-host-tmpl]="viewerCtxMenuTmpl"> - </div> --> </div> </ng-template> <!-- region-hierarchy-tmpl --> <ng-template #regionHierarchyTmpl> - <div class="sxplr-d-flex sxplr-flex-column sxplr-h-100"> - <sxplr-sapiviews-core-rich-regionshierarchy - class="sxplr-w-100 sxplr-flex-var" - [sxplr-sapiviews-core-rich-regionshierarchy-regions]="allAvailableRegions$ | async" - [sxplr-sapiviews-core-rich-regionshierarchy-accent-regions]="selectedRegions$ | async" - (sxplr-sapiviews-core-rich-regionshierarchy-region-select)="selectRoi($event)" - (sxplr-sapiviews-core-rich-regionshierarchy-region-toggle)="toggleRoi($event)" - > - </sxplr-sapiviews-core-rich-regionshierarchy> - - <mat-dialog-actions align="center" class="sxplr-flex-static"> - <button mat-button mat-dialog-close>Close</button> - </mat-dialog-actions> - </div> + <ng-template [ngIf]="view$ | async" let-view> + + <div class="sxplr-d-flex sxplr-flex-column sxplr-h-100"> + <sxplr-sapiviews-core-rich-regionshierarchy + class="sxplr-w-100 sxplr-flex-var" + [sxplr-sapiviews-core-rich-regionshierarchy-regions]="allAvailableRegions$ | async" + [sxplr-sapiviews-core-rich-regionshierarchy-accent-regions]="view.selectedRegions" + (sxplr-sapiviews-core-rich-regionshierarchy-region-select)="selectRoi($event)" + (sxplr-sapiviews-core-rich-regionshierarchy-region-toggle)="toggleRoi($event)" + > + </sxplr-sapiviews-core-rich-regionshierarchy> + + <mat-dialog-actions align="center" class="sxplr-flex-static"> + <button mat-button mat-dialog-close>Close</button> + </mat-dialog-actions> + </div> + </ng-template> </ng-template> <!-- auto complete search box --> <ng-template #autocompleteTmpl let-showTour="showTour"> - <div class="sxplr-custom-cmp bg card ml-2 mr-2 mat-elevation-z8 pe-all auto-complete-container"> + <ng-template [ngIf]="view$ | async" let-view> + + <div class="sxplr-custom-cmp bg card ml-2 mr-2 mat-elevation-z8 pe-all auto-complete-container"> - <sxplr-sapiviews-core-rich-regionlistsearch - [sxplr-sapiviews-core-rich-regionlistsearch-regions]="allAvailableRegions$ | async" - [sxplr-sapiviews-core-rich-regionlistsearch-current-search]="selectedRegions$ | async | getProperty : 0 | getProperty : 'name'" - (sxplr-sapiviews-core-rich-regionlistsearch-region-select)="selectRoi($event)" - (sxplr-sapiviews-core-rich-regionlistsearch-region-toggle)="toggleRoi($event)"> - <ng-template regionTemplate let-region> - <div class="sxplr-d-flex"> - <button - mat-icon-button - class="sxplr-mt-a sxplr-mb-a"> - <i [ngClass]="(selectedRegions$ | async | includes : region) ? 'fa-circle' : 'fa-none'" class="fas"></i> - </button> - - <sxplr-sapiviews-core-region-region-list-item - [sxplr-sapiviews-core-region-region]="region"> - </sxplr-sapiviews-core-region-region-list-item> - </div> - </ng-template> + <sxplr-sapiviews-core-rich-regionlistsearch + [sxplr-sapiviews-core-rich-regionlistsearch-regions]="allAvailableRegions$ | async" + [sxplr-sapiviews-core-rich-regionlistsearch-current-search]="view.selectedRegions.length === 1 ? view.selectedRegions[0].name : null" + (sxplr-sapiviews-core-rich-regionlistsearch-region-select)="selectRoi($event)" + (sxplr-sapiviews-core-rich-regionlistsearch-region-toggle)="toggleRoi($event)"> + <ng-template regionTemplate let-region> + <div class="sxplr-d-flex"> + <button + mat-icon-button + class="sxplr-mt-a sxplr-mb-a"> + <i [ngClass]="(view.selectedRegions | includes : region) ? 'fa-circle' : 'fa-none'" class="fas"></i> + </button> + + <sxplr-sapiviews-core-region-region-list-item + [sxplr-sapiviews-core-region-region]="region"> + </sxplr-sapiviews-core-region-region-list-item> + </div> + </ng-template> + <button mat-icon-button + search-input-suffix + *ngIf="view.selectedRegions.length > 0" + (click)="clearRoi()"> + <i class="fas fa-times"></i> + </button> + </sxplr-sapiviews-core-rich-regionlistsearch> + <button mat-icon-button - search-input-suffix - *ngIf="selectedRegions$ | async | getProperty : 'length'" - (click)="clearRoi()"> - <i class="fas fa-times"></i> + color="primary" + [sxplr-dialog]="regionHierarchyTmpl" + sxplr-dialog-size="xl"> + <i class="fas fa-sitemap"></i> </button> - </sxplr-sapiviews-core-rich-regionlistsearch> - - <button mat-icon-button - color="primary" - [sxplr-dialog]="regionHierarchyTmpl" - sxplr-dialog-size="xl"> - <i class="fas fa-sitemap"></i> - </button> - - <div class="w-100 h-100 position-absolute sxplr-pe-none" *ngIf="showTour"> + + <div class="w-100 h-100 position-absolute sxplr-pe-none" *ngIf="showTour"> + </div> + </div> - - </div> + </ng-template> </ng-template> <!-- template for rendering tab --> <ng-template #tabTmpl let-isOpen="isOpen" - let-regionSelected="regionSelected" let-iavAdditionallayers="iavAdditionallayers" let-click="click" - let-badge="badge"> + let-badge="badge" + let-view="view"> <!-- if mat drawer is open --> <ng-template [ngIf]="isOpen" [ngIfElse]="tabTmpl_closedTmpl"> @@ -597,15 +633,15 @@ <!-- if additional layers not not being shown --> <ng-template #tabTmpl_noAdditionalLayers> - <!-- if region selected > 0 --> - <ng-template [ngIf]="regionSelected?.length > 0" [ngIfElse]="tabTmpl_nothingSelected"> + <!-- if region selected === 1 --> + <ng-template [ngIf]="view.regionSelected?.length === 1" [ngIfElse]="tabTmpl_nothingSelected"> <div sxplr-sapiviews-core-region [sxplr-sapiviews-core-region-detail-flag]="true" [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" - [sxplr-sapiviews-core-region-template]="templateSelected$ | async" - [sxplr-sapiviews-core-region-parcellation]="parcellationSelected$ | async" - [sxplr-sapiviews-core-region-region]="regionSelected[0]" + [sxplr-sapiviews-core-region-template]="view.selectedTemplate" + [sxplr-sapiviews-core-region-parcellation]="view.selectedParcellation" + [sxplr-sapiviews-core-region-region]="view.regionSelected[0]" #tabTmpl_iavRegion="sapiViewsCoreRegion"> </div> @@ -616,7 +652,7 @@ customColor: tabTmpl_iavRegion.regionRgbString, customColorDarkmode: tabTmpl_iavRegion.regionDarkmode, fontIcon: 'fa-brain', - tooltip: 'Explore ' + regionSelected[0].name, + tooltip: 'Explore ' + view.regionSelected[0].name, click: click }"> @@ -673,8 +709,8 @@ <!-- region sidenav tmpl --> <ng-template #sidenavRegionTmpl - let-drawer="drawer" - let-showFullSidenavSwitch="showFullSidenavSwitch"> + let-showFullSidenavSwitch="showFullSidenavSwitch" + let-view="view"> <!-- region search autocomplete --> <!-- [@openCloseAnchor]="sideNavFullLeftSwitch.switchState ? 'open' : 'closed'" --> @@ -684,45 +720,43 @@ </div> <div class="flex-shrink-1 flex-grow-1 d-flex flex-column sxplr-h-100" - [ngClass]="{'region-populated': (selectedRegions$ | async).length > 0 }"> - <!-- region detail --> - <ng-container *ngIf="selectedRegions$ | async as selectedRegions; else selectRegionErrorTmpl"> + [ngClass]="{'region-populated': (view.selectedRegions || []).length > 0 }"> - <!-- single-region-wrapper --> - <ng-template [ngIf]="selectedRegions.length === 1" [ngIfElse]="multiRegionWrapperTmpl"> - <!-- a series of bugs result in requiring this hacky --> - <!-- see https://github.com/HumanBrainProject/interactive-viewer/issues/698 --> + <!-- region detail --> + <!-- single-region-wrapper --> + <ng-template [ngIf]="view.selectedRegions.length === 1" [ngIfElse]="multiRegionWrapperTmpl"> + <!-- a series of bugs result in requiring this hacky --> + <!-- see https://github.com/HumanBrainProject/interactive-viewer/issues/698 --> - <ng-template [ngIf]="regionDirective.fetchInProgress$ | async"> - <spinner-cmp class="sxplr-mt-10 fs-200"></spinner-cmp> - </ng-template> - <sxplr-sapiviews-core-region-region-rich - [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" - [sxplr-sapiviews-core-region-template]="templateSelected$ | async" - [sxplr-sapiviews-core-region-parcellation]="parcellationSelected$ | async" - [sxplr-sapiviews-core-region-region]="selectedRegions[0]" - (sxplr-sapiviews-core-region-region-rich-feature-clicked)="showDataset($event)" - (sxplr-sapiviews-core-region-navigate-to)="navigateTo($event)" - #regionDirective="sapiViewsCoreRegionRich" - > - <div class="sapi-container" header></div> - </sxplr-sapiviews-core-region-region-rich> - </ng-template> - - <!-- multi region wrapper --> - <ng-template #multiRegionWrapperTmpl> - <ng-container *ngTemplateOutlet="multiRegionTmpl; context: { - regions: selectedRegions - }"> - </ng-container> - <!-- This is a wrapper for multiregion consisting of {{ selectedRegions.length }} regions --> + <ng-template [ngIf]="regionDirective.fetchInProgress$ | async"> + <spinner-cmp class="sxplr-mt-10 fs-200"></spinner-cmp> </ng-template> + <sxplr-sapiviews-core-region-region-rich + [sxplr-sapiviews-core-region-atlas]="selectedAtlas$ | async" + [sxplr-sapiviews-core-region-template]="view.selectedTemplate" + [sxplr-sapiviews-core-region-parcellation]="view.selectedParcellation" + [sxplr-sapiviews-core-region-region]="view.selectedRegions[0]" + (sxplr-sapiviews-core-region-region-rich-feature-clicked)="showDataset($event)" + (sxplr-sapiviews-core-region-navigate-to)="navigateTo($event)" + #regionDirective="sapiViewsCoreRegionRich" + > + <div class="sapi-container" header></div> + </sxplr-sapiviews-core-region-region-rich> + </ng-template> - <!-- place holder if length === 0 --> - <ng-container *ngIf="selectedRegions.length === 0"> - no region selected + <!-- multi region wrapper --> + <ng-template #multiRegionWrapperTmpl> + <ng-container *ngTemplateOutlet="multiRegionTmpl; context: { + regions: view.selectedRegions + }"> </ng-container> + <!-- This is a wrapper for multiregion consisting of {{ selectedRegions.length }} regions --> + </ng-template> + + <!-- place holder if length === 0 --> + <ng-container *ngIf="view.selectedRegions.length === 0"> + no region selected </ng-container> <div class="spacer"> @@ -748,11 +782,6 @@ let-content="content"> </ng-template> -<!-- select region error... for whatever reason --> -<ng-template #selectRegionErrorTmpl> - SELECT REGION ERROR -</ng-template> - <!-- multi region tmpl --> <ng-template #multiRegionTmpl let-regions="regions"> @@ -871,48 +900,65 @@ <!-- viewer status ctx menu --> <ng-template #viewerStatusCtxMenu let-data> - <mat-list> - - <!-- ref space & position --> - <ng-container [ngSwitch]="data.context.viewerType"> - - <!-- volumetric i.e. nehuba --> - <ng-container *ngSwitchCase="'nehuba'"> - <mat-list-item> - <span mat-line> - {{ data.context.payload.mouse.real | nmToMm | numbers | addUnitAndJoin : '' }} (mm) - </span> - <span mat-line class="text-muted"> - <i class="fas fa-map"></i> - <span> - {{ data.metadata.template.displayName || data.metadata.template.name }} - </span> - </span> - </mat-list-item> - </ng-container> + <ng-template [ngIf]="data.context" let-context> - <ng-container *ngSwitchCase="'threeSurfer'"> - <mat-list-item *ngIf="data?.context?.payload?.faceIndex && data?.context?.payload?.vertexIndices"> - <span mat-line> - face#{{ data.context.payload.faceIndex }} - </span> - <span mat-line> - vertices#{{ data.context.payload.vertexIndices | addUnitAndJoin : '' }} - </span> - <span mat-line class="text-muted"> - <i class="fas fa-map"></i> - <span> - {{ data.context.payload.fsversion }} - </span> - </span> - </mat-list-item> - </ng-container> + <mat-list> - <ng-container *ngSwitchDefault> - DEFAULT + <!-- ref space & position --> + <ng-container [ngSwitch]="context.viewerType"> + + <!-- volumetric i.e. nehuba --> + <ng-container *ngSwitchCase="'nehuba'"> + <mat-list-item mat-ripple + (click)="selectPoint({ point: context.payload.mouse.real }, data.metadata.template)"> + <div mat-list-icon> + <i class="fas fa-map"></i> + </div> + + <div mat-line> + {{ context.payload.mouse.real | nmToMm | numbers | addUnitAndJoin : '' }} (mm) + </div> + <div mat-line class="text-muted"> + Point + </div> + <div mat-line class="text-muted"> + {{ data.metadata.template.name }} + </div> + </mat-list-item> + </ng-container> + + <ng-container *ngSwitchCase="'threeSurfer'"> + + <ng-template [ngIf]="context.payload?.faceIndex" let-faceIndex> + <ng-template [ngIf]="context.payload?.vertexIndices" let-vertexIndices> + <mat-list-item mat-ripple + (click)="selectPoint({ face: faceIndex, vertices: vertexIndices }, data.metadata.template)"> + + <div mat-list-icon> + <i class="fas fa-map"></i> + </div> + + <div mat-line> + Face Index: {{ faceIndex }}, Vertices Index: {{ vertexIndices | addUnitAndJoin : '' }} + </div> + <div mat-line class="text-muted"> + Mesh Face + </div> + <div mat-line class="text-muted"> + {{ data.metadata.template.name }} + </div> + </mat-list-item> + </ng-template> + </ng-template> + + </ng-container> + + <ng-container *ngSwitchDefault> + DEFAULT + </ng-container> </ng-container> - </ng-container> - </mat-list> + </mat-list> + </ng-template> </ng-template> @@ -920,38 +966,42 @@ <ng-template #viewerStatusRegionCtxMenu let-data> <!-- hovered ROIs --> <mat-list> - <mat-list-item *ngFor="let region of data.metadata.hoveredRegions; let first = first"> - <mat-divider class="top-0" *ngIf="!first"></mat-divider> + <ng-template ngFor [ngForOf]="data.metadata.hoveredRegions" + let-region + let-first="first"> - <ng-container *ngTemplateOutlet="viewerStateSapiRegionTmpl; context: { $implicit: region }"> - </ng-container> + <mat-divider class="top-0" *ngIf="!first"></mat-divider> - </mat-list-item> - </mat-list> -</ng-template> + <mat-list-item mat-ripple + class="cursor-default" + (click)="$event.ctrlKey ? toggleRoi(region) : selectRoi(region)"> + <div mat-list-icon> + <i class="fas fa-brain"></i> + </div> -<!-- sapi region tmpl --> -<ng-template #viewerStateSapiRegionTmpl let-region> - <span mat-line> - {{ region.name }} - </span> - <span mat-line class="text-muted"> - <i class="fas fa-brain"></i> - <span> - Brain region - </span> - </span> + <span mat-line> + {{ region.name }} + </span> + <span mat-line class="text-muted"> + <span> + Brain region + </span> + </span> + + <!-- lookup region --> + <!-- <button mat-icon-button + (click)="selectRoi(region)" + ctx-menu-dismiss> + <i class="fas fa-search"></i> + </button> --> + </mat-list-item> + - <!-- lookup region --> - <button mat-icon-button - (click)="selectRoi(region)" - ctx-menu-dismiss> - <i class="fas fa-search"></i> - </button> + </ng-template> + </mat-list> </ng-template> - <!-- feature tmpls --> <ng-template #sapiBaseFeatureTmpl let-backCb="backCb" @@ -980,7 +1030,6 @@ </ng-template> <!-- general feature tmpl --> - <ng-template let-feature="feature" #selectedFeatureTmpl> <!-- TODO differentiate between features (spatial, regional etc) --> @@ -999,11 +1048,41 @@ </ng-layer-ctl> <ng-template #sapiVOITmpl> </ng-template> +</ng-template> +<!-- general point tmpl --> +<ng-template let-view="view" #selectedPointTmpl> + <sxplr-side-panel> + <div class="sxplr-custom-cmp lighttheme" header> + + <!-- back btn --> + <button mat-button + (click)="clearPoint()" + [attr.aria-label]="ARIA_LABELS.CLOSE" + class="sxplr-mb-2" + > + <i class="fas fa-chevron-left"></i> + <span class="ml-1"> + Back + </span> + </button> + </div> + <div title> + {{ view.spatialObjectTitle }} + </div> + <div subtitle> + {{ view.spatialObjectSubtitle }} + </div> + </sxplr-side-panel> + <sxplr-point-assignment + [point]="view.selectedPoint" + [template]="view.selectedTemplate" + [parcellation]="view.selectedParcellation"> + </sxplr-point-assignment> </ng-template> <!-- spatial search tmpls --> -<ng-template #spatialFeatureListTmpl> +<ng-template #spatialFeatureListTmpl let-view="view"> <mat-card class="sxplr-pe-all" [ngClass]="{ 'sxplr-d-none': !(voiSwitch.switchState$ | async) || (voiFeatureEntryCmp.totals$ | async) === 0 @@ -1013,8 +1092,8 @@ Anchored to current view </mat-card-title> <mat-card-subtitle> - <div> - {{ templateSelected$ | async | getProperty : 'name' }} + <div *ngIf="view.selectedTemplate"> + {{ view.selectedTemplate.name }} </div> <ng-template [ngIf]="bbox.bbox$ | async | getProperty : 'bbox'" let-bbox> <div> @@ -1038,7 +1117,7 @@ <sxplr-feature-entry [ngClass]="(voiSwitch.switchState$ | async) ? 'sxplr-d-block' : 'sxplr-d-none'" class="sxplr-pe-all mat-elevation-z8" - [template]="templateSelected$ | async" + [template]="view.selectedTemplate" [bbox]="bbox.bbox$ | async | getProperty : 'bbox'" #voiFeatureEntryCmp="featureEntryCmp"> </sxplr-feature-entry>