From b08ba41440685135136ee14df07454f0d047d17a Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Wed, 18 Oct 2023 17:14:47 +0200 Subject: [PATCH] feat: changed appearance of related region refactor: using collapse for selecting ATP --- .../sapi/decisionCollapse.service.ts | 127 ++++++ src/atlasComponents/sapi/sapi.service.ts | 16 +- .../sapiViews/core/region/module.ts | 2 + .../region/region/region.base.directive.ts | 48 ++- .../region/rich/region.rich.component.ts | 18 +- .../region/region/rich/region.rich.style.css | 6 + .../region/rich/region.rich.template.html | 94 +++-- .../ATPSelector/wrapper/wrapper.component.ts | 113 +----- src/state/atlasSelection/actions.ts | 31 +- src/state/atlasSelection/effects.ts | 370 ++++++++---------- .../viewerCmp/viewerCmp.component.ts | 16 +- .../viewerCmp/viewerCmp.template.html | 1 + 12 files changed, 463 insertions(+), 379 deletions(-) create mode 100644 src/atlasComponents/sapi/decisionCollapse.service.ts diff --git a/src/atlasComponents/sapi/decisionCollapse.service.ts b/src/atlasComponents/sapi/decisionCollapse.service.ts new file mode 100644 index 000000000..bb3c1cb73 --- /dev/null +++ b/src/atlasComponents/sapi/decisionCollapse.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from "@angular/core"; +import { SAPI } from "./sapi.service"; +import { SxplrAtlas, SxplrParcellation, SxplrTemplate } from "./sxplrTypes"; +import { take } from "rxjs/operators"; + +type PossibleATP = { + atlases: SxplrAtlas[] + spaces: SxplrTemplate[] + parcellations: SxplrParcellation[] +} + +@Injectable({ + providedIn: "root" +}) +export class DecisionCollapse{ + constructor(private sapi: SAPI){} + + cleanup<T>(val: T[]|T|null){ + if (!val) { + return [] + } + if (Array.isArray(val)){ + return val?.filter(v => !!v) || [] + } + return [val] + } + + async collapseAtlasId(atlasId: string): Promise<PossibleATP> { + const atlases = await this.sapi.atlases$.pipe( + take(1) + ).toPromise() + const atlas = atlases.find(a => a.id === atlasId) + const parcellations = atlas && await this.sapi.getAllParcellations(atlas).toPromise() + const spaces = atlas && await this.sapi.getAllSpaces(atlas).toPromise() + + return { + atlases: this.cleanup(atlas), + parcellations: this.cleanup(parcellations), + spaces: this.cleanup(spaces), + } + } + + async collapseTemplateId(templateId: string): Promise<PossibleATP> { + const atlases = await this.sapi.atlases$.pipe( + take(1) + ).toPromise() + + const atlasId = this.sapi.reverseLookupAtlas(templateId) + const atlas = atlasId && atlases.find(a => a.id === atlasId) + const spaces = atlas && await this.sapi.getAllSpaces(atlas).toPromise() + + const space = spaces.find(s => s.id === templateId) + const parcellations = atlas && space && await this.sapi.getSupportedParcellations(atlas, space).toPromise() + + return { + atlases: this.cleanup(atlas), + parcellations: this.cleanup(parcellations), + spaces: this.cleanup(space), + } + } + + async collapseParcId(parcellationId: string): Promise<PossibleATP> { + const atlases = await this.sapi.atlases$.pipe( + take(1) + ).toPromise() + + const atlasId = this.sapi.reverseLookupAtlas(parcellationId) + const atlas = atlasId && atlases.find(a => a.id === atlasId) + const parcellations = atlas && await this.sapi.getAllParcellations(atlas).toPromise() + + const parcellation = parcellations && parcellations.find(p => p.id === parcellationId) + const spaces = atlas && parcellation && await this.sapi.getSupportedTemplates(atlas, parcellation).toPromise() + + return { + atlases: this.cleanup(atlas), + parcellations: this.cleanup(parcellation), + spaces: this.cleanup(spaces), + } + } + + static _Intersect(parta: PossibleATP, partb: PossibleATP): PossibleATP { + const partbAtlasIds = partb.atlases.map(a => a.id) + const partbParcIds = partb.parcellations.map(a => a.id) + const partbSpaceIds = partb.spaces.map(a => a.id) + return { + atlases: parta.atlases.filter(a => partbAtlasIds.includes(a.id)), + parcellations: parta.parcellations.filter(a => partbParcIds.includes(a.id)), + spaces: parta.spaces.filter(a => partbSpaceIds.includes(a.id)), + } + } + + static Intersect(...allATPs: (PossibleATP|null)[]): PossibleATP { + let result: PossibleATP + for (const item of allATPs) { + if (!item) { + continue + } + if (!result) { + result = item + continue + } + result = DecisionCollapse._Intersect(result, item) + } + return result + } + + /** + * @param result + * @returns error of Verify as a list of string + */ + static Verify(result: PossibleATP|null): string[] { + const errorMessage: string[] = [] + if (!result) { + errorMessage.push(`Result is nullish.`) + } + if (result?.atlases.length === 0) { + errorMessage.push(`No overlapping atlas is found!`) + } + if (result?.parcellations.length === 0) { + errorMessage.push(`No overlapping parcellation is found!`) + } + if (result?.spaces.length === 0) { + errorMessage.push(`No overlapping space is found!`) + } + return errorMessage + } +} diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index 53868ab6a..a8a423ed6 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -279,7 +279,16 @@ export class SAPI{ query: {} }).pipe( switchMap(atlases => forkJoin( - atlases.items.map(atlas => translateV3Entities.translateAtlas(atlas)) + atlases.items.map(atlas => { + const { parcellations, spaces } = atlas + for (const parc of parcellations){ + this.#reverseMap.set(parc["@id"], atlas["@id"]) + } + for (const space of spaces){ + this.#reverseMap.set(space["@id"], atlas["@id"]) + } + return translateV3Entities.translateAtlas(atlas) + }) )), map(atlases => atlases.sort((a, b) => (speciesOrder as string[]).indexOf(a.species) - (speciesOrder as string[]).indexOf(b.species))), tap(() => { @@ -293,6 +302,11 @@ export class SAPI{ shareReplay(1), ) + #reverseMap = new Map<string, string>() + public reverseLookupAtlas(parcOrSpaceId: string): string { + return this.#reverseMap.get(parcOrSpaceId) + } + public getAllSpaces(atlas: SxplrAtlas): Observable<SxplrTemplate[]> { return forkJoin( translateV3Entities.retrieveAtlas(atlas).spaces.map( diff --git a/src/atlasComponents/sapiViews/core/region/module.ts b/src/atlasComponents/sapiViews/core/region/module.ts index 46b01dfb0..b1e56652d 100644 --- a/src/atlasComponents/sapiViews/core/region/module.ts +++ b/src/atlasComponents/sapiViews/core/region/module.ts @@ -19,6 +19,7 @@ import { MatTooltipModule } from "@angular/material/tooltip"; import { TranslateQualificationPipe } from "./translateQualification.pipe"; import { DedupRelatedRegionPipe } from "./dedupRelatedRegion.pipe"; import { MatExpansionModule } from "@angular/material/expansion"; +import { MatTableModule } from "@angular/material/table"; @NgModule({ imports: [ @@ -37,6 +38,7 @@ import { MatExpansionModule } from "@angular/material/expansion"; SapiViewsCoreParcellationModule, MatTooltipModule, MatExpansionModule, + MatTableModule, ExperimentalModule, ], declarations: [ diff --git a/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts b/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts index fc5b5c1df..d8ecb2a83 100644 --- a/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts +++ b/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts @@ -3,8 +3,9 @@ import { SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/a import { translateV3Entities } from "src/atlasComponents/sapi/translateV3" import { rgbToHsl } from 'common/util' import { SAPI } from "src/atlasComponents/sapi/sapi.service"; -import { BehaviorSubject, combineLatest, forkJoin, of } from "rxjs"; -import { map, switchMap } from "rxjs/operators"; +import { BehaviorSubject, combineLatest, forkJoin, from, of } from "rxjs"; +import { catchError, map, switchMap } from "rxjs/operators"; +import { DecisionCollapse } from "src/atlasComponents/sapi/decisionCollapse.service"; @Directive({ selector: `[sxplr-sapiviews-core-region]`, @@ -147,11 +148,50 @@ export class SapiViewsCoreRegionRegionBase { region: translateV3Entities.translateRegion(assigned_structure), parcellation: translateV3Entities.translateParcellation(assigned_structure_parcellation), })) - )) + )), + switchMap(relatedRegions => { + + const uniqueParc = relatedRegions.map(v => v.parcellation).reduce( + (acc, curr) => acc.map(v => v.id).includes(curr.id) ? acc : acc.concat(curr), + [] as SxplrParcellation[] + ) + + return forkJoin( + uniqueParc.map(parc => + from(this.collapser.collapseParcId(parc.id)).pipe( + switchMap(collapsed => forkJoin( + collapsed.spaces.map(space => + from(this.sapi.getLabelledMap(parc, space)).pipe( + catchError(() => of(null)) + ) + ) + )), + map(labelMap => ({ + parcellation: parc, + mappedRegions: labelMap + .filter(v => !!v) + .map(m => Object.keys(m.indices)) + .flatMap(v => v), + })), + ) + ) + ).pipe( + map(allMappedRegions => { + const regMap: Record<string, string[]> = {} + for (const { parcellation, mappedRegions } of allMappedRegions) { + regMap[parcellation.id] = mappedRegions + } + return relatedRegions.map(prev => ({ + ...prev, + mapped: (regMap[prev.parcellation.id] || []).includes(prev.region.name) + })) + }) + ) + }) ).toPromise() } - constructor(protected sapi: SAPI){ + constructor(protected sapi: SAPI, private collapser: DecisionCollapse){ } } diff --git a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts index ce52048ff..242bb2ff5 100644 --- a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts +++ b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts @@ -3,11 +3,12 @@ import { Component, EventEmitter, Inject, Output } from "@angular/core"; import { DARKTHEME } from "src/util/injectionTokens"; import { SapiViewsCoreRegionRegionBase } from "../region.base.directive"; import { ARIA_LABELS, CONST } from 'common/constants' -import { Feature } from "src/atlasComponents/sapi/sxplrTypes"; +import { Feature, SxplrParcellation, SxplrRegion } from "src/atlasComponents/sapi/sxplrTypes"; import { SAPI } from "src/atlasComponents/sapi/sapi.service"; import { environment } from "src/environments/environment"; import { catchError, map, shareReplay, switchMap } from "rxjs/operators"; import { PathReturn } from "src/atlasComponents/sapi/typeV3"; +import { DecisionCollapse } from "src/atlasComponents/sapi/decisionCollapse.service"; @Component({ selector: 'sxplr-sapiviews-core-region-region-rich', @@ -28,11 +29,15 @@ export class SapiViewsCoreRegionRegionRich extends SapiViewsCoreRegionRegionBase @Output('sxplr-sapiviews-core-region-region-rich-feature-clicked') featureClicked = new EventEmitter<Feature>() + @Output('sxplr-sapiviews-core-region-region-rich-related-region-clicked') + relatedRegion = new EventEmitter<{ region: SxplrRegion, parcellation: SxplrParcellation }>() + constructor( sapi: SAPI, + collapser: DecisionCollapse, @Inject(DARKTHEME) public darktheme$: Observable<boolean>, ){ - super(sapi) + super(sapi, collapser) } handleRegionalFeatureClicked(feat: Feature) { @@ -58,7 +63,7 @@ export class SapiViewsCoreRegionRegionRich extends SapiViewsCoreRegionRegionBase return this.sapi.getMap(parcellation.id, template.id, "LABELLED").pipe( map(v => { const mapIndices = v.indices[region.name] - return mapIndices.map(mapIdx => v.volumes[mapIdx.volume]) + return (mapIndices || []).map(mapIdx => v.volumes[mapIdx.volume]) }) ) }) @@ -97,4 +102,11 @@ export class SapiViewsCoreRegionRegionRich extends SapiViewsCoreRegionRegionBase switchMap(({ region }) => this.fetchRelated(region)), shareReplay(1), ) + + public selectATPR(region: SxplrRegion, parcellation: SxplrParcellation){ + this.relatedRegion.next({ + region, + parcellation + }) + } } diff --git a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.style.css b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.style.css index 2f9290a93..83724a58b 100644 --- a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.style.css +++ b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.style.css @@ -11,6 +11,12 @@ overflow-y: scroll; } +.mat-column-qualification, +.mat-column-relatedRegion +{ + padding: 0.5rem; +} + readmore-component { width: 100%; diff --git a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html index b48e7804e..c3587d8ec 100644 --- a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html +++ b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html @@ -36,6 +36,7 @@ <mat-action-list class="overview-container" dense> + <!-- parcellation button --> <button mat-list-item sxplr-dialog @@ -49,6 +50,7 @@ <div mat-line class="overview-content">{{ parcellation.name }}</div> </button> + <!-- region position (if eixsts) --> <ng-template [ngIf]="regionPosition"> <button mat-list-item (click)="navigateTo(regionPosition)"> <mat-icon mat-list-icon fontSet="fas" fontIcon="fa-map-marker"></mat-icon> @@ -56,6 +58,7 @@ </button> </ng-template> + <!-- all dois --> <ng-template ngFor [ngForOf]="dois$ | async" let-doi> <a mat-list-item [href]="doi" target="_blank" class="no-hover"> <mat-icon mat-list-icon fontSet="ai" fontIcon="ai-doi"></mat-icon> @@ -66,7 +69,8 @@ <ng-template sxplrExperimentalFlag [experimental]="true" #relatedRegionsExport="sxplrExperimentalFlag" [ngIf]="relatedRegionsExport.show$ | async"> - + + <!-- related regions --> <ng-template [ngIf]="relatedRegions$ | async | dedupRelatedRegionPipe" let-relatedRegions> <!-- only show icon if related regions length > 0 --> <ng-template [ngIf]="relatedRegions.length > 0"> @@ -76,45 +80,59 @@ <mat-icon mat-list-icon fontSet="fas" fontIcon="fa-link"></mat-icon> <div mat-line class="overview-content">Related Regions ({{ relatedRegions.length }})</div> </button> + + <!-- dialog when user clicks related regions --> + <ng-template #relatedRegionsTmpl> + <!-- header --> + <h2 mat-dialog-title>Related Regions</h2> + + <!-- body --> + <mat-dialog-content> + + <!-- iterate over all related --> + <table mat-table [dataSource]="relatedRegions"> + + <ng-container matColumnDef="currentRegion"> + <th mat-header-cell *matHeaderCellDef> Current Region </th> + <td mat-cell *matCellDef="let related"> {{ region.name }} </td> + </ng-container> + + <ng-container matColumnDef="qualification"> + <th mat-header-cell *matHeaderCellDef> Qualification </th> + <td mat-cell *matCellDef="let related"> {{ related.qualification | translateQualificationPipe }} </td> + </ng-container> + + <ng-container matColumnDef="relatedRegion"> + <th mat-header-cell *matHeaderCellDef> Related Region </th> + <td mat-cell *matCellDef="let related"> + <button tabindex="-1" mat-stroked-button + (click)="selectATPR(related.region, related.parcellation)" + class="sxplr-w-100" + [disabled]="!related.mapped"> + {{ related.region.name }} + </button> + </td> + </ng-container> + + <ng-container matColumnDef="relatedRegionParc"> + <th mat-header-cell *matHeaderCellDef> Related Region Parcellation </th> + <td mat-cell *matCellDef="let related"> + {{ related.parcellation.name }} + </td> + </ng-container> + + <tr mat-header-row *matHeaderRowDef="['currentRegion', 'qualification', 'relatedRegion', 'relatedRegionParc']"></tr> + <tr mat-row *matRowDef="let row; columns: ['currentRegion', 'qualification', 'relatedRegion', 'relatedRegionParc'];"></tr> + </table> + </mat-dialog-content> + + <!-- footer --> + <mat-dialog-actions align="center"> + <button mat-button mat-dialog-close>close</button> + </mat-dialog-actions> + </ng-template> </ng-template> - <!-- dialog when user clicks related regions --> - <ng-template #relatedRegionsTmpl> - <!-- header --> - <h2 mat-dialog-title>Current region {{ region.name }} ...</h2> - - <!-- body --> - <mat-dialog-content> - - <!-- iterate over all related --> - <ng-template ngFor [ngForOf]="relatedRegions" let-related let-isLast="last"> - - <!-- related region body --> - <div class="sxplr-p-2"> - <div> - {{ related.qualification | translateQualificationPipe }} - </div> - - <div> - {{ related.region.name }} in - </div> - <div> - {{ related.parcellation.name}} - </div> - </div> - - <!-- divider --> - <ng-template [ngIf]="!isLast"> - <mat-divider></mat-divider> - </ng-template> - </ng-template> - </mat-dialog-content> - - <!-- footer --> - <mat-dialog-actions> - <button mat-button mat-dialog-close>close</button> - </mat-dialog-actions> - </ng-template> </ng-template> </ng-template> diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts index 064e46124..9da752e04 100644 --- a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts @@ -1,8 +1,8 @@ import { Component, EventEmitter, Inject, OnDestroy, Output } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { select, Store } from "@ngrx/store"; -import { Observable, of, Subject, Subscription } from "rxjs"; -import { filter, map, switchMap, tap, withLatestFrom } from "rxjs/operators"; +import { Observable, Subject, Subscription } from "rxjs"; +import { switchMap, withLatestFrom } from "rxjs/operators"; import { SAPI } from "src/atlasComponents/sapi/sapi.service"; import { atlasAppearance, atlasSelection } from "src/state"; import { fromRootStore } from "src/state/atlasSelection"; @@ -15,11 +15,6 @@ type AskUserConfig = { actionsAsList: boolean } -function isATPGuard(obj: any): obj is Partial<ATP&{ requested: Partial<ATP> }> { - if (!obj) return false - return (obj.atlas || obj.template || obj.parcellation) && (!obj.requested || isATPGuard(obj.requested)) -} - @Component({ selector: 'sxplr-wrapper-atp-selector', templateUrl: './wrapper.template.html', @@ -63,6 +58,8 @@ export class WrapperATPSelector implements OnDestroy{ select(atlasSelection.selectors.selectedAtlas), switchMap(atlas => this.sapi.getAllParcellations(atlas)) ) + + // TODO how do we check busy'ness? isBusy$ = new Subject<boolean>() parcellationVisibility$ = this.store$.pipe( @@ -78,92 +75,24 @@ export class WrapperATPSelector implements OnDestroy{ ){ this.#subscription.push( this.selectLeaf$.pipe( - tap(() => this.isBusy$.next(true)), withLatestFrom(this.selectedATP$), - switchMap(([{ atlas, template, parcellation }, selectedATP]) => { - if (atlas) { - /** - * do not need to ask permission to switch atlas - */ - return of({ atlas }) - } - if (template) { - return this.sapi.getSupportedParcellations(selectedATP.atlas, template).pipe( - switchMap(parcs => { - if (parcs.find(p => p.id === selectedATP.parcellation.id)) { - return of({ template }) - } - return this.#askUser( - null, - `Current parcellation **${selectedATP.parcellation.name}** is not mapped in the selected template **${template.name}**. Please select one of the following parcellations:`, - null, - parcs.map(p => p.name), - { - actionsAsList: true - } - ).pipe( - map(parcname => { - const foundParc = parcs.find(p => p.name === parcname) - if (foundParc) { - return ({ template, requested: { parcellation: foundParc } }) - } - return null - }) - ) - }) - ) - } - if (parcellation) { - return this.sapi.getSupportedTemplates(selectedATP.atlas, parcellation).pipe( - switchMap(tmpls => { - if (tmpls.find(t => t.id === selectedATP.template.id)) { - return of({ parcellation }) - } - return this.#askUser( - null, - `Selected parcellation **${parcellation.name}** is not mapped in the current template **${selectedATP.template.name}**. Please select one of the following templates:`, - null, - tmpls.map(tmpl => tmpl.name), - { - actionsAsList: true - } - ).pipe( - map(tmplname => { - const foundTmpl = tmpls.find(tmpl => tmpl.name === tmplname) - if (foundTmpl) { - return ({ requested: { template: foundTmpl }, parcellation }) - } - return null - }) - ) - }) - ) - } - return of(null) - }), - filter(val => { - this.isBusy$.next(false) - return !!val - }) - ).subscribe((obj) => { - if (!isATPGuard(obj)) return - const { atlas, parcellation, template, requested } = obj - if (atlas) { - this.store$.dispatch( - atlasSelection.actions.selectAtlas({ atlas }) - ) - } - if (parcellation) { - this.store$.dispatch( - atlasSelection.actions.selectParcellation({ parcellation, requested }) - ) - } - if (template) { - this.store$.dispatch( - atlasSelection.actions.selectTemplate({ template, requested }) - ) - } - }) + ).subscribe(([{ template, parcellation, atlas }, selectedATP]) => { + + this.store$.dispatch( + atlasSelection.actions.selectATPById({ + templateId: template?.id, + parcellationId: parcellation?.id, + atlasId: atlas?.id, + config: { + autoSelect: !!atlas, + messages: { + parcellation: `Current parcellation **${selectedATP?.parcellation?.name}** is not mapped in the selected template **${template?.name}**. Please select one of the following parcellations:`, + template: `Selected parcellation **${parcellation?.name}** is not mapped in the current template **${selectedATP?.template?.name}**. Please select one of the following templates:`, + } + } + }) + ) + }), ) } diff --git a/src/state/atlasSelection/actions.ts b/src/state/atlasSelection/actions.ts index a4d044ae4..fb9713d76 100644 --- a/src/state/atlasSelection/actions.ts +++ b/src/state/atlasSelection/actions.ts @@ -14,28 +14,6 @@ export const selectAtlas = createAction( }>() ) -export const selectTemplate = createAction( - `${nameSpace} selectTemplate`, - props<{ - template: SxplrTemplate - requested?: { - template?: SxplrTemplate - parcellation?: SxplrParcellation - } - }>() -) - -export const selectParcellation = createAction( - `${nameSpace} selectParcellation`, - props<{ - parcellation: SxplrParcellation - requested?: { - parcellation?: SxplrParcellation - template?: SxplrTemplate - } - }>() -) - /** * setAtlasSelectionState is called as a final step to (potentially) set: * - selectedAtlas @@ -133,6 +111,15 @@ export const selectATPById = createAction( atlasId?: string templateId?: string parcellationId?: string + regionId?: string + + config?: { + autoSelect?: boolean + messages?: { + template?: string + parcellation?: string + } + } }>() ) diff --git a/src/state/atlasSelection/effects.ts b/src/state/atlasSelection/effects.ts index 3960d8624..fdebec2b6 100644 --- a/src/state/atlasSelection/effects.ts +++ b/src/state/atlasSelection/effects.ts @@ -1,16 +1,19 @@ import { Injectable } from "@angular/core"; import { Actions, createEffect, ofType } from "@ngrx/effects"; -import { forkJoin, merge, NEVER, Observable, of } from "rxjs"; -import { catchError, filter, map, mapTo, switchMap, switchMapTo, take, withLatestFrom } from "rxjs/operators"; +import { forkJoin, from, NEVER, Observable, of, throwError } from "rxjs"; +import { catchError, filter, map, mapTo, switchMap, take, withLatestFrom } from "rxjs/operators"; import { SAPI } from "src/atlasComponents/sapi"; import * as mainActions from "../actions" import { select, Store } from "@ngrx/store"; -import { selectors, actions } from '.' +import { selectors, actions, fromRootStore } from '.' import { AtlasSelectionState } from "./const" import { atlasAppearance, atlasSelection } from ".."; import { InterSpaceCoordXformSvc } from "src/atlasComponents/sapi/core/space/interSpaceCoordXform.service"; import { SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; +import { DecisionCollapse } from "src/atlasComponents/sapi/decisionCollapse.service"; +import { DialogFallbackCmp } from "src/ui/dialogInfo"; +import { MatDialog } from "@angular/material/dialog"; type OnTmplParcHookArg = { previous: { @@ -30,11 +33,27 @@ const prefParcId = [ "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290", ] -const prefSpcId = [] - @Injectable() export class Effect { + #askUserATP<T extends SxplrAtlas|SxplrTemplate|SxplrParcellation>(titleMd: string, options: T[]) { + if (options.length === 0) { + return throwError(`Expecting at least one option, but got 0`) + } + if (options.length === 1) { + return of(options[0]) + } + return this.dialog.open(DialogFallbackCmp, { + data: { + titleMd, + actions: options.map(v => v.name), + actionsAsList: true + } + }).afterClosed().pipe( + map(v => options.find(o => o.name === v)) + ) + } + onTemplateParcSelectionPostHook: ((arg: OnTmplParcHookArg) => Observable<Partial<AtlasSelectionState>>)[] = [ /** * This hook gets the region associated with the selected parcellation and template, @@ -91,115 +110,10 @@ export class Effect { } ] - onTemplateParcSelection = createEffect(() => merge( - this.action.pipe( - ofType(actions.selectTemplate), - map(({ template, requested }) => { - return { - template, - parcellation: null as SxplrParcellation, - requested, - } - }) - ), - this.action.pipe( - ofType(actions.selectParcellation), - map(({ parcellation, requested }) => { - return { - template: null as SxplrTemplate, - parcellation, - requested, - } - }) - ) - ).pipe( - withLatestFrom(this.store), - switchMap(([ { template, parcellation, requested }, store ]) => { - - const currTmpl = selectors.selectedTemplate(store) - const currParc = selectors.selectedParcellation(store) - const currAtlas = selectors.selectedAtlas(store) - - const requestedTmpl = requested?.template - const requestedParc = requested?.parcellation - - const resolvedTmpl = template || requestedTmpl || currTmpl - const resolvedParc = parcellation || requestedParc || currParc - - return this.sapiSvc.getSupportedTemplates(currAtlas, resolvedParc).pipe( - switchMap(tmpls => { - const flag = tmpls.some(tmpl => tmpl.id === resolvedTmpl.id) - if (flag) { - return of({ - atlas: currAtlas, - template: resolvedTmpl, - parcellation: resolvedParc, - }) - } - - /** - * TODO code below should not be reached - */ - /** - * if template is defined, find the first parcellation that is supported - */ - if (!!template) { - return this.sapiSvc.getSupportedParcellations(currAtlas, template).pipe( - map(parcs => { - if (parcs.length === 0) { - throw new Error(`Cannot find any supported parcellations for template ${template.name}`) - } - const sortedByPref = parcs.sort((a, b) => prefParcId.indexOf(a.id) - prefParcId.indexOf(b.id)) - const selectParc = sortedByPref.find(p => requestedParc?.id === p.id) || sortedByPref[0] - return { - atlas: currAtlas, - template, - parcellation: selectParc - } - }) - ) - } - if (!!parcellation) { - return this.sapiSvc.getSupportedTemplates(currAtlas, parcellation).pipe( - map(templates => { - if (templates.length === 0) { - throw new Error(`Cannot find any supported templates for parcellation ${parcellation.name}`) - } - const selectTmpl = templates.find(tmp => requestedTmpl?.id === tmp.id || prefSpcId.includes(tmp.id)) || templates[0] - return { - atlas: currAtlas, - template: selectTmpl, - parcellation - } - }) - ) - } - throw new Error(`neither template nor parcellation has been defined!`) - }), - switchMap(({ atlas, template, parcellation }) => - forkJoin( - this.onTemplateParcSelectionPostHook.map(fn => fn({ previous: { atlas: currAtlas, template: currTmpl, parcellation: currParc }, current: { atlas, template, parcellation } })) - ).pipe( - map(partialStates => { - let returnState: Partial<AtlasSelectionState> = { - selectedAtlas: atlas, - selectedTemplate: template, - selectedParcellation: parcellation - } - for (const s of partialStates) { - returnState = { - ...returnState, - ...s, - } - } - return actions.setAtlasSelectionState(returnState) - }) - ) - ) - ) - }) - )) - + /** + * clear template/parc to trigger loading screen + * since getting the map/config etc are not sync + */ onAtlasSelClearTmplParc = createEffect(() => this.action.pipe( ofType(actions.selectAtlas), map(() => actions.setAtlasSelectionState({ @@ -211,46 +125,12 @@ export class Effect { onAtlasSelectionSelectTmplParc = createEffect(() => this.action.pipe( ofType(actions.selectAtlas), filter(action => !!action.atlas), - switchMap(({ atlas }) => - this.sapiSvc.getAllParcellations(atlas).pipe( - map(parcellations => { - const parcPrevIds = parcellations.map(p => p.prevId) - const latestParcs = parcellations.filter(p => !parcPrevIds.includes(p.id)) - const prefParc = parcellations.filter(p => prefParcId.includes(p.id)).sort((a, b) => prefParcId.indexOf(a.id) - prefParcId.indexOf(b.id)) - const selectedParc = prefParc[0] || latestParcs[0] || parcellations[0] - return { - parcellation: selectedParc, - atlas - } - }) - ) - ), - switchMap(({ atlas, parcellation }) => { - return this.sapiSvc.getSupportedTemplates(atlas, parcellation).pipe( - switchMap(spaces => { - const selectedSpace = spaces.find(s => s.name.includes("152")) || spaces[0] - return forkJoin( - this.onTemplateParcSelectionPostHook.map(fn => fn({ previous: null, current: { atlas, parcellation, template: selectedSpace } })) - ).pipe( - map(partialStates => { - - let returnState: Partial<AtlasSelectionState> = { - selectedAtlas: atlas, - selectedTemplate: selectedSpace, - selectedParcellation: parcellation - } - for (const s of partialStates) { - returnState = { - ...returnState, - ...s, - } - } - return actions.setAtlasSelectionState(returnState) - }) - ) - }) - ) - }) + map(({ atlas }) => actions.selectATPById({ + atlasId: atlas.id, + config: { + autoSelect: true, + } + })) )) onATPSelectionClearBaseLayerColorMap = createEffect(() => this.store.pipe( @@ -313,59 +193,139 @@ export class Effect { */ onSelectATPById = createEffect(() => this.action.pipe( ofType(actions.selectATPById), - switchMap(({ atlasId, parcellationId, templateId }) => - this.sapiSvc.atlases$.pipe( - switchMap(atlases => { + switchMap(({ atlasId, parcellationId, templateId, regionId, config }) => { + const { autoSelect, messages } = config || { autoSelect: false, messages: {} } + return from( + Promise.all([ + atlasId && this.collapser.collapseAtlasId(atlasId), + templateId && this.collapser.collapseTemplateId(templateId), + parcellationId && this.collapser.collapseParcId(parcellationId), + ]) + ).pipe( + withLatestFrom(this.store.pipe( + fromRootStore.distinctATP() + )), + switchMap(([requestedPossibleATPs, { atlas, template, parcellation }]) => { + let result = DecisionCollapse.Intersect(...requestedPossibleATPs) + + const errorMessages = DecisionCollapse.Verify(result) + if (errorMessages.length > 0) { + const errMessage = `Cannot process selectATP with parameter ${atlasId}, ${parcellationId}, ${templateId} and ${regionId}. ${errorMessages.join(" ")}` + return throwError(errMessage) + } - const selectedAtlas = atlasId - ? atlases.find(atlas => atlas.id === atlasId) - : atlases[0] + /** + * narrow down the valid atlas, template and parcellation according to the current state + * If intersection is None, then leave the possibility intact + */ + const foundAtlas = atlas && result.atlases.find(a => a.id === atlas.id) + const foundParc = parcellation && result.parcellations.find(a => a.id === parcellation.id) + const foundSpace = template && result.spaces.find(a => a.id === template.id) + + result.atlases = foundAtlas && [foundAtlas] || result.atlases + result.parcellations = foundParc && [foundParc] || result.parcellations + result.spaces = foundSpace && [foundSpace] || result.spaces - if (!selectedAtlas) { - return of( - mainActions.generalActionError({ - message: `Atlas with id ${atlasId} not found!` - }) - ) + /** + * + * with the remaining possible options, ask user to decide on which atlas/parc/space to select + */ + const prAskUser = async () => { + let atlas: SxplrAtlas + let template: SxplrTemplate + let parcellation: SxplrParcellation + + if (result.atlases.length === 1) { + atlas = result.atlases[0] + } + if (result.spaces.length === 1) { + template = result.spaces[0] + } + if (result.parcellations.length === 1) { + parcellation = result.parcellations[0] + } + + if (autoSelect) { + atlas ||= atlas[0] + template ||= result.spaces.find(s => s.name.includes("152")) || result.spaces[0] + + const parcPrevIds = result.parcellations.map(p => p.prevId) + const latestParcs = result.parcellations.filter(p => !parcPrevIds.includes(p.id)) + const prefParc = result.parcellations.filter(p => prefParcId.includes(p.id)).sort((a, b) => prefParcId.indexOf(a.id) - prefParcId.indexOf(b.id)) + + parcellation ||= prefParc[0] || latestParcs[0] || result.parcellations[0] + } + + atlas ||= await this.#askUserATP("Please select an atlas", result.atlases).toPromise() + if (!atlas) return // user cancelled + template ||= await this.#askUserATP(messages.template || "Please select a space", result.spaces).toPromise() + if (!template) return // user cancelled + parcellation ||= await this.#askUserATP(messages.parcellation || "Please select a parcellation", result.parcellations).toPromise() + if (!parcellation) return // user cancelled + + return { + atlas, + template, + parcellation, + } } - return this.sapiSvc.getAllParcellations(selectedAtlas).pipe( - switchMap(parcs => { - const selectedParcellation = parcellationId - ? parcs.find(parc => parc.id === parcellationId) - : parcs[0] - if (!selectedParcellation) { + return from(prAskUser()).pipe( + switchMap(val => { + /** user cancelled */ + if (!val) { + return of(null) + } + const { atlas, parcellation, template } = val + return of({ + atlas, parcellation, template + }) + }), + switchMap(current => { + if (!current) { return of( - mainActions.generalActionError({ - message: `Parcellation with id ${parcellationId} not found!` - }) + mainActions.noop() ) } - return this.sapiSvc.getSupportedTemplates(selectedAtlas, selectedParcellation).pipe( - switchMap(templates => { - const selectedTemplate = templateId - ? templates.find(tmpl => tmpl.id === templateId) - : templates[0] - if (!selectedTemplate) { - return of( - mainActions.generalActionError({ - message: `Template with id ${templateId} not found` - }) - ) + return forkJoin( + this.onTemplateParcSelectionPostHook.map(fn => + fn({previous: { atlas, template, parcellation }, current}) + ) + ).pipe( + map(partialState => { + let state: Partial<AtlasSelectionState> = { + selectedAtlas: current.atlas, + selectedParcellation: current.parcellation, + selectedTemplate: current.template } - return of( - actions.setAtlasSelectionState({ - selectedAtlas, - selectedParcellation, - selectedTemplate - }) - ) + for (const partial of partialState){ + state = { + ...state, + ...partial, + } + } + + if (!!regionId) { + const selectedRegions = (state.selectedParcellationAllRegions || []).filter(r => r.name === regionId) + state.selectedRegions = selectedRegions + } + + + return actions.setAtlasSelectionState(state) }) ) + }), + ) + }), + catchError((err) => { + console.log("error!", err) + return of( + mainActions.generalActionError({ + message: err.toString() }) ) }) ) - ) + }) )) onClearViewerMode = createEffect(() => this.action.pipe( @@ -425,26 +385,6 @@ export class Effect { }) )) - onSelAtlasTmplParcClearRegion = createEffect(() => merge( - this.action.pipe( - ofType(actions.selectAtlas) - ), - this.action.pipe( - ofType(actions.selectTemplate) - ), - this.action.pipe( - ofType(actions.selectParcellation) - ) - ).pipe( - switchMapTo( - of( - actions.setSelectedRegions({ - regions: [] - }) - ) - ) - )) - onRegionSelectionClearPointSelection = createEffect(() => this.action.pipe( ofType(actions.selectRegion), map(() => actions.clearSelectedPoint()) @@ -460,6 +400,8 @@ export class Effect { private sapiSvc: SAPI, private store: Store, private interSpaceCoordXformSvc: InterSpaceCoordXformSvc, + private collapser: DecisionCollapse, + private dialog: MatDialog, ){ } } \ No newline at end of file diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 3fb85a4d6..e6dd65887 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -404,11 +404,8 @@ export class ViewerCmp implements OnDestroy { } if (pointOfInterest) { this.store$.dispatch( - atlasSelection.actions.selectTemplate({ - template, - requested: { - parcellation: this.#parcellationSelected - } + atlasSelection.actions.selectATPById({ + templateId: template.id }) ) this.store$.dispatch( @@ -513,4 +510,13 @@ export class ViewerCmp implements OnDestroy { this.voiFeatureEntryCmp.pullAll() } } + + selectATPR(region: SxplrRegion, parcellation: SxplrParcellation){ + this.store$.dispatch( + atlasSelection.actions.selectATPById({ + parcellationId: parcellation.id, + regionId: region.name + }) + ) + } } diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index ecdd84d7e..486717925 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -729,6 +729,7 @@ [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-region-rich-related-region-clicked)="selectATPR($event.region, $event.parcellation)" (sxplr-sapiviews-core-region-navigate-to)="navigateTo($event)" #regionDirective="sapiViewsCoreRegionRich" > -- GitLab