diff --git a/docs/releases/v2.11.0.md b/docs/releases/v2.11.0.md index 66b299e4f20eb350124565f90f434fc437956519..548e2e064c8be5d708a744b7fa0d682b12c45e85 100644 --- a/docs/releases/v2.11.0.md +++ b/docs/releases/v2.11.0.md @@ -5,6 +5,7 @@ - Automatically selects human multilevel atlas on startup, if no atlases are selected - Allow multiple region selection for visualization purposes (`<ctrl>` + `[click]` on region) - Allow point to be selected (`[right click]`) and enabling map assignment +- Allow atlas download ## Behind the scenes diff --git a/e2e/util/selenium/layout.js b/e2e/util/selenium/layout.js index 2b102b6245845b4ec55a57d7b99df3c249292381..27f1f6e15d88cb4afa500609f1c49e62c7ad586b 100644 --- a/e2e/util/selenium/layout.js +++ b/e2e/util/selenium/layout.js @@ -600,11 +600,6 @@ class WdLayoutPage extends WdBase{ .findElement( By.css('[aria-label="Show pinned datasets"]') ) } - async getNumberOfFavDataset(){ - const attr = await this._getFavDatasetIcon().getAttribute('pinned-datasets-length') - return Number(attr) - } - async showPinnedDatasetPanel(){ await this._getFavDatasetIcon().click() await this.wait(500) diff --git a/src/atlas-download/atlas-download.directive.spec.ts b/src/atlas-download/atlas-download.directive.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e81fe2966b376e21eded2b6ec96a79a85faaf616 --- /dev/null +++ b/src/atlas-download/atlas-download.directive.spec.ts @@ -0,0 +1,9 @@ +import { NEVER } from 'rxjs'; +import { AtlasDownloadDirective } from './atlas-download.directive'; + +describe('AtlasDownloadDirective', () => { + it('should create an instance', () => { + const directive = new AtlasDownloadDirective(NEVER as any); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/atlas-download/atlas-download.directive.ts b/src/atlas-download/atlas-download.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..03629a5e09e189e09397b833840cee1b8093ce3a --- /dev/null +++ b/src/atlas-download/atlas-download.directive.ts @@ -0,0 +1,99 @@ +import { Directive, HostListener } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Subject, concat, of } from 'rxjs'; +import { distinctUntilChanged, shareReplay, take } from 'rxjs/operators'; +import { SAPI } from 'src/atlasComponents/sapi'; +import { MainState } from 'src/state'; +import { fromRootStore, selectors } from "src/state/atlasSelection" +import { wait } from "src/util/fn" + +@Directive({ + selector: '[sxplrAtlasDownload]', + exportAs: 'atlasDlDct' +}) +export class AtlasDownloadDirective { + + @HostListener('click') + async onClick(){ + try { + + this.#busy$.next(true) + const { parcellation, template } = await this.store.pipe( + fromRootStore.distinctATP(), + take(1) + ).toPromise() + + const selectedRegions = await this.store.pipe( + select(selectors.selectedRegions), + take(1) + ).toPromise() + + const endpoint = await SAPI.BsEndpoint$.pipe( + take(1) + ).toPromise() + + const url = new URL(`${endpoint}/atlas_download`) + const query = { + parcellation_id: parcellation.id, + space_id: template.id, + } + if (selectedRegions.length === 1) { + query['region_id'] = selectedRegions[0].name + } + for (const key in query) { + url.searchParams.set(key, query[key]) + } + + const resp = await fetch(url) + const { task_id } = await resp.json() + + if (!task_id) { + throw new Error(`Task id not found`) + } + const pingUrl = new URL(`${endpoint}/atlas_download/${task_id}`) + while (true) { + await wait(320) + const resp = await fetch(pingUrl) + if (resp.status >= 400) { + throw new Error(`task id thrown error ${resp.status}, ${resp.statusText}, ${resp.body}`) + } + const { status } = await resp.json() + if (status === "SUCCESS") { + break + } + } + + /** + * n.b. this *needs* to happen in the same invocation chain from when click happened + * modern browser is pretty strict on what can and cannot + */ + const anchor = document.createElement('a') + anchor.href = `${endpoint}/atlas_download/${task_id}/download` + anchor.target = "_blank" + anchor.download = "download.zip" + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + this.#busy$.next(false) + } catch (e) { + this.#busy$.next(false) + this.#error$.next(e.toString()) + } + + } + + #busy$ = new Subject<boolean>() + busy$ = concat( + of(false), + this.#busy$, + ).pipe( + distinctUntilChanged(), + shareReplay(1) + ) + + #error$ = new Subject<string>() + error$ = this.#error$.pipe() + + constructor(private store: Store<MainState>) { } + +} diff --git a/src/atlas-download/atlas-download.module.ts b/src/atlas-download/atlas-download.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4181a9cc0526254f2cabd6fbe1aa7b9894acfc8 --- /dev/null +++ b/src/atlas-download/atlas-download.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AtlasDownloadDirective } from './atlas-download.directive'; + + +@NgModule({ + declarations: [ + AtlasDownloadDirective + ], + imports: [ + CommonModule, + ], + exports: [ + AtlasDownloadDirective + ] +}) +export class AtlasDownloadModule { } diff --git a/src/atlasComponents/sapi/schemaV3.ts b/src/atlasComponents/sapi/schemaV3.ts index 41ea4e33a0d995c722403fb5d0027b2289c91d1f..de27935f67b5a381ef1d04069f41fad1733a6b6e 100644 --- a/src/atlasComponents/sapi/schemaV3.ts +++ b/src/atlasComponents/sapi/schemaV3.ts @@ -73,6 +73,14 @@ export interface paths { /** Router Assign Point */ get: operations["router_assign_point_map_assign_get"] } + "/atlas_download": { + /** Prepare Download */ + get: operations["prepare_download_atlas_download_get"] + } + "/atlas_download/{task_id}": { + /** Get Task Id */ + get: operations["get_task_id_atlas_download__task_id__get"] + } "/feature/_types": { /** Get All Feature Types */ get: operations["get_all_feature_types_feature__types_get"] @@ -498,8 +506,6 @@ export interface components { "@type": string /** Index */ index: unknown[] - /** Dtype */ - dtype: string /** Columns */ columns: unknown[] /** Ndim */ @@ -1697,6 +1703,52 @@ export interface operations { } } } + prepare_download_atlas_download_get: { + /** Prepare Download */ + parameters: { + query: { + space_id: string + parcellation_id: string + region_id?: string + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record<string, never> + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } + get_task_id_atlas_download__task_id__get: { + /** Get Task Id */ + parameters: { + path: { + task_id: string + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record<string, never> + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } get_all_feature_types_feature__types_get: { /** Get All Feature Types */ parameters?: { diff --git a/src/extra_styles.css b/src/extra_styles.css index 741c49c6f681474c3abe5e514be06dcd32320068..82f71ff959dc619fe28f06c56fab628041b69b03 100644 --- a/src/extra_styles.css +++ b/src/extra_styles.css @@ -266,6 +266,11 @@ markdown-dom p animation: spinning 700ms linear infinite running; } +.spinning +{ + animation: spinning 700ms linear infinite running; +} + .theme-controlled.btn, .theme-controlled.btn, .theme-controlled.btn diff --git a/src/ui/topMenu/module.ts b/src/ui/topMenu/module.ts index 241a0e3d27543f3968c81d4964834b3f7dce2fa8..71c28c73963e064bfbc1d181af1dc0a6b8707627 100644 --- a/src/ui/topMenu/module.ts +++ b/src/ui/topMenu/module.ts @@ -15,6 +15,7 @@ import { TopMenuCmp } from "./topMenuCmp/topMenu.components"; import { UserAnnotationsModule } from "src/atlasComponents/userAnnotations"; import { QuickTourModule } from "src/ui/quickTour/module"; import { KeyFrameModule } from "src/keyframesModule/module"; +import { AtlasDownloadModule } from "src/atlas-download/atlas-download.module"; @NgModule({ imports: [ @@ -33,6 +34,7 @@ import { KeyFrameModule } from "src/keyframesModule/module"; UserAnnotationsModule, KeyFrameModule, QuickTourModule, + AtlasDownloadModule, ], declarations: [ TopMenuCmp diff --git a/src/ui/topMenu/topMenuCmp/topMenu.components.ts b/src/ui/topMenu/topMenuCmp/topMenu.components.ts index 19bd0ade69a6850a56712527af4ed665ed5e00d1..1480ecdfc96496ef9965a7132029ac0415abf0cd 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.components.ts +++ b/src/ui/topMenu/topMenuCmp/topMenu.components.ts @@ -5,7 +5,7 @@ import { TemplateRef, ViewChild, } from "@angular/core"; -import { Observable, of } from "rxjs"; +import { Observable } from "rxjs"; import { map } from "rxjs/operators"; import { AuthService } from "src/auth"; import { MatDialog, MatDialogConfig, MatDialogRef } from "@angular/material/dialog"; @@ -53,24 +53,23 @@ export class TopMenuCmp { public user$: Observable<any> public userBtnTooltip$: Observable<string> - public favDataEntries$: Observable<Partial<any>[]> public pluginTooltipText: string = `Plugins and Tools` public screenshotTooltipText: string = 'Take screenshot' public annotateTooltipText: string = 'Start annotating' public keyFrameText = `Start KeyFrames` + + busyTxt = 'Preparing bundle for download ...' + idleTxt = 'Download the atlas bundle' public quickTourData: IQuickTourData = { description: QUICKTOUR_DESC.TOP_MENU, order: 8, } - public pinnedDsNotAvail = 'We are reworking pinned dataset feature. Please check back later.' - @ViewChild('savedDatasets', { read: TemplateRef }) - private savedDatasetTmpl: TemplateRef<any> - - public openPinnedDatasets(){ - // this.bottomSheet.open(this.savedDatasetTmpl) + public downloadAtlas: IQuickTourData = { + description: 'You can download what you see in the viewer with this button.', + order: 9 } constructor( @@ -86,8 +85,6 @@ export class TopMenuCmp { ? `Logged in as ${(user && user.name) ? user.name : 'Unknown name'}` : `Not logged in`), ) - - this.favDataEntries$ = of([]) } private dialogRef: MatDialogRef<any> diff --git a/src/ui/topMenu/topMenuCmp/topMenu.template.html b/src/ui/topMenu/topMenuCmp/topMenu.template.html index 42712e8a8894c622ff5b8832b195255ecd877ee2..a33377a72f41a7126bd272754e3862651fd8b86a 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.template.html +++ b/src/ui/topMenu/topMenuCmp/topMenu.template.html @@ -102,22 +102,27 @@ <!-- pinned dataset btn --> <ng-template #pinnedDatasetBtnTmpl> <div class="btnWrapper" - (click)="openPinnedDatasets()" - [matBadge]="(favDataEntries$ | async)?.length > 0 ? (favDataEntries$ | async)?.length : null " - matBadgeColor="accent" - matBadgePosition="above after" - [matBadgeDescription]="PINNED_DATASETS_BADGE_DESC" - [matTooltip]="pinnedDsNotAvail" + [matTooltip]="(atlasDlDct.busy$| async) ? busyTxt : idleTxt" + quick-tour + [quick-tour-description]="downloadAtlas.description" + [quick-tour-order]="downloadAtlas.order" + sxplrAtlasDownload aria-disabled="true" - role="button"> + role="button" + #atlasDlDct="atlasDlDct"> <iav-dynamic-mat-button - [attr.pinned-datasets-length]="(favDataEntries$ | async)?.length" [iav-dynamic-mat-button-style]="matBtnStyle" [iav-dynamic-mat-button-color]="matBtnColor" - [iav-dynamic-mat-button-disabled]="true" + [iav-dynamic-mat-button-disabled]="atlasDlDct.busy$ | async" iav-dynamic-mat-button-aria-label="Show pinned datasets"> - <i class="fas fa-thumbtack"></i> + <ng-template [ngIf]="atlasDlDct.busy$ | async" [ngIfElse]="dlEnabledTmpl"> + <i class="spinning fas fa-spinner"></i> + </ng-template> + + <ng-template #dlEnabledTmpl> + <i class="fas fa-download"></i> + </ng-template> </iav-dynamic-mat-button> </div> </ng-template> @@ -246,37 +251,3 @@ </config-component> </mat-dialog-content> </ng-template> - -<!-- saved dataset tmpl --> - -<ng-template #savedDatasets> - <mat-list rol="list" - aria-label="Pinned datasets panel"> - <h3 mat-subheader> - <span> - Pinned Datasets - </span> - - <!-- bulk download btn --> - </h3> - - <!-- place holder when no fav data is available --> - <mat-card *ngIf="(!(favDataEntries$ | async)) || (favDataEntries$ | async).length === 0"> - <mat-card-content class="muted"> - No pinned datasets. - </mat-card-content> - </mat-card> - - <!-- render all fav dataset as mat list --> - <!-- TODO maybe use virtual scroll here? --> - - <mat-list-item - class="align-items-center" - *ngFor="let ds of (favDataEntries$ | async)" - role="listitem"> - - <!-- TODO render fav dataset --> - - </mat-list-item> - </mat-list> -</ng-template> diff --git a/src/util/fn.ts b/src/util/fn.ts index 713bc7cbe70fff8c149fdc128ada1de3671936cf..ff9b1c07fc1d93938e4f6e62a990aa34ce84422a 100644 --- a/src/util/fn.ts +++ b/src/util/fn.ts @@ -431,3 +431,9 @@ export function defaultdict<T>(fn: () => T): Record<string, T> { }, }) } + +export function wait(ms: number){ + return new Promise(rs => setTimeout(() => { + rs(null) + }, ms)) +}