diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d6858fc25d2c152df0d775c0590b4b7df249a3a7..9203ddec11d5972bd515d88cabe518281765a226 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -74,7 +74,7 @@ jobs: - name: Set output when workflow fails if: failure() - run: echo '::set-output name=failure-state::true' + run: echo "failure-state=true" >> $GITHUB_OUTPUT id: failure-state-step - name: Define screenshot artefact diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab32b4c8b1c72ec36e2357c4aa01792734e10cbd..bf45ae2f90ca8e5f77b188d7ea25a08611187221 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: id: set-version run: | PACKAGEJSON_VER=v$(cat package.json | jq -r '.version') - echo "::set-output name=package-version-from-json::$PACKAGEJSON_VER" + echo "package-version-from-json=$PACKAGEJSON_VER" >> $GITHUB_OUTPUT create-release: needs: check-version diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 1095b2dd6ede917c1a36f2768a39d6198d500716..4fc7d43c724b20c459f30fe30003336909a341a5 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -15,5 +15,5 @@ } </style> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> -<script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.5/dist/connectivity-component/connectivity-component.js" defer></script> +<script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.6/dist/connectivity-component/connectivity-component.js" defer></script> <link rel="stylesheet" href="icons/iav-icons.css"> diff --git a/deploy/csp/index.js b/deploy/csp/index.js index 4905289c335c2a0af1495eed901cc9f66c1aba93..1d67da419baa8f3c60bb7e93893ce5939f9d5ee2 100644 --- a/deploy/csp/index.js +++ b/deploy/csp/index.js @@ -116,7 +116,7 @@ module.exports = { 'https://unpkg.com/mathjax@3.1.2/', // math jax 'https://unpkg.com/three-surfer@0.0.13/dist/bundle.js', // for threeSurfer (freesurfer support in browser) 'https://unpkg.com/ng-layer-tune@0.0.6/dist/ng-layer-tune/', // needed for ng layer control - 'https://unpkg.com/hbp-connectivity-component@0.6.5/', // needed for connectivity component + 'https://unpkg.com/hbp-connectivity-component@0.6.6/', // needed for connectivity component (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, ...SCRIPT_SRC, ...WHITE_LIST_SRC, diff --git a/docs/releases/v2.10.1.md b/docs/releases/v2.10.1.md new file mode 100644 index 0000000000000000000000000000000000000000..43b34eb46b24731a77b07733f201507b58eec49e --- /dev/null +++ b/docs/releases/v2.10.1.md @@ -0,0 +1,12 @@ +# v2.10.1 + +## Bugfix + +- fsaverage on change variant, showing multiple meshes +- fsaverage erroneous URL encoding of selected region +- some annotations no longe rwork properly + +## Behind the scenes + +- Housekeeping CI/CD +- Simplify some behind the scenes code diff --git a/mkdocs.yml b/mkdocs.yml index 930e1107660415acb1e6e21ed42719ef4a11dd28..4bc43ba5039d6018e642df3e329279aba41773c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - Fetching datasets: 'advanced/datasets.md' - Display non-atlas volumes: 'advanced/otherVolumes.md' - Release notes: + - v2.10.1: 'releases/v2.10.1.md' - v2.10.0: 'releases/v2.10.0.md' - v2.9.1: 'releases/v2.9.1.md' - v2.9.0: 'releases/v2.9.0.md' diff --git a/package.json b/package.json index 816cf85477e6fec9ec48d807c098cd7559b25529..2ee728b99bcd28332fe61fd2bd853037bae814ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "siibra-explorer", - "version": "2.10.0", + "version": "2.10.1", "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "lint": "eslint src --ext .ts", @@ -11,7 +11,7 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "test-ci": "ng test --progress false --watch false --browsers=ChromeHeadless", - "sapi-schema": "npx openapi-typescript@6.1.0 https://siibra-api-latest.apps-dev.hbp.eu/v3_0/openapi.json --output ./src/atlasComponents/sapi/schemaV3.ts && eslint ./src/atlasComponents/sapi/schemaV3.ts --no-ignore --fix", + "sapi-schema": "npx openapi-typescript@6.1.0 https://siibra-api-stable.apps-dev.hbp.eu/v3_0/openapi.json --output ./src/atlasComponents/sapi/schemaV3.ts && eslint ./src/atlasComponents/sapi/schemaV3.ts --no-ignore --fix", "api-schema": "node src/plugin/generateTypes.js", "docs:json": "compodoc -p ./tsconfig.json -e json -d .", "storybook": "npm run docs:json && start-storybook -p 6006", diff --git a/src/atlasComponents/sapi/constants.ts b/src/atlasComponents/sapi/constants.ts index c558e1726cbcb629e302f4122dddcd600607ea5d..da2294c69e9db1351e9c7f2d8606e9879abb7e99 100644 --- a/src/atlasComponents/sapi/constants.ts +++ b/src/atlasComponents/sapi/constants.ts @@ -9,6 +9,7 @@ export const IDS = { COLIN27: "minds/core/referencespace/v1.0.0/7f39f7be-445b-47c0-9791-e971c0b6d992", WAXHOLM: "minds/core/referencespace/v1.0.0/d5717c4a-0fa1-46e6-918c-b8003069ade8", MEBRAINS: "minds/core/referencespace/v1.0.0/MEBRAINS", + FSAVERAGE: 'minds/core/referencespace/v1.0.0/tmp-fsaverage' }, PARCELLATION: { JBA29: "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290", diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index 8b9206271330d02c016324e967eea2b2693eeccb..65a12c19b1c460020327b4fbb6346c933e240294 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -22,7 +22,7 @@ export const useViewer = { } as const export const SIIBRA_API_VERSION_HEADER_KEY='x-siibra-api-version' -export const EXPECTED_SIIBRA_API_VERSION = '0.3.0' +export const EXPECTED_SIIBRA_API_VERSION = '0.3.1' let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null diff --git a/src/atlasComponents/sapi/schemaV3.ts b/src/atlasComponents/sapi/schemaV3.ts index 7c926b526224af5d5bc4ccdff711341591591ce3..c6740c619bf9de58fed30778b672d8996cb46ea6 100644 --- a/src/atlasComponents/sapi/schemaV3.ts +++ b/src/atlasComponents/sapi/schemaV3.ts @@ -489,11 +489,11 @@ export interface components { /** @Type */ "@type": string /** Index */ - index: string[] + index: unknown[] /** Dtype */ dtype: string /** Columns */ - columns: string[] + columns: unknown[] /** Ndim */ ndim: number /** Data */ @@ -510,7 +510,7 @@ export interface components { /** Urls */ urls: (components["schemas"]["EbrainsDsUrl"])[] /** Description */ - description: string + description?: string /** Contributors */ contributors: (components["schemas"]["EbrainsDsPerson"])[] /** Ebrains Page */ @@ -722,6 +722,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[FeatureMetaModel] */ Page_FeatureMetaModel_: { @@ -733,6 +735,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[ParcellationEntityVersionModel] */ Page_ParcellationEntityVersionModel_: { @@ -744,6 +748,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraAtlasModel] */ Page_SiibraAtlasModel_: { @@ -755,6 +761,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraCorticalProfileModel] */ Page_SiibraCorticalProfileModel_: { @@ -766,6 +774,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraEbrainsDataFeatureModel] */ Page_SiibraEbrainsDataFeatureModel_: { @@ -777,6 +787,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraParcellationModel] */ Page_SiibraParcellationModel_: { @@ -788,6 +800,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraRegionalConnectivityModel] */ Page_SiibraRegionalConnectivityModel_: { @@ -799,6 +813,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraTabularModel] */ Page_SiibraTabularModel_: { @@ -810,6 +826,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[SiibraVoiModel] */ Page_SiibraVoiModel_: { @@ -821,6 +839,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** Page[Union[SiibraCorticalProfileModel, SiibraReceptorDensityFp, SiibraTabularModel]] */ Page_Union_SiibraCorticalProfileModel__SiibraReceptorDensityFp__SiibraTabularModel__: { @@ -832,6 +852,8 @@ export interface components { page: number /** Size */ size: number + /** Pages */ + pages?: number } /** ParcellationEntityVersionModel */ ParcellationEntityVersionModel: { @@ -985,7 +1007,8 @@ export interface components { SiibraAnchorModel: { /** @Type */ "@type": string - location?: components["schemas"]["LocationModel"] + /** Location */ + location?: components["schemas"]["LocationModel"] | components["schemas"]["CoordinatePointModel"] /** Regions */ regions: (components["schemas"]["SiibraRegionAssignmentQual"])[] } diff --git a/src/atlasComponents/sapi/sxplrTypes.ts b/src/atlasComponents/sapi/sxplrTypes.ts index 0fcf7abfe9067e80de67b0ec59c788d47632c77b..ef556f25eb9c77344aa826d7b6d88e1e478c1227 100644 --- a/src/atlasComponents/sapi/sxplrTypes.ts +++ b/src/atlasComponents/sapi/sxplrTypes.ts @@ -125,8 +125,8 @@ export type CorticalFeature<T extends CorticalDataType, IndexType extends string type TabularDataType = number | string | number[] export type TabularFeature<T extends TabularDataType> = { - index: string[] - columns: string[] + index: unknown[] + columns: unknown[] data?: T[][] } & Feature diff --git a/src/features/connectivity/connectivityBrowser/connectivityBrowser.component.ts b/src/features/connectivity/connectivityBrowser/connectivityBrowser.component.ts index 05ffb3f2c9f303055796d1a14f31653bf222db40..dbc546a7ead3d44685043b5654767b8499debe28 100644 --- a/src/features/connectivity/connectivityBrowser/connectivityBrowser.component.ts +++ b/src/features/connectivity/connectivityBrowser/connectivityBrowser.component.ts @@ -1,387 +1,554 @@ -import {AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, Input, ChangeDetectorRef} from "@angular/core"; -import {select, Store} from "@ngrx/store"; -import {fromEvent, Subscription, BehaviorSubject, Observable} from "rxjs"; -import {catchError, take, switchMap} from "rxjs/operators"; +import { Component, ElementRef, OnDestroy, ViewChild, Input, SimpleChanges, HostListener, OnChanges } from "@angular/core"; +import { Store, select} from "@ngrx/store"; +import { Subscription, BehaviorSubject, combineLatest, merge, concat, NEVER} from "rxjs"; +import { switchMap, map, tap, shareReplay, distinctUntilChanged, withLatestFrom, filter, finalize } from "rxjs/operators"; -import { atlasAppearance } from "src/state"; -import {SAPI} from "src/atlasComponents/sapi/sapi.service"; +import { atlasAppearance, atlasSelection } from "src/state"; +import { SAPI } from "src/atlasComponents/sapi/sapi.service"; import { of } from "rxjs"; -import { HttpClient } from "@angular/common/http"; import { SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; -import { actions, selectors } from "src/state/atlasSelection"; +import { actions } from "src/state/atlasSelection"; import { translateV3Entities } from "src/atlasComponents/sapi/translateV3"; +import { DS } from "src/features/filterCategories.pipe"; +import { FormControl, FormGroup } from "@angular/forms"; +import { PathReturn } from "src/atlasComponents/sapi/typeV3"; +import { arrayEqual } from "src/util/array"; +import { switchMapWaitFor } from "src/util/fn"; + +type PathParam = DS['value'][number] +type ConnFeat = PathReturn<"/feature/RegionalConnectivity/{feature_id}"> @Component({ selector: 'sxplr-features-connectivity-browser', templateUrl: './connectivityBrowser.template.html', styleUrls: ['./connectivityBrowser.style.scss'] }) -export class ConnectivityBrowserComponent implements AfterViewInit, OnDestroy { +export class ConnectivityBrowserComponent implements OnChanges, OnDestroy { + @Input('sxplr-features-connectivity-browser-atlas') + atlas: SxplrAtlas - @Input('sxplr-features-connectivity-browser-atlas') - atlas: SxplrAtlas + @Input('sxplr-features-connectivity-browser-template') + template: SxplrTemplate - @Input('sxplr-features-connectivity-browser-template') - template: SxplrTemplate + @Input('sxplr-features-connectivity-browser-parcellation') + parcellation: SxplrParcellation - @Input('sxplr-features-connectivity-browser-parcellation') - parcellation: SxplrParcellation + parcellation$ = new BehaviorSubject<SxplrParcellation>(null) - /** - * accordion expansion should only toggle the clearviewqueue state - * which should be the single source of truth - * setcolormaps$ is set by the presence/absence of clearviewqueue[CONNECTIVITY_NAME_PLATE] - */ - private _isFirstUpdate = true - @Input() - set accordionExpanded(flag: boolean) { - /** - * ignore first update - */ - if (this._isFirstUpdate) { - this._isFirstUpdate = false - return - } + #accordionExpanded$ = new BehaviorSubject<boolean>(null) + @Input() + set accordionExpanded(flag: boolean) { + this.#accordionExpanded$.next(flag) + } + + region$ = new BehaviorSubject<SxplrRegion>(null) + @Input() + set region(region: SxplrRegion) { + this.region$.next(region) + } - if (this.types.length && !this.selectedType) this.selectType(this._types[0]) + types$ = new BehaviorSubject<PathParam[]>(null) + @Input() + types: PathParam[] - if (flag) { - if (this.selectedSubjectIndex >= 0 && this.allRegions.length) { - this.setCustomLayer() - } else { - this.setCustomLayerOnLoad = true - } - } else { - this.removeCustomLayer() - } + connectivityFilterForm = new FormGroup({ + selectedType: new FormControl<PathParam>(null), + selectedView: new FormControl<'average'|'subject'>('subject'), + selectedCohort: new FormControl<string>(null), + selectedDatasetIndex: new FormControl<number>(0), + selectedSubjectIndex: new FormControl<number>(0), + }) - } - - private _region: SxplrRegion - @Input() - set region(region) { - this._region = region - this.regionName = region.name - } + displayForm = new FormGroup({ + logChecked: new FormControl<boolean>(false) + }) - get region() { - return this._region - } + formValue$ = this.connectivityFilterForm.valueChanges.pipe( + shareReplay(1), + ) - - private _types: any[] = [] - @Input() - set types(val) { - this._types = val.map(t => ({...t, shortName: t.name.split('.').pop()})) - } - get types() { - return this._types - } + private subscriptions: Subscription[] = [] + static LayerId = 'connectivity-colormap-id' - public selectedType: any - public selectedCohort: string - - public cohortDatasets: any[] - - public selectedSubjectIndex = null - public selectedCohortDatasetIndex: any - public selectedCohortSubjects: any - public fetchedItems: any[] = [] - public cohorts: string[] - public selectedView: 'subject' | 'average' | null - public averageDisabled: boolean = true - public subjectsDisabled: boolean = true - - public regionName: string - - public selectedDataset: any - public connectionsString: string - public pureConnections: { [key: string]: number } - public connectedAreas: BehaviorSubject<ConnectedArea[]> = new BehaviorSubject([]) - public noConnectivityForRegion: boolean - private subscriptions: Subscription[] = [] - public allRegions: SxplrRegion[] = [] - private regionIndexInMatrix = -1 - public defaultColorMap: Map<string, Map<number, { red: number, green: number, blue: number }>> - public matrixString: string - public fetchingPreData: boolean - public fetching: boolean - public connectivityLayerId = 'connectivity-colormap-id' - private setCustomLayerOnLoad = false - private customLayerEnabled: boolean - - public logDisabled: boolean = true - public logChecked: boolean = true - - private endpoint: string - - - @ViewChild('connectivityComponent') public connectivityComponentElement: ElementRef<any> - @ViewChild('fullConnectivityGrid') public fullConnectivityGridElement: ElementRef<any> - - constructor( - private store$: Store, - private http: HttpClient, - private changeDetectionRef: ChangeDetectorRef, - protected sapi: SAPI - ) { - SAPI.BsEndpoint$.pipe(take(1)).subscribe(en => this.endpoint = `${en}/feature/RegionalConnectivity`) - } + @ViewChild('connectivityComponent') public connectivityComponentElement: ElementRef<any> + @ViewChild('fullConnectivityGrid') public fullConnectivityGridElement: ElementRef<any> - public ngAfterViewInit(): void { - this.subscriptions.push( - - this.store$.pipe( - select(selectors.selectedParcAllRegions) - ).subscribe(flattenedRegions => { - this.defaultColorMap = null - this.allRegions = flattenedRegions - if (this.setCustomLayerOnLoad) { - this.setCustomLayer() - this.setCustomLayerOnLoad = false - } - }), - ) + constructor( + private store$: Store, + protected sapi: SAPI + ) { - this.subscriptions.push( - fromEvent(this.connectivityComponentElement.nativeElement, 'connectedRegionClicked', {capture: true}) - .subscribe((e: CustomEvent) => { - this.navigateToRegion(e.detail.name) - }), - ) - } - - setCustomLayer() { - if (this.customLayerEnabled) { - this.removeCustomLayer() - } - const map = new Map<SxplrRegion, number[]>() - const areas = this.connectedAreas.value - for (const region of this.allRegions) { - const area = areas.find(a => a.name === region.name) - if (area) { - map.set(region, Object.values(area.color)) - } else { - map.set(region, [255,255,255,0.1]) + this.subscriptions.push( + /** + * on accordion expansion, if nothing is selected, select default (0) type + */ + combineLatest([ + this.#accordionExpanded$, + this.types$, + concat( + of(null as PathParam), + this.formValue$.pipe( + map(v => v.selectedType), + distinctUntilChanged((n, o) => n.name === o.name) + ) + ), + ]).pipe( + ).subscribe(([flag, types, selectedType]) => { + if (flag && !selectedType) { + this.connectivityFilterForm.patchValue({ + selectedType: types[0] + }) } - } - this.customLayerEnabled = true - const customLayer: atlasAppearance.const.CustomLayer = { - clType: 'customlayer/colormap', - id: this.connectivityLayerId, - colormap: map - } - - this.store$.dispatch( - atlasAppearance.actions.addCustomLayer({customLayer}) - ) - } - - removeCustomLayer() { - this.store$.dispatch( - atlasAppearance.actions.removeCustomLayer({ - id: this.connectivityLayerId + }), + /** + * on set log + */ + this.displayForm.valueChanges.pipe( + map(v => v.logChecked), + switchMap(switchMapWaitFor({ + condition: () => !!this.connectivityComponentElement, + leading: true + })) + ).subscribe(flag => { + const el = this.connectivityComponentElement + el.nativeElement.setShowLog(flag) + }), + /** + * on type selection, select first cohort + */ + this.formValue$.pipe( + map(v => v.selectedType), + distinctUntilChanged((n, o) => n.name === o.name), + switchMap(() => + this.cohorts$.pipe( + /** + * it's important to not use distinctUntilChanged + * new corhots emit should always trigger this flow + */ + ) + ) + ).subscribe(cohorts => { + if (cohorts.length > 0) { + this.connectivityFilterForm.patchValue({ + selectedCohort: cohorts[0] + }) + } + }), + /** + * on select cohort + */ + + this.selectedCohort$.pipe( + switchMap(() => this.cohortDatasets$.pipe( + map(dss => dss.length), + distinctUntilChanged(), + filter(length => length > 0), + )) + ).subscribe(() => { + this.connectivityFilterForm.patchValue({ + selectedDatasetIndex: 0, + selectedSubjectIndex: 0, }) - ) - } - - clearCohortSelection() { - this.fetchedItems = [] - this.selectedCohort = null - this.cohorts = [] - this.selectedCohort = null - this.selectedCohortDatasetIndex = null - this.selectedCohortSubjects = null - - this.selectedSubjectIndex = null - } - - selectType(type) { - this.clearCohortSelection() - this.selectedType = type - this.removeCustomLayer() - this.getModality() - } - - - getModality() { - this.fetchingPreData = true - this.fetchModality().subscribe((res: any) => { + }), + /** + * on + * - accordion update + * - colormap change + * - fetching matrix + * remove custom layer + */ + merge( + this.#accordionExpanded$, + this.colormap$, + this.#fetchingMatrix$, + ).subscribe(() => { + this.removeCustomLayer() + }), + /** + * on update colormap, add new custom layer + */ + combineLatest([ + this.#accordionExpanded$, + this.colormap$, + ]).pipe( + withLatestFrom( + this.store$.pipe( + select(atlasSelection.selectors.selectedParcAllRegions) + ) + ) + ).subscribe(([[accordionExpanded, conn], allregions]) => { + if (!accordionExpanded || !conn) { + return + } - this.fetchedItems.push(...res.items) + const map = new Map<SxplrRegion, number[]>() + for (const region of allregions) { + const area = conn.find(a => a.name === region.name) + if (area) { + map.set(region, Object.values(area.color)) + } else { + map.set(region, [255,255,255,0.1]) + } + } - this.cohorts = [...new Set(this.fetchedItems.map(item => item.cohort))] - this.fetchingPreData = false - this.changeDetectionRef.detectChanges() - this.selectCohort(this.cohorts[0]) - + this.store$.dispatch( + atlasAppearance.actions.addCustomLayer({ + customLayer: { + clType: 'customlayer/colormap', + id: ConnectivityBrowserComponent.LayerId, + colormap: map + } + }) + ) + }), + /** + * on pure connection update, update logchecked box + */ + this.#pureConnections$.subscribe(v => { + for (const val of Object.values(v)) { + if (val > 1) { + this.displayForm.get("logChecked").enable() + return + } + } + this.displayForm.get("logChecked").patchValue(false) + this.displayForm.get("logChecked").disable() }) - } - - public fetchModality = (): Observable<any> => { - const url = `${this.endpoint}?parcellation_id=${encodeURIComponent(this.parcellation.id)}&type=${encodeURIComponent(this.selectedType.shortName)}` - return this.http.get(url) - } - - selectCohort(cohort: string) { - this.selectedCohort = cohort - this.averageDisabled = !this.fetchedItems.find(s => s.cohort === this.selectedCohort && !s.subjects.length) - this.subjectsDisabled = !this.fetchedItems.find(s => s.cohort === this.selectedCohort && s.subjects.length > 0) - this.selectedView = !this.averageDisabled? 'average' : 'subject' - - this.cohortDatasets = this.fetchedItems.filter(i => this.selectedCohort === i.cohort) - - this.selectedCohortDatasetChanged(0) - } - - selectedCohortDatasetChanged(index) { - this.selectedCohortDatasetIndex = index - this.selectedCohortSubjects = this.cohortDatasets[index].subjects + ) + } - this.selectedDataset = this.cohortDatasets[index].datasets[0] - - - const keepSubject = this.selectedSubjectIndex >= 0 && this.cohortDatasets[this.selectedCohortDatasetIndex].subjects - .includes(this.selectedCohortSubjects[this.selectedSubjectIndex]) - - this.subjectSliderChanged(keepSubject? this.selectedSubjectIndex : 0) - } - - subjectSliderChanged(index: number) { - this.selectedSubjectIndex = index - this.fetchConnectivity() + public ngOnChanges(changes: SimpleChanges): void { + const { parcellation, types } = changes + if (parcellation) { + this.parcellation$.next(parcellation.currentValue) } - - fetchConnectivity() { - const subject = this.selectedCohortSubjects[this.selectedSubjectIndex] - const dataset = this.cohortDatasets[this.selectedCohortDatasetIndex] - - this.fetching = true - const url = `${this.endpoint}/${dataset.id}?parcellation_id=${this.parcellation.id}&subject=${subject}&type=${this.selectedType.shortName}` - - this.http.get(url).pipe(catchError(() => { - this.fetching = false - return of(null) - })).subscribe(ds => { - this.fetching = false - this.setMatrixData(ds.matrices[subject]) + if (types) [ + this.types$.next(types.currentValue) + ] + } + + removeCustomLayer() { + this.store$.dispatch( + atlasAppearance.actions.removeCustomLayer({ + id: ConnectivityBrowserComponent.LayerId }) - } - - setMatrixData(data) { - this.regionIndexInMatrix = data.columns.findIndex(re => this.region.id === re['@id']) - - if (this.regionIndexInMatrix < 0) { - this.noConnectivityForRegion = true - this.changeDetectionRef.detectChanges() - return - } else if (this.noConnectivityForRegion) { - this.noConnectivityForRegion = false - } - - const regionProfile = data.data[this.regionIndexInMatrix] - - const maxStrength = Math.max(...regionProfile) - - this.logChecked = maxStrength > 1 - this.logDisabled = maxStrength <= 1 - const areas = regionProfile.reduce((p, c, i) => { - return { - ...p, - [data.columns[i].name]: c + ) + } + + busy$ = new BehaviorSubject<boolean>(false) + + #selectedType$ = this.formValue$.pipe( + map(v => v.selectedType), + distinctUntilChanged((o, n) => o?.name === n?.name), + shareReplay(1), + ) + + #connFeatures$ = this.parcellation$.pipe( + switchMap(parc => concat( + of(null as PathParam), + this.#selectedType$, + ).pipe( + switchMap(selectedType => { + if (!selectedType || !parc) { + return of([] as ConnFeat[]) } - }, {}) - this.pureConnections = areas - this.connectionsString = JSON.stringify(areas) - this.connectedAreas.next(this.formatConnections(areas)) - - this.setCustomLayer() - this.matrixString = JSON.stringify(data.columns.map((mc, i) => ([mc.name, ...data.data[i]]))) - this.changeDetectionRef.detectChanges() - - - return data - } - - - changeLog(checked: boolean) { - this.logChecked = checked - this.connectedAreas.next(this.formatConnections(this.pureConnections)) - this.connectivityComponentElement.nativeElement.toggleShowLog() - this.setCustomLayer() - } + const typedName = getType(selectedType.name) + if (!guardType(typedName)) { + throw new Error(`type ${typedName} is not in ${validTypes.join(',')}`) + } + const query = { + parcellation_id: parc.id, + type: typedName + } + this.busy$.next(true) + return this.sapi.v3Get( + "/feature/RegionalConnectivity", + { query } + ).pipe( + switchMap(resp => + this.sapi.iteratePages( + resp, + page => this.sapi.v3Get( + "/feature/RegionalConnectivity", + { query: { ...query, page } } + ) + ) + ), + finalize(() => { + this.busy$.next(false) + }) + ) + }) + )), + ) + + cohorts$ = this.#connFeatures$.pipe( + map(v => { + const cohorts: string[] = [] + for (const item of v) { + if (!cohorts.includes(item.cohort)) { + cohorts.push(item.cohort) + } + } + return cohorts + }) + ) + + selectedCohort$ = this.formValue$.pipe( + map(v => v.selectedCohort), + distinctUntilChanged() + ) + + cohortDatasets$ = combineLatest([ + this.#connFeatures$, + this.formValue$.pipe( + map(v => v.selectedCohort), + distinctUntilChanged() + ), + ]).pipe( + map(([ features, selectedCohort ]) => features.filter(f => f.cohort === selectedCohort)), + distinctUntilChanged( + arrayEqual((o, n) => o?.id === n?.id) + ), + shareReplay(1), + ) + + selectedDataset$ = this.cohortDatasets$.pipe( + switchMap(features => this.formValue$.pipe( + map(v => v.selectedDatasetIndex), + distinctUntilChanged(), + map(dsIdx => features[dsIdx]), + shareReplay(1), + )), + ) + + displaySubject$ = this.selectedDataset$.pipe( + distinctUntilChanged((o, n) => o?.id === n?.id), + map(ds => { + return (idx: number) => ds.subjects[idx] + }) + ) + + selectedDatasetAdditionalInfos$ = this.selectedDataset$.pipe( + map(ds => ds ? ds.datasets : []) + ) + + #fetchingMatrix$ = new BehaviorSubject<boolean>(true) + + #matrixInput$ = combineLatest([ + this.parcellation$, + this.formValue$, + this.cohortDatasets$, + ]).pipe( + map(([ parcellation, form, dss ]) => { + const { + selectedDatasetIndex: dsIdx, + selectedSubjectIndex: subIdx + } = form + const ds = dss[dsIdx] + if (!ds) { + return null + } - navigateToRegion(regionName: string) { - this.sapi.v3Get("/regions/{region_id}", { - path: {region_id: regionName}, + const subject = ds.subjects[subIdx] + if (!subject) { + return null + } + return { + parcellation, + feature_id: ds.id, + subject + } + }), + shareReplay(1), + ) + + #selectedMatrix$ = this.#matrixInput$.pipe( + switchMap(input => { + if (!input) { + return NEVER + } + const { parcellation, feature_id, subject } = input + + this.#fetchingMatrix$.next(true) + return this.sapi.v3Get( + "/feature/RegionalConnectivity/{feature_id}", + { query: { - parcellation_id: this.parcellation.id, - space_id: this.template.id + parcellation_id: parcellation.id, + subject, + }, + path: { + feature_id } - }).pipe( - switchMap(r => translateV3Entities.translateRegion(r)) - ).subscribe(region => { - const centroid = region.centroid?.loc - if (centroid) { - this.store$.dispatch( - actions.navigateTo({ - navigation: { - position: centroid.map(v => v*1e6), - }, - animation: true - }) - ) + } + ) + }), + tap(() => this.#fetchingMatrix$.next(false)), + shareReplay(1), + ) + + #pureConnections$ = this.#matrixInput$.pipe( + filter(v => !!v), + switchMap(({ subject }) => + this.#selectedMatrix$.pipe( + filter(v => !!v.matrices[subject]), + withLatestFrom(this.region$), + map(([ v, region ]) => { + const b = v.matrices[subject] + const foundIdx = b.columns.findIndex(v => v['name'] === region.name) + if (typeof foundIdx !== 'number') { + return null } + const profile = b.data[foundIdx] + if (!profile) { + return null + } + const rObj: Record<string, number> = {} + b.columns.reduce((acc, curr, idx) => { + const rName = curr['name'] as string + acc[rName] = profile[idx] as number + return acc + }, rObj) + return rObj }) - } - - getRegionWithName(region: string) { - return this.allRegions.find(r => r.name === region) - } + ), + ), + ) + + colormap$ = this.#matrixInput$.pipe( + switchMap(() => concat( + of(null as ConnectedArea[]), + combineLatest([ + this.#pureConnections$, + this.displayForm.valueChanges.pipe( + map(v => v.logChecked), + distinctUntilChanged() + ) + ]).pipe( + map(([ conn, flag ]) => processProfile(conn, flag)) + ) + )) + ) + + view$ = combineLatest([ + this.selectedDataset$, + this.formValue$, + this.#fetchingMatrix$, + concat( + of(null as Record<string, number>), + this.#pureConnections$, + ), + this.region$, + ]).pipe( + map(([sDs, form, fetchingMatrix, pureConnections, region]) => { + return { + showSubject: sDs && form.selectedView === "subject", + numSubjects: sDs?.subjects.length, + fetchingMatrix, + connections: pureConnections, + region, + } + }), + shareReplay(1), + ) + + @HostListener('connectedRegionClicked', ['$event']) + onRegionClicked(event: CustomEvent) { + const regionName = event.detail.name as string + this.sapi.v3Get("/regions/{region_id}", { + path: {region_id: regionName}, + query: { + parcellation_id: this.parcellation.id, + space_id: this.template.id + } + }).pipe( + switchMap(r => translateV3Entities.translateRegion(r)) + ).subscribe(region => { + const centroid = region.centroid?.loc + if (centroid) { + this.store$.dispatch( + actions.navigateTo({ + navigation: { + position: centroid.map(v => v*1e6), + }, + animation: true + }) + ) + } + }) + } + + exportConnectivityProfile() { + const a = document.querySelector('hbp-connectivity-matrix-row'); + (a as any).downloadCSV() + } + + public exportFullConnectivity() { + this.fullConnectivityGridElement?.nativeElement['downloadCSV']() + } + + public ngOnDestroy(): void { + this.removeCustomLayer() + this.subscriptions.forEach(s => s.unsubscribe()) + } +} - exportConnectivityProfile() { - const a = document.querySelector('hbp-connectivity-matrix-row'); - (a as any).downloadCSV() - } +function clamp(min: number, max: number) { + return function(val: number) { + return Math.max(min, Math.min(max, val)) + } +} +const clamp01 = clamp(0, 1) +function interpolate255(val: number) { + return Math.round(clamp01(val) * 255) +} +function jet(val: number) { + return { + r: val < 0.7 + ? interpolate255(4 * val - 1.5) + : interpolate255(-4.0 * val + 4.5), + g: val < 0.5 + ? interpolate255(4.0 * val - 0.5) + : interpolate255(-4.0 * val + 3.5), + b: val < 0.3 + ? interpolate255(4.0 * val + 0.5) + : interpolate255(-4.0 * val + 2.5) + } +} - public exportFullConnectivity() { - this.fullConnectivityGridElement?.nativeElement['downloadCSV']() - } +function processProfile(areas: Record<string, number>, logFlag=false): ConnectedArea[] { + const returnValue: Omit<ConnectedArea, "color">[] = [] + for (const areaname in areas) { + returnValue.push({ + name: areaname, + numberOfConnections: areas[areaname], + }) + } + returnValue.sort((a, b) => b.numberOfConnections - a.numberOfConnections) + if (returnValue.length === 0) { + return [] + } + const preprocess = (v: number) => logFlag ? Math.log10(v) : v + return returnValue.map(v => ({ + ...v, + color: jet( + preprocess(v.numberOfConnections) / preprocess(returnValue[0].numberOfConnections) + ) + })) +} - public ngOnDestroy(): void { - this.removeCustomLayer() - this.subscriptions.forEach(s => s.unsubscribe()) - } +function getType(name: string) { + return name.split(".").slice(-1)[0] +} - private formatConnections(areas: { [key: string]: number }) { - const cleanedObj = Object.keys(areas) - .map(key => ({name: key, numberOfConnections: areas[key]})) - .filter(f => f.numberOfConnections > 0) - .sort((a, b) => +b.numberOfConnections - +a.numberOfConnections) - - const logMax = this.logChecked ? Math.log(cleanedObj[0].numberOfConnections) : cleanedObj[0].numberOfConnections - const colorAreas = [] - - cleanedObj.forEach((a) => { - colorAreas.push({ - ...a, - color: { - r: this.colormap_red((this.logChecked ? Math.log(a.numberOfConnections) : a.numberOfConnections) / logMax ), - g: this.colormap_green((this.logChecked ? Math.log(a.numberOfConnections) : a.numberOfConnections) / logMax ), - b: this.colormap_blue((this.logChecked ? Math.log(a.numberOfConnections) : a.numberOfConnections) / logMax ) - }, - }) - }) - return colorAreas - } - private clamp = val => Math.round(Math.max(0, Math.min(1.0, val)) * 255) - private colormap_red = x => x < 0.7? this.clamp(4.0 * x - 1.5) : this.clamp(-4.0 * x + 4.5) - private colormap_green = x => x < 0.5? this.clamp(4.0 * x - 0.5) : this.clamp(-4.0 * x + 3.5) - private colormap_blue = x => x < 0.3? this.clamp(4.0 * x + 0.5) : this.clamp(-4.0 * x + 2.5) +const validTypes = ["FunctionalConnectivity", "StreamlineCounts", "StreamlineLengths"] +function guardType(name: unknown): name is "FunctionalConnectivity" | "StreamlineCounts" | "StreamlineLengths" { + return typeof name === "string" && validTypes.includes(name) } type ConnectedArea = { diff --git a/src/features/connectivity/connectivityBrowser/connectivityBrowser.template.html b/src/features/connectivity/connectivityBrowser/connectivityBrowser.template.html index 87e51584a0f6b4daf50190cf72221eec4ba185d2..a7061fe7a83fedfb63c776e39deb613d1c5008dd 100644 --- a/src/features/connectivity/connectivityBrowser/connectivityBrowser.template.html +++ b/src/features/connectivity/connectivityBrowser/connectivityBrowser.template.html @@ -1,104 +1,142 @@ <div class="w-100 h-100 d-block d-flex flex-column sxplr-pb-2"> - <div> - <div *ngIf="types && types.length" - class="flex-grow-0 flex-shrink-0 d-flex flex-row flex-nowrap d-flex flex-column"> - <mat-form-field class="flex-grow-1 flex-shrink-1 w-100"> - <mat-label> - Modality - </mat-label> - <mat-select [value]="selectedType" (selectionChange)="selectType($event.value)"> - <mat-option *ngFor="let type of types" [value]="type"> - {{ type.shortName }} - </mat-option> - </mat-select> - </mat-form-field> + <form [formGroup]="connectivityFilterForm"> + + <ng-template [ngIf]="types$ | async" let-types> - <mat-form-field *ngIf="!fetchingPreData && selectedType" class="flex-grow-1 flex-shrink-1 w-100"> - <mat-label> - Cohort - </mat-label> - <mat-select [value]="selectedCohort" (selectionChange)="selectCohort($event.value)"> - <mat-option *ngFor="let cohort of cohorts" [value]="cohort"> - {{ cohort }} - </mat-option> - </mat-select> - </mat-form-field> - </div> + <div + class="flex-grow-0 flex-shrink-0 d-flex flex-row flex-nowrap d-flex flex-column"> + <mat-form-field class="flex-grow-1 flex-shrink-1 w-100"> + <mat-label> + Modality + </mat-label> + <mat-select formControlName="selectedType"> + <mat-option *ngFor="let type of types" [value]="type"> + {{ type.display_name }} + </mat-option> + </mat-select> + </mat-form-field> - <mat-radio-group *ngIf="selectedCohort" [(ngModel)]="selectedView"> - <mat-radio-button value="average" class="m-2" [disabled]="averageDisabled" color="primary"> - Average - </mat-radio-button> - <mat-radio-button value="subject" class="m-2" [disabled]="subjectsDisabled" color="primary"> - Subject - </mat-radio-button> - </mat-radio-group> - - <div *ngIf="cohortDatasets && cohortDatasets.length > 1" - class="flex-grow-0 flex-shrink-0 d-flex flex-nowrap align-items-center"> - <div class="flex-grow-1 flex-shrink-1 w-100"> - <mat-label> - Dataset - </mat-label> - <mat-slider [min]="0" [max]="cohortDatasets.length - 1" (change)="selectedCohortDatasetChanged($event.value)" - [value]="selectedCohortDatasetIndex" thumbLabel step="1" class="w-100"> - </mat-slider> + <mat-form-field *ngIf="!(busy$ | async) && (formValue$ | async | getProperty : 'selectedType')" class="flex-grow-1 flex-shrink-1 w-100"> + <mat-label> + Cohort + </mat-label> + <mat-select formControlName="selectedCohort"> + <mat-option *ngFor="let cohort of cohorts$ | async" + [value]="cohort"> + {{ cohort }} + </mat-option> + </mat-select> + </mat-form-field> </div> - </div> + </ng-template> + + <ng-template [ngIf]="formValue$ | async | getProperty : 'selectedCohort'"> + <mat-radio-group formControlName="selectedView"> + <mat-radio-button value="average" class="m-2" color="primary"> + Average + </mat-radio-button> + <mat-radio-button value="subject" class="m-2" color="primary"> + Subject + </mat-radio-button> + </mat-radio-group> + </ng-template> + + <ng-template [ngIf]="cohortDatasets$ | async" let-cohortDatasets> + <ng-template [ngIf]="cohortDatasets.length > 1"> + <div class="flex-grow-0 flex-shrink-0 d-flex flex-nowrap align-items-center"> + <div class="flex-grow-1 flex-shrink-1 w-100"> + <mat-label> + Dataset + </mat-label> + <mat-slider [min]="0" + [max]="cohortDatasets.length - 1" + step="1" + formControlName="selectedDatasetIndex" + thumbLabel + class="w-100"> + </mat-slider> + </div> + </div> + </ng-template> + </ng-template> - <div *ngIf="selectedCohortDatasetIndex >= 0 && selectedCohortSubjects" + <div *ngIf="view$ | async | getProperty : 'showSubject'" class="flex-grow-0 flex-shrink-0 d-flex flex-nowrap align-items-center"> <div class="flex-grow-1 flex-shrink-1 w-100"> <mat-label> Subject </mat-label> - <mat-slider [min]="0" [max]="selectedCohortSubjects.length - 1" - (change)="subjectSliderChanged($event.value)" [value]="selectedSubjectIndex" - thumbLabel step="1" class="w-100"> + <mat-slider [min]="0" + [max]="(view$ | async | getProperty : 'numSubjects') - 1" + thumbLabel + [displayWith]="displaySubject$ | async" + step="1" + formControlName="selectedSubjectIndex" + class="w-100"> </mat-slider> </div> </div> - </div> - <div class="d-flex justify-content-center"> - <mat-spinner *ngIf="fetching"></mat-spinner> - </div> + </form> - <div *ngIf="regionName && !fetching" - [style.visibility]="selectedCohort && (selectedSubjectIndex >= 0 || !averageDisabled)? 'visible' : 'hidden'" - class="d-flex align-items-center"> - <mat-checkbox class="mr-2" [checked]="logChecked" (change)="changeLog($event.checked)" - [disabled]="logDisabled || noConnectivityForRegion">Log 10</mat-checkbox> - <button mat-button [matMenuTriggerFor]="exportMenu" [disabled]="!connectedAreas.value"> - <i class="fas fa-download mr-2"></i> - <span>Export</span> - </button> - <button *ngIf="selectedDataset" iav-stop="mousedown click" class="icons" mat-icon-button sxplr-dialog - [sxplr-dialog-size]="null" [sxplr-dialog-data]="{ - title: selectedDataset?.name, - descMd: selectedDataset?.description + '' + (selectedDataset?.authors && selectedDataset?.authors.join()), - actions: [selectedDataset.ebrains_page] - }"> - <i class="fas fa-info"></i> - </button> - </div> + <ng-template [ngIf]="view$ | async | getProperty : 'region'" let-region> + + <!-- loading spinner --> + <ng-template [ngIf]="view$ | async | getProperty : 'fetchingMatrix'" + [ngIfElse]="profileTmpl"> + <div class="d-flex justify-content-center" id = 'blabla'> + <mat-spinner></mat-spinner> + </div> + </ng-template> - <hbp-connectivity-matrix-row #connectivityComponent - [style.visibility]="regionName && !fetching && !noConnectivityForRegion && selectedCohort - && (selectedSubjectIndex >= 0 || !averageDisabled)? 'visible' : 'hidden'" - [region]="regionName" - [connections]="connectionsString" - show-export="true" hide-export-view="true" theme="dark"> - </hbp-connectivity-matrix-row> - <div *ngIf="noConnectivityForRegion">No connectivity for the region.</div> + <!-- profile --> + <!-- <pre>{{ view$ | async | json }}</pre> --> + <ng-template #profileTmpl> + <ng-template [ngIf]="view$ | async | getProperty : 'connections'" let-conn> + <div class="d-flex align-items-center"> + <form [formGroup]="displayForm"> + <mat-checkbox + class="mr-2" + formControlName="logChecked"> + Log 10 + </mat-checkbox> + </form> + <button mat-button [matMenuTriggerFor]="exportMenu"> + <i class="fas fa-download mr-2"></i> + <span>Export</span> + </button> + <ng-template ngFor [ngForOf]="selectedDatasetAdditionalInfos$ | async" let-info> + <button class="icons" + mat-icon-button + sxplr-dialog + [sxplr-dialog-size]="null" + [sxplr-dialog-data]="{ + title: info?.name, + descMd: info?.description, + actions: [info.ebrains_page] + }"> + <i class="fas fa-info"></i> + </button> + </ng-template> + </div> + + <hbp-connectivity-matrix-row #connectivityComponent + [region]="region.name" + [connections]="conn | json" + show-export="true" + hide-export-view="true" + theme="dark"> + </hbp-connectivity-matrix-row> + </ng-template> + </ng-template> + </ng-template> +</div> - <full-connectivity-grid #fullConnectivityGrid [matrix]="matrixString" [datasetName]="selectedDataset?.name" - [datasetDescription]="selectedDataset?.description" only-export="true"> - </full-connectivity-grid> - <mat-menu #exportMenu="matMenu"> - <button mat-menu-item [disabled]="noConnectivityForRegion" - (click)="exportConnectivityProfile()">Regional</button> - <button mat-menu-item (click)="exportFullConnectivity()">Dataset</button> - </mat-menu> -</div> \ No newline at end of file +<mat-menu #exportMenu="matMenu"> + <button mat-menu-item + (click)="exportConnectivityProfile()"> + Regional + </button> + <button mat-menu-item (click)="exportFullConnectivity()">Dataset</button> +</mat-menu> diff --git a/src/features/connectivity/module.ts b/src/features/connectivity/module.ts index 97a7793ff41ee74247aa5de2a506d12763828dea..221ad467e3b1e8b9402ab9d9f2e5a1a8ada90b07 100644 --- a/src/features/connectivity/module.ts +++ b/src/features/connectivity/module.ts @@ -1,19 +1,31 @@ import { CommonModule } from "@angular/common"; import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from "@angular/core"; import { SAPI } from "src/atlasComponents/sapi"; - -import {AngularMaterialModule} from "src/sharedModules"; -import {FormsModule} from "@angular/forms"; -import { DialogModule } from "src/ui/dialogInfo"; - import { ConnectivityBrowserComponent } from "./connectivityBrowser/connectivityBrowser.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { DialogModule } from "src/ui/dialogInfo"; +import { MatSelectModule } from "@angular/material/select"; +import { MatRadioModule } from "@angular/material/radio"; +import { MatSliderModule } from "@angular/material/slider"; +import { MatMenuModule } from "@angular/material/menu"; +import { UtilModule } from "src/util"; +import { MatCheckboxModule } from "@angular/material/checkbox"; +import { MatButtonModule } from "@angular/material/button"; +import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; @NgModule({ imports: [ CommonModule, - FormsModule, - AngularMaterialModule, - DialogModule + ReactiveFormsModule, + MatSelectModule, + MatRadioModule, + MatSliderModule, + MatMenuModule, + DialogModule, + UtilModule, + MatCheckboxModule, + MatButtonModule, + MatProgressSpinnerModule, ], declarations: [ ConnectivityBrowserComponent, diff --git a/src/features/feature-view/feature-view.component.ts b/src/features/feature-view/feature-view.component.ts index 400644f14c73fe60a0292c13ceb41916b96280c4..3d9f1cdc9e68f8804c728a71f1a378db5a259331 100644 --- a/src/features/feature-view/feature-view.component.ts +++ b/src/features/feature-view/feature-view.component.ts @@ -43,7 +43,7 @@ export class FeatureViewComponent implements OnChanges { voi$ = new BehaviorSubject<VoiFeature>(null) columns$: Observable<string[]> = this.tabular$.pipe( map(data => data - ? ['index', ...data.columns] + ? ['index', ...data.columns] as string[] : []), ) @@ -52,7 +52,7 @@ export class FeatureViewComponent implements OnChanges { map(v => { return v.index.map((receptor, idx) => ({ receptor: { - label: receptor + label: receptor as string }, density: { mean: v.data[idx][0] as number, diff --git a/src/features/filterCategories.pipe.ts b/src/features/filterCategories.pipe.ts index 8f103328732218fd6a28717eedfc6eaf87baaa30..8521c7454e6589e123e6aaa92efd77bdbdadb704 100644 --- a/src/features/filterCategories.pipe.ts +++ b/src/features/filterCategories.pipe.ts @@ -2,7 +2,7 @@ import { KeyValue } from "@angular/common" import { Pipe, PipeTransform } from "@angular/core" import { PathReturn } from "src/atlasComponents/sapi/typeV3" -type DS = KeyValue<string, PathReturn<"/feature/_types">["items"]> +export type DS = KeyValue<string, PathReturn<"/feature/_types">["items"]> @Pipe({ name: 'filterCategory', diff --git a/src/features/transform-pd-to-ds.pipe.ts b/src/features/transform-pd-to-ds.pipe.ts index 09f598d9d6ceea05a41c7599d5ea90a45b32b5b0..c0cc94e292ffa751b4f0d979e58d05ade128ce3e 100644 --- a/src/features/transform-pd-to-ds.pipe.ts +++ b/src/features/transform-pd-to-ds.pipe.ts @@ -2,6 +2,14 @@ import { CdkTableDataSourceInput } from '@angular/cdk/table'; import { Pipe, PipeTransform } from '@angular/core'; import { TabularFeature } from 'src/atlasComponents/sapi/sxplrTypes'; +function typeGuard(input: unknown): input is string | number | number[]{ + return typeof input === "string" || typeof input === "number" || (Array.isArray(input) && input.every(v => typeof v === "number")) +} + +function isString(input: unknown): input is string { + return typeof input === "string" +} + @Pipe({ name: 'transformPdToDs', pure: true @@ -10,11 +18,19 @@ export class TransformPdToDsPipe implements PipeTransform { transform(pd: TabularFeature<string|number|number[]>): CdkTableDataSourceInput<unknown> { return pd.data.map((arr, idx) => { + const val = pd.index[idx] + if (!typeGuard(val)) { + throw new Error(`Expected val to be of type string, number or number[], but was none.`) + } const returnVal: Record<string, string|number|number[]> = { - index: pd.index[idx], + index: val, } arr.forEach((val, colIdx) => { - returnVal[pd.columns[colIdx]] = val + const key = pd.columns[colIdx] + if (!isString(key)) { + 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/index.html b/src/index.html index 8b04e68c01a64a496496ff53735756fd2166b36d..0f009f4f2d5ab33219754015cfc136a5c8758899 100644 --- a/src/index.html +++ b/src/index.html @@ -15,7 +15,7 @@ <script src="https://unpkg.com/kg-dataset-previewer@1.2.0/dist/kg-dataset-previewer/kg-dataset-previewer.js" defer></script> <script src="https://unpkg.com/three-surfer@0.0.13/dist/bundle.js" defer></script> <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.6/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> - <script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.5/dist/connectivity-component/connectivity-component.js" ></script> + <script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.6/dist/connectivity-component/connectivity-component.js" ></script> <script defer src="https://unpkg.com/mathjax@3.1.2/es5/tex-svg.js"></script> <script defer src="https://unpkg.com/d3@6.2.0/dist/d3.min.js"></script> <title>Siibra Explorer</title> diff --git a/src/routerModule/routeStateTransform.service.ts b/src/routerModule/routeStateTransform.service.ts index bbc9c3ab99f3cb27af71e07c49ceb4aa3ca225b4..1032448ec67ea1c9bf80d3f18f82f901bf7c6057 100644 --- a/src/routerModule/routeStateTransform.service.ts +++ b/src/routerModule/routeStateTransform.service.ts @@ -77,7 +77,6 @@ export class RouteStateTransformSvc { } const regionMap = new Map<string, SxplrRegion>(allParcellationRegions.map(region => [region.name, region])) - const ngIdToRegionMap: Map<string, Map<number, SxplrRegion[]>> = new Map() const [ ngMap, threeMap ] = await Promise.all([ this.sapi.getTranslatedLabelledNgMap(selectedParcellation, selectedTemplate), diff --git a/src/state/annotations/selectors.ts b/src/state/annotations/selectors.ts index 5504a6bb1fb7ab03a4053a365c24d55290a50036..9de6923bec414ecf52d1f1d1d5cf8b374b4348ca 100644 --- a/src/state/annotations/selectors.ts +++ b/src/state/annotations/selectors.ts @@ -14,7 +14,7 @@ export const spaceFilteredAnnotations = createSelector( selectStore, atlasSelectionSelectors.selectStore, (annState, atlasSelState) => annState.annotations.filter(ann => { - const spaceId = atlasSelState.selectedTemplate['@id'] + const spaceId = atlasSelState.selectedTemplate.id if (ann['openminds']) { return (ann as Annotation<'openminds'>).openminds.coordinateSpace['@id'] === spaceId } diff --git a/src/state/atlasSelection/effects.spec.ts b/src/state/atlasSelection/effects.spec.ts index a1a820f677b1449d1ce4a3ebaa1a5d44e2e89c51..603c1c4ec5b13f3dea3c66ad13e8002dfedd2823 100644 --- a/src/state/atlasSelection/effects.spec.ts +++ b/src/state/atlasSelection/effects.spec.ts @@ -12,14 +12,67 @@ import { Effect } from "./effects" import * as mainActions from "../actions" import { atlasSelection } from ".." import { BrowserAnimationsModule } from "@angular/platform-browser/animations" +import { translateV3Entities } from "src/atlasComponents/sapi/translateV3" +import { PathReturn } from "src/atlasComponents/sapi/typeV3" describe("> effects.ts", () => { describe("> Effect", () => { let actions$ = new Observable<Action>() - let hoc1left: SxplrRegion - let hoc1leftCentroid: SxplrRegion - let hoc1leftCentroidWrongSpc: SxplrRegion + + let simpleHoc1: SxplrRegion = { + name: 'foo', + id: '', + type: "SxplrRegion", + parentIds: [], + } + + let hoc1LeftMni152: PathReturn<"/regions/{region_id}"> = { + "@id": "", + versionIdentifier: '', + "@type": '', + hasAnnotation: { + criteriaQualityType: {}, + internalIdentifier: "", + bestViewPoint: { + coordinateSpace: { + "@id": IDS.TEMPLATES.MNI152 + } as any, + coordinates: [ + { + value: 1, + },{ + value: 2, + },{ + value: 3, + } + ] + } + } + } + let hoc1LeftColin27: PathReturn<"/regions/{region_id}"> = { + "@id": "", + versionIdentifier: '', + "@type": '', + hasAnnotation: { + criteriaQualityType: {}, + internalIdentifier: "", + bestViewPoint: { + coordinateSpace: { + "@id": IDS.TEMPLATES.COLIN27 + } as any, + coordinates: [ + { + value: 1, + },{ + value: 2, + },{ + value: 3, + } + ] + } + } + } beforeEach(async () => { TestBed.configureTestingModule({ @@ -34,26 +87,6 @@ describe("> effects.ts", () => { provideMockActions(() => actions$) ] }) - - /** - * only need to populate hoc1 left once - */ - if (!hoc1left) { - - const sapisvc = TestBed.inject(SAPI) - const regions = await sapisvc.getParcRegions(IDS.PARCELLATION.JBA29).toPromise() - hoc1left = regions.find(r => /hoc1/i.test(r.name) && /left/i.test(r.name)) - if (!hoc1left) throw new Error(`cannot find hoc1 left`) - hoc1leftCentroid = JSON.parse(JSON.stringify(hoc1left)) - hoc1leftCentroid.centroid = { - space: { - id: IDS.TEMPLATES.BIG_BRAIN - } as SxplrTemplate, - loc: [1, 2, 3] - } - hoc1leftCentroidWrongSpc = JSON.parse(JSON.stringify(hoc1leftCentroid)) - hoc1leftCentroidWrongSpc.centroid.space.id = IDS.TEMPLATES.COLIN27 - } }) it('> can be init', () => { @@ -229,10 +262,39 @@ describe("> effects.ts", () => { }) describe('> onNavigateToRegion', () => { + + const translatedRegion = { + "@id": "", + versionIdentifier: '', + "@type": '', + hasAnnotation: { + criteriaQualityType: {}, + internalIdentifier: "", + bestViewPoint: { + coordinateSpace: { + "@id": IDS.TEMPLATES.MNI152 + } as any, + coordinates: [ + { + value: 1, + },{ + value: 2, + },{ + value: 3, + } + ] + } + } + } as PathReturn<"/regions/{region_id}"> + + let retrieveRegionSpy: jasmine.Spy + beforeEach(async () => { + retrieveRegionSpy = spyOn(translateV3Entities, 'retrieveRegion') + actions$ = hot('a', { a: actions.navigateToRegion({ - region: hoc1left + region: simpleHoc1 }) }) const mockStore = TestBed.inject(MockStore) @@ -264,6 +326,7 @@ describe("> effects.ts", () => { beforeEach(() => { const mockStore = TestBed.inject(MockStore) mockStore.overrideSelector(atpSelector, null) + retrieveRegionSpy.and. }) it('> returns general error', () => { @@ -302,16 +365,7 @@ describe("> effects.ts", () => { }) describe('> if inputs are fine', () => { - let regionGetDetailSpy: jasmine.Spy = jasmine.createSpy() - beforeEach(() => { - const sapi = TestBed.inject(SAPI) - regionGetDetailSpy.and.returnValue( - of(hoc1leftCentroid) - ) - }) - afterEach(() => { - if (regionGetDetailSpy) regionGetDetailSpy.calls.reset() - }) + it('> getRegionDetailSpy is called, and calls navigateTo', () => { const eff = TestBed.inject(Effect) expect(eff.onNavigateToRegion).toBeObservable( @@ -330,9 +384,6 @@ describe("> effects.ts", () => { describe('> returns null', () => { beforeEach(() => { - regionGetDetailSpy.and.returnValue( - of(null) - ) }) it('> generalactionerror', () => { @@ -348,9 +399,7 @@ describe("> effects.ts", () => { }) describe('> general throw', () => { beforeEach(() => { - regionGetDetailSpy.and.returnValue( - throwError(`oh noes`) - ) + }) it('> generalactionerror', () => { @@ -358,7 +407,7 @@ describe("> effects.ts", () => { expect(eff.onNavigateToRegion).toBeObservable( hot(`a`, { a: mainActions.generalActionError({ - message: `Error getting region centroid` + message: `getting region detail error! cannot get coordinates` }) }) ) @@ -368,9 +417,7 @@ describe("> effects.ts", () => { describe('> does not contain props attr', () => { beforeEach(() => { - regionGetDetailSpy.and.returnValue( - of(hoc1left) - ) + }) it('> generalactionerror', () => { diff --git a/src/state/atlasSelection/effects.ts b/src/state/atlasSelection/effects.ts index 673a54d0fdfc6fefa2fce02bf5571bb6ee2492d8..601ea9f8edddebcb88ffab46159b4dfd5f660e2b 100644 --- a/src/state/atlasSelection/effects.ts +++ b/src/state/atlasSelection/effects.ts @@ -1,7 +1,7 @@ import { Injectable } from "@angular/core"; import { Actions, createEffect, ofType } from "@ngrx/effects"; import { forkJoin, merge, NEVER, Observable, of } from "rxjs"; -import { filter, map, mapTo, switchMap, switchMapTo, take, withLatestFrom } from "rxjs/operators"; +import { catchError, filter, map, mapTo, switchMap, switchMapTo, take, withLatestFrom } from "rxjs/operators"; import { SAPI, SAPIRegion } from "src/atlasComponents/sapi"; import * as mainActions from "../actions" import { select, Store } from "@ngrx/store"; @@ -346,27 +346,35 @@ export class Effect { select(selectors.selectedParcellation) ) ), - map(([{ region: _region }, selectedTemplate, selectedAtlas, selectedParcellation]) => { + switchMap(([{ region: _region }, selectedTemplate, selectedAtlas, selectedParcellation]) => { if (!selectedAtlas || !selectedTemplate || !selectedParcellation || !_region) { - return mainActions.generalActionError({ - message: `atlas, template, parcellation or region not set` - }) + return of( + mainActions.generalActionError({ + message: `atlas, template, parcellation or region not set` + }) + ) } - - const region = translateV3Entities.retrieveRegion(_region) - - if (region.hasAnnotation?.bestViewPoint && region.hasAnnotation.bestViewPoint.coordinateSpace['@id'] === selectedTemplate["@id"]) { - return actions.navigateTo({ + return this.sapiSvc.v3Get("/regions/{region_id}", { + path: { + region_id: _region.name + }, + query: { + parcellation_id: selectedParcellation.id, + space_id: selectedTemplate.id + } + }).pipe( + map(reg => actions.navigateTo({ animation: true, navigation: { - position: region.hasAnnotation.bestViewPoint.coordinates.map(v => v.value * 1e6) + position: reg.hasAnnotation.bestViewPoint.coordinates.map(v => v.value * 1e6) } - }) - } - - return mainActions.generalActionError({ - message: `getting region detail error! cannot get coordinates` - }) + })), + catchError(() => of( + mainActions.generalActionError({ + message: `getting region detail error! cannot get coordinates` + }) + )), + ) }) )) diff --git a/src/viewerModule/nehuba/config.service/util.ts b/src/viewerModule/nehuba/config.service/util.ts index fa76f139af7aed4ffb7b21b97e87f7c39caf4633..ddc82c6482332f12aeb7adef4013e51bb442217f 100644 --- a/src/viewerModule/nehuba/config.service/util.ts +++ b/src/viewerModule/nehuba/config.service/util.ts @@ -174,7 +174,7 @@ export function getParcNgId(atlas: SxplrAtlas, tmpl: SxplrTemplate, parc: SxplrP : null } - if (parc.id === IDS.PARCELLATION.JBA30) { + if (parc.id === IDS.PARCELLATION.JBA30 && tmpl.id !== IDS.TEMPLATES.FSAVERAGE) { return `_${MultiDimMap.GetKey(atlas.id, tmpl.id, parc.id, "whole brain")}` } diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.spec.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.spec.ts index 45a5aee2d56784a90c6d75c72d4d1b0e3ab2cd83..e29f976cfbf97399cc3592d480fbd49841cd8081 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.spec.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.spec.ts @@ -10,12 +10,17 @@ import { import { LayerCtrlEffects } from "./layerCtrl.effects" import { NEVER } from "rxjs" import { RouterService } from "src/routerModule/router.service" +import { HttpClientModule } from "@angular/common/http" +import { BaseService } from "../base.service/base.service" describe('> layerctrl.service.ts', () => { describe('> NehubaLayerControlService', () => { let mockStore: MockStore beforeEach(() => { TestBed.configureTestingModule({ + imports:[ + HttpClientModule, + ], providers: [ { provide: RouterService, @@ -30,6 +35,13 @@ describe('> layerctrl.service.ts', () => { useValue: { onATPDebounceNgLayers$: NEVER } + }, + { + provide: BaseService, + useValue: { + selectedATPR$: NEVER, + completeNgIdLabelRegionMap$: NEVER, + } } ] }) diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts index 971f599b69ece635aab52fc9290c3ae4b954703d..f68170be4a59e2610000d997b852084512529dc4 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts @@ -32,8 +32,6 @@ export class NehubaLayerControlService implements OnDestroy{ private defaultNgLayers$ = this.layerEffects.onATPDebounceNgLayers$ - private selectedATP$ = this.baseService.selectedATP$ - public selectedATPR$ = this.baseService.selectedATPR$ private customLayers$ = this.store$.pipe( diff --git a/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts b/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts index 0b7d702369e87a97b70b8bfa1057a83e274d08f1..1cc0bdc7fb1d52813ff0ea1fa58a6ff9bdfadbf0 100644 --- a/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts +++ b/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts @@ -9,6 +9,8 @@ import { LayerCtrlEffects } from "../layerCtrl.service/layerCtrl.effects" import { NEVER, of, pipe } from "rxjs" import { mapTo, take } from "rxjs/operators" import { selectorAuxMeshes } from "../store" +import { HttpClientModule } from "@angular/common/http" +import { BaseService } from "../base.service/base.service" const fits1 = {} as SxplrRegion @@ -59,6 +61,9 @@ describe('> mesh.service.ts', () => { describe('> NehubaMeshService', () => { beforeEach(() => { TestBed.configureTestingModule({ + imports: [ + HttpClientModule, + ], providers: [ provideMockStore(), NehubaMeshService, @@ -67,6 +72,12 @@ describe('> mesh.service.ts', () => { useValue: { onATPDebounceNgLayers$: NEVER } + }, + { + provide: BaseService, + useValue: { + completeNgIdLabelRegionMap$: NEVER + } } ] }) @@ -151,11 +162,18 @@ describe('> mesh.service.ts', () => { * in the case of julich brain 2.9 in colin 27, we expect selecting a region will hide meshes from all relevant ngIds (both left and right) */ it('> expect the emitted value to be incl all ngIds', () => { + const bService = TestBed.inject(BaseService) + bService.completeNgIdLabelRegionMap$ = of({ + [ngId1]: {}, + [ngId2]: { + [labelIndex2]: fits1 + } + }) const service = TestBed.inject(NehubaMeshService) expect( service.loadMeshes$ ).toBeObservable( - hot('(ab)', { + hot('abc', { a: { layer: { name: ngId1 @@ -167,6 +185,12 @@ describe('> mesh.service.ts', () => { name: ngId2 }, labelIndicies: [ labelIndex2 ] + }, + c: { + layer: { + name: auxMesh.ngId + }, + labelIndicies: auxMesh.labelIndicies } }) ) diff --git a/src/viewerModule/nehuba/store/store.ts b/src/viewerModule/nehuba/store/store.ts index 0dd7043f3ac0da58ad43d7211fcce3983d6b85d2..33f4275ebab27bc960ced88c92265794d494f4dc 100644 --- a/src/viewerModule/nehuba/store/store.ts +++ b/src/viewerModule/nehuba/store/store.ts @@ -20,7 +20,6 @@ const defaultState: INehubaFeature = { panelMode: EnumPanelMode.FOUR_PANEL, panelOrder: '0123', octantRemoval: true, - clearViewQueue: {}, auxMeshes: [] } diff --git a/src/viewerModule/nehuba/store/type.ts b/src/viewerModule/nehuba/store/type.ts index a55be8ced700fb825deb317904573577a0c8c7fb..7908af13aaee35fdd228773f51bc50739a9fba49 100644 --- a/src/viewerModule/nehuba/store/type.ts +++ b/src/viewerModule/nehuba/store/type.ts @@ -16,8 +16,5 @@ export interface INehubaFeature { panelMode: string panelOrder: string octantRemoval: boolean - clearViewQueue: { - [key: string]: boolean - } auxMeshes: IAuxMesh[] } diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index 7d6634342b3daf28b948e97a7d6fcca9d6b9310f..e4e729b1cfcf6817d00afd57681e2bbc9c817e9c 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -1,13 +1,13 @@ import { Component, Output, EventEmitter, ElementRef, OnDestroy, AfterViewInit, Inject, Optional, ChangeDetectionStrategy } from "@angular/core"; import { EnumViewerEvt, IViewer, TViewerEvent } from "src/viewerModule/viewer.interface"; -import { combineLatest, from, merge, NEVER, Observable, Subject } from "rxjs"; -import { catchError, debounceTime, distinctUntilChanged, filter, map, scan, shareReplay, switchMap } from "rxjs/operators"; +import { combineLatest, concat, forkJoin, from, merge, NEVER, Observable, of, Subject } from "rxjs"; +import { catchError, debounceTime, distinctUntilChanged, filter, map, scan, shareReplay, switchMap, withLatestFrom } from "rxjs/operators"; import { ComponentStore } from "src/viewerModule/componentStore"; import { select, Store } from "@ngrx/store"; import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { MatSnackBar } from "@angular/material/snack-bar"; import { CONST } from 'common/constants' -import { getUuid } from "src/util/fn"; +import { getUuid, switchMapWaitFor } from "src/util/fn"; import { AUTO_ROTATE, TInteralStatePayload, ViewerInternalStateSvc } from "src/viewerModule/viewerInternalState.service"; import { atlasAppearance, atlasSelection } from "src/state"; import { ThreeSurferCustomLabelLayer, ThreeSurferCustomLayer, ColorMapCustomLayer } from "src/state/atlasAppearance/const"; @@ -38,6 +38,48 @@ type THandlingCustomEv = { } } +type TLatVtxIdxRecord = LateralityRecord<{ + indexLayer: ThreeSurferCustomLabelLayer + vertexIndices: number[] +}> + +type TLatMeshRecord = LateralityRecord<{ + meshLayer: ThreeSurferCustomLayer + mesh: TThreeGeometry +}> + +type MeshVisOp = 'toggle' | 'noop' + +type TApplyColorArg = LateralityRecord<{ + labelIndices: number[] + idxReg: Record<number, SxplrRegion> + isBaseCm: boolean + showDelin: boolean + selectedRegions: SxplrRegion[] + mesh: TThreeGeometry + vertexIndices: number[] + map?: Map<number, number[]> +}> + +type THandleCustomMouseEv = { + latMeshRecord: TLatMeshRecord + latLblIdxRecord: TLatVtxIdxRecord + evDetail: any + latLblIdxReg: TLatIdxReg + meshVisibility: { + label: string + visible: boolean + mesh: TThreeGeometry + }[] +} + +type TLatIdxReg = LateralityRecord<Record<number, SxplrRegion>> + +type TLatCm = LateralityRecord<{ + labelIndices: number[] + map: Map<number, number[]> +}> + type TCameraOrientation = { perspectiveOrientation: number[] perspectiveZoom: number @@ -92,6 +134,8 @@ function cameraNavsAreSimilar(c1: TCameraOrientation, c2: TCameraOrientation){ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit, OnDestroy { + #cameraEv$ = new Subject<{ position: { x: number, y: number, z: number }, zoom: number }>() + #mouseEv$ = new Subject() @Output() viewerEvent = new EventEmitter<TViewerEvent<'threeSurfer'>>() @@ -100,44 +144,106 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit private mainStoreCameraNav: TCameraOrientation = null private localCameraNav: TCameraOrientation = null - public lateralityMeshRecord: LateralityRecord<{ - visible: boolean - meshLayer: ThreeSurferCustomLayer - mesh: TThreeGeometry - }> = {} - public latLblIdxRecord: LateralityRecord<{ - indexLayer: ThreeSurferCustomLabelLayer - labelIndices: number[] - }> = {} private internalStateNext: (arg: TInteralStatePayload<TInternalState>) => void private mouseoverRegions: SxplrRegion[] = [] - - private selectedRegions$ = this.store$.pipe( - select(atlasSelection.selectors.selectedRegions) - ) private customLayers$ = this.store$.pipe( select(atlasAppearance.selectors.customLayers), distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)), shareReplay(1) ) - public meshLayers$: Observable<ThreeSurferCustomLayer[]> = this.customLayers$.pipe( + #meshLayers$: Observable<ThreeSurferCustomLayer[]> = this.customLayers$.pipe( map(layers => layers.filter(l => l.clType === "baselayer/threesurfer") as ThreeSurferCustomLayer[]), distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)), ) + #lateralMeshRecord$ = new Subject<TLatMeshRecord>() + lateralMeshRecord$ = concat( + of({} as TLatMeshRecord), + this.#lateralMeshRecord$.asObservable() + ) + + #meshVisOp$ = new Subject<{ op: MeshVisOp, label?: string }>() + meshVisible$ = this.lateralMeshRecord$.pipe( + map(v => { + const returnVal: { + label: string + visible: boolean + mesh: TThreeGeometry + }[] = [] + for (const lat in v) { + returnVal.push({ + visible: true, + mesh: v[lat].mesh, + label: lat + }) + } + return returnVal + }), + switchMap(arr => concat( + of({ op: 'noop', label: null }), + this.#meshVisOp$ + ).pipe( + map(({ op, label }) => arr.map(v => { + if (label !== v.label) { + return v + } + if (op === "toggle") { + v.visible = !v.visible + } + return v + })) + )) + ) + private vertexIndexLayers$: Observable<ThreeSurferCustomLabelLayer[]> = this.customLayers$.pipe( map(layers => layers.filter(l => l.clType === "baselayer/threesurfer-label") as ThreeSurferCustomLabelLayer[]), distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)), ) + #latVtxIdxRecord$: Observable<TLatVtxIdxRecord> = this.vertexIndexLayers$.pipe( + switchMap( + switchMapWaitFor({ + condition: () => !!this.tsRef, + leading: true + }) + ), + switchMap(layers => + forkJoin( + layers.map(layer => + from( + this.tsRef.loadColormap(layer.source) + ).pipe( + map(giiInstance => { + let vertexIndices: number[] = giiInstance[0].getData() + if (giiInstance[0].attributes.DataType === 'NIFTI_TYPE_INT16') { + vertexIndices = (window as any).ThreeSurfer.GiftiBase.castF32UInt16(vertexIndices) + } + return { + indexLayer: layer, + vertexIndices + } + }) + ) + ) + ) + ), + map(layers => { + const returnObj = {} + for (const { indexLayer, vertexIndices } of layers) { + returnObj[indexLayer.laterality] = { indexLayer, vertexIndices } + } + return returnObj + }) + ) + /** * maps laterality to label index to sapi region */ - private latLblIdxToRegionRecord: LateralityRecord<Record<number, SxplrRegion>> = {} - private latLblIdxToRegionRecord$: Observable<LateralityRecord<Record<number, SxplrRegion>>> = combineLatest([ + + #latLblIdxToRegionRecord$: Observable<TLatIdxReg> = combineLatest([ this.store$.pipe( atlasSelection.fromRootStore.distinctATP() ), @@ -183,15 +289,43 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit * colormap in use (both base & custom) */ - private colormapInUse: ColorMapCustomLayer - private colormaps$: Observable<ColorMapCustomLayer[]> = this.customLayers$.pipe( + #colormaps$: Observable<ColorMapCustomLayer[]> = this.customLayers$.pipe( map(layers => layers.filter(l => l.clType === "baselayer/colormap" || l.clType === "customlayer/colormap") as ColorMapCustomLayer[]), + distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)) + ) + + #latLblIdxToCm$ = combineLatest([ + this.#latLblIdxToRegionRecord$, + this.#colormaps$ + ]).pipe( + map(([ latIdxReg, cms ]) => { + const cm = cms[0] + const returnValue: TLatCm = {} + for (const lat in latIdxReg) { + returnValue[lat] = { + labelIndices: [], + map: new Map() + } + for (const lblIdx in latIdxReg[lat]) { + returnValue[lat].labelIndices.push(Number(lblIdx)) + const reg = latIdxReg[lat][lblIdx] + returnValue[lat].map.set( + Number(lblIdx), (cm.colormap.get(reg) || [255, 255, 255]).map(v => v/255) + ) + } + } + return returnValue + }) ) /** - * show delination map + * when do we need to call apply color? + * - when mesh loads + * - when vertex index layer changes + * - selected region changes + * - custom color map added (by plugin, etc) + * - show delineation updates */ - private showDelineation: boolean = true public threeSurferSurfaceVariants$ = this.effect.onATPDebounceThreeSurferLayers$.pipe( map(({ surfaces }) => surfaces.reduce((acc, val) => acc.includes(val.variant) ? acc : [...acc, val.variant] ,[] as string[])) @@ -275,7 +409,7 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit /** * subscribe to camera custom event */ - const cameraSub = this.cameraEv$.pipe( + const cameraSub = this.#cameraEv$.pipe( filter(v => !!v), debounceTime(160) ).subscribe(() => { @@ -378,7 +512,6 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit } private tsRef: TThreeSurfer - private selectedRegions: SxplrRegion[] = [] private relayStoreLock: () => void = null private tsRefInitCb: ((tsRef: any) => void)[] = [] @@ -390,47 +523,67 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit this.tsRefInitCb.push(callback) } - private async loadMeshes(layers: ThreeSurferCustomLayer[]) { + async #loadMeshes(layers: ThreeSurferCustomLayer[], currMeshRecord: TLatMeshRecord) { if (!this.tsRef) throw new Error(`loadMeshes error: this.tsRef is not defined!!`) - + const copiedCurrMeshRecord: TLatMeshRecord = {...currMeshRecord} /** * remove the layers... */ for (const layer of layers) { - if (!!this.lateralityMeshRecord[layer.laterality]) { - this.tsRef.unloadMesh(this.lateralityMeshRecord[layer.laterality].mesh) + if (!!copiedCurrMeshRecord[layer.laterality]) { + this.tsRef.unloadMesh(copiedCurrMeshRecord[layer.laterality].mesh) } } for (const layer of layers) { const threeMesh = await this.tsRef.loadMesh(layer.source) - this.lateralityMeshRecord[layer.laterality] = { - visible: true, + copiedCurrMeshRecord[layer.laterality] = { meshLayer: layer, mesh: threeMesh } } - this.applyColor() + this.#lateralMeshRecord$.next(copiedCurrMeshRecord) } - private async loadVertexIndexMap(layers: ThreeSurferCustomLabelLayer[]) { - if (!this.tsRef) throw new Error(`loadVertexIndexMap error: this.tsRef is not defined!!`) - for (const layer of layers) { - const giiInstance = await this.tsRef.loadColormap(layer.source) - - let labelIndices: number[] = giiInstance[0].getData() - if (giiInstance[0].attributes.DataType === 'NIFTI_TYPE_INT16') { - labelIndices = (window as any).ThreeSurfer.GiftiBase.castF32UInt16(labelIndices) - } - this.latLblIdxRecord[layer.laterality] = { - indexLayer: layer, - labelIndices + #applyColor$ = combineLatest([ + combineLatest([ + this.lateralMeshRecord$, + this.store$.pipe( + select(atlasSelection.selectors.selectedRegions), + distinctUntilChanged(arrayEqual((o, n) => o.name === n.name)) + ), + this.#colormaps$.pipe( + map(cms => cms[0]), + distinctUntilChanged((o, n) => o?.id === n?.id) + ), + this.store$.pipe( + select(atlasAppearance.selectors.showDelineation), + distinctUntilChanged() + ), + this.#latLblIdxToCm$, + this.#latLblIdxToRegionRecord$, + ]), + this.#latVtxIdxRecord$ + ]).pipe( + debounceTime(16), + map(([[ latMeshDict, selReg, cm, showDelFlag, latLblIdxToCm, latLblIdxToRegionRecord ], latVtxIdx]) => { + const arg: TApplyColorArg = {} + for (const lat in latMeshDict) { + arg[lat] = { + mesh: latMeshDict[lat].mesh, + selectedRegions: selReg, + showDelin: showDelFlag, + isBaseCm: cm.clType === "baselayer/colormap", + labelIndices: latLblIdxToCm[lat].labelIndices, + idxReg: latLblIdxToRegionRecord[lat], + map: latLblIdxToCm[lat].map, + vertexIndices: latVtxIdx[lat].vertexIndices + } } - } - this.applyColor() - } - - private applyColor() { + return arg + }) + ) + private applyColor(applyArg: TApplyColorArg) { /** * on apply color map, reset mesh visibility * this issue is more difficult to solve than first anticiplated. @@ -440,41 +593,81 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit * 2/ hide hemisphere, select region, unhide hemisphere * 3/ select region, hide hemisphere, deselect region */ - if (!this.colormapInUse) return + if (!this.tsRef) return - const isBaseCM = this.colormapInUse?.clType === "baselayer/colormap" - - for (const laterality in this.lateralityMeshRecord) { - const { mesh } = this.lateralityMeshRecord[laterality] - if (!this.latLblIdxRecord[laterality]) continue - const { labelIndices } = this.latLblIdxRecord[laterality] + for (const laterality in applyArg) { + const { labelIndices, map, mesh, showDelin, selectedRegions, isBaseCm, idxReg, vertexIndices } = applyArg[laterality] - const lblIdxToRegionRecord = this.latLblIdxToRegionRecord[laterality] - if (!lblIdxToRegionRecord) { - this.tsRef.applyColorMap(mesh, labelIndices) + if (!map) { + this.tsRef.applyColorMap(mesh, vertexIndices) continue } - const map = new Map<number, number[]>() - for (const lblIdx in lblIdxToRegionRecord) { - const region = lblIdxToRegionRecord[lblIdx] - let color: number[] - if (!this.showDelineation) { - color = [1,1,1] - } else if (isBaseCM && this.selectedRegions.length > 0 && !this.selectedRegions.includes(region)) { - color = [1,1,1] - } else { - color = (this.colormapInUse.colormap.get(region) || [255, 255, 255]).map(v => v/255) + + const actualApplyMap = new Map<number, number[]>() + + if (!showDelin) { + for (const lblIdx of labelIndices){ + actualApplyMap.set(lblIdx, [1, 1, 1]) + } + this.tsRef.applyColorMap(mesh, vertexIndices, { + custom: actualApplyMap + }) + return + } + + const highlightIdx = new Set<number>() + if (isBaseCm && selectedRegions.length > 0) { + for (const [idx, region] of Object.entries(idxReg)) { + if (selectedRegions.findIndex(r => r.name === region.name) >= 0) { + highlightIdx.add(Number(idx)) + } + } + } + if (isBaseCm && selectedRegions.length > 0) { + for (const lblIdx of labelIndices) { + actualApplyMap.set( + Number(lblIdx), + highlightIdx.has(lblIdx) + ? map.get(lblIdx) || [1, 0.8, 0.8] + : [1, 1, 1] + ) + } + } else { + for (const lblIdx of labelIndices) { + actualApplyMap.set( + Number(lblIdx), + map.get(lblIdx) || [1, 0.8, 0.8] + ) } - map.set(Number(lblIdx), color) } - this.tsRef.applyColorMap(mesh, labelIndices, { - custom: map + this.tsRef.applyColorMap(mesh, vertexIndices, { + custom: actualApplyMap }) } } - private handleCustomMouseEv(detail: any){ + #handleCustomMouseEv$ = this.#mouseEv$.pipe( + withLatestFrom( + this.lateralMeshRecord$, + this.#latLblIdxToRegionRecord$, + this.meshVisible$, + this.#latVtxIdxRecord$, + ) + ).pipe( + map(([ evDetail, latMeshRecord, latLblIdxReg, meshVis, latVtxIdx ]) => { + const returnVal: THandleCustomMouseEv = { + evDetail, + meshVisibility: meshVis, + latLblIdxReg: latLblIdxReg, + latMeshRecord: latMeshRecord, + latLblIdxRecord: latVtxIdx + } + return returnVal + }) + ) + #handleCustomMouseEv(arg: THandleCustomMouseEv){ + const { evDetail: detail, latMeshRecord, latLblIdxRecord, latLblIdxReg, meshVisibility } = arg const evMesh = detail.mesh && { faceIndex: detail.mesh.faceIndex, // typo in three-surfer @@ -495,25 +688,26 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit verticesIdicies: evVerticesIndicies, } = detail.mesh as { geometry: TThreeGeometry, verticesIdicies: number[] } - for (const laterality in this.lateralityMeshRecord) { - const meshRecord = this.lateralityMeshRecord[laterality] + for (const laterality in latMeshRecord) { + const meshRecord = latMeshRecord[laterality] if (meshRecord.mesh !== evGeometry) { continue } /** * if either labelindex record or colormap record is undefined for this laterality, emit empty event */ - if (!this.latLblIdxRecord[laterality] || !this.latLblIdxToRegionRecord[laterality]) { + if (!latLblIdxRecord[laterality] || !latLblIdxReg[laterality]) { return this.handleMouseoverEvent(custEv) } - const labelIndexRecord = this.latLblIdxRecord[laterality] - const regionRecord = this.latLblIdxToRegionRecord[laterality] + const labelIndexRecord = latLblIdxRecord[laterality] + const regionRecord = latLblIdxReg[laterality] /** * check if the mesh is toggled off * if so, do not proceed */ - if (!meshRecord.visible) { + const mVis = meshVisibility.filter(({ mesh }) => mesh === meshRecord.mesh) + if (!mVis.every(m => m.visible)) { return } @@ -522,7 +716,7 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit */ const labelIndexSet = new Set<number>() for (const idx of evVerticesIndicies){ - const labelOfInterest = labelIndexRecord.labelIndices[idx] + const labelOfInterest = labelIndexRecord.vertexIndices[idx] if (!labelOfInterest) { continue } @@ -551,31 +745,28 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit } } - private cameraEv$ = new Subject<{ position: { x: number, y: number, z: number }, zoom: number }>() - private handleCustomCameraEvent(detail: any){ - if (this.internalStateNext) { - this.internalStateNext({ - "@id": getUuid(), - "@type": 'TViewerInternalStateEmitterEvent', - viewerType, - payload: { - mode: '', - camera: detail.position, - hemisphere: 'both' - } - }) - } - this.cameraEv$.next(detail) - } - ngAfterViewInit(): void{ const customEvHandler = (ev: CustomEvent) => { const { type, data } = ev.detail if (type === 'mouseover') { - return this.handleCustomMouseEv(data) + this.#mouseEv$.next(data) + return } if (type === 'camera') { - return this.handleCustomCameraEvent(data) + if (this.internalStateNext) { + this.internalStateNext({ + "@id": getUuid(), + "@type": 'TViewerInternalStateEmitterEvent', + viewerType, + payload: { + mode: '', + camera: data.position, + hemisphere: 'both' + } + }) + } + this.#cameraEv$.next(data) + return } } this.domEl.addEventListener((window as any).ThreeSurfer.CUSTOM_EVENTNAME_UPDATED, customEvHandler) @@ -596,40 +787,46 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit tsCb(this.tsRef) } - const meshSub = this.meshLayers$.pipe( - distinctUntilChanged(), + const meshSub = this.#meshLayers$.pipe( + switchMap( + switchMapWaitFor({ + condition: () => !!this.tsRef, + leading: true + }) + ), debounceTime(16), - ).subscribe(layers => { - this.loadMeshes(layers) + withLatestFrom( + this.lateralMeshRecord$ + ) + ).subscribe(([layers, currMeshRecord]) => { + this.#loadMeshes(layers, currMeshRecord) }) - const vertexIdxSub = this.vertexIndexLayers$.subscribe(layers => this.loadVertexIndexMap(layers)) - const roiSelectedSub = this.selectedRegions$.subscribe(regions => { - this.selectedRegions = regions - this.applyColor() + + const applyColorSub = this.#applyColor$.subscribe(arg => { + this.applyColor(arg) }) - const colormapSub = this.colormaps$.subscribe(cm => { - this.colormapInUse = cm[0] || null - this.applyColor() + + const mouseSub = this.#handleCustomMouseEv$.subscribe(arg => { + this.#handleCustomMouseEv(arg) }) - const recordToRegionSub = this.latLblIdxToRegionRecord$.subscribe(val => this.latLblIdxToRegionRecord = val) - const hideDelineationSub = this.store$.pipe( - select(atlasAppearance.selectors.showDelineation) - ).subscribe(flag => { - this.showDelineation = flag - this.applyColor() - /** - * apply color resets mesh visibility - */ - this.updateMeshVisibility() + + const visibilitySub = this.meshVisible$.subscribe(arr => { + for (const { visible, mesh } of arr) { + mesh.visible = visible + + const meshObj = this.tsRef.customColormap.get(mesh) + if (!meshObj) { + throw new Error(`mesh obj not found!`) + } + meshObj.mesh.visible = visible + } }) this.onDestroyCb.push(() => { meshSub.unsubscribe() - vertexIdxSub.unsubscribe() - roiSelectedSub.unsubscribe() - colormapSub.unsubscribe() - recordToRegionSub.unsubscribe() - hideDelineationSub.unsubscribe() + applyColorSub.unsubscribe() + mouseSub.unsubscribe() + visibilitySub.unsubscribe() }) this.viewerEvent.emit({ @@ -666,20 +863,11 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit if (this.mouseoverText === '') this.mouseoverText = null } - public updateMeshVisibility(): void{ - - for (const key in this.lateralityMeshRecord) { - - const latMeshRecord = this.lateralityMeshRecord[key] - if (!latMeshRecord) { - return - } - const meshObj = this.tsRef.customColormap.get(latMeshRecord.mesh) - if (!meshObj) { - throw new Error(`mesh obj not found!`) - } - meshObj.mesh.visible = latMeshRecord.visible - } + public toggleMeshVis(label: string) { + this.#meshVisOp$.next({ + label, + op: 'toggle' + }) } switchSurfaceLayer(variant: string): void{ diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html index fcef001a1ff56073a379a11d82573f85b109f069..778626e2c267217735a3664b82e5644e6a14e1fc 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.template.html @@ -19,12 +19,12 @@ <mat-menu #fsModeSelMenu="matMenu"> <div class="sxplr-custom-cmp text sxplr-pl-2 m-2"> - <mat-checkbox *ngFor="let item of lateralityMeshRecord | keyvalue" + <mat-checkbox *ngFor="let item of meshVisible$ | async " class="d-block" iav-stop="click" - (change)="updateMeshVisibility()" - [(ngModel)]="item.value.visible"> - {{ item.key }} + (change)="toggleMeshVis(item.label)" + [checked]="item.visible"> + {{ item.label }} </mat-checkbox> </div> <mat-divider></mat-divider>