diff --git a/Dockerfile b/Dockerfile index a20f2d36324fee46ce3a90f029ba9361839ab550..67876d05bf57addb564b5e6c48622c12719ef1ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:8 as builder +FROM node:10 as builder ARG BACKEND_URL ENV BACKEND_URL=$BACKEND_URL @@ -8,12 +8,23 @@ WORKDIR /iv ENV VERSION=devNext +RUN apt update && apt upgrade -y && apt install brotli + RUN npm i RUN npm run build-aot +# gzipping container +FROM ubuntu:18.10 as compressor +RUN apt upgrade -y && apt update && apt install brotli + +RUN mkdir /iv +COPY --from=builder /iv/dist/aot /iv +WORKDIR /iv + +RUN for f in $(find . -type f); do gzip < $f > $f.gz && brotli < $f > $f.br; done # prod container -FROM node:8-alpine +FROM node:10-alpine ARG PORT ENV PORT=$PORT @@ -23,14 +34,15 @@ RUN apk --no-cache add ca-certificates RUN mkdir /iv-app WORKDIR /iv-app -# Copy built interactive viewer -COPY --from=builder /iv/dist/aot ./public - # Copy the express server COPY --from=builder /iv/deploy . +# Copy built interactive viewer +COPY --from=compressor /iv ./public + # Copy the resources files needed to respond to queries -COPY --from=builder /iv/src/res/ext ./res +# is this even necessary any more? +COPY --from=compressor /iv/res/json ./res RUN npm i EXPOSE $PORT diff --git a/deploy/app.js b/deploy/app.js index 8b815a3607c836f6810190d68ffb632916bc2ba5..210e2ac9a32f39229d1f377025c8a1373760603a 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -3,6 +3,7 @@ const express = require('express') const app = express() const session = require('express-session') const MemoryStore = require('memorystore')(session) +const crypto = require('crypto') app.disable('x-powered-by') @@ -10,14 +11,17 @@ if (process.env.NODE_ENV !== 'production') { app.use(require('cors')()) } +const hash = string => crypto.createHash('sha256').update(string).digest('hex') + app.use((req, _, next) => { if (/main\.bundle\.js$/.test(req.originalUrl)){ const xForwardedFor = req.headers['x-forwarded-for'] const ip = req.connection.remoteAddress console.log({ type: 'visitorLog', - xForwardedFor, - ip + method: 'main.bundle.js', + xForwardedFor: xForwardedFor.replace(/\ /g, '').split(',').map(hash), + ip: hash(ip) }) } next() @@ -62,12 +66,18 @@ const PUBLIC_PATH = process.env.NODE_ENV === 'production' */ app.use('/.well-known', express.static(path.join(__dirname, 'well-known'))) -app.use(express.static(PUBLIC_PATH)) +/** + * only use compression for production + * this allows locally built aot to be served without errors + */ + +const { compressionMiddleware } = require('./compression') +app.use(compressionMiddleware, express.static(PUBLIC_PATH)) -app.use((req, res, next) => { +const jsonMiddleware = (req, res, next) => { res.set('Content-Type', 'application/json') next() -}) +} const templateRouter = require('./templates') const nehubaConfigRouter = require('./nehubaConfig') @@ -75,11 +85,11 @@ const datasetRouter = require('./datasets') const pluginRouter = require('./plugins') const previewRouter = require('./preview') -app.use('/templates', templateRouter) -app.use('/nehubaConfig', nehubaConfigRouter) -app.use('/datasets', datasetRouter) -app.use('/plugins', pluginRouter) -app.use('/preview', previewRouter) +app.use('/templates', jsonMiddleware, templateRouter) +app.use('/nehubaConfig', jsonMiddleware, nehubaConfigRouter) +app.use('/datasets', jsonMiddleware, datasetRouter) +app.use('/plugins', jsonMiddleware, pluginRouter) +app.use('/preview', jsonMiddleware, previewRouter) const catchError = require('./catchError') app.use(catchError) diff --git a/deploy/compression/index.js b/deploy/compression/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bc5d2acb4b69d219d1a6c32bb0148296c4d020c4 --- /dev/null +++ b/deploy/compression/index.js @@ -0,0 +1,59 @@ +const BROTLI = `br` +const GZIP = `gzip` + +const detEncoding = (acceptEncoding = '') => { + if (process.env.NODE_ENV !== 'production') return null + + return /br/i.test(acceptEncoding) + ? BROTLI + : /gzip/i.test(acceptEncoding) + ? GZIP + : null +} + +const mimeMap = new Map([ + ['.png', 'image/png'], + ['.gif', 'image/gif'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.css', 'text/css'], + ['.html', 'text/html'], + ['.js', 'text/javascript'] +]) + +exports.BROTLI = BROTLI + +exports.GZIP = GZIP + +exports.detEncoding = detEncoding + +exports.compressionMiddleware = (req, res, next) => { + const acceptEncoding = req.get('Accept-Encoding') + const encoding = detEncoding(acceptEncoding) + + // if no encoding is accepted + // or in dev mode, do not use compression + if (!encoding) return next() + + const ext = /(\.\w*?)$/.exec(req.url) + + // if cannot determine mime-type, do not use encoding + // as Content-Type header is required for browser to understand response + if (!ext || !mimeMap.get(ext[1])) return next() + + res.set('Content-Type', mimeMap.get(ext[1])) + + if (encoding === BROTLI) { + req.url = req.url + '.br' + res.set('Content-Encoding', encoding) + return next() + } + + if (encoding === GZIP) { + req.url = req.url + '.gz' + res.set('Content-Encoding', encoding) + return next() + } + + next() +} \ No newline at end of file diff --git a/deploy/compression/index.spec.js b/deploy/compression/index.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3c1a3826fb3b8151a73c8d180b8d31b49f82c6c4 --- /dev/null +++ b/deploy/compression/index.spec.js @@ -0,0 +1,39 @@ +const mocha = require('mocha') +const chai = require('chai') +const expect = chai.expect + +const { detEncoding, GZIP, BROTLI } = require('./index') + +const gzip = 'gzip' +const gzipDeflate = 'gzip, deflate' +const gzipDeflateBr = 'gzip, deflate, br' + +describe('compression/index.js', () => { + let nodeEnv + + before(() => { + nodeEnv = process.env.NODE_ENV + }) + + after(() => { + process.env.NODE_ENV = nodeEnv + }) + + describe('#detEncoding', () => { + it('When NODE_ENV is set to production, returns appropriate encoding', () => { + process.env.NODE_ENV = 'production' + expect(detEncoding(null)).to.equal(null) + expect(detEncoding(gzip)).to.equal(GZIP) + expect(detEncoding(gzipDeflate)).to.equal(GZIP) + expect(detEncoding(gzipDeflateBr)).to.equal(BROTLI) + }) + + it('When NODE_ENV is set to non production, returns null always', () => { + process.env.NODE_ENV = 'development' + expect(detEncoding(null)).to.equal(null) + expect(detEncoding(gzip)).to.equal(null) + expect(detEncoding(gzipDeflate)).to.equal(null) + expect(detEncoding(gzipDeflateBr)).to.equal(null) + }) + }) +}) \ No newline at end of file diff --git a/deploy/datasets/index.js b/deploy/datasets/index.js index aae9728421904d2487af620abc6a51e18159529d..cb69686463aa6cd2240a183f3c09340a5d0e5b02 100644 --- a/deploy/datasets/index.js +++ b/deploy/datasets/index.js @@ -136,7 +136,7 @@ datasetsRouter.get('/kgInfo', checkKgQuery, cacheMaxAge24Hr, async (req, res) => stream.pipe(res) }) -datasetsRouter.get('/downloadKgFiles', checkKgQuery, cacheMaxAge24Hr, async (req, res) => { +datasetsRouter.get('/downloadKgFiles', checkKgQuery, async (req, res) => { const { kgId } = req.query const { user } = req try { diff --git a/deploy/nehubaConfig/index.js b/deploy/nehubaConfig/index.js index a5072c82865d230eb8d5320c028d5f9505ec1e38..145441ee8d730b5a0df2cabccf6e2612d86f2cb4 100644 --- a/deploy/nehubaConfig/index.js +++ b/deploy/nehubaConfig/index.js @@ -1,19 +1,18 @@ const express = require('express') -const path = require('path') -const fs = require('fs') const { getTemplateNehubaConfig } = require('./query') +const { detEncoding } = require('../compression') const nehubaConfigRouter = express.Router() nehubaConfigRouter.get('/:configId', (req, res, next) => { + + const header = req.get('Accept-Encoding') + const acceptedEncoding = detEncoding(header) + const { configId } = req.params - getTemplateNehubaConfig(configId) - .then(data => res.status(200).send(data)) - .catch(error => next({ - code: 500, - error, - trace: 'nehubaConfigRouter#getTemplateNehubaConfig' - })) + if (acceptedEncoding) res.set('Content-Encoding', acceptedEncoding) + + getTemplateNehubaConfig({ configId, acceptedEncoding, returnAsStream:true}).pipe(res) }) module.exports = nehubaConfigRouter \ No newline at end of file diff --git a/deploy/nehubaConfig/query.js b/deploy/nehubaConfig/query.js index 972c3d59633cd3748d3678a636f8bcecdf7ed0d9..23a6132ba39c72a83f47159e3ed106a296ac7250 100644 --- a/deploy/nehubaConfig/query.js +++ b/deploy/nehubaConfig/query.js @@ -1,15 +1,31 @@ const fs = require('fs') const path = require('path') +const { BROTLI, GZIP } = require('../compression') -exports.getTemplateNehubaConfig = (configId) => new Promise((resolve, reject) => { - let filepath +const getFileAsPromise = filepath => new Promise((resolve, reject) => { + fs.readFile(filepath, 'utf-8', (err, data) => { + if (err) return reject(err) + resolve(data) + }) +}) + +exports.getTemplateNehubaConfig = ({configId, acceptedEncoding, returnAsStream}) => { if (process.env.NODE_ENV === 'production') { filepath = path.join(__dirname, '..', 'res', `${configId}.json`) } else { filepath = path.join(__dirname, '..', '..', 'src', 'res', 'ext', `${configId}.json`) } - fs.readFile(filepath, 'utf-8', (err, data) => { - if (err) return reject(err) - resolve(data) - }) -}) \ No newline at end of file + + if (acceptedEncoding === BROTLI) { + if (returnAsStream) return fs.createReadStream(`${filepath}.br`) + else return getFileAsPromise(`${filepath}.br`) + } + + if (acceptedEncoding === GZIP) { + if (returnAsStream) return fs.createReadStream(`${filepath}.gz`) + else return getFileAsPromise(`${filepath}.gz`) + } + + if (returnAsStream) return fs.createReadStream(filepath) + else return getFileAsPromise(filepath) +} \ No newline at end of file diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js index 9f15b4687a85be1f07b76284694132fd0fb799eb..f0216dea9b1aa14f7a85dfc955a11b530e9f740e 100644 --- a/deploy/plugins/index.js +++ b/deploy/plugins/index.js @@ -5,15 +5,11 @@ const express = require('express') const router = express.Router() -const PLUGIN_URLS = process.env.PLUGIN_URLS && JSON.stringify(process.env.PLUGIN_URLS.split(';')) +const PLUGIN_URLS = (process.env.PLUGIN_URLS && process.env.PLUGIN_URLS.split(';')) + || [] router.get('', (_req, res) => { - - if (PLUGIN_URLS) { - return res.status(200).send(PLUGIN_URLS) - } else { - return res.status(200).send('[]') - } + return res.status(200).json(PLUGIN_URLS) }) module.exports = router \ No newline at end of file diff --git a/deploy/server.js b/deploy/server.js index 4256b418128deae6033e4f2bfe29aa94a6c33008..9045664cf8a90542b482513e2026eacf291ea016 100644 --- a/deploy/server.js +++ b/deploy/server.js @@ -51,7 +51,7 @@ if (process.env.FLUENT_HOST) { console.warn = function () { emitWarn([...arguments]) } - console.emitError = function () { + console.error = function () { emitError([...arguments]) } } diff --git a/deploy/templates/index.js b/deploy/templates/index.js index 1ed84e544f0754e84ee8f48855d7b30975361de4..d9fbeb8313a14464177a2c00f47fbda8392d707b 100644 --- a/deploy/templates/index.js +++ b/deploy/templates/index.js @@ -1,6 +1,8 @@ const router = require('express').Router() const query = require('./query') const path = require('path') +const { detEncoding } = require('../compression') + /** * root path fetches all templates */ @@ -20,6 +22,10 @@ router.get('/', (req, res, next) => { router.get('/:template', (req, res, next) => { const { template } = req.params + + const header = req.get('Accept-Encoding') + const acceptedEncoding = detEncoding(header) + query.getAllTemplates() .then(templates => { if (templates.indexOf(template) < 0) @@ -27,11 +33,9 @@ router.get('/:template', (req, res, next) => { code : 404, error: 'template not in the list supported' }) - return query.getTemplate(template) - }) - .then(data => { - if (data) - res.status(200).send(data) + + if (acceptedEncoding) res.set('Content-Encoding', acceptedEncoding) + query.getTemplate({ template, acceptedEncoding, returnAsStream:true }).pipe(res) }) .catch(error => next({ code: 500, diff --git a/deploy/templates/query.js b/deploy/templates/query.js index 1ecebe476dce91c21819342ad6031f46643b1db8..c1886db02efd03033514ec5a33c113b60b5b8842 100644 --- a/deploy/templates/query.js +++ b/deploy/templates/query.js @@ -1,5 +1,6 @@ const fs = require('fs') const path = require('path') +const { BROTLI, GZIP } = require('../compression') exports.getAllTemplates = () => new Promise((resolve, reject) => { @@ -17,15 +18,32 @@ exports.getAllTemplates = () => new Promise((resolve, reject) => { resolve(templates) }) -exports.getTemplate = (template) => new Promise((resolve, reject) => { +const getFileAsPromise = filepath => new Promise((resolve, reject) => { + fs.readFile(filepath, 'utf-8', (err, data) => { + if (err) return reject(err) + resolve(data) + }) +}) + +exports.getTemplate = ({ template, acceptedEncoding, returnAsStream }) => { + let filepath if (process.env.NODE_ENV === 'production') { filepath = path.join(__dirname, '..', 'res', `${template}.json`) } else { filepath = path.join(__dirname, '..', '..', 'src', 'res', 'ext', `${template}.json`) } - fs.readFile(filepath, 'utf-8', (err, data) => { - if (err) reject(err) - resolve(data) - }) -}) \ No newline at end of file + + if (acceptedEncoding === BROTLI) { + if (returnAsStream) return fs.createReadStream(`${filepath}.br`) + else return getFileAsPromise(`${filepath}.br`) + } + + if (acceptedEncoding === GZIP) { + if (returnAsStream) return fs.createReadStream(`${filepath}.gz`) + else return getFileAsPromise(`${filepath}.gz`) + } + + if (returnAsStream) return fs.createReadStream(filepath) + else return getFileAsPromise(filepath) +} \ No newline at end of file diff --git a/deploy/test/mocha.test.js b/deploy/test/mocha.test.js index 8f55b54de1f2566b8d2348adef651303b0ef1fca..366f5af4a874c10e907272ba0ece2b686248078a 100644 --- a/deploy/test/mocha.test.js +++ b/deploy/test/mocha.test.js @@ -1 +1,2 @@ -require('../auth/util.spec') \ No newline at end of file +require('../auth/util.spec') +require('../compression/index.spec') \ No newline at end of file diff --git a/package.json b/package.json index 606c5a8660664fd4388fe152db45482345e010d7..a2ed139c5d747a35ef3c5ed5d8db25138d6831cb 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "chart.js": "^2.7.2", "codelyzer": "^5.0.1", "core-js": "^3.0.1", + "css-loader": "^3.2.0", "file-loader": "^1.1.11", "hammerjs": "^2.0.8", "html-webpack-plugin": "^3.2.0", @@ -61,12 +62,15 @@ "karma-typescript": "^3.0.13", "karma-webpack": "^3.0.0", "lodash.merge": "^4.6.1", + "mini-css-extract-plugin": "^0.8.0", "ng2-charts": "^1.6.0", "ngx-bootstrap": "3.0.1", + "node-sass": "^4.12.0", "protractor": "^5.4.2", "raw-loader": "^0.5.1", "reflect-metadata": "^0.1.12", "rxjs": "6.5.1", + "sass-loader": "^7.2.0", "showdown": "^1.8.6", "ts-loader": "^4.3.0", "ts-node": "^8.1.0", diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index c77f929e75c57a05e1fb0c1ae086681fd809baa7..475cb5c09879a2d1fc134e753752428d2884b706 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -6,6 +6,7 @@ import { map, distinctUntilChanged, filter } from "rxjs/operators"; import { ModalHandler } from "../util/pluginHandlerClasses/modalHandler"; import { ToastHandler } from "../util/pluginHandlerClasses/toastHandler"; import { PluginManifest } from "./atlasViewer.pluginService.service"; +import { DialogService } from "src/services/dialogService.service"; declare var window @@ -22,7 +23,8 @@ export class AtlasViewerAPIServices{ public loadedLibraries : Map<string,{counter:number,src:HTMLElement|null}> = new Map() constructor( - private store : Store<ViewerStateInterface> + private store : Store<ViewerStateInterface>, + private dialogService: DialogService, ){ this.loadedTemplates$ = this.store.pipe( @@ -107,7 +109,10 @@ export class AtlasViewerAPIServices{ */ launchNewWidget: (manifest) => { return Promise.reject('Needs to be overwritted') - } + }, + + getUserInput: config => this.dialogService.getUserInput(config), + getUserConfirmation: config => this.dialogService.getUserConfirm(config) }, pluginControl : { loadExternalLibraries : ()=>Promise.reject('load External Library method not over written') @@ -175,6 +180,8 @@ export interface InteractiveViewerInterface{ getModalHandler: () => ModalHandler getToastHandler: () => ToastHandler launchNewWidget: (manifest:PluginManifest) => Promise<any> + getUserInput: (config:GetUserInputConfig) => Promise<string> + getUserConfirmation: (config: GetUserConfirmation) => Promise<any> } pluginControl : { @@ -184,6 +191,16 @@ export interface InteractiveViewerInterface{ } } +interface GetUserConfirmation{ + title?: string + message?: string +} + +interface GetUserInputConfig extends GetUserConfirmation{ + placeholder?: string + defaultValue?: string +} + export interface UserLandmark{ name : string position : [number, number, number] diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index d012da787f89856adb803548c86fd5be2799a558..c2b66ac5731e0d3805b5d54eb27ad21743cbaf9c 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -10,8 +10,6 @@ import { AtlasViewerConstantsServices, UNSUPPORTED_PREVIEW, UNSUPPORTED_INTERVAL import { AtlasViewerURLService } from "./atlasViewer.urlService.service"; import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; -import '@angular/material/prebuilt-themes/indigo-pink.css' -import '../res/css/extra_styles.css' import { NehubaContainer } from "../ui/nehubaContainer/nehubaContainer.component"; import { colorAnimation } from "./atlasViewer.animation" import { FixedMouseContextualContainerDirective } from "src/util/directives/FixedMouseContextualContainerDirective.directive"; @@ -317,6 +315,12 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { filter(() => typeof this.layoutMainSide !== 'undefined') ).subscribe(v => this.layoutMainSide.showSide = isDefined(v)) ) + + this.subscriptions.push( + this.constantsService.darktheme$.subscribe(flag => { + this.rd.setAttribute(document.body,'darktheme', flag.toString()) + }) + ) } ngAfterViewInit() { diff --git a/src/atlasViewer/atlasViewer.constantService.service.spec.ts b/src/atlasViewer/atlasViewer.constantService.service.spec.ts index 71c9ecd02371900c4e3403e024f9d6e9c648a153..72f7f4c3f146f2fef352a2a3e0c6daa3c91ea224 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.spec.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.spec.ts @@ -107,4 +107,26 @@ describe('encodeNumber/decodeToNumber', () => { expect(floatNums.map(v => v.toFixed(FLOAT_PRECISION))).toEqual(decodedNumber.map(n => n.toFixed(FLOAT_PRECISION))) }) + + it('poisoned hash should throw', () => { + const illegialCharacters = './\\?#!@#^%&*()+={}[]\'"\n\t;:' + for (let char of illegialCharacters.split('')) { + expect(function (){ + decodeToNumber(char) + }).toThrow() + } + }) + + it('poisoned hash can be caught', () => { + + const testArray = ['abc', './\\', 'Cde'] + const decodedNum = testArray.map(v => { + try { + return decodeToNumber(v) + } catch (e) { + return null + } + }).filter(v => !!v) + expect(decodedNum.length).toEqual(2) + }) }) \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.constantService.service.ts b/src/atlasViewer/atlasViewer.constantService.service.ts index 958026caa32850fd8fe5f87fbcb979ec2766db7f..bb1d4183e203ac97f3c9d54c52a49d18e5e660e1 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.ts @@ -1,8 +1,9 @@ import { Injectable } from "@angular/core"; -import { Store } from "@ngrx/store"; -import { ViewerStateInterface, Property } from "../services/stateStore.service"; -import { Subject } from "rxjs"; +import { Store, select } from "@ngrx/store"; +import { ViewerStateInterface } from "../services/stateStore.service"; +import { Subject, Observable } from "rxjs"; import { ACTION_TYPES, ViewerConfiguration } from 'src/services/state/viewerConfig.store' +import { map, shareReplay, filter } from "rxjs/operators"; export const CM_THRESHOLD = `0.05` export const CM_MATLAB_JET = `float r;if( x < 0.7 ){r = 4.0 * x - 1.5;} else {r = -4.0 * x + 4.5;}float g;if (x < 0.5) {g = 4.0 * x - 0.5;} else {g = -4.0 * x + 3.5;}float b;if (x < 0.3) {b = 4.0 * x + 0.5;} else {b = -4.0 * x + 2.5;}float a = 1.0;` @@ -14,6 +15,7 @@ export const CM_MATLAB_JET = `float r;if( x < 0.7 ){r = 4.0 * x - 1.5;} else {r export class AtlasViewerConstantsServices{ public darktheme: boolean = false + public darktheme$: Observable<boolean> public mobile: boolean public loadExportNehubaPromise : Promise<boolean> @@ -246,6 +248,14 @@ Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float /* https://stackoverflow.com/a/25394023/6059235 */ this.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(ua) + this.darktheme$ = this.store.pipe( + select('viewerState'), + select('templateSelected'), + filter(v => !!v), + map(({useTheme}) => useTheme === 'dark'), + shareReplay(1) + ) + /** * set gpu limit if user is on mobile */ @@ -258,6 +268,14 @@ Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float }) } } + + catchError(e: Error | string){ + /** + * DO NOT REMOVE + * general catch all & reflect in UI + */ + console.warn(e) + } } const parseURLToElement = (url:string):HTMLElement=>{ @@ -312,8 +330,7 @@ const negString = '~' const encodeInt = (number: number) => { if (number % 1 !== 0) throw 'cannot encodeInt on a float. Ensure float flag is set' - if (isNaN(Number(number)) || number === null || number === Number.POSITIVE_INFINITY) - throw 'The input is not valid' + if (isNaN(Number(number)) || number === null || number === Number.POSITIVE_INFINITY) throw 'The input is not valid' let rixit // like 'digit', only in some non-decimal radix let residual @@ -370,7 +387,9 @@ const decodetoInt = (encodedString: string) => { _encodedString = encodedString } return (negFlag ? -1 : 1) * [..._encodedString].reduce((acc,curr) => { - return acc * 64 + cipher.indexOf(curr) + const index = cipher.indexOf(curr) + if (index < 0) throw new Error(`Poisoned b64 encoding ${encodedString}`) + return acc * 64 + index }, 0) } diff --git a/src/atlasViewer/atlasViewer.pluginService.service.spec.ts b/src/atlasViewer/atlasViewer.pluginService.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a040e3f2360184ecb8df8e08eef7111667012a3c --- /dev/null +++ b/src/atlasViewer/atlasViewer.pluginService.service.spec.ts @@ -0,0 +1,108 @@ +import { PluginServices } from "./atlasViewer.pluginService.service"; +import { TestBed, inject } from "@angular/core/testing"; +import { MainModule } from "src/main.module"; +import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing' + +const MOCK_PLUGIN_MANIFEST = { + name: 'fzj.xg.MOCK_PLUGIN_MANIFEST', + templateURL: 'http://localhost:10001/template.html', + scriptURL: 'http://localhost:10001/script.js' +} + +describe('PluginServices', () => { + let pluginService: PluginServices + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + MainModule + ] + }).compileComponents() + + pluginService = TestBed.get(PluginServices) + }) + + it( + 'is instantiated in test suite OK', + () => expect(TestBed.get(PluginServices)).toBeTruthy() + ) + + it( + 'expectOne is working as expected', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + expect(httpMock.match('test').length).toBe(0) + pluginService.fetch('test') + expect(httpMock.match('test').length).toBe(1) + pluginService.fetch('test') + pluginService.fetch('test') + expect(httpMock.match('test').length).toBe(2) + }) + ) + + describe('#launchPlugin', () => { + + describe('basic fetching functionality', () => { + it( + 'fetches templateURL and scriptURL properly', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockTemplate).toBeTruthy() + expect(mockScript).toBeTruthy() + }) + ) + + it( + 'template overrides templateURL', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + template: '' + }) + + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) + const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockScript).toBeTruthy() + }) + ) + + it( + 'script overrides scriptURL', + + inject([HttpTestingController], (httpMock: HttpTestingController) => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + script: '' + }) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockTemplate).toBeTruthy() + }) + ) + }) + + describe('racing slow cconnection when launching plugin', () => { + it( + 'when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', + inject([HttpTestingController], (httpMock:HttpTestingController) => { + + expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + expect(httpMock.match(MOCK_PLUGIN_MANIFEST.scriptURL).length).toBe(1) + expect(httpMock.match(MOCK_PLUGIN_MANIFEST.templateURL).length).toBe(1) + + expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeTruthy() + }) + ) + }) + }) +}) \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.pluginService.service.ts b/src/atlasViewer/atlasViewer.pluginService.service.ts index 46da82439242c06a5a491c78b1a8b639fe28de3c..84f5e30d687124fd8ebc5b457c70929385de1a62 100644 --- a/src/atlasViewer/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/atlasViewer.pluginService.service.ts @@ -1,13 +1,14 @@ import { Injectable, ViewContainerRef, ComponentFactoryResolver, ComponentFactory } from "@angular/core"; import { PluginInitManifestInterface, PLUGIN_STATE_ACTION_TYPES } from "src/services/state/pluginState.store"; +import { HttpClient } from '@angular/common/http' import { isDefined } from 'src/services/stateStore.service' import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; import { PluginUnit } from "./pluginUnit/pluginUnit.component"; import { WidgetServices } from "./widgetUnit/widgetService.service"; import '../res/css/plugin_styles.css' -import { interval } from "rxjs"; -import { take, takeUntil } from "rxjs/operators"; +import { BehaviorSubject, Observable, merge, of } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; import { Store } from "@ngrx/store"; import { WidgetUnit } from "./widgetUnit/widgetUnit.component"; import { AtlasViewerConstantsServices } from "./atlasViewer.constantService.service"; @@ -23,13 +24,20 @@ export class PluginServices{ public appendSrc : (script:HTMLElement)=>void public removeSrc: (script:HTMLElement) => void private pluginUnitFactory : ComponentFactory<PluginUnit> + public minimisedPlugins$ : Observable<Set<string>> + + /** + * TODO remove polyfil and convert all calls to this.fetch to http client + */ + public fetch: (url:string, httpOption?: any) => Promise<any> = (url, httpOption = {}) => this.http.get(url, httpOption).toPromise() constructor( private apiService : AtlasViewerAPIServices, private constantService : AtlasViewerConstantsServices, private widgetService : WidgetServices, private cfr : ComponentFactoryResolver, - private store : Store<PluginInitManifestInterface> + private store : Store<PluginInitManifestInterface>, + private http: HttpClient ){ this.pluginUnitFactory = this.cfr.resolveComponentFactory( PluginUnit ) @@ -44,30 +52,30 @@ export class PluginServices{ * PLUGINDEV should return an array of */ PLUGINDEV - ? fetch(PLUGINDEV).then(res => res.json()) + ? this.fetch(PLUGINDEV).then(res => res.json()) : Promise.resolve([]), new Promise(resolve => { - fetch(`${this.constantService.backendUrl}plugins`) - .then(res => res.json()) + this.fetch(`${this.constantService.backendUrl}plugins`) .then(arr => Promise.all( arr.map(url => new Promise(rs => /** * instead of failing all promises when fetching manifests, only fail those that fails to fetch */ - fetch(url).then(res => res.json()).then(rs).catch(e => (console.log('fetching manifest error', e), rs(null)))) + this.fetch(url).then(rs).catch(e => (this.constantService.catchError(`fetching manifest error: ${e.toString()}`), rs(null)))) ) )) .then(manifests => resolve( manifests.filter(m => !!m) )) .catch(e => { + this.constantService.catchError(e) resolve([]) }) }), Promise.all( BUNDLEDPLUGINS .filter(v => typeof v === 'string') - .map(v => fetch(`res/plugin_examples/${v}/manifest.json`).then(res => res.json())) + .map(v => this.fetch(`res/plugin_examples/${v}/manifest.json`).then(res => res.json())) ) .then(arr => arr.reduce((acc,curr) => acc.concat(curr) ,[])) ]) @@ -79,6 +87,24 @@ export class PluginServices{ .then(arr=> this.fetchedPluginManifests = arr) .catch(console.error) + + this.minimisedPlugins$ = merge( + of(new Set()), + this.widgetService.minimisedWindow$ + ).pipe( + map(set => { + const returnSet = new Set<string>() + for (let [pluginName, wu] of this.mapPluginNameToWidgetUnit) { + if (set.has(wu)) { + returnSet.add(pluginName) + } + } + return returnSet + }), + shareReplay(1) + ) + + this.launchedPlugins$ = new BehaviorSubject(new Set()) } launchNewWidget = (manifest) => this.launchPlugin(manifest) @@ -94,37 +120,74 @@ export class PluginServices{ isDefined(plugin.template) ? Promise.resolve('template already provided') : isDefined(plugin.templateURL) ? - fetch(plugin.templateURL) - .then(res=>res.text()) + this.fetch(plugin.templateURL, {responseType: 'text'}) .then(template=>plugin.template = template) : Promise.reject('both template and templateURL are not defined') , isDefined(plugin.script) ? Promise.resolve('script already provided') : isDefined(plugin.scriptURL) ? - fetch(plugin.scriptURL) - .then(res=>res.text()) + this.fetch(plugin.scriptURL, {responseType: 'text'}) .then(script=>plugin.script = script) : Promise.reject('both script and scriptURL are not defined') ]) } - public launchedPlugins: Set<string> = new Set() + private launchedPlugins: Set<string> = new Set() + public launchedPlugins$: BehaviorSubject<Set<string>> + pluginHasLaunched(pluginName:string) { + return this.launchedPlugins.has(pluginName) + } + addPluginToLaunchedSet(pluginName:string){ + this.launchedPlugins.add(pluginName) + this.launchedPlugins$.next(this.launchedPlugins) + } + removePluginFromLaunchedSet(pluginName:string){ + this.launchedPlugins.delete(pluginName) + this.launchedPlugins$.next(this.launchedPlugins) + } + + + pluginIsLaunching(pluginName:string){ + return this.launchingPlugins.has(pluginName) + } + addPluginToIsLaunchingSet(pluginName:string) { + this.launchingPlugins.add(pluginName) + } + removePluginFromIsLaunchingSet(pluginName:string){ + this.launchedPlugins.delete(pluginName) + } + private mapPluginNameToWidgetUnit: Map<string, WidgetUnit> = new Map() - pluginMinimised(pluginManifest:PluginManifest){ - return this.widgetService.minimisedWindow.has( this.mapPluginNameToWidgetUnit.get(pluginManifest.name) ) + pluginIsMinimised(pluginName:string) { + return this.widgetService.isMinimised( this.mapPluginNameToWidgetUnit.get(pluginName) ) } + private launchingPlugins: Set<string> = new Set() public orphanPlugins: Set<PluginManifest> = new Set() launchPlugin(plugin:PluginManifest){ - if(this.apiService.interactiveViewer.pluginControl[plugin.name]) - { - console.warn('plugin already launched. blinking for 10s.') - this.apiService.interactiveViewer.pluginControl[plugin.name].blink(10) + if (this.pluginIsLaunching(plugin.name)) { + // plugin launching please be patient + // TODO add visual feedback + return + } + if ( this.pluginHasLaunched(plugin.name)) { + // plugin launched + // TODO add visual feedback + + // if widget window is minimized, maximize it + const wu = this.mapPluginNameToWidgetUnit.get(plugin.name) - this.widgetService.minimisedWindow.delete(wu) - return Promise.reject('plugin already launched') + if (this.widgetService.isMinimised(wu)) { + this.widgetService.unminimise(wu) + } else { + this.widgetService.minimise(wu) + } + return } + + this.addPluginToIsLaunchingSet(plugin.name) + return this.readyPlugin(plugin) .then(()=>{ const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) @@ -163,7 +226,7 @@ export class PluginServices{ const shutdownCB = [ () => { - this.launchedPlugins.delete(plugin.name) + this.removePluginFromLaunchedSet(plugin.name) } ] @@ -191,28 +254,18 @@ export class PluginServices{ title : plugin.displayName || plugin.name }) - this.launchedPlugins.add(plugin.name) + this.addPluginToLaunchedSet(plugin.name) + this.removePluginFromIsLaunchingSet(plugin.name) + this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance) const unsubscribeOnPluginDestroy = [] handler.blink = (sec?:number)=>{ - if(typeof sec !== 'number') - console.warn(`sec is not a number, default blink interval used`) - widgetCompRef.instance.containerClass = '' - interval(typeof sec === 'number' ? sec * 1000 : 500).pipe( - take(11), - takeUntil(widgetCompRef.instance.clickedEmitter) - ).subscribe(()=> - widgetCompRef.instance.containerClass = widgetCompRef.instance.containerClass === 'panel-success' ? - '' : - 'panel-success') + widgetCompRef.instance.blinkOn = true } - unsubscribeOnPluginDestroy.push( - widgetCompRef.instance.clickedEmitter.subscribe(()=> - widgetCompRef.instance.containerClass = '') - ) + handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val handler.shutdown = ()=>{ widgetCompRef.instance.exit() @@ -244,6 +297,8 @@ export class PluginHandler{ initStateUrl? : string setInitManifestUrl : (url:string|null)=>void + + setProgressIndicator: (progress:number) => void } export interface PluginManifest{ diff --git a/src/atlasViewer/atlasViewer.urlService.service.ts b/src/atlasViewer/atlasViewer.urlService.service.ts index 8506cf25f696ab09fbe69dd4647c8d650b5dc6de..1008c7a50e424d52c4c1bc32fc072163d8bd852e 100644 --- a/src/atlasViewer/atlasViewer.urlService.service.ts +++ b/src/atlasViewer/atlasViewer.urlService.service.ts @@ -57,8 +57,12 @@ export class AtlasViewerURLService{ */ this.additionalNgLayers$ = combineLatest( this.changeQueryObservable$.pipe( - map(state => state.templateSelected) + select('templateSelected'), + filter(v => !!v) ), + /** + * TODO duplicated with viewerState.loadedNgLayers ? + */ this.store.pipe( select('ngViewerState'), select('layers') @@ -170,7 +174,16 @@ export class AtlasViewerURLService{ for (let ngId in json) { const val = json[ngId] - const labelIndicies = val.split(separator).map(n =>decodeToNumber(n)) + 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 (let labelIndex of labelIndicies) { selectRegionIds.push(`${ngId}#${labelIndex}`) } @@ -208,22 +221,29 @@ export class AtlasViewerURLService{ const cViewerState = searchparams.get('cNavigation') if (cViewerState) { - const [ cO, cPO, cPZ, cP, cZ ] = cViewerState.split(`${separator}${separator}`) - const o = cO.split(separator).map(s => decodeToNumber(s, {float: true})) - const po = cPO.split(separator).map(s => decodeToNumber(s, {float: true})) - const pz = decodeToNumber(cPZ) - const p = cP.split(separator).map(s => decodeToNumber(s)) - const z = decodeToNumber(cZ) - this.store.dispatch({ - type : CHANGE_NAVIGATION, - navigation : { - orientation: o, - perspectiveOrientation: po, - perspectiveZoom: pz, - position: p, - zoom: z - } - }) + try { + const [ cO, cPO, cPZ, cP, cZ ] = cViewerState.split(`${separator}${separator}`) + const o = cO.split(separator).map(s => decodeToNumber(s, {float: true})) + const po = cPO.split(separator).map(s => decodeToNumber(s, {float: true})) + const pz = decodeToNumber(cPZ) + const p = cP.split(separator).map(s => decodeToNumber(s)) + const z = decodeToNumber(cZ) + this.store.dispatch({ + type : CHANGE_NAVIGATION, + navigation : { + orientation: o, + perspectiveOrientation: po, + perspectiveZoom: pz, + position: p, + zoom: z + } + }) + } catch (e) { + /** + * TODO Poisoned encoded char + * send error message + */ + } } const niftiLayers = searchparams.get('niftiLayers') diff --git a/src/atlasViewer/widgetUnit/widgetService.service.ts b/src/atlasViewer/widgetUnit/widgetService.service.ts index 521d69ac8356087702807ec34ebeec44204f7888..7f1cc48355967bb9bfec97c60f3ee3713cb62ee0 100644 --- a/src/atlasViewer/widgetUnit/widgetService.service.ts +++ b/src/atlasViewer/widgetUnit/widgetService.service.ts @@ -1,9 +1,7 @@ import { ComponentRef, ComponentFactory, Injectable, ViewContainerRef, ComponentFactoryResolver, Injector } from "@angular/core"; - import { WidgetUnit } from "./widgetUnit.component"; import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.service"; -import { Subscription } from "rxjs"; - +import { Subscription, BehaviorSubject } from "rxjs"; @Injectable({ providedIn : 'root' @@ -20,7 +18,8 @@ export class WidgetServices{ private clickedListener : Subscription[] = [] - public minimisedWindow: Set<WidgetUnit> = new Set() + public minimisedWindow$: BehaviorSubject<Set<WidgetUnit>> + private minimisedWindow: Set<WidgetUnit> = new Set() constructor( private cfr:ComponentFactoryResolver, @@ -28,6 +27,7 @@ export class WidgetServices{ private injector : Injector ){ this.widgetUnitFactory = this.cfr.resolveComponentFactory(WidgetUnit) + this.minimisedWindow$ = new BehaviorSubject(this.minimisedWindow) } clearAllWidgets(){ @@ -38,8 +38,26 @@ export class WidgetServices{ this.clickedListener.forEach(s=>s.unsubscribe()) } + rename(wu:WidgetUnit, {title, titleHTML}: {title: string, titleHTML: string}){ + /** + * WARNING: always sanitize before pass to rename fn! + */ + wu.title = title + wu.titleHTML = titleHTML + } + minimise(wu:WidgetUnit){ this.minimisedWindow.add(wu) + this.minimisedWindow$.next(new Set(this.minimisedWindow)) + } + + isMinimised(wu:WidgetUnit){ + return this.minimisedWindow.has(wu) + } + + unminimise(wu:WidgetUnit){ + this.minimisedWindow.delete(wu) + this.minimisedWindow$.next(new Set(this.minimisedWindow)) } addNewWidget(guestComponentRef:ComponentRef<any>,options?:Partial<WidgetOptionsInterface>):ComponentRef<WidgetUnit>{ @@ -93,9 +111,12 @@ export class WidgetServices{ this.clickedListener.push( _component.instance.clickedEmitter.subscribe((widgetUnit:WidgetUnit)=>{ + /** + * TODO this operation + */ if(widgetUnit.state !== 'floating') return - const widget = [...this.widgetComponentRefs].find(widget=>widget.instance===widgetUnit) + const widget = [...this.widgetComponentRefs].find(widget=>widget.instance === widgetUnit) if(!widget) return const idx = this.floatingContainer.indexOf(widget.hostView) @@ -103,7 +124,6 @@ export class WidgetServices{ return this.floatingContainer.detach(idx) this.floatingContainer.insert(widget.hostView) - }) ) diff --git a/src/atlasViewer/widgetUnit/widgetUnit.component.ts b/src/atlasViewer/widgetUnit/widgetUnit.component.ts index fc32210cd0774a8b1b75126c25f9e10b2de9195a..35463f92e9d75f4804964dae65439e52c9984a35 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.component.ts +++ b/src/atlasViewer/widgetUnit/widgetUnit.component.ts @@ -1,6 +1,9 @@ -import { Component, ViewChild, ViewContainerRef,ComponentRef, HostBinding, HostListener, Output, EventEmitter, Input, ElementRef, OnInit } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef,ComponentRef, HostBinding, HostListener, Output, EventEmitter, Input, ElementRef, OnInit, OnDestroy } from "@angular/core"; + import { WidgetServices } from "./widgetService.service"; import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.service"; +import { Subscription, Observable } from "rxjs"; +import { map } from "rxjs/operators"; @Component({ @@ -10,9 +13,8 @@ import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.ser ] }) -export class WidgetUnit implements OnInit{ +export class WidgetUnit implements OnInit, OnDestroy{ @ViewChild('container',{read:ViewContainerRef}) container : ViewContainerRef - @ViewChild('emptyspan',{read:ElementRef}) emtpy : ElementRef @HostBinding('attr.state') public state : 'docked' | 'floating' = 'docked' @@ -24,29 +26,61 @@ export class WidgetUnit implements OnInit{ height : string = this.state === 'docked' ? null : '0px' @HostBinding('style.display') - get isMinimised(){ - return this.widgetServices.minimisedWindow.has(this) ? 'none' : null + isMinimised: string + + isMinimised$: Observable<boolean> + + /** + * Timed alternates of blinkOn property should result in attention grabbing blink behaviour + */ + private _blinkOn: boolean = false + get blinkOn(){ + return this._blinkOn } + + set blinkOn(val: boolean) { + this._blinkOn = !!val + } + + get showProgress(){ + return this.progressIndicator !== null + } + /** - * TODO - * upgrade to angular>=7, and use cdk to handle draggable components + * Some plugins may like to show progress indicator for long running processes + * If null, no progress is running + * This value should be between 0 and 1 */ - get transform(){ - return this.state === 'floating' ? - `translate(${this.position[0]}px, ${this.position[1]}px)` : - `translate(0 , 0)` + private _progressIndicator: number = null + get progressIndicator(){ + return this._progressIndicator + } + + set progressIndicator(val:number) { + if (isNaN(val)) { + this._progressIndicator = null + return + } + if (val < 0) { + this._progressIndicator = 0 + return + } + if (val > 1) { + this._progressIndicator = 1 + return + } + this._progressIndicator = val } public canBeDocked: boolean = false @HostListener('mousedown') clicked(){ this.clickedEmitter.emit(this) + this.blinkOn = false } @Input() title : string = 'Untitled' - @Input() containerClass : string = '' - @Output() clickedEmitter : EventEmitter<WidgetUnit> = new EventEmitter() @@ -59,16 +93,30 @@ export class WidgetUnit implements OnInit{ public guestComponentRef : ComponentRef<any> public widgetServices:WidgetServices public cf : ComponentRef<WidgetUnit> + private subscriptions: Subscription[] = [] public id: string constructor( private constantsService: AtlasViewerConstantsServices - ){ + ){ this.id = Date.now().toString() } ngOnInit(){ this.canBeDocked = typeof this.widgetServices.dockedContainer !== 'undefined' + + this.isMinimised$ = this.widgetServices.minimisedWindow$.pipe( + map(set => set.has(this)) + ) + this.subscriptions.push( + this.isMinimised$.subscribe(flag => this.isMinimised = flag ? 'none' : null) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0){ + this.subscriptions.pop().unsubscribe() + } } /** diff --git a/src/atlasViewer/widgetUnit/widgetUnit.style.css b/src/atlasViewer/widgetUnit/widgetUnit.style.css index 10289f924763603c034766cd15e23a819cb0e857..9ed85f20e0fc549bc2c9ab0ac31f8ae156dc48e8 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.style.css +++ b/src/atlasViewer/widgetUnit/widgetUnit.style.css @@ -41,7 +41,62 @@ panel-component[widgetUnitPanel] cursor : move; } -[emptyspan] +:host > panel-component { - opacity:0.01; + max-width: 100%; + width: 300px; + border-width: 1px !important; + border: solid; + border-color: rgba(0, 0, 0, 0); + box-sizing: border-box; } + +@keyframes blinkDark +{ + 0% { + border-color: rgba(128, 128, 200, 0.0); + } + + 100% { + border-color: rgba(128, 128, 200, 1.0); + } +} + +@keyframes blink +{ + 0% { + border-color: rgba(128, 128, 255, 0.0); + } + + 100% { + border-color: rgba(128, 128, 255, 1.0); + } +} + +:host-context([darktheme="true"]) .blinkOn +{ + animation: 0.5s blinkDark ease-in-out 9 alternate; + border: 1px solid rgba(128, 128, 200, 1.0) !important; +} + +:host-context([darktheme="false"]) .blinkOn +{ + animation: 0.5s blink ease-in-out 9 alternate; + border: 1px solid rgba(128, 128, 255, 1.0) !important; +} + +[heading] +{ + position:relative; +} + +[heading] > [progressBar] +{ + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + opacity: 0.4; + pointer-events: none; +} \ No newline at end of file diff --git a/src/atlasViewer/widgetUnit/widgetUnit.template.html b/src/atlasViewer/widgetUnit/widgetUnit.template.html index 62600cf8ba9b22a3901d84fdb50517fe9ab7badf..84a9b851949d66beed27043783c46130c0832157 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.template.html +++ b/src/atlasViewer/widgetUnit/widgetUnit.template.html @@ -1,17 +1,13 @@ <panel-component - [style.transform] = "transform" - [containerClass] = "containerClass" widgetUnitPanel + [ngClass]="{'blinkOn': blinkOn}" [bodyCollapsable] = "state === 'docked'" [cdkDragDisabled]="state === 'docked'" - cdkDrag - [ngStyle]="{'max-width': isMobile? '100%' : '300px', - 'margin-bottom': isMobile? '5px': '0'}"> + cdkDrag> <div widgetUnitHeading heading cdkDragHandle> - <div #emptyspan emptyspan>.</div> <div title> <div *ngIf="!titleHTML"> {{ title }} @@ -41,6 +37,9 @@ class = "fas fa-times" [hoverable] ="{translateY: -1}"></i> </div> + <progress-bar [progress]="progressIndicator" *ngIf="showProgress" progressBar> + + </progress-bar> </div> <div widgetUnitBody body> <ng-template #container> diff --git a/src/components/components.module.ts b/src/components/components.module.ts index e435687fd0541445a7989f8f87f1bb3eb392bae9..8ddb291d165ab8771f9e6b1f6e36ea8ea465f2d4 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -29,6 +29,10 @@ import { CommonModule } from '@angular/common'; import { RadioList } from './radiolist/radiolist.component'; import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module'; import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; +import { ProgressBar } from './progress/progress.component'; +import { SleightOfHand } from './sleightOfHand/soh.component'; +import { DialogComponent } from './dialog/dialog.component'; +import { ConfirmDialogComponent } from './confirmDialog/confirmDialog.component'; @NgModule({ @@ -52,6 +56,10 @@ import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; TimerComponent, PillComponent, RadioList, + ProgressBar, + SleightOfHand, + DialogComponent, + ConfirmDialogComponent, /* directive */ HoverableBlockDirective, @@ -83,6 +91,10 @@ import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; TimerComponent, PillComponent, RadioList, + ProgressBar, + SleightOfHand, + DialogComponent, + ConfirmDialogComponent, SearchResultPaginationPipe, TreeSearchPipe, diff --git a/src/components/confirmDialog/confirmDialog.component.ts b/src/components/confirmDialog/confirmDialog.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c16434ac9910839c345d0a5fe9a132411627ba4 --- /dev/null +++ b/src/components/confirmDialog/confirmDialog.component.ts @@ -0,0 +1,24 @@ +import { Component, Inject, Input } from "@angular/core"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material"; + +@Component({ + selector: 'confirm-dialog-component', + templateUrl: './confirmDialog.template.html', + styleUrls: [ + './confirmDialog.style.css' + ] +}) +export class ConfirmDialogComponent{ + + @Input() + public title: string = 'Confirm' + + @Input() + public message: string = 'Would you like to proceed?' + + constructor(@Inject(MAT_DIALOG_DATA) data: any){ + const { title = null, message = null} = data || {} + if (title) this.title = title + if (message) this.message = message + } +} \ No newline at end of file diff --git a/src/components/confirmDialog/confirmDialog.style.css b/src/components/confirmDialog/confirmDialog.style.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/components/confirmDialog/confirmDialog.template.html b/src/components/confirmDialog/confirmDialog.template.html new file mode 100644 index 0000000000000000000000000000000000000000..eb0c76fffdca11eda3487eb259b2be3cbbaa032e --- /dev/null +++ b/src/components/confirmDialog/confirmDialog.template.html @@ -0,0 +1,16 @@ +<h1 mat-dialog-title> + {{ title }} +</h1> + +<mat-dialog-content> + <p> + {{ message }} + </p> +</mat-dialog-content> + +<mat-divider></mat-divider> + +<mat-dialog-actions class="justify-content-start flex-row-reverse"> + <button [mat-dialog-close]="true" mat-raised-button color="primary">OK</button> + <button [mat-dialog-close]="false" mat-button>Cancel</button> +</mat-dialog-actions> \ No newline at end of file diff --git a/src/components/dialog/dialog.component.ts b/src/components/dialog/dialog.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..7001bbf092dd04bf1b36cdf3ffd31d5304d146bb --- /dev/null +++ b/src/components/dialog/dialog.component.ts @@ -0,0 +1,70 @@ +import { Component, Input, ChangeDetectionStrategy, ViewChild, ElementRef, OnInit, OnDestroy, Inject } from "@angular/core"; +import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material"; +import { Subscription, Observable, fromEvent } from "rxjs"; +import { filter, share } from "rxjs/operators"; + +@Component({ + selector: 'dialog-component', + templateUrl: './dialog.template.html', + styleUrls: [ + './dialog.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class DialogComponent implements OnInit, OnDestroy { + + private subscrptions: Subscription[] = [] + + @Input() title: string = 'Message' + @Input() placeholder: string = "Type your response here" + @Input() defaultValue: string = '' + @Input() message: string = '' + @ViewChild('inputField', {read: ElementRef}) private inputField: ElementRef + + public value: string = '' + private keyListener$: Observable<any> + + constructor( + @Inject(MAT_DIALOG_DATA) public data:any, + private dialogRef: MatDialogRef<DialogComponent> + ){ + const { title, placeholder, defaultValue, message } = this.data + if (title) this.title = title + if (placeholder) this.placeholder = placeholder + if (defaultValue) this.value = defaultValue + if (message) this.message = message + } + + ngOnInit(){ + + this.keyListener$ = fromEvent(this.inputField.nativeElement, 'keyup').pipe( + filter((ev: KeyboardEvent) => ev.key === 'Enter' || ev.key === 'Esc' || ev.key === 'Escape'), + share() + ) + this.subscrptions.push( + this.keyListener$.subscribe(ev => { + if (ev.key === 'Enter') { + this.dialogRef.close(this.value) + } + if (ev.key === 'Esc' || ev.key === 'Escape') { + this.dialogRef.close(null) + } + }) + ) + } + + confirm(){ + this.dialogRef.close(this.value) + } + + cancel(){ + this.dialogRef.close(null) + } + + ngOnDestroy(){ + while(this.subscrptions.length > 0) { + this.subscrptions.pop().unsubscribe() + } + } +} \ No newline at end of file diff --git a/src/components/dialog/dialog.style.css b/src/components/dialog/dialog.style.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/components/dialog/dialog.template.html b/src/components/dialog/dialog.template.html new file mode 100644 index 0000000000000000000000000000000000000000..311b6f30f1582e1cb04b789782a27c87519c0e78 --- /dev/null +++ b/src/components/dialog/dialog.template.html @@ -0,0 +1,38 @@ +<h1 mat-dialog-title> + {{ title }} +</h1> + +<div> + {{ message }} +</div> + +<div mat-dialog-content> + <mat-form-field> + <input + tabindex="0" + [(ngModel)]="value" + matInput + [placeholder]="placeholder" + #inputField> + </mat-form-field> +</div> + +<mat-divider></mat-divider> + +<div class="mt-2 d-flex flex-row justify-content-end"> + <button + (click)="cancel()" + color="primary" + mat-button> + Cancel + </button> + + <button + (click)="confirm()" + class="ml-1" + mat-raised-button + color="primary"> + <i class="fas fa-save mr-1"></i> + Confirm + </button> +</div> \ No newline at end of file diff --git a/src/components/flatTree/flatTree.component.ts b/src/components/flatTree/flatTree.component.ts index ba6c13546925efa8aab616454280e3402c488230..60e0ba44fd00d214d3fb0e9a5997b78d813ffa69 100644 --- a/src/components/flatTree/flatTree.component.ts +++ b/src/components/flatTree/flatTree.component.ts @@ -98,4 +98,10 @@ export class FlatTreeComponent implements AfterViewChecked { .some(id => this.isCollapsedById(id)) } + handleTreeNodeClick(event:MouseEvent, inputItem: any){ + this.treeNodeClick.emit({ + event, + inputItem + }) + } } \ No newline at end of file diff --git a/src/components/flatTree/flatTree.template.html b/src/components/flatTree/flatTree.template.html index 48ff87a252308c179c0c207b244a9cbb9009a58c..893c539a9ff029933606880e3e37776e3d7fa0c1 100644 --- a/src/components/flatTree/flatTree.template.html +++ b/src/components/flatTree/flatTree.template.html @@ -27,7 +27,7 @@ <i [ngClass]="isCollapsed(flattenedItem) ? 'r-270' : ''" class="fas fa-chevron-down"></i> </span> <span - (click)="treeNodeClick.emit({event:$event,inputItem:flattenedItem})" + (click)="handleTreeNodeClick($event, flattenedItem)" class="render-node-text" [innerHtml]="flattenedItem | renderPipe : renderNode "> </span> @@ -63,7 +63,7 @@ <i [ngClass]="isCollapsed(flattenedItem) ? 'r-270' : ''" class="fas fa-chevron-down"></i> </span> <span - (click)="treeNodeClick.emit({event:$event,inputItem:flattenedItem})" + (click)="handleTreeNodeClick($event, flattenedItem)" class="render-node-text" [innerHtml]="flattenedItem | renderPipe : renderNode "> </span> diff --git a/src/components/panel/panel.component.ts b/src/components/panel/panel.component.ts index 6b2c5095c1f04c39d1a85123e0f793163290d845..ccfc382fb8297e7b2671bec08bfffb7b694d1a11 100644 --- a/src/components/panel/panel.component.ts +++ b/src/components/panel/panel.component.ts @@ -1,5 +1,4 @@ -import { Component, Input, ViewChild, ElementRef, AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, SimpleChanges, HostBinding, ApplicationRef } from "@angular/core"; -import { panelAnimations } from "./panel.animation"; +import { Component, Input, ViewChild, ElementRef, ChangeDetectionStrategy } from "@angular/core"; import { ParseAttributeDirective } from "../parseAttribute.directive"; @Component({ @@ -8,9 +7,6 @@ import { ParseAttributeDirective } from "../parseAttribute.directive"; styleUrls : [ `./panel.style.css` ], - host: { - '[class]': 'getClassNames' - }, changeDetection:ChangeDetectionStrategy.OnPush }) @@ -23,8 +19,6 @@ export class PanelComponent extends ParseAttributeDirective { @Input() collapseBody : boolean = false @Input() bodyCollapsable : boolean = false - @Input() containerClass : string = '' - @ViewChild('panelBody',{ read : ElementRef }) efPanelBody : ElementRef @ViewChild('panelFooter',{ read : ElementRef }) efPanelFooter : ElementRef @@ -32,10 +26,6 @@ export class PanelComponent extends ParseAttributeDirective { super() } - get getClassNames(){ - return `panel ${this.containerClass === '' ? 'panel-default' : this.containerClass}` - } - toggleCollapseBody(_event:Event){ if(this.bodyCollapsable){ this.collapseBody = !this.collapseBody diff --git a/src/components/panel/panel.template.html b/src/components/panel/panel.template.html index d2c55e22b6daed1c0b223e86492e6429e34a143d..84ca6a9f7a786cc51b3ba4a32e9ff8e1b3ff416d 100644 --- a/src/components/panel/panel.template.html +++ b/src/components/panel/panel.template.html @@ -1,10 +1,3 @@ -<div - *ngIf = "showHeading" - class = "panel-heading" - hoverable> - -</div> - <div class="l-card"> <div class="l-card-body"> <div diff --git a/src/components/progress/progress.component.ts b/src/components/progress/progress.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..03e34f9f304f5c6a0b86016c5a73760392434053 --- /dev/null +++ b/src/components/progress/progress.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, ChangeDetectionStrategy } from "@angular/core"; + + +@Component({ + selector: 'progress-bar', + templateUrl: './progress.template.html', + styleUrls: [ + './progress.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class ProgressBar{ + @Input() progressStyle: any + + private _progress: number = 0 + /** + * between 0 and 1 + */ + @Input() + set progress(val: number) { + if (isNaN(val)) { + this._progress = 0 + return + } + if (val < 0 || val === null) { + this._progress = 0 + return + } + if (val > 1) { + this._progress = 1 + return + } + this._progress = val + } + + get progress(){ + return this._progress + } + + get progressPercent(){ + return `${this.progress * 100}%` + } +} \ No newline at end of file diff --git a/src/components/progress/progress.style.css b/src/components/progress/progress.style.css new file mode 100644 index 0000000000000000000000000000000000000000..36858a282cdd6c793041f515aa8c4cd7e0cab97a --- /dev/null +++ b/src/components/progress/progress.style.css @@ -0,0 +1,41 @@ +.progress +{ + height: 100%; + width: 100%; + position:relative; + overflow:hidden; + background-color:rgba(255,255,255,0.5); +} + +:host-context([darktheme="true"]) .progress +{ + background-color:rgba(0,0,0,0.5); +} + +@keyframes moveRight +{ + from { + transform: translateX(-105%); + } + to { + transform: translateX(205%); + } +} + +.progress::after +{ + content: ''; + width: 100%; + height: 100%; + position:absolute; + border-left-width: 10em; + border-right-width:0; + border-style: solid; + border-image: linear-gradient( + to right, + rgba(128, 200, 128, 0.0), + rgba(128, 200, 128, 0.5), + rgba(128, 200, 128, 0.0) + ) 0 100%; + animation: moveRight 2000ms linear infinite; +} \ No newline at end of file diff --git a/src/components/progress/progress.template.html b/src/components/progress/progress.template.html new file mode 100644 index 0000000000000000000000000000000000000000..7df81f0819667454c5aa5127a780a94a557d1ae6 --- /dev/null +++ b/src/components/progress/progress.template.html @@ -0,0 +1,3 @@ +<div class="progress rounded-0"> + <div [style.width]="progressPercent" class="progress-bar bg-success rounded-0" role="progressbar"></div> +</div> \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.component.ts b/src/components/sleightOfHand/soh.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b6fbe5fdb989088e847e10716703dfb4c044c5a --- /dev/null +++ b/src/components/sleightOfHand/soh.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, HostBinding, ChangeDetectionStrategy, HostListener } from "@angular/core"; + +@Component({ + selector: 'sleight-of-hand', + templateUrl: './soh.template.html', + styleUrls: [ + './soh.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class SleightOfHand{ + + @HostBinding('class.do-not-close') + get doNotCloseClass(){ + return this.doNotClose || this.focusInStatus + } + + @HostListener('focusin') + focusInHandler(){ + this.focusInStatus = true + } + + @HostListener('focusout') + focusOutHandler(){ + this.focusInStatus = false + } + + private focusInStatus: boolean = false + + @Input() + doNotClose: boolean = false +} \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.style.css b/src/components/sleightOfHand/soh.style.css new file mode 100644 index 0000000000000000000000000000000000000000..2c4f61aa210ae817635fc0764eb87f5cbe187c50 --- /dev/null +++ b/src/components/sleightOfHand/soh.style.css @@ -0,0 +1,28 @@ +:host:not(.do-not-close):not(:hover) > .sleight-of-hand-back, +:host:not(.do-not-close):hover > .sleight-of-hand-front, +:host-context(.do-not-close) > .sleight-of-hand-front +{ + opacity: 0; + pointer-events: none; +} + +:host * +{ + transition: opacity 300ms ease-in-out; +} + +:host +{ + position: relative; +} + +:host > .sleight-of-hand-front +{ + position: relative; +} + +:host > .sleight-of-hand-back +{ + position: absolute; + z-index: 1; +} \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.template.html b/src/components/sleightOfHand/soh.template.html new file mode 100644 index 0000000000000000000000000000000000000000..fca028aa043b8a5c77fdb98261a0e6d5a57dd19c --- /dev/null +++ b/src/components/sleightOfHand/soh.template.html @@ -0,0 +1,9 @@ +<div class="sleight-of-hand-back"> + <ng-content select="[sleight-of-hand-back]"> + </ng-content> +</div> + +<div class="sleight-of-hand-front"> + <ng-content select="[sleight-of-hand-front]"> + </ng-content> +</div> \ No newline at end of file diff --git a/src/index.html b/src/index.html index 467e979f0b14b8f6bbaedae248b488a4f72745d6..e8c99ff3b3da1efe140cddeb3466f0de7ba47c1f 100644 --- a/src/index.html +++ b/src/index.html @@ -6,9 +6,9 @@ <meta http-equiv="X-UA-Compatible" content="ie=edge"> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> - <link rel = "stylesheet" href = "extra_styles.css"> - <link rel = "stylesheet" href = "plugin_styles.css"> - <link rel = "stylesheet" href = "indigo-pink.css"> + <link rel="stylesheet" href="extra_styles.css"> + <link rel="stylesheet" href="plugin_styles.css"> + <link rel="stylesheet" href="theme.css"> <title>Interactive Atlas Viewer</title> </head> diff --git a/src/main-aot.ts b/src/main-aot.ts index 171bd6ce7beb8ae5a35e9d43553fdc134235d0e1..7c25c3f1aa849b9ea8265c99ffce474f78142635 100644 --- a/src/main-aot.ts +++ b/src/main-aot.ts @@ -4,6 +4,9 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' import { MainModule } from './main.module'; import { enableProdMode } from '@angular/core'; +import './theme.scss' +import './res/css/extra_styles.css' + const requireAll = (r:any) => {r.keys().forEach(r)} requireAll(require.context('./res/ext', false, /\.json$/)) requireAll(require.context('./res/images', true, /\.jpg|\.png/)) diff --git a/src/main.module.ts b/src/main.module.ts index d2dc2b916e9ed72f0ea110b6d049b942db581be6..b557d69b45e03f6990b3ea808e6470ba210462e1 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -5,7 +5,7 @@ import { UIModule } from "./ui/ui.module"; import { LayoutModule } from "./layouts/layout.module"; import { AtlasViewer } from "./atlasViewer/atlasViewer.component"; import { StoreModule, Store, select } from "@ngrx/store"; -import { viewerState, dataStore,spatialSearchState,uiState, ngViewerState, pluginState, viewerConfigState } from "./services/stateStore.service"; +import { viewerState, dataStore,spatialSearchState,uiState, ngViewerState, pluginState, viewerConfigState, userConfigState, UserConfigStateUseEffect } from "./services/stateStore.service"; import { GetNamesPipe } from "./util/pipes/getNames.pipe"; import { CommonModule } from "@angular/common"; import { GetNamePipe } from "./util/pipes/getName.pipe"; @@ -21,7 +21,6 @@ import { TabsModule } from 'ngx-bootstrap/tabs' import { ModalUnit } from "./atlasViewer/modalUnit/modalUnit.component"; import { AtlasViewerURLService } from "./atlasViewer/atlasViewer.urlService.service"; import { ToastComponent } from "./components/toast/toast.component"; -import { GetFilenameFromPathnamePipe } from "./util/pipes/getFileNameFromPathName.pipe"; import { AtlasViewerAPIServices } from "./atlasViewer/atlasViewer.apiService.service"; import { PluginUnit } from "./atlasViewer/pluginUnit/pluginUnit.component"; import { NewViewerDisctinctViewToLayer } from "./util/pipes/newViewerDistinctViewToLayer.pipe"; @@ -41,7 +40,12 @@ import { TransformOnhoverSegmentPipe } from "./atlasViewer/onhoverSegment.pipe"; import {HttpClientModule} from "@angular/common/http"; import { EffectsModule } from "@ngrx/effects"; import { UseEffects } from "./services/effect/effect"; -import { MatDialogModule, MatTabsModule } from "@angular/material"; +import { DataBrowserUseEffect } from "./ui/databrowserModule/databrowser.useEffect"; +import { DialogService } from "./services/dialogService.service"; +import { DialogComponent } from "./components/dialog/dialog.component"; +import { ViewerStateControllerUseEffect } from "./ui/viewerStateController/viewerState.useEffect"; +import { ConfirmDialogComponent } from "./components/confirmDialog/confirmDialog.component"; +import { ViewerStateUseEffect } from "./services/state/viewerState.store"; import 'hammerjs' @@ -54,17 +58,15 @@ import 'hammerjs' DragDropModule, UIModule, AngularMaterialModule, - - /** - * move to angular material module - */ - MatDialogModule, - MatTabsModule, TooltipModule.forRoot(), TabsModule.forRoot(), EffectsModule.forRoot([ - UseEffects + DataBrowserUseEffect, + UseEffects, + UserConfigStateUseEffect, + ViewerStateControllerUseEffect, + ViewerStateUseEffect, ]), StoreModule.forRoot({ pluginState, @@ -74,6 +76,7 @@ import 'hammerjs' dataStore, spatialSearchState, uiState, + userConfigState }), HttpClientModule ], @@ -103,7 +106,6 @@ import 'hammerjs' GetNamesPipe, GetNamePipe, TransformOnhoverSegmentPipe, - GetFilenameFromPathnamePipe, NewViewerDisctinctViewToLayer ], entryComponents : [ @@ -111,6 +113,8 @@ import 'hammerjs' ModalUnit, ToastComponent, PluginUnit, + DialogComponent, + ConfirmDialogComponent, ], providers : [ AtlasViewerDataService, @@ -120,6 +124,7 @@ import 'hammerjs' ToastService, AtlasWorkerService, AuthService, + DialogService, /** * TODO diff --git a/src/main.ts b/src/main.ts index 5eec78190f22de8b0a62e4d71eb812efc3a842ab..c1c1f32e340242fc556c097b7351f8b2cd28e277 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,9 @@ import 'reflect-metadata' import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' import { MainModule } from './main.module'; +import './theme.scss' +import './res/css/extra_styles.css' + const requireAll = (r:any) => {r.keys().forEach(r)} requireAll(require.context('./res/ext',false, /\.json$/)) requireAll(require.context('./res/images',true,/\.jpg|\.png/)) diff --git a/src/plugin_examples/plugin_api.md b/src/plugin_examples/plugin_api.md index 131e564e47fc8852553f0815d14ce8c23baca2aa..45aa8ef15d44e3a6330435fba8cd4b36a83ee609 100644 --- a/src/plugin_examples/plugin_api.md +++ b/src/plugin_examples/plugin_api.md @@ -141,6 +141,23 @@ window.interactiveViewer - timeout : auto hide (in ms). set to 0 for not auto hide. - *launchNewWidget(manifest)* returns a Promise. expects a JSON object, with the same key value as a plugin manifest. the *name* key must be unique, or the promise will be rejected. + + - *getUserInput(config)* returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: + ```javascript + const config = { + "title": "Title of the modal", // default: "Message" + "message":"Message to be seen by the user.", // default: "" + "placeholder": "Start typing here", // default: "Type your response here" + "defaultValue": "42" // default: "" + } + ``` + - *getUserConfirmation(config)* returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: + ```javascript + const config = { + "title": "Title of the modal", // default: "Message" + "message":"Message to be seen by the user." // default: "" + } + ``` - pluginControl @@ -162,7 +179,8 @@ window.interactiveViewer - **[PLUGINNAME]** returns a plugin handler. This would be how to interface with the plugins. - - *blink(sec?:number)* : Function that causes the floating widget to blink, attempt to grab user attention (silently fails if called on startup). + - *blink()* : Function that causes the floating widget to blink, attempt to grab user attention (silently fails if called on startup). + - *setProgressIndicator(val:number|null)* : Set the progress of the plugin. Useful for giving user feedbacks on the progress of a long running process. Call the function with null to unset the progress. - *shutdown()* : Function that causes the widget to shutdown dynamically. (triggers onShutdown callback, silently fails if called on startup) - *onShutdown(callback)* : Attaches a callback function, which is called when the plugin is shutdown. - *initState* : passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). diff --git a/src/res/css/extra_styles.css b/src/res/css/extra_styles.css index 61564e98a21122542f1bdad2118b7d0ca1374992..d5b2c296496bf6c94cd3127c620dcc1812176f36 100644 --- a/src/res/css/extra_styles.css +++ b/src/res/css/extra_styles.css @@ -137,10 +137,14 @@ span.regionSelected span.regionNotSelected, span.regionSelected { - cursor : default; user-select: none; } +.cursor-default +{ + cursor: default; +} + markdown-dom pre code { white-space:pre; @@ -290,9 +294,10 @@ markdown-dom pre code { width: 10em!important; } -.w-20em + +.mw-400px { - width: 20em!important; + max-width: 400px!important; } .mw-100 @@ -310,11 +315,26 @@ markdown-dom pre code max-width: 60%!important; } +.mw-20em +{ + max-width: 20em!important; +} + +.w-20em +{ + width: 20em!important; +} + .mh-20em { max-height: 20em; } +.mh-10em +{ + max-height: 10em; +} + .pe-all { pointer-events: all; @@ -359,7 +379,7 @@ markdown-dom pre code .overflow-x-hidden { - overflow-x:hidden; + overflow-x:hidden!important; } .muted @@ -378,6 +398,21 @@ markdown-dom pre code border:none; } +.w-1em +{ + width: 1em; +} + +.bs-content-box +{ + box-sizing: content-box; +} + +/* required to hide */ +.cdk-global-scrollblock +{ + overflow-y:hidden !important; +} .h-90vh { height: 90vh!important; diff --git a/src/res/ext/colinNehubaConfig.json b/src/res/ext/colinNehubaConfig.json index 7b40174f13aa5e7e65e074d58372b3bc8e56e7c0..6028ab6b9200aec69c249512ad70e6783ceb05bf 100644 --- a/src/res/ext/colinNehubaConfig.json +++ b/src/res/ext/colinNehubaConfig.json @@ -1 +1,179 @@ -{"globals":{"hideNullImageValues":true,"useNehubaLayout":true,"useNehubaMeshLayer":true,"useCustomSegmentColors":true},"zoomWithoutCtrl":true,"hideNeuroglancerUI":true,"rightClickWithCtrl":true,"rotateAtViewCentre":true,"zoomAtViewCentre":true,"enableMeshLoadingControl":true,"layout":{"useNehubaPerspective":{"fixedZoomPerspectiveSlices":{"sliceViewportWidth":300,"sliceViewportHeight":300,"sliceZoom":724698.1843689409,"sliceViewportSizeMultiplier":2},"centerToOrigin":true,"mesh":{"removeBasedOnNavigation":true,"flipRemovedOctant":true,"surfaceParcellation":false},"removePerspectiveSlicesBackground":{"mode":"=="},"waitForMesh":false,"drawSubstrates":{"color":[0.5,0.5,1,0.2]},"drawZoomLevels":{"cutOff":150000},"restrictZoomLevel":{"minZoom":2500000,"maxZoom":3500000}}},"dataset":{"imageBackground":[0,0,0,1],"initialNgState":{"showDefaultAnnotations":false,"layers":{"colin":{"type":"image","visible":true,"source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/v2.2c/colin27_seg","transform":[[1,0,0,-75500000],[0,1,0,-111500000],[0,0,1,-67500000],[0,0,0,1]]},"jubrain v2_2c":{"type":"segmentation","source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/v2.2c/MPM","transform":[[1,0,0,-75500000],[0,1,0,-111500000],[0,0,1,-67500000],[0,0,0,1]]},"jubrain colin v17 left":{"type":"segmentation","visible":true,"source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/left","transform":[[1,0,0,-128500000],[0,1,0,-148500000],[0,0,1,-110500000],[0,0,0,1]]},"jubrain colin v17 right":{"type":"segmentation","visible":true,"source":"precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/right","transform":[[1,0,0,-128500000],[0,1,0,-148500000],[0,0,1,-110500000],[0,0,0,1]]}},"navigation":{"pose":{"position":{"voxelSize":[1000000,1000000,1000000],"voxelCoordinates":[0,-32,0]}},"zoomFactor":1000000},"perspectiveOrientation":[-0.2753947079181671,0.6631333827972412,-0.6360703706741333,0.2825356423854828],"perspectiveZoom":3000000}}} \ No newline at end of file +{ + "globals": { + "hideNullImageValues": true, + "useNehubaLayout": true, + "useNehubaMeshLayer": true, + "useCustomSegmentColors": true + }, + "zoomWithoutCtrl": true, + "hideNeuroglancerUI": true, + "rightClickWithCtrl": true, + "rotateAtViewCentre": true, + "zoomAtViewCentre": true, + "enableMeshLoadingControl": true, + "layout": { + "useNehubaPerspective": { + "fixedZoomPerspectiveSlices": { + "sliceViewportWidth": 300, + "sliceViewportHeight": 300, + "sliceZoom": 724698.1843689409, + "sliceViewportSizeMultiplier": 2 + }, + "centerToOrigin": true, + "mesh": { + "removeBasedOnNavigation": true, + "flipRemovedOctant": true, + "surfaceParcellation": false + }, + "removePerspectiveSlicesBackground": { + "mode": "==" + }, + "waitForMesh": false, + "drawSubstrates": { + "color": [ + 0.5, + 0.5, + 1, + 0.2 + ] + }, + "drawZoomLevels": { + "cutOff": 150000 + }, + "restrictZoomLevel": { + "minZoom": 2500000, + "maxZoom": 3500000 + } + } + }, + "dataset": { + "imageBackground": [ + 0, + 0, + 0, + 1 + ], + "initialNgState": { + "showDefaultAnnotations": false, + "layers": { + "colin": { + "type": "image", + "visible": true, + "source": "precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/v2.2c/colin27_seg", + "transform": [ + [ + 1, + 0, + 0, + -75500000 + ], + [ + 0, + 1, + 0, + -111500000 + ], + [ + 0, + 0, + 1, + -67500000 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "jubrain colin v17 left": { + "type": "segmentation", + "visible": true, + "source": "precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/left", + "transform": [ + [ + 1, + 0, + 0, + -128500000 + ], + [ + 0, + 1, + 0, + -148500000 + ], + [ + 0, + 0, + 1, + -110500000 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "jubrain colin v17 right": { + "type": "segmentation", + "visible": true, + "source": "precomputed://https://neuroglancer.humanbrainproject.org/precomputed/JuBrain/17/colin27/right", + "transform": [ + [ + 1, + 0, + 0, + -128500000 + ], + [ + 0, + 1, + 0, + -148500000 + ], + [ + 0, + 0, + 1, + -110500000 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + } + }, + "navigation": { + "pose": { + "position": { + "voxelSize": [ + 1000000, + 1000000, + 1000000 + ], + "voxelCoordinates": [ + 0, + -32, + 0 + ] + } + }, + "zoomFactor": 1000000 + }, + "perspectiveOrientation": [ + -0.2753947079181671, + 0.6631333827972412, + -0.6360703706741333, + 0.2825356423854828 + ], + "perspectiveZoom": 3000000 + } + } +} \ No newline at end of file diff --git a/src/res/images/1-100.png b/src/res/images/1-100.png new file mode 100644 index 0000000000000000000000000000000000000000..66f27f2321c73cb215e28a03ca48efd4fa8f813d Binary files /dev/null and b/src/res/images/1-100.png differ diff --git a/src/res/images/1-200.png b/src/res/images/1-200.png new file mode 100644 index 0000000000000000000000000000000000000000..75435265e1f04a145d264f4b69010e03cc0f75d4 Binary files /dev/null and b/src/res/images/1-200.png differ diff --git a/src/res/images/1-300.png b/src/res/images/1-300.png new file mode 100644 index 0000000000000000000000000000000000000000..a990036ac12431e51d442bcaa76614df273e32cd Binary files /dev/null and b/src/res/images/1-300.png differ diff --git a/src/res/images/1-400.png b/src/res/images/1-400.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8880161ac1364984184e796a0d896b08549a45 Binary files /dev/null and b/src/res/images/1-400.png differ diff --git a/src/res/images/2-100.png b/src/res/images/2-100.png new file mode 100644 index 0000000000000000000000000000000000000000..62817e68db8cd9bf570e3923f3fc92b294df2afa Binary files /dev/null and b/src/res/images/2-100.png differ diff --git a/src/res/images/2-200.png b/src/res/images/2-200.png new file mode 100644 index 0000000000000000000000000000000000000000..1036281b27359d0021be9e843758fa8c5db3270c Binary files /dev/null and b/src/res/images/2-200.png differ diff --git a/src/res/images/2-300.png b/src/res/images/2-300.png new file mode 100644 index 0000000000000000000000000000000000000000..e1718b621a1ea092afea438e1bfc5fbcfa68eeaf Binary files /dev/null and b/src/res/images/2-300.png differ diff --git a/src/res/images/2-400.png b/src/res/images/2-400.png new file mode 100644 index 0000000000000000000000000000000000000000..fb349bd8a361bed5ce75cf2daea616cf20306ed6 Binary files /dev/null and b/src/res/images/2-400.png differ diff --git a/src/res/images/3-100.png b/src/res/images/3-100.png new file mode 100644 index 0000000000000000000000000000000000000000..aec2e7b0297a557dee3843504b41cf3775f9341b Binary files /dev/null and b/src/res/images/3-100.png differ diff --git a/src/res/images/3-200.png b/src/res/images/3-200.png new file mode 100644 index 0000000000000000000000000000000000000000..72665ecaa83a501b02e96e0f77701dae192147d7 Binary files /dev/null and b/src/res/images/3-200.png differ diff --git a/src/res/images/3-300.png b/src/res/images/3-300.png new file mode 100644 index 0000000000000000000000000000000000000000..f2d5f9795d69557b267039774e072b98dfc53845 Binary files /dev/null and b/src/res/images/3-300.png differ diff --git a/src/res/images/3-400.png b/src/res/images/3-400.png new file mode 100644 index 0000000000000000000000000000000000000000..f41ecd0ec5dc922177a95e7713f541cb55e17d8f Binary files /dev/null and b/src/res/images/3-400.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-100.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-100.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a9fb0e291661a457f9492df96a50462999969d Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-100.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-200.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-200.png new file mode 100644 index 0000000000000000000000000000000000000000..36c143ae11e00675c45d16c5a95457a0b5880b43 Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-200.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-300.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-300.png new file mode 100644 index 0000000000000000000000000000000000000000..52f57e5412d618286dfd716fce13b13cef10741d Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-300.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-400.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-400.png new file mode 100644 index 0000000000000000000000000000000000000000..df6e4acd8be3ae0cccca705dc824932e70eefd05 Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-400.png differ diff --git a/src/res/images/BigBrainHistology-100.png b/src/res/images/BigBrainHistology-100.png new file mode 100644 index 0000000000000000000000000000000000000000..50559bfb8359d5a71eb2a82dc276f72f80951f7a Binary files /dev/null and b/src/res/images/BigBrainHistology-100.png differ diff --git a/src/res/images/BigBrainHistology-200.png b/src/res/images/BigBrainHistology-200.png new file mode 100644 index 0000000000000000000000000000000000000000..a17a7f83393e0a57dfe80afad199150b16e605d4 Binary files /dev/null and b/src/res/images/BigBrainHistology-200.png differ diff --git a/src/res/images/BigBrainHistology-300.png b/src/res/images/BigBrainHistology-300.png new file mode 100644 index 0000000000000000000000000000000000000000..f7cd0b28ec4cbbc78488799057878e1e8225215f Binary files /dev/null and b/src/res/images/BigBrainHistology-300.png differ diff --git a/src/res/images/BigBrainHistology-400.png b/src/res/images/BigBrainHistology-400.png new file mode 100644 index 0000000000000000000000000000000000000000..7d8fea17c6d8dd5b3075df22c1f3ce9054275abb Binary files /dev/null and b/src/res/images/BigBrainHistology-400.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-100.png b/src/res/images/ICBM2009cNonlinearAsymmetric-100.png new file mode 100644 index 0000000000000000000000000000000000000000..c8912fe70f594f0a0d60ad7596802ad690166c56 Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-100.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-200.png b/src/res/images/ICBM2009cNonlinearAsymmetric-200.png new file mode 100644 index 0000000000000000000000000000000000000000..7f38858407d751d4e5b1a200ed310d2c81cb6e07 Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-200.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-300.png b/src/res/images/ICBM2009cNonlinearAsymmetric-300.png new file mode 100644 index 0000000000000000000000000000000000000000..481339124d296586ec68455fd0298aed2b392e2b Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-300.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-400.png b/src/res/images/ICBM2009cNonlinearAsymmetric-400.png new file mode 100644 index 0000000000000000000000000000000000000000..0ce95ddc0988de4f48ce8cdcda9c6b0d98b66a79 Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-400.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-100.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-100.png new file mode 100644 index 0000000000000000000000000000000000000000..859167bec29489f7e787d3820081e8d21d69564d Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-100.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-200.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-200.png new file mode 100644 index 0000000000000000000000000000000000000000..5c9e3d78e789eefef7e608d5807713f7d3a59769 Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-200.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-300.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-300.png new file mode 100644 index 0000000000000000000000000000000000000000..29c1ea8f17d80fc7c850fcca49964791dc96ea15 Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-300.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-400.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-400.png new file mode 100644 index 0000000000000000000000000000000000000000..07b8475e00616e3235955d65cbc3598f0bd82e82 Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-400.png differ diff --git a/src/res/images/MNIColin27-100.png b/src/res/images/MNIColin27-100.png new file mode 100644 index 0000000000000000000000000000000000000000..d4748838a9e7d341efe95a9823fa7edca0b6126a Binary files /dev/null and b/src/res/images/MNIColin27-100.png differ diff --git a/src/res/images/MNIColin27-200.png b/src/res/images/MNIColin27-200.png new file mode 100644 index 0000000000000000000000000000000000000000..683341c65daaa5170e77197e614acbcaa3e48f5b Binary files /dev/null and b/src/res/images/MNIColin27-200.png differ diff --git a/src/res/images/MNIColin27-300.png b/src/res/images/MNIColin27-300.png new file mode 100644 index 0000000000000000000000000000000000000000..d55090287e9925d795fbf6772024e6cb81100563 Binary files /dev/null and b/src/res/images/MNIColin27-300.png differ diff --git a/src/res/images/MNIColin27-400.png b/src/res/images/MNIColin27-400.png new file mode 100644 index 0000000000000000000000000000000000000000..6bd9fd61b6ab6bde92c61cd035bb0999ecf1ed3b Binary files /dev/null and b/src/res/images/MNIColin27-400.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-100.png b/src/res/images/WaxholmSpaceratbrainatlasv20-100.png new file mode 100644 index 0000000000000000000000000000000000000000..e1eddc7bfbc2e46b4c4bcf1146558a27d277c57b Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-100.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-200.png b/src/res/images/WaxholmSpaceratbrainatlasv20-200.png new file mode 100644 index 0000000000000000000000000000000000000000..938d3c00746f6c4a1eefa6468715a8af0e205cf0 Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-200.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-300.png b/src/res/images/WaxholmSpaceratbrainatlasv20-300.png new file mode 100644 index 0000000000000000000000000000000000000000..37e7635b5b55e317455e12d727654b89d872067a Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-300.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-400.png b/src/res/images/WaxholmSpaceratbrainatlasv20-400.png new file mode 100644 index 0000000000000000000000000000000000000000..57840e545f0f1804104c93cd09fb65bb16813e4b Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-400.png differ diff --git a/src/services/dialogService.service.ts b/src/services/dialogService.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..110edf6663f748866b952af474d56291e2229149 --- /dev/null +++ b/src/services/dialogService.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@angular/core"; +import { MatDialog, MatDialogRef } from "@angular/material"; +import { DialogComponent } from "src/components/dialog/dialog.component"; +import { ConfirmDialogComponent } from "src/components/confirmDialog/confirmDialog.component"; + + +@Injectable({ + providedIn: 'root' +}) + +export class DialogService{ + + private dialogRef: MatDialogRef<DialogComponent> + private confirmDialogRef: MatDialogRef<ConfirmDialogComponent> + + constructor(private dialog:MatDialog){ + + } + + public getUserConfirm(config: Partial<DialogConfig> = {}): Promise<string>{ + this.confirmDialogRef = this.dialog.open(ConfirmDialogComponent, { + data: config + }) + return new Promise((resolve, reject) => this.confirmDialogRef.afterClosed() + .subscribe(val => { + if (val) resolve() + else reject('User cancelled') + }, + reject, + () => this.confirmDialogRef = null)) + } + + public getUserInput(config: Partial<DialogConfig> = {}):Promise<string>{ + const { defaultValue = '', placeholder = 'Type your response here', title = 'Message', message = '' } = config + this.dialogRef = this.dialog.open(DialogComponent, { + data: { + title, + placeholder, + defaultValue, + message + } + }) + return new Promise((resolve, reject) => { + /** + * nb: one one value is ever emitted, then the subscription ends + * Should not result in leak + */ + this.dialogRef.afterClosed().subscribe(value => { + if (value) resolve(value) + else reject('User cancelled input') + this.dialogRef = null + }) + }) + } +} + +export interface DialogConfig{ + title: string + placeholder: string + defaultValue: string + message: string +} \ No newline at end of file diff --git a/src/services/effect/effect.ts b/src/services/effect/effect.ts index 399bcae7652b8bb8b1ec3becba2188d341dfaf2c..603ed7be86372dc6b93d4922979882c219da09f3 100644 --- a/src/services/effect/effect.ts +++ b/src/services/effect/effect.ts @@ -1,9 +1,9 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Effect, Actions, ofType } from "@ngrx/effects"; -import { Subscription, merge, fromEvent, combineLatest } from "rxjs"; -import { withLatestFrom, map, filter } from "rxjs/operators"; +import { Subscription, merge, fromEvent, combineLatest, Observable } from "rxjs"; +import { withLatestFrom, map, filter, shareReplay, tap, switchMap, take } from "rxjs/operators"; import { Store, select } from "@ngrx/store"; -import { SELECT_PARCELLATION, SELECT_REGIONS, NEWVIEWER, UPDATE_PARCELLATION, SELECT_REGIONS_WITH_ID } from "../state/viewerState.store"; +import { SELECT_PARCELLATION, SELECT_REGIONS, NEWVIEWER, UPDATE_PARCELLATION, SELECT_REGIONS_WITH_ID, DESELECT_REGIONS, ADD_TO_REGIONS_SELECTION_WITH_IDS } from "../state/viewerState.store"; import { worker } from 'src/atlasViewer/atlasViewer.workerService.service' import { getNgIdLabelIndexFromId, generateLabelIndexId, recursiveFindRegionWithLabelIndexId } from '../stateStore.service'; @@ -24,8 +24,51 @@ export class UseEffects implements OnDestroy{ }) }) ) + + this.regionsSelected$ = this.store$.pipe( + select('viewerState'), + select('regionsSelected'), + shareReplay(1) + ) + + this.onDeselectRegions = this.actions$.pipe( + ofType(DESELECT_REGIONS), + withLatestFrom(this.regionsSelected$), + map(([action, regionsSelected]) => { + const { deselectRegions } = action + const deselectSet = new Set((deselectRegions as any[]).map(r => r.name)) + const selectRegions = regionsSelected.filter(r => !deselectSet.has(r.name)) + return { + type: SELECT_REGIONS, + selectRegions + } + }) + ) + + this.addToSelectedRegions$ = this.actions$.pipe( + ofType(ADD_TO_REGIONS_SELECTION_WITH_IDS), + map(action => { + const { selectRegionIds } = action + return selectRegionIds + }), + switchMap(selectRegionIds => this.updatedParcellation$.pipe( + filter(p => !!p), + take(1), + map(p => [selectRegionIds, p]) + )), + map(this.convertRegionIdsToRegion), + withLatestFrom(this.regionsSelected$), + map(([ selectedRegions, alreadySelectedRegions ]) => { + return { + type: SELECT_REGIONS, + selectRegions: this.removeDuplicatedRegions(selectedRegions, alreadySelectedRegions) + } + }) + ) } + private regionsSelected$: Observable<any[]> + ngOnDestroy(){ while(this.subscriptions.length > 0) { this.subscriptions.pop().unsubscribe() @@ -53,45 +96,76 @@ export class UseEffects implements OnDestroy{ private updatedParcellation$ = this.store$.pipe( select('viewerState'), select('parcellationSelected'), - filter(p => !!p && !!p.regions) + map(p => p.updated ? p : null), + shareReplay(1) ) + @Effect() + onDeselectRegions: Observable<any> + + private convertRegionIdsToRegion = ([selectRegionIds, parcellation]) => { + const { ngId: defaultNgId } = parcellation + return (<any[]>selectRegionIds) + .map(labelIndexId => getNgIdLabelIndexFromId({ labelIndexId })) + .map(({ ngId, labelIndex }) => { + return { + labelIndexId: generateLabelIndexId({ + ngId: ngId || defaultNgId, + labelIndex + }) + } + }) + .map(({ labelIndexId }) => { + return recursiveFindRegionWithLabelIndexId({ + regions: parcellation.regions, + labelIndexId, + inheritedNgId: defaultNgId + }) + }) + .filter(v => { + if (!v) { + console.log(`SELECT_REGIONS_WITH_ID, some ids cannot be parsed intto label index`) + } + return !!v + }) + } + + private removeDuplicatedRegions = (...args) => { + const set = new Set() + const returnArr = [] + for (const regions of args){ + for (const region of regions){ + if (!set.has(region.name)) { + returnArr.push(region) + set.add(region.name) + } + } + } + return returnArr + } + + @Effect() + addToSelectedRegions$: Observable<any> + + /** * for backwards compatibility. * older versions of atlas viewer may only have labelIndex as region identifier */ @Effect() - onSelectRegionWithId = combineLatest( - this.actions$.pipe( - ofType(SELECT_REGIONS_WITH_ID) - ), - this.updatedParcellation$ - ).pipe( - map(([action, parcellation]) => { + onSelectRegionWithId = this.actions$.pipe( + ofType(SELECT_REGIONS_WITH_ID), + map(action => { const { selectRegionIds } = action - const { ngId: defaultNgId } = parcellation - - const selectRegions = (<any[]>selectRegionIds) - .map(labelIndexId => getNgIdLabelIndexFromId({ labelIndexId })) - .map(({ ngId, labelIndex }) => { - return { - labelIndexId: generateLabelIndexId({ - ngId: ngId || defaultNgId, - labelIndex - }) - } - }) - .map(({ labelIndexId }) => { - return recursiveFindRegionWithLabelIndexId({ - regions: parcellation.regions, - labelIndexId, - inheritedNgId: defaultNgId - }) - }) - .filter(v => { - if (!v) console.log(`SELECT_REGIONS_WITH_ID, some ids cannot be parsed intto label index`) - return !!v - }) + return selectRegionIds + }), + switchMap(selectRegionIds => this.updatedParcellation$.pipe( + filter(p => !!p), + take(1), + map(parcellation => [selectRegionIds, parcellation]) + )), + map(this.convertRegionIdsToRegion), + map(selectRegions => { return { type: SELECT_REGIONS, selectRegions @@ -118,7 +192,14 @@ export class UseEffects implements OnDestroy{ filter((message: MessageEvent) => message && message.data && message.data.type === 'UPDATE_PARCELLATION_REGIONS'), map(({data}) => data.parcellation), withLatestFrom(this.newParcellationSelected$), - filter(([ propagatedP, selectedP ] : [any, any]) => propagatedP.name === selectedP.name), + filter(([ propagatedP, selectedP ] : [any, any]) => { + /** + * TODO + * use id + * but jubrain may have same id for different template spaces + */ + return propagatedP.name === selectedP.name + }), map(([ propagatedP, _ ]) => propagatedP), map(parcellation => ({ type: UPDATE_PARCELLATION, diff --git a/src/services/state/dataStore.store.ts b/src/services/state/dataStore.store.ts index 4b25d5eae3b134ab7f48c07cf9d290eee06e1d8a..44f21f0bb825e10cdb63e51c23813c7f3fa7a0ec 100644 --- a/src/services/state/dataStore.store.ts +++ b/src/services/state/dataStore.store.ts @@ -1,6 +1,22 @@ import { Action } from '@ngrx/store' -export function dataStore(state:any,action:DatasetAction){ +/** + * TODO merge with databrowser.usereffect.ts + */ + +interface DataEntryState{ + fetchedDataEntries: DataEntry[] + favDataEntries: DataEntry[] + fetchedSpatialData: DataEntry[] +} + +const defaultState = { + fetchedDataEntries: [], + favDataEntries: [], + fetchedSpatialData: [] +} + +export function dataStore(state:DataEntryState = defaultState, action:Partial<DatasetAction>){ switch (action.type){ case FETCHED_DATAENTRIES: { return { @@ -14,12 +30,19 @@ export function dataStore(state:any,action:DatasetAction){ fetchedSpatialData : action.fetchedDataEntries } } - default: - return state + case ACTION_TYPES.UPDATE_FAV_DATASETS: { + const { favDataEntries = [] } = action + return { + ...state, + favDataEntries + } + } + default: return state } } export interface DatasetAction extends Action{ + favDataEntries: DataEntry[] fetchedDataEntries : DataEntry[] fetchedSpatialData : DataEntry[] } @@ -57,6 +80,9 @@ export interface DataEntry{ * TODO typo, should be kgReferences */ kgReference: string[] + + id: string + fullId: string } export interface ParcellationRegion { @@ -133,4 +159,12 @@ export interface ViewerPreviewFile{ export interface FileSupplementData{ data: any -} \ No newline at end of file +} + +const ACTION_TYPES = { + FAV_DATASET: `FAV_DATASET`, + UPDATE_FAV_DATASETS: `UPDATE_FAV_DATASETS`, + UNFAV_DATASET: 'UNFAV_DATASET' +} + +export const DATASETS_ACTIONS_TYPES = ACTION_TYPES \ No newline at end of file diff --git a/src/services/state/userConfigState.store.ts b/src/services/state/userConfigState.store.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4af6c3420f635d37fe38c79cbaf40b0c5534598 --- /dev/null +++ b/src/services/state/userConfigState.store.ts @@ -0,0 +1,340 @@ +import { Action, Store, select } from "@ngrx/store"; +import { Injectable, OnDestroy } from "@angular/core"; +import { Actions, Effect, ofType } from "@ngrx/effects"; +import { Observable, combineLatest, Subscription, from, of } from "rxjs"; +import { shareReplay, withLatestFrom, map, distinctUntilChanged, filter, take, tap, switchMap, catchError, share } from "rxjs/operators"; +import { generateLabelIndexId, recursiveFindRegionWithLabelIndexId } from "../stateStore.service"; +import { SELECT_REGIONS, NEWVIEWER, SELECT_PARCELLATION } from "./viewerState.store"; +import { DialogService } from "../dialogService.service"; + +interface UserConfigState{ + savedRegionsSelection: RegionSelection[] +} + +export interface RegionSelection{ + templateSelected: any + parcellationSelected: any + regionsSelected: any[] + name: string + id: string +} + +/** + * for serialisation into local storage/database + */ +interface SimpleRegionSelection{ + id: string, + name: string, + tName: string, + pName: string, + rSelected: string[] +} + +interface UserConfigAction extends Action{ + config?: Partial<UserConfigState> + payload?: any +} + +const defaultUserConfigState: UserConfigState = { + savedRegionsSelection: [] +} + +const ACTION_TYPES = { + UPDATE_REGIONS_SELECTIONS: `UPDATE_REGIONS_SELECTIONS`, + UPDATE_REGIONS_SELECTION:'UPDATE_REGIONS_SELECTION', + SAVE_REGIONS_SELECTION: `SAVE_REGIONS_SELECTIONN`, + DELETE_REGIONS_SELECTION: 'DELETE_REGIONS_SELECTION', + + LOAD_REGIONS_SELECTION: 'LOAD_REGIONS_SELECTION' +} + +export const USER_CONFIG_ACTION_TYPES = ACTION_TYPES + +export function userConfigState(prevState: UserConfigState = defaultUserConfigState, action: UserConfigAction) { + switch(action.type) { + case ACTION_TYPES.UPDATE_REGIONS_SELECTIONS: + const { config = {} } = action + const { savedRegionsSelection } = config + return { + ...prevState, + savedRegionsSelection + } + default: + return { + ...prevState + } + } +} + +@Injectable({ + providedIn: 'root' +}) +export class UserConfigStateUseEffect implements OnDestroy{ + + private subscriptions: Subscription[] = [] + + constructor( + private actions$: Actions, + private store$: Store<any>, + private dialogService: DialogService + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + share() + ) + + this.tprSelected$ = combineLatest( + viewerState$.pipe( + select('templateSelected'), + distinctUntilChanged() + ), + this.parcellationSelected$, + viewerState$.pipe( + select('regionsSelected'), + /** + * TODO + * distinct selectedRegions + */ + ) + ).pipe( + map(([ templateSelected, parcellationSelected, regionsSelected ]) => { + return { + templateSelected, parcellationSelected, regionsSelected + } + }), + shareReplay(1) + ) + + this.savedRegionsSelections$ = this.store$.pipe( + select('userConfigState'), + select('savedRegionsSelection'), + shareReplay(1) + ) + + this.onSaveRegionsSelection$ = this.actions$.pipe( + ofType(ACTION_TYPES.SAVE_REGIONS_SELECTION), + withLatestFrom(this.tprSelected$), + withLatestFrom(this.savedRegionsSelections$), + + map(([[action, tprSelected], savedRegionsSelection]) => { + const { payload = {} } = action as UserConfigAction + const { name = 'Untitled' } = payload + + const { templateSelected, parcellationSelected, regionsSelected } = tprSelected + const newSavedRegionSelection: RegionSelection = { + id: Date.now().toString(), + name, + templateSelected, + parcellationSelected, + regionsSelected + } + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { + savedRegionsSelection: savedRegionsSelection.concat([newSavedRegionSelection]) + } + } as UserConfigAction + }) + ) + + this.onDeleteRegionsSelection$ = this.actions$.pipe( + ofType(ACTION_TYPES.DELETE_REGIONS_SELECTION), + withLatestFrom(this.savedRegionsSelections$), + map(([ action, savedRegionsSelection ]) => { + const { payload = {} } = action as UserConfigAction + const { id } = payload + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { + savedRegionsSelection: savedRegionsSelection.filter(srs => srs.id !== id) + } + } + }) + ) + + this.onUpdateRegionsSelection$ = this.actions$.pipe( + ofType(ACTION_TYPES.UPDATE_REGIONS_SELECTION), + withLatestFrom(this.savedRegionsSelections$), + map(([ action, savedRegionsSelection]) => { + const { payload = {} } = action as UserConfigAction + const { id, ...rest } = payload + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { + savedRegionsSelection: savedRegionsSelection + .map(srs => srs.id === id + ? { ...srs, ...rest } + : { ...srs }) + } + } + }) + ) + + this.subscriptions.push( + this.actions$.pipe( + ofType(ACTION_TYPES.LOAD_REGIONS_SELECTION), + map(action => { + const { payload = {}} = action as UserConfigAction + const { savedRegionsSelection } : {savedRegionsSelection : RegionSelection} = payload + return savedRegionsSelection + }), + filter(val => !!val), + withLatestFrom(this.tprSelected$), + switchMap(([savedRegionsSelection, { parcellationSelected, templateSelected, regionsSelected }]) => + from(this.dialogService.getUserConfirm({ + title: `Load region selection: ${savedRegionsSelection.name}`, + message: `This action would cause the viewer to navigate away from the current view. Proceed?` + })).pipe( + catchError((e, obs) => of(null)), + map(() => { + return { + savedRegionsSelection, + parcellationSelected, + templateSelected, + regionsSelected + } + }), + filter(val => !!val) + ) + ), + switchMap(({ savedRegionsSelection, parcellationSelected, templateSelected, regionsSelected }) => { + if (templateSelected.name !== savedRegionsSelection.templateSelected.name ) { + /** + * template different, dispatch NEWVIEWER + */ + this.store$.dispatch({ + type: NEWVIEWER, + selectParcellation: savedRegionsSelection.parcellationSelected, + selectTemplate: savedRegionsSelection.templateSelected + }) + return this.parcellationSelected$.pipe( + filter(p => p.updated), + take(1), + map(() => { + return { + regionsSelected: savedRegionsSelection.regionsSelected + } + }) + ) + } + + if (parcellationSelected.name !== savedRegionsSelection.parcellationSelected.name) { + /** + * parcellation different, dispatch SELECT_PARCELLATION + */ + + this.store$.dispatch({ + type: SELECT_PARCELLATION, + selectParcellation: savedRegionsSelection.parcellationSelected + }) + return this.parcellationSelected$.pipe( + filter(p => p.updated), + take(1), + map(() => { + return { + regionsSelected: savedRegionsSelection.regionsSelected + } + }) + ) + } + + return of({ + regionsSelected: savedRegionsSelection.regionsSelected + }) + }) + ).subscribe(({ regionsSelected }) => { + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: regionsSelected + }) + }) + ) + + this.subscriptions.push( + this.actions$.pipe( + ofType(ACTION_TYPES.UPDATE_REGIONS_SELECTIONS) + ).subscribe(action => { + const { config = {} } = action as UserConfigAction + const { savedRegionsSelection } = config + const simpleSRSs = savedRegionsSelection.map(({ id, name, templateSelected, parcellationSelected, regionsSelected }) => { + return { + id, + name, + tName: templateSelected.name, + pName: parcellationSelected.name, + rSelected: regionsSelected.map(({ ngId, labelIndex }) => generateLabelIndexId({ ngId, labelIndex })) + } as SimpleRegionSelection + }) + + /** + * TODO save server side on per user basis + */ + window.localStorage.setItem(LOCAL_STORAGE_KEY.SAVED_REGION_SELECTIONS, JSON.stringify(simpleSRSs)) + }) + ) + + const savedSRSsString = window.localStorage.getItem(LOCAL_STORAGE_KEY.SAVED_REGION_SELECTIONS) + const savedSRSs:SimpleRegionSelection[] = savedSRSsString && JSON.parse(savedSRSsString) + + this.restoreSRSsFromStorage$ = viewerState$.pipe( + filter(() => !!savedSRSs), + select('fetchedTemplates'), + distinctUntilChanged(), + map(fetchedTemplates => savedSRSs.map(({ id, name, tName, pName, rSelected }) => { + const templateSelected = fetchedTemplates.find(t => t.name === tName) + const parcellationSelected = templateSelected && templateSelected.parcellations.find(p => p.name === pName) + const regionsSelected = parcellationSelected && rSelected.map(labelIndexId => recursiveFindRegionWithLabelIndexId({ regions: parcellationSelected.regions, labelIndexId, inheritedNgId: parcellationSelected.ngId })) + return { + templateSelected, + parcellationSelected, + id, + name, + regionsSelected + } as RegionSelection + })), + filter(RSs => RSs.every(rs => rs.regionsSelected && rs.regionsSelected.every(r => !!r))), + take(1), + map(savedRegionsSelection => { + return { + type: ACTION_TYPES.UPDATE_REGIONS_SELECTIONS, + config: { savedRegionsSelection } + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } + + /** + * Temmplate Parcellation Regions selected + */ + private tprSelected$: Observable<{templateSelected:any, parcellationSelected: any, regionsSelected: any[]}> + private savedRegionsSelections$: Observable<any[]> + private parcellationSelected$: Observable<any> + + @Effect() + public onSaveRegionsSelection$: Observable<any> + + @Effect() + public onDeleteRegionsSelection$: Observable<any> + + @Effect() + public onUpdateRegionsSelection$: Observable<any> + + @Effect() + public restoreSRSsFromStorage$: Observable<any> +} + +const LOCAL_STORAGE_KEY = { + SAVED_REGION_SELECTIONS: 'fzj.xg.iv.SAVED_REGION_SELECTIONS' +} \ No newline at end of file diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index 0b1edc38bcf23d9001d9554392d83c9e26789432..359cf2be6547ee998f73cf05b36b9909ed1e1ae4 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -1,6 +1,10 @@ -import { Action } from '@ngrx/store' +import { Action, Store, select } from '@ngrx/store' import { UserLandmark } from 'src/atlasViewer/atlasViewer.apiService.service'; import { NgLayerInterface } from 'src/atlasViewer/atlasViewer.component'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { withLatestFrom, map, shareReplay, startWith, tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; export interface ViewerStateInterface{ fetchedTemplates : any[] @@ -34,13 +38,17 @@ export interface AtlasAction extends Action{ deselectLandmarks : UserLandmark[] navigation? : any + + payload: any } export function viewerState( state:Partial<ViewerStateInterface> = { landmarksSelected : [], fetchedTemplates : [], - loadedNgLayers: [] + loadedNgLayers: [], + regionsSelected: [], + userLandmarks: [] }, action:AtlasAction ){ @@ -106,7 +114,10 @@ export function viewerState( const { updatedParcellation } = action return { ...state, - parcellationSelected: updatedParcellation + parcellationSelected: { + ...updatedParcellation, + updated: true + } } } case SELECT_REGIONS: @@ -133,6 +144,10 @@ export function viewerState( userLandmarks: action.landmarks } } + /** + * TODO + * duplicated with ngViewerState.layers ? + */ case NEHUBA_LAYER_CHANGED: { if (!window['viewer']) { return { @@ -167,10 +182,88 @@ export const CHANGE_NAVIGATION = 'CHANGE_NAVIGATION' export const SELECT_PARCELLATION = `SELECT_PARCELLATION` export const UPDATE_PARCELLATION = `UPDATE_PARCELLATION` +export const DESELECT_REGIONS = `DESELECT_REGIONS` export const SELECT_REGIONS = `SELECT_REGIONS` export const SELECT_REGIONS_WITH_ID = `SELECT_REGIONS_WITH_ID` export const SELECT_LANDMARKS = `SELECT_LANDMARKS` export const DESELECT_LANDMARKS = `DESELECT_LANDMARKS` export const USER_LANDMARKS = `USER_LANDMARKS` +export const ADD_TO_REGIONS_SELECTION_WITH_IDS = `ADD_TO_REGIONS_SELECTION_WITH_IDS` + export const NEHUBA_LAYER_CHANGED = `NEHUBA_LAYER_CHANGED` + +@Injectable({ + providedIn: 'root' +}) + +export class ViewerStateUseEffect{ + constructor( + private actions$: Actions, + private store$: Store<any> + ){ + this.currentLandmarks$ = this.store$.pipe( + select('viewerState'), + select('userLandmarks'), + shareReplay(1), + ) + + this.removeUserLandmarks = this.actions$.pipe( + ofType(ACTION_TYPES.REMOVE_USER_LANDMARKS), + withLatestFrom(this.currentLandmarks$), + map(([action, currentLandmarks]) => { + const { landmarkIds } = (action as AtlasAction).payload + for ( const rmId of landmarkIds ){ + const idx = currentLandmarks.findIndex(({ id }) => id === rmId) + if (idx < 0) console.warn(`remove userlandmark with id ${rmId} does not exist`) + } + const removeSet = new Set(landmarkIds) + return { + type: USER_LANDMARKS, + landmarks: currentLandmarks.filter(({ id }) => !removeSet.has(id)) + } + }) + ) + + this.addUserLandmarks$ = this.actions$.pipe( + ofType(ACTION_TYPES.ADD_USERLANDMARKS), + withLatestFrom(this.currentLandmarks$), + map(([action, currentLandmarks]) => { + const { landmarks } = action as AtlasAction + const landmarkMap = new Map() + for (const landmark of currentLandmarks) { + const { id } = landmark + landmarkMap.set(id, landmark) + } + for (const landmark of landmarks) { + const { id } = landmark + if (landmarkMap.has(id)) { + console.warn(`Attempting to add a landmark that already exists, id: ${id}`) + } else { + landmarkMap.set(id, landmark) + } + } + const userLandmarks = Array.from(landmarkMap).map(([id, landmark]) => landmark) + return { + type: USER_LANDMARKS, + landmarks: userLandmarks + } + }) + ) + } + + private currentLandmarks$: Observable<any[]> + + @Effect() + removeUserLandmarks: Observable<any> + + @Effect() + addUserLandmarks$: Observable<any> +} + +const ACTION_TYPES = { + ADD_USERLANDMARKS: `ADD_USERLANDMARKS`, + REMOVE_USER_LANDMARKS: 'REMOVE_USER_LANDMARKS' +} + +export const VIEWERSTATE_ACTION_TYPES = ACTION_TYPES \ No newline at end of file diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts index 96048ec2192d311c34a01528e4e24ee52ffa5dce..842499c18bcb4d3083a9494fab4f71ca85ca96ad 100644 --- a/src/services/stateStore.service.ts +++ b/src/services/stateStore.service.ts @@ -7,6 +7,11 @@ export { CHANGE_NAVIGATION, AtlasAction, DESELECT_LANDMARKS, FETCHED_TEMPLATE, N export { DataEntry, ParcellationRegion, DataStateInterface, DatasetAction, FETCHED_DATAENTRIES, FETCHED_SPATIAL_DATA, Landmark, OtherLandmarkGeometry, PlaneLandmarkGeometry, PointLandmarkGeometry, Property, Publication, ReferenceSpace, dataStore, File, FileSupplementData } from './state/dataStore.store' export { CLOSE_SIDE_PANEL, MOUSE_OVER_LANDMARK, MOUSE_OVER_SEGMENT, OPEN_SIDE_PANEL, TOGGLE_SIDE_PANEL, UIAction, UIStateInterface, uiState } from './state/uiState.store' export { SPATIAL_GOTO_PAGE, SpatialDataEntries, SpatialDataStateInterface, UPDATE_SPATIAL_DATA, spatialSearchState } from './state/spatialSearchState.store' +export { userConfigState, UserConfigStateUseEffect, USER_CONFIG_ACTION_TYPES } from './state/userConfigState.store' + +export const GENERAL_ACTION_TYPES = { + ERROR: 'ERROR' +} export function safeFilter(key:string){ return filter((state:any)=> diff --git a/src/theme.scss b/src/theme.scss new file mode 100644 index 0000000000000000000000000000000000000000..e85cfdce26366235f96280ccae6f742f46218b35 --- /dev/null +++ b/src/theme.scss @@ -0,0 +1,21 @@ +@import '~@angular/material/theming'; + +@include mat-core(); + +$iv-theme-primary: mat-palette($mat-indigo); +$iv-theme-accent: mat-palette($mat-amber); +$iv-theme-warn: mat-palette($mat-red); + +$iv-theme: mat-light-theme($iv-theme-primary, $iv-theme-accent, $iv-theme-warn); + +@include angular-material-theme($iv-theme); + +$iv-dark-theme-primary: mat-palette($mat-blue); +$iv-dark-theme-accent: mat-palette($mat-amber, A200, A100, A400); +$iv-dark-theme-warn: mat-palette($mat-red); +$iv-dark-theme: mat-dark-theme($iv-dark-theme-primary, $iv-dark-theme-accent, $iv-dark-theme-warn); + +[darktheme=true] +{ + @include angular-material-theme($iv-dark-theme); +} diff --git a/src/ui/databrowserModule/databrowser.module.ts b/src/ui/databrowserModule/databrowser.module.ts index d272cf211d8f2b0648453c12ddccd88d7f094ce1..6c3ee1c3504db297134d6795e199cb778c18555e 100644 --- a/src/ui/databrowserModule/databrowser.module.ts +++ b/src/ui/databrowserModule/databrowser.module.ts @@ -25,6 +25,8 @@ import { KgSingleDatasetService } from "./kgSingleDatasetService.service" import { SingleDatasetView } from './singleDataset/singleDataset.component' import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module' import { DoiParserPipe } from "src/util/pipes/doiPipe.pipe"; +import { DatasetIsFavedPipe } from "./util/datasetIsFaved.pipe"; +import { RegionBackgroundToRgbPipe } from "./util/regionBackgroundToRgb.pipe"; @NgModule({ imports:[ @@ -56,7 +58,9 @@ import { DoiParserPipe } from "src/util/pipes/doiPipe.pipe"; FilterDataEntriesbyMethods, FilterDataEntriesByRegion, AggregateArrayIntoRootPipe, - DoiParserPipe + DoiParserPipe, + DatasetIsFavedPipe, + RegionBackgroundToRgbPipe ], exports:[ DataBrowser, diff --git a/src/ui/databrowserModule/databrowser.service.ts b/src/ui/databrowserModule/databrowser.service.ts index 5baa496dc446a07906ffb41a99358a134d98a357..65f9a241b4e1640b4e6cdffc8938a83e84bf38c8 100644 --- a/src/ui/databrowserModule/databrowser.service.ts +++ b/src/ui/databrowserModule/databrowser.service.ts @@ -4,7 +4,7 @@ import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; import { select, Store } from "@ngrx/store"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { ADD_NG_LAYER, REMOVE_NG_LAYER, DataEntry, safeFilter, FETCHED_DATAENTRIES, FETCHED_SPATIAL_DATA, UPDATE_SPATIAL_DATA } from "src/services/stateStore.service"; -import { map, distinctUntilChanged, debounceTime, filter, tap, switchMap, catchError } from "rxjs/operators"; +import { map, distinctUntilChanged, debounceTime, filter, tap, switchMap, catchError, shareReplay } from "rxjs/operators"; import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; import { FilterDataEntriesByRegion } from "./util/filterDataEntriesByRegion.pipe"; import { NO_METHODS } from "./util/filterDataEntriesByMethods.pipe"; @@ -13,6 +13,7 @@ import { DataBrowser } from "./databrowser/databrowser.component"; import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; import { SHOW_KG_TOS } from "src/services/state/uiState.store"; import { regionFlattener } from "src/util/regionFlattener"; +import { DATASETS_ACTIONS_TYPES } from "src/services/state/dataStore.store"; const noMethodDisplayName = 'No methods described' @@ -45,6 +46,8 @@ function generateToken() { }) export class DatabrowserService implements OnDestroy{ + public favedDataentries$: Observable<DataEntry[]> + public darktheme: boolean = false public instantiatedWidgetUnits: WidgetUnit[] = [] @@ -80,6 +83,12 @@ export class DatabrowserService implements OnDestroy{ private store: Store<ViewerConfiguration> ){ + this.favedDataentries$ = this.store.pipe( + select('dataStore'), + select('favDataEntries'), + shareReplay(1) + ) + this.subscriptions.push( this.store.pipe( select('ngViewerState') @@ -130,6 +139,9 @@ export class DatabrowserService implements OnDestroy{ return from(fetch(`${this.constantService.backendUrl}datasets/spatialSearch/templateName/${encodedTemplateName}/bbox/${pt1.join('_')}__${pt2.join("_")}`) .then(res => res.json())) }), + /** + * TODO pipe to constantService.catchError + */ catchError((err) => (console.log(err), of([]))) ) @@ -194,6 +206,20 @@ export class DatabrowserService implements OnDestroy{ this.subscriptions.forEach(s => s.unsubscribe()) } + public saveToFav(dataentry: DataEntry){ + this.store.dispatch({ + type: DATASETS_ACTIONS_TYPES.FAV_DATASET, + payload: dataentry + }) + } + + public removeFromFav(dataentry: DataEntry){ + this.store.dispatch({ + type: DATASETS_ACTIONS_TYPES.UNFAV_DATASET, + payload: dataentry + }) + } + public fetchPreviewData(datasetName: string){ const encodedDatasetName = encodeURI(datasetName) return new Promise((resolve, reject) => { @@ -306,12 +332,6 @@ export class DatabrowserService implements OnDestroy{ } public getModalityFromDE = getModalityFromDE - - public getBackgroundColorStyleFromRegion(region:any = null){ - return region && region.rgb - ? `rgb(${region.rgb.join(',')})` - : `white` - } } @@ -346,6 +366,12 @@ export function getModalityFromDE(dataentries:DataEntry[]):CountedDataModality[] return dataentries.reduce((acc, de) => reduceDataentry(acc, de), []) } +export function getIdFromDataEntry(dataentry: DataEntry){ + const { id, fullId } = dataentry + const regex = /\/([a-zA-Z0-9\-]*?)$/.exec(fullId) + return (regex && regex[1]) || id +} + export interface CountedDataModality{ name: string diff --git a/src/ui/databrowserModule/databrowser.useEffect.ts b/src/ui/databrowserModule/databrowser.useEffect.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9a57b51905c88e841669aa192463b0947d3c6be --- /dev/null +++ b/src/ui/databrowserModule/databrowser.useEffect.ts @@ -0,0 +1,146 @@ +import { Injectable, OnDestroy } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Actions, ofType, Effect } from "@ngrx/effects"; +import { DATASETS_ACTIONS_TYPES, DataEntry } from "src/services/state/dataStore.store"; +import { Observable, of, from, merge, Subscription } from "rxjs"; +import { withLatestFrom, map, catchError, filter, switchMap, scan, share, switchMapTo, shareReplay } from "rxjs/operators"; +import { KgSingleDatasetService } from "./kgSingleDatasetService.service"; +import { getIdFromDataEntry } from "./databrowser.service"; + +@Injectable({ + providedIn: 'root' +}) + +export class DataBrowserUseEffect implements OnDestroy{ + + private subscriptions: Subscription[] = [] + + constructor( + private store$: Store<any>, + private actions$: Actions<any>, + private kgSingleDatasetService: KgSingleDatasetService + + ){ + this.favDataEntries$ = this.store$.pipe( + select('dataStore'), + select('favDataEntries') + ) + + this.unfavDataset$ = this.actions$.pipe( + ofType(DATASETS_ACTIONS_TYPES.UNFAV_DATASET), + withLatestFrom(this.favDataEntries$), + map(([action, prevFavDataEntries]) => { + + const { payload = {} } = action as any + const { id } = payload + return { + type: DATASETS_ACTIONS_TYPES.UPDATE_FAV_DATASETS, + favDataEntries: prevFavDataEntries.filter(ds => ds.id !== id) + } + }) + ) + + this.favDataset$ = this.actions$.pipe( + ofType(DATASETS_ACTIONS_TYPES.FAV_DATASET), + withLatestFrom(this.favDataEntries$), + map(([ action, prevFavDataEntries ]) => { + const { payload } = action as any + + /** + * check duplicate + */ + const favDataEntries = prevFavDataEntries.find(favDEs => favDEs.id === payload.id) + ? prevFavDataEntries + : prevFavDataEntries.concat(payload) + + return { + type: DATASETS_ACTIONS_TYPES.UPDATE_FAV_DATASETS, + favDataEntries + } + }) + ) + + + this.subscriptions.push( + merge( + this.favDataset$, + this.unfavDataset$ + ).pipe( + switchMapTo(this.favDataEntries$) + ).subscribe(favDataEntries => { + /** + * only store the minimal data in localstorage/db, hydrate when needed + * for now, only save id + * + * do not save anything else on localstorage. This could potentially be leaking sensitive information + */ + const serialisedFavDataentries = favDataEntries.map(dataentry => { + const id = getIdFromDataEntry(dataentry) + return { id } + }) + window.localStorage.setItem(LOCAL_STORAGE_CONST.FAV_DATASET, JSON.stringify(serialisedFavDataentries)) + }) + ) + + this.savedFav$ = of(window.localStorage.getItem(LOCAL_STORAGE_CONST.FAV_DATASET)).pipe( + map(string => JSON.parse(string)), + map(arr => { + if (arr.every(item => item.id )) return arr + throw new Error('Not every item has id and/or name defined') + }), + catchError(err => { + /** + * TODO emit proper error + * possibly wipe corrupted local stoage here? + */ + return null + }) + ) + + this.onInitGetFav$ = this.savedFav$.pipe( + filter(v => !!v), + switchMap(arr => + merge( + ...arr.map(({ id: kgId }) => + from( this.kgSingleDatasetService.getInfoFromKg({ kgId })) + .pipe(catchError(err => { + console.log(`fetchInfoFromKg error`, err) + return null + }))) + ).pipe( + filter(v => !!v), + scan((acc, curr) => acc.concat(curr), []) + ) + ), + map(favDataEntries => { + return { + type: DATASETS_ACTIONS_TYPES.UPDATE_FAV_DATASETS, + favDataEntries + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } + + private savedFav$: Observable<{id: string, name: string}[] | null> + + @Effect() + public onInitGetFav$: Observable<any> + + private favDataEntries$: Observable<DataEntry[]> + + @Effect() + public favDataset$: Observable<any> + + @Effect() + public unfavDataset$: Observable<any> +} + +const LOCAL_STORAGE_CONST = { + FAV_DATASET: 'fzj.xg.iv.FAV_DATASET' +} \ No newline at end of file diff --git a/src/ui/databrowserModule/databrowser/databrowser.component.ts b/src/ui/databrowserModule/databrowser/databrowser.component.ts index b3568e039e3be5c046e2571e2d014ed1cac1877d..3b9c6ace0ddeecca61f7fd57fadb5eaa940576d7 100644 --- a/src/ui/databrowserModule/databrowser/databrowser.component.ts +++ b/src/ui/databrowserModule/databrowser/databrowser.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, ViewChild, Input } from "@angular/core"; import { DataEntry } from "src/services/stateStore.service"; -import { Subscription, merge } from "rxjs"; +import { Subscription, merge, Observable } from "rxjs"; import { DatabrowserService, CountedDataModality } from "../databrowser.service"; import { ModalityPicker } from "../modalityPicker/modalityPicker.component"; @@ -14,6 +14,8 @@ import { ModalityPicker } from "../modalityPicker/modalityPicker.component"; export class DataBrowser implements OnDestroy,OnInit{ + public favedDataentries$: Observable<DataEntry[]> + @Input() public regions: any[] = [] @@ -55,7 +57,7 @@ export class DataBrowser implements OnDestroy,OnInit{ constructor( private dbService: DatabrowserService ){ - + this.favedDataentries$ = this.dbService.favedDataentries$ } ngOnInit(){ @@ -136,6 +138,14 @@ export class DataBrowser implements OnDestroy,OnInit{ this.dbService.manualFetchDataset$.next(null) } + saveToFavourite(dataset: DataEntry){ + this.dbService.saveToFav(dataset) + } + + removeFromFavourite(dataset: DataEntry){ + this.dbService.removeFromFav(dataset) + } + public showParcellationList: boolean = false public filePreviewName: string @@ -155,10 +165,6 @@ export class DataBrowser implements OnDestroy,OnInit{ resetFilters(event?:MouseEvent){ this.clearAll() } - - getBackgroundColorStyleFromRegion(region:any) { - return this.dbService.getBackgroundColorStyleFromRegion(region) - } } export interface DataEntryFilter{ diff --git a/src/ui/databrowserModule/databrowser/databrowser.template.html b/src/ui/databrowserModule/databrowser/databrowser.template.html index 515a94d8331c6235c7ef253d927c7d159ba597ea..060a49ad118bbaea29a100e86db11bc0b128d144 100644 --- a/src/ui/databrowserModule/databrowser/databrowser.template.html +++ b/src/ui/databrowserModule/databrowser/databrowser.template.html @@ -16,7 +16,7 @@ <span *ngFor="let region of regions" class="badge badge-secondary mr-1 mw-100"> - <span [ngStyle]="{backgroundColor:getBackgroundColorStyleFromRegion(region)}" class="dot"> + <span [ngStyle]="{backgroundColor: (region | regionBackgroundToRgbPipe)}" class="dot"> </span> <span class="d-inline-block mw-100 overflow-hidden text-truncate"> @@ -114,15 +114,18 @@ <dataset-viewer class="mt-1" *ngFor="let dataset of filteredDataEntry | searchResultPagination : currentPage : hitsPerPage" + (saveToFavourite)="saveToFavourite(dataset)" + (removeFromFavourite)="removeFromFavourite(dataset)" (showPreviewDataset)="onShowPreviewDataset($event)" - [dataset]="dataset"> + [dataset]="dataset" + [isFaved]="favedDataentries$ | async | datasetIsFaved : dataset"> <div regionTagsContainer> <!-- TODO may want to separate the region badge into a separate component --> <span *ngFor="let region of dataset.parcellationRegion" class="badge badge-secondary mr-1 mw-100"> - <span [ngStyle]="{backgroundColor:getBackgroundColorStyleFromRegion(region)}" class="dot"> + <span [ngStyle]="{backgroundColor:(region | regionBackgroundToRgbPipe)}" class="dot"> </span> <span class="d-inline-block mw-100 overflow-hidden text-truncate"> diff --git a/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts b/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts index 7be8d563234f7e854b1bda067e07a1a758471718..aea172ffcc434d3fef4bc11c479ef2333a0e448c 100644 --- a/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts +++ b/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts @@ -9,6 +9,7 @@ import { DataEntry } from "src/services/stateStore.service"; export class DatasetViewerComponent{ @Input() dataset : DataEntry + @Input() isFaved: boolean @Output() showPreviewDataset: EventEmitter<{datasetName:string, event:MouseEvent}> = new EventEmitter() @ViewChild('kgrRef', {read:ElementRef}) kgrRef: ElementRef @@ -39,4 +40,21 @@ export class DatasetViewerComponent{ get kgReference(): string[] { return this.dataset.kgReference.map(ref => `https://doi.org/${ref}`) } + + /** + * Dummy functions, the store.dispatch is the important function + */ + @Output() + saveToFavourite: EventEmitter<boolean> = new EventEmitter() + + @Output() + removeFromFavourite: EventEmitter<boolean> = new EventEmitter() + + saveToFav(){ + this.saveToFavourite.emit() + } + + removeFromFav(){ + this.removeFromFavourite.emit() + } } \ No newline at end of file diff --git a/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html b/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html index c1248259e4ac13b17b3395b63ef41d1d3396b277..6b19fd16aa4248e039dfdb96a9ef8ca7af1f8fc5 100644 --- a/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html +++ b/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html @@ -30,6 +30,13 @@ [hoverable]="{translateY:-3}"> <i class="fas fa-eye"></i> </div> + + <div + (click)="isFaved ? removeFromFav() : saveToFav()" + [class]="(isFaved ? 'text-primary' : 'text-muted') + ' ds-container ml-1 p-2 preview-container d-flex align-items-center'" + [hoverable]="{translateY:-3}"> + <i class="fas fa-star"></i> + </div> </div> <ng-template #defaultDisplay> diff --git a/src/ui/databrowserModule/kgSingleDatasetService.service.ts b/src/ui/databrowserModule/kgSingleDatasetService.service.ts index 31738eee7c4f55724f35c36dae69372b629b2b18..551b2cf34d1b2c7f70b93e085371c910c0ef35bf 100644 --- a/src/ui/databrowserModule/kgSingleDatasetService.service.ts +++ b/src/ui/databrowserModule/kgSingleDatasetService.service.ts @@ -7,7 +7,7 @@ export class KgSingleDatasetService { constructor(private constantService: AtlasViewerConstantsServices) { } - public getInfoFromKg({ kgId, kgSchema }: KgQueryInterface) { + public getInfoFromKg({ kgId, kgSchema = 'minds/core/dataset/v1.0.0' }: Partial<KgQueryInterface>) { const _url = new URL(`${this.constantService.backendUrl}datasets/kgInfo`) const searchParam = _url.searchParams searchParam.set('kgSchema', kgSchema) @@ -19,7 +19,7 @@ export class KgSingleDatasetService { }) } - public downloadZipFromKg({ kgSchema, kgId } : KgQueryInterface, filename = 'download'){ + public downloadZipFromKg({ kgSchema = 'minds/core/dataset/v1.0.0', kgId } : Partial<KgQueryInterface>, filename = 'download'){ const _url = new URL(`${this.constantService.backendUrl}datasets/downloadKgFiles`) const searchParam = _url.searchParams searchParam.set('kgSchema', kgSchema) diff --git a/src/ui/databrowserModule/util/datasetIsFaved.pipe.ts b/src/ui/databrowserModule/util/datasetIsFaved.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..2befe6fed9e1be922ab987d998a8a274ceb2d3d6 --- /dev/null +++ b/src/ui/databrowserModule/util/datasetIsFaved.pipe.ts @@ -0,0 +1,11 @@ +import { PipeTransform, Pipe } from "@angular/core"; +import { DataEntry } from "src/services/stateStore.service"; + +@Pipe({ + name: 'datasetIsFaved' +}) +export class DatasetIsFavedPipe implements PipeTransform{ + public transform(favedDataEntry: DataEntry[], dataentry: DataEntry):boolean{ + return favedDataEntry.findIndex(ds => ds.id === dataentry.id) >= 0 + } +} \ No newline at end of file diff --git a/src/ui/databrowserModule/util/regionBackgroundToRgb.pipe.ts b/src/ui/databrowserModule/util/regionBackgroundToRgb.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a03cd9dc7230bfbab69b5a4f19866e249914c80 --- /dev/null +++ b/src/ui/databrowserModule/util/regionBackgroundToRgb.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'regionBackgroundToRgbPipe' +}) + +export class RegionBackgroundToRgbPipe implements PipeTransform{ + public transform(region = null): string{ + return region && region.rgb + ? `rgb(${region.rgb.join(',')})` + : 'white' + } +} \ No newline at end of file diff --git a/src/ui/layerbrowser/layerbrowser.component.ts b/src/ui/layerbrowser/layerbrowser.component.ts index 18c2cb75b7971ea8e4a7e3a15c9fa4787e49308f..35178d86ea0c6322ccf0f799cc2eee64c7c701ae 100644 --- a/src/ui/layerbrowser/layerbrowser.component.ts +++ b/src/ui/layerbrowser/layerbrowser.component.ts @@ -1,9 +1,9 @@ -import { Component, OnDestroy } from "@angular/core"; +import { Component, OnDestroy, Input, Pipe, PipeTransform } from "@angular/core"; import { NgLayerInterface } from "../../atlasViewer/atlasViewer.component"; import { Store, select } from "@ngrx/store"; import { ViewerStateInterface, isDefined, REMOVE_NG_LAYER, FORCE_SHOW_SEGMENT, safeFilter, getNgIds } from "../../services/stateStore.service"; -import { Subscription, Observable } from "rxjs"; -import { filter, map } from "rxjs/operators"; +import { Subscription, Observable, combineLatest } from "rxjs"; +import { filter, map, shareReplay, tap } from "rxjs/operators"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; @Component({ @@ -20,13 +20,15 @@ export class LayerBrowser implements OnDestroy{ /** * TODO make untangle nglayernames and its dependency on ng */ - loadedNgLayers$: Observable<NgLayerInterface[]> - lockedLayers : string[] = [] + public loadedNgLayers$: Observable<NgLayerInterface[]> + public lockedLayers : string[] = [] + + public nonBaseNgLayers$: Observable<NgLayerInterface[]> public forceShowSegmentCurrentState : boolean | null = null public forceShowSegment$ : Observable<boolean|null> - public ngLayers$: Observable<any> + public ngLayers$: Observable<string[]> public advancedMode: boolean = false private subscriptions : Subscription[] = [] @@ -35,6 +37,11 @@ export class LayerBrowser implements OnDestroy{ /* TODO temporary measure. when datasetID can be used, will use */ public fetchedDataEntries$ : Observable<any> + @Input() + showPlaceholder: boolean = true + + darktheme$: Observable<boolean> + constructor( private store : Store<ViewerStateInterface>, private constantsService: AtlasViewerConstantsServices){ @@ -64,6 +71,22 @@ export class LayerBrowser implements OnDestroy{ */ map(arr => arr.filter(v => !!v)) ) + + this.loadedNgLayers$ = this.store.pipe( + select('viewerState'), + select('loadedNgLayers') + ) + + this.nonBaseNgLayers$ = combineLatest( + this.ngLayers$, + this.loadedNgLayers$ + ).pipe( + map(([baseNgLayerNames, loadedNgLayers]) => { + const baseNameSet = new Set(baseNgLayerNames) + return loadedNgLayers.filter(l => !baseNameSet.has(l.name)) + }) + ) + /** * TODO * this is no longer populated @@ -80,9 +103,9 @@ export class LayerBrowser implements OnDestroy{ map(state => state.forceShowSegment) ) - this.loadedNgLayers$ = this.store.pipe( - select('viewerState'), - select('loadedNgLayers') + + this.darktheme$ = this.constantsService.darktheme$.pipe( + shareReplay(1) ) this.subscriptions.push( @@ -128,6 +151,9 @@ export class LayerBrowser implements OnDestroy{ return } + /** + * TODO perhaps useEffects ? + */ this.store.dispatch({ type : FORCE_SHOW_SEGMENT, forceShowSegment : this.forceShowSegmentCurrentState === null @@ -151,6 +177,9 @@ export class LayerBrowser implements OnDestroy{ }) } + /** + * TODO use observable and pipe to make this more perf + */ segmentationTooltip(){ return `toggle segments visibility: ${this.forceShowSegmentCurrentState === true ? 'always show' : this.forceShowSegmentCurrentState === false ? 'always hide' : 'auto'}` @@ -169,4 +198,16 @@ export class LayerBrowser implements OnDestroy{ get isMobile(){ return this.constantsService.mobile } + + public matTooltipPosition: string = 'below' } + +@Pipe({ + name: 'lockedLayerBtnClsPipe' +}) + +export class LockedLayerBtnClsPipe implements PipeTransform{ + public transform(ngLayer:NgLayerInterface, lockedLayers?: string[]): boolean{ + return (lockedLayers && new Set(lockedLayers).has(ngLayer.name)) || false + } +} \ No newline at end of file diff --git a/src/ui/layerbrowser/layerbrowser.style.css b/src/ui/layerbrowser/layerbrowser.style.css index 83bf14bb66993088b1d86f607e24784f07926bd4..495211b5c91f0746e1c4d522c18a8d3dfdb3a835 100644 --- a/src/ui/layerbrowser/layerbrowser.style.css +++ b/src/ui/layerbrowser/layerbrowser.style.css @@ -16,11 +16,6 @@ div[body] background-color:rgba(0, 0, 0, 0.1); } -.muted-text -{ - text-decoration: line-through; -} - .layerContainer { display: flex; diff --git a/src/ui/layerbrowser/layerbrowser.template.html b/src/ui/layerbrowser/layerbrowser.template.html index 6ac015c163020f210e8adce0b33e9da34d9be011..b5d5c8e45f37ff1e3ca3fe0de68e1edce3a14c1c 100644 --- a/src/ui/layerbrowser/layerbrowser.template.html +++ b/src/ui/layerbrowser/layerbrowser.template.html @@ -1,71 +1,59 @@ -<ng-container *ngIf="ngLayers$ | async | filterNgLayer : (loadedNgLayers$ | async) as filteredNgLayers; else noLayerPlaceHolder"> - <ng-container *ngIf="filteredNgLayers.length > 0; else noLayerPlaceHolder"> - <div - class="layerContainer overflow-hidden" - *ngFor = "let ngLayer of filteredNgLayers"> - +<ng-container *ngIf="nonBaseNgLayers$ | async as nonBaseNgLayers; else noLayerPlaceHolder"> + <mat-list *ngIf="nonBaseNgLayers.length > 0; else noLayerPlaceHolder"> + <mat-list-item *ngFor="let ngLayer of nonBaseNgLayers"> + <!-- toggle visibility --> - <div class="btnWrapper"> - <div - container = "body" - placement = "bottom" - [tooltip] = "checkLocked(ngLayer) ? 'base layer cannot be hidden' : 'toggle visibility'" - (click) = "checkLocked(ngLayer) ? null : toggleVisibility(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i [ngClass] = "checkLocked(ngLayer) ? 'fas fa-lock muted' :ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> - </i> - </div> - </div> + + <button + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layer cannot be hidden' : 'toggle visibility'" + (click)="toggleVisibility(ngLayer)" + mat-icon-button + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [color]="ngLayer.visible ? 'primary' : null"> + <i [ngClass]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> + </i> + </button> <!-- advanced mode only: toggle force show segmentation --> - <div class="btnWrapper"> - <div - *ngIf="advancedMode" - container="body" - placement="bottom" - [tooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" - #forceSegment="bs-tooltip" - (click)="forceSegment.hide();toggleForceShowSegment(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i - class="fas" - [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> - - </i> - </div> - </div> + <button + *ngIf="advancedMode" + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" + (click)="toggleForceShowSegment(ngLayer)" + mat-icon-button> + <i + class="fas" + [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> + + </i> + </button> <!-- remove layer --> - <div class="btnWrapper"> - <div - container="body" - placement="bottom" - [tooltip]="checkLocked(ngLayer) ? 'base layers cannot be removed' : 'remove layer'" - (click)="removeLayer(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i [ngClass]="checkLocked(ngLayer) ? 'fas fa-lock muted' : 'far fa-times-circle'"> - </i> - </div> - </div> + <button + color="warn" + mat-icon-button + (click)="removeLayer(ngLayer)" + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layers cannot be removed' : 'remove layer'"> + <i [class]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : 'fas fa-trash'"> + </i> + </button> <!-- layer description --> - <panel-component [ngClass]="{'muted-text muted' : !classVisible(ngLayer)}"> - - <div heading> - {{ ngLayer.name | getLayerNameFromDatasets : (fetchedDataEntries$ | async) }} - </div> - - <div bodyy> - {{ ngLayer.source }} - </div> - </panel-component> - </div> - </ng-container> + <div + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.name | getFilenamePipe " + [class]="((darktheme$ | async) ? 'text-light' : 'text-dark') + ' text-truncate'"> + {{ ngLayer.name | getFilenamePipe | getFileExtension }} + </div> + </mat-list-item> + </mat-list> </ng-container> <!-- fall back when no layers are showing --> <ng-template #noLayerPlaceHolder> - <h5 class="noLayerPlaceHolder text-muted"> + <small *ngIf="showPlaceholder" class="noLayerPlaceHolder text-muted"> No additional layers added. - </h5> + </small> </ng-template> \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.component.ts b/src/ui/menuicons/menuicons.component.ts index c488a4e5253d88b9e1b5bea7a3abfa438952ac6a..3ff7f8b1db939b83b0fb91d7f057aa84841c0a04 100644 --- a/src/ui/menuicons/menuicons.component.ts +++ b/src/ui/menuicons/menuicons.component.ts @@ -1,15 +1,17 @@ -import { Component, ComponentRef, Injector, ComponentFactory, ComponentFactoryResolver, AfterViewInit } from "@angular/core"; +import { Component, ComponentRef, Injector, ComponentFactory, ComponentFactoryResolver } from "@angular/core"; import { WidgetServices } from "src/atlasViewer/widgetUnit/widgetService.service"; import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; -import { LayerBrowser } from "src/ui/layerbrowser/layerbrowser.component"; import { DataBrowser } from "src/ui/databrowserModule/databrowser/databrowser.component"; import { PluginBannerUI } from "../pluginBanner/pluginBanner.component"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { DatabrowserService } from "../databrowserModule/databrowser.service"; -import { PluginServices } from "src/atlasViewer/atlasViewer.pluginService.service"; +import { PluginServices, PluginManifest } from "src/atlasViewer/atlasViewer.pluginService.service"; import { Store, select } from "@ngrx/store"; -import { Observable } from "rxjs"; +import { Observable, BehaviorSubject, combineLatest, merge, of } from "rxjs"; +import { map, shareReplay, startWith } from "rxjs/operators"; +import { DESELECT_REGIONS, SELECT_REGIONS, CHANGE_NAVIGATION } from "src/services/state/viewerState.store"; +import { ToastService } from "src/services/toastService.service"; @Component({ selector: 'menu-icons', @@ -22,6 +24,8 @@ import { Observable } from "rxjs"; export class MenuIconsBar{ + public badgetPosition: string = 'above before' + /** * databrowser */ @@ -29,13 +33,6 @@ export class MenuIconsBar{ dataBrowser: ComponentRef<DataBrowser> = null dbWidget: ComponentRef<WidgetUnit> = null - /** - * layerBrowser - */ - lbcf: ComponentFactory<LayerBrowser> - layerBrowser: ComponentRef<LayerBrowser> = null - lbWidget: ComponentRef<WidgetUnit> = null - /** * pluginBrowser */ @@ -43,11 +40,24 @@ export class MenuIconsBar{ pluginBanner: ComponentRef<PluginBannerUI> = null pbWidget: ComponentRef<WidgetUnit> = null - get isMobile(){ - return this.constantService.mobile + isMobile: boolean = false + mobileRespBtnClass: string + + public darktheme$: Observable<boolean> + + public themedBtnClass$: Observable<string> + + public skeletonBtnClass$: Observable<string> + + public toolBtnClass$: Observable<string> + public getKgSearchBtnCls$: Observable<[Set<WidgetUnit>, string]> + + get darktheme(){ + return this.constantService.darktheme } public selectedTemplate$: Observable<any> + public selectedRegions$: Observable<any[]> constructor( private widgetServices:WidgetServices, @@ -56,19 +66,63 @@ export class MenuIconsBar{ public dbService: DatabrowserService, cfr: ComponentFactoryResolver, public pluginServices:PluginServices, - store: Store<any> + private store: Store<any>, + private toastService: ToastService ){ + this.isMobile = this.constantService.mobile + this.mobileRespBtnClass = this.constantService.mobile ? 'btn-lg' : 'btn-sm' + this.dbService.createDatabrowser = this.clickSearch.bind(this) this.dbcf = cfr.resolveComponentFactory(DataBrowser) - this.lbcf = cfr.resolveComponentFactory(LayerBrowser) this.pbcf = cfr.resolveComponentFactory(PluginBannerUI) this.selectedTemplate$ = store.pipe( select('viewerState'), select('templateSelected') ) + + this.selectedRegions$ = store.pipe( + select('viewerState'), + select('regionsSelected'), + startWith([]), + shareReplay(1) + ) + + this.themedBtnClass$ = this.constantService.darktheme$.pipe( + map(flag => flag ? 'btn-dark' : 'btn-light' ), + shareReplay(1) + ) + + this.skeletonBtnClass$ = this.constantService.darktheme$.pipe( + map(flag => `${this.mobileRespBtnClass} ${flag ? 'text-light' : 'text-dark'}`), + shareReplay(1) + ) + + this.launchedPlugins$ = this.pluginServices.launchedPlugins$.pipe( + map(set => Array.from(set)), + shareReplay(1) + ) + + /** + * TODO remove dependency on themedBtnClass$ + */ + this.getPluginBtnClass$ = combineLatest( + this.pluginServices.launchedPlugins$, + this.pluginServices.minimisedPlugins$, + this.themedBtnClass$ + ) + + this.darktheme$ = this.constantService.darktheme$ + + /** + * TODO remove dependency on themedBtnClass$ + */ + this.getKgSearchBtnCls$ = combineLatest( + this.widgetServices.minimisedWindow$, + this.themedBtnClass$ + ) } /** @@ -98,36 +152,8 @@ export class MenuIconsBar{ } public catchError(e) { - + this.constantService.catchError(e) } - - public clickLayer(event: MouseEvent){ - - if (this.lbWidget) { - this.lbWidget.destroy() - this.lbWidget = null - return - } - this.layerBrowser = this.lbcf.create(this.injector) - this.lbWidget = this.widgetServices.addNewWidget(this.layerBrowser, { - exitable: true, - persistency: true, - state: 'floating', - title: 'Layer Browser', - titleHTML: '<i class="fas fa-layer-group"></i> Layer Browser' - }) - - this.lbWidget.onDestroy(() => { - this.layerBrowser = null - this.lbWidget = null - }) - - const el = event.currentTarget as HTMLElement - const top = el.offsetTop - const left = el.offsetLeft + 50 - this.lbWidget.instance.position = [left, top] - } - public clickPlugins(event: MouseEvent){ if(this.pbWidget) { this.pbWidget.destroy() @@ -154,19 +180,32 @@ export class MenuIconsBar{ this.pbWidget.instance.position = [left, top] } - get databrowserIsShowing() { - return this.dataBrowser !== null + public clickPluginIcon(manifest: PluginManifest){ + this.pluginServices.launchPlugin(manifest) + .catch(this.constantService.catchError) } - get layerbrowserIsShowing() { - return this.layerBrowser !== null + public searchIconClickHandler(wu: WidgetUnit){ + if (this.widgetServices.isMinimised(wu)) { + this.widgetServices.unminimise(wu) + } else { + this.widgetServices.minimise(wu) + } } - get pluginbrowserIsShowing() { - return this.pluginBanner !== null + public closeWidget(event: MouseEvent, wu:WidgetUnit){ + event.stopPropagation() + this.widgetServices.exitWidget(wu) } - get dataBrowserTitle() { - return `Browse` + public renameKgSearchWidget(event:MouseEvent, wu: WidgetUnit) { + event.stopPropagation() } + + public favKgSearch(event: MouseEvent, wu: WidgetUnit) { + event.stopPropagation() + } + + public getPluginBtnClass$: Observable<[Set<string>, Set<string>, string]> + public launchedPlugins$: Observable<string[]> } \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.style.css b/src/ui/menuicons/menuicons.style.css index 8a1663369fecdd67c4c7c70ee73774316f49e430..c76c706c7151f5276eaba98168489ee4bfc82dc5 100644 --- a/src/ui/menuicons/menuicons.style.css +++ b/src/ui/menuicons/menuicons.style.css @@ -20,4 +20,19 @@ :host >>> .tooltip.right .tooltip-arrow { border-right-color: rgba(128, 128, 128, 0.5); +} + +.soh-row > *:not(:first-child) +{ + margin-left: 0.1em; +} + +.soh-column > *:not(:first-child) +{ + margin-top: 0.1em; +} + +layer-browser +{ + max-width: 20em; } \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.template.html b/src/ui/menuicons/menuicons.template.html index 214281632fa62839e0a9d3d0fa0b27e10ec18cbf..00b4065a9219b171ebc38c8df0dce6d503eaa460 100644 --- a/src/ui/menuicons/menuicons.template.html +++ b/src/ui/menuicons/menuicons.template.html @@ -3,94 +3,309 @@ <!-- hide icons when templates has yet been selected --> <ng-template [ngIf]="selectedTemplate$ | async"> - <div - *ngIf="false" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + + <!-- layer browser --> + <sleight-of-hand> + <div sleight-of-hand-front> + <button + [matBadge]="layerBrowser && (layerBrowser.nonBaseNgLayers$ | async)?.length > 0 ? (layerBrowser.nonBaseNgLayers$ | async)?.length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + color="primary" + mat-icon-button> + <i class="fas fa-layer-group"></i> + </button> + </div> <div - [tooltip]="dataBrowserTitle" - placement="right" - (click)="clickSearch($event)" - [ngClass]="databrowserIsShowing ? 'btn-primary' : 'btn-secondary'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-search"> - - </i> + class="d-flex flex-row align-items-center soh-row" + sleight-of-hand-back> + + <button + [matBadge]="layerBrowser && (layerBrowser.nonBaseNgLayers$ | async)?.length > 0 ? (layerBrowser.nonBaseNgLayers$ | async)?.length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + color="primary" + mat-icon-button> + <i class="fas fa-layer-group"></i> + </button> + + <div class="position-relative"> + + <div [ngClass]="{'invisible pe-none': (layerBrowser.nonBaseNgLayers$ | async).length === 0}" class="position-absolute"> + <mat-card> + <layer-browser #layerBrowser> + </layer-browser> + </mat-card> + </div> + + <ng-container *ngIf="(layerBrowser.nonBaseNgLayers$ | async).length === 0" #noNonBaseNgLayerTemplate> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + No additional layers added + </small> + </ng-container> + + <!-- invisible button to prop up the size of parent block --> + <!-- otherwise, sibling block position will be wonky --> + <button + color="primary" + class="invisible pe-none" + mat-icon-button> + <i class="fas fa-layer-group"></i> + </button> + + </div> + </div> - </div> + </sleight-of-hand> + + <!-- tools --> + <sleight-of-hand> - <div - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> - <div - tooltip="Layer" - placement="right" - (click)="clickLayer($event)" - [ngClass]="layerbrowserIsShowing ? 'btn-primary' : 'btn-secondary'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-layer-group"> - - </i> + <!-- shown icon prior to mouse over --> + <div sleight-of-hand-front> + <button + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + [matBadge]="(launchedPlugins$ | async)?.length > 0 ? (launchedPlugins$ | async)?.length : null" + mat-icon-button + color="primary"> + <i class="fas fa-tools"></i> + </button> </div> - </div> - <div - *ngIf="false" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + <!-- shown after mouse over --> <div - tooltip="Plugins" - (click)="clickPlugins($event)" - placement="right" - [ngClass]="pluginbrowserIsShowing ? 'btn-primary' : 'btn-secondary'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-tools"> + class="d-flex flex-row soh-row align-items-start" + sleight-of-hand-back> + + <!-- placeholder icon --> + <button + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + [matBadge]="(launchedPlugins$ | async)?.length > 0 ? (launchedPlugins$ | async)?.length : null" + mat-icon-button + color="primary"> + <i class="fas fa-tools"></i> + </button> + + <!-- render all fetched tools --> + <div class="d-flex flex-row soh-row"> + + <!-- add new tool btn --> + <button + matTooltip="Add new plugin" + matTooltipPosition="below" + mat-icon-button + color="primary"> + <i class="fas fa-plus"></i> + </button> - </i> + <button + *ngFor="let manifest of pluginServices.fetchedPluginManifests" + mat-mini-fab + matTooltipPosition="below" + [matTooltip]="manifest.displayName || manifest.name" + [color]="getPluginBtnClass$ | async | pluginBtnFabColorPipe : manifest.name" + (click)="clickPluginIcon(manifest)"> + {{ (manifest.displayName || manifest.name).slice(0, 1) }} + </button> + </div> </div> - </div> + </sleight-of-hand> - <div - *ngFor="let manifest of pluginServices.fetchedPluginManifests" - [tooltip]="manifest.displayName || manifest.name" - placement="right" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + <!-- search kg --> + <sleight-of-hand> + + <!-- shown icon prior to mouse over --> + <div sleight-of-hand-front> + <button + mat-icon-button + color="primary" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + [matBadge]="dbService.instantiatedWidgetUnits.length > 0 ? dbService.instantiatedWidgetUnits.length : null"> + <i class="fas fa-search"></i> + </button> + </div> + <!-- shown after mouse over --> <div - (click)="pluginServices.launchPlugin(manifest).catch(catchError)" - [ngClass]="!pluginServices.launchedPlugins.has(manifest.name) ? 'btn-outline-secondary' : pluginServices.pluginMinimised(manifest) ? 'btn-outline-info' : 'btn-info'" - class="shadow btn btn-sm rounded-circle"> - {{ (manifest.displayName || manifest.name).slice(0, 1) }} + sleight-of-hand-back + class="d-flex flex-row align-items-center soh-row pe-none"> + + <!-- placeholder icon --> + <button + mat-icon-button + color="primary" + matBadgeColor="accent" + [matBadgePosition]="badgetPosition" + [matBadge]="dbService.instantiatedWidgetUnits.length > 0 ? dbService.instantiatedWidgetUnits.length : null"> + <i class="fas fa-search"></i> + </button> + + <!-- only renders if there is at least one search result --> + <div + *ngIf="dbService.instantiatedWidgetUnits.length > 0; else noKgSearchTemplate" + class="position-relative pe-all"> + + <div class="position-absolute d-flex flex-column soh-column"> + + <!-- render all searched kg --> + <sleight-of-hand + *ngFor="let wu of dbService.instantiatedWidgetUnits" + (click)="searchIconClickHandler(wu)"> + + <!-- shown prior to mouseover --> + <div sleight-of-hand-front> + <button + mat-mini-fab + [color]="getKgSearchBtnCls$ | async | kgSearchBtnColorPipe : wu"> + <i class="fas fa-search"></i> + </button> + </div> + + <!-- shown on mouse over --> + <!-- showing additional information. in this case, name of the kg search --> + <div class="d-flex flex-row align-items-center" sleight-of-hand-back> + + <div sleight-of-hand-front> + <button + mat-mini-fab + [color]="getKgSearchBtnCls$ | async | kgSearchBtnColorPipe : wu"> + <i class="fas fa-search"></i> + </button> + </div> + + <!-- on hover, show full name and action possible: rename, close --> + <div [class]="((darktheme$ | async) ? 'text-light' : 'text-dark' ) + ' h-0 d-flex flex-row align-items-center'"> + + <sleight-of-hand class="ml-1 h-0"> + <!-- prior mouse over --> + <div class="h-0 d-flex align-items-center flex-row" sleight-of-hand-front> + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light' ) + ' muted d-flex flex-row align-items-center'"> + + <small class="cursor-default ml-2 text-nowrap"> + {{ wu.title }} + </small> + + <!-- dummy class to keep height --> + <div + matTooltip="Rename" + matTooltipPosition="below" + [class]="(skeletonBtnClass$ | async) + ' invisible w-0 pe-none'"> + <i class="fas fa-edit"></i> + </div> + </div> + </div> + + <!-- on mouse over --> + <div class="h-0 d-flex align-items-center flex-row" sleight-of-hand-back> + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light' ) + ' d-flex flex-row align-items-center'"> + + <small class="cursor-default ml-2 text-nowrap"> + {{ wu.title }} + </small> + + <!-- rename --> + <div + (click)="renameKgSearchWidget($event, wu)" + matTooltip="Rename (NYI)" + matTooltipPosition="below" + [class]="(skeletonBtnClass$ | async) + ' text-muted'"> + <i class="fas fa-edit"></i> + </div> + + <!-- star --> + <div + (click)="favKgSearch($event, wu)" + matTooltip="Favourite (NYI)" + matTooltipPosition="below" + [class]="(skeletonBtnClass$ | async) + ' text-muted'"> + <i class="far fa-star"></i> + </div> + + <!-- close --> + <div + (click)="closeWidget($event, wu)" + matTooltip="Close" + matTooltipPosition="below" + [class]="skeletonBtnClass$ | async"> + <i class="fas fa-times"></i> + </div> + </div> + </div> + </sleight-of-hand> + </div> + </div> + </sleight-of-hand> + </div> + + <!-- invisible icon to keep height of the otherwise unstable flex block --> + <div class="invisible pe-none"> + <button mat-icon-button> + <i class="fas fa-search"></i> + </button> + </div> + </div> + + <!-- displayed when no search is visible --> + <ng-template #noKgSearchTemplate> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + Right click any area to search + </small> + </ng-template> </div> - </div> - <div - *ngFor="let manifest of pluginServices.orphanPlugins" - [tooltip]="manifest.displayName || manifest.name" - placement="right" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + </sleight-of-hand> - <div - (click)="pluginServices.launchPlugin(manifest).catch(catchError)" - [ngClass]="pluginServices.pluginMinimised(manifest) ? 'btn-outline-info' : 'btn-info'" - class="shadow btn btn-sm rounded-circle"> - {{ (manifest.displayName || manifest.name).slice(0, 1) }} + <!-- selected regions --> + <sleight-of-hand + [doNotClose]="viewerStateController.focused"> + + <!-- shown prior to mouse over --> + <div sleight-of-hand-front> + <button + [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + mat-icon-button + color="primary"> + <i class="fas fa-brain"></i> + </button> </div> - </div> - - <div - *ngFor="let wu of dbService.instantiatedWidgetUnits" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - placement="right" - [tooltip]="wu.title" - class="btnWrapper"> + + <!-- shown upon mouseover --> <div - (click)="widgetServices.minimisedWindow.delete(wu)" - [ngClass]="widgetServices.minimisedWindow.has(wu) ? 'btn-outline-info' : 'btn-info'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-search"></i> + sleight-of-hand-back + class="d-flex flex-row align-items-center soh-row"> + + <!-- place holder icon --> + <button + [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + mat-icon-button + color="primary"> + <i class="fas fa-brain"></i> + </button> + + <div class="position-relative"> + + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light') + ' position-absolute card'"> + <viewer-state-controller #viewerStateController></viewer-state-controller> + </div> + + <!-- invisible icon to keep height of the otherwise unstable flex block --> + <div class="invisible pe-none"> + <i class="fas fa-brain"></i> + </div> + </div> + + <ng-template #noBrainRegionSelected> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + Double click any brain region to select it. + </small> + </ng-template> </div> - </div> + </sleight-of-hand> </ng-template> \ No newline at end of file diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index 85e32cf11f9a5f03ae084a54ff0386e4928ec45b..3112fa91a944a0766c871af90034af33c989a715 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -1,9 +1,9 @@ import { Component, ViewChild, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, ComponentRef, OnInit, OnDestroy, ElementRef } from "@angular/core"; import { NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component"; import { Store, select } from "@ngrx/store"; -import { ViewerStateInterface, safeFilter, CHANGE_NAVIGATION, isDefined, USER_LANDMARKS, ADD_NG_LAYER, REMOVE_NG_LAYER, NgViewerStateInterface, MOUSE_OVER_LANDMARK, SELECT_LANDMARKS, Landmark, PointLandmarkGeometry, PlaneLandmarkGeometry, OtherLandmarkGeometry, getNgIds, getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId } from "../../services/stateStore.service"; +import { ViewerStateInterface, safeFilter, CHANGE_NAVIGATION, isDefined, USER_LANDMARKS, ADD_NG_LAYER, REMOVE_NG_LAYER, NgViewerStateInterface, MOUSE_OVER_LANDMARK, SELECT_LANDMARKS, Landmark, PointLandmarkGeometry, PlaneLandmarkGeometry, OtherLandmarkGeometry, getNgIds, getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId, DataEntry } from "../../services/stateStore.service"; import { Observable, Subscription, fromEvent, combineLatest, merge } from "rxjs"; -import { filter,map, take, scan, debounceTime, distinctUntilChanged, switchMap, skip, withLatestFrom, buffer, tap, switchMapTo, shareReplay } from "rxjs/operators"; +import { filter,map, take, scan, debounceTime, distinctUntilChanged, switchMap, skip, withLatestFrom, buffer, tap, switchMapTo, shareReplay, throttleTime, bufferTime, startWith } from "rxjs/operators"; import { AtlasViewerAPIServices, UserLandmark } from "../../atlasViewer/atlasViewer.apiService.service"; import { timedValues } from "../../util/generator"; import { AtlasViewerConstantsServices } from "../../atlasViewer/atlasViewer.constantService.service"; @@ -11,8 +11,12 @@ import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; import { pipeFromArray } from "rxjs/internal/util/pipe"; import { NEHUBA_READY, H_ONE_THREE, V_ONE_THREE, FOUR_PANEL, SINGLE_PANEL } from "src/services/state/ngViewerState.store"; import { MOUSE_OVER_SEGMENTS } from "src/services/state/uiState.store"; -import { SELECT_REGIONS_WITH_ID, NEHUBA_LAYER_CHANGED } from "src/services/state/viewerState.store"; import { getHorizontalOneThree, getVerticalOneThree, getFourPanel, getSinglePanel } from "./util"; +import { SELECT_REGIONS_WITH_ID, NEHUBA_LAYER_CHANGED, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store"; +import { MatBottomSheet, MatButton } from "@angular/material"; +import { DATASETS_ACTIONS_TYPES } from "src/services/state/dataStore.store"; +import { KgSingleDatasetService } from "../databrowserModule/kgSingleDatasetService.service"; +import { getIdFromDataEntry } from "../databrowserModule/databrowser.service"; const getProxyUrl = (ngUrl) => `nifti://${BACKEND_URL}preview/file?fileUrl=${encodeURIComponent(ngUrl.replace(/^nifti:\/\//,''))}` const getProxyOther = ({source}) => /AUTH_227176556f3c4bb38df9feea4b91200c/.test(source) @@ -132,8 +136,6 @@ export class NehubaContainer implements OnInit, OnDestroy{ private landmarksLabelIndexMap : Map<number, any> = new Map() private landmarksNameMap : Map<string,number> = new Map() - private userLandmarks : UserLandmark[] = [] - private subscriptions : Subscription[] = [] private nehubaViewerSubscriptions : Subscription[] = [] @@ -143,14 +145,22 @@ export class NehubaContainer implements OnInit, OnDestroy{ private viewPanels: [HTMLElement, HTMLElement, HTMLElement, HTMLElement] = [null, null, null, null] public panelMode$: Observable<string> private redrawLayout$: Observable<[string, string]> + public favDataEntries$: Observable<DataEntry[]> constructor( private constantService : AtlasViewerConstantsServices, private apiService :AtlasViewerAPIServices, private csf:ComponentFactoryResolver, private store : Store<ViewerStateInterface>, - private elementRef : ElementRef + private elementRef : ElementRef, + public bottomSheet: MatBottomSheet, + private kgSingleDataset: KgSingleDatasetService ){ + this.favDataEntries$ = this.store.pipe( + select('dataStore'), + select('favDataEntries') + ) + this.viewerPerformanceConfig$ = this.store.pipe( select('viewerConfigState'), /** @@ -240,13 +250,9 @@ export class NehubaContainer implements OnInit, OnDestroy{ ) this.userLandmarks$ = this.store.pipe( - /* TODO: distinct until changed */ select('viewerState'), - // filter(state => isDefined(state) && isDefined(state.userLandmarks)), - map(state => isDefined(state) && isDefined(state.userLandmarks) - ? state.userLandmarks - : []), - distinctUntilChanged(userLmUnchanged) + select('userLandmarks'), + distinctUntilChanged() ) this.onHoverSegments$ = this.store.pipe( @@ -536,10 +542,7 @@ export class NehubaContainer implements OnInit, OnDestroy{ ) this.subscriptions.push( - this.userLandmarks$.pipe( - // distinctUntilChanged((old,new) => ) - ).subscribe(landmarks => { - this.userLandmarks = landmarks + this.userLandmarks$.subscribe(landmarks => { if(this.nehubaViewer){ this.nehubaViewer.updateUserLandmarks(landmarks) } @@ -772,11 +775,13 @@ export class NehubaContainer implements OnInit, OnDestroy{ this.subscriptions.push( this.selectedLandmarks$.pipe( - map(lms => lms.map(lm => this.landmarksNameMap.get(lm.name))) + map(lms => lms.map(lm => this.landmarksNameMap.get(lm.name))), + debounceTime(16) ).subscribe(indices => { const filteredIndices = indices.filter(v => typeof v !== 'undefined' && v !== null) - if(this.nehubaViewer) + if(this.nehubaViewer) { this.nehubaViewer.spatialLandmarkSelectionChanged(filteredIndices) + } }) ) } @@ -1050,14 +1055,16 @@ export class NehubaContainer implements OnInit, OnDestroy{ if(!landmarks.every(l => l.position.constructor === Array) || !landmarks.every(l => l.position.every(v => !isNaN(v))) || !landmarks.every(l => l.position.length == 3)) throw new Error('position needs to be a length 3 tuple of numbers ') this.store.dispatch({ - type: USER_LANDMARKS, + type: VIEWERSTATE_ACTION_TYPES.ADD_USERLANDMARKS, landmarks : landmarks }) }, - remove3DLandmarks : ids => { + remove3DLandmarks : landmarkIds => { this.store.dispatch({ - type : USER_LANDMARKS, - landmarks : this.userLandmarks.filter(l => ids.findIndex(id => id === l.id) < 0) + type: VIEWERSTATE_ACTION_TYPES.REMOVE_USER_LANDMARKS, + payload: { + landmarkIds + } }) }, hideSegment : (labelIndex) => { @@ -1236,6 +1243,19 @@ export class NehubaContainer implements OnInit, OnDestroy{ } } + removeFav(event: MouseEvent, ds: DataEntry){ + this.store.dispatch({ + type: DATASETS_ACTIONS_TYPES.UNFAV_DATASET, + payload: ds + }) + } + + downloadDs(event: MouseEvent, ds: DataEntry, downloadBtn: MatButton){ + downloadBtn.disabled = true + const id = getIdFromDataEntry(ds) + this.kgSingleDataset.downloadZipFromKg({kgId: id}) + .finally(() => downloadBtn.disabled = false) + } } export const identifySrcElement = (element:HTMLElement) => { diff --git a/src/ui/nehubaContainer/nehubaContainer.style.css b/src/ui/nehubaContainer/nehubaContainer.style.css index 2d9e1542749aa6d838e172434fd3205e6beda3d3..57a6ad380921978025fdb9692967d716743d5eab 100644 --- a/src/ui/nehubaContainer/nehubaContainer.style.css +++ b/src/ui/nehubaContainer/nehubaContainer.style.css @@ -172,3 +172,8 @@ div#scratch-pad pointer-events: none; } +.load-fav-dataentries-fab +{ + right: 0; + bottom: 0; +} \ No newline at end of file diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index 4a66e0fa605fccbeac175d89aea500954c9cd31b..6ba9a1443ce3e189057eac4e8c347a48fd0a6b52 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -23,6 +23,22 @@ </current-layout> <layout-floating-container *ngIf="viewerLoaded && !isMobile"> + + <!-- tmp fab --> + <div class="m-3 load-fav-dataentries-fab position-absolute pe-all"> + <button + (click)="bottomSheet.open(savedDatasets)" + [matBadge]="(favDataEntries$ | async)?.length > 0 ? (favDataEntries$ | async)?.length : null " + matBadgeColor="accent" + matBadgePosition="above before" + matTooltip="Favourite datasets" + matTooltipPosition="before" + mat-fab + color="primary"> + <i class="fas fa-star"></i> + </button> + </div> + <!-- StatusCard container--> <ui-status-card [selectedTemplate]="selectedTemplate" @@ -106,6 +122,7 @@ </div> </layout-floating-container> </ng-template> + <ng-template #overlayiii> <layout-floating-container pos10 landmarkContainer> <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" @@ -123,6 +140,7 @@ </div> </layout-floating-container> </ng-template> + <ng-template #overlayiv> <layout-floating-container pos11 landmarkContainer> <div *ngIf="perspectiveViewLoading$ | async" class="loadingIndicator"> @@ -132,4 +150,50 @@ </div> </div> </layout-floating-container> +</ng-template> + +<ng-template #savedDatasets> + <mat-list rol="list"> + <h3 mat-subheader>Favourite Datasets</h3> + + <!-- place holder when no fav data is available --> + <mat-card *ngIf="(!(favDataEntries$ | async)) || (favDataEntries$ | async).length === 0"> + <mat-card-content class="muted"> + No dataset favourited... yet. + </mat-card-content> + </mat-card> + + <!-- render all fav dataset as mat list --> + <mat-list-item + class="align-items-center" + *ngFor="let ds of (favDataEntries$ | async)" + role="listitem"> + <span class="flex-grow-1 flex-shrink-1"> + {{ ds.name }} + </span> + + <!-- download --> + <button + #downloadBtn="matButton" + (click)="downloadDs($event, ds, downloadBtn)" + matTooltip="Download Dataset" + matTooltipPosition="after" + color="primary" + class="flex-grow-0 flex-shrink-0" + mat-icon-button> + <i class="fas fa-download"></i> + </button> + + <!-- remove from fav --> + <button + (click)="removeFav($event, ds)" + matTooltip="Remove Favourite" + matTooltipPosition="after" + color="warn" + class="flex-grow-0 flex-shrink-0" + mat-icon-button> + <i class="fas fa-trash"></i> + </button> + </mat-list-item> + </mat-list> </ng-template> \ No newline at end of file diff --git a/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts b/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts index eb27393a77f1c284743cc34314d09db21040946f..22fd3655e72c0553f62a90716301cbac92f26723 100644 --- a/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts +++ b/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts @@ -1,10 +1,11 @@ -import { Component } from "@angular/core"; -import { Observable } from "rxjs"; +import { Component, Pipe, PipeTransform, ElementRef, ViewChild, AfterViewInit } from "@angular/core"; +import { Observable, fromEvent, Subscription, Subject } from "rxjs"; import { Store, select } from "@ngrx/store"; -import { filter,map } from 'rxjs/operators' +import { switchMap, bufferTime, take, filter, withLatestFrom, map, tap } from 'rxjs/operators' import { ViewerStateInterface, NEWVIEWER } from "../../../services/stateStore.service"; import { AtlasViewerConstantsServices } from "../../../atlasViewer/atlasViewer.constantService.service"; + @Component({ selector : 'ui-splashscreen', templateUrl : './splashScreen.template.html', @@ -13,17 +14,52 @@ import { AtlasViewerConstantsServices } from "../../../atlasViewer/atlasViewer.c ] }) -export class SplashScreen{ - loadedTemplate$ : Observable<any[]> +export class SplashScreen implements AfterViewInit{ + + public loadedTemplate$ : Observable<any[]> + @ViewChild('parentContainer', {read:ElementRef}) + private parentContainer: ElementRef + private activatedTemplate$: Subject<any> = new Subject() + + private subscriptions: Subscription[] = [] + constructor( private store:Store<ViewerStateInterface>, private constanceService: AtlasViewerConstantsServices, private constantsService: AtlasViewerConstantsServices, -){ + ){ this.loadedTemplate$ = this.store.pipe( select('viewerState'), - filter((state:ViewerStateInterface)=> typeof state !== 'undefined' && typeof state.fetchedTemplates !== 'undefined' && state.fetchedTemplates !== null), - map(state=>state.fetchedTemplates)) + select('fetchedTemplates') + ) + } + + ngAfterViewInit(){ + + /** + * instead of blindly listening to click event, this event stream waits to see if user mouseup within 200ms + * if yes, it is interpreted as a click + * if no, user may want to select a text + */ + this.subscriptions.push( + fromEvent(this.parentContainer.nativeElement, 'mousedown').pipe( + switchMap(() => fromEvent(this.parentContainer.nativeElement, 'mouseup').pipe( + bufferTime(200), + take(1) + )), + filter(arr => arr.length > 0), + withLatestFrom(this.activatedTemplate$), + map(([_, template]) => template) + ).subscribe(template => this.selectTemplate(template)) + ) + } + + selectTemplateParcellation(template, parcellation){ + this.store.dispatch({ + type : NEWVIEWER, + selectTemplate : template, + selectParcellation : parcellation + }) } selectTemplate(template:any){ @@ -38,11 +74,31 @@ export class SplashScreen{ return this.constanceService.templateUrls.length } - correctString(name){ - return name.replace(/[|&;$%@()+,\s./]/g, '') - } - get isMobile(){ return this.constantsService.mobile } -} \ No newline at end of file +} + +@Pipe({ + name: 'getTemplateImageSrcPipe' +}) + +export class GetTemplateImageSrcPipe implements PipeTransform{ + public transform(name:string):string{ + return `./res/image/${name.replace(/[|&;$%@()+,\s./]/g, '')}.png` + } +} + +@Pipe({ + name: 'imgSrcSetPipe' +}) + +export class ImgSrcSetPipe implements PipeTransform{ + public transform(src:string):string{ + const regex = /^(.*?)(\.\w*?)$/.exec(src) + if (!regex) throw new Error(`cannot find filename, ext ${src}`) + const filename = regex[1] + const ext = regex[2] + return [100, 200, 300, 400].map(val => `${filename}-${val}${ext} ${val}w`).join(',') + } +} \ No newline at end of file diff --git a/src/ui/nehubaContainer/splashScreen/splashScreen.style.css b/src/ui/nehubaContainer/splashScreen/splashScreen.style.css index cea1195130e1a729c44170d5e47fe742595ff3c3..49774d56632717713c75fc404f681049c0e06e5c 100644 --- a/src/ui/nehubaContainer/splashScreen/splashScreen.style.css +++ b/src/ui/nehubaContainer/splashScreen/splashScreen.style.css @@ -1,61 +1,11 @@ -.appendMargin +:host { - padding-top:10em; + display: block; + overflow: auto; + height: 100%; } -.splashScreenHeaderTitle { - font-size: 45px; -} - -div[splashScreenTemplateItem] { - max-width: 600px; - width: 400px; - margin: 20px 20px; - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: center; - -} - -div[splashScreenTemplateHeader] { - display: flex; - justify-content: center; - align-items: center; - height: 70px; - align-self: center; - text-align: center; - font-size: 30px; - margin: 5px 0; - -} - -.template-image { - width: 100%; - height: auto; -} - -.template-card { - width: 100%; - cursor: pointer; - background: #fff; - border-radius: 2px; - box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); - transition: all 0.3s cubic-bezier(.25,.8,.25,1); -} - -.template-card:hover { - box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 5px 5px rgba(0,0,0,0.22); -} - -@media screen and (max-width: 670px) { - .splashScreenHeaderTitle { - visibility: hidden; - } - div[splashScreenTemplate] { - margin: 20px 20px; - } - div[splashScreenTemplateBody] { - flex-direction: column; - } +.font-stretch +{ + font-stretch: extra-condensed; } \ No newline at end of file diff --git a/src/ui/nehubaContainer/splashScreen/splashScreen.template.html b/src/ui/nehubaContainer/splashScreen/splashScreen.template.html index 28ec10df8e43de608f367da127f8ee292524c48a..a63adbeb71157ff8282a9abda4d5e30e58d79924 100644 --- a/src/ui/nehubaContainer/splashScreen/splashScreen.template.html +++ b/src/ui/nehubaContainer/splashScreen/splashScreen.template.html @@ -1,20 +1,43 @@ -<div [ngClass]="isMobile ? '' : 'appendMargin'" class="h-100 d-flex flex-column justify-content-start align-items-center overflow-auto"> +<div + #parentContainer + class="m-5 d-flex flex-row flex-wrap justify-content-center align-items-stretch pe-none"> + <mat-card + (mousedown)="activatedTemplate$.next(template)" + matRipple + *ngFor="let template of loadedTemplate$ | async | filterNull" + class="m-3 col-md-12 col-lg-6 pe-all mw-400px"> + <mat-card-header> + <mat-card-title class="text-nowrap font-stretch"> + {{ template.properties.name }} + </mat-card-title> + </mat-card-header> + <img + [src]="template.properties.name | getTemplateImageSrcPipe" + [srcset]="template.properties.name | getTemplateImageSrcPipe | imgSrcSetPipe" + sizes="(max-width:576px) 90vw;(max-width: 768px) 50vw; 400px" + [alt]="'Screenshot of ' + template.properties.name" + mat-card-image /> + <mat-card-content> + {{ template.properties.description }} + </mat-card-content> - <div class="d-flex w-100 flex-wrap justify-content-center"> - <div *ngFor="let template of loadedTemplate$ | async | filterNull" splashScreenTemplateItem> - <div class="template-card" (click) = "selectTemplate(template)"> - <div splashScreenTemplateHeader> - {{template.properties.name}} - </div> - <div class="d-flex flex-column"> - <div class="flex-grow-1"> - <img class="template-image" [src]="'./res/image/' + correctString(template.properties.name) + '.png'"> - </div> - <div class="flex-grow-1 text-justify ml-2 mr-2 mb-2 mt-0"> - {{template.properties.description}} - </div> - </div> - </div> - </div> - </div> + <mat-card-content> + <mat-card-subtitle class="text-nowrap"> + Parcellations available + </mat-card-subtitle> + <button + (mousedown)="$event.stopPropagation()" + (click)="$event.stopPropagation(); selectTemplateParcellation(template, parcellation)" + *ngFor="let parcellation of template.parcellations" + mat-button + color="primary"> + {{ parcellation.name }} + </button> + </mat-card-content> + + <!-- required... or on ripple, angular adds 16px margin to the bottom --> + <!-- see https://github.com/angular/components/issues/10898 --> + <mat-card-footer> + </mat-card-footer> + </mat-card> </div> \ No newline at end of file diff --git a/src/ui/regionHierachy/regionHierarchy.style.css b/src/ui/regionHierachy/regionHierarchy.style.css deleted file mode 100644 index a4aa507f4db1a32886dc1309934dc8481d49a425..0000000000000000000000000000000000000000 --- a/src/ui/regionHierachy/regionHierarchy.style.css +++ /dev/null @@ -1,65 +0,0 @@ - -div[treeContainer] -{ - padding:1em; - z-index: 3; - - height:20em; - width: calc(100% + 4em); - overflow-y:auto; - overflow-x:hidden; - - /* color:white; - background-color:rgba(12,12,12,0.8); */ -} - -:host-context([darktheme="false"]) div[treeContainer] -{ - background-color:rgba(240,240,240,0.8); -} - -:host-context([darktheme="true"]) div[treeContainer] -{ - background-color:rgba(30,30,30,0.8); - color:rgba(255,255,255,1.0); -} - -div[hideScrollbarcontainer] -{ - width: 20em; - overflow:hidden; - margin-top:2px; -} - -input[type="text"] -{ - border:none; -} - -:host-context([darktheme="false"]) input[type="text"] -{ - background-color:rgba(245,245,245,0.85); - box-shadow: inset 0 4px 6px 0 rgba(5,5,5,0.1); -} - -:host-context([darktheme="true"]) input[type="text"] -{ - background-color:rgba(30,30,30,0.85); - box-shadow: inset 0 4px 6px 0 rgba(0,0,0,0.2); - color:rgba(255,255,255,0.8); -} - -.regionSearch -{ - width:20em; -} - -.tree-header -{ - flex: 0 0 auto; -} - -.tree-body -{ - flex: 1 1 auto; -} \ No newline at end of file diff --git a/src/ui/sharedModules/angularMaterial.module.ts b/src/ui/sharedModules/angularMaterial.module.ts index 7c501dee8f70df574ef14e530791f88694130683..9967e80d64279a7ef9e9b760a1c784593926c3b3 100644 --- a/src/ui/sharedModules/angularMaterial.module.ts +++ b/src/ui/sharedModules/angularMaterial.module.ts @@ -5,13 +5,63 @@ import { MatCardModule, MatTabsModule, MatTooltipModule, + MatBadgeModule, + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, MatSlideToggleModule, - MatDialogModule, + MatRippleModule + } from '@angular/material'; import { NgModule } from '@angular/core'; +/** + * TODO should probably be in src/util + */ + @NgModule({ - imports: [MatDialogModule, MatSlideToggleModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], - exports: [MatDialogModule, MatSlideToggleModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], + imports: [ + MatButtonModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatBadgeModule, + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule, + MatRippleModule + ], + exports: [ + MatButtonModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatBadgeModule, + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule, + MatRippleModule + ], }) export class AngularMaterialModule { } \ No newline at end of file diff --git a/src/ui/signinBanner/signinBanner.components.ts b/src/ui/signinBanner/signinBanner.components.ts index e5db9cc39536c5d28338f794005dc9e296d91599..abd1eb3397f1c1a3e7ada69d3ead390f29bfbed7 100644 --- a/src/ui/signinBanner/signinBanner.components.ts +++ b/src/ui/signinBanner/signinBanner.components.ts @@ -1,19 +1,8 @@ -import {Component, ChangeDetectionStrategy, OnDestroy, OnInit, Input, ViewChild, TemplateRef, ElementRef } from "@angular/core"; +import {Component, ChangeDetectionStrategy, Input, TemplateRef } from "@angular/core"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { AuthService, User } from "src/services/auth.service"; -import { Store, select } from "@ngrx/store"; -import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; -import { Subscription, Observable, merge, Subject, combineLatest } from "rxjs"; -import { safeFilter, isDefined, NEWVIEWER, SELECT_REGIONS, SELECT_PARCELLATION, CHANGE_NAVIGATION } from "src/services/stateStore.service"; -import { map, filter, distinctUntilChanged, bufferTime, delay, share, tap, withLatestFrom } from "rxjs/operators"; -import { regionFlattener } from "src/util/regionFlattener"; -import { ToastService } from "src/services/toastService.service"; -import { getSchemaIdFromName } from "src/util/pipes/templateParcellationDecoration.pipe"; import { MatDialog } from "@angular/material"; -const compareParcellation = (o, n) => !o || !n - ? false - : o.name === n.name @Component({ selector: 'signin-banner', @@ -25,198 +14,18 @@ const compareParcellation = (o, n) => !o || !n changeDetection: ChangeDetectionStrategy.OnPush }) -export class SigninBanner implements OnInit, OnDestroy{ +export class SigninBanner{ - public compareParcellation = compareParcellation - - private subscriptions: Subscription[] = [] - - public loadedTemplates$: Observable<any[]> - - public selectedTemplate$: Observable<any> - public selectedParcellation$: Observable<any> - - public selectedRegions$: Observable<any[]> - private selectedRegions: any[] = [] @Input() darktheme: boolean - @ViewChild('publicationTemplate', {read:TemplateRef}) publicationTemplate: TemplateRef<any> - @ViewChild('settingBtn', {read: ElementRef}) settingBtn: ElementRef - - public focusedDatasets$: Observable<any[]> - private userFocusedDataset$: Subject<any> = new Subject() - public focusedDatasets: any[] = [] - private dismissToastHandler: () => void + public isMobile: boolean constructor( private constantService: AtlasViewerConstantsServices, private authService: AuthService, - private store: Store<ViewerConfiguration>, - private toastService: ToastService, private dialog: MatDialog ){ - this.loadedTemplates$ = this.store.pipe( - select('viewerState'), - safeFilter('fetchedTemplates'), - map(state => state.fetchedTemplates) - ) - - this.selectedTemplate$ = this.store.pipe( - select('viewerState'), - select('templateSelected'), - filter(v => !!v), - distinctUntilChanged((o, n) => o.name === n.name), - ) - - this.selectedParcellation$ = this.store.pipe( - select('viewerState'), - select('parcellationSelected'), - filter(v => !!v) - ) - - this.selectedRegions$ = this.store.pipe( - select('viewerState'), - safeFilter('regionsSelected'), - map(state => state.regionsSelected), - distinctUntilChanged((arr1, arr2) => arr1.length === arr2.length && (arr1 as any[]).every((item, index) => item.name === arr2[index].name)) - ) - - this.focusedDatasets$ = this.userFocusedDataset$.pipe( - filter(v => !!v), - withLatestFrom( - combineLatest(this.selectedTemplate$, this.selectedParcellation$) - ), - ).pipe( - map(([userFocusedDataset, [selectedTemplate, selectedParcellation]]) => { - const { type, ...rest } = userFocusedDataset - if (type === 'template') return { ...selectedTemplate, ...rest} - if (type === 'parcellation') return { ...selectedParcellation, ...rest } - return { ...rest } - }), - bufferTime(100), - filter(arr => arr.length > 0), - /** - * merge properties field with the root level - * with the prop in properties taking priority - */ - map(arr => arr.map(item => { - const { properties } = item - return { - ...item, - ...properties - } - })), - share() - ) - } - - ngOnInit(){ - - this.subscriptions.push( - this.selectedRegions$.subscribe(regions => { - this.selectedRegions = regions - }) - ) - - this.subscriptions.push( - this.focusedDatasets$.subscribe(() => { - if (this.dismissToastHandler) this.dismissToastHandler() - }) - ) - - this.subscriptions.push( - this.focusedDatasets$.pipe( - /** - * creates the illusion that the toast complete disappears before reappearing - */ - delay(100) - ).subscribe(arr => { - this.focusedDatasets = arr - this.dismissToastHandler = this.toastService.showToast(this.publicationTemplate, { - dismissable: true, - timeout:7000 - }) - }) - ) - } - - ngOnDestroy(){ - this.subscriptions.forEach(s => s.unsubscribe()) - } - - changeTemplate({ current, previous }){ - if (previous && current && current.name === previous.name) return - this.store.dispatch({ - type: NEWVIEWER, - selectTemplate: current, - selectParcellation: current.parcellations[0] - }) - } - - changeParcellation({ current, previous }){ - const { ngId: prevNgId} = previous - const { ngId: currNgId} = current - if (prevNgId === currNgId) return - this.store.dispatch({ - type: SELECT_PARCELLATION, - selectParcellation: current - }) - } - - // TODO handle mobile - handleRegionClick({ mode = 'single', region }){ - if (!region) return - - /** - * single click on region hierarchy => toggle selection - */ - if (mode === 'single') { - const flattenedRegion = regionFlattener(region).filter(r => isDefined(r.labelIndex)) - const flattenedRegionNames = new Set(flattenedRegion.map(r => r.name)) - const selectedRegionNames = new Set(this.selectedRegions.map(r => r.name)) - const selectAll = flattenedRegion.every(r => !selectedRegionNames.has(r.name)) - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: selectAll - ? this.selectedRegions.concat(flattenedRegion) - : this.selectedRegions.filter(r => !flattenedRegionNames.has(r.name)) - }) - } - - /** - * double click on region hierarchy => navigate to region area if it exists - */ - if (mode === 'double') { - - /** - * if position is defined, go to position (in nm) - * if not, show error messagea s toast - * - * nb: currently, only supports a single triplet - */ - if (region.position) { - this.store.dispatch({ - type: CHANGE_NAVIGATION, - navigation: { - position: region.position, - animation: {} - } - }) - } else { - this.toastService.showToast(`${region.name} does not have a position defined`, { - timeout: 5000, - dismissable: true - }) - } - } - } - - displayActiveParcellation(parcellation:any){ - return `<div class="d-flex"><small>Parcellation</small> <small class = "flex-grow-1 mute-text">${parcellation ? '(' + parcellation.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` - } - - displayActiveTemplate(template: any) { - return `<div class="d-flex"><small>Template</small> <small class = "flex-grow-1 mute-text">${template ? '(' + template.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + this.isMobile = this.constantService.mobile } /** @@ -242,56 +51,7 @@ export class SigninBanner implements OnInit, OnDestroy{ this.constantService.showSigninSubject$.next(this.user) } - clearAllRegions(){ - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: [] - }) - } - - handleActiveDisplayBtnClicked(event, type: 'parcellation' | 'template'){ - const { - extraBtn, - event: extraBtnClickEvent - } = event - - const { name } = extraBtn - const { kgSchema, kgId } = getSchemaIdFromName(name) - - this.userFocusedDataset$.next({ - kgSchema, - kgId, - type - }) - } - - handleExtraBtnClicked(event, toastType: 'parcellation' | 'template'){ - const { - extraBtn, - inputItem, - event: extraBtnClickEvent - } = event - - const { name } = extraBtn - const { kgSchema, kgId } = getSchemaIdFromName(name) - - this.userFocusedDataset$.next({ - ...inputItem, - kgSchema, - kgId - }) - - extraBtnClickEvent.stopPropagation() - } - - get isMobile(){ - return this.constantService.mobile - } - get user() : User | null { return this.authService.user } - - public flexItemIsMobileClass = 'mt-2' - public flexItemIsDesktopClass = 'mr-2' } \ No newline at end of file diff --git a/src/ui/signinBanner/signinBanner.template.html b/src/ui/signinBanner/signinBanner.template.html index 00cdd6cf385fab29210487a5868c1fcd85ea7324..1da0fb01da873392fbe79901047520aaff3bf699 100644 --- a/src/ui/signinBanner/signinBanner.template.html +++ b/src/ui/signinBanner/signinBanner.template.html @@ -2,48 +2,6 @@ class="d-flex" [ngClass]="{ 'flex-column w-100 align-items-stretch' : isMobile}" > - <dropdown-component - (itemSelected)="changeTemplate($event)" - [checkSelected]="compareParcellation" - [activeDisplay]="displayActiveTemplate" - [selectedItem]="selectedTemplate$ | async" - [inputArray]="loadedTemplates$ | async | filterNull | appendTooltipTextPipe" - [ngClass]="isMobile ? flexItemIsMobileClass : flexItemIsDesktopClass" - (extraBtnClicked)="handleExtraBtnClicked($event, 'template')" - [activeDisplayBtns]="(selectedTemplate$ | async | templateParcellationsDecorationPipe)?.extraButtons" - (activeDisplayBtnClicked)="handleActiveDisplayBtnClicked($event, 'template')" - (listItemButtonClicked)="handleExtraBtnClicked($event, 'template')"> - </dropdown-component> - - <ng-container *ngIf="selectedTemplate$ | async as selectedTemplate"> - <dropdown-component - *ngIf="selectedParcellation$ | async as selectedParcellation" - (itemSelected)="changeParcellation($event)" - [checkSelected]="compareParcellation" - [activeDisplay]="displayActiveParcellation" - [selectedItem]="selectedParcellation" - [inputArray]="selectedTemplate.parcellations | appendTooltipTextPipe" - [ngClass]="isMobile ? flexItemIsMobileClass : flexItemIsDesktopClass" - (extraBtnClicked)="handleExtraBtnClicked($event, 'parcellation')" - [activeDisplayBtns]="(selectedParcellation | templateParcellationsDecorationPipe)?.extraButtons" - (activeDisplayBtnClicked)="handleActiveDisplayBtnClicked($event, 'parcellation')" - (listItemButtonClicked)="handleExtraBtnClicked($event, 'parcellation')"> - - </dropdown-component> - <region-hierarchy - [selectedRegions]="selectedRegions$ | async | filterNull" - (singleClickRegion)="handleRegionClick({ mode: 'single', region: $event })" - (doubleClickRegion)="handleRegionClick({ mode: 'double', region: $event })" - (clearAllRegions)="clearAllRegions()" - [isMobile] = "isMobile" - *ngIf="selectedParcellation$ | async as selectedParcellation" - class="h-0" - [selectedParcellation]="selectedParcellation" - [ngClass]="isMobile ? flexItemIsMobileClass : flexItemIsDesktopClass"> - - </region-hierarchy> - </ng-container> - <!-- help btn --> <div class="btnWrapper"> <button @@ -84,27 +42,3 @@ </button> </div> </div> - -<!-- TODO somehow, using async pipe here does not work --> -<!-- maybe have something to do with bufferTime, and that it replays from the beginning? --> -<ng-template #publicationTemplate> - <single-dataset-view - *ngFor="let focusedDataset of focusedDatasets" - [name]="focusedDataset.name" - [description]="focusedDataset.description" - [publications]="focusedDataset.publications" - [kgSchema]="focusedDataset.kgSchema" - [kgId]="focusedDataset.kgId"> - - </single-dataset-view> -</ng-template> - -<ng-template #settingTemplate> - <h2 mat-dialog-title>Settings</h2> - <mat-dialog-content> - <!-- required to avoid showing an ugly vertical scroll bar --> - <!-- TODO investigate why, then remove the friller class --> - <config-component class="mb-4 d-block"> - </config-component> - </mat-dialog-content> -</ng-template> \ No newline at end of file diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 52cedbda807d3d8a655e346495d87829fbf26b90..ae129d6220403450c0ebc00cff530c85e1cf842c 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -3,9 +3,9 @@ import { ComponentsModule } from "../components/components.module"; import { NehubaViewerUnit } from "./nehubaContainer/nehubaViewer/nehubaViewer.component"; import { NehubaContainer } from "./nehubaContainer/nehubaContainer.component"; -import { SplashScreen } from "./nehubaContainer/splashScreen/splashScreen.component"; +import { SplashScreen, GetTemplateImageSrcPipe, ImgSrcSetPipe } from "./nehubaContainer/splashScreen/splashScreen.component"; import { LayoutModule } from "../layouts/layout.module"; -import { FormsModule } from "@angular/forms"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { GroupDatasetByRegion } from "../util/pipes/groupDataEntriesByRegion.pipe"; import { filterRegionDataEntries } from "../util/pipes/filterRegionDataEntries.pipe"; @@ -17,7 +17,7 @@ import { LandmarkUnit } from "./nehubaContainer/landmarkUnit/landmarkUnit.compon import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; import { PluginBannerUI } from "./pluginBanner/pluginBanner.component"; import { CitationsContainer } from "./citation/citations.component"; -import { LayerBrowser } from "./layerbrowser/layerbrowser.component"; +import { LayerBrowser, LockedLayerBtnClsPipe } from "./layerbrowser/layerbrowser.component"; import { TooltipModule } from "ngx-bootstrap/tooltip"; import { KgEntryViewer } from "./kgEntryViewer/kgentry.component"; import { SubjectViewer } from "./kgEntryViewer/subjectViewer/subjectViewer.component"; @@ -38,42 +38,44 @@ import { PopoverModule } from 'ngx-bootstrap/popover' import { DatabrowserModule } from "./databrowserModule/databrowser.module"; import { SigninBanner } from "./signinBanner/signinBanner.components"; import { SigninModal } from "./signinModal/signinModal.component"; -import { FilterNgLayer } from "src/util/pipes/filterNgLayer.pipe"; import { UtilModule } from "src/util/util.module"; -import { RegionHierarchy } from "./regionHierachy/regionHierarchy.component"; -import { FilterNameBySearch } from "./regionHierachy/filterNameBySearch.pipe"; +import { RegionHierarchy } from "./viewerStateController/regionHierachy/regionHierarchy.component"; +import { FilterNameBySearch } from "./viewerStateController/regionHierachy/filterNameBySearch.pipe"; import { StatusCardComponent } from "./nehubaContainer/statusCard/statusCard.component"; import { CookieAgreement } from "./cookieAgreement/cookieAgreement.component"; import { KGToS } from "./kgtos/kgtos.component"; import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module' import { TemplateParcellationsDecorationPipe } from "src/util/pipes/templateParcellationDecoration.pipe"; import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.pipe"; -import { MatSliderModule, MatRippleModule } from "@angular/material"; import { FourPanelLayout } from "./config/layouts/fourPanel/fourPanel.component"; import { HorizontalOneThree } from "./config/layouts/h13/h13.component"; import { VerticalOneThree } from "./config/layouts/v13/v13.component"; import { SinglePanel } from "./config/layouts/single/single.component"; -import { DragDropModule } from "@angular/cdk/drag-drop"; import { CurrentLayout } from "./config/currentLayout/currentLayout.component"; import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControlNubStyle.pipe"; +import { ScrollingModule } from "@angular/cdk/scrolling" +import { HttpClientModule } from "@angular/common/http"; +import { GetFilenamePipe } from "src/util/pipes/getFilename.pipe"; +import { GetFileExtension } from "src/util/pipes/getFileExt.pipe"; +import { ViewerStateController } from "./viewerStateController/viewerState.component"; +import { BinSavedRegionsSelectionPipe, SavedRegionsSelectionBtnDisabledPipe } from "./viewerStateController/viewerState.pipes"; +import { RegionTextSearchAutocomplete } from "./viewerStateController/regionSearch/regionSearch.component"; +import { PluginBtnFabColorPipe } from "src/util/pipes/pluginBtnFabColor.pipe"; +import { KgSearchBtnColorPipe } from "src/util/pipes/kgSearchBtnColor.pipe"; @NgModule({ imports : [ + HttpClientModule, FormsModule, + ReactiveFormsModule, LayoutModule, ComponentsModule, DatabrowserModule, UtilModule, + ScrollingModule, AngularMaterialModule, - /** - * move to angular material module - */ - MatSliderModule, - DragDropModule, - MatRippleModule, - PopoverModule.forRoot(), TooltipModule.forRoot() ], @@ -104,6 +106,8 @@ import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControl VerticalOneThree, SinglePanel, CurrentLayout, + ViewerStateController, + RegionTextSearchAutocomplete, /* pipes */ GroupDatasetByRegion, @@ -115,11 +119,19 @@ import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControl SortDataEntriesToRegion, SpatialLandmarksToDataBrowserItemPipe, FilterNullPipe, - FilterNgLayer, FilterNameBySearch, TemplateParcellationsDecorationPipe, AppendtooltipTextPipe, MobileControlNubStylePipe, + GetTemplateImageSrcPipe, + ImgSrcSetPipe, + PluginBtnFabColorPipe, + KgSearchBtnColorPipe, + LockedLayerBtnClsPipe, + GetFilenamePipe, + GetFileExtension, + BinSavedRegionsSelectionPipe, + SavedRegionsSelectionBtnDisabledPipe, /* directive */ DownloadDirective, @@ -130,7 +142,7 @@ import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControl /* dynamically created components needs to be declared here */ NehubaViewerUnit, LayerBrowser, - PluginBannerUI + PluginBannerUI, ], exports : [ SubjectViewer, diff --git a/src/ui/regionHierachy/filterNameBySearch.pipe.ts b/src/ui/viewerStateController/regionHierachy/filterNameBySearch.pipe.ts similarity index 100% rename from src/ui/regionHierachy/filterNameBySearch.pipe.ts rename to src/ui/viewerStateController/regionHierachy/filterNameBySearch.pipe.ts diff --git a/src/ui/regionHierachy/regionHierarchy.component.ts b/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts similarity index 75% rename from src/ui/regionHierachy/regionHierarchy.component.ts rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts index f8b4fd35dba949a3b4fc7620ad67309a41f0f7c4..2fd52a746bd58437785353e315e68bcdf2b6f2a9 100644 --- a/src/ui/regionHierachy/regionHierarchy.component.ts +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts @@ -1,6 +1,6 @@ import { EventEmitter, Component, ElementRef, ViewChild, HostListener, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, Input, Output, AfterViewInit } from "@angular/core"; import { Subscription, Subject, fromEvent } from "rxjs"; -import { buffer, debounceTime } from "rxjs/operators"; +import { buffer, debounceTime, tap } from "rxjs/operators"; import { FilterNameBySearch } from "./filterNameBySearch.pipe"; import { generateLabelIndexId } from "src/services/stateStore.service"; @@ -17,8 +17,8 @@ const getDisplayTreeNode : (searchTerm:string, selectedRegions:any[]) => (item:a && selectedRegions.findIndex(re => generateLabelIndexId({ labelIndex: re.labelIndex, ngId: re.ngId }) === generateLabelIndexId({ ngId, labelIndex }) ) >= 0 - ? `<span class="regionSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) - : `<span class="regionNotSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) + ? `<span class="cursor-default regionSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) + : `<span class="cursor-default regionNotSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) } const getFilterTreeBySearch = (pipe:FilterNameBySearch, searchTerm:string) => (node:any) => pipe.transform([node.name, node.status], searchTerm) @@ -38,9 +38,7 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ public selectedRegions: any[] = [] @Input() - public selectedParcellation: any - - @Input() isMobile: boolean; + public parcellationSelected: any private _showRegionTree: boolean = false @@ -54,7 +52,7 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ private doubleClickRegion: EventEmitter<any> = new EventEmitter() @Output() - private clearAllRegions: EventEmitter<null> = new EventEmitter() + private clearAllRegions: EventEmitter<MouseEvent> = new EventEmitter() public searchTerm: string = '' private subscriptions: Subscription[] = [] @@ -62,6 +60,8 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ @ViewChild('searchTermInput', {read: ElementRef}) private searchTermInput: ElementRef + public placeHolderText: string = `Start by selecting a template and a parcellation.` + /** * set the height to max, bound by max-height */ @@ -95,17 +95,19 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ } ngOnChanges(){ - this.aggregatedRegionTree = { - name: this.selectedParcellation.name, - children: this.selectedParcellation.regions + if (this.parcellationSelected) { + this.placeHolderText = `Search region in ${this.parcellationSelected.name}` + this.aggregatedRegionTree = { + name: this.parcellationSelected.name, + children: this.parcellationSelected.regions + } } this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) } clearRegions(event:MouseEvent){ - event.stopPropagation() - this.clearAllRegions.emit() + this.clearAllRegions.emit(event) } get showRegionTree(){ @@ -133,20 +135,6 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ } ngAfterViewInit(){ - /** - * TODO - * bandaid fix on - * when region search loses focus, the searchTerm is cleared, - * but hierarchy filter does not reset - */ - this.subscriptions.push( - fromEvent(this.searchTermInput.nativeElement, 'focus').pipe( - - ).subscribe(() => { - this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) - this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) - }) - ) this.subscriptions.push( fromEvent(this.searchTermInput.nativeElement, 'input').pipe( debounceTime(200) @@ -156,13 +144,6 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ ) } - getInputPlaceholder(parcellation:any) { - if (parcellation) - return `Search region in ${parcellation.name}` - else - return `Start by selecting a template and a parcellation.` - } - escape(event:KeyboardEvent){ this.showRegionTree = false this.searchTerm = ''; @@ -182,27 +163,12 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ }) } - focusInput(event?:MouseEvent){ - if (event) { - /** - * need to stop propagation, or @closeRegion will be triggered - */ - event.stopPropagation() - } - this.searchTermInput.nativeElement.focus() - this.showRegionTree = true - } - /* NB need to bind two way data binding like this. Or else, on searchInput blur, the flat tree will be rebuilt, resulting in first click to be ignored */ changeSearchTerm(event: any) { - if (event.target.value === this.searchTerm) - return + if (event.target.value === this.searchTerm) return this.searchTerm = event.target.value - /** - * TODO maybe introduce debounce - */ this.ngOnChanges() this.cdr.markForCheck() } @@ -214,18 +180,17 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ /** * TODO figure out why @closeRegion gets triggered, but also, contains returns false */ - if (event) + if (event) { event.stopPropagation() + } this.handleRegionTreeClickSubject.next(obj) } /* single click selects/deselects region(s) */ private singleClick(obj: any) { - if (!obj) - return + if (!obj) return const { inputItem : region } = obj - if (!region) - return + if (!region) return this.singleClickRegion.emit(region) } diff --git a/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css b/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css new file mode 100644 index 0000000000000000000000000000000000000000..372068bbf85ed6be2b7f1f2ae06fb467f7f4a701 --- /dev/null +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css @@ -0,0 +1,57 @@ + +div[treeContainer] +{ + padding:1em; + z-index: 3; + + height:20em; + width: calc(100% + 4em); + overflow-y:auto; + overflow-x:hidden; + + /* color:white; + background-color:rgba(12,12,12,0.8); */ +} + +div[hideScrollbarcontainer] +{ + overflow:hidden; + margin-top:2px; +} + +input[type="text"] +{ + border:none; +} + + +.regionSearch +{ + width:20em; +} + +.tree-header +{ + flex: 0 0 auto; +} + +.tree-body +{ + flex: 1 1 auto; +} + +:host +{ + display: flex; + flex-direction: column; +} + +:host > mat-form-field +{ + flex: 0 0 auto; +} + +:host > [hideScrollbarContainer] +{ + flex: 1 1 0; +} \ No newline at end of file diff --git a/src/ui/regionHierachy/regionHierarchy.template.html b/src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html similarity index 69% rename from src/ui/regionHierachy/regionHierarchy.template.html rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html index 91d9f552cb194227a8e4462ed6e9b82868e3f40c..5a55fdc8c328c231048001c9340687ebdf890f3b 100644 --- a/src/ui/regionHierachy/regionHierarchy.template.html +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html @@ -1,24 +1,18 @@ -<div class="input-group regionSearch"> +<mat-form-field class="w-100"> <input #searchTermInput - tabindex="0" + matInput (keydown.esc)="escape($event)" - (focus)="showRegionTree = true && !isMobile" + (focus)="showRegionTree = true" [value]="searchTerm" - class="form-control form-control-sm" type="text" autocomplete="off" - [placeholder]="getInputPlaceholder(selectedParcellation)"/> - -</div> + [placeholder]="placeHolderText"/> +</mat-form-field> -<div - *ngIf="showRegionTree" - hideScrollbarContainer> - +<div hideScrollbarContainer> <div - [ngStyle]="regionHierarchyHeight()" - class="d-flex flex-column" + class="d-flex flex-column h-100" treeContainer #treeContainer> <div class="tree-header d-inline-flex align-items-center"> @@ -34,7 +28,7 @@ </div> <div - *ngIf="selectedParcellation && selectedParcellation.regions as regions" + *ngIf="parcellationSelected && parcellationSelected.regions as regions" class="tree-body"> <flat-tree-component (treeNodeClick)="handleClickRegion($event)" diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.component.ts b/src/ui/viewerStateController/regionSearch/regionSearch.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0e1783234cccfc6c9101b1f0fdc3290d8dea209 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.component.ts @@ -0,0 +1,149 @@ +import { Component, EventEmitter, Output, ViewChild, ElementRef, TemplateRef } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable } from "rxjs"; +import { map, distinctUntilChanged, startWith, withLatestFrom, filter, debounceTime, tap, share, shareReplay, take } from "rxjs/operators"; +import { getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId } from "src/services/stateStore.service"; +import { FormControl } from "@angular/forms"; +import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger, MatDialog } from "@angular/material"; +import { ADD_TO_REGIONS_SELECTION_WITH_IDS, SELECT_REGIONS } from "src/services/state/viewerState.store"; +import { VIEWERSTATE_ACTION_TYPES } from "../viewerState.component"; + +const filterRegionBasedOnText = searchTerm => region => region.name.toLowerCase().includes(searchTerm.toLowerCase()) + +@Component({ + selector: 'region-text-search-autocomplete', + templateUrl: './regionSearch.template.html', + styleUrls: [ + './regionSearch.style.css' + ] +}) + +export class RegionTextSearchAutocomplete{ + + @ViewChild('autoTrigger', {read: ElementRef}) autoTrigger: ElementRef + @ViewChild('regionHierarchy', {read:TemplateRef}) regionHierarchyTemplate: TemplateRef<any> + constructor( + private store$: Store<any>, + private dialog: MatDialog, + ){ + + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.regionsWithLabelIndex$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + map(parcellationSelected => { + const returnArray = [] + const ngIdMap = getMultiNgIdsRegionsLabelIndexMap(parcellationSelected) + for (const [ngId, labelIndexMap] of ngIdMap) { + for (const [labelIndex, region] of labelIndexMap){ + returnArray.push({ + ...region, + ngId, + labelIndex, + labelIndexId: generateLabelIndexId({ ngId, labelIndex }) + }) + } + } + return returnArray + }) + ) + + this.autocompleteList$ = this.formControl.valueChanges.pipe( + startWith(''), + debounceTime(200), + withLatestFrom(this.regionsWithLabelIndex$.pipe( + startWith([]) + )), + map(([searchTerm, regionsWithLabelIndex]) => regionsWithLabelIndex.filter(filterRegionBasedOnText(searchTerm))), + map(arr => arr.slice(0, 5)) + ) + + this.regionsSelected$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + } + + public optionSelected(ev: MatAutocompleteSelectedEvent){ + const id = ev.option.value + this.store$.dispatch({ + type: ADD_TO_REGIONS_SELECTION_WITH_IDS, + selectRegionIds : [id] + }) + + this.autoTrigger.nativeElement.value = '' + this.autoTrigger.nativeElement.focus() + } + + private regionsWithLabelIndex$: Observable<any[]> + public autocompleteList$: Observable<any[]> + public formControl = new FormControl() + + public regionsSelected$: Observable<any> + public parcellationSelected$: Observable<any> + + + @Output() + public focusedStateChanged: EventEmitter<boolean> = new EventEmitter() + + private _focused: boolean = false + set focused(val: boolean){ + this._focused = val + this.focusedStateChanged.emit(val) + } + get focused(){ + return this._focused + } + + public deselectAllRegions(event: MouseEvent){ + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: [] + }) + } + + // TODO handle mobile + handleRegionClick({ mode = null, region = null } = {}){ + const type = mode === 'single' + ? VIEWERSTATE_ACTION_TYPES.SINGLE_CLICK_ON_REGIONHIERARCHY + : mode === 'double' + ? VIEWERSTATE_ACTION_TYPES.DOUBLE_CLICK_ON_REGIONHIERARCHY + : '' + this.store$.dispatch({ + type, + payload: { region } + }) + } + + showHierarchy(event:MouseEvent){ + const dialog = this.dialog.open(this.regionHierarchyTemplate, { + height: '90vh', + width: '90vw' + }) + + /** + * keep sleight of hand shown while modal is shown + * + */ + this.focused = true + + /** + * take 1 to avoid memory leak + */ + dialog.afterClosed().pipe( + take(1) + ).subscribe(() => this.focused = false) + } + +} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.style.css b/src/ui/viewerStateController/regionSearch/regionSearch.style.css new file mode 100644 index 0000000000000000000000000000000000000000..17cda15a41ee862383f3f4e802121021525894f9 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.style.css @@ -0,0 +1,4 @@ +region-hierarchy +{ + height: 100%; +} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.template.html b/src/ui/viewerStateController/regionSearch/regionSearch.template.html new file mode 100644 index 0000000000000000000000000000000000000000..ef828ac2e265761736d416066215e137c39b3053 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.template.html @@ -0,0 +1,45 @@ +<div class="d-flex flex-row align-items-center"> + + <form class="flex-grow-1 flex-shrink-1"> + <mat-form-field class="w-100"> + <input + placeholder="Regions" + #autoTrigger + #trigger="matAutocompleteTrigger" + type="text" + matInput + [formControl]="formControl" + [matAutocomplete]="auto"> + </mat-form-field> + <mat-autocomplete + (opened)="focused = true" + (closed)="focused = false" + (optionSelected)="optionSelected($event)" + autoActiveFirstOption + #auto="matAutocomplete"> + <mat-option + *ngFor="let region of autocompleteList$ | async" + [value]="region.labelIndexId"> + {{ region.name }} + </mat-option> + </mat-autocomplete> + </form> + + <button + class="flex-grow-0 flex-shrink-0" + (click)="showHierarchy($event)" + mat-icon-button color="primary"> + <i class="fas fa-sitemap"></i> + </button> +</div> + +<ng-template #regionHierarchy> + <region-hierarchy + [selectedRegions]="regionsSelected$ | async | filterNull" + (singleClickRegion)="handleRegionClick({ mode: 'single', region: $event })" + (doubleClickRegion)="handleRegionClick({ mode: 'double', region: $event })" + (clearAllRegions)="deselectAllRegions($event)" + [parcellationSelected]="parcellationSelected$ | async"> + + </region-hierarchy> +</ng-template> diff --git a/src/ui/viewerStateController/viewerState.component.ts b/src/ui/viewerStateController/viewerState.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..726c1e895802beea84b8fe44fa72ca0685ae159e --- /dev/null +++ b/src/ui/viewerStateController/viewerState.component.ts @@ -0,0 +1,304 @@ +import { Component, ViewChild, TemplateRef, OnInit } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable, Subject, combineLatest, Subscription } from "rxjs"; +import { distinctUntilChanged, shareReplay, bufferTime, filter, map, withLatestFrom, delay, take, tap } from "rxjs/operators"; +import { SELECT_REGIONS, USER_CONFIG_ACTION_TYPES } from "src/services/stateStore.service"; +import { DESELECT_REGIONS, CHANGE_NAVIGATION } from "src/services/state/viewerState.store"; +import { ToastService } from "src/services/toastService.service"; +import { getSchemaIdFromName } from "src/util/pipes/templateParcellationDecoration.pipe"; +import { MatDialog, MatSelectChange, MatBottomSheet, MatBottomSheetRef } from "@angular/material"; +import { ExtraButton } from "src/components/radiolist/radiolist.component"; +import { DialogService } from "src/services/dialogService.service"; +import { RegionSelection } from "src/services/state/userConfigState.store"; + +const compareWith = (o, n) => !o || !n + ? false + : o.name === n.name + +@Component({ + selector: 'viewer-state-controller', + templateUrl: './viewerState.template.html', + styleUrls: [ + './viewerState.style.css' + ] +}) + +export class ViewerStateController implements OnInit{ + + @ViewChild('publicationTemplate', {read:TemplateRef}) publicationTemplate: TemplateRef<any> + @ViewChild('savedRegionBottomSheetTemplate', {read:TemplateRef}) savedRegionBottomSheetTemplate: TemplateRef<any> + + public focused: boolean = false + + private subscriptions: Subscription[] = [] + + public availableTemplates$: Observable<any[]> + public availableParcellations$: Observable<any[]> + + public templateSelected$: Observable<any> + public parcellationSelected$: Observable<any> + public regionsSelected$: Observable<any> + + public savedRegionsSelections$: Observable<any[]> + + public focusedDatasets$: Observable<any[]> + private userFocusedDataset$: Subject<any> = new Subject() + private dismissToastHandler: () => void + + public compareWith = compareWith + + private savedRegionBottomSheetRef: MatBottomSheetRef + + constructor( + private store$: Store<any>, + private toastService: ToastService, + private dialogService: DialogService, + private bottomSheet: MatBottomSheet + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.savedRegionsSelections$ = this.store$.pipe( + select('userConfigState'), + select('savedRegionsSelection'), + shareReplay(1) + ) + + this.templateSelected$ = viewerState$.pipe( + select('templateSelected'), + distinctUntilChanged() + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.regionsSelected$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.availableTemplates$ = viewerState$.pipe( + select('fetchedTemplates'), + distinctUntilChanged() + ) + + this.availableParcellations$ = this.templateSelected$.pipe( + select('parcellations') + ) + + this.focusedDatasets$ = this.userFocusedDataset$.pipe( + filter(v => !!v), + withLatestFrom( + combineLatest(this.templateSelected$, this.parcellationSelected$) + ), + ).pipe( + map(([userFocusedDataset, [selectedTemplate, selectedParcellation]]) => { + const { type, ...rest } = userFocusedDataset + if (type === 'template') return { ...selectedTemplate, ...rest} + if (type === 'parcellation') return { ...selectedParcellation, ...rest } + return { ...rest } + }), + bufferTime(100), + filter(arr => arr.length > 0), + /** + * merge properties field with the root level + * with the prop in properties taking priority + */ + map(arr => arr.map(item => { + const { properties } = item + return { + ...item, + ...properties + } + })), + shareReplay(1) + ) + } + + ngOnInit(){ + this.subscriptions.push( + this.savedRegionsSelections$.pipe( + filter(srs => srs.length === 0) + ).subscribe(() => this.savedRegionBottomSheetRef && this.savedRegionBottomSheetRef.dismiss()) + ) + this.subscriptions.push( + this.focusedDatasets$.subscribe(() => this.dismissToastHandler && this.dismissToastHandler()) + ) + this.subscriptions.push( + this.focusedDatasets$.pipe( + /** + * creates the illusion that the toast complete disappears before reappearing + */ + delay(100) + ).subscribe(() => this.dismissToastHandler = this.toastService.showToast(this.publicationTemplate, { + dismissable: true, + timeout:7000 + })) + ) + } + + handleActiveDisplayBtnClicked(event: MouseEvent, type: 'parcellation' | 'template', extraBtn: ExtraButton, inputItem:any = {}){ + const { name } = extraBtn + const { kgSchema, kgId } = getSchemaIdFromName(name) + this.userFocusedDataset$.next({ + ...inputItem, + kgSchema, + kgId + }) + } + + handleTemplateChange(event:MatSelectChange){ + + this.store$.dispatch({ + type: ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME, + payload: { + name: event.value + } + }) + } + + handleParcellationChange(event:MatSelectChange){ + if (!event.value) return + this.store$.dispatch({ + type: ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME, + payload: { + name: event.value + } + }) + } + + loadSavedRegion(event:MouseEvent, savedRegionsSelection:RegionSelection){ + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.LOAD_REGIONS_SELECTION, + payload: { + savedRegionsSelection + } + }) + } + + public editSavedRegion(event: MouseEvent, savedRegionsSelection: RegionSelection){ + event.preventDefault() + event.stopPropagation() + this.dialogService.getUserInput({ + defaultValue: savedRegionsSelection.name, + placeholder: `Enter new name`, + title: 'Edit name' + }).then(name => { + if (!name) throw new Error('user cancelled') + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.UPDATE_REGIONS_SELECTION, + payload: { + ...savedRegionsSelection, + name + } + }) + }).catch(e => { + // TODO catch user cancel + }) + } + public removeSavedRegion(event: MouseEvent, savedRegionsSelection: RegionSelection){ + event.preventDefault() + event.stopPropagation() + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.DELETE_REGIONS_SELECTION, + payload: { + ...savedRegionsSelection + } + }) + } + + + displayActiveParcellation(parcellation:any){ + return `<div class="d-flex"><small>Parcellation</small> <small class = "flex-grow-1 mute-text">${parcellation ? '(' + parcellation.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + } + + displayActiveTemplate(template: any) { + return `<div class="d-flex"><small>Template</small> <small class = "flex-grow-1 mute-text">${template ? '(' + template.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + } + + public loadSelection(event: MouseEvent){ + this.focused = true + + this.savedRegionBottomSheetRef = this.bottomSheet.open(this.savedRegionBottomSheetTemplate) + this.savedRegionBottomSheetRef.afterDismissed() + .subscribe(val => { + + }, error => { + + }, () => { + this.focused = false + this.savedRegionBottomSheetRef = null + }) + } + + public saveSelection(event: MouseEvent){ + this.focused = true + this.dialogService.getUserInput({ + defaultValue: `Saved Region`, + placeholder: `Name the selection`, + title: 'Save region selection' + }) + .then(name => { + if (!name) throw new Error('User cancelled') + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.SAVE_REGIONS_SELECTION, + payload: { name } + }) + }) + .catch(e => { + /** + * USER CANCELLED, HANDLE + */ + }) + .finally(() => this.focused = false) + } + + public deselectAllRegions(event: MouseEvent){ + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: [] + }) + } + + public deselectRegion(event: MouseEvent, region: any){ + this.store$.dispatch({ + type: DESELECT_REGIONS, + deselectRegions: [region] + }) + } + + public gotoRegion(event: MouseEvent, region:any){ + if (region.position) { + this.store$.dispatch({ + type: CHANGE_NAVIGATION, + navigation: { + position: region.position, + animation: {} + } + }) + } else { + /** + * TODO convert to snack bar + */ + this.toastService.showToast(`${region.name} does not have a position defined`, { + timeout: 5000, + dismissable: true + }) + } + } +} + +const ACTION_TYPES = { + SINGLE_CLICK_ON_REGIONHIERARCHY: 'SINGLE_CLICK_ON_REGIONHIERARCHY', + DOUBLE_CLICK_ON_REGIONHIERARCHY: 'DOUBLE_CLICK_ON_REGIONHIERARCHY', + SELECT_TEMPLATE_WITH_NAME: 'SELECT_TEMPLATE_WITH_NAME', + SELECT_PARCELLATION_WITH_NAME: 'SELECT_PARCELLATION_WITH_NAME', +} + +export const VIEWERSTATE_ACTION_TYPES = ACTION_TYPES diff --git a/src/ui/viewerStateController/viewerState.pipes.ts b/src/ui/viewerStateController/viewerState.pipes.ts new file mode 100644 index 0000000000000000000000000000000000000000..659d35778f3378966cb58f82d47f789e9ca95d89 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.pipes.ts @@ -0,0 +1,38 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { RegionSelection } from "src/services/state/userConfigState.store"; + +@Pipe({ + name: 'binSavedRegionsSelectionPipe' +}) + +export class BinSavedRegionsSelectionPipe implements PipeTransform{ + public transform(regionSelections:RegionSelection[]):{parcellationSelected:any, templateSelected:any, regionSelections: RegionSelection[]}[]{ + const returnMap = new Map() + for (let regionSelection of regionSelections){ + const key = `${regionSelection.templateSelected.name}\n${regionSelection.parcellationSelected.name}` + const existing = returnMap.get(key) + if (existing) existing.push(regionSelection) + else returnMap.set(key, [regionSelection]) + } + return Array.from(returnMap) + .map(([_, regionSelections]) => { + const {parcellationSelected = null, templateSelected = null} = regionSelections[0] || {} + return { + regionSelections, + parcellationSelected, + templateSelected + } + }) + } +} + +@Pipe({ + name: 'savedRegionsSelectionBtnDisabledPipe' +}) + +export class SavedRegionsSelectionBtnDisabledPipe implements PipeTransform{ + public transform(regionSelection: RegionSelection, templateSelected: any, parcellationSelected: any): boolean{ + return regionSelection.parcellationSelected.name !== parcellationSelected.name + || regionSelection.templateSelected.name !== templateSelected.name + } +} \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.style.css b/src/ui/viewerStateController/viewerState.style.css new file mode 100644 index 0000000000000000000000000000000000000000..0da6d0ea50d5d1df72cd96e1c3bd1273b6c9741b --- /dev/null +++ b/src/ui/viewerStateController/viewerState.style.css @@ -0,0 +1,35 @@ +.virtual-scroll-viewport-container +{ + height: 20em; + width: 20em; + overflow: hidden; +} + +.virtual-scroll-viewport-container > cdk-virtual-scroll-viewport +{ + width: 100%; + height: 100%; + box-sizing: content-box; + padding-right: 3em; +} + +.virtual-scroll-row +{ + width: 20em; +} + +/* required to match virtual scroll itemSize property */ +.virtual-scroll-unit +{ + height: 26px +} + +.selected-region-container +{ + flex: 1 1 auto; +} + +.selected-region-actionbtn +{ + flex: 0 0 auto; +} diff --git a/src/ui/viewerStateController/viewerState.template.html b/src/ui/viewerStateController/viewerState.template.html new file mode 100644 index 0000000000000000000000000000000000000000..9e1718231d4037b57fefcd2155df8bc51e12e362 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.template.html @@ -0,0 +1,202 @@ +<mat-card> + + <!-- template selection --> + <mat-form-field> + <mat-label> + Template + </mat-label> + <mat-select + [value]="(templateSelected$ | async)?.name" + (selectionChange)="handleTemplateChange($event)" + (openedChange)="focused = $event"> + <mat-option + *ngFor="let template of (availableTemplates$ | async)" + [value]="template.name"> + {{ template.name }} + </mat-option> + </mat-select> + </mat-form-field> + <ng-container *ngIf="templateSelected$ | async as templateSelected"> + <ng-container *ngIf="(templateSelected | templateParcellationsDecorationPipe)?.extraButtons as extraButtons"> + <button + *ngFor="let extraBtn of extraButtons" + (click)="handleActiveDisplayBtnClicked($event, 'template', extraBtn, templateSelected)" + mat-icon-button> + <i [class]="extraBtn.faIcon"></i> + </button> + </ng-container> + </ng-container> + + <!-- parcellation selection --> + <mat-form-field *ngIf="templateSelected$ | async as templateSelected"> + <mat-label> + Parcellation + </mat-label> + <mat-select + (selectionChange)="handleParcellationChange($event)" + [value]="(parcellationSelected$ | async)?.name" + (openedChange)="focused = $event"> + <mat-option + *ngFor="let parcellation of (templateSelected.parcellations | appendTooltipTextPipe)" + [value]="parcellation.name"> + {{ parcellation.name }} + </mat-option> + </mat-select> + </mat-form-field> + + <ng-container *ngIf="parcellationSelected$ | async as parcellationSelected"> + <ng-container *ngIf="(parcellationSelected | templateParcellationsDecorationPipe)?.extraButtons as extraButtons"> + <button + *ngFor="let extraBtn of extraButtons" + (click)="handleActiveDisplayBtnClicked($event, 'parcellation', extraBtn, parcellationSelected)" + mat-icon-button> + <i [class]="extraBtn.faIcon"></i> + </button> + </ng-container> + </ng-container> + + <!-- divider --> + <mat-divider></mat-divider> + + <!-- selected regions --> + + <div class="d-flex"> + <region-text-search-autocomplete + (focusedStateChanged)="focused = $event"> + </region-text-search-autocomplete> + </div> + + <!-- chips --> + <mat-card class="w-20em mh-10em overflow-auto overflow-x-hidden"> + <mat-chip-list class="mat-chip-list-stacked" #selectedRegionsChipList> + <mat-chip class="w-100" *ngFor="let region of (regionsSelected$ | async)"> + <span class="flex-grow-1 flex-shrink-1 text-truncate"> + {{ region.name }} + </span> + <button + *ngIf="region.position" + (click)="gotoRegion($event, region)" + mat-icon-button> + <i class="fas fa-map-marked-alt"></i> + </button> + <button + (click)="deselectRegion($event, region)" + mat-icon-button> + <i class="fas fa-trash"></i> + </button> + </mat-chip> + </mat-chip-list> + + <!-- place holder when no regions has been selected --> + <span class="muted" *ngIf="(regionsSelected$ | async).length === 0"> + No regions selected. Double click on any regions in the viewer, or use the search tool to select regions of interest. + </span> + </mat-card> + + <!-- control btns --> + <div class="mt-2 mb-2 d-flex justify-content-between"> + <div class="d-flex"> + + <!-- save --> + <button + matTooltip="Save this selection of regions" + matTooltipPosition="below" + mat-button + (click)="saveSelection($event)" + color="primary"> + <i class="fas fa-save"></i> + + </button> + + <!-- load --> + <button + (click)="loadSelection($event)" + matTooltip="Load a selection of regions" + matTooltipPosition="below" + mat-button + color="primary" + [disabled]="(savedRegionsSelections$ | async)?.length === 0"> + <i + matBadgeColor="accent" + [matBadgeOverlap]="false" + [matBadge]="(savedRegionsSelections$ | async)?.length > 0 ? (savedRegionsSelections$ | async)?.length : null" + class="fas fa-folder-open"></i> + + </button> + </div> + + <!-- deselect all --> + <button + (click)="deselectAllRegions($event)" + matTooltip="Deselect all selected regions" + matTooltipPosition="below" + mat-raised-button + color="warn" + [disabled]="(regionsSelected$ | async)?.length === 0"> + <i class="fas fa-trash"></i> + </button> + </div> +</mat-card> + +<ng-template #publicationTemplate> + <single-dataset-view + *ngFor="let focusedDataset of (focusedDatasets$ | async)" + [name]="focusedDataset.name" + [description]="focusedDataset.description" + [publications]="focusedDataset.publications" + [kgSchema]="focusedDataset.kgSchema" + [kgId]="focusedDataset.kgId"> + + </single-dataset-view> +</ng-template> + +<!-- bottom sheet for saved regions --> +<ng-template #savedRegionBottomSheetTemplate> + <mat-action-list> + + <!-- separated (binned) by template/parcellation --> + <ng-container *ngFor="let binnedRS of (savedRegionsSelections$ | async | binSavedRegionsSelectionPipe); let index = index"> + + <!-- only render divider if it is not the leading element --> + <mat-divider *ngIf="index !== 0"></mat-divider> + + <!-- header --> + <h3 mat-subheader> + {{ binnedRS.templateSelected.name }} / {{ binnedRS.parcellationSelected.name }} + </h3> + + <!-- ng for all saved regions --> + <button + *ngFor="let savedRegionsSelection of binnedRS.regionSelections" + (click)="loadSavedRegion($event, savedRegionsSelection)" + mat-list-item> + <!-- [class]="savedRegionsSelection | savedRegionsSelectionBtnDisabledPipe : (templateSelected$ | async) : (parcellationSelected$ | async) ? 'text-muted' : ''" --> + <!-- [disabled]="savedRegionsSelection | savedRegionsSelectionBtnDisabledPipe : (templateSelected$ | async) : (parcellationSelected$ | async)" --> + <!-- main content --> + <span class="flex-grow-0 flex-shrink-1"> + {{ savedRegionsSelection.name }} + </span> + <small class="ml-1 mr-1 text-muted flex-grow-1 flex-shrink-0"> + ({{ savedRegionsSelection.regionsSelected.length }} selected regions) + </small> + + <!-- edit btn --> + <button + (mousedown)="$event.stopPropagation()" + (click)="editSavedRegion($event, savedRegionsSelection)" + mat-icon-button> + <i class="fas fa-edit"></i> + </button> + + <!-- trash btn --> + <button + (mousedown)="$event.stopPropagation()" + (click)="removeSavedRegion($event, savedRegionsSelection)" + mat-icon-button + color="warn"> + <i class="fas fa-trash"></i> + </button> + </button> + </ng-container> + </mat-action-list> +</ng-template> \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c8232e1c4509088bf1ac287bdd5057508505bef --- /dev/null +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -0,0 +1,180 @@ +import { Subscription, Observable } from "rxjs"; +import { Injectable, OnInit, OnDestroy } from "@angular/core"; +import { Actions, ofType, Effect } from "@ngrx/effects"; +import { Store, select, Action } from "@ngrx/store"; +import { ToastService } from "src/services/toastService.service"; +import { shareReplay, distinctUntilChanged, map, withLatestFrom, filter } from "rxjs/operators"; +import { VIEWERSTATE_ACTION_TYPES } from "./viewerState.component"; +import { CHANGE_NAVIGATION, SELECT_REGIONS, NEWVIEWER, GENERAL_ACTION_TYPES, SELECT_PARCELLATION, isDefined } from "src/services/stateStore.service"; +import { regionFlattener } from "src/util/regionFlattener"; + +@Injectable({ + providedIn: 'root' +}) + +export class ViewerStateControllerUseEffect implements OnInit, OnDestroy{ + + private subscriptions: Subscription[] = [] + + private selectedRegions$: Observable<any[]> + + @Effect() + singleClickOnHierarchy$: Observable<any> + + @Effect() + selectTemplateWithName$: Observable<any> + + @Effect() + selectParcellationWithName$: Observable<any> + + doubleClickOnHierarchy$: Observable<any> + + constructor( + private actions$: Actions, + private store$: Store<any>, + private toastService: ToastService + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.selectedRegions$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged() + ) + + this.selectParcellationWithName$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME), + map(action => { + const { payload = {} } = action as ViewerStateAction + const { name } = payload + return name + }), + filter(name => !!name), + withLatestFrom(viewerState$.pipe( + select('parcellationSelected') + )), + filter(([name, parcellationSelected]) => { + if (parcellationSelected && parcellationSelected.name === name) return false + return true + }), + map(([name, _]) => name), + withLatestFrom(viewerState$.pipe( + select('templateSelected') + )), + map(([name, templateSelected]) => { + + const { parcellations: availableParcellations } = templateSelected + const newParcellation = availableParcellations.find(t => t.name === name) + if (!newParcellation) { + return { + type: GENERAL_ACTION_TYPES.ERROR, + payload: { + message: 'Selected parcellation not found.' + } + } + } + return { + type: SELECT_PARCELLATION, + selectParcellation: newParcellation + } + }) + ) + + this.selectTemplateWithName$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME), + map(action => { + const { payload = {} } = action as ViewerStateAction + const { name } = payload + return name + }), + filter(name => !!name), + withLatestFrom(viewerState$.pipe( + select('templateSelected') + )), + filter(([name, templateSelected]) => { + if (templateSelected && templateSelected.name === name) return false + return true + }), + map(([name, templateSelected]) => name), + withLatestFrom(viewerState$.pipe( + select('fetchedTemplates') + )), + map(([name, availableTemplates]) => { + const newTemplateTobeSelected = availableTemplates.find(t => t.name === name) + if (!newTemplateTobeSelected) { + return { + type: GENERAL_ACTION_TYPES.ERROR, + payload: { + message: 'Selected template not found.' + } + } + } + return { + type: NEWVIEWER, + selectTemplate: newTemplateTobeSelected, + selectParcellation: newTemplateTobeSelected.parcellations[0] + } + }) + ) + + this.doubleClickOnHierarchy$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.DOUBLE_CLICK_ON_REGIONHIERARCHY) + ) + + this.singleClickOnHierarchy$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SINGLE_CLICK_ON_REGIONHIERARCHY), + withLatestFrom(this.selectedRegions$), + map(([action, regionsSelected]) => { + + const {payload = {}} = action as ViewerStateAction + const { region } = payload + + const flattenedRegion = regionFlattener(region).filter(r => isDefined(r.labelIndex)) + const flattenedRegionNames = new Set(flattenedRegion.map(r => r.name)) + const selectedRegionNames = new Set(regionsSelected.map(r => r.name)) + const selectAll = flattenedRegion.every(r => !selectedRegionNames.has(r.name)) + return { + type: SELECT_REGIONS, + selectRegions: selectAll + ? regionsSelected.concat(flattenedRegion) + : regionsSelected.filter(r => !flattenedRegionNames.has(r.name)) + } + }) + ) + } + + ngOnInit(){ + this.subscriptions.push( + this.doubleClickOnHierarchy$.subscribe(({ region } = {}) => { + const { position } = region + if (position) { + this.store$.dispatch({ + type: CHANGE_NAVIGATION, + navigation: { + position, + animation: {} + } + }) + } else { + this.toastService.showToast(`${region.name} does not have a position defined`, { + timeout: 5000, + dismissable: true + }) + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } +} + +interface ViewerStateAction extends Action{ + payload: any + config: any +} \ No newline at end of file diff --git a/src/util/pipes/filterNgLayer.pipe.ts b/src/util/pipes/filterNgLayer.pipe.ts deleted file mode 100644 index 798075159645feed923f9cca4ac750afc8ac4ebc..0000000000000000000000000000000000000000 --- a/src/util/pipes/filterNgLayer.pipe.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; -import { NgLayerInterface } from "src/atlasViewer/atlasViewer.component"; - -/** - * TODO deprecate - * use regular pipe to achieve the same effect - */ - -@Pipe({ - name: 'filterNgLayer' -}) - -export class FilterNgLayer implements PipeTransform{ - public transform(excludedLayers: string[] = [], ngLayers: NgLayerInterface[]): NgLayerInterface[] { - const set = new Set(excludedLayers) - return ngLayers.filter(l => !set.has(l.name)) - } -} \ No newline at end of file diff --git a/src/util/pipes/getFileExt.pipe.ts b/src/util/pipes/getFileExt.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..aea77ceba51c36451836366656529eef661a852c --- /dev/null +++ b/src/util/pipes/getFileExt.pipe.ts @@ -0,0 +1,36 @@ +import { PipeTransform, Pipe } from "@angular/core"; + +const NIFTI = `NIFTI Volume` +const VTK = `VTK Mesh` + +const extMap = new Map([ + ['.nii', NIFTI], + ['.nii.gz', NIFTI], + ['.vtk', VTK] +]) + +@Pipe({ + name: 'getFileExtension' +}) + +export class GetFileExtension implements PipeTransform{ + private regex: RegExp = new RegExp('(\\.[\\w\\.]*?)$') + + private getRegexp(ext){ + return new RegExp(`${ext.replace(/\./g, '\\.')}$`, 'i') + } + + private detFileExt(filename:string):string{ + for (let [key, val] of extMap){ + if(this.getRegexp(key).test(filename)){ + return val + } + } + return filename + } + + public transform(filename:string):string{ + return this.detFileExt(filename) + } +} + diff --git a/src/util/pipes/getFileNameFromPathName.pipe.ts b/src/util/pipes/getFileNameFromPathName.pipe.ts deleted file mode 100644 index d64a96c1c9de83ef55c9efa7f3f43e0181b9fe21..0000000000000000000000000000000000000000 --- a/src/util/pipes/getFileNameFromPathName.pipe.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; - - -@Pipe({ - name : 'getFilenameFromPathname' -}) - -export class GetFilenameFromPathnamePipe implements PipeTransform{ - public transform(pathname:string):string{ - return pathname.split('/')[pathname.split('/').length - 1] - } -} \ No newline at end of file diff --git a/src/util/pipes/getFilename.pipe.ts b/src/util/pipes/getFilename.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..76afea5627e1ce05a7f527727f99084f30557291 --- /dev/null +++ b/src/util/pipes/getFilename.pipe.ts @@ -0,0 +1,14 @@ +import { PipeTransform, Pipe } from "@angular/core"; + +@Pipe({ + name: 'getFilenamePipe' +}) + +export class GetFilenamePipe implements PipeTransform{ + private regex: RegExp = new RegExp('[\\/\\\\]([\\w\\.]*?)$') + public transform(fullname: string): string{ + return this.regex.test(fullname) + ? this.regex.exec(fullname)[1] + : fullname + } +} \ No newline at end of file diff --git a/src/util/pipes/kgSearchBtnColor.pipe.ts b/src/util/pipes/kgSearchBtnColor.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9bbb25bf655bbe4d5e90ef08768f058fae915f2 --- /dev/null +++ b/src/util/pipes/kgSearchBtnColor.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; + +@Pipe({ + name: 'kgSearchBtnColorPipe' +}) + +export class KgSearchBtnColorPipe implements PipeTransform{ + public transform([minimisedWidgetUnit, themedBtnCls]: [Set<WidgetUnit>, string], wu: WidgetUnit ){ + return minimisedWidgetUnit.has(wu) + ? 'primary' + : 'accent' + } +} \ No newline at end of file diff --git a/src/util/pipes/pluginBtnFabColor.pipe.ts b/src/util/pipes/pluginBtnFabColor.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfb11d4ae59e41107e8fa258be73a122c1b1de7c --- /dev/null +++ b/src/util/pipes/pluginBtnFabColor.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'pluginBtnFabColorPipe' +}) + +export class PluginBtnFabColorPipe implements PipeTransform{ + public transform([launchedSet, minimisedSet, themedBtnCls], pluginName){ + return minimisedSet.has(pluginName) + ? 'primary' + : launchedSet.has(pluginName) + ? 'accent' + : 'basic' + } +} \ No newline at end of file diff --git a/webpack.staticassets.js b/webpack.staticassets.js index cad3e8a0d4b3861ba397d125e8c1f3276dacb1b5..05119ba58f3c746b113b673b7abb41849d63bfab 100644 --- a/webpack.staticassets.js +++ b/webpack.staticassets.js @@ -1,8 +1,19 @@ const webpack = require('webpack') +const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.exports = { module : { rules : [ + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader + }, + 'css-loader', + 'sass-loader' + ] + }, { test : /jpg|png/, exclude : /export\_nehuba|index/, @@ -49,6 +60,9 @@ module.exports = { ] }, plugins : [ + new MiniCssExtractPlugin({ + filename: 'theme.css' + }), new webpack.DefinePlugin({ PLUGINDEV : process.env.PLUGINDEV ? JSON.stringify(process.env.PLUGINDEV) @@ -67,5 +81,10 @@ module.exports = { BACKEND_URL: JSON.stringify(process.env.BACKEND_URL || 'http://localhost:3000/') }) // ...ignoreArr.map(dirname => new webpack.IgnorePlugin(/\.\/plugin_examples/)) - ] + ], + resolve: { + extensions: [ + '.scss' + ] + } } \ No newline at end of file