diff --git a/common/constants.js b/common/constants.js index 878b0f2bfdca4df5d9b3d261c5e682b1ad3079db..24f62981b7f7962f08ddaac2e453456ed5c7117b 100644 --- a/common/constants.js +++ b/common/constants.js @@ -37,4 +37,9 @@ // additional volumes TOGGLE_SHOW_LAYER_CONTROL: `Show layer control`, } + + exports.IDS = { + // mesh loading status + MESH_LOADING_STATUS: 'mesh-loading-status' + } })(typeof exports === 'undefined' ? module.exports : exports) diff --git a/docs/releases/v2.3.0.md b/docs/releases/v2.3.0.md index f1c252954124027ddae342d240177e93a357adc8..a4bf02816a303bd2a99582a30eb82172fb5fa1bc 100644 --- a/docs/releases/v2.3.0.md +++ b/docs/releases/v2.3.0.md @@ -9,7 +9,9 @@ - Updated `README.md` - introduced zoom buttons - major UI overhaul +- tweaked mesh loading strategies. Now it will wait for all image chunks to be loaded before loading any meshes # Bugfixes: - dataset list view explicitly show loading status + diff --git a/e2e/src/navigating/loadingOrder.prod.e2e-spec.js b/e2e/src/navigating/loadingOrder.prod.e2e-spec.js new file mode 100644 index 0000000000000000000000000000000000000000..dc62278d79d3ef39bf926952d4f8eec73e0a30cb --- /dev/null +++ b/e2e/src/navigating/loadingOrder.prod.e2e-spec.js @@ -0,0 +1,120 @@ +const { AtlasPage } = require('../util') +const { IDS } = require('../../../common/constants') +const { width, height } = require('../../opts') +const { hrtime } = process + +const templates = [ + ["Big Brain (Histology)", "Cytoarchitectonic Maps"], + ["Waxholm Space rat brain MRI/DTI", "Waxholm Space rat brain atlas v3"] +] + +describe('> when loading an atlas', () => { + let iavPage = new AtlasPage() + + const getNumOfSpinners = async () => { + + try { + return await iavPage.execScript(` + const els = document.querySelectorAll('.spinnerAnimationCircle') + return els && els.length + `) + } catch (e) { + return false + } + } + + const checkRoleStatus = async () => { + try { + const text = await iavPage.execScript(` + const el = document.getElementById('${IDS.MESH_LOADING_STATUS}') + return el && el.textContent + `) + return text && /Loading\s.*?chunks/.test(text) + } catch (e) { + return false + } + } + + beforeEach(async () => { + iavPage = new AtlasPage() + await iavPage.init() + await iavPage.goto() + }) + + for (const [template, parcelation] of templates) { + + const masterTimer = {moving: null, nonmoving: null} + + it(`> for ${template}, image will be loaded first`, async () => { + const timer = hrtime() + await iavPage.selectTitleTemplateParcellation(template, parcelation) + await iavPage.wait(500) + const preSpinners = await getNumOfSpinners() + expect(preSpinners).toBe(4) + while( await checkRoleStatus() ) { + await iavPage.wait(200) + } + + const finishedTimer = hrtime(timer) + + const postSpinners = await getNumOfSpinners() + /** + * There is currently a known bug that spinner in IDS.MESH_LOADING_STATUS is hidden with opacity 0 rather than unload from DOM + * TODO fix bug + */ + expect(postSpinners).toBeLessThanOrEqual(2) + expect(postSpinners).toBeGreaterThanOrEqual(1) + + const sec = finishedTimer[0] + finishedTimer[1] / 1e9 + masterTimer.nonmoving = sec + }) + + it('> if user was panning, it should take longer as loading takes priority', async () => { + const timer = hrtime() + await iavPage.selectTitleTemplateParcellation(template, parcelation) + await iavPage.wait(500) + + await iavPage.cursorMoveToAndDrag({ + position: [ Math.round(width / 4 * 3), Math.round(height / 4) ], + delta: [ Math.round(width / 8), Math.round(height / 8)] + }) + + await iavPage.wait(500) + + await iavPage.cursorMoveToAndDrag({ + position: [ Math.round(width / 4 * 3), Math.round(height / 4) ], + delta: [ 1, Math.round(height / -4)] + }) + + while( await checkRoleStatus() ) { + await iavPage.wait(200) + } + + const finishedTimer = hrtime(timer) + + const sec = finishedTimer[0] + finishedTimer[1] / 1e9 + masterTimer.moving = sec + + expect(masterTimer.moving).toBeGreaterThan(masterTimer.nonmoving) + }) + + it('> if the meshes are already loading, do not show overlay', async () => { + + await iavPage.selectTitleTemplateParcellation(template, parcelation) + await iavPage.wait(500) + + while( await checkRoleStatus() ) { + await iavPage.wait(200) + } + + await iavPage.cursorMoveToAndDrag({ + position: [ Math.round(width / 4 * 3), Math.round(height / 4) ], + delta: [ Math.round(width / 8), Math.round(height / 8)] + }) + + const roleStatus = await checkRoleStatus() + + expect(roleStatus).toBeFalsy() + }) + } +}) diff --git a/e2e/src/util.js b/e2e/src/util.js index 4fd6e1db3c93034063432025888360c55d641cd3..faa4c075089321477470dfcc4f6fa98490ab925c 100644 --- a/e2e/src/util.js +++ b/e2e/src/util.js @@ -235,17 +235,22 @@ class WdBase{ async waitForAsync(){ const checkReady = async () => { - const el = await this._browser.findElements( + const els = await this._browser.findElements( By.css('.spinnerAnimationCircle') ) - return !el.length + const visibleEls = [] + for (const el of els) { + if (await el.isDisplayed()) { + visibleEls.push(el) + } + } + return !visibleEls.length } do { - // Do nothing, until ready + await this.wait(500) } while ( - await this.wait(100), - !(await checkReady()) + !(await retry(checkReady.bind(this), { timeout: 1000, retries: 10 })) ) } diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index af96ee110dbff8e7447eed7e7290a97e87b098a6..ce664b8210b801a8ba204181aa93204706c0f087 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -1,6 +1,6 @@ import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, ChangeDetectorRef, Output, EventEmitter } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { combineLatest, fromEvent, merge, Observable, of, Subscription, timer } from "rxjs"; +import { combineLatest, fromEvent, merge, Observable, of, Subscription, timer, asyncScheduler } from "rxjs"; import { pipeFromArray } from "rxjs/internal/util/pipe"; import { buffer, @@ -19,6 +19,7 @@ import { tap, withLatestFrom, delayWhen, + throttleTime, } from "rxjs/operators"; import { LoggingService } from "src/logging"; import { FOUR_PANEL, H_ONE_THREE, NG_VIEWER_ACTION_TYPES, SINGLE_PANEL, V_ONE_THREE } from "src/services/state/ngViewerState.store"; @@ -27,49 +28,20 @@ import { ADD_NG_LAYER, generateLabelIndexId, getMultiNgIdsRegionsLabelIndexMap, import { getExportNehuba, isSame } from "src/util/fn"; import { AtlasViewerAPIServices, IUserLandmark } from "src/atlasViewer/atlasViewer.apiService.service"; import { NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component"; -import { getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, calculateSliceZoomFactor } from "./util"; +import { getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, calculateSliceZoomFactor, scanSliceViewRenderFn as scanFn, isFirstRow, isFirstCell } from "./util"; import { NehubaViewerContainerDirective } from "./nehubaViewerInterface/nehubaViewerInterface.directive"; import { ITunableProp } from "./mobileOverlay/mobileOverlay.component"; import { compareLandmarksChanged } from "src/util/constants"; import { PureContantService } from "src/util"; -import { ARIA_LABELS } from 'common/constants' +import { ARIA_LABELS, IDS } from 'common/constants' + +const { MESH_LOADING_STATUS } = IDS const { ZOOM_IN, ZOOM_OUT, } = ARIA_LABELS -const isFirstRow = (cell: HTMLElement) => { - const { parentElement: row } = cell - const { parentElement: container } = row - return container.firstElementChild === row -} - -const isFirstCell = (cell: HTMLElement) => { - const { parentElement: row } = cell - return row.firstElementChild === cell -} - -const scanFn: (acc: [boolean, boolean, boolean], curr: CustomEvent) => [boolean, boolean, boolean] = (acc, curr) => { - - const target = curr.target as HTMLElement - const targetIsFirstRow = isFirstRow(target) - const targetIsFirstCell = isFirstCell(target) - const idx = targetIsFirstRow - ? targetIsFirstCell - ? 0 - : 1 - : targetIsFirstCell - ? 2 - : null - - const returnAcc = [...acc] - const num1 = typeof curr.detail.missingChunks === 'number' ? curr.detail.missingChunks : 0 - const num2 = typeof curr.detail.missingImageChunks === 'number' ? curr.detail.missingImageChunks : 0 - returnAcc[idx] = Math.max(num1, num2) > 0 - return returnAcc as [boolean, boolean, boolean] -} - @Component({ selector : 'ui-nehuba-container', templateUrl : './nehubaContainer.template.html', @@ -83,6 +55,7 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public ARIA_LABEL_ZOOM_IN = ZOOM_IN public ARIA_LABEL_ZOOM_OUT = ZOOM_OUT + public ID_MESH_LOADING_STATUS = MESH_LOADING_STATUS @ViewChild(NehubaViewerContainerDirective,{static: true}) public nehubaContainerDirective: NehubaViewerContainerDirective @@ -97,8 +70,10 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { public viewerLoaded: boolean = false + private sliceRenderEvent$: Observable<CustomEvent> public sliceViewLoadingMain$: Observable<[boolean, boolean, boolean]> public perspectiveViewLoading$: Observable<string|null> + public showPerpsectiveScreen$: Observable<string> public templateSelected$: Observable<any> private newViewer$: Observable<any> @@ -230,34 +205,69 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { distinctUntilChanged(), ) - this.sliceViewLoadingMain$ = fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent').pipe( + this.sliceRenderEvent$ = fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent').pipe( + shareReplay(1), + map(ev => ev as CustomEvent) + ) + + this.sliceViewLoadingMain$ = this.sliceRenderEvent$.pipe( scan(scanFn, [null, null, null]), startWith([true, true, true] as [boolean, boolean, boolean]), shareReplay(1), ) + this.showPerpsectiveScreen$ = this.newViewer$.pipe( + switchMapTo(this.sliceRenderEvent$.pipe( + scan((acc, curr) => { + + /** + * if at any point, all chunks have been loaded, always return loaded state + */ + if (acc.every(v => v === 0)) return [0, 0, 0] + const { detail = {}, target } = curr || {} + const { missingChunks = -1, missingImageChunks = -1 } = detail + const idx = this.findPanelIndex(target as HTMLElement) + const returnAcc = [...acc] + if (idx >= 0) { + returnAcc[idx] = missingChunks + missingImageChunks + } + return returnAcc + }, [-1, -1, -1]), + map(arr => { + let sum = 0 + let uncertain = false + for (const num of arr) { + if (num < 0) { + uncertain = true + } else { + sum += num + } + } + return sum > 0 + ? `Loading ${sum}${uncertain ? '+' : ''} chunks ...` + : null + }), + distinctUntilChanged(), + startWith('Loading ...'), + throttleTime(100, asyncScheduler, { leading: true, trailing: true }) + )) + ) + /* missing chunk perspective view */ this.perspectiveViewLoading$ = fromEvent(this.elementRef.nativeElement, 'perpspectiveRenderEvent') .pipe( filter(event => isDefined(event) && isDefined((event as any).detail) && isDefined((event as any).detail.lastLoadedMeshId) ), map(event => { - const e = (event as any) - const lastLoadedIdString = e.detail.lastLoadedMeshId.split(',')[0] - const lastLoadedIdNum = Number(lastLoadedIdString) /** * TODO dig into event detail to see if the exact mesh loaded */ - return e.detail.meshesLoaded >= this.nehubaViewer.numMeshesToBeLoaded + const { meshesLoaded, meshFragmentsLoaded, lastLoadedMeshId } = (event as any).detail + return meshesLoaded >= this.nehubaViewer.numMeshesToBeLoaded ? null - : isNaN(lastLoadedIdNum) - ? 'Loading unknown chunk' - : lastLoadedIdNum >= 65500 - ? 'Loading auxiliary chunk' - // : this.regionsLabelIndexMap.get(lastLoadedIdNum) - // ? `Loading ${this.regionsLabelIndexMap.get(lastLoadedIdNum).name}` - : 'Loading meshes ...' + : 'Loading meshes ...' }), + distinctUntilChanged() ) this.ngLayers$ = this.store.pipe( @@ -308,7 +318,7 @@ export class NehubaContainer implements OnInit, OnChanges, OnDestroy { /* each time a new viewer is initialised, take the first event to get the translation function */ this.subscriptions.push( this.newViewer$.pipe( - switchMap(() => pipeFromArray([...takeOnePipe])(fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent'))), + switchMap(() => pipeFromArray([...takeOnePipe])(this.sliceRenderEvent$)), ).subscribe((events) => { for (const idx in [0, 1, 2]) { const ev = events[idx] as CustomEvent diff --git a/src/ui/nehubaContainer/nehubaContainer.style.css b/src/ui/nehubaContainer/nehubaContainer.style.css index dbb79cdf0a18e1c6d4d788e15662b3c15c35ca69..a3a28715e6e18ae84d41dba8115525a6c5568bb9 100644 --- a/src/ui/nehubaContainer/nehubaContainer.style.css +++ b/src/ui/nehubaContainer/nehubaContainer.style.css @@ -170,3 +170,13 @@ div#scratch-pad opacity: 1.0 !important; pointer-events: all !important; } + +.screen-overlay +{ + background-color: rgba(255, 255, 255, 0.7); +} + +:host-context([darktheme="true"]) .screen-overlay +{ + background-color: rgba(0, 0, 0, 0.7); +} diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index f338d47990e31c4468de71b5e77375a73d15d645..bdaedfe4394a0b9d986616ad624373faff96119c 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -86,16 +86,32 @@ <!-- perspective view tmpl --> <ng-template #overlayPerspectiveTmpl> - <layout-floating-container landmarkContainer> + <layout-floating-container class="tmp" landmarkContainer> + + <div class="d-flex flex-column justify-content-center align-items-center w-100 h-100 position-absolute opacity-crossfade screen-overlay pe-none" + [ngClass]="{onHover: !!(showPerpsectiveScreen$ | async)}" + [attr.id]="ID_MESH_LOADING_STATUS" + role="status"> + + <div class="spinnerAnimationCircle"> + </div> + <mat-list> + <mat-list-item> + {{ showPerpsectiveScreen$ | async }} + </mat-list-item> + </mat-list> + </div> <!-- maximise/minimise button --> <ng-container *ngTemplateOutlet="panelCtrlTmpl; context: { panelIndex: 3, visible: (panelOrder$ | async | reorderPanelIndexPipe : ( hoveredPanelIndices$ | async )) === 3 }"> </ng-container> + <!-- mesh loading is still weird --> + <!-- if the precomputed server does not have the necessary fragment file, then the numberws will not collate --> <div *ngIf="perspectiveViewLoading$ | async" class="loadingIndicator"> <div class="spinnerAnimationCircle"></div> - <div perspectiveLoadingText> + <div *ngIf="false" perspectiveLoadingText> {{ perspectiveViewLoading$ | async }} </div> </div> diff --git a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts index 34904988db844bd741a769ce2e185fd109f7c4e8..035fb084662906c801ae9b11d2724cec4f50067f 100644 --- a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts +++ b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts @@ -1,7 +1,7 @@ import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, Renderer2, Optional, Inject } from "@angular/core"; -import { fromEvent, Subscription, ReplaySubject, Subject, BehaviorSubject } from 'rxjs' +import { fromEvent, Subscription, ReplaySubject, Subject, BehaviorSubject, Observable } from 'rxjs' import { pipeFromArray } from "rxjs/internal/util/pipe"; -import { debounceTime, filter, map, scan } from "rxjs/operators"; +import { debounceTime, filter, map, scan, startWith, debounce, tap } from "rxjs/operators"; import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; import { StateInterface as ViewerConfiguration } from "src/services/state/viewerConfig.store"; import { getNgIdLabelIndexFromId } from "src/services/stateStore.service"; @@ -12,6 +12,7 @@ import { getExportNehuba, getViewer, setNehubaViewer } from "src/util/fn"; import '!!file-loader?context=third_party&name=main.bundle.js!export-nehuba/dist/min/main.bundle.js' import '!!file-loader?context=third_party&name=chunk_worker.bundle.js!export-nehuba/dist/min/chunk_worker.bundle.js' +import { scanSliceViewRenderFn } from "../util"; const NG_LANDMARK_LAYER_NAME = 'spatial landmark layer' const NG_USER_LANDMARK_LAYER_NAME = 'user landmark layer' @@ -63,6 +64,8 @@ const scanFn: (acc: LayerLabelIndex[], curr: LayerLabelIndex) => LayerLabelIndex export class NehubaViewerUnit implements OnInit, OnDestroy { + private sliceviewLoading$: Observable<boolean> + public overrideShowLayers: string[] = [] get showLayersName() { return [ @@ -413,10 +416,19 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { } public ngOnInit() { + this.sliceviewLoading$ = fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent').pipe( + scan(scanSliceViewRenderFn, [ null, null, null ]), + map(arrOfFlags => arrOfFlags.some(flag => flag)), + startWith(true), + ) + this.subscriptions.push( this.loadMeshes$.pipe( scan(scanFn, []), - debounceTime(100) + debounceTime(100), + debounce(() => this.sliceviewLoading$.pipe( + filter(flag => !flag), + )) ).subscribe(layersLabelIndex => { let totalMeshes = 0 for (const layerLayerIndex of layersLabelIndex) { @@ -425,7 +437,7 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { this.nehubaViewer.setMeshesToLoad(labelIndicies, layer) } // TODO implement total mesh to be loaded and mesh loading UI - // this.numMeshesToBeLoaded = totalMeshes + this.numMeshesToBeLoaded = totalMeshes }), ) diff --git a/src/ui/nehubaContainer/util.ts b/src/ui/nehubaContainer/util.ts index f1fbb9348f338683c81fbabe86680140c2f3fc55..edadec62d60a7c10ff954a35b669aa0e90315412 100644 --- a/src/ui/nehubaContainer/util.ts +++ b/src/ui/nehubaContainer/util.ts @@ -214,3 +214,39 @@ export const importNehubaFactory = appendSrc => { return pr } } + + +export const isFirstRow = (cell: HTMLElement) => { + const { parentElement: row } = cell + const { parentElement: container } = row + return container.firstElementChild === row +} + +export const isFirstCell = (cell: HTMLElement) => { + const { parentElement: row } = cell + return row.firstElementChild === cell +} + +export const scanSliceViewRenderFn: (acc: [boolean, boolean, boolean], curr: CustomEvent) => [boolean, boolean, boolean] = (acc, curr) => { + + const target = curr.target as HTMLElement + const targetIsFirstRow = isFirstRow(target) + const targetIsFirstCell = isFirstCell(target) + const idx = targetIsFirstRow + ? targetIsFirstCell + ? 0 + : 1 + : targetIsFirstCell + ? 2 + : null + + const returnAcc = [...acc] + const num1 = typeof curr.detail.missingChunks === 'number' ? curr.detail.missingChunks : 0 + const num2 = typeof curr.detail.missingImageChunks === 'number' ? curr.detail.missingImageChunks : 0 + if (num1 < 0 && num2 < 0) { + returnAcc[idx] = true + } else { + returnAcc[idx] = Math.max(num1, num2) > 0 + } + return returnAcc as [boolean, boolean, boolean] +}