From f5085e496e7d8686ee1d75ddff1f62012dfdb661 Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Wed, 4 May 2022 10:07:11 +0200 Subject: [PATCH] feat: added plugin api for adding annotations chore: remove unused --- deploy/plugins/index.js | 35 ++++-- src/api/service.ts | 66 ++++++++++- src/atlasComponents/sapi/type.ts | 7 ++ .../features/entry/entry.component.ts | 92 ++++++++------- src/auth/auth.service.ts | 3 +- src/messaging/service.ts | 12 +- src/plugin/handshake.md | 22 ++++ src/plugin/plugin.module.ts | 2 + .../pluginBanner/pluginBanner.component.ts | 37 +++--- .../pluginBanner/pluginBanner.template.html | 11 +- .../pluginPortal/pluginPortal.component.ts | 37 +++--- src/plugin/request.md | 79 ++++++++++++- src/plugin/service.ts | 8 ++ src/plugin/types.ts | 2 +- .../parseRouteToTmplParcReg.spec.ts | 24 ---- src/routerModule/parseRouteToTmplParcReg.ts | 110 ------------------ src/state/annotations/actions.ts | 6 +- src/state/annotations/index.ts | 2 +- src/state/annotations/selectors.ts | 23 ++++ src/state/annotations/store.ts | 34 +++++- src/state/atlasSelection/selectors.ts | 2 +- src/viewerModule/nehuba/annotation/effects.ts | 20 ++++ src/viewerModule/nehuba/annotation/service.ts | 89 ++++++++++++++ .../layerCtrl.service/layerCtrl.service.ts | 3 +- src/viewerModule/nehuba/module.ts | 15 ++- 25 files changed, 491 insertions(+), 250 deletions(-) delete mode 100644 src/routerModule/parseRouteToTmplParcReg.spec.ts delete mode 100644 src/routerModule/parseRouteToTmplParcReg.ts create mode 100644 src/viewerModule/nehuba/annotation/effects.ts create mode 100644 src/viewerModule/nehuba/annotation/service.ts diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js index 8d6bee52d..a90197652 100644 --- a/deploy/plugins/index.js +++ b/deploy/plugins/index.js @@ -6,24 +6,26 @@ const express = require('express') const lruStore = require('../lruStore') const got = require('got') +const { URL } = require('url') +const path = require("path") const router = express.Router() -const DEV_PLUGINS = (() => { +const V2_7_DEV_PLUGINS = (() => { try { return JSON.parse( - process.env.DEV_PLUGINS || `[]` + process.env.V2_7_DEV_PLUGINS || `[]` ) } catch (e) { console.warn(`Parsing DEV_PLUGINS failed: ${e}`) return [] } })() -const PLUGIN_URLS = (process.env.PLUGIN_URLS && process.env.PLUGIN_URLS.split(';')) || [] -const STAGING_PLUGIN_URLS = (process.env.STAGING_PLUGIN_URLS && process.env.STAGING_PLUGIN_URLS.split(';')) || [] +const V2_7_PLUGIN_URLS = (process.env.V2_7_PLUGIN_URLS && process.env.V2_7_PLUGIN_URLS.split(';')) || [] +const V2_7_STAGING_PLUGIN_URLS = (process.env.V2_7_STAGING_PLUGIN_URLS && process.env.V2_7_STAGING_PLUGIN_URLS.split(';')) || [] router.get('', (_req, res) => { return res.status(200).json([ - ...PLUGIN_URLS, - ...STAGING_PLUGIN_URLS + ...V2_7_PLUGIN_URLS, + ...V2_7_STAGING_PLUGIN_URLS ]) }) @@ -32,8 +34,8 @@ const getKey = url => `plugin:manifest-cache:${url}}` router.get('/manifests', async (_req, res) => { const allManifests = await Promise.all([ - ...PLUGIN_URLS, - ...STAGING_PLUGIN_URLS + ...V2_7_PLUGIN_URLS, + ...V2_7_STAGING_PLUGIN_URLS ].map(async url => { const key = getKey(url) @@ -47,14 +49,25 @@ router.get('/manifests', async (_req, res) => { } catch (e) { const resp = await got(url) const json = JSON.parse(resp.body) + + const { iframeUrl, 'siibra-explorer': flag } = json + if (!flag) return null + if (!iframeUrl) return null + const u = new URL(url) - await store.set(key, JSON.stringify(json), { maxAge: 1000 * 60 * 60 }) - return json + let replaceObj = {} + if (!/^https?:\/\//.test(iframeUrl)) { + u.pathname = path.resolve(path.dirname(u.pathname), iframeUrl) + replaceObj['iframeUrl'] = u.toString() + } + const returnObj = {...json, ...replaceObj} + await store.set(key, JSON.stringify(returnObj), { maxAge: 1000 * 60 * 60 }) + return returnObj } })) res.status(200).json( - [...DEV_PLUGINS, ...allManifests.filter(v => !!v)] + [...V2_7_DEV_PLUGINS, ...allManifests.filter(v => !!v)] ) }) diff --git a/src/api/service.ts b/src/api/service.ts index 2427dcaa9..41b100f78 100644 --- a/src/api/service.ts +++ b/src/api/service.ts @@ -3,7 +3,8 @@ import { select, Store } from "@ngrx/store"; import { Subject } from "rxjs"; import { distinctUntilChanged, filter, map, take } from "rxjs/operators"; import { SAPI, SapiAtlasModel, SapiParcellationModel, SapiRegionModel, SapiSpaceModel, OpenMINDSCoordinatePoint } from "src/atlasComponents/sapi"; -import { MainState, atlasSelection, userInteraction } from "src/state" +import { SxplrCoordinatePointExtension } from "src/atlasComponents/sapi/type"; +import { MainState, atlasSelection, userInteraction, annotation } from "src/state" import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { CANCELLABLE_DIALOG, CANCELLABLE_DIALOG_OPTS } from "src/util/interfaces"; import { Booth, BoothResponder, createBroadcastingJsonRpcChannel, JRPCRequest, JRPCResp } from "./jsonrpc" @@ -71,6 +72,27 @@ export type ApiBoothEvents = { } response: SapiRegionModel | OpenMINDSCoordinatePoint } + + addAnnotations: { + request: { + annotations: SxplrCoordinatePointExtension[] + } + response: 'OK' + } + + rmAnnotations: { + request: { + annotations: AtId[] + } + response: 'OK' + } + + exit: { + request: { + requests: JRPCRequest<keyof ApiBoothEvents, ApiBoothEvents[keyof ApiBoothEvents]['request']>[] + } + response: 'OK' + } cancelRequest: { request: { @@ -225,7 +247,7 @@ export class ApiService implements BoothResponder<ApiBoothEvents>{ this.broadcastCh.emit('allRegions', regions) }) } - async onRequest(event: JRPCRequest<keyof ApiBoothEvents, null>): Promise<void | JRPCResp<ApiBoothEvents[keyof ApiBoothEvents]['response'], string>> { + async onRequest(event: JRPCRequest<keyof ApiBoothEvents, unknown>): Promise<void | JRPCResp<ApiBoothEvents[keyof ApiBoothEvents]['response'], string>> { /** * if id is not present, then it's a no-op */ @@ -386,6 +408,46 @@ export class ApiService implements BoothResponder<ApiBoothEvents>{ } }) } + case 'addAnnotations': { + const { annotations } = event.params as ApiBoothEvents['addAnnotations']['request'] + const ann = annotations as (annotation.Annotation<'openminds'>)[] + this.store.dispatch( + annotation.actions.addAnnotations({ + annotations: ann + }) + ) + if (event.id) { + return { + jsonrpc: '2.0', + id: event.id, + result: 'OK' + } + } + break + } + case 'rmAnnotations': { + const { annotations } = event.params as ApiBoothEvents['rmAnnotations']['request'] + this.store.dispatch( + annotation.actions.rmAnnotations({ + annotations + }) + ) + if (event.id){ + return { + jsonrpc: '2.0', + id: event.id, + result: 'OK' + } + } + break + } + case 'exit': { + const { requests } = event.params as ApiBoothEvents['exit']['request'] + for (const req of requests) { + await this.onRequest(req) + } + break + } case 'cancelRequest': { const { id } = event.params as ApiBoothEvents['cancelRequest']['request'] const idx = this.requestUserQueue.findIndex(q => q.id === id) diff --git a/src/atlasComponents/sapi/type.ts b/src/atlasComponents/sapi/type.ts index 27574a962..1796c54d0 100644 --- a/src/atlasComponents/sapi/type.ts +++ b/src/atlasComponents/sapi/type.ts @@ -16,6 +16,13 @@ export type SapiSpaceModel = components["schemas"]["SapiSpaceModel"] export type SapiParcellationModel = components["schemas"]["SapiParcellationModel"] export type SapiRegionModel = components["schemas"]["siibra__openminds__SANDS__v3__atlas__parcellationEntityVersion__Model"] export type OpenMINDSCoordinatePoint = components['schemas']['siibra__openminds__SANDS__v3__miscellaneous__coordinatePoint__Model'] +export type SxplrCoordinatePointExtension = { + openminds: OpenMINDSCoordinatePoint + name: string + description: string + color: string + '@id': string // should match the id of opendminds specs +} export type SapiRegionMapInfoModel = components["schemas"]["NiiMetadataModel"] export type SapiVOIDataResponse = components["schemas"]["VOIDataModel"] diff --git a/src/atlasComponents/sapiViews/features/entry/entry.component.ts b/src/atlasComponents/sapiViews/features/entry/entry.component.ts index 53b27fb30..691af83bd 100644 --- a/src/atlasComponents/sapiViews/features/entry/entry.component.ts +++ b/src/atlasComponents/sapiViews/features/entry/entry.component.ts @@ -1,9 +1,9 @@ import { Component, Input, OnDestroy } from "@angular/core"; import { Store } from "@ngrx/store"; -import { AnnotationLayer, TNgAnnotationPoint } from "src/atlasComponents/annotations"; +import { TNgAnnotationPoint } from "src/atlasComponents/annotations"; import { SapiFeatureModel, SapiParcellationModel, SapiRegionModel, SapiSpaceModel, CLEANED_IEEG_DATASET_TYPE } from "src/atlasComponents/sapi"; import { IeegOnFocusEvent, ContactPoint, Electrode, Session, IeegOnDefocusEvent } from "../ieeg"; -import { atlasSelection, annotation, atlasAppearance } from "src/state" +import { atlasSelection, annotation } from "src/state" @Component({ selector: 'sxplr-sapiviews-features-entry', @@ -39,11 +39,8 @@ export class FeatureEntryCmp implements OnDestroy{ ieeg: CLEANED_IEEG_DATASET_TYPE } - static readonly IEEG_ANNOTATION_LAYER_RED = `ieeg-annotation-layer-red` - static readonly IEEG_ANNOTATION_LAYER_WHITE = `ieeg-annotation-layer-white` - private ieegRedAnnLayer: AnnotationLayer - private ieegWhiteAnnLayer: AnnotationLayer - + private addedAnnotations: annotation.UnionAnnotation[] = [] + ieegOnFocus(ev: IeegOnFocusEvent){ if (ev.contactPoint) { /** @@ -63,30 +60,51 @@ export class FeatureEntryCmp implements OnDestroy{ /** * */ - if (!this.ieegRedAnnLayer) { - this.ieegRedAnnLayer = new AnnotationLayer(FeatureEntryCmp.IEEG_ANNOTATION_LAYER_RED, "#ff0000") - } - if (!this.ieegWhiteAnnLayer) { - this.ieegWhiteAnnLayer = new AnnotationLayer(FeatureEntryCmp.IEEG_ANNOTATION_LAYER_WHITE, "#ffffff") - } const allInRoiPoints: TNgAnnotationPoint[] = this.getPointsFromSession(ev.session, true) const allNonInRoiPoints: TNgAnnotationPoint[] = this.getPointsFromSession(ev.session, false) + const annotationsToBeAdded: annotation.UnionAnnotation[] = [] for (const pt of allInRoiPoints) { - this.ieegRedAnnLayer.addAnnotation(pt) + annotationsToBeAdded.push({ + "@id": pt.id, + color: annotation.AnnotationColor.RED, + openminds: { + "@id": pt.id, + "@type": "https://openminds.ebrains.eu/sands/CoordinatePoint", + coordinateSpace: { + "@id": this.space["@id"] + }, + coordinates: pt.point.map(v => { + return { + value: v / 1e6 + } + }) + }, + name: pt.description || "Untitled" + }) } for (const pt of allNonInRoiPoints) { - this.ieegWhiteAnnLayer.addAnnotation(pt) + annotationsToBeAdded.push({ + "@id": pt.id, + color: annotation.AnnotationColor.WHITE, + openminds: { + "@id": pt.id, + "@type": "https://openminds.ebrains.eu/sands/CoordinatePoint", + coordinateSpace: { + "@id": this.space["@id"] + }, + coordinates: pt.point.map(v => { + return { + value: v / 1e6 + } + }) + }, + name: pt.description || "Untitled" + }) } + this.addedAnnotations = annotationsToBeAdded this.store.dispatch( annotation.actions.addAnnotations({ - annotations: [...allInRoiPoints, ...allNonInRoiPoints].map(p => { - return { "@id": p.id } - }) - }) - ) - this.store.dispatch( - atlasAppearance.actions.setOctantRemoval({ - flag: false + annotations: annotationsToBeAdded }) ) } @@ -103,31 +121,15 @@ export class FeatureEntryCmp implements OnDestroy{ }) }) ) - - this.store.dispatch( - atlasAppearance.actions.setOctantRemoval({ - flag: true - }) - ) - - if (this.ieegRedAnnLayer) { - for (const pt of allInRoiPoints) { - this.ieegRedAnnLayer.removeAnnotation(pt) - } - } - - if (this.ieegWhiteAnnLayer) { - for (const pt of allNonInRoiPoints) { - this.ieegWhiteAnnLayer.removeAnnotation(pt) - } - } - - } } + ngOnDestroy(): void { - if (this.ieegRedAnnLayer) this.ieegRedAnnLayer.dispose() - if (this.ieegWhiteAnnLayer) this.ieegWhiteAnnLayer.dispose() + this.store.dispatch( + annotation.actions.rmAnnotations({ + annotations: this.addedAnnotations + }) + ) } constructor( diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 487fb0d5e..e2067301c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from "@angular/common/http"; import { Injectable, OnDestroy } from "@angular/core"; import { Observable, of, Subscription } from "rxjs"; import { catchError, map, shareReplay } from "rxjs/operators"; +import { environment } from "src/environments/environment" const IV_REDIRECT_TOKEN = `IV_REDIRECT_TOKEN` @@ -37,7 +38,7 @@ export class AuthService implements OnDestroy { constructor( private httpClient: HttpClient, ) { - this.user$ = this.httpClient.get<TUserRouteResp>(`user`).pipe( + this.user$ = this.httpClient.get<TUserRouteResp>(`${environment.BACKEND_URL || ''}user`).pipe( map(json => { if (json.error) { throw new Error(json.message || 'User not loggedin.') diff --git a/src/messaging/service.ts b/src/messaging/service.ts index d3649cdf3..d810a7fce 100644 --- a/src/messaging/service.ts +++ b/src/messaging/service.ts @@ -71,11 +71,13 @@ export class MessagingService { result }, origin) } catch (error) { - src.postMessage({ - id, - jsonrpc: '2.0', - error - }, origin) + if (src) { + src.postMessage({ + id, + jsonrpc: '2.0', + error + }, origin) + } } }) diff --git a/src/plugin/handshake.md b/src/plugin/handshake.md index e69de29bb..a55a97d23 100644 --- a/src/plugin/handshake.md +++ b/src/plugin/handshake.md @@ -0,0 +1,22 @@ +# Handshake API + +Handshake messages are meant for siibra-explorer to probe if the plugin is alive and well (and also a way for the plugin to check if siibra-explorer is responsive) + +<!-- the API reference below are auto generated by generateTypes.js --> +<!-- do not edit, as the edit will be overwritten by the auto generation --> + +## API +### `sxplr.init` + +- request + + ```ts + null + ``` + +- response + + ```ts + {"name": string} + ``` + diff --git a/src/plugin/plugin.module.ts b/src/plugin/plugin.module.ts index 08d54d85d..ea1ec730a 100644 --- a/src/plugin/plugin.module.ts +++ b/src/plugin/plugin.module.ts @@ -1,4 +1,5 @@ import { CommonModule, DOCUMENT } from "@angular/common"; +import { HttpClientModule } from "@angular/common/http"; import { NgModule } from "@angular/core"; import { LoggingModule } from "src/logging"; import { AngularMaterialModule } from "src/sharedModules"; @@ -15,6 +16,7 @@ import { PluginPortal } from "./pluginPortal/pluginPortal.component"; LoggingModule, UtilModule, AngularMaterialModule, + HttpClientModule, ], declarations: [ PluginBannerUI, diff --git a/src/plugin/pluginBanner/pluginBanner.component.ts b/src/plugin/pluginBanner/pluginBanner.component.ts index e6dac94af..df682e4cf 100644 --- a/src/plugin/pluginBanner/pluginBanner.component.ts +++ b/src/plugin/pluginBanner/pluginBanner.component.ts @@ -1,9 +1,10 @@ -import { Component, ViewChild, TemplateRef } from "@angular/core"; +import { Component, TemplateRef } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { environment } from 'src/environments/environment'; -import { MatSnackBar } from "@angular/material/snack-bar"; import { PluginService } from "../service"; import { PluginManifest } from "../types"; +import { combineLatest, Observable, Subject } from "rxjs"; +import { map, scan, startWith } from "rxjs/operators"; @Component({ selector : 'plugin-banner', @@ -17,17 +18,14 @@ export class PluginBannerUI { EXPERIMENTAL_FEATURE_FLAG = environment.EXPERIMENTAL_FEATURE_FLAG - pluginManifests: PluginManifest[] = [] - constructor( private svc: PluginService, private matDialog: MatDialog, - private matSnackbar: MatSnackBar, ) { } public launchPlugin(plugin: PluginManifest) { - this.svc.launchPlugin(plugin.url) + this.svc.launchPlugin(plugin.iframeUrl) } public showTmpl(tmpl: TemplateRef<any>){ @@ -36,12 +34,25 @@ export class PluginBannerUI { }) } - public loadingThirdpartyPlugin = false - public async addThirdPartyPlugin(manifestUrl: string) { - this.matSnackbar.open(`Adding third party plugin is current unavailable.`) - } - - test(){ - this.svc.launchPlugin('http://localhost:8000') + private thirdpartyPlugin$: Subject<{name: 'Added Plugin', iframeUrl: string}> = new Subject() + + availablePlugins$: Observable<{ + name: string + iframeUrl: string + }[]> = combineLatest([ + this.svc.pluginManifests$, + this.thirdpartyPlugin$.pipe( + scan((acc, curr) => acc.concat(curr), []), + startWith([]) + ), + ]).pipe( + map(([builtIn, thirdParty]) => [...builtIn, ...thirdParty]) + ) + + public addThirdPartyPlugin(iframeUrl: string) { + this.thirdpartyPlugin$.next({ + name: 'Added Plugin', + iframeUrl + }) } } diff --git a/src/plugin/pluginBanner/pluginBanner.template.html b/src/plugin/pluginBanner/pluginBanner.template.html index 80ffd873c..4f9ef3ae2 100644 --- a/src/plugin/pluginBanner/pluginBanner.template.html +++ b/src/plugin/pluginBanner/pluginBanner.template.html @@ -1,16 +1,12 @@ <mat-action-list> <button mat-menu-item - *ngFor="let plugin of pluginManifests" + *ngFor="let plugin of availablePlugins$ | async" (click)="launchPlugin(plugin)"> <span> {{ plugin.name }} </span> </button> - <button *ngIf="EXPERIMENTAL_FEATURE_FLAG" mat-menu-item (click)="test()"> - test - </button> - <button mat-menu-item *ngIf="EXPERIMENTAL_FEATURE_FLAG" (click)="showTmpl(thirdPartyPluginTmpl)"> <span> @@ -28,9 +24,9 @@ <form> <mat-form-field class="d-block"> <mat-label> - manifest.json URL + iframe index.html URL </mat-label> - <input type="text" matInput placeholder="https://example.com/manifest.json" #urlInput> + <input type="text" matInput placeholder="https://example.com/index.html" #urlInput> </mat-form-field> </form> </mat-dialog-content> @@ -38,7 +34,6 @@ <mat-dialog-actions align="end"> <button (click)="addThirdPartyPlugin(urlInput.value)" mat-raised-button - [disabled]="loadingThirdpartyPlugin" color="primary"> Load </button> diff --git a/src/plugin/pluginPortal/pluginPortal.component.ts b/src/plugin/pluginPortal/pluginPortal.component.ts index ce0da6086..7d5b4d421 100644 --- a/src/plugin/pluginPortal/pluginPortal.component.ts +++ b/src/plugin/pluginPortal/pluginPortal.component.ts @@ -109,25 +109,28 @@ export class PluginPortal implements AfterViewInit, OnDestroy, ListenerChannel{ * listening to plugin requests * only start after boothVisitor is defined */ - this.sub.push( - fromEvent<MessageEvent>(window, 'message').pipe( - startWith(null as MessageEvent), - filter(msg => !!this.boothVisitor && msg.data.jsonrpc === "2.0" && !!msg.data.method) - ).subscribe(async msg => { - try { - const result = await this.boothVisitor.request(msg.data) + const sub = fromEvent<MessageEvent>(window, 'message').pipe( + startWith(null as MessageEvent), + filter(msg => !!this.boothVisitor && msg.data.jsonrpc === "2.0" && !!msg.data.method && msg.origin === this.origin) + ).subscribe(async msg => { + try { + if (msg.data.method === `${namespace}.exit`) { + sub.unsubscribe() + } + const result = await this.boothVisitor.request(msg.data) + if (!!result) { this.childWindow.postMessage(result, this.origin) - } catch (e) { - this.childWindow.postMessage({ - id: msg.data.id, - error: { - code: -32603, - message: e.toString() - } - }, this.origin) } - }) - ) + } catch (e) { + this.childWindow.postMessage({ + id: msg.data.id, + error: { + code: -32603, + message: e.toString() + } + }, this.origin) + } + }) } } notify(payload: JRPCRequest<keyof BroadCastingApiEvents, BroadCastingApiEvents[keyof BroadCastingApiEvents]>) { diff --git a/src/plugin/request.md b/src/plugin/request.md index 6260c2ae2..a2575863a 100644 --- a/src/plugin/request.md +++ b/src/plugin/request.md @@ -1,6 +1,38 @@ # Request API -TBD +Request messages are sent when the plugin requests siibra-explorer to do something on its behalf. + +Be it request the user to select a region, a point, navigate to a specific location etc. + +> :warning: Please note that `beforeunload` window event does not fire on iframe windows. Plugins should do whatever cleanup it needs, then send the message `sxplr.exit`. + +```javascript + +let parentWindow +window.addEventListener('message', ev => { + const { source, data, origin } = msg + const { id, method, params, result, error } = data + + if (method === "sxplr.init") { + parentWindow = source + } +}) + +window.addEventListener('pagehide', () => { + + // do cleanup + // n.b. since iframe unload usually do not trigger DOM events + // one will need to manually trigger destroying any apps manually + + parentWindow.postMessage({ + jsonrpc: '2.0', + method: `sxplr.exit`, + params: { + requests: [] // any remaining requests to be carried out + } + }) +}) +``` <!-- the API reference below are auto generated by generateTypes.js --> <!-- do not edit, as the edit will be overwritten by the auto generation --> @@ -126,6 +158,51 @@ TBD ``` +### `sxplr.addAnnotations` + +- request + + ```ts + {"annotations": SxplrCoordinatePointExtension[]} + ``` + +- response + + ```ts + 'OK' + ``` + + +### `sxplr.rmAnnotations` + +- request + + ```ts + {"annotations": AtId[]} + ``` + +- response + + ```ts + 'OK' + ``` + + +### `sxplr.exit` + +- request + + ```ts + {"requests": JRPCRequest[]} + ``` + +- response + + ```ts + 'OK' + ``` + + ### `sxplr.cancelRequest` - request diff --git a/src/plugin/service.ts b/src/plugin/service.ts index 97a72e542..4f5a5e74f 100644 --- a/src/plugin/service.ts +++ b/src/plugin/service.ts @@ -1,9 +1,11 @@ +import { HttpClient } from "@angular/common/http"; import { Injectable, Injector, NgZone } from "@angular/core"; import { WIDGET_PORTAL_TOKEN } from "src/widget/constants"; import { WidgetService } from "src/widget/service"; import { WidgetPortal } from "src/widget/widgetPortal/widgetPortal.component"; import { setPluginSrc } from "./const"; import { PluginPortal } from "./pluginPortal/pluginPortal.component"; +import { environment } from "src/environments/environment" @Injectable({ providedIn: 'root' @@ -16,8 +18,14 @@ export class PluginService { private wSvc: WidgetService, private injector: Injector, private zone: NgZone, + private http: HttpClient ){} + pluginManifests$ = this.http.get<{ + 'siibra-explorer': true + name: string + iframeUrl: string + }[]>(`${environment.BACKEND_URL || ''}plugins/manifests`) async launchPlugin(htmlSrc: string){ if (this.loadedPlugins.includes(htmlSrc)) return diff --git a/src/plugin/types.ts b/src/plugin/types.ts index 83ba2e312..f51aa57d9 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -1,4 +1,4 @@ export type PluginManifest = { name: string - url: string + iframeUrl: string } diff --git a/src/routerModule/parseRouteToTmplParcReg.spec.ts b/src/routerModule/parseRouteToTmplParcReg.spec.ts deleted file mode 100644 index d81c9491c..000000000 --- a/src/routerModule/parseRouteToTmplParcReg.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { parseSearchParamForTemplateParcellationRegion } from './parseRouteToTmplParcReg' - -const url = `/a:juelich:iav:atlas:v1.0.0:1/t:minds:core:referencespace:v1.0.0:dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2/p:minds:core:parcellationatlas:v1.0.0:94c1125b-b87e-45e4-901c-00daee7f2579-26/@:0.0.0.-W000..2_ZG29.-ASCS.2-8jM2._aAY3..BSR0..0.1w4W0~.0..1jtG` - -const fakeState = { -} -const fakeTmpl = { - '@id': 'foobar' -} -const fakeParc = { - '@id': 'buzbaz' -} -const fakeRegions = [{ - ngId: 'foo', - labelIndex: 152 -}] - -describe('parseRouteToTmplParcReg.ts', () => { - describe('> parseSearchParamForTemplateParcellationRegion', () => { - it('> parses selected region properly', () => { - - }) - }) -}) \ No newline at end of file diff --git a/src/routerModule/parseRouteToTmplParcReg.ts b/src/routerModule/parseRouteToTmplParcReg.ts deleted file mode 100644 index ee4d505a1..000000000 --- a/src/routerModule/parseRouteToTmplParcReg.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { decodeToNumber, separator } from './cipher' - -import { - TUrlAtlas, - TUrlPathObj, -} from './type' -import { UrlTree } from '@angular/router' -import { serializeSegment } from "src/viewerModule/nehuba/util" - - -export const PARSE_ERROR = { - TEMPALTE_NOT_SET: 'TEMPALTE_NOT_SET', - TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', - PARCELLATION_NOT_UPDATED: 'PARCELLATION_NOT_UPDATED', -} - -export const encodeId = (id: string) => id && id.replace(/\//g, ':') -export const decodeId = (codedId: string) => codedId && codedId.replace(/:/g, '/') - -export function parseSearchParamForTemplateParcellationRegion(obj: TUrlPathObj<string[], TUrlAtlas<string[]>>, fullPath: UrlTree, state: any, warnCb: (arg: string) => void) { - - /** - * TODO if search param of either template or parcellation is incorrect, wrong things are searched - */ - - - const templateSelected = (() => { - const { fetchedTemplates } = state.viewerState - - const searchedId = obj.t && decodeId(obj.t[0]) - - if (!searchedId) return null - const templateToLoad = fetchedTemplates.find(template => (template['@id'] || template['fullId']) === searchedId) - if (!templateToLoad) { throw new Error(PARSE_ERROR.TEMPLATE_NOT_FOUND) } - return templateToLoad - })() - - const parcellationSelected = (() => { - if (!templateSelected) return null - const searchedId = obj.p && decodeId(obj.p[0]) - - const parcellationToLoad = templateSelected.parcellations.find(parcellation => (parcellation['@id'] || parcellation['fullId']) === searchedId) - if (!parcellationToLoad) { - warnCb(`parcellation with id ${searchedId} not found... load the first parc instead`) - } - return parcellationToLoad || templateSelected.parcellations[0] - })() - - /* selected regions */ - - const regionsSelected = (() => { - if (!parcellationSelected) return [] - - // TODO deprecate. Fallback (defaultNgId) (should) already exist - // if (!viewerState.parcellationSelected.updated) throw new Error(PARCELLATION_NOT_UPDATED) - - - /** - * either or both parcellationToLoad and .regions maybe empty - */ - - try { - const json = obj.r - ? { [obj.r[0]]: obj.r[1] } - : JSON.parse(fullPath.queryParams['cRegionsSelected'] || '{}') - - const selectRegionIds = [] - - for (const ngId in json) { - const val = json[ngId] - const labelIndicies = val.split(separator).map(n => { - try { - return decodeToNumber(n) - } catch (e) { - /** - * TODO poisonsed encoded char, send error message - */ - return null - } - }).filter(v => !!v) - for (const labelIndex of labelIndicies) { - selectRegionIds.push( serializeSegment(ngId, labelIndex) ) - } - } - return [] - // selectRegionIds - // .map(labelIndexId => { - // const region = getRegionFromlabelIndexId({ labelIndexId }) - // if (!region) { - // // cb && cb({ type: ID_ERROR, message: `region with id ${labelIndexId} not found, and will be ignored.` }) - // } - // return region - // }) - // .filter(r => !!r) - - } catch (e) { - /** - * parsing cRegionSelected error - */ - // cb && cb({ type: DECODE_CIPHER_ERROR, message: `parsing cRegionSelected error ${e.toString()}` }) - } - return [] - })() - - return { - templateSelected, - parcellationSelected, - regionsSelected, - } -} diff --git a/src/state/annotations/actions.ts b/src/state/annotations/actions.ts index daaddd8ce..8527aaaf3 100644 --- a/src/state/annotations/actions.ts +++ b/src/state/annotations/actions.ts @@ -1,6 +1,6 @@ import { createAction, props } from "@ngrx/store" import { nameSpace } from "./const" -import { Annotation } from "./store" +import { UnionAnnotation } from "./store" export const clearAllAnnotations = createAction( `${nameSpace} clearAllAnnotations` @@ -9,13 +9,13 @@ export const clearAllAnnotations = createAction( export const rmAnnotations = createAction( `${nameSpace} rmAnnotations`, props<{ - annotations: Annotation[] + annotations: {'@id': string }[] }>() ) export const addAnnotations = createAction( `${nameSpace} addAnnotations`, props<{ - annotations: Annotation[] + annotations: UnionAnnotation[] }>() ) diff --git a/src/state/annotations/index.ts b/src/state/annotations/index.ts index 4ad0f0680..13a9be8ec 100644 --- a/src/state/annotations/index.ts +++ b/src/state/annotations/index.ts @@ -1,4 +1,4 @@ export * as actions from "./actions" -export { Annotation, AnnotationState, reducer, defaultState } from "./store" +export { Annotation, AnnotationState, reducer, defaultState, TypesOfDetailedAnnotations, UnionAnnotation, AnnotationColor } from "./store" export { nameSpace } from "./const" export * as selectors from "./selectors" diff --git a/src/state/annotations/selectors.ts b/src/state/annotations/selectors.ts index abbb3444f..d44a9e211 100644 --- a/src/state/annotations/selectors.ts +++ b/src/state/annotations/selectors.ts @@ -1,6 +1,8 @@ import { createSelector } from "@ngrx/store" import { nameSpace } from "./const" import { Annotation, AnnotationState } from "./store" +import { selectors as atlasSelectionSelectors } from "../atlasSelection" +import { annotation } from ".." const selectStore = state => state[nameSpace] as AnnotationState @@ -8,3 +10,24 @@ export const annotations = createSelector( selectStore, state => state.annotations ) + +export const spaceFilteredAnnotations = createSelector( + selectStore, + atlasSelectionSelectors.selectStore, + (annState, atlasSelState) => annState.annotations.filter(ann => { + const spaceId = atlasSelState.selectedTemplate['@id'] + if (ann['openminds']) { + return (ann as Annotation<'openminds'>).openminds.coordinateSpace['@id'] === spaceId + } + + if (ann['line']) { + return (ann as Annotation<'line'>).line.pointA.coordinateSpace['@id'] === spaceId + && (ann as Annotation<'line'>).line.pointB.coordinateSpace['@id'] === spaceId + } + + if (ann['box']) { + return (ann as Annotation<'box'>).box.pointA.coordinateSpace['@id'] === spaceId + && (ann as Annotation<'box'>).box.pointB.coordinateSpace['@id'] === spaceId + } + }) +) diff --git a/src/state/annotations/store.ts b/src/state/annotations/store.ts index 113220b47..21198ff81 100644 --- a/src/state/annotations/store.ts +++ b/src/state/annotations/store.ts @@ -1,12 +1,40 @@ import { createReducer, on } from "@ngrx/store" +import { OpenMINDSCoordinatePoint } from "src/atlasComponents/sapi" import * as actions from "./actions" -export type Annotation = { - "@id": string +type Line = { + pointA: OpenMINDSCoordinatePoint + pointB: OpenMINDSCoordinatePoint +} + +type BBox = { + pointA: OpenMINDSCoordinatePoint + pointB: OpenMINDSCoordinatePoint +} + +export type TypesOfDetailedAnnotations = { + openminds: OpenMINDSCoordinatePoint + line: Line + box: BBox } +export enum AnnotationColor { + WHITE="WHITE", + RED="RED", + BLUE="BLUE", +} + +export type Annotation<T extends keyof TypesOfDetailedAnnotations> = { + "@id": string + name: string + description?: string + color?: AnnotationColor +} & { [key in T] : TypesOfDetailedAnnotations[T]} + +export type UnionAnnotation = Annotation<'openminds'> | Annotation<'line'> | Annotation<'box'> + export type AnnotationState = { - annotations: Annotation[] + annotations: UnionAnnotation[] } export const defaultState: AnnotationState = { diff --git a/src/state/atlasSelection/selectors.ts b/src/state/atlasSelection/selectors.ts index 93f075350..ad3079a56 100644 --- a/src/state/atlasSelection/selectors.ts +++ b/src/state/atlasSelection/selectors.ts @@ -3,7 +3,7 @@ import { nameSpace, AtlasSelectionState } from "./const" export const viewerStateHelperStoreName = 'viewerStateHelper' -const selectStore = (state: any) => state[nameSpace] as AtlasSelectionState +export const selectStore = (state: any) => state[nameSpace] as AtlasSelectionState export const selectedAtlas = createSelector( selectStore, diff --git a/src/viewerModule/nehuba/annotation/effects.ts b/src/viewerModule/nehuba/annotation/effects.ts new file mode 100644 index 000000000..b4a6de67c --- /dev/null +++ b/src/viewerModule/nehuba/annotation/effects.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@angular/core"; +import { createEffect } from "@ngrx/effects"; +import { select, Store } from "@ngrx/store"; +import { map } from "rxjs/operators"; +import { annotation, atlasAppearance } from "src/state" + +@Injectable() +export class NgAnnotationEffects{ + constructor(private store: Store){} + + onAnnotationHideQuadrant = createEffect(() => this.store.pipe( + select(annotation.selectors.spaceFilteredAnnotations), + map(arr => { + const spaceFilteredAnnotationExists = arr.length > 0 + return atlasAppearance.actions.setOctantRemoval({ + flag: !spaceFilteredAnnotationExists + }) + }), + )) +} diff --git a/src/viewerModule/nehuba/annotation/service.ts b/src/viewerModule/nehuba/annotation/service.ts new file mode 100644 index 000000000..0bfd9f926 --- /dev/null +++ b/src/viewerModule/nehuba/annotation/service.ts @@ -0,0 +1,89 @@ +import { Injectable, OnDestroy } from "@angular/core"; +import { select, Store } from "@ngrx/store"; +import { Subscription } from "rxjs"; +import { filter, map, pairwise, startWith } from "rxjs/operators"; +import { AnnotationLayer, TNgAnnotationAABBox, TNgAnnotationLine, TNgAnnotationPoint } from "src/atlasComponents/annotations"; +import { annotation } from "src/state"; + + +@Injectable({ + providedIn: 'root' +}) + +export class NgAnnotationService implements OnDestroy { + + private subs: Subscription[] = [] + static ANNOTATION_LAYER_NAME = 'whale-annotation-layer' + static INIT_LAYERS: Set<string> = new Set<string>() + static COLOR_MAP = new Map<keyof typeof annotation.AnnotationColor, string>([ + [annotation.AnnotationColor.WHITE, "#ffffff"], + [annotation.AnnotationColor.BLUE, "#00ff00"], + [annotation.AnnotationColor.RED, "#ff0000"], + ]) + + static GET_ANN_LAYER(ann: annotation.UnionAnnotation): AnnotationLayer { + const color = ann.color || annotation.AnnotationColor.WHITE + const layerName = `${NgAnnotationService.ANNOTATION_LAYER_NAME}-${annotation.AnnotationColor[color]}` + + const annLayer = AnnotationLayer.Get(layerName, NgAnnotationService.COLOR_MAP.get(color) || "#ffffff") + NgAnnotationService.INIT_LAYERS.add(layerName) + return annLayer + } + + ngOnDestroy(): void { + NgAnnotationService.INIT_LAYERS.forEach(layername => { + const layer = AnnotationLayer.Get(layername, '#ffffff') + layer.dispose() + }) + while(this.subs.length) this.subs.pop().unsubscribe() + } + + constructor( + private store: Store + ){ + + this.subs.push( + this.store.pipe( + select(annotation.selectors.spaceFilteredAnnotations) + ).pipe( + startWith<annotation.UnionAnnotation[]>([]), + pairwise(), + map(([prevAnnotations, currAnnotations]) => { + const prevAnnotationIds = new Set(prevAnnotations.map(ann => ann["@id"])) + const currAnnotationIds = new Set(currAnnotations.map(ann => ann["@id"])) + const newAnnotations = currAnnotations.filter(ann => !prevAnnotationIds.has(ann["@id"])) + const expiredAnnotations = prevAnnotations.filter(ann => !currAnnotationIds.has(ann["@id"])) + return { + newAnnotations, + expiredAnnotations + } + }), + filter(({ newAnnotations, expiredAnnotations }) => newAnnotations.length > 0 || expiredAnnotations.length > 0) + ).subscribe(({ newAnnotations, expiredAnnotations }) => { + + for (const ann of expiredAnnotations) { + const annLayer = NgAnnotationService.GET_ANN_LAYER(ann) + annLayer.removeAnnotation({ + id: ann["@id"] + }) + } + for (const ann of newAnnotations) { + let annotation: TNgAnnotationPoint | TNgAnnotationLine | TNgAnnotationAABBox + let annLayer: AnnotationLayer + if (!!ann['openminds']) { + annLayer = NgAnnotationService.GET_ANN_LAYER(ann) + annotation = { + id: ann["@id"], + description: ann.description, + type: 'point', + point: (ann as annotation.Annotation<'openminds'>).openminds.coordinates.map(coord => coord.value * 1e6) as [number, number, number] + } as TNgAnnotationPoint + } + + if (annotation && annLayer) annLayer.addAnnotation(annotation) + else console.warn(`annotation or annotation layer was not initialized.`) + } + }) + ) + } +} diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts index 1fda36377..85f67513d 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts @@ -12,6 +12,7 @@ import { LayerCtrlEffects } from "./layerCtrl.effects"; import { arrayEqual } from "src/util/array"; import { ColorMapCustomLayer } from "src/state/atlasAppearance"; import { SapiRegionModel } from "src/atlasComponents/sapi"; +import { AnnotationLayer } from "src/atlasComponents/annotations"; export const BACKUP_COLOR = { red: 255, @@ -359,6 +360,6 @@ export class NehubaLayerControlService implements OnDestroy{ ) ) ]).pipe( - map(([ expectedLayerNames, customLayerNames, pmapName ]) => [...expectedLayerNames, ...customLayerNames, ...pmapName]) + map(([ expectedLayerNames, customLayerNames, pmapName ]) => [...expectedLayerNames, ...customLayerNames, ...pmapName, ...AnnotationLayer.Map.keys()]) ) } diff --git a/src/viewerModule/nehuba/module.ts b/src/viewerModule/nehuba/module.ts index fe4025538..5ba4843f6 100644 --- a/src/viewerModule/nehuba/module.ts +++ b/src/viewerModule/nehuba/module.ts @@ -26,6 +26,8 @@ import { NgLayerCtrlCmp } from "./ngLayerCtl/ngLayerCtrl.component"; import { EffectsModule } from "@ngrx/effects"; import { MeshEffects } from "./mesh.effects/mesh.effects"; import { NehubaLayoutOverlayModule } from "./layoutOverlay"; +import { NgAnnotationService } from "./annotation/service"; +import { NgAnnotationEffects } from "./annotation/effects"; @NgModule({ imports: [ @@ -51,7 +53,8 @@ import { NehubaLayoutOverlayModule } from "./layoutOverlay"; reducer ), EffectsModule.forFeature([ - MeshEffects + MeshEffects, + NgAnnotationEffects, ]), QuickTourModule, NehubaLayoutOverlayModule, @@ -81,11 +84,17 @@ import { NehubaLayoutOverlayModule } from "./layoutOverlay"; { provide: NEHUBA_INSTANCE_INJTKN, useValue: new BehaviorSubject(null) - } + }, + NgAnnotationService ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] }) -export class NehubaModule{} +export class NehubaModule{ + + constructor(_svc: NgAnnotationService){ + + } +} -- GitLab