diff --git a/deploy/app.js b/deploy/app.js index d62f1edf76234d12614bae93c6df97d03e49e1af..3364e2113a0a746bc60d8aac2d3457c3bff70b5d 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -7,6 +7,8 @@ const MemoryStore = require('memorystore')(session) const crypto = require('crypto') const cookieParser = require('cookie-parser') +const { router: regionalFeaturesRouter, regionalFeaturesIsReady } = require('./regionalFeatures') + const LOCAL_CDN_FLAG = !!process.env.PRECOMPUTED_SERVER if (process.env.NODE_ENV !== 'production') { @@ -164,8 +166,10 @@ app.use('/logo', require('./logo')) app.get('/ready', async (req, res) => { const authIsReady = await authReady() + const regionalFeatureReady = await regionalFeaturesIsReady() const allReady = [ - authIsReady + authIsReady, + regionalFeatureReady, /** * add other ready endpoints here * call sig is await fn(): boolean @@ -214,6 +218,7 @@ app.use('/atlases', setResLocalMiddleWare('atlases'), atlasesRouter) app.use('/templates', setResLocalMiddleWare('templates'), jsonMiddleware, templateRouter) app.use('/nehubaConfig', jsonMiddleware, nehubaConfigRouter) app.use('/datasets', jsonMiddleware, datasetRouter) +app.use('/regionalFeatures', jsonMiddleware, regionalFeaturesRouter) app.use('/plugins', jsonMiddleware, pluginRouter) app.use('/preview', jsonMiddleware, previewRouter) diff --git a/deploy/regionalFeatures/index.js b/deploy/regionalFeatures/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e05545ab5499d23d8c6e60205c35b26b92a685ac --- /dev/null +++ b/deploy/regionalFeatures/index.js @@ -0,0 +1,224 @@ +const router = require('express').Router() +const request = require('request') + +/** + * TODO migrate to brainscape in the future + */ + +const REGIONAL_FEATURE_ENDPOINT_ARRAY = process.env.REGIONAL_FEATURE_ENDPOINT_ARRAY || [] + +let arrayToFetch = [] +try { + arrayToFetch = JSON.parse(REGIONAL_FEATURE_ENDPOINT_ARRAY) +} catch (e) { + console.warn(`parsing arrayToFetch parse failed`) +} + +const regionIdToDataIdMap = new Map() +const datasetIdToDataMap = new Map() +const datasetIdDetailMap = new Map() + +const ITERABLE_KEY_SYMBOL = Symbol('ITERABLE_KEY_SYMBOL') + +/** + * this pattern allows all of the special data to be fetched in parallel + * async await would mean it is fetched one at a time + */ +const init = Promise.all( + arrayToFetch.map(url => + new Promise((rs, rj) => { + request.get(url, (err, _resp, body) => { + if (err) return rj(err) + const { regions, data, ['@id']: datasetId, type, name } = JSON.parse(body) + datasetIdDetailMap.set(datasetId, { + ['@id']: datasetId, + type, + name + }) + for (const { status, ['@id']: regionId, name, files } of regions) { + if (regionIdToDataIdMap.has(regionId)) { + const existingObj = regionIdToDataIdMap.get(regionId) + existingObj[datasetId][status] = (existingObj[datasetId][status] || []).concat(files) + existingObj[datasetId][ITERABLE_KEY_SYMBOL] = existingObj[datasetId][ITERABLE_KEY_SYMBOL].concat(status) + } else { + const datasetObj = { + [status]: files, + type, + } + datasetObj[ITERABLE_KEY_SYMBOL] = [status] + const obj = { + name, + '@id': regionId, + [datasetId]: datasetObj + } + obj[ITERABLE_KEY_SYMBOL] = [datasetId] + regionIdToDataIdMap.set(regionId, obj) + } + } + + const dataIdToDataMap = new Map() + datasetIdToDataMap.set(datasetId, dataIdToDataMap) + + for (const { ['@id']: dataId, contact_points: contactPoints, referenceSpaces } of data) { + dataIdToDataMap.set(dataId, { + ['@id']: dataId, + contactPoints, + referenceSpaces, + }) + } + rs() + }) + }) + ) +) + +const getFeatureMiddleware = (req, res, next) => { + const { featureFullId } = req.params + const datasetIdToDataMapToUse = res.locals['overwrite_datasetIdToDataMap'] || datasetIdToDataMap + if (!datasetIdToDataMapToUse.has(featureFullId)) { + return res.status(404).send(`Not found. - getFeatureMiddleware -`) + } + res.locals['getFeatureMiddleware_cache_0'] = datasetIdToDataMapToUse.get(featureFullId) + res.locals['getFeatureMiddleware_cache_1'] = datasetIdDetailMap.get(featureFullId) + next() +} + +const sendFeatureResponse = (req, res) => { + if (!res.locals['getFeatureMiddleware_cache_0']) return res.status(500).send(`getFeatureMiddleware_cache_0 not populated`) + const fullIdMap = res.locals['getFeatureMiddleware_cache_0'] + const featureDetail = res.locals['getFeatureMiddleware_cache_1'] || {} + const dataKeys = Array.from(fullIdMap.keys()) + return res.status(200).json({ + ...featureDetail, + data: dataKeys.map(dataId => { + return { + ['@id']: dataId, + } + }) + }) +} + +const getFeatureGetDataMiddleware = (req, res, next) => { + const { dataId } = req.params + if (!res.locals['getFeatureMiddleware_cache_0']) return res.status(500).send(`getFeatureMiddleware_cache_0 not populated`) + const map = res.locals['getFeatureMiddleware_cache_0'] + if (!map.has(dataId)) { + return res.status(404).send(`Not found. - getFeatureGetDataMiddleware -`) + } + res.locals['getFeatureGetDataMiddleware_cache_0'] = map.get(dataId) + next() +} + +const sendFeatureDataResponse = (req, res) => { + if (!res.locals['getFeatureGetDataMiddleware_cache_0']) return res.stauts(500).send(`getFeatureGetDataMiddleware_cache_0 not populated`) + const result = res.locals['getFeatureGetDataMiddleware_cache_0'] + res.status(200).json(result) +} + +router.get( + '/byFeature/:featureFullId', + getFeatureMiddleware, + sendFeatureResponse, +) + +router.get( + '/byFeature/:featureFullId/:dataId', + getFeatureMiddleware, + getFeatureGetDataMiddleware, + sendFeatureDataResponse, +) + +const byRegionMiddleware = (req, res, next) => { + + const { regionFullId } = req.params + const { hemisphere, referenceSpaceId } = req.query + + if (!regionIdToDataIdMap.has(regionFullId)) { + return res.status(404).send(`Not found. - byRegionMiddleware -`) + } + + /** + * datasetIdToDataMap: + * datasetId -> dataId -> { ['@id']: string, contactPoints, referenceSpaces } + */ + const overWriteDatasetIdToMap = new Map() + res.locals['byRegionMiddleware_cache_0'] = overWriteDatasetIdToMap + res.locals['overwrite_datasetIdToDataMap'] = overWriteDatasetIdToMap + + /** + * TODO filter by reference spaces + */ + + const regionObj = regionIdToDataIdMap.get(regionFullId) + + for (const datasetId of regionObj[ITERABLE_KEY_SYMBOL]) { + const returnMap = new Map() + overWriteDatasetIdToMap.set(datasetId, returnMap) + for (const hemisphereKey of regionObj[datasetId][ITERABLE_KEY_SYMBOL]) { + + /** + * if hemisphere is defined, then skip if hemisphereKey does not match + */ + + if (!!hemisphere && hemisphereKey !== hemisphere) continue + for (const { ['@id']: dataId } of regionObj[datasetId][hemisphereKey] || []) { + try { + const dataObj = datasetIdToDataMap.get(datasetId).get(dataId) + if ( + !!referenceSpaceId + && !! dataObj['referenceSpaces'] + && dataObj['referenceSpaces'].every(rs => rs['fullId'] !== referenceSpaceId) + ) { + continue + } + returnMap.set( + dataId, + dataObj + ) + } catch (e) { + console.warn(`${datasetId} or ${dataId} could not be found in datasetIdToDataMap`) + } + } + } + } + + next() +} + +router.get( + '/byRegion/:regionFullId', + byRegionMiddleware, + async (req, res) => { + if (!res.locals['byRegionMiddleware_cache_0']) return res.status(500).send(`byRegionMiddleware_cache_0 not populated`) + + const returnMap = res.locals['byRegionMiddleware_cache_0'] + return res.status(200).json({ + features: Array.from(returnMap.keys()).map(id => ({ ['@id']: id })) + }) + } +) + +router.get( + '/byRegion/:regionFullId/:featureFullId', + byRegionMiddleware, + getFeatureMiddleware, + sendFeatureResponse, +) + +router.get( + '/byRegion/:regionFullId/:featureFullId/:dataId', + byRegionMiddleware, + getFeatureMiddleware, + getFeatureGetDataMiddleware, + sendFeatureDataResponse, +) + +const regionalFeatureIsReady = () => Promise.race([ + init.then(() => true), + new Promise(rs => setTimeout(() => rs(false), 500)) +]) + +module.exports = { + router, + regionalFeatureIsReady, +} diff --git a/docs/releases/v2.3.0.md b/docs/releases/v2.3.0.md index a0c965335ab9379b2a8ebfd3f29282255fd9a360..168e0384fb236945f7766bd619d092035e351136 100644 --- a/docs/releases/v2.3.0.md +++ b/docs/releases/v2.3.0.md @@ -12,6 +12,7 @@ - tweaked mesh loading strategies. Now it will wait for all image chunks to be loaded before loading any meshes - showing contributors to a regional feature/dataset if publications are not available - added the ability to customize preview origin dataset to labels other to `View probability map` +- **experimental** : previewing of curated regional features: iEEG coordinates ## Bugfixes: diff --git a/src/atlasViewer/atlasViewer.apiService.service.spec.ts b/src/atlasViewer/atlasViewer.apiService.service.spec.ts index 846b29bd7c57d4ae34735873fa9029fb3ac2619d..f8dd6f5a8eb71361a90e4dbf07069205808d6235 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.spec.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.spec.ts @@ -1,9 +1,8 @@ -import { AtlasViewerAPIServices, overrideNehubaClickFactory, CANCELLABLE_DIALOG } from "src/atlasViewer/atlasViewer.apiService.service"; +import { AtlasViewerAPIServices, CANCELLABLE_DIALOG } from "src/atlasViewer/atlasViewer.apiService.service"; import { async, TestBed, fakeAsync, tick } from "@angular/core/testing"; import { provideMockStore } from "@ngrx/store/testing"; import { defaultRootState } from "src/services/stateStore.service"; import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; -import { HttpClientModule } from '@angular/common/http'; import { WidgetModule } from 'src/widget'; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { PluginServices } from "./pluginUnit"; @@ -276,376 +275,4 @@ describe('atlasViewer.apiService.service.ts', () => { }) }) }) - - - describe('overrideNehubaClickFactory', () => { - - const OVERRIDE_NEHUBA_TOKEN = 'OVERRIDE_NEHUBA_TOKEN' - const MOCK_GET_STATE_SNAPSHOT_TOKEN = 'MOCK_GET_STATE_SNAPSHOT_TOKEN' - - let mockGetMouseOverSegments = [] - let mousePositionReal = [1,2,3] - - afterEach(() => { - mockGetMouseOverSegments = [] - }) - - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - AngularMaterialModule, - HttpClientModule, - WidgetModule, - ], - providers: [ - { - provide: OVERRIDE_NEHUBA_TOKEN, - useFactory: overrideNehubaClickFactory, - deps: [ - AtlasViewerAPIServices, - MOCK_GET_STATE_SNAPSHOT_TOKEN, - ] - }, - { - provide: MOCK_GET_STATE_SNAPSHOT_TOKEN, - useValue: () => { - return { - state: { - uiState: { - mouseOverSegments: mockGetMouseOverSegments - } - }, - other: { - mousePositionReal - } - } - } - }, - { - provide: PluginServices, - useValue: {} - }, - AtlasViewerAPIServices, - provideMockStore({ initialState: defaultRootState }), - ] - }).compileComponents() - })) - - it('can obtain override fn', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) - expect(fn).not.toBeNull() - }) - - describe('> if getUserToSelectRegion.length === 0', () => { - - it('by default, next fn will be called', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - expect(nextSpy).toHaveBeenCalled() - }) - - it('if apiService.getUserToSelectRegion.length === 0, and mouseoversegment.length > 0 calls next', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - - const mockSegment = { - layer: { - name: 'apple' - }, - segment: { - name: 'bananas' - } - } - mockGetMouseOverSegments = [ mockSegment ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(nextSpy).toHaveBeenCalled() - }) - }) - describe('> if getUserToSelectRegion.length > 0', () => { - it('if both apiService.getUserToSelectRegion.length > 0 and mouseoverSegment.length >0, then next will not be called, but rs will be', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy = jasmine.createSpy('rs') - const rjSpy = jasmine.createSpy('rj') - apiService.getUserToSelectRegion = [ - { - message: 'test', - promise: null, - rs: rsSpy, - rj: rjSpy, - } - ] - - const mockSegment = { - layer: { - name: 'apple' - }, - segment: { - name: 'bananas' - } - } - mockGetMouseOverSegments = [ mockSegment ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(nextSpy).not.toHaveBeenCalled() - expect(rsSpy).toHaveBeenCalledWith(mockSegment) - }) - it('if multiple getUserToSelectRegion handler exists, it resolves in a LIFO manner', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy1 = jasmine.createSpy('rs1') - const rjSpy1 = jasmine.createSpy('rj1') - - const rsSpy2 = jasmine.createSpy('rs2') - const rjSpy2 = jasmine.createSpy('rj2') - apiService.getUserToSelectRegion = [ - { - message: 'test1', - promise: null, - rs: rsSpy1, - rj: rjSpy1, - }, - { - message: 'test2', - promise: null, - rs: rsSpy2, - rj: rjSpy2, - } - ] - - const mockSegment = { - layer: { - name: 'apple' - }, - segment: { - name: 'bananas' - } - } - - mockGetMouseOverSegments = [ mockSegment ] - - const nextSpy1 = jasmine.createSpy('next1') - fn(nextSpy1) - - expect(rsSpy2).toHaveBeenCalledWith(mockSegment) - expect(rjSpy2).not.toHaveBeenCalled() - - expect(nextSpy1).not.toHaveBeenCalled() - expect(rsSpy1).not.toHaveBeenCalled() - expect(rjSpy1).not.toHaveBeenCalled() - - const nextSpy2 = jasmine.createSpy('next2') - fn(nextSpy2) - - expect(nextSpy2).not.toHaveBeenCalled() - expect(rsSpy1).toHaveBeenCalledWith(mockSegment) - expect(rjSpy1).not.toHaveBeenCalled() - - const nextSpy3 = jasmine.createSpy('next3') - fn(nextSpy3) - - expect(nextSpy3).toHaveBeenCalled() - }) - - describe('> if spec is not set (defaults to parcellation region mode)', () => { - - it('if apiService.getUserToSelectRegion.length > 0, but mouseoversegment.length ===0, will not call next, will not rs, will not call rj', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy = jasmine.createSpy('rs') - const rjSpy = jasmine.createSpy('rj') - apiService.getUserToSelectRegion = [ - { - message: 'test', - promise: null, - rs: rsSpy, - rj: rjSpy, - } - ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(rsSpy).not.toHaveBeenCalled() - expect(nextSpy).toHaveBeenCalled() - expect(rjSpy).not.toHaveBeenCalled() - }) - }) - - describe('> if spec is set', () => { - describe('> if spec is set to PARCELLATION_REGION', () => { - - it('> mouseoversegment.length === 0, will not call next, will not rs, will not call rj', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy = jasmine.createSpy('rs') - const rjSpy = jasmine.createSpy('rj') - apiService.getUserToSelectRegion = [ - { - message: 'test', - promise: null, - spec: { - type: 'PARCELLATION_REGION' - }, - rs: rsSpy, - rj: rjSpy, - } - ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(rsSpy).not.toHaveBeenCalled() - expect(nextSpy).toHaveBeenCalled() - expect(rjSpy).not.toHaveBeenCalled() - }) - - it('> mouseoversegment.length > 0, will not call next, will call rs', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const mockSegment = { - layer: { - name: 'apple' - }, - segment: { - name: 'bananas' - } - } - mockGetMouseOverSegments = [ mockSegment ] - - const rsSpy = jasmine.createSpy('rs') - const rjSpy = jasmine.createSpy('rj') - apiService.getUserToSelectRegion = [ - { - message: 'test', - promise: null, - spec: { - type: 'PARCELLATION_REGION' - }, - rs: rsSpy, - rj: rjSpy, - } - ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(rsSpy).toHaveBeenCalled() - expect(nextSpy).not.toHaveBeenCalled() - expect(rjSpy).not.toHaveBeenCalled() - }) - }) - - describe('> if spec is set to POINT', () => { - it('> rs is called if mouseoversegment.length === 0', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy = jasmine.createSpy('rs') - const rjSpy = jasmine.createSpy('rj') - apiService.getUserToSelectRegion = [ - { - message: 'test', - promise: null, - spec: { - type: 'POINT' - }, - rs: rsSpy, - rj: rjSpy, - } - ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(rsSpy).toHaveBeenCalled() - expect(nextSpy).not.toHaveBeenCalled() - expect(rjSpy).not.toHaveBeenCalled() - }) - it('> rs is called with correct arg if mouseoversegment.length > 0', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy = jasmine.createSpy('rs') - const rjSpy = jasmine.createSpy('rj') - apiService.getUserToSelectRegion = [ - { - message: 'test', - promise: null, - spec: { - type: 'POINT' - }, - rs: rsSpy, - rj: rjSpy, - } - ] - - const nextSpy = jasmine.createSpy('next') - fn(nextSpy) - - expect(rsSpy).toHaveBeenCalledWith({ - type: 'POINT', - payload: mousePositionReal - }) - expect(nextSpy).not.toHaveBeenCalled() - expect(rjSpy).not.toHaveBeenCalled() - }) - }) - - describe('> if multiple getUserToSelectRegion exist', () => { - it('> only the last Promise will be evaluated', () => { - const fn = TestBed.inject(OVERRIDE_NEHUBA_TOKEN as any) as (next: () => void) => void - const apiService = TestBed.inject(AtlasViewerAPIServices) - - const rsSpy1 = jasmine.createSpy('rs1') - const rjSpy1 = jasmine.createSpy('rj1') - - const rsSpy2 = jasmine.createSpy('rs2') - const rjSpy2 = jasmine.createSpy('rj2') - apiService.getUserToSelectRegion = [ - { - message: 'test1', - promise: null, - spec: { - type: 'POINT' - }, - rs: rsSpy1, - rj: rjSpy1, - }, - { - message: 'test2', - promise: null, - spec: { - type: 'PARCELLATION_REGION' - }, - rs: rsSpy2, - rj: rjSpy2, - } - ] - - const nextSpy1 = jasmine.createSpy('next1') - fn(nextSpy1) - - expect(rsSpy2).not.toHaveBeenCalled() - expect(rjSpy2).not.toHaveBeenCalled() - - expect(nextSpy1).toHaveBeenCalled() - expect(rsSpy1).not.toHaveBeenCalled() - expect(rjSpy1).not.toHaveBeenCalled() - - }) - }) - }) - }) - }) }) diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index aea9618aa57060e40a6333322834bbadb14220c3..7277a3ebb7422137b55ea234b7d1b3db591baeab 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -2,14 +2,16 @@ import {Injectable, NgZone, Optional, Inject, OnDestroy, InjectionToken} from "@angular/core"; import { select, Store } from "@ngrx/store"; import { Observable, Subject, Subscription, from, race, of, } from "rxjs"; -import { distinctUntilChanged, map, filter, startWith, switchMap, catchError, mapTo } from "rxjs/operators"; +import { distinctUntilChanged, map, filter, startWith, switchMap, catchError, mapTo, take } from "rxjs/operators"; import { DialogService } from "src/services/dialogService.service"; +import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors"; import { getLabelIndexMap, getMultiNgIdsRegionsLabelIndexMap, IavRootStoreInterface, safeFilter } from "src/services/stateStore.service"; +import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { ModalHandler } from "../util/pluginHandlerClasses/modalHandler"; import { ToastHandler } from "../util/pluginHandlerClasses/toastHandler"; import { IPluginManifest, PluginServices } from "./pluginUnit"; @@ -38,6 +40,7 @@ export const GET_TOAST_HANDLER_TOKEN = 'GET_TOAST_HANDLER_TOKEN' export class AtlasViewerAPIServices implements OnDestroy{ + private onDestoryCb: Function[] = [] private loadedTemplates$: Observable<any> private selectParcellation$: Observable<any> public interactiveViewer: IInteractiveViewerInterface @@ -77,6 +80,68 @@ export class AtlasViewerAPIServices implements OnDestroy{ private s: Subscription[] = [] + private onMouseClick(ev: any, next){ + const { rs, spec } = this.getNextUserRegionSelectHandler() || {} + if (!!rs) { + + let moSegments + this.store.pipe( + select(uiStateMouseOverSegmentsSelector), + take(1) + ).subscribe(val => moSegments = val) + + /** + * getROI api + */ + if (spec) { + /** + * if spec of overwrite click is for a point + */ + if (spec.type === EnumCustomRegion.POINT) { + this.popUserRegionSelectHandler() + let mousePositionReal + // rather than commiting mousePositionReal in state via action, do a single subscription instead. + // otherwise, the state gets updated way too often + if (window && (window as any).nehubaViewer) { + (window as any).nehubaViewer.mousePosition.inRealSpace + .take(1) + .subscribe(floatArr => { + mousePositionReal = floatArr && Array.from(floatArr).map((val: number) => val / 1e6) + }) + } + return rs({ + type: spec.type, + payload: mousePositionReal + }) + } + + /** + * if spec of overwrite click is for a point + */ + if (spec.type === EnumCustomRegion.PARCELLATION_REGION) { + + if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { + this.popUserRegionSelectHandler() + return rs({ + type: spec.type, + payload: moSegments + }) + } + } + } else { + /** + * selectARegion API + * TODO deprecate + */ + if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { + this.popUserRegionSelectHandler() + return rs(moSegments[0]) + } + } + } + next() + } + constructor( private store: Store<IavRootStoreInterface>, private dialogService: DialogService, @@ -84,7 +149,14 @@ export class AtlasViewerAPIServices implements OnDestroy{ private pluginService: PluginServices, @Optional() @Inject(CANCELLABLE_DIALOG) openCancellableDialog: (message: string, options: any) => () => void, @Optional() @Inject(GET_TOAST_HANDLER_TOKEN) private getToastHandler: Function, + @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor ) { + if (clickInterceptor) { + const { register, deregister } = clickInterceptor + const onMouseClick = this.onMouseClick.bind(this) + register(onMouseClick) + this.onDestoryCb.push(() => deregister(onMouseClick)) + } if (openCancellableDialog) { this.s.push( this.getUserToSelectRegionUI$.pipe( @@ -299,6 +371,7 @@ export class AtlasViewerAPIServices implements OnDestroy{ } ngOnDestroy(){ + while (this.onDestoryCb.length > 0) this.onDestoryCb.pop()() while(this.s.length > 0){ this.s.pop().unsubscribe() } @@ -396,57 +469,6 @@ export interface ICustomRegionSpec{ type: string // type of EnumCustomRegion } -export const overrideNehubaClickFactory = (apiService: AtlasViewerAPIServices, getState: () => any ) => { - return (next: () => void) => { - const { state, other } = getState() - const moSegments = state?.uiState?.mouseOverSegments - const { mousePositionReal } = other || {} - const { rs, spec } = apiService.getNextUserRegionSelectHandler() || {} - if (!!rs) { - - /** - * getROI api - */ - if (spec) { - - /** - * if spec of overwrite click is for a point - */ - if (spec.type === EnumCustomRegion.POINT) { - apiService.popUserRegionSelectHandler() - return rs({ - type: spec.type, - payload: mousePositionReal - }) - } - - /** - * if spec of overwrite click is for a point - */ - if (spec.type === EnumCustomRegion.PARCELLATION_REGION) { - if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { - apiService.popUserRegionSelectHandler() - return rs({ - type: spec.type, - payload: moSegments - }) - } - } - } else { - /** - * selectARegion API - * TODO deprecate - */ - if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { - apiService.popUserRegionSelectHandler() - return rs(moSegments[0]) - } - } - } - next() - } -} - export const API_SERVICE_SET_VIEWER_HANDLE_TOKEN = new InjectionToken<(viewerHandle) => void>('API_SERVICE_SET_VIEWER_HANDLE_TOKEN') export const setViewerHandleFactory = (apiService: AtlasViewerAPIServices) => { diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index b584c241041aa0b53cd036ab78dcf56a8da9c5a9..66a9b72648ecf3a751de5c2014e0d402173f5795 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -35,8 +35,6 @@ import {MatSnackBar, MatSnackBarRef} from "@angular/material/snack-bar"; import {MatDialog, MatDialogRef} from "@angular/material/dialog"; import { ARIA_LABELS, CONST } from 'common/constants' -export const NEHUBA_CLICK_OVERRIDE: InjectionToken<(next: () => void) => void> = new InjectionToken('NEHUBA_CLICK_OVERRIDE') - import { MIN_REQ_EXPLAINER } from 'src/util/constants' import { SlServiceService } from "src/spotlight/sl-service.service"; import { PureContantService } from "src/util"; @@ -45,6 +43,7 @@ import { viewerStateGetOverlayingAdditionalParcellations, viewerStateParcVersion import { ngViewerSelectorClearViewEntries } from "src/services/state/ngViewerState/selectors"; import { ngViewerActionClearView } from "src/services/state/ngViewerState/actions"; import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors"; +import { ClickInterceptorService } from "src/glue"; /** * TODO @@ -149,8 +148,8 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { public localFileService: LocalFileService, private snackbar: MatSnackBar, private el: ElementRef, - @Optional() @Inject(NEHUBA_CLICK_OVERRIDE) private nehubaClickOverride: Function, - private slService: SlServiceService + private slService: SlServiceService, + private clickIntService: ClickInterceptorService ) { this.snackbarMessage$ = this.store.pipe( @@ -249,6 +248,7 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { public ngOnInit() { this.meetsRequirement = this.meetsRequirements() + this.clickIntService.addInterceptor(this.selectHoveredRegion.bind(this), true) if (KIOSK_MODE) { @@ -388,6 +388,18 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { */ public ngOnDestroy() { this.subscriptions.forEach(s => s.unsubscribe()) + this.clickIntService.removeInterceptor(this.selectHoveredRegion.bind(this)) + } + + private selectHoveredRegion(ev: any, next: Function){ + if (!this.onhoverSegments) return + + this.store.dispatch( + viewerStateSetSelectedRegions({ + selectRegions: this.onhoverSegments.slice(0, 1) + }) + ) + next() } public unsetClearViewByKey(key: string){ @@ -431,18 +443,7 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { } public mouseClickDocument(_event: MouseEvent) { - - const next = () => { - if (!this.onhoverSegments) return - this.store.dispatch( - viewerStateSetSelectedRegions({ - selectRegions: this.onhoverSegments.slice(0, 1) - }) - ) - } - - this.nehubaClickOverride(next) - + this.clickIntService.run(_event) } /** diff --git a/src/atlasViewer/mouseOver.directive.ts b/src/atlasViewer/mouseOver.directive.ts index 9ba058431e326445fe97014d2decf15bbca6dabf..7938d4a36c1ef02ce7431565426a34af42af7b62 100644 --- a/src/atlasViewer/mouseOver.directive.ts +++ b/src/atlasViewer/mouseOver.directive.ts @@ -5,7 +5,7 @@ import { combineLatest, merge, Observable } from "rxjs"; import { distinctUntilChanged, filter, map, scan, shareReplay, startWith, withLatestFrom } from "rxjs/operators"; import { TransformOnhoverSegmentPipe } from "src/atlasViewer/onhoverSegment.pipe"; import { LoggingService } from "src/logging"; -import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors"; +import { uiStateMouseOverSegmentsSelector, uiStateMouseoverUserLandmark } from "src/services/state/uiState/selectors"; import { getNgIdLabelIndexFromId } from "src/services/stateStore.service"; /** @@ -53,8 +53,7 @@ export class MouseHoverDirective { // can potentially net better performance const onHoverUserLandmark$ = this.store$.pipe( - select('uiState'), - select('mouseOverUserLandmark'), + select(uiStateMouseoverUserLandmark) ) const onHoverLandmark$ = combineLatest( diff --git a/src/glue.spec.ts b/src/glue.spec.ts index a78f1a6058706ea7d56add6cbee66fadc95322e9..aba6f15912e2050d8e2a25bc11d58216bd001934 100644 --- a/src/glue.spec.ts +++ b/src/glue.spec.ts @@ -1206,4 +1206,70 @@ describe('> glue.ts', () => { }) }) + + describe('> ClickInterceptorService', () => { + /** + * TODO finish writing the test for ClickInterceptorService + */ + + it('can obtain override fn', () => { + + }) + + describe('> if getUserToSelectRegion.length === 0', () => { + + it('by default, next fn will be called', () => { + + }) + + it('if apiService.getUserToSelectRegion.length === 0, and mouseoversegment.length > 0 calls next', () => { + + }) + }) + describe('> if getUserToSelectRegion.length > 0', () => { + it('if both apiService.getUserToSelectRegion.length > 0 and mouseoverSegment.length >0, then next will not be called, but rs will be', () => { + + }) + it('if multiple getUserToSelectRegion handler exists, it resolves in a LIFO manner', () => { + + }) + + describe('> if spec is not set (defaults to parcellation region mode)', () => { + + it('if apiService.getUserToSelectRegion.length > 0, but mouseoversegment.length ===0, will not call next, will not rs, will not call rj', () => { + + }) + }) + + describe('> if spec is set', () => { + describe('> if spec is set to PARCELLATION_REGION', () => { + + it('> mouseoversegment.length === 0, will not call next, will not rs, will not call rj', () => { + + }) + + it('> mouseoversegment.length > 0, will not call next, will call rs', () => { + + }) + }) + + describe('> if spec is set to POINT', () => { + it('> rs is called if mouseoversegment.length === 0', () => { + + }) + it('> rs is called with correct arg if mouseoversegment.length > 0', () => { + + }) + }) + + describe('> if multiple getUserToSelectRegion exist', () => { + it('> only the last Promise will be evaluated', () => { + + + }) + }) + }) + }) + + }) }) diff --git a/src/glue.ts b/src/glue.ts index 671fc0bebf2039930a5278ea645f2936cd553ae1..5d94c0c6a839b9b4c85042123044d6899cd501b3 100644 --- a/src/glue.ts +++ b/src/glue.ts @@ -720,3 +720,39 @@ export const gluActionSetFavDataset = createAction( '[glue] favDataset', props<{dataentries: Partial<IKgDataEntry>[]}>() ) + +@Injectable({ + providedIn: 'root' +}) + +export class ClickInterceptorService{ + private clickInterceptorStack: Function[] = [] + + removeInterceptor(fn: Function) { + const idx = this.clickInterceptorStack.findIndex(int => int === fn) + if (idx < 0) { + console.warn(`clickInterceptorService could not remove the function. Did you pass the EXACT reference? + Anonymouse functions such as () => {} or .bind will create a new reference! + You may want to assign .bind to a ref, and pass it to register and unregister functions`) + } else { + this.clickInterceptorStack.splice(idx, 1) + } + } + addInterceptor(fn: Function, atTheEnd?: boolean) { + if (atTheEnd) { + this.clickInterceptorStack.push(fn) + } else { + this.clickInterceptorStack.unshift(fn) + } + } + + run(ev: any){ + for (const clickInc of this.clickInterceptorStack) { + let runNext = false + clickInc(ev, () => { + runNext = true + }) + if (!runNext) break + } + } +} diff --git a/src/main.module.ts b/src/main.module.ts index 0c274937627d74c3498016649b6aab73c2b5f55c..b0ca199211e7e8476df25182d2dc1c195a24373b 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -4,7 +4,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule, InjectionToken } from "@angular/core" import { FormsModule } from "@angular/forms"; import { StoreModule, Store, ActionReducer } from "@ngrx/store"; import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module' -import { AtlasViewer, NEHUBA_CLICK_OVERRIDE } from "./atlasViewer/atlasViewer.component"; +import { AtlasViewer } from "./atlasViewer/atlasViewer.component"; import { ComponentsModule } from "./components/components.module"; import { LayoutModule } from "./layouts/layout.module"; import { ngViewerState, pluginState, uiState, userConfigState, UserConfigStateUseEffect, viewerConfigState, viewerState } from "./services/stateStore.service"; @@ -14,7 +14,7 @@ import { GetNamesPipe } from "./util/pipes/getNames.pipe"; import { HttpClientModule } from "@angular/common/http"; import { EffectsModule } from "@ngrx/effects"; -import { AtlasViewerAPIServices, overrideNehubaClickFactory, CANCELLABLE_DIALOG, GET_TOAST_HANDLER_TOKEN, API_SERVICE_SET_VIEWER_HANDLE_TOKEN, setViewerHandleFactory } from "./atlasViewer/atlasViewer.apiService.service"; +import { AtlasViewerAPIServices, CANCELLABLE_DIALOG, GET_TOAST_HANDLER_TOKEN, API_SERVICE_SET_VIEWER_HANDLE_TOKEN, setViewerHandleFactory } from "./atlasViewer/atlasViewer.apiService.service"; import { AtlasWorkerService } from "./atlasViewer/atlasViewer.workerService.service"; import { ModalUnit } from "./atlasViewer/modalUnit/modalUnit.component"; import { TransformOnhoverSegmentPipe } from "./atlasViewer/onhoverSegment.pipe"; @@ -33,7 +33,7 @@ import { DragDropDirective } from "./util/directives/dragDrop.directive"; import { FloatingContainerDirective } from "./util/directives/floatingContainer.directive"; import { FloatingMouseContextualContainerDirective } from "./util/directives/floatingMouseContextualContainer.directive"; import { NewViewerDisctinctViewToLayer } from "./util/pipes/newViewerDistinctViewToLayer.pipe"; -import { UtilModule } from "src/util"; +import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR, UtilModule } from "src/util"; import { SpotLightModule } from 'src/spotlight/spot-light.module' import { TryMeComponent } from "./ui/tryme/tryme.component"; import { MouseHoverDirective, MouseOverIconPipe, MouseOverTextPipe } from "./atlasViewer/mouseOver.directive"; @@ -53,7 +53,7 @@ import 'hammerjs' import 'src/res/css/extra_styles.css' import 'src/res/css/version.css' import 'src/theme.scss' -import { DatasetPreviewGlue, datasetPreviewMetaReducer, IDatasetPreviewGlue, GlueEffects } from './glue'; +import { DatasetPreviewGlue, datasetPreviewMetaReducer, IDatasetPreviewGlue, GlueEffects, ClickInterceptorService } from './glue'; import { viewerStateHelperReducer, viewerStateFleshOutDetail, viewerStateMetaReducers, ViewerStateHelperEffect } from './services/state/viewerState.store.helper'; import { take } from 'rxjs/operators'; import { TOS_OBS_INJECTION_TOKEN } from './ui/kgtos/kgtos.component'; @@ -68,8 +68,6 @@ export function debug(reducer: ActionReducer<any>): ActionReducer<any> { }; } -export const GET_STATE_SNAPSHOT_TOKEN = new InjectionToken('GET_STATE_SNAPSHOT_TOKEN') - @NgModule({ imports : [ FormsModule, @@ -154,37 +152,7 @@ export const GET_STATE_SNAPSHOT_TOKEN = new InjectionToken('GET_STATE_SNAPSHOT_T DialogService, UIService, TemplateCoordinatesTransformation, - { - provide: NEHUBA_CLICK_OVERRIDE, - useFactory: overrideNehubaClickFactory, - deps: [ - AtlasViewerAPIServices, - GET_STATE_SNAPSHOT_TOKEN - ] - }, - { - provide: GET_STATE_SNAPSHOT_TOKEN, - useFactory: (store: Store<any>) => { - return () => { - const other: any = {} - let state - // rather than commiting mousePositionReal in state via action, do a single subscription instead. - // otherwise, the state gets updated way too often - if (window && (window as any).nehubaViewer) { - (window as any).nehubaViewer.mousePosition.inRealSpace - .take(1) - .subscribe(floatArr => { - other.mousePositionReal = floatArr && Array.from(floatArr).map((val: number) => val / 1e6) - }) - } - store.pipe( - take(1) - ).subscribe(v => state = v) - return { state, other } - } - }, - deps: [ Store ] - }, + ClickInterceptorService, { provide: GET_TOAST_HANDLER_TOKEN, useFactory: (uiService: UIService) => { @@ -263,6 +231,18 @@ export const GET_STATE_SNAPSHOT_TOKEN = new InjectionToken('GET_STATE_SNAPSHOT_T useFactory: setViewerHandleFactory, deps: [ AtlasViewerAPIServices ] }, + { + provide: CLICK_INTERCEPTOR_INJECTOR, + useFactory: (clickIntService: ClickInterceptorService) => { + return { + deregister: clickIntService.removeInterceptor.bind(clickIntService), + register: clickIntService.addInterceptor.bind(clickIntService) + } as ClickInterceptor + }, + deps: [ + ClickInterceptorService + ] + } ], bootstrap : [ AtlasViewer, diff --git a/src/services/state/uiState/selectors.ts b/src/services/state/uiState/selectors.ts index 3a8e3f913b509c03cf4f0bd8e767b9bd3c098ad3..f72757b436adbf77e19becde70cff55213484504 100644 --- a/src/services/state/uiState/selectors.ts +++ b/src/services/state/uiState/selectors.ts @@ -21,3 +21,8 @@ export const uiStateMouseOverSegmentsSelector = createSelector( }) } ) + +export const uiStateMouseoverUserLandmark = createSelector( + state => state['uiState'], + uiState => uiState['mouseOverUserLandmark'] +) diff --git a/src/services/state/viewerState.store.helper.ts b/src/services/state/viewerState.store.helper.ts index d69751026a665900376790f645a887beaf2fa6b0..16ee0b763bf6211febbb479318463d0ca84bc849 100644 --- a/src/services/state/viewerState.store.helper.ts +++ b/src/services/state/viewerState.store.helper.ts @@ -61,6 +61,7 @@ import { viewerStateFetchedTemplatesSelector, viewerStateNavigationStateSelector, } from './viewerState/selectors' +import { IHasId } from "src/util/interfaces"; export { viewerStateSelectedRegionsSelector, @@ -160,10 +161,6 @@ interface IHasVersion{ ['@version']: IVersion } -interface IHasId{ - ['@id']: string -} - export function isNewerThan(arr: IHasVersion[], srcObj: IHasId, compObj: IHasId): boolean { function* GenNewerVersions(flag){ diff --git a/src/ui/atlasDropdown/atlasDropdown.template.html b/src/ui/atlasDropdown/atlasDropdown.template.html index 3516dc424273afd50b6f98a4bf04d6d94f3d2455..4b5ddc4b0309e62ff937c8e6e5ffefeadfc55a6a 100644 --- a/src/ui/atlasDropdown/atlasDropdown.template.html +++ b/src/ui/atlasDropdown/atlasDropdown.template.html @@ -4,7 +4,7 @@ </mat-label> <mat-select [aria-label]="SELECT_ATLAS_ARIA_LABEL" - [value]="selectedAtlas$ | async | mapToProperty" + [value]="selectedAtlas$ | async | getProperty" (selectionChange)="handleChangeAtlas($event)"> <mat-option *ngFor="let atlas of (fetchedAtlases$ | async)" diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index 34e1da0f90683908fd3330ae7f0d2571af001da7..479512f7a9ebc9896b018f6e594f325073f7d1f8 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -459,12 +459,15 @@ </ng-container> <!-- regional features--> - <ng-template #regionalFeaturesTmpl> - <data-browser [template]="templateSelected$ | async" - [parcellation]="selectedParcellation" - [disableVirtualScroll]="true" - [regions]="[region]"> - </data-browser> + <ng-template #regionalFeaturesTmpl let-expansionPanel="expansionPanel"> + <regional-features *ngIf="expansionPanel.expanded" + [region]="region"> + <data-browser [template]="templateSelected$ | async" + [parcellation]="selectedParcellation" + [disableVirtualScroll]="true" + [regions]="[region]"> + </data-browser> + </regional-features> </ng-template> <div class="hidden" iav-databrowser-directive @@ -576,8 +579,8 @@ <layout-floating-container *ngIf="panelIndex < 3" landmarkContainer> <!-- customLandmarks --> - <nehuba-2dlandmark-unit *ngFor="let lm of (customLandmarks$ | async)" - (mouseenter)="handleMouseEnterCustomLandmark(lm)" + <nehuba-2dlandmark-unit *ngFor="let lm of (customLandmarks$ | async | filterByProperty : 'showInSliceView')" + (mouseenter)="handleMouseEnterCustomLandmark(lm)" (mouseleave)="handleMouseLeaveCustomLandmark(lm)" fasClass="fa-chevron-down" [color]="lm.color || [255, 255, 255]" diff --git a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts index c20f8f143ad0e8f27928597e1ead856309d2fd2b..4557642ee848cae8c9fa6523221ffe9bafe6fb60 100644 --- a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts +++ b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.ts @@ -527,7 +527,7 @@ export class NehubaViewerUnit implements OnInit, OnDestroy { const appendConditional = (frag, idx) => frag && `if (label > ${idx - 0.01} && label < ${idx + 0.01}) { ${frag} }` if (landmarks.some(parseLmColor)) { - this.userLandmarkShader = `void main(){ ${landmarks.map(parseLmColor).map(appendConditional).filter(v => !!v).join('else ')} else {${FRAGMENT_EMIT_RED}} }` + this.userLandmarkShader = `void main(){ ${landmarks.map(parseLmColor).map(appendConditional).filter(v => !!v).join('else ')} else {${FRAGMENT_EMIT_WHITE}} }` } else { this.userLandmarkShader = FRAGMENT_MAIN_WHITE } diff --git a/src/ui/regionalFeatures/featureExplorer/featureExplorer.component.ts b/src/ui/regionalFeatures/featureExplorer/featureExplorer.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..8be301cd949c843d6722f1c3e3d201695af3b564 --- /dev/null +++ b/src/ui/regionalFeatures/featureExplorer/featureExplorer.component.ts @@ -0,0 +1,146 @@ +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from "@angular/core"; +import { BehaviorSubject, forkJoin, Observable, Subject, Subscription } from "rxjs"; +import { filter, shareReplay, switchMap, switchMapTo, tap } from "rxjs/operators"; +import { IHasId } from "src/util/interfaces"; +import { IFeature, RegionalFeaturesService } from "../regionalFeature.service"; +import { RegionFeatureBase } from "../regionFeature.base"; + +const selectedColor = [ 255, 0, 0 ] + +@Component({ + selector: 'feature-explorer', + templateUrl: './featureExplorer.template.html', + styleUrls: [ + './featureExplorer.style.css' + ] +}) + +export class FeatureExplorer extends RegionFeatureBase implements OnChanges, OnInit, OnDestroy{ + + private landmarksLoaded: IHasId[] = [] + private onDestroyCb: Function[] = [] + private sub: Subscription[] = [] + private _feature: IFeature + + private feature$ = new BehaviorSubject(null) + @Input() + set feature(val) { + this._feature = val + this.feature$.next(val) + } + + public data$: Observable<IHasId[]> + + constructor( + private regionFeatureService: RegionalFeaturesService, + ){ + super(regionFeatureService) + /** + * once feature stops loading, watch for input feature + */ + this.data$ = this.isLoading$.pipe( + filter(v => !v), + tap(() => this.dataIsLoading = true), + switchMapTo(this.feature$), + switchMap((feature: IFeature) => forkJoin( + feature.data.map(datum => this.regionFeatureService.getFeatureData(this.region, feature, datum))) + ), + tap(() => this.dataIsLoading = false), + shareReplay(1), + ) + } + + ngOnInit(){ + this.sub.push( + this.data$.subscribe(data => { + const landmarksTobeLoaded: IHasId[] = [] + + for (const datum of data) { + const electrodeId = datum['@id'] + landmarksTobeLoaded.push( + ...datum['contactPoints'].map(({ ['@id']: contactPtId, position }) => { + return { + _: { + electrodeId, + contactPtId + }, + ['@id']: `${electrodeId}#${contactPtId}`, + position + } + }) + ) + } + /** + * remove first, then add + */ + if (this.landmarksLoaded.length > 0) this.regionFeatureService.removeLandmarks(this.landmarksLoaded) + if (landmarksTobeLoaded.length > 0) this.regionFeatureService.addLandmarks(landmarksTobeLoaded) + this.landmarksLoaded = landmarksTobeLoaded + }) + ) + + this.onDestroyCb.push(() => { + if (this.landmarksLoaded.length > 0) this.regionFeatureService.removeLandmarks(this.landmarksLoaded) + }) + } + + public dataIsLoading$ = new BehaviorSubject(false) + private _dataIsLoading = false + set dataIsLoading(val) { + if (val === this._dataIsLoading) return + this._dataIsLoading = val + this.dataIsLoading$.next(val) + } + get dataIsLoading(){ + return this._dataIsLoading + } + + ngOnChanges(changes: SimpleChanges){ + super.ngOnChanges(changes) + } + + ngOnDestroy(){ + while(this.onDestroyCb.length > 0) this.onDestroyCb.pop()() + while(this.sub.length > 0) this.sub.pop().unsubscribe() + } + + handleDatumExpansion(electrodeId: string, open: boolean){ + /** + * TODO either debounce call here, or later down stream + */ + if (this.landmarksLoaded.length > 0) this.regionFeatureService.removeLandmarks(this.landmarksLoaded) + if (this.landmarksLoaded.length > 0) { + this.regionFeatureService.addLandmarks(this.landmarksLoaded.map(lm => { + if (lm['_']['electrodeId'] === electrodeId) { + return { + ...lm, + color: open ? selectedColor : null, + showInSliceView: open + } + } else { + return lm + } + })) + } + } + + public exploreElectrode$ = new Subject() + + handleLandmarkClick(arg: { landmark: any, next: Function }) { + const { landmark, next } = arg + + /** + * there may be other custom landmarks + * so check if the landmark clicked is one that's managed by this cmp + */ + const isOne = this.landmarksLoaded.some(lm => { + return lm['_']['electrodeId'] === landmark['_']['electrodeId'] + }) + + if (isOne) { + this.exploreElectrode$.next(landmark) + } else { + next() + } + } +} \ No newline at end of file diff --git a/src/ui/regionalFeatures/featureExplorer/featureExplorer.style.css b/src/ui/regionalFeatures/featureExplorer/featureExplorer.style.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/ui/regionalFeatures/featureExplorer/featureExplorer.template.html b/src/ui/regionalFeatures/featureExplorer/featureExplorer.template.html new file mode 100644 index 0000000000000000000000000000000000000000..06f519eb256af715bf1f419ee13089f1e6d20ee5 --- /dev/null +++ b/src/ui/regionalFeatures/featureExplorer/featureExplorer.template.html @@ -0,0 +1,38 @@ +<ng-container *ngIf="!dataIsLoading; else loadingTmpl"> + + <mat-accordion + regional-feature-interactiviity + (rf-interact-onclick-3d-landmark)="handleLandmarkClick($event)" + #interactDir="regionalFeatureInteractivity"> + <mat-expansion-panel *ngFor="let datum of (data$ | async)" + [expanded]="(exploreElectrode$ | async | getProperty : '_' | getProperty : 'electrodeId') === datum['@id']" + (opened)="handleDatumExpansion(datum['@id'], true)" + (closed)="handleDatumExpansion(datum['@id'], false)" + hideToggle> + <mat-expansion-panel-header> + <mat-panel-title> + Electrode + </mat-panel-title> + <mat-panel-description class="text-nowrap"> + {{ datum['@id'] }} + </mat-panel-description> + </mat-expansion-panel-header> + + <span> + contact points + </span> + <mat-list> + <mat-list-item *ngFor="let contactPt of datum['contactPoints']"> + {{ contactPt.position | addUnitAndJoin : '' }} + </mat-list-item> + </mat-list> + + </mat-expansion-panel> + </mat-accordion> + +</ng-container> + +<!-- loading template --> +<ng-template #loadingTmpl> + <div class="spinnerAnimationCircle"></div> +</ng-template> diff --git a/src/ui/regionalFeatures/index.ts b/src/ui/regionalFeatures/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a71230d7107939f7da63d18d843339937ed89290 --- /dev/null +++ b/src/ui/regionalFeatures/index.ts @@ -0,0 +1,2 @@ +export { RegionalFeaturesModule } from './module' +export { IFeature } from './regionalFeature.service' \ No newline at end of file diff --git a/src/ui/regionalFeatures/interactivity.directive.ts b/src/ui/regionalFeatures/interactivity.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5c24ff8f456e762a01da417307f504790b3c148 --- /dev/null +++ b/src/ui/regionalFeatures/interactivity.directive.ts @@ -0,0 +1,54 @@ +import { Directive, Inject, EventEmitter, OnDestroy, Optional, Output } from "@angular/core"; +import { take } from "rxjs/operators"; +import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; +import { RegionalFeaturesService } from "./regionalFeature.service"; + +@Directive({ + selector: '[regional-feature-interactiviity]', + exportAs: 'regionalFeatureInteractivity' +}) + +export class RegionalFeatureInteractivity implements OnDestroy{ + + @Output('rf-interact-onclick-3d-landmark') + onClick3DLandmark: EventEmitter<{ landmark: any, next: Function }> = new EventEmitter() + + private onDestroyCb: Function[] = [] + + constructor( + private regionalFeatureService: RegionalFeaturesService, + @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) private regClickIntp: ClickInterceptor, + ){ + + if (this.regClickIntp) { + const { deregister, register } = this.regClickIntp + const clickIntp = this.clickIntp.bind(this) + register(clickIntp) + this.onDestroyCb.push(() => { + deregister(clickIntp) + }) + } + + } + + ngOnDestroy(){ + while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()() + } + + private clickIntp(ev: any, next: Function) { + let hoveredLandmark = null + this.regionalFeatureService.onHoverLandmarks$.pipe( + take(1) + ).subscribe(val => { + hoveredLandmark = val + }) + if (hoveredLandmark) { + this.onClick3DLandmark.emit({ + landmark: hoveredLandmark, + next + }) + } else { + next() + } + } +} diff --git a/src/ui/regionalFeatures/module.ts b/src/ui/regionalFeatures/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..813cc93fc4877f1f0a55c9a7e6b93e48db5ac37b --- /dev/null +++ b/src/ui/regionalFeatures/module.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { UtilModule } from "src/util"; +import { AngularMaterialModule } from "../sharedModules/angularMaterial.module"; +import { FeatureExplorer } from "./featureExplorer/featureExplorer.component"; +import { RegionalFeatureInteractivity } from "./interactivity.directive"; +import { FilterRegionalFeaturesByTypePipe } from "./pipes/filterRegionalFeaturesByType.pipe"; +import { FindRegionFEatureById } from "./pipes/findRegionFeatureById.pipe"; +import { RegionalFeaturesService } from "./regionalFeature.service"; +import { RegionalFeaturesCmp } from "./regionalFeaturesCmp/regionalFeaturesCmp.component"; + +@NgModule({ + imports: [ + CommonModule, + UtilModule, + AngularMaterialModule, + ], + declarations: [ + /** + * components + */ + RegionalFeaturesCmp, + FeatureExplorer, + + /** + * Directives + */ + RegionalFeatureInteractivity, + + /** + * pipes + */ + FilterRegionalFeaturesByTypePipe, + FindRegionFEatureById, + ], + exports: [ + RegionalFeaturesCmp, + ], + providers: [ + RegionalFeaturesService, + ] +}) + +export class RegionalFeaturesModule{} diff --git a/src/ui/regionalFeatures/pipes/filterRegionalFeaturesByType.pipe.ts b/src/ui/regionalFeatures/pipes/filterRegionalFeaturesByType.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5bf231bddf4cbb5fa1ae97121c6315074b2305e --- /dev/null +++ b/src/ui/regionalFeatures/pipes/filterRegionalFeaturesByType.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { IFeature } from "../regionalFeature.service"; + +@Pipe({ + name: 'filterRegionalFeaturesBytype', + pure: true, +}) + +export class FilterRegionalFeaturesByTypePipe implements PipeTransform{ + public transform(array: IFeature[], featureType: string){ + return array.filter(f => featureType ? f.type === featureType : true ) + } +} diff --git a/src/ui/regionalFeatures/pipes/findRegionFeatureById.pipe.ts b/src/ui/regionalFeatures/pipes/findRegionFeatureById.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..a083a28a5a843ef36c47c29183efd72ed7e28936 --- /dev/null +++ b/src/ui/regionalFeatures/pipes/findRegionFeatureById.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { IFeature } from "../regionalFeature.service"; + +@Pipe({ + name: 'findRegionFeaturebyId', + pure: true +}) + +export class FindRegionFEatureById implements PipeTransform{ + public transform(features: IFeature[], id: string){ + return features.find(f => f['@id'] === id) + } +} diff --git a/src/ui/regionalFeatures/regionFeature.base.ts b/src/ui/regionalFeatures/regionFeature.base.ts new file mode 100644 index 0000000000000000000000000000000000000000..de8a190b216c86b1ad5d1ab486c1e8bcfa1b5729 --- /dev/null +++ b/src/ui/regionalFeatures/regionFeature.base.ts @@ -0,0 +1,46 @@ +import { Input, SimpleChanges } from "@angular/core" +import { BehaviorSubject } from "rxjs" +import { IFeature, RegionalFeaturesService } from "./regionalFeature.service" + +export class RegionFeatureBase{ + + @Input() + public region: any + + public features: IFeature[] = [] + + /** + * using isLoading flag for conditional rendering of root element (or display loading spinner) + * this is necessary, or the transcluded tab will always be the active tab, + * as this.features as populated via async + */ + public isLoading$ = new BehaviorSubject(false) + private _isLoading: boolean = false + get isLoading(){ + return this._isLoading + } + set isLoading(val){ + if (val !== this._isLoading) + this._isLoading = val + this.isLoading$.next(val) + } + + ngOnChanges(changes: SimpleChanges){ + if (changes.region && changes.region.previousValue !== changes.region.currentValue) { + this.isLoading = true + this.features = [] + this._regionalFeatureService.getAllFeaturesByRegion(changes.region.currentValue).pipe( + + ).subscribe({ + next: features => this.features = features, + complete: () => this.isLoading = false + }) + } + } + + constructor( + private _regionalFeatureService: RegionalFeaturesService + ){ + + } +} diff --git a/src/ui/regionalFeatures/regionalFeature.service.ts b/src/ui/regionalFeatures/regionalFeature.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b129bb1e64545c2481278f865bf90d75253842cc --- /dev/null +++ b/src/ui/regionalFeatures/regionalFeature.service.ts @@ -0,0 +1,122 @@ +import { HttpClient } from "@angular/common/http"; +import { Injectable, OnDestroy } from "@angular/core"; +import { PureContantService } from "src/util"; +import { getIdFromFullId } from 'common/util' +import { forkJoin, Subscription } from "rxjs"; +import { switchMap } from "rxjs/operators"; +import { IHasId } from "src/util/interfaces"; +import { select, Store } from "@ngrx/store"; +import { viewerStateSelectedTemplateSelector } from "src/services/state/viewerState/selectors"; +import { viewerStateAddUserLandmarks, viewreStateRemoveUserLandmarks } from "src/services/state/viewerState/actions"; +import { uiStateMouseoverUserLandmark } from "src/services/state/uiState/selectors"; + +export interface IFeature extends IHasId{ + type: string + name: string + data?: IHasId[] +} + +@Injectable({ + providedIn: 'root' +}) + +export class RegionalFeaturesService implements OnDestroy{ + + private subs: Subscription[] = [] + private templateSelected: any + constructor( + private http: HttpClient, + private pureConstantService: PureContantService, + private store$: Store<any> + ){ + this.subs.push( + this.store$.pipe( + select(viewerStateSelectedTemplateSelector) + ).subscribe(val => this.templateSelected = val) + ) + } + + ngOnDestroy(){ + while (this.subs.length > 0) this.subs.pop().unsubscribe() + } + + public onHoverLandmarks$ = this.store$.pipe( + select(uiStateMouseoverUserLandmark) + ) + + public getAllFeaturesByRegion(region: {['fullId']: string}){ + if (!region.fullId) throw new Error(`getAllFeaturesByRegion - region does not have fullId defined`) + const regionFullId = getIdFromFullId(region.fullId) + + const hemisphereObj = region['status'] ? { hemisphere: region['status'] } : {} + const refSpaceObj = this.templateSelected && this.templateSelected.fullId + ? { referenceSpaceId: getIdFromFullId(this.templateSelected.fullId) } + : {} + + return this.http.get<{features: IHasId[]}>( + `${this.pureConstantService.backendUrl}regionalFeatures/byRegion/${encodeURIComponent( regionFullId )}`, + { + params: { + ...hemisphereObj, + ...refSpaceObj, + + }, + responseType: 'json' + } + ).pipe( + switchMap(({ features }) => forkJoin( + features.map(({ ['@id']: featureId }) => + this.http.get<IFeature>( + `${this.pureConstantService.backendUrl}regionalFeatures/byRegion/${encodeURIComponent( regionFullId )}/${encodeURIComponent( featureId )}`, + { + params: { + ...hemisphereObj, + ...refSpaceObj, + }, + responseType: 'json' + } + ) + ) + )), + ) + } + + public getFeatureData(region: any,feature: IFeature, data: IHasId){ + if (!feature['@id']) throw new Error(`@id attribute for feature is required`) + if (!data['@id']) throw new Error(`@id attribute for data is required`) + const refSpaceObj = this.templateSelected && this.templateSelected.fullId + ? { referenceSpaceId: getIdFromFullId(this.templateSelected.fullId) } + : {} + return this.http.get<IHasId>( + `${this.pureConstantService.backendUrl}regionalFeatures/byFeature/${encodeURIComponent(feature['@id'])}/${encodeURIComponent(data['@id'])}`, + { + params: { + ...refSpaceObj, + }, + responseType: 'json' + } + ) + } + + public addLandmarks(lms: IHasId[]) { + this.store$.dispatch( + viewerStateAddUserLandmarks({ + landmarks: lms.map(lm => ({ + ...lm, + id: lm['@id'], + name: `region feature: ${lm['@id']}` + })) + }) + ) + } + + public removeLandmarks(lms: IHasId[]) { + this.store$.dispatch( + viewreStateRemoveUserLandmarks({ + payload: { + landmarkIds: lms.map(l => l['@id']) + } + }) + ) + } +} diff --git a/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.component.ts b/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cf5ec0e70d2a0407927155025525a1e1beb1463 --- /dev/null +++ b/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.component.ts @@ -0,0 +1,28 @@ +import { Component, OnChanges, SimpleChanges } from "@angular/core"; +import { RegionalFeaturesService } from "../regionalFeature.service"; +import { RegionFeatureBase } from "../regionFeature.base"; + +@Component({ + selector: 'regional-features', + templateUrl: './regionalFeaturesCmp.template.html', + styleUrls: [ + './regionalFeaturesCmp.style.css' + ], +}) + +export class RegionalFeaturesCmp extends RegionFeatureBase implements OnChanges{ + + ngOnChanges(changes: SimpleChanges){ + super.ngOnChanges(changes) + } + + constructor( + regionalFeatureService: RegionalFeaturesService + ){ + super(regionalFeatureService) + } + + public showingRegionFeatureId: string + public showingRegionFeatureIsLoading = false + +} diff --git a/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.style.css b/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.style.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.template.html b/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.template.html new file mode 100644 index 0000000000000000000000000000000000000000..0cac5e15927dfa0527277f5eed635480f65b453d --- /dev/null +++ b/src/ui/regionalFeatures/regionalFeaturesCmp/regionalFeaturesCmp.template.html @@ -0,0 +1,76 @@ +<mat-tab-group *ngIf="!isLoading; else loadingTmpl"> + <mat-tab *ngFor="let featureType of features | mapToProperty : 'type' | getUniquePipe"> + <ng-template mat-tab-label> + {{ featureType }} + </ng-template> + + <!-- lazy loading feature content --> + <ng-template matTabContent> + + <!-- selector --> + <div> + <ng-container *ngTemplateOutlet="selectorTmpl; context: { + label: 'Dataset', + options: features | filterRegionalFeaturesBytype : featureType + }"> + </ng-container> + + </div> + + <!-- content --> + <ng-template [ngIf]="showingRegionFeatureId"> + <ng-container *ngTemplateOutlet="featureContentTmpl; context: { + region: region, + feature: features | findRegionFeaturebyId : showingRegionFeatureId + }"> + </ng-container> + </ng-template> + </ng-template> + </mat-tab> + + <!-- transcluded content --> + <mat-tab> + <ng-template mat-tab-label> + Other + </ng-template> + + <!-- lazy loading transcluded content --> + <ng-template matTabContent> + <ng-content></ng-content> + </ng-template> + </mat-tab> + +</mat-tab-group> + +<!-- feature selector --> +<ng-template #selectorTmpl + let-label="label" + let-options="options"> + + <mat-form-field> + <mat-label> + {{ label }} + </mat-label> + <mat-select [(value)]="showingRegionFeatureId"> + <mat-option *ngFor="let option of options" + [value]="option['@id']"> + {{ option.name }} + </mat-option> + </mat-select> + </mat-form-field> +</ng-template> + +<!-- feature content template --> +<ng-template #featureContentTmpl + let-region="region" + let-feature="feature"> + <feature-explorer + [region]="region" + [feature]="feature"> + </feature-explorer> +</ng-template> + +<!-- loading tmpl --> +<ng-template #loadingTmpl> + <div class="spinnerAnimationCircle"></div> +</ng-template> diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 3d1faaed1492929200b9ebedac5b77a2db625889..0e09814462b7d34fe606b8872d771623af78b0cf 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -10,8 +10,6 @@ import { GetTemplateImageSrcPipe, ImgSrcSetPipe, SplashScreen } from "./nehubaCo import { FilterRegionDataEntries } from "src/util/pipes/filterRegionDataEntries.pipe"; import { GroupDatasetByRegion } from "src/util/pipes/groupDataEntriesByRegion.pipe"; -import { GetUniquePipe } from "src/util/pipes/getUnique.pipe"; - import { GetLayerNameFromDatasets } from "../util/pipes/getLayerNamePipe.pipe"; import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; import { SortDataEntriesToRegion } from "../util/pipes/sortDataEntriesIntoRegion.pipe"; @@ -87,6 +85,7 @@ import { RegionDirective } from "./parcellationRegion/region.directive"; import { RenderViewOriginDatasetLabelPipe } from "./parcellationRegion/region.base"; import { RegionAccordionTooltipTextPipe } from './util' import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; +import { RegionalFeaturesModule } from "./regionalFeatures"; @NgModule({ imports : [ @@ -105,6 +104,7 @@ import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; StateModule, AuthModule, FabSpeedDialModule, + RegionalFeaturesModule, ], declarations : [ NehubaContainer, @@ -150,7 +150,6 @@ import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; /* pipes */ GroupDatasetByRegion, FilterRegionDataEntries, - GetUniquePipe, FlatmapArrayPipe, SafeStylePipe, GetLayerNameFromDatasets, diff --git a/src/util/index.ts b/src/util/index.ts index c2a2a8efd93d7369454b862d11509288af3c9994..25e810ea70b989a4e67ea70ae107fb598444037e 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,2 +1,3 @@ export { UtilModule } from './util.module' export { PureContantService } from './pureConstant.service' +export { CLICK_INTERCEPTOR_INJECTOR, ClickInterceptor } from './injectionTokens' diff --git a/src/util/injectionTokens.ts b/src/util/injectionTokens.ts new file mode 100644 index 0000000000000000000000000000000000000000..c910f15350fec0dd087c674d54688dd4ccfe882f --- /dev/null +++ b/src/util/injectionTokens.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from "@angular/core"; + +export const CLICK_INTERCEPTOR_INJECTOR = new InjectionToken<ClickInterceptor>('CLICK_INTERCEPTOR_INJECTOR') + +export interface ClickInterceptor{ + register: (interceptorFunction: (ev: any, next: Function) => void) => void + deregister: (interceptorFunction: Function) => void +} diff --git a/src/util/interfaces.ts b/src/util/interfaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6c4147186789ad0b22d879077f2facd8c0877b5 --- /dev/null +++ b/src/util/interfaces.ts @@ -0,0 +1,7 @@ +/** + * Only the most common interfaces should reside here + */ + +export interface IHasId{ + ['@id']: string +} diff --git a/src/util/pipes/filterArray.pipe.ts b/src/util/pipes/filterArray.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..eca8e461026de20361e62e71eeefe4d76ba96d37 --- /dev/null +++ b/src/util/pipes/filterArray.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'filterArray' +}) + +export class FilterArrayPipe implements PipeTransform{ + public transform<T>(arr: T[], filterFn: (item: T, index: number, array: T[]) => boolean){ + return arr.filter(filterFn) + } +} diff --git a/src/util/pipes/filterByProperty.pipe.ts b/src/util/pipes/filterByProperty.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e7819bfe2104dcd4f7f6cc433edbc4c21311c85 --- /dev/null +++ b/src/util/pipes/filterByProperty.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'filterByProperty', + pure: true +}) + +export class FilterByPropertyPipe implements PipeTransform{ + public transform<T>(input: T[], prop: string): T[]{ + return input.filter(item => !!item[prop]) + } +} diff --git a/src/util/pipes/mapToProperty.pipe.spec.ts b/src/util/pipes/getProperty.pipe.spec.ts similarity index 100% rename from src/util/pipes/mapToProperty.pipe.spec.ts rename to src/util/pipes/getProperty.pipe.spec.ts diff --git a/src/util/pipes/getProperty.pipe.ts b/src/util/pipes/getProperty.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..024135aee89a50d1554f41894ba2cac9f1bd813e --- /dev/null +++ b/src/util/pipes/getProperty.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'getProperty', + pure: true +}) + +export class GetPropertyPipe implements PipeTransform{ + public transform(input, property = '@id') { + return input && input[property] + } +} diff --git a/src/util/pipes/getUnique.pipe.ts b/src/util/pipes/getUnique.pipe.ts index 892e28ef85e5c8b7003de589b579d42bc4cdd7f4..a0cdfe91857fd77afc56dfb808036636cffa9d44 100644 --- a/src/util/pipes/getUnique.pipe.ts +++ b/src/util/pipes/getUnique.pipe.ts @@ -2,6 +2,7 @@ import { Pipe, PipeTransform } from "@angular/core"; @Pipe({ name: 'getUniquePipe', + pure: true }) export class GetUniquePipe implements PipeTransform { diff --git a/src/util/pipes/mapToProperty.pipe.ts b/src/util/pipes/mapToProperty.pipe.ts index 43496f4145743984744831ea077d18b086a1c573..25d8b6267ada5bd07fa666905c9f712f44aaa53c 100644 --- a/src/util/pipes/mapToProperty.pipe.ts +++ b/src/util/pipes/mapToProperty.pipe.ts @@ -6,7 +6,7 @@ import { Pipe, PipeTransform } from "@angular/core"; }) export class MapToPropertyPipe implements PipeTransform{ - public transform(input, property = '@id') { - return input && input[property] + public transform(arr: any[], prop: string){ + return arr.map(item => prop ? item[prop] : item) } } diff --git a/src/util/util.module.ts b/src/util/util.module.ts index f29aa368ea5931808431ac7f5ca87f3761655c04..d03ee97f4efa39239d298b76cde710f8ba003fde 100644 --- a/src/util/util.module.ts +++ b/src/util/util.module.ts @@ -14,10 +14,14 @@ import { SwitchDirective } from "./directives/switch.directive"; import { MediaQueryDirective } from './directives/mediaQuery.directive' import { LayoutModule } from "@angular/cdk/layout"; import { MapToPropertyPipe } from "./pipes/mapToProperty.pipe"; -import {ClickOutsideDirective} from "src/util/directives/clickOutside.directive"; +import { ClickOutsideDirective } from "src/util/directives/clickOutside.directive"; import { CounterDirective } from "./directives/counter.directive"; import { GetNthElementPipe } from "./pipes/getNthElement.pipe"; import { ParseAsNumberPipe } from "./pipes/parseAsNumber.pipe"; +import { GetUniquePipe } from "./pipes/getUnique.pipe"; +import { GetPropertyPipe } from "./pipes/getProperty.pipe"; +import { FilterArrayPipe } from "./pipes/filterArray.pipe"; +import { FilterByPropertyPipe } from "./pipes/filterByProperty.pipe"; @NgModule({ imports:[ @@ -41,6 +45,10 @@ import { ParseAsNumberPipe } from "./pipes/parseAsNumber.pipe"; CounterDirective, GetNthElementPipe, ParseAsNumberPipe, + GetUniquePipe, + GetPropertyPipe, + FilterArrayPipe, + FilterByPropertyPipe, ], exports: [ FilterNullPipe, @@ -60,6 +68,10 @@ import { ParseAsNumberPipe } from "./pipes/parseAsNumber.pipe"; CounterDirective, GetNthElementPipe, ParseAsNumberPipe, + GetUniquePipe, + GetPropertyPipe, + FilterArrayPipe, + FilterByPropertyPipe, ] })