From 3716557bcbbcc69e899f99b9449cf2b9c88e7539 Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 10:13:43 +0100 Subject: [PATCH 001/147] Reorder package.json properties --- package.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 025c934..79801a7 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,21 @@ { + "name": "tourdino", + "description": "Calculate and visualize similarity measures.", + "homepage": "https://phovea.caleydo.org", + "version": "1.0.1-SNAPSHOT", + "author": { + "name": "Klaus Eckelt", + "email": "Klaus.Eckelt@jku.at", + "url": "https://www.jku.at/en/institute-of-computer-graphics/about-us/team/klaus-eckelt/" + }, + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/caleydo/tourdino/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/caleydo/tourdino.git" + }, "main": "build/tourdino.js", "types": "src/index.d.ts", "files": [ @@ -76,22 +93,5 @@ "d3.parsets": "git+https://github.com/jasondavies/d3-parsets.git#v1.2.4", "d3-grubert-boxplot": "gist:366430a0ac8e6e55ce09b040b5b493a6", "jStat": "1.7.1" - }, - "name": "tourdino", - "description": "Calculate and visualize similarity measures.", - "homepage": "https://phovea.caleydo.org", - "version": "1.0.0-SNAPSHOT", - "author": { - "name": "Klaus Eckelt", - "email": "Klaus.Eckelt@jku.at", - "url": "https://www.jku.at/en/institute-of-computer-graphics/about-us/team/klaus-eckelt/" - }, - "license": "BSD-3-Clause", - "bugs": { - "url": "https://github.com/caleydo/tourdino/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/caleydo/tourdino.git" } } From cc4a1fce469d0e99d9f161b1fa5d00bfdfa622ae Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 10:27:00 +0100 Subject: [PATCH 002/147] Using generator-phovea v2.0.1 Closes #8 - use Node.js v12 - update to TypeScript 2.8.1 - update CircleCI config - update dev dependencies - udpate webpack.config.js - update buildInfo.js --- .circleci/config.yml | 54 ++++++++++++++++++----- .gitignore | 2 +- .gitlab-ci.yml | 2 +- .yo-rc.json | 10 ++++- buildInfo.js | 2 +- package.json | 29 ++++++++----- phovea.js | 16 +++++++ phovea_registry.js | 15 +++++++ tests.webpack.js | 3 ++ webpack.config.js | 100 ++++++++++++++++++++++++++++++++++++++++--- 10 files changed, 201 insertions(+), 32 deletions(-) create mode 100644 phovea.js create mode 100644 phovea_registry.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 0018acf..8909887 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,29 +3,61 @@ jobs: build: working_directory: ~/phovea docker: - - image: circleci/node:6-browsers - tags: - - /v\d+.\d+.\d+.*/ + - image: circleci/node:12-browsers steps: - checkout + - run: + name: Show Node.js and npm version + command: | + node -v + npm -v - restore_cache: - key: deps2-{{ .Branch }}-{{ checksum "package.json" }} + key: deps1-{{ .Branch }}-{{ checksum "package.json" }} - run: - name: install-npm-wee + name: Install npm dependencies command: npm install - - run: #remove all resolved github dependencies - name: delete-vcs-dependencies + - run: + name: Remove npm dependencies installed from git repositories (avoid caching of old commits) command: | (grep -l '._resolved.: .\(git[^:]*\|bitbucket\):' ./node_modules/*/package.json || true) | xargs -r dirname | xargs -r rm -rf - save_cache: - key: deps2-{{ .Branch }}-{{ checksum "package.json" }} + key: deps1-{{ .Branch }}-{{ checksum "package.json" }} paths: - ./node_modules - - run: #install all dependencies - name: install-npm-wee2 + - run: + name: Install npm dependencies from git repositories (always get latest commit) command: npm install - run: - name: dist + name: Show installed npm dependencies + command: npm list --depth=1 || true + - run: + name: Build command: npm run dist - store_artifacts: path: dist +workflows: + version: 2 +# build-nightly: +# triggers: +# - schedule: +# cron: "15 1 * * 1-5" # "At 01:15 on every day-of-week from Monday through Friday.”, see: https://crontab.guru/#15_1_*_*_1-5 +# filters: +# branches: +# only: +# - develop +# jobs: +# - build + build-branch: + jobs: + - build: + filters: + tags: + ignore: /^v.*/ + build-tag: + jobs: + - build: + filters: + branches: + ignore: /.*/ + tags: + only: /^v.*/ diff --git a/.gitignore b/.gitignore index e4e082d..8de9a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ node_modules/ *.map *.css *.log -/.cache-loader \ No newline at end of file +/.cache-loader diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 05d99fb..7e48588 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: circleci/node:6-browsers +image: circleci/node:12-browsers variables: GIT_DEPTH: "1" diff --git a/.yo-rc.json b/.yo-rc.json index 1eead26..a168b99 100644 --- a/.yo-rc.json +++ b/.yo-rc.json @@ -18,6 +18,12 @@ "name": "touring", "author": "Klaus Eckelt", "today": "Wed, 19 Sep 2018 10:27:30 GMT", - "githubAccount": "caleydo" + "githubAccount": "caleydo", + "promptValues": { + "authorName": "The Caleydo Team", + "authorEmail": "contact@caleydo.org", + "authorUrl": "https://caleydo.org/", + "githubAccount": "caleydo" + } } -} \ No newline at end of file +} diff --git a/buildInfo.js b/buildInfo.js index 09204ef..4e7bd68 100644 --- a/buildInfo.js +++ b/buildInfo.js @@ -139,7 +139,7 @@ function resolveScreenshot() { if (!fs.existsSync(f)) { return null; } - const buffer = new Buffer(fs.readFileSync(f)).toString('base64'); + const buffer = Buffer.from(fs.readFileSync(f)).toString('base64'); return `data:image/png;base64,${buffer}`; } diff --git a/package.json b/package.json index 79801a7..7af6ba9 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,17 @@ "homepage": "https://phovea.caleydo.org", "version": "1.0.1-SNAPSHOT", "author": { - "name": "Klaus Eckelt", - "email": "Klaus.Eckelt@jku.at", - "url": "https://www.jku.at/en/institute-of-computer-graphics/about-us/team/klaus-eckelt/" + "name": "The Caleydo Team", + "email": "contact@caleydo.org", + "url": "https://caleydo.org" }, + "contributors": [ + { + "name": "Klaus Eckelt", + "email": "Klaus.Eckelt@jku.at", + "url": "https://www.jku.at/en/institute-of-computer-graphics/about-us/team/klaus-eckelt/" + } + ], "license": "BSD-3-Clause", "bugs": { "url": "https://github.com/caleydo/tourdino/issues" @@ -21,15 +28,17 @@ "files": [ "src", "index.js", + "phovea.js", + "phovea_registry.js", "build" ], "engines": { - "npm": ">= 3", - "node": ">= 6" + "npm": ">= 6.12", + "node": ">= 12.13" }, "scripts": { "compile": "tsc", - "lint": "tslint -c tslint.json src/**.ts tests/**.ts", + "lint": "tslint -c tslint.json -p . 'src/**/*.ts?(x)' 'tests/**/*.ts?(x)'", "docs": "typedoc --options typedoc.json src/**.ts", "prebuild": "node -e \"process.exit(process.env.PHOVEA_SKIP_TESTS === undefined?1:0)\" || npm run test", "pretest": "npm run compile", @@ -70,7 +79,7 @@ "karma-sourcemap-loader": "0.3.7", "karma-webpack": "2.0.3", "mkdirp": "0.5.1", - "node-sass": "^4.10.x", + "node-sass": "^4.12.0", "null-loader": "0.1.1", "raw-loader": "0.5.1", "sass-loader": "6.0.7", @@ -80,16 +89,16 @@ "tslib": "1.9.0", "tslint": "5.9.1", "typedoc": "0.11.1", - "typescript": "2.7.2", + "typescript": "2.8.1", "url-loader": "0.5.8", "webpack": "2.3.3", "webpack-dev-server": "2.4.2", "worker-loader": "^2.0.0" }, "dependencies": { - "@types/d3": "3.5.36", + "@types/d3": "~3.5.36", "@types/jquery": "2.0.33", - "d3": "3.5.17", + "d3": "~3.5.17", "d3.parsets": "git+https://github.com/jasondavies/d3-parsets.git#v1.2.4", "d3-grubert-boxplot": "gist:366430a0ac8e6e55ce09b040b5b493a6", "jStat": "1.7.1" diff --git a/phovea.js b/phovea.js new file mode 100644 index 0000000..62a530e --- /dev/null +++ b/phovea.js @@ -0,0 +1,16 @@ +/* ***************************************************************************** + * Caleydo - Visualization for Molecular Biology - http://caleydo.org + * Copyright (c) The Caleydo Team. All rights reserved. + * Licensed under the new BSD license, available at http://caleydo.org/license + **************************************************************************** */ + +//register all extensions in the registry following the given pattern +module.exports = function(registry) { + /// #if include('extension-type', 'extension-id') + //registry.push('extension-type', 'extension-id', function() { return import('./src/extension_impl'); }, {}); + /// #endif + // generator-phovea:begin + + // generator-phovea:end +}; + diff --git a/phovea_registry.js b/phovea_registry.js new file mode 100644 index 0000000..84dea42 --- /dev/null +++ b/phovea_registry.js @@ -0,0 +1,15 @@ +/* ***************************************************************************** + * Caleydo - Visualization for Molecular Biology - http://caleydo.org + * Copyright (c) The Caleydo Team. All rights reserved. + * Licensed under the new BSD license, available at http://caleydo.org/license + **************************************************************************** */ + +import {register} from 'phovea_core/src/plugin'; + +/** + * build a registry by registering all phovea modules + */ +//other modules + +//self +register('tourdino', require('./phovea.js')); diff --git a/tests.webpack.js b/tests.webpack.js index 0ddbcf2..9438ac4 100644 --- a/tests.webpack.js +++ b/tests.webpack.js @@ -4,6 +4,9 @@ * Licensed under the new BSD license, available at http://caleydo.org/license **************************************************************************** */ +// build registry +require('./phovea_registry.js'); + /** * find all tests in the spec directory and load them */ diff --git a/webpack.config.js b/webpack.config.js index ce98599..7dbb9a1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -66,6 +66,12 @@ const webpackloaders = [ {test: /\.scss$/, use: 'style-loader!css-loader!sass-loader'}, {test: /\.css$/, use: 'style-loader!css-loader'}, {test: /\.tsx?$/, use: tsLoader}, + { + test: /phovea(_registry)?\.js$/, use: [{ + loader: 'ifdef-loader', + options: Object.assign({include: includeFeature}, preCompilerFlags) + }] + }, {test: /\.json$/, use: 'json-loader'}, { test: /\.(png|jpg)$/, @@ -93,13 +99,52 @@ const webpackloaders = [ {test: /\.(ttf|eot)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'file-loader'} ]; +/** + * tests whether the given phovea module name is matching the requested file and if so convert it to an external lookup + * depending on the loading type + */ +function testPhoveaModule(moduleName, request) { + if (!(new RegExp('^' + moduleName + '/src.*')).test(request)) { + return false; + } + const subModule = request.match(/.*\/src\/?(.*)/)[1]; + // skip empty modules = root + const path = subModule === '' ? [moduleName] : [moduleName, subModule]; + // phovea_ ... phovea.name + const rootPath = /phovea_.*/.test(moduleName) ? ['phovea', moduleName.slice(7)].concat(path.slice(1)) : path; + return { + root: rootPath, + commonjs2: path, + commonjs: path, + amd: request + (subModule === '' ? '/main' : '') + }; +} + +function testPhoveaModules(modules) { + return (context, request, callback) => { + for (let i = 0; i < modules.length; ++i) { + const r = testPhoveaModule(modules[i], request); + if (r) { + return callback(null, r); + } + } + callback(); + }; +} + +// use workspace registry file if available +const isWorkspaceContext = fs.existsSync(resolve(__dirname, '..', 'phovea_registry.js')); +const registryFile = isWorkspaceContext ? '../phovea_registry.js' : './phovea_registry.js'; +const actMetaData = `file-loader?name=phoveaMetaData.json!${buildInfo.metaDataTmpFile(pkg)}`; +const actBuildInfoFile = `file-loader?name=buildInfo.json!${buildInfo.tmpFile()}`; + /** * inject the registry to be included * @param entry * @returns {*} */ function injectRegistry(entry) { - const extraFiles = [`file-loader?name=buildInfo.json!${buildInfo.tmpFile()}`]; + const extraFiles = [registryFile, actBuildInfoFile, actMetaData]; // build also the registry if (typeof entry === 'string') { return extraFiles.concat(entry); @@ -129,7 +174,10 @@ function generateWebpack(options) { alias: Object.assign({}, options.libs || {}), symlinks: false, // fallback to the directory above if they are siblings just in the workspace context - modules: ['node_modules'] + modules: isWorkspaceContext ? [ + resolve(__dirname, '../'), + 'node_modules' + ] : ['node_modules'] }, plugins: [ // define magic constants that are replaced @@ -212,15 +260,27 @@ function generateWebpack(options) { // if we don't bundle don't include external libraries and other phovea modules base.externals.push(...(options.externals || Object.keys(options.libs || {}))); + // ignore all phovea modules + if (options.modules) { + base.externals.push(testPhoveaModules(options.modules)); + } + // ignore extra modules (options.ignore || []).forEach(function (d) { base.module.loaders.push({test: new RegExp(d), loader: 'null-loader'}); // use null loader }); + // ingore phovea module registry calls + (options.modules || []).forEach(function (m) { + base.module.loaders.push({ + test: new RegExp('.*[\\\\/]' + m + '[\\\\/]phovea_registry.js'), + loader: 'null-loader' + }); // use null loader + }); } if (!options.bundle || options.isApp) { // extract the included css file to own file const p = new ExtractTextPlugin({ - filename: (options.isApp || options.moduleBundle ? 'style' : pkg.name) + (options.min && !options.nosuffix ? '.min' : '') + '.css', + filename: (options.isApp || options.moduleBundle ? '[name]' : pkg.name) + (options.min && !options.nosuffix ? '.min' : '') + '.css', allChunks: true // there seems to be a bug in dynamically loaded chunk styles are not loaded, workaround: extract all styles from all chunks }); base.plugins.push(p); @@ -256,8 +316,18 @@ function generateWebpack(options) { })); }); } - // generate source maps - base.devtool = 'inline-source-map'; + if (options.min) { + // use a minifier + base.plugins.push( + new webpack.LoaderOptionsPlugin({ + minimize: true, + debug: false + }), + new webpack.optimize.UglifyJsPlugin()); + } else { + // generate source maps + base.devtool = 'inline-source-map'; + } return base; } @@ -298,7 +368,25 @@ function generateWebpackConfig(env) { base.library = true; } - return generateWebpack(base); + // single generation + if (isDev) { + return generateWebpack(base); + } + if (type.startsWith('app')) { // isProduction app + return generateWebpack(Object.assign({}, base, { + min: true, + nosuffix: true + })); + } + // isProduction + return [ + // plain + generateWebpack(base), + // minified + generateWebpack(Object.assign({}, base, { + min: true + })) + ]; } module.exports = generateWebpackConfig; From 8a8bdf9b17bae6431fe29c29c54afd42bb47794a Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 10:54:03 +0100 Subject: [PATCH 003/147] Add phovea_core dependency --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7af6ba9..19554cd 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "d3": "~3.5.17", "d3.parsets": "git+https://github.com/jasondavies/d3-parsets.git#v1.2.4", "d3-grubert-boxplot": "gist:366430a0ac8e6e55ce09b040b5b493a6", - "jStat": "1.7.1" + "jStat": "1.7.1", + "phovea_core": "github:phovea/phovea_core#develop" } } From 6927b5f4164d3c5be67e0394f7982a423fe6afc5 Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 11:14:43 +0100 Subject: [PATCH 004/147] Switch TS compile target to ES5 --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 1e83373..3951c3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "es6", - "target": "es6", + "target": "es5", "importHelpers": true, "sourceMap": true, "moduleResolution": "node", @@ -9,7 +9,7 @@ "experimentalDecorators": true, "lib": [ "dom", - "es6", + "es2015", "es2016.array.include", "es2017.object" ], From b662c8c141da236e3605e75e8f0df5d3b3d8876f Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 11:17:11 +0100 Subject: [PATCH 005/147] Remove tsconfig flag noImplicitReturns Enabling this flag will raise some errors in phovea_core due to empty `return;` statements. --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 3951c3a..834ac23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,6 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "noImplicitReturns": true, }, "include": [ "src/**/*.ts", From f4d004bc3c6a590444c971c07f254cb2fda63a05 Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 11:19:41 +0100 Subject: [PATCH 006/147] Enabling downlevelIteration in tsconfig Enabling this TS flag adds a polyfill for ES6 iteratable features for ES5. See https://mariusschulz.com/blog/downlevel-iteration-for-es3-es5-in-typescript --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 834ac23..0d8c52d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, + "downlevelIteration": true }, "include": [ "src/**/*.ts", From 839da6f83a09365e724f623586ffce16e505ab8b Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 5 Dec 2019 11:35:50 +0100 Subject: [PATCH 007/147] Update dev version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 19554cd..159dfb7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tourdino", "description": "Calculate and visualize similarity measures.", "homepage": "https://phovea.caleydo.org", - "version": "1.0.1-SNAPSHOT", + "version": "1.1.1-SNAPSHOT", "author": { "name": "The Caleydo Team", "email": "contact@caleydo.org", From 40bdcffbea7dad9aaddb962a7b22542ed96f9314 Mon Sep 17 00:00:00 2001 From: oltionchampari Date: Thu, 5 Dec 2019 14:58:25 +0100 Subject: [PATCH 008/147] Added start touring button to lineup panel actions through tdpRankingButton extension Caleydo/tourdino#10 --- phovea_registry.js | 3 ++- src/InitTourdino.ts | 6 ++++++ src/phovea.ts | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/InitTourdino.ts create mode 100644 src/phovea.ts diff --git a/phovea_registry.js b/phovea_registry.js index 84dea42..bb7213c 100644 --- a/phovea_registry.js +++ b/phovea_registry.js @@ -5,6 +5,7 @@ **************************************************************************** */ import {register} from 'phovea_core/src/plugin'; +import reg from './src/phovea' /** * build a registry by registering all phovea modules @@ -12,4 +13,4 @@ import {register} from 'phovea_core/src/plugin'; //other modules //self -register('tourdino', require('./phovea.js')); +register('tourdino', reg); diff --git a/src/InitTourdino.ts b/src/InitTourdino.ts new file mode 100644 index 0000000..098a7fa --- /dev/null +++ b/src/InitTourdino.ts @@ -0,0 +1,6 @@ + + + +export default function initTourdino() { + +} diff --git a/src/phovea.ts b/src/phovea.ts new file mode 100644 index 0000000..28cbb47 --- /dev/null +++ b/src/phovea.ts @@ -0,0 +1,24 @@ +/* ***************************************************************************** + * Caleydo - Visualization for Molecular Biology - http://caleydo.org + * Copyright (c) The Caleydo Team. All rights reserved. + * Licensed under the new BSD license, available at http://caleydo.org/license + **************************************************************************** */ +import {IRegistry} from 'phovea_core/src/plugin'; +import parseRange from 'phovea_core/src/range/parser'; +import ActionNode from 'phovea_core/src/provenance/ActionNode'; + +export default function (registry: IRegistry) { + //registry.push('extension-type', 'extension-id', function() { return import('./extension_impl'); }, {}); + // generator-phovea:begin + + registry.push('tdpRankingButton', 'openTourdino', function () { + return System.import('./InitTourdino'); + }, { + cssClass: 'fa-calculator', + factory: 'initTourdino', + title: 'Start Touring' + }); + + // generator-phovea:end + +} From 964d4ea422bbc31843ed43d301340576d7ae7ede Mon Sep 17 00:00:00 2001 From: rumersdorfer <45141967+rumersdorfer@users.noreply.github.com> Date: Thu, 12 Dec 2019 13:05:09 +0100 Subject: [PATCH 009/147] Add TS compiler flags for i18n to tsconfig.json --- tsconfig.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 0d8c52d..2772026 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,9 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "downlevelIteration": true + "downlevelIteration": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true }, "include": [ "src/**/*.ts", From 4ebe39b6ba80f2c8e6cfc3a3d1cb1260e6919c5f Mon Sep 17 00:00:00 2001 From: oltionchampari Date: Tue, 17 Dec 2019 12:40:09 +0100 Subject: [PATCH 010/147] extarcted and refactored code from tdp_core Caleydo/tourdino#11 --- phovea.js | 16 ---------------- src/phovea.ts | 12 +++++------- 2 files changed, 5 insertions(+), 23 deletions(-) delete mode 100644 phovea.js diff --git a/phovea.js b/phovea.js deleted file mode 100644 index 62a530e..0000000 --- a/phovea.js +++ /dev/null @@ -1,16 +0,0 @@ -/* ***************************************************************************** - * Caleydo - Visualization for Molecular Biology - http://caleydo.org - * Copyright (c) The Caleydo Team. All rights reserved. - * Licensed under the new BSD license, available at http://caleydo.org/license - **************************************************************************** */ - -//register all extensions in the registry following the given pattern -module.exports = function(registry) { - /// #if include('extension-type', 'extension-id') - //registry.push('extension-type', 'extension-id', function() { return import('./src/extension_impl'); }, {}); - /// #endif - // generator-phovea:begin - - // generator-phovea:end -}; - diff --git a/src/phovea.ts b/src/phovea.ts index 28cbb47..30b00a3 100644 --- a/src/phovea.ts +++ b/src/phovea.ts @@ -11,14 +11,12 @@ export default function (registry: IRegistry) { //registry.push('extension-type', 'extension-id', function() { return import('./extension_impl'); }, {}); // generator-phovea:begin - registry.push('tdpRankingButton', 'openTourdino', function () { - return System.import('./InitTourdino'); + registry.push('tdpLineupPanelTab', 'openTourdino', function () { + return System.import('./Tourdino'); }, { - cssClass: 'fa-calculator', - factory: 'initTourdino', - title: 'Start Touring' - }); - + cssClass: 'fa-calculator', + title: 'Start Touring', + }); // generator-phovea:end } From 5d357dde56753b1361cecc76846e23b5a661d15c Mon Sep 17 00:00:00 2001 From: oltionchampari Date: Tue, 17 Dec 2019 12:40:55 +0100 Subject: [PATCH 011/147] refactored TouringPanel Caleydo/tourdino#11 --- package.json | 3 +- src/InitTourdino.ts | 6 - src/RankingAdapter.ts | 281 ++++++ src/TouringPanel.ts | 149 ++++ src/phovea.ts | 6 +- src/styles/_tourdino.scss | 714 +++++++++++++++ src/tasks/ColumnComparison.html | 38 + src/tasks/RowComparison.html | 46 + src/tasks/Tasks.ts | 1440 +++++++++++++++++++++++++++++++ src/tasks/colCmp.png | Bin 0 -> 625 bytes src/tasks/rowCmp.png | Bin 0 -> 664 bytes 11 files changed, 2673 insertions(+), 10 deletions(-) delete mode 100644 src/InitTourdino.ts create mode 100644 src/RankingAdapter.ts create mode 100644 src/TouringPanel.ts create mode 100644 src/styles/_tourdino.scss create mode 100644 src/tasks/ColumnComparison.html create mode 100644 src/tasks/RowComparison.html create mode 100644 src/tasks/Tasks.ts create mode 100644 src/tasks/colCmp.png create mode 100644 src/tasks/rowCmp.png diff --git a/package.json b/package.json index 159dfb7..bc4e56a 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "d3.parsets": "git+https://github.com/jasondavies/d3-parsets.git#v1.2.4", "d3-grubert-boxplot": "gist:366430a0ac8e6e55ce09b040b5b493a6", "jStat": "1.7.1", - "phovea_core": "github:phovea/phovea_core#develop" + "phovea_core": "github:phovea/phovea_core#develop", + "xxhashjs": "^0.2.2" } } diff --git a/src/InitTourdino.ts b/src/InitTourdino.ts deleted file mode 100644 index 098a7fa..0000000 --- a/src/InitTourdino.ts +++ /dev/null @@ -1,6 +0,0 @@ - - - -export default function initTourdino() { - -} diff --git a/src/RankingAdapter.ts b/src/RankingAdapter.ts new file mode 100644 index 0000000..a4277b8 --- /dev/null +++ b/src/RankingAdapter.ts @@ -0,0 +1,281 @@ +import {LocalDataProvider, IColumnDesc, ICategory, Column, Ranking, IDataRow} from 'lineupjs'; +import {isProxyAccessor} from 'tdp_core/src/lineup/internal/utils'; +import {IServerColumn} from 'tdp_core/src/rest'; + + +export interface IAttributeCategory extends ICategory { + attribute: IServerColumn; +} + +export class RankingAdapter { + static readonly RANK_COLUMN_ID = 'rank'; + static readonly SELECTION_COLUMN_ID = 'selection'; + static readonly GROUP_COLUMN_ID = 'group_hierarchy'; + + getRowsWithCategory(attrCategory: IAttributeCategory): number[] { + const indices = []; + + const attrData = this.getAttributeDataDisplayed(attrCategory.attribute.column); + for (const [rowIndex, rowData] of attrData.entries()) { + if (rowData === attrCategory.name) { + indices.push(rowIndex); + } + } + return indices; + } + + constructor(protected readonly provider: LocalDataProvider, private rankingIndex = 0) {} + + public getProvider(): LocalDataProvider { + return this.provider; + } + + + private getScoreColumns() { + return this.getDisplayedAttributes().filter((attr) => (attr.desc as any)._score); + } + + + private oldOrder: Array = new Array(); + private oldSelection: Array = new Array(); + private oldAttributes: Array = new Array(); + private data: Array; + + /** + * Return an array of displayed items, with their id and data (including selection status and rank). + * Data Template: + * [{ + * _id: 123, + * rank: 0, + * selection: 'Selected, + * attr1: 3.14159 + * }, + * ... + * ] + */ + public getItemsDisplayed(sort = true): Array { + const allItems = this.getItems(); + // get currently displayed data + return this.getItemOrder().map((rowId) => allItems[rowId]); + } + + + public getItems(): Array { + // if the attributes are the same, we can reuse the data array + // if the selection + + // TODO events may be better? + const sameAttr = this.oldAttributes.length === this.getDisplayedAttributes().length && this.oldAttributes.filter((attr) => /*note the negation*/ !this.getDisplayedAttributes().some((attr2) => attr2.desc.label === attr.desc.label)).length === 0; + const sameSel = this.oldSelection.length === this.getSelectionUnsorted().length && this.oldSelection.every((val, i) => this.getSelectionUnsorted()[i] === val); + const sameOrder = this.oldOrder.length === this.getItemOrder().length && this.oldOrder.every((val, i) => this.getItemOrder()[i] === val); + + if (sameAttr && sameSel && sameOrder) { + // NOOP + // attributes have to be the same (added / remvoed columns) + // selection has to be the same TODO just updated selection data + // item order has to be the same (i.e. the same items order in the same way) TODO just update the rank, the filtering is done in getItemsDisplayed + + // console.log('reuse the data array') + } else { + // console.log('update the data array'); + // refresh the data array + this.data = null; + this.oldAttributes = this.getDisplayedAttributes(); + + const databaseData = new Array(); + + const scoreCols = this.getScoreColumns(); + const scoresData = [].concat(...scoreCols.map((col) => this.getScoreData(col.desc))); + + this.oldOrder = this.getItemOrder(); + this.oldSelection = this.getSelectionUnsorted(); + + this.provider.data.forEach((item, i) => { + const index = this.oldOrder.indexOf(i); + item[RankingAdapter.RANK_COLUMN_ID] = index >= 0 ? index : Number.NaN; //NaN if not found + + // include wether the row is selected + item[RankingAdapter.SELECTION_COLUMN_ID] = this.oldSelection.includes(i) ? 'Selected' : 'Unselected'; // TODO compare perfomance with assiging all Unselected and then only set those from the selection array + const groupIndex = this.getRanking().getGroups().findIndex((grp) => grp.order.indexOf(i) >= 0); + const groupName = groupIndex === -1 ? 'Unknown' : this.getRanking().getGroups()[groupIndex].name; + item[RankingAdapter.GROUP_COLUMN_ID] = groupName; // index of group = category name, find index by looking up i. -1 if not found + databaseData.push(item); + }); + + // merge score and database data + this.data = [...databaseData.concat(scoresData) + .reduce((map, curr) => { + if (!map.has(curr.id)) { + map.set(curr.id, {}); //include id in map if not already part of it, initialize with empty object + } + + const item = map.get(curr.id); // get stored data for this id + + Object.entries(curr).forEach(([k, v]) => item[k] = v); // add the content of the current array item to the data already stored in the map's entry (overwrites if there are the same properties in databaseData and scoreColumn) + + return map; + }, new Map()).values()]; // give map as input and return it's value + } + + return this.data; + } + + /** + * Returns an array of indices for the providers data array + */ + private getItemOrder() { + // order is always defined for groups (rows (data) only if there is a grouping) + return [].concat(...this.getRanking().getGroups().map((grp) => grp.order)); // Map groups to order arrays and concat those + + } + + public getDisplayedIds() { + const items = this.provider.data; + return this.getItemOrder().map((i) => items[i].id); + } + + + public getDisplayedAttributes() { + return this.getRanking().children; + } + + /** + * Return an array of displayed items, with their id and rank. + * Data Template: + * [{ + * _id: 123, + * rank: 0 + * }, + * ... + * ] + */ + public getItemRanks() { + let i = 0; + return this.getItemOrder().map((id) => ({_id: id, rank: i++})); + } + + public getRanking(): Ranking { + return this.provider.getRankings()[this.rankingIndex]; + } + + /** + * Contains selection, rank and score data. + */ + public getGroupedData() { + // console.time('get data (getGroupedData) time') + const data = this.getItems(); + // console.timeEnd('get data (getGroupedData) time') + const groups = []; + + for (const grp of this.getRanking().getGroups()) { + groups.push({ + name: grp.name, + label: grp.name, + color: grp.color, + rows: grp.order.map((index) => data[index]).filter((item) => item !== undefined) + }); + } + return groups; + } + + + /** + * returns the data for the given attribute + * @param attributeId column property of the column description + */ + public getAttributeDataDisplayed(attributeId: string) { // use lower case string + const data = this.getItemsDisplayed(); + return data.map((row) => row[attributeId]); + } + + /** + * returns the categories of the given attribute + * @param attributeId column property of the column description + */ + public getAttributeCategoriesDisplayed(attributeId: string) { + return new Set(this.getAttributeDataDisplayed(attributeId)); + } + + /** + * Returns the index of the selected items in the provider data array + */ + public getSelectionUnsorted() { + return this.provider.getSelection(); + } + + /** + * Returns the '_id' of the selected items + */ + public getSelection() { + // we have the indices for the unsorted data array by this.getSelectionUnsorted() { + // and we have an array of indices to sort the data array by this.getItemOrder(); + // --> the position of the indices from the selection in the order array is the new index + const orderedIndices = this.getItemOrder(); + const unorderedSelectionINdices = this.getSelectionUnsorted(); + const orderedSelectionIndices = unorderedSelectionINdices.map((unorderedIndex) => orderedIndices.findIndex((orderedIndex) => orderedIndex === unorderedIndex)); + const sortedOreredSelectionIndices = orderedSelectionIndices.sort((a, b) => a - b); + return sortedOreredSelectionIndices; + } + + public getScoreData(desc: IColumnDesc | any) { + const accessor = desc.accessor; + const ids = this.getDisplayedIds(); + const data = []; + + if (desc.column && isProxyAccessor(accessor)) { + for (const id of ids) { + const dataEntry = {id}; + dataEntry[desc.column] = accessor({v: {id}, i: null} as IDataRow); // i is not used by the accessor function + data.push(dataEntry); + } + } + return data; + } + + /** + * Generate a Attribute description that represents the current selection + */ + public getSelectionDesc() { + const selCategories = new Array(); + const numberOfRows = this.getItemOrder().length; // get length of groups and sum them up + if (this.getSelectionUnsorted().length > 0) { + selCategories.push({name: 'Selected', label: 'Selected', value: 0, color: '#1f77b4', }); + } // else: none selected + + if (this.getSelectionUnsorted().length < numberOfRows) { + selCategories.push({name: 'Unselected', label: 'Unselected', value: 1, color: '#ff7f0e', }); + } // else: all selected + + return { + categories: selCategories, + label: 'Selection', + type: 'categorical', + column: RankingAdapter.SELECTION_COLUMN_ID + }; + } + + /** + * Generate an attribute description that represents the current grouping hierarchy + */ + public getGroupDesc() { + return { + categories: this.getRanking().getGroups().map((group, index) => ({ + name: group.name, + label: group.name, + color: group.color, + value: index + })), // if not grouped, there is only one group ('Default') + label: 'Groups', + type: 'categorical', + column: RankingAdapter.GROUP_COLUMN_ID + }; + } + + public getRankDesc() { + return { + label: 'Rank', + type: 'number', + column: RankingAdapter.RANK_COLUMN_ID + }; + } +} diff --git a/src/TouringPanel.ts b/src/TouringPanel.ts new file mode 100644 index 0000000..fdf028c --- /dev/null +++ b/src/TouringPanel.ts @@ -0,0 +1,149 @@ +import {RankingAdapter} from './RankingAdapter'; +import * as d3 from 'd3'; +import {tasks as Tasks, ATouringTask} from './tasks/Tasks'; +import {LocalDataProvider} from 'lineupjs'; +import {IARankingViewOptions} from 'tdp_core/src/lineup'; +import {IPluginDesc} from '../../phovea_core/src/plugin'; + + +const touringTemplate = ` +
+
+
+ +
+
+
+ +
`; + + +class TouringPanel { + + private columnOverview: HTMLElement; searchbox: HTMLElement; itemCounter: HTMLElement; // default sidepanel elements + private ranking: RankingAdapter; + private currentTask: ATouringTask; + + constructor(private readonly node: HTMLElement, protected readonly provider: LocalDataProvider, protected readonly desc: IPluginDesc) { + this.node.classList.add('touring'); + this.node.innerHTML = touringTemplate; + + this.init(); + } + + + private init() { + this.ranking = new RankingAdapter(this.provider); + + // this.columnOverview = this.node.querySelector('main')!; // ! = bang operator --> can not be null + // this.searchbox = this.node.querySelector('.lu-adder')!; + // this.itemCounter = this.node.querySelector('.lu-stats')!; + + // const buttons = this.node.querySelector('section'); + // buttons.appendChild(this.createMarkup('Start Touring', 'touring fa fa-calculator', () => { + // this.toggleTouring(); + // })); + + this.initTasks(); + this.insertTasks(); + this.addEventListeners(); + } + private initTasks() { + for (const task of Tasks) { + task.init(this.ranking, d3.select(this.node).select('div.output').node() as HTMLElement); + } + } + + private insertTasks() { + // For each Task, create a button + // Link tasks with buttons + const taskSelectForm = d3.select(this.node).select('.input .type .form-group'); + const taskButtons = taskSelectForm.selectAll('.btn-wrapper').data(Tasks, (task) => task.id); + + taskButtons.enter() //enter: add a button for each task + .append('div').attr('class', `btn-wrapper col-sm-${Math.max(Math.floor(8 / Tasks.length), 1)}`) + .append('button').attr('class', 'task-btn btn btn-default btn-block') + .classed('active', (d, i) => i === 0) // Activate first task + .html((d) => `${d.label}`); + + + // update: nothing to do + taskButtons.exit().remove(); // exit: remove tasks no longer displayed + taskButtons.order(); // order domelements as in the array + } + + private addEventListeners() { + // Click a different task + d3.select(this.node).selectAll('button.task-btn').on('click', (task) => { + const taskButtons = d3.select(this.node).selectAll('button.task-btn'); + + if (this.currentTask && this.currentTask.id !== task.id) { // task changed + taskButtons.classed('active', (d) => d.id === task.id); + + this.currentTask.hide(); // hide old task + this.updateOutput(); // will show new task + } + }); + } + + public async updateOutput() { + if (!this.node.hidden) { + await setTimeout(() => this.updateTask(), 0); + } else { + console.log('Touring Panel is hidden, skip update.'); + } + } + + private updateTask() { + this.currentTask = d3.select(this.node).select('button.task-btn.active').datum() as ATouringTask; + this.currentTask.show(); + } + + + private toggleTouring(hide?: boolean) { + if (!this.node) { + return; // the elements are undefined + } + + if (hide === undefined) { + hide = !this.node.hidden; // if not hidden -> hide + } + // hide touring -> not hide normal content + this.searchbox.hidden = !hide; + this.itemCounter.hidden = !hide; + this.columnOverview.hidden = !hide; + + this.node.hidden = hide; + + if (!hide) { + console.log('Open Touring Panel'); + this.node.style.flex = '0.33 0.33 auto'; // lineup is 1 1 auto + this.collapse = false; //if touring is displayed, ensure the panel is visible + this.updateOutput(); //Will also update output + } else { + this.node.style.flex = null; + this.currentTask.abort(); // abort workers + } + + const button = d3.select(this.node).select('.lu-side-panel button.touring'); + button.classed('active', !hide); + } + + get collapse() { + return this.node.classList.contains('collapsed'); + } + + set collapse(value: boolean) { + this.node.classList.toggle('collapsed', value); + if (value) { + // panel gets collapsed, Touring is hidden to ensure the default look when the panel is expanded again. + this.toggleTouring(true); + } + } +} + + +export default function create(parent: HTMLElement, provider: LocalDataProvider, desc: IPluginDesc): void { + // tslint:disable-next-line:no-unused-expression + new TouringPanel(parent, provider, desc); +} diff --git a/src/phovea.ts b/src/phovea.ts index 30b00a3..68b0569 100644 --- a/src/phovea.ts +++ b/src/phovea.ts @@ -4,18 +4,18 @@ * Licensed under the new BSD license, available at http://caleydo.org/license **************************************************************************** */ import {IRegistry} from 'phovea_core/src/plugin'; -import parseRange from 'phovea_core/src/range/parser'; -import ActionNode from 'phovea_core/src/provenance/ActionNode'; + export default function (registry: IRegistry) { //registry.push('extension-type', 'extension-id', function() { return import('./extension_impl'); }, {}); // generator-phovea:begin registry.push('tdpLineupPanelTab', 'openTourdino', function () { - return System.import('./Tourdino'); + return System.import('./TouringPanel'); }, { cssClass: 'fa-calculator', title: 'Start Touring', + tabWidth: '40em' }); // generator-phovea:end diff --git a/src/styles/_tourdino.scss b/src/styles/_tourdino.scss new file mode 100644 index 0000000..ab59c1e --- /dev/null +++ b/src/styles/_tourdino.scss @@ -0,0 +1,714 @@ + + +$active-color: #eaeaea; +$dark-font-color: #333; +$highlight-color: rgb(212, 212, 212); +$highlight-color-1: rgb(207, 230, 230); //blueish +$highlight-color-2: rgb(207, 230, 207); //greenish + +[hidden] { + display: none !important; +} + + +.touring-highlight-hover { + background-color: $highlight-color !important; +} + +.touring-highlight-hover-1 { + background-color: $highlight-color-1 !important; +} + +.touring-highlight-hover-2 { + background-color: $highlight-color-2 !important; +} + +.touring-highlight-hover-dark { + background-color: darken($highlight-color, 10%) !important; +} + +.touring-highlight-hover-border { + border: 1px black dashed !important; +} + +.select2-dropdown { + //dropdown is seperatly attached to DOM + $hover-background-color: darken($color: #ddd, $amount: 20%); // #ddd is default color for selected elements + $hover-color: white; + + .select2-results>.select2-results__options { + max-height: 25vh; //50% of browser window + } + + .select2-results__option { + //changes the padding of every option in the dropdown + padding: 2px 6px; + } + + .select2-results__option--highlighted { + //hover effect for non nested options (not in optgroup) + background-color: $hover-background-color; // do my own hover styling + color: $hover-color; + } + + .select2-results__options--nested { + + //hover effect for options in optgroups + .select2-results__option--highlighted { + // inside the dropdown, which is not inside the .select2 + background-color: transparent; // default hover styling is done by adding a class + color: $dark-font-color; + + &[aria-selected=true] { + //reset + background-color: #ddd; + color: $dark-font-color; + } + } + + .select2-results__option:hover { + background-color: $hover-background-color; // do my own hover styling + color: $hover-color; + } + } + + .select2-results__group { + cursor: pointer; // let it look clickable + + &:hover { + background-color: $hover-background-color; + color: $hover-color; + } + } +} + +.select2 { + + // the input that is always visible + .select2-selection__choice { + background-color: $active-color; + + span { + color: $dark-font-color; + } + } +} + +button { + &.touring.active::before { + color: #ffa500; // use highlight color of column headers + border: none; + } + + img { + height: 1.2em; + width: auto; + padding-right: 1em; + } +} + +.tooltip.measure { + background-color: #C0C0C0; + position: absolute; + padding: 5px; + opacity: 1; +} + +div.touring { + flex: 1 0 auto; // sidebar uses flexbox + + // the touring div itself also uses a flexbox for the children: + display: flex; + flex-direction: column; + + padding-top: 1em; + padding-left: 0.5em; + + .task-btn.active { + background-color: $active-color; + } + + .removeMiniVis-btn { + float: right !important; + padding: 0px 7px !important; + margin: 2px 1px !important; + } + + + label.control-label { + padding: 0px; + padding-left: 15px; //bootstrap default + } + + + .input { + // task switcher & task input + overflow: hidden; // from the bootstrap stuff we get some overflow which introduces scrollbars --> hide it + } + + .output { + position: relative; + flex: 1; + + $wide-col-padding: 0.5em; + $narrow-col-padding: 0.2em; + $col-min-size: 4.5ch; + $col-max-size: 15em; + + /*.select2-dropdown { //dropdown is seperatly attached to DOM + $hover-background-color: darken($color: #ddd, $amount: 20%); // #ddd is default color for selected elements + $hover-color: white; + .select2-results>.select2-results__options { + max-height: 25vh; //50% of browser window + } + .select2-results__option { //changes the padding of every option in the dropdown + padding: 2px 6px; + } + .select2-results__option--highlighted { //hover effect for non nested options (not in optgroup) + background-color: $hover-background-color; // do my own hover styling + color: $hover-color; + } + .select2-results__options--nested { //hover effect for options in optgroups + .select2-results__option--highlighted { // inside the dropdown, which is not inside the .select2 + background-color: transparent; // default hover styling is done by adding a class + color: $dark-font-color; + &[aria-selected=true] { //reset + background-color: #ddd; + color: $dark-font-color; + } + } + .select2-results__option:hover { + background-color: $hover-background-color; // do my own hover styling + color: $hover-color; + } + } + .select2-results__group { + cursor: pointer; // let it look clickable + &:hover { + background-color: $hover-background-color; + color: $hover-color; + } + } + } + .select2 { // the input that is always visible + .select2-selection__choice { + background-color: $active-color; + span { + color: $dark-font-color; + } + } + }*/ + + .task { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: auto; + + .table-container { + position: relative; + min-height: 100px; + border-top: 1px #AAA solid; + + header { + z-index: -1; // above td elements + background-color: white; + + h1 { + font-size: 1.2em; + } + + p { + font-weight: normal; + } + } + } + } + + a:hover, + a:focus { + color: #1f77b4; + text-decoration: none; + } + + a[aria-expanded="false"]::before { + content: "\f18e "; + font-family: FontAwesome; + color: $dark-font-color; + font-size: 0.9em; + } + + a[aria-expanded="true"]::before { + content: "\f01a "; + font-family: FontAwesome; + color: $dark-font-color; + font-size: 0.9em; + } + + .measure-legend { + margin-top: 1em; + margin-bottom: 0.5em; + + svg { + vertical-align: middle; + } + } + + table { + $table-border-color: white; + + table-layout: fixed; // The table and column widths are set by the widths of table and col or by the width of the first row of cells. Cells in other rows do not affect column widths. + + tbody.bottom-margin { + border-bottom: 0.5em solid $table-border-color; // table's border spacing applies to all rows and tbody has no margin + } + + th { + min-width: $col-min-size; + max-width: $col-min-size; + position: sticky; + top: 0; + z-index: 2; + + &:not(.head) { + // cells of row labels in header + min-width: $col-max-size*0.7; //flexible from min to max + max-width: $col-max-size; + padding: 0 $wide-col-padding; + background-color: white; + } + + &.head { + + &.rotate { + // adapted from https://css-tricks.com/rotated-table-column-headers/ + height: 120px; + + svg { + position: absolute; + top: 0; + left: 0; + + polygon { + fill: white; + } + } + + >div { + transform: translate(12px, 48px) //3. Back to the correct position + rotate(-45deg) //2. Rotate to correct angle + skew(45deg, 0deg); //1. skew the div, so that the background is correct (text content is unskewed below) + width: 30px; + + >span { + position: relative; + display: inline-block; // to set a width + min-width: 165px; + max-width: 165px; + box-sizing: border-box; // i dont want to mess with the padding + + padding-bottom: 0px; + padding-top: 1px; + line-height: 1.25; //defines height of headers + border-bottom: 2px solid $table-border-color; //make space between headers non-transparent (removed for last header below) + background-color: white; + + >span { + transform: skew(-45deg, 0deg); //unskew the text + + max-width: 100%; // just as wide as the parent + display: inline-block; // to make width & string truncation work + vertical-align: bottom; // at the bottom of the parent + overflow: hidden; // important to stop text at right border + white-space: nowrap; // no line breaks + text-overflow: ellipsis; + font-weight: normal; + + box-sizing: border-box; // i dont want to mess with the padding + padding: 0.1em 0.5em 0.1em 1.5em; + } + } + } + + &:last-child>div>span { + border-bottom: none; // no need for a border on last header + } + } + } + + + } + + .cross-selection, + .cross-selection::before, + .cross-selection span::before { + background-color: $highlight-color !important; // important because to overwrite the htmml inline color definition + color: black !important; + } + + td { + border: 1px solid $table-border-color; + border-collapse: collapse; + position: relative; + background: white; + background-clip: padding-box; // fix firefox bug: td background stretches over tbody border + + &[rowspan] { + vertical-align: text-top; // move text of multi-row cells to the top + } + + &:not(.score) { + // row labels + min-width: $col-max-size*0.7; + max-width: $col-max-size; + padding: 0 $wide-col-padding; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.score { + // no min-width here because than the cell gets larger on hover (wich makes the text bolder) + max-width: $col-min-size; + text-align: center; // padding workaround, because we make the font bolder on hover and can use the whitespace of the cell this way + overflow: hidden; + text-overflow: clip; // cells are so small, the ... would take to much space + white-space: nowrap; + + // border-style: solid; + // border-color: $table-border-color; + // border-width: 0px; // overwrite the default + // border-right-width: 1px; // just on the right + } + + &.action, + &.score { + cursor: pointer; + vertical-align: bottom; // Actually, it should be middle, but bottom looks more centered ¯\_(ツ)_/¯ + + &:hover, + &:focus { + // = td.action:hover + background-color: darken($highlight-color, 25%) !important; // important because to overwrite the htmml inline color definition + color: black !important; + font-weight: bolder; + } + + &.selectedCell { + background-color: #fba74d !important; // important because to overwrite the htmml inline color definition + color: black !important; + font-weight: bolder; + } + } + + span.circle { + display: inline-block; + width: 0.8em; + height: 0.8em; + vertical-align: middle; + background-color: $dark-font-color; + border: 1px solid transparent; + border-radius: 100%; + } + } + } + + .detailVis { + border-top: #AAA 1px solid; + padding: 0.5em 0; + + .detailDiv { + font-weight: bold; + color: $dark-font-color; + + span { + font-weight: normal; + + &.detail-label { + font-weight: bold; + } + } + } + } + + // ------ parallel sets with D3 + .dimension tspan.name { + font-size: 1.2em; + fill: $dark-font-color; + font-weight: bold; + } + + .dimension tspan.sort { + fill: #000; + cursor: pointer; + opacity: 0; + } + + .dimension tspan.sort:hover { + fill: $dark-font-color; + } + + .dimension:hover tspan.name { + fill: #000; + } + + .dimension:hover tspan.sort { + opacity: 1; + } + + .dimension line { + stroke: #000; + } + + .dimension rect { + stroke: none; + fill-opacity: 0; + } + + .dimension>rect, + .category-background { + fill: #fff; + } + + .dimension>rect { + display: none; + } + + // .category:hover rect { fill-opacity: .3; } + // .dimension:hover > rect { fill-opacity: .3; } + svg .parSets .ribbon path { + stroke-opacity: 0; + fill-opacity: .5; + } + + //.ribbon path.active { fill-opacity: .9; } + .ribbon-mouse path { + fill-opacity: 0; + stroke: none; + } + + .ribbon path.selected { + fill-opacity: .9 !important; + } + + //new + .category rect.selected { + fill-opacity: .3; + } + + //new + .dimension:hover>rect.selcted { + fill-opacity: .3; + } + + //new + + // predefined colours + .category-0 { + fill: #1f77b4; + stroke: #1f77b4; + } + + .category-1 { + fill: #ff7f0e; + stroke: #ff7f0e; + } + + .category-2 { + fill: #2ca02c; + stroke: #2ca02c; + } + + .category-3 { + fill: #d62728; + stroke: #d62728; + } + + .category-4 { + fill: #9467bd; + stroke: #9467bd; + } + + .category-5 { + fill: #8c564b; + stroke: #8c564b; + } + + .category-6 { + fill: #e377c2; + stroke: #e377c2; + } + + .category-7 { + fill: #7f7f7f; + stroke: #7f7f7f; + } + + .category-8 { + fill: #bcbd22; + stroke: #bcbd22; + } + + .category-9 { + fill: #17becf; + stroke: #17becf; + } + + .category-10 { + fill: #aec7e8; + stroke: #aec7e8; + } + + .category-11 { + fill: #ffbb78; + stroke: #ffbb78; + } + + .category-12 { + fill: #98df8a; + stroke: #98df8a; + } + + .category-13 { + fill: #ff9896; + stroke: #ff9896; + } + + .category-14 { + fill: #c5b0d5; + stroke: #c5b0d5; + } + + .category-15 { + fill: #c49c94; + stroke: #c49c94; + } + + .category-16 { + fill: #f7b6d2; + stroke: #f7b6d2; + } + + .category-17 { + fill: #c7c7c7; + stroke: #c7c7c7; + } + + .category-18 { + fill: #dbdb8d; + stroke: #dbdb8d; + } + + .category-19 { + fill: #9edae5; + stroke: #9edae5; + } + + .category-gray { + fill: #808080 !important; + stroke: #808080 !important; + } + + .category-selected { + fill: #fba74d !important; + stroke: #fba74d !important; + } + + + + // ------ boxplot with D3 + g.box-element text { + stroke: none; + fill: black; + } + + g.box-element { + rect.box { + stroke: black; + stroke-width: 1; + stroke-opacity: 0; + fill-opacity: .5; + } + + line.center, + line.median, + line.whisker { + stroke-opacity: 0.2; + } + } + + g.box-element.selected { + rect.box { + stroke: black; + stroke-width: 1; + stroke-opacity: 1; + fill-opacity: 1; + } + + line.center, + line.median, + line.whisker { + stroke-opacity: 1; + } + } + + // ------ scattter plot with D3 + g.scatterplot circle.datapoint { + stroke: none; + opacity: 1; + fill: black; + } + + g.scatterplot circle.datapoint:hover { + opacity: 1; + fill: #fba74d !important; + } + + g.scatterplot g.regression { + + path, + line { + stroke: rgb(194, 194, 194) !important; + stroke-width: 2 !important; + } + } + + // // ------ line chart with D3 + g.linechart path.dataline { + fill-opacity: 0; + fill: #fba74d; + stroke-width: 2; + stroke-linejoin: "round"; + stroke-linecap: "round"; + } + + g.linechart g.baseline { + fill: "none"; + stroke-width: 1; + stroke-linejoin: "round"; + stroke-linecap: "round"; + stroke: black; + } + + g.linechart path.dataline:hover { + fill-opacity: 0.4; + } + } + + [data-type]::before { + display: inline-block; // so that the width property works + width: 1.2em; //to have some space between icon and the text in the td + font-family: lu-font; // some icon font + color: #999; // #515151 is to dark + } + + [data-type='categorical']::before { + content: "\e810"; + } + + [data-type='number']::before { + content: "\e811"; + } +} diff --git a/src/tasks/ColumnComparison.html b/src/tasks/ColumnComparison.html new file mode 100644 index 0000000..6f95767 --- /dev/null +++ b/src/tasks/ColumnComparison.html @@ -0,0 +1,38 @@ +
+
+ +
+ +
+
+
+
+ vs. +
+
+
+
+ +
+
+
+ +
+ + + + + + + +
+
+
+
diff --git a/src/tasks/RowComparison.html b/src/tasks/RowComparison.html new file mode 100644 index 0000000..51f2c50 --- /dev/null +++ b/src/tasks/RowComparison.html @@ -0,0 +1,46 @@ +
+
+ +
+ +
+
+
+
+ vs. +
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+ + + + + + + + +
+
+ +
+
diff --git a/src/tasks/Tasks.ts b/src/tasks/Tasks.ts new file mode 100644 index 0000000..66afaa3 --- /dev/null +++ b/src/tasks/Tasks.ts @@ -0,0 +1,1440 @@ +import {IServerColumn} from 'tdp_core/src/rest'; +import {RankingAdapter, IAttributeCategory} from '../RankingAdapter'; +import {MethodManager, IMeasureResult, ISimilarityMeasure, IMeasureVisualization, ISetParameters, Type, SCOPE, WorkerManager} from 'tourdino'; +import {IColumnDesc, ICategory, Column, CategoricalColumn, ICategoricalColumnDesc, LocalDataProvider} from 'lineupjs'; +import colCmpHtml from 'html-loader!./ColumnComparison.html'; // webpack imports html to variable +import colCmpIcon from './colCmp.png'; +import rowCmpHtml from 'html-loader!./RowComparison.html'; // webpack imports html to variable +import rowCmpIcon from './rowCmp.png'; +import * as $ from 'jquery'; +import * as d3 from 'd3'; +import * as XXH from 'xxhashjs'; +import {isNumber} from 'util'; + +export const tasks = new Array(); +export function TaskDecorator() { + return function (target: {new(): ATouringTask}) { // only instantiable subtypes of ATouringTask can be passed. + tasks.push(new target()); + tasks.sort((a, b) => b.order - a.order); //sort descending + }; +} + + +// SOURCE: https://stackoverflow.com/a/51592360/2549748 +/** + * Deep copy function for TypeScript. + * @param T Generic type of target/copied value. + * @param target Target value to be copied. + * @see Source project, ts-deepcopy https://github.com/ykdr2017/ts-deepcopy + * @see Code pen https://codepen.io/erikvullings/pen/ejyBYg + */ +const deepCopy = (target: T): T => { + if (target === null) { + return target; + } + if (target instanceof Date) { + return new Date(target.getTime()) as any; + } + if (target instanceof Array) { + const cp = [] as any[]; + (target as any[]).forEach((v) => {cp.push(v);}); + return cp.map((n: any) => deepCopy(n)) as any; + } + if (typeof target === 'object' && target !== {}) { + const cp = {...(target as {[key: string]: any})} as {[key: string]: any}; + Object.keys(cp).forEach((k) => { + cp[k] = deepCopy(cp[k]); + }); + return cp as T; + } + return target; +}; + + +export interface ITouringTask { + id: string; + label: string; + + scope: SCOPE; // Does the Task use attributes or subsets of them? +} + + +export abstract class ATouringTask implements ITouringTask { + public static EVENTTYPE = '.touringTask'; + public id: string; + public label: string; + public node: HTMLElement; + public icon: string; + + public scope: SCOPE; + + public order: number = 0; // order of the tasks, the higher the more important + + ranking: RankingAdapter; + + private hoverTimerId: number; + + public abort() { + WorkerManager.terminateAll(); + } + + public init(ranking: RankingAdapter, node: HTMLElement) { + this.ranking = ranking; + this.node = d3.select(node).append('div').attr('class', `task ${this.id}`).node() as HTMLElement; + this.hide(); //hide initially + this.initContent(); + } + + public show() { + d3.select(this.node).attr('hidden', null); + this.addEventListeners(); + this.update(true); + } + + public hide() { + d3.select(this.node).attr('hidden', true); + this.removeEventListeners(); + } + + initContent() { + // add legend for the p-values + this.createLegend(d3.select(this.node).select('div.legend')); + } + + createSelect2(): void { + // make selectors functional + const updateTable = this.updateTable.bind(this); + d3.select(this.node).selectAll('select').each(function () { // Convert to select2 + const select2 = this; + //console.log('convert', select2.name); + const $select2 = $(select2).select2({width: '100%', allowClear: true, closeOnSelect: false, placeholder: 'Select one or more columns. '}); + $select2.on('select2:select select2:unselect', updateTable); + $select2.on('select2:open', () => { // elements are created when select2 is opened, and destroyed when closed + setTimeout(() => { // setTimeout so this shit actually works (mouseover listener not registered if done immidiatly) + const optgroups = d3.selectAll('.select2-results__group'); + optgroups.on('click', function () { + const hoverGrp = d3.select(this).text(); // get text of hovered select2 label + // update html in the actual select html element + const optGroup = d3.select(select2).select(`optgroup[label="${hoverGrp}"]`); //get optgroup of hovered select2 label + const options = optGroup.selectAll('option'); + const newState = !options.filter(':not(:checked)').empty(); // if not all options are selected --> true = select all, deselect if all options are already selected + options.each(function () {(this as HTMLOptionElement).selected = newState;}); // set state of all child options + // update styles in open dropdown + $(this).next().find('li').attr('aria-selected', newState.toString()); // accesability and styling + + $select2.trigger('change').trigger(newState ? 'select2:select' : 'select2:unselect'); // notify select2 of these updates + }); + }, 0); + }); + }); + } + + updateSelect2(): void { + // console.log('update select2'); + this.destroySelect2(); + this.createSelect2(); + } + + destroySelect2(): void { + // check if initialized with class, see: https://select2.org/programmatic-control/methods#checking-if-the-plugin-is-initialized + d3.select(this.node).selectAll('select.select2-hidden-accessible').each(function () { + $(this).select2('destroy'); // reset to standard select element + // unbind events: https://select2.org/programmatic-control/methods#event-unbinding + $(this).off('select2:select'); + $(this).off('select2:unselect'); + }); + } + + updateTableDescription(isTableEmpty: boolean): any { + if (isTableEmpty) { + const text = 'Please specify the data to compare with the select boxes above.'; + d3.select(this.node).select('header').style('width', null).select('p').text(text); + } else { + const text = 'Click on a p-Value in the table for details.'; + d3.select(this.node).select('header').style('width', '13em').select('p').text(text); + } + } + + private addEventListeners() { + // DATA CHANGE LISTENERS + // ----------------------------------------------- + // change in selection + // might cause changes the displayed table / scores + this.ranking.getProvider().on(LocalDataProvider.EVENT_SELECTION_CHANGED + ATouringTask.EVENTTYPE, () => this.update(true)); //fat arrow to preserve scope in called function (this) + + // column of a table was added/removed + // causes changes in the available attributes (b) + // might cause changes the displayed table / scores + this.ranking.getProvider().on(LocalDataProvider.EVENT_ADD_COLUMN + ATouringTask.EVENTTYPE, () => { /*console.log('added column');*/ setTimeout(this.update, 100, false);}); + this.ranking.getProvider().on(LocalDataProvider.EVENT_REMOVE_COLUMN + ATouringTask.EVENTTYPE, () => { /*console.log('rem column');*/ this.update(false);}); + + // for filter changes and grouping changes + // After the number of items has changed, the score change aswell + // If the grouping changes, the "Group" attribute and possibly the table has to be changed + this.ranking.getProvider().on(LocalDataProvider.EVENT_ORDER_CHANGED + ATouringTask.EVENTTYPE, () => this.update(true)); + } + + private removeEventListeners() { + this.ranking.getProvider().on(LocalDataProvider.EVENT_SELECTION_CHANGED + ATouringTask.EVENTTYPE, null); + this.ranking.getProvider().on(LocalDataProvider.EVENT_ADD_COLUMN + ATouringTask.EVENTTYPE, null); + this.ranking.getProvider().on(LocalDataProvider.EVENT_REMOVE_COLUMN + ATouringTask.EVENTTYPE, null); + this.ranking.getProvider().on(LocalDataProvider.EVENT_ORDER_CHANGED + ATouringTask.EVENTTYPE, null); + } + + public abstract update(forceTableUpdate: boolean): void; + public abstract updateTable(): void; + + + getAttriubuteDescriptions(): IColumnDesc[] { + let descriptions: IColumnDesc[] = this.ranking.getDisplayedAttributes().map((col: Column) => { + const desc: IColumnDesc = deepCopy(col.desc); + if ((col as CategoricalColumn).categories) { + const displayedCategories = this.ranking.getAttributeCategoriesDisplayed((col.desc as IServerColumn).column); + (desc as ICategoricalColumnDesc).categories = deepCopy((col as CategoricalColumn).categories).filter((category) => displayedCategories.has(category.name)); + } + + return desc; + }); + + const validTypes = ['categorical', 'number']; + descriptions = descriptions.filter((desc) => validTypes.includes(desc.type)); // filter attributes by type + const groupDesc = this.ranking.getGroupDesc(); + const reallyGrouped = groupDesc.categories.length > 1; //grouping is only the "default group" + const groupingHierarchy = reallyGrouped && groupDesc.categories.some((cat) => cat.label.indexOf('∩') >= 0); //not grouping hierachy if intersection symbol is not in label (https://github.com/lineupjs/lineupjs/blob/60bffa3b8c665bd7fa28c1ab577ba24dba84913c/src/model/internal.ts#L31) + if (groupingHierarchy) { + descriptions.unshift(groupDesc); + } + descriptions.unshift(this.ranking.getSelectionDesc()); + descriptions.unshift(this.ranking.getRankDesc()); + return descriptions; + } + + toScoreCell(score: IMeasureResult, measure: ISimilarityMeasure, setParameters: ISetParameters, highlightData: IHighlightData[]): IScoreCell { + let color = score2color(score.pValue); + let cellLabel = score.pValue.toFixed(3); + + cellLabel = cellLabel.startsWith('0') ? cellLabel.substring(1) : score.pValue.toFixed(2); // [0,1) --> .123, 1 --> 1.00 + if (score.pValue > 0.1) { + color = { + background: '#ffffff', //white + foreground: '#ffffff', //white + }; + } + if (score.pValue === -1) { + cellLabel = '-'; + color = { + background: '#ffffff', //white + foreground: '#ffffff', //white + }; + } + return { + label: cellLabel, + background: color.background, + foreground: color.foreground, + score, + measure, + setParameters, + highlightData + }; + } + + // creates legend for the p-value + private createLegend(parentElement: d3.Selection) { + const divLegend = parentElement.append('div').classed('measure-legend', true); + + const svgLegendContainer = divLegend.append('svg') + .attr('width', '100%') + .attr('height', 50); + // .attr('viewBox','0 0 100% 35') + // .attr('preserveAspectRatio','xMaxYMin meet'); + + const legendId = Date.now(); + const svgDefs = svgLegendContainer.append('defs').append('linearGradient') + .attr('id', 'pValue-gradLegend-' + legendId); + svgDefs.append('stop') + .attr('offset', '0%') + .attr('stop-color', '#000000'); + // svgDefs.append('stop') + // .attr('offset','50%') + // .attr('stop-color','#F1F1F1'); + svgDefs.append('stop') + .attr('offset', '25%') + .attr('stop-color', '#FFFFFF'); + + let xStart = 0; + const yStart = 0; + const barWidth = 300; + const barHeight = 10; + const space = 5; + const textHeight = 15; + const textWidth = 50; + const tickLength = 5; + const lineWidth = 1; + + xStart = xStart + textWidth; + const svgLegend = svgLegendContainer.append('g'); + const svgLegendLabel = svgLegend.append('g'); + // label + svgLegendLabel.append('text') + .attr('x', xStart) + .attr('y', yStart + barHeight) + .attr('text-anchor', 'end') + .text('p-Value'); + + xStart = xStart + space; + + const svgLegendGroup = svgLegend.append('g'); + // bar + bottom line + svgLegendGroup.append('rect') + .attr('x', xStart).attr('y', yStart) + .attr('width', barWidth) + .attr('height', barHeight) + .style('fill', 'url(#pValue-gradLegend-' + legendId + ')'); + svgLegendGroup.append('line') + .attr('x1', xStart).attr('y1', yStart + barHeight) + .attr('x2', xStart + barWidth).attr('y2', yStart + barHeight) + .style('stroke-width', lineWidth).style('stroke', 'black'); + + // label: 0 + tick + svgLegendGroup.append('text') + .attr('x', xStart).attr('y', yStart + barHeight + textHeight) + .attr('text-anchor', 'middle').text('0'); + svgLegendGroup.append('line') + .attr('x1', xStart).attr('y1', yStart) + .attr('x2', xStart).attr('y2', yStart + barHeight - (lineWidth / 2) + tickLength) + .style('stroke-width', lineWidth / 2).style('stroke', 'black'); + + // label: 0.05 + tick + svgLegendGroup.append('text') + .attr('x', xStart + (barWidth * 0.25)).attr('y', yStart + barHeight + textHeight) + .attr('text-anchor', 'middle').text('0.05'); + svgLegendGroup.append('line') + .attr('x1', xStart + (barWidth * 0.25)).attr('y1', yStart + barHeight - (lineWidth / 2)) + .attr('x2', xStart + (barWidth * 0.25)).attr('y2', yStart + barHeight - (lineWidth / 2) + tickLength) + .style('stroke-width', lineWidth / 2).style('stroke', 'black'); + + // label: 0.05 + tick + svgLegendGroup.append('text') + .attr('x', xStart + (barWidth * 0.5)).attr('y', yStart + barHeight + textHeight) + .attr('text-anchor', 'middle').text('0.1'); + svgLegendGroup.append('line') + .attr('x1', xStart + (barWidth * 0.5)).attr('y1', yStart + barHeight - (lineWidth / 2)) + .attr('x2', xStart + (barWidth * 0.5)).attr('y2', yStart + barHeight - (lineWidth / 2) + tickLength) + .style('stroke-width', lineWidth / 2).style('stroke', 'black'); + + // label: 0.5 + tick + svgLegendGroup.append('text') + .attr('x', xStart + (barWidth * 0.75)).attr('y', yStart + barHeight + textHeight) + .attr('text-anchor', 'middle').text('0.5'); + svgLegendGroup.append('line') + .attr('x1', xStart + (barWidth * 0.75)).attr('y1', yStart + barHeight - (lineWidth / 2)) + .attr('x2', xStart + (barWidth * 0.75)).attr('y2', yStart + barHeight - (lineWidth / 2) + tickLength) + .style('stroke-width', lineWidth / 2).style('stroke', 'black'); + + // label: 1 + tick + svgLegendGroup.append('text') + .attr('x', xStart + barWidth).attr('y', yStart + barHeight + textHeight) + .attr('text-anchor', 'middle').text('1'); + svgLegendGroup.append('line') + .attr('x1', xStart + barWidth).attr('y1', yStart) + .attr('x2', xStart + barWidth).attr('y2', yStart + barHeight - (lineWidth / 2) + tickLength) + .style('stroke-width', lineWidth / 2).style('stroke', 'black'); + + // label: no p-value correction + svgLegendLabel.append('text') + .attr('x', xStart) + .attr('y', yStart + barHeight + 2 * textHeight) + .attr('text-anchor', 'start') + .text('No p-Value correction for multiple comparisons.'); + + } + + // removes mini visualization with details, and highlighting + private removeCellDetails(details: d3.Selection) { + // remove bg highlighting from all tds + d3.select(this.node).selectAll('div.table-container').selectAll('td').classed('selectedCell', false); + + // remove saved selection from session storage + const selCellObj = {task: this.id, colLabel: null, rowLabels: null}; + console.log('selectionLabels: ', selCellObj); + const selCellObjString = JSON.stringify(selCellObj); + sessionStorage.setItem('touringSelCell', selCellObjString); + + // remove mini visualization with details + details.selectAll('*').remove(); + } + + // generates the detail inforamtion to the test and the remove button + private generateVisualDetails(miniVisualisation: d3.Selection, measure: ISimilarityMeasure, measureResult: IMeasureResult, setParameters: ISetParameters) { + + const divDetailInfoContainer = miniVisualisation.append('div') + .classed('detailVisContainer', true); + + //button for mini visualization removal + const that = this; + const detailRemoveButton = divDetailInfoContainer.append('button'); + detailRemoveButton.attr('class', 'btn btn-default removeMiniVis-btn'); + detailRemoveButton.on('click', function () {that.removeCellDetails.bind(that)(miniVisualisation);}); + detailRemoveButton.html('x'); + + const divDetailInfo = divDetailInfoContainer.append('div') + .classed('detailVis', true); + + // the 2 compared sets + const setALabel = setParameters.setACategory ? setParameters.setACategory.label : setParameters.setADesc.label; + const setBLabel = setParameters.setBCategory ? setParameters.setBCategory.label : setParameters.setBDesc.label; + const detailSetInfo = divDetailInfo.append('div') + .classed('detailDiv', true); + if (setParameters.setACategory) { + detailSetInfo.append('span') + .classed('detail-label', true) + .text('Data Column: ') + .append('span') + .text(setParameters.setADesc.label); + detailSetInfo.append('span') + .text(' / '); + } + detailSetInfo.append('span') + .classed('detail-label', true) + .text('Comparing '); + detailSetInfo.append('span') + .text(setALabel + ' ') + .append('span') + .text('[' + measureResult.setSizeA + ']'); + detailSetInfo.append('span') + .classed('detail-label', true) + .text(' vs. '); + detailSetInfo.append('span') + .text(setBLabel + ' ') + .append('span') + .text('[' + measureResult.setSizeB + ']'); + + // test value + p-value + const scoreValue = isNumber(measureResult.scoreValue) && !isNaN(measureResult.scoreValue) ? measureResult.scoreValue.toFixed(3) : 'n/a'; + const pValue = measureResult.pValue === -1 ? 'n/a' : (measureResult.pValue as number).toExponential(3); + const detailInfoValues = divDetailInfo.append('div') + .classed('detailDiv', true); + // .text(`Test-Value: ${scoreValue}, p-Value: ${pValue}`); + detailInfoValues.append('span') + .classed('detail-label', true) + .text(measure.label + ': '); + detailInfoValues.append('span') + .text(scoreValue); + + detailInfoValues.append('span') + .text(', '); + + detailInfoValues.append('span') + .classed('detail-label', true) + .text('p-Value: '); + detailInfoValues.append('span') + .text(pValue); + + // test description + divDetailInfo.append('div') + .classed('detailDiv', true) + .text('Description: ') + .append('span') + .text(measure.description); + } + + protected updateSelectionAndVisuallization(row) { + + // current task + const currTask = d3.select(this.node).attr('class'); + // save selection + const selCellObjString = sessionStorage.getItem('touringSelCell'); + const selCellObj = JSON.parse(selCellObjString); + // console.log('stored selection labels: ', selCellObj); + + if (selCellObj && currTask === selCellObj.task) { + + let rowLabel = null; + let categoryLabel = null; + if (selCellObj.rowLabels !== null && selCellObj.rowLabels.length === 1) { + rowLabel = selCellObj.rowLabels[0]; + } else if (selCellObj.rowLabels !== null && selCellObj.rowLabels.length === 2) { + const firstEle = selCellObj.rowLabels[0]; + + rowLabel = firstEle.rowspan !== null ? firstEle : selCellObj.rowLabels[1]; + categoryLabel = firstEle.rowspan !== null ? selCellObj.rowLabels[1] : firstEle; + } + // console.log('selected labels: ',{selCellObj,rowLabel,categoryLabel}); + + // get index for column + let colIndex = null; + d3.select(this.node).select('thead').selectAll('th').each(function (d, i) { + const classedHead = d3.select(this).classed('head'); + const classedRotate = d3.select(this).classed('rotate'); + if (classedHead && classedRotate) { + const currCol = d3.select(this) === null ? '' : d3.select(this).text(); + // console.log('currCol:', currCol, ' | index: ', i); + if (currCol === selCellObj.colLabel) { + colIndex = i; + } + } + + }); + // get table body + let tableBody = null; + d3.select(this.node).selectAll('tbody').select('tr:nth-child(1)').select('td:nth-child(1)').each(function (d) { + const currRow = d3.select(this).select('b').text(); + if (rowLabel !== null && currRow === rowLabel.label) { + tableBody = this.parentNode.parentNode; + } + }); + + + let selectedCell = null; + // look for last selected cell + if (colIndex !== null && tableBody !== null) { + + // console.log('selectedBody: ', tableBody, ' | colIndex: ' ,colIndex); + if (categoryLabel === null) { + const allTds = d3.select(tableBody).select('tr').selectAll('td'); + selectedCell = allTds[0][colIndex]; + } else { + d3.select(tableBody).selectAll('tr').each(function (d, i) { + const currTds = d3.select(this).selectAll('td'); + const catIndex = i === 0 ? 1 : 0; + const cellIndex = i === 0 ? colIndex : colIndex - 1; + const currCate = d3.select(currTds[0][catIndex]).text(); + if (currCate === categoryLabel.label) { + selectedCell = currTds[0][cellIndex]; + } + }); + } + + // console.log('updateSelectionAndVisuallization: ', {row, tableBody, colIndex, selectedCell}); + // highlight selected cell and update visualization + if (selectedCell) { + const cellData = d3.select(selectedCell).datum(); + // console.log('selectedCell data: ', cellData); + + // highlight selected cell + this.highlightSelectedCell(selectedCell, cellData); + + if (row !== null && row.label === rowLabel.label) { + // generate visualization for cell + this.visualizeSelectedCell(selectedCell, cellData); + } + } + } + + + } + } + + private highlightSelectedCell(tableCell, cellData) { + // remove bg highlighting from all tds + d3.select(this.node).selectAll('td').classed('selectedCell', false); + + if (cellData.score) { //Currenlty only cells with a score are calculated (no category or attribute label cells) + // Color table cell + d3.select(tableCell).classed('selectedCell', true); // add bg highlighting + } + + } + + private visualizeSelectedCell(tableCell, cellData) { + // remove all old details + const details = d3.select(this.node).select('div.details'); + details.selectAll('*').remove(); // avada kedavra outdated details! + + if (cellData.score) { //Currenlty only cells with a score are calculated (no category or attribute label cells) + + const resultScore: IMeasureResult = cellData.score; + const measure: ISimilarityMeasure = cellData.measure; + + // Display details + if (measure) { + this.generateVisualDetails(details, measure, resultScore, cellData.setParameters); //generate description into details div + } else { + details.append('p').text('There are no details for the selected table cell.'); + } + + // display visualisation + if (measure.visualization) { + const visualization: IMeasureVisualization = measure.visualization; + if (cellData.setParameters) { + visualization.generateVisualization(details, cellData.setParameters, cellData.score); + } + + } + } + } + + onClick(tableCell) { + const cellData = d3.select(tableCell).datum(); + console.log('Cell click - data: ', cellData); + + // save data for selected cell in sesisonStorage + let selCellObj; + const task = d3.select(this.node).attr('class'); + // save selected cell in sessionStorage + if (cellData.measure !== null && cellData.score) { + const colLabel = d3.select(this.node).selectAll('span.cross-selection').text(); + const rowLabels = []; + d3.select(this.node).selectAll('td.cross-selection').each(function (d) { + const label = d3.select(this).text(); + const rowspan = d3.select(this).attr('rowspan'); + const obj = {label, rowspan}; + rowLabels.push(obj); + }); + + // create selected cell object + selCellObj = {task, colLabel, rowLabels}; + } else { + selCellObj = {task, colLabel: null, rowLabels: null}; + } + + console.log('selectionLabels: ', selCellObj); + const selCellObjString = JSON.stringify(selCellObj); + sessionStorage.setItem('touringSelCell', selCellObjString); + + this.highlightSelectedCell(tableCell, cellData); + this.visualizeSelectedCell(tableCell, cellData); + } + + onMouseOver(tableCell, state: boolean) { + if (d3.select(tableCell).classed('score')) { + + const tr = tableCell.parentNode; //current row + const tbody = tr.parentNode; //current body + const table = tbody.parentNode; //current table + + const allTds = d3.select(tr).selectAll('td'); + // console.log('allTds', allTds[0]); + let index = -1; + const currLength = allTds[0].length; + // get current index of cell in row + for (let i = 0; i < currLength; i++) { + if (allTds[0][i] === tableCell) { + index = i; + } + } + + // highlight all label cells in row + d3.select(tr).selectAll('td:not(.score)').classed('cross-selection', state); + // highlight the first cell in the first row of the cells tbody + d3.select(tbody).select('tr').select('td').classed('cross-selection', state); + + // maxIndex is the maximum number of table cell in the table + const maxLength = d3.select(tbody).select('tr').selectAll('td')[0].length; + + // if currMaxIndex and maxIndex are not the same -> increase headerIndex by one + // because the current row has one cell fewer + const headerIndex = (currLength === maxLength) ? index : index + 1; + + // highlight column label + const allHeads = d3.select(table).select('thead').selectAll('th'); + if (index > -1) { + // use header index + d3.select(allHeads[0][headerIndex]).select('div').select('span').classed('cross-selection', state); + } + + const cellData = d3.select(tableCell).datum() as IScoreCell; + this.setLineupHighlight(cellData, state, 'touring-highlight-hover'); + } + } + + setLineupHighlight(cellData: IScoreCell, enable: boolean, cssClass: string) { + if (cellData && cellData.highlightData) { + if (enable) { + this.hoverTimerId = window.setTimeout(() => { //highlight after 800ms if mouse is still on cell + // highlight col headers + let id; + for (const attr of cellData.highlightData.filter((data) => data.category === undefined)) { + const header = d3.select(`.lineup-engine header .lu-header[title^="${attr.label}"]`).classed(`${cssClass}`, true); // |= starts with whole word (does not work for selection checkboxes) + id = header.attr('data-col-id'); + } + + if (id) { + // highlight cat rows + let i = 1; + for (const attr of cellData.highlightData.filter((data) => data.category !== undefined)) { + const indices = this.ranking.getAttributeDataDisplayed(attr.column).reduce((indices, cat, index) => cat === attr.category ? [...indices, index] : indices, []); + for (const index of indices) { + const elem = d3.select(`.lineup-engine main .lu-row[data-index="${index}"][data-agg="detail"] [data-id="${id}"]`); + if (!elem.empty()) { + const setDarker = elem.classed(`${cssClass}-1`); //if previous class is already set + elem.classed(`${cssClass}-${i}`, true) + .classed(`${cssClass}-dark`, setDarker); + + const catId = d3.select(`.lineup-engine header .lu-header[title^="${attr.label}"]`).attr('data-col-id'); + d3.select(`.lineup-engine main .lu-row[data-index="${index}"] [data-id="${catId}"]`).classed(`${cssClass}-border`, true); + } + } + i++; + } + } + }, 200); + } else { + window.clearTimeout(this.hoverTimerId); + d3.selectAll(`.${cssClass},.${cssClass}-dark,.${cssClass}-1,.${cssClass}-2,.${cssClass}-border`).classed(`${cssClass} ${cssClass}-1 ${cssClass}-2 ${cssClass}-dark ${cssClass}-border`, false); + } + } + } + + createToolTip(tableCell): String { + if (d3.select(tableCell).classed('score') && d3.select(tableCell).classed('action')) { + const tr = tableCell.parentNode; //current row + const tbody = tr.parentNode; //current body + const table = tbody.parentNode; //current table + + const allTds = d3.select(tr).selectAll('td'); + // console.log('allTds', allTds[0]); + let index = -1; + const currLength = allTds[0].length; + // get current index of cell in row + for (let i = 0; i < currLength; i++) { + if (allTds[0][i] === tableCell) { + index = i; + } + } + + // all label cells in row + const rowCategories = []; + d3.select(tr).selectAll('td:not(.score)').each(function () { + rowCategories.push(d3.select(this).text()); + }); + // the first cell in the first row of the cells tbody + const row = d3.select(tbody).select('tr').select('td').text(); + + // maxIndex is the maximum number of table cell in the table + const maxLength = d3.select(tbody).select('tr').selectAll('td')[0].length; + + // if currMaxIndex and maxIndex are not the same -> increase headerIndex by one + // because the current row has one cell fewer + const headerIndex = (currLength === maxLength) ? index : index + 1; + + // column label + const allHeads = d3.select(table).select('thead').selectAll('th'); + const header = d3.select(allHeads[0][headerIndex]).select('div').select('span').text(); + + const category = rowCategories.pop(); + const isColTask = category === row ? true : false; + const cellData = d3.select(tableCell).datum() as IScoreCell; + const scoreValue = isNumber(cellData.score.scoreValue) && !isNaN(cellData.score.scoreValue) ? cellData.score.scoreValue.toFixed(3) : 'n/a'; + let scorePvalue: string | number = cellData.score.pValue; + if (scorePvalue === -1) { + scorePvalue = 'n/a'; + } else { + scorePvalue = (scorePvalue as number).toExponential(3); + } + + + let tooltipText = ''; + if (isColTask) { + tooltipText = `Column: ${header}\nRow: ${row}\nScore: ${scoreValue}\np-Value: ${scorePvalue}`; + } else { + tooltipText = `Data Column: ${row}\nColumn: ${header}\nRow: ${category}\nScore: ${scoreValue}\np-Value: ${scorePvalue}`; + } + + return tooltipText; + } else { + // cell that have no p-values + return null; + } + + } + + +} + +@TaskDecorator() +export class ColumnComparison extends ATouringTask { + + constructor() { + super(); + this.id = 'colCmp'; + this.label = 'Columns'; + this.order = 20; + this.icon = colCmpIcon; + + this.scope = SCOPE.ATTRIBUTES; + } + + public initContent() { + this.node.insertAdjacentHTML('beforeend', colCmpHtml); + super.initContent(); + + const headerDesc = d3.select(this.node).select('thead tr').select('th').classed('head-descr', true).append('header'); + headerDesc.append('h1').text('Similarity of Columns'); + headerDesc.append('p').text('Click on a p-Value in the table for details.'); + } + + + public update(forceTableUpdate: boolean): void { + const tableChanged = this.updateAttributeSelectors(); + if (forceTableUpdate || tableChanged) { + this.updateTable(); + } + } + + public updateAttributeSelectors(): boolean { + // console.log('update selectors'); + const descriptions = this.getAttriubuteDescriptions(); + + const attrSelectors = d3.select(this.node).selectAll('select.attr optgroup'); + const options = attrSelectors.selectAll('option').data(descriptions, (desc) => desc.label); // duplicates are filtered automatically + options.enter().append('option').text((desc) => desc.label); + + let tableChanged = !options.exit().filter(':checked').empty(); //if checked attributes are removed, the table has to update + + const attrSelect1 = d3.select(this.node).select('select.attr[name="attr1[]"]'); + if (attrSelect1.selectAll('option:checked').empty()) { // make a default selection + attrSelect1.selectAll('option').each(function (desc, i) {(this as HTMLOptionElement).selected = i === descriptions.length - 1 ? true : false;}); // by default, select last column. set the others to null to remove the selected property + tableChanged = true; // attributes have changed + } + + const attrSelect2 = d3.select(this.node).select('select.attr[name="attr2[]"]'); + if (attrSelect2.selectAll('option:checked').empty()) { // make a default selection + attrSelect2.selectAll('option').each(function () {(this as HTMLOptionElement).selected = true;}); // by default, select all + tableChanged = true; // attributes have changed + } + + options.exit().remove(); + options.order(); + + super.updateSelect2(); + + return tableChanged; + } + + public updateTable() { + // console.log('update table'); + WorkerManager.terminateAll(); // Abort all calculations as their results are no longer needed + + const timestamp = new Date().getTime().toString(); + d3.select(this.node).attr('data-timestamp', timestamp); + + + let colData = d3.selectAll('select.attr[name="attr1[]"] option:checked').data(); + let rowData = d3.selectAll('select.attr[name="attr2[]"] option:checked').data(); + if (colData.length > rowData.length) { + [rowData, colData] = [colData, rowData]; // avoid having more columns than rows --> flip table + } + + const colHeads = d3.select(this.node).select('thead tr').selectAll('th.head').data(colData, (d) => d.column); // column is key + const colHeadsSpan = colHeads.enter().append('th') + .attr('class', 'head rotate').append('div').append('span').append('span'); //th.head are the column headers + + const that = this; // for the function below + function updateTableBody(bodyData: Array>>) { + if (d3.select(that.node).attr('data-timestamp') !== timestamp) { + return; // skip outdated result + } + + that.updateTableDescription(bodyData.length === 0); + + + // create a table body for every column + const bodies = d3.select(that.node).select('table').selectAll('tbody').data(bodyData, (d) => d[0][0].label); // the data of each body is of type: Array> + bodies.enter().append('tbody'); //For each IColumnTableData, create a tbody + + // the data of each row is of type: Array + const trs = bodies.selectAll('tr').data((d) => d, (d) => d[0].label); // had to specify the function to derive the data (d -> d) + trs.enter().append('tr'); + const tds = trs.selectAll('td').data((d) => d); + tds.enter().append('td'); + // Set colheads in thead + colHeadsSpan.html((d) => `${d.label}`); + colHeadsSpan.attr('data-type', (d) => (d.type)); + // set data in tbody + tds.attr('colspan', (d) => d.colspan); + tds.attr('rowspan', (d) => d.rowspan); + tds.style('color', (d) => d.foreground); + tds.style('background-color', (d) => d.background); + tds.attr('data-type', (d) => d.type); + tds.classed('action', (d) => d.score !== undefined); + tds.classed('score', (d) => d.measure !== undefined); + tds.html((d) => d.label); + tds.on('click', function () {that.onClick.bind(that)(this);}); + tds.on('mouseover', function () {that.onMouseOver.bind(that)(this, true);}); + tds.on('mouseout', function () {that.onMouseOver.bind(that)(this, false);}); + tds.attr('title', function () {return that.createToolTip.bind(that)(this);}); + + // Exit + colHeads.exit().remove(); // remove attribute columns + colHeads.order(); + tds.exit().remove(); // remove cells of removed columns + trs.exit().remove(); // remove attribute rows + bodies.exit().remove(); + trs.order(); + bodies.order(); + + const svgWidth = 120 + 33 * colData.length; // calculated width for the svg and polygon + + d3.select(that.node).select('th.head.rotate svg').remove(); + d3.select(that.node).select('th.head.rotate') //select first + .insert('svg', ':first-child') + .attr('width', svgWidth) + .attr('height', 120) + .append('polygon').attr('points', '0,0 ' + svgWidth + ',0 0,120'); // 120 is thead height, 45° rotation --> 120 is also width + } + + this.getAttrTableBody(colData, rowData, true, null).then(updateTableBody); // initialize + this.getAttrTableBody(colData, rowData, false, updateTableBody).then(updateTableBody); // set values + } + + /** + * async: return promise + * @param attr1 columns + * @param arr2 rows + * @param scaffold only create the matrix with row headers, but no value calculation + */ + private async getAttrTableBody(colAttributes: IColumnDesc[], rowAttributes: IColumnDesc[], scaffold: boolean, update: (bodyData: IScoreCell[][][]) => void): Promise>>> { + const data = this.prepareDataArray(colAttributes, rowAttributes); + + if (scaffold) { + return data; + } else { + const promises = []; + for (const [rowIndex, row] of rowAttributes.entries()) { + const rowPromises = []; + for (const [colIndex, col] of colAttributes.entries()) { + const colIndexInRows = rowAttributes.indexOf(col); + const rowIndexInCols = colAttributes.indexOf(row); + + if (row.label === col.label) { + //identical attributes + data[rowIndex][0][colIndex + 1] = {label: '', measure: null}; + } else if (rowIndexInCols >= 0 && colIndexInRows >= 0 && colIndexInRows < rowIndex) { + // the row is also part of the column array, and the column is one of the previous rows + } else { + const measures = MethodManager.getMeasuresByType(Type.get(row.type), Type.get(col.type), SCOPE.ATTRIBUTES); + if (measures.length > 0) { // start at + const measure = measures[0]; // Always the first + const data1 = this.ranking.getAttributeDataDisplayed((col as IServerColumn).column); //minus one because the first column is headers + const data2 = this.ranking.getAttributeDataDisplayed((row as IServerColumn).column); + const setParameters = { + setA: data1, + setADesc: col, + setB: data2, + setBDesc: row + }; + + const highlight: IHighlightData[] = [ + {column: (row as IServerColumn).column, label: row.label}, + {column: (col as IServerColumn).column, label: col.label}]; + + //generate HashObject and hash value + const hashObject = { + ids: this.ranking.getDisplayedIds(), + selection: this.ranking.getSelection(), + row: {lable: (row as IServerColumn).label, column: (row as IServerColumn).column}, + column: {lable: (col as IServerColumn).label, column: (col as IServerColumn).column}, + }; + + // remove selection ids, if both row and column are not 'Selection' + if (hashObject.row.lable !== 'Selection' && hashObject.column.lable !== 'Selection') { + delete hashObject.selection; + } + // sort the ids, if both row and column are not 'Rank' + if (hashObject.row.lable !== 'Rank' && hashObject.column.lable !== 'Rank') { + hashObject.ids = this.ranking.getDisplayedIds().sort(); + } + + console.log('hashObject: ', hashObject, ' | unsortedSelction: ', this.ranking.getSelectionUnsorted()); + const hashObjectString = JSON.stringify(hashObject); + // console.log('hashObject.srtringify: ', hashObjectString); + const hashValue = XXH.h32(hashObjectString, 0).toString(16); + // console.log('Hash: ', hashValue); + + let isStoredScoreAvailable = false; //flag for the availability of a stored score + + rowPromises.push(new Promise((resolve, reject) => { + + //get score from sessionStorage + const sessionScore = sessionStorage.getItem(hashValue); + console.log('sessionScore: ', sessionScore); + // score for the measure + let score: Promise = null; + + if (sessionScore === null || sessionScore === undefined || sessionScore.length === 2) { + score = measure.calc(data1, data2, null); + } else if (sessionScore !== null || sessionScore !== undefined) { + score = Promise.resolve(JSON.parse(sessionScore)) as Promise; + isStoredScoreAvailable = true; + } + + // check if all values are NaN + const uniqueData1 = data1.filter((item) => Number.isNaN(item)); + const uniqueData2 = data2.filter((item) => Number.isNaN(item)); + if (data1.length === uniqueData1.length || data2.length === uniqueData2.length) { + isStoredScoreAvailable = true; + } + + // return score + resolve(score); + + }).then((score) => { + + if (!isStoredScoreAvailable) { + const scoreString = JSON.stringify(score); + // console.log('new score: ', score); + // console.log('new scoreString: ', scoreString); + sessionStorage.setItem(hashValue, scoreString); + } + + data[rowIndex][0][colIndex + 1] = this.toScoreCell(score, measure, setParameters, highlight); + + if (rowIndexInCols >= 0 && colIndexInRows >= 0) { + //invert A and B so that the axis labels are conistent + const setParametersInverted = { + setA: setParameters.setB, + setADesc: setParameters.setBDesc, + setB: setParameters.setA, + setBDesc: setParameters.setADesc + }; + data[colIndexInRows][0][rowIndexInCols + 1] = this.toScoreCell(score, measure, setParametersInverted, highlight); + } + }).catch((err) => { + console.error(err); + const errorCell = {label: 'err', measure}; + data[rowIndex][0][colIndex + 1] = errorCell; + if (rowIndexInCols >= 0 && colIndexInRows >= 0) { + data[colIndexInRows][0][rowIndexInCols + 1] = errorCell; + } + }) + ); + + + } + } + } + + promises.concat(rowPromises); + Promise.all(rowPromises).then(() => {update(data); this.updateSelectionAndVisuallization(row);}); + } + + await Promise.all(promises); //rather await all at once: https://developers.google.com/web/fundamentals/primers/async-functions#careful_avoid_going_too_sequential + return data; // then return the data + } + } + + prepareDataArray(colAttributes: IColumnDesc[], rowAttributes: IColumnDesc[]) { + if (rowAttributes.length === 0 || colAttributes.length === 0) { + return []; + } + const data = new Array(rowAttributes.length); // n2 arrays (bodies) + for (const i of data.keys()) { + data[i] = new Array(1); //currently just one row per attribute + data[i][0] = new Array(colAttributes.length + 1).fill({label: '', measure: null} as IScoreCell); // containing n1+1 elements (header + n1 vlaues) + data[i][0][0] = {label: `${rowAttributes[i].label}`, type: rowAttributes[i].type}; + } + + return data; + } +} + + +@TaskDecorator() +export class RowComparison extends ATouringTask { + + constructor() { + super(); + this.id = 'rowCmp'; + this.label = 'Rows'; + this.order = 10; + this.icon = rowCmpIcon; + + this.scope = SCOPE.SETS; + } + + initContent() { + this.node.insertAdjacentHTML('beforeend', rowCmpHtml); + super.initContent(); + + const headerDesc = d3.select(this.node).select('thead tr').select('th').classed('head-descr', true).append('header'); + headerDesc.append('h1').text('Difference of Rows'); + headerDesc.append('p').text('Click on a p-Value in the table for details.'); + + d3.select(this.node).selectAll('select.rowGrp').each(function () { // Convert to select2 + $(this).data('placeholder', 'Select one or more groups of rows.'); + }); + } + + + public update(forceTableUpdate: boolean): void { + const tableChanged = this.updateSelectors(); + if (forceTableUpdate || tableChanged) { + this.updateTable(); + } + } + + private updateSelectors(): boolean { + const descriptions = this.getAttriubuteDescriptions(); + + // Update Row Selectors + // Rows are grouped by categories, so we filter the categorical attributes: + const catDescriptions = descriptions.filter((desc) => (desc as ICategoricalColumnDesc).categories); + catDescriptions.forEach((catDescription) => { + (catDescription as ICategoricalColumnDesc).categories.forEach((category) => { + (category as IAttributeCategory).attribute = (catDescription as IServerColumn); // store the attribute taht the category belongs to + }); + }); + + // For each attribute, create a + const rowSelectors = d3.select(this.node).selectAll('select.rowGrp'); + const optGroups = rowSelectors.selectAll('optgroup').data(catDescriptions, (desc) => desc.label); + optGroups.enter().append('optgroup').attr('label', (desc) => desc.label); + // For each category, create a