diff --git a/docs/releases/v2.12.2.md b/docs/releases/v2.12.2.md index 460f1ad348722d61037a9818bc164fc191a7ded3..8043ff60944528957119ef01c3299f31e352be08 100644 --- a/docs/releases/v2.12.2.md +++ b/docs/releases/v2.12.2.md @@ -4,3 +4,4 @@ - fixes screenshot in fsaverage - on hover region label in fsaverage now display properly +- fixes annotation mode (export annotations, annotations fail to render in viewer on startup (via shared link, local storage etc)) diff --git a/src/atlasComponents/annotations/annotation.service.ts b/src/atlasComponents/annotations/annotation.service.ts index 543271a3a11ac766c9f194152d06b3c497a26a4f..d133671c3682ccf16c5af36271a12b90fbb4cf73 100644 --- a/src/atlasComponents/annotations/annotation.service.ts +++ b/src/atlasComponents/annotations/annotation.service.ts @@ -1,6 +1,6 @@ import { BehaviorSubject, Observable } from "rxjs"; import { distinctUntilChanged } from "rxjs/operators"; -import { getUuid } from "src/util/fn"; +import { getUuid, waitFor } from "src/util/fn"; import { PeriodicSvc } from "src/util/periodic.service"; export type TNgAnnotationEv = { @@ -144,8 +144,8 @@ export class AnnotationLayer { return false }) } - removeAnnotation(spec: { id: string }) { - if (!this.nglayer) return + async removeAnnotation(spec: { id: string }) { + await waitFor(() => !!this.nglayer?.layer?.localAnnotations) const { localAnnotations } = this.nglayer.layer this.idset.delete(spec.id) const ref = localAnnotations.references.get(spec.id) @@ -155,8 +155,8 @@ export class AnnotationLayer { } } async updateAnnotation(spec: AnnotationSpec) { - const localAnnotations = this.nglayer?.layer?.localAnnotations - if (!localAnnotations) return + await waitFor(() => !!this.nglayer?.layer?.localAnnotations) + const { localAnnotations } = this.nglayer.layer const ref = localAnnotations.references.get(spec.id) const _spec = this.parseNgSpecType(spec) if (ref) { diff --git a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.spec.ts b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..23662fcfdf9d305ec6daa00710944996af46b7fd --- /dev/null +++ b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.spec.ts @@ -0,0 +1,117 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" +import { AnnotationList } from "./annotationList.component" +import { FileInputModule } from "src/getFileInput/module" +import { CommonModule } from "@angular/common" +import { ModularUserAnnotationToolService } from "../tools/service" +import { NoopAnimationsModule } from "@angular/platform-browser/animations" +import { MatDialogModule } from "@angular/material/dialog" +import { ComponentStore } from "@ngrx/component-store" +import { NEVER, of } from "rxjs" +import { MatSnackBarModule } from "@angular/material/snack-bar" +import { StateModule } from "src/state" +import { hot } from "jasmine-marbles" +import { IAnnotationGeometry } from "../tools/type" +import { MatTooltipModule } from "@angular/material/tooltip" +import { MatButtonModule } from "@angular/material/button" +import { MatCardModule } from "@angular/material/card" +import { ZipFilesOutputModule } from "src/zipFilesOutput/module" +import { AnnotationVisiblePipe } from "../annotationVisible.pipe" +import { SingleAnnotationClsIconPipe, SingleAnnotationNamePipe } from "../singleAnnotationUnit/singleAnnotationUnit.component" +import { MatExpansionModule } from "@angular/material/expansion" + +class MockModularUserAnnotationToolService { + hiddenAnnotations$ = of([]) + toggleAnnotationVisibilityById = jasmine.createSpy() + parseAnnotationObject = jasmine.createSpy() + importAnnotation = jasmine.createSpy() + spaceFilteredManagedAnnotations$ = NEVER + rSpaceManagedAnnotations$ = NEVER + otherSpaceManagedAnnotations$ = NEVER +} + +const readmeContent = `{id}.sands.json file contains the data of annotations. {id}.desc.json contains the metadata of annotations.` + +describe("annotationList.component.ts", () => { + let component: AnnotationList; + let fixture: ComponentFixture<AnnotationList>; + + describe("AnnotationList", () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + FileInputModule, + NoopAnimationsModule, + MatDialogModule, + MatSnackBarModule, + StateModule, // needed for iavStateAggregator directive + MatTooltipModule, + MatButtonModule, + MatCardModule, + MatExpansionModule, + ZipFilesOutputModule, + ], + providers: [ + ComponentStore, + { + provide: ModularUserAnnotationToolService, + useClass: MockModularUserAnnotationToolService + } + ], + declarations: [ + AnnotationList, + AnnotationVisiblePipe, + SingleAnnotationNamePipe, + SingleAnnotationClsIconPipe, + ] + }).compileComponents() + }) + it("> can be init", () => { + + fixture = TestBed.createComponent(AnnotationList) + component = fixture.componentInstance + fixture.detectChanges() + expect(component).toBeTruthy() + }) + + describe("> filesExport$", () => { + beforeEach(() => { + const svc = TestBed.inject(ModularUserAnnotationToolService) + + const dummyGeom: Partial<IAnnotationGeometry> = { + id: 'foo', + toSands() { + return {} as any + }, + toMetadata() { + return {} as any + }, + toJSON() { + return {} + } + } + svc.spaceFilteredManagedAnnotations$ = of([dummyGeom] as IAnnotationGeometry[]) + }) + it("> do not emit duplicated values", () => { + + fixture = TestBed.createComponent(AnnotationList) + component = fixture.componentInstance + + expect(component.filesExport$).toBeObservable( + hot('(a|)', { + a: [{ + filename: 'README.md', + filecontent: readmeContent + }, { + filename: `foo.sands.json`, + filecontent: '{}' + }, { + filename: `foo.desc.json`, + filecontent: '{}' + }], + }) + ) + }) + }) + }) +}) diff --git a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts index bad568f3822e640174da67c0162142b3a2188894..7463f9a105d179f96eb273d1880e2facda9a9155 100644 --- a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts +++ b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts @@ -3,8 +3,8 @@ import { ARIA_LABELS, CONST } from "common/constants"; import { ModularUserAnnotationToolService } from "../tools/service"; import { IAnnotationGeometry, TExportFormats } from "../tools/type"; import { ComponentStore } from "src/viewerModule/componentStore"; -import { map, shareReplay, startWith } from "rxjs/operators"; -import { combineLatest, Observable, Subscription } from "rxjs"; +import { debounceTime, map, shareReplay, startWith } from "rxjs/operators"; +import { combineLatest, concat, Observable, of, Subscription } from "rxjs"; import { TZipFileConfig } from "src/zipFilesOutput/type"; import { TFileInputEvent } from "src/getFileInput/type"; import { FileInputDirective } from "src/getFileInput/getFileInput.directive"; @@ -44,9 +44,11 @@ export class AnnotationList { startWith(false) ) - public filesExport$: Observable<TZipFileConfig[]> = this.managedAnnotations$.pipe( - startWith([] as IAnnotationGeometry[]), - shareReplay(1), + public filesExport$: Observable<TZipFileConfig[]> = concat( + of([] as IAnnotationGeometry[]), + this.managedAnnotations$ + ).pipe( + debounceTime(0), map(manAnns => { const readme = { filename: 'README.md', @@ -61,11 +63,12 @@ export class AnnotationList { const annotationDesc = manAnns.map(ann => { return { filename: `${ann.id}.desc.json`, - filecontent: JSON.stringify(this.annotSvc.exportAnnotationMetadata(ann), null, 2) + filecontent: JSON.stringify(ann.toMetadata(), null, 2) } }) return [ readme, ...annotationSands, ...annotationDesc ] - }) + }), + shareReplay(1), ) constructor( private annotSvc: ModularUserAnnotationToolService, @@ -82,10 +85,10 @@ export class AnnotationList { this.managedAnnotations$.subscribe(anns => this.managedAnnotations = anns), combineLatest([ this.managedAnnotations$.pipe( - startWith([]) + startWith([] as IAnnotationGeometry[]) ), this.annotationInOtherSpaces$.pipe( - startWith([]) + startWith([] as IAnnotationGeometry[]) ) ]).subscribe(([ann, annOther]) => { this.userAnnRoute = { diff --git a/src/atlasComponents/userAnnotations/tools/line.ts b/src/atlasComponents/userAnnotations/tools/line.ts index c156a9e09544fb3e82b50820f49d50668a4ea094..9223b1d989aacccab83e470f9145aca1b81674a9 100644 --- a/src/atlasComponents/userAnnotations/tools/line.ts +++ b/src/atlasComponents/userAnnotations/tools/line.ts @@ -78,11 +78,12 @@ export class Line extends IAnnotationGeometry{ x: x1, y: y1, z: z1 } = this.points[1] + const { id } = this.space return { '@id': this.id, '@type': "tmp/line", coordinateSpace: { - '@id': this.space["@id"] + '@id': id }, coordinatesFrom: [getCoord(x0/1e6), getCoord(y0/1e6), getCoord(z0/1e6)], coordinatesTo: [getCoord(x1/1e6), getCoord(y1/1e6), getCoord(z1/1e6)], diff --git a/src/atlasComponents/userAnnotations/tools/point.ts b/src/atlasComponents/userAnnotations/tools/point.ts index 58d8cdde8acd05863e682814e37f8d667124e36e..5086a30459665423439da1b8ee6cbb2fe51d35df 100644 --- a/src/atlasComponents/userAnnotations/tools/point.ts +++ b/src/atlasComponents/userAnnotations/tools/point.ts @@ -82,12 +82,13 @@ export class Point extends IAnnotationGeometry { } toSands(): TSandsPoint{ + const { id } = this.space const {x, y, z} = this return { '@id': this.id, '@type': 'https://openminds.ebrains.eu/sands/CoordinatePoint', coordinateSpace: { - '@id': this.space["@id"] + '@id': id }, coordinates:[ getCoord(x/1e6), getCoord(y/1e6), getCoord(z/1e6) ] } diff --git a/src/atlasComponents/userAnnotations/tools/poly.ts b/src/atlasComponents/userAnnotations/tools/poly.ts index 244c71a6a2d11163ba70af11727adcefd1ac399f..4bc86182aa6849161b384923cabd24d76534f502 100644 --- a/src/atlasComponents/userAnnotations/tools/poly.ts +++ b/src/atlasComponents/userAnnotations/tools/poly.ts @@ -94,11 +94,12 @@ export class Polygon extends IAnnotationGeometry{ } toSands(): TSandsPolyLine{ + const { id } = this.space return { "@id": this.id, "@type": 'tmp/poly', coordinateSpace: { - '@id': this.space["@id"], + '@id': id, }, coordinates: this.points.map(p => { const { x, y, z } = p diff --git a/src/atlasComponents/userAnnotations/tools/service.spec.ts b/src/atlasComponents/userAnnotations/tools/service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdeacfde828aff2d09b31c81fd9b7e2de5baae63 --- /dev/null +++ b/src/atlasComponents/userAnnotations/tools/service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from "@angular/core/testing" +import { ModularUserAnnotationToolService } from "./service" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { NoopAnimationsModule } from "@angular/platform-browser/animations" +import { MatSnackBarModule } from "@angular/material/snack-bar" +import { ANNOTATION_EVENT_INJ_TOKEN, INJ_ANNOT_TARGET } from "./type" +import { NEVER, Subject } from "rxjs" +import { atlasSelection } from "src/state" + +describe("userAnnotations/service.ts", () => { + + describe("ModularUserAnnotationToolService", () => { + let service: ModularUserAnnotationToolService + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + MatSnackBarModule, + ], + providers: [ + provideMockStore(), + { + provide: INJ_ANNOT_TARGET, + useValue: NEVER + }, + { + provide: ANNOTATION_EVENT_INJ_TOKEN, + useValue: new Subject() + }, + ModularUserAnnotationToolService + ] + }) + + const mStore = TestBed.inject(MockStore) + mStore.overrideSelector(atlasSelection.selectors.selectedTemplate, null) + mStore.overrideSelector(atlasSelection.selectors.viewerMode, null) + }) + it("> can be init", () => { + const svc = TestBed.inject(ModularUserAnnotationToolService) + expect(svc).toBeDefined() + }) + }) +}) diff --git a/src/atlasComponents/userAnnotations/tools/service.ts b/src/atlasComponents/userAnnotations/tools/service.ts index 03f0f060e620f05897e380044ef381bea50c5c71..9ef8d363ac2a1201e28854b1b940d44967f5def6 100644 --- a/src/atlasComponents/userAnnotations/tools/service.ts +++ b/src/atlasComponents/userAnnotations/tools/service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, combineLatest, fromEvent, merge, Observable, of, Subje import {map, switchMap, filter, shareReplay, pairwise, withLatestFrom } from "rxjs/operators"; import { NehubaViewerUnit } from "src/viewerModule/nehuba"; import { NEHUBA_INSTANCE_INJTKN } from "src/viewerModule/nehuba/util"; -import { AbsToolClass, ANNOTATION_EVENT_INJ_TOKEN, IAnnotationEvents, IAnnotationGeometry, INgAnnotationTypes, INJ_ANNOT_TARGET, TAnnotationEvent, ClassInterface, TCallbackFunction, TSands, TGeometryJson, TCallback } from "./type"; +import { AbsToolClass, ANNOTATION_EVENT_INJ_TOKEN, IAnnotationEvents, IAnnotationGeometry, INgAnnotationTypes, INJ_ANNOT_TARGET, TAnnotationEvent, ClassInterface, TCallbackFunction, TSands, TGeometryJson, TCallback, DESC_TYPE } from "./type"; import { getExportNehuba, switchMapWaitFor } from "src/util/fn"; import { Polygon } from "./poly"; import { Line } from "./line"; @@ -27,7 +27,6 @@ type TAnnotationMetadata = { desc: string } -const descType = 'siibra-ex/meta/desc' as const type TTypedAnnMetadata = { '@type': 'siibra-ex/meta/desc' } & TAnnotationMetadata @@ -325,21 +324,22 @@ export class ModularUserAnnotationToolService implements OnDestroy{ /** * on new nehubaViewer, unset annotationLayer */ - this.subscription.push( - nehubaViewer$.subscribe(() => { - this.annotationLayer = null - }) - ) - - /** - * get mouse real position - */ - this.subscription.push( - nehubaViewer$.pipe( - switchMap(v => v?.mousePosInReal$ || of(null)) - ).subscribe(v => this.mousePosReal = v) - ) - + if (!!nehubaViewer$) { + this.subscription.push( + nehubaViewer$.subscribe(() => { + this.annotationLayer = null + }) + ) + + /** + * get mouse real position + */ + this.subscription.push( + nehubaViewer$.pipe( + switchMap(v => v?.mousePosInReal$ || of(null)) + ).subscribe(v => this.mousePosReal = v) + ) + } /** * on mouse move, render preview annotation */ @@ -410,13 +410,9 @@ export class ModularUserAnnotationToolService implements OnDestroy{ })), ) ]).pipe( - map(([_, annts]) => { - const out = [] - for (const ann of annts) { - out.push(...ann.toNgAnnotation()) - } - return out - }), + map(([_, annts]) => + annts.map(ann => ann.toNgAnnotation()).flatMap(v => v) + ), shareReplay(1), ) this.subscription.push( @@ -552,15 +548,6 @@ export class ModularUserAnnotationToolService implements OnDestroy{ } } - public exportAnnotationMetadata(ann: IAnnotationGeometry): TAnnotationMetadata & { '@type': 'siibra-ex/meta/desc' } { - return { - '@type': descType, - id: ann.id, - name: ann.name, - desc: ann.desc, - } - } - /** * stop gap measure when exporting/import annotations in sands format * metadata (name/desc) will be saved in a separate metadata file @@ -650,7 +637,7 @@ export class ModularUserAnnotationToolService implements OnDestroy{ if (json['@type'] === 'siibra-ex/annotation/polyline') { returnObj = Polygon.fromJSON(json) } - if (json['@type'] === descType) { + if (json['@type'] === DESC_TYPE) { const existingAnn = this.managedAnnotations.find(ann => json.id === ann.id) if (existingAnn) { diff --git a/src/atlasComponents/userAnnotations/tools/type.ts b/src/atlasComponents/userAnnotations/tools/type.ts index e2abb6f9cf4b745180c5d971ccd8a36178637388..d54b231afd55ca503430125855acdc6701c3fb72 100644 --- a/src/atlasComponents/userAnnotations/tools/type.ts +++ b/src/atlasComponents/userAnnotations/tools/type.ts @@ -9,6 +9,8 @@ import { TSandsCoord, TSandsPoint } from "src/util/types" export { getCoord, TSandsPoint } from "src/util/types" +export const DESC_TYPE = 'siibra-ex/meta/desc' as const + type TRecord = Record<string, unknown> /** @@ -311,6 +313,15 @@ export abstract class IAnnotationGeometry extends Highlightable { abstract toString(): string abstract toSands(): ISandsAnnotation[keyof ISandsAnnotation] + toMetadata(){ + return { + '@type': DESC_TYPE, + id: this.id, + name: this.name, + desc: this.desc, + } + } + public remove() { throw new Error(`The remove method needs to be overwritten by the tool manager`) } diff --git a/src/util/fn.ts b/src/util/fn.ts index a859bad0898788e5c06c1e8292ddf2c66cafb452..c7edd341b1bad2170efa8a0f0cb51712885180d5 100644 --- a/src/util/fn.ts +++ b/src/util/fn.ts @@ -419,3 +419,15 @@ export function wait(ms: number){ rs(null) }, ms)) } + +/** + * @description Wait until predicate returns true. Tries once every 16 ms. + * @param predicate + */ +export async function waitFor(predicate: () => boolean) { + // eslint-disable-next-line no-constant-condition + while (true) { + if (predicate()) break + await wait(16) + } +}