From d4e850d18fdab59ea52bbae9584786a14dec8403 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Mon, 17 Sep 2018 20:43:39 -0700 Subject: [PATCH] #130 redux refactor (#208) * mocks for redux refactor - for discussion * more API design on redux refactor * add new reuqired dependencies for build * change babel target to use modern browser * remove dead code * remove dead code - joy plots * checkpoint on redux refactoring * checkpoint on redux refactoring * fix mistaken rebase conflict resolution * dead code removal; add name to dataframe backmap * rename dataframe to universe * update eslint config to more closely match prettier * lint * more eslint updates to match prettier * additional config to make eslint match prettier * add expression data to Universe/World * remove obsolete reducers * lint fixes * more eslint cleanup * lint * lint * fix but in countAllOnes when dimensions gt 1 * lint; do not display name metadata field * lint; colors refactor * lint; colors refactor * update comments * first cut at regraph and reset * enable object-curly-braces consistent mode * lint, handle regraph with no selection * fix expression scatterplot bugs * fix regression legend display * add expression data cache * remove console logging * reset cell color on regraph/reset * remove obsolete server URLs * rename UniverseV01 to Universe_REST_API_v01 * add additional comments on the varDataCache * merge universe reducer into controls reducer; simplify initialization-related actions * use spread operator * fix erroneous comment * convert universe and world state to plain objects, and functionalize supporting code (remove ES6 classes) * use spread operator * lint * improve variable names * rename obsCrossfilter to crossfilter and obsDimensionMap to dimensionMap * rename controls2 to controls --- client/configuration/babel/babel.dev.js | 6 +- client/configuration/babel/babel.prod.js | 6 +- client/configuration/babel/babel.test.js | 6 +- client/configuration/eslint/eslint.js | 17 +- client/package-lock.json | 359 ++++++++++++++ client/package.json | 3 + client/src/actions/index.js | 434 +++++++++-------- client/src/components/app.js | 33 +- .../src/components/categorical/categorical.js | 22 +- .../src/components/continuous/continuous.js | 15 +- .../components/continuous/histogramBrush.js | 98 ++-- client/src/components/continuous/parallel.js | 145 ------ .../src/components/continuousLegend/index.js | 15 +- .../components/expression/cellSetButtons.js | 3 +- .../components/expression/diffExpHeatmap.js | 14 +- .../expression/expressionButtons.js | 7 +- client/src/components/graph/graph.js | 68 +-- client/src/components/joy/drawJoy.js | 163 ------- client/src/components/joy/joy.css | 42 -- client/src/components/joy/joy.js | 38 -- client/src/components/joy/joyParser.js | 29 -- .../src/components/scatterplot/scatterplot.js | 227 +++++---- client/src/globals.js | 4 +- client/src/middleware/updateCellColors.js | 191 ++++---- .../updateCellSelectionMiddleware.js | 105 ----- client/src/reducers/cells.js | 41 -- client/src/reducers/controls.js | 440 ++++++++---------- client/src/reducers/differential.js | 25 +- client/src/reducers/expression.js | 33 -- client/src/reducers/index.js | 25 +- client/src/reducers/initialize.js | 33 -- client/src/reducers/responsive.js | 5 +- client/src/util/stateManager/index.js | 19 + client/src/util/stateManager/keyvalcache.js | 72 +++ client/src/util/stateManager/universe.js | 252 ++++++++++ client/src/util/stateManager/world.js | 315 +++++++++++++ client/src/util/typedCrossfilter/bitArray.js | 86 ++-- client/src/util/typedCrossfilter/index.js | 108 ++--- .../typedCrossfilter/positiveIntervals.js | 31 +- client/src/util/typedCrossfilter/util.js | 57 ++- 40 files changed, 2008 insertions(+), 1584 deletions(-) delete mode 100644 client/src/components/continuous/parallel.js delete mode 100644 client/src/components/joy/drawJoy.js delete mode 100644 client/src/components/joy/joy.css delete mode 100644 client/src/components/joy/joy.js delete mode 100644 client/src/components/joy/joyParser.js delete mode 100644 client/src/middleware/updateCellSelectionMiddleware.js delete mode 100644 client/src/reducers/cells.js delete mode 100644 client/src/reducers/expression.js delete mode 100644 client/src/reducers/initialize.js create mode 100644 client/src/util/stateManager/index.js create mode 100644 client/src/util/stateManager/keyvalcache.js create mode 100644 client/src/util/stateManager/universe.js create mode 100644 client/src/util/stateManager/world.js diff --git a/client/configuration/babel/babel.dev.js b/client/configuration/babel/babel.dev.js index bc9dc712..71dd505d 100644 --- a/client/configuration/babel/babel.dev.js +++ b/client/configuration/babel/babel.dev.js @@ -1,7 +1,11 @@ module.exports = { babelrc: false, cacheDirectory: true, - presets: [["env", { loose: true, modules: false }], "stage-0", "react"], + presets: [ + ["modern-browsers", { loose: true, modules: false }], + "stage-0", + "react" + ], plugins: [ "react-hot-loader/babel", "babel-plugin-transform-decorators-legacy" diff --git a/client/configuration/babel/babel.prod.js b/client/configuration/babel/babel.prod.js index 49bb57d2..22d49c7a 100644 --- a/client/configuration/babel/babel.prod.js +++ b/client/configuration/babel/babel.prod.js @@ -1,6 +1,10 @@ module.exports = { babelrc: false, - presets: [["env", { loose: true, modules: false }], "stage-0", "react"], + presets: [ + ["modern-browsers", { loose: true, modules: false }], + "stage-0", + "react" + ], plugins: [ "babel-plugin-transform-react-constant-elements", "babel-plugin-transform-decorators-legacy", diff --git a/client/configuration/babel/babel.test.js b/client/configuration/babel/babel.test.js index 989402ce..d27045ff 100644 --- a/client/configuration/babel/babel.test.js +++ b/client/configuration/babel/babel.test.js @@ -1,9 +1,9 @@ module.exports = { babelrc: false, presets: [ - "babel-preset-env", - "babel-preset-stage-0", - "babel-preset-react" + ["modern-browsers", { loose: true, modules: false }], + "stage-0", + "react" ], plugins: ["istanbul"] }; diff --git a/client/configuration/eslint/eslint.js b/client/configuration/eslint/eslint.js index 012c6c44..4bb44c30 100644 --- a/client/configuration/eslint/eslint.js +++ b/client/configuration/eslint/eslint.js @@ -5,7 +5,7 @@ module.exports = { env: { browser: true, commonjs: true, es6: true }, globals: { expect: true }, parserOptions: { - ecmaVersion: 6, + ecmaVersion: 2017, sourceType: "module", ecmaFeatures: { jsx: true, @@ -17,6 +17,19 @@ module.exports = { "func-style": "off", "arrow-parens": "off", "no-use-before-define": "off", - "react/jsx-filename-extension": "off" + "react/jsx-filename-extension": "off", + "comma-dangle": "off", + "no-underscore-dangle": "off", + quotes: ["error", "double"], + "implicit-arrow-linebreak": "off", + "operator-linebreak": [ + "error", + "after", + { overrides: { "?": "before", ":": "before" } } + ], + "no-console": "off", + "spaced-comment": ["error", "always", { exceptions: ["*"] }], + "no-param-reassign": "off", + "object-curly-newline": ["error", { consistent: true }] } }; diff --git a/client/package-lock.json b/client/package-lock.json index 5ef0701e..ea7155be 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,181 @@ "@babel/highlight": "7.0.0-rc.1" } }, + "@babel/core": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.0.0-rc.2.tgz", + "integrity": "sha512-8VZqKdLMUBfvSDq+V8CWjVBh7y+b2FY+4daFAWN0pgrdgw/UfrEy8afe9CVfppwblROZZVCxGWSSGOBo84rQjg==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-rc.2", + "@babel/generator": "7.0.0-rc.2", + "@babel/helpers": "7.0.0-rc.2", + "@babel/parser": "7.0.0-rc.2", + "@babel/template": "7.0.0-rc.2", + "@babel/traverse": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2", + "convert-source-map": "^1.1.0", + "debug": "^3.1.0", + "json5": "^0.5.0", + "lodash": "^4.17.10", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0-rc.2.tgz", + "integrity": "sha512-+cVix+HBNakVp7IU1WReJV8dnJl/yaBA5JRXc758BSrvJCH2hKp1Z0xHIiUaOvxMwKXc3EXGIYhlnx5T+6ofGA==", + "dev": true, + "requires": { + "@babel/highlight": "7.0.0-rc.2" + } + }, + "@babel/generator": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.0.0-rc.2.tgz", + "integrity": "sha512-kD6hlprDaBy17V8qd9uXJbYC5ZYyCggieT+tiGzCwayA7oyT5ynPec3MNkWQHkLyhB7IP2n3c/Ep329jOPQY/g==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-rc.2", + "jsesc": "^2.5.1", + "lodash": "^4.17.10", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-function-name": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.0.0-rc.2.tgz", + "integrity": "sha512-1frd4Bm/8yfZoAj87tmB6gtQNWtKAzfRzjASVdmsItzq9X13yUlyFLdo6/tNhazftwJO8iIZeadOpi3rNKDXhg==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "7.0.0-rc.2", + "@babel/template": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-rc.2.tgz", + "integrity": "sha512-5tjNc0hYngGqBGdjvzN89p92WY6aCntaDv8AadB/xgyUx4VievZwEbz8pc6GKkO6+qfghfZhv1F3+9SC6IA3Eg==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0-rc.2.tgz", + "integrity": "sha512-MBtzTAeZT7MxWETY0JRh5yyIKY4tN/q68BU4/XgzZUaHJ+G74fJUoR7mPO3TbTiwLIEFVBbZQA9AG4yYqe5W2g==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/highlight": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0-rc.2.tgz", + "integrity": "sha512-96V6XHAh9XHzjmucShCP8tULwXsC446doZ6REaLVdZDPNj3NsWbsC7OBeY+u6UWiFxHTTv6YmA4Veh4wXuucYw==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.0.0-rc.2.tgz", + "integrity": "sha512-zDB1QPgQWYwuJty3Ymbx1hq7zbBEbZjTprHOhforvzyQFV86LNh6FS0InjnOUXM6p6QUyONz8KTt/v+MRMd0Hg==", + "dev": true + }, + "@babel/template": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.0.0-rc.2.tgz", + "integrity": "sha512-CryGZ01Nko2/g8gkYiiPc7x9ZinrX59/BTWMZV1sDj5cAeia64vhyNnXTcNeim885IdGOdYyia1PNBWKnFxuSw==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-rc.2", + "@babel/parser": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/traverse": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.0.0-rc.2.tgz", + "integrity": "sha512-x8y9E+KZHs3Xmy5uiYmr1TtDhOBAZnL9vUtLIt95Pw3jovkY9q2NIwgLzfSlzOU83sQvzAooZWuJ65JERwxx+Q==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-rc.2", + "@babel/generator": "7.0.0-rc.2", + "@babel/helper-function-name": "7.0.0-rc.2", + "@babel/helper-split-export-declaration": "7.0.0-rc.2", + "@babel/parser": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2", + "debug": "^3.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.10" + } + }, + "@babel/types": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0-rc.2.tgz", + "integrity": "sha512-I2SMGD8bUX7sysOwGM8TcwCoaHiOx2YWZmT9h5oAncsPQ9Wy068yJneCF4vkOGTCzPFIETPDR5i3EIEm5QgMFg==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "globals": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz", + "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==", + "dev": true + }, + "jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha1-5CGiqOINawgZ3yiQj3glJrlt0f4=", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + } + } + }, "@babel/generator": { "version": "7.0.0-beta.51", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.0.0-beta.51.tgz", @@ -69,6 +244,170 @@ "@babel/types": "7.0.0-beta.51" } }, + "@babel/helpers": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.0.0-rc.2.tgz", + "integrity": "sha512-X5e6FnKEhS8UtSJfjjkEvY8Mq+W52FES6p55g16gHmVycVrggjwZryQKqK+iMJlus7Dgz6MrrdOtC1SWx4jDDg==", + "dev": true, + "requires": { + "@babel/template": "7.0.0-rc.2", + "@babel/traverse": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0-rc.2.tgz", + "integrity": "sha512-+cVix+HBNakVp7IU1WReJV8dnJl/yaBA5JRXc758BSrvJCH2hKp1Z0xHIiUaOvxMwKXc3EXGIYhlnx5T+6ofGA==", + "dev": true, + "requires": { + "@babel/highlight": "7.0.0-rc.2" + } + }, + "@babel/generator": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.0.0-rc.2.tgz", + "integrity": "sha512-kD6hlprDaBy17V8qd9uXJbYC5ZYyCggieT+tiGzCwayA7oyT5ynPec3MNkWQHkLyhB7IP2n3c/Ep329jOPQY/g==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-rc.2", + "jsesc": "^2.5.1", + "lodash": "^4.17.10", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-function-name": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.0.0-rc.2.tgz", + "integrity": "sha512-1frd4Bm/8yfZoAj87tmB6gtQNWtKAzfRzjASVdmsItzq9X13yUlyFLdo6/tNhazftwJO8iIZeadOpi3rNKDXhg==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "7.0.0-rc.2", + "@babel/template": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-rc.2.tgz", + "integrity": "sha512-5tjNc0hYngGqBGdjvzN89p92WY6aCntaDv8AadB/xgyUx4VievZwEbz8pc6GKkO6+qfghfZhv1F3+9SC6IA3Eg==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0-rc.2.tgz", + "integrity": "sha512-MBtzTAeZT7MxWETY0JRh5yyIKY4tN/q68BU4/XgzZUaHJ+G74fJUoR7mPO3TbTiwLIEFVBbZQA9AG4yYqe5W2g==", + "dev": true, + "requires": { + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/highlight": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0-rc.2.tgz", + "integrity": "sha512-96V6XHAh9XHzjmucShCP8tULwXsC446doZ6REaLVdZDPNj3NsWbsC7OBeY+u6UWiFxHTTv6YmA4Veh4wXuucYw==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.0.0-rc.2.tgz", + "integrity": "sha512-zDB1QPgQWYwuJty3Ymbx1hq7zbBEbZjTprHOhforvzyQFV86LNh6FS0InjnOUXM6p6QUyONz8KTt/v+MRMd0Hg==", + "dev": true + }, + "@babel/template": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.0.0-rc.2.tgz", + "integrity": "sha512-CryGZ01Nko2/g8gkYiiPc7x9ZinrX59/BTWMZV1sDj5cAeia64vhyNnXTcNeim885IdGOdYyia1PNBWKnFxuSw==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-rc.2", + "@babel/parser": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2" + } + }, + "@babel/traverse": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.0.0-rc.2.tgz", + "integrity": "sha512-x8y9E+KZHs3Xmy5uiYmr1TtDhOBAZnL9vUtLIt95Pw3jovkY9q2NIwgLzfSlzOU83sQvzAooZWuJ65JERwxx+Q==", + "dev": true, + "requires": { + "@babel/code-frame": "7.0.0-rc.2", + "@babel/generator": "7.0.0-rc.2", + "@babel/helper-function-name": "7.0.0-rc.2", + "@babel/helper-split-export-declaration": "7.0.0-rc.2", + "@babel/parser": "7.0.0-rc.2", + "@babel/types": "7.0.0-rc.2", + "debug": "^3.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.10" + } + }, + "@babel/types": { + "version": "7.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0-rc.2.tgz", + "integrity": "sha512-I2SMGD8bUX7sysOwGM8TcwCoaHiOx2YWZmT9h5oAncsPQ9Wy068yJneCF4vkOGTCzPFIETPDR5i3EIEm5QgMFg==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "globals": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz", + "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==", + "dev": true + }, + "jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha1-5CGiqOINawgZ3yiQj3glJrlt0f4=", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + } + } + }, "@babel/highlight": { "version": "7.0.0-rc.1", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0-rc.1.tgz", @@ -2316,6 +2655,21 @@ "babel-plugin-syntax-object-rest-spread": "^6.13.0" } }, + "babel-preset-modern-browsers": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-modern-browsers/-/babel-preset-modern-browsers-11.0.1.tgz", + "integrity": "sha512-9QR7Mq4BkHyWZ3I2lRwgfpYOhqOaw9wBGAyXfyKV3hasIEBNehBOTmEFwFYnQqVjLtovGrmHE/l+SV3MunwkdA==", + "dev": true, + "requires": { + "babel-plugin-check-es2015-constants": "^6.22.0", + "babel-plugin-syntax-object-rest-spread": "^6.13.0", + "babel-plugin-syntax-trailing-function-commas": "^6.22.0", + "babel-plugin-transform-es2015-arrow-functions": "^6.22.0", + "babel-plugin-transform-es2015-function-name": "^6.24.1", + "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", + "babel-plugin-transform-object-rest-spread": "^6.26.0" + } + }, "babel-preset-react": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.24.1.tgz", @@ -8579,6 +8933,11 @@ "mimic-fn": "^1.0.0" } }, + "memoize-one": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.0.tgz", + "integrity": "sha512-wdpOJ4XBejprGn/xhd1i2XR8Dv1A25FJeIvR7syQhQlz9eXsv+06llcvcmBxlWVGv4C73QBsWA8kxvZozzNwiQ==" + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", diff --git a/client/package.json b/client/package.json index f04256bc..79760d97 100644 --- a/client/package.json +++ b/client/package.json @@ -35,6 +35,7 @@ "gl-matrix": "^2.7.1", "key-pressed": "0.0.1", "lodash": "^4.17.4", + "memoize-one": "^4.0.0", "mouse-position": "^2.0.1", "mouse-pressed": "^1.0.0", "orbit-camera": "^1.0.0", @@ -53,6 +54,7 @@ "urijs": "^1.19.0" }, "devDependencies": { + "@babel/core": "^7.0.0-rc.1", "autoprefixer": "^9.1.1", "babel-core": "^6.13.2", "babel-eslint": "^8.2.6", @@ -62,6 +64,7 @@ "babel-plugin-transform-react-constant-elements": "^6.9.1", "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-env": "^1.6.1", + "babel-preset-modern-browsers": "^11.0.1", "babel-preset-react": "^6.11.1", "babel-preset-stage-0": "^6.5.0", "babel-register": "^6.11.6", diff --git a/client/src/actions/index.js b/client/src/actions/index.js index b3dc14bc..6034c266 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -1,101 +1,149 @@ // jshint esversion: 6 +import _ from "lodash"; +import memoize from "memoize-one"; import * as globals from "../globals"; import store from "../reducers"; -import URI from "urijs"; -import _ from "lodash"; +import { Universe } from "../util/stateManager"; -const requestCells = (query = "") => { - return dispatch => { - dispatch({ type: "request cells started" }); - return fetch(`${globals.API.prefix}${globals.API.version}cells${query}`, { +/* +Catch unexpected errors and make sure we don't lose them! +*/ +function catchErrorsWrap(fn) { + return (dispatch, getState) => { + fn(dispatch, getState).catch(error => { + console.error(error); + dispatch({ type: "UNEXPECTED ERROR", error }); + }); + }; +} + +async function doRequestInitialize() { + const res = await fetch( + `${globals.API.prefix}${globals.API.version}initialize`, + { method: "get", headers: new Headers({ "Content-Type": "application/json" }) - }) - .then(res => res.json()) - .then( - data => dispatch({ type: "request cells success", data }), - error => dispatch({ type: "request cells error", error }) + } + ); + return res.json(); +} + +async function doRequestCells(query) { + const res = await fetch( + `${globals.API.prefix}${globals.API.version}cells${query}`, + { + method: "get", + headers: new Headers({ + "Content-Type": "application/json" + }) + } + ); + return res.json(); +} + +function doInitialDataLoad(query = "") { + return catchErrorsWrap(async dispatch => { + dispatch({ type: "initial data load start" }); + try { + const res = await Promise.all([ + doRequestInitialize(), + doRequestCells(query) + ]); + const universe = Universe.createUniverseFromRESTv01Response( + res[0], + res[1] ); - }; -}; - -/* SELECT */ -const regraph = () => { - return (dispatch, getState) => { - dispatch({ type: "regraph started" }); - - const state = getState(); - const selectedMetadata = {}; - - _.each(state.controls.categoricalAsBooleansMap, (options, field) => { - let atLeastOneOptionDeselected = false; - - _.each(options, (isActive, option) => { - if (!isActive) { - atLeastOneOptionDeselected = true; - } + dispatch({ + type: "initial data load complete (universe exists)", + universe }); + } catch (error) { + dispatch({ type: "initial data load error", error }); + } + }); +} - if (atLeastOneOptionDeselected) { - _.each(options, (isActive, option) => { - if (isActive) { - if (selectedMetadata[field]) { - selectedMetadata[field].push(option); - } else if (!selectedMetadata[field]) { - selectedMetadata[field] = [option]; - } - } - }); - } - }); +// XXX TODO - this is the old code for doing a regraph. Preserving it solely +// until we port to 0.2 API. The new UX for regraph can't be implemented on +// the 0.1 API (doesn't allow for re-layout on arbitrary sets of cells), so just +// punting for now. See ticket #88 +// +// +// /* SELECT */ +// const regraph = () => { +// return (dispatch, getState) => { +// dispatch({ type: "regraph started" }); +// +// const state = getState(); +// const selectedMetadata = {}; +// +// _.each(state.controls.categoricalAsBooleansMap, (options, field) => { +// let atLeastOneOptionDeselected = false; +// +// _.each(options, (isActive, option) => { +// if (!isActive) { +// atLeastOneOptionDeselected = true; +// } +// }); +// +// if (atLeastOneOptionDeselected) { +// _.each(options, (isActive, option) => { +// if (isActive) { +// if (selectedMetadata[field]) { +// selectedMetadata[field].push(option); +// } else if (!selectedMetadata[field]) { +// selectedMetadata[field] = [option]; +// } +// } +// }); +// } +// }); +// +// let uri = new URI(); +// uri.setSearch(selectedMetadata); +// console.log(uri.search(), selectedMetadata); +// +// dispatch(requestCells(uri.search())).then(res => { +// if (res.error) { +// dispatch({ type: "regraph error" }); +// } else { +// dispatch({ type: "regraph success" }); +// } +// }); +// }; +// }; - let uri = new URI(); - uri.setSearch(selectedMetadata); - console.log(uri.search(), selectedMetadata); - - dispatch(requestCells(uri.search())).then(res => { - if (res.error) { - dispatch({ type: "regraph error" }); - } else { - dispatch({ type: "regraph success" }); - } - }); - }; +const regraph = () => (dispatch, getState) => { + const { universe, world, crossfilter } = getState().controls; + dispatch({ + type: "set World to current selection", + universe, + world, + crossfilter + }); }; -const resetGraph = () => { - return (dispatch, getState) => { - dispatch({ type: "reset graph" }); - }; -}; - -const initialize = () => { - return (dispatch, getState) => { - dispatch({ type: "initialize started" }); - fetch(`${globals.API.prefix}${globals.API.version}initialize`, { - method: "get", - headers: new Headers({ - "Content-Type": "application/json" - }) - }) - .then(res => res.json()) - .then( - data => dispatch({ type: "initialize success", data }), - error => dispatch({ type: "initialize error", error }) - ); - }; -}; +const resetGraph = () => (dispatch, getState) => + dispatch({ + type: "reset World to eq Universe", + universe: getState().controls.universe + }); // This code defends against the case where /expression returns a cellname // never seen before (ie, not returned by /cells). This should not happen // (see https://github.com/chanzuckerberg/cellxgene-rest-api/issues/34) but // occasionally does. // +// XXX TODO - this code is only relevant in v0.1 REST API, and can be retired +// when we port to 0.2. +// +const makeMetadataMap = memoize(metadata => _.keyBy(metadata, "CellName")); function cleanupExpressionResponse(data) { const s = store.getState(); - const metadata = s.controls.allCellsMetadataMap; + const { universe } = s.controls; + const metadata = makeMetadataMap(universe.obsAnnotations); let errorFound = false; data.data.cells = _.filter(data.data.cells, cell => { if (!errorFound && !metadata[cell.cellname]) { @@ -110,123 +158,145 @@ function cleanupExpressionResponse(data) { return data; } -const requestGeneExpressionCounts = () => { - return (dispatch, getState) => { - dispatch({ type: "get expression started" }); - fetch(`${globals.API.prefix}${globals.API.version}expression`, { - method: "get", - headers: new Headers({ - accept: "application/json" - }) - }) - .then(res => res.json()) - .then(data => cleanupExpressionResponse(data)) - .then( - data => dispatch({ type: "get expression success", data }), - error => dispatch({ type: "get expression error", error }) - ); - }; -}; +/* +Fetch [gene, ...] from V0.1 API. Not an action function - just a helper +which implements the new expression data caching. +*/ +async function _doRequestExpressionData(dispatch, getState, genes) { + const state = getState(); + /* check cache and only fetch data we do not already have */ + const { universe } = state.controls; + const genesToFetch = _.filter(genes, g => !universe.varDataCache[g]); -const requestSingleGeneExpressionCountsForColoringPOST = gene => { - return (dispatch, getState) => { - dispatch({ type: "get single gene expression for coloring started" }); - fetch(`${globals.API.prefix}${globals.API.version}expression`, { - method: "POST", - body: JSON.stringify({ - genelist: [gene] - }), - headers: new Headers({ - accept: "application/json", - "Content-Type": "application/json" - }) - }) - .then(res => res.json()) - .then(data => cleanupExpressionResponse(data)) - .then( - data => - dispatch({ - type: "color by expression", - gene: gene, - data + dispatch({ type: "expression load start" }); + let expressionData = {}; // { gene: data } + if (genesToFetch.length) { + try { + const res = await fetch( + `${globals.API.prefix}${globals.API.version}expression`, + { + method: "POST", + body: JSON.stringify({ + genelist: genes }), - error => - dispatch({ - type: "get single gene expression for coloring error", - error + headers: new Headers({ + accept: "application/json", + "Content-Type": "application/json" }) + } ); - }; -}; + let data = await res.json(); + data = cleanupExpressionResponse(data); + data = Universe.convertExpressionRESTv01ToObject(universe, data); + expressionData = { + ...expressionData, + ...data + }; + } catch (error) { + dispatch({ type: "expression load error", error }); + throw error; // rethrow + } + } -const requestGeneExpressionCountsPOST = genes => { - return (dispatch, getState) => { - dispatch({ type: "get expression started" }); - fetch(`${globals.API.prefix}${globals.API.version}expression`, { - method: "POST", - body: JSON.stringify({ - genelist: genes - }), - headers: new Headers({ - accept: "application/json", - "Content-Type": "application/json" - }) - }) - .then(res => res.json()) - .then(data => cleanupExpressionResponse(data)) - .then( - data => dispatch({ type: "get expression success", data }), - error => dispatch({ type: "get expression error", error }) - ); - }; -}; + // add the cached values + _.forEach(genes, g => { + if (expressionData[g] === undefined) { + expressionData[g] = universe.varDataCache[g]; + } + }); -const requestDifferentialExpression = (celllist1, celllist2, num_genes = 7) => { - return (dispatch, getState) => { - dispatch({ type: "request differential expression started" }); - fetch(`${globals.API.prefix}${globals.API.version}diffexpression`, { - method: "POST", - body: JSON.stringify({ - celllist1, - celllist2, - num_genes - }), - headers: new Headers({ - accept: "application/json", - "Content-Type": "application/json" - }) - }) - .then(res => res.json()) - .then( - data => { - /* kick off a secondary action to get all expression counts for all cells now that we know what the top expressed are */ - dispatch( - requestGeneExpressionCountsPOST( - _.union( - data.data.celllist1.topgenes, - data.data.celllist2.topgenes - ) // ["GPM6B", "FEZ1", "TSPAN31", "PCSK1N", "TUBA1A", "GPM6A", "CLU", "FCER1G", "TYROBP", "C1QB", "CD74", "CYBA", "GPX1", "TMSB4X"] - ) - ); - /* then send the success case action through */ - return dispatch({ - type: "request differential expression success", - data - }); + return dispatch({ type: "expression load success", expressionData }); +} + +function requestSingleGeneExpressionCountsForColoringPOST(gene) { + return async (dispatch, getState) => { + dispatch({ type: "get single gene expression for coloring started" }); + try { + await _doRequestExpressionData(dispatch, getState, [gene]); + dispatch({ + type: "color by expression", + gene, + data: { + [gene]: getState().controls.world.varDataCache[gene] + } + }); + } catch (error) { + dispatch({ + type: "get single gene expression for coloring error", + error + }); + } + }; +} + +const requestGeneExpressionCountsPOST = genes => async (dispatch, getState) => { + dispatch({ type: "get expression started" }); + try { + await _doRequestExpressionData(dispatch, getState, genes); + return dispatch({ + type: "get expression success", + genes, + data: _.transform( + genes, + (res, gene) => { + res[gene] = getState().controls.world.varDataCache[gene]; }, - error => - dispatch({ type: "request differential expression error", error }) - ); - }; + {} + ) + }); + } catch (error) { + return dispatch({ type: "get expression error", error }); + } +}; + +const requestDifferentialExpression = ( + celllist1, + celllist2, + num_genes = 7 +) => dispatch => { + dispatch({ type: "request differential expression started" }); + fetch(`${globals.API.prefix}${globals.API.version}diffexpression`, { + method: "POST", + body: JSON.stringify({ + celllist1, + celllist2, + num_genes + }), + headers: new Headers({ + accept: "application/json", + "Content-Type": "application/json" + }) + }) + .then(res => res.json()) + .then( + data => { + /* + kick off a secondary action to get all expression counts for all cells + now that we know what the top expressed are + */ + dispatch( + requestGeneExpressionCountsPOST( + _.union(data.data.celllist1.topgenes, data.data.celllist2.topgenes) + ) + ); + /* then send the success case action through */ + return dispatch({ + type: "request differential expression success", + data + }); + }, + error => + dispatch({ + type: "request differential expression error", + error + }) + ); }; export default { - initialize, - requestCells, regraph, resetGraph, - requestGeneExpressionCounts, - requestGeneExpressionCountsPOST, requestSingleGeneExpressionCountsForColoringPOST, - requestDifferentialExpression + requestDifferentialExpression, + doInitialDataLoad }; diff --git a/client/src/components/app.js b/client/src/components/app.js index 275eb997..dd8dd23b 100644 --- a/client/src/components/app.js +++ b/client/src/components/app.js @@ -7,9 +7,7 @@ import { connect } from "react-redux"; // import PulseLoader from "halogen/PulseLoader"; import LeftSideBar from "./leftsidebar"; -import Parallel from "./continuous/parallel"; import Legend from "./continuousLegend"; -// import Joy from "./joy/joy"; import Graph from "./graph/graph"; import * as globals from "../globals"; import actions from "../actions"; @@ -18,8 +16,8 @@ import SectionHeader from "./framework/sectionHeader"; @connect(state => { return { - cells: state.cells, - initialize: state.initialize + loading: state.controls.loading, + error: state.controls.error }; }) class App extends React.Component { @@ -27,20 +25,18 @@ class App extends React.Component { super(props); this.state = {}; } + _onURLChanged() { this.props.dispatch({ type: "url changed", url: document.location.href }); } + componentDidMount() { /* listen for url changes, fire one when we start the app up */ window.addEventListener("popstate", this._onURLChanged); this._onURLChanged(); - this.props.dispatch(actions.initialize()); + this.props.dispatch(actions.doInitialDataLoad(window.location.search)); - /* - first request includes query straight off the url bar for now - */ - this.props.dispatch(actions.requestCells(window.location.search)); /* listen for resize events */ window.addEventListener("resize", () => { this.props.dispatch({ @@ -61,10 +57,11 @@ class App extends React.Component { } render() { + const { loading, error } = this.props; return ( - {this.props.cells.loading || this.props.initialize.loading ? ( + {loading ? (
) : null} - {this.props.cells.error ? "Error loading cells" : null} - + {error ? "Error loading cells" : null}
- {this.props.cells.loading || this.props.initialize.loading ? null : ( - - )} + {loading ? null : }
- {this.props.cells.loading || - this.props.initialize.loading ? null : ( - - )} + {loading ? null : } - {/**/} + {}
@@ -104,5 +95,3 @@ class App extends React.Component { } export default App; - -// diff --git a/client/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js index f49b915d..05f034d6 100644 --- a/client/src/components/categorical/categorical.js +++ b/client/src/components/categorical/categorical.js @@ -3,16 +3,14 @@ import React from "react"; import _ from "lodash"; import { connect } from "react-redux"; -import * as globals from "../../globals"; -import styles from "./categorical.css"; -import SectionHeader from "../framework/sectionHeader"; -import Value from "./value"; -import { alphabeticallySortedValues } from "./util"; - import FaArrowRight from "react-icons/lib/fa/angle-right"; import FaArrowDown from "react-icons/lib/fa/angle-down"; import FaPaintBrush from "react-icons/lib/fa/paint-brush"; +import * as globals from "../../globals"; +import Value from "./value"; +import { alphabeticallySortedValues } from "./util"; + @connect(state => { return { colorAccessor: state.controls.colorAccessor, @@ -27,6 +25,7 @@ class Category extends React.Component { isExpanded: false }; } + componentDidUpdate() { const valuesAsBool = _.values( this.props.categoricalAsBooleansMap[this.props.metadataField] @@ -45,12 +44,14 @@ class Category extends React.Component { this.checkbox.indeterminate = true; } } + handleColorChange() { this.props.dispatch({ type: "color by categorical metadata", colorAccessor: this.props.metadataField }); } + toggleAll() { this.props.dispatch({ type: "categorical metadata filter all of these", @@ -58,6 +59,7 @@ class Category extends React.Component { }); this.setState({ isChecked: true }); } + toggleNone() { this.props.dispatch({ type: "categorical metadata filter none of these", @@ -66,6 +68,7 @@ class Category extends React.Component { }); this.setState({ isChecked: false }); } + renderCategoryItems() { return _.map(alphabeticallySortedValues(this.props.values), (v, i) => { return ( @@ -79,6 +82,7 @@ class Category extends React.Component { ); }); } + handleToggleAllClick() { // || this.checkbox.indeterminate === false if (this.state.isChecked) { @@ -89,6 +93,7 @@ class Category extends React.Component { this.toggleAll(); } } + render() { return (
{ - const ranges = _.get(state, "cells.cells.data.ranges", null); - + const ranges = _.get(state.controls.world, "summary.obs", null); return { ranges }; @@ -192,7 +196,7 @@ class Categories extends React.Component { > {_.map(this.props.ranges, (value, key) => { const isColorField = key.includes("color") || key.includes("Color"); - if (value.options && key !== "CellName" && !isColorField) { + if (value.options && !isColorField && key !== "name") { return ( ); diff --git a/client/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js index 0efbfc08..8c12609f 100644 --- a/client/src/components/continuous/continuous.js +++ b/client/src/components/continuous/continuous.js @@ -17,18 +17,15 @@ import HistogramBrush from "./histogramBrush"; import { margin, width, height, createDimensions } from "./util"; @connect(state => { - const ranges = _.get(state, "cells.cells.data.ranges", null); - const metadata = _.get(state, "cells.cells.data.metadata", null); - const initializeRanges = _.get(state, "initialize.data.data.ranges", null); + const metadata = _.get(state.controls.world, "obsAnnotations", null); + const ranges = _.get(state.controls.world, "summary.obs", null); return { ranges, metadata, - initializeRanges, colorAccessor: state.controls.colorAccessor, colorScale: state.controls.colorScale, - graphBrushSelection: state.controls.graphBrushSelection, - axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn + selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null) }; }) class Continuous extends React.Component { @@ -41,17 +38,19 @@ class Continuous extends React.Component { dimensions: null }; } + handleBrushAction(selection) { this.props.dispatch({ type: "continuous selection using parallel coords brushing", data: selection }); } + handleColorAction(key) { this.props.dispatch({ type: "color by continuous metadata", colorAccessor: key, - rangeMaxForColorAccessor: this.props.initializeRanges[key].range.max + rangeMaxForColorAccessor: this.props.ranges[key].range.max }); } @@ -60,7 +59,7 @@ class Continuous extends React.Component {
{_.map(this.props.ranges, (value, key) => { const isColorField = key.includes("color") || key.includes("Color"); - if (value.range && key !== "CellName" && !isColorField) { + if (value.range && key !== "name" && !isColorField) { return ( { - const initializeRanges = _.get(state, "initialize.data.data.ranges", null); - return { - initializeRanges, + initializeRanges: _.get(state.controls.world, "summary.obs"), colorAccessor: state.controls.colorAccessor, colorScale: state.controls.colorScale, - cellsMetadata: state.controls.cellsMetadata + obsAnnotations: _.get(state.controls.world, "obsAnnotations", null) }; }) class HistogramBrush extends React.Component { + calcHistogramCache = memoize((obsAnnotations, metadataField, ranges) => { + // recalculate expensive stuff + const allValuesForContinuousFieldAsArray = _.map( + obsAnnotations, + metadataField + ); + const histogramCache = {}; + + histogramCache.x = d3 + .scaleLinear() + .domain([ranges.min, ranges.max]) + .range([0, this.width]); + + histogramCache.y = d3 + .scaleLinear() + .range([this.height - this.marginBottom, 0]); + // .range([height - margin.bottom, margin.top]); + + histogramCache.bins = d3 + .histogram() + .domain(histogramCache.x.domain()) + .thresholds(40)(allValuesForContinuousFieldAsArray); + + histogramCache.numValues = allValuesForContinuousFieldAsArray.length; + + return histogramCache; + }); + constructor(props) { super(props); this.width = 300; this.height = 100; this.marginBottom = 20; - this.histogramCache = {}; this.state = { - svg: null, - ctx: null, - axes: null, - dimensions: null, brush: null }; } - calcHistogramCache(nextProps) { - // recalculate expensive stuff - const allValuesForContinuousFieldAsArray = _.map( - nextProps.cellsMetadata, - nextProps.metadataField - ); - - this.histogramCache.x = d3 - .scaleLinear() - .domain([nextProps.ranges.min, nextProps.ranges.max]) - .range([0, this.width]); - - this.histogramCache.y = d3 - .scaleLinear() - .range([this.height - this.marginBottom, 0]); - // .range([height - margin.bottom, margin.top]); - - this.histogramCache.bins = d3 - .histogram() - .domain(this.histogramCache.x.domain()) - .thresholds(40)(allValuesForContinuousFieldAsArray); - - this.histogramCache.numValues = allValuesForContinuousFieldAsArray.length; - } - - componentWillMount() { - this.calcHistogramCache(this.props); - } onBrush(selection, x) { return () => { + const { dispatch, metadataField } = this.props; if (d3.event.selection) { - this.props.dispatch({ + dispatch({ type: "continuous metadata histogram brush", - selection: this.props.metadataField, + selection: metadataField, range: [x(d3.event.selection[0]), x(d3.event.selection[1])] }); } else { - this.props.dispatch({ + dispatch({ type: "continuous metadata histogram brush", - selection: this.props.metadataField, + selection: metadataField, range: null }); } }; } + drawHistogram(svgRef) { - const x = this.histogramCache.x; - const y = this.histogramCache.y; - const bins = this.histogramCache.bins; - const numValues = this.histogramCache.numValues; + const { obsAnnotations, metadataField, ranges } = this.props; + const histogramCache = this.calcHistogramCache( + obsAnnotations, + metadataField, + ranges + ); + + const { x, y, bins, numValues } = histogramCache; + d3.select(svgRef) + .selectAll(".bar") + .remove(); d3.select(svgRef) .insert("g", "*") @@ -97,6 +100,7 @@ class HistogramBrush extends React.Component { .data(bins) .enter() .append("rect") + .attr("class", "bar") .attr("x", function(d) { return x(d.x0) + 1; }) @@ -144,6 +148,7 @@ class HistogramBrush extends React.Component { this.setState({ brush, xAxis }); } } + handleColorAction() { this.props.dispatch({ type: "color by continuous metadata", @@ -153,6 +158,7 @@ class HistogramBrush extends React.Component { ].range.max }); } + render() { return (
{ - const ranges = _.get(state, "cells.cells.data.ranges", null); - const metadata = _.get(state, "cells.cells.data.metadata", null); - const initializeRanges = _.get(state, "initialize.data.data.ranges", null); - - return { - ranges, - metadata, - initializeRanges, - colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colorScale, - graphBrushSelection: state.controls.graphBrushSelection, - cellsMetadata: state.controls.cellsMetadata, - axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn - }; -}) -class Parallel extends React.Component { - constructor(props) { - super(props); - this.state = { - svg: null, - ctx: null, - axes: null, - dimensions: null - }; - } - componentDidMount() { - const { svg, ctx } = setupParallelCoordinates(width, height, margin); - this.setState({ svg, ctx }); - } - componentWillReceiveProps(nextProps) { - this.maybeDrawAxes(nextProps); - this.maybeDrawLines(nextProps); - } - maybeDrawAxes(nextProps) { - if ( - !this.state.axes && - nextProps.initializeRanges /* axes are created on full range of data */ - ) { - const dimensions = createDimensions(nextProps.initializeRanges); - - const xscale = d3 - .scalePoint() - .domain(d3.range(dimensions.length)) - .range([0, width]); - - const axes = drawAxes( - this.state.svg, - this.state.ctx, - dimensions, - xscale, - height, - width, - this.handleBrushAction.bind(this), - this.handleColorAction.bind(this) - ); - - this.setState({ - axes, - xscale, - dimensions - }); - - this.props.dispatch({ - type: "parallel coordinates axes have been drawn" - }); - } - } - maybeDrawLines = _.debounce(nextProps => { - /* https://stackoverflow.com/questions/23123138/perform-debounce-in-react-js */ - if ( - nextProps.ranges && - nextProps.cellsMetadata && - nextProps.axesHaveBeenDrawn - ) { - if (this.state._drawLinesCanvas) { - this.state._drawLinesCanvas.invalidate(); /* this is only necessary if the internals of drawLinesCanvas are using the render queue */ - } - - this.state.ctx.clearRect(0, 0, width, height); - - const _drawLinesCanvas = drawLinesCanvas( - nextProps.cellsMetadata, - this.state.dimensions, - this.state.xscale, - this.state.ctx, - nextProps.colorAccessor, - nextProps.colorScale - ); - - this.setState({ - _drawLinesCanvas /* this will only exist if the internals of drawLinesCanvas are using the render queue */ - }); - } - }, 200); - - handleBrushAction(selection) { - this.props.dispatch({ - type: "continuous selection using parallel coords brushing", - data: selection - }); - } - handleColorAction(key) { - this.props.dispatch({ - type: "color by continuous metadata", - colorAccessor: key, - rangeMaxForColorAccessor: this.props.initializeRanges[key].range.max - }); - } - - render() { - return ( -
-
-
- ); - } -} - -export default Parallel; - -// diff --git a/client/src/components/continuousLegend/index.js b/client/src/components/continuousLegend/index.js index 06be9b3b..32ac0e63 100644 --- a/client/src/components/continuousLegend/index.js +++ b/client/src/components/continuousLegend/index.js @@ -1,19 +1,17 @@ // jshint esversion: 6 import React from "react"; -import _ from "lodash"; import { connect } from "react-redux"; -import * as globals from "../../globals"; import * as d3 from "d3"; import { interpolateViridis } from "d3-scale-chromatic"; // create continuous color legend // http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f const continuous = (selector_id, colorscale) => { - var legendheight = 200, - legendwidth = 80, - margin = { top: 10, right: 60, bottom: 10, left: 2 }; + const legendheight = 200; + const legendwidth = 80; + const margin = { top: 10, right: 60, bottom: 10, left: 2 }; - var canvas = d3 + const canvas = d3 .select(selector_id) .style("height", legendheight + "px") .style("width", legendwidth + "px") @@ -104,6 +102,7 @@ class ContinuousLegend extends React.Component { super(props); this.state = {}; } + componentDidUpdate(prevProps) { if ( prevProps.colorAccessor !== this.props.colorAccessor || @@ -122,13 +121,15 @@ class ContinuousLegend extends React.Component { continuous( "#continuous_legend", d3 - .scaleSequential(d3.interpolateViridis) + .scaleSequential(interpolateViridis) .domain(this.props.colorScale.domain()) ); } } } + drawScale() {} + render() { return (
diff --git a/client/src/components/expression/diffExpHeatmap.js b/client/src/components/expression/diffExpHeatmap.js index 5e7dd448..08b2fde5 100644 --- a/client/src/components/expression/diffExpHeatmap.js +++ b/client/src/components/expression/diffExpHeatmap.js @@ -1,6 +1,7 @@ // jshint esversion: 6 import React from "react"; import _ from "lodash"; +import memoize from "memoize-one"; import { connect } from "react-redux"; import * as globals from "../../globals"; import styles from "./expression.css"; @@ -19,6 +20,7 @@ class HeatmapSquare extends React.Component { value: "" }; } + render() { const contrastColor = getContrast( this.props.backgroundColor @@ -66,6 +68,7 @@ class HeatmapRow extends React.Component { value: "" }; } + handleGeneColorScaleClick(gene) { return () => { this.props.dispatch( @@ -75,6 +78,7 @@ class HeatmapRow extends React.Component { ); }; } + handleSetGeneAsScatterplotX(gene) { return () => { this.props.dispatch({ @@ -83,6 +87,7 @@ class HeatmapRow extends React.Component { }); }; } + handleSetGeneAsScatterplotY(gene) { return () => { this.props.dispatch({ @@ -91,6 +96,7 @@ class HeatmapRow extends React.Component { }); }; } + render() { return (
{ return { differential: state.differential, - allGeneNames: state.controls.allGeneNames + world: state.controls.world }; }) class Heatmap extends React.Component { @@ -221,12 +227,18 @@ class Heatmap extends React.Component { value: "" }; } + + getAllGeneNames = memoize(world => + _.map(this.props.world.varAnnotations, "name") + ); + render() { if (!this.props.differential.diffExp) return

Select cells & compute differential to see heatmap

; const topGenesForCellSet1 = this.props.differential.diffExp.data.celllist1; const topGenesForCellSet2 = this.props.differential.diffExp.data.celllist2; + // const allGeneNames = this.getAllGeneNames(this.props.world); const extent = d3.extent( _.union( diff --git a/client/src/components/expression/expressionButtons.js b/client/src/components/expression/expressionButtons.js index b8ff8ea7..a49102c0 100644 --- a/client/src/components/expression/expressionButtons.js +++ b/client/src/components/expression/expressionButtons.js @@ -9,7 +9,9 @@ import CellSetButton from "./cellSetButtons"; @connect(state => { return { differential: state.differential, - crossfilter: state.controls.crossfilter + world: state.controls.world, + crossfilter: _.get(state.controls, "crossfilter", null), + selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null) }; }) class Expression extends React.Component { @@ -17,6 +19,7 @@ class Expression extends React.Component { super(props); this.state = {}; } + handleClick(gene) { return () => { this.props.dispatch({ @@ -25,6 +28,7 @@ class Expression extends React.Component { }); }; } + computeDiffExp() { this.props.dispatch( actions.requestDifferentialExpression( @@ -33,6 +37,7 @@ class Expression extends React.Component { ) ); } + render() { if (!this.props.differential) { return null; diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index 4d6778ba..32cfaec9 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -24,10 +24,12 @@ import FaSave from "react-icons/lib/fa/download"; @connect(state => { return { - cellsMetadata: state.controls.cellsMetadata, - opacityForDeselectedCells: state.controls.opacityForDeselectedCells, + world: state.controls.world, + crossfilter: state.controls.crossfilter, responsive: state.responsive, - crossfilter: state.controls.crossfilter + colorRGB: _.get(state.controls, "colorRGB", null), + opacityForDeselectedCells: state.controls.opacityForDeselectedCells, + selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null) }; }) class Graph extends React.Component { @@ -43,13 +45,12 @@ class Graph extends React.Component { colors: null }; this.state = { - drawn: false, svg: null, - ctx: null, brush: null, mode: "brush" }; } + reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) { regl.clear({ depth: 1, @@ -64,6 +65,7 @@ class Graph extends React.Component { view: camera.view() }); } + restartReglLoop() { const reglRender = this.state.regl.frame(() => { this.reglDraw( @@ -83,6 +85,7 @@ class Graph extends React.Component { reglRender }); } + componentDidMount() { // setup canvas and camera const camera = _camera(this.reglCanvas, { scale: true, rotate: false }); @@ -120,7 +123,16 @@ class Graph extends React.Component { reglRender }); } + componentDidUpdate(prevProps, prevState) { + const { + world, + crossfilter, + selectionUpdate, + colorRGB, + responsive + } = this.props; + if ( this.state.reglRender && this.reglRenderState === "rendering" && @@ -130,25 +142,22 @@ class Graph extends React.Component { this.reglRenderState = "paused"; } - if (this.state.regl && this.props.crossfilter) { + if (this.state.regl && world) { /* update the regl state */ - const crossfilter = this.props.crossfilter.cells; - const cells = crossfilter.all(); - const cellCount = cells.length; + const obsLayout = world.obsLayout; + const cellCount = crossfilter.size(); // X/Y positions for each point - a cached value that only // changes if we have loaded entirely new cell data // if ( !this.renderCache.positions || - this.props.crossfilter.cells != prevProps.crossfilter.cells + selectionUpdate != prevProps.selectionUpdate ) { if (!this.renderCache.positions) this.renderCache.positions = new Float32Array(2 * cellCount); - // d3.scaleLinear().domain([0,1]).range([-1,1]) const glScaleX = scaleLinear([0, 1], [-1, 1]); - // d3.scaleLinear().domain([0,1]).range([1,-1]) const glScaleY = scaleLinear([0, 1], [1, -1]); for ( @@ -156,8 +165,8 @@ class Graph extends React.Component { i < cellCount; i++ ) { - positions[2 * i] = glScaleX(cells[i].__x__); - positions[2 * i + 1] = glScaleY(cells[i].__y__); + positions[2 * i] = glScaleX(obsLayout.X[i]); + positions[2 * i + 1] = glScaleY(obsLayout.Y[i]); } this.state.pointBuffer({ data: this.renderCache.positions, @@ -171,14 +180,12 @@ class Graph extends React.Component { // could have changed for some other reason, but for now color is // the only metadata that changes client-side. If this is problematic, // we could add some sort of color-specific indicator to the app state. - if ( - !this.renderCache.colors || - this.props.cellsMetadata != prevProps.cellsMetadata - ) { + if (!this.renderCache.colors || colorRGB != prevProps.colorRGB) { + const rgb = colorRGB; if (!this.renderCache.colors) - this.renderCache.colors = new Float32Array(3 * cellCount); - for (let i = 0, colors = this.renderCache.colors; i < cellCount; i++) { - colors.set(cells[i].__colorRGB__, 3 * i); + this.renderCache.colors = new Float32Array(3 * rgb.length); + for (let i = 0, colors = this.renderCache.colors; i < rgb.length; i++) { + colors.set(rgb[i], 3 * i); } this.state.colorBuffer({ data: this.renderCache.colors, dimension: 3 }); } @@ -188,12 +195,8 @@ class Graph extends React.Component { // most property upates are due to changes driving a crossfilter // selection set change. // - if ( - !this.renderCache.sizes || - this.props.crossfilter.cells != prevProps.crossfilter.cells - ) { + if (!this.renderCache.sizes) this.renderCache.sizes = new Float32Array(cellCount); - } crossfilter.fillByIsFiltered(this.renderCache.sizes, 4, 0.2); this.state.sizeBuffer({ data: this.renderCache.sizes, dimension: 1 }); @@ -211,12 +214,10 @@ class Graph extends React.Component { } if ( - prevProps.responsive.height !== this.props.responsive.height || - prevProps.responsive.width !== this.props.responsive.width || + prevProps.responsive.height !== responsive.height || + prevProps.responsive.width !== responsive.width || /* first time */ - (this.props.responsive.height && - this.props.responsive.width && - !this.state.svg) + (responsive.height && responsive.width && !this.state.svg) ) { /* clear out whatever was on the div, even if nothing, but usually the brushes etc */ d3.select("#graphAttachPoint") @@ -225,12 +226,13 @@ class Graph extends React.Component { const { svg, brush, brushContainer } = setupSVGandBrushElements( this.handleBrushSelectAction.bind(this), this.handleBrushDeselectAction.bind(this), - this.props.responsive, + responsive, this.graphPaddingTop ); this.setState({ svg, brush, brushContainer }); } } + handleBrushSelectAction() { /* This conditional handles procedural brush deselect. Brush emits an event on procedural deselect because it is move: null */ if (d3.event.sourceEvent !== null) { @@ -280,6 +282,7 @@ class Graph extends React.Component { }); } } + handleBrushDeselectAction() { if (d3.event && !d3.event.selection) { this.props.dispatch({ @@ -295,6 +298,7 @@ class Graph extends React.Component { }); } } + handleOpacityRangeChange(e) { this.props.dispatch({ type: "change opacity deselected cells in 2d graph background", diff --git a/client/src/components/joy/drawJoy.js b/client/src/components/joy/drawJoy.js deleted file mode 100644 index e2026f33..00000000 --- a/client/src/components/joy/drawJoy.js +++ /dev/null @@ -1,163 +0,0 @@ -// jshint esversion: 6 -import styles from "./joy.css"; - -/* - via https://bl.ocks.org/armollica/3b5f83836c1de5cca7b1d35409a013e3 -*/ - -var margin = { top: 30, right: 10, bottom: 30, left: 100 }, - width = 400 - margin.left - margin.right, - height = 600 - margin.top - margin.bottom; - -// Percent two area charts can overlap -var overlap = 0.4; - -var formatTime = d3.timeFormat("%I %p"); - -var x = function(d) { - return d.time; - }, - xScale = d3.scaleTime().range([0, width]), - xValue = function(d) { - return xScale(x(d)); - }, - xAxis = d3.axisBottom(xScale).tickFormat(formatTime); - -var y = function(d) { - return d.value; - }, - yScale = d3.scaleLinear(), - yValue = function(d) { - return yScale(y(d)); - }; - -var activity = function(d) { - return d.key; - }, - activityScale = d3.scaleBand().range([0, height]), - activityValue = function(d) { - return activityScale(activity(d)); - }, - activityAxis = d3.axisLeft(activityScale); - -var area = d3 - .area() - .x(xValue) - .y1(yValue); - -var line = area.lineY1(); - -function parseTime(offset) { - var date = new Date(2017, 0, 1); // chose an arbitrary day - return d3.timeMinute.offset(date, offset); -} - -function row(d) { - return { - activity: d.activity, - time: parseTime(d.time), - value: +d.p_smooth - }; -} - -const drawJoy = data => { - console.log("drawJoy: ", data); - - var svg = d3 - .select("#joyplot") - .append("svg") - .attr("width", width + margin.left + margin.right) - .attr("height", height + margin.top + margin.bottom) - .append("g") - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - - d3.tsv( - "https://gist.githubusercontent.com/armollica/3b5f83836c1de5cca7b1d35409a013e3/raw/783d1bbc2dd3aabbfcba83ece4a670de6f1ec371/data.tsv", - row, - function(error, dataFlat) { - // Sort by time - dataFlat.sort(function(a, b) { - return a.time - b.time; - }); - - var data = d3 - .nest() - .key(function(d) { - return d.activity; - }) - .entries(dataFlat); - - // Sort activities by peak activity time - function peakTime(d) { - var i = d3.scan(d.values, function(a, b) { - return y(b) - y(a); - }); - return d.values[i].time; - } - data.sort(function(a, b) { - return peakTime(b) - peakTime(a); - }); - - console.log("sorted", data); - - xScale.domain(d3.extent(dataFlat, x)); - - activityScale.domain( - data.map(function(d) { - return d.key; - }) - ); - - var areaChartHeight = - (1 + overlap) * (height / activityScale.domain().length); - - yScale.domain(d3.extent(dataFlat, y)).range([areaChartHeight, 0]); - - area.y0(yScale(0)); - - var gActivity = svg - .append("g") - .attr("class", "activities") - .selectAll(".activity") - .data(data) - .enter() - .append("g") - .attr("class", function(d) { - return `${styles.activity} ${styles.activity["--" + d.key]}`; - }) - .attr("transform", function(d) { - var ty = activityValue(d) - activityScale.bandwidth() + 5; - return "translate(0," + ty + ")"; - }); - - gActivity - .append("path") - .attr("class", styles.area) - .datum(function(d) { - return d.values; - }) - .attr("d", area); - - gActivity - .append("path") - .attr("class", styles.line) - .datum(function(d) { - return d.values; - }) - .attr("d", line); - - svg - .append("g") - .attr("class", `${styles.axis} ${styles["axis--x"]}`) - .attr("transform", "translate(0," + height + ")") - .call(xAxis); - - svg - .append("g") - .attr("class", `${styles.axis} ${styles["axis--activity"]}`) - .call(activityAxis); - } - ); -}; - -export default drawJoy; diff --git a/client/src/components/joy/joy.css b/client/src/components/joy/joy.css deleted file mode 100644 index a888cdb5..00000000 --- a/client/src/components/joy/joy.css +++ /dev/null @@ -1,42 +0,0 @@ -svg { - display: block; - /*margin: 0 auto;*/ -} - -.axis .domain { - display: none; -} - -.axis--x text { - fill: #999; -} - -.axis--x line { - stroke: #aaa; -} - -.axis--activity .tick line { - display: none; -} - -.axis--activity text { - font-size: 12px; - fill: #000; -} - -/*.axis--activity .tick:nth-child(odd) text { - fill: #222; -}*/ - -.line { - fill: none; - stroke: #fff; -} - -.area { - fill: #448cab; -} - -.activity:nth-child(odd) .area { - fill: #5ca3c1; -} diff --git a/client/src/components/joy/joy.js b/client/src/components/joy/joy.js deleted file mode 100644 index 2ac66fe9..00000000 --- a/client/src/components/joy/joy.js +++ /dev/null @@ -1,38 +0,0 @@ -// jshint esversion: 6 -import React from "react"; -import _ from "lodash"; -import styles from "./joy.css"; -import drawJoy from "./drawJoy"; -import joyParser from "./joyParser"; - -class Joy extends React.Component { - constructor(props) { - super(props); - this.state = {}; - } - - componentWillReceiveProps(nextProps) { - if (nextProps.data) { - console.log("joyplot data 44", nextProps.data); - drawJoy(joyParser(nextProps.data)); - } - } - - componentDidMount() {} - - render() { - return ( -
-

Joy

-

- {" "} - Cell expression distribution per gene & if differential expression, - Ie., cells for cluster 5, top genes expressed by cluster 8 -

-
-
- ); - } -} - -export default Joy; diff --git a/client/src/components/joy/joyParser.js b/client/src/components/joy/joyParser.js deleted file mode 100644 index 7ccaa08a..00000000 --- a/client/src/components/joy/joyParser.js +++ /dev/null @@ -1,29 +0,0 @@ -// jshint esversion: 6 - -const joyParser = (data, count = 20) => { - const genes = []; - - /* setup */ - - for (let i = 0; i < count; i++) { - const gene = { - key: - data.genes[ - i - ] /* key values naming: https://bl.ocks.org/armollica/3b5f83836c1de5cca7b1d35409a013e3 */, - values: [] - }; - - data.cells.forEach(cell => { - gene.values.push({ - value: cell["e"][i] - }); - }); - - genes.push(gene); - } - - return genes; -}; - -export default joyParser; diff --git a/client/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js index 1807f010..8597528e 100644 --- a/client/src/components/scatterplot/scatterplot.js +++ b/client/src/components/scatterplot/scatterplot.js @@ -5,38 +5,55 @@ import React from "react"; import _ from "lodash"; import { connect } from "react-redux"; - -import scatterplot from "./scatterplot"; -import setupScatterplot from "./setupScatterplot"; -import styles from "./scatterplot.css"; +import _regl from "regl"; import * as d3 from "d3"; -import mat4 from "gl-mat4"; -import fit from "canvas-fit"; -import _camera from "../../util/camera.js"; -import _regl from "regl"; +import _camera from "../../util/camera"; + +import setupScatterplot from "./setupScatterplot"; +import styles from "./scatterplot.css"; + import _drawPoints from "./drawPointsRegl"; import { scaleLinear } from "../../util/scaleLinear"; -import { margin, width, height, createDimensions } from "./util"; +import { margin, width, height } from "./util"; @connect(state => { - const ranges = _.get(state, "cells.cells.data.ranges", null); - const metadata = _.get(state, "cells.cells.data.metadata", null); - const initializeRanges = _.get(state, "initialize.data.data.ranges", null); + const { + world, + crossfilter, + scatterplotXXaccessor, + scatterplotYYaccessor + } = state.controls; + const expressionX = + world && scatterplotXXaccessor + ? state.controls.world.varDataCache[scatterplotXXaccessor] + : null; + const expressionY = + world && scatterplotYYaccessor + ? state.controls.world.varDataCache[scatterplotYYaccessor] + : null; return { - ranges, - metadata, - initializeRanges, + world, + + colorRGB: state.controls.colorRGB, colorAccessor: state.controls.colorAccessor, colorScale: state.controls.colorScale, - scatterplotXXaccessor: state.controls.scatterplotXXaccessor, - scatterplotYYaccessor: state.controls.scatterplotYYaccessor, + + // Accessors are var/gene names (strings) + scatterplotXXaccessor, + scatterplotYYaccessor, opacityForDeselectedCells: state.controls.opacityForDeselectedCells, - crossfilter: state.controls.crossfilter, + differential: state.differential, - expression: state.expression + + expressionX, + expressionY, + + crossfilter, + // updated whenever the crossfilter selection is updated + selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null) }; }) class Scatterplot extends React.Component { @@ -46,9 +63,6 @@ class Scatterplot extends React.Component { this.axes = false; this.state = { svg: null, - // ctx: null, - axes: null, - dimensions: null, xScale: null, yScale: null }; @@ -57,19 +71,10 @@ class Scatterplot extends React.Component { componentDidMount() { const { svg } = setupScatterplot(width, height, margin); let scales; + const { expressionX, expressionY } = this.props; - /* if we've already got the data, user clicked back and forth between tabs, so render the scatterplot */ - if ( - this.props.expression && - this.props.expression.data && - this.props.scatterplotXXaccessor && - this.props.scatterplotYYaccessor - ) { - scales = this.setupScales( - this.props.expression, - this.props.scatterplotXXaccessor, - this.props.scatterplotYYaccessor - ); + if (svg && expressionX && expressionY) { + scales = Scatterplot.setupScales(expressionX, expressionY); this.drawAxesSVG(scales.xScale, scales.yScale, svg); } @@ -115,115 +120,101 @@ class Scatterplot extends React.Component { colorBuffer }); } + componentDidUpdate(prevProps) { + const { + svg, + xScale, + yScale, + regl, + pointBuffer, + colorBuffer, + sizeBuffer + } = this.state; + const { + world, + crossfilter, + scatterplotXXaccessor, + scatterplotYYaccessor, + expressionX, + expressionY, + colorRGB + } = this.props; + if ( - this.state.svg && - this.state.xScale && - this.state.yScale && - this.props.scatterplotXXaccessor && - this.props.scatterplotYYaccessor && - (this.props.scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc - this.props.scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc + world && + svg && + xScale && + yScale && + scatterplotXXaccessor && + scatterplotYYaccessor && + (scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc + scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc !this.axes) // clicked off the tab and back again, rerender ) { - this.drawAxesSVG(this.state.xScale, this.state.yScale, this.state.svg); + this.drawAxesSVG(xScale, yScale, svg); } if ( - this.props.metadata && - this.state.regl && - this.state.pointBuffer && - this.state.colorBuffer && - this.state.sizeBuffer && - this.props.expression.data && - this.props.expression.data.genes && - this.props.scatterplotXXaccessor && - this.props.scatterplotYYaccessor && - this.state.xScale && - this.state.yScale + world && + regl && + pointBuffer && + colorBuffer && + sizeBuffer && + expressionX && + expressionY && + scatterplotXXaccessor && + scatterplotYYaccessor && + xScale && + yScale ) { - const crossfilter = this.props.crossfilter.cells; - const data = this.props.expression.data; - const cells = data.cells; - const genes = data.genes; - const cellCount = cells.length; - const positions = new Float32Array(2 * cellCount); - const colors = new Float32Array(3 * cellCount); - const sizes = new Float32Array(cellCount); + const cellCount = expressionX.length; + const positionsBuf = new Float32Array(2 * cellCount); + const colorsBuf = new Float32Array(3 * cellCount); + const sizesBuf = new Float32Array(cellCount); - // d3.scaleLinear().domain([0, width]).range([-0.95, 0.95]) const glScaleX = scaleLinear([0, width], [-0.95, 0.95]); - - // d3.scaleLinear().domain([0, height]).range([-1, 1]) const glScaleY = scaleLinear([0, height], [-1, 1]); - const geneXXaccessorIndex = genes.indexOf( - this.props.scatterplotXXaccessor - ); - const geneYYaccessorIndex = genes.indexOf( - this.props.scatterplotYYaccessor - ); - /* Construct Vectors */ - for (let i = 0; i < cellCount; i++) { - const cell = cells[i]; - - positions[2 * i] = glScaleX( - this.state.xScale(cell.e[geneXXaccessorIndex]) - ); /* scale each point first to the window as we calculate extents separately below, so no need to repeat */ - positions[2 * i + 1] = glScaleY( - this.state.yScale(cell.e[geneYYaccessorIndex]) - ); + for (let i = 0; i < cellCount; i += 1) { + positionsBuf[2 * i] = glScaleX(xScale(expressionX[i])); + positionsBuf[2 * i + 1] = glScaleY(yScale(expressionY[i])); } - for (let i = 0; i < cellCount; i++) { - const metadata = this.props.metadata[i]; - colors.set(metadata.__colorRGB__, 3 * i); + for (let i = 0; i < cellCount; i += 1) { + colorsBuf.set(colorRGB[i], 3 * i); } - crossfilter.fillByIsFiltered(sizes, 4, 0.2); + crossfilter.fillByIsFiltered(sizesBuf, 4, 0.2); - this.state.pointBuffer({ data: positions, dimension: 2 }); - this.state.colorBuffer({ data: colors, dimension: 3 }); - this.state.sizeBuffer({ data: sizes, dimension: 1 }); + pointBuffer({ data: positionsBuf, dimension: 2 }); + colorBuffer({ data: colorsBuf, dimension: 3 }); + sizeBuffer({ data: sizesBuf, dimension: 1 }); this.count = cellCount; } if ( - this.props.expression && - this.props.expression.data && - this.props.scatterplotXXaccessor && - this.props.scatterplotYYaccessor && - (this.props.scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc - this.props.scatterplotYYaccessor !== prevProps.scatterplotYYaccessor) + expressionX && + expressionY && + (scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc + scatterplotYYaccessor !== prevProps.scatterplotYYaccessor) ) { - const scales = this.setupScales( - this.props.expression, - this.props.scatterplotXXaccessor, - this.props.scatterplotYYaccessor - ); + const scales = Scatterplot.setupScales(expressionX, expressionY); this.setState(scales); } } - setupScales(expression, scatterplotXXaccessor, scatterplotYYaccessor) { + + static setupScales(expressionX, expressionY) { const xScale = d3 .scaleLinear() - .domain( - d3.extent(expression.data.cells, (cell, i) => { - return cell.e[expression.data.genes.indexOf(scatterplotXXaccessor)]; - }) - ) + .domain(d3.extent(expressionX)) .range([0, width]); - const yScale = d3 .scaleLinear() - .domain( - d3.extent(expression.data.cells, cell => { - return cell.e[expression.data.genes.indexOf(scatterplotYYaccessor)]; - }) - ) + .domain(d3.extent(expressionY)) .range([height, 0]); return { @@ -231,15 +222,19 @@ class Scatterplot extends React.Component { yScale }; } + drawAxesSVG(xScale, yScale, svg) { + const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props; svg.selectAll("*").remove(); - // the axes are much cleaner and easier now. No need to rotate and orient the axis, just call axisBottom, axisLeft etc. - var xAxis = d3.axisBottom().scale(xScale); + // the axes are much cleaner and easier now. No need to rotate and orient + // the axis, just call axisBottom, axisLeft etc. + const xAxis = d3.axisBottom().scale(xScale); - var yAxis = d3.axisLeft().scale(yScale); + const yAxis = d3.axisLeft().scale(yScale); - // adding axes is also simpler now, just translate x-axis to (0,height) and it's alread defined to be a bottom axis. + // adding axes is also simpler now, just translate x-axis to (0,height) + // and it's alread defined to be a bottom axis. svg .append("g") .attr("transform", "translate(0," + height + ")") @@ -259,7 +254,7 @@ class Scatterplot extends React.Component { .attr("x", 10) .attr("y", 10) .attr("class", "label") - .text(this.props.scatterplotYYaccessor); + .text(scatterplotYYaccessor); svg .append("text") @@ -267,7 +262,7 @@ class Scatterplot extends React.Component { .attr("y", height - 10) .attr("text-anchor", "end") .attr("class", "label") - .text(this.props.scatterplotXXaccessor); + .text(scatterplotXXaccessor); } render() { diff --git a/client/src/globals.js b/client/src/globals.js index 193321c3..25cbb8f6 100644 --- a/client/src/globals.js +++ b/client/src/globals.js @@ -41,6 +41,8 @@ export const brightBlue = "#4a90e2"; export const brightGreen = "#A2D729"; export const darkGreen = "#448C4D"; +export const defaultCellColor = "rgb(0,0,0,1)"; + export const tiniestFontSize = 12; export const bolder = 700; @@ -48,8 +50,6 @@ export const bolder = 700; export let API = { // prefix: "http://api.clustering.czi.technology/api/", //prefix: "http://tabulamuris.cxg.czi.technology/api/", - // prefix: "http://pbmc3k.cxg.czi.technology/api/", - // prefix: "http://pbmc33k.cxg.czi.technology/api/", prefix: "http://api-staging.clustering.czi.technology/api/", version: "v0.1/" diff --git a/client/src/middleware/updateCellColors.js b/client/src/middleware/updateCellColors.js index ff0a393f..5d11377c 100644 --- a/client/src/middleware/updateCellColors.js +++ b/client/src/middleware/updateCellColors.js @@ -1,138 +1,115 @@ // jshint esversion: 6 -import uri from "urijs"; -import * as globals from "../globals"; import _ from "lodash"; -import { parseRGB } from "../util/parseRGB"; import * as d3 from "d3"; import { interpolateViridis } from "d3-scale-chromatic"; +import * as globals from "../globals"; +import { parseRGB } from "../util/parseRGB"; /* https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6 - storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall + storeInstance => + functionToCallWithAnActionThatWillSendItToTheNextMiddleware => + actionThatDispatchWasCalledWith => + valueToUseAsTheReturnValueOfTheDispatchCall */ /* What this file does: 1. fire a filter action anywhere in the app - 2. ** this middleware checks to see the state of all the currently selected filters, including the new one - 3. ** create updated selection from a copy of all the cells presently on the client (this may be a subset of 'all') - 4. ** append that new selection to the action so that it magically appears in the reducer just because the action was fired + 2. ** this middleware checks to see the state of all the currently selected filters, + including the new one + 3. ** create updated selection from a copy of all the cells presently on the client + (this may be a subset of 'all') + 4. ** append that new selection to the action so that it magically appears in the reducer + just because the action was fired - This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected) + This is nice because we keep a lot of filtering business logic centralized + (what it means in practice to be selected) */ -const updateCellColorsMiddleware = store => { - return next => { - return action => { - const s = store.getState(); +const updateCellColorsMiddleware = store => next => action => { + const s = store.getState(); - /* this is a hardcoded map of the things we need to keep an eye on and update global cell selection in response to */ - const filterJustChanged = - action.type === "color by expression" || - action.type === "color by continuous metadata" || - action.type === "color by categorical metadata"; + /* + this is a hardcoded map of the things we need to keep an eye on and update + global cell selection in response to + */ + const filterJustChanged = + action.type === "color by expression" || + action.type === "color by continuous metadata" || + action.type === "color by categorical metadata"; - if (!filterJustChanged || !s.controls.cellsMetadata) { - return next( - action - ); /* if the cells haven't loaded or the action wasn't a color change, bail */ - } + if (!filterJustChanged || !s.controls.world.obsAnnotations) { + return next( + action + ); /* if the cells haven't loaded or the action wasn't a color change, bail */ + } - let cellsMetadataWithUpdatedColors = s.controls.cellsMetadata.slice(0); - let colorScale; + const { obsAnnotations } = s.controls.world; + let colorScale; + const colorsByName = new Array(obsAnnotations.length); + const colorsByRGB = new Array(obsAnnotations.length); - /* - in plain language... + /* + in plain language... + (a) once the cells have loaded. + (b) each time a user changes a color control we need to update cellsMetadata colors + This is available to all the draw functions as world.colorName[index] or world.colorRGB[index] + */ - (a) once the cells have loaded. - (b) each time a user changes a color control we need to update cellsMetadata colors + if (action.type === "color by categorical metadata") { + colorScale = d3.scaleOrdinal().range(globals.ordinalColors); - This is available to all the draw functions as cell["__color__"] and cell["__colorRGB__"] - */ + for (let i = 0; i < obsAnnotations.length; i += 1) { + const obs = obsAnnotations[i]; + const c = colorScale(obs[action.colorAccessor]); + colorsByName[i] = c; + colorsByRGB[i] = parseRGB(c); + } + } - if (action.type === "color by categorical metadata") { - colorScale = d3.scaleOrdinal().range(globals.ordinalColors); + if (action.type === "color by continuous metadata") { + colorScale = d3 + .scaleLinear() + .domain([0, action.rangeMaxForColorAccessor]) + .range([1, 0]); - for (let i = 0; i < cellsMetadataWithUpdatedColors.length; i++) { - const cell = cellsMetadataWithUpdatedColors[i]; - let c = colorScale(cell[action.colorAccessor]); - cell.__color__ = c; - cell.__colorRGB__ = parseRGB(c); - } - } + for (let i = 0; i < obsAnnotations.length; i += 1) { + const obs = obsAnnotations[i]; + const c = interpolateViridis(colorScale(obs[action.colorAccessor])); + colorsByName[i] = c; + colorsByRGB[i] = parseRGB(c); + } + } - if (action.type === "color by continuous metadata") { - colorScale = d3 - .scaleLinear() - .domain([0, action.rangeMaxForColorAccessor]) - .range([1, 0]); + if (action.type === "color by expression") { + const { gene, data } = action; + const expression = data[gene]; // Float32Array + colorScale = d3 + .scaleLinear() + .domain([_.min(expression), _.max(expression)]) + .range([ + 1, + 0 + ]); /* invert viridis... probably pass this scale through to others */ - _.each(cellsMetadataWithUpdatedColors, (cell, i) => { - let c = interpolateViridis(colorScale(cell[action.colorAccessor])); - cellsMetadataWithUpdatedColors[i]["__color__"] = c; - cellsMetadataWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c); - }); - } + for (let i = 0, len = expression.length; i < len; i += 1) { + const c = interpolateViridis(colorScale(expression[i])); + colorsByName[i] = c; + colorsByRGB[i] = parseRGB(c); + } + } - if (action.type === "color by expression") { - const indexOfGene = 0; /* we only get one, this comes from server as needed now */ + /* + append the result of all the filters to the action the user just triggered + */ + const modifiedAction = Object.assign({}, action, { + colors: { name: colorsByName, rgb: colorsByRGB }, + colorScale + }); - const expressionMap = {}; - /* - converts [{cellname: cell123, e}, {}] - - expressionMap = { - cell123: [123, 2], - cell789: [0, 8] - } - */ - _.each(action.data.data.cells, cell => { - /* this action is coming directly from the server */ - expressionMap[cell.cellname] = cell.e; - }); - - const minExpressionCell = _.minBy(action.data.data.cells, cell => { - return cell.e[indexOfGene]; - }); - - const maxExpressionCell = _.maxBy(action.data.data.cells, cell => { - return cell.e[indexOfGene]; - }); - - // console.log('middle', action, expressionMap, minExpressionCell) - - colorScale = d3 - .scaleLinear() - .domain([ - minExpressionCell.e[indexOfGene], - maxExpressionCell.e[indexOfGene] - ]) - .range([ - 1, - 0 - ]); /* invert viridis... probably pass this scale through to others */ - - _.each(cellsMetadataWithUpdatedColors, (cell, i) => { - let c = interpolateViridis( - colorScale(expressionMap[cell.CellName][indexOfGene]) - ); - cellsMetadataWithUpdatedColors[i]["__color__"] = c; - cellsMetadataWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c); - }); - } - - /* - append the result of all the filters to the action the user just triggered - */ - let modifiedAction = Object.assign({}, action, { - cellsMetadataWithUpdatedColors, - colorScale - }); - - return next(modifiedAction); - }; - }; + return next(modifiedAction); }; export default updateCellColorsMiddleware; diff --git a/client/src/middleware/updateCellSelectionMiddleware.js b/client/src/middleware/updateCellSelectionMiddleware.js deleted file mode 100644 index 927f7761..00000000 --- a/client/src/middleware/updateCellSelectionMiddleware.js +++ /dev/null @@ -1,105 +0,0 @@ -// jshint esversion: 6 -import uri from "urijs"; -import * as globals from "../globals"; - -/* -XXX: this file should be obsolete. We just need to complete the refactoring -of parallel.js and it can be removed entirely. - -It is currently not in use - the middleware constructor does not include include it -*/ - -/* - https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6 - storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall -*/ - -/* - What this file does: - - 1. fire a filter action anywhere in the app - 2. ** this middleware checks to see the state of all the currently selected filters, including the new one - 3. ** create updated selection from a copy of all the cells presently on the client (this may be a subset of 'all') - 4. ** append that new selection to the action so that it magically appears in the reducer just because the action was fired - - This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected) -*/ - -const updateCellSelectionMiddleware = store => { - return next => { - return action => { - const s = store.getState(); - - /* this is a hardcoded map of the things we need to keep an eye on and update global cell selection in response to */ - const filterJustChanged = - action.type === "continuous selection using parallel coords brushing" || - action.type === "continuous metadata histogram brush" || - action.type === "graph brush selection change" || - action.type === "graph brush deselect" || - action.type === "categorical metadata filter deselect" || - action.type === "categorical metadata filter select" || - action.type === "categorical metadata filter none of these" || - action.type === "categorical metadata filter all of these"; - - if (!filterJustChanged || !s.controls.cellsMetadata) { - return next( - action - ); /* if the cells haven't loaded or the action wasn't a filter, bail */ - } - - /* - - make a FRESH copy of all of the cells - - metadata has cellname and index, and that's all we ever need to reference cell info - */ - let newSelection = s.controls.cellsMetadata.slice(0); - // _.forEach(newSelection, cell => (cell.__selected__ = true)); - for (let i = 0; i < newSelection.length; i++) { - newSelection[i].__selected__ = true; - } - - /* - in plain language... - - (a) once the cells have loaded. - (b) each time a user changes ANY control we need to update cellsMetadata - there are two states: - - 1. control state we already know about (state.foo) - 2. control states that override states we already know about (action.foo applied instead of state.foo) - - */ - - if ( - (action.type === - "continuous selection using parallel coords brushing" && - s.controls.continuousSelection) || - s.controls.continuousSelection - ) { - _.each(newSelection, (cell, i) => { - const cellExtentsAreWithinContinuousSelectionBounds = s.controls.continuousSelection.every( - active => { - // test if point is within extents for each active brush - return active.dimension.type.within( - cell[active.dimension.key], - active.extent, - active.dimension - ); - } - ); - - if (!cellExtentsAreWithinContinuousSelectionBounds) { - newSelection[i]["__selected__"] = false; - } - }); - } - - let modifiedAction = Object.assign({}, action, { - newSelection - }); /* append the result of all the filters to the action the user just triggered */ - - return next(modifiedAction); - }; - }; -}; - -export default updateCellSelectionMiddleware; diff --git a/client/src/reducers/cells.js b/client/src/reducers/cells.js deleted file mode 100644 index 57c79044..00000000 --- a/client/src/reducers/cells.js +++ /dev/null @@ -1,41 +0,0 @@ -// jshint esversion: 6 -const Cells = ( - state = { - cells: null /* world */, - loading: null, - error: null, - - allCells: null /* this comes from cells endpoint, this is universe */ - }, - action -) => { - switch (action.type) { - case "request cells started": - return Object.assign({}, state, { - loading: true, - error: null - }); - case "request cells success": - return Object.assign({}, state, { - error: null, - loading: false, - cells: action.data, // most recently loaded cells - - /* Universe - initialize once */ - allCells: state.allCells ? state.allCells : action.data - }); - case "request cells error": - return Object.assign({}, state, { - loading: false, - error: action.data - }); - case "reset graph": - return Object.assign({}, state, { - cells: state.allCells - }); - default: - return state; - } -}; - -export default Cells; diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js index 2160268a..b71e0d62 100644 --- a/client/src/reducers/controls.js +++ b/client/src/reducers/controls.js @@ -1,117 +1,44 @@ // jshint esversion: 6 + import _ from "lodash"; +import { World, kvCache } from "../util/stateManager"; import { parseRGB } from "../util/parseRGB"; -import { createSchemaByDataSniffing } from "../util/schema"; -import crossfilter from "../util/typedCrossfilter"; +import Crossfilter from "../util/typedCrossfilter"; +import * as globals from "../globals"; -// Deduce the correct crossfilter dimension type from a metadata -// schema description. -// -function deduceDimensionType(attributes, fieldName) { - let dimensionType; - if (attributes.type === "string") { - dimensionType = "enum"; - } else if (attributes.type === "int") { - dimensionType = Int32Array; - } else if (attributes.type === "float") { - dimensionType = Float32Array; - } else { - console.error( - `Warning - REST API returned unknown metadata schema (${ - attributes.type - }) for field ${fieldName}.` - ); - // skip it - we don't know what to do with this type - } - return dimensionType; -} - -// Create view state from /cells data response. Used both during a data -// load and during a graph reset. -// -function createViewState(schema, data) { - const cellsMetadata = data.metadata.slice(0); - - /* - construct a copy of the ranges object that only has categorical - replace all counts with bool flags - ie., everything starts out checked - we mutate this map in the actions below - */ - const categoricalAsBooleansMap = {}; - _.each(data.ranges, (value, key) => { - if ( - key !== "CellName" && - value.options /* it's categorical, it has options instead of ranges */ - ) { +function createCategoricalAsBooleansMap(world) { + const res = {}; + _.each(world.summary.obs, (value, key) => { + if (value.options) { const optionsAsBooleans = {}; _.each(value.options, (_value, _key) => { optionsAsBooleans[_key] = true; }); - categoricalAsBooleansMap[key] = optionsAsBooleans; + res[key] = optionsAsBooleans; } }); - - const graph = data.graph; - _.each(cellsMetadata, (cell, idx) => { - cell.__cellIndex__ = idx; - cell.__color__ = - "rgba(0,0,0,1)"; /* initial color for all cells in all charts */ - cell.__colorRGB__ = parseRGB(cell.__color__); - cell.__x__ = graph[idx][1]; - cell.__y__ = graph[idx][2]; - }); - - // Build the selection crossfilter. - // - let cellsCrossfilter = crossfilter(cellsMetadata); - let cellsDimensionsMap = {}; - cellsDimensionsMap.x = cellsCrossfilter.dimension(r => r.__x__, Float32Array); - cellsDimensionsMap.y = cellsCrossfilter.dimension(r => r.__y__, Float32Array); - - // Now walk the schema and make an appropriate dimension for each - // metadata field. This is a simplistic mapping, and could be - // optmized to use smaller scalars (to save memory) or larger - // floating point where precision is needed. - // - _.forEach(schema, (attributes, key) => { - if (key !== "CellName") { - const dimensionType = deduceDimensionType(attributes, key); - if (dimensionType) { - cellsDimensionsMap[key] = cellsCrossfilter.dimension( - r => r[key], - dimensionType - ); - } - } - }); - - return { - cellsMetadata, - crossfilter: { - cells: cellsCrossfilter, - dimensionMap: cellsDimensionsMap - }, - categoricalAsBooleansMap - }; + return res; } const Controls = ( state = { - /* Universe - all cells known to us. Set once, during initial load */ - _ranges: null /* this comes from initialize, this is universe */, - allGeneNames: null, - allCells: null /* this comes from cells endpoint, this is universe */, - allCellsMetadata: null /* this comes from cells endpoint, and is just the metadata for universe */, - allCellsMetadataMap: null, + // data loading flag + loading: false, + error: null, - /* View / World - all cells currently being displayed. May be a subset of Universe. */ - cellsMetadata: null, - crossfilter: null /* the current user selection state */, + universe: null, + + // all of the data + selection state + world: null, + colorName: null, + colorRGB: null, categoricalAsBooleansMap: null, + crossfilter: null, + dimensionMap: null, colorAccessor: null, colorScale: null, + opacityForDeselectedCells: 0.2, graphBrushSelection: null, continuousSelection: null, @@ -123,118 +50,161 @@ const Controls = ( }, action ) => { + /* + For now, log anything looking like an error to the console. + */ + if (action.error || /error/i.test(action.type)) { + console.error(action.error); + } + switch (action.type) { - /********************************** - Keep a copy of 'universe' - ***********************************/ - case "initialize success": { - if (!action.data.data.schema) { - console.error("Warning - REST API omitted schema description."); - } - return Object.assign({}, state, { - _ranges: action.data.data.ranges, - allGeneNames: action.data.data.genes, - schema: action.data.data.schema - }); + /***************************************************** + Initialization, World/Universe management + and data loading. + ******************************************************/ + case "initial data load start": { + return { ...state, loading: true }; } - case "request cells success": { - // If we don't have a schema (bad server!), fake it by inferring - // important fields from the ranges element. - // - if (!state.schema) { - state.schema = createSchemaByDataSniffing(action.data.data.ranges); - } - - /* Set viewable world to the provided cell data */ - const viewState = createViewState(state.schema, action.data.data); - return Object.assign({}, state, { - /* Universe - initialize once */ - allCells: state.allCells ? state.allCells : action.data, - allCellsMetadata: state.allCellsMetadata - ? state.allCellsMetadata - : viewState.cellsMetadata, - allCellsMetadataMap: state.allCellsMetadataMap - ? state.allCellsMetadataMap - : _.keyBy(viewState.cellsMetadata, "CellName"), - - /* World */ - ...viewState, - - graphBrushSelection: null /* if we are getting new cells from the server, the layout (probably? definitely?) just changed, so this is now irrelevant, and we WILL need to call a function to reset state of this kind when cells success happens */ - }); + case "initial data load complete (universe exists)": + case "reset World to eq Universe": { + /* first light - create world & other data-driven defaults */ + const { universe } = action; + const world = World.createWorldFromEntireUniverse(universe); + const colorName = new Array(universe.nObs).fill(globals.defaultCellColor); + const colorRGB = _.map(colorName, c => parseRGB(c)); + const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world); + const crossfilter = Crossfilter(world.obsAnnotations); + const dimensionMap = World.createObsDimensionMap(crossfilter, world); + return { + ...state, + loading: false, + error: null, + universe, + world, + colorName, + colorRGB, + categoricalAsBooleansMap, + crossfilter, + dimensionMap, + colorAccessor: null + }; } - /* * * * * * * * * * * * * * * * * * - User events - * * * * * * * * * * * * * * * * * */ - case "reset graph": { - /* Reset viewable world to the entire Universe */ - const viewState = createViewState(state.schema, state.allCells.data); - return Object.assign({}, state, { - ...viewState - }); + case "set World to current selection": { + /* Set viewable world to be the currently selected data */ + const world = World.createWorldFromCurrentSelection( + action.universe, + action.world, + action.crossfilter + ); + const colorName = new Array(world.nObs).fill(globals.defaultCellColor); + const colorRGB = _.map(colorName, c => parseRGB(c)); + const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world); + const crossfilter = Crossfilter(world.obsAnnotations); + const dimensionMap = World.createObsDimensionMap(crossfilter, world); + return { + ...state, + loading: false, + error: null, + world, + colorName, + colorRGB, + categoricalAsBooleansMap, + crossfilter, + dimensionMap, + colorAccessor: null + }; } - case "parallel coordinates axes have been drawn": { - return Object.assign({}, state, { - axesHaveBeenDrawn: true - }); - } - case "continuous selection using parallel coords brushing": { - return Object.assign({}, state, { - continuousSelection: action.data, - crossfilter: { - ...state.crossfilter + case "expression load success": { + const { world, universe } = state; + let universeVarDataCache = universe.varDataCache; + let worldVarDataCache = world.varDataCache; + _.forEach(action.expressionData, (val, key) => { + universeVarDataCache = kvCache.set(universeVarDataCache, key, val); + if (kvCache.get(worldVarDataCache, key) === undefined) { + worldVarDataCache = kvCache.set( + worldVarDataCache, + key, + World.subsetVarData(world, universe, val) + ); } }); + return { + ...state, + universe: { + ...universe, + varDataCache: universeVarDataCache + }, + world: { + ...world, + varDataCache: worldVarDataCache + } + }; + } + case "expression load error": + case "initial data load error": { + return { + ...state, + loading: false, + error: action.error + }; + } + + /******************************* + User Events + *******************************/ + case "parallel coordinates axes have been drawn": { + return { + ...state, + axesHaveBeenDrawn: true + }; + } + case "continuous selection using parallel coords brushing": { + return { + ...state, + continuousSelection: action.data + }; } case "graph brush selection change": { - state.crossfilter.dimensionMap.x.filterRange([ + state.dimensionMap.x.filterRange([ action.brushCoords.northwest[0], action.brushCoords.southeast[0] ]); - state.crossfilter.dimensionMap.y.filterRange([ + state.dimensionMap.y.filterRange([ action.brushCoords.southeast[1], action.brushCoords.northwest[1] ]); - return Object.assign({}, state, { - graphBrushSelection: action.brushCoords, - crossfilter: { - ...state.crossfilter - } - }); + return { + ...state, + graphBrushSelection: action.brushCoords + }; } case "graph brush deselect": { - state.crossfilter.dimensionMap.x.filterAll(); - state.crossfilter.dimensionMap.y.filterAll(); - return Object.assign({}, state, { - graphBrushSelection: null, - crossfilter: { - ...state.crossfilter - } - }); + state.dimensionMap.x.filterAll(); + state.dimensionMap.y.filterAll(); + return { + ...state, + graphBrushSelection: null + }; } case "continuous metadata histogram brush": { // action.selection: metadata name being selected // action.range: filter range, or null if deselected if (!action.range) { - state.crossfilter.dimensionMap[action.selection].filterAll(); + state.dimensionMap[action.selection].filterAll(); } else { - state.crossfilter.dimensionMap[action.selection].filterRange( - action.range - ); + state.dimensionMap[action.selection].filterRange(action.range); } - return Object.assign({}, state, { - crossfilter: { - ...state.crossfilter - } - }); + return { ...state }; } case "change opacity deselected cells in 2d graph background": - return Object.assign({}, state, { + return { + ...state, opacityForDeselectedCells: action.data - }); + }; + /******************************* - Categorical metadata - *******************************/ + Categorical metadata + *******************************/ case "categorical metadata filter select": { const newCategoricalAsBooleansMap = { ...state.categoricalAsBooleansMap, @@ -244,7 +214,7 @@ const Controls = ( } }; // update the filter for the one category that changed state - state.crossfilter.dimensionMap[action.metadataField].filterEnum( + state.dimensionMap[action.metadataField].filterEnum( _.filter( _.map( newCategoricalAsBooleansMap[action.metadataField], @@ -252,12 +222,10 @@ const Controls = ( ) ) ); - return Object.assign({}, state, { - categoricalAsBooleansMap: newCategoricalAsBooleansMap, - crossfilter: { - ...state.crossfilter - } - }); + return { + ...state, + categoricalAsBooleansMap: newCategoricalAsBooleansMap + }; } case "categorical metadata filter deselect": { const newCategoricalAsBooleansMap = { @@ -268,7 +236,7 @@ const Controls = ( } }; // update the filter for the one category that changed state - state.crossfilter.dimensionMap[action.metadataField].filterEnum( + state.dimensionMap[action.metadataField].filterEnum( _.filter( _.map( newCategoricalAsBooleansMap[action.metadataField], @@ -276,12 +244,10 @@ const Controls = ( ) ) ); - return Object.assign({}, state, { - categoricalAsBooleansMap: newCategoricalAsBooleansMap, - crossfilter: { - ...state.crossfilter - } - }); + return { + ...state, + categoricalAsBooleansMap: newCategoricalAsBooleansMap + }; } case "categorical metadata filter none of these": { const newCategoricalAsBooleansMap = { @@ -293,13 +259,11 @@ const Controls = ( c[k] = false; } ); - state.crossfilter.dimensionMap[action.metadataField].filterNone(); - return Object.assign({}, state, { - categoricalAsBooleansMap: newCategoricalAsBooleansMap, - crossfilter: { - ...state.crossfilter - } - }); + state.dimensionMap[action.metadataField].filterNone(); + return { + ...state, + categoricalAsBooleansMap: newCategoricalAsBooleansMap + }; } case "categorical metadata filter all of these": { const newCategoricalAsBooleansMap = { @@ -311,54 +275,50 @@ const Controls = ( c[k] = true; } ); - state.crossfilter.dimensionMap[action.metadataField].filterAll(); - return Object.assign({}, state, { - categoricalAsBooleansMap: newCategoricalAsBooleansMap, - crossfilter: { - ...state.crossfilter - } - }); + state.dimensionMap[action.metadataField].filterAll(); + return { + ...state, + categoricalAsBooleansMap: newCategoricalAsBooleansMap + }; } + /******************************* - Color Scale - *******************************/ - case "color by continuous metadata": - return Object.assign({}, state, { - colorAccessor: action.colorAccessor, - cellsMetadata: - action.cellsMetadataWithUpdatedColors /* this comes from middleware */, - colorScale: action.colorScale - }); - case "color by expression": - return Object.assign({}, state, { - colorAccessor: action.gene, - cellsMetadata: - action.cellsMetadataWithUpdatedColors /* this comes from middleware */, - colorScale: action.colorScale - }); + Color Scale + *******************************/ case "color by categorical metadata": - return Object.assign({}, state, { - colorAccessor: - action.colorAccessor /* pass the scale through additionally, and it's a legend! */, - cellsMetadata: - action.cellsMetadataWithUpdatedColors /* this comes from middleware */, + case "color by continuous metadata": { + return { + ...state, + colorName: action.colors.name, + colorRGB: action.colors.rgb, + colorAccessor: action.colorAccessor, colorScale: action.colorScale - }); - case "store current cell selection as differential set 1": - return Object.assign({}, state, { - __storedStateForCelllist1__: action.data - }); + }; + } + case "color by expression": { + return { + ...state, + colorName: action.colors.name, + colorRGB: action.colors.rgb, + colorAccessor: action.gene, + colorScale: action.colorScale + }; + } + /******************************* - Scatterplot - *******************************/ + Scatterplot + *******************************/ case "set scatterplot x": - return Object.assign({}, state, { + return { + ...state, scatterplotXXaccessor: action.data - }); + }; case "set scatterplot y": - return Object.assign({}, state, { + return { + ...state, scatterplotYYaccessor: action.data - }); + }; + default: return state; } diff --git a/client/src/reducers/differential.js b/client/src/reducers/differential.js index 5ff9a73b..751d9979 100644 --- a/client/src/reducers/differential.js +++ b/client/src/reducers/differential.js @@ -11,29 +11,34 @@ const Differential = ( ) => { switch (action.type) { case "request differential expression started": - return Object.assign({}, state, { + return { + ...state, loading: true, error: null - }); + }; case "request differential expression success": - return Object.assign({}, state, { + return { + ...state, error: null, loading: false, diffExp: action.data - }); + }; case "request differential expression error": - return Object.assign({}, state, { + return { + ...state, loading: false, error: action.data - }); + }; case "store current cell selection as differential set 1": - return Object.assign({}, state, { + return { + ...state, celllist1: action.data - }); + }; case "store current cell selection as differential set 2": - return Object.assign({}, state, { + return { + ...state, celllist2: action.data - }); + }; default: return state; } diff --git a/client/src/reducers/expression.js b/client/src/reducers/expression.js deleted file mode 100644 index aae4c760..00000000 --- a/client/src/reducers/expression.js +++ /dev/null @@ -1,33 +0,0 @@ -// jshint esversion: 6 -const Expression = ( - state = { - data: null, - loading: null, - error: null - }, - action -) => { - switch (action.type) { - case "get expression started": - return Object.assign({}, state, { - loading: true, - error: null - }); - case "get expression success": - return Object.assign({}, state, { - error: null, - loading: false, - data: action.data.data - }); - case "get expression error": - return Object.assign({}, state, { - data: null, - loading: false, - error: action.data - }); - default: - return state; - } -}; - -export default Expression; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index ea44deec..8b698890 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -1,36 +1,23 @@ // jshint esversion: 6 import { combineReducers, createStore, applyMiddleware } from "redux"; +import thunk from "redux-thunk"; import updateURLMiddleware from "../middleware/updateURLMiddleware"; -// import updateCellSelectionMiddleware from "../middleware/updateCellSelectionMiddleware"; import updateCellColors from "../middleware/updateCellColors"; -import thunk from "redux-thunk"; - -import initialize from "./initialize"; -import cells from "./cells"; -import expression from "./expression"; -import controls from "./controls"; import differential from "./differential"; import responsive from "./responsive"; +import controls from "./controls"; const Reducer = combineReducers({ - initialize, - cells, - expression, + responsive, controls, - differential, - responsive + differential }); -let store = createStore( +const store = createStore( Reducer, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), - applyMiddleware( - thunk, - updateURLMiddleware, - // updateCellSelectionMiddleware, - updateCellColors - ) + applyMiddleware(thunk, updateURLMiddleware, updateCellColors) ); export default store; diff --git a/client/src/reducers/initialize.js b/client/src/reducers/initialize.js deleted file mode 100644 index 201f98fc..00000000 --- a/client/src/reducers/initialize.js +++ /dev/null @@ -1,33 +0,0 @@ -// jshint esversion: 6 -const Initialize = ( - state = { - data: null, - loading: null, - error: null - }, - action -) => { - switch (action.type) { - case "initialize started": - return Object.assign({}, state, { - loading: true, - error: null - }); - case "initialize success": - return Object.assign({}, state, { - error: null, - loading: false, - data: action.data - }); - case "initialize error": - return Object.assign({}, state, { - data: null, - loading: false, - error: action.data - }); - default: - return state; - } -}; - -export default Initialize; diff --git a/client/src/reducers/responsive.js b/client/src/reducers/responsive.js index 406bb247..73587f16 100644 --- a/client/src/reducers/responsive.js +++ b/client/src/reducers/responsive.js @@ -8,10 +8,11 @@ const Responsive = ( ) => { switch (action.type) { case "window resize": - return Object.assign({}, state, { + return { + ...state, width: action.data.width, height: action.data.height - }); + }; default: return state; } diff --git a/client/src/util/stateManager/index.js b/client/src/util/stateManager/index.js new file mode 100644 index 00000000..a9e28593 --- /dev/null +++ b/client/src/util/stateManager/index.js @@ -0,0 +1,19 @@ +// jshint esversion: 6 + +/* +Model manager providing an abstraction for the use of the reducer code. +This module provides several buckets of functionality: + - schema and config driven tranformation of the dataframe wire protocol + into a format that is easy for the UI code to use. + - manage the universe/world abstraction: + + universe: all of the server-provided, read-only data + + world: subset of universe + - lazy access and caching of dataframe contents as needed + +This is all VERY tightly integrated with reducers and actions, and +exists to support those concepts. +*/ + +export * as Universe from "./universe"; +export * as World from "./world"; +export * as kvCache from "./keyvalcache"; diff --git a/client/src/util/stateManager/keyvalcache.js b/client/src/util/stateManager/keyvalcache.js new file mode 100644 index 00000000..aa3d8c20 --- /dev/null +++ b/client/src/util/stateManager/keyvalcache.js @@ -0,0 +1,72 @@ +// jshint esversion: 6 +import _ from "lodash"; + +/* +Very simple key/value cache for use by World & Universe. + + * constructor(lowWatermark, cachekey): + - lowWatermark defines the number of cache elements below which + flushing will not occur. + - minTTL defines minimum time in MS that cache entries will live. + A value of -1 disables automatic flushing (flush() can still + be called by external user). + - cachekey is a key that will be assigned to any value to track age + * set() - add a key/val pair. + * get() - get a value or undefined if not present. + * flush(minAgeMs) - flush cache entries in excess of lowWatermark if those + entries are older than minAgeMs. + +*/ + +const cachePrivateKey = "__kvcachekey__"; + +function create(lowWatermark = 32, minTTL = 1000) { + return { + [cachePrivateKey]: { + lowWatermark, + minTTL + } + }; +} + +function get(kvcache, key) { + const val = kvcache[key]; + if (val) { + val[cachePrivateKey] = Date.now(); + } + return val; +} + +function set(kvcache, key, val) { + const newKvCache = { ...kvcache }; + newKvCache[key] = val; + val[cachePrivateKey] = Date.now(); + flush(newKvCache, newKvCache[cachePrivateKey].minTTL); + return newKvCache; +} + +/* +Flush elements from cache IF cache size is greater than lowWatermark, and +those elements are older than minAgeMS +*/ +function flush(kvcache, minAgeMs = 0) { + if (minAgeMs < 0) return kvcache; + const eol = Date.now() - minAgeMs; + const { lowWatermark } = kvcache[cachePrivateKey]; + const keys = _(kvcache) + .keys() + .filter(k => k !== cachePrivateKey) + .filter(k => kvcache[k][cachePrivateKey] < eol) + .sortBy([k => kvcache[k][cachePrivateKey]]) + .value(); + + if (keys.length > lowWatermark) { + const numKeysToDelete = keys.length - lowWatermark; + const keysToDelete = _.slice(keys, 0, numKeysToDelete); + _.forEach(keysToDelete, k => delete kvcache[k]); + } + + return kvcache; +} + +export { create, get, set, flush }; diff --git a/client/src/util/stateManager/universe.js b/client/src/util/stateManager/universe.js new file mode 100644 index 00000000..278c7011 --- /dev/null +++ b/client/src/util/stateManager/universe.js @@ -0,0 +1,252 @@ +// jshint esversion: 6 + +import _ from "lodash"; +import * as kvCache from "./keyvalcache"; + +/* +This module implements functions that support storage of "Universe", +aka all of the var/obs data and annotations. + +These functions are used exclusively by the actions and reducers to +build an internal POJO for use by the rendering components. +*/ + +/* +Cherry pick from /api/v0.1 response format to make somethign similar +to the v0.2 schema, which we use for internal interfaces. +*/ +function RESTv01ResponseToSchema(response) { + /* + Annotation schemas in V02 (our target) look like: + + annotations: { + obs: [ + { name: "name", type: "string" }, + { name: "num_reads", type: "int32" }, + { + name: "clusters", + type: "categorical", + categories=[ 99, 1, "unknown cluster" ] + }, + { name: "QScore", type: "float32" } + ], + var: [ + { "name": "name", "type": "string" }, + { "name": "gene", "type": "string" } + ] + } + + In V01, our source, it looks like: + + "schema": { + "CellName": { + "displayname": "Name", + "include": true, + "type": "string", + "variabletype": "categorical" + }, + "Cluster_2d": { + "displayname": "Cluster2d", + "include": true, + "type": "string", + "variabletype": "categorical" + }, + "ERCC_reads": { + "displayname": "ERCC Reads", + "include": true, + "type": "int", + "variabletype": "continuous" + }, + ... + } + + Mapping between the two assumes: + - V01 only has schema for observations + - CellName is mapped to 'name' + - type conversion: float->float32, int->int32, string->string + + */ + return { + annotations: { + obs: _.map(response.data.schema, (val, key) => { + const name = key === "CellName" ? "name" : key; + let { type } = val; + if (type === "int") { + type = "int32"; + } + if (type === "float") { + type = "float32"; + } + return { + name, + type + }; + }), + var: [{ name: "name", type: "string" }] + } + }; +} + +function RESTv01ResponseToVarAnnotations(response) { + /* + v0.1 initialize response contains 'genes' - names of all genes + in order. + */ + return _.map(response.data.genes, (g, i) => ({ __varIndex__: i, name: g })); +} + +function RESTv01ResponseToObsAnnotations(response) { + /* + v0.1 format for metadata: + metadata: [ { key: val, key: val, ... }, ... ] + + Target format is essentially the same, except the CellName key becomes name. + */ + return _.map(response.data.metadata, (c, i) => ({ + __obsIndex__: i, + name: c.CellName, + ...c + })); +} + +function RESTv01ResponseToLayout(obsAnnotations, response) { + /* + v0.1 format for the graph is: + [ [ 'cellname', x, y ], [ 'cellname', x, y, ], ... ] + + NOTE XXX: this code does not assume any particular array ordering in the V0.1 + response. But for Universe initial load, the layout will be in the same + order as annotations, so this extra work isn't really necessary. + */ + + const obsAnnotationsByName = _.keyBy(obsAnnotations, "name"); + const { graph } = response.data; + const layout = { + X: new Float32Array(graph.length), + Y: new Float32Array(graph.length) + }; + + for (let i = 0; i < graph.length; i += 1) { + const [name, x, y] = graph[i]; + const anno = obsAnnotationsByName[name]; + const idx = anno.__obsIndex__; + layout.X[idx] = x; + layout.Y[idx] = y; + } + return layout; +} + +function finalize(universe) { + /* A bit of sanity checking! */ + const { nObs, nVar } = universe; + if ( + nObs !== universe.obsAnnotations.length || + nObs !== universe.obsLayout.X.length || + nObs !== universe.obsLayout.Y.length || + nVar !== universe.varAnnotations.length + ) { + throw new Error("Universe dimensionality mismatch - failed to load"); + } + + universe.obsNameToIndexMap = _.transform( + universe.obsAnnotations, + (acc, value, idx) => { + acc[value.name] = idx; + }, + {} + ); + universe.varNameToIndexMap = _.transform( + universe.varAnnotations, + (acc, value, idx) => { + acc[value.name] = idx; + }, + {} + ); + universe.finalized = true; + return universe; +} + +function templateUniverse() { + /* default universe template */ + const VarDataCacheLowWatermark = 32; + const VarDataCacheTTLMs = 1000; + + return { + api: "0.1", + finalized: true, // XXX: may not be needed + + nObs: 0, + nVar: 0, + schema: {}, + + /* + Annotations + */ + obsAnnotations: [] /* all obs annotations, by obs index */, + varAnnotations: [] /* all var annotations, by var index */, + obsNameToIndexMap: {} /* reverse map 'name' to index */, + varNameToIndexMap: {} /* reverse map 'name' to index */, + + obsLayout: { X: [], Y: [] } /* xy layout */, + + varDataCache: kvCache.create( + VarDataCacheLowWatermark, + VarDataCacheTTLMs + ) /* cache of var data (expression) */ + }; +} + +export function createUniverseFromRESTv01Response(initResponse, cellsResponse) { + /* + build & return universe from a REST 0.1 /init and /cells response + */ + + const universe = templateUniverse(); + + /* extract information from init OTA response */ + universe.schema = RESTv01ResponseToSchema(initResponse); + universe.varAnnotations = RESTv01ResponseToVarAnnotations(initResponse); + universe.nVar = universe.varAnnotations.length; + + /* extract information fron cells REST json response */ + /* + NOTE: this code *assumes* that cell order in data.metadata and data.graph + are the same. TODO: error checking. + */ + universe.obsAnnotations = RESTv01ResponseToObsAnnotations(cellsResponse); + universe.nObs = universe.obsAnnotations.length; + universe.obsLayout = RESTv01ResponseToLayout( + universe.obsAnnotations, + cellsResponse + ); + + return finalize(universe); +} + +export function convertExpressionRESTv01ToObject(universe, response) { + /* + v0.1 ota looks like: + { + genes: [ "name1", "name2", ... ], + cells: [ + { cellname: 'cell1', e: [ 3, 4, n, x, y, ... ] }, + ... + ] + } + + convert expression to a simple Float32Array, and return + [ [geneName, array], [geneName, array], ... ] + */ + const result = {}; + const { genes, cells } = response.data; + for (let idx = 0; idx < genes.length; idx += 1) { + const gene = genes[idx]; + const data = new Float32Array(universe.nObs); + for (let c = 0; c < cells.length; c += 1) { + const obsIndex = universe.obsNameToIndexMap[cells[c].cellname]; + data[obsIndex] = cells[c].e[idx]; + } + result[gene] = data; + } + return result; +} diff --git a/client/src/util/stateManager/world.js b/client/src/util/stateManager/world.js new file mode 100644 index 00000000..03c9ad2f --- /dev/null +++ b/client/src/util/stateManager/world.js @@ -0,0 +1,315 @@ +// jshint esversion: 6 + +import _ from "lodash"; +import * as kvCache from "./keyvalcache"; + +/* +World is a subset of universe. Most code should use world, and should +(generally) not use Universe. World contains any per-obs or per-var data +that must be consisstent acorss the app when we view/manipulate subsets +of Universe. + +Private API indicated by leading underscore in key name (eg, _foo). Anything else +is public. + +World contains several public keys, obsAnnotations, and obsLayout, which are +arrays contianing information about an OBS in the same order/offset. In +other words, world.obsAnnotations[0] and world.obsLayout.X[0] refer to the same +obs/cell. + +* obsAnnotations: + + obsAnnotations will return an array of objects. Each object contains all annotation + values for a given observation/cell, keyed by annotation name, PLUS a key + '__cellId__', containing a REST API ID for this obs/cell (referred to as the + obsIndex in the REST 0.2 spec or cellIndex in the 0.1 spec. + + Example: [ { __cellId__: 99, cluster: 'blue', numReads: 93933 } ] + + NOTE: world.obsAnnotation should be identical to the old state.cells value, + EXCEPT that + * __cellIndex__ renamed to __obsIndex__ + * __x__ and __y__ are now in world.obsLayout + * __color__ and __colorRBG__ should be moved to controls reducer + +* obsLayout: + + obsLayout will return an object containing two arrays, containing X and Y + coordinates respectively. + + Example: { X: [ 0.33, 0.23, ... ], Y: [ 0.8, 0.777, ... ]} + +* crossfilter - a crossfilter object across world.obsAnnotations + +* dimensionMap - an object mapping annotation names to dimensions on + the crossfilter + +*/ + +/* +Summary information for each annotation, keyed by annotation name. +Value will be an object, containing either 'range' or 'options' object, +depending on the annotation schema type (categorical or continuous). + +Summarize for BOTH obs and var annotations. Result format: + +{ + obs: { + annotation_name: { ... }, + ... + }, + var: { + annotation_name: { ... }, + ... + } +} + +Example: + { + "Splice_sites_Annotated": { + "range": { + "min": 26, + "max": 1075869 + } + }, + "Selection": { + "options": { + "Astrocytes(HEPACAM)": 714, + "Endothelial(BSC)": 123, + "Oligodendrocytes(GC)": 294, + "Neurons(Thy1)": 685, + "Microglia(CD45)": 1108, + "Unpanned": 665 + } + } + } +*/ +function summarizeAnnotations(schema, obsAnnotations) { + /* + Build and return obs/var summary using any annotation in the schema + */ + const obsSummary = _(schema.annotations.obs) + .keyBy("name") + .mapValues(anno => { + const { name, type } = anno; + const continuous = type === "int32" || type === "float32"; + + if (!continuous) { + return { + options: _.countBy(obsAnnotations, name) + }; + } + + if (continuous) { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + _.forEach(obsAnnotations, obs => { + const val = Number(obs[name]); + min = val < min ? val : min; + max = val > max ? val : max; + }); + return { range: { min, max } }; + } + + throw new Error("incomprehensible schema"); + }) + .value(); + + const varSummary = {}; // TODO XXX - not currently used, so skip it + + return { + obs: obsSummary, + var: varSummary + }; +} + +function templateWorld() { + const VarDataCacheLowWatermark = 32; + const VarDataCacheTTLMs = 1000; + + return { + // map from universe obsIndex to world offset. + // Undefined / null indicates identity mapping. + worldObsIndex: null, + + /* schema/version related */ + api: null, + schema: null, + nObs: 0, + nVar: 0, + + /* annotations */ + obsAnnotations: null, + varAnnotations: null, + + /* layout of graph */ + obsLayout: null, + + /* derived data summaries XXX: consider exploding in place */ + summary: null, + + varDataCache: kvCache.create( + VarDataCacheLowWatermark, + VarDataCacheTTLMs + ) /* cache of var data (expression) */ + }; +} + +export function createWorldFromEntireUniverse(universe) { + if (!universe.finalized) { + throw new Error("World can't be created from an partial Universe"); + } + + const world = templateWorld(); + + // map from the universe obsIndex to our world offset. + // undefined/null indicates identity map. + world.worldObsIndex = null; + + /* + public interface follows + */ + + /* Schema related */ + world.api = universe.api; + world.schema = universe.schema; + world.nObs = universe.nObs; + world.nVar = universe.nVar; + + /* annotations */ + world.obsAnnotations = universe.obsAnnotations; + world.varAnnotations = universe.varAnnotations; + + /* layout and display characteristics */ + world.obsLayout = universe.obsLayout; + + /* derived data & summaries */ + world.summary = summarizeAnnotations(world.schema, world.obsAnnotations); + + return world; +} + +export function createWorldFromCurrentSelection(universe, world, crossfilter) { + const newWorld = templateWorld(); + + /* these don't change as only OBS are selected in our current implementation */ + newWorld.api = world.api; + newWorld.nVar = world.nVar; + newWorld.schema = world.schema; + newWorld.varAnnotations = world.varAnnotations; + + /* + Subset world from universe based upon world's current selection. Only those + fields which are subset by observation selection/filtering need to be updated. + */ + const numSelected = crossfilter.countFiltered(); + + /* + Create a world which is based upon current selection + */ + newWorld.nObs = numSelected; + newWorld.obsAnnotations = new Array(numSelected); + newWorld.obsLayout = { + X: new Array(numSelected), + Y: new Array(numSelected) + }; + newWorld.worldObsIndex = new Array(universe.nObs); + + for (let i = 0, sel = 0; i < world.nObs; i += 1) { + if (crossfilter.isElementFiltered(i)) { + newWorld.obsAnnotations[sel] = world.obsAnnotations[i]; + newWorld.obsLayout.X[sel] = world.obsLayout.X[i]; + newWorld.obsLayout.Y[sel] = world.obsLayout.Y[i]; + sel += 1; + } + } + + // build index to our world offset + newWorld.worldObsIndex.fill(-1); // default - aka unused + for (let i = 0; i < newWorld.nObs; i += 1) { + newWorld.worldObsIndex[newWorld.obsAnnotations[i].__obsIndex__] = i; + } + + newWorld.summary = summarizeAnnotations( + newWorld.schema, + newWorld.obsAnnotations + ); + return newWorld; +} + +/* + Deduce the correct crossfilter dimension type from a metadata + schema description. +*/ +function deduceDimensionType(attributes, fieldName) { + let dimensionType; + if (attributes.type === "string") { + dimensionType = "enum"; + } else if (attributes.type === "int32") { + dimensionType = Int32Array; + } else if (attributes.type === "float32") { + dimensionType = Float32Array; + } else { + /* + Currently not supporting boolean and categorical types. + */ + console.error( + `Warning - REST API returned unknown metadata schema (${ + attributes.type + }) for field ${fieldName}.` + ); + // skip it - we don't know what to do with this type + } + return dimensionType; +} + +export function createObsDimensionMap(crossfilter, world) { + /* + create and return a crossfilter dimension for every obs annotation + for which we have a supported type. + */ + const { schema, obsLayout, worldObsIndex } = world; + + const dimensionMap = _.transform( + schema.annotations.obs, + (result, anno) => { + const dimType = deduceDimensionType(anno, anno.name); + if (dimType) { + result[anno.name] = crossfilter.dimension(r => r[anno.name], dimType); + } // else ignore the annotation + }, + {} + ); + + /* + Add crossfilter dimensions allowing filtering on layout + */ + const worldIndex = worldObsIndex ? idx => worldObsIndex[idx] : idx => idx; + dimensionMap.x = crossfilter.dimension( + r => obsLayout.X[worldIndex(r.__obsIndex__)], + Float32Array + ); + dimensionMap.y = crossfilter.dimension( + r => obsLayout.Y[worldIndex(r.__obsIndex__)], + Float32Array + ); + + return dimensionMap; +} + +function worldEqUniverse(world, universe) { + return world.obsAnnotations === universe.obsAnnotations; +} + +export function subsetVarData(world, universe, varData) { + // If world === universe, just return the entire varData array + if (worldEqUniverse(world, universe)) { + return varData; + } + + const newVarData = new Float32Array(world.nObs); + for (let i = 0; i < world.nObs; i += 1) { + newVarData[i] = varData[world.obsAnnotations[i].__obsIndex__]; + } + return newVarData; +} diff --git a/client/src/util/typedCrossfilter/bitArray.js b/client/src/util/typedCrossfilter/bitArray.js index 63f61826..3ee8eda9 100644 --- a/client/src/util/typedCrossfilter/bitArray.js +++ b/client/src/util/typedCrossfilter/bitArray.js @@ -1,5 +1,5 @@ -"use strict"; // jshint esversion: 6 +/* eslint no-bitwise: "off" */ // BitArray is a 2D bitarray with size [length, nBitWidth]. // Each bit is referred to as a `dimension`. Dimensions may be @@ -29,7 +29,7 @@ class BitArray { // Fixed for the life of this object. this.length = length; - // Bitarray width. width is always greater than 32*dimensionCount. + // Bitarray width. width is always greater than dimensionCount/32. this.width = 1; // underlying number of 32 bit arrays this.dimensionCount = 0; // num allocated dimensions @@ -48,10 +48,16 @@ class BitArray { // countAllOnes() { let count = 0; - for (let i = 0; i < this.width; i++) { - const bitmask = this.bitmask[i]; - for (let j = i * this.length, len = j + this.length; j < len; j++) { - if (this.bitarray[i * this.length + j] === bitmask) count++; + const { bitarray, bitmask, length, width } = this; + for (let l = 0; l < length; l += 1) { + let dimensionsSet = 0; + for (let w = 0; w < width; w += 1) { + if (bitarray[w * length + l] === bitmask[w]) { + dimensionsSet += 1; + } + } + if (dimensionsSet === width) { + count += 1; } } return count; @@ -59,10 +65,11 @@ class BitArray { // count trailing zeros - hard to do fast in JS! // https://en.wikipedia.org/wiki/Find_first_set#CTZ - static ctz(v) { + static ctz(av) { let c = 32; + let v = av; v &= -v; // isolate lowest non-zero bit - if (v) c--; + if (v) c -= 1; if (v & 0x0000ffff) c -= 16; if (v & 0x00ff00ff) c -= 8; if (v & 0x0f0f0f0f) c -= 4; @@ -74,8 +81,7 @@ class BitArray { // find a free dimension. Return undefined if none _findFreeDimension() { let dim; - for (let col = 0; col < this.width; col++) { - const bitmask = this.bitmask[col]; + for (let col = 0; col < this.width; col += 1) { const lowestZeroBit = ~this.bitmask[col] & -~this.bitmask[col]; if (lowestZeroBit) { this.bitmask[col] |= lowestZeroBit; @@ -92,7 +98,7 @@ class BitArray { // if we did not find free dimension, expand the bitarray. if (dim === undefined) { - this.width++; + this.width += 1; const biggerBitArray = new Int32Array(this.width * this.length); biggerBitArray.set(this.bitarray); @@ -105,7 +111,7 @@ class BitArray { dim = this._findFreeDimension(); } - this.dimensionCount++; + this.dimensionCount += 1; return dim; } @@ -117,17 +123,15 @@ class BitArray { this.deselectAll(dim); const col = dim >>> 5; this.bitmask[col] &= ~(1 << dim % 32); - this.dimensionCount--; + this.dimensionCount -= 1; } // return true if this index is selected in ALL dimensions. // isSelected(index) { - const width = this.width; - const length = this.length; - const bitarray = this.bitarray; + const { width, length, bitarray } = this; - for (let w = 0; w < width; w++) { + for (let w = 0; w < width; w += 1) { const bitmask = this.bitmask[w]; if (!bitmask || bitarray[w * length + index] !== bitmask) return false; } @@ -140,20 +144,19 @@ class BitArray { const ignoreOffset = dim >>> 5; const ignoreMask = ~(1 << dim % 32); - const width = this.width; - const length = this.length; - const bitarray = this.bitarray; + const { width, length, bitarray } = this; - for (let w = 0; w < width; w++) { + for (let w = 0; w < width; w += 1) { const bitmask = this.bitmask[w]; if (w === ignoreOffset) { if ( bitmask && (bitarray[w * length + index] & ignoreMask) !== (bitmask & ignoreMask) - ) + ) { return false; - } else { - if (bitmask && bitarray[w * length + index] !== bitmask) return false; + } + } else if (bitmask && bitarray[w * length + index] !== bitmask) { + return false; } } return true; @@ -180,24 +183,20 @@ class BitArray { // select all indices on dimension. // selectAll(dim) { - let col = dim >> 5; - const bitmask = this.bitmask[col]; - const bitarray = this.bitarray; + const col = dim >> 5; const one = 1 << dim % 32; - for (let i = col * this.length, len = i + this.length; i < len; i++) { - bitarray[i] |= one; + for (let i = col * this.length, len = i + this.length; i < len; i += 1) { + this.bitarray[i] |= one; } } // deselect all indices on dimension // deselectAll(dim) { - let col = dim >> 5; - const bitmask = this.bitmask[col]; - const bitarray = this.bitarray; + const col = dim >> 5; const zero = ~(1 << dim % 32); - for (let i = col * this.length, len = i + this.length; i < len; i++) { - bitarray[i] &= zero; + for (let i = col * this.length, len = i + this.length; i < len; i += 1) { + this.bitarray[i] &= zero; } } @@ -208,11 +207,10 @@ class BitArray { const col = dim >>> 5; const first = range[0]; const last = range[1]; - const bitarray = this.bitarray; const one = 1 << dim % 32; const offset = col * this.length; - for (let i = first; i < last; i++) { - bitarray[offset + indirect[i]] |= one; + for (let i = first; i < last; i += 1) { + this.bitarray[offset + indirect[i]] |= one; } } @@ -222,11 +220,10 @@ class BitArray { const col = dim >>> 5; const first = range[0]; const last = range[1]; - const bitarray = this.bitarray; const zero = ~(1 << dim % 32); const offset = col * this.length; - for (let i = first; i < last; i++) { - bitarray[offset + indirect[i]] &= zero; + for (let i = first; i < last; i += 1) { + this.bitarray[offset + indirect[i]] &= zero; } } @@ -237,13 +234,14 @@ class BitArray { // special case (width === 1) for performance if (this.width === 1) { const bitmask = this.bitmask[0]; - const bitarray = this.bitarray; - for (let i = 0, len = this.length; i < len; i++) { + for (let i = 0, len = this.length; i < len; i += 1) { result[i] = - bitmask && bitarray[i] === bitmask ? selectedValue : deselectedValue; + bitmask && this.bitarray[i] === bitmask + ? selectedValue + : deselectedValue; } } else { - for (let i = 0, len = this.length; i < len; i++) { + for (let i = 0, len = this.length; i < len; i += 1) { result[i] = this.isSelected(i) ? selectedValue : deselectedValue; } } diff --git a/client/src/util/typedCrossfilter/index.js b/client/src/util/typedCrossfilter/index.js index f12d4027..64b7617b 100644 --- a/client/src/util/typedCrossfilter/index.js +++ b/client/src/util/typedCrossfilter/index.js @@ -1,4 +1,3 @@ -"use strict"; // jshint esversion: 6 /* @@ -35,7 +34,6 @@ import { fillRange, lowerBound, lowerBoundIndirect, - upperBound, upperBoundIndirect } from "./util"; @@ -57,6 +55,7 @@ class TypedCrossfilter { // filters: array of { id, dimension } this.filters = []; this.selection = new BitArray(data.length); + this.updateTime = 0; } size() { @@ -82,15 +81,15 @@ class TypedCrossfilter { _freeDimension(id) { this.selection.freeDimension(id); - this.filters = this.filters.filter(f => f._id != id); + this.filters = this.filters.filter(f => f._id !== id); } // return array of all records that are selected/filtered // by all dimensions. allFiltered() { - const selection = this.selection; + const { selection } = this; const res = []; - for (let i = 0, len = this.data.length; i < len; i++) { + for (let i = 0, len = this.data.length; i < len; i += 1) { if (selection.isSelected(i)) { res.push(this.data[i]); } @@ -120,8 +119,8 @@ class TypedCrossfilter { // and value array must be a TypedArray. // class ScalarDimension { - constructor(value, valueArrayType, crossfilter, id) { - this.crossfilter = crossfilter; + constructor(value, ValueArrayType, xfltr, id) { + this.crossfilter = xfltr; this._id = id; // current selection filter, expressed as PostiveIntervals. @@ -130,7 +129,7 @@ class ScalarDimension { // Create value array const array = this._createValueArray( value, - new valueArrayType(this.crossfilter.data.length) + new ValueArrayType(this.crossfilter.data.length) ); this.value = array; @@ -144,12 +143,13 @@ class ScalarDimension { _createValueArray(value, array) { // create dimension value array - const data = this.crossfilter.data; + const { data } = this.crossfilter; const len = data.length; - for (let i = 0; i < len; i++) { - array[i] = value(data[i]); + const larray = array; + for (let i = 0; i < len; i += 1) { + larray[i] = value(data[i]); } - return array; + return larray; } dispose() { @@ -164,10 +164,10 @@ class ScalarDimension { // Argument is an array of intervals indicating records newly selected/filtered // _updateFilters(newFilter) { - newFilter = PositiveIntervals.canonicalize(newFilter); + const cNewFilter = PositiveIntervals.canonicalize(newFilter); - const adds = PositiveIntervals.difference(newFilter, this.currentFilter); - const dels = PositiveIntervals.difference(this.currentFilter, newFilter); + const adds = PositiveIntervals.difference(cNewFilter, this.currentFilter); + const dels = PositiveIntervals.difference(this.currentFilter, cNewFilter); this.crossfilter.filters.forEach(f => f.dim.groups.forEach(grp => grp._updateReduceDel(this, dels)) @@ -193,7 +193,8 @@ class ScalarDimension { f.dim.groups.forEach(grp => grp._updateReduceAdd(this, adds)) ); - this.currentFilter = newFilter; + this.currentFilter = cNewFilter; + this.crossfilter.updateTime += 1; } // filter by value - exact match @@ -213,7 +214,7 @@ class ScalarDimension { // filter by a set of values, eg. enum. filterEnum(values) { const newFilter = []; - for (let v = 0, len = values.length; v < len; v++) { + for (let v = 0, len = values.length; v < len; v += 1) { const intv = [ lowerBoundIndirect( this.value, @@ -269,9 +270,8 @@ class ScalarDimension { // return top k records, starting with offset, in descending order. // Order is this dimension's sort order top(k, offset = 0) { - const data = this.crossfilter.data; - const selection = this.crossfilter.selection; - const index = this.index; + const { data, selection } = this.crossfilter; + const { index } = this; const len = index.length; const ret = []; let i = 0; @@ -279,17 +279,17 @@ class ScalarDimension { let found = 0; // skip up to offset records - for (i = len - 1; 0 <= i && skip < offset; i--) { + for (i = len - 1; 0 <= i && skip < offset; i -= 1) { if (selection.isSelected(index[i])) { - skip++; + skip += 1; } } // grab up to k records - for (; 0 <= i && found < k; i--) { + for (; 0 <= i && found < k; i -= 1) { if (selection.isSelected(index[i])) { ret.push(data[index[i]]); - found++; + found += 1; } } @@ -299,9 +299,8 @@ class ScalarDimension { // return bottom k records, starting with offset, in ascending order. // Order is this dimension's sort order bottom(k, offset = 0) { - const data = this.crossfilter.data; - const selection = this.crossfilter.selection; - const index = this.index; + const { data, selection } = this.crossfilter; + const { index } = this; const len = index.length; const ret = []; let skip = 0; @@ -309,17 +308,17 @@ class ScalarDimension { let i = 0; // skip up to offset records - for (i = 0; i < len && skip < offset; i++) { + for (i = 0; i < len && skip < offset; i += 1) { if (selection.isSelected(index[i])) { - skip++; + skip += 1; } } // grab up to k records - for (; i < len && found < k; i++) { + for (; i < len && found < k; i += 1) { if (selection.isSelected(index[i])) { ret.push(data[index[i]]); - found++; + found += 1; } } @@ -341,18 +340,19 @@ class ScalarDimension { // strings, which can be mapped into an fixed numeric range [0..n). // class EnumDimension extends ScalarDimension { - constructor(value, crossfilter, id) { - super(value, Uint32Array, crossfilter, id); + constructor(value, xfltr, id) { + super(value, Uint32Array, xfltr, id); } _createValueArray(value, array) { - const data = this.crossfilter.data; + const { data } = this.crossfilter; const len = data.length; + const larray = array; // create enumeration table - mapping between the value // and the enum. const s = new Set(); - for (let i = 0; i < len; i++) { + for (let i = 0; i < len; i += 1) { s.add(value(data[i])); } this.enumIndex = Array.from(s); @@ -360,12 +360,12 @@ class EnumDimension extends ScalarDimension { // create dimension value array const enumLen = this.enumIndex.length; - for (let i = 0; i < len; i++) { + for (let i = 0; i < len; i += 1) { const v = value(data[i]); const e = lowerBound(this.enumIndex, v, 0, enumLen); - array[i] = e; + larray[i] = e; } - return array; + return larray; } filterExact(value) { @@ -415,7 +415,7 @@ class ScalarGroup { // internal support function - map all dimension values to group values. // - _map(groupValue, groupValueType, dimension) { + static _map(groupValue, GroupValueType, dimension) { // groupValue is optional. Defaults to identity. Used to perform // initial map operation. // @@ -424,8 +424,8 @@ class ScalarGroup { const data = dimension.value; const len = data.length; - const mapValue = new groupValueType(dimension.value.length); - for (let i = 0; i < len; i++) { + const mapValue = new GroupValueType(dimension.value.length); + for (let i = 0; i < len; i += 1) { mapValue[i] = groupValue(data[i]); } return mapValue; @@ -444,10 +444,9 @@ class ScalarGroup { // Each item in the range was just added to `dim`. It was NOT previously // selected - reduceAdd if it is now selected. - const selection = this.dimension.crossfilter.selection; - const data = this.dimension.crossfilter.data; + const { data, selection } = this.dimension.crossfilter; intv.forEach(rng => { - for (let r = rng[0]; r < rng[1]; r++) { + for (let r = rng[0]; r < rng[1]; r += 1) { const i = dim.index[r]; if (selection.isSelectedIgnoringDim(i, this.dimension.id())) { const group = this.groups[this.groupIndex[i]]; @@ -470,10 +469,9 @@ class ScalarGroup { // Each item in the range will be remved from `dim`. reduceRemove if it // is currently selected. - const selection = this.dimension.crossfilter.selection; - const data = this.dimension.crossfilter.data; + const { data, selection } = this.dimension.crossfilter.selection; intv.forEach(rng => { - for (let r = rng[0]; r < rng[1]; r++) { + for (let r = rng[0]; r < rng[1]; r += 1) { const i = dim.index[r]; if (selection.isSelectedIgnoringDim(i, this.dimension.id())) { const group = this.groups[this.groupIndex[i]]; @@ -487,8 +485,8 @@ class ScalarGroup { // groups data. // _reduce() { - const dimension = this.dimension; - const data = dimension.crossfilter.data; + const { dimension } = this; + const { data } = dimension.crossfilter; // Create groups const groupNames = new Set(this.mapValue); @@ -500,13 +498,13 @@ class ScalarGroup { }); // Create groupIndex - index map between data record index and group index - for (let i = 0, len = this.mapValue.length; i < len; i++) { + for (let i = 0, len = this.mapValue.length; i < len; i += 1) { this.groupIndex[i] = groupIndexByName[this.mapValue[i]]; } // reduce all filtered records, IGNORING the current dimension's filter - const selection = dimension.crossfilter.selection; - for (let i = 0, len = data.length; i < len; i++) { + const { selection } = dimension.crossfilter; + for (let i = 0, len = data.length; i < len; i += 1) { if (selection.isSelectedIgnoringDim(i, dimension.id())) { const group = this.groups[this.groupIndex[i]]; group.value = this.reduceAdd(group.value, data[i]); @@ -554,11 +552,7 @@ class ScalarGroup { } class EnumGroup extends ScalarGroup { - constructor(groupValue, groupValueType, dimension) { - super(groupValue, groupValueType, dimension); - } - - _map(groupValue, groupValueType, dimension) { + static _map(groupValue, groupValueType, dimension) { // groupValue is optional. Defaults to identity. Used to perform // initial map operation. // diff --git a/client/src/util/typedCrossfilter/positiveIntervals.js b/client/src/util/typedCrossfilter/positiveIntervals.js index 66742faa..1578812f 100644 --- a/client/src/util/typedCrossfilter/positiveIntervals.js +++ b/client/src/util/typedCrossfilter/positiveIntervals.js @@ -1,4 +1,3 @@ -"use strict"; // jshint esversion: 6 // Interval operations - very simple version of interval set relationship @@ -21,11 +20,11 @@ class PositiveIntervals { // static canonicalize(A) { if (A.length <= 1) return A; - let copy = A.slice(); + const copy = A.slice(); copy.sort((a, b) => a[0] - b[0]); const res = []; res.push(copy[0]); - for (let i = 1, len = copy.length; i < len; i++) { + for (let i = 1, len = copy.length; i < len; i += 1) { if (copy[i][0] > res[res.length - 1][1]) { // non-overlapping, add to result res.push(copy[i]); @@ -45,12 +44,12 @@ class PositiveIntervals { } static _flatten(A, B) { - let points = []; /* point, A, start */ - for (let a = 0; a < A.length; a++) { + const points = []; /* point, A, start */ + for (let a = 0; a < A.length; a += 1) { points.push([A[a][0], true, true]); points.push([A[a][1], true, false]); } - for (let b = 0; b < B.length; b++) { + for (let b = 0; b < B.length; b += 1) { points.push([B[b][0], false, true]); points.push([B[b][1], false, false]); } @@ -68,18 +67,16 @@ class PositiveIntervals { return PositiveIntervals.canonicalize(A); } - A = PositiveIntervals.canonicalize(A); - B = PositiveIntervals.canonicalize(B); + const cA = PositiveIntervals.canonicalize(A); + const cB = PositiveIntervals.canonicalize(B); - const points = PositiveIntervals._flatten(A, B); + const points = PositiveIntervals._flatten(cA, cB); const res = []; let aDepth = 0; let depth = 0; let intervalStart; - let prevPoint; - for (let i = 0; i < points.length; i++) { + for (let i = 0; i < points.length; i += 1) { const p = points[i]; - const before = depth; const delta = p[2] ? 1 : -1; depth += delta; if (p[1]) aDepth += delta; @@ -92,7 +89,6 @@ class PositiveIntervals { intervalStart = undefined; } } - prevPoint = p[0]; } // guaranteed to be in canonical form return res; @@ -106,16 +102,15 @@ class PositiveIntervals { return []; } - A = PositiveIntervals.canonicalize(A); - B = PositiveIntervals.canonicalize(B); + const cA = PositiveIntervals.canonicalize(A); + const cB = PositiveIntervals.canonicalize(B); - const points = PositiveIntervals._flatten(A, B); + const points = PositiveIntervals._flatten(cA, cB); const res = []; let depth = 0; let intervalStart; - for (let i = 0; i < points.length; i++) { + for (let i = 0; i < points.length; i += 1) { const p = points[i]; - const before = depth; depth += p[2] ? 1 : -1; if (depth === 2) { intervalStart = p[0]; diff --git a/client/src/util/typedCrossfilter/util.js b/client/src/util/typedCrossfilter/util.js index 1f1f148c..028b6db4 100644 --- a/client/src/util/typedCrossfilter/util.js +++ b/client/src/util/typedCrossfilter/util.js @@ -1,5 +1,5 @@ -"use strict"; // jshint esversion: 6 +/* eslint no-bitwise: "off" */ /* Utility functions, private to this module. @@ -9,10 +9,11 @@ // starting with `start` // export function fillRange(arr, start = 0) { - for (let i = 0, len = arr.length; i < len; i++) { - arr[i] = i + start; + const larr = arr; + for (let i = 0, len = larr.length; i < len; i += 1) { + larr[i] = i + start; } - return arr; + return larr; } // Search for `value` in the sorted array `arr`, in the range [first, last). @@ -31,31 +32,35 @@ export function fillRange(arr, start = 0) { // a special-cased version for lining the indirection). // export function lowerBound(valueArray, value, first, last) { + let lfirst = first; + let llast = last; // this is just a binary search - while (first < last) { - const middle = (first + last) >>> 1; + while (lfirst < llast) { + const middle = (lfirst + llast) >>> 1; if (valueArray[middle] < value) { - first = middle + 1; + lfirst = middle + 1; } else { - last = middle; + llast = middle; } } - return first; + return lfirst; } // Inlined performance optimization - used to indirect through a sort map. // export function lowerBoundIndirect(valueArray, indexArray, value, first, last) { + let lfirst = first; + let llast = last; // this is just a binary search - while (first < last) { - const middle = (first + last) >>> 1; + while (lfirst < llast) { + const middle = (lfirst + llast) >>> 1; if (valueArray[indexArray[middle]] < value) { - first = middle + 1; + lfirst = middle + 1; } else { - last = middle; + llast = middle; } } - return first; + return lfirst; } // Search for `value in the sorted array `arr`, in the range [first, last). @@ -70,29 +75,33 @@ export function lowerBoundIndirect(valueArray, indexArray, value, first, last) { // Python: bisect.bisect_right() // export function upperBound(valueArray, value, first, last) { + let lfirst = first; + let llast = last; // this is just a binary search - while (first < last) { - const middle = (first + last) >>> 1; + while (lfirst < llast) { + const middle = (lfirst + llast) >>> 1; if (valueArray[middle] > value) { - last = middle; + llast = middle; } else { - first = middle + 1; + lfirst = middle + 1; } } - return first; + return lfirst; } // Inline performance optimization // export function upperBoundIndirect(valueArray, indexArray, value, first, last) { + let lfirst = first; + let llast = last; // this is just a binary search - while (first < last) { - const middle = (first + last) >>> 1; + while (lfirst < llast) { + const middle = (lfirst + llast) >>> 1; if (valueArray[indexArray[middle]] > value) { - last = middle; + llast = middle; } else { - first = middle + 1; + lfirst = middle + 1; } } - return first; + return lfirst; }