diff --git a/angular.json b/angular.json index 3964720d4694e01f3a452109d5e6e53d2445a850..af94adeb567bf3ab3406beeba94e8191e7e97c11 100644 --- a/angular.json +++ b/angular.json @@ -10,7 +10,7 @@ "projectType": "application", "schematics": { "@schematics/angular:component": { - "style": "sass" + "style": "scss" }, "@schematics/angular:application": { "strict": true @@ -18,7 +18,7 @@ }, "root": "", "sourceRoot": "src", - "prefix": "app", + "prefix": "sxplr", "architect": { "build": { "builder": "@angular-devkit/build-angular:browser", diff --git a/src/atlasComponents/sapi/core/base.ts b/src/atlasComponents/sapi/core/base.ts index 9783082a284974c593798f1e05cd11cf0d17b0e0..2bfaa27d605cb05633277602e9fdf5dc870c8bc5 100644 --- a/src/atlasComponents/sapi/core/base.ts +++ b/src/atlasComponents/sapi/core/base.ts @@ -4,13 +4,12 @@ import { RouteParam } from "../typeV3" import { SapiQueryPriorityArg } from "../sxplrTypes" const AllFeatures = { - // Feature: "Feature", CorticalProfile: "CorticalProfile", // EbrainsDataFeature: "EbrainsDataFeature", RegionalConnectivity: "RegionalConnectivity", Tabular: "Tabular", - GeneExpressions: "GeneExpressions", - VolumeOfInterest: "VolumeOfInterest", + // GeneExpressions: "GeneExpressions", + Image: "Image", } as const type AF = keyof typeof AllFeatures diff --git a/src/atlasComponents/sapi/core/sapiSpace.ts b/src/atlasComponents/sapi/core/sapiSpace.ts index e1826a6d97d116f23a46af1378c344d1aa56d5d0..c6e8741eaf2be6adf9b1a5e9af540c7ec33a0a1c 100644 --- a/src/atlasComponents/sapi/core/sapiSpace.ts +++ b/src/atlasComponents/sapi/core/sapiSpace.ts @@ -8,7 +8,7 @@ import { SAPIBase } from "./base" * All valid parcellation features */ const SpaceFeatures = { - VolumeOfInterest: "VolumeOfInterest", + Image: "Image", } as const export type SF = keyof typeof SpaceFeatures diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index 924fce5ce1c7181ac2b7e5fdaf54b00a05aacb5d..fb51ddd60f159234296d393e7f0bf65fdc209d5a 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -12,7 +12,7 @@ import { PRIORITY_HEADER } from "src/util/priority"; import { forkJoin, from, NEVER, Observable, of, throwError } from "rxjs"; import { SAPIFeature } from "./features"; import { environment } from "src/environments/environment" -import { PathReturn, RouteParam, SapiRoute } from "./typeV3"; +import { FeatureType, PathReturn, RouteParam, SapiRoute } from "./typeV3"; import { BoundingBox, SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate, VoiFeature, SapiQueryPriorityArg } from "./sxplrTypes"; @@ -173,6 +173,43 @@ export class SAPI{ } }) } + + #isPaged<T>(resp: any): resp is PaginatedResponse<T>{ + if (!!resp.total) return true + return false + } + getV3Features<T extends FeatureType>(featureType: T, sapiParam: RouteParam<`/feature/${T}`>): Observable<PathReturn<`/feature/${T}/{feature_id}`>[]> { + const query = structuredClone(sapiParam) + return this.v3Get<`/feature/${T}`>(`/feature/${featureType}`, { + ...query + }).pipe( + switchMap(resp => { + if (!this.#isPaged(resp)) return throwError(`endpoint not returning paginated response`) + return this.iteratePages( + resp, + page => { + const query = structuredClone(sapiParam) + query.query.page = page + return this.v3Get(`/feature/${featureType}`, { + ...query, + }).pipe( + map(val => { + if (this.#isPaged(val)) return val + return { items: [], total: 0, page: 0, size: 0 } + }) + ) + } + ) + }), + catchError(() => of([])) + ) + } + + getV3FeatureDetail<T extends FeatureType>(featureType: T, sapiParam: RouteParam<`/feature/${T}/{feature_id}`>): Observable<PathReturn<`/feature/${T}/{feature_id}`>> { + return this.v3Get<`/feature/${T}/{feature_id}`>(`/feature/${featureType}/{feature_id}`, { + ...sapiParam + }) + } getFeature(featureId: string, opts: Record<string, string> = {}) { return new SAPIFeature(this, featureId, opts) @@ -257,7 +294,6 @@ export class SAPI{ }), shareReplay(1), ) - public getAllSpaces(atlas: SxplrAtlas): Observable<SxplrTemplate[]> { return forkJoin( @@ -391,7 +427,7 @@ export class SAPI{ /** * FIXME iterate over all pages */ - return this.v3Get("/feature/VolumeOfInterest", { + return this.v3Get("/feature/Image", { query: { space_id: bbox.space.id, bbox: JSON.stringify([bbox.minpoint, bbox.maxpoint]), diff --git a/src/atlasComponents/sapi/schemaV3.ts b/src/atlasComponents/sapi/schemaV3.ts index 7147fd8c7a5b5c5511895b02e296518108ac20d1..98036ee8f5e613ec49583d5d79cce51f1f35db6d 100644 --- a/src/atlasComponents/sapi/schemaV3.ts +++ b/src/atlasComponents/sapi/schemaV3.ts @@ -42,11 +42,23 @@ export interface paths { get: operations["route_get_map_map_get"] } "/map/labelled_map.nii.gz": { - /** Route Get Parcellation Labelled Map */ + /** + * Route Get Parcellation Labelled Map + * @description Returns a labelled map if region_id is not provided. + * + * Returns a mask if a region_id is provided. + * + * region_id MAY refer to ANY region on the region hierarchy, and a combined mask will be returned. + */ get: operations["route_get_parcellation_labelled_map_map_labelled_map_nii_gz_get"] } "/map/statistical_map.nii.gz": { - /** Route Get Region Statistical Map */ + /** + * Route Get Region Statistical Map + * @description Returns a statistic map. + * + * region_id MUST refer to leaf region on the region hierarchy. + */ get: operations["route_get_region_statistical_map_map_statistical_map_nii_gz_get"] } "/map/statistical_map.info.json": { @@ -81,13 +93,13 @@ export interface paths { /** Get Single Tabular */ get: operations["get_single_tabular_feature_Tabular__feature_id__get"] } - "/feature/VolumeOfInterest": { + "/feature/Image": { /** Get All Voi */ - get: operations["get_all_voi_feature_VolumeOfInterest_get"] + get: operations["get_all_voi_feature_Image_get"] } - "/feature/VolumeOfInterest/{feature_id}": { + "/feature/Image/{feature_id}": { /** Get Single Voi */ - get: operations["get_single_voi_feature_VolumeOfInterest__feature_id__get"] + get: operations["get_single_voi_feature_Image__feature_id__get"] } "/feature/GeneExpressions": { /** Get All Gene */ @@ -97,6 +109,16 @@ export interface paths { /** Get All Gene */ get: operations["get_all_gene_feature_GeneExpressions__feature_id__get"] } + "/feature/{feature_id}": { + /** + * Get Single Feature + * @description This endpoint allows detail of a single feature to be fetched, without the necessary context. However, the tradeoff for this endpoint is: + * + * - the endpoint typing is the union of all possible return types + * - the client needs to supply any necessary query param (e.g. subject for regional connectivity, gene for gene expression etc) + */ + get: operations["get_single_feature_feature__feature_id__get"] + } } export type webhooks = Record<string, never>; @@ -341,9 +363,9 @@ export interface components { axesOrigin: (components["schemas"]["AxesOrigin"])[] /** * defaultImage - * @description Two or three dimensional image that particluarly represents a specific coordinate space. + * @description Two or three dimensional image that particluarly represents a specific coordinate space. Overriden by Siibra API to use as VolumeModel */ - defaultImage?: components["schemas"]["VolumeModel"][] + defaultImage?: (components["schemas"]["VolumeModel"])[] /** * digitalIdentifier * @description Digital handle to identify objects or legal persons. @@ -505,6 +527,19 @@ export interface components { /** Url */ url: string } + /** FeatureMetaModel */ + FeatureMetaModel: { + /** Name */ + name: string + /** Path */ + path?: string + /** Query Params */ + query_params?: (string)[] + /** Path Params */ + path_params?: (string)[] + /** Category */ + category?: string + } /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -662,8 +697,17 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number + } + /** Page[FeatureMetaModel] */ + Page_FeatureMetaModel_: { + /** Items */ + items: (components["schemas"]["FeatureMetaModel"])[] + /** Total */ + total: number + /** Page */ + page: number + /** Size */ + size: number } /** Page[ParcellationEntityVersionModel] */ Page_ParcellationEntityVersionModel_: { @@ -675,8 +719,6 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number } /** Page[SiibraAtlasModel] */ Page_SiibraAtlasModel_: { @@ -688,8 +730,6 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number } /** Page[SiibraCorticalProfileModel] */ Page_SiibraCorticalProfileModel_: { @@ -701,8 +741,6 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number } /** Page[SiibraParcellationModel] */ Page_SiibraParcellationModel_: { @@ -714,8 +752,6 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number } /** Page[SiibraRegionalConnectivityModel] */ Page_SiibraRegionalConnectivityModel_: { @@ -727,8 +763,6 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number } /** Page[SiibraTabularModel] */ Page_SiibraTabularModel_: { @@ -740,8 +774,6 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number } /** Page[SiibraVoiModel] */ Page_SiibraVoiModel_: { @@ -753,34 +785,17 @@ export interface components { page: number /** Size */ size: number - /** Pages */ - pages?: number - } - /** Page[Union[SiibraCorticalProfileModel, SiibraTabularModel, SiibraReceptorDensityFp]] */ - Page_Union_SiibraCorticalProfileModel__SiibraTabularModel__SiibraReceptorDensityFp__: { - /** Items */ - items: (components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraTabularModel"] | components["schemas"]["SiibraReceptorDensityFp"])[] - /** Total */ - total: number - /** Page */ - page: number - /** Size */ - size: number - /** Pages */ - pages?: number } - /** Page[str] */ - Page_str_: { + /** Page[Union[SiibraCorticalProfileModel, SiibraReceptorDensityFp, SiibraTabularModel]] */ + Page_Union_SiibraCorticalProfileModel__SiibraReceptorDensityFp__SiibraTabularModel__: { /** Items */ - items: (string)[] + items: (components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraReceptorDensityFp"] | components["schemas"]["SiibraTabularModel"])[] /** Total */ total: number /** Page */ page: number /** Size */ size: number - /** Pages */ - pages?: number } /** ParcellationEntityVersionModel */ ParcellationEntityVersionModel: { @@ -979,6 +994,8 @@ export interface components { id: string /** Modality */ modality: string + /** Category */ + category: string /** Description */ description: string /** Name */ @@ -1038,6 +1055,8 @@ export interface components { id: string /** Modality */ modality: string + /** Category */ + category: string /** Description */ description: string /** Name */ @@ -1067,6 +1086,8 @@ export interface components { id: string /** Modality */ modality: string + /** Category */ + category: string /** Description */ description: string /** Name */ @@ -1091,6 +1112,8 @@ export interface components { id: string /** Modality */ modality: string + /** Category */ + category: string /** Description */ description: string /** Name */ @@ -1108,6 +1131,8 @@ export interface components { id: string /** Modality */ modality: string + /** Category */ + category: string /** Description */ description: string /** Name */ @@ -1453,11 +1478,19 @@ export interface operations { } } route_get_parcellation_labelled_map_map_labelled_map_nii_gz_get: { - /** Route Get Parcellation Labelled Map */ + /** + * Route Get Parcellation Labelled Map + * @description Returns a labelled map if region_id is not provided. + * + * Returns a mask if a region_id is provided. + * + * region_id MAY refer to ANY region on the region hierarchy, and a combined mask will be returned. + */ parameters: { query: { parcellation_id: string space_id: string + region_id?: string } } responses: { @@ -1472,7 +1505,12 @@ export interface operations { } } route_get_region_statistical_map_map_statistical_map_nii_gz_get: { - /** Route Get Region Statistical Map */ + /** + * Route Get Region Statistical Map + * @description Returns a statistic map. + * + * region_id MUST refer to leaf region on the region hierarchy. + */ parameters: { query: { parcellation_id: string @@ -1527,7 +1565,7 @@ export interface operations { /** @description Successful Response */ 200: { content: { - "application/json": components["schemas"]["Page_str_"] + "application/json": components["schemas"]["Page_FeatureMetaModel_"] } } /** @description Validation Error */ @@ -1658,7 +1696,7 @@ export interface operations { /** @description Successful Response */ 200: { content: { - "application/json": components["schemas"]["Page_Union_SiibraCorticalProfileModel__SiibraTabularModel__SiibraReceptorDensityFp__"] + "application/json": components["schemas"]["Page_Union_SiibraCorticalProfileModel__SiibraReceptorDensityFp__SiibraTabularModel__"] } } /** @description Validation Error */ @@ -1685,7 +1723,7 @@ export interface operations { /** @description Successful Response */ 200: { content: { - "application/json": components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraTabularModel"] | components["schemas"]["SiibraReceptorDensityFp"] + "application/json": components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraReceptorDensityFp"] | components["schemas"]["SiibraTabularModel"] } } /** @description Validation Error */ @@ -1696,7 +1734,7 @@ export interface operations { } } } - get_all_voi_feature_VolumeOfInterest_get: { + get_all_voi_feature_Image_get: { /** Get All Voi */ parameters: { query: { @@ -1721,7 +1759,7 @@ export interface operations { } } } - get_single_voi_feature_VolumeOfInterest__feature_id__get: { + get_single_voi_feature_Image__feature_id__get: { /** Get Single Voi */ parameters: { query: { @@ -1800,4 +1838,32 @@ export interface operations { } } } + get_single_feature_feature__feature_id__get: { + /** + * Get Single Feature + * @description This endpoint allows detail of a single feature to be fetched, without the necessary context. However, the tradeoff for this endpoint is: + * + * - the endpoint typing is the union of all possible return types + * - the client needs to supply any necessary query param (e.g. subject for regional connectivity, gene for gene expression etc) + */ + parameters: { + path: { + feature_id: string + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["SiibraVoiModel"] | components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraRegionalConnectivityModel"] | components["schemas"]["SiibraReceptorDensityFp"] | components["schemas"]["SiibraTabularModel"] + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } } diff --git a/src/atlasComponents/sapi/sxplrTypes.ts b/src/atlasComponents/sapi/sxplrTypes.ts index 7626acbf313b9e0889dbbf4013aa0fb295875172..28622c040ca66f8161688f3f9c184a138d4812e4 100644 --- a/src/atlasComponents/sapi/sxplrTypes.ts +++ b/src/atlasComponents/sapi/sxplrTypes.ts @@ -77,6 +77,17 @@ export { TThreeSurferMesh, TThreeMesh, TThreeMeshLabel } */ export type TemplateDefaultImage = NgLayerSpec | TThreeMesh +export type LabelledMap = { + name: string + label: number +} + +export type StatisticalMap = { + url: string + min: number + max: number +} + /** * Features */ @@ -84,6 +95,7 @@ export type TemplateDefaultImage = NgLayerSpec | TThreeMesh export type Feature = { id: string name: string + category?: string } & Partial<AdditionalInfo> type DataFrame = { @@ -94,25 +106,21 @@ export type VoiFeature = { bbox: BoundingBox } & Feature +type CorticalDataType = number + +export type CorticalFeature<T extends CorticalDataType, IndexType extends string|number=string> = { + indices?: IndexType[] + corticalProfile?: T[] +} & Feature + type TabularDataType = number | string | number[] export type TabularFeature<T extends TabularDataType> = { index: string[] columns: string[] - data: T[][] + data?: T[][] } & Feature -export type LabelledMap = { - name: string - label: number -} - -export type StatisticalMap = { - url: string - min: number - max: number -} - /** * Support types diff --git a/src/atlasComponents/sapi/translateV3.ts b/src/atlasComponents/sapi/translateV3.ts index d8505ceda11b8c093295f6e3cec2608014f35026..4134af67f94b6ca59e8d72bbf4ab94ac76d527cd 100644 --- a/src/atlasComponents/sapi/translateV3.ts +++ b/src/atlasComponents/sapi/translateV3.ts @@ -1,5 +1,5 @@ import { - SxplrAtlas, SxplrParcellation, SxplrTemplate, SxplrRegion, NgLayerSpec, NgPrecompMeshSpec, NgSegLayerSpec, VoiFeature, Point, TemplateDefaultImage, TThreeSurferMesh, TThreeMesh, LabelledMap + SxplrAtlas, SxplrParcellation, SxplrTemplate, SxplrRegion, NgLayerSpec, NgPrecompMeshSpec, NgSegLayerSpec, VoiFeature, Point, TemplateDefaultImage, TThreeSurferMesh, TThreeMesh, LabelledMap, CorticalFeature } from "./sxplrTypes" import { PathReturn } from "./typeV3" import { hexToRgb } from 'common/util' @@ -63,7 +63,18 @@ class TranslateV3 { name: region.name, color: hexToRgb(region.hasAnnotation?.displayColor) as [number, number, number], parentIds: region.hasParent.map( v => v["@id"] ), - type: "SxplrRegion" + type: "SxplrRegion", + centroid: region.hasAnnotation?.bestViewPoint + ? await (async () => { + const bestViewPoint = region.hasAnnotation?.bestViewPoint + const fullSpace = this.#templateMap.get(bestViewPoint.coordinateSpace['@id']) + const space = await this.translateTemplate(fullSpace) + return { + loc: bestViewPoint.coordinates.map(v => v.value) as [number, number, number], + space + } + })() + : null } } @@ -322,7 +333,7 @@ class TranslateV3 { } } - async translateVoi(voi: PathReturn<"/feature/VolumeOfInterest/{feature_id}">): Promise<VoiFeature> { + async translateVoi(voi: PathReturn<"/feature/Image/{feature_id}">): Promise<VoiFeature> { const { boundingbox } = voi const { loc: center, space } = await this.translatePoint(boundingbox.center) const { loc: maxpoint } = await this.translatePoint(boundingbox.maxpoint) @@ -339,6 +350,29 @@ class TranslateV3 { id: voi.id } } + + + async translateCorticalProfile(feat: PathReturn<"/feature/CorticalProfile/{feature_id}">): Promise<CorticalFeature<number>> { + return { + id: feat.id, + name: feat.name, + desc: feat.description, + link: [ + ...feat.datasets + .map(ds => ds.urls) + .flatMap(v => v) + .map(url => ({ + href: url.url, + text: url.url + })), + ...feat.datasets + .map(ds => ({ + href: ds.ebrains_page, + text: "ebrains resource" + })) + ] + } + } } export const translateV3Entities = new TranslateV3() diff --git a/src/atlasComponents/sapi/typeV3.ts b/src/atlasComponents/sapi/typeV3.ts index 7aa938fdae4f5496cfdaf06e4e535df633f62f3b..d65db66debe2bff385851d785213cec40749b3c5 100644 --- a/src/atlasComponents/sapi/typeV3.ts +++ b/src/atlasComponents/sapi/typeV3.ts @@ -12,11 +12,23 @@ export type SxplrCoordinatePointExtension = { color: string '@id': string // should match the id of opendminds specs } -export type SapiSpatialFeatureModel = PathReturn<"/feature/VolumeOfInterest/{feature_id}"> +export type SapiSpatialFeatureModel = PathReturn<"/feature/Image/{feature_id}"> export type SapiFeatureModel = SapiSpatialFeatureModel | PathReturn<"/feature/Tabular/{feature_id}"> | PathReturn<"/feature/RegionalConnectivity/{feature_id}"> | PathReturn<"/feature/CorticalProfile/{feature_id}"> export type SapiRoute = keyof paths +type _FeatureType<FeatureRoute extends SapiRoute> = FeatureRoute extends `/feature/${infer FT}` + ? FT extends "_types" + ? never + : FT extends "{feature_id}" + ? never + : FT extends `${infer _FT}/{${infer _FID}}` + ? never + : FT + : never + +export type FeatureType = _FeatureType<SapiRoute> + /** * Support types */ diff --git a/src/atlasComponents/sapiViews/core/module.ts b/src/atlasComponents/sapiViews/core/module.ts index c30a1d83ebe1a72a911acbfdc796729e683431d8..d307b3b54b77681c32a3c8cc8cbbbd0a3cb2cf14 100644 --- a/src/atlasComponents/sapiViews/core/module.ts +++ b/src/atlasComponents/sapiViews/core/module.ts @@ -1,7 +1,6 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { SapiViewsCoreAtlasModule } from "./atlas/module"; -import { SapiViewsCoreDatasetModule } from "./datasets"; import { SapiViewsCoreParcellationModule } from "./parcellation/module"; import { SapiViewsCoreRegionModule } from "./region"; import { SapiViewsCoreRichModule } from "./rich/module"; @@ -10,7 +9,6 @@ import { SapiViewsCoreSpaceModule } from "./space"; @NgModule({ imports: [ CommonModule, - SapiViewsCoreDatasetModule, SapiViewsCoreRegionModule, SapiViewsCoreAtlasModule, SapiViewsCoreSpaceModule, @@ -18,7 +16,6 @@ import { SapiViewsCoreSpaceModule } from "./space"; SapiViewsCoreRichModule, ], exports: [ - SapiViewsCoreDatasetModule, SapiViewsCoreRegionModule, SapiViewsCoreAtlasModule, SapiViewsCoreSpaceModule, diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts index 31b8acbe287630ef23e7006e2bbafc3439a9d666..97867311c4d2af1aa72992d683a35a744afab168 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts +++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts @@ -14,6 +14,6 @@ function isGroupedParc(parc: GroupedParcellation|unknown): parc is GroupedParcel export class ParcellationGroupSelectedPipe implements PipeTransform { public transform(parc: GroupedParcellation|unknown, selectedParcellation: SxplrParcellation): boolean { if (!isGroupedParc(parc)) return false - return parc.parcellations.some(p => p["@id"] === selectedParcellation["@id"]) + return parc.parcellations.some(p => p.id === selectedParcellation.id) } } diff --git a/src/atlasComponents/sapiViews/core/region/module.ts b/src/atlasComponents/sapiViews/core/region/module.ts index 50759b93a63e6993946a337212559589c38e31a6..faee32dfa4dcb15ff6f70e44ddde84d6a36a93b5 100644 --- a/src/atlasComponents/sapiViews/core/region/module.ts +++ b/src/atlasComponents/sapiViews/core/region/module.ts @@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { MarkdownModule } from "src/components/markdown"; import { SpinnerModule } from "src/components/spinner"; +import { FeatureModule } from "src/features"; import { AngularMaterialModule } from "src/sharedModules"; import { StrictLocalModule } from "src/strictLocal"; // import { SapiViewsFeaturesModule } from "../../features"; @@ -21,6 +22,7 @@ import { SapiViewsCoreRegionRegionRich } from "./region/rich/region.rich.compone SpinnerModule, MarkdownModule, StrictLocalModule, + FeatureModule, ], declarations: [ SapiViewsCoreRegionRegionListItem, 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 461fcf176f48d78582a92a6f7e2149ee0b500450..c3acb2dfb3de57075448fc2e25615408f24bf6f5 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 @@ -82,6 +82,11 @@ </markdown-dom> </ng-template> + <sxplr-feature-entry + [parcellation]="parcellation" + [region]="region"> + </sxplr-feature-entry> + <mat-accordion class="d-block mt-2"> <!-- desc --> @@ -100,7 +105,7 @@ <ng-template [ngIf]="!environment.STRICT_LOCAL"> <!-- feature list --> - <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { + <!-- <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { title: CONST.REGIONAL_FEATURES, iconClass: 'fas fa-database', content: kgRegionalFeatureList, @@ -108,7 +113,7 @@ iconTooltip: 'Regional Features', iavNgIf: true }"> - </ng-container> + </ng-container> --> <!-- connectivity --> <ng-template #sxplrSapiviewsFeaturesConnectivityBrowser> diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html index 044965ed56ec6ad515764fb75a503bcea7269220..58378b95d7c01864f9b6eeef3dcae8e8ce707848 100644 --- a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html +++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html @@ -99,7 +99,7 @@ <ng-template [ngIf]="selectedIds" let-selectedIds> <mat-icon fontSet="fas" - [fontIcon]="selectedIds.includes(item['@id']) ? 'fa-circle' : 'fa-none'" + [fontIcon]="selectedIds.includes(item.id) ? 'fa-circle' : 'fa-none'" > </mat-icon> </ng-template> diff --git a/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.html b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.html new file mode 100644 index 0000000000000000000000000000000000000000..203d751df7c6adf7f028f84016545e126450117c --- /dev/null +++ b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.html @@ -0,0 +1,68 @@ +<ng-template #headerTmpl> + <ng-content select="[header]"></ng-content> +</ng-template> + +<mat-card *ngIf="!feature"> + + <ng-template [ngTemplateOutlet]="headerTmpl"></ng-template> + <span> + Feature not specified. + </span> +</mat-card> + +<mat-card *ngIf="feature" + class="mat-elevation-z4 sxplr-z-4"> + <mat-card-title> + <ng-template [ngTemplateOutlet]="headerTmpl"></ng-template> + <div class="feature-title"> + {{ feature.name }} + </div> + </mat-card-title> + + <mat-card-subtitle class="sxplr-d-inline-flex sxplr-align-items-stretch"> + <ng-template [ngIf]="feature.category"> + <mat-icon class="sxplr-m-a" fontSet="fas" fontIcon="fa-database"></mat-icon> + <span class="sxplr-m-a"> + {{ feature.category }} + </span> + </ng-template> + + <mat-divider class="sxplr-pl-1" [vertical]="true"></mat-divider> + + <ng-template [ngIf]="busy$ | async"> + <spinner-cmp></spinner-cmp> + </ng-template> + + <!-- <a mat-icon-button sxplr-hide-when-local *ngFor="let url of dataset.link" [href]="url.doi | parseDoi" target="_blank"> + <i class="fas fa-external-link-alt"></i> + </a> --> + </mat-card-subtitle> +</mat-card> + +<mat-card *ngIf="feature" class="sxplr-z-0"> + <mat-card-content> + <!-- TODO fix feature typing! with proper translate fn --> + <markdown-dom class="sxplr-muted" [markdown]="feature['description']"> + </markdown-dom> + </mat-card-content> +</mat-card> + + +<!-- tabular special view --> +<ng-template [ngIf]="tabular$ | async" let-tabular> + <table class="feature-detail" mat-table [dataSource]="tabular | transformPdToDs"> + + <ng-container *ngFor="let column of columns$ | async" + [matColumnDef]="column"> + <th mat-header-cell *matHeaderCellDef> + {{ column }} + </th> + <td mat-cell *matCellDef="let element"> + {{ element[column] }} + </td> + </ng-container> + + <tr mat-header-row *matHeaderRowDef="columns$ | async"></tr> + <tr mat-row *matRowDef="let row; columns: columns$ | async;"></tr> + </table> +</ng-template> diff --git a/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.scss b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e39c0ecb5869b91204fd4782600e6686f8581cee --- /dev/null +++ b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.scss @@ -0,0 +1,11 @@ +.feature-title +{ + max-height: 8rem; + overflow-x: hidden; + overflow-y: auto; +} + +.feature-detail +{ + width: 100%; +} diff --git a/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.spec.ts b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6606d5571625b9e2f89713a26d37626d95b1ac1b --- /dev/null +++ b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FeatureViewComponent } from './feature-view.component'; + +describe('FeatureViewComponent', () => { + let component: FeatureViewComponent; + let fixture: ComponentFixture<FeatureViewComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FeatureViewComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FeatureViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.ts b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..903ec3ffef6a55045266ce0efea3c610fd5d1630 --- /dev/null +++ b/src/atlasComponents/sapiViews/features/feature-view/feature-view.component.ts @@ -0,0 +1,50 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { SAPI } from 'src/atlasComponents/sapi/sapi.service'; +import { Feature, TabularFeature } from 'src/atlasComponents/sapi/sxplrTypes'; + +function isTabularData(feature: unknown): feature is { data: TabularFeature<number|string|number[]> } { + return feature['@type'].includes("siibra-0.4/feature/tabular") +} + +@Component({ + selector: 'sxplr-feature-view', + templateUrl: './feature-view.component.html', + styleUrls: ['./feature-view.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FeatureViewComponent implements OnChanges { + + @Input() + feature: Feature + + busy$ = new BehaviorSubject<boolean>(false) + + tabular$: BehaviorSubject<TabularFeature<number|string|number[]>> = new BehaviorSubject(null) + columns$: Observable<string[]> = this.tabular$.pipe( + map(data => data + ? ['index', ...data.columns] + : []), + ) + constructor(private sapi: SAPI) { } + + ngOnChanges(): void { + console.log(this.feature) + this.tabular$.next(null) + this.busy$.next(true) + this.sapi.v3Get("/feature/{feature_id}", { + path: { + feature_id: this.feature.id + } + }).subscribe( + val => { + this.busy$.next(false) + + if (!isTabularData(val)) return + this.tabular$.next(val.data) + }, + () => this.busy$.next(false) + ) + } +} diff --git a/src/atlasComponents/sapiViews/features/features.module.ts b/src/atlasComponents/sapiViews/features/features.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..c05518863875722d5a7f11dcf77938a2188c1866 --- /dev/null +++ b/src/atlasComponents/sapiViews/features/features.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FeatureViewComponent } from './feature-view/feature-view.component'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTableModule } from "@angular/material/table" +import { MarkdownModule } from 'src/components/markdown'; +import { TransformPdToDsPipe } from './transform-pd-to-ds.pipe'; +import { SpinnerModule } from 'src/components/spinner'; + + +@NgModule({ + declarations: [ + FeatureViewComponent, + TransformPdToDsPipe, + ], + imports: [ + CommonModule, + MatCardModule, + MatIconModule, + MatDividerModule, + MarkdownModule, + MatTableModule, + SpinnerModule, + ], + exports: [ + FeatureViewComponent, + ] +}) +export class FeaturesModule { } diff --git a/src/atlasComponents/sapiViews/features/transform-pd-to-ds.pipe.spec.ts b/src/atlasComponents/sapiViews/features/transform-pd-to-ds.pipe.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..66eb0d5f6e111a63b285e8d1dcf3460759468bcc --- /dev/null +++ b/src/atlasComponents/sapiViews/features/transform-pd-to-ds.pipe.spec.ts @@ -0,0 +1,8 @@ +import { TransformPdToDsPipe } from './transform-pd-to-ds.pipe'; + +describe('TransformPdToDsPipe', () => { + it('create an instance', () => { + const pipe = new TransformPdToDsPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/atlasComponents/sapiViews/features/transform-pd-to-ds.pipe.ts b/src/atlasComponents/sapiViews/features/transform-pd-to-ds.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..09f598d9d6ceea05a41c7599d5ea90a45b32b5b0 --- /dev/null +++ b/src/atlasComponents/sapiViews/features/transform-pd-to-ds.pipe.ts @@ -0,0 +1,23 @@ +import { CdkTableDataSourceInput } from '@angular/cdk/table'; +import { Pipe, PipeTransform } from '@angular/core'; +import { TabularFeature } from 'src/atlasComponents/sapi/sxplrTypes'; + +@Pipe({ + name: 'transformPdToDs', + pure: true +}) +export class TransformPdToDsPipe implements PipeTransform { + + transform(pd: TabularFeature<string|number|number[]>): CdkTableDataSourceInput<unknown> { + return pd.data.map((arr, idx) => { + const returnVal: Record<string, string|number|number[]> = { + index: pd.index[idx], + } + arr.forEach((val, colIdx) => { + returnVal[pd.columns[colIdx]] = val + }) + return returnVal + }) + } + +} diff --git a/src/atlasComponents/sapiViews/module.ts b/src/atlasComponents/sapiViews/module.ts index dd53fa9756db3e026c90a5c95c6b2ec228cec616..7e62c1e29a0be3110390d263fb2bc6e0c2b7d37d 100644 --- a/src/atlasComponents/sapiViews/module.ts +++ b/src/atlasComponents/sapiViews/module.ts @@ -1,13 +1,14 @@ import { NgModule } from "@angular/core"; import { SapiViewsCoreModule } from "./core"; +import { FeaturesModule } from "./features/features.module"; @NgModule({ imports: [ - // SapiViewsFeaturesModule, + FeaturesModule, SapiViewsCoreModule, ], exports: [ - // SapiViewsFeaturesModule, + FeaturesModule, SapiViewsCoreModule, ] }) diff --git a/src/atlasComponents/sapiViews/richDataset.stories.ts b/src/atlasComponents/sapiViews/richDataset.stories.ts index 03ac405b682240aedd06eac97db2b8d659e55d00..112fcebe070eb9d518e7612e0f4a2bfb11ad85f1 100644 --- a/src/atlasComponents/sapiViews/richDataset.stories.ts +++ b/src/atlasComponents/sapiViews/richDataset.stories.ts @@ -7,7 +7,6 @@ import { SAPI } from "src/atlasComponents/sapi" import { getHoc1RightFeatureDetail, getHoc1RightFeatures, getHoc1Right, getHumanAtlas, getJba29, getMni152, provideDarkTheme, getMni152SpatialFeatureHoc1Right } from "src/atlasComponents/sapi/stories.base" import { AngularMaterialModule } from "src/sharedModules" import { cleanIeegSessionDatasets, SapiAtlasModel, SapiFeatureModel, SapiParcellationModel, SapiRegionModel, SapiSpaceModel } from "../sapi/type" -import { SapiViewsCoreDatasetModule } from "./core/datasets" import { SapiViewsFeaturesModule } from "./features" @Component({ @@ -52,7 +51,6 @@ export default { AngularMaterialModule, CommonModule, HttpClientModule, - SapiViewsCoreDatasetModule, SapiViewsFeaturesModule, ], providers: [ diff --git a/src/components/smartChip/component/smartChip.component.ts b/src/components/smartChip/component/smartChip.component.ts index d933960efe0b817d2cb3c29b23b306c62706df04..2e2ee0b7a9ae97931ba9d0a65dd84f302051fc3a 100644 --- a/src/components/smartChip/component/smartChip.component.ts +++ b/src/components/smartChip/component/smartChip.component.ts @@ -36,7 +36,7 @@ const cssColorToHsl = (input: string) => { selector: `sxplr-smart-chip`, templateUrl: `./smartChip.template.html`, styleUrls: [ - `/smartChip.style.css` + `./smartChip.style.scss` ], changeDetection: ChangeDetectionStrategy.OnPush }) diff --git a/src/components/smartChip/component/smartChip.style.css b/src/components/smartChip/component/smartChip.style.scss similarity index 100% rename from src/components/smartChip/component/smartChip.style.css rename to src/components/smartChip/component/smartChip.style.scss diff --git a/src/features/base.ts b/src/features/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..cda6bb56ed795abbfaecf68dcdf23e9644c2297d --- /dev/null +++ b/src/features/base.ts @@ -0,0 +1,37 @@ +import { Input, OnChanges, Directive } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; + +@Directive() +export class FeatureBase implements OnChanges{ + + @Input() + template: SxplrTemplate + + @Input() + parcellation: SxplrParcellation + + @Input() + region: SxplrRegion + + @Input() + queryParams: Record<string, string> = {} + + protected TPR$ = new BehaviorSubject<{ template?: SxplrTemplate, parcellation?: SxplrParcellation, region?: SxplrRegion }>({ template: null, parcellation: null, region: null }) + + ngOnChanges(): void { + const { template, parcellation, region } = this + this.TPR$.next({ template, parcellation, region }) + } +} + + + +export const AllFeatures = { + CorticalProfile: "CorticalProfile", + // EbrainsDataFeature: "EbrainsDataFeature", + RegionalConnectivity: "RegionalConnectivity", + Tabular: "Tabular", + // GeneExpressions: "GeneExpressions", + Image: "Image", +} as const \ No newline at end of file diff --git a/src/features/category-acc.directive.spec.ts b/src/features/category-acc.directive.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cdbb5846fc5df06a4f5ba60672f803700c78341 --- /dev/null +++ b/src/features/category-acc.directive.spec.ts @@ -0,0 +1,8 @@ +import { CategoryAccDirective } from './category-acc.directive'; + +describe('CategoryAccDirective', () => { + it('should create an instance', () => { + const directive = new CategoryAccDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/features/category-acc.directive.ts b/src/features/category-acc.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..b998efb0a8cd27b8260424ee8928bf7a3f2efbd9 --- /dev/null +++ b/src/features/category-acc.directive.ts @@ -0,0 +1,54 @@ +import { AfterContentInit, ContentChildren, Directive, OnDestroy, QueryList } from '@angular/core'; +import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ListComponent } from './list/list.component'; + +@Directive({ + selector: '[sxplrCategoryAcc]', + exportAs: 'categoryAcc' +}) +export class CategoryAccDirective implements AfterContentInit, OnDestroy { + + constructor() { } + + public isBusy$ = new BehaviorSubject<boolean>(false) + public total$ = new BehaviorSubject<number>(0) + + @ContentChildren(ListComponent, { read: ListComponent, descendants: true }) + listCmps: QueryList<ListComponent> + + #changeSub: Subscription + ngAfterContentInit(): void { + this.#registerListCmps() + this.#changeSub = this.listCmps.changes.subscribe(() => this.#registerListCmps()) + } + + ngOnDestroy(): void { + this.#cleanup() + } + + #subscriptions: Subscription[] = [] + #cleanup(){ + if (this.#changeSub) this.#changeSub.unsubscribe() + while(this.#subscriptions.length > 0) this.#subscriptions.pop().unsubscribe() + } + #registerListCmps(){ + this.#cleanup() + + const listCmp = Array.from(this.listCmps) + + this.#subscriptions.push( + combineLatest( + listCmp.map(listCmp => listCmp.features$) + ).pipe( + map(features => features.reduce((acc, curr) => acc + curr.length, 0)) + ).subscribe(total => this.total$.next(total)), + + combineLatest( + listCmp.map(listCmp => listCmp.state$) + ).pipe( + map(states => states.some(state => state === "busy")) + ).subscribe(flag => this.isBusy$.next(flag)) + ) + } +} diff --git a/src/features/entry/entry.component.html b/src/features/entry/entry.component.html new file mode 100644 index 0000000000000000000000000000000000000000..bd413fc42578e9e81770dcfb62a677fcd70da7f8 --- /dev/null +++ b/src/features/entry/entry.component.html @@ -0,0 +1,65 @@ +<mat-card> + <mat-accordion> + <mat-expansion-panel *ngFor="let keyvalue of (cateogryCollections$ | async | keyvalue)" + sxplrCategoryAcc + #categoryAcc="categoryAcc" + [ngClass]="{ + 'sxplr-d-none': !(categoryAcc.isBusy$ | async) && (categoryAcc.total$ | async) === 0 + }"> + + <mat-expansion-panel-header> + + <mat-panel-title> + {{ keyvalue.key }} + </mat-panel-title> + + <mat-panel-description> + <spinner-cmp *ngIf="categoryAcc.isBusy$ | async"></spinner-cmp> + <span> + {{ categoryAcc.total$ | async }} + </span> + </mat-panel-description> + </mat-expansion-panel-header> + + <div class="c3-outer"> + <div class="c3-inner"> + + <mat-card class="c3 mat-elevation-z4" + *ngFor="let feature of keyvalue.value" + [ngClass]="{ + 'sxplr-d-none': (list.state$ | async) === 'noresult' + }"> + <mat-card-header> + + <mat-card-title> + <span class="category-title sxplr-white-space-nowrap"> + {{ feature.name | featureNamePipe }} + </span> + </mat-card-title> + + </mat-card-header> + + + <mat-card-content> + <sxplr-feature-list + [template]="template" + [parcellation]="parcellation" + [region]="region" + [queryParams]="queryParams | mergeObj : { type: (feature.name | featureNamePipe) }" + [featureRoute]="feature.path" + (onClickFeature)="onClickFeature($event)" + #list="featureList" + > + </sxplr-feature-list> + + <spinner-cmp *ngIf="(list.state$ | async) === 'busy'"></spinner-cmp> + + </mat-card-content> + </mat-card> + </div> + </div> + </mat-expansion-panel> + </mat-accordion> + +</mat-card> + diff --git a/src/features/entry/entry.component.scss b/src/features/entry/entry.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..44bcd0aaaf08d7a4741746162402595f23e08b63 --- /dev/null +++ b/src/features/entry/entry.component.scss @@ -0,0 +1,62 @@ +:host > mat-card +{ + padding-left: 0; + padding-right: 0; +} + +mat-list-item +{ + text-overflow: ellipsis; + white-space: nowrap; +} + +// card in card container +.c3-outer +{ + display: inline-block; + overflow-x: auto; + height: 15rem; + width: 100%; +} + +.c3-inner +{ + + height: 100%; + display: inline-flex; + gap: 1.5rem; + flex-wrap: nowrap; + + margin: 0 2rem; + align-items: stretch; +} + +.c3-inner > mat-card +{ + width: 16rem; + overflow:hidden; + height: 95%; +} + +.category-title:hover +{ + cursor: default; +} + +mat-card.c3 +{ + display: flex; + flex-direction: column; +} + +mat-card.c3 > mat-card-header +{ + flex: 0 0 auto; +} + +mat-card.c3 > mat-card-content +{ + flex: 1 1 auto; + height: 75%; + overflow: auto; +} \ No newline at end of file diff --git a/src/features/entry/entry.component.spec.ts b/src/features/entry/entry.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..198c166cd85f9ce339f243f948689b83c6119649 --- /dev/null +++ b/src/features/entry/entry.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EntryComponent } from './entry.component'; + +describe('EntryComponent', () => { + let component: EntryComponent; + let fixture: ComponentFixture<EntryComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ EntryComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(EntryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/features/entry/entry.component.ts b/src/features/entry/entry.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2bb436d012072e10c44060a60f4d8f4c2bf7096 --- /dev/null +++ b/src/features/entry/entry.component.ts @@ -0,0 +1,73 @@ +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { SAPI } from 'src/atlasComponents/sapi'; +import { Feature } from 'src/atlasComponents/sapi/sxplrTypes'; +import { FeatureBase } from '../base'; +import * as userInteraction from "src/state/userInteraction" + +const categoryAcc = <T extends Record<string, unknown>>(categories: T[]) => { + const returnVal: Record<string, T[]> = {} + for (const item of categories) { + const { category, ...rest } = item + if (!category) continue + if (typeof category !== "string") continue + if (!returnVal[category]) { + returnVal[category] = [] + } + returnVal[category].push(item) + } + return returnVal +} + +@Component({ + selector: 'sxplr-feature-entry', + templateUrl: './entry.component.html', + styleUrls: ['./entry.component.scss'] +}) +export class EntryComponent extends FeatureBase { + + constructor(private sapi: SAPI, private store: Store) { + super() + } + + private featureTypes$ = this.sapi.v3Get("/feature/_types", {}).pipe( + switchMap(resp => + this.sapi.iteratePages( + resp, + page => this.sapi.v3Get( + "/feature/_types", + { query: { page } } + ) + ) + ), + ) + + public cateogryCollections$ = this.TPR$.pipe( + switchMap(({ template, parcellation, region }) => this.featureTypes$.pipe( + map(features => { + const filteredFeatures = features.filter(v => { + const params = [ + ...(v.path_params || []), + ...(v.query_params || []), + ] + return [ + params.includes("space_id") === (!!template), + params.includes("parcellation_id") === (!!parcellation), + params.includes("region_id") === (!!region), + ].some(val => val) + }) + return categoryAcc(filteredFeatures) + }), + )), + ) + + onClickFeature(feature: Feature) { + this.store.dispatch( + userInteraction.actions.showFeature({ + feature + }) + ) + } +} + diff --git a/src/features/featureName.pipe.ts b/src/features/featureName.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..004eedba3f94b147a77bd2e858d4f7962dce3998 --- /dev/null +++ b/src/features/featureName.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'featureNamePipe', + pure: true, +}) + +export class FeatureNamePipe implements PipeTransform{ + public transform(name: string): string { + return name.split(".").slice(-1)[0] + } +} diff --git a/src/features/fetch.directive.spec.ts b/src/features/fetch.directive.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5765d9f6e81b81b3b2109a0cc480e13da82ac94a --- /dev/null +++ b/src/features/fetch.directive.spec.ts @@ -0,0 +1,8 @@ +import { FetchDirective } from './fetch.directive'; + +describe('FetchDirective', () => { + it('should create an instance', () => { + const directive = new FetchDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/features/fetch.directive.ts b/src/features/fetch.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb40e4fd0b1c5bd5c8f33156272eab8598f7c3a2 --- /dev/null +++ b/src/features/fetch.directive.ts @@ -0,0 +1,71 @@ +import { Directive, Input, OnChanges, Output, SimpleChanges, EventEmitter } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { SAPI } from 'src/atlasComponents/sapi'; +import { SxplrParcellation, SxplrRegion, SxplrTemplate } from 'src/atlasComponents/sapi/sxplrTypes'; +import { FeatureType, SapiRoute } from 'src/atlasComponents/sapi/typeV3'; +import { Feature, CorticalFeature, VoiFeature, TabularFeature } from "src/atlasComponents/sapi/sxplrTypes" + +type ObservableOf<Obs extends Observable<unknown>> = Parameters<Obs['subscribe']>[0] extends Function +? Parameters<Parameters<Obs['subscribe']>[0]>[0] +: never + +const b = new Subject<{ t: string }>() + +type typeOfB = ObservableOf<typeof b> + +type FeatureMap = { + "RegionalConnectivity": Feature + "CorticalProfile": CorticalFeature<number> + "Tabular": TabularFeature<number|string|number[]> + "Image": VoiFeature +} + +@Directive({ + selector: '[sxplrFeatureFetch]', + exportAs: "sxplrFeatureFetch" +}) +export class FetchDirective<T extends keyof FeatureMap> implements OnChanges { + + /** + * TODO check if the decorated property survive on inheritence + */ + + @Input() + template: SxplrTemplate + + @Input() + parcellation: SxplrParcellation + + @Input() + region: SxplrRegion + + @Input() + featureType: T + + @Output() + features: BehaviorSubject<FeatureMap[T][]> = new BehaviorSubject([]) + + @Output() + busy$: BehaviorSubject<boolean> = new BehaviorSubject(false) + + constructor( + private sapi: SAPI + ) { } + + async ngOnChanges(changes: SimpleChanges) { + if (!this.featureType) { + console.warn(`featureType must be defined!`) + } + this.busy$.next(true) + const features = await this.sapi.getV3Features(this.featureType, { + query: { + parcellation_id: this.parcellation?.id, + space_id: this.template?.id, + region_id: this.region?.name, + } + }).toPromise() + this.busy$.next(false) + this.features.next(features as any[]) + } +} diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d99543461bfe83c8376f42512683005eeb8df5ac --- /dev/null +++ b/src/features/index.ts @@ -0,0 +1 @@ +export { FeatureModule } from "./module" \ No newline at end of file diff --git a/src/features/list/list.component.html b/src/features/list/list.component.html new file mode 100644 index 0000000000000000000000000000000000000000..c50515f2580ecaa0d415a5e5e9b14fb7639aae3b --- /dev/null +++ b/src/features/list/list.component.html @@ -0,0 +1,11 @@ +<mat-list> + <mat-list-item mat-ripple + *ngFor="let feature of features$ | async" + [matTooltip]="feature.name" + matTooltipPosition="right" + (click)="onClickItem(feature)"> + <span class="feature-name"> + {{ feature.name }} + </span> + </mat-list-item> +</mat-list> diff --git a/src/features/list/list.component.scss b/src/features/list/list.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..99448693600d945c9af3622013e7f196f900d221 --- /dev/null +++ b/src/features/list/list.component.scss @@ -0,0 +1,15 @@ +:host +{ + display: block; + width: 100%; +} + +.feature-name +{ + white-space: nowrap; +} + +.feature-name:hover +{ + cursor: default; +} diff --git a/src/features/list/list.component.spec.ts b/src/features/list/list.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..54ae348082a0c2157da9006173bec361526f64a8 --- /dev/null +++ b/src/features/list/list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ListComponent } from './list.component'; + +describe('ListComponent', () => { + let component: ListComponent; + let fixture: ComponentFixture<ListComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ListComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/features/list/list.component.ts b/src/features/list/list.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d81ab404dceaa2904b9435bbb0861ac1a32e3ca --- /dev/null +++ b/src/features/list/list.component.ts @@ -0,0 +1,67 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { BehaviorSubject, combineLatest, NEVER, Observable, of, throwError } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; +import { SAPI } from 'src/atlasComponents/sapi'; +import { Feature } from 'src/atlasComponents/sapi/sxplrTypes'; +import { FeatureType } from 'src/atlasComponents/sapi/typeV3'; +import { AllFeatures, FeatureBase } from '../base'; + +@Component({ + selector: 'sxplr-feature-list', + templateUrl: './list.component.html', + styleUrls: ['./list.component.scss'], + exportAs: "featureList" +}) +export class ListComponent extends FeatureBase { + + @Output() + onClickFeature = new EventEmitter<Feature>() + + @Input() + featureRoute: string + private guardedRoute$ = new BehaviorSubject<FeatureType>(null) + + public state$ = new BehaviorSubject<'busy'|'noresult'|'result'>('noresult') + + constructor(private sapi: SAPI) { + super() + } + + ngOnChanges(): void { + super.ngOnChanges() + const featureType = (this.featureRoute || '').split("/").slice(-1)[0] + this.guardedRoute$.next(AllFeatures[featureType]) + } + + public features$: Observable<Feature[]> = combineLatest([ + this.guardedRoute$, + this.TPR$, + ]).pipe( + tap(() => this.state$.next('busy')), + switchMap(([route, { template, parcellation, region }]) => { + if (!route) { + return throwError("noresult") + } + const query = {} + if (template) query['space_id'] = template.id + if (parcellation) query['parcellation_id'] = parcellation.id + if (region) query['region_id'] = region.name + return this.sapi.getV3Features(route, { + query: { + ...this.queryParams, + ...query, + } as any + }).pipe( + ) + }), + catchError(() => { + this.state$.next("noresult") + return of([] as Feature[]) + }), + tap(result => this.state$.next(result.length > 0 ? 'result' : 'noresult')), + ) + + onClickItem(feature: Feature){ + this.onClickFeature.emit(feature) + } +} diff --git a/src/features/module.ts b/src/features/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..16214d8b14c72d81c28ffc8d3b1e5cc74eace3d0 --- /dev/null +++ b/src/features/module.ts @@ -0,0 +1,40 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatRippleModule } from "@angular/material/core"; +import { MatExpansionModule } from "@angular/material/expansion"; +import { MatListModule } from "@angular/material/list"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { SpinnerModule } from "src/components/spinner"; +import { UtilModule } from "src/util"; +import { EntryComponent } from './entry/entry.component' +import { FeatureNamePipe } from "./featureName.pipe"; +import { FetchDirective } from "./fetch.directive"; +import { ListComponent } from './list/list.component'; +import { CategoryAccDirective } from './category-acc.directive'; + +@NgModule({ + imports: [ + CommonModule, + MatCardModule, + MatExpansionModule, + SpinnerModule, + MatListModule, + MatTooltipModule, + UtilModule, + MatRippleModule, + ], + declarations: [ + EntryComponent, + ListComponent, + + FetchDirective, + CategoryAccDirective, + + FeatureNamePipe, + ], + exports: [ + EntryComponent, + ] +}) +export class FeatureModule{} \ No newline at end of file diff --git a/src/overwrite.scss b/src/overwrite.scss index 86f98096cead1a68e768e98ebd77aa084859fdf2..a60e2755623bed99f769435701c0798d47ce927d 100644 --- a/src/overwrite.scss +++ b/src/overwrite.scss @@ -153,7 +153,7 @@ $transform-origin-maps: ( margin-bottom: auto!important; } -$display-vars: none, block, inline-block, flex, inline-flex; +$display-vars: none, block, inline-block, flex, inline-flex, grid; @each $display-var in $display-vars { .d-#{$display-var} { diff --git a/src/routerModule/routeStateTransform.service.ts b/src/routerModule/routeStateTransform.service.ts index c1e2460f8eccd65d3e28266a956966ccc3c9161f..8a6fe4d335e622fc62672716790827f3cb9ba2ba 100644 --- a/src/routerModule/routeStateTransform.service.ts +++ b/src/routerModule/routeStateTransform.service.ts @@ -39,7 +39,7 @@ export class RouteStateTransformSvc { allParcellationRegions = [] ] = await Promise.all([ this.sapi.atlases$.pipe( - map(atlases => atlases.find(atlas => atlas["@id"] === selectedAtlasId)) + map(atlases => atlases.find(atlas => atlas.id === selectedAtlasId)) ).toPromise(), this.sapi.v3Get("/spaces/{space_id}", { path: { @@ -296,17 +296,17 @@ export class RouteStateTransformSvc { routes = { // for atlas - a: selectedAtlas && encodeId(selectedAtlas['@id']), + a: selectedAtlas && encodeId(selectedAtlas.id), // for template - t: selectedTemplate && encodeId(selectedTemplate['@id']), + t: selectedTemplate && encodeId(selectedTemplate.id), // for parcellation - p: selectedParcellation && encodeId(selectedParcellation['@id']), + p: selectedParcellation && encodeId(selectedParcellation.id), // for regions r: selectedRegionsString && encodeURIFull(selectedRegionsString), // nav ['@']: cNavString, // showing dataset - f: selectedFeature && encodeId(selectedFeature["@id"]) + f: selectedFeature && encodeId(selectedFeature.id) } /** diff --git a/src/state/atlasSelection/effects.ts b/src/state/atlasSelection/effects.ts index 92442f0794a9c192674b96cf1d5fc855479f6da9..7b25c2e9a7ab8e5dc27171647702ee4276d67692 100644 --- a/src/state/atlasSelection/effects.ts +++ b/src/state/atlasSelection/effects.ts @@ -160,7 +160,6 @@ export class Effect { this.onTemplateParcSelectionPostHook.map(fn => fn({ previous: { atlas: currAtlas, template: currTmpl, parcellation: currParc }, current: { atlas, template, parcellation } })) ).pipe( map(partialStates => { - console.log('selected teplate', template, parcellation) let returnState: Partial<AtlasSelectionState> = { selectedAtlas: atlas, selectedTemplate: template, diff --git a/src/util/priority.ts b/src/util/priority.ts index b12abb243d9c984ea9f9d3ec7dd608d375357e9e..24cdd94725c8774bc937c5f0098218d3e7d07151 100644 --- a/src/util/priority.ts +++ b/src/util/priority.ts @@ -170,7 +170,7 @@ export class PriorityHttpInterceptor implements HttpInterceptor{ filter(v => v.urlWithParams === urlWithParams), take(1), map(v => { - if (v instanceof Error) { + if (v['error'] instanceof Error) { throw v } return (v as Result<unknown>).result diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index 0731f9bf2aad326458d20373fc7395d174f7763b..ce5992c136c461465922a8cca38ebcc19c8dedd5 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -911,8 +911,8 @@ let-backCb="backCb" let-feature="feature"> - <sxplr-sapiviews-core-datasets-dataset class="sxplr-z-2 mat-elevation-z2" - [sxplr-sapiviews-core-datasets-dataset-input]="feature"> + <sxplr-feature-view class="sxplr-z-2 mat-elevation-z2" + [feature]="feature"> <div header> <!-- back btn --> @@ -929,7 +929,7 @@ </button> </div> - </sxplr-sapiviews-core-datasets-dataset> + </sxplr-feature-view> <!-- TODO FIXME --> <!-- <sxplr-sapiviews-features-entry