diff --git a/Dockerfile b/Dockerfile index a20f2d36324fee46ce3a90f029ba9361839ab550..67876d05bf57addb564b5e6c48622c12719ef1ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:8 as builder +FROM node:10 as builder ARG BACKEND_URL ENV BACKEND_URL=$BACKEND_URL @@ -8,12 +8,23 @@ WORKDIR /iv ENV VERSION=devNext +RUN apt update && apt upgrade -y && apt install brotli + RUN npm i RUN npm run build-aot +# gzipping container +FROM ubuntu:18.10 as compressor +RUN apt upgrade -y && apt update && apt install brotli + +RUN mkdir /iv +COPY --from=builder /iv/dist/aot /iv +WORKDIR /iv + +RUN for f in $(find . -type f); do gzip < $f > $f.gz && brotli < $f > $f.br; done # prod container -FROM node:8-alpine +FROM node:10-alpine ARG PORT ENV PORT=$PORT @@ -23,14 +34,15 @@ RUN apk --no-cache add ca-certificates RUN mkdir /iv-app WORKDIR /iv-app -# Copy built interactive viewer -COPY --from=builder /iv/dist/aot ./public - # Copy the express server COPY --from=builder /iv/deploy . +# Copy built interactive viewer +COPY --from=compressor /iv ./public + # Copy the resources files needed to respond to queries -COPY --from=builder /iv/src/res/ext ./res +# is this even necessary any more? +COPY --from=compressor /iv/res/json ./res RUN npm i EXPOSE $PORT diff --git a/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..210e2ac9a32f39229d1f377025c8a1373760603a 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -3,6 +3,7 @@ const express = require('express') const app = express() const session = require('express-session') const MemoryStore = require('memorystore')(session) +const crypto = require('crypto') app.disable('x-powered-by') @@ -10,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,12 +61,23 @@ const PUBLIC_PATH = process.env.NODE_ENV === 'production' ? path.join(__dirname, 'public') : path.join(__dirname, '..', 'dist', 'aot') -app.use(express.static(PUBLIC_PATH)) +/** + * well known path + */ +app.use('/.well-known', express.static(path.join(__dirname, 'well-known'))) + +/** + * only use compression for production + * this allows locally built aot to be served without errors + */ + +const { compressionMiddleware } = require('./compression') +app.use(compressionMiddleware, express.static(PUBLIC_PATH)) -app.use((req, res, next) => { +const jsonMiddleware = (req, res, next) => { res.set('Content-Type', 'application/json') next() -}) +} const templateRouter = require('./templates') const nehubaConfigRouter = require('./nehubaConfig') @@ -57,11 +85,11 @@ const datasetRouter = require('./datasets') const pluginRouter = require('./plugins') const previewRouter = require('./preview') -app.use('/templates', templateRouter) -app.use('/nehubaConfig', nehubaConfigRouter) -app.use('/datasets', datasetRouter) -app.use('/plugins', pluginRouter) -app.use('/preview', previewRouter) +app.use('/templates', jsonMiddleware, templateRouter) +app.use('/nehubaConfig', jsonMiddleware, nehubaConfigRouter) +app.use('/datasets', jsonMiddleware, datasetRouter) +app.use('/plugins', jsonMiddleware, pluginRouter) +app.use('/preview', jsonMiddleware, previewRouter) const catchError = require('./catchError') app.use(catchError) diff --git a/deploy/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/compression/index.js b/deploy/compression/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bc5d2acb4b69d219d1a6c32bb0148296c4d020c4 --- /dev/null +++ b/deploy/compression/index.js @@ -0,0 +1,59 @@ +const BROTLI = `br` +const GZIP = `gzip` + +const detEncoding = (acceptEncoding = '') => { + if (process.env.NODE_ENV !== 'production') return null + + return /br/i.test(acceptEncoding) + ? BROTLI + : /gzip/i.test(acceptEncoding) + ? GZIP + : null +} + +const mimeMap = new Map([ + ['.png', 'image/png'], + ['.gif', 'image/gif'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.css', 'text/css'], + ['.html', 'text/html'], + ['.js', 'text/javascript'] +]) + +exports.BROTLI = BROTLI + +exports.GZIP = GZIP + +exports.detEncoding = detEncoding + +exports.compressionMiddleware = (req, res, next) => { + const acceptEncoding = req.get('Accept-Encoding') + const encoding = detEncoding(acceptEncoding) + + // if no encoding is accepted + // or in dev mode, do not use compression + if (!encoding) return next() + + const ext = /(\.\w*?)$/.exec(req.url) + + // if cannot determine mime-type, do not use encoding + // as Content-Type header is required for browser to understand response + if (!ext || !mimeMap.get(ext[1])) return next() + + res.set('Content-Type', mimeMap.get(ext[1])) + + if (encoding === BROTLI) { + req.url = req.url + '.br' + res.set('Content-Encoding', encoding) + return next() + } + + if (encoding === GZIP) { + req.url = req.url + '.gz' + res.set('Content-Encoding', encoding) + return next() + } + + next() +} \ No newline at end of file diff --git a/deploy/compression/index.spec.js b/deploy/compression/index.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3c1a3826fb3b8151a73c8d180b8d31b49f82c6c4 --- /dev/null +++ b/deploy/compression/index.spec.js @@ -0,0 +1,39 @@ +const mocha = require('mocha') +const chai = require('chai') +const expect = chai.expect + +const { detEncoding, GZIP, BROTLI } = require('./index') + +const gzip = 'gzip' +const gzipDeflate = 'gzip, deflate' +const gzipDeflateBr = 'gzip, deflate, br' + +describe('compression/index.js', () => { + let nodeEnv + + before(() => { + nodeEnv = process.env.NODE_ENV + }) + + after(() => { + process.env.NODE_ENV = nodeEnv + }) + + describe('#detEncoding', () => { + it('When NODE_ENV is set to production, returns appropriate encoding', () => { + process.env.NODE_ENV = 'production' + expect(detEncoding(null)).to.equal(null) + expect(detEncoding(gzip)).to.equal(GZIP) + expect(detEncoding(gzipDeflate)).to.equal(GZIP) + expect(detEncoding(gzipDeflateBr)).to.equal(BROTLI) + }) + + it('When NODE_ENV is set to non production, returns null always', () => { + process.env.NODE_ENV = 'development' + expect(detEncoding(null)).to.equal(null) + expect(detEncoding(gzip)).to.equal(null) + expect(detEncoding(gzipDeflate)).to.equal(null) + expect(detEncoding(gzipDeflateBr)).to.equal(null) + }) + }) +}) \ No newline at end of file diff --git a/deploy/datasets/index.js b/deploy/datasets/index.js index 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/nehubaConfig/index.js b/deploy/nehubaConfig/index.js index a5072c82865d230eb8d5320c028d5f9505ec1e38..145441ee8d730b5a0df2cabccf6e2612d86f2cb4 100644 --- a/deploy/nehubaConfig/index.js +++ b/deploy/nehubaConfig/index.js @@ -1,19 +1,18 @@ const express = require('express') -const path = require('path') -const fs = require('fs') const { getTemplateNehubaConfig } = require('./query') +const { detEncoding } = require('../compression') const nehubaConfigRouter = express.Router() nehubaConfigRouter.get('/:configId', (req, res, next) => { + + const header = req.get('Accept-Encoding') + const acceptedEncoding = detEncoding(header) + const { configId } = req.params - getTemplateNehubaConfig(configId) - .then(data => res.status(200).send(data)) - .catch(error => next({ - code: 500, - error, - trace: 'nehubaConfigRouter#getTemplateNehubaConfig' - })) + if (acceptedEncoding) res.set('Content-Encoding', acceptedEncoding) + + getTemplateNehubaConfig({ configId, acceptedEncoding, returnAsStream:true}).pipe(res) }) module.exports = nehubaConfigRouter \ No newline at end of file diff --git a/deploy/nehubaConfig/query.js b/deploy/nehubaConfig/query.js index 972c3d59633cd3748d3678a636f8bcecdf7ed0d9..23a6132ba39c72a83f47159e3ed106a296ac7250 100644 --- a/deploy/nehubaConfig/query.js +++ b/deploy/nehubaConfig/query.js @@ -1,15 +1,31 @@ const fs = require('fs') const path = require('path') +const { BROTLI, GZIP } = require('../compression') -exports.getTemplateNehubaConfig = (configId) => new Promise((resolve, reject) => { - let filepath +const getFileAsPromise = filepath => new Promise((resolve, reject) => { + fs.readFile(filepath, 'utf-8', (err, data) => { + if (err) return reject(err) + resolve(data) + }) +}) + +exports.getTemplateNehubaConfig = ({configId, acceptedEncoding, returnAsStream}) => { if (process.env.NODE_ENV === 'production') { filepath = path.join(__dirname, '..', 'res', `${configId}.json`) } else { filepath = path.join(__dirname, '..', '..', 'src', 'res', 'ext', `${configId}.json`) } - fs.readFile(filepath, 'utf-8', (err, data) => { - if (err) return reject(err) - resolve(data) - }) -}) \ No newline at end of file + + if (acceptedEncoding === BROTLI) { + if (returnAsStream) return fs.createReadStream(`${filepath}.br`) + else return getFileAsPromise(`${filepath}.br`) + } + + if (acceptedEncoding === GZIP) { + if (returnAsStream) return fs.createReadStream(`${filepath}.gz`) + else return getFileAsPromise(`${filepath}.gz`) + } + + if (returnAsStream) return fs.createReadStream(filepath) + else return getFileAsPromise(filepath) +} \ No newline at end of file diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js index 9f15b4687a85be1f07b76284694132fd0fb799eb..f0216dea9b1aa14f7a85dfc955a11b530e9f740e 100644 --- a/deploy/plugins/index.js +++ b/deploy/plugins/index.js @@ -5,15 +5,11 @@ const express = require('express') const router = express.Router() -const PLUGIN_URLS = process.env.PLUGIN_URLS && JSON.stringify(process.env.PLUGIN_URLS.split(';')) +const PLUGIN_URLS = (process.env.PLUGIN_URLS && process.env.PLUGIN_URLS.split(';')) + || [] router.get('', (_req, res) => { - - if (PLUGIN_URLS) { - return res.status(200).send(PLUGIN_URLS) - } else { - return res.status(200).send('[]') - } + return res.status(200).json(PLUGIN_URLS) }) module.exports = router \ No newline at end of file diff --git a/deploy/server.js b/deploy/server.js index 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/templates/index.js b/deploy/templates/index.js index 1ed84e544f0754e84ee8f48855d7b30975361de4..d9fbeb8313a14464177a2c00f47fbda8392d707b 100644 --- a/deploy/templates/index.js +++ b/deploy/templates/index.js @@ -1,6 +1,8 @@ const router = require('express').Router() const query = require('./query') const path = require('path') +const { detEncoding } = require('../compression') + /** * root path fetches all templates */ @@ -20,6 +22,10 @@ router.get('/', (req, res, next) => { router.get('/:template', (req, res, next) => { const { template } = req.params + + const header = req.get('Accept-Encoding') + const acceptedEncoding = detEncoding(header) + query.getAllTemplates() .then(templates => { if (templates.indexOf(template) < 0) @@ -27,11 +33,9 @@ router.get('/:template', (req, res, next) => { code : 404, error: 'template not in the list supported' }) - return query.getTemplate(template) - }) - .then(data => { - if (data) - res.status(200).send(data) + + if (acceptedEncoding) res.set('Content-Encoding', acceptedEncoding) + query.getTemplate({ template, acceptedEncoding, returnAsStream:true }).pipe(res) }) .catch(error => next({ code: 500, diff --git a/deploy/templates/query.js b/deploy/templates/query.js index 1ecebe476dce91c21819342ad6031f46643b1db8..c1886db02efd03033514ec5a33c113b60b5b8842 100644 --- a/deploy/templates/query.js +++ b/deploy/templates/query.js @@ -1,5 +1,6 @@ const fs = require('fs') const path = require('path') +const { BROTLI, GZIP } = require('../compression') exports.getAllTemplates = () => new Promise((resolve, reject) => { @@ -17,15 +18,32 @@ exports.getAllTemplates = () => new Promise((resolve, reject) => { resolve(templates) }) -exports.getTemplate = (template) => new Promise((resolve, reject) => { +const getFileAsPromise = filepath => new Promise((resolve, reject) => { + fs.readFile(filepath, 'utf-8', (err, data) => { + if (err) return reject(err) + resolve(data) + }) +}) + +exports.getTemplate = ({ template, acceptedEncoding, returnAsStream }) => { + let filepath if (process.env.NODE_ENV === 'production') { filepath = path.join(__dirname, '..', 'res', `${template}.json`) } else { filepath = path.join(__dirname, '..', '..', 'src', 'res', 'ext', `${template}.json`) } - fs.readFile(filepath, 'utf-8', (err, data) => { - if (err) reject(err) - resolve(data) - }) -}) \ No newline at end of file + + if (acceptedEncoding === BROTLI) { + if (returnAsStream) return fs.createReadStream(`${filepath}.br`) + else return getFileAsPromise(`${filepath}.br`) + } + + if (acceptedEncoding === GZIP) { + if (returnAsStream) return fs.createReadStream(`${filepath}.gz`) + else return getFileAsPromise(`${filepath}.gz`) + } + + if (returnAsStream) return fs.createReadStream(filepath) + else return getFileAsPromise(filepath) +} \ No newline at end of file diff --git a/deploy/test/mocha.test.js b/deploy/test/mocha.test.js index 8f55b54de1f2566b8d2348adef651303b0ef1fca..366f5af4a874c10e907272ba0ece2b686248078a 100644 --- a/deploy/test/mocha.test.js +++ b/deploy/test/mocha.test.js @@ -1 +1,2 @@ -require('../auth/util.spec') \ No newline at end of file +require('../auth/util.spec') +require('../compression/index.spec') \ No newline at end of file diff --git a/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..a2ed139c5d747a35ef3c5ed5d8db25138d6831cb 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" }, @@ -24,6 +23,7 @@ "license": "ISC", "devDependencies": { "@angular/animations": "^7.2.15", + "@angular/cdk": "^7.3.7", "@angular/common": "^7.2.15", "@angular/compiler": "^7.2.15", "@angular/compiler-cli": "^7.2.15", @@ -32,8 +32,10 @@ "@angular/forms": "^7.2.15", "@angular/http": "^7.2.15", "@angular/language-service": "^7.2.15", + "@angular/material": "^7.3.7", "@angular/platform-browser": "^7.2.15", "@angular/platform-browser-dynamic": "^7.2.15", + "@angular/router": "^7.2.15", "@ngrx/effects": "^7.4.0", "@ngrx/store": "^6.0.1", "@ngtools/webpack": "^6.0.5", @@ -46,7 +48,9 @@ "chart.js": "^2.7.2", "codelyzer": "^5.0.1", "core-js": "^3.0.1", + "css-loader": "^3.2.0", "file-loader": "^1.1.11", + "hammerjs": "^2.0.8", "html-webpack-plugin": "^3.2.0", "jasmine": "^3.1.0", "jasmine-core": "^3.4.0", @@ -58,12 +62,15 @@ "karma-typescript": "^3.0.13", "karma-webpack": "^3.0.0", "lodash.merge": "^4.6.1", + "mini-css-extract-plugin": "^0.8.0", "ng2-charts": "^1.6.0", "ngx-bootstrap": "3.0.1", + "node-sass": "^4.12.0", "protractor": "^5.4.2", "raw-loader": "^0.5.1", "reflect-metadata": "^0.1.12", "rxjs": "6.5.1", + "sass-loader": "^7.2.0", "showdown": "^1.8.6", "ts-loader": "^4.3.0", "ts-node": "^8.1.0", @@ -74,12 +81,8 @@ "webpack-cli": "^3.3.2", "webpack-closure-compiler": "^2.1.6", "webpack-dev-server": "^3.1.4", - "webpack-merge": "^4.1.2" - }, - "dependencies": { - "@angular/cdk": "^7.3.7", - "@angular/material": "^7.3.7", - "@angular/router": "^7.2.15", + "webpack-merge": "^4.1.2", "zone.js": "^0.9.1" - } + }, + "dependencies": {} } diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 5173ec7a7825790bb058a09b56aa5cf0cb8c6c11..475cb5c09879a2d1fc134e753752428d2884b706 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,13 @@ 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 dialogService: DialogService, ){ this.loadedTemplates$ = this.store.pipe( @@ -36,13 +33,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 +73,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 +109,10 @@ export class AtlasViewerAPIServices{ */ launchNewWidget: (manifest) => { return Promise.reject('Needs to be overwritted') - } + }, + + getUserInput: config => this.dialogService.getUserInput(config), + getUserConfirmation: config => this.dialogService.getUserConfirm(config) }, pluginControl : { loadExternalLibraries : ()=>Promise.reject('load External Library method not over written') @@ -137,7 +134,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 +180,8 @@ export interface InteractiveViewerInterface{ getModalHandler: () => ModalHandler getToastHandler: () => ToastHandler launchNewWidget: (manifest:PluginManifest) => Promise<any> + getUserInput: (config:GetUserInputConfig) => Promise<string> + getUserConfirmation: (config: GetUserConfirmation) => Promise<any> } pluginControl : { @@ -193,6 +191,16 @@ export interface InteractiveViewerInterface{ } } +interface GetUserConfirmation{ + title?: string + message?: string +} + +interface GetUserInputConfig extends GetUserConfirmation{ + placeholder?: string + defaultValue?: string +} + export interface UserLandmark{ name : string position : [number, number, number] diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 9abbd42776480899103ff4b228616cccd1fd7f80..f58775aa9923e70c4e83ee5496e3cb2c21705335 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -7,13 +7,9 @@ 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"; @@ -21,6 +17,7 @@ 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 { LocalFileService } from "src/services/localFile.service"; +import { MatDialog, MatDialogRef } from "@angular/material"; /** * TODO @@ -107,7 +104,7 @@ 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 rd: Renderer2, @@ -245,6 +242,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() @@ -266,25 +268,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 }) }) ) @@ -329,6 +325,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() { @@ -358,12 +360,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( @@ -376,12 +373,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( @@ -427,12 +419,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 } @@ -452,14 +448,14 @@ 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 }) @@ -521,27 +517,16 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { }) } - /** - * TODO deprecated - */ - - toggleSidePanel(panelName:string){ - this.store.dispatch({ - type : TOGGLE_SIDE_PANEL, - focusedSidePanel :panelName - }) - } + closeModal(mode){ + if (mode === 'help') { + this.helpDialogRef && this.helpDialogRef.close() + } - /** - * TODO deprecated - */ - panelAnimationEnd(){ - if( this.nehubaContainer && this.nehubaContainer.nehubaViewer && this.nehubaContainer.nehubaViewer.nehubaViewer ) { - this.nehubaContainer.nehubaViewer.nehubaViewer.redraw() + if (mode === 'login') { + this.loginDialogRef && this.loginDialogRef.close() } } - closeMenuWithSwipe(documentToSwipe: ElementRef) { if (documentToSwipe && documentToSwipe.nativeElement) { const swipeDistance = 150; // swipe distance 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 a452c83b0d81b948ff923994f2fa5952ab9e9146..bb1d4183e203ac97f3c9d54c52a49d18e5e660e1 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.ts @@ -1,8 +1,9 @@ import { Injectable } from "@angular/core"; -import { Store } from "@ngrx/store"; -import { ViewerStateInterface, Property } from "../services/stateStore.service"; -import { Subject } from "rxjs"; +import { Store, select } from "@ngrx/store"; +import { ViewerStateInterface } from "../services/stateStore.service"; +import { Subject, Observable } from "rxjs"; import { ACTION_TYPES, ViewerConfiguration } from 'src/services/state/viewerConfig.store' +import { map, shareReplay, filter } from "rxjs/operators"; export const CM_THRESHOLD = `0.05` export const CM_MATLAB_JET = `float r;if( x < 0.7 ){r = 4.0 * x - 1.5;} else {r = -4.0 * x + 4.5;}float g;if (x < 0.5) {g = 4.0 * x - 0.5;} else {g = -4.0 * x + 3.5;}float b;if (x < 0.3) {b = 4.0 * x + 0.5;} else {b = -4.0 * x + 2.5;}float a = 1.0;` @@ -14,6 +15,7 @@ export const CM_MATLAB_JET = `float r;if( x < 0.7 ){r = 4.0 * x - 1.5;} else {r export class AtlasViewerConstantsServices{ public darktheme: boolean = false + public darktheme$: Observable<boolean> public mobile: boolean public loadExportNehubaPromise : Promise<boolean> @@ -177,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'], @@ -247,6 +248,14 @@ Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float /* https://stackoverflow.com/a/25394023/6059235 */ this.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(ua) + this.darktheme$ = this.store.pipe( + select('viewerState'), + select('templateSelected'), + filter(v => !!v), + map(({useTheme}) => useTheme === 'dark'), + shareReplay(1) + ) + /** * set gpu limit if user is on mobile */ @@ -259,6 +268,14 @@ Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float }) } } + + catchError(e: Error | string){ + /** + * DO NOT REMOVE + * general catch all & reflect in UI + */ + console.warn(e) + } } const parseURLToElement = (url:string):HTMLElement=>{ @@ -313,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 @@ -371,7 +387,9 @@ const decodetoInt = (encodedString: string) => { _encodedString = encodedString } return (negFlag ? -1 : 1) * [..._encodedString].reduce((acc,curr) => { - return acc * 64 + cipher.indexOf(curr) + const index = cipher.indexOf(curr) + if (index < 0) throw new Error(`Poisoned b64 encoding ${encodedString}`) + return acc * 64 + index }, 0) } diff --git a/src/atlasViewer/atlasViewer.pluginService.service.spec.ts b/src/atlasViewer/atlasViewer.pluginService.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a040e3f2360184ecb8df8e08eef7111667012a3c --- /dev/null +++ b/src/atlasViewer/atlasViewer.pluginService.service.spec.ts @@ -0,0 +1,108 @@ +import { PluginServices } from "./atlasViewer.pluginService.service"; +import { TestBed, inject } from "@angular/core/testing"; +import { MainModule } from "src/main.module"; +import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing' + +const MOCK_PLUGIN_MANIFEST = { + name: 'fzj.xg.MOCK_PLUGIN_MANIFEST', + templateURL: 'http://localhost:10001/template.html', + scriptURL: 'http://localhost:10001/script.js' +} + +describe('PluginServices', () => { + let pluginService: PluginServices + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + MainModule + ] + }).compileComponents() + + pluginService = TestBed.get(PluginServices) + }) + + it( + 'is instantiated in test suite OK', + () => expect(TestBed.get(PluginServices)).toBeTruthy() + ) + + it( + 'expectOne is working as expected', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + expect(httpMock.match('test').length).toBe(0) + pluginService.fetch('test') + expect(httpMock.match('test').length).toBe(1) + pluginService.fetch('test') + pluginService.fetch('test') + expect(httpMock.match('test').length).toBe(2) + }) + ) + + describe('#launchPlugin', () => { + + describe('basic fetching functionality', () => { + it( + 'fetches templateURL and scriptURL properly', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockTemplate).toBeTruthy() + expect(mockScript).toBeTruthy() + }) + ) + + it( + 'template overrides templateURL', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + template: '' + }) + + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) + const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockScript).toBeTruthy() + }) + ) + + it( + 'script overrides scriptURL', + + inject([HttpTestingController], (httpMock: HttpTestingController) => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + script: '' + }) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockTemplate).toBeTruthy() + }) + ) + }) + + describe('racing slow cconnection when launching plugin', () => { + it( + 'when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', + inject([HttpTestingController], (httpMock:HttpTestingController) => { + + expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + expect(httpMock.match(MOCK_PLUGIN_MANIFEST.scriptURL).length).toBe(1) + expect(httpMock.match(MOCK_PLUGIN_MANIFEST.templateURL).length).toBe(1) + + expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeTruthy() + }) + ) + }) + }) +}) \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.pluginService.service.ts b/src/atlasViewer/atlasViewer.pluginService.service.ts index 7adc864521c39d581871bd6be3b1a8172e065ed4..84f5e30d687124fd8ebc5b457c70929385de1a62 100644 --- a/src/atlasViewer/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/atlasViewer.pluginService.service.ts @@ -1,13 +1,14 @@ import { Injectable, ViewContainerRef, ComponentFactoryResolver, ComponentFactory } from "@angular/core"; -import { PluginInitManifestInterface, ACTION_TYPES } from "src/services/state/pluginState.store"; +import { PluginInitManifestInterface, PLUGIN_STATE_ACTION_TYPES } from "src/services/state/pluginState.store"; +import { HttpClient } from '@angular/common/http' import { isDefined } from 'src/services/stateStore.service' import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; import { PluginUnit } from "./pluginUnit/pluginUnit.component"; import { WidgetServices } from "./widgetUnit/widgetService.service"; import '../res/css/plugin_styles.css' -import { interval } from "rxjs"; -import { take, takeUntil } from "rxjs/operators"; +import { BehaviorSubject, Observable, merge, of } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; import { Store } from "@ngrx/store"; import { WidgetUnit } from "./widgetUnit/widgetUnit.component"; import { AtlasViewerConstantsServices } from "./atlasViewer.constantService.service"; @@ -23,13 +24,20 @@ export class PluginServices{ public appendSrc : (script:HTMLElement)=>void public removeSrc: (script:HTMLElement) => void private pluginUnitFactory : ComponentFactory<PluginUnit> + public minimisedPlugins$ : Observable<Set<string>> + + /** + * TODO remove polyfil and convert all calls to this.fetch to http client + */ + public fetch: (url:string, httpOption?: any) => Promise<any> = (url, httpOption = {}) => this.http.get(url, httpOption).toPromise() constructor( private apiService : AtlasViewerAPIServices, private constantService : AtlasViewerConstantsServices, private widgetService : WidgetServices, private cfr : ComponentFactoryResolver, - private store : Store<PluginInitManifestInterface> + private store : Store<PluginInitManifestInterface>, + private http: HttpClient ){ this.pluginUnitFactory = this.cfr.resolveComponentFactory( PluginUnit ) @@ -44,30 +52,30 @@ export class PluginServices{ * PLUGINDEV should return an array of */ PLUGINDEV - ? fetch(PLUGINDEV).then(res => res.json()) + ? this.fetch(PLUGINDEV).then(res => res.json()) : Promise.resolve([]), new Promise(resolve => { - fetch(`${this.constantService.backendUrl}plugins`) - .then(res => res.json()) + this.fetch(`${this.constantService.backendUrl}plugins`) .then(arr => Promise.all( arr.map(url => new Promise(rs => /** * instead of failing all promises when fetching manifests, only fail those that fails to fetch */ - fetch(url).then(res => res.json()).then(rs).catch(e => (console.log('fetching manifest error', e), rs(null)))) + this.fetch(url).then(rs).catch(e => (this.constantService.catchError(`fetching manifest error: ${e.toString()}`), rs(null)))) ) )) .then(manifests => resolve( manifests.filter(m => !!m) )) .catch(e => { + this.constantService.catchError(e) resolve([]) }) }), Promise.all( BUNDLEDPLUGINS .filter(v => typeof v === 'string') - .map(v => fetch(`res/plugin_examples/${v}/manifest.json`).then(res => res.json())) + .map(v => this.fetch(`res/plugin_examples/${v}/manifest.json`).then(res => res.json())) ) .then(arr => arr.reduce((acc,curr) => acc.concat(curr) ,[])) ]) @@ -79,6 +87,24 @@ export class PluginServices{ .then(arr=> this.fetchedPluginManifests = arr) .catch(console.error) + + this.minimisedPlugins$ = merge( + of(new Set()), + this.widgetService.minimisedWindow$ + ).pipe( + map(set => { + const returnSet = new Set<string>() + for (let [pluginName, wu] of this.mapPluginNameToWidgetUnit) { + if (set.has(wu)) { + returnSet.add(pluginName) + } + } + return returnSet + }), + shareReplay(1) + ) + + this.launchedPlugins$ = new BehaviorSubject(new Set()) } launchNewWidget = (manifest) => this.launchPlugin(manifest) @@ -94,37 +120,74 @@ export class PluginServices{ isDefined(plugin.template) ? Promise.resolve('template already provided') : isDefined(plugin.templateURL) ? - fetch(plugin.templateURL) - .then(res=>res.text()) + this.fetch(plugin.templateURL, {responseType: 'text'}) .then(template=>plugin.template = template) : Promise.reject('both template and templateURL are not defined') , isDefined(plugin.script) ? Promise.resolve('script already provided') : isDefined(plugin.scriptURL) ? - fetch(plugin.scriptURL) - .then(res=>res.text()) + this.fetch(plugin.scriptURL, {responseType: 'text'}) .then(script=>plugin.script = script) : Promise.reject('both script and scriptURL are not defined') ]) } - public launchedPlugins: Set<string> = new Set() + private launchedPlugins: Set<string> = new Set() + public launchedPlugins$: BehaviorSubject<Set<string>> + pluginHasLaunched(pluginName:string) { + return this.launchedPlugins.has(pluginName) + } + addPluginToLaunchedSet(pluginName:string){ + this.launchedPlugins.add(pluginName) + this.launchedPlugins$.next(this.launchedPlugins) + } + removePluginFromLaunchedSet(pluginName:string){ + this.launchedPlugins.delete(pluginName) + this.launchedPlugins$.next(this.launchedPlugins) + } + + + pluginIsLaunching(pluginName:string){ + return this.launchingPlugins.has(pluginName) + } + addPluginToIsLaunchingSet(pluginName:string) { + this.launchingPlugins.add(pluginName) + } + removePluginFromIsLaunchingSet(pluginName:string){ + this.launchedPlugins.delete(pluginName) + } + private mapPluginNameToWidgetUnit: Map<string, WidgetUnit> = new Map() - pluginMinimised(pluginManifest:PluginManifest){ - return this.widgetService.minimisedWindow.has( this.mapPluginNameToWidgetUnit.get(pluginManifest.name) ) + pluginIsMinimised(pluginName:string) { + return this.widgetService.isMinimised( this.mapPluginNameToWidgetUnit.get(pluginName) ) } + private launchingPlugins: Set<string> = new Set() public orphanPlugins: Set<PluginManifest> = new Set() launchPlugin(plugin:PluginManifest){ - if(this.apiService.interactiveViewer.pluginControl[plugin.name]) - { - console.warn('plugin already launched. blinking for 10s.') - this.apiService.interactiveViewer.pluginControl[plugin.name].blink(10) + if (this.pluginIsLaunching(plugin.name)) { + // plugin launching please be patient + // TODO add visual feedback + return + } + if ( this.pluginHasLaunched(plugin.name)) { + // plugin launched + // TODO add visual feedback + + // if widget window is minimized, maximize it + const wu = this.mapPluginNameToWidgetUnit.get(plugin.name) - this.widgetService.minimisedWindow.delete(wu) - return Promise.reject('plugin already launched') + if (this.widgetService.isMinimised(wu)) { + this.widgetService.unminimise(wu) + } else { + this.widgetService.minimise(wu) + } + return } + + this.addPluginToIsLaunchingSet(plugin.name) + return this.readyPlugin(plugin) .then(()=>{ const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) @@ -154,7 +217,7 @@ export class PluginServices{ : null handler.setInitManifestUrl = (url) => this.store.dispatch({ - type : ACTION_TYPES.SET_INIT_PLUGIN, + type : PLUGIN_STATE_ACTION_TYPES.SET_INIT_PLUGIN, manifest : { name : plugin.name, initManifestUrl : url @@ -163,7 +226,7 @@ export class PluginServices{ const shutdownCB = [ () => { - this.launchedPlugins.delete(plugin.name) + this.removePluginFromLaunchedSet(plugin.name) } ] @@ -191,28 +254,18 @@ export class PluginServices{ title : plugin.displayName || plugin.name }) - this.launchedPlugins.add(plugin.name) + this.addPluginToLaunchedSet(plugin.name) + this.removePluginFromIsLaunchingSet(plugin.name) + this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance) const unsubscribeOnPluginDestroy = [] handler.blink = (sec?:number)=>{ - if(typeof sec !== 'number') - console.warn(`sec is not a number, default blink interval used`) - widgetCompRef.instance.containerClass = '' - interval(typeof sec === 'number' ? sec * 1000 : 500).pipe( - take(11), - takeUntil(widgetCompRef.instance.clickedEmitter) - ).subscribe(()=> - widgetCompRef.instance.containerClass = widgetCompRef.instance.containerClass === 'panel-success' ? - '' : - 'panel-success') + widgetCompRef.instance.blinkOn = true } - unsubscribeOnPluginDestroy.push( - widgetCompRef.instance.clickedEmitter.subscribe(()=> - widgetCompRef.instance.containerClass = '') - ) + handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val handler.shutdown = ()=>{ widgetCompRef.instance.exit() @@ -244,6 +297,8 @@ export class PluginHandler{ initStateUrl? : string setInitManifestUrl : (url:string|null)=>void + + setProgressIndicator: (progress:number) => void } export interface PluginManifest{ diff --git a/src/atlasViewer/atlasViewer.template.html b/src/atlasViewer/atlasViewer.template.html index d77828f903cc1b0856653f37d146e698c21426e5..d22eae5adfd3f8b045326761fc9d7bc2033e233b 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,81 @@ </ng-template> </ng-container> - <ng-template #helpComponent> - <tabset> - <tab heading="Help"> - <help-component> - </help-component> - </tab> - <tab heading="Settings"> - <div class="mt-2"> - <config-component> - </config-component> - </div> - </tab> - <tab heading="Privacy Policy"> - <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="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 118d5123f19854425acaa5a8f65eec612c08a584..72a5ed8886d1d34c518ec48a674d32b9c9c9d388 100644 --- a/src/atlasViewer/atlasViewer.urlService.service.ts +++ b/src/atlasViewer/atlasViewer.urlService.service.ts @@ -57,8 +57,12 @@ export class AtlasViewerURLService{ */ this.additionalNgLayers$ = combineLatest( this.changeQueryObservable$.pipe( - map(state => state.templateSelected) + select('templateSelected'), + filter(v => !!v) ), + /** + * TODO duplicated with viewerState.loadedNgLayers ? + */ this.store.pipe( select('ngViewerState'), select('layers') @@ -170,7 +174,16 @@ export class AtlasViewerURLService{ for (let ngId in json) { const val = json[ngId] - const labelIndicies = val.split(separator).map(n =>decodeToNumber(n)) + const labelIndicies = val.split(separator).map(n =>{ + try{ + return decodeToNumber(n) + } catch (e) { + /** + * TODO poisonsed encoded char, send error message + */ + return null + } + }).filter(v => !!v) for (let labelIndex of labelIndicies) { selectRegionIds.push(`${ngId}#${labelIndex}`) } @@ -208,22 +221,29 @@ export class AtlasViewerURLService{ const cViewerState = searchparams.get('cNavigation') if (cViewerState) { - const [ cO, cPO, cPZ, cP, cZ ] = cViewerState.split(`${separator}${separator}`) - const o = cO.split(separator).map(s => decodeToNumber(s, {float: true})) - const po = cPO.split(separator).map(s => decodeToNumber(s, {float: true})) - const pz = decodeToNumber(cPZ) - const p = cP.split(separator).map(s => decodeToNumber(s)) - const z = decodeToNumber(cZ) - this.store.dispatch({ - type : CHANGE_NAVIGATION, - navigation : { - orientation: o, - perspectiveOrientation: po, - perspectiveZoom: pz, - position: p, - zoom: z - } - }) + try { + const [ cO, cPO, cPZ, cP, cZ ] = cViewerState.split(`${separator}${separator}`) + const o = cO.split(separator).map(s => decodeToNumber(s, {float: true})) + const po = cPO.split(separator).map(s => decodeToNumber(s, {float: true})) + const pz = decodeToNumber(cPZ) + const p = cP.split(separator).map(s => decodeToNumber(s)) + const z = decodeToNumber(cZ) + this.store.dispatch({ + type : CHANGE_NAVIGATION, + navigation : { + orientation: o, + perspectiveOrientation: po, + perspectiveZoom: pz, + position: p, + zoom: z + } + }) + } catch (e) { + /** + * TODO Poisoned encoded char + * send error message + */ + } } const niftiLayers = searchparams.get('niftiLayers') diff --git a/src/atlasViewer/widgetUnit/widgetService.service.ts b/src/atlasViewer/widgetUnit/widgetService.service.ts index 521d69ac8356087702807ec34ebeec44204f7888..7f1cc48355967bb9bfec97c60f3ee3713cb62ee0 100644 --- a/src/atlasViewer/widgetUnit/widgetService.service.ts +++ b/src/atlasViewer/widgetUnit/widgetService.service.ts @@ -1,9 +1,7 @@ import { ComponentRef, ComponentFactory, Injectable, ViewContainerRef, ComponentFactoryResolver, Injector } from "@angular/core"; - import { WidgetUnit } from "./widgetUnit.component"; import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.service"; -import { Subscription } from "rxjs"; - +import { Subscription, BehaviorSubject } from "rxjs"; @Injectable({ providedIn : 'root' @@ -20,7 +18,8 @@ export class WidgetServices{ private clickedListener : Subscription[] = [] - public minimisedWindow: Set<WidgetUnit> = new Set() + public minimisedWindow$: BehaviorSubject<Set<WidgetUnit>> + private minimisedWindow: Set<WidgetUnit> = new Set() constructor( private cfr:ComponentFactoryResolver, @@ -28,6 +27,7 @@ export class WidgetServices{ private injector : Injector ){ this.widgetUnitFactory = this.cfr.resolveComponentFactory(WidgetUnit) + this.minimisedWindow$ = new BehaviorSubject(this.minimisedWindow) } clearAllWidgets(){ @@ -38,8 +38,26 @@ export class WidgetServices{ this.clickedListener.forEach(s=>s.unsubscribe()) } + rename(wu:WidgetUnit, {title, titleHTML}: {title: string, titleHTML: string}){ + /** + * WARNING: always sanitize before pass to rename fn! + */ + wu.title = title + wu.titleHTML = titleHTML + } + minimise(wu:WidgetUnit){ this.minimisedWindow.add(wu) + this.minimisedWindow$.next(new Set(this.minimisedWindow)) + } + + isMinimised(wu:WidgetUnit){ + return this.minimisedWindow.has(wu) + } + + unminimise(wu:WidgetUnit){ + this.minimisedWindow.delete(wu) + this.minimisedWindow$.next(new Set(this.minimisedWindow)) } addNewWidget(guestComponentRef:ComponentRef<any>,options?:Partial<WidgetOptionsInterface>):ComponentRef<WidgetUnit>{ @@ -93,9 +111,12 @@ export class WidgetServices{ this.clickedListener.push( _component.instance.clickedEmitter.subscribe((widgetUnit:WidgetUnit)=>{ + /** + * TODO this operation + */ if(widgetUnit.state !== 'floating') return - const widget = [...this.widgetComponentRefs].find(widget=>widget.instance===widgetUnit) + const widget = [...this.widgetComponentRefs].find(widget=>widget.instance === widgetUnit) if(!widget) return const idx = this.floatingContainer.indexOf(widget.hostView) @@ -103,7 +124,6 @@ export class WidgetServices{ return this.floatingContainer.detach(idx) this.floatingContainer.insert(widget.hostView) - }) ) diff --git a/src/atlasViewer/widgetUnit/widgetUnit.component.ts b/src/atlasViewer/widgetUnit/widgetUnit.component.ts index fc32210cd0774a8b1b75126c25f9e10b2de9195a..35463f92e9d75f4804964dae65439e52c9984a35 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.component.ts +++ b/src/atlasViewer/widgetUnit/widgetUnit.component.ts @@ -1,6 +1,9 @@ -import { Component, ViewChild, ViewContainerRef,ComponentRef, HostBinding, HostListener, Output, EventEmitter, Input, ElementRef, OnInit } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef,ComponentRef, HostBinding, HostListener, Output, EventEmitter, Input, ElementRef, OnInit, OnDestroy } from "@angular/core"; + import { WidgetServices } from "./widgetService.service"; import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.service"; +import { Subscription, Observable } from "rxjs"; +import { map } from "rxjs/operators"; @Component({ @@ -10,9 +13,8 @@ import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.ser ] }) -export class WidgetUnit implements OnInit{ +export class WidgetUnit implements OnInit, OnDestroy{ @ViewChild('container',{read:ViewContainerRef}) container : ViewContainerRef - @ViewChild('emptyspan',{read:ElementRef}) emtpy : ElementRef @HostBinding('attr.state') public state : 'docked' | 'floating' = 'docked' @@ -24,29 +26,61 @@ export class WidgetUnit implements OnInit{ height : string = this.state === 'docked' ? null : '0px' @HostBinding('style.display') - get isMinimised(){ - return this.widgetServices.minimisedWindow.has(this) ? 'none' : null + isMinimised: string + + isMinimised$: Observable<boolean> + + /** + * Timed alternates of blinkOn property should result in attention grabbing blink behaviour + */ + private _blinkOn: boolean = false + get blinkOn(){ + return this._blinkOn } + + set blinkOn(val: boolean) { + this._blinkOn = !!val + } + + get showProgress(){ + return this.progressIndicator !== null + } + /** - * TODO - * upgrade to angular>=7, and use cdk to handle draggable components + * Some plugins may like to show progress indicator for long running processes + * If null, no progress is running + * This value should be between 0 and 1 */ - get transform(){ - return this.state === 'floating' ? - `translate(${this.position[0]}px, ${this.position[1]}px)` : - `translate(0 , 0)` + private _progressIndicator: number = null + get progressIndicator(){ + return this._progressIndicator + } + + set progressIndicator(val:number) { + if (isNaN(val)) { + this._progressIndicator = null + return + } + if (val < 0) { + this._progressIndicator = 0 + return + } + if (val > 1) { + this._progressIndicator = 1 + return + } + this._progressIndicator = val } public canBeDocked: boolean = false @HostListener('mousedown') clicked(){ this.clickedEmitter.emit(this) + this.blinkOn = false } @Input() title : string = 'Untitled' - @Input() containerClass : string = '' - @Output() clickedEmitter : EventEmitter<WidgetUnit> = new EventEmitter() @@ -59,16 +93,30 @@ export class WidgetUnit implements OnInit{ public guestComponentRef : ComponentRef<any> public widgetServices:WidgetServices public cf : ComponentRef<WidgetUnit> + private subscriptions: Subscription[] = [] public id: string constructor( private constantsService: AtlasViewerConstantsServices - ){ + ){ this.id = Date.now().toString() } ngOnInit(){ this.canBeDocked = typeof this.widgetServices.dockedContainer !== 'undefined' + + this.isMinimised$ = this.widgetServices.minimisedWindow$.pipe( + map(set => set.has(this)) + ) + this.subscriptions.push( + this.isMinimised$.subscribe(flag => this.isMinimised = flag ? 'none' : null) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0){ + this.subscriptions.pop().unsubscribe() + } } /** diff --git a/src/atlasViewer/widgetUnit/widgetUnit.style.css b/src/atlasViewer/widgetUnit/widgetUnit.style.css index 10289f924763603c034766cd15e23a819cb0e857..9ed85f20e0fc549bc2c9ab0ac31f8ae156dc48e8 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.style.css +++ b/src/atlasViewer/widgetUnit/widgetUnit.style.css @@ -41,7 +41,62 @@ panel-component[widgetUnitPanel] cursor : move; } -[emptyspan] +:host > panel-component { - opacity:0.01; + max-width: 100%; + width: 300px; + border-width: 1px !important; + border: solid; + border-color: rgba(0, 0, 0, 0); + box-sizing: border-box; } + +@keyframes blinkDark +{ + 0% { + border-color: rgba(128, 128, 200, 0.0); + } + + 100% { + border-color: rgba(128, 128, 200, 1.0); + } +} + +@keyframes blink +{ + 0% { + border-color: rgba(128, 128, 255, 0.0); + } + + 100% { + border-color: rgba(128, 128, 255, 1.0); + } +} + +:host-context([darktheme="true"]) .blinkOn +{ + animation: 0.5s blinkDark ease-in-out 9 alternate; + border: 1px solid rgba(128, 128, 200, 1.0) !important; +} + +:host-context([darktheme="false"]) .blinkOn +{ + animation: 0.5s blink ease-in-out 9 alternate; + border: 1px solid rgba(128, 128, 255, 1.0) !important; +} + +[heading] +{ + position:relative; +} + +[heading] > [progressBar] +{ + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + opacity: 0.4; + pointer-events: none; +} \ No newline at end of file diff --git a/src/atlasViewer/widgetUnit/widgetUnit.template.html b/src/atlasViewer/widgetUnit/widgetUnit.template.html index 62600cf8ba9b22a3901d84fdb50517fe9ab7badf..84a9b851949d66beed27043783c46130c0832157 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.template.html +++ b/src/atlasViewer/widgetUnit/widgetUnit.template.html @@ -1,17 +1,13 @@ <panel-component - [style.transform] = "transform" - [containerClass] = "containerClass" widgetUnitPanel + [ngClass]="{'blinkOn': blinkOn}" [bodyCollapsable] = "state === 'docked'" [cdkDragDisabled]="state === 'docked'" - cdkDrag - [ngStyle]="{'max-width': isMobile? '100%' : '300px', - 'margin-bottom': isMobile? '5px': '0'}"> + cdkDrag> <div widgetUnitHeading heading cdkDragHandle> - <div #emptyspan emptyspan>.</div> <div title> <div *ngIf="!titleHTML"> {{ title }} @@ -41,6 +37,9 @@ class = "fas fa-times" [hoverable] ="{translateY: -1}"></i> </div> + <progress-bar [progress]="progressIndicator" *ngIf="showProgress" progressBar> + + </progress-bar> </div> <div widgetUnitBody body> <ng-template #container> diff --git a/src/components/components.module.ts b/src/components/components.module.ts index e435687fd0541445a7989f8f87f1bb3eb392bae9..8ddb291d165ab8771f9e6b1f6e36ea8ea465f2d4 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -29,6 +29,10 @@ import { CommonModule } from '@angular/common'; import { RadioList } from './radiolist/radiolist.component'; import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module'; import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; +import { ProgressBar } from './progress/progress.component'; +import { SleightOfHand } from './sleightOfHand/soh.component'; +import { DialogComponent } from './dialog/dialog.component'; +import { ConfirmDialogComponent } from './confirmDialog/confirmDialog.component'; @NgModule({ @@ -52,6 +56,10 @@ import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; TimerComponent, PillComponent, RadioList, + ProgressBar, + SleightOfHand, + DialogComponent, + ConfirmDialogComponent, /* directive */ HoverableBlockDirective, @@ -83,6 +91,10 @@ import { FilterCollapsePipe } from './flatTree/filterCollapse.pipe'; TimerComponent, PillComponent, RadioList, + ProgressBar, + SleightOfHand, + DialogComponent, + ConfirmDialogComponent, SearchResultPaginationPipe, TreeSearchPipe, diff --git a/src/components/confirmDialog/confirmDialog.component.ts b/src/components/confirmDialog/confirmDialog.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c16434ac9910839c345d0a5fe9a132411627ba4 --- /dev/null +++ b/src/components/confirmDialog/confirmDialog.component.ts @@ -0,0 +1,24 @@ +import { Component, Inject, Input } from "@angular/core"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material"; + +@Component({ + selector: 'confirm-dialog-component', + templateUrl: './confirmDialog.template.html', + styleUrls: [ + './confirmDialog.style.css' + ] +}) +export class ConfirmDialogComponent{ + + @Input() + public title: string = 'Confirm' + + @Input() + public message: string = 'Would you like to proceed?' + + constructor(@Inject(MAT_DIALOG_DATA) data: any){ + const { title = null, message = null} = data || {} + if (title) this.title = title + if (message) this.message = message + } +} \ No newline at end of file diff --git a/src/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 d8cd1d832244fca9882eff9ecc18889a7d9bac79..60e0ba44fd00d214d3fb0e9a5997b78d813ffa69 100644 --- a/src/components/flatTree/flatTree.component.ts +++ b/src/components/flatTree/flatTree.component.ts @@ -48,6 +48,12 @@ export class FlatTreeComponent implements AfterViewChecked { uncollapsedLevels : Set<string> = new Set() ngAfterViewChecked(){ + /** + * if useDefaultList is true, virtualscrollViewPort will be undefined + */ + if (!this.virtualScrollViewPort) { + return + } const currentTotalDataLength = this.virtualScrollViewPort.getDataLength() const previousDataLength = this.totalDataLength @@ -92,4 +98,10 @@ export class FlatTreeComponent implements AfterViewChecked { .some(id => this.isCollapsedById(id)) } + handleTreeNodeClick(event:MouseEvent, inputItem: any){ + this.treeNodeClick.emit({ + event, + inputItem + }) + } } \ No newline at end of file diff --git a/src/components/flatTree/flatTree.template.html b/src/components/flatTree/flatTree.template.html index 48ff87a252308c179c0c207b244a9cbb9009a58c..893c539a9ff029933606880e3e37776e3d7fa0c1 100644 --- a/src/components/flatTree/flatTree.template.html +++ b/src/components/flatTree/flatTree.template.html @@ -27,7 +27,7 @@ <i [ngClass]="isCollapsed(flattenedItem) ? 'r-270' : ''" class="fas fa-chevron-down"></i> </span> <span - (click)="treeNodeClick.emit({event:$event,inputItem:flattenedItem})" + (click)="handleTreeNodeClick($event, flattenedItem)" class="render-node-text" [innerHtml]="flattenedItem | renderPipe : renderNode "> </span> @@ -63,7 +63,7 @@ <i [ngClass]="isCollapsed(flattenedItem) ? 'r-270' : ''" class="fas fa-chevron-down"></i> </span> <span - (click)="treeNodeClick.emit({event:$event,inputItem:flattenedItem})" + (click)="handleTreeNodeClick($event, flattenedItem)" class="render-node-text" [innerHtml]="flattenedItem | renderPipe : renderNode "> </span> diff --git a/src/components/panel/panel.component.ts b/src/components/panel/panel.component.ts index 6b2c5095c1f04c39d1a85123e0f793163290d845..ccfc382fb8297e7b2671bec08bfffb7b694d1a11 100644 --- a/src/components/panel/panel.component.ts +++ b/src/components/panel/panel.component.ts @@ -1,5 +1,4 @@ -import { Component, Input, ViewChild, ElementRef, AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, SimpleChanges, HostBinding, ApplicationRef } from "@angular/core"; -import { panelAnimations } from "./panel.animation"; +import { Component, Input, ViewChild, ElementRef, ChangeDetectionStrategy } from "@angular/core"; import { ParseAttributeDirective } from "../parseAttribute.directive"; @Component({ @@ -8,9 +7,6 @@ import { ParseAttributeDirective } from "../parseAttribute.directive"; styleUrls : [ `./panel.style.css` ], - host: { - '[class]': 'getClassNames' - }, changeDetection:ChangeDetectionStrategy.OnPush }) @@ -23,8 +19,6 @@ export class PanelComponent extends ParseAttributeDirective { @Input() collapseBody : boolean = false @Input() bodyCollapsable : boolean = false - @Input() containerClass : string = '' - @ViewChild('panelBody',{ read : ElementRef }) efPanelBody : ElementRef @ViewChild('panelFooter',{ read : ElementRef }) efPanelFooter : ElementRef @@ -32,10 +26,6 @@ export class PanelComponent extends ParseAttributeDirective { super() } - get getClassNames(){ - return `panel ${this.containerClass === '' ? 'panel-default' : this.containerClass}` - } - toggleCollapseBody(_event:Event){ if(this.bodyCollapsable){ this.collapseBody = !this.collapseBody diff --git a/src/components/panel/panel.template.html b/src/components/panel/panel.template.html index d2c55e22b6daed1c0b223e86492e6429e34a143d..84ca6a9f7a786cc51b3ba4a32e9ff8e1b3ff416d 100644 --- a/src/components/panel/panel.template.html +++ b/src/components/panel/panel.template.html @@ -1,10 +1,3 @@ -<div - *ngIf = "showHeading" - class = "panel-heading" - hoverable> - -</div> - <div class="l-card"> <div class="l-card-body"> <div diff --git a/src/components/progress/progress.component.ts b/src/components/progress/progress.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..03e34f9f304f5c6a0b86016c5a73760392434053 --- /dev/null +++ b/src/components/progress/progress.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, ChangeDetectionStrategy } from "@angular/core"; + + +@Component({ + selector: 'progress-bar', + templateUrl: './progress.template.html', + styleUrls: [ + './progress.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class ProgressBar{ + @Input() progressStyle: any + + private _progress: number = 0 + /** + * between 0 and 1 + */ + @Input() + set progress(val: number) { + if (isNaN(val)) { + this._progress = 0 + return + } + if (val < 0 || val === null) { + this._progress = 0 + return + } + if (val > 1) { + this._progress = 1 + return + } + this._progress = val + } + + get progress(){ + return this._progress + } + + get progressPercent(){ + return `${this.progress * 100}%` + } +} \ No newline at end of file diff --git a/src/components/progress/progress.style.css b/src/components/progress/progress.style.css new file mode 100644 index 0000000000000000000000000000000000000000..36858a282cdd6c793041f515aa8c4cd7e0cab97a --- /dev/null +++ b/src/components/progress/progress.style.css @@ -0,0 +1,41 @@ +.progress +{ + height: 100%; + width: 100%; + position:relative; + overflow:hidden; + background-color:rgba(255,255,255,0.5); +} + +:host-context([darktheme="true"]) .progress +{ + background-color:rgba(0,0,0,0.5); +} + +@keyframes moveRight +{ + from { + transform: translateX(-105%); + } + to { + transform: translateX(205%); + } +} + +.progress::after +{ + content: ''; + width: 100%; + height: 100%; + position:absolute; + border-left-width: 10em; + border-right-width:0; + border-style: solid; + border-image: linear-gradient( + to right, + rgba(128, 200, 128, 0.0), + rgba(128, 200, 128, 0.5), + rgba(128, 200, 128, 0.0) + ) 0 100%; + animation: moveRight 2000ms linear infinite; +} \ No newline at end of file diff --git a/src/components/progress/progress.template.html b/src/components/progress/progress.template.html new file mode 100644 index 0000000000000000000000000000000000000000..7df81f0819667454c5aa5127a780a94a557d1ae6 --- /dev/null +++ b/src/components/progress/progress.template.html @@ -0,0 +1,3 @@ +<div class="progress rounded-0"> + <div [style.width]="progressPercent" class="progress-bar bg-success rounded-0" role="progressbar"></div> +</div> \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.component.ts b/src/components/sleightOfHand/soh.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b6fbe5fdb989088e847e10716703dfb4c044c5a --- /dev/null +++ b/src/components/sleightOfHand/soh.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, HostBinding, ChangeDetectionStrategy, HostListener } from "@angular/core"; + +@Component({ + selector: 'sleight-of-hand', + templateUrl: './soh.template.html', + styleUrls: [ + './soh.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class SleightOfHand{ + + @HostBinding('class.do-not-close') + get doNotCloseClass(){ + return this.doNotClose || this.focusInStatus + } + + @HostListener('focusin') + focusInHandler(){ + this.focusInStatus = true + } + + @HostListener('focusout') + focusOutHandler(){ + this.focusInStatus = false + } + + private focusInStatus: boolean = false + + @Input() + doNotClose: boolean = false +} \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.style.css b/src/components/sleightOfHand/soh.style.css new file mode 100644 index 0000000000000000000000000000000000000000..2c4f61aa210ae817635fc0764eb87f5cbe187c50 --- /dev/null +++ b/src/components/sleightOfHand/soh.style.css @@ -0,0 +1,28 @@ +:host:not(.do-not-close):not(:hover) > .sleight-of-hand-back, +:host:not(.do-not-close):hover > .sleight-of-hand-front, +:host-context(.do-not-close) > .sleight-of-hand-front +{ + opacity: 0; + pointer-events: none; +} + +:host * +{ + transition: opacity 300ms ease-in-out; +} + +:host +{ + position: relative; +} + +:host > .sleight-of-hand-front +{ + position: relative; +} + +:host > .sleight-of-hand-back +{ + position: absolute; + z-index: 1; +} \ No newline at end of file diff --git a/src/components/sleightOfHand/soh.template.html b/src/components/sleightOfHand/soh.template.html new file mode 100644 index 0000000000000000000000000000000000000000..fca028aa043b8a5c77fdb98261a0e6d5a57dd19c --- /dev/null +++ b/src/components/sleightOfHand/soh.template.html @@ -0,0 +1,9 @@ +<div class="sleight-of-hand-back"> + <ng-content select="[sleight-of-hand-back]"> + </ng-content> +</div> + +<div class="sleight-of-hand-front"> + <ng-content select="[sleight-of-hand-front]"> + </ng-content> +</div> \ No newline at end of file diff --git a/src/index.html b/src/index.html index 467e979f0b14b8f6bbaedae248b488a4f72745d6..e8c99ff3b3da1efe140cddeb3466f0de7ba47c1f 100644 --- a/src/index.html +++ b/src/index.html @@ -6,9 +6,9 @@ <meta http-equiv="X-UA-Compatible" content="ie=edge"> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> - <link rel = "stylesheet" href = "extra_styles.css"> - <link rel = "stylesheet" href = "plugin_styles.css"> - <link rel = "stylesheet" href = "indigo-pink.css"> + <link rel="stylesheet" href="extra_styles.css"> + <link rel="stylesheet" href="plugin_styles.css"> + <link rel="stylesheet" href="theme.css"> <title>Interactive Atlas Viewer</title> </head> diff --git a/src/main-aot.ts b/src/main-aot.ts index 171bd6ce7beb8ae5a35e9d43553fdc134235d0e1..7c25c3f1aa849b9ea8265c99ffce474f78142635 100644 --- a/src/main-aot.ts +++ b/src/main-aot.ts @@ -4,6 +4,9 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' import { MainModule } from './main.module'; import { enableProdMode } from '@angular/core'; +import './theme.scss' +import './res/css/extra_styles.css' + const requireAll = (r:any) => {r.keys().forEach(r)} requireAll(require.context('./res/ext', false, /\.json$/)) requireAll(require.context('./res/images', true, /\.jpg|\.png/)) diff --git a/src/main.module.ts b/src/main.module.ts index 336718e137af62d3ec860a5c80cc6018876b6810..ad4bd2ea47da47dd16e7e149f1d0f0c07f507951 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,7 +33,7 @@ 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"; @@ -44,6 +42,14 @@ import { EffectsModule } from "@ngrx/effects"; import { UseEffects } from "./services/effect/effect"; import { DragDropDirective } from "./util/directives/dragDrop.directive"; import { LocalFileService } from "./services/localFile.service"; +import { DataBrowserUseEffect } from "./ui/databrowserModule/databrowser.useEffect"; +import { DialogService } from "./services/dialogService.service"; +import { DialogComponent } from "./components/dialog/dialog.component"; +import { ViewerStateControllerUseEffect } from "./ui/viewerStateController/viewerState.useEffect"; +import { ConfirmDialogComponent } from "./components/confirmDialog/confirmDialog.component"; +import { ViewerStateUseEffect } from "./services/state/viewerState.store"; + +import 'hammerjs' @NgModule({ imports : [ @@ -55,11 +61,14 @@ import { LocalFileService } from "./services/localFile.service"; UIModule, AngularMaterialModule, - ModalModule.forRoot(), TooltipModule.forRoot(), TabsModule.forRoot(), EffectsModule.forRoot([ - UseEffects + DataBrowserUseEffect, + UseEffects, + UserConfigStateUseEffect, + ViewerStateControllerUseEffect, + ViewerStateUseEffect, ]), StoreModule.forRoot({ pluginState, @@ -69,6 +78,7 @@ import { LocalFileService } from "./services/localFile.service"; dataStore, spatialSearchState, uiState, + userConfigState }), HttpClientModule ], @@ -99,7 +109,6 @@ import { LocalFileService } from "./services/localFile.service"; GetNamesPipe, GetNamePipe, TransformOnhoverSegmentPipe, - GetFilenameFromPathnamePipe, NewViewerDisctinctViewToLayer ], entryComponents : [ @@ -107,6 +116,8 @@ import { LocalFileService } from "./services/localFile.service"; ModalUnit, ToastComponent, PluginUnit, + DialogComponent, + ConfirmDialogComponent, ], providers : [ AtlasViewerDataService, @@ -117,6 +128,7 @@ import { LocalFileService } from "./services/localFile.service"; AtlasWorkerService, AuthService, LocalFileService, + DialogService, /** * TODO @@ -145,9 +157,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 d780236c080f653df3db2846abf3e6ad34afdd16..d5b2c296496bf6c94cd3127c620dcc1812176f36 100644 --- a/src/res/css/extra_styles.css +++ b/src/res/css/extra_styles.css @@ -137,10 +137,14 @@ span.regionSelected span.regionNotSelected, span.regionSelected { - cursor : default; user-select: none; } +.cursor-default +{ + cursor: default; +} + markdown-dom pre code { white-space:pre; @@ -282,6 +286,20 @@ markdown-dom pre code white-space: initial!important; } +.w-5em +{ + width: 5em!important; +} +.w-10em +{ + width: 10em!important; +} + +.mw-400px +{ + max-width: 400px!important; +} + .mw-100 { max-width: 100%!important; @@ -297,11 +315,26 @@ markdown-dom pre code max-width: 60%!important; } +.mw-20em +{ + max-width: 20em!important; +} + +.w-20em +{ + width: 20em!important; +} + .mh-20em { max-height: 20em; } +.mh-10em +{ + max-height: 10em; +} + .pe-all { pointer-events: all; @@ -322,14 +355,31 @@ markdown-dom pre code pointer-events: none; } -.h-100 +.h-5em { - height:100%; + height: 5em!important; +} + +.h-7em +{ + height:7em!important; +} +.h-10em +{ + height:10em!important; +} +.h-15em +{ + height:15em!important; +} +.h-20em +{ + height:20em!important; } .overflow-x-hidden { - overflow-x:hidden; + overflow-x:hidden!important; } .muted @@ -346,4 +396,42 @@ markdown-dom pre code { background:none; border:none; +} + +.w-1em +{ + width: 1em; +} + +.bs-content-box +{ + box-sizing: content-box; +} + +/* required to hide */ +.cdk-global-scrollblock +{ + overflow-y:hidden !important; +} +.h-90vh +{ + height: 90vh!important; +} + +.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/res/images/1-100.png b/src/res/images/1-100.png new file mode 100644 index 0000000000000000000000000000000000000000..66f27f2321c73cb215e28a03ca48efd4fa8f813d Binary files /dev/null and b/src/res/images/1-100.png differ diff --git a/src/res/images/1-200.png b/src/res/images/1-200.png new file mode 100644 index 0000000000000000000000000000000000000000..75435265e1f04a145d264f4b69010e03cc0f75d4 Binary files /dev/null and b/src/res/images/1-200.png differ diff --git a/src/res/images/1-300.png b/src/res/images/1-300.png new file mode 100644 index 0000000000000000000000000000000000000000..a990036ac12431e51d442bcaa76614df273e32cd Binary files /dev/null and b/src/res/images/1-300.png differ diff --git a/src/res/images/1-400.png b/src/res/images/1-400.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8880161ac1364984184e796a0d896b08549a45 Binary files /dev/null and b/src/res/images/1-400.png differ diff --git a/src/res/images/2-100.png b/src/res/images/2-100.png new file mode 100644 index 0000000000000000000000000000000000000000..62817e68db8cd9bf570e3923f3fc92b294df2afa Binary files /dev/null and b/src/res/images/2-100.png differ diff --git a/src/res/images/2-200.png b/src/res/images/2-200.png new file mode 100644 index 0000000000000000000000000000000000000000..1036281b27359d0021be9e843758fa8c5db3270c Binary files /dev/null and b/src/res/images/2-200.png differ diff --git a/src/res/images/2-300.png b/src/res/images/2-300.png new file mode 100644 index 0000000000000000000000000000000000000000..e1718b621a1ea092afea438e1bfc5fbcfa68eeaf Binary files /dev/null and b/src/res/images/2-300.png differ diff --git a/src/res/images/2-400.png b/src/res/images/2-400.png new file mode 100644 index 0000000000000000000000000000000000000000..fb349bd8a361bed5ce75cf2daea616cf20306ed6 Binary files /dev/null and b/src/res/images/2-400.png differ diff --git a/src/res/images/3-100.png b/src/res/images/3-100.png new file mode 100644 index 0000000000000000000000000000000000000000..aec2e7b0297a557dee3843504b41cf3775f9341b Binary files /dev/null and b/src/res/images/3-100.png differ diff --git a/src/res/images/3-200.png b/src/res/images/3-200.png new file mode 100644 index 0000000000000000000000000000000000000000..72665ecaa83a501b02e96e0f77701dae192147d7 Binary files /dev/null and b/src/res/images/3-200.png differ diff --git a/src/res/images/3-300.png b/src/res/images/3-300.png new file mode 100644 index 0000000000000000000000000000000000000000..f2d5f9795d69557b267039774e072b98dfc53845 Binary files /dev/null and b/src/res/images/3-300.png differ diff --git a/src/res/images/3-400.png b/src/res/images/3-400.png new file mode 100644 index 0000000000000000000000000000000000000000..f41ecd0ec5dc922177a95e7713f541cb55e17d8f Binary files /dev/null and b/src/res/images/3-400.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-100.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-100.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a9fb0e291661a457f9492df96a50462999969d Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-100.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-200.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-200.png new file mode 100644 index 0000000000000000000000000000000000000000..36c143ae11e00675c45d16c5a95457a0b5880b43 Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-200.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-300.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-300.png new file mode 100644 index 0000000000000000000000000000000000000000..52f57e5412d618286dfd716fce13b13cef10741d Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-300.png differ diff --git a/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-400.png b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-400.png new file mode 100644 index 0000000000000000000000000000000000000000..df6e4acd8be3ae0cccca705dc824932e70eefd05 Binary files /dev/null and b/src/res/images/AllenadultmousebrainreferenceatlasV3BrainAtlas-400.png differ diff --git a/src/res/images/BigBrainHistology-100.png b/src/res/images/BigBrainHistology-100.png new file mode 100644 index 0000000000000000000000000000000000000000..50559bfb8359d5a71eb2a82dc276f72f80951f7a Binary files /dev/null and b/src/res/images/BigBrainHistology-100.png differ diff --git a/src/res/images/BigBrainHistology-200.png b/src/res/images/BigBrainHistology-200.png new file mode 100644 index 0000000000000000000000000000000000000000..a17a7f83393e0a57dfe80afad199150b16e605d4 Binary files /dev/null and b/src/res/images/BigBrainHistology-200.png differ diff --git a/src/res/images/BigBrainHistology-300.png b/src/res/images/BigBrainHistology-300.png new file mode 100644 index 0000000000000000000000000000000000000000..f7cd0b28ec4cbbc78488799057878e1e8225215f Binary files /dev/null and b/src/res/images/BigBrainHistology-300.png differ diff --git a/src/res/images/BigBrainHistology-400.png b/src/res/images/BigBrainHistology-400.png new file mode 100644 index 0000000000000000000000000000000000000000..7d8fea17c6d8dd5b3075df22c1f3ce9054275abb Binary files /dev/null and b/src/res/images/BigBrainHistology-400.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-100.png b/src/res/images/ICBM2009cNonlinearAsymmetric-100.png new file mode 100644 index 0000000000000000000000000000000000000000..c8912fe70f594f0a0d60ad7596802ad690166c56 Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-100.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-200.png b/src/res/images/ICBM2009cNonlinearAsymmetric-200.png new file mode 100644 index 0000000000000000000000000000000000000000..7f38858407d751d4e5b1a200ed310d2c81cb6e07 Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-200.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-300.png b/src/res/images/ICBM2009cNonlinearAsymmetric-300.png new file mode 100644 index 0000000000000000000000000000000000000000..481339124d296586ec68455fd0298aed2b392e2b Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-300.png differ diff --git a/src/res/images/ICBM2009cNonlinearAsymmetric-400.png b/src/res/images/ICBM2009cNonlinearAsymmetric-400.png new file mode 100644 index 0000000000000000000000000000000000000000..0ce95ddc0988de4f48ce8cdcda9c6b0d98b66a79 Binary files /dev/null and b/src/res/images/ICBM2009cNonlinearAsymmetric-400.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-100.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-100.png new file mode 100644 index 0000000000000000000000000000000000000000..859167bec29489f7e787d3820081e8d21d69564d Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-100.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-200.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-200.png new file mode 100644 index 0000000000000000000000000000000000000000..5c9e3d78e789eefef7e608d5807713f7d3a59769 Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-200.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-300.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-300.png new file mode 100644 index 0000000000000000000000000000000000000000..29c1ea8f17d80fc7c850fcca49964791dc96ea15 Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-300.png differ diff --git a/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-400.png b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-400.png new file mode 100644 index 0000000000000000000000000000000000000000..07b8475e00616e3235955d65cbc3598f0bd82e82 Binary files /dev/null and b/src/res/images/MNI152ICBM2009cNonlinearAsymmetric-400.png differ diff --git a/src/res/images/MNIColin27-100.png b/src/res/images/MNIColin27-100.png new file mode 100644 index 0000000000000000000000000000000000000000..d4748838a9e7d341efe95a9823fa7edca0b6126a Binary files /dev/null and b/src/res/images/MNIColin27-100.png differ diff --git a/src/res/images/MNIColin27-200.png b/src/res/images/MNIColin27-200.png new file mode 100644 index 0000000000000000000000000000000000000000..683341c65daaa5170e77197e614acbcaa3e48f5b Binary files /dev/null and b/src/res/images/MNIColin27-200.png differ diff --git a/src/res/images/MNIColin27-300.png b/src/res/images/MNIColin27-300.png new file mode 100644 index 0000000000000000000000000000000000000000..d55090287e9925d795fbf6772024e6cb81100563 Binary files /dev/null and b/src/res/images/MNIColin27-300.png differ diff --git a/src/res/images/MNIColin27-400.png b/src/res/images/MNIColin27-400.png new file mode 100644 index 0000000000000000000000000000000000000000..6bd9fd61b6ab6bde92c61cd035bb0999ecf1ed3b Binary files /dev/null and b/src/res/images/MNIColin27-400.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-100.png b/src/res/images/WaxholmSpaceratbrainatlasv20-100.png new file mode 100644 index 0000000000000000000000000000000000000000..e1eddc7bfbc2e46b4c4bcf1146558a27d277c57b Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-100.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-200.png b/src/res/images/WaxholmSpaceratbrainatlasv20-200.png new file mode 100644 index 0000000000000000000000000000000000000000..938d3c00746f6c4a1eefa6468715a8af0e205cf0 Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-200.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-300.png b/src/res/images/WaxholmSpaceratbrainatlasv20-300.png new file mode 100644 index 0000000000000000000000000000000000000000..37e7635b5b55e317455e12d727654b89d872067a Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-300.png differ diff --git a/src/res/images/WaxholmSpaceratbrainatlasv20-400.png b/src/res/images/WaxholmSpaceratbrainatlasv20-400.png new file mode 100644 index 0000000000000000000000000000000000000000..57840e545f0f1804104c93cd09fb65bb16813e4b Binary files /dev/null and b/src/res/images/WaxholmSpaceratbrainatlasv20-400.png differ diff --git a/src/services/dialogService.service.ts b/src/services/dialogService.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..110edf6663f748866b952af474d56291e2229149 --- /dev/null +++ b/src/services/dialogService.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@angular/core"; +import { MatDialog, MatDialogRef } from "@angular/material"; +import { DialogComponent } from "src/components/dialog/dialog.component"; +import { ConfirmDialogComponent } from "src/components/confirmDialog/confirmDialog.component"; + + +@Injectable({ + providedIn: 'root' +}) + +export class DialogService{ + + private dialogRef: MatDialogRef<DialogComponent> + private confirmDialogRef: MatDialogRef<ConfirmDialogComponent> + + constructor(private dialog:MatDialog){ + + } + + public getUserConfirm(config: Partial<DialogConfig> = {}): Promise<string>{ + this.confirmDialogRef = this.dialog.open(ConfirmDialogComponent, { + data: config + }) + return new Promise((resolve, reject) => this.confirmDialogRef.afterClosed() + .subscribe(val => { + if (val) resolve() + else reject('User cancelled') + }, + reject, + () => this.confirmDialogRef = null)) + } + + public getUserInput(config: Partial<DialogConfig> = {}):Promise<string>{ + const { defaultValue = '', placeholder = 'Type your response here', title = 'Message', message = '' } = config + this.dialogRef = this.dialog.open(DialogComponent, { + data: { + title, + placeholder, + defaultValue, + message + } + }) + return new Promise((resolve, reject) => { + /** + * nb: one one value is ever emitted, then the subscription ends + * Should not result in leak + */ + this.dialogRef.afterClosed().subscribe(value => { + if (value) resolve(value) + else reject('User cancelled input') + this.dialogRef = null + }) + }) + } +} + +export interface DialogConfig{ + title: string + placeholder: string + defaultValue: string + message: string +} \ No newline at end of file diff --git a/src/services/effect/effect.ts b/src/services/effect/effect.ts index 399bcae7652b8bb8b1ec3becba2188d341dfaf2c..603ed7be86372dc6b93d4922979882c219da09f3 100644 --- a/src/services/effect/effect.ts +++ b/src/services/effect/effect.ts @@ -1,9 +1,9 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Effect, Actions, ofType } from "@ngrx/effects"; -import { Subscription, merge, fromEvent, combineLatest } from "rxjs"; -import { withLatestFrom, map, filter } from "rxjs/operators"; +import { Subscription, merge, fromEvent, combineLatest, Observable } from "rxjs"; +import { withLatestFrom, map, filter, shareReplay, tap, switchMap, take } from "rxjs/operators"; import { Store, select } from "@ngrx/store"; -import { SELECT_PARCELLATION, SELECT_REGIONS, NEWVIEWER, UPDATE_PARCELLATION, SELECT_REGIONS_WITH_ID } from "../state/viewerState.store"; +import { SELECT_PARCELLATION, SELECT_REGIONS, NEWVIEWER, UPDATE_PARCELLATION, SELECT_REGIONS_WITH_ID, DESELECT_REGIONS, ADD_TO_REGIONS_SELECTION_WITH_IDS } from "../state/viewerState.store"; import { worker } from 'src/atlasViewer/atlasViewer.workerService.service' import { getNgIdLabelIndexFromId, generateLabelIndexId, recursiveFindRegionWithLabelIndexId } from '../stateStore.service'; @@ -24,8 +24,51 @@ export class UseEffects implements OnDestroy{ }) }) ) + + this.regionsSelected$ = this.store$.pipe( + select('viewerState'), + select('regionsSelected'), + shareReplay(1) + ) + + this.onDeselectRegions = this.actions$.pipe( + ofType(DESELECT_REGIONS), + withLatestFrom(this.regionsSelected$), + map(([action, regionsSelected]) => { + const { deselectRegions } = action + const deselectSet = new Set((deselectRegions as any[]).map(r => r.name)) + const selectRegions = regionsSelected.filter(r => !deselectSet.has(r.name)) + return { + type: SELECT_REGIONS, + selectRegions + } + }) + ) + + this.addToSelectedRegions$ = this.actions$.pipe( + ofType(ADD_TO_REGIONS_SELECTION_WITH_IDS), + map(action => { + const { selectRegionIds } = action + return selectRegionIds + }), + switchMap(selectRegionIds => this.updatedParcellation$.pipe( + filter(p => !!p), + take(1), + map(p => [selectRegionIds, p]) + )), + map(this.convertRegionIdsToRegion), + withLatestFrom(this.regionsSelected$), + map(([ selectedRegions, alreadySelectedRegions ]) => { + return { + type: SELECT_REGIONS, + selectRegions: this.removeDuplicatedRegions(selectedRegions, alreadySelectedRegions) + } + }) + ) } + private regionsSelected$: Observable<any[]> + ngOnDestroy(){ while(this.subscriptions.length > 0) { this.subscriptions.pop().unsubscribe() @@ -53,45 +96,76 @@ export class UseEffects implements OnDestroy{ private updatedParcellation$ = this.store$.pipe( select('viewerState'), select('parcellationSelected'), - filter(p => !!p && !!p.regions) + map(p => p.updated ? p : null), + shareReplay(1) ) + @Effect() + onDeselectRegions: Observable<any> + + private convertRegionIdsToRegion = ([selectRegionIds, parcellation]) => { + const { ngId: defaultNgId } = parcellation + return (<any[]>selectRegionIds) + .map(labelIndexId => getNgIdLabelIndexFromId({ labelIndexId })) + .map(({ ngId, labelIndex }) => { + return { + labelIndexId: generateLabelIndexId({ + ngId: ngId || defaultNgId, + labelIndex + }) + } + }) + .map(({ labelIndexId }) => { + return recursiveFindRegionWithLabelIndexId({ + regions: parcellation.regions, + labelIndexId, + inheritedNgId: defaultNgId + }) + }) + .filter(v => { + if (!v) { + console.log(`SELECT_REGIONS_WITH_ID, some ids cannot be parsed intto label index`) + } + return !!v + }) + } + + private removeDuplicatedRegions = (...args) => { + const set = new Set() + const returnArr = [] + for (const regions of args){ + for (const region of regions){ + if (!set.has(region.name)) { + returnArr.push(region) + set.add(region.name) + } + } + } + return returnArr + } + + @Effect() + addToSelectedRegions$: Observable<any> + + /** * for backwards compatibility. * older versions of atlas viewer may only have labelIndex as region identifier */ @Effect() - onSelectRegionWithId = combineLatest( - this.actions$.pipe( - ofType(SELECT_REGIONS_WITH_ID) - ), - this.updatedParcellation$ - ).pipe( - map(([action, parcellation]) => { + onSelectRegionWithId = this.actions$.pipe( + ofType(SELECT_REGIONS_WITH_ID), + map(action => { const { selectRegionIds } = action - const { ngId: defaultNgId } = parcellation - - const selectRegions = (<any[]>selectRegionIds) - .map(labelIndexId => getNgIdLabelIndexFromId({ labelIndexId })) - .map(({ ngId, labelIndex }) => { - return { - labelIndexId: generateLabelIndexId({ - ngId: ngId || defaultNgId, - labelIndex - }) - } - }) - .map(({ labelIndexId }) => { - return recursiveFindRegionWithLabelIndexId({ - regions: parcellation.regions, - labelIndexId, - inheritedNgId: defaultNgId - }) - }) - .filter(v => { - if (!v) console.log(`SELECT_REGIONS_WITH_ID, some ids cannot be parsed intto label index`) - return !!v - }) + return selectRegionIds + }), + switchMap(selectRegionIds => this.updatedParcellation$.pipe( + filter(p => !!p), + take(1), + map(parcellation => [selectRegionIds, parcellation]) + )), + map(this.convertRegionIdsToRegion), + map(selectRegions => { return { type: SELECT_REGIONS, selectRegions @@ -118,7 +192,14 @@ export class UseEffects implements OnDestroy{ filter((message: MessageEvent) => message && message.data && message.data.type === 'UPDATE_PARCELLATION_REGIONS'), map(({data}) => data.parcellation), withLatestFrom(this.newParcellationSelected$), - filter(([ propagatedP, selectedP ] : [any, any]) => propagatedP.name === selectedP.name), + filter(([ propagatedP, selectedP ] : [any, any]) => { + /** + * TODO + * use id + * but jubrain may have same id for different template spaces + */ + return propagatedP.name === selectedP.name + }), map(([ propagatedP, _ ]) => propagatedP), map(parcellation => ({ type: UPDATE_PARCELLATION, diff --git a/src/services/state/dataStore.store.ts b/src/services/state/dataStore.store.ts index 4b25d5eae3b134ab7f48c07cf9d290eee06e1d8a..44f21f0bb825e10cdb63e51c23813c7f3fa7a0ec 100644 --- a/src/services/state/dataStore.store.ts +++ b/src/services/state/dataStore.store.ts @@ -1,6 +1,22 @@ import { Action } from '@ngrx/store' -export function dataStore(state:any,action:DatasetAction){ +/** + * TODO merge with databrowser.usereffect.ts + */ + +interface DataEntryState{ + fetchedDataEntries: DataEntry[] + favDataEntries: DataEntry[] + fetchedSpatialData: DataEntry[] +} + +const defaultState = { + fetchedDataEntries: [], + favDataEntries: [], + fetchedSpatialData: [] +} + +export function dataStore(state:DataEntryState = defaultState, action:Partial<DatasetAction>){ switch (action.type){ case FETCHED_DATAENTRIES: { return { @@ -14,12 +30,19 @@ export function dataStore(state:any,action:DatasetAction){ fetchedSpatialData : action.fetchedDataEntries } } - default: - return state + case ACTION_TYPES.UPDATE_FAV_DATASETS: { + const { favDataEntries = [] } = action + return { + ...state, + favDataEntries + } + } + default: return state } } export interface DatasetAction extends Action{ + favDataEntries: DataEntry[] fetchedDataEntries : DataEntry[] fetchedSpatialData : DataEntry[] } @@ -57,6 +80,9 @@ export interface DataEntry{ * TODO typo, should be kgReferences */ kgReference: string[] + + id: string + fullId: string } export interface ParcellationRegion { @@ -133,4 +159,12 @@ export interface ViewerPreviewFile{ export interface FileSupplementData{ data: any -} \ No newline at end of file +} + +const ACTION_TYPES = { + FAV_DATASET: `FAV_DATASET`, + UPDATE_FAV_DATASETS: `UPDATE_FAV_DATASETS`, + UNFAV_DATASET: 'UNFAV_DATASET' +} + +export const DATASETS_ACTIONS_TYPES = ACTION_TYPES \ No newline at end of file diff --git a/src/services/state/ngViewerState.store.ts b/src/services/state/ngViewerState.store.ts index d1dd8b27a79daa1b2a73ad56df959cac71f87636..289489d7e549bd4266aac0a57ffd67c877aa2804 100644 --- a/src/services/state/ngViewerState.store.ts +++ b/src/services/state/ngViewerState.store.ts @@ -1,32 +1,58 @@ import { Action } from '@ngrx/store' +export const FOUR_PANEL = 'FOUR_PANEL' +export const V_ONE_THREE = 'V_ONE_THREE' +export const H_ONE_THREE = 'H_ONE_THREE' +export const SINGLE_PANEL = 'SINGLE_PANEL' + export interface NgViewerStateInterface{ layers : NgLayerInterface[] forceShowSegment : boolean | null nehubaReady: boolean + panelMode: string + panelOrder: string } export interface NgViewerAction extends Action{ layer : NgLayerInterface forceShowSegment : boolean nehubaReady: boolean + payload: any } -const defaultState:NgViewerStateInterface = {layers:[], forceShowSegment:null, nehubaReady: false} +const defaultState:NgViewerStateInterface = { + layers:[], + forceShowSegment:null, + nehubaReady: false, + panelMode: FOUR_PANEL, + panelOrder: `0123` +} export function ngViewerState(prevState:NgViewerStateInterface = defaultState, action:NgViewerAction):NgViewerStateInterface{ switch(action.type){ + case ACTION_TYPES.SET_PANEL_ORDER: { + const { payload } = action + const { panelOrder } = payload + return { + ...prevState, + panelOrder + } + } + case ACTION_TYPES.SWITCH_PANEL_MODE: { + const { payload } = action + const { panelMode } = payload + if (SUPPORTED_PANEL_MODES.indexOf(panelMode) < 0) return prevState + return { + ...prevState, + panelMode + } + } case ADD_NG_LAYER: - return Object.assign({}, prevState, { + return { + ...prevState, + /* this configration hides the layer if a non mixable layer already present */ - layers : action.layer.constructor === Array - ? prevState.layers.concat(action.layer) - : prevState.layers.concat( - Object.assign({}, action.layer, - action.layer.mixability === 'nonmixable' && prevState.layers.findIndex(l => l.mixability === 'nonmixable') >= 0 - ? {visible: false} - : {})) - + /* this configuration does not the addition of multiple non mixable layers */ // layers : action.layer.mixability === 'nonmixable' && prevState.layers.findIndex(l => l.mixability === 'nonmixable') >= 0 // ? prevState.layers @@ -34,39 +60,47 @@ export function ngViewerState(prevState:NgViewerStateInterface = defaultState, a /* this configuration allows the addition of multiple non mixables */ // layers : prevState.layers.map(l => mapLayer(l, action.layer)).concat(action.layer) - }) + layers : action.layer.constructor === Array + ? prevState.layers.concat(action.layer) + : prevState.layers.concat({ + ...action.layer, + ...( action.layer.mixability === 'nonmixable' && prevState.layers.findIndex(l => l.mixability === 'nonmixable') >= 0 + ? {visible: false} + : {}) + }) + } case REMOVE_NG_LAYER: - return Object.assign({}, prevState, { + return { + ...prevState, layers : prevState.layers.filter(l => l.name !== action.layer.name) - } as NgViewerStateInterface) + } case SHOW_NG_LAYER: - return Object.assign({}, prevState, { + return { + ...prevState, layers : prevState.layers.map(l => l.name === action.layer.name - ? Object.assign({}, l, { - visible : true - } as NgLayerInterface) + ? { ...l, visible: true } : l) - }) + } case HIDE_NG_LAYER: - return Object.assign({}, prevState, { + return { + ...prevState, + layers : prevState.layers.map(l => l.name === action.layer.name - ? Object.assign({}, l, { - visible : false - } as NgLayerInterface) + ? { ...l, visible: false } : l) - }) + } case FORCE_SHOW_SEGMENT: - return Object.assign({}, prevState, { + return { + ...prevState, forceShowSegment : action.forceShowSegment - }) as NgViewerStateInterface + } case NEHUBA_READY: const { nehubaReady } = action return { ...prevState, nehubaReady } - default: - return prevState + default: return prevState } } @@ -84,4 +118,19 @@ interface NgLayerInterface{ visible : boolean shader? : string transform? : any -} \ No newline at end of file +} + +const ACTION_TYPES = { + SWITCH_PANEL_MODE: 'SWITCH_PANEL_MODE', + SET_PANEL_ORDER: 'SET_PANEL_ORDER' +} + +export const SUPPORTED_PANEL_MODES = [ + FOUR_PANEL, + H_ONE_THREE, + V_ONE_THREE, + SINGLE_PANEL, +] + + +export const NG_VIEWER_ACTION_TYPES = ACTION_TYPES \ No newline at end of file diff --git a/src/services/state/pluginState.store.ts b/src/services/state/pluginState.store.ts index 93d6b1e9678128b624fbe146c26afb08f494306b..e9a80cc8d3b180b847ec8e6b86294093e8a7f903 100644 --- a/src/services/state/pluginState.store.ts +++ b/src/services/state/pluginState.store.ts @@ -12,10 +12,12 @@ export interface PluginInitManifestActionInterface extends Action{ } } -export const ACTION_TYPES = { +const ACTION_TYPES = { SET_INIT_PLUGIN: `SET_INIT_PLUGIN` } +export const PLUGIN_STATE_ACTION_TYPES = ACTION_TYPES + export function pluginState(prevState:PluginInitManifestInterface = {initManifests : new Map()}, action:PluginInitManifestActionInterface):PluginInitManifestInterface{ switch(action.type){ case ACTION_TYPES.SET_INIT_PLUGIN: 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 0b1edc38bcf23d9001d9554392d83c9e26789432..359cf2be6547ee998f73cf05b36b9909ed1e1ae4 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -1,6 +1,10 @@ -import { Action } from '@ngrx/store' +import { Action, Store, select } from '@ngrx/store' import { UserLandmark } from 'src/atlasViewer/atlasViewer.apiService.service'; import { NgLayerInterface } from 'src/atlasViewer/atlasViewer.component'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { withLatestFrom, map, shareReplay, startWith, tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; export interface ViewerStateInterface{ fetchedTemplates : any[] @@ -34,13 +38,17 @@ export interface AtlasAction extends Action{ deselectLandmarks : UserLandmark[] navigation? : any + + payload: any } export function viewerState( state:Partial<ViewerStateInterface> = { landmarksSelected : [], fetchedTemplates : [], - loadedNgLayers: [] + loadedNgLayers: [], + regionsSelected: [], + userLandmarks: [] }, action:AtlasAction ){ @@ -106,7 +114,10 @@ export function viewerState( const { updatedParcellation } = action return { ...state, - parcellationSelected: updatedParcellation + parcellationSelected: { + ...updatedParcellation, + updated: true + } } } case SELECT_REGIONS: @@ -133,6 +144,10 @@ export function viewerState( userLandmarks: action.landmarks } } + /** + * TODO + * duplicated with ngViewerState.layers ? + */ case NEHUBA_LAYER_CHANGED: { if (!window['viewer']) { return { @@ -167,10 +182,88 @@ export const CHANGE_NAVIGATION = 'CHANGE_NAVIGATION' export const SELECT_PARCELLATION = `SELECT_PARCELLATION` export const UPDATE_PARCELLATION = `UPDATE_PARCELLATION` +export const DESELECT_REGIONS = `DESELECT_REGIONS` export const SELECT_REGIONS = `SELECT_REGIONS` export const SELECT_REGIONS_WITH_ID = `SELECT_REGIONS_WITH_ID` export const SELECT_LANDMARKS = `SELECT_LANDMARKS` export const DESELECT_LANDMARKS = `DESELECT_LANDMARKS` export const USER_LANDMARKS = `USER_LANDMARKS` +export const ADD_TO_REGIONS_SELECTION_WITH_IDS = `ADD_TO_REGIONS_SELECTION_WITH_IDS` + export const NEHUBA_LAYER_CHANGED = `NEHUBA_LAYER_CHANGED` + +@Injectable({ + providedIn: 'root' +}) + +export class ViewerStateUseEffect{ + constructor( + private actions$: Actions, + private store$: Store<any> + ){ + this.currentLandmarks$ = this.store$.pipe( + select('viewerState'), + select('userLandmarks'), + shareReplay(1), + ) + + this.removeUserLandmarks = this.actions$.pipe( + ofType(ACTION_TYPES.REMOVE_USER_LANDMARKS), + withLatestFrom(this.currentLandmarks$), + map(([action, currentLandmarks]) => { + const { landmarkIds } = (action as AtlasAction).payload + for ( const rmId of landmarkIds ){ + const idx = currentLandmarks.findIndex(({ id }) => id === rmId) + if (idx < 0) console.warn(`remove userlandmark with id ${rmId} does not exist`) + } + const removeSet = new Set(landmarkIds) + return { + type: USER_LANDMARKS, + landmarks: currentLandmarks.filter(({ id }) => !removeSet.has(id)) + } + }) + ) + + this.addUserLandmarks$ = this.actions$.pipe( + ofType(ACTION_TYPES.ADD_USERLANDMARKS), + withLatestFrom(this.currentLandmarks$), + map(([action, currentLandmarks]) => { + const { landmarks } = action as AtlasAction + const landmarkMap = new Map() + for (const landmark of currentLandmarks) { + const { id } = landmark + landmarkMap.set(id, landmark) + } + for (const landmark of landmarks) { + const { id } = landmark + if (landmarkMap.has(id)) { + console.warn(`Attempting to add a landmark that already exists, id: ${id}`) + } else { + landmarkMap.set(id, landmark) + } + } + const userLandmarks = Array.from(landmarkMap).map(([id, landmark]) => landmark) + return { + type: USER_LANDMARKS, + landmarks: userLandmarks + } + }) + ) + } + + private currentLandmarks$: Observable<any[]> + + @Effect() + removeUserLandmarks: Observable<any> + + @Effect() + addUserLandmarks$: Observable<any> +} + +const ACTION_TYPES = { + ADD_USERLANDMARKS: `ADD_USERLANDMARKS`, + REMOVE_USER_LANDMARKS: 'REMOVE_USER_LANDMARKS' +} + +export const VIEWERSTATE_ACTION_TYPES = ACTION_TYPES \ No newline at end of file diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts index 96048ec2192d311c34a01528e4e24ee52ffa5dce..842499c18bcb4d3083a9494fab4f71ca85ca96ad 100644 --- a/src/services/stateStore.service.ts +++ b/src/services/stateStore.service.ts @@ -7,6 +7,11 @@ export { CHANGE_NAVIGATION, AtlasAction, DESELECT_LANDMARKS, FETCHED_TEMPLATE, N export { DataEntry, ParcellationRegion, DataStateInterface, DatasetAction, FETCHED_DATAENTRIES, FETCHED_SPATIAL_DATA, Landmark, OtherLandmarkGeometry, PlaneLandmarkGeometry, PointLandmarkGeometry, Property, Publication, ReferenceSpace, dataStore, File, FileSupplementData } from './state/dataStore.store' export { CLOSE_SIDE_PANEL, MOUSE_OVER_LANDMARK, MOUSE_OVER_SEGMENT, OPEN_SIDE_PANEL, TOGGLE_SIDE_PANEL, UIAction, UIStateInterface, uiState } from './state/uiState.store' export { SPATIAL_GOTO_PAGE, SpatialDataEntries, SpatialDataStateInterface, UPDATE_SPATIAL_DATA, spatialSearchState } from './state/spatialSearchState.store' +export { userConfigState, UserConfigStateUseEffect, USER_CONFIG_ACTION_TYPES } from './state/userConfigState.store' + +export const GENERAL_ACTION_TYPES = { + ERROR: 'ERROR' +} export function safeFilter(key:string){ return filter((state:any)=> diff --git a/src/theme.scss b/src/theme.scss new file mode 100644 index 0000000000000000000000000000000000000000..e85cfdce26366235f96280ccae6f742f46218b35 --- /dev/null +++ b/src/theme.scss @@ -0,0 +1,21 @@ +@import '~@angular/material/theming'; + +@include mat-core(); + +$iv-theme-primary: mat-palette($mat-indigo); +$iv-theme-accent: mat-palette($mat-amber); +$iv-theme-warn: mat-palette($mat-red); + +$iv-theme: mat-light-theme($iv-theme-primary, $iv-theme-accent, $iv-theme-warn); + +@include angular-material-theme($iv-theme); + +$iv-dark-theme-primary: mat-palette($mat-blue); +$iv-dark-theme-accent: mat-palette($mat-amber, A200, A100, A400); +$iv-dark-theme-warn: mat-palette($mat-red); +$iv-dark-theme: mat-dark-theme($iv-dark-theme-primary, $iv-dark-theme-accent, $iv-dark-theme-warn); + +[darktheme=true] +{ + @include angular-material-theme($iv-dark-theme); +} diff --git a/src/ui/config/config.component.ts b/src/ui/config/config.component.ts index e0104405a1b4bc8efda4e8d5b8e18af4598b7b06..6f6f0b4f4a06b7110d9481dc9324b3947bb2106d 100644 --- a/src/ui/config/config.component.ts +++ b/src/ui/config/config.component.ts @@ -1,8 +1,14 @@ 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 { Observable, Subscription } from 'rxjs'; +import { map, distinctUntilChanged, startWith, shareReplay } from 'rxjs/operators'; +import { MatSlideToggleChange, MatSliderChange } from '@angular/material'; +import { NG_VIEWER_ACTION_TYPES, SUPPORTED_PANEL_MODES } from 'src/services/state/ngViewerState.store'; + +const GPU_TOOLTIP = `GPU TOOLTIP` +const ANIMATION_TOOLTIP = `ANIMATION_TOOLTIP` +const ROOT_TEXT_ORDER = ['Coronal', 'Sagittal', 'Axial', '3D'] @Component({ selector: 'config-component', @@ -14,16 +20,27 @@ import { map, distinctUntilChanged, debounce, debounceTime } from 'rxjs/operator export class ConfigComponent implements OnInit, OnDestroy{ + public GPU_TOOLTIP = GPU_TOOLTIP + public ANIMATION_TOOLTIP = ANIMATION_TOOLTIP + public supportedPanelModes = SUPPORTED_PANEL_MODES + /** * in MB */ public gpuLimit$: Observable<number> - public keydown$: Subject<Event> = new Subject() + + public animationFlag$: Observable<boolean> private subscriptions: Subscription[] = [] public gpuMin : number = 100 public gpuMax : number = 1000 + + public panelMode$: Observable<string> + private panelOrder: string + private panelOrder$: Observable<string> + public panelTexts$: Observable<[string, string, string, string]> + constructor(private store: Store<ViewerConfiguration>) { this.gpuLimit$ = this.store.pipe( select('viewerConfigState'), @@ -31,24 +48,32 @@ export class ConfigComponent implements OnInit, OnDestroy{ distinctUntilChanged(), map(v => v / 1e6) ) + + this.animationFlag$ = this.store.pipe( + select('viewerConfigState'), + map((config:ViewerConfiguration) => config.animation), + ) + + this.panelMode$ = this.store.pipe( + select('ngViewerState'), + select('panelMode'), + startWith(SUPPORTED_PANEL_MODES[0]) + ) + + this.panelOrder$ = this.store.pipe( + select('ngViewerState'), + select('panelOrder') + ) + + this.panelTexts$ = this.panelOrder$.pipe( + map(string => string.split('').map(s => Number(s))), + map(arr => arr.map(idx => ROOT_TEXT_ORDER[idx]) as [string, string, string, string]) + ) } ngOnInit(){ this.subscriptions.push( - this.keydown$.pipe( - debounceTime(250) - ).subscribe(ev => { - /** - * maybe greak in FF. ev.srcElement is IE non standard property - */ - const val = (<HTMLInputElement>ev.srcElement).value - const numVal = val && Number(val) - if (isNaN(numVal) || numVal < this.gpuMin || numVal > this.gpuMax ) - return - this.setGpuPreset({ - value: numVal - }) - }) + this.panelOrder$.subscribe(panelOrder => this.panelOrder = panelOrder) ) } @@ -56,20 +81,63 @@ export class ConfigComponent implements OnInit, OnDestroy{ this.subscriptions.forEach(s => s.unsubscribe()) } - public wheelEvent(ev:WheelEvent) { - const delta = ev.deltaY * -1e5 + public toggleAnimationFlag(ev: MatSlideToggleChange ){ + const { checked } = ev this.store.dispatch({ - type: ACTION_TYPES.CHANGE_GPU_LIMIT, - payload: { delta } + type: ACTION_TYPES.UPDATE_CONFIG, + config: { + animation: checked + } }) } - public setGpuPreset({value}: {value: number}) { + public handleMatSliderChange(ev:MatSliderChange){ this.store.dispatch({ type: ACTION_TYPES.UPDATE_CONFIG, config: { - gpuLimit: value * 1e6 + gpuLimit: ev.value * 1e6 } }) } + usePanelMode(panelMode: string){ + this.store.dispatch({ + type: NG_VIEWER_ACTION_TYPES.SWITCH_PANEL_MODE, + payload: { panelMode } + }) + } + + handleDrop(event:DragEvent){ + const droppedAttri = (event.target as HTMLElement).getAttribute('panel-order') + const draggedAttri = event.dataTransfer.getData('text/plain') + if (droppedAttri === draggedAttri) return + const idx1 = Number(droppedAttri) + const idx2 = Number(draggedAttri) + const arr = this.panelOrder.split(''); + + [arr[idx1], arr[idx2]] = [arr[idx2], arr[idx1]] + this.store.dispatch({ + type: NG_VIEWER_ACTION_TYPES.SET_PANEL_ORDER, + payload: { panelOrder: arr.join('') } + }) + } + handleDragOver(event:DragEvent){ + event.preventDefault() + const target = (event.target as HTMLElement) + target.classList.add('onDragOver') + } + handleDragLeave(event:DragEvent){ + (event.target as HTMLElement).classList.remove('onDragOver') + } + handleDragStart(event:DragEvent){ + const target = (event.target as HTMLElement) + const attri = target.getAttribute('panel-order') + event.dataTransfer.setData('text/plain', attri) + + } + handleDragend(event:DragEvent){ + const target = (event.target as HTMLElement) + target.classList.remove('onDragOver') + } + + public stepSize: number = 10 } \ No newline at end of file diff --git a/src/ui/config/config.style.css b/src/ui/config/config.style.css index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..907391412ce3868cca03f2b38599f2f551238085 100644 --- a/src/ui/config/config.style.css +++ b/src/ui/config/config.style.css @@ -0,0 +1,14 @@ +.config-transition +{ + transition: background-color ease-in-out 200ms; +} + +.config-transition:hover +{ + background-color: rgba(128,128,128,0.1); +} + +.onDragOver +{ + background-color: rgba(128,128,128,0.2); +} \ No newline at end of file diff --git a/src/ui/config/config.template.html b/src/ui/config/config.template.html index 18f84d0b297f9ef71422960695f032c567faafb1..20927120dd97ec7f77e783ae3fcabfbcdc6243b6 100644 --- a/src/ui/config/config.template.html +++ b/src/ui/config/config.template.html @@ -1,32 +1,190 @@ -<div class="input-group"> - <span class="input-group-prepend"> - <span class="input-group-text"> - GPU Limit - </span> - </span> - <input - (wheel)="wheelEvent($event)" - type="number" - [min]="100" - [max]="1000" - [step]="0.1" - class="form-control" - (input)="keydown$.next($event)" - [value]="gpuLimit$ | async "> - - <div class="input-group-append"> - - <div (click)="setGpuPreset({ value: 100 })" class="btn btn-outline-secondary"> - 100 +<mat-tab-group> + + <!-- viewer preference --> + <mat-tab label="Viewer Preference"> + + <div class="m-2"> + <div class="mat-h2"> + Rearrange Viewports </div> - <div (click)="setGpuPreset({ value: 500 })" class="btn btn-outline-secondary"> - 500 + <div class="mat-h4 text-muted"> + Click and drag to rearrange viewport positions </div> - <div (click)="setGpuPreset({ value: 1000 })" class="btn btn-outline-secondary"> - 1000 + <current-layout class="d-flex w-20em h-15em p-2"> + <div + matRipple + (dragstart)="handleDragStart($event)" + (dragover)="handleDragOver($event)" + (dragleave)="handleDragLeave($event)" + (dragend)="handleDragend($event)" + (drop)="handleDrop($event)" + class="w-100 h-100 config-transition" + cell-i> + <div + [attr.panel-order]="0" + class="config-transition w-100 h-100 d-flex align-items-center justify-content-center border" + draggable="true"> + {{ (panelTexts$ | async)[0] }} + </div> + </div> + <div + matRipple + (dragstart)="handleDragStart($event)" + (dragover)="handleDragOver($event)" + (dragleave)="handleDragLeave($event)" + (dragend)="handleDragend($event)" + (drop)="handleDrop($event)" + class="w-100 h-100 config-transition" + cell-ii> + <div + [attr.panel-order]="1" + class="config-transition w-100 h-100 d-flex align-items-center justify-content-center border" + draggable="true"> + {{ (panelTexts$ | async)[1] }} + </div> + </div> + <div + matRipple + (dragstart)="handleDragStart($event)" + (dragover)="handleDragOver($event)" + (dragleave)="handleDragLeave($event)" + (dragend)="handleDragend($event)" + (drop)="handleDrop($event)" + class="w-100 h-100 config-transition" + cell-iii> + <div + [attr.panel-order]="2" + class="config-transition w-100 h-100 d-flex align-items-center justify-content-center border" + draggable="true"> + {{ (panelTexts$ | async)[2] }} + </div> + </div> + <div + matRipple + (dragstart)="handleDragStart($event)" + (dragover)="handleDragOver($event)" + (dragleave)="handleDragLeave($event)" + (dragend)="handleDragend($event)" + (drop)="handleDrop($event)" + class="w-100 h-100 config-transition" + cell-iv> + <div + [attr.panel-order]="3" + class="config-transition w-100 h-100 d-flex align-items-center justify-content-center border" + draggable="true"> + {{ (panelTexts$ | async)[3] }} + </div> + </div> + </current-layout> + + <div class="mat-body text-muted font-italic"> + Plane designation refers to default orientation (without oblique rotation). </div> - <span class="input-group-text"> - MB + </div> + + <!-- scroll window --> + + <div class="m-2"> + <div class="mat-h2"> + Select a viewports configuration + </div> + </div> + + <div class="d-flex flex-row flex-nowrap p-2"> + + <!-- Four Panel Card --> + <button + class="m-2 p-2" + mat-flat-button + (click)="usePanelMode(supportedPanelModes[0])" + [color]="(panelMode$ | async) === supportedPanelModes[0] ? 'primary' : null"> + <layout-four-panel class="d-block w-10em h-7em"> + <div class="border w-100 h-100" cell-i></div> + <div class="border w-100 h-100" cell-ii></div> + <div class="border w-100 h-100" cell-iii></div> + <div class="border w-100 h-100" cell-iv></div> + </layout-four-panel> + </button> + + <!-- horizontal 1 3 card --> + <button + class="m-2 p-2" + mat-flat-button + (click)="usePanelMode(supportedPanelModes[1])" + [color]="(panelMode$ | async) === supportedPanelModes[1] ? 'primary' : null"> + <layout-horizontal-one-three class="d-block w-10em h-7em"> + <div class="border w-100 h-100" cell-i></div> + <div class="border w-100 h-100" cell-ii></div> + <div class="border w-100 h-100" cell-iii></div> + <div class="border w-100 h-100" cell-iv></div> + </layout-horizontal-one-three> + </button> + + <!-- vertical 1 3 card --> + <button + class="m-2 p-2" + mat-flat-button + (click)="usePanelMode(supportedPanelModes[2])" + [color]="(panelMode$ | async) === supportedPanelModes[2] ? 'primary' : null"> + <layout-vertical-one-three class="d-block w-10em h-7em"> + <div class="border w-100 h-100" cell-i></div> + <div class="border w-100 h-100" cell-ii></div> + <div class="border w-100 h-100" cell-iii></div> + <div class="border w-100 h-100" cell-iv></div> + </layout-vertical-one-three> + </button> + + <!-- single --> + <button + class="m-2 p-2" + mat-flat-button + (click)="usePanelMode(supportedPanelModes[3])" + [color]="(panelMode$ | async) === supportedPanelModes[3] ? 'primary' : null"> + <layout-single-panel class="d-block w-10em h-7em"> + <div class="border w-100 h-100" cell-i></div> + <div class="border w-100 h-100" cell-ii></div> + <div class="border w-100 h-100" cell-iii></div> + <div class="border w-100 h-100" cell-iv></div> + </layout-single-panel> + </button> + </div> + </mat-tab> + + <!-- hard ware --> + <mat-tab label="Hardware"> + <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> + <div class="d-flex flex-row align-items-center justify-content start"> + <label + class="m-0 d-inline-block flex-grow-0 flex-shrink-0" + for="gpuLimitSlider"> + GPU Limit + <small [matTooltip]="GPU_TOOLTIP" class="ml-2 fas fa-question"></small> + </label> + <mat-slider + class="flex-grow-1 flex-shrink-1 ml-2 mr-2" + id="gpuLimitSlider" + name="gpuLimitSlider" + thumbLabel="true" + min="100" + max="1000" + [step]="stepSize" + (change)="handleMatSliderChange($event)" + [value]="gpuLimit$ | async"> + </mat-slider> + <span class="d-inline-block flex-grow-0 flex-shrink-0 w-10em"> + {{ gpuLimit$ | async }} MB </span> </div> - </div> \ No newline at end of file + + + </mat-tab> + +</mat-tab-group> + diff --git a/src/ui/config/currentLayout/currentLayout.component.ts b/src/ui/config/currentLayout/currentLayout.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..df40022f719efb0d9cce84fa1a8bcdfbe6512b06 --- /dev/null +++ b/src/ui/config/currentLayout/currentLayout.component.ts @@ -0,0 +1,27 @@ +import { Component } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable } from "rxjs"; +import { SUPPORTED_PANEL_MODES } from "src/services/state/ngViewerState.store"; +import { startWith } from "rxjs/operators"; + +@Component({ + selector: 'current-layout', + templateUrl: './currentLayout.template.html', + styleUrls: [ + './currentLayout.style.css' + ] +}) + +export class CurrentLayout{ + + public supportedPanelModes = SUPPORTED_PANEL_MODES + public panelMode$: Observable<string> + + constructor(private store$: Store<any>){ + this.panelMode$ = this.store$.pipe( + select('ngViewerState'), + select('panelMode'), + startWith(SUPPORTED_PANEL_MODES[0]) + ) + } +} \ No newline at end of file diff --git a/src/ui/config/currentLayout/currentLayout.style.css b/src/ui/config/currentLayout/currentLayout.style.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/ui/config/currentLayout/currentLayout.template.html b/src/ui/config/currentLayout/currentLayout.template.html new file mode 100644 index 0000000000000000000000000000000000000000..f3d04c104239b9e81f3ff0552c892e86c444ba0d --- /dev/null +++ b/src/ui/config/currentLayout/currentLayout.template.html @@ -0,0 +1,82 @@ +<div [ngSwitch]="panelMode$ | async" class="w-100 h-100 d-flex flex-row"> + <layout-four-panel + *ngSwitchCase="supportedPanelModes[0]" + class="d-block w-100 h-100"> + <div class="w-100 h-100" cell-i> + <ng-content *ngTemplateOutlet="celli"></ng-content> + </div> + <div class="w-100 h-100" cell-ii> + <ng-content *ngTemplateOutlet="cellii"></ng-content> + </div> + <div class="w-100 h-100" cell-iii> + <ng-content *ngTemplateOutlet="celliii"></ng-content> + </div> + <div class="w-100 h-100" cell-iv> + <ng-content *ngTemplateOutlet="celliv"></ng-content> + </div> + </layout-four-panel> + <layout-horizontal-one-three + *ngSwitchCase="supportedPanelModes[1]" + class="d-block w-100 h-100"> + <div class="w-100 h-100" cell-i> + <ng-content *ngTemplateOutlet="celli"></ng-content> + </div> + <div class="w-100 h-100" cell-ii> + <ng-content *ngTemplateOutlet="cellii"></ng-content> + </div> + <div class="w-100 h-100" cell-iii> + <ng-content *ngTemplateOutlet="celliii"></ng-content> + </div> + <div class="w-100 h-100" cell-iv> + <ng-content *ngTemplateOutlet="celliv"></ng-content> + </div> + </layout-horizontal-one-three> + <layout-vertical-one-three + *ngSwitchCase="supportedPanelModes[2]" + class="d-block w-100 h-100"> + <div class="w-100 h-100" cell-i> + <ng-content *ngTemplateOutlet="celli"></ng-content> + </div> + <div class="w-100 h-100" cell-ii> + <ng-content *ngTemplateOutlet="cellii"></ng-content> + </div> + <div class="w-100 h-100" cell-iii> + <ng-content *ngTemplateOutlet="celliii"></ng-content> + </div> + <div class="w-100 h-100" cell-iv> + <ng-content *ngTemplateOutlet="celliv"></ng-content> + </div> + </layout-vertical-one-three> + <layout-single-panel + *ngSwitchCase="supportedPanelModes[3]" + class="d-block w-100 h-100"> + <div class="w-100 h-100" cell-i> + <ng-content *ngTemplateOutlet="celli"></ng-content> + </div> + <div class="w-100 h-100" cell-ii> + <ng-content *ngTemplateOutlet="cellii"></ng-content> + </div> + <div class="w-100 h-100" cell-iii> + <ng-content *ngTemplateOutlet="celliii"></ng-content> + </div> + <div class="w-100 h-100" cell-iv> + <ng-content *ngTemplateOutlet="celliv"></ng-content> + </div> + </layout-single-panel> + <div *ngSwitchDefault> + A panel mode which I have never seen before ... + </div> +</div> + +<ng-template #celli> + <ng-content select="[cell-i]"></ng-content> +</ng-template> +<ng-template #cellii> + <ng-content select="[cell-ii]"></ng-content> +</ng-template> +<ng-template #celliii> + <ng-content select="[cell-iii]"></ng-content> +</ng-template> +<ng-template #celliv> + <ng-content select="[cell-iv]"></ng-content> +</ng-template> \ No newline at end of file diff --git a/src/ui/config/layouts/fourPanel/fourPanel.component.ts b/src/ui/config/layouts/fourPanel/fourPanel.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7a22a241e08a3862385b7e7704bf20f0adfded7 --- /dev/null +++ b/src/ui/config/layouts/fourPanel/fourPanel.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: 'layout-four-panel', + templateUrl: './fourPanel.template.html', + styleUrls: [ + './fourPanel.style.css' + ] +}) + +export class FourPanelLayout{ + +} \ No newline at end of file diff --git a/src/ui/config/layouts/fourPanel/fourPanel.style.css b/src/ui/config/layouts/fourPanel/fourPanel.style.css new file mode 100644 index 0000000000000000000000000000000000000000..03169dfb4b9564f62c86ca9250303c5337927861 --- /dev/null +++ b/src/ui/config/layouts/fourPanel/fourPanel.style.css @@ -0,0 +1,4 @@ +.four-panel-cell +{ + flex: 0 0 50%; +} \ No newline at end of file diff --git a/src/ui/config/layouts/fourPanel/fourPanel.template.html b/src/ui/config/layouts/fourPanel/fourPanel.template.html new file mode 100644 index 0000000000000000000000000000000000000000..ddb10f1f6a34adda68f578625dd843baf536dfa0 --- /dev/null +++ b/src/ui/config/layouts/fourPanel/fourPanel.template.html @@ -0,0 +1,18 @@ +<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-stretch"> + <div class="d-flex flex-row flex-grow-1 flex-shrink-1"> + <div class="d-flex flex-row four-panel-cell align-items-center justify-content-center"> + <ng-content select="[cell-i]"></ng-content> + </div> + <div class="d-flex flex-row four-panel-cell align-items-center justify-content-center"> + <ng-content select="[cell-ii]"></ng-content> + </div> + </div> + <div class="d-flex flex-row flex-grow-1 flex-shrink-1"> + <div class="d-flex flex-row four-panel-cell align-items-center justify-content-center"> + <ng-content select="[cell-iii]"></ng-content> + </div> + <div class="d-flex flex-row four-panel-cell align-items-center justify-content-center"> + <ng-content select="[cell-iv]"></ng-content> + </div> + </div> +</div> diff --git a/src/ui/config/layouts/h13/h13.component.ts b/src/ui/config/layouts/h13/h13.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..eccf98f96c6e49993db2a2ac609bfbc05e9a6bc7 --- /dev/null +++ b/src/ui/config/layouts/h13/h13.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: 'layout-horizontal-one-three', + templateUrl: './h13.template.html', + styleUrls: [ + './h13.style.css' + ] +}) + +export class HorizontalOneThree{ + +} \ No newline at end of file diff --git a/src/ui/config/layouts/h13/h13.style.css b/src/ui/config/layouts/h13/h13.style.css new file mode 100644 index 0000000000000000000000000000000000000000..be83c538297487ff9c330ba551ac85e2badfd309 --- /dev/null +++ b/src/ui/config/layouts/h13/h13.style.css @@ -0,0 +1,12 @@ +.major-column +{ + flex: 0 0 67%; +} +.minor-column +{ + flex: 0 0 33%; +} +.layout-31-cell +{ + flex: 0 0 33.33%; +} \ No newline at end of file diff --git a/src/ui/config/layouts/h13/h13.template.html b/src/ui/config/layouts/h13/h13.template.html new file mode 100644 index 0000000000000000000000000000000000000000..d389ce304f6671f2610920c5c6c3d7780b63ac1d --- /dev/null +++ b/src/ui/config/layouts/h13/h13.template.html @@ -0,0 +1,18 @@ +<div class="w-100 h-100 d-flex flex-row justify-content-center align-items-stretch"> + <div class="d-flex flex-column major-column"> + <div class="overflow-hidden flex-grow-1 d-flex align-items-center justify-content-center"> + <ng-content select="[cell-i]"></ng-content> + </div> + </div> + <div class="d-flex flex-column minor-column"> + <div class="overflow-hidden layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-ii]"></ng-content> + </div> + <div class="overflow-hidden layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-iii]"></ng-content> + </div> + <div class="overflow-hidden layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-iv]"></ng-content> + </div> + </div> +</div> \ No newline at end of file diff --git a/src/ui/config/layouts/single/single.component.ts b/src/ui/config/layouts/single/single.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..25101d27a9462b37e4072a912bd281126fdb7500 --- /dev/null +++ b/src/ui/config/layouts/single/single.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: 'layout-single-panel', + templateUrl: './single.template.html', + styleUrls: [ + './single.style.css' + ] +}) + +export class SinglePanel{ + +} \ No newline at end of file diff --git a/src/ui/config/layouts/single/single.style.css b/src/ui/config/layouts/single/single.style.css new file mode 100644 index 0000000000000000000000000000000000000000..19615b37060256eef0194131b901b11e89b01b49 --- /dev/null +++ b/src/ui/config/layouts/single/single.style.css @@ -0,0 +1,12 @@ +.major-column +{ + flex: 0 0 100%; +} +.minor-column +{ + flex: 0 0 0%; +} +.layout-31-cell +{ + flex: 0 0 33%; +} \ No newline at end of file diff --git a/src/ui/config/layouts/single/single.template.html b/src/ui/config/layouts/single/single.template.html new file mode 100644 index 0000000000000000000000000000000000000000..561a6363b701518ec5d27b1f0d78507e02afe30a --- /dev/null +++ b/src/ui/config/layouts/single/single.template.html @@ -0,0 +1,18 @@ +<div class="w-100 h-100 d-flex flex-row justify-content-center align-items-stretch"> + <div class="d-flex flex-column major-column"> + <div class="overflow-hidden flex-grow-1 d-flex align-items-center justify-content-center"> + <ng-content select="[cell-i]"></ng-content> + </div> + </div> + <div class="d-flex flex-column minor-column"> + <div class="overflow-hidden layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-ii]"></ng-content> + </div> + <div class="overflow-hidden layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-iii]"></ng-content> + </div> + <div class="overflow-hidden layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-iv]"></ng-content> + </div> + </div> + </div> \ No newline at end of file diff --git a/src/ui/config/layouts/v13/v13.component.ts b/src/ui/config/layouts/v13/v13.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c650ac701de3fff14118a9487403410ae89e72ad --- /dev/null +++ b/src/ui/config/layouts/v13/v13.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: 'layout-vertical-one-three', + templateUrl: './v13.template.html', + styleUrls: [ + './v13.style.css' + ] +}) + +export class VerticalOneThree{ + +} \ No newline at end of file diff --git a/src/ui/config/layouts/v13/v13.style.css b/src/ui/config/layouts/v13/v13.style.css new file mode 100644 index 0000000000000000000000000000000000000000..be83c538297487ff9c330ba551ac85e2badfd309 --- /dev/null +++ b/src/ui/config/layouts/v13/v13.style.css @@ -0,0 +1,12 @@ +.major-column +{ + flex: 0 0 67%; +} +.minor-column +{ + flex: 0 0 33%; +} +.layout-31-cell +{ + flex: 0 0 33.33%; +} \ No newline at end of file diff --git a/src/ui/config/layouts/v13/v13.template.html b/src/ui/config/layouts/v13/v13.template.html new file mode 100644 index 0000000000000000000000000000000000000000..148f70cb596d2df0183f25d012bb9d948b610f60 --- /dev/null +++ b/src/ui/config/layouts/v13/v13.template.html @@ -0,0 +1,18 @@ +<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-stretch"> + <div class="d-flex flex-column major-column"> + <div class="flex-grow-1 d-flex align-items-center justify-content-center"> + <ng-content select="[cell-i]"></ng-content> + </div> + </div> + <div class="d-flex flex-row minor-column"> + <div class="layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-ii]"></ng-content> + </div> + <div class="layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-iii]"></ng-content> + </div> + <div class="layout-31-cell d-flex align-items-center justify-content-center"> + <ng-content select="[cell-iv]"></ng-content> + </div> + </div> +</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..6c3ee1c3504db297134d6795e199cb778c18555e 100644 --- a/src/ui/databrowserModule/databrowser.module.ts +++ b/src/ui/databrowserModule/databrowser.module.ts @@ -25,6 +25,8 @@ import { KgSingleDatasetService } from "./kgSingleDatasetService.service" import { SingleDatasetView } from './singleDataset/singleDataset.component' import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module' import { DoiParserPipe } from "src/util/pipes/doiPipe.pipe"; +import { DatasetIsFavedPipe } from "./util/datasetIsFaved.pipe"; +import { RegionBackgroundToRgbPipe } from "./util/regionBackgroundToRgb.pipe"; @NgModule({ imports:[ @@ -56,7 +58,9 @@ import { DoiParserPipe } from "src/util/pipes/doiPipe.pipe"; FilterDataEntriesbyMethods, FilterDataEntriesByRegion, AggregateArrayIntoRootPipe, - DoiParserPipe + DoiParserPipe, + DatasetIsFavedPipe, + RegionBackgroundToRgbPipe ], exports:[ DataBrowser, diff --git a/src/ui/databrowserModule/databrowser.service.ts b/src/ui/databrowserModule/databrowser.service.ts index 5baa496dc446a07906ffb41a99358a134d98a357..65f9a241b4e1640b4e6cdffc8938a83e84bf38c8 100644 --- a/src/ui/databrowserModule/databrowser.service.ts +++ b/src/ui/databrowserModule/databrowser.service.ts @@ -4,7 +4,7 @@ import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; import { select, Store } from "@ngrx/store"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { ADD_NG_LAYER, REMOVE_NG_LAYER, DataEntry, safeFilter, FETCHED_DATAENTRIES, FETCHED_SPATIAL_DATA, UPDATE_SPATIAL_DATA } from "src/services/stateStore.service"; -import { map, distinctUntilChanged, debounceTime, filter, tap, switchMap, catchError } from "rxjs/operators"; +import { map, distinctUntilChanged, debounceTime, filter, tap, switchMap, catchError, shareReplay } from "rxjs/operators"; import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; import { FilterDataEntriesByRegion } from "./util/filterDataEntriesByRegion.pipe"; import { NO_METHODS } from "./util/filterDataEntriesByMethods.pipe"; @@ -13,6 +13,7 @@ import { DataBrowser } from "./databrowser/databrowser.component"; import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; import { SHOW_KG_TOS } from "src/services/state/uiState.store"; import { regionFlattener } from "src/util/regionFlattener"; +import { DATASETS_ACTIONS_TYPES } from "src/services/state/dataStore.store"; const noMethodDisplayName = 'No methods described' @@ -45,6 +46,8 @@ function generateToken() { }) export class DatabrowserService implements OnDestroy{ + public favedDataentries$: Observable<DataEntry[]> + public darktheme: boolean = false public instantiatedWidgetUnits: WidgetUnit[] = [] @@ -80,6 +83,12 @@ export class DatabrowserService implements OnDestroy{ private store: Store<ViewerConfiguration> ){ + this.favedDataentries$ = this.store.pipe( + select('dataStore'), + select('favDataEntries'), + shareReplay(1) + ) + this.subscriptions.push( this.store.pipe( select('ngViewerState') @@ -130,6 +139,9 @@ export class DatabrowserService implements OnDestroy{ return from(fetch(`${this.constantService.backendUrl}datasets/spatialSearch/templateName/${encodedTemplateName}/bbox/${pt1.join('_')}__${pt2.join("_")}`) .then(res => res.json())) }), + /** + * TODO pipe to constantService.catchError + */ catchError((err) => (console.log(err), of([]))) ) @@ -194,6 +206,20 @@ export class DatabrowserService implements OnDestroy{ this.subscriptions.forEach(s => s.unsubscribe()) } + public saveToFav(dataentry: DataEntry){ + this.store.dispatch({ + type: DATASETS_ACTIONS_TYPES.FAV_DATASET, + payload: dataentry + }) + } + + public removeFromFav(dataentry: DataEntry){ + this.store.dispatch({ + type: DATASETS_ACTIONS_TYPES.UNFAV_DATASET, + payload: dataentry + }) + } + public fetchPreviewData(datasetName: string){ const encodedDatasetName = encodeURI(datasetName) return new Promise((resolve, reject) => { @@ -306,12 +332,6 @@ export class DatabrowserService implements OnDestroy{ } public getModalityFromDE = getModalityFromDE - - public getBackgroundColorStyleFromRegion(region:any = null){ - return region && region.rgb - ? `rgb(${region.rgb.join(',')})` - : `white` - } } @@ -346,6 +366,12 @@ export function getModalityFromDE(dataentries:DataEntry[]):CountedDataModality[] return dataentries.reduce((acc, de) => reduceDataentry(acc, de), []) } +export function getIdFromDataEntry(dataentry: DataEntry){ + const { id, fullId } = dataentry + const regex = /\/([a-zA-Z0-9\-]*?)$/.exec(fullId) + return (regex && regex[1]) || id +} + export interface CountedDataModality{ name: string diff --git a/src/ui/databrowserModule/databrowser.useEffect.ts b/src/ui/databrowserModule/databrowser.useEffect.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9a57b51905c88e841669aa192463b0947d3c6be --- /dev/null +++ b/src/ui/databrowserModule/databrowser.useEffect.ts @@ -0,0 +1,146 @@ +import { Injectable, OnDestroy } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Actions, ofType, Effect } from "@ngrx/effects"; +import { DATASETS_ACTIONS_TYPES, DataEntry } from "src/services/state/dataStore.store"; +import { Observable, of, from, merge, Subscription } from "rxjs"; +import { withLatestFrom, map, catchError, filter, switchMap, scan, share, switchMapTo, shareReplay } from "rxjs/operators"; +import { KgSingleDatasetService } from "./kgSingleDatasetService.service"; +import { getIdFromDataEntry } from "./databrowser.service"; + +@Injectable({ + providedIn: 'root' +}) + +export class DataBrowserUseEffect implements OnDestroy{ + + private subscriptions: Subscription[] = [] + + constructor( + private store$: Store<any>, + private actions$: Actions<any>, + private kgSingleDatasetService: KgSingleDatasetService + + ){ + this.favDataEntries$ = this.store$.pipe( + select('dataStore'), + select('favDataEntries') + ) + + this.unfavDataset$ = this.actions$.pipe( + ofType(DATASETS_ACTIONS_TYPES.UNFAV_DATASET), + withLatestFrom(this.favDataEntries$), + map(([action, prevFavDataEntries]) => { + + const { payload = {} } = action as any + const { id } = payload + return { + type: DATASETS_ACTIONS_TYPES.UPDATE_FAV_DATASETS, + favDataEntries: prevFavDataEntries.filter(ds => ds.id !== id) + } + }) + ) + + this.favDataset$ = this.actions$.pipe( + ofType(DATASETS_ACTIONS_TYPES.FAV_DATASET), + withLatestFrom(this.favDataEntries$), + map(([ action, prevFavDataEntries ]) => { + const { payload } = action as any + + /** + * check duplicate + */ + const favDataEntries = prevFavDataEntries.find(favDEs => favDEs.id === payload.id) + ? prevFavDataEntries + : prevFavDataEntries.concat(payload) + + return { + type: DATASETS_ACTIONS_TYPES.UPDATE_FAV_DATASETS, + favDataEntries + } + }) + ) + + + this.subscriptions.push( + merge( + this.favDataset$, + this.unfavDataset$ + ).pipe( + switchMapTo(this.favDataEntries$) + ).subscribe(favDataEntries => { + /** + * only store the minimal data in localstorage/db, hydrate when needed + * for now, only save id + * + * do not save anything else on localstorage. This could potentially be leaking sensitive information + */ + const serialisedFavDataentries = favDataEntries.map(dataentry => { + const id = getIdFromDataEntry(dataentry) + return { id } + }) + window.localStorage.setItem(LOCAL_STORAGE_CONST.FAV_DATASET, JSON.stringify(serialisedFavDataentries)) + }) + ) + + this.savedFav$ = of(window.localStorage.getItem(LOCAL_STORAGE_CONST.FAV_DATASET)).pipe( + map(string => JSON.parse(string)), + map(arr => { + if (arr.every(item => item.id )) return arr + throw new Error('Not every item has id and/or name defined') + }), + catchError(err => { + /** + * TODO emit proper error + * possibly wipe corrupted local stoage here? + */ + return null + }) + ) + + this.onInitGetFav$ = this.savedFav$.pipe( + filter(v => !!v), + switchMap(arr => + merge( + ...arr.map(({ id: kgId }) => + from( this.kgSingleDatasetService.getInfoFromKg({ kgId })) + .pipe(catchError(err => { + console.log(`fetchInfoFromKg error`, err) + return null + }))) + ).pipe( + filter(v => !!v), + scan((acc, curr) => acc.concat(curr), []) + ) + ), + map(favDataEntries => { + return { + type: DATASETS_ACTIONS_TYPES.UPDATE_FAV_DATASETS, + favDataEntries + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } + + private savedFav$: Observable<{id: string, name: string}[] | null> + + @Effect() + public onInitGetFav$: Observable<any> + + private favDataEntries$: Observable<DataEntry[]> + + @Effect() + public favDataset$: Observable<any> + + @Effect() + public unfavDataset$: Observable<any> +} + +const LOCAL_STORAGE_CONST = { + FAV_DATASET: 'fzj.xg.iv.FAV_DATASET' +} \ No newline at end of file diff --git a/src/ui/databrowserModule/databrowser/databrowser.component.ts b/src/ui/databrowserModule/databrowser/databrowser.component.ts index b3568e039e3be5c046e2571e2d014ed1cac1877d..3b9c6ace0ddeecca61f7fd57fadb5eaa940576d7 100644 --- a/src/ui/databrowserModule/databrowser/databrowser.component.ts +++ b/src/ui/databrowserModule/databrowser/databrowser.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, ViewChild, Input } from "@angular/core"; import { DataEntry } from "src/services/stateStore.service"; -import { Subscription, merge } from "rxjs"; +import { Subscription, merge, Observable } from "rxjs"; import { DatabrowserService, CountedDataModality } from "../databrowser.service"; import { ModalityPicker } from "../modalityPicker/modalityPicker.component"; @@ -14,6 +14,8 @@ import { ModalityPicker } from "../modalityPicker/modalityPicker.component"; export class DataBrowser implements OnDestroy,OnInit{ + public favedDataentries$: Observable<DataEntry[]> + @Input() public regions: any[] = [] @@ -55,7 +57,7 @@ export class DataBrowser implements OnDestroy,OnInit{ constructor( private dbService: DatabrowserService ){ - + this.favedDataentries$ = this.dbService.favedDataentries$ } ngOnInit(){ @@ -136,6 +138,14 @@ export class DataBrowser implements OnDestroy,OnInit{ this.dbService.manualFetchDataset$.next(null) } + saveToFavourite(dataset: DataEntry){ + this.dbService.saveToFav(dataset) + } + + removeFromFavourite(dataset: DataEntry){ + this.dbService.removeFromFav(dataset) + } + public showParcellationList: boolean = false public filePreviewName: string @@ -155,10 +165,6 @@ export class DataBrowser implements OnDestroy,OnInit{ resetFilters(event?:MouseEvent){ this.clearAll() } - - getBackgroundColorStyleFromRegion(region:any) { - return this.dbService.getBackgroundColorStyleFromRegion(region) - } } export interface DataEntryFilter{ diff --git a/src/ui/databrowserModule/databrowser/databrowser.template.html b/src/ui/databrowserModule/databrowser/databrowser.template.html index 515a94d8331c6235c7ef253d927c7d159ba597ea..060a49ad118bbaea29a100e86db11bc0b128d144 100644 --- a/src/ui/databrowserModule/databrowser/databrowser.template.html +++ b/src/ui/databrowserModule/databrowser/databrowser.template.html @@ -16,7 +16,7 @@ <span *ngFor="let region of regions" class="badge badge-secondary mr-1 mw-100"> - <span [ngStyle]="{backgroundColor:getBackgroundColorStyleFromRegion(region)}" class="dot"> + <span [ngStyle]="{backgroundColor: (region | regionBackgroundToRgbPipe)}" class="dot"> </span> <span class="d-inline-block mw-100 overflow-hidden text-truncate"> @@ -114,15 +114,18 @@ <dataset-viewer class="mt-1" *ngFor="let dataset of filteredDataEntry | searchResultPagination : currentPage : hitsPerPage" + (saveToFavourite)="saveToFavourite(dataset)" + (removeFromFavourite)="removeFromFavourite(dataset)" (showPreviewDataset)="onShowPreviewDataset($event)" - [dataset]="dataset"> + [dataset]="dataset" + [isFaved]="favedDataentries$ | async | datasetIsFaved : dataset"> <div regionTagsContainer> <!-- TODO may want to separate the region badge into a separate component --> <span *ngFor="let region of dataset.parcellationRegion" class="badge badge-secondary mr-1 mw-100"> - <span [ngStyle]="{backgroundColor:getBackgroundColorStyleFromRegion(region)}" class="dot"> + <span [ngStyle]="{backgroundColor:(region | regionBackgroundToRgbPipe)}" class="dot"> </span> <span class="d-inline-block mw-100 overflow-hidden text-truncate"> diff --git a/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts b/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts index 7be8d563234f7e854b1bda067e07a1a758471718..aea172ffcc434d3fef4bc11c479ef2333a0e448c 100644 --- a/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts +++ b/src/ui/databrowserModule/datasetViewer/datasetViewer.component.ts @@ -9,6 +9,7 @@ import { DataEntry } from "src/services/stateStore.service"; export class DatasetViewerComponent{ @Input() dataset : DataEntry + @Input() isFaved: boolean @Output() showPreviewDataset: EventEmitter<{datasetName:string, event:MouseEvent}> = new EventEmitter() @ViewChild('kgrRef', {read:ElementRef}) kgrRef: ElementRef @@ -39,4 +40,21 @@ export class DatasetViewerComponent{ get kgReference(): string[] { return this.dataset.kgReference.map(ref => `https://doi.org/${ref}`) } + + /** + * Dummy functions, the store.dispatch is the important function + */ + @Output() + saveToFavourite: EventEmitter<boolean> = new EventEmitter() + + @Output() + removeFromFavourite: EventEmitter<boolean> = new EventEmitter() + + saveToFav(){ + this.saveToFavourite.emit() + } + + removeFromFav(){ + this.removeFromFavourite.emit() + } } \ No newline at end of file diff --git a/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html b/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html index c1248259e4ac13b17b3395b63ef41d1d3396b277..6b19fd16aa4248e039dfdb96a9ef8ca7af1f8fc5 100644 --- a/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html +++ b/src/ui/databrowserModule/datasetViewer/datasetViewer.template.html @@ -30,6 +30,13 @@ [hoverable]="{translateY:-3}"> <i class="fas fa-eye"></i> </div> + + <div + (click)="isFaved ? removeFromFav() : saveToFav()" + [class]="(isFaved ? 'text-primary' : 'text-muted') + ' ds-container ml-1 p-2 preview-container d-flex align-items-center'" + [hoverable]="{translateY:-3}"> + <i class="fas fa-star"></i> + </div> </div> <ng-template #defaultDisplay> diff --git a/src/ui/databrowserModule/kgSingleDatasetService.service.ts b/src/ui/databrowserModule/kgSingleDatasetService.service.ts index 31738eee7c4f55724f35c36dae69372b629b2b18..551b2cf34d1b2c7f70b93e085371c910c0ef35bf 100644 --- a/src/ui/databrowserModule/kgSingleDatasetService.service.ts +++ b/src/ui/databrowserModule/kgSingleDatasetService.service.ts @@ -7,7 +7,7 @@ export class KgSingleDatasetService { constructor(private constantService: AtlasViewerConstantsServices) { } - public getInfoFromKg({ kgId, kgSchema }: KgQueryInterface) { + public getInfoFromKg({ kgId, kgSchema = 'minds/core/dataset/v1.0.0' }: Partial<KgQueryInterface>) { const _url = new URL(`${this.constantService.backendUrl}datasets/kgInfo`) const searchParam = _url.searchParams searchParam.set('kgSchema', kgSchema) @@ -19,7 +19,7 @@ export class KgSingleDatasetService { }) } - public downloadZipFromKg({ kgSchema, kgId } : KgQueryInterface, filename = 'download'){ + public downloadZipFromKg({ kgSchema = 'minds/core/dataset/v1.0.0', kgId } : Partial<KgQueryInterface>, filename = 'download'){ const _url = new URL(`${this.constantService.backendUrl}datasets/downloadKgFiles`) const searchParam = _url.searchParams searchParam.set('kgSchema', kgSchema) diff --git a/src/ui/databrowserModule/util/datasetIsFaved.pipe.ts b/src/ui/databrowserModule/util/datasetIsFaved.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..2befe6fed9e1be922ab987d998a8a274ceb2d3d6 --- /dev/null +++ b/src/ui/databrowserModule/util/datasetIsFaved.pipe.ts @@ -0,0 +1,11 @@ +import { PipeTransform, Pipe } from "@angular/core"; +import { DataEntry } from "src/services/stateStore.service"; + +@Pipe({ + name: 'datasetIsFaved' +}) +export class DatasetIsFavedPipe implements PipeTransform{ + public transform(favedDataEntry: DataEntry[], dataentry: DataEntry):boolean{ + return favedDataEntry.findIndex(ds => ds.id === dataentry.id) >= 0 + } +} \ No newline at end of file diff --git a/src/ui/databrowserModule/util/regionBackgroundToRgb.pipe.ts b/src/ui/databrowserModule/util/regionBackgroundToRgb.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a03cd9dc7230bfbab69b5a4f19866e249914c80 --- /dev/null +++ b/src/ui/databrowserModule/util/regionBackgroundToRgb.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'regionBackgroundToRgbPipe' +}) + +export class RegionBackgroundToRgbPipe implements PipeTransform{ + public transform(region = null): string{ + return region && region.rgb + ? `rgb(${region.rgb.join(',')})` + : 'white' + } +} \ No newline at end of file diff --git a/src/ui/layerbrowser/layerbrowser.component.ts b/src/ui/layerbrowser/layerbrowser.component.ts index 18c2cb75b7971ea8e4a7e3a15c9fa4787e49308f..35178d86ea0c6322ccf0f799cc2eee64c7c701ae 100644 --- a/src/ui/layerbrowser/layerbrowser.component.ts +++ b/src/ui/layerbrowser/layerbrowser.component.ts @@ -1,9 +1,9 @@ -import { Component, OnDestroy } from "@angular/core"; +import { Component, OnDestroy, Input, Pipe, PipeTransform } from "@angular/core"; import { NgLayerInterface } from "../../atlasViewer/atlasViewer.component"; import { Store, select } from "@ngrx/store"; import { ViewerStateInterface, isDefined, REMOVE_NG_LAYER, FORCE_SHOW_SEGMENT, safeFilter, getNgIds } from "../../services/stateStore.service"; -import { Subscription, Observable } from "rxjs"; -import { filter, map } from "rxjs/operators"; +import { Subscription, Observable, combineLatest } from "rxjs"; +import { filter, map, shareReplay, tap } from "rxjs/operators"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; @Component({ @@ -20,13 +20,15 @@ export class LayerBrowser implements OnDestroy{ /** * TODO make untangle nglayernames and its dependency on ng */ - loadedNgLayers$: Observable<NgLayerInterface[]> - lockedLayers : string[] = [] + public loadedNgLayers$: Observable<NgLayerInterface[]> + public lockedLayers : string[] = [] + + public nonBaseNgLayers$: Observable<NgLayerInterface[]> public forceShowSegmentCurrentState : boolean | null = null public forceShowSegment$ : Observable<boolean|null> - public ngLayers$: Observable<any> + public ngLayers$: Observable<string[]> public advancedMode: boolean = false private subscriptions : Subscription[] = [] @@ -35,6 +37,11 @@ export class LayerBrowser implements OnDestroy{ /* TODO temporary measure. when datasetID can be used, will use */ public fetchedDataEntries$ : Observable<any> + @Input() + showPlaceholder: boolean = true + + darktheme$: Observable<boolean> + constructor( private store : Store<ViewerStateInterface>, private constantsService: AtlasViewerConstantsServices){ @@ -64,6 +71,22 @@ export class LayerBrowser implements OnDestroy{ */ map(arr => arr.filter(v => !!v)) ) + + this.loadedNgLayers$ = this.store.pipe( + select('viewerState'), + select('loadedNgLayers') + ) + + this.nonBaseNgLayers$ = combineLatest( + this.ngLayers$, + this.loadedNgLayers$ + ).pipe( + map(([baseNgLayerNames, loadedNgLayers]) => { + const baseNameSet = new Set(baseNgLayerNames) + return loadedNgLayers.filter(l => !baseNameSet.has(l.name)) + }) + ) + /** * TODO * this is no longer populated @@ -80,9 +103,9 @@ export class LayerBrowser implements OnDestroy{ map(state => state.forceShowSegment) ) - this.loadedNgLayers$ = this.store.pipe( - select('viewerState'), - select('loadedNgLayers') + + this.darktheme$ = this.constantsService.darktheme$.pipe( + shareReplay(1) ) this.subscriptions.push( @@ -128,6 +151,9 @@ export class LayerBrowser implements OnDestroy{ return } + /** + * TODO perhaps useEffects ? + */ this.store.dispatch({ type : FORCE_SHOW_SEGMENT, forceShowSegment : this.forceShowSegmentCurrentState === null @@ -151,6 +177,9 @@ export class LayerBrowser implements OnDestroy{ }) } + /** + * TODO use observable and pipe to make this more perf + */ segmentationTooltip(){ return `toggle segments visibility: ${this.forceShowSegmentCurrentState === true ? 'always show' : this.forceShowSegmentCurrentState === false ? 'always hide' : 'auto'}` @@ -169,4 +198,16 @@ export class LayerBrowser implements OnDestroy{ get isMobile(){ return this.constantsService.mobile } + + public matTooltipPosition: string = 'below' } + +@Pipe({ + name: 'lockedLayerBtnClsPipe' +}) + +export class LockedLayerBtnClsPipe implements PipeTransform{ + public transform(ngLayer:NgLayerInterface, lockedLayers?: string[]): boolean{ + return (lockedLayers && new Set(lockedLayers).has(ngLayer.name)) || false + } +} \ No newline at end of file diff --git a/src/ui/layerbrowser/layerbrowser.style.css b/src/ui/layerbrowser/layerbrowser.style.css index 83bf14bb66993088b1d86f607e24784f07926bd4..495211b5c91f0746e1c4d522c18a8d3dfdb3a835 100644 --- a/src/ui/layerbrowser/layerbrowser.style.css +++ b/src/ui/layerbrowser/layerbrowser.style.css @@ -16,11 +16,6 @@ div[body] background-color:rgba(0, 0, 0, 0.1); } -.muted-text -{ - text-decoration: line-through; -} - .layerContainer { display: flex; diff --git a/src/ui/layerbrowser/layerbrowser.template.html b/src/ui/layerbrowser/layerbrowser.template.html index 6ac015c163020f210e8adce0b33e9da34d9be011..b5d5c8e45f37ff1e3ca3fe0de68e1edce3a14c1c 100644 --- a/src/ui/layerbrowser/layerbrowser.template.html +++ b/src/ui/layerbrowser/layerbrowser.template.html @@ -1,71 +1,59 @@ -<ng-container *ngIf="ngLayers$ | async | filterNgLayer : (loadedNgLayers$ | async) as filteredNgLayers; else noLayerPlaceHolder"> - <ng-container *ngIf="filteredNgLayers.length > 0; else noLayerPlaceHolder"> - <div - class="layerContainer overflow-hidden" - *ngFor = "let ngLayer of filteredNgLayers"> - +<ng-container *ngIf="nonBaseNgLayers$ | async as nonBaseNgLayers; else noLayerPlaceHolder"> + <mat-list *ngIf="nonBaseNgLayers.length > 0; else noLayerPlaceHolder"> + <mat-list-item *ngFor="let ngLayer of nonBaseNgLayers"> + <!-- toggle visibility --> - <div class="btnWrapper"> - <div - container = "body" - placement = "bottom" - [tooltip] = "checkLocked(ngLayer) ? 'base layer cannot be hidden' : 'toggle visibility'" - (click) = "checkLocked(ngLayer) ? null : toggleVisibility(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i [ngClass] = "checkLocked(ngLayer) ? 'fas fa-lock muted' :ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> - </i> - </div> - </div> + + <button + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layer cannot be hidden' : 'toggle visibility'" + (click)="toggleVisibility(ngLayer)" + mat-icon-button + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [color]="ngLayer.visible ? 'primary' : null"> + <i [ngClass]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : ngLayer.visible ? 'far fa-eye' : 'far fa-eye-slash'"> + </i> + </button> <!-- advanced mode only: toggle force show segmentation --> - <div class="btnWrapper"> - <div - *ngIf="advancedMode" - container="body" - placement="bottom" - [tooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" - #forceSegment="bs-tooltip" - (click)="forceSegment.hide();toggleForceShowSegment(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i - class="fas" - [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> - - </i> - </div> - </div> + <button + *ngIf="advancedMode" + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.type === 'segmentation' ? segmentationTooltip() : 'only segmentation layer can hide/show segments'" + (click)="toggleForceShowSegment(ngLayer)" + mat-icon-button> + <i + class="fas" + [ngClass]="ngLayer.type === 'segmentation' ? ('fa-th-large ' + segmentationAdditionalClass) : 'fa-lock muted' "> + + </i> + </button> <!-- remove layer --> - <div class="btnWrapper"> - <div - container="body" - placement="bottom" - [tooltip]="checkLocked(ngLayer) ? 'base layers cannot be removed' : 'remove layer'" - (click)="removeLayer(ngLayer)" - class="btn btn-sm btn-outline-secondary rounded-circle"> - <i [ngClass]="checkLocked(ngLayer) ? 'fas fa-lock muted' : 'far fa-times-circle'"> - </i> - </div> - </div> + <button + color="warn" + mat-icon-button + (click)="removeLayer(ngLayer)" + [disabled]="ngLayer | lockedLayerBtnClsPipe : lockedLayers" + [matTooltip]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'base layers cannot be removed' : 'remove layer'"> + <i [class]="(ngLayer | lockedLayerBtnClsPipe : lockedLayers) ? 'fas fa-lock muted' : 'fas fa-trash'"> + </i> + </button> <!-- layer description --> - <panel-component [ngClass]="{'muted-text muted' : !classVisible(ngLayer)}"> - - <div heading> - {{ ngLayer.name | getLayerNameFromDatasets : (fetchedDataEntries$ | async) }} - </div> - - <div bodyy> - {{ ngLayer.source }} - </div> - </panel-component> - </div> - </ng-container> + <div + [matTooltipPosition]="matTooltipPosition" + [matTooltip]="ngLayer.name | getFilenamePipe " + [class]="((darktheme$ | async) ? 'text-light' : 'text-dark') + ' text-truncate'"> + {{ ngLayer.name | getFilenamePipe | getFileExtension }} + </div> + </mat-list-item> + </mat-list> </ng-container> <!-- fall back when no layers are showing --> <ng-template #noLayerPlaceHolder> - <h5 class="noLayerPlaceHolder text-muted"> + <small *ngIf="showPlaceholder" class="noLayerPlaceHolder text-muted"> No additional layers added. - </h5> + </small> </ng-template> \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.component.ts b/src/ui/menuicons/menuicons.component.ts index c488a4e5253d88b9e1b5bea7a3abfa438952ac6a..3ff7f8b1db939b83b0fb91d7f057aa84841c0a04 100644 --- a/src/ui/menuicons/menuicons.component.ts +++ b/src/ui/menuicons/menuicons.component.ts @@ -1,15 +1,17 @@ -import { Component, ComponentRef, Injector, ComponentFactory, ComponentFactoryResolver, AfterViewInit } from "@angular/core"; +import { Component, ComponentRef, Injector, ComponentFactory, ComponentFactoryResolver } from "@angular/core"; import { WidgetServices } from "src/atlasViewer/widgetUnit/widgetService.service"; import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; -import { LayerBrowser } from "src/ui/layerbrowser/layerbrowser.component"; import { DataBrowser } from "src/ui/databrowserModule/databrowser/databrowser.component"; import { PluginBannerUI } from "../pluginBanner/pluginBanner.component"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { DatabrowserService } from "../databrowserModule/databrowser.service"; -import { PluginServices } from "src/atlasViewer/atlasViewer.pluginService.service"; +import { PluginServices, PluginManifest } from "src/atlasViewer/atlasViewer.pluginService.service"; import { Store, select } from "@ngrx/store"; -import { Observable } from "rxjs"; +import { Observable, BehaviorSubject, combineLatest, merge, of } from "rxjs"; +import { map, shareReplay, startWith } from "rxjs/operators"; +import { DESELECT_REGIONS, SELECT_REGIONS, CHANGE_NAVIGATION } from "src/services/state/viewerState.store"; +import { ToastService } from "src/services/toastService.service"; @Component({ selector: 'menu-icons', @@ -22,6 +24,8 @@ import { Observable } from "rxjs"; export class MenuIconsBar{ + public badgetPosition: string = 'above before' + /** * databrowser */ @@ -29,13 +33,6 @@ export class MenuIconsBar{ dataBrowser: ComponentRef<DataBrowser> = null dbWidget: ComponentRef<WidgetUnit> = null - /** - * layerBrowser - */ - lbcf: ComponentFactory<LayerBrowser> - layerBrowser: ComponentRef<LayerBrowser> = null - lbWidget: ComponentRef<WidgetUnit> = null - /** * pluginBrowser */ @@ -43,11 +40,24 @@ export class MenuIconsBar{ pluginBanner: ComponentRef<PluginBannerUI> = null pbWidget: ComponentRef<WidgetUnit> = null - get isMobile(){ - return this.constantService.mobile + isMobile: boolean = false + mobileRespBtnClass: string + + public darktheme$: Observable<boolean> + + public themedBtnClass$: Observable<string> + + public skeletonBtnClass$: Observable<string> + + public toolBtnClass$: Observable<string> + public getKgSearchBtnCls$: Observable<[Set<WidgetUnit>, string]> + + get darktheme(){ + return this.constantService.darktheme } public selectedTemplate$: Observable<any> + public selectedRegions$: Observable<any[]> constructor( private widgetServices:WidgetServices, @@ -56,19 +66,63 @@ export class MenuIconsBar{ public dbService: DatabrowserService, cfr: ComponentFactoryResolver, public pluginServices:PluginServices, - store: Store<any> + private store: Store<any>, + private toastService: ToastService ){ + this.isMobile = this.constantService.mobile + this.mobileRespBtnClass = this.constantService.mobile ? 'btn-lg' : 'btn-sm' + this.dbService.createDatabrowser = this.clickSearch.bind(this) this.dbcf = cfr.resolveComponentFactory(DataBrowser) - this.lbcf = cfr.resolveComponentFactory(LayerBrowser) this.pbcf = cfr.resolveComponentFactory(PluginBannerUI) this.selectedTemplate$ = store.pipe( select('viewerState'), select('templateSelected') ) + + this.selectedRegions$ = store.pipe( + select('viewerState'), + select('regionsSelected'), + startWith([]), + shareReplay(1) + ) + + this.themedBtnClass$ = this.constantService.darktheme$.pipe( + map(flag => flag ? 'btn-dark' : 'btn-light' ), + shareReplay(1) + ) + + this.skeletonBtnClass$ = this.constantService.darktheme$.pipe( + map(flag => `${this.mobileRespBtnClass} ${flag ? 'text-light' : 'text-dark'}`), + shareReplay(1) + ) + + this.launchedPlugins$ = this.pluginServices.launchedPlugins$.pipe( + map(set => Array.from(set)), + shareReplay(1) + ) + + /** + * TODO remove dependency on themedBtnClass$ + */ + this.getPluginBtnClass$ = combineLatest( + this.pluginServices.launchedPlugins$, + this.pluginServices.minimisedPlugins$, + this.themedBtnClass$ + ) + + this.darktheme$ = this.constantService.darktheme$ + + /** + * TODO remove dependency on themedBtnClass$ + */ + this.getKgSearchBtnCls$ = combineLatest( + this.widgetServices.minimisedWindow$, + this.themedBtnClass$ + ) } /** @@ -98,36 +152,8 @@ export class MenuIconsBar{ } public catchError(e) { - + this.constantService.catchError(e) } - - public clickLayer(event: MouseEvent){ - - if (this.lbWidget) { - this.lbWidget.destroy() - this.lbWidget = null - return - } - this.layerBrowser = this.lbcf.create(this.injector) - this.lbWidget = this.widgetServices.addNewWidget(this.layerBrowser, { - exitable: true, - persistency: true, - state: 'floating', - title: 'Layer Browser', - titleHTML: '<i class="fas fa-layer-group"></i> Layer Browser' - }) - - this.lbWidget.onDestroy(() => { - this.layerBrowser = null - this.lbWidget = null - }) - - const el = event.currentTarget as HTMLElement - const top = el.offsetTop - const left = el.offsetLeft + 50 - this.lbWidget.instance.position = [left, top] - } - public clickPlugins(event: MouseEvent){ if(this.pbWidget) { this.pbWidget.destroy() @@ -154,19 +180,32 @@ export class MenuIconsBar{ this.pbWidget.instance.position = [left, top] } - get databrowserIsShowing() { - return this.dataBrowser !== null + public clickPluginIcon(manifest: PluginManifest){ + this.pluginServices.launchPlugin(manifest) + .catch(this.constantService.catchError) } - get layerbrowserIsShowing() { - return this.layerBrowser !== null + public searchIconClickHandler(wu: WidgetUnit){ + if (this.widgetServices.isMinimised(wu)) { + this.widgetServices.unminimise(wu) + } else { + this.widgetServices.minimise(wu) + } } - get pluginbrowserIsShowing() { - return this.pluginBanner !== null + public closeWidget(event: MouseEvent, wu:WidgetUnit){ + event.stopPropagation() + this.widgetServices.exitWidget(wu) } - get dataBrowserTitle() { - return `Browse` + public renameKgSearchWidget(event:MouseEvent, wu: WidgetUnit) { + event.stopPropagation() } + + public favKgSearch(event: MouseEvent, wu: WidgetUnit) { + event.stopPropagation() + } + + public getPluginBtnClass$: Observable<[Set<string>, Set<string>, string]> + public launchedPlugins$: Observable<string[]> } \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.style.css b/src/ui/menuicons/menuicons.style.css index 8a1663369fecdd67c4c7c70ee73774316f49e430..c76c706c7151f5276eaba98168489ee4bfc82dc5 100644 --- a/src/ui/menuicons/menuicons.style.css +++ b/src/ui/menuicons/menuicons.style.css @@ -20,4 +20,19 @@ :host >>> .tooltip.right .tooltip-arrow { border-right-color: rgba(128, 128, 128, 0.5); +} + +.soh-row > *:not(:first-child) +{ + margin-left: 0.1em; +} + +.soh-column > *:not(:first-child) +{ + margin-top: 0.1em; +} + +layer-browser +{ + max-width: 20em; } \ No newline at end of file diff --git a/src/ui/menuicons/menuicons.template.html b/src/ui/menuicons/menuicons.template.html index 214281632fa62839e0a9d3d0fa0b27e10ec18cbf..00b4065a9219b171ebc38c8df0dce6d503eaa460 100644 --- a/src/ui/menuicons/menuicons.template.html +++ b/src/ui/menuicons/menuicons.template.html @@ -3,94 +3,309 @@ <!-- hide icons when templates has yet been selected --> <ng-template [ngIf]="selectedTemplate$ | async"> - <div - *ngIf="false" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + + <!-- layer browser --> + <sleight-of-hand> + <div sleight-of-hand-front> + <button + [matBadge]="layerBrowser && (layerBrowser.nonBaseNgLayers$ | async)?.length > 0 ? (layerBrowser.nonBaseNgLayers$ | async)?.length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + color="primary" + mat-icon-button> + <i class="fas fa-layer-group"></i> + </button> + </div> <div - [tooltip]="dataBrowserTitle" - placement="right" - (click)="clickSearch($event)" - [ngClass]="databrowserIsShowing ? 'btn-primary' : 'btn-secondary'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-search"> - - </i> + class="d-flex flex-row align-items-center soh-row" + sleight-of-hand-back> + + <button + [matBadge]="layerBrowser && (layerBrowser.nonBaseNgLayers$ | async)?.length > 0 ? (layerBrowser.nonBaseNgLayers$ | async)?.length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + color="primary" + mat-icon-button> + <i class="fas fa-layer-group"></i> + </button> + + <div class="position-relative"> + + <div [ngClass]="{'invisible pe-none': (layerBrowser.nonBaseNgLayers$ | async).length === 0}" class="position-absolute"> + <mat-card> + <layer-browser #layerBrowser> + </layer-browser> + </mat-card> + </div> + + <ng-container *ngIf="(layerBrowser.nonBaseNgLayers$ | async).length === 0" #noNonBaseNgLayerTemplate> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + No additional layers added + </small> + </ng-container> + + <!-- invisible button to prop up the size of parent block --> + <!-- otherwise, sibling block position will be wonky --> + <button + color="primary" + class="invisible pe-none" + mat-icon-button> + <i class="fas fa-layer-group"></i> + </button> + + </div> + </div> - </div> + </sleight-of-hand> + + <!-- tools --> + <sleight-of-hand> - <div - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> - <div - tooltip="Layer" - placement="right" - (click)="clickLayer($event)" - [ngClass]="layerbrowserIsShowing ? 'btn-primary' : 'btn-secondary'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-layer-group"> - - </i> + <!-- shown icon prior to mouse over --> + <div sleight-of-hand-front> + <button + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + [matBadge]="(launchedPlugins$ | async)?.length > 0 ? (launchedPlugins$ | async)?.length : null" + mat-icon-button + color="primary"> + <i class="fas fa-tools"></i> + </button> </div> - </div> - <div - *ngIf="false" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + <!-- shown after mouse over --> <div - tooltip="Plugins" - (click)="clickPlugins($event)" - placement="right" - [ngClass]="pluginbrowserIsShowing ? 'btn-primary' : 'btn-secondary'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-tools"> + class="d-flex flex-row soh-row align-items-start" + sleight-of-hand-back> + + <!-- placeholder icon --> + <button + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + [matBadge]="(launchedPlugins$ | async)?.length > 0 ? (launchedPlugins$ | async)?.length : null" + mat-icon-button + color="primary"> + <i class="fas fa-tools"></i> + </button> + + <!-- render all fetched tools --> + <div class="d-flex flex-row soh-row"> + + <!-- add new tool btn --> + <button + matTooltip="Add new plugin" + matTooltipPosition="below" + mat-icon-button + color="primary"> + <i class="fas fa-plus"></i> + </button> - </i> + <button + *ngFor="let manifest of pluginServices.fetchedPluginManifests" + mat-mini-fab + matTooltipPosition="below" + [matTooltip]="manifest.displayName || manifest.name" + [color]="getPluginBtnClass$ | async | pluginBtnFabColorPipe : manifest.name" + (click)="clickPluginIcon(manifest)"> + {{ (manifest.displayName || manifest.name).slice(0, 1) }} + </button> + </div> </div> - </div> + </sleight-of-hand> - <div - *ngFor="let manifest of pluginServices.fetchedPluginManifests" - [tooltip]="manifest.displayName || manifest.name" - placement="right" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + <!-- search kg --> + <sleight-of-hand> + + <!-- shown icon prior to mouse over --> + <div sleight-of-hand-front> + <button + mat-icon-button + color="primary" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + [matBadge]="dbService.instantiatedWidgetUnits.length > 0 ? dbService.instantiatedWidgetUnits.length : null"> + <i class="fas fa-search"></i> + </button> + </div> + <!-- shown after mouse over --> <div - (click)="pluginServices.launchPlugin(manifest).catch(catchError)" - [ngClass]="!pluginServices.launchedPlugins.has(manifest.name) ? 'btn-outline-secondary' : pluginServices.pluginMinimised(manifest) ? 'btn-outline-info' : 'btn-info'" - class="shadow btn btn-sm rounded-circle"> - {{ (manifest.displayName || manifest.name).slice(0, 1) }} + sleight-of-hand-back + class="d-flex flex-row align-items-center soh-row pe-none"> + + <!-- placeholder icon --> + <button + mat-icon-button + color="primary" + matBadgeColor="accent" + [matBadgePosition]="badgetPosition" + [matBadge]="dbService.instantiatedWidgetUnits.length > 0 ? dbService.instantiatedWidgetUnits.length : null"> + <i class="fas fa-search"></i> + </button> + + <!-- only renders if there is at least one search result --> + <div + *ngIf="dbService.instantiatedWidgetUnits.length > 0; else noKgSearchTemplate" + class="position-relative pe-all"> + + <div class="position-absolute d-flex flex-column soh-column"> + + <!-- render all searched kg --> + <sleight-of-hand + *ngFor="let wu of dbService.instantiatedWidgetUnits" + (click)="searchIconClickHandler(wu)"> + + <!-- shown prior to mouseover --> + <div sleight-of-hand-front> + <button + mat-mini-fab + [color]="getKgSearchBtnCls$ | async | kgSearchBtnColorPipe : wu"> + <i class="fas fa-search"></i> + </button> + </div> + + <!-- shown on mouse over --> + <!-- showing additional information. in this case, name of the kg search --> + <div class="d-flex flex-row align-items-center" sleight-of-hand-back> + + <div sleight-of-hand-front> + <button + mat-mini-fab + [color]="getKgSearchBtnCls$ | async | kgSearchBtnColorPipe : wu"> + <i class="fas fa-search"></i> + </button> + </div> + + <!-- on hover, show full name and action possible: rename, close --> + <div [class]="((darktheme$ | async) ? 'text-light' : 'text-dark' ) + ' h-0 d-flex flex-row align-items-center'"> + + <sleight-of-hand class="ml-1 h-0"> + <!-- prior mouse over --> + <div class="h-0 d-flex align-items-center flex-row" sleight-of-hand-front> + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light' ) + ' muted d-flex flex-row align-items-center'"> + + <small class="cursor-default ml-2 text-nowrap"> + {{ wu.title }} + </small> + + <!-- dummy class to keep height --> + <div + matTooltip="Rename" + matTooltipPosition="below" + [class]="(skeletonBtnClass$ | async) + ' invisible w-0 pe-none'"> + <i class="fas fa-edit"></i> + </div> + </div> + </div> + + <!-- on mouse over --> + <div class="h-0 d-flex align-items-center flex-row" sleight-of-hand-back> + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light' ) + ' d-flex flex-row align-items-center'"> + + <small class="cursor-default ml-2 text-nowrap"> + {{ wu.title }} + </small> + + <!-- rename --> + <div + (click)="renameKgSearchWidget($event, wu)" + matTooltip="Rename (NYI)" + matTooltipPosition="below" + [class]="(skeletonBtnClass$ | async) + ' text-muted'"> + <i class="fas fa-edit"></i> + </div> + + <!-- star --> + <div + (click)="favKgSearch($event, wu)" + matTooltip="Favourite (NYI)" + matTooltipPosition="below" + [class]="(skeletonBtnClass$ | async) + ' text-muted'"> + <i class="far fa-star"></i> + </div> + + <!-- close --> + <div + (click)="closeWidget($event, wu)" + matTooltip="Close" + matTooltipPosition="below" + [class]="skeletonBtnClass$ | async"> + <i class="fas fa-times"></i> + </div> + </div> + </div> + </sleight-of-hand> + </div> + </div> + </sleight-of-hand> + </div> + + <!-- invisible icon to keep height of the otherwise unstable flex block --> + <div class="invisible pe-none"> + <button mat-icon-button> + <i class="fas fa-search"></i> + </button> + </div> + </div> + + <!-- displayed when no search is visible --> + <ng-template #noKgSearchTemplate> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + Right click any area to search + </small> + </ng-template> </div> - </div> - <div - *ngFor="let manifest of pluginServices.orphanPlugins" - [tooltip]="manifest.displayName || manifest.name" - placement="right" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - class="btnWrapper"> + </sleight-of-hand> - <div - (click)="pluginServices.launchPlugin(manifest).catch(catchError)" - [ngClass]="pluginServices.pluginMinimised(manifest) ? 'btn-outline-info' : 'btn-info'" - class="shadow btn btn-sm rounded-circle"> - {{ (manifest.displayName || manifest.name).slice(0, 1) }} + <!-- selected regions --> + <sleight-of-hand + [doNotClose]="viewerStateController.focused"> + + <!-- shown prior to mouse over --> + <div sleight-of-hand-front> + <button + [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + mat-icon-button + color="primary"> + <i class="fas fa-brain"></i> + </button> </div> - </div> - - <div - *ngFor="let wu of dbService.instantiatedWidgetUnits" - [ngClass]="isMobile ? 'btnWrapper-lg' : ''" - placement="right" - [tooltip]="wu.title" - class="btnWrapper"> + + <!-- shown upon mouseover --> <div - (click)="widgetServices.minimisedWindow.delete(wu)" - [ngClass]="widgetServices.minimisedWindow.has(wu) ? 'btn-outline-info' : 'btn-info'" - class="shadow btn btn-sm rounded-circle"> - <i class="fas fa-search"></i> + sleight-of-hand-back + class="d-flex flex-row align-items-center soh-row"> + + <!-- place holder icon --> + <button + [matBadge]="(selectedRegions$ | async).length > 0 ? (selectedRegions$ | async).length : null" + [matBadgePosition]="badgetPosition" + matBadgeColor="accent" + mat-icon-button + color="primary"> + <i class="fas fa-brain"></i> + </button> + + <div class="position-relative"> + + <div [class]="((darktheme$ | async) ? 'bg-dark' : 'bg-light') + ' position-absolute card'"> + <viewer-state-controller #viewerStateController></viewer-state-controller> + </div> + + <!-- invisible icon to keep height of the otherwise unstable flex block --> + <div class="invisible pe-none"> + <i class="fas fa-brain"></i> + </div> + </div> + + <ng-template #noBrainRegionSelected> + <small [class]="((darktheme$ | async) ? 'bg-dark text-light' : 'bg-light text-dark') + ' muted pl-2 pr-2 p-1 text-nowrap'"> + Double click any brain region to select it. + </small> + </ng-template> </div> - </div> + </sleight-of-hand> </ng-template> \ No newline at end of file diff --git a/src/ui/nehubaContainer/nehubaContainer.component.ts b/src/ui/nehubaContainer/nehubaContainer.component.ts index 44dfc715979eabe3d800db61441c919171ecb669..3112fa91a944a0766c871af90034af33c989a715 100644 --- a/src/ui/nehubaContainer/nehubaContainer.component.ts +++ b/src/ui/nehubaContainer/nehubaContainer.component.ts @@ -1,17 +1,22 @@ import { Component, ViewChild, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, ComponentRef, OnInit, OnDestroy, ElementRef } from "@angular/core"; import { NehubaViewerUnit } from "./nehubaViewer/nehubaViewer.component"; import { Store, select } from "@ngrx/store"; -import { ViewerStateInterface, safeFilter, CHANGE_NAVIGATION, isDefined, USER_LANDMARKS, ADD_NG_LAYER, REMOVE_NG_LAYER, NgViewerStateInterface, MOUSE_OVER_LANDMARK, SELECT_LANDMARKS, Landmark, PointLandmarkGeometry, PlaneLandmarkGeometry, OtherLandmarkGeometry, getNgIds, getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId } from "../../services/stateStore.service"; +import { ViewerStateInterface, safeFilter, CHANGE_NAVIGATION, isDefined, USER_LANDMARKS, ADD_NG_LAYER, REMOVE_NG_LAYER, NgViewerStateInterface, MOUSE_OVER_LANDMARK, SELECT_LANDMARKS, Landmark, PointLandmarkGeometry, PlaneLandmarkGeometry, OtherLandmarkGeometry, getNgIds, getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId, DataEntry } from "../../services/stateStore.service"; import { Observable, Subscription, fromEvent, combineLatest, merge } from "rxjs"; -import { filter,map, take, scan, debounceTime, distinctUntilChanged, switchMap, skip, withLatestFrom, buffer, tap, throttleTime, bufferTime } from "rxjs/operators"; +import { filter,map, take, scan, debounceTime, distinctUntilChanged, switchMap, skip, withLatestFrom, buffer, tap, switchMapTo, shareReplay, throttleTime, bufferTime, startWith } from "rxjs/operators"; import { AtlasViewerAPIServices, UserLandmark } from "../../atlasViewer/atlasViewer.apiService.service"; import { timedValues } from "../../util/generator"; import { AtlasViewerConstantsServices } from "../../atlasViewer/atlasViewer.constantService.service"; 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 { NEHUBA_READY, H_ONE_THREE, V_ONE_THREE, FOUR_PANEL, SINGLE_PANEL } from "src/services/state/ngViewerState.store"; import { MOUSE_OVER_SEGMENTS } from "src/services/state/uiState.store"; -import { SELECT_REGIONS_WITH_ID, NEHUBA_LAYER_CHANGED } from "src/services/state/viewerState.store"; +import { getHorizontalOneThree, getVerticalOneThree, getFourPanel, getSinglePanel } from "./util"; +import { SELECT_REGIONS_WITH_ID, NEHUBA_LAYER_CHANGED, VIEWERSTATE_ACTION_TYPES } from "src/services/state/viewerState.store"; +import { MatBottomSheet, MatButton } from "@angular/material"; +import { DATASETS_ACTIONS_TYPES } from "src/services/state/dataStore.store"; +import { KgSingleDatasetService } from "../databrowserModule/kgSingleDatasetService.service"; +import { getIdFromDataEntry } from "../databrowserModule/databrowser.service"; const getProxyUrl = (ngUrl) => `nifti://${BACKEND_URL}preview/file?fileUrl=${encodeURIComponent(ngUrl.replace(/^nifti:\/\//,''))}` const getProxyOther = ({source}) => /AUTH_227176556f3c4bb38df9feea4b91200c/.test(source) @@ -85,10 +90,6 @@ const scanFn : (acc:[boolean, boolean, boolean], curr: CustomEvent) => [boolean, export class NehubaContainer implements OnInit, OnDestroy{ @ViewChild('container',{read:ViewContainerRef}) container : ViewContainerRef - @ViewChild('[pos00]',{read:ElementRef}) topleft : ElementRef - @ViewChild('[pos01]',{read:ElementRef}) topright : ElementRef - @ViewChild('[pos10]',{read:ElementRef}) bottomleft : ElementRef - @ViewChild('[pos11]',{read:ElementRef}) bottomright : ElementRef private nehubaViewerFactory : ComponentFactory<NehubaViewerUnit> @@ -135,21 +136,31 @@ 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[] = [] public nanometersToOffsetPixelsFn : Function[] = [] private viewerConfig : Partial<ViewerConfiguration> = {} + private viewPanels: [HTMLElement, HTMLElement, HTMLElement, HTMLElement] = [null, null, null, null] + public panelMode$: Observable<string> + private redrawLayout$: Observable<[string, string]> + public favDataEntries$: Observable<DataEntry[]> + constructor( private constantService : AtlasViewerConstantsServices, private apiService :AtlasViewerAPIServices, private csf:ComponentFactoryResolver, private store : Store<ViewerStateInterface>, - private elementRef : ElementRef + private elementRef : ElementRef, + public bottomSheet: MatBottomSheet, + private kgSingleDataset: KgSingleDatasetService ){ + this.favDataEntries$ = this.store.pipe( + select('dataStore'), + select('favDataEntries') + ) + this.viewerPerformanceConfig$ = this.store.pipe( select('viewerConfigState'), /** @@ -162,6 +173,25 @@ export class NehubaContainer implements OnInit, OnDestroy{ filter(() => isDefined(this.nehubaViewer) && isDefined(this.nehubaViewer.nehubaViewer)) ) + this.redrawLayout$ = this.store.pipe( + select('ngViewerState'), + select('nehubaReady'), + distinctUntilChanged(), + filter(v => !!v), + switchMapTo(combineLatest( + this.store.pipe( + select('ngViewerState'), + select('panelMode'), + distinctUntilChanged() + ), + this.store.pipe( + select('ngViewerState'), + select('panelOrder'), + distinctUntilChanged() + ) + )) + ) + this.nehubaViewerFactory = this.csf.resolveComponentFactory(NehubaViewerUnit) this.newViewer$ = this.store.pipe( @@ -220,13 +250,9 @@ export class NehubaContainer implements OnInit, OnDestroy{ ) this.userLandmarks$ = this.store.pipe( - /* TODO: distinct until changed */ select('viewerState'), - // filter(state => isDefined(state) && isDefined(state.userLandmarks)), - map(state => isDefined(state) && isDefined(state.userLandmarks) - ? state.userLandmarks - : []), - distinctUntilChanged(userLmUnchanged) + select('userLandmarks'), + distinctUntilChanged() ) this.onHoverSegments$ = this.store.pipe( @@ -333,24 +359,10 @@ export class NehubaContainer implements OnInit, OnDestroy{ ).pipe( map(results => results[1] === null ? results[0] : '') ) - - /* each time a new viewer is initialised, take the first event to get the translation function */ - this.newViewer$.pipe( - // switchMap(() => fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent') - // .pipe( - // ...takeOnePipe - // ) - // ) - - switchMap(() => pipeFromArray([...takeOnePipe])(fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent'))) - - - ).subscribe((events)=>{ - [0,1,2].forEach(idx=>this.nanometersToOffsetPixelsFn[idx] = (events[idx] as any).detail.nanometersToOffsetPixels) - }) this.sliceViewLoadingMain$ = fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent').pipe( scan(scanFn, [null, null, null]), + shareReplay(1) ) this.sliceViewLoading0$ = this.sliceViewLoadingMain$ @@ -401,14 +413,90 @@ export class NehubaContainer implements OnInit, OnDestroy{ ? state.layers.findIndex(l => l.mixability === 'nonmixable') >= 0 : false) ) + + this.panelMode$ = this.store.pipe( + select('ngViewerState'), + select('panelMode'), + distinctUntilChanged(), + ) } get isMobile(){ return this.constantService.mobile } + private removeExistingPanels() { + const element = this.nehubaViewer.nehubaViewer.ngviewer.layout.container.componentValue.element as HTMLElement + while (element.childElementCount > 0) { + element.removeChild(element.firstElementChild) + } + return element + } + ngOnInit(){ + /* each time a new viewer is initialised, take the first event to get the translation function */ + this.subscriptions.push( + this.newViewer$.pipe( + switchMap(() => pipeFromArray([...takeOnePipe])(fromEvent(this.elementRef.nativeElement, 'sliceRenderEvent'))) + ).subscribe((events)=>{ + for (const idx in [0,1,2]) { + const ev = events[idx] as CustomEvent + this.viewPanels[idx] = ev.target as HTMLElement + this.nanometersToOffsetPixelsFn[idx] = ev.detail.nanometersToOffsetPixels + } + }) + ) + + this.subscriptions.push( + this.newViewer$.pipe( + switchMapTo(fromEvent(this.elementRef.nativeElement, 'perpspectiveRenderEvent').pipe( + take(1) + )), + ).subscribe(ev => this.viewPanels[3] = ((ev as CustomEvent).target) as HTMLElement) + ) + + this.subscriptions.push( + this.redrawLayout$.subscribe(([mode, panelOrder]) => { + const viewPanels = panelOrder.split('').map(v => Number(v)).map(idx => this.viewPanels[idx]) as [HTMLElement, HTMLElement, HTMLElement, HTMLElement] + /** + * TODO be smarter with event stream + */ + if (!this.nehubaViewer) return + + switch (mode) { + case H_ONE_THREE:{ + const element = this.removeExistingPanels() + const newEl = getHorizontalOneThree(viewPanels) + element.appendChild(newEl) + break; + } + case V_ONE_THREE:{ + const element = this.removeExistingPanels() + const newEl = getVerticalOneThree(viewPanels) + element.appendChild(newEl) + break; + } + case FOUR_PANEL: { + const element = this.removeExistingPanels() + const newEl = getFourPanel(viewPanels) + element.appendChild(newEl) + break; + } + case SINGLE_PANEL: { + const element = this.removeExistingPanels() + const newEl = getSinglePanel(viewPanels) + element.appendChild(newEl) + break; + } + default: + } + for (const panel of viewPanels){ + (panel as HTMLElement).classList.add('neuroglancer-panel') + } + }) + ) + this.subscriptions.push( this.viewerPerformanceConfig$.subscribe(config => { this.nehubaViewer.applyPerformanceConfig(config) @@ -454,10 +542,7 @@ export class NehubaContainer implements OnInit, OnDestroy{ ) this.subscriptions.push( - this.userLandmarks$.pipe( - // distinctUntilChanged((old,new) => ) - ).subscribe(landmarks => { - this.userLandmarks = landmarks + this.userLandmarks$.subscribe(landmarks => { if(this.nehubaViewer){ this.nehubaViewer.updateUserLandmarks(landmarks) } @@ -690,11 +775,13 @@ export class NehubaContainer implements OnInit, OnDestroy{ this.subscriptions.push( this.selectedLandmarks$.pipe( - map(lms => lms.map(lm => this.landmarksNameMap.get(lm.name))) + map(lms => lms.map(lm => this.landmarksNameMap.get(lm.name))), + debounceTime(16) ).subscribe(indices => { const filteredIndices = indices.filter(v => typeof v !== 'undefined' && v !== null) - if(this.nehubaViewer) + if(this.nehubaViewer) { this.nehubaViewer.spatialLandmarkSelectionChanged(filteredIndices) + } }) ) } @@ -968,14 +1055,16 @@ export class NehubaContainer implements OnInit, OnDestroy{ if(!landmarks.every(l => l.position.constructor === Array) || !landmarks.every(l => l.position.every(v => !isNaN(v))) || !landmarks.every(l => l.position.length == 3)) throw new Error('position needs to be a length 3 tuple of numbers ') this.store.dispatch({ - type: USER_LANDMARKS, + type: VIEWERSTATE_ACTION_TYPES.ADD_USERLANDMARKS, landmarks : landmarks }) }, - remove3DLandmarks : ids => { + remove3DLandmarks : landmarkIds => { this.store.dispatch({ - type : USER_LANDMARKS, - landmarks : this.userLandmarks.filter(l => ids.findIndex(id => id === l.id) < 0) + type: VIEWERSTATE_ACTION_TYPES.REMOVE_USER_LANDMARKS, + payload: { + landmarkIds + } }) }, hideSegment : (labelIndex) => { @@ -1154,6 +1243,19 @@ export class NehubaContainer implements OnInit, OnDestroy{ } } + removeFav(event: MouseEvent, ds: DataEntry){ + this.store.dispatch({ + type: DATASETS_ACTIONS_TYPES.UNFAV_DATASET, + payload: ds + }) + } + + downloadDs(event: MouseEvent, ds: DataEntry, downloadBtn: MatButton){ + downloadBtn.disabled = true + const id = getIdFromDataEntry(ds) + this.kgSingleDataset.downloadZipFromKg({kgId: id}) + .finally(() => downloadBtn.disabled = false) + } } export const identifySrcElement = (element:HTMLElement) => { @@ -1181,7 +1283,6 @@ export const takeOnePipe = [ * 4 ??? */ const key = identifySrcElement(target) - const _ = {} _[key] = event return Object.assign({},acc,_) diff --git a/src/ui/nehubaContainer/nehubaContainer.style.css b/src/ui/nehubaContainer/nehubaContainer.style.css index 301c664de0d831b6e848e7f5af27422c27326749..57a6ad380921978025fdb9692967d716743d5eab 100644 --- a/src/ui/nehubaContainer/nehubaContainer.style.css +++ b/src/ui/nehubaContainer/nehubaContainer.style.css @@ -15,6 +15,11 @@ input[navigateInput] box-shadow: inset 0px 2px 2px 2px rgba(0,0,0,0.05); } +current-layout +{ + top: 0; + left: 0; +} div[landmarkMasterContainer] { @@ -66,7 +71,7 @@ hr } -div[landmarkMasterContainer] > div > [landmarkContainer] > div.loadingIndicator +div.loadingIndicator { left: auto; top: auto; @@ -75,6 +80,7 @@ div[landmarkMasterContainer] > div > [landmarkContainer] > div.loadingIndicator margin-right: 0.2em; margin-bottom: 0.2em; width: 100%; + position:absolute; height:2em; display: flex; flex-direction: row-reverse; @@ -166,3 +172,8 @@ div#scratch-pad pointer-events: none; } +.load-fav-dataentries-fab +{ + right: 0; + bottom: 0; +} \ No newline at end of file diff --git a/src/ui/nehubaContainer/nehubaContainer.template.html b/src/ui/nehubaContainer/nehubaContainer.template.html index c453d302cb382720296e778641c7ef05465fab9f..6ba9a1443ce3e189057eac4e8c347a48fd0a6b52 100644 --- a/src/ui/nehubaContainer/nehubaContainer.template.html +++ b/src/ui/nehubaContainer/nehubaContainer.template.html @@ -4,75 +4,47 @@ <ui-splashscreen (contextmenu)="$event.stopPropagation();" *ngIf="!viewerLoaded"> </ui-splashscreen> -<div landmarkMasterContainer> +<!-- spatial landmarks overlay --> +<!-- loading indicator --> - <div> - <layout-floating-container pos00 landmarkContainer> - <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" - (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)" - [highlight]="spatialData.highlight ? spatialData.highlight : false" - [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'" - [positionX]="getPositionX(0,spatialData)" [positionY]="getPositionY(0,spatialData)" - [positionZ]="getPositionZ(0,spatialData)"> - </nehuba-2dlandmark-unit> - - <div *ngIf="sliceViewLoading0$ | async" class="loadingIndicator"> - <div class="spinnerAnimationCircle"> - - </div> - </div> - </layout-floating-container> +<current-layout class="position-absolute w-100 h-100 d-block pe-none"> + <div class="w-100 h-100 position-relative" cell-i> + <ng-content *ngTemplateOutlet="overlayi"></ng-content> </div> - <div> - <layout-floating-container pos01 landmarkContainer> - <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" - (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)" - [highlight]="spatialData.highlight ? spatialData.highlight : false" - [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'" - [positionX]="getPositionX(1,spatialData)" [positionY]="getPositionY(1,spatialData)" - [positionZ]="getPositionZ(1,spatialData)"> - </nehuba-2dlandmark-unit> - - <div *ngIf="sliceViewLoading1$ | async" class="loadingIndicator"> - <div class="spinnerAnimationCircle"> - - </div> - </div> - </layout-floating-container> + <div class="w-100 h-100 position-relative" cell-ii> + <ng-content *ngTemplateOutlet="overlayii"></ng-content> </div> - <div> - <layout-floating-container pos10 landmarkContainer> - <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" - (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)" - [highlight]="spatialData.highlight ? spatialData.highlight : false" - [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'" - [positionX]="getPositionX(2,spatialData)" [positionY]="getPositionY(2,spatialData)" - [positionZ]="getPositionZ(2,spatialData)"> - </nehuba-2dlandmark-unit> - - <div *ngIf="sliceViewLoading2$ | async" class="loadingIndicator"> - <div class="spinnerAnimationCircle"> - - </div> - </div> - </layout-floating-container> + <div class="w-100 h-100 position-relative" cell-iii> + <ng-content *ngTemplateOutlet="overlayiii"></ng-content> </div> - <div> - <layout-floating-container pos11 landmarkContainer> - <div *ngIf="perspectiveViewLoading$ | async" class="loadingIndicator"> - <div class="spinnerAnimationCircle"></div> - <div perspectiveLoadingText> - {{ perspectiveViewLoading$ | async }} - </div> - </div> - </layout-floating-container> + <div class="w-100 h-100 position-relative" cell-iv> + <ng-content *ngTemplateOutlet="overlayiv"></ng-content> </div> -</div> +</current-layout> <layout-floating-container *ngIf="viewerLoaded && !isMobile"> + + <!-- tmp fab --> + <div class="m-3 load-fav-dataentries-fab position-absolute pe-all"> + <button + (click)="bottomSheet.open(savedDatasets)" + [matBadge]="(favDataEntries$ | async)?.length > 0 ? (favDataEntries$ | async)?.length : null " + matBadgeColor="accent" + matBadgePosition="above before" + matTooltip="Favourite datasets" + matTooltipPosition="before" + mat-fab + color="primary"> + <i class="fas fa-star"></i> + </button> + </div> + <!-- StatusCard container--> - <ui-status-card [selectedTemplate]="selectedTemplate" [isMobile]="isMobile" - [onHoverSegmentName]="onHoverSegmentName$ | async" [nehubaViewer]="nehubaViewer"> + <ui-status-card + [selectedTemplate]="selectedTemplate" + [isMobile]="isMobile" + [onHoverSegmentName]="onHoverSegmentName$ | async" + [nehubaViewer]="nehubaViewer"> </ui-status-card> </layout-floating-container> @@ -80,7 +52,10 @@ </div> -<mobile-overlay *ngIf="isMobile && viewerLoaded" [tunableProperties]="tunableMobileProperties" +<!-- mobile nub, allowing for ooblique slicing in mobile --> +<mobile-overlay + *ngIf="isMobile && viewerLoaded" + [tunableProperties]="tunableMobileProperties" (deltaValue)="handleMobileOverlayEvent($event)"> <div class="base" delta> <div mobileObliqueGuide class="p-2 mb-4 shadow"> @@ -88,7 +63,9 @@ </div> </div> <div class="base" guide> - <div mobileObliqueGuide class="p-2 mb-4 shadow"> + <div + mobileObliqueGuide + class="p-2 mb-4 shadow"> <div> <i class="fas fa-arrows-alt-v"></i> oblique mode </div> @@ -97,7 +74,126 @@ </div> </div> </div> - <div (contextmenu)="$event.stopPropagation(); $event.preventDefaul();" mobileObliqueCtrl initiator> - <i class="fas fa-globe"></i> + <div + (contextmenu)="$event.stopPropagation(); $event.preventDefaul();" + [ngStyle]="panelMode$ | async | mobileControlNubStylePipe" + mobileObliqueCtrl + initiator> + <button mat-mini-fab color="primary"> + <i class="fas fa-globe"></i> + </button> </div> -</mobile-overlay> \ No newline at end of file +</mobile-overlay> + +<!-- overlay templates --> +<!-- inserted using ngTemplateOutlet --> +<ng-template #overlayi> + <layout-floating-container pos00 landmarkContainer> + <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" + (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)" + [highlight]="spatialData.highlight ? spatialData.highlight : false" + [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'" + [positionX]="getPositionX(0,spatialData)" [positionY]="getPositionY(0,spatialData)" + [positionZ]="getPositionZ(0,spatialData)"> + </nehuba-2dlandmark-unit> + + <div *ngIf="sliceViewLoading0$ | async" class="loadingIndicator"> + <div class="spinnerAnimationCircle"> + + </div> + </div> + </layout-floating-container> +</ng-template> + +<ng-template #overlayii> + <layout-floating-container pos01 landmarkContainer> + <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" + (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)" + [highlight]="spatialData.highlight ? spatialData.highlight : false" + [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'" + [positionX]="getPositionX(1,spatialData)" [positionY]="getPositionY(1,spatialData)" + [positionZ]="getPositionZ(1,spatialData)"> + </nehuba-2dlandmark-unit> + + <div *ngIf="sliceViewLoading1$ | async" class="loadingIndicator"> + <div class="spinnerAnimationCircle"> + + </div> + </div> + </layout-floating-container> +</ng-template> + +<ng-template #overlayiii> + <layout-floating-container pos10 landmarkContainer> + <nehuba-2dlandmark-unit *ngFor="let spatialData of (selectedPtLandmarks$ | async)" + (mouseenter)="handleMouseEnterLandmark(spatialData)" (mouseleave)="handleMouseLeaveLandmark(spatialData)" + [highlight]="spatialData.highlight ? spatialData.highlight : false" + [fasClass]="spatialData.type === 'userLandmark' ? 'fa-chevron-down' : 'fa-map-marker'" + [positionX]="getPositionX(2,spatialData)" [positionY]="getPositionY(2,spatialData)" + [positionZ]="getPositionZ(2,spatialData)"> + </nehuba-2dlandmark-unit> + + <div *ngIf="sliceViewLoading2$ | async" class="loadingIndicator"> + <div class="spinnerAnimationCircle"> + + </div> + </div> + </layout-floating-container> +</ng-template> + +<ng-template #overlayiv> + <layout-floating-container pos11 landmarkContainer> + <div *ngIf="perspectiveViewLoading$ | async" class="loadingIndicator"> + <div class="spinnerAnimationCircle"></div> + <div perspectiveLoadingText> + {{ perspectiveViewLoading$ | async }} + </div> + </div> + </layout-floating-container> +</ng-template> + +<ng-template #savedDatasets> + <mat-list rol="list"> + <h3 mat-subheader>Favourite Datasets</h3> + + <!-- place holder when no fav data is available --> + <mat-card *ngIf="(!(favDataEntries$ | async)) || (favDataEntries$ | async).length === 0"> + <mat-card-content class="muted"> + No dataset favourited... yet. + </mat-card-content> + </mat-card> + + <!-- render all fav dataset as mat list --> + <mat-list-item + class="align-items-center" + *ngFor="let ds of (favDataEntries$ | async)" + role="listitem"> + <span class="flex-grow-1 flex-shrink-1"> + {{ ds.name }} + </span> + + <!-- download --> + <button + #downloadBtn="matButton" + (click)="downloadDs($event, ds, downloadBtn)" + matTooltip="Download Dataset" + matTooltipPosition="after" + color="primary" + class="flex-grow-0 flex-shrink-0" + mat-icon-button> + <i class="fas fa-download"></i> + </button> + + <!-- remove from fav --> + <button + (click)="removeFav($event, ds)" + matTooltip="Remove Favourite" + matTooltipPosition="after" + color="warn" + class="flex-grow-0 flex-shrink-0" + mat-icon-button> + <i class="fas fa-trash"></i> + </button> + </mat-list-item> + </mat-list> +</ng-template> \ No newline at end of file diff --git a/src/ui/nehubaContainer/pipes/mobileControlNubStyle.pipe.ts b/src/ui/nehubaContainer/pipes/mobileControlNubStyle.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..4566c470506588e39edc79f005843a24be80c748 --- /dev/null +++ b/src/ui/nehubaContainer/pipes/mobileControlNubStyle.pipe.ts @@ -0,0 +1,30 @@ +import { PipeTransform, Pipe } from "@angular/core"; +import { FOUR_PANEL, H_ONE_THREE, V_ONE_THREE, SINGLE_PANEL } from "src/services/state/ngViewerState.store"; + +@Pipe({ + name: 'mobileControlNubStylePipe' +}) + +export class MobileControlNubStylePipe implements PipeTransform{ + public transform(panelMode: string): any{ + switch (panelMode) { + case SINGLE_PANEL: + return { + top: '80%', + left: '95%' + } + case V_ONE_THREE: + case H_ONE_THREE: + return { + top: '66.66%', + left: '66.66%' + } + case FOUR_PANEL: + default: + return { + top: '50%', + left: '50%' + } + } + } +} \ No newline at end of file diff --git a/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts b/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts index eb27393a77f1c284743cc34314d09db21040946f..22fd3655e72c0553f62a90716301cbac92f26723 100644 --- a/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts +++ b/src/ui/nehubaContainer/splashScreen/splashScreen.component.ts @@ -1,10 +1,11 @@ -import { Component } from "@angular/core"; -import { Observable } from "rxjs"; +import { Component, Pipe, PipeTransform, ElementRef, ViewChild, AfterViewInit } from "@angular/core"; +import { Observable, fromEvent, Subscription, Subject } from "rxjs"; import { Store, select } from "@ngrx/store"; -import { filter,map } from 'rxjs/operators' +import { switchMap, bufferTime, take, filter, withLatestFrom, map, tap } from 'rxjs/operators' import { ViewerStateInterface, NEWVIEWER } from "../../../services/stateStore.service"; import { AtlasViewerConstantsServices } from "../../../atlasViewer/atlasViewer.constantService.service"; + @Component({ selector : 'ui-splashscreen', templateUrl : './splashScreen.template.html', @@ -13,17 +14,52 @@ import { AtlasViewerConstantsServices } from "../../../atlasViewer/atlasViewer.c ] }) -export class SplashScreen{ - loadedTemplate$ : Observable<any[]> +export class SplashScreen implements AfterViewInit{ + + public loadedTemplate$ : Observable<any[]> + @ViewChild('parentContainer', {read:ElementRef}) + private parentContainer: ElementRef + private activatedTemplate$: Subject<any> = new Subject() + + private subscriptions: Subscription[] = [] + constructor( private store:Store<ViewerStateInterface>, private constanceService: AtlasViewerConstantsServices, private constantsService: AtlasViewerConstantsServices, -){ + ){ this.loadedTemplate$ = this.store.pipe( select('viewerState'), - filter((state:ViewerStateInterface)=> typeof state !== 'undefined' && typeof state.fetchedTemplates !== 'undefined' && state.fetchedTemplates !== null), - map(state=>state.fetchedTemplates)) + select('fetchedTemplates') + ) + } + + ngAfterViewInit(){ + + /** + * instead of blindly listening to click event, this event stream waits to see if user mouseup within 200ms + * if yes, it is interpreted as a click + * if no, user may want to select a text + */ + this.subscriptions.push( + fromEvent(this.parentContainer.nativeElement, 'mousedown').pipe( + switchMap(() => fromEvent(this.parentContainer.nativeElement, 'mouseup').pipe( + bufferTime(200), + take(1) + )), + filter(arr => arr.length > 0), + withLatestFrom(this.activatedTemplate$), + map(([_, template]) => template) + ).subscribe(template => this.selectTemplate(template)) + ) + } + + selectTemplateParcellation(template, parcellation){ + this.store.dispatch({ + type : NEWVIEWER, + selectTemplate : template, + selectParcellation : parcellation + }) } selectTemplate(template:any){ @@ -38,11 +74,31 @@ export class SplashScreen{ return this.constanceService.templateUrls.length } - correctString(name){ - return name.replace(/[|&;$%@()+,\s./]/g, '') - } - get isMobile(){ return this.constantsService.mobile } -} \ No newline at end of file +} + +@Pipe({ + name: 'getTemplateImageSrcPipe' +}) + +export class GetTemplateImageSrcPipe implements PipeTransform{ + public transform(name:string):string{ + return `./res/image/${name.replace(/[|&;$%@()+,\s./]/g, '')}.png` + } +} + +@Pipe({ + name: 'imgSrcSetPipe' +}) + +export class ImgSrcSetPipe implements PipeTransform{ + public transform(src:string):string{ + const regex = /^(.*?)(\.\w*?)$/.exec(src) + if (!regex) throw new Error(`cannot find filename, ext ${src}`) + const filename = regex[1] + const ext = regex[2] + return [100, 200, 300, 400].map(val => `${filename}-${val}${ext} ${val}w`).join(',') + } +} \ No newline at end of file diff --git a/src/ui/nehubaContainer/splashScreen/splashScreen.style.css b/src/ui/nehubaContainer/splashScreen/splashScreen.style.css index cea1195130e1a729c44170d5e47fe742595ff3c3..49774d56632717713c75fc404f681049c0e06e5c 100644 --- a/src/ui/nehubaContainer/splashScreen/splashScreen.style.css +++ b/src/ui/nehubaContainer/splashScreen/splashScreen.style.css @@ -1,61 +1,11 @@ -.appendMargin +:host { - padding-top:10em; + display: block; + overflow: auto; + height: 100%; } -.splashScreenHeaderTitle { - font-size: 45px; -} - -div[splashScreenTemplateItem] { - max-width: 600px; - width: 400px; - margin: 20px 20px; - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: center; - -} - -div[splashScreenTemplateHeader] { - display: flex; - justify-content: center; - align-items: center; - height: 70px; - align-self: center; - text-align: center; - font-size: 30px; - margin: 5px 0; - -} - -.template-image { - width: 100%; - height: auto; -} - -.template-card { - width: 100%; - cursor: pointer; - background: #fff; - border-radius: 2px; - box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); - transition: all 0.3s cubic-bezier(.25,.8,.25,1); -} - -.template-card:hover { - box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 5px 5px rgba(0,0,0,0.22); -} - -@media screen and (max-width: 670px) { - .splashScreenHeaderTitle { - visibility: hidden; - } - div[splashScreenTemplate] { - margin: 20px 20px; - } - div[splashScreenTemplateBody] { - flex-direction: column; - } +.font-stretch +{ + font-stretch: extra-condensed; } \ No newline at end of file diff --git a/src/ui/nehubaContainer/splashScreen/splashScreen.template.html b/src/ui/nehubaContainer/splashScreen/splashScreen.template.html index 28ec10df8e43de608f367da127f8ee292524c48a..a63adbeb71157ff8282a9abda4d5e30e58d79924 100644 --- a/src/ui/nehubaContainer/splashScreen/splashScreen.template.html +++ b/src/ui/nehubaContainer/splashScreen/splashScreen.template.html @@ -1,20 +1,43 @@ -<div [ngClass]="isMobile ? '' : 'appendMargin'" class="h-100 d-flex flex-column justify-content-start align-items-center overflow-auto"> +<div + #parentContainer + class="m-5 d-flex flex-row flex-wrap justify-content-center align-items-stretch pe-none"> + <mat-card + (mousedown)="activatedTemplate$.next(template)" + matRipple + *ngFor="let template of loadedTemplate$ | async | filterNull" + class="m-3 col-md-12 col-lg-6 pe-all mw-400px"> + <mat-card-header> + <mat-card-title class="text-nowrap font-stretch"> + {{ template.properties.name }} + </mat-card-title> + </mat-card-header> + <img + [src]="template.properties.name | getTemplateImageSrcPipe" + [srcset]="template.properties.name | getTemplateImageSrcPipe | imgSrcSetPipe" + sizes="(max-width:576px) 90vw;(max-width: 768px) 50vw; 400px" + [alt]="'Screenshot of ' + template.properties.name" + mat-card-image /> + <mat-card-content> + {{ template.properties.description }} + </mat-card-content> - <div class="d-flex w-100 flex-wrap justify-content-center"> - <div *ngFor="let template of loadedTemplate$ | async | filterNull" splashScreenTemplateItem> - <div class="template-card" (click) = "selectTemplate(template)"> - <div splashScreenTemplateHeader> - {{template.properties.name}} - </div> - <div class="d-flex flex-column"> - <div class="flex-grow-1"> - <img class="template-image" [src]="'./res/image/' + correctString(template.properties.name) + '.png'"> - </div> - <div class="flex-grow-1 text-justify ml-2 mr-2 mb-2 mt-0"> - {{template.properties.description}} - </div> - </div> - </div> - </div> - </div> + <mat-card-content> + <mat-card-subtitle class="text-nowrap"> + Parcellations available + </mat-card-subtitle> + <button + (mousedown)="$event.stopPropagation()" + (click)="$event.stopPropagation(); selectTemplateParcellation(template, parcellation)" + *ngFor="let parcellation of template.parcellations" + mat-button + color="primary"> + {{ parcellation.name }} + </button> + </mat-card-content> + + <!-- required... or on ripple, angular adds 16px margin to the bottom --> + <!-- see https://github.com/angular/components/issues/10898 --> + <mat-card-footer> + </mat-card-footer> + </mat-card> </div> \ No newline at end of file diff --git a/src/ui/nehubaContainer/util.ts b/src/ui/nehubaContainer/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..edd3a98f8fea9b48e40fe7cd16702f469a95ca93 --- /dev/null +++ b/src/ui/nehubaContainer/util.ts @@ -0,0 +1,74 @@ +const flexContCmnCls = ['w-100', 'h-100', 'd-flex', 'justify-content-center', 'align-items-stretch'] + +const makeRow = (...els:HTMLElement[]) => { + const container = document.createElement('div') + container.classList.add(...flexContCmnCls, 'flex-row') + for (const el of els){ + container.appendChild(el) + } + return container +} + +const makeCol = (...els:HTMLElement[]) => { + const container = document.createElement('div') + container.classList.add(...flexContCmnCls, 'flex-column') + for (const el of els){ + container.appendChild(el) + } + return container +} + +export const getHorizontalOneThree = (panels:[HTMLElement, HTMLElement, HTMLElement, HTMLElement]) => { + for (let panel of panels){ + panel.className = '' + } + const majorContainer = makeCol(panels[0]) + const minorContainer = makeCol(panels[1], panels[2], panels[3]) + + majorContainer.style.flexBasis = '67%' + minorContainer.style.flexBasis = '33%' + + return makeRow(majorContainer, minorContainer) +} + +export const getVerticalOneThree = (panels:[HTMLElement, HTMLElement, HTMLElement, HTMLElement]) => { + for (let panel of panels){ + panel.className = '' + } + const majorContainer = makeRow(panels[0]) + const minorContainer = makeRow(panels[1], panels[2], panels[3]) + + majorContainer.style.flexBasis = '67%' + minorContainer.style.flexBasis = '33%' + + return makeCol(majorContainer, minorContainer) +} + + +export const getFourPanel = (panels:[HTMLElement, HTMLElement, HTMLElement, HTMLElement]) => { + for (let panel of panels){ + panel.className = '' + } + const majorContainer = makeRow(panels[0], panels[1]) + const minorContainer = makeRow(panels[2], panels[3]) + + majorContainer.style.flexBasis = '50%' + minorContainer.style.flexBasis = '50%' + + return makeCol(majorContainer, minorContainer) +} + +export const getSinglePanel = (panels:[HTMLElement, HTMLElement, HTMLElement, HTMLElement]) => { + for (let panel of panels){ + panel.className = '' + } + const majorContainer = makeRow(panels[0]) + const minorContainer = makeRow(panels[1], panels[2], panels[3]) + + majorContainer.style.flexBasis = '100%' + minorContainer.style.flexBasis = '0%' + + minorContainer.className = '' + minorContainer.style.height = '0px' + return makeCol(majorContainer, minorContainer) +} \ No newline at end of file diff --git a/src/ui/regionHierachy/regionHierarchy.style.css b/src/ui/regionHierachy/regionHierarchy.style.css deleted file mode 100644 index a4aa507f4db1a32886dc1309934dc8481d49a425..0000000000000000000000000000000000000000 --- a/src/ui/regionHierachy/regionHierarchy.style.css +++ /dev/null @@ -1,65 +0,0 @@ - -div[treeContainer] -{ - padding:1em; - z-index: 3; - - height:20em; - width: calc(100% + 4em); - overflow-y:auto; - overflow-x:hidden; - - /* color:white; - background-color:rgba(12,12,12,0.8); */ -} - -:host-context([darktheme="false"]) div[treeContainer] -{ - background-color:rgba(240,240,240,0.8); -} - -:host-context([darktheme="true"]) div[treeContainer] -{ - background-color:rgba(30,30,30,0.8); - color:rgba(255,255,255,1.0); -} - -div[hideScrollbarcontainer] -{ - width: 20em; - overflow:hidden; - margin-top:2px; -} - -input[type="text"] -{ - border:none; -} - -:host-context([darktheme="false"]) input[type="text"] -{ - background-color:rgba(245,245,245,0.85); - box-shadow: inset 0 4px 6px 0 rgba(5,5,5,0.1); -} - -:host-context([darktheme="true"]) input[type="text"] -{ - background-color:rgba(30,30,30,0.85); - box-shadow: inset 0 4px 6px 0 rgba(0,0,0,0.2); - color:rgba(255,255,255,0.8); -} - -.regionSearch -{ - width:20em; -} - -.tree-header -{ - flex: 0 0 auto; -} - -.tree-body -{ - flex: 1 1 auto; -} \ No newline at end of file diff --git a/src/ui/sharedModules/angularMaterial.module.ts b/src/ui/sharedModules/angularMaterial.module.ts index 707c71a085245706c04898b9b2935b34432b3b0a..22ab6307004196d3b182682848014717adb67a8c 100644 --- a/src/ui/sharedModules/angularMaterial.module.ts +++ b/src/ui/sharedModules/angularMaterial.module.ts @@ -5,12 +5,66 @@ import { MatCardModule, MatTabsModule, MatTooltipModule, - MatSnackBarModule + MatSnackBarModule, + MatBadgeModule, + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule, + MatRippleModule + } from '@angular/material'; import { NgModule } from '@angular/core'; +/** + * TODO should probably be in src/util + */ + @NgModule({ - imports: [MatSnackBarModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], - exports: [MatSnackBarModule, MatButtonModule, MatCheckboxModule, MatSidenavModule, MatCardModule, MatTabsModule, MatTooltipModule], + imports: [ + MatButtonModule, + MatSnackBarModule, + MatCheckboxModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatBadgeModule, + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule, + MatRippleModule + ], + exports: [ + MatButtonModule, + MatCheckboxModule, + MatSnackBarModule, + MatSidenavModule, + MatCardModule, + MatTabsModule, + MatTooltipModule, + MatBadgeModule, + MatDividerModule, + MatSelectModule, + MatChipsModule, + MatAutocompleteModule, + MatDialogModule, + MatInputModule, + MatBottomSheetModule, + MatListModule, + MatSlideToggleModule, + MatRippleModule + ], }) export class AngularMaterialModule { } \ No newline at end of file diff --git a/src/ui/signinBanner/signinBanner.components.ts b/src/ui/signinBanner/signinBanner.components.ts index 592e3101d2f6bc897fdee15efdabbefd948011c5..abd1eb3397f1c1a3e7ada69d3ead390f29bfbed7 100644 --- a/src/ui/signinBanner/signinBanner.components.ts +++ b/src/ui/signinBanner/signinBanner.components.ts @@ -1,18 +1,8 @@ -import {Component, ChangeDetectionStrategy, OnDestroy, OnInit, Input, ViewChild, TemplateRef } from "@angular/core"; +import {Component, ChangeDetectionStrategy, Input, TemplateRef } from "@angular/core"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { AuthService, User } from "src/services/auth.service"; -import { Store, select } from "@ngrx/store"; -import { ViewerConfiguration } from "src/services/state/viewerConfig.store"; -import { Subscription, Observable, merge, Subject, combineLatest } from "rxjs"; -import { safeFilter, isDefined, NEWVIEWER, SELECT_REGIONS, SELECT_PARCELLATION, CHANGE_NAVIGATION } from "src/services/stateStore.service"; -import { map, filter, distinctUntilChanged, bufferTime, delay, share, tap, withLatestFrom } from "rxjs/operators"; -import { regionFlattener } from "src/util/regionFlattener"; -import { ToastService } from "src/services/toastService.service"; -import { getSchemaIdFromName } from "src/util/pipes/templateParcellationDecoration.pipe"; +import { MatDialog } from "@angular/material"; -const compareParcellation = (o, n) => !o || !n - ? false - : o.name === n.name @Component({ selector: 'signin-banner', @@ -24,256 +14,44 @@ const compareParcellation = (o, n) => !o || !n changeDetection: ChangeDetectionStrategy.OnPush }) -export class SigninBanner implements OnInit, OnDestroy{ +export class SigninBanner{ - public compareParcellation = compareParcellation - - private subscriptions: Subscription[] = [] - - public loadedTemplates$: Observable<any[]> - - public selectedTemplate$: Observable<any> - public selectedParcellation$: Observable<any> - - public selectedRegions$: Observable<any[]> - private selectedRegions: any[] = [] @Input() darktheme: boolean - @ViewChild('publicationTemplate', {read:TemplateRef}) publicationTemplate: TemplateRef<any> - - public focusedDatasets$: Observable<any[]> - private userFocusedDataset$: Subject<any> = new Subject() - public focusedDatasets: any[] = [] - private dismissToastHandler: () => void + public isMobile: boolean constructor( private constantService: AtlasViewerConstantsServices, private authService: AuthService, - private store: Store<ViewerConfiguration>, - private toastService: ToastService + private dialog: MatDialog ){ - this.loadedTemplates$ = this.store.pipe( - select('viewerState'), - safeFilter('fetchedTemplates'), - map(state => state.fetchedTemplates) - ) - - this.selectedTemplate$ = this.store.pipe( - select('viewerState'), - select('templateSelected'), - filter(v => !!v), - distinctUntilChanged((o, n) => o.name === n.name), - ) - - this.selectedParcellation$ = this.store.pipe( - select('viewerState'), - select('parcellationSelected'), - filter(v => !!v) - ) - - this.selectedRegions$ = this.store.pipe( - select('viewerState'), - safeFilter('regionsSelected'), - map(state => state.regionsSelected), - distinctUntilChanged((arr1, arr2) => arr1.length === arr2.length && (arr1 as any[]).every((item, index) => item.name === arr2[index].name)) - ) - - this.focusedDatasets$ = this.userFocusedDataset$.pipe( - filter(v => !!v), - withLatestFrom( - combineLatest(this.selectedTemplate$, this.selectedParcellation$) - ), - ).pipe( - map(([userFocusedDataset, [selectedTemplate, selectedParcellation]]) => { - const { type, ...rest } = userFocusedDataset - if (type === 'template') return { ...selectedTemplate, ...rest} - if (type === 'parcellation') return { ...selectedParcellation, ...rest } - return { ...rest } - }), - bufferTime(100), - filter(arr => arr.length > 0), - /** - * merge properties field with the root level - * with the prop in properties taking priority - */ - map(arr => arr.map(item => { - const { properties } = item - return { - ...item, - ...properties - } - })), - share() - ) - } - - ngOnInit(){ - - this.subscriptions.push( - this.selectedRegions$.subscribe(regions => { - this.selectedRegions = regions - }) - ) - - this.subscriptions.push( - this.focusedDatasets$.subscribe(() => { - if (this.dismissToastHandler) this.dismissToastHandler() - }) - ) - - this.subscriptions.push( - this.focusedDatasets$.pipe( - /** - * creates the illusion that the toast complete disappears before reappearing - */ - delay(100) - ).subscribe(arr => { - this.focusedDatasets = arr - this.dismissToastHandler = this.toastService.showToast(this.publicationTemplate, { - dismissable: true, - timeout:7000 - }) - }) - ) - } - - ngOnDestroy(){ - this.subscriptions.forEach(s => s.unsubscribe()) - } - - changeTemplate({ current, previous }){ - if (previous && current && current.name === previous.name) return - this.store.dispatch({ - type: NEWVIEWER, - selectTemplate: current, - selectParcellation: current.parcellations[0] - }) - } - - changeParcellation({ current, previous }){ - const { ngId: prevNgId} = previous - const { ngId: currNgId} = current - if (prevNgId === currNgId) return - this.store.dispatch({ - type: SELECT_PARCELLATION, - selectParcellation: current - }) - } - - // TODO handle mobile - handleRegionClick({ mode = 'single', region }){ - if (!region) return - - /** - * single click on region hierarchy => toggle selection - */ - if (mode === 'single') { - const flattenedRegion = regionFlattener(region).filter(r => isDefined(r.labelIndex)) - const flattenedRegionNames = new Set(flattenedRegion.map(r => r.name)) - const selectedRegionNames = new Set(this.selectedRegions.map(r => r.name)) - const selectAll = flattenedRegion.every(r => !selectedRegionNames.has(r.name)) - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: selectAll - ? this.selectedRegions.concat(flattenedRegion) - : this.selectedRegions.filter(r => !flattenedRegionNames.has(r.name)) - }) - } - - /** - * double click on region hierarchy => navigate to region area if it exists - */ - if (mode === 'double') { - - /** - * if position is defined, go to position (in nm) - * if not, show error messagea s toast - * - * nb: currently, only supports a single triplet - */ - if (region.position) { - this.store.dispatch({ - type: CHANGE_NAVIGATION, - navigation: { - position: region.position, - animation: {} - } - }) - } else { - this.toastService.showToast(`${region.name} does not have a position defined`, { - timeout: 5000, - dismissable: true - }) - } - } - } - - displayActiveParcellation(parcellation:any){ - return `<div class="d-flex"><small>Parcellation</small> <small class = "flex-grow-1 mute-text">${parcellation ? '(' + parcellation.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` - } - - displayActiveTemplate(template: any) { - return `<div class="d-flex"><small>Template</small> <small class = "flex-grow-1 mute-text">${template ? '(' + template.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + this.isMobile = this.constantService.mobile } + /** + * move the templates to signin banner when pluginprettify is merged + */ showHelp() { this.constantService.showHelpSubject$.next() } - showSignin() { - this.constantService.showSigninSubject$.next(this.user) - } - - clearAllRegions(){ - this.store.dispatch({ - type: SELECT_REGIONS, - selectRegions: [] + /** + * move the templates to signin banner when pluginprettify is merged + */ + showSetting(settingTemplate:TemplateRef<any>){ + this.dialog.open(settingTemplate, { + autoFocus: false }) } - 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 + /** + * move the templates to signin banner when pluginprettify is merged + */ + showSignin() { + this.constantService.showSigninSubject$.next(this.user) } 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..1da0fb01da873392fbe79901047520aaff3bf699 100644 --- a/src/ui/signinBanner/signinBanner.template.html +++ b/src/ui/signinBanner/signinBanner.template.html @@ -2,96 +2,43 @@ 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 --> - + <!-- setting btn --> <div class="btnWrapper"> - - <div - *ngIf="!isMobile" - (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> + <button + #settingBtn + matTooltip="Settings" + matTooltipPosition="below" + (click)="showSetting(settingTemplate)" + mat-icon-button + color="basic"> + <i class="fas fa-cog"></i> + </button> </div> - <div *ngIf="isMobile" class="login-button-panel-mobile"> - <div + <!-- signin --> + <div class="btnWrapper"> + <button + [matTooltip]="user && user.name ? ('Logged in as ' + (user && user.name)) : 'Not logged in'" + matTooltipPosition="below" (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 a7c08e93c200ab6454d88efa8a618687ca049dec..ae129d6220403450c0ebc00cff530c85e1cf842c 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -3,9 +3,9 @@ import { ComponentsModule } from "../components/components.module"; import { NehubaViewerUnit } from "./nehubaContainer/nehubaViewer/nehubaViewer.component"; import { NehubaContainer } from "./nehubaContainer/nehubaContainer.component"; -import { SplashScreen } from "./nehubaContainer/splashScreen/splashScreen.component"; +import { SplashScreen, GetTemplateImageSrcPipe, ImgSrcSetPipe } from "./nehubaContainer/splashScreen/splashScreen.component"; import { LayoutModule } from "../layouts/layout.module"; -import { FormsModule } from "@angular/forms"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { GroupDatasetByRegion } from "../util/pipes/groupDataEntriesByRegion.pipe"; import { filterRegionDataEntries } from "../util/pipes/filterRegionDataEntries.pipe"; @@ -17,7 +17,7 @@ import { LandmarkUnit } from "./nehubaContainer/landmarkUnit/landmarkUnit.compon import { SafeStylePipe } from "../util/pipes/safeStyle.pipe"; import { PluginBannerUI } from "./pluginBanner/pluginBanner.component"; import { CitationsContainer } from "./citation/citations.component"; -import { LayerBrowser } from "./layerbrowser/layerbrowser.component"; +import { LayerBrowser, LockedLayerBtnClsPipe } from "./layerbrowser/layerbrowser.component"; import { TooltipModule } from "ngx-bootstrap/tooltip"; import { KgEntryViewer } from "./kgEntryViewer/kgentry.component"; import { SubjectViewer } from "./kgEntryViewer/subjectViewer/subjectViewer.component"; @@ -38,25 +38,42 @@ 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 { FourPanelLayout } from "./config/layouts/fourPanel/fourPanel.component"; +import { HorizontalOneThree } from "./config/layouts/h13/h13.component"; +import { VerticalOneThree } from "./config/layouts/v13/v13.component"; +import { SinglePanel } from "./config/layouts/single/single.component"; +import { CurrentLayout } from "./config/currentLayout/currentLayout.component"; +import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControlNubStyle.pipe"; +import { ScrollingModule } from "@angular/cdk/scrolling" +import { HttpClientModule } from "@angular/common/http"; +import { GetFilenamePipe } from "src/util/pipes/getFilename.pipe"; +import { GetFileExtension } from "src/util/pipes/getFileExt.pipe"; +import { ViewerStateController } from "./viewerStateController/viewerState.component"; +import { BinSavedRegionsSelectionPipe, SavedRegionsSelectionBtnDisabledPipe } from "./viewerStateController/viewerState.pipes"; +import { RegionTextSearchAutocomplete } from "./viewerStateController/regionSearch/regionSearch.component"; +import { PluginBtnFabColorPipe } from "src/util/pipes/pluginBtnFabColor.pipe"; +import { KgSearchBtnColorPipe } from "src/util/pipes/kgSearchBtnColor.pipe"; @NgModule({ imports : [ + HttpClientModule, FormsModule, + ReactiveFormsModule, LayoutModule, ComponentsModule, DatabrowserModule, UtilModule, + ScrollingModule, AngularMaterialModule, PopoverModule.forRoot(), @@ -84,6 +101,13 @@ import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.pipe"; StatusCardComponent, CookieAgreement, KGToS, + FourPanelLayout, + HorizontalOneThree, + VerticalOneThree, + SinglePanel, + CurrentLayout, + ViewerStateController, + RegionTextSearchAutocomplete, /* pipes */ GroupDatasetByRegion, @@ -95,10 +119,19 @@ import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.pipe"; SortDataEntriesToRegion, SpatialLandmarksToDataBrowserItemPipe, FilterNullPipe, - FilterNgLayer, FilterNameBySearch, TemplateParcellationsDecorationPipe, AppendtooltipTextPipe, + MobileControlNubStylePipe, + GetTemplateImageSrcPipe, + ImgSrcSetPipe, + PluginBtnFabColorPipe, + KgSearchBtnColorPipe, + LockedLayerBtnClsPipe, + GetFilenamePipe, + GetFileExtension, + BinSavedRegionsSelectionPipe, + SavedRegionsSelectionBtnDisabledPipe, /* directive */ DownloadDirective, @@ -109,7 +142,7 @@ import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.pipe"; /* dynamically created components needs to be declared here */ NehubaViewerUnit, LayerBrowser, - PluginBannerUI + PluginBannerUI, ], exports : [ SubjectViewer, diff --git a/src/ui/regionHierachy/filterNameBySearch.pipe.ts b/src/ui/viewerStateController/regionHierachy/filterNameBySearch.pipe.ts similarity index 100% rename from src/ui/regionHierachy/filterNameBySearch.pipe.ts rename to src/ui/viewerStateController/regionHierachy/filterNameBySearch.pipe.ts diff --git a/src/ui/regionHierachy/regionHierarchy.component.ts b/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts similarity index 75% rename from src/ui/regionHierachy/regionHierarchy.component.ts rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts index f8b4fd35dba949a3b4fc7620ad67309a41f0f7c4..2fd52a746bd58437785353e315e68bcdf2b6f2a9 100644 --- a/src/ui/regionHierachy/regionHierarchy.component.ts +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.component.ts @@ -1,6 +1,6 @@ import { EventEmitter, Component, ElementRef, ViewChild, HostListener, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, Input, Output, AfterViewInit } from "@angular/core"; import { Subscription, Subject, fromEvent } from "rxjs"; -import { buffer, debounceTime } from "rxjs/operators"; +import { buffer, debounceTime, tap } from "rxjs/operators"; import { FilterNameBySearch } from "./filterNameBySearch.pipe"; import { generateLabelIndexId } from "src/services/stateStore.service"; @@ -17,8 +17,8 @@ const getDisplayTreeNode : (searchTerm:string, selectedRegions:any[]) => (item:a && selectedRegions.findIndex(re => generateLabelIndexId({ labelIndex: re.labelIndex, ngId: re.ngId }) === generateLabelIndexId({ ngId, labelIndex }) ) >= 0 - ? `<span class="regionSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) - : `<span class="regionNotSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) + ? `<span class="cursor-default regionSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) + : `<span class="cursor-default regionNotSelected">${insertHighlight(name, searchTerm)}</span>` + (status ? ` <span class="text-muted">(${insertHighlight(status, searchTerm)})</span>` : ``) } const getFilterTreeBySearch = (pipe:FilterNameBySearch, searchTerm:string) => (node:any) => pipe.transform([node.name, node.status], searchTerm) @@ -38,9 +38,7 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ public selectedRegions: any[] = [] @Input() - public selectedParcellation: any - - @Input() isMobile: boolean; + public parcellationSelected: any private _showRegionTree: boolean = false @@ -54,7 +52,7 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ private doubleClickRegion: EventEmitter<any> = new EventEmitter() @Output() - private clearAllRegions: EventEmitter<null> = new EventEmitter() + private clearAllRegions: EventEmitter<MouseEvent> = new EventEmitter() public searchTerm: string = '' private subscriptions: Subscription[] = [] @@ -62,6 +60,8 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ @ViewChild('searchTermInput', {read: ElementRef}) private searchTermInput: ElementRef + public placeHolderText: string = `Start by selecting a template and a parcellation.` + /** * set the height to max, bound by max-height */ @@ -95,17 +95,19 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ } ngOnChanges(){ - this.aggregatedRegionTree = { - name: this.selectedParcellation.name, - children: this.selectedParcellation.regions + if (this.parcellationSelected) { + this.placeHolderText = `Search region in ${this.parcellationSelected.name}` + this.aggregatedRegionTree = { + name: this.parcellationSelected.name, + children: this.parcellationSelected.regions + } } this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) } clearRegions(event:MouseEvent){ - event.stopPropagation() - this.clearAllRegions.emit() + this.clearAllRegions.emit(event) } get showRegionTree(){ @@ -133,20 +135,6 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ } ngAfterViewInit(){ - /** - * TODO - * bandaid fix on - * when region search loses focus, the searchTerm is cleared, - * but hierarchy filter does not reset - */ - this.subscriptions.push( - fromEvent(this.searchTermInput.nativeElement, 'focus').pipe( - - ).subscribe(() => { - this.displayTreeNode = getDisplayTreeNode(this.searchTerm, this.selectedRegions) - this.filterTreeBySearch = getFilterTreeBySearch(this.filterNameBySearchPipe, this.searchTerm) - }) - ) this.subscriptions.push( fromEvent(this.searchTermInput.nativeElement, 'input').pipe( debounceTime(200) @@ -156,13 +144,6 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ ) } - getInputPlaceholder(parcellation:any) { - if (parcellation) - return `Search region in ${parcellation.name}` - else - return `Start by selecting a template and a parcellation.` - } - escape(event:KeyboardEvent){ this.showRegionTree = false this.searchTerm = ''; @@ -182,27 +163,12 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ }) } - focusInput(event?:MouseEvent){ - if (event) { - /** - * need to stop propagation, or @closeRegion will be triggered - */ - event.stopPropagation() - } - this.searchTermInput.nativeElement.focus() - this.showRegionTree = true - } - /* NB need to bind two way data binding like this. Or else, on searchInput blur, the flat tree will be rebuilt, resulting in first click to be ignored */ changeSearchTerm(event: any) { - if (event.target.value === this.searchTerm) - return + if (event.target.value === this.searchTerm) return this.searchTerm = event.target.value - /** - * TODO maybe introduce debounce - */ this.ngOnChanges() this.cdr.markForCheck() } @@ -214,18 +180,17 @@ export class RegionHierarchy implements OnInit, AfterViewInit{ /** * TODO figure out why @closeRegion gets triggered, but also, contains returns false */ - if (event) + if (event) { event.stopPropagation() + } this.handleRegionTreeClickSubject.next(obj) } /* single click selects/deselects region(s) */ private singleClick(obj: any) { - if (!obj) - return + if (!obj) return const { inputItem : region } = obj - if (!region) - return + if (!region) return this.singleClickRegion.emit(region) } diff --git a/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css b/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css new file mode 100644 index 0000000000000000000000000000000000000000..372068bbf85ed6be2b7f1f2ae06fb467f7f4a701 --- /dev/null +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.style.css @@ -0,0 +1,57 @@ + +div[treeContainer] +{ + padding:1em; + z-index: 3; + + height:20em; + width: calc(100% + 4em); + overflow-y:auto; + overflow-x:hidden; + + /* color:white; + background-color:rgba(12,12,12,0.8); */ +} + +div[hideScrollbarcontainer] +{ + overflow:hidden; + margin-top:2px; +} + +input[type="text"] +{ + border:none; +} + + +.regionSearch +{ + width:20em; +} + +.tree-header +{ + flex: 0 0 auto; +} + +.tree-body +{ + flex: 1 1 auto; +} + +:host +{ + display: flex; + flex-direction: column; +} + +:host > mat-form-field +{ + flex: 0 0 auto; +} + +:host > [hideScrollbarContainer] +{ + flex: 1 1 0; +} \ No newline at end of file diff --git a/src/ui/regionHierachy/regionHierarchy.template.html b/src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html similarity index 69% rename from src/ui/regionHierachy/regionHierarchy.template.html rename to src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html index 91d9f552cb194227a8e4462ed6e9b82868e3f40c..5a55fdc8c328c231048001c9340687ebdf890f3b 100644 --- a/src/ui/regionHierachy/regionHierarchy.template.html +++ b/src/ui/viewerStateController/regionHierachy/regionHierarchy.template.html @@ -1,24 +1,18 @@ -<div class="input-group regionSearch"> +<mat-form-field class="w-100"> <input #searchTermInput - tabindex="0" + matInput (keydown.esc)="escape($event)" - (focus)="showRegionTree = true && !isMobile" + (focus)="showRegionTree = true" [value]="searchTerm" - class="form-control form-control-sm" type="text" autocomplete="off" - [placeholder]="getInputPlaceholder(selectedParcellation)"/> - -</div> + [placeholder]="placeHolderText"/> +</mat-form-field> -<div - *ngIf="showRegionTree" - hideScrollbarContainer> - +<div hideScrollbarContainer> <div - [ngStyle]="regionHierarchyHeight()" - class="d-flex flex-column" + class="d-flex flex-column h-100" treeContainer #treeContainer> <div class="tree-header d-inline-flex align-items-center"> @@ -34,7 +28,7 @@ </div> <div - *ngIf="selectedParcellation && selectedParcellation.regions as regions" + *ngIf="parcellationSelected && parcellationSelected.regions as regions" class="tree-body"> <flat-tree-component (treeNodeClick)="handleClickRegion($event)" diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.component.ts b/src/ui/viewerStateController/regionSearch/regionSearch.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0e1783234cccfc6c9101b1f0fdc3290d8dea209 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.component.ts @@ -0,0 +1,149 @@ +import { Component, EventEmitter, Output, ViewChild, ElementRef, TemplateRef } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable } from "rxjs"; +import { map, distinctUntilChanged, startWith, withLatestFrom, filter, debounceTime, tap, share, shareReplay, take } from "rxjs/operators"; +import { getMultiNgIdsRegionsLabelIndexMap, generateLabelIndexId } from "src/services/stateStore.service"; +import { FormControl } from "@angular/forms"; +import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger, MatDialog } from "@angular/material"; +import { ADD_TO_REGIONS_SELECTION_WITH_IDS, SELECT_REGIONS } from "src/services/state/viewerState.store"; +import { VIEWERSTATE_ACTION_TYPES } from "../viewerState.component"; + +const filterRegionBasedOnText = searchTerm => region => region.name.toLowerCase().includes(searchTerm.toLowerCase()) + +@Component({ + selector: 'region-text-search-autocomplete', + templateUrl: './regionSearch.template.html', + styleUrls: [ + './regionSearch.style.css' + ] +}) + +export class RegionTextSearchAutocomplete{ + + @ViewChild('autoTrigger', {read: ElementRef}) autoTrigger: ElementRef + @ViewChild('regionHierarchy', {read:TemplateRef}) regionHierarchyTemplate: TemplateRef<any> + constructor( + private store$: Store<any>, + private dialog: MatDialog, + ){ + + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.regionsWithLabelIndex$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + map(parcellationSelected => { + const returnArray = [] + const ngIdMap = getMultiNgIdsRegionsLabelIndexMap(parcellationSelected) + for (const [ngId, labelIndexMap] of ngIdMap) { + for (const [labelIndex, region] of labelIndexMap){ + returnArray.push({ + ...region, + ngId, + labelIndex, + labelIndexId: generateLabelIndexId({ ngId, labelIndex }) + }) + } + } + return returnArray + }) + ) + + this.autocompleteList$ = this.formControl.valueChanges.pipe( + startWith(''), + debounceTime(200), + withLatestFrom(this.regionsWithLabelIndex$.pipe( + startWith([]) + )), + map(([searchTerm, regionsWithLabelIndex]) => regionsWithLabelIndex.filter(filterRegionBasedOnText(searchTerm))), + map(arr => arr.slice(0, 5)) + ) + + this.regionsSelected$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + } + + public optionSelected(ev: MatAutocompleteSelectedEvent){ + const id = ev.option.value + this.store$.dispatch({ + type: ADD_TO_REGIONS_SELECTION_WITH_IDS, + selectRegionIds : [id] + }) + + this.autoTrigger.nativeElement.value = '' + this.autoTrigger.nativeElement.focus() + } + + private regionsWithLabelIndex$: Observable<any[]> + public autocompleteList$: Observable<any[]> + public formControl = new FormControl() + + public regionsSelected$: Observable<any> + public parcellationSelected$: Observable<any> + + + @Output() + public focusedStateChanged: EventEmitter<boolean> = new EventEmitter() + + private _focused: boolean = false + set focused(val: boolean){ + this._focused = val + this.focusedStateChanged.emit(val) + } + get focused(){ + return this._focused + } + + public deselectAllRegions(event: MouseEvent){ + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: [] + }) + } + + // TODO handle mobile + handleRegionClick({ mode = null, region = null } = {}){ + const type = mode === 'single' + ? VIEWERSTATE_ACTION_TYPES.SINGLE_CLICK_ON_REGIONHIERARCHY + : mode === 'double' + ? VIEWERSTATE_ACTION_TYPES.DOUBLE_CLICK_ON_REGIONHIERARCHY + : '' + this.store$.dispatch({ + type, + payload: { region } + }) + } + + showHierarchy(event:MouseEvent){ + const dialog = this.dialog.open(this.regionHierarchyTemplate, { + height: '90vh', + width: '90vw' + }) + + /** + * keep sleight of hand shown while modal is shown + * + */ + this.focused = true + + /** + * take 1 to avoid memory leak + */ + dialog.afterClosed().pipe( + take(1) + ).subscribe(() => this.focused = false) + } + +} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.style.css b/src/ui/viewerStateController/regionSearch/regionSearch.style.css new file mode 100644 index 0000000000000000000000000000000000000000..17cda15a41ee862383f3f4e802121021525894f9 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.style.css @@ -0,0 +1,4 @@ +region-hierarchy +{ + height: 100%; +} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionSearch/regionSearch.template.html b/src/ui/viewerStateController/regionSearch/regionSearch.template.html new file mode 100644 index 0000000000000000000000000000000000000000..ef828ac2e265761736d416066215e137c39b3053 --- /dev/null +++ b/src/ui/viewerStateController/regionSearch/regionSearch.template.html @@ -0,0 +1,45 @@ +<div class="d-flex flex-row align-items-center"> + + <form class="flex-grow-1 flex-shrink-1"> + <mat-form-field class="w-100"> + <input + placeholder="Regions" + #autoTrigger + #trigger="matAutocompleteTrigger" + type="text" + matInput + [formControl]="formControl" + [matAutocomplete]="auto"> + </mat-form-field> + <mat-autocomplete + (opened)="focused = true" + (closed)="focused = false" + (optionSelected)="optionSelected($event)" + autoActiveFirstOption + #auto="matAutocomplete"> + <mat-option + *ngFor="let region of autocompleteList$ | async" + [value]="region.labelIndexId"> + {{ region.name }} + </mat-option> + </mat-autocomplete> + </form> + + <button + class="flex-grow-0 flex-shrink-0" + (click)="showHierarchy($event)" + mat-icon-button color="primary"> + <i class="fas fa-sitemap"></i> + </button> +</div> + +<ng-template #regionHierarchy> + <region-hierarchy + [selectedRegions]="regionsSelected$ | async | filterNull" + (singleClickRegion)="handleRegionClick({ mode: 'single', region: $event })" + (doubleClickRegion)="handleRegionClick({ mode: 'double', region: $event })" + (clearAllRegions)="deselectAllRegions($event)" + [parcellationSelected]="parcellationSelected$ | async"> + + </region-hierarchy> +</ng-template> diff --git a/src/ui/viewerStateController/viewerState.component.ts b/src/ui/viewerStateController/viewerState.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..726c1e895802beea84b8fe44fa72ca0685ae159e --- /dev/null +++ b/src/ui/viewerStateController/viewerState.component.ts @@ -0,0 +1,304 @@ +import { Component, ViewChild, TemplateRef, OnInit } from "@angular/core"; +import { Store, select } from "@ngrx/store"; +import { Observable, Subject, combineLatest, Subscription } from "rxjs"; +import { distinctUntilChanged, shareReplay, bufferTime, filter, map, withLatestFrom, delay, take, tap } from "rxjs/operators"; +import { SELECT_REGIONS, USER_CONFIG_ACTION_TYPES } from "src/services/stateStore.service"; +import { DESELECT_REGIONS, CHANGE_NAVIGATION } from "src/services/state/viewerState.store"; +import { ToastService } from "src/services/toastService.service"; +import { getSchemaIdFromName } from "src/util/pipes/templateParcellationDecoration.pipe"; +import { MatDialog, MatSelectChange, MatBottomSheet, MatBottomSheetRef } from "@angular/material"; +import { ExtraButton } from "src/components/radiolist/radiolist.component"; +import { DialogService } from "src/services/dialogService.service"; +import { RegionSelection } from "src/services/state/userConfigState.store"; + +const compareWith = (o, n) => !o || !n + ? false + : o.name === n.name + +@Component({ + selector: 'viewer-state-controller', + templateUrl: './viewerState.template.html', + styleUrls: [ + './viewerState.style.css' + ] +}) + +export class ViewerStateController implements OnInit{ + + @ViewChild('publicationTemplate', {read:TemplateRef}) publicationTemplate: TemplateRef<any> + @ViewChild('savedRegionBottomSheetTemplate', {read:TemplateRef}) savedRegionBottomSheetTemplate: TemplateRef<any> + + public focused: boolean = false + + private subscriptions: Subscription[] = [] + + public availableTemplates$: Observable<any[]> + public availableParcellations$: Observable<any[]> + + public templateSelected$: Observable<any> + public parcellationSelected$: Observable<any> + public regionsSelected$: Observable<any> + + public savedRegionsSelections$: Observable<any[]> + + public focusedDatasets$: Observable<any[]> + private userFocusedDataset$: Subject<any> = new Subject() + private dismissToastHandler: () => void + + public compareWith = compareWith + + private savedRegionBottomSheetRef: MatBottomSheetRef + + constructor( + private store$: Store<any>, + private toastService: ToastService, + private dialogService: DialogService, + private bottomSheet: MatBottomSheet + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.savedRegionsSelections$ = this.store$.pipe( + select('userConfigState'), + select('savedRegionsSelection'), + shareReplay(1) + ) + + this.templateSelected$ = viewerState$.pipe( + select('templateSelected'), + distinctUntilChanged() + ) + + this.parcellationSelected$ = viewerState$.pipe( + select('parcellationSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.regionsSelected$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged(), + shareReplay(1) + ) + + this.availableTemplates$ = viewerState$.pipe( + select('fetchedTemplates'), + distinctUntilChanged() + ) + + this.availableParcellations$ = this.templateSelected$.pipe( + select('parcellations') + ) + + this.focusedDatasets$ = this.userFocusedDataset$.pipe( + filter(v => !!v), + withLatestFrom( + combineLatest(this.templateSelected$, this.parcellationSelected$) + ), + ).pipe( + map(([userFocusedDataset, [selectedTemplate, selectedParcellation]]) => { + const { type, ...rest } = userFocusedDataset + if (type === 'template') return { ...selectedTemplate, ...rest} + if (type === 'parcellation') return { ...selectedParcellation, ...rest } + return { ...rest } + }), + bufferTime(100), + filter(arr => arr.length > 0), + /** + * merge properties field with the root level + * with the prop in properties taking priority + */ + map(arr => arr.map(item => { + const { properties } = item + return { + ...item, + ...properties + } + })), + shareReplay(1) + ) + } + + ngOnInit(){ + this.subscriptions.push( + this.savedRegionsSelections$.pipe( + filter(srs => srs.length === 0) + ).subscribe(() => this.savedRegionBottomSheetRef && this.savedRegionBottomSheetRef.dismiss()) + ) + this.subscriptions.push( + this.focusedDatasets$.subscribe(() => this.dismissToastHandler && this.dismissToastHandler()) + ) + this.subscriptions.push( + this.focusedDatasets$.pipe( + /** + * creates the illusion that the toast complete disappears before reappearing + */ + delay(100) + ).subscribe(() => this.dismissToastHandler = this.toastService.showToast(this.publicationTemplate, { + dismissable: true, + timeout:7000 + })) + ) + } + + handleActiveDisplayBtnClicked(event: MouseEvent, type: 'parcellation' | 'template', extraBtn: ExtraButton, inputItem:any = {}){ + const { name } = extraBtn + const { kgSchema, kgId } = getSchemaIdFromName(name) + this.userFocusedDataset$.next({ + ...inputItem, + kgSchema, + kgId + }) + } + + handleTemplateChange(event:MatSelectChange){ + + this.store$.dispatch({ + type: ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME, + payload: { + name: event.value + } + }) + } + + handleParcellationChange(event:MatSelectChange){ + if (!event.value) return + this.store$.dispatch({ + type: ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME, + payload: { + name: event.value + } + }) + } + + loadSavedRegion(event:MouseEvent, savedRegionsSelection:RegionSelection){ + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.LOAD_REGIONS_SELECTION, + payload: { + savedRegionsSelection + } + }) + } + + public editSavedRegion(event: MouseEvent, savedRegionsSelection: RegionSelection){ + event.preventDefault() + event.stopPropagation() + this.dialogService.getUserInput({ + defaultValue: savedRegionsSelection.name, + placeholder: `Enter new name`, + title: 'Edit name' + }).then(name => { + if (!name) throw new Error('user cancelled') + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.UPDATE_REGIONS_SELECTION, + payload: { + ...savedRegionsSelection, + name + } + }) + }).catch(e => { + // TODO catch user cancel + }) + } + public removeSavedRegion(event: MouseEvent, savedRegionsSelection: RegionSelection){ + event.preventDefault() + event.stopPropagation() + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.DELETE_REGIONS_SELECTION, + payload: { + ...savedRegionsSelection + } + }) + } + + + displayActiveParcellation(parcellation:any){ + return `<div class="d-flex"><small>Parcellation</small> <small class = "flex-grow-1 mute-text">${parcellation ? '(' + parcellation.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + } + + displayActiveTemplate(template: any) { + return `<div class="d-flex"><small>Template</small> <small class = "flex-grow-1 mute-text">${template ? '(' + template.name + ')' : ''}</small> <span class = "fas fa-caret-down"></span></div>` + } + + public loadSelection(event: MouseEvent){ + this.focused = true + + this.savedRegionBottomSheetRef = this.bottomSheet.open(this.savedRegionBottomSheetTemplate) + this.savedRegionBottomSheetRef.afterDismissed() + .subscribe(val => { + + }, error => { + + }, () => { + this.focused = false + this.savedRegionBottomSheetRef = null + }) + } + + public saveSelection(event: MouseEvent){ + this.focused = true + this.dialogService.getUserInput({ + defaultValue: `Saved Region`, + placeholder: `Name the selection`, + title: 'Save region selection' + }) + .then(name => { + if (!name) throw new Error('User cancelled') + this.store$.dispatch({ + type: USER_CONFIG_ACTION_TYPES.SAVE_REGIONS_SELECTION, + payload: { name } + }) + }) + .catch(e => { + /** + * USER CANCELLED, HANDLE + */ + }) + .finally(() => this.focused = false) + } + + public deselectAllRegions(event: MouseEvent){ + this.store$.dispatch({ + type: SELECT_REGIONS, + selectRegions: [] + }) + } + + public deselectRegion(event: MouseEvent, region: any){ + this.store$.dispatch({ + type: DESELECT_REGIONS, + deselectRegions: [region] + }) + } + + public gotoRegion(event: MouseEvent, region:any){ + if (region.position) { + this.store$.dispatch({ + type: CHANGE_NAVIGATION, + navigation: { + position: region.position, + animation: {} + } + }) + } else { + /** + * TODO convert to snack bar + */ + this.toastService.showToast(`${region.name} does not have a position defined`, { + timeout: 5000, + dismissable: true + }) + } + } +} + +const ACTION_TYPES = { + SINGLE_CLICK_ON_REGIONHIERARCHY: 'SINGLE_CLICK_ON_REGIONHIERARCHY', + DOUBLE_CLICK_ON_REGIONHIERARCHY: 'DOUBLE_CLICK_ON_REGIONHIERARCHY', + SELECT_TEMPLATE_WITH_NAME: 'SELECT_TEMPLATE_WITH_NAME', + SELECT_PARCELLATION_WITH_NAME: 'SELECT_PARCELLATION_WITH_NAME', +} + +export const VIEWERSTATE_ACTION_TYPES = ACTION_TYPES diff --git a/src/ui/viewerStateController/viewerState.pipes.ts b/src/ui/viewerStateController/viewerState.pipes.ts new file mode 100644 index 0000000000000000000000000000000000000000..659d35778f3378966cb58f82d47f789e9ca95d89 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.pipes.ts @@ -0,0 +1,38 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { RegionSelection } from "src/services/state/userConfigState.store"; + +@Pipe({ + name: 'binSavedRegionsSelectionPipe' +}) + +export class BinSavedRegionsSelectionPipe implements PipeTransform{ + public transform(regionSelections:RegionSelection[]):{parcellationSelected:any, templateSelected:any, regionSelections: RegionSelection[]}[]{ + const returnMap = new Map() + for (let regionSelection of regionSelections){ + const key = `${regionSelection.templateSelected.name}\n${regionSelection.parcellationSelected.name}` + const existing = returnMap.get(key) + if (existing) existing.push(regionSelection) + else returnMap.set(key, [regionSelection]) + } + return Array.from(returnMap) + .map(([_, regionSelections]) => { + const {parcellationSelected = null, templateSelected = null} = regionSelections[0] || {} + return { + regionSelections, + parcellationSelected, + templateSelected + } + }) + } +} + +@Pipe({ + name: 'savedRegionsSelectionBtnDisabledPipe' +}) + +export class SavedRegionsSelectionBtnDisabledPipe implements PipeTransform{ + public transform(regionSelection: RegionSelection, templateSelected: any, parcellationSelected: any): boolean{ + return regionSelection.parcellationSelected.name !== parcellationSelected.name + || regionSelection.templateSelected.name !== templateSelected.name + } +} \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.style.css b/src/ui/viewerStateController/viewerState.style.css new file mode 100644 index 0000000000000000000000000000000000000000..0da6d0ea50d5d1df72cd96e1c3bd1273b6c9741b --- /dev/null +++ b/src/ui/viewerStateController/viewerState.style.css @@ -0,0 +1,35 @@ +.virtual-scroll-viewport-container +{ + height: 20em; + width: 20em; + overflow: hidden; +} + +.virtual-scroll-viewport-container > cdk-virtual-scroll-viewport +{ + width: 100%; + height: 100%; + box-sizing: content-box; + padding-right: 3em; +} + +.virtual-scroll-row +{ + width: 20em; +} + +/* required to match virtual scroll itemSize property */ +.virtual-scroll-unit +{ + height: 26px +} + +.selected-region-container +{ + flex: 1 1 auto; +} + +.selected-region-actionbtn +{ + flex: 0 0 auto; +} diff --git a/src/ui/viewerStateController/viewerState.template.html b/src/ui/viewerStateController/viewerState.template.html new file mode 100644 index 0000000000000000000000000000000000000000..9e1718231d4037b57fefcd2155df8bc51e12e362 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.template.html @@ -0,0 +1,202 @@ +<mat-card> + + <!-- template selection --> + <mat-form-field> + <mat-label> + Template + </mat-label> + <mat-select + [value]="(templateSelected$ | async)?.name" + (selectionChange)="handleTemplateChange($event)" + (openedChange)="focused = $event"> + <mat-option + *ngFor="let template of (availableTemplates$ | async)" + [value]="template.name"> + {{ template.name }} + </mat-option> + </mat-select> + </mat-form-field> + <ng-container *ngIf="templateSelected$ | async as templateSelected"> + <ng-container *ngIf="(templateSelected | templateParcellationsDecorationPipe)?.extraButtons as extraButtons"> + <button + *ngFor="let extraBtn of extraButtons" + (click)="handleActiveDisplayBtnClicked($event, 'template', extraBtn, templateSelected)" + mat-icon-button> + <i [class]="extraBtn.faIcon"></i> + </button> + </ng-container> + </ng-container> + + <!-- parcellation selection --> + <mat-form-field *ngIf="templateSelected$ | async as templateSelected"> + <mat-label> + Parcellation + </mat-label> + <mat-select + (selectionChange)="handleParcellationChange($event)" + [value]="(parcellationSelected$ | async)?.name" + (openedChange)="focused = $event"> + <mat-option + *ngFor="let parcellation of (templateSelected.parcellations | appendTooltipTextPipe)" + [value]="parcellation.name"> + {{ parcellation.name }} + </mat-option> + </mat-select> + </mat-form-field> + + <ng-container *ngIf="parcellationSelected$ | async as parcellationSelected"> + <ng-container *ngIf="(parcellationSelected | templateParcellationsDecorationPipe)?.extraButtons as extraButtons"> + <button + *ngFor="let extraBtn of extraButtons" + (click)="handleActiveDisplayBtnClicked($event, 'parcellation', extraBtn, parcellationSelected)" + mat-icon-button> + <i [class]="extraBtn.faIcon"></i> + </button> + </ng-container> + </ng-container> + + <!-- divider --> + <mat-divider></mat-divider> + + <!-- selected regions --> + + <div class="d-flex"> + <region-text-search-autocomplete + (focusedStateChanged)="focused = $event"> + </region-text-search-autocomplete> + </div> + + <!-- chips --> + <mat-card class="w-20em mh-10em overflow-auto overflow-x-hidden"> + <mat-chip-list class="mat-chip-list-stacked" #selectedRegionsChipList> + <mat-chip class="w-100" *ngFor="let region of (regionsSelected$ | async)"> + <span class="flex-grow-1 flex-shrink-1 text-truncate"> + {{ region.name }} + </span> + <button + *ngIf="region.position" + (click)="gotoRegion($event, region)" + mat-icon-button> + <i class="fas fa-map-marked-alt"></i> + </button> + <button + (click)="deselectRegion($event, region)" + mat-icon-button> + <i class="fas fa-trash"></i> + </button> + </mat-chip> + </mat-chip-list> + + <!-- place holder when no regions has been selected --> + <span class="muted" *ngIf="(regionsSelected$ | async).length === 0"> + No regions selected. Double click on any regions in the viewer, or use the search tool to select regions of interest. + </span> + </mat-card> + + <!-- control btns --> + <div class="mt-2 mb-2 d-flex justify-content-between"> + <div class="d-flex"> + + <!-- save --> + <button + matTooltip="Save this selection of regions" + matTooltipPosition="below" + mat-button + (click)="saveSelection($event)" + color="primary"> + <i class="fas fa-save"></i> + + </button> + + <!-- load --> + <button + (click)="loadSelection($event)" + matTooltip="Load a selection of regions" + matTooltipPosition="below" + mat-button + color="primary" + [disabled]="(savedRegionsSelections$ | async)?.length === 0"> + <i + matBadgeColor="accent" + [matBadgeOverlap]="false" + [matBadge]="(savedRegionsSelections$ | async)?.length > 0 ? (savedRegionsSelections$ | async)?.length : null" + class="fas fa-folder-open"></i> + + </button> + </div> + + <!-- deselect all --> + <button + (click)="deselectAllRegions($event)" + matTooltip="Deselect all selected regions" + matTooltipPosition="below" + mat-raised-button + color="warn" + [disabled]="(regionsSelected$ | async)?.length === 0"> + <i class="fas fa-trash"></i> + </button> + </div> +</mat-card> + +<ng-template #publicationTemplate> + <single-dataset-view + *ngFor="let focusedDataset of (focusedDatasets$ | async)" + [name]="focusedDataset.name" + [description]="focusedDataset.description" + [publications]="focusedDataset.publications" + [kgSchema]="focusedDataset.kgSchema" + [kgId]="focusedDataset.kgId"> + + </single-dataset-view> +</ng-template> + +<!-- bottom sheet for saved regions --> +<ng-template #savedRegionBottomSheetTemplate> + <mat-action-list> + + <!-- separated (binned) by template/parcellation --> + <ng-container *ngFor="let binnedRS of (savedRegionsSelections$ | async | binSavedRegionsSelectionPipe); let index = index"> + + <!-- only render divider if it is not the leading element --> + <mat-divider *ngIf="index !== 0"></mat-divider> + + <!-- header --> + <h3 mat-subheader> + {{ binnedRS.templateSelected.name }} / {{ binnedRS.parcellationSelected.name }} + </h3> + + <!-- ng for all saved regions --> + <button + *ngFor="let savedRegionsSelection of binnedRS.regionSelections" + (click)="loadSavedRegion($event, savedRegionsSelection)" + mat-list-item> + <!-- [class]="savedRegionsSelection | savedRegionsSelectionBtnDisabledPipe : (templateSelected$ | async) : (parcellationSelected$ | async) ? 'text-muted' : ''" --> + <!-- [disabled]="savedRegionsSelection | savedRegionsSelectionBtnDisabledPipe : (templateSelected$ | async) : (parcellationSelected$ | async)" --> + <!-- main content --> + <span class="flex-grow-0 flex-shrink-1"> + {{ savedRegionsSelection.name }} + </span> + <small class="ml-1 mr-1 text-muted flex-grow-1 flex-shrink-0"> + ({{ savedRegionsSelection.regionsSelected.length }} selected regions) + </small> + + <!-- edit btn --> + <button + (mousedown)="$event.stopPropagation()" + (click)="editSavedRegion($event, savedRegionsSelection)" + mat-icon-button> + <i class="fas fa-edit"></i> + </button> + + <!-- trash btn --> + <button + (mousedown)="$event.stopPropagation()" + (click)="removeSavedRegion($event, savedRegionsSelection)" + mat-icon-button + color="warn"> + <i class="fas fa-trash"></i> + </button> + </button> + </ng-container> + </mat-action-list> +</ng-template> \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c8232e1c4509088bf1ac287bdd5057508505bef --- /dev/null +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -0,0 +1,180 @@ +import { Subscription, Observable } from "rxjs"; +import { Injectable, OnInit, OnDestroy } from "@angular/core"; +import { Actions, ofType, Effect } from "@ngrx/effects"; +import { Store, select, Action } from "@ngrx/store"; +import { ToastService } from "src/services/toastService.service"; +import { shareReplay, distinctUntilChanged, map, withLatestFrom, filter } from "rxjs/operators"; +import { VIEWERSTATE_ACTION_TYPES } from "./viewerState.component"; +import { CHANGE_NAVIGATION, SELECT_REGIONS, NEWVIEWER, GENERAL_ACTION_TYPES, SELECT_PARCELLATION, isDefined } from "src/services/stateStore.service"; +import { regionFlattener } from "src/util/regionFlattener"; + +@Injectable({ + providedIn: 'root' +}) + +export class ViewerStateControllerUseEffect implements OnInit, OnDestroy{ + + private subscriptions: Subscription[] = [] + + private selectedRegions$: Observable<any[]> + + @Effect() + singleClickOnHierarchy$: Observable<any> + + @Effect() + selectTemplateWithName$: Observable<any> + + @Effect() + selectParcellationWithName$: Observable<any> + + doubleClickOnHierarchy$: Observable<any> + + constructor( + private actions$: Actions, + private store$: Store<any>, + private toastService: ToastService + ){ + const viewerState$ = this.store$.pipe( + select('viewerState'), + shareReplay(1) + ) + + this.selectedRegions$ = viewerState$.pipe( + select('regionsSelected'), + distinctUntilChanged() + ) + + this.selectParcellationWithName$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SELECT_PARCELLATION_WITH_NAME), + map(action => { + const { payload = {} } = action as ViewerStateAction + const { name } = payload + return name + }), + filter(name => !!name), + withLatestFrom(viewerState$.pipe( + select('parcellationSelected') + )), + filter(([name, parcellationSelected]) => { + if (parcellationSelected && parcellationSelected.name === name) return false + return true + }), + map(([name, _]) => name), + withLatestFrom(viewerState$.pipe( + select('templateSelected') + )), + map(([name, templateSelected]) => { + + const { parcellations: availableParcellations } = templateSelected + const newParcellation = availableParcellations.find(t => t.name === name) + if (!newParcellation) { + return { + type: GENERAL_ACTION_TYPES.ERROR, + payload: { + message: 'Selected parcellation not found.' + } + } + } + return { + type: SELECT_PARCELLATION, + selectParcellation: newParcellation + } + }) + ) + + this.selectTemplateWithName$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME), + map(action => { + const { payload = {} } = action as ViewerStateAction + const { name } = payload + return name + }), + filter(name => !!name), + withLatestFrom(viewerState$.pipe( + select('templateSelected') + )), + filter(([name, templateSelected]) => { + if (templateSelected && templateSelected.name === name) return false + return true + }), + map(([name, templateSelected]) => name), + withLatestFrom(viewerState$.pipe( + select('fetchedTemplates') + )), + map(([name, availableTemplates]) => { + const newTemplateTobeSelected = availableTemplates.find(t => t.name === name) + if (!newTemplateTobeSelected) { + return { + type: GENERAL_ACTION_TYPES.ERROR, + payload: { + message: 'Selected template not found.' + } + } + } + return { + type: NEWVIEWER, + selectTemplate: newTemplateTobeSelected, + selectParcellation: newTemplateTobeSelected.parcellations[0] + } + }) + ) + + this.doubleClickOnHierarchy$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.DOUBLE_CLICK_ON_REGIONHIERARCHY) + ) + + this.singleClickOnHierarchy$ = this.actions$.pipe( + ofType(VIEWERSTATE_ACTION_TYPES.SINGLE_CLICK_ON_REGIONHIERARCHY), + withLatestFrom(this.selectedRegions$), + map(([action, regionsSelected]) => { + + const {payload = {}} = action as ViewerStateAction + const { region } = payload + + const flattenedRegion = regionFlattener(region).filter(r => isDefined(r.labelIndex)) + const flattenedRegionNames = new Set(flattenedRegion.map(r => r.name)) + const selectedRegionNames = new Set(regionsSelected.map(r => r.name)) + const selectAll = flattenedRegion.every(r => !selectedRegionNames.has(r.name)) + return { + type: SELECT_REGIONS, + selectRegions: selectAll + ? regionsSelected.concat(flattenedRegion) + : regionsSelected.filter(r => !flattenedRegionNames.has(r.name)) + } + }) + ) + } + + ngOnInit(){ + this.subscriptions.push( + this.doubleClickOnHierarchy$.subscribe(({ region } = {}) => { + const { position } = region + if (position) { + this.store$.dispatch({ + type: CHANGE_NAVIGATION, + navigation: { + position, + animation: {} + } + }) + } else { + this.toastService.showToast(`${region.name} does not have a position defined`, { + timeout: 5000, + dismissable: true + }) + } + }) + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0) { + this.subscriptions.pop().unsubscribe() + } + } +} + +interface ViewerStateAction extends Action{ + payload: any + config: any +} \ No newline at end of file diff --git a/src/util/pipes/filterNgLayer.pipe.ts b/src/util/pipes/filterNgLayer.pipe.ts deleted file mode 100644 index 798075159645feed923f9cca4ac750afc8ac4ebc..0000000000000000000000000000000000000000 --- a/src/util/pipes/filterNgLayer.pipe.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; -import { NgLayerInterface } from "src/atlasViewer/atlasViewer.component"; - -/** - * TODO deprecate - * use regular pipe to achieve the same effect - */ - -@Pipe({ - name: 'filterNgLayer' -}) - -export class FilterNgLayer implements PipeTransform{ - public transform(excludedLayers: string[] = [], ngLayers: NgLayerInterface[]): NgLayerInterface[] { - const set = new Set(excludedLayers) - return ngLayers.filter(l => !set.has(l.name)) - } -} \ No newline at end of file diff --git a/src/util/pipes/getFileExt.pipe.ts b/src/util/pipes/getFileExt.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..aea77ceba51c36451836366656529eef661a852c --- /dev/null +++ b/src/util/pipes/getFileExt.pipe.ts @@ -0,0 +1,36 @@ +import { PipeTransform, Pipe } from "@angular/core"; + +const NIFTI = `NIFTI Volume` +const VTK = `VTK Mesh` + +const extMap = new Map([ + ['.nii', NIFTI], + ['.nii.gz', NIFTI], + ['.vtk', VTK] +]) + +@Pipe({ + name: 'getFileExtension' +}) + +export class GetFileExtension implements PipeTransform{ + private regex: RegExp = new RegExp('(\\.[\\w\\.]*?)$') + + private getRegexp(ext){ + return new RegExp(`${ext.replace(/\./g, '\\.')}$`, 'i') + } + + private detFileExt(filename:string):string{ + for (let [key, val] of extMap){ + if(this.getRegexp(key).test(filename)){ + return val + } + } + return filename + } + + public transform(filename:string):string{ + return this.detFileExt(filename) + } +} + diff --git a/src/util/pipes/getFileNameFromPathName.pipe.ts b/src/util/pipes/getFileNameFromPathName.pipe.ts deleted file mode 100644 index d64a96c1c9de83ef55c9efa7f3f43e0181b9fe21..0000000000000000000000000000000000000000 --- a/src/util/pipes/getFileNameFromPathName.pipe.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; - - -@Pipe({ - name : 'getFilenameFromPathname' -}) - -export class GetFilenameFromPathnamePipe implements PipeTransform{ - public transform(pathname:string):string{ - return pathname.split('/')[pathname.split('/').length - 1] - } -} \ No newline at end of file diff --git a/src/util/pipes/getFilename.pipe.ts b/src/util/pipes/getFilename.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..76afea5627e1ce05a7f527727f99084f30557291 --- /dev/null +++ b/src/util/pipes/getFilename.pipe.ts @@ -0,0 +1,14 @@ +import { PipeTransform, Pipe } from "@angular/core"; + +@Pipe({ + name: 'getFilenamePipe' +}) + +export class GetFilenamePipe implements PipeTransform{ + private regex: RegExp = new RegExp('[\\/\\\\]([\\w\\.]*?)$') + public transform(fullname: string): string{ + return this.regex.test(fullname) + ? this.regex.exec(fullname)[1] + : fullname + } +} \ No newline at end of file diff --git a/src/util/pipes/kgSearchBtnColor.pipe.ts b/src/util/pipes/kgSearchBtnColor.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9bbb25bf655bbe4d5e90ef08768f058fae915f2 --- /dev/null +++ b/src/util/pipes/kgSearchBtnColor.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { WidgetUnit } from "src/atlasViewer/widgetUnit/widgetUnit.component"; + +@Pipe({ + name: 'kgSearchBtnColorPipe' +}) + +export class KgSearchBtnColorPipe implements PipeTransform{ + public transform([minimisedWidgetUnit, themedBtnCls]: [Set<WidgetUnit>, string], wu: WidgetUnit ){ + return minimisedWidgetUnit.has(wu) + ? 'primary' + : 'accent' + } +} \ No newline at end of file diff --git a/src/util/pipes/pluginBtnFabColor.pipe.ts b/src/util/pipes/pluginBtnFabColor.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfb11d4ae59e41107e8fa258be73a122c1b1de7c --- /dev/null +++ b/src/util/pipes/pluginBtnFabColor.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'pluginBtnFabColorPipe' +}) + +export class PluginBtnFabColorPipe implements PipeTransform{ + public transform([launchedSet, minimisedSet, themedBtnCls], pluginName){ + return minimisedSet.has(pluginName) + ? 'primary' + : launchedSet.has(pluginName) + ? 'accent' + : 'basic' + } +} \ No newline at end of file diff --git a/webpack.staticassets.js b/webpack.staticassets.js index cad3e8a0d4b3861ba397d125e8c1f3276dacb1b5..05119ba58f3c746b113b673b7abb41849d63bfab 100644 --- a/webpack.staticassets.js +++ b/webpack.staticassets.js @@ -1,8 +1,19 @@ const webpack = require('webpack') +const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.exports = { module : { rules : [ + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader + }, + 'css-loader', + 'sass-loader' + ] + }, { test : /jpg|png/, exclude : /export\_nehuba|index/, @@ -49,6 +60,9 @@ module.exports = { ] }, plugins : [ + new MiniCssExtractPlugin({ + filename: 'theme.css' + }), new webpack.DefinePlugin({ PLUGINDEV : process.env.PLUGINDEV ? JSON.stringify(process.env.PLUGINDEV) @@ -67,5 +81,10 @@ module.exports = { BACKEND_URL: JSON.stringify(process.env.BACKEND_URL || 'http://localhost:3000/') }) // ...ignoreArr.map(dirname => new webpack.IgnorePlugin(/\.\/plugin_examples/)) - ] + ], + resolve: { + extensions: [ + '.scss' + ] + } } \ No newline at end of file