diff --git a/README.md b/README.md index 3481157abf73562adf36db5ae3eb836009a7c064..7e5bf0ced2ed36396ee0d75aaabba9f79996b27c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A live version of the Interactive Atlas Viewer is available at [https://kg.human ### General information Interactive atlas viewer is built with [Angular (v6.0)](https://angular.io/), [Bootstrap (v4)](http://getbootstrap.com/), and [fontawesome icons](https://fontawesome.com/). Some other notable packages used are: [ng2-charts](https://valor-software.com/ng2-charts/) for charts visualisation, [ngx-bootstrap](https://valor-software.com/ngx-bootstrap/) for UI and [ngrx/store](https://github.com/ngrx/platform) for state management. +Releases newer than [v0.2.9](https://github.com/HumanBrainProject/interactive-viewer/tree/v0.2.9) also uses a nodejs backend, which uses [passportjs](http://www.passportjs.org/) for user authentication, [express](https://expressjs.com/) as a http framework. + ### Prerequisites - node > 6 @@ -19,27 +21,48 @@ Interactive atlas viewer is built with [Angular (v6.0)](https://angular.io/), [B To run a dev server, run: ``` -git clone https://github.com/HumanBrainProject/interactive-viewer -cd interactive-viewer -npm i -npm run dev-server +$ git clone https://github.com/HumanBrainProject/interactive-viewer +$ cd interactive-viewer +$ npm i +$ npm run dev ``` ### Develop Plugins -To develop plugins for the interactive viewer, run: +For releases newer than [v0.2.9](https://github.com/HumanBrainProject/interactive-viewer/tree/v0.2.9), Interactive Atlas Viewer attempts to fetch `GET {BACKEND_URL}/plugins` to retrieve a list of URLs. The interactive atlas viewer will then perform a `GET` request for each of the listed URLs, parsing them as [manifests](src/plugin_examples/README.md#Manifest%20JSON). + +The backend reads the environment variable `PLUGIN_URLS` and separate the string with `;` as a delimiter. In order to return a response akin to the following: + +```JSON +["http://localhost:3001/manifest.json","http://localhost:9001/manifest.json"] +``` + +Plugin developers may choose to do any of the following: + +_shell_ + +set env var every time + +```bash +$ PLUGIN_URLS=http://localhost:3001/manifest.json;http://localhost:9001/manifest.json npm run dev +``` + +_dotenv_ + +set a `.env` file in `./deploy/` once + +```bash +$ echo `PLUGIN_URLS=http://localhost:3001/manifest.json;http://localhost:9001/manifest.json` > ./deploy/.env ``` -git clone https://github.com/HumanBrainProject/interactive-viewer -cd interactive-viewer -npm i -npm run dev-plugin -/* or define your own endpoint that returns string of manifests */ -PLUGINDEV=http://mycustom.io/allPluginmanifest npm run dev-server +then, simple start the dev process with +```bash +$ npm run dev ``` -The contents inside the folder in `./src/plugin_examples` will be automatically fetched by the dev instance of the interactive-viewer on load. +Plugin developers can start their own webserver, use [interactive-viewer-plugin-template](https://github.com/HumanBrainProject/interactive-viewer-plugin-template), or (coming soon) provide link to a github repository. + [plugin readme](src/plugin_examples/README.md) @@ -48,19 +71,17 @@ The contents inside the folder in `./src/plugin_examples` will be automatically [plugin migration guide](src/plugin_examples/migrationGuide.md) -## Deployment +## Compilation `package.json` provide with two ways of building the interactive atlas viewer, `JIT` or `AOT` compilation. In general, `AOT` compilation produces a smaller package and has better performance. -## AOT compilation - -Define `BUNDLEDPLUGINS` as a comma separated environment variables to bundle the plugins. +### AOT compilation ``` -[BUNDLEDPLUGINS=pluginDir1[,pluginDir2...]] npm run build-aot +npm run build-aot ``` -## JIT Compilation +### JIT Compilation ``` npm run build @@ -69,6 +90,21 @@ npm run build npm run build-min ``` +### Docker + +The repository also provides a `Dockerfile`. Here are the environment variables used: + +_build time_ +- __BACKEND_URL__ : same as `HOSTNAME` during run time. Needed as root URL when fetching templates / datasets etc. If left empty, will fetch without hostname. + +_run time_ + +- __SESSION_SECRET__ : needed for session +- __HOSTNAME__ : needed for OIDC redirect +- __HBP_CLIENTID__ : neded for OIDC authentication +- __HBP_CLIENTSECRET__ : needed for OIDC authentication +- __PLUGIN_URLS__ : optional. Allows plugins to be populated +- __REFRESH_TOKEN__ : needed for access of public data ## Contributing @@ -80,4 +116,4 @@ Commit history prior to v0.2.0 is available in the [legacy-v0.2.0](https://githu ## License -MIT +TO BE DECIDED \ No newline at end of file diff --git a/deploy/app.js b/deploy/app.js index e2260239c9dc507b9079e7a510b2750c20c5d27d..f1e4fdcba43d43f5b1ebd091ac5259de606468b3 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,6 +11,22 @@ 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', + method: 'main.bundle.js', + xForwardedFor: xForwardedFor.replace(/\ /g, '').split(',').map(hash), + ip: hash(ip) + }) + } + next() +}) + /** * load env first, then load other modules */ @@ -44,6 +61,11 @@ const PUBLIC_PATH = process.env.NODE_ENV === 'production' ? path.join(__dirname, 'public') : path.join(__dirname, '..', 'dist', 'aot') +/** + * well known path + */ +app.use('/.well-known', express.static(path.join(__dirname, 'well-known'))) + app.use(express.static(PUBLIC_PATH)) app.use((req, res, next) => { diff --git a/deploy/auth/hbp-oidc.js b/deploy/auth/hbp-oidc.js index cbb7ce83f0d34de5814048a3e2986a660640f40c..a6963750d8469a224c04018c1ef63209cfa9bfc8 100644 --- a/deploy/auth/hbp-oidc.js +++ b/deploy/auth/hbp-oidc.js @@ -17,23 +17,27 @@ const cb = (tokenset, {sub, given_name, family_name, ...rest}, done) => { } module.exports = async (app) => { - const { oidcStrategy } = await configureAuth({ - clientId, - clientSecret, - discoveryUrl, - redirectUri, - cb, - scope: 'openid offline_access', - clientConfig: { - redirect_uris: [ redirectUri ], - response_types: [ 'code' ] - } - }) - - passport.use('hbp-oidc', oidcStrategy) - app.get('/hbp-oidc/auth', passport.authenticate('hbp-oidc')) - app.get('/hbp-oidc/cb', passport.authenticate('hbp-oidc', { - successRedirect: '/', - failureRedirect: '/' - })) + try { + const { oidcStrategy } = await configureAuth({ + clientId, + clientSecret, + discoveryUrl, + redirectUri, + cb, + scope: 'openid offline_access', + clientConfig: { + redirect_uris: [ redirectUri ], + response_types: [ 'code' ] + } + }) + + passport.use('hbp-oidc', oidcStrategy) + app.get('/hbp-oidc/auth', passport.authenticate('hbp-oidc')) + app.get('/hbp-oidc/cb', passport.authenticate('hbp-oidc', { + successRedirect: '/', + failureRedirect: '/' + })) + } catch (e) { + console.error(e) + } } diff --git a/deploy/auth/index.js b/deploy/auth/index.js index bfd8fab1724d4a1f052734684b2440baa1374e79..8c3895710418e81e78ef7aa5aea483013a799886 100644 --- a/deploy/auth/index.js +++ b/deploy/auth/index.js @@ -14,10 +14,8 @@ module.exports = async (app) => { passport.deserializeUser((id, done) => { const user = objStoreDb.get(id) - if (user) - return done(null, user) - else - return done(null, false) + if (user) return done(null, user) + else return done(null, false) }) await hbpOidc(app) diff --git a/deploy/catchError.js b/deploy/catchError.js index 0fdc484348a9e6f8e1f1fc9072eaec5b8de5e1d9..38e1a000f5b78c53d5275bef01caf2ec9cbfb0db 100644 --- a/deploy/catchError.js +++ b/deploy/catchError.js @@ -2,7 +2,7 @@ module.exports = ({code = 500, error = 'an error had occured', trace = 'undefine /** * probably use more elaborate logging? */ - console.log('Catching error', { + console.error('Catching error', { code, error, trace diff --git a/deploy/datasets/index.js b/deploy/datasets/index.js index e974366263cff811887167510658d9a63938f652..cb69686463aa6cd2240a183f3c09340a5d0e5b02 100644 --- a/deploy/datasets/index.js +++ b/deploy/datasets/index.js @@ -25,6 +25,15 @@ const noCacheMiddleWare = (_req, res, next) => { next() } +const getVary = (headers) => (_req, res, next) => { + if (!headers instanceof Array) { + console.warn(`getVary arguments needs to be an Array of string`) + return next() + } + res.setHeader('Vary', headers.join(', ')) + next() +} + datasetsRouter.use('/spatialSearch', noCacheMiddleWare, require('./spatialRouter')) datasetsRouter.get('/templateName/:templateName', noCacheMiddleWare, (req, res, next) => { @@ -59,7 +68,10 @@ datasetsRouter.get('/parcellationName/:parcellationName', noCacheMiddleWare, (re }) }) -datasetsRouter.get('/preview/:datasetName', cacheMaxAge24Hr, (req, res, next) => { +/** + * It appears that query param are not + */ +datasetsRouter.get('/preview/:datasetName', getVary(['referer']), cacheMaxAge24Hr, (req, res, next) => { const { datasetName } = req.params const ref = url.parse(req.headers.referer) const { templateSelected, parcellationSelected } = qs.parse(ref.query) @@ -94,7 +106,7 @@ const PUBLIC_PATH = process.env.NODE_ENV === 'production' const RECEPTOR_PATH = path.join(PUBLIC_PATH, 'res', 'image') fs.readdir(RECEPTOR_PATH, (err, files) => { if (err) { - console.log('reading receptor error', err) + console.warn('reading receptor error', err) return } files.forEach(file => previewFileMap.set(`res/image/receptor/${file}`, path.join(RECEPTOR_PATH, file))) @@ -124,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 { @@ -132,7 +144,7 @@ datasetsRouter.get('/downloadKgFiles', checkKgQuery, cacheMaxAge24Hr, async (req res.setHeader('Content-Type', 'application/zip') stream.pipe(res) } catch (e) { - console.log('datasets/index#downloadKgFiles', e) + console.warn('datasets/index#downloadKgFiles', e) res.status(400).send(e) } }) diff --git a/deploy/datasets/supplements/previewFile.js b/deploy/datasets/supplements/previewFile.js index ea178530c1ed118387393dd8754f325b2ff521a2..311ec7d273becf80a84d74615128924cb302551a 100644 --- a/deploy/datasets/supplements/previewFile.js +++ b/deploy/datasets/supplements/previewFile.js @@ -13,7 +13,7 @@ let previewMap = new Map(), const readFile = (filename) => new Promise((resolve) => { fs.readFile(path.join(__dirname, 'data', filename), 'utf-8', (err, data) => { if (err){ - console.log('read file error', err) + console.warn('read file error', err) return resolve([]) } resolve(JSON.parse(data)) diff --git a/deploy/logging/index.js b/deploy/logging/index.js new file mode 100644 index 0000000000000000000000000000000000000000..173ec49afb0c3d45332e0c04fbae38283541a3e6 --- /dev/null +++ b/deploy/logging/index.js @@ -0,0 +1,43 @@ +const request = require('request') +const qs = require('querystring') + +class Logger { + constructor(name, { protocol = 'http', host = 'localhost', port = '24224', username = '', password = '' } = {}){ + this.name = qs.escape(name) + this.protocol = protocol + this.host = host + this.port = port + this.username = username + this.password = password + } + + emit(logLevel, message, callback){ + const { + name, + protocol, + host, + port, + username, + password + } = this + + const auth = username !== '' ? `${username}:${password}@` : '' + const url = `${protocol}://${auth}${host}:${port}/${name}.${qs.escape(logLevel)}` + const formData = { + json: JSON.stringify(message) + } + if (callback) { + request.post({ + url, + formData + }, callback) + } else { + return request.post({ + url, + formData + }) + } + } +} + +module.exports = Logger \ No newline at end of file diff --git a/deploy/server.js b/deploy/server.js index 85596178cccb7433a77195c447e3efa665cd2583..9045664cf8a90542b482513e2026eacf291ea016 100644 --- a/deploy/server.js +++ b/deploy/server.js @@ -5,6 +5,57 @@ if (process.env.NODE_ENV !== 'production') { }) } +if (process.env.FLUENT_HOST) { + const Logger = require('./logging') + + const name = process.env.IAV_NAME || 'IAV' + const stage = process.env.IAV_STAGE || 'unnamed-stage' + + const protocol = process.env.FLUENT_PROTOCOL || 'http' + const host = process.env.FLUENT_HOST || 'localhost' + const port = process.env.FLUENT_PORT || 24224 + + const prefix = `${name}.${stage}` + + const log = new Logger(prefix, { + protocol, + host, + port + }) + + const handleRequestCallback = (err, resp, body) => { + if (err) { + process.stderr.write(`fluentD logging failed\n`) + process.stderr.write(err.toString()) + process.stderr.write('\n') + } + + if (resp && resp.statusCode >= 400) { + process.stderr.write(`fluentD logging responded error\n`) + process.stderr.write(resp.toString()) + process.stderr.write('\n') + } + } + + const emitInfo = message => log.emit('info', { message }, handleRequestCallback) + + const emitWarn = message => log.emit('warn', { message }, handleRequestCallback) + + const emitError = message => log.emit('error', { message }, handleRequestCallback) + + console.log('starting fluentd logging') + + console.log = function () { + emitInfo([...arguments]) + } + console.warn = function () { + emitWarn([...arguments]) + } + console.error = function () { + emitError([...arguments]) + } +} + const app = require('./app') const PORT = process.env.PORT || 3000 diff --git a/deploy/well-known/robot.txt b/deploy/well-known/robot.txt new file mode 100644 index 0000000000000000000000000000000000000000..4f9540ba358a64607438da92eebe85889fdad50a --- /dev/null +++ b/deploy/well-known/robot.txt @@ -0,0 +1 @@ +User-agent: * \ No newline at end of file diff --git a/deploy/well-known/security.txt b/deploy/well-known/security.txt new file mode 100644 index 0000000000000000000000000000000000000000..42a2d3940dfcc87456b84a6165c9e741068c6b31 --- /dev/null +++ b/deploy/well-known/security.txt @@ -0,0 +1,2 @@ +# If you would like to report a security issue, please contact us via: +Contact: inm1-bda@fz-juelich.de \ No newline at end of file diff --git a/package.json b/package.json index 2acb48676fed72ee9fa9639abe26b48488e7a0f4..491667075a4a27dfec14a0a4693c05ac17bab5d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interactiveviewer", - "version": "1.0.0", + "version": "0.0.2", "description": "", "scripts": { "dev-server-export": "webpack-dev-server --config webpack.export.js", @@ -10,12 +10,11 @@ "build-aot": "PRODUCTION=true GIT_HASH=`git log --pretty=format:'%h' --invert-grep --grep=^.ignore -1` webpack --config webpack.aot.js", "build-min": "webpack --config webpack.prod.js", "build": "webpack --config webpack.dev.js", - "dev-plugin": "PLUGINDEV=http://localhost:10080/allPluginmanifests npm run dev-server & npm run plugin-server", "plugin-server": "node ./src/plugin_examples/server.js", "dev-server": "webpack-dev-server --config webpack.dev.js --mode development", + "dev": "npm run dev-server & (cd deploy; node server.js)", "dev-server-aot": "PRODUCTION=true GIT_HASH=`git log --pretty=format:'%h' --invert-grep --grep=^.ignore -1` webpack-dev-server --config webpack.aot.js", "dev-server-all-interfaces": "webpack-dev-server --config webpack.dev.js --mode development --hot --host 0.0.0.0", - "serve-plugins": "node src/plugin_examples/server.js", "test": "karma start spec/karma.conf.js", "e2e": "protractor e2e/protractor.conf" }, @@ -46,6 +45,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", "html-webpack-plugin": "^3.2.0", "jasmine": "^3.1.0", @@ -58,12 +58,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", @@ -74,12 +77,12 @@ "webpack-cli": "^3.3.2", "webpack-closure-compiler": "^2.1.6", "webpack-dev-server": "^3.1.4", - "webpack-merge": "^4.1.2" - }, - "dependencies": { + "webpack-merge": "^4.1.2", "@angular/cdk": "^7.3.7", "@angular/material": "^7.3.7", "@angular/router": "^7.2.15", "zone.js": "^0.9.1" + }, + "dependencies": { } } diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 5173ec7a7825790bb058a09b56aa5cf0cb8c6c11..1691568f7c53b2d1b59f7d897706c37f45c865f5 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -3,11 +3,10 @@ import { Store, select } from "@ngrx/store"; import { ViewerStateInterface, safeFilter, getLabelIndexMap, isDefined } from "src/services/stateStore.service"; import { Observable } from "rxjs"; import { map, distinctUntilChanged, filter } from "rxjs/operators"; -import { BsModalService } from "ngx-bootstrap/modal"; -import { ModalUnit } from "./modalUnit/modalUnit.component"; 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 @@ -19,15 +18,14 @@ export class AtlasViewerAPIServices{ private loadedTemplates$ : Observable<any> private selectParcellation$ : Observable<any> - private selectTemplate$ : Observable<any> - private darktheme : boolean public interactiveViewer : InteractiveViewerInterface public loadedLibraries : Map<string,{counter:number,src:HTMLElement|null}> = new Map() constructor( private store : Store<ViewerStateInterface>, - private modalService: BsModalService + private modalService: BsModalService, + private dialogService: DialogService, ){ this.loadedTemplates$ = this.store.pipe( @@ -36,13 +34,6 @@ export class AtlasViewerAPIServices{ map(state=>state.fetchedTemplates) ) - this.selectTemplate$ = this.store.pipe( - select('viewerState'), - filter(state => isDefined(state) && isDefined(state.templateSelected)), - map(state => state.templateSelected), - distinctUntilChanged((t1, t2) => t1.name === t2.name) - ) - this.selectParcellation$ = this.store.pipe( select('viewerState'), safeFilter('parcellationSelected'), @@ -83,18 +74,22 @@ export class AtlasViewerAPIServices{ const handler = new ModalHandler() let modalRef handler.show = () => { - modalRef = this.modalService.show(ModalUnit, { - initialState : { - title : handler.title, - body : handler.body - ? handler.body - : 'handler.body has yet been defined ...', - footer : handler.footer - }, - class : this.darktheme ? 'darktheme' : 'not-darktheme', - backdrop : handler.dismissable ? true : 'static', - keyboard : handler.dismissable - }) + /** + * TODO enable + * temporarily disabled + */ + // modalRef = this.modalService.show(ModalUnit, { + // initialState : { + // title : handler.title, + // body : handler.body + // ? handler.body + // : 'handler.body has yet been defined ...', + // footer : handler.footer + // }, + // class : this.darktheme ? 'darktheme' : 'not-darktheme', + // backdrop : handler.dismissable ? true : 'static', + // keyboard : handler.dismissable + // }) } handler.hide = () => { if(modalRef){ @@ -115,7 +110,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') @@ -137,7 +135,6 @@ export class AtlasViewerAPIServices{ private init(){ this.loadedTemplates$.subscribe(templates=>this.interactiveViewer.metadata.loadedTemplates = templates) this.selectParcellation$.subscribe(parcellation => this.interactiveViewer.metadata.regionsLabelIndexMap = getLabelIndexMap(parcellation.regions)) - this.selectTemplate$.subscribe(template => this.darktheme = template.useTheme === 'dark') } } @@ -184,6 +181,8 @@ export interface InteractiveViewerInterface{ getModalHandler: () => ModalHandler getToastHandler: () => ToastHandler launchNewWidget: (manifest:PluginManifest) => Promise<any> + getUserInput: (config:GetUserInputConfig) => Promise<string> + getUserConfirmation: (config: GetUserConfirmation) => Promise<any> } pluginControl : { @@ -193,6 +192,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 a1175088db8d56f6f0a879ef5c2e40b439db917e..c2b66ac5731e0d3805b5d54eb27ad21743cbaf9c 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -7,20 +7,16 @@ import { AtlasViewerDataService } from "./atlasViewer.dataService.service"; import { WidgetServices } from "./widgetUnit/widgetService.service"; import { LayoutMainSide } from "../layouts/mainside/mainside.component"; import { AtlasViewerConstantsServices, UNSUPPORTED_PREVIEW, UNSUPPORTED_INTERVAL } from "./atlasViewer.constantService.service"; -import { BsModalService } from "ngx-bootstrap/modal"; -import { ModalUnit } from "./modalUnit/modalUnit.component"; 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"; import { DatabrowserService } from "src/ui/databrowserModule/databrowser.service"; import { AGREE_COOKIE, AGREE_KG_TOS, SHOW_KG_TOS } from "src/services/state/uiState.store"; import { TabsetComponent } from "ngx-bootstrap/tabs"; -import { ToastService } from "src/services/toastService.service"; +import { MatDialog, MatDialogRef } from "@angular/material"; /** * TODO @@ -106,10 +102,9 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { private constantsService: AtlasViewerConstantsServices, public urlService: AtlasViewerURLService, public apiService: AtlasViewerAPIServices, - private modalService: BsModalService, + private matDialog: MatDialog, private databrowserService: DatabrowserService, private dispatcher$: ActionsSubject, - private toastService: ToastService, private rd: Renderer2 ) { this.ngLayerNames$ = this.store.pipe( @@ -240,6 +235,11 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { private selectedParcellation$: Observable<any> private selectedParcellation: any + private cookieDialogRef: MatDialogRef<any> + private kgTosDialogRef: MatDialogRef<any> + private helpDialogRef: MatDialogRef<any> + private loginDialogRef: MatDialogRef<any> + ngOnInit() { this.meetsRequirement = this.meetsRequirements() @@ -261,25 +261,19 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { } this.subscriptions.push( - this.showHelp$.subscribe(() => - this.modalService.show(ModalUnit, { - initialState: { - title: this.constantsService.showHelpTitle, - template: this.helpComponent - } + this.showHelp$.subscribe(() => { + this.helpDialogRef = this.matDialog.open(this.helpComponent, { + autoFocus: false }) - ) + }) ) this.subscriptions.push( this.constantsService.showSigninSubject$.pipe( debounceTime(160) ).subscribe(user => { - this.modalService.show(ModalUnit, { - initialState: { - title: user ? 'Logout' : `Login`, - template: this.signinModalComponent - } + this.loginDialogRef = this.matDialog.open(this.signinModalComponent, { + autoFocus: false }) }) ) @@ -321,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() { @@ -350,12 +350,7 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { filter(agreed => !agreed), delay(0) ).subscribe(() => { - this.modalService.show(ModalUnit, { - initialState: { - title: 'Cookie Disclaimer', - template: this.cookieAgreementComponent - } - }) + this.cookieDialogRef = this.matDialog.open(this.cookieAgreementComponent) }) this.dispatcher$.pipe( @@ -368,12 +363,7 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { filter(flag => !flag), delay(0) ).subscribe(val => { - this.modalService.show(ModalUnit, { - initialState: { - title: 'Knowldge Graph ToS', - template: this.kgTosComponent - } - }) + this.kgTosDialogRef = this.matDialog.open(this.kgTosComponent) }) this.onhoverSegmentsForFixed$ = this.rClContextualMenu.onShow.pipe( @@ -415,12 +405,16 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { } if(this.constantsService.mobile){ - this.modalService.show(ModalUnit,{ - initialState: { - title: this.constantsService.mobileWarningHeader, - body: this.constantsService.mobileWarning - } - }) + /** + * TODO change to snack bar in future + */ + + // this.modalService.show(ModalUnit,{ + // initialState: { + // title: this.constantsService.mobileWarningHeader, + // body: this.constantsService.mobileWarning + // } + // }) } return true } @@ -437,23 +431,23 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { } kgTosClickedOk(){ - this.modalService.hide(1) + this.kgTosDialogRef && this.kgTosDialogRef.close() this.store.dispatch({ type: AGREE_KG_TOS }) } cookieClickedOk(){ - this.modalService.hide(1) + this.cookieDialogRef && this.cookieDialogRef.close() this.store.dispatch({ type: AGREE_COOKIE }) } panelAnimationEnd(){ - - if( this.nehubaContainer && this.nehubaContainer.nehubaViewer && this.nehubaContainer.nehubaViewer.nehubaViewer ) + if( this.nehubaContainer && this.nehubaContainer.nehubaViewer && this.nehubaContainer.nehubaViewer.nehubaViewer ) { this.nehubaContainer.nehubaViewer.nehubaViewer.redraw() + } } nehubaClickHandler(event:MouseEvent){ @@ -508,6 +502,15 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { }) } + closeModal(mode){ + if (mode === 'help') { + this.helpDialogRef && this.helpDialogRef.close() + } + + if (mode === 'login') { + this.loginDialogRef && this.loginDialogRef.close() + } + } closeMenuWithSwipe(documentToSwipe: ElementRef) { if (documentToSwipe && documentToSwipe.nativeElement) { 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 b1ce0aaca772c8664ff6a185b7e54b322ab37d61..bb1d4183e203ac97f3c9d54c52a49d18e5e660e1 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.ts @@ -3,7 +3,7 @@ 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 } from "rxjs/operators"; +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;` @@ -179,7 +179,6 @@ Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float * Observable for showing help modal */ public showHelpSubject$: Subject<null> = new Subject() - public showHelpTitle: String = 'About' private showHelpGeneralMobile = [ ['hold 🌠+ ↕', 'change oblique slice mode'], @@ -252,6 +251,7 @@ Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float this.darktheme$ = this.store.pipe( select('viewerState'), select('templateSelected'), + filter(v => !!v), map(({useTheme}) => useTheme === 'dark'), shareReplay(1) ) @@ -330,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 @@ -388,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.ts b/src/atlasViewer/atlasViewer.pluginService.service.ts index ff2ff680d645b4744c66ef2f8e44acefba07385c..66d7a3ff527cd84d8c11af520c3bd82cdf3741e3 100644 --- a/src/atlasViewer/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/atlasViewer.pluginService.service.ts @@ -262,22 +262,10 @@ export class PluginServices{ 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() @@ -309,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.template.html b/src/atlasViewer/atlasViewer.template.html index 429869cf77846acea64457a1b8cdbf52c32b4ad5..1a4876f532732f6415988deb76849f926ab3377a 100644 --- a/src/atlasViewer/atlasViewer.template.html +++ b/src/atlasViewer/atlasViewer.template.html @@ -28,10 +28,10 @@ <signin-banner [darktheme] = "darktheme" signinWrapper></signin-banner> <layout-floating-container *ngIf="this.nehubaContainer && this.nehubaContainer.nehubaViewer"> <ui-status-card - [selectedTemplate]="selectedTemplate" - [isMobile]="isMobile" - [onHoverSegmentName]="this.nehubaContainer.onHoverSegmentName$ | async" - [nehubaViewer]="this.nehubaContainer.nehubaViewer"> + [selectedTemplate]="selectedTemplate" + [isMobile]="isMobile" + [onHoverSegmentName]="this.nehubaContainer.onHoverSegmentName$ | async" + [nehubaViewer]="this.nehubaContainer.nehubaViewer"> </ui-status-card> </layout-floating-container> </tab> @@ -70,54 +70,85 @@ </ng-template> </ng-container> - <ng-template #helpComponent> - <tabset> - <tab heading="Help"> - <help-component> - </help-component> - </tab> - <tab heading="Settings"> - <div class="mt-2"> + <h2 mat-dialog-title>About Interactive Viewer</h2> + <mat-dialog-content class="h-90vh w-50vw"> + <mat-tab-group> + <mat-tab label="Help"> + <help-component> + </help-component> + </mat-tab> + <mat-tab label="Settings"> <config-component> </config-component> - </div> - </tab> - <tab heading="Privacy Policy"> - <div class="mt-2"> + </mat-tab> + <mat-tab label="Privacy Policy"> + <!-- TODO make tab container scrollable --> <cookie-agreement> </cookie-agreement> - </div> - </tab> - <tab heading="Terms of Use"> - <div class="mt-2"> + </mat-tab> + <mat-tab label="Terms of Use"> <kgtos-component> </kgtos-component> - </div> - </tab> - </tabset> + </mat-tab> + </mat-tab-group> + </mat-dialog-content> + + <mat-dialog-actions class="justify-content-center"> + <button + mat-stroked-button + (click)="closeModal('help')" + cdkFocusInitial> + close + </button> + </mat-dialog-actions> </ng-template> + +<!-- signin --> <ng-template #signinModalComponent> - <signin-modal> - - </signin-modal> + <h2 mat-dialog-title>Sign in</h2> + <mat-dialog-content> + <signin-modal> + </signin-modal> + </mat-dialog-content> </ng-template> +<!-- kg tos --> <ng-template #kgToS> + <h2 mat-dialog-title>Knowldge Graph ToS</h2> + <mat-dialog-content class="w-50vw"> <kgtos-component> </kgtos-component> - <div class="modal-footer"> - <button type="button" class="btn btn-primary" (click)="kgTosClickedOk()">Ok</button> - </div> - </ng-template> + </mat-dialog-content> + <mat-dialog-actions class="justify-content-end"> + <button + color="primary" + mat-raised-button + (click)="kgTosClickedOk()" + cdkFocusInitial> + Ok + </button> + </mat-dialog-actions> +</ng-template> + +<!-- cookie --> <ng-template #cookieAgreementComponent> - <cookie-agreement> - </cookie-agreement> + <h2 mat-dialog-title>Cookie Disclaimer</h2> + <mat-dialog-content class="w-50vw"> + <cookie-agreement> + </cookie-agreement> + </mat-dialog-content> - <div class="modal-footer"> - <button type="button" class="btn btn-primary" (click)="cookieClickedOk()">Ok</button> - </div> + <mat-dialog-actions class="justify-content-end"> + <button + color="primary" + mat-raised-button + (click)="cookieClickedOk()" + cdkFocusInitial> + Ok + </button> + </mat-dialog-actions> </ng-template> <!-- atlas template --> diff --git a/src/atlasViewer/atlasViewer.urlService.service.ts b/src/atlasViewer/atlasViewer.urlService.service.ts index 8f5dbc96c88a11b1b6d9013891cff2f87edceb52..1008c7a50e424d52c4c1bc32fc072163d8bd852e 100644 --- a/src/atlasViewer/atlasViewer.urlService.service.ts +++ b/src/atlasViewer/atlasViewer.urlService.service.ts @@ -60,6 +60,9 @@ export class AtlasViewerURLService{ select('templateSelected'), filter(v => !!v) ), + /** + * TODO duplicated with viewerState.loadedNgLayers ? + */ this.store.pipe( select('ngViewerState'), select('layers') @@ -171,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}`) } @@ -209,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/widgetUnit.component.ts b/src/atlasViewer/widgetUnit/widgetUnit.component.ts index b1667492cc7306ce708d7a1dc0393592e9566cb4..35463f92e9d75f4804964dae65439e52c9984a35 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.component.ts +++ b/src/atlasViewer/widgetUnit/widgetUnit.component.ts @@ -1,4 +1,5 @@ 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"; @@ -14,7 +15,6 @@ import { map } from "rxjs/operators"; 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' @@ -31,25 +31,56 @@ export class WidgetUnit implements OnInit, OnDestroy{ isMinimised$: Observable<boolean> /** - * TODO - * upgrade to angular>=7, and use cdk to handle draggable components + * Timed alternates of blinkOn property should result in attention grabbing blink behaviour */ - get transform(){ - return this.state === 'floating' ? - `translate(${this.position[0]}px, ${this.position[1]}px)` : - `translate(0 , 0)` + private _blinkOn: boolean = false + get blinkOn(){ + return this._blinkOn + } + + set blinkOn(val: boolean) { + this._blinkOn = !!val + } + + get showProgress(){ + return this.progressIndicator !== null + } + + /** + * 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 + */ + 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() @@ -67,7 +98,7 @@ export class WidgetUnit implements OnInit, OnDestroy{ public id: string constructor( private constantsService: AtlasViewerConstantsServices - ){ + ){ this.id = Date.now().toString() } 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 f6114ed86a8151d84023ee0e283b0dd581034f28..8ddb291d165ab8771f9e6b1f6e36ea8ea465f2d4 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -29,7 +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({ @@ -53,7 +56,10 @@ import { SleightOfHand } from './sleightOfHand/soh.component'; TimerComponent, PillComponent, RadioList, + ProgressBar, SleightOfHand, + DialogComponent, + ConfirmDialogComponent, /* directive */ HoverableBlockDirective, @@ -85,7 +91,10 @@ import { SleightOfHand } from './sleightOfHand/soh.component'; 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/plugin_examples/samplePlugin/template.html b/src/components/confirmDialog/confirmDialog.style.css similarity index 100% rename from src/plugin_examples/samplePlugin/template.html rename to src/components/confirmDialog/confirmDialog.style.css 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 index ed7d8cfab7f06eb27731ebdbb868476bb2152108..4b6fbe5fdb989088e847e10716703dfb4c044c5a 100644 --- a/src/components/sleightOfHand/soh.component.ts +++ b/src/components/sleightOfHand/soh.component.ts @@ -1,13 +1,33 @@ -import { Component } from "@angular/core"; +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 index b862eb7212f10e399d6ac5ee6d8acd46cd865aac..2c4f61aa210ae817635fc0764eb87f5cbe187c50 100644 --- a/src/components/sleightOfHand/soh.style.css +++ b/src/components/sleightOfHand/soh.style.css @@ -1,10 +1,6 @@ -:host:not(:hover) > .sleight-of-hand-back -{ - opacity: 0; - pointer-events: none; -} - -:host:hover > .sleight-of-hand-front +: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; 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 1fb66d55c46ea77a249b850a7a349d681d4528f2..6640175d0d34b0a0b1d338aaf770bc3ab0f2f92b 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"; @@ -18,11 +18,9 @@ import { WidgetServices } from './atlasViewer/widgetUnit/widgetService.service' import { fasTooltipScreenshotDirective,fasTooltipInfoSignDirective,fasTooltipLogInDirective,fasTooltipNewWindowDirective,fasTooltipQuestionSignDirective,fasTooltipRemoveDirective,fasTooltipRemoveSignDirective } from "./util/directives/glyphiconTooltip.directive"; import { TooltipModule } from "ngx-bootstrap/tooltip"; import { TabsModule } from 'ngx-bootstrap/tabs' -import { ModalModule } from 'ngx-bootstrap/modal' 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"; @@ -35,13 +33,19 @@ import { FloatingContainerDirective } from "./util/directives/floatingContainer. import { PluginFactoryDirective } from "./util/directives/pluginFactory.directive"; import { FloatingMouseContextualContainerDirective } from "./util/directives/floatingMouseContextualContainer.directive"; import { AuthService } from "./services/auth.service"; -import { ViewerConfiguration } from "./services/state/viewerConfig.store"; +import { ViewerConfiguration, LOCAL_STORAGE_CONST } from "./services/state/viewerConfig.store"; import { FixedMouseContextualContainerDirective } from "./util/directives/FixedMouseContextualContainerDirective.directive"; import { DatabrowserService } from "./ui/databrowserModule/databrowser.service"; import { TransformOnhoverSegmentPipe } from "./atlasViewer/onhoverSegment.pipe"; import {HttpClientModule} from "@angular/common/http"; import { EffectsModule } from "@ngrx/effects"; import { UseEffects } from "./services/effect/effect"; +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 { MatDialogModule, MatTabsModule } from "@angular/material"; +import { ViewerStateUseEffect } from "./services/state/viewerState.store"; @NgModule({ imports : [ @@ -52,12 +56,20 @@ import { UseEffects } from "./services/effect/effect"; DragDropModule, UIModule, AngularMaterialModule, + + /** + * move to angular material module + */ + MatDialogModule, + MatTabsModule, - ModalModule.forRoot(), TooltipModule.forRoot(), TabsModule.forRoot(), EffectsModule.forRoot([ - UseEffects + UseEffects, + UserConfigStateUseEffect, + ViewerStateControllerUseEffect + ViewerStateUseEffect, ]), StoreModule.forRoot({ pluginState, @@ -67,6 +79,7 @@ import { UseEffects } from "./services/effect/effect"; dataStore, spatialSearchState, uiState, + userConfigState }), HttpClientModule ], @@ -96,7 +109,6 @@ import { UseEffects } from "./services/effect/effect"; GetNamesPipe, GetNamePipe, TransformOnhoverSegmentPipe, - GetFilenameFromPathnamePipe, NewViewerDisctinctViewToLayer ], entryComponents : [ @@ -104,6 +116,8 @@ import { UseEffects } from "./services/effect/effect"; ModalUnit, ToastComponent, PluginUnit, + DialogComponent, + ConfirmDialogComponent, ], providers : [ AtlasViewerDataService, @@ -113,6 +127,7 @@ import { UseEffects } from "./services/effect/effect"; ToastService, AtlasWorkerService, AuthService, + DialogService, /** * TODO @@ -141,9 +156,13 @@ export class MainModule{ authServce.authReloadState() store.pipe( select('viewerConfigState') - ).subscribe(({ gpuLimit }) => { - if (gpuLimit) - window.localStorage.setItem('iv-gpulimit', gpuLimit.toString()) + ).subscribe(({ gpuLimit, animation }) => { + if (gpuLimit) { + window.localStorage.setItem(LOCAL_STORAGE_CONST.GPU_LIMIT, gpuLimit.toString()) + } + if (typeof animation !== 'undefined' && animation !== null) { + window.localStorage.setItem(LOCAL_STORAGE_CONST.ANIMATION, animation.toString()) + } }) } } \ No newline at end of file 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/README.md b/src/plugin_examples/README.md index 1549c0963fcea559271259fa0fd121187394e066..2b1b126b3a0166eb2a416a5493705d5be63acd5e 100644 --- a/src/plugin_examples/README.md +++ b/src/plugin_examples/README.md @@ -1,5 +1,5 @@ -Plugin README -====== +# Plugin README + A plugin needs to contain three files. - Manifest JSON - template HTML @@ -9,8 +9,9 @@ A plugin needs to contain three files. These files need to be served by GET requests over HTTP with appropriate CORS header. If your application requires a backend, it is strongly recommended to host these three files with your backend. --- -Manifest JSON ------- + +## Manifest JSON + The manifest JSON file describes the metadata associated with the plugin. ```json @@ -34,8 +35,9 @@ The manifest JSON file describes the metadata associated with the plugin. - the `initState` object and `initStateUrl` will be available prior to the evaluation of `script.js`, and will populate the objects `interactiveViewer.pluginControl[MANIFEST.name].initState` and `interactiveViewer.pluginControl[MANIFEST.name].initStateUrl` respectively. --- -Template HTML ------- + +## Template HTML + The template HTML file describes the HTML view that will be rendered in the widget. @@ -74,14 +76,17 @@ The template HTML file describes the HTML view that will be rendered in the widg </div> </form> ``` + *NB* - *bootstrap 3.3.6* css is already included for templating. - keep in mind of the widget width restriction (400px) when crafting the template - whilst there are no vertical limits on the widget, contents can be rendered outside the viewport. Consider setting the *max-height* attribute. - your template and script will interact with each other likely via *element id*. As a result, it is highly recommended that unique id's are used. Please adhere to the convention: **AFFILIATION.AUTHOR.PACKAGENAME.ELEMENTID** + --- -Script JS ------- + +## Script JS + The script will always be appended **after** the rendering of the template. ```javascript 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/plugin_examples/samplePlugin/manifest.json b/src/plugin_examples/samplePlugin/manifest.json deleted file mode 100644 index 765339e14a65bceeb5e8fea2a4056ebfa868b894..0000000000000000000000000000000000000000 --- a/src/plugin_examples/samplePlugin/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name":"fzj.xg.samplePlugin", - "displayName":"Sample Plugin Display Name (Optional)", - "templateURL":"http://localhost:10080/samplePlugin/template.html", - "scriptURL":"http://localhost:10080/samplePlugin/script.js", - "initState":{ - "hello": "world", - "foo": "bar" - }, - "initStateUrl":"http://localhost:10080/samplePlugin/optionalInitStateJson.json" -} \ No newline at end of file diff --git a/src/plugin_examples/samplePlugin/optionalInitStateJson.json b/src/plugin_examples/samplePlugin/optionalInitStateJson.json deleted file mode 100644 index 8b478475478e55bd6855b11061661c5ed524b5c9..0000000000000000000000000000000000000000 --- a/src/plugin_examples/samplePlugin/optionalInitStateJson.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "foo2": "bar2" -} \ No newline at end of file diff --git a/src/plugin_examples/samplePlugin/script.js b/src/plugin_examples/samplePlugin/script.js deleted file mode 100644 index 1dbd080572707585356a699dc4f76d7f944325aa..0000000000000000000000000000000000000000 --- a/src/plugin_examples/samplePlugin/script.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * use IIEF to avoid scope poisoning - */ -(() => { - const PLUGIN_NAME = 'fzj.xg.samplePlugin' - const initState = window.interactiveViewer.pluginControl[PLUGIN_NAME].initState - const initUrl = window.interactiveViewer.pluginControl[PLUGIN_NAME].initStateUrl - console.log(initState, initUrl) -})() \ No newline at end of file diff --git a/src/plugin_examples/server.js b/src/plugin_examples/server.js deleted file mode 100644 index 355a0d40287f3748f6100ef6995e8583f3c2d920..0000000000000000000000000000000000000000 --- a/src/plugin_examples/server.js +++ /dev/null @@ -1,44 +0,0 @@ -const express = require('express') -const fs = require('fs') -const path = require('path') - -const app = express() - -const cors = (req, res, next)=>{ - res.setHeader('Access-Control-Allow-Origin','*') - next() -} - -app.get('/allPluginManifests', cors, (req, res) => { - try{ - res.status(200).send(JSON.stringify( - fs.readdirSync(__dirname) - .filter(file => fs.statSync(path.join(__dirname, file)).isDirectory()) - .filter(dir => fs.existsSync(path.join(__dirname, dir, 'manifest.json'))) - .map(dir => JSON.parse(fs.readFileSync(path.join(__dirname, dir, 'manifest.json'), 'utf-8'))) - )) - }catch(e){ - res.status(500).send(JSON.stringify(e)) - } -}) - -app.get('/test.json', cors, (req, res) => { - - console.log('test.json pinged') - res.status(200).send(JSON.stringify({ - "name": "fzj.xg.mime", - "displayName":"Mime", - "type": "plugin", - "templateURL": "http://localhost:10080/mime/template.html", - "scriptURL": "http://localhost:10080/mime/script.js", - "initState" : { - "test" : "value" - } - })) -}) - -app.use(cors,express.static(__dirname)) - -app.listen(10080, () => { - console.log(`listening on 10080, serving ${__dirname}`) -}) \ No newline at end of file diff --git a/src/res/css/extra_styles.css b/src/res/css/extra_styles.css index bef52b79290f782e09f6f08e67a9ea7a5fc8adc2..f383116251b99198bd07bb5ba980211616b166bf 100644 --- a/src/res/css/extra_styles.css +++ b/src/res/css/extra_styles.css @@ -301,11 +301,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; @@ -333,7 +348,7 @@ markdown-dom pre code .overflow-x-hidden { - overflow-x:hidden; + overflow-x:hidden!important; } .muted @@ -360,4 +375,32 @@ markdown-dom pre code .bs-content-box { box-sizing: content-box; +} + +/* required to hide */ +.cdk-global-scrollblock +{ + overflow-y:hidden !important; +} +.h-90vh +{ + height: 90vh!important; +} + +.w-50vw +{ + width: 50vw!important; +} +/* TODO fix hack */ +/* ngx boostrap default z index for modal-container is 1050, which is higher than material tooltip 1000 */ +/* when migration away from ngx bootstrap is complete, remove these classes */ + +modal-container.modal +{ + z-index: 950; +} + +bs-modal-backdrop.modal-backdrop +{ + z-index: 940; } \ No newline at end of file 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/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 7ab1195be0cd9d02f8522e2bc00688998fc0dc23..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, Observable } from "rxjs"; -import { withLatestFrom, map, filter, shareReplay } from "rxjs/operators"; +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, DESELECT_REGIONS } 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'; @@ -44,6 +44,27 @@ export class UseEffects implements OnDestroy{ } }) ) + + 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[]> @@ -75,48 +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 @@ -143,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/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/viewerConfig.store.ts b/src/services/state/viewerConfig.store.ts index eb634fee9dbeda27a7e5915258048bd43b93c885..e6bf3881472833ec2f2adbf939ecbf41f21ee993 100644 --- a/src/services/state/viewerConfig.store.ts +++ b/src/services/state/viewerConfig.store.ts @@ -21,16 +21,29 @@ export const CONFIG_CONSTANTS = { } export const ACTION_TYPES = { + SET_ANIMATION: `SET_ANIMATION`, UPDATE_CONFIG: `UPDATE_CONFIG`, CHANGE_GPU_LIMIT: `CHANGE_GPU_LIMIT` } -const lsGpuLimit = localStorage.getItem('iv-gpulimit') +export const LOCAL_STORAGE_CONST = { + GPU_LIMIT: 'iv-gpulimit', + ANIMATION: 'iv-animationFlag' +} + +const lsGpuLimit = localStorage.getItem(LOCAL_STORAGE_CONST.GPU_LIMIT) +const lsAnimationFlag = localStorage.getItem(LOCAL_STORAGE_CONST.ANIMATION) const gpuLimit = lsGpuLimit && !isNaN(Number(lsGpuLimit)) ? Number(lsGpuLimit) : CONFIG_CONSTANTS.defaultGpuLimit -export function viewerConfigState(prevState:ViewerConfiguration = {animation: CONFIG_CONSTANTS.defaultAnimation, gpuLimit}, action:ViewerConfigurationAction) { +const animation = lsAnimationFlag && lsAnimationFlag === 'true' + ? true + : lsAnimationFlag === 'false' + ? false + : CONFIG_CONSTANTS.defaultAnimation + +export function viewerConfigState(prevState:ViewerConfiguration = {animation, gpuLimit}, action:ViewerConfigurationAction) { switch (action.type) { case ACTION_TYPES.UPDATE_CONFIG: return { diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index 874b8e311828accc4fde2e5a73287e869e05eb7a..07702ffc0adbc76c0796193314c8769b8a6db33c 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,6 +38,8 @@ export interface AtlasAction extends Action{ deselectLandmarks : UserLandmark[] navigation? : any + + payload: any } export function viewerState( @@ -42,6 +48,7 @@ export function viewerState( fetchedTemplates : [], loadedNgLayers: [], regionsSelected: [] + userLandmarks: [] }, action:AtlasAction ){ @@ -107,7 +114,10 @@ export function viewerState( const { updatedParcellation } = action return { ...state, - parcellationSelected: updatedParcellation + parcellationSelected: { + ...updatedParcellation, + updated: true + } } } case SELECT_REGIONS: @@ -134,6 +144,10 @@ export function viewerState( userLandmarks: action.landmarks } } + /** + * TODO + * duplicated with ngViewerState.layers ? + */ case NEHUBA_LAYER_CHANGED: { if (!window['viewer']) { return { @@ -175,4 +189,81 @@ 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/config/config.component.ts b/src/ui/config/config.component.ts index e0104405a1b4bc8efda4e8d5b8e18af4598b7b06..12a3a700a6f84bdf71fa77699c0be0b772756385 100644 --- a/src/ui/config/config.component.ts +++ b/src/ui/config/config.component.ts @@ -2,7 +2,11 @@ import { Component, OnInit, OnDestroy } from '@angular/core' import { Store, select } from '@ngrx/store'; import { ViewerConfiguration, ACTION_TYPES } from 'src/services/state/viewerConfig.store' import { Observable, Subject, Subscription } from 'rxjs'; -import { map, distinctUntilChanged, debounce, debounceTime } from 'rxjs/operators'; +import { map, distinctUntilChanged, debounceTime } from 'rxjs/operators'; +import { MatSlideToggleChange } from '@angular/material'; + +const GPU_TOOLTIP = `GPU TOOLTIP` +const ANIMATION_TOOLTIP = `ANIMATION_TOOLTIP` @Component({ selector: 'config-component', @@ -14,10 +18,15 @@ import { map, distinctUntilChanged, debounce, debounceTime } from 'rxjs/operator export class ConfigComponent implements OnInit, OnDestroy{ + public GPU_TOOLTIP = GPU_TOOLTIP + public ANIMATION_TOOLTIP = ANIMATION_TOOLTIP + /** * in MB */ public gpuLimit$: Observable<number> + + public animationFlag$: Observable<boolean> public keydown$: Subject<Event> = new Subject() private subscriptions: Subscription[] = [] @@ -31,6 +40,11 @@ export class ConfigComponent implements OnInit, OnDestroy{ distinctUntilChanged(), map(v => v / 1e6) ) + + this.animationFlag$ = this.store.pipe( + select('viewerConfigState'), + map((config:ViewerConfiguration) => config.animation), + ) } ngOnInit(){ @@ -64,6 +78,16 @@ export class ConfigComponent implements OnInit, OnDestroy{ }) } + public toggleAnimationFlag(ev: MatSlideToggleChange ){ + const { checked } = ev + this.store.dispatch({ + type: ACTION_TYPES.UPDATE_CONFIG, + config: { + animation: checked + } + }) + } + public setGpuPreset({value}: {value: number}) { this.store.dispatch({ type: ACTION_TYPES.UPDATE_CONFIG, diff --git a/src/ui/config/config.template.html b/src/ui/config/config.template.html index 18f84d0b297f9ef71422960695f032c567faafb1..dca3b164d0fbe6bd4235e50e856073835acb13d0 100644 --- a/src/ui/config/config.template.html +++ b/src/ui/config/config.template.html @@ -1,7 +1,8 @@ -<div class="input-group"> +<div class="input-group mb-2"> <span class="input-group-prepend"> <span class="input-group-text"> GPU Limit + <small tooltipClass="z-1060" [matTooltip]="GPU_TOOLTIP" class="ml-2 fas fa-question"></small> </span> </span> <input @@ -17,16 +18,25 @@ <div class="input-group-append"> <div (click)="setGpuPreset({ value: 100 })" class="btn btn-outline-secondary"> - 100 - </div> - <div (click)="setGpuPreset({ value: 500 })" class="btn btn-outline-secondary"> - 500 - </div> - <div (click)="setGpuPreset({ value: 1000 })" class="btn btn-outline-secondary"> - 1000 - </div> - <span class="input-group-text"> - MB - </span> + 100 </div> - </div> \ No newline at end of file + <div (click)="setGpuPreset({ value: 500 })" class="btn btn-outline-secondary"> + 500 + </div> + <div (click)="setGpuPreset({ value: 1000 })" class="btn btn-outline-secondary"> + 1000 + </div> + <span class="input-group-text"> + MB + </span> + </div> +</div> + +<div class="d-flex align-items-center"> + <mat-slide-toggle + [checked]="animationFlag$ | async" + (change)="toggleAnimationFlag($event)"> + Enable Animation + </mat-slide-toggle> + <small [matTooltip]="ANIMATION_TOOLTIP" class="ml-2 fas fa-question"></small> +</div> \ No newline at end of file diff --git a/src/ui/cookieAgreement/cookieAgreement.template.html b/src/ui/cookieAgreement/cookieAgreement.template.html index d0b6acdf5d2a98b0ec64f94640507d22c283cc91..b92a7d8f8c7d592823ad9b7fcd4ff198685f1c1e 100644 --- a/src/ui/cookieAgreement/cookieAgreement.template.html +++ b/src/ui/cookieAgreement/cookieAgreement.template.html @@ -11,11 +11,17 @@ <p>To opt-out of being tracked by Google Analytics across all websites, visit <a href="http://tools.google.com/dlpage/gaoptout">http://tools.google.com/dlpage/gaoptout</a> .</p> - <button - class="btn btn-outline-info btn-block mb-2" - (click)="showMore = !showMore"> - Show {{showMore? "less" : "more"}} - </button> + <div class="d-flex"> + + <button + mat-stroked-button + color="primary" + class="d-flex justify-content-center flex-grow-1" + (click)="showMore = !showMore"> + Show {{showMore? "less" : "more"}} + </button> + + </div> <div *ngIf="showMore"> <small> diff --git a/src/ui/databrowserModule/databrowser.module.ts b/src/ui/databrowserModule/databrowser.module.ts index d272cf211d8f2b0648453c12ddccd88d7f094ce1..42dcfc4305c466b61730dbe75a49b2bc5dddddab 100644 --- a/src/ui/databrowserModule/databrowser.module.ts +++ b/src/ui/databrowserModule/databrowser.module.ts @@ -25,6 +25,7 @@ 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 { RegionBackgroundToRgbPipe } from "./util/regionBackgroundToRgb.pipe"; @NgModule({ imports:[ @@ -56,7 +57,8 @@ import { DoiParserPipe } from "src/util/pipes/doiPipe.pipe"; FilterDataEntriesbyMethods, FilterDataEntriesByRegion, AggregateArrayIntoRootPipe, - DoiParserPipe + DoiParserPipe, + RegionBackgroundToRgbPipe ], exports:[ DataBrowser, diff --git a/src/ui/databrowserModule/databrowser.service.ts b/src/ui/databrowserModule/databrowser.service.ts index 0b3c964f71c4819410a5af212bd0355b4411ec13..1400e7a55213e8a95473c25eae03e8e52b549846 100644 --- a/src/ui/databrowserModule/databrowser.service.ts +++ b/src/ui/databrowserModule/databrowser.service.ts @@ -309,12 +309,6 @@ export class DatabrowserService implements OnDestroy{ } public getModalityFromDE = getModalityFromDE - - public getBackgroundColorStyleFromRegion(region:any = null){ - return region && region.rgb - ? `rgb(${region.rgb.join(',')})` - : `white` - } } diff --git a/src/ui/databrowserModule/databrowser/databrowser.component.ts b/src/ui/databrowserModule/databrowser/databrowser.component.ts index b3568e039e3be5c046e2571e2d014ed1cac1877d..9a4f538541b7f4301fa6e7ff3d6e4932f9d5dd2c 100644 --- a/src/ui/databrowserModule/databrowser/databrowser.component.ts +++ b/src/ui/databrowserModule/databrowser/databrowser.component.ts @@ -155,10 +155,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..f852c03d933f8f7740a696510c058a0e622c9962 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"> @@ -122,7 +122,7 @@ <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/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 2773794f939241cccee6d4b5457ccee0e0a6511c..3ff7f8b1db939b83b0fb91d7f057aa84841c0a04 100644 --- a/src/ui/menuicons/menuicons.component.ts +++ b/src/ui/menuicons/menuicons.component.ts @@ -2,7 +2,6 @@ import { Component, ComponentRef, Injector, ComponentFactory, ComponentFactoryRe 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"; @@ -10,7 +9,7 @@ import { DatabrowserService } from "../databrowserModule/databrowser.service"; import { PluginServices, PluginManifest } from "src/atlasViewer/atlasViewer.pluginService.service"; import { Store, select } from "@ngrx/store"; import { Observable, BehaviorSubject, combineLatest, merge, of } from "rxjs"; -import { map, shareReplay } from "rxjs/operators"; +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"; @@ -34,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 */ @@ -56,9 +48,6 @@ export class MenuIconsBar{ public themedBtnClass$: Observable<string> public skeletonBtnClass$: Observable<string> - - private layerBrowserExists$: BehaviorSubject<boolean> = new BehaviorSubject(false) - public layerBrowserBtnClass$: Observable<string> public toolBtnClass$: Observable<string> public getKgSearchBtnCls$: Observable<[Set<WidgetUnit>, string]> @@ -87,7 +76,6 @@ export class MenuIconsBar{ 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( @@ -95,13 +83,10 @@ export class MenuIconsBar{ select('templateSelected') ) - this.selectedRegions$ = merge( - of([]), - store.pipe( - select('viewerState'), - select('regionsSelected') - ) - ).pipe( + this.selectedRegions$ = store.pipe( + select('viewerState'), + select('regionsSelected'), + startWith([]), shareReplay(1) ) @@ -115,18 +100,14 @@ export class MenuIconsBar{ shareReplay(1) ) - this.layerBrowserBtnClass$ = combineLatest( - this.layerBrowserExists$, - this.themedBtnClass$ - ).pipe( - map(([flag,themedBtnClass]) => `${this.mobileRespBtnClass} ${flag ? 'btn-primary' : themedBtnClass}`) - ) - 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$, @@ -135,6 +116,9 @@ export class MenuIconsBar{ this.darktheme$ = this.constantService.darktheme$ + /** + * TODO remove dependency on themedBtnClass$ + */ this.getKgSearchBtnCls$ = combineLatest( this.widgetServices.minimisedWindow$, this.themedBtnClass$ @@ -170,37 +154,6 @@ 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.layerBrowserExists$.next(true) - - this.lbWidget.onDestroy(() => { - this.layerBrowserExists$.next(false) - 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() @@ -245,45 +198,6 @@ export class MenuIconsBar{ this.widgetServices.exitWidget(wu) } - public deselectRegion(event: MouseEvent, region: any){ - event.stopPropagation() - - this.store.dispatch({ - type: DESELECT_REGIONS, - deselectRegions: [region] - }) - } - - public deselectAllRegions(event: MouseEvent){ - event.stopPropagation() - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: [] - }) - } - - public gotoRegion(event: MouseEvent, region:any){ - event.stopPropagation() - - 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 - }) - } - } - public renameKgSearchWidget(event:MouseEvent, wu: WidgetUnit) { event.stopPropagation() } diff --git a/src/ui/menuicons/menuicons.style.css b/src/ui/menuicons/menuicons.style.css index 1873b453df3ac917f1f16c1f00300ff381a7a39a..c76c706c7151f5276eaba98168489ee4bfc82dc5 100644 --- a/src/ui/menuicons/menuicons.style.css +++ b/src/ui/menuicons/menuicons.style.css @@ -32,38 +32,7 @@ margin-top: 0.1em; } -.virtual-scroll-viewport-container +layer-browser { - 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; -} + 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 796fe089095132054a501c5ec23b3046c1ea0725..00b4065a9219b171ebc38c8df0dce6d503eaa460 100644 --- a/src/ui/menuicons/menuicons.template.html +++ b/src/ui/menuicons/menuicons.template.html @@ -5,92 +5,148 @@ <ng-template [ngIf]="selectedTemplate$ | async"> <!-- layer browser --> - <div - matTooltip="Layer" - matTooltipPosition="right" - (click)="clickLayer($event)" - [class]="layerBrowserBtnClass$ | async" - class="btn"> - <i class="fas fa-layer-group"> - </i> - </div> + <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 + 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> + </sleight-of-hand> + <!-- tools --> <sleight-of-hand> <!-- shown icon prior to mouse over --> - <div - [matBadgePosition]="badgetPosition" - [matBadge]="(launchedPlugins$ | async)?.length > 0 ? (launchedPlugins$ | async)?.length : null" - [class]="(skeletonBtnClass$ | async) + ' btn'" - sleight-of-hand-front> - <i class="fas fa-tools"></i> + <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> <!-- shown after mouse over --> <div - class="d-flex flex-row soh-row" + class="d-flex flex-row soh-row align-items-start" sleight-of-hand-back> <!-- placeholder icon --> - <div + <button [matBadgePosition]="badgetPosition" + matBadgeColor="accent" [matBadge]="(launchedPlugins$ | async)?.length > 0 ? (launchedPlugins$ | async)?.length : null" - [class]="(skeletonBtnClass$ | async) + ' btn muted'"> + mat-icon-button + color="primary"> <i class="fas fa-tools"></i> - </div> + </button> <!-- render all fetched tools --> - <div class="d-flex flex-column soh-column"> - <div - *ngFor="let manifest of pluginServices.fetchedPluginManifests" - [matTooltip]="manifest.displayName || manifest.name" - matTooltipPosition="right" - (click)="clickPluginIcon(manifest)" - [class]="(getPluginBtnClass$ | async | menuIconPluginBtnClsPipe : manifest.name) + ' btn w-1em bs-content-box ' + mobileRespBtnClass"> - {{ (manifest.displayName || manifest.name).slice(0, 1) }} - </div> + <div class="d-flex flex-row soh-row"> <!-- add new tool btn --> - <div + <button matTooltip="Add new plugin" - [matTooltipShowDelay]="100" - matTooltipPosition="right" - [class]="(skeletonBtnClass$ | async)"> + matTooltipPosition="below" + mat-icon-button + color="primary"> <i class="fas fa-plus"></i> - </div> + </button> + + <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> </sleight-of-hand> <!-- search kg --> <sleight-of-hand> + <!-- shown icon prior to mouse over --> - <div - sleight-of-hand-front - [class]="(skeletonBtnClass$ | async) + ' btn'" - [matBadgePosition]="badgetPosition" - [matBadge]="dbService.instantiatedWidgetUnits.length > 0 ? dbService.instantiatedWidgetUnits.length : null"> - <i class="fas fa-search"></i> + <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 sleight-of-hand-back - class="d-flex flex-row align-items-center soh-row"> + class="d-flex flex-row align-items-center soh-row pe-none"> <!-- placeholder icon --> - <div - [ngClass]="(skeletonBtnClass$ | async) + ' btn muted'" + <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> - </div> + </button> <!-- only renders if there is at least one search result --> <div *ngIf="dbService.instantiatedWidgetUnits.length > 0; else noKgSearchTemplate" - class="position-relative"> + class="position-relative pe-all"> <div class="position-absolute d-flex flex-column soh-column"> @@ -100,17 +156,24 @@ (click)="searchIconClickHandler(wu)"> <!-- shown prior to mouseover --> - <div - [class]="(getKgSearchBtnCls$ | async | menuIconKgSearchBtnClsPipe : wu) + ' position-relative sleight-of-hand btn w-1em bs-content-box ' + mobileRespBtnClass" - sleight-of-hand-front> - <i class="fas fa-search"></i> + <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 [class]="(getKgSearchBtnCls$ | async | menuIconKgSearchBtnClsPipe : wu) + ' btn w-1em bs-content-box ' + mobileRespBtnClass"> - <i class="fas fa-search"></i> + + <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 --> @@ -179,7 +242,9 @@ <!-- invisible icon to keep height of the otherwise unstable flex block --> <div class="invisible pe-none"> - <i class="fas fa-search"></i> + <button mat-icon-button> + <i class="fas fa-search"></i> + </button> </div> </div> @@ -194,16 +259,19 @@ </sleight-of-hand> <!-- selected regions --> - <sleight-of-hand> + <sleight-of-hand + [doNotClose]="viewerStateController.focused"> <!-- shown prior to mouse over --> - <div - sleight-of-hand-front - [class]="(skeletonBtnClass$ | async) + ' btn'" - [matBadgePosition]="badgetPosition" - matBadgeColor="warn" - [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null"> - <i class="fas fa-brain"></i> + <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> <!-- shown upon mouseover --> @@ -212,84 +280,21 @@ class="d-flex flex-row align-items-center soh-row"> <!-- place holder icon --> - <div - [class]="(skeletonBtnClass$ | async) + ' btn muted'" + <button + [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null" [matBadgePosition]="badgetPosition" - matBadgeColor="warn" - [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null"> + matBadgeColor="accent" + mat-icon-button + color="primary"> <i class="fas fa-brain"></i> - </div> + </button> - <div - *ngIf="(selectedRegions$ | async).length > 0; else noBrainRegionSelected" - class="position-relative"> - - <!-- rendering all the selected regions --> - <div [class]="((darktheme$ | async) ? 'text-light bg-dark' : 'text-dark bg-light') + ' position-absolute'"> - <div class="m-1 position-relative"> - <button - mat-raised-button - (click)="deselectAllRegions($event)" - class="virtual-scroll-row" - color="warn"> - <i class="fas fa-trash"></i> - Deselect all {{ (selectedRegions$ | async).length }} region{{ (selectedRegions$ | async).length > 1 ? 's' : '' }} - </button> - </div> - <mat-divider></mat-divider> - <div class="virtual-scroll-viewport-container"> - <cdk-virtual-scroll-viewport - class="d-flex-flex-column soh-column" - itemSize="26"> - <div - *cdkVirtualFor="let region of (selectedRegions$ | async)" - class="virtual-scroll-unit"> - <sleight-of-hand> - <!-- prior to mouseover --> - <div - [class]="((darktheme$ | async) ? 'text-light bg-dark' : 'text-dark bg-light') + ' d-flex flex-row align-items-center virtual-scroll-row'" - sleight-of-hand-front> - <div class="ml-2 cursor-default text-nowrap"> - {{ region.name }} - </div> - - <!-- place holder icon to support height --> - <div [class]="(skeletonBtnClass$ | async) + ' invisible w-0 pe-none'"> - <i class="fas fa-trash"></i> - </div> - </div> - - <!-- on mouse over --> - <div - [class]="((darktheme$ | async) ? 'text-light bg-dark' : 'text-dark bg-light') + ' d-flex flex-row align-items-center virtual-scroll-row'" - sleight-of-hand-back> - <div class="text-truncate selected-region-container ml-2 cursor-default text-nowrap"> - {{ region.name }} - </div> - <div - *ngIf="region.position" - (click)="gotoRegion($event, region)" - matTooltip="Goto" - matTooltipPosition="below" - [class]="(skeletonBtnClass$ | async) + ' selected-region-actionbtn'"> - <i class="fas fa-map-marked-alt"></i> - </div> - <div - #trashBtn="matTooltip" - (mousedown)="trashBtn.hide()" - (click)="deselectRegion($event, region)" - matTooltip="Deselect" - matTooltipPosition="below" - [class]="(skeletonBtnClass$ | async) + ' selected-region-actionbtn'"> - <i class="fas fa-trash"></i> - </div> - </div> - </sleight-of-hand> - </div> - </cdk-virtual-scroll-viewport> - </div> - </div> + <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> diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index 00760f639cc3b2ae9e0ebe3f8d8f4523b9ee2352..dc5d758c8617f758244d9e43655ae3c2cbf5a386 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -3,7 +3,7 @@ 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 { Observable, Subscription, fromEvent, combineLatest, merge } from "rxjs"; -import { filter,map, take, scan, debounceTime, distinctUntilChanged, switchMap, skip, withLatestFrom, buffer, tap, throttleTime, bufferTime } from "rxjs/operators"; +import { filter,map, take, scan, debounceTime, distinctUntilChanged, switchMap, skip, withLatestFrom, buffer, tap, 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,7 +11,7 @@ import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; import { pipeFromArray } from "rxjs/internal/util/pipe"; import { NEHUBA_READY } 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 { SELECT_REGIONS_WITH_ID, NEHUBA_LAYER_CHANGED, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store"; const getProxyUrl = (ngUrl) => `nifti://${BACKEND_URL}preview/file?fileUrl=${encodeURIComponent(ngUrl.replace(/^nifti:\/\//,''))}` const getProxyOther = ({source}) => /AUTH_227176556f3c4bb38df9feea4b91200c/.test(source) @@ -135,8 +135,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[] = [] @@ -220,13 +218,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( @@ -454,10 +448,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) } @@ -970,14 +961,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) => { 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 a1b13144fec592d6f83f785474645a733994f120..32588e77ca2e1baa6fe71018c330d99444c56af4 100644 --- a/src/ui/sharedModules/angularMaterial.module.ts +++ b/src/ui/sharedModules/angularMaterial.module.ts @@ -6,12 +6,58 @@ import { MatTabsModule, MatTooltipModule, MatBadgeModule, - MatDividerModule + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule } from '@angular/material'; import { NgModule } from '@angular/core'; +/** + * TODO should probably be in src/util + */ + @NgModule({ - imports: [MatDividerModule, MatBadgeModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], - exports: [MatDividerModule, MatBadgeModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], + imports: [ + MatDividerModule, + MatBadgeModule, + MatButtonModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule + ], + exports: [ + MatDividerModule, + MatBadgeModule, + MatButtonModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule + ], }) 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 07428cb0c23ac552916b15f323c58a21c574631f..f559248fe9f5b47382e39b0cb7f3d52da6e7b705 100644 --- a/src/ui/signinBanner/signinBanner.components.ts +++ b/src/ui/signinBanner/signinBanner.components.ts @@ -1,18 +1,9 @@ import {Component, ChangeDetectionStrategy, OnDestroy, OnInit, Input, ViewChild, 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 { Store} 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"; - -const compareParcellation = (o, n) => !o || !n - ? false - : o.name === n.name @Component({ selector: 'signin-banner', @@ -24,199 +15,18 @@ const compareParcellation = (o, n) => !o || !n changeDetection: ChangeDetectionStrategy.OnPush }) -export class SigninBanner implements OnInit, OnDestroy{ - - public compareParcellation = compareParcellation - - private subscriptions: Subscription[] = [] - - public loadedTemplates$: Observable<any[]> +export class SigninBanner{ - 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> - - 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 store: Store<ViewerConfiguration> ){ - 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 { - /** - * TODO convert to snack bar - */ - 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 } showHelp() { @@ -227,56 +37,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 c3d288680c6670e0fe8c0d555e8895d70427d1e1..5636dc1707484127091201d0d09e7bae552e1152 100644 --- a/src/ui/signinBanner/signinBanner.template.html +++ b/src/ui/signinBanner/signinBanner.template.html @@ -2,96 +2,30 @@ 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"> - <div - *ngIf="!isMobile" + <button + matTooltip="About" + matTooltipPosition="below" (click)="showHelp()" - class="btn btn-outline-secondary btn-sm rounded-circle login-icon"> - <i class="fas fa-question-circle"></i> - </div> + mat-icon-button + color="basic"> + <i class="fas fa-question"></i> + </button> </div> <!-- signin --> - <div class="btnWrapper"> - - <div - *ngIf="!isMobile" + <button + [matTooltip]="user && user.name ? ('Logged in as ' + (user && user.name)) : 'Not logged in'" + matTooltipPosition="below" (click)="showSignin()" - class="btn btn-outline-secondary btn-sm rounded-circle login-icon"> - <i - [ngClass]="user ? 'fa-user' : 'fa-sign-in-alt'" - class="fas"></i> - </div> - </div> - - <div *ngIf="isMobile" class="login-button-panel-mobile"> - <div - (click)="showSignin()" - class="login-button-mobile"> - <button mat-button [ngStyle]="{'color': darktheme? '#D7D7D7' : 'black'}">Log In</button> - </div> - - <div (click)="showHelp()" class="login-button-mobile"> - <button mat-button [ngStyle]="{'color': darktheme? '#D7D7D7' : 'black'}">Help</button> - </div> - + mat-icon-button + color="primary"> + <i *ngIf="!user; else userInitialTempl" class="fas fa-user"></i> + <ng-template #userInitialTempl> + {{ (user && user.name || 'Unnamed User').slice(0,1) }} + </ng-template> + </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> \ No newline at end of file diff --git a/src/ui/signinModal/signinModal.template.html b/src/ui/signinModal/signinModal.template.html index 7d2ce3b21d85ca8840b4bf3592f4067d88507f1c..92feed3d10578773b41cc7c24ede52debef857f4 100644 --- a/src/ui/signinModal/signinModal.template.html +++ b/src/ui/signinModal/signinModal.template.html @@ -1,24 +1,29 @@ <div *ngIf="user; else notLoggedIn"> - Hi {{ user.name }}. + Logged in as {{ user && user.name || 'Unnamed User' }}. <a (click)="loginBtnOnclick()" - [href]="logoutHref" - class="btn btn-sm btn-outline-secondary"> - <i class="fas fa-sign-out-alt"></i> Logout + [href]="logoutHref"> + <button + mat-button + color="warn"> + <i class="fas fa-sign-out-alt"></i> Logout + </button> </a> </div> <ng-template #notLoggedIn> <div> Not logged in. Login via: - <div class="btn-group-vertical"> - <a - *ngFor="let m of loginMethods" + + <a *ngFor="let m of loginMethods" + [href]="m.href"> + <button (click)="loginBtnOnclick()" - [href]="m.href" - class="btn btn-sm btn-outline-secondary"> + mat-raised-button + color="primary"> <i class="fas fa-sign-in-alt"></i> {{ m.name }} - </a> - </div> + </button> + </a> + </div> </ng-template> \ No newline at end of file diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 7e69c597b1dfb9e7828b9fcb9dd82d711a090757..a549034fb7e5a4e895b3ed9b96f580ea52be6b28 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -5,7 +5,7 @@ import { NehubaViewerUnit } from "./nehubaContainer/nehubaViewer/nehubaViewer.co import { NehubaContainer } from "./nehubaContainer/nehubaContainer.component"; import { SplashScreen } 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,26 +38,31 @@ 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 { MenuIconPluginBtnClsPipe } from "src/util/pipes/menuIconPluginBtnCls.pipe"; -import { MenuIconKgSearchBtnClsPipe } from "src/util/pipes/menuIconKgSearchBtnCls.pipe"; +import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.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, @@ -90,6 +95,8 @@ import { HttpClientModule } from "@angular/common/http"; StatusCardComponent, CookieAgreement, KGToS, + ViewerStateController, + RegionTextSearchAutocomplete, /* pipes */ GroupDatasetByRegion, @@ -101,12 +108,16 @@ import { HttpClientModule } from "@angular/common/http"; SortDataEntriesToRegion, SpatialLandmarksToDataBrowserItemPipe, FilterNullPipe, - FilterNgLayer, FilterNameBySearch, TemplateParcellationsDecorationPipe, AppendtooltipTextPipe, - MenuIconPluginBtnClsPipe, - MenuIconKgSearchBtnClsPipe, + PluginBtnFabColorPipe, + KgSearchBtnColorPipe, + LockedLayerBtnClsPipe, + GetFilenamePipe, + GetFileExtension, + BinSavedRegionsSelectionPipe, + SavedRegionsSelectionBtnDisabledPipe, /* directive */ DownloadDirective, @@ -117,7 +128,7 @@ import { HttpClientModule } from "@angular/common/http"; /* 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 80% rename from src/ui/regionHierachy/regionHierarchy.component.ts rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts index 397d87c947162bc0c0d9a1c96a45b9032054d1cb..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"; @@ -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/menuIconKgSearchBtnCls.pipe.ts b/src/util/pipes/kgSearchBtnColor.pipe.ts similarity index 55% rename from src/util/pipes/menuIconKgSearchBtnCls.pipe.ts rename to src/util/pipes/kgSearchBtnColor.pipe.ts index 1b6b1d7b560f6d5bc8548b911402d51ff446a76f..c9bbb25bf655bbe4d5e90ef08768f058fae915f2 100644 --- a/src/util/pipes/menuIconKgSearchBtnCls.pipe.ts +++ b/src/util/pipes/kgSearchBtnColor.pipe.ts @@ -2,13 +2,13 @@ import { Pipe, PipeTransform } from "@angular/core"; import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; @Pipe({ - name: 'menuIconKgSearchBtnClsPipe' + name: 'kgSearchBtnColorPipe' }) -export class MenuIconKgSearchBtnClsPipe implements PipeTransform{ - public transform([minimisedWidgetUnit, themedBtnCls]: [Set<WidgetUnit>, string], wu: WidgetUnit, ){ +export class KgSearchBtnColorPipe implements PipeTransform{ + public transform([minimisedWidgetUnit, themedBtnCls]: [Set<WidgetUnit>, string], wu: WidgetUnit ){ return minimisedWidgetUnit.has(wu) - ? themedBtnCls + ' border-primary' - : 'btn-primary' + ? 'primary' + : 'accent' } } \ No newline at end of file diff --git a/src/util/pipes/menuIconPluginBtnCls.pipe.ts b/src/util/pipes/menuIconPluginBtnCls.pipe.ts deleted file mode 100644 index 94df7495cd7c8350908243f1850a9fafdc8aeb23..0000000000000000000000000000000000000000 --- a/src/util/pipes/menuIconPluginBtnCls.pipe.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PipeTransform, Pipe } from "@angular/core"; - -@Pipe({ - name: 'menuIconPluginBtnClsPipe' -}) - -export class MenuIconPluginBtnClsPipe implements PipeTransform{ - public transform([launchedSet, minimisedSet, themedBtnCls], pluginName){ - return `${launchedSet.has(pluginName) - ? minimisedSet.has(pluginName) - ? themedBtnCls + ' border-primary' - : 'btn-primary' - : themedBtnCls}` - } -} \ 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