From b839aebf81a18df06c28cefb9cdbb147c70d6ce6 Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Tue, 2 May 2023 19:20:47 +0200 Subject: [PATCH] feat: paginate features --- docs/releases/v2.10.2.md | 7 + mkdocs.yml | 1 + package.json | 2 +- src/atlasComponents/sapi/sapi.service.ts | 2 +- src/atlasComponents/sapi/translateV3.ts | 2 +- src/features/category-acc.directive.ts | 154 ++++++++++++----- src/features/entry/entry.component.ts | 23 ++- .../entry/entry.flattened.component.html | 65 ++++--- src/features/feature.filter.directive.ts | 2 +- src/features/filterGrpFeat.directive.ts | 31 ---- src/features/grpFeatToName.pipe.ts | 9 +- src/features/list/list.component.html | 44 +++-- src/features/list/list.component.ts | 14 ++ src/features/list/list.directive.ts | 123 ++++++++++---- src/features/module.ts | 2 - src/util/pullable.ts | 159 ++++++++++++++++++ .../ngLayerCtl/ngLayerCtrl.style.css | 2 +- .../ngLayerCtl/ngLayerCtrl.template.html | 2 +- .../viewerCmp/viewerCmp.template.html | 9 +- 19 files changed, 487 insertions(+), 166 deletions(-) create mode 100644 docs/releases/v2.10.2.md delete mode 100644 src/features/filterGrpFeat.directive.ts create mode 100644 src/util/pullable.ts diff --git a/docs/releases/v2.10.2.md b/docs/releases/v2.10.2.md new file mode 100644 index 000000000..2e0980ac5 --- /dev/null +++ b/docs/releases/v2.10.2.md @@ -0,0 +1,7 @@ +# v2.10.2 + +## Bugfix + +- paginate feature requests to hopefully improve server performance +- bump siibra-api version +- fix ng layer control style pollution diff --git a/mkdocs.yml b/mkdocs.yml index 4bc43ba50..004934703 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.2: 'releases/v2.10.2.md' - v2.10.1: 'releases/v2.10.1.md' - v2.10.0: 'releases/v2.10.0.md' - v2.9.1: 'releases/v2.9.1.md' diff --git a/package.json b/package.json index 2ee728b99..34add91c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "siibra-explorer", - "version": "2.10.1", + "version": "2.10.2", "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "lint": "eslint src --ext .ts", diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index 65a12c19b..c5ac1ecd6 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.1' +export const EXPECTED_SIIBRA_API_VERSION = '0.3.2' let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null diff --git a/src/atlasComponents/sapi/translateV3.ts b/src/atlasComponents/sapi/translateV3.ts index b5dcc4a01..79775ba84 100644 --- a/src/atlasComponents/sapi/translateV3.ts +++ b/src/atlasComponents/sapi/translateV3.ts @@ -419,7 +419,7 @@ class TranslateV3 { } } - async translateFeature(feat: PathReturn<"/feature/{feature_id}">): Promise<TabularFeature<number|string|number[]>|Feature> { + async translateFeature(feat: PathReturn<"/feature/{feature_id}">): Promise<TabularFeature<number|string|number[]>|VoiFeature|Feature> { if (this.#isTabular(feat)) { return await this.translateTabularFeature(feat) } diff --git a/src/features/category-acc.directive.ts b/src/features/category-acc.directive.ts index 1ddb52d0c..ca2b5e818 100644 --- a/src/features/category-acc.directive.ts +++ b/src/features/category-acc.directive.ts @@ -1,13 +1,14 @@ -import { AfterContentInit, ContentChildren, Directive, OnDestroy, QueryList } from '@angular/core'; -import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Feature } from "src/atlasComponents/sapi/sxplrTypes" -import { ListDirective } from './list/list.directive'; +import { AfterContentInit, ContentChildren, Directive, Input, OnDestroy, QueryList } from '@angular/core'; +import { combineLatest, merge, of, Subject, Subscription } from 'rxjs'; +import { distinctUntilChanged, map, scan, shareReplay, switchMap } from 'rxjs/operators'; +import { ListDirective, TranslatedFeature } from './list/list.directive'; +import { ParentDatasource, PulledDataSource } from 'src/util/pullable'; +import { arrayEqual } from 'src/util/array'; -export type GroupedFeature = { - features: Feature[] +export type FeatureMetadata = { meta: { displayName: string + total: number } } @@ -16,57 +17,118 @@ export type GroupedFeature = { exportAs: 'categoryAcc' }) export class CategoryAccDirective implements AfterContentInit, OnDestroy { + + #listCmps$ = new Subject<ListDirective[]>() + + public isBusy$ = this.#listCmps$.pipe( + switchMap(cmps => + combineLatest( + cmps.map( + cmp => cmp.isBusy$ + ) + ).pipe( + map(isBusyState => isBusyState.some(state => state)) + ) + ) + ) + public total$ = this.#listCmps$.pipe( + switchMap(listCmps => + combineLatest( + listCmps.map(cmp => cmp.total$) + ).pipe( + map(totals => totals.reduce((acc, curr) => acc + curr)), + ) + ), + shareReplay(1) + ) - public isBusy$ = new BehaviorSubject<boolean>(false) - public total$ = new BehaviorSubject<number>(0) - public groupedFeatures$ = new BehaviorSubject<GroupedFeature[]>([]) - public features$ = this.groupedFeatures$.pipe( - map(arr => arr.flatMap(val => val.features)) + public featureMetadata$ = this.#listCmps$.pipe( + switchMap(listcmps => { + if (listcmps.length === 0) { + return of([] as FeatureMetadata[]) + } + return merge( + ...listcmps.map(cmp => + cmp.total$.pipe( + map(total => ({ + [cmp.displayName]: total + })) + ) + ) + ).pipe( + scan((acc, curr) => { + return { + ...acc, + ...curr + } + }, {} as Record<string, number>), + map(record => { + const returnVal: FeatureMetadata[] = [] + for (const key in record) { + returnVal.push({ + meta: { + displayName: key, + total: record[key] + } + }) + } + return returnVal + }) + ) + }) ) @ContentChildren(ListDirective, { read: ListDirective, descendants: true }) listCmps: QueryList<ListDirective> - #changeSub: Subscription - ngAfterContentInit(): void { - this.#registerListCmps() - this.#changeSub = this.listCmps.changes.subscribe(() => this.#registerListCmps()) - } - - ngOnDestroy(): void { - this.#cleanup() - } + #unchecked$ = new Subject<FeatureMetadata[]>() + unchecked$ = this.#unchecked$.pipe( + distinctUntilChanged( + arrayEqual((o, n) => o.meta.displayName === n.meta.displayName && o.meta.total === n.meta.total) + ) + ) - #subscriptions: Subscription[] = [] - #cleanup(){ - if (this.#changeSub) this.#changeSub.unsubscribe() - while(this.#subscriptions.length > 0) this.#subscriptions.pop().unsubscribe() + @Input() + set unchecked(val: FeatureMetadata[]){ + this.#unchecked$.next(val) } - #registerListCmps(){ - this.#cleanup() - const listCmp = Array.from(this.listCmps) - - this.#subscriptions.push( - combineLatest( - listCmp.map( - listC => listC.features$.pipe( - map(features => ({ features, meta: { displayName: listC.displayName } })) - ) - ) - ).subscribe(val => this.groupedFeatures$.next(val)), + public datasource$ = combineLatest([ + this.unchecked$, + this.#listCmps$, + ]).pipe( + switchMap(([ unchecked, listCmps ]) => { + const hideFeatureNames = unchecked.map(c => c.meta.displayName) + const filteredListCmps = listCmps.filter(cmp => !hideFeatureNames.includes(cmp.displayName)) - combineLatest( - listCmp.map(listC => listC.features$) + if (filteredListCmps.length === 0) { + return of( + new ParentDatasource({ + children: [] as PulledDataSource<TranslatedFeature>[] + }) + ) + } + return combineLatest( + filteredListCmps.map(cmp => cmp.datasource$) ).pipe( - map(features => features.reduce((acc, curr) => acc + curr.length, 0)) - ).subscribe(total => this.total$.next(total)), + map(dss => new ParentDatasource({ children: dss })), + ) + }) + ) - combineLatest( - listCmp.map(listC => listC.state$) - ).pipe( - map(states => states.some(state => state === "busy")) - ).subscribe(flag => this.isBusy$.next(flag)) + #changeSub: Subscription + ngAfterContentInit(): void { + this.#changeSub = this.listCmps.changes.subscribe(() => { + this.#listCmps$.next( + Array.from(this.listCmps) + ) + }) + this.#listCmps$.next( + Array.from(this.listCmps) ) } + + ngOnDestroy(): void { + if (this.#changeSub) this.#changeSub.unsubscribe() + } } diff --git a/src/features/entry/entry.component.ts b/src/features/entry/entry.component.ts index 52ab5d22e..abe592030 100644 --- a/src/features/entry/entry.component.ts +++ b/src/features/entry/entry.component.ts @@ -8,6 +8,7 @@ import * as userInteraction from "src/state/userInteraction" import { atlasSelection } from 'src/state'; import { CategoryAccDirective } from "../category-acc.directive" import { BehaviorSubject, combineLatest, merge, of, Subscription } from 'rxjs'; +import { IsAlreadyPulling, PulledDataSource } from 'src/util/pullable'; const categoryAcc = <T extends Record<string, unknown>>(categories: T[]) => { const returnVal: Record<string, T[]> = {} @@ -36,7 +37,6 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest public busyTallying$ = new BehaviorSubject<boolean>(false) public totals$ = new BehaviorSubject<number>(null) - public features$ = new BehaviorSubject<Feature[]>([]) constructor(private sapi: SAPI, private store: Store) { super() @@ -77,13 +77,6 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest this.totals$.next(num) }), ).subscribe(), - - catAccDirs$.pipe( - switchMap(catArrDirs => combineLatest( - catArrDirs.map(dir => dir.features$) - )), - map(features => features.flatMap(f => f)) - ).subscribe(features => this.features$.next(features)) ) } @@ -140,5 +133,17 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest }) ) } -} + async onScroll(datasource: PulledDataSource<unknown>, scrollIndex: number){ + if ((datasource.currentValue.length - scrollIndex) < 30) { + try { + await datasource.pull() + } catch (e) { + if (e instanceof IsAlreadyPulling) { + return + } + throw e + } + } + } +} diff --git a/src/features/entry/entry.flattened.component.html b/src/features/entry/entry.flattened.component.html index 92c930de8..900633de8 100644 --- a/src/features/entry/entry.flattened.component.html +++ b/src/features/entry/entry.flattened.component.html @@ -51,6 +51,7 @@ <ng-template #featureCategoryFeatureTmpl let-keyvalue> <mat-expansion-panel sxplrCategoryAcc + [unchecked]="filterFeatureCls.unchecked$ | async" #categoryAcc="categoryAcc" [ngClass]="{ 'sxplr-d-none': !(categoryAcc.isBusy$ | async) && (categoryAcc.total$ | async) === 0 @@ -60,12 +61,12 @@ {{ keyvalue.key }} </mat-panel-title> <mat-panel-description> - <spinner-cmp *ngIf="categoryAcc.isBusy$ | async"></spinner-cmp> <ng-template [ngIf]="categoryAcc.total$ | async" let-total> <span> {{ total }} </span> </ng-template> + <spinner-cmp class="sxplr-ml-2 sxplr-d-block" *ngIf="categoryAcc.isBusy$ | async"></spinner-cmp> </mat-panel-description> </mat-expansion-panel-header> @@ -74,7 +75,7 @@ <div class="mat-chip-container" feature-filter-directive [initValue]="true" - [items]="categoryAcc.groupedFeatures$ | async " + [items]="categoryAcc.featureMetadata$ | async " #filterFeatureCls="featureFilterDirective"> <div class="mat-chip-inner-container"> @@ -85,7 +86,7 @@ </button> <ng-template ngFor [ngForOf]="filterFeatureCls.items" let-grpFeat> - <ng-template [ngIf]="grpFeat.features.length > 0"> + <ng-template [ngIf]="grpFeat.meta.total > 0"> <ng-template [ngIf]="filterFeatureCls.checked$ | async | grpFeatToName | includes : grpFeat.meta.displayName" [ngIfThen]="selectedTmpl" @@ -97,7 +98,7 @@ {{ grpFeat.meta.displayName }} </span> <span class="text-muted1"> - ({{ grpFeat.features.length }}) + ({{ grpFeat.meta.total }}) </span> </ng-template> <ng-template #selectedTmpl> @@ -127,6 +128,7 @@ <ng-template ngFor [ngForOf]="keyvalue.value" let-feature> + <!-- collected by CategoryAccDirective with ContentChildren --> <div sxplr-feature-list-directive [template]="template" [parcellation]="parcellation" @@ -135,26 +137,43 @@ [queryParams]="queryParams | mergeObj : { type: (feature.name | featureNamePipe) }" [featureRoute]="feature.path" [name]="feature.name" - [displayName]="feature.display_name" - #featureListDirective="featureListDirective"> + [displayName]="feature.display_name"> </div> </ng-template> + + <ng-template [ngIf]="categoryAcc.datasource$ | async" let-datasource> + <cdk-virtual-scroll-viewport itemSize="36" + (scrolledIndexChange)="onScroll(datasource, $event)" + class="virtual-scroll-viewport"> + + <ng-template cdkVirtualFor + [cdkVirtualForOf]="datasource" + let-feature + let-last="last"> + + <button + mat-button + class="virtual-scroll-item sxplr-w-100" + [matTooltip]="feature.name" + matTooltipPosition="right" + (click)="onClickFeature(feature)"> + {{ feature.name }} + </button> + + <div *ngIf="last && datasource.isPulling$ | async"> + <ng-template [ngTemplateOutlet]="loadingSpinnerTmpl"> + </ng-template> + </div> + + + </ng-template> + </cdk-virtual-scroll-viewport> + </ng-template> - <cdk-virtual-scroll-viewport itemSize="36" - filter-grp-feat - [featureDisplayName]="filterFeatureCls.checked$ | async | mapToProperty : 'meta' | mapToProperty : 'displayName'" - [groupFeature]="categoryAcc.groupedFeatures$ | async" - #filterGrpFeat="filterGrpFeat" - class="virtual-scroll-viewport"> - - <button *cdkVirtualFor="let feature of filterGrpFeat.filteredFeatures$" - mat-button - class="virtual-scroll-item sxplr-w-100" - [matTooltip]="feature.name" - matTooltipPosition="right" - (click)="onClickFeature(feature)"> - {{ feature.name }} - </button> - </cdk-virtual-scroll-viewport> </mat-expansion-panel> -</ng-template> \ No newline at end of file +</ng-template> + + +<ng-template #loadingSpinnerTmpl> + <spinner-cmp class="sxplr-pl-2 sxplr-d-block"></spinner-cmp> +</ng-template> diff --git a/src/features/feature.filter.directive.ts b/src/features/feature.filter.directive.ts index 14421065f..e5d6bceaf 100644 --- a/src/features/feature.filter.directive.ts +++ b/src/features/feature.filter.directive.ts @@ -32,7 +32,7 @@ export class FeatureFilterDirective<T> implements OnChanges{ this.#initValue$ ]).pipe( switchMap(([items, initFlag]) => { - const initialCondition = items.map(item => ({ item, flag: initFlag })) + const initialCondition = (items || []).map(item => ({ item, flag: initFlag })) return merge<{ target: T, flag?: boolean, op: string }>( this.#toggle$.pipe( map(v => ({ ...v, op: 'toggle' })) diff --git a/src/features/filterGrpFeat.directive.ts b/src/features/filterGrpFeat.directive.ts deleted file mode 100644 index 3caa9416d..000000000 --- a/src/features/filterGrpFeat.directive.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { GroupedFeature } from "./category-acc.directive"; -import { combineLatest, Subject } from "rxjs"; -import { map } from "rxjs/operators"; - -@Directive({ - selector: '[filter-grp-feat]', - exportAs: 'filterGrpFeat' -}) -export class FilterGroupList implements OnChanges{ - - @Input() - featureDisplayName: string[] = [] - #featureDisplayName = new Subject<string[]>() - - @Input() - groupFeature: GroupedFeature[] = [] - #groupFeature = new Subject<GroupedFeature[]>() - - filteredFeatures$ = combineLatest([ - this.#featureDisplayName, - this.#groupFeature - ]).pipe( - map(([ displaynames, grpfeats ]) => grpfeats.filter(feat => displaynames.includes(feat.meta.displayName)).flatMap(f => f.features)) - ) - - ngOnChanges(): void { - this.#featureDisplayName.next(this.featureDisplayName) - this.#groupFeature.next(this.groupFeature) - } -} diff --git a/src/features/grpFeatToName.pipe.ts b/src/features/grpFeatToName.pipe.ts index f124a6836..050f6ec2c 100644 --- a/src/features/grpFeatToName.pipe.ts +++ b/src/features/grpFeatToName.pipe.ts @@ -1,5 +1,10 @@ import { Pipe, PipeTransform } from "@angular/core"; -import { GroupedFeature } from "./category-acc.directive"; + +interface MetaDisplayName { + meta: { + displayName: string + } +} @Pipe({ name: 'grpFeatToName', @@ -7,7 +12,7 @@ import { GroupedFeature } from "./category-acc.directive"; }) export class GroupFeaturesToName implements PipeTransform{ - public transform(groupFeats: GroupedFeature[]): string[] { + public transform(groupFeats: MetaDisplayName[]): string[] { return groupFeats.map(f => f.meta.displayName) } } diff --git a/src/features/list/list.component.html b/src/features/list/list.component.html index 28d0b7dab..f8316d76c 100644 --- a/src/features/list/list.component.html +++ b/src/features/list/list.component.html @@ -1,11 +1,33 @@ -<cdk-virtual-scroll-viewport itemSize="36" - class="virtual-scroll-viewport"> - <button *cdkVirtualFor="let feature of features$ | async" - mat-button - class="virtual-scroll-item sxplr-w-100" - [matTooltip]="feature.name" - matTooltipPosition="right" - (click)="onClickItem(feature)"> - {{ feature.name }} - </button> -</cdk-virtual-scroll-viewport> +<ng-template [ngIf]="datasource$ | async" let-datasource> + + <cdk-virtual-scroll-viewport itemSize="36" + (scrolledIndexChange)="onScroll(datasource, $event)" + class="virtual-scroll-viewport"> + + <ng-template cdkVirtualFor + [cdkVirtualForOf]="datasource" + let-feature + let-last="last" + let-index="index"> + + <button + mat-button + class="virtual-scroll-item sxplr-w-100" + [matTooltip]="feature.name" + matTooltipPosition="right" + (click)="onClickItem(feature)"> + {{ feature.name }} + </button> + + <div *ngIf="last && datasource.isPulling$ | async"> + <ng-template [ngTemplateOutlet]="loadingSpinnerTmpl"> + </ng-template> + </div> + + </ng-template> + </cdk-virtual-scroll-viewport> +</ng-template> + +<ng-template #loadingSpinnerTmpl> + <spinner-cmp class="sxplr-pl-2 sxplr-d-block"></spinner-cmp> +</ng-template> diff --git a/src/features/list/list.component.ts b/src/features/list/list.component.ts index 29500aca7..f19f852f5 100644 --- a/src/features/list/list.component.ts +++ b/src/features/list/list.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Output } from '@angular/core'; import { SAPI } from 'src/atlasComponents/sapi'; import { Feature } from 'src/atlasComponents/sapi/sxplrTypes'; import { ListDirective } from './list.directive'; +import { IsAlreadyPulling, PulledDataSource } from 'src/util/pullable'; @Component({ selector: 'sxplr-feature-list', @@ -21,4 +22,17 @@ export class ListComponent extends ListDirective { onClickItem(feature: Feature){ this.onClickFeature.emit(feature) } + + async onScroll(datasource: PulledDataSource<unknown>, scrollIndex: number){ + if ((datasource.currentValue.length - scrollIndex) < 30) { + try { + await datasource.pull() + } catch (e) { + if (e instanceof IsAlreadyPulling) { + return + } + throw e + } + } + } } diff --git a/src/features/list/list.directive.ts b/src/features/list/list.directive.ts index a2a7353ec..119fae57b 100644 --- a/src/features/list/list.directive.ts +++ b/src/features/list/list.directive.ts @@ -1,16 +1,22 @@ -import { Input, Directive, SimpleChanges } from "@angular/core"; -import { BehaviorSubject, combineLatest, Observable, of, throwError } from "rxjs"; -import { catchError, switchMap, tap } from "rxjs/operators"; +import { Input, Directive, SimpleChanges, OnDestroy } from "@angular/core"; +import { BehaviorSubject, combineLatest, forkJoin, of, Subject, Subscription } from "rxjs"; +import { distinctUntilChanged, shareReplay, startWith, switchMap } 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"; +import { PulledDataSource } from "src/util/pullable"; +import { + translateV3Entities +} from "src/atlasComponents/sapi/translateV3" + + +export type TranslatedFeature = Awaited< ReturnType<(typeof translateV3Entities)['translateFeature']> > @Directive({ selector: '[sxplr-feature-list-directive]', exportAs: 'featureListDirective' }) -export class ListDirective extends FeatureBase{ +export class ListDirective extends FeatureBase implements OnDestroy{ @Input() name: string @@ -22,10 +28,87 @@ export class ListDirective extends FeatureBase{ featureRoute: string private guardedRoute$ = new BehaviorSubject<FeatureType>(null) - public state$ = new BehaviorSubject<'busy'|'noresult'|'result'>('noresult') + #total = new Subject<number>() + total$ = this.#total.pipe( + distinctUntilChanged(), + ) + + private _datasource$ = new Subject<PulledDataSource<TranslatedFeature>>() + datasource$ = this._datasource$.asObservable().pipe( + shareReplay(1) + ) + + public isBusy$ = this.datasource$.pipe( + switchMap(ds => ds.isPulling$), + startWith(false) + ) + + #params = combineLatest([ + this.guardedRoute$, + this.TPRBbox$, + ]) + + #subscription: Subscription[] = [] + + ngOnDestroy(): void { + while (this.#subscription.length > 0) this.#subscription.pop().unsubscribe() + } constructor(private sapi: SAPI) { super() + this.#subscription.push( + this.#params.subscribe(([ route, { template, parcellation, region, bbox } ]) => { + let page: number = 1 + let totalPages: number = null + const datasource = new PulledDataSource({ + pull: async () => { + if (totalPages && page > totalPages) { + return [] + } + const query: any = {} + if (template) query['space_id'] = template.id + if (parcellation) query['parcellation_id'] = parcellation.id + if (region) query['region_id'] = region.name + if (bbox) query['bbox'] = JSON.stringify(bbox) + + /** + * some routes, such as geneexpression, are specifically disabled + */ + if (!route) { + totalPages = 0 + this.#total.next(0) + return [] + } + + const results = await this.sapi.v3Get(`/feature/${route}`, { + query: { + ...this.queryParams, + ...query, + page + } + }).pipe( + switchMap(resp => { + totalPages = resp.pages || 0 + this.#total.next(resp.total || 0) + if (resp.items.length === 0) { + return of([] as TranslatedFeature[]) + } + return forkJoin( + resp.items.map(feature => translateV3Entities.translateFeature(feature)) + ) + }) + ).toPromise() + page += 1 + return results + }, + annotations: { + ...this.queryParams, + } + }) + this._datasource$.next(datasource) + datasource.pull() + }) + ) } ngOnChanges(sc: SimpleChanges): void { @@ -36,32 +119,4 @@ export class ListDirective extends FeatureBase{ this.guardedRoute$.next(AllFeatures[featureType]) } } - - public features$: Observable<Feature[]> = combineLatest([ - this.guardedRoute$, - this.TPRBbox$, - ]).pipe( - tap(() => this.state$.next('busy')), - switchMap(([route, { template, parcellation, region, bbox }]) => { - 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 - if (bbox) query['bbox'] = JSON.stringify(bbox) - return this.sapi.getV3Features(route, { - query: { - ...this.queryParams, - ...query, - } as any - }) - }), - catchError(() => { - this.state$.next("noresult") - return of([] as Feature[]) - }), - tap(result => this.state$.next(result.length > 0 ? 'result' : 'noresult')), - ) } diff --git a/src/features/module.ts b/src/features/module.ts index ba78304b9..1e472a8df 100644 --- a/src/features/module.ts +++ b/src/features/module.ts @@ -26,7 +26,6 @@ import { FilterCategoriesPipe } from "./filterCategories.pipe"; import { ListDirective } from "./list/list.directive"; import { MatChipsModule } from "@angular/material/chips"; import { FeatureFilterDirective } from "./feature.filter.directive"; -import { FilterGroupList } from "./filterGrpFeat.directive" import { GroupFeaturesToName } from "./grpFeatToName.pipe"; @NgModule({ @@ -56,7 +55,6 @@ import { GroupFeaturesToName } from "./grpFeatToName.pipe"; FilterCategoriesPipe, ListDirective, FeatureFilterDirective, - FilterGroupList, CategoryAccDirective, VoiBboxDirective, diff --git a/src/util/pullable.ts b/src/util/pullable.ts new file mode 100644 index 000000000..2c041f309 --- /dev/null +++ b/src/util/pullable.ts @@ -0,0 +1,159 @@ +import { DataSource } from "@angular/cdk/collections" +import { BehaviorSubject, Observable, ReplaySubject, Subscription, combineLatest, concat, of, pipe } from "rxjs" +import { finalize, map, scan, shareReplay, startWith, tap } from "rxjs/operators" + +export interface IPuller<T> { + next: (cb: (val: T) => void) => void + complete: (cb: () => void) => void +} + +interface PaginatedArg<T> { + pull?: () => Promise<T[]> + children?: PulledDataSource<T>[] + annotations?: Record<string, string> +} + +export class IsAlreadyPulling extends Error {} + + +/** + * Modifed Datasource + * Allowing pull driven datasource + * With backwards compatibility with original datasource. + */ +export class PulledDataSource<T> extends DataSource<T> { + + protected annotations: Record<string, string> + + protected onPulled() { + return pipe( + ) + } + + #pull: () => Promise<T[]> + + completed = false + private _data = new ReplaySubject<T[]>() + currentValue: T[] = [] + finalValue: T[] = [] + + protected _isPulling = false + protected _isPulling$ = new BehaviorSubject<boolean>(false) + isPulling$ = this._isPulling$.pipe( + shareReplay(1) + ) + set isPulling(val: boolean) { + this._isPulling = val + this._isPulling$.next(val) + } + get isPulling(){ + return this._isPulling + } + + + async pull(): Promise<T[]> { + if (this.completed) { + return [] + } + if (this.isPulling) { + throw new IsAlreadyPulling(`PulledDataSource is already pulling`) + } + + if (!this.#pull) { + return [] + } + this.isPulling = true + const newResults = await this.#pull() + this.isPulling = false + if (newResults.length === 0) { + this.complete() + return [] + } + this._data.next(newResults) + return newResults + } + + constructor(arg?: PaginatedArg<T>){ + super() + const { pull, annotations } = arg || {} + if (!pull) { + throw new Error(`pull method must be provided for PulledDataSource`) + } + this.#pull = pull + this.annotations = annotations + + } + + connect(): Observable<readonly T[]> { + return this._data.pipe( + startWith([] as T[]), + scan((acc, curr) => [...acc, ...curr]), + tap((v: T[]) => { + this.currentValue = v + }), + ) + } + complete() { + this.completed = true + // must assign final value synchronously + this.finalValue = this.currentValue || [] + this._data.complete() + } + disconnect(): void { + + } +} + +export class ParentDatasource<T> extends PulledDataSource<T> { + + #subscriptions: Subscription[] = [] + _children: PulledDataSource<T>[] = [] + constructor(arg: PaginatedArg<T>){ + super({ pull: async () => [], annotations: arg.annotations }) + const { children } = arg + this._children = children + } + + async pull() { + for (const ds of this._children) { + if (!ds.completed) { + return await ds.pull() + } + } + return [] + } + + connect(): Observable<readonly T[]> { + if (this._children.length === 0) { + return of([] as T[]) + } + + this.#subscriptions.push( + combineLatest(this._children.map(c => c.isPulling$)).subscribe(flags => { + this.isPulling = flags.some(flag => flag) + }) + ) + + return concat( + ...this._children.map(ds => ds.connect()) + ).pipe( + map(arr => { + const alreadyEmitted = this._children.filter(c => c.completed) + const prevValues = alreadyEmitted.flatMap(v => v.finalValue) + return [...prevValues, ...arr] + }), + + tap((v: T[]) => { + this.currentValue = v + }), + finalize(() => { + this.finalValue = this.currentValue || [] + }) + ) + } + + disconnect(): void { + super.disconnect() + while (this.#subscriptions.length > 0) this.#subscriptions.pop().unsubscribe() + } +} diff --git a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css index f2fe15fbc..ea70c88ed 100644 --- a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css +++ b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css @@ -3,7 +3,7 @@ padding: 0.5rem 0; } -.container +.nglayer-container { display: flex; width: 100%; diff --git a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html index cb90abf4d..6ed7afca6 100644 --- a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html +++ b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html @@ -1,4 +1,4 @@ -<div class="container" [ngClass]="{ 'text-muted': !visible }"> +<div class="nglayer-container" [ngClass]="{ 'text-muted': !visible }"> <button mat-icon-button [matTooltip]="CONST.TOGGLE_LAYER_VISILITY" diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index f71a92fe0..19914ede1 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -1009,12 +1009,17 @@ </span> </ng-template> </button> - <div + + <!-- TODO voiBbox directive is used to draw outlines for VOI + this has been temporarily disabled, since datasource is paginated + and how bounding boxes are drawn needs to be reconsidered --> + + <!-- <div *ngIf="voiSwitch.switchState$ | async" voiBbox [features]="voiFeatureEntryCmp.features$ | async"> - </div> + </div> --> </ng-template> <div -- GitLab