diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d0f9ddc1..0d09f24d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.15.0 +current_version = 0.16.0 [bumpversion:file:setup.py] search = version="{current_version}" @@ -12,4 +12,3 @@ replace = "version": "{new_version}" [bumpversion:file:server/__init__.py] search = __version__ = "{current_version}" replace = __version__ = "{new_version}" - diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md new file mode 100644 index 00000000..099be093 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -0,0 +1,32 @@ +--- +name: "\U0001F41E Bug report" +about: Create a report to identify a bug in cellxgene +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Version (please complete the following information):** + - Desktop or hosted?: + - Browser (if hosted) [e.g. chrome, safari]: + - Version [e.g. 0.13.0]: + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md new file mode 100644 index 00000000..4983c5a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---feature-request.md @@ -0,0 +1,20 @@ +--- +name: "\U0001F4A1 Feature request" +about: Suggest an idea for this project +title: "[FEATURE REQUEST]" +labels: user request +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/---question-clarification.md b/.github/ISSUE_TEMPLATE/---question-clarification.md new file mode 100644 index 00000000..aefca3a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---question-clarification.md @@ -0,0 +1,10 @@ +--- +name: "\U0001F9D0 Question/Clarification" +about: Ask a question or for a clarification +title: "[QUESTION]" +labels: question +assignees: '' + +--- + + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 48d1923a..aafcabd5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,4 +10,4 @@ jobs: steps: - name: repository dispatch run: | - curl -XPOST -u mdunitz:${{secrets.SCINFRA_TOKEN}} -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" https://api.github.com/repos/chanzuckerberg/single-cell-infra/dispatches --data '{"event_type": "cellxgene-hook"}' + curl -XPOST -u czi-sci-single-cell-eng:${{secrets.SCI_GITHUB_TOKEN}} -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" https://api.github.com/repos/chanzuckerberg/single-cell-infra/dispatches --data '{"event_type": "cellxgene-hook"}' diff --git a/.github/workflows/push_tests.yml b/.github/workflows/push_tests.yml index 8324af42..b4cfe599 100644 --- a/.github/workflows/push_tests.yml +++ b/.github/workflows/push_tests.yml @@ -73,6 +73,7 @@ jobs: smoke-tests: runs-on: macos-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v2 - name: Set up Python 3.7 @@ -102,6 +103,7 @@ jobs: smoke-tests-annotations: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v2 - name: Set up Python 3.7 diff --git a/.gitignore b/.gitignore index 509d9153..4de2274c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ __pycache__ *.DS_Store* data tags +myconfig.yaml # Jekyll docs/_site/ diff --git a/Dockerfile b/Dockerfile index c25e6cde..dd695256 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 RUN apt-get update && \ - apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev python3-requests && \ + apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev python3-requests python3-aiohttp && \ pip3 install cellxgene ENTRYPOINT ["cellxgene"] diff --git a/Makefile b/Makefile index 0fe4633f..9c0eb686 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,11 @@ smoke-test: smoke-test-annotations: cd client && $(MAKE) smoke-test-annotations +.PHONY: test-db +test-db: + cd server && $(MAKE) test-db + + # FORMATTING CODE .PHOHY: fmt diff --git a/Procfile b/Procfile deleted file mode 100644 index 4f7adcf3..00000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn --chdir server/eb app:application --log-file - diff --git a/README.md b/README.md index 2330f20f..18f6fdc3 100644 --- a/README.md +++ b/README.md @@ -14,26 +14,30 @@ Whether you need to visualize one thousand cells or one million, cellxgene helps # Getting started + ### The comprehensive guide to cellxgene + [The cellxgene documentation is your one-stop-shop for information about cellxgene](https://chanzuckerberg.github.io/cellxgene/)! You may be particularly interested in: -* Seeing [what cellxgene can do](https://chanzuckerberg.github.io/cellxgene/posts/gallery) -* Learning more about cellxgene [installation](https://chanzuckerberg.github.io/cellxgene/posts/install) and [usage](https://chanzuckerberg.github.io/cellxgene/posts/launch) -* [Preparing your own data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) for use in cellxgene -* Checking out [our roadmap](https://chanzuckerberg.github.io/cellxgene/posts/roadmap) for future development -* [Contributing](https://chanzuckerberg.github.io/cellxgene/posts/contribute) to cellxgene + +- Seeing [what cellxgene can do](https://chanzuckerberg.github.io/cellxgene/posts/gallery) +- Learning more about cellxgene [installation](https://chanzuckerberg.github.io/cellxgene/posts/install) and [usage](https://chanzuckerberg.github.io/cellxgene/posts/launch) +- [Preparing your own data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) for use in cellxgene +- Checking out [our roadmap](https://chanzuckerberg.github.io/cellxgene/posts/roadmap) for future development +- [Contributing](https://chanzuckerberg.github.io/cellxgene/posts/contribute) to cellxgene ### Quick start To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/posts/install) Install the package. -``` bash + +```bash pip install cellxgene ``` Launch cellxgene with an example [anndata](https://anndata.readthedocs.io/en/latest/) file -``` bash +```bash cellxgene launch https://cellxgene-example-data.czi.technology/pbmc3k.h5ad ``` @@ -41,6 +45,17 @@ To explore more datasets already formatted for cellxgene, check out the [Demo da see [Preparing your data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) to learn more about formatting your own data for cellxgene. +### Supported browsers + +cellxgene currently supports the following browsers: + +- Google Chrome 61+ +- Edge 15+ +- Firefox 60+ +- Safari 10.1+ + +Please [file an issue](https://github.com/chanzuckerberg/cellxgene/issues/new/choose) if you would like us to add support for an unsupported browser. + ### Finding help We'd love to hear from you! @@ -51,6 +66,7 @@ For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxg # Developing with cellxgene ### Contributing + We warmly welcome contributions from the community! Please see our [contributing guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute) and don't hesitate to open an issue or send a pull request to improve cellxgene. This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com. @@ -64,6 +80,7 @@ This project was started with the sole goal of empowering the scientific communi If you believe you have found a security issue, we would appreciate notification. Please send email to . # About + ### Core team The current core team: diff --git a/app.json b/app.json deleted file mode 100644 index 107f8f27..00000000 --- a/app.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "cellxgene", - "description": "An interactive explorer for single-cell transcriptomics data", - "repository": "https://github.com/chanzuckerberg/cellxgene", - "logo": "https://cellxgene-example-data.czi.technology/favicon.png", - "keywords": [ - "scientific", - "visualization", - "scrna-seq", - "transcriptomics", - "dataviz" - ], - "buildpacks": [ - { - "url": "heroku/nodejs" - }, - { - "url": "heroku/python" - } - ], - "stack": "heroku-18", - "env": { - "DATASET": { - "description": "Link to dataset", - "value": "https://cellxgene-example-data.czi.technology/pbmc3k.h5ad", - "required": "true" - } - } -} diff --git a/client/Makefile b/client/Makefile index 5033053f..3b36337a 100644 --- a/client/Makefile +++ b/client/Makefile @@ -1,6 +1,6 @@ include ../common.mk -ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../server/test/test_datasets/pbmc3k-annotations.csv) +ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../server/test/fixtures/pbmc3k-annotations.csv) ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS)) # Packaging diff --git a/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap b/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap index 3ad14bca..67b743ce 100644 --- a/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap +++ b/client/__tests__/e2e/__snapshots__/e2eAnnotations.test.js.snap @@ -3,7 +3,7 @@ exports[`annotations stacked bar graph renders 1`] = ` Array [ "
TEST-LABELLABEL
0
", - "
unassignedigned
2132
", + "
unassignedigned
2133
", ] `; @@ -13,3 +13,11 @@ Array [ "
unassignedigned
2638
", ] `; + +exports[`annotations truncate midpoint whitespace 1`] = `"123 456 456"`; + +exports[`annotations truncate midpoint whitespace 2`] = `"123 456 456"`; + +exports[`annotations truncate single character 1`] = `"T"`; + +exports[`annotations truncate single character 2`] = `"T"`; diff --git a/client/__tests__/e2e/data.js b/client/__tests__/e2e/data.js index b7f8db77..9796b96b 100644 --- a/client/__tests__/e2e/data.js +++ b/client/__tests__/e2e/data.js @@ -27,7 +27,7 @@ export const datasets = { lasso: [ { "coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 }, - count: "1173", + count: "1131", }, ], categorical: [ @@ -121,8 +121,8 @@ export const datasets = { }, newCount: { bySubsetConfig: { - false: "599", - true: "594", + false: "668", + true: "659", }, }, }, diff --git a/client/__tests__/e2e/e2eAnnotations.test.js b/client/__tests__/e2e/e2eAnnotations.test.js index e284845c..e998da6b 100644 --- a/client/__tests__/e2e/e2eAnnotations.test.js +++ b/client/__tests__/e2e/e2eAnnotations.test.js @@ -297,6 +297,28 @@ describe.each([ expect(result).toMatchSnapshot(); }); + test("truncate midpoint whitespace", async () => { + await setup(config); + const newLabelName = "123 456"; + await renameLabel(perTestCategoryName, perTestLabelName, newLabelName); + const value = await waitByID( + `categorical-value-${perTestCategoryName}-${newLabelName}` + ); + const result = await page.evaluate((elem) => elem.outerHTML, value); + expect(result).toMatchSnapshot(); + }); + + test("truncate single character", async () => { + await setup(config); + const newLabelName = "T"; + await renameLabel(perTestCategoryName, perTestLabelName, newLabelName); + const value = await waitByID( + `categorical-value-${perTestCategoryName}-${newLabelName}` + ); + const result = await page.evaluate((elem) => elem.outerHTML, value); + expect(result).toMatchSnapshot(); + }); + async function assertCategoryExists(categoryName) { const handle = await waitByID(`${categoryName}:category-label`); diff --git a/client/configuration/babel/babel.dev.js b/client/configuration/babel/babel.dev.js index 8f32d635..117f6da5 100644 --- a/client/configuration/babel/babel.dev.js +++ b/client/configuration/babel/babel.dev.js @@ -2,7 +2,14 @@ module.exports = { babelrc: false, cacheDirectory: true, presets: [ - ["modern-browsers", { loose: true, modules: false }], + [ + "@babel/preset-env", + { + useBuiltIns: "entry", + corejs: 3, + modules: false, + }, + ], "@babel/preset-react", ], plugins: [ diff --git a/client/configuration/babel/babel.prod.js b/client/configuration/babel/babel.prod.js index 604e2ef2..620cd563 100644 --- a/client/configuration/babel/babel.prod.js +++ b/client/configuration/babel/babel.prod.js @@ -1,7 +1,14 @@ module.exports = { babelrc: false, presets: [ - ["modern-browsers", { loose: true, modules: false }], + [ + "@babel/preset-env", + { + useBuiltIns: "entry", + corejs: 3, + modules: false, + }, + ], "@babel/preset-react", ], plugins: [ diff --git a/client/configuration/eslint/eslint.js b/client/configuration/eslint/eslint.js index a1189581..66637c26 100644 --- a/client/configuration/eslint/eslint.js +++ b/client/configuration/eslint/eslint.js @@ -4,9 +4,21 @@ module.exports = { extends: [ "airbnb", "plugin:eslint-comments/recommended", + "plugin:compat/recommended", "plugin:prettier/recommended", "prettier/react", ], + settings: { + polyfills: [ + "TextDecoder", + "TextEncoder", + "fetch", + "Request", + "Response", + "Headers", + "AbortController", + ], + }, env: { browser: true, commonjs: true, es6: true }, globals: { expect: true, diff --git a/client/configuration/webpack/cspHashPlugin.js b/client/configuration/webpack/cspHashPlugin.js index 99afd46f..3b32efc0 100644 --- a/client/configuration/webpack/cspHashPlugin.js +++ b/client/configuration/webpack/cspHashPlugin.js @@ -1,7 +1,12 @@ +/* eslint-disable import/no-extraneous-dependencies -- this file is a devDependency*/ const cheerio = require("cheerio"); const crypto = require("crypto"); -HtmlWebpackPlugin = require("html-webpack-plugin"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const digest = (str) => { + const hash = crypto.createHash("sha256").update(str, "utf8").digest("base64"); + return `sha256-${hash}`; +}; class CspHashPlugin { constructor(opts) { this.opts = { ...opts }; @@ -19,10 +24,7 @@ class CspHashPlugin { if (filename) { const results = {}; results["script-hashes"] = $("script:not([src]):not([no-csp-hash])") - .map((i, elmt) => this.digest($(elmt).html())) - .get(); - results["style-hashes"] = $("style:not([href]):not([no-csp-hash])") - .map((i, elmt) => this.digest($(elmt).html())) + .map((i, elmt) => digest($(elmt).html())) .get(); const json = JSON.stringify(results); @@ -34,13 +36,10 @@ class CspHashPlugin { // Remove no-csp-hash attributes. Cheerio does not parse Jinja templates // correctly, so we brute force this with a regular expression. - data.html = data.html - .replace(/( +
+ +
+
+ Unsupported Browser +
+
+ cellxgene is currently supported on the following browsers +
+
+ + +
Chrome > 60
+
+ + +
Safari ≥ 10.1
+
+ + +
Firefox ≥ 60
+
+ + +
Edge ≥ 15
+
+
+
+
diff --git a/client/configuration/webpack/webpack.config.dev.js b/client/configuration/webpack/webpack.config.dev.js index 5175a08b..e0881c0a 100644 --- a/client/configuration/webpack/webpack.config.dev.js +++ b/client/configuration/webpack/webpack.config.dev.js @@ -1,65 +1,32 @@ -// jshint esversion: 6 const path = require("path"); const webpack = require("webpack"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); +const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +const { merge } = require("webpack-merge"); + +const sharedConfig = require("./webpack.config.shared"); +const babelOptions = require("../babel/babel.dev"); -const src = path.resolve("src"); const fonts = path.resolve("src/fonts"); const nodeModules = path.resolve("node_modules"); -const babelOptions = require("../babel/babel.dev"); - -module.exports = { +const devConfig = { mode: "development", devtool: "eval", - entry: ["./src/index"], output: { - path: path.resolve("build"), pathinfo: true, filename: "static/js/bundle.js", - publicPath: "/", }, module: { rules: [ { - test: /\.js$/, - include: src, + test: /\.jsx?$/, loader: "babel-loader", options: babelOptions, }, - { - test: /\.css$/, - include: src, - exclude: [path.resolve(src, "index.css")], - loader: [ - { - loader: "style-loader", - }, - { - loader: "css-loader", - options: { - modules: { - localIdentName: "[name]__[local]___[hash:base64:5]", - }, - }, - }, - ], - }, - { - test: /index\.css$/, - include: [path.resolve(src, "index.css")], - loader: [ - { - loader: "style-loader", - }, - { - loader: "css-loader", - }, - ], - }, - - { test: /\.json$/, include: [src, nodeModules], loader: "json-loader" }, { test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i, loader: "file-loader", @@ -88,6 +55,9 @@ module.exports = { }, }, }), + new MiniCssExtractPlugin({ + filename: "static/[name].css", + }), new webpack.NoEmitOnErrorsPlugin(), new webpack.DefinePlugin({ __REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })", @@ -97,5 +67,10 @@ module.exports = { process.env.CXG_SERVER_PORT ), }), + new ScriptExtHtmlWebpackPlugin({ + async: "obsolete", + }), ], }; + +module.exports = merge(sharedConfig, devConfig); diff --git a/client/configuration/webpack/webpack.config.prod.js b/client/configuration/webpack/webpack.config.prod.js index 0bcee9ee..a2115e7f 100644 --- a/client/configuration/webpack/webpack.config.prod.js +++ b/client/configuration/webpack/webpack.config.prod.js @@ -1,32 +1,28 @@ -// jshint esversion: 6 const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const TerserJSPlugin = require("terser-webpack-plugin"); const CleanCss = require("clean-css"); const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); +const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const CspHashPlugin = require("./cspHashPlugin"); - -const src = path.resolve("src"); -const fonts = path.resolve("src/fonts"); -const nodeModules = path.resolve("node_modules"); +const { merge } = require("webpack-merge"); const babelOptions = require("../babel/babel.prod"); -const publicPath = "/"; +const CspHashPlugin = require("./cspHashPlugin"); +const sharedConfig = require("./webpack.config.shared"); -module.exports = { +const fonts = path.resolve("src/fonts"); +const nodeModules = path.resolve("node_modules"); + +const prodConfig = { mode: "production", bail: true, cache: false, - entry: ["./src/index.js"], output: { filename: "static/[name]-[contenthash].js", - path: path.resolve("build"), - publicPath, }, optimization: { minimize: true, @@ -41,39 +37,10 @@ module.exports = { module: { rules: [ { - test: /\.js$/, - include: src, + test: /\.jsx?$/, loader: "babel-loader", options: babelOptions, }, - { - test: /\.css$/, - include: src, - exclude: [path.resolve(src, "index.css")], - use: [ - MiniCssExtractPlugin.loader, - { - loader: "css-loader", - options: { - modules: { - localIdentName: "[name]__[local]___[hash:base64:5]", - }, - importLoaders: 1, - }, - }, - ], - }, - { - test: /index\.css$/, - include: [path.resolve(src, "index.css")], - use: [MiniCssExtractPlugin.loader, "css-loader"], - }, - { - test: /\.json$/, - include: [src, nodeModules], - loader: "json-loader", - exclude: /manifest.json$/, - }, { test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i, loader: "file-loader", @@ -121,3 +88,5 @@ module.exports = { maxAssetSize: 2000000, }, }; + +module.exports = merge(sharedConfig, prodConfig); diff --git a/client/configuration/webpack/webpack.config.shared.js b/client/configuration/webpack/webpack.config.shared.js new file mode 100644 index 00000000..2a8fcfdd --- /dev/null +++ b/client/configuration/webpack/webpack.config.shared.js @@ -0,0 +1,74 @@ +const path = require("path"); +const fs = require("fs"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const ObsoleteWebpackPlugin = require("obsolete-webpack-plugin"); +const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin"); + +const src = path.resolve("src"); +const nodeModules = path.resolve("node_modules"); + +const publicPath = "/"; + +const rawObsoleteHTMLTemplate = fs.readFileSync( + `${__dirname}/obsoleteHTMLTemplate.html`, + "utf8" +); + +const obsoleteHTMLTemplate = rawObsoleteHTMLTemplate.replace(/'/g, '"'); + +module.exports = { + entry: [ + "core-js", + "regenerator-runtime/runtime", + "fastestsmallesttextencoderdecoder", + "whatwg-fetch", + "abort-controller/polyfill", + "./src/index", + ], + output: { + path: path.resolve("build"), + publicPath, + }, + module: { + rules: [ + { + test: /\.css$/, + include: src, + exclude: [path.resolve(src, "index.css")], + use: [ + MiniCssExtractPlugin.loader, + { + loader: "css-loader", + options: { + modules: { + localIdentName: "[name]__[local]___[hash:base64:5]", + }, + importLoaders: 1, + }, + }, + ], + }, + { + test: /index\.css$/, + include: [path.resolve(src, "index.css")], + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + { + test: /\.json$/, + include: [src, nodeModules], + loader: "json-loader", + exclude: /manifest.json$/, + }, + ], + }, + plugins: [ + new ObsoleteWebpackPlugin({ + name: "obsolete", + template: obsoleteHTMLTemplate, + promptOnNonTargetBrowser: false, + }), + new ScriptExtHtmlWebpackPlugin({ + async: "obsolete", + }), + ], +}; diff --git a/client/index_template.html b/client/index_template.html index fabe219f..07c5678a 100644 --- a/client/index_template.html +++ b/client/index_template.html @@ -39,7 +39,7 @@ diff --git a/client/package-lock.json b/client/package-lock.json index e9a54d60..9038fb82 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,6 +1,6 @@ { "name": "cellxgene", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4069,6 +4069,24 @@ "regenerator-runtime": "^0.13.4" } }, + "@babel/runtime-corejs2": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.10.5.tgz", + "integrity": "sha512-LJwyb1ac//Jr2zrGTTaNJhrP1wYCgVw9rzHbQPogKXCTLQ60EEWgeNtuqs6cLsq64O557SYzziCrOxNp0rRi8w==", + "dev": true, + "requires": { + "core-js": "^2.6.5", + "regenerator-runtime": "^0.13.4" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true + } + } + }, "@babel/runtime-corejs3": { "version": "7.10.5", "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.5.tgz", @@ -5686,6 +5704,14 @@ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==" }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -6057,6 +6083,12 @@ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=" }, + "ast-metadata-inferer": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/ast-metadata-inferer/-/ast-metadata-inferer-0.4.0.tgz", + "integrity": "sha512-tKHdBe8N/Vq2nLAm4YPBVREVZjMux6KrqyPfNQgIbDl0t7HaNSmy8w4OyVHYg/cvyn5BW7o7pVwpjPte89Zhcg==", + "dev": true + }, "ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -6944,6 +6976,12 @@ "lodash.uniq": "^4.5.0" } }, + "caniuse-db": { + "version": "1.0.30001107", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001107.tgz", + "integrity": "sha512-ffbV17yvEamsNm4N4dDDHdj147tWwdKw+mGyeOmvQcnu+gu455xUg8degvUOCB+fIAm7Rv3gXVn7XlTiYKymMQ==", + "dev": true + }, "caniuse-lite": { "version": "1.0.30001039", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001039.tgz", @@ -7469,9 +7507,9 @@ "dev": true }, "codecov": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/codecov/-/codecov-3.7.0.tgz", - "integrity": "sha512-uIixKofG099NbUDyzRk1HdGtaG8O+PBUAg3wfmjwXw2+ek+PZp+puRvbTohqrVfuudaezivJHFgTtSC3M8MXww==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/codecov/-/codecov-3.7.1.tgz", + "integrity": "sha512-JHWxyPTkMLLJn9SmKJnwAnvY09kg2Os2+Ux+GG7LwZ9g8gzDDISpIN5wAsH1UBaafA/yGcd3KofMaorE8qd6Lw==", "dev": true, "requires": { "argv": "0.0.2", @@ -7725,10 +7763,9 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" }, "core-js": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", - "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", - "dev": true + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", + "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==" }, "core-js-compat": { "version": "3.6.5", @@ -8946,9 +8983,9 @@ "dev": true }, "elliptic": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", - "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", + "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -9448,6 +9485,94 @@ } } }, + "eslint-plugin-compat": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-3.8.0.tgz", + "integrity": "sha512-5CuWUSZXZkXLCQJBriEpndn/YWrvggDSHTpRJq++kR8GVcsWbTdp8Eh+nBA7JlrNi7ZJ/+kniOVXmn3bpnxuRA==", + "dev": true, + "requires": { + "ast-metadata-inferer": "^0.4.0", + "browserslist": "^4.12.2", + "caniuse-db": "^1.0.30001090", + "core-js": "^3.6.5", + "find-up": "^4.1.0", + "lodash.memoize": "4.1.2", + "mdn-browser-compat-data": "^1.0.28", + "semver": "7.3.2" + }, + "dependencies": { + "browserslist": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.13.0.tgz", + "integrity": "sha512-MINatJ5ZNrLnQ6blGvePd/QOz9Xtu+Ne+x29iQSCHfkU5BugKVJwZKn/iiL8UbpIpa3JhviKjz+XxMo0m2caFQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001093", + "electron-to-chromium": "^1.3.488", + "escalade": "^3.0.1", + "node-releases": "^1.1.58" + } + }, + "caniuse-lite": { + "version": "1.0.30001107", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001107.tgz", + "integrity": "sha512-86rCH+G8onCmdN4VZzJet5uPELII59cUzDphko3thQFgAQG1RNa+sVLDoALIhRYmflo5iSIzWY3vu1XTWtNMQQ==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.510", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.510.tgz", + "integrity": "sha512-sLtGB0znXdmo6lM8hy5wTVo+fLqvIuO8hEpgc0DvPmFZqvBu/WB7AarEwhxVKjf3rVbws/rC8Xf+AlsOb36lJQ==", + "dev": true + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "node-releases": { + "version": "1.1.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz", + "integrity": "sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA==", + "dev": true + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + } + } + }, "eslint-plugin-eslint-comments": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", @@ -9716,6 +9841,11 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "dev": true }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "events": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz", @@ -10067,6 +10197,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fastestsmallesttextencoderdecoder": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==" + }, "favicons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/favicons/-/favicons-5.5.0.tgz", @@ -13886,6 +14021,15 @@ "safe-buffer": "^5.1.2" } }, + "mdn-browser-compat-data": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/mdn-browser-compat-data/-/mdn-browser-compat-data-1.0.32.tgz", + "integrity": "sha512-dqIstpk2ysqa6XcI8/fz1yB6bOKrIs61RIEE00Dj7+WHReXlGrCIiol1NBPsLUNE+HC/4y2f8va8vy1WsiCkAQ==", + "dev": true, + "requires": { + "extend": "3.0.2" + } + }, "mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -14769,6 +14913,26 @@ "has": "^1.0.3" } }, + "obsolete-web": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/obsolete-web/-/obsolete-web-0.5.6.tgz", + "integrity": "sha512-rrs7kSJxOVFvvY7wuCfjg/ngXbO6q1m7Llq30RaN9WYIDH1zAoA1xWLIY39D3e5967g/NeCasI/o8ojhMMkaaA==", + "dev": true, + "requires": { + "@babel/runtime-corejs2": "^7.0.0" + } + }, + "obsolete-webpack-plugin": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/obsolete-webpack-plugin/-/obsolete-webpack-plugin-0.5.6.tgz", + "integrity": "sha512-oKlRW4ycxJfF/mojtpGuQwaP+J4JwIgjFuFnMgURB6AaKxAVaRwiO0oWhqYjwwJ5LxhVybOl+CnGAlhHBHBdEQ==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "obsolete-web": "^0.5.6", + "webpack-sources": "^1.0.0" + } + }, "omggif": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", @@ -16491,9 +16655,9 @@ } }, "regenerator-runtime": { - "version": "0.13.5", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", - "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" }, "regenerator-transform": { "version": "0.14.5", @@ -17147,6 +17311,14 @@ "ajv-keywords": "^3.1.0" } }, + "script-ext-html-webpack-plugin": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/script-ext-html-webpack-plugin/-/script-ext-html-webpack-plugin-2.1.4.tgz", + "integrity": "sha512-7MAv3paAMfh9y2Rg+yQKp9jEGC5cEcmdge4EomRqri10qoczmliYEVPVNz0/5e9QQ202e05qDll9B8zZlY9N1g==", + "requires": { + "debug": "^4.1.1" + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -19513,6 +19685,14 @@ "minimist": "^1.2.0", "request": "^2.88.0", "rx": "^4.1.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true + } } }, "wait-port": { @@ -20168,6 +20348,35 @@ "uuid": "^3.3.2" } }, + "webpack-merge": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.0.9.tgz", + "integrity": "sha512-P4teh6O26xIDPugOGX61wPxaeP918QOMjmzhu54zTVcLtOS28ffPWtnv+ilt3wscwBUCL2WNMnh97XkrKqt9Fw==", + "requires": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + }, + "dependencies": { + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "requires": { + "kind-of": "^6.0.2" + } + } + } + }, "webpack-sources": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", @@ -20194,6 +20403,11 @@ "iconv-lite": "0.4.24" } }, + "whatwg-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.2.0.tgz", + "integrity": "sha512-SdGPoQMMnzVYThUbSrEvqTlkvC1Ux27NehaJ/GUHBfNrh5Mjg+1/uRyFMwVnxO2MrikMWvWAqUGgQOfVU4hT7w==" + }, "whatwg-mimetype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", @@ -20319,6 +20533,11 @@ } } }, + "wildcard": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", + "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==" + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/client/package.json b/client/package.json index 8edc8b24..b49053d0 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "cellxgene", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "description": "cellxgene is a web application for the interactive exploration of single cell sequence data.", "repository": "https://github.com/chanzuckerberg/cellxgene", @@ -29,12 +29,23 @@ "resolutions": { "eslint-scope": "3.7.1" }, + "browserslist": [ + "Chrome > 60", + "Safari >= 10.1", + "iOS >= 10.3", + "Firefox >= 60", + "Edge >= 15", + "not Explorer > 0" + ], "dependencies": { "@blueprintjs/core": "^3.30.0", "@blueprintjs/icons": "^3.19.0", "@blueprintjs/select": "^3.13.5", + "abort-controller": "^3.0.0", + "core-js": "^3.6.5", "d3": "^4.10.0", "d3-scale-chromatic": "^1.5.0", + "fastestsmallesttextencoderdecoder": "^1.0.22", "flatbuffers": "^1.11.0", "fuzzysort": "^1.1.4", "gl-mat4": "^1.2.0", @@ -52,8 +63,12 @@ "react-redux": "^7.2.0", "redux": "^4.0.5", "redux-thunk": "^2.3.0", + "regenerator-runtime": "^0.13.7", "regl": "^1.6.1", - "tinyqueue": "^2.0.3" + "script-ext-html-webpack-plugin": "^2.1.4", + "tinyqueue": "^2.0.3", + "webpack-merge": "^5.0.9", + "whatwg-fetch": "^3.2.0" }, "devDependencies": { "@babel/core": "^7.10.5", @@ -78,13 +93,14 @@ "cheerio": "^1.0.0-rc.3", "clean-css": "^4.2.3", "clean-webpack-plugin": "^3.0.0", - "codecov": "^3.7.0", + "codecov": "^3.7.1", "connect-history-api-fallback": "^1.6.0", "css-loader": "^3.6.0", "eslint": "^7.4.0", "eslint-config-airbnb": "^18.2.0", "eslint-config-prettier": "^6.11.0", "eslint-loader": "^3.0.4", + "eslint-plugin-compat": "^3.8.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-filenames": "^1.3.2", "eslint-plugin-import": "^2.22.0", @@ -107,6 +123,7 @@ "json-loader": "^0.5.7", "lint-staged": "^10.2.11", "mini-css-extract-plugin": "^0.9.0", + "obsolete-webpack-plugin": "^0.5.6", "optimize-css-assets-webpack-plugin": "^5.0.3", "prettier": "^2.0.5", "puppeteer": "^3.3.0", diff --git a/client/src/actions/annotation.js b/client/src/actions/annotation.js index de89f1d1..8a7a154b 100644 --- a/client/src/actions/annotation.js +++ b/client/src/actions/annotation.js @@ -34,7 +34,8 @@ export const annotationCreateCategoryAction = ( throw new Error("name collision on annotation category create"); let initialValue; - let categories; + let newSchema; + let ctor; if (categoryToDuplicate) { /* if we are duplicating a category, retrieve it */ const catDupSchema = schema.annotations.obsByName[categoryToDuplicate]; @@ -47,25 +48,33 @@ export const annotationCreateCategoryAction = ( .fetch("obs", categoryToDuplicate); const col = catToDupDf.col(categoryToDuplicate); initialValue = col.asArray(); - ({ categories } = col.summarize()); + const { categories } = col.summarizeCategorical(); // all user-created annotations must have the unassigned category if (!categories.includes(globals.unassignedCategoryLabel)) { categories.push(globals.unassignedCategoryLabel); } + ctor = initialValue.constructor; + newSchema = { + ...catDupSchema, + name: newCategoryName, + categories, + writable: true, + }; } else { /* else assign to the standard default value */ initialValue = globals.unassignedCategoryLabel; - categories = [globals.unassignedCategoryLabel]; + ctor = Array; + newSchema = { + name: newCategoryName, + type: "categorical", + categories: [globals.unassignedCategoryLabel], + writable: true, + }; } const obsCrossfilter = prevObsCrossfilter.addObsColumn( - { - name: newCategoryName, - type: "categorical", - categories, - writable: true, - }, - Array, + newSchema, + ctor, initialValue ); diff --git a/client/src/actions/embedding.js b/client/src/actions/embedding.js new file mode 100644 index 00000000..5e882160 --- /dev/null +++ b/client/src/actions/embedding.js @@ -0,0 +1,44 @@ +/* +action creators related to embeddings choice +*/ + +import { AnnoMatrixObsCrossfilter } from "../annoMatrix"; +import { _setEmbeddingSubset } from "../util/stateManager/viewStackHelpers"; + +export async function _switchEmbedding(prevAnnoMatrix, newEmbeddingName) { + /* + DRY helper used by this and reembedding action creators + */ + const base = prevAnnoMatrix.base(); + const embeddingDf = await base.fetch("emb", newEmbeddingName); + const annoMatrix = _setEmbeddingSubset(prevAnnoMatrix, embeddingDf); + const obsCrossfilter = await new AnnoMatrixObsCrossfilter(annoMatrix).select( + "emb", + newEmbeddingName, + { + mode: "all", + } + ); + return [annoMatrix, obsCrossfilter]; +} + +export const layoutChoiceAction = (newLayoutChoice) => async ( + dispatch, + getState +) => { + /* + On layout choice, make sure we have selected all on the previous layout, AND the new + layout. + */ + const { annoMatrix: prevAnnoMatrix } = getState(); + const [annoMatrix, obsCrossfilter] = await _switchEmbedding( + prevAnnoMatrix, + newLayoutChoice + ); + dispatch({ + type: "set layout choice", + layoutChoice: newLayoutChoice, + obsCrossfilter, + annoMatrix, + }); +}; diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 2ae586f4..a829b45b 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -12,6 +12,7 @@ import { loadUserColorConfig } from "../util/stateManager/colorHelpers"; import * as selnActions from "./selection"; import * as annoActions from "./annotation"; import * as viewActions from "./viewStack"; +import * as embActions from "./embedding"; /* return promise fetching user-configured colors @@ -40,6 +41,15 @@ async function configFetch(dispatch) { }); } +function prefetchEmbeddings(annoMatrix) { + /* + prefetch requests for all embeddings + */ + const { schema } = annoMatrix; + const available = schema.layout.obs.map((v) => v.name); + available.forEach((embName) => annoMatrix.prefetch("emb", embName)); +} + /* Application bootstrap */ @@ -48,7 +58,7 @@ const doInitialDataLoad = () => dispatch({ type: "initial data load start" }); try { - const [, schema] = await Promise.all([ + const [config, schema] = await Promise.all([ configFetch(dispatch), schemaFetch(dispatch), userColorsFetchAndLoad(dispatch), @@ -57,12 +67,23 @@ const doInitialDataLoad = () => const baseDataUrl = `${globals.API.prefix}${globals.API.version}`; const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); + prefetchEmbeddings(annoMatrix); + dispatch({ type: "annoMatrix: init complete", annoMatrix, obsCrossfilter, }); dispatch({ type: "initial data load complete" }); + + const defaultEmbedding = config?.parameters?.["default_embedding"]; + const layoutSchema = schema?.schema?.layout?.obs ?? []; + if ( + defaultEmbedding && + layoutSchema.some((s) => s.name === defaultEmbedding) + ) { + dispatch(embActions.layoutChoiceAction(defaultEmbedding)); + } } catch (error) { dispatch({ type: "initial data load error", error }); } @@ -210,6 +231,6 @@ export default { annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection, saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction, needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations, - layoutChoiceAction: selnActions.layoutChoiceAction, + layoutChoiceAction: embActions.layoutChoiceAction, setCellSetFromSelection: selnActions.setCellSetFromSelection, }; diff --git a/client/src/actions/reembed.js b/client/src/actions/reembed.js index 43b38e1f..2212f380 100644 --- a/client/src/actions/reembed.js +++ b/client/src/actions/reembed.js @@ -1,10 +1,10 @@ import { API } from "../globals"; -import { MatrixFBS } from "../util/stateManager"; import { postNetworkErrorToast, postAsyncSuccessToast, postAsyncFailureToast, } from "../components/framework/toasters"; +import { _switchEmbedding } from "./embedding"; function abortableFetch(request, opts, timeout = 0) { const controller = new AbortController(); @@ -24,7 +24,7 @@ function abortableFetch(request, opts, timeout = 0) { async function doReembedFetch(dispatch, getState) { const state = getState(); - let cells = state.world.obsAnnotations.rowIndex.labels(); + let cells = state.annoMatrix.rowIndex.labels(); // These lines ensure that we convert any TypedArray to an Array. // This is necessary because JSON.stringify() does some very strange @@ -54,10 +54,7 @@ async function doReembedFetch(dispatch, getState) { }); const res = await af.ready(); - if ( - res.ok && - res.headers.get("Content-Type").includes("application/octet-stream") - ) { + if (res.ok && res.headers.get("Content-Type").includes("application/json")) { return res; } @@ -67,7 +64,6 @@ async function doReembedFetch(dispatch, getState) { if (body && body.length > 0) { msg = `${msg} -- ${body}`; } - postNetworkErrorToast(msg); throw new Error(msg); } @@ -78,17 +74,24 @@ export function requestReembed() { return async (dispatch, getState) => { try { const res = await doReembedFetch(dispatch, getState); - const schema = JSON.parse(res.headers.get("CxG-Schema")); - const buffer = await res.arrayBuffer(); - const df = MatrixFBS.matrixFBSToDataframe(buffer); + const schema = await res.json(); dispatch({ type: "reembed: request completed", }); + + const { annoMatrix: prevAnnoMatrix } = getState(); + const base = prevAnnoMatrix.base().addEmbedding(schema); + const [annoMatrix, obsCrossfilter] = await _switchEmbedding( + base, + schema.name + ); dispatch({ type: "reembed: add reembedding", - embedding: df, schema, + annoMatrix, + obsCrossfilter, }); + postAsyncSuccessToast("Re-embedding has completed."); } catch (error) { dispatch({ @@ -103,13 +106,3 @@ export function requestReembed() { } }; } - -/* disabled until reimplementation occurs -export function reembedResetWorldToUniverse(dispatch, getState) { - const { reembedController } = getState(); - if (reembedController.pendingFetch) reembedController.pendingFetch.abort(); - dispatch({ - type: "reembed: clear all reembeddings", - }); -} -*/ diff --git a/client/src/actions/selection.js b/client/src/actions/selection.js index 8afdad8f..4657bcdd 100644 --- a/client/src/actions/selection.js +++ b/client/src/actions/selection.js @@ -185,31 +185,6 @@ export const graphLassoEndAction = (embName, polygon) => async ( }); }; -export const layoutChoiceAction = (newLayoutChoice) => async ( - dispatch, - getState -) => { - /* - On layout choice, make sure we have selected all on the previous layout, AND the new - layout. - */ - const { obsCrossfilter: prevObsCrossfilter, layoutChoice } = getState(); - - let obsCrossfilter = await prevObsCrossfilter.select( - "emb", - layoutChoice.current, - { mode: "all" } - ); - obsCrossfilter = await obsCrossfilter.select("emb", newLayoutChoice, { - mode: "all", - }); - dispatch({ - type: "set layout choice", - layoutChoice: newLayoutChoice, - obsCrossfilter, - }); -}; - /* Differential expression set selection */ diff --git a/client/src/actions/viewStack.js b/client/src/actions/viewStack.js index cc297a82..9d402cbd 100644 --- a/client/src/actions/viewStack.js +++ b/client/src/actions/viewStack.js @@ -11,7 +11,12 @@ stack multiple subsets. If these conventions change, code elsewhere (eg. menubar/clip.js) will need to change as well. */ -import { AnnoMatrixObsCrossfilter, clip, isubsetMask } from "../annoMatrix"; +import { AnnoMatrixObsCrossfilter } from "../annoMatrix"; +import { + _clipAnnoMatrix, + _userSubsetAnnoMatrix, + _userResetSubsetAnnoMatrix, +} from "../util/stateManager/viewStackHelpers"; export const clipAction = (min, max) => (dispatch, getState) => { /* @@ -19,9 +24,7 @@ export const clipAction = (min, max) => (dispatch, getState) => { view is ALWAYS the top view. */ const { annoMatrix: prevAnnoMatrix } = getState(); - const annoMatrix = prevAnnoMatrix.isClipped - ? clip(prevAnnoMatrix.viewOf, min, max) - : clip(prevAnnoMatrix, min, max); + const annoMatrix = _clipAnnoMatrix(prevAnnoMatrix, min, max); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); dispatch({ type: "set clip quantiles", @@ -43,24 +46,10 @@ export const subsetAction = () => (dispatch, getState) => { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter, } = getState(); - - let annoMatrix; - if (prevAnnoMatrix.isClipped) { - // if there is a clip view, pop it and reapply after we subset - const { clipRange } = prevAnnoMatrix; - annoMatrix = isubsetMask( - prevAnnoMatrix.viewOf, - prevObsCrossfilter.allSelectedMask() - ); - annoMatrix = clip(annoMatrix, ...clipRange); - } else { - // else just push a subset view. - annoMatrix = isubsetMask( - prevAnnoMatrix, - prevObsCrossfilter.allSelectedMask() - ); - } - + const annoMatrix = _userSubsetAnnoMatrix( + prevAnnoMatrix, + prevObsCrossfilter.allSelectedMask() + ); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); dispatch({ type: "subset to selection", @@ -77,20 +66,7 @@ export const resetSubsetAction = () => (dispatch, getState) => { */ const { annoMatrix: prevAnnoMatrix } = getState(); - - const clipRange = prevAnnoMatrix.isClipped ? prevAnnoMatrix.clipRange : null; - - /* pop all views */ - let annoMatrix = prevAnnoMatrix; - while (annoMatrix.isView) { - annoMatrix = annoMatrix.viewOf; - } - - /* re-apply the clip, if any */ - if (clipRange !== null) { - annoMatrix = clip(annoMatrix, ...clipRange); - } - + const annoMatrix = _userResetSubsetAnnoMatrix(prevAnnoMatrix); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); dispatch({ type: "reset subset", diff --git a/client/src/annoMatrix/annoMatrix.js b/client/src/annoMatrix/annoMatrix.js index 1b24a6bb..092d9007 100644 --- a/client/src/annoMatrix/annoMatrix.js +++ b/client/src/annoMatrix/annoMatrix.js @@ -70,6 +70,8 @@ export default class AnnoMatrix { The row index labels are as defined by the base dataset from the server. * isView - true if this is a view, false if not. * viewOf - pointer to parent annomatrix if a view, undefined/null if not a view. + * userFlags - container for any additional state a user of this API wants to hang + off of an annoMatrix, and have propagated by the (shallow) cloning protocol. */ this.schema = indexEntireSchema(schema); this.nObs = nObs; @@ -77,6 +79,7 @@ export default class AnnoMatrix { this.rowIndex = rowIndex || new IdentityInt32Index(nObs); this.isView = false; this.viewOf = undefined; + this.userFlags = {}; /* Private instance variables. @@ -394,6 +397,19 @@ export default class AnnoMatrix { _subclassResponsibility(); } + // eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements + addEmbedding(colSchema) { + /* + Add a new obs embedding to the AnnoMatrix, with provided schema. + Returns a new annomatrix. + + Typical use will be to add a re-embedding that the server has calculated. + + Will throw if the column schema is invalid (eg, duplicate name). + */ + _subclassResponsibility(); + } + /** ** Private interfaces below. **/ diff --git a/client/src/annoMatrix/crossfilter.js b/client/src/annoMatrix/crossfilter.js index b3972978..f9f5bc53 100644 --- a/client/src/annoMatrix/crossfilter.js +++ b/client/src/annoMatrix/crossfilter.js @@ -118,6 +118,11 @@ export default class AnnoMatrixObsCrossfilter { return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter); } + addEmbedding(colSchema) { + const annoMatrix = this.annoMatrix.addEmbedding(colSchema); + return new AnnoMatrixObsCrossfilter(annoMatrix, this.obsCrossfilter); + } + /** Selection state - API is identical to ImmutableTypedCrossfilter, as these are just wrappers to lazy create indices. diff --git a/client/src/annoMatrix/loader.js b/client/src/annoMatrix/loader.js index 7ee27553..0b5fedcf 100644 --- a/client/src/annoMatrix/loader.js +++ b/client/src/annoMatrix/loader.js @@ -6,6 +6,7 @@ import { removeObsAnnoColumn, addObsAnnoCategory, removeObsAnnoCategory, + addObsLayout, } from "../util/stateManager/schemaHelpers"; import { isArrayOrTypedArray } from "../util/typeHelpers"; import { _whereCacheCreate } from "./whereCache"; @@ -47,9 +48,9 @@ export default class AnnoMatrixLoader extends AnnoMatrix { const colSchema = _getColumnSchema(this.schema, "obs", col); _writableCategoryTypeCheck(colSchema); // throws on error - const o = this._clone(); - o.schema = addObsAnnoCategory(this.schema, col, category); - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.schema = addObsAnnoCategory(this.schema, col, category); + return newAnnoMatrix; } async removeObsAnnoCategory(col, category, unassignedCategory) { @@ -59,13 +60,17 @@ export default class AnnoMatrixLoader extends AnnoMatrix { const colSchema = _getColumnSchema(this.schema, "obs", col); _writableCategoryTypeCheck(colSchema); // throws on error - const o = await this.resetObsColumnValues( + const newAnnoMatrix = await this.resetObsColumnValues( col, category, unassignedCategory ); - o.schema = removeObsAnnoCategory(o.schema, col, category); - return o; + newAnnoMatrix.schema = removeObsAnnoCategory( + newAnnoMatrix.schema, + col, + category + ); + return newAnnoMatrix; } dropObsColumn(col) { @@ -75,10 +80,10 @@ export default class AnnoMatrixLoader extends AnnoMatrix { const colSchema = _getColumnSchema(this.schema, "obs", col); _writableCheck(colSchema); // throws on error - const o = this._clone(); - o._cache.obs = this._cache.obs.dropCol(col); - o.schema = removeObsAnnoColumn(this.schema, col); - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix._cache.obs = this._cache.obs.dropCol(col); + newAnnoMatrix.schema = removeObsAnnoColumn(this.schema, col); + return newAnnoMatrix; } addObsColumn(colSchema, Ctor, value) { @@ -90,15 +95,15 @@ export default class AnnoMatrixLoader extends AnnoMatrix { If an array, it must be of same size as nObs and same type as Ctor */ colSchema.writable = true; - const col = colSchema.name; + const colName = colSchema.name; if ( - _getColumnSchema(this.schema, "obs", col) || - this._cache.obs.hasCol(col) + _getColumnSchema(this.schema, "obs", colName) || + this._cache.obs.hasCol(colName) ) { throw new Error("column already exists"); } - const o = this._clone(); + const newAnnoMatrix = this._clone(); let data; if (isArrayOrTypedArray(value)) { if (value.constructor !== Ctor) @@ -109,12 +114,13 @@ export default class AnnoMatrixLoader extends AnnoMatrix { } else { data = new Ctor(this.nObs).fill(value); } - o._cache.obs = this._cache.obs.withCol(col, data); - o.schema = addObsAnnoColumn(this.schema, col, { - ...colSchema, - writable: true, - }); - return o; + newAnnoMatrix._cache.obs = this._cache.obs.withCol(colName, data); + _normalizeCategoricalSchema( + colSchema, + newAnnoMatrix._cache.obs.col(colName) + ); + newAnnoMatrix.schema = addObsAnnoColumn(this.schema, colName, colSchema); + return newAnnoMatrix; } renameObsColumn(oldCol, newCol) { @@ -157,13 +163,13 @@ export default class AnnoMatrixLoader extends AnnoMatrix { data[idx] = value; } - const o = this._clone(); - o._cache.obs = this._cache.obs.replaceColData(col, data); + const newAnnoMatrix = this._clone(); + newAnnoMatrix._cache.obs = this._cache.obs.replaceColData(col, data); const { categories } = colSchema; if (!categories?.includes(value)) { - o.schema = addObsAnnoCategory(this.schema, col, value); + newAnnoMatrix.schema = addObsAnnoCategory(this.schema, col, value); } - return o; + return newAnnoMatrix; } async resetObsColumnValues(col, oldValue, newValue) { @@ -187,13 +193,27 @@ export default class AnnoMatrixLoader extends AnnoMatrix { if (data[i] === oldValue) data[i] = newValue; } - const o = this._clone(); - o._cache.obs = this._cache.obs.replaceColData(col, data); + const newAnnoMatrix = this._clone(); + newAnnoMatrix._cache.obs = this._cache.obs.replaceColData(col, data); const { categories } = colSchema; if (!categories?.includes(newValue)) { - o.schema = addObsAnnoCategory(this.schema, col, newValue); + newAnnoMatrix.schema = addObsAnnoCategory(this.schema, col, newValue); } - return o; + return newAnnoMatrix; + } + + addEmbedding(colSchema) { + /* + add new layout to the obs embeddings + */ + const { name: colName } = colSchema; + if (_getColumnSchema(this.schema, "emb", colName)) { + throw new Error("column already exists"); + } + + const newAnnoMatrix = this._clone(); + newAnnoMatrix.schema = addObsLayout(this.schema, colSchema); + return newAnnoMatrix; } /** diff --git a/client/src/annoMatrix/schema.js b/client/src/annoMatrix/schema.js index 9ce03765..29c7e959 100644 --- a/client/src/annoMatrix/schema.js +++ b/client/src/annoMatrix/schema.js @@ -66,9 +66,14 @@ export function _isContinuousType(schema) { export function _normalizeCategoricalSchema(colSchema, col) { const { type, writable } = colSchema; - if (type === "string" || type === "boolean" || type === "categorical") { + if ( + type === "string" || + type === "boolean" || + type === "categorical" || + writable + ) { const categorySet = new Set( - col.summarize().categories.concat(colSchema.categories ?? []) + col.summarizeCategorical().categories.concat(colSchema.categories ?? []) ); if (writable && !categorySet.has(unassignedCategoryLabel)) { categorySet.add(unassignedCategoryLabel); @@ -79,4 +84,5 @@ export function _normalizeCategoricalSchema(colSchema, col) { if (colSchema.categories) { colSchema.categories = catLabelSort(writable, colSchema.categories); } + return colSchema; } diff --git a/client/src/annoMatrix/viewCreators.js b/client/src/annoMatrix/viewCreators.js index d3488699..28991b44 100644 --- a/client/src/annoMatrix/viewCreators.js +++ b/client/src/annoMatrix/viewCreators.js @@ -33,6 +33,13 @@ export function subset(annoMatrix, obsLabels) { return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex); } +export function subsetByIndex(annoMatrix, obsIndex) { + /* + subset based upon the new obs index. + */ + return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex); +} + export function clip(annoMatrix, qmin, qmax) { /* Create a view that clips all continuous data to the [min, max] range. @@ -59,5 +66,5 @@ function _maskToList(mask) { elems += 1; } } - return new Int32Array(list.buffer, 0, elems); + return list.subarray(0, elems); } diff --git a/client/src/annoMatrix/views.js b/client/src/annoMatrix/views.js index f97e05a0..5b9b9c26 100644 --- a/client/src/annoMatrix/views.js +++ b/client/src/annoMatrix/views.js @@ -17,59 +17,74 @@ class AnnoMatrixView extends AnnoMatrix { } addObsAnnoCategory(col, category) { - const o = this._clone(); - o.viewOf = this.viewOf.addObsAnnoCategory(col, category); - o.schema = o.viewOf.schema; - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = this.viewOf.addObsAnnoCategory(col, category); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } async removeObsAnnoCategory(col, category, unassignedCategory) { - const o = this._clone(); - o.viewOf = await this.viewOf.removeObsAnnoCategory( + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = await this.viewOf.removeObsAnnoCategory( col, category, unassignedCategory ); - o.schema = o.viewOf.schema; - return o; + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } dropObsColumn(col) { - const o = this._clone(); - o.viewOf = this.viewOf.dropObsColumn(col); - o._cache.obs = this._cache.obs.dropCol(col); - o.schema = o.viewOf.schema; - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = this.viewOf.dropObsColumn(col); + newAnnoMatrix._cache.obs = this._cache.obs.dropCol(col); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } addObsColumn(colSchema, Ctor, value) { - const o = this._clone(); - o.viewOf = this.viewOf.addObsColumn(colSchema, Ctor, value); - o.schema = o.viewOf.schema; - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = this.viewOf.addObsColumn(colSchema, Ctor, value); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } renameObsColumn(oldCol, newCol) { - const o = this._clone(); - o.viewOf = this.viewOf.renameObsColumn(oldCol, newCol); - o.schema = o.viewOf.schema; - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = this.viewOf.renameObsColumn(oldCol, newCol); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } async setObsColumnValues(col, rowLabels, value) { - const o = this._clone(); - o.viewOf = await this.viewOf.setObsColumnValues(col, rowLabels, value); - o._cache.obs = this._cache.obs.dropCol(col); - o.schema = o.viewOf.schema; - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = await this.viewOf.setObsColumnValues( + col, + rowLabels, + value + ); + newAnnoMatrix._cache.obs = this._cache.obs.dropCol(col); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } async resetObsColumnValues(col, oldValue, newValue) { - const o = this._clone(); - o.viewOf = await this.viewOf.resetObsColumnValues(col, oldValue, newValue); - o._cache.obs = this._cache.obs.dropCol(col); - o.schema = o.viewOf.schema; - return o; + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = await this.viewOf.resetObsColumnValues( + col, + oldValue, + newValue + ); + newAnnoMatrix._cache.obs = this._cache.obs.dropCol(col); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; + } + + addEmbedding(colSchema) { + const newAnnoMatrix = this._clone(); + newAnnoMatrix.viewOf = this.viewOf.addEmbedding(colSchema); + newAnnoMatrix.schema = newAnnoMatrix.viewOf.schema; + return newAnnoMatrix; } } diff --git a/client/src/components/app.js b/client/src/components/app.js index fe1de5e7..4de58f1c 100644 --- a/client/src/components/app.js +++ b/client/src/components/app.js @@ -11,6 +11,7 @@ import Legend from "./continuousLegend"; import Graph from "./graph/graph"; import MenuBar from "./menubar"; import Autosave from "./autosave"; +import Embedding from "./embedding"; import TermsOfServicePrompt from "./termsPrompt"; import actions from "../actions"; @@ -73,6 +74,7 @@ class App extends React.Component { {(viewportRef) => ( <> + diff --git a/client/src/components/autosave/filenameDialog.js b/client/src/components/autosave/filenameDialog.js index 6ede84b5..9b67dc17 100644 --- a/client/src/components/autosave/filenameDialog.js +++ b/client/src/components/autosave/filenameDialog.js @@ -13,6 +13,7 @@ import { @connect((state) => ({ idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null, annotations: state.annotations, + auth: state.config?.authentication, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, })) class FilenameDialog extends React.Component { @@ -90,12 +91,13 @@ class FilenameDialog extends React.Component { }; render() { - const { writableCategoriesEnabled, annotations, idhash } = this.props; + const { writableCategoriesEnabled, annotations, idhash, auth } = this.props; const { filenameText } = this.state; return writableCategoriesEnabled && !annotations.dataCollectionNameIsReadOnly && - !annotations.dataCollectionName ? ( + !annotations.dataCollectionName && + auth.is_authenticated ? ( col.type === "int32" || col.type === "float32") .filter((col) => col.name !== obsIndex) + .filter((col) => !col.writable) // skip user annotations - they will be treated as categorical .map((col) => col.name); return ( diff --git a/client/src/components/embedding/index.js b/client/src/components/embedding/index.js new file mode 100644 index 00000000..d4c7562c --- /dev/null +++ b/client/src/components/embedding/index.js @@ -0,0 +1,153 @@ +import React from "react"; +import { connect } from "react-redux"; +import { useAsync } from "react-async"; +import { + ButtonGroup, + Popover, + Button, + Radio, + RadioGroup, + Tooltip, + Position, +} from "@blueprintjs/core"; +import * as globals from "../../globals"; +import actions from "../../actions"; +import { getDiscreteCellEmbeddingRowIndex } from "../../util/stateManager/viewStackHelpers"; + +@connect((state) => { + return { + layoutChoice: state.layoutChoice, // TODO: really should clean up naming, s/layout/embedding/g + schema: state.annoMatrix?.schema, + crossfilter: state.obsCrossfilter, + }; +}) +class Embedding extends React.PureComponent { + constructor(props) { + super(props); + this.state = {}; + } + + handleLayoutChoiceChange = (e) => { + const { dispatch } = this.props; + dispatch(actions.layoutChoiceAction(e.currentTarget.value)); + }; + + render() { + const { layoutChoice, schema, crossfilter } = this.props; + const { annoMatrix } = crossfilter; + return ( + + + + + } + // minimal /* removes arrow */ + position={Position.TOP_LEFT} + content={ +
+

Embedding Choice

+

+ There are {schema?.dataframe?.nObs} cells in the entire dataset. +

+ +
+ } + /> +
+ ); + } +} + +export default Embedding; + +const loadAllEmbeddingCounts = async ({ annoMatrix, available }) => { + const embeddings = await Promise.all( + available.map((name) => annoMatrix.base().fetch("emb", name)) + ); + return available.map((name, idx) => ({ + embeddingName: name, + embedding: embeddings[idx], + discreteCellIndex: getDiscreteCellEmbeddingRowIndex(embeddings[idx]), + })); +}; + +const EmbeddingChoices = ({ onChange, annoMatrix, layoutChoice }) => { + const { available } = layoutChoice; + const { data, error, isPending } = useAsync({ + promiseFn: loadAllEmbeddingCounts, + annoMatrix, + available, + }); + + if (error) { + /* log, as this is unexpected */ + console.error(error); + } + if (error || isPending) { + /* still loading, or errored out - just omit counts (TODO: spinner?) */ + return ( + + {layoutChoice.available.map((name) => ( + + ))} + + ); + } + if (data) { + return ( + + {data.map((summary) => { + const { discreteCellIndex, embeddingName } = summary; + const sizeHint = `${discreteCellIndex.size()} cells`; + return ( + + ); + })} + + ); + } + return null; +}; diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index c91f6e0a..7ec11976 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -34,8 +34,10 @@ function createProjectionTF(viewportWidth, viewportHeight) { the projection transform accounts for the screen size & other layout */ const fractionToUse = 0.95; // fraction of min dimension to use - const topGutterSizePx = 32; // toolbar box height - const heightMinusGutter = viewportHeight - topGutterSizePx; + const topGutterSizePx = 32; // top gutter for tools + const bottomGutterSizePx = 32; // bottom gutter for tools + const heightMinusGutter = + viewportHeight - topGutterSizePx - bottomGutterSizePx; const minDim = Math.min(viewportWidth, heightMinusGutter); const aspectScale = [ (fractionToUse * minDim) / viewportWidth, @@ -44,7 +46,7 @@ function createProjectionTF(viewportWidth, viewportHeight) { const m = mat3.create(); mat3.fromTranslation(m, [ 0, - -topGutterSizePx / viewportHeight / aspectScale[1], + (bottomGutterSizePx - topGutterSizePx) / viewportHeight / aspectScale[1], ]); mat3.scale(m, m, aspectScale); return m; diff --git a/client/src/components/menubar/authButtons.js b/client/src/components/menubar/authButtons.js new file mode 100644 index 00000000..af7a2a93 --- /dev/null +++ b/client/src/components/menubar/authButtons.js @@ -0,0 +1,32 @@ +import React from "react"; +import { AnchorButton, Tooltip } from "@blueprintjs/core"; +import * as globals from "../../globals"; +import styles from "./menubar.css"; + +const Auth = React.memo((props) => { + const { auth } = props; + + if (!auth || (auth && !auth.requires_client_login)) return null; + + return ( +
+ + + {!auth.is_authenticated ? "Log In" : "Log Out"} + + +
+ ); +}); + +export default Auth; diff --git a/client/src/components/menubar/embedding.js b/client/src/components/menubar/embedding.js deleted file mode 100644 index d0bfc260..00000000 --- a/client/src/components/menubar/embedding.js +++ /dev/null @@ -1,118 +0,0 @@ -import React from "react"; -import { - ButtonGroup, - Popover, - Button, - Radio, - RadioGroup, - Tooltip, - Position, -} from "@blueprintjs/core"; -import { connect } from "react-redux"; -import * as globals from "../../globals"; -import styles from "./menubar.css"; -import actions from "../../actions"; - -@connect((state) => ({ - layoutChoice: state.layoutChoice, - // disabled temporarily. TODO - issue #1606 - // reembedController: state.reembedController, - // enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false, - enableReembedding: false, -})) -class Embedding extends React.PureComponent { - handleLayoutChoiceChange = (e) => { - const { dispatch } = this.props; - dispatch(actions.layoutChoiceAction(e.currentTarget.value)); - }; - - // eslint-disable-next-line class-methods-use-this -- temporary disable - renderReembedding() { - return null; - /* disabled pending rewrite. TODO - issue #1606 - const { - enableReembedding, - world, - universe, - dispatch, - reembedController, - } = this.props; - - if (!enableReembedding) return null; - - const loading = !!reembedController?.pendingFetch; - const disabled = World.worldEqUniverse(world, universe); - const tipContent = disabled - ? "Subset cells first, then click to recompute UMAP embedding." - : "Click to recompute UMAP embedding on the current cell subset."; - - return ( - - dispatch(actions.requestReembed())} - loading={loading} - /> - - ); -*/ - } - - render() { - const { layoutChoice } = this.props; - - return ( - - -