Compare commits

..
48 Commits
Author SHA1 Message Date
Colin Megill d16a69e158 bump version to 0.11.0 (#850)
* bump version to 0.11.0

* manually fix version number
2019-07-16 14:27:41 -04:00
Severiano Badajoz 3d6bb88556 improve performance around category highlighting (#849)
* remove function call and add comment

* separate crossfilter size calc into memoized function
2019-07-15 16:00:23 -07:00
Bruce Martin e199b14259 correctly handle float columns which only contain NaN (#848) 2019-07-15 14:49:33 -07:00
Severiano Badajoz 9c05aa6766 remove selection interaction from categorical value label (#847) 2019-07-15 11:32:42 -07:00
Severiano Badajoz 3833bada54 style mini-histogram popups (#845)
* popup styling changes

* change to target hover only and change delay

* change popover wording

* change popover wording
2019-07-13 16:52:00 -07:00
Charlotte Weaver 3d98797d8c Installer (#840) 2019-07-12 12:29:22 -07:00
Colin Megill 777214cc14 upgrade lodash (#844) 2019-07-11 18:35:57 -04:00
Severiano Badajoz 9f0f60b5eb add point dilatation on hover (#841)
* enable centroid

* introduce new sizing

* scale point size based off hovered category

* add styling

* fix margins

* disable centroid labels

* remove unused code and add detail to comment

* remove cell dilation on selection toggle

* move hover to name label

* hover on value except for checkbox

* add border radius to value
2019-07-11 15:33:44 -07:00
Severiano Badajoz ca20add577 reset colorAccessor and colorMode if colored diffexp gene is removed (#843)
* add action to clear colorMode and colorAccessor if diffexp is removed

* create new colorHelper function

* creater colorHelper for conditionally setting state

* add abbr

* revert abbr
2019-07-11 14:12:38 -07:00
Sidney Bell 98b07b1284 Add code of conduct; move contributing guidelines to CONTRIBUTING.md (#842) 2019-07-11 10:09:35 -07:00
Severiano Badajoz 941c297363 categorical vs continuous mini histograms (#827)
* comment

* add histogram functionality to Dataframe; port category occupancy to use it

* fix binning and create histogram for continous by catagorical

* Remove unnecessary logs

* Begin work on KDE

* Replace broken KDE with working histogram

* Define domain and range based on data from histogram

* Fix occupancy

* Add continuous obs and switch to canvas

* Stop value from always rerendering

* clear before render

* Clear canvas on render

* refactor categorical occupancy to canvas

* Remove log

* simplify finding max

* refactor kde->histogram and occupancy->bins

* refactor svg -> canvas

* rename to occupancy stack

* create popup

* add metadata and categorical values to popup

* fix overflow

* remove zeros info

* style graph

* fix shouldComponentUpdate to look for world changes

* change categorySelected -> categoryValueSelected

* refactor out render

* remove comment

* conditionally have bottom border

* remove diff comp

* remove comments

* remove unnecessary mapping

* Add comments describing drawing functions

* comments

* flip comparison order

* remove logging

* move default to parameter

* move defaults to parameter

* disable popover if not showing histogram

* fix wording and styling

* add line break
2019-07-09 11:19:01 -07:00
Charlotte Weaver 722a91f1d2 remove options widget + launch on file selection (#839)
* remove options widget + launch on file selection

* extend drop area width
2019-07-09 10:46:13 -07:00
Severiano Badajoz acdc810f82 disable centroids (#833)
* disable hover actions

* Remove action firing

* Remove disabled on action type and leave function call commented
2019-07-08 15:15:50 -07:00
Charlotte Weaver 8afb22a017 GUI UI Elements (#816) 2019-06-27 17:30:57 -07:00
Charlotte Weaver 5effe4bbbb removed --diffexp cli param (#826) 2019-06-26 14:46:58 -07:00
Justin Kiggins df1109e920 updating roadmap (#825) 2019-06-26 09:47:37 -07:00
Sidney Bell 2df93d6d94 Add prepare example and update demo datasets (#810)
* Update example datasets w/ pbmc3k and tabula muris

* Add `prepare` overview and example

* Add S3 data links

* Incorporate PR feedback & copyedits

* Switch to letter pointers

* unix line endings

* path
2019-06-13 16:55:09 -07:00
Charlotte Weaver d5deb1579f [EASY] fix max-category-items (#813)
* fix max-category-items

* match default for max category items

fe had 1000, be had 100
2019-06-13 15:45:43 -07:00
Severiano Badajoz 334b8bb8da draw labels marking the centroids of category value clusters (#809)
* Connect mouse over events to reducer actions

* Change Styling on hover

* Rename reducer actions to be more descriptive

* Reorder reducer in cascade

* Create centroid calculation util

* Whitespace

* Typo fix, use correct action

* Create centroid calc util

* Create centroid svg setup

* Refactor existing svg layer to toolSVG

* Change calcCentroid signature and centroidXY to match mapPointToScreen

* Add id and styling

* Run prettier

* Set z-index to 999

* Draw the label

* Introduce the centroid SVG, refactor code to allow both SVG layers

* Add text label and compute radius based on population

* Implement optional chaining

* Update font family

* Optimize calcMeanCentroid()

* Create and utilize calcMedianCentroid()

* Remove mass circle from label

* Remove styling change on hover

* Remove reducer action logs

* Prettier

* Swap out binds for arrow functions

* Style text

* switch from selectAll() to select()

* Reflect centroid container's purpose in id

* Remove mass from the output

* Swap to obj

* Add finite check

* Don't draw centroid if no finite values

* Fix finite check

* Remove log

* Toggle label coloring based on colorBy state

* Pass cursor events through centroid svg
2019-06-13 15:40:57 -07:00
Colin Megill 6aeefb0fe6 menubar (#804)
* menubar 1

* zoom switching

* centering, pixel perfect canvas

* remove dead args and code

* clipping

* remove log

* if

* connect props

* lint

* undo

* logo left, componetize

* graph back to full height

* shadow to top

* do not prematurely call event handlers during render

* change test to deal with async histogram creation

* left section padding

* lint

* adjust graph to account for top bar,

* lasso tests

* refine histogram tests

* remove testing (onlys)
2019-06-13 15:15:43 -07:00
Charlotte Weaver eac514e04d update favicon (#814) 2019-06-13 11:29:27 -07:00
Charlotte Weaver afeddad343 windows fixes (#767) 2019-06-11 11:41:27 -07:00
Charlotte Weaver 0f17b84dc1 Add reload back in (#808) 2019-06-06 13:49:15 -07:00
Charlotte Weaver 3edb87d125 gui multiprocess - experimental feature (#780) 2019-06-06 10:52:05 -07:00
Justin KigginsandCharlotte Weaver 9f9393a486 adds section on risks of hosted instances (#807)
Co-Authored-By: Charlotte Weaver <charlottesweaver@gmail.com>
2019-06-04 09:16:11 -07:00
Charlotte Weaver 1d6bb032a3 remove async from describe blocks (#805) 2019-06-03 14:59:16 -07:00
Justin Kiggins 3152de4b7f fixes URL to getting started (#801) 2019-05-31 19:34:51 -07:00
Bruce Martin ca9a6796d8 release 0.10.1 (#797) 2019-05-30 13:44:42 -07:00
Bruce Martin a6142bdf93 improve graph scale and centering (#796)
* add gutter to embedding canvas

* improve layout scale and translate

* fix lint

* pin tables to version 3.5.1

* fix lasso coordinate smoke tests
2019-05-30 13:31:41 -07:00
Justin Kiggins ffd7f0db49 adds zenodo badge (#795) 2019-05-30 10:23:49 -07:00
Bruce Martin c6252825f3 release 0.10.0 (#794) 2019-05-29 16:55:43 -07:00
Bruce Martin 4b96b3a635 fix incompatibility of flask reload and port searching (#793)
* WIP

* add --developer; fix incompatibility of --port and --debug

* put REST tests on separate ports

* PR review
2019-05-29 16:38:57 -07:00
Colin Megill 862d8feb5e x (#792) 2019-05-29 12:23:30 -04:00
Bruce Martin 1ef77d1596 fix misconfiguration for history management (#787) 2019-05-24 21:01:11 -07:00
Bruce Martin 3dc45d6330 do not hard-wire column names in annotations (#785)
* enforce column name uniqueness for obs and var

* parameterize the column name containing obs and var user-readable names

* use the new annotation index value from schema

* update f/e unit tests

* PR review suggestions

* lint
2019-05-24 21:00:54 -07:00
Bruce Martin a8c2e408d1 update to latest anndata and remove restriction on scipy (#790) 2019-05-24 11:23:35 -07:00
Bruce Martin e941c1a496 scaling omitted from event handlers (#789)
* scaling omitted from event handlers

* fix smoke tests
2019-05-24 07:00:04 -07:00
Colin Megill a657eb3152 Logo (#782)
* logo, black

* fixes

* remove template, move header
2019-05-23 11:47:24 -04:00
Bruce Martin ef7c26e799 correctly handle selection of trunctated categories (#781) 2019-05-23 08:46:22 -07:00
Bruce Martin 49af278de7 cleanup memoiziation in graph component (#783) 2019-05-22 17:37:18 -07:00
Bruce Martin 2357d0c1b8 layout change UI (#776)
* add layout to schema

* add layout choice action and reducer

* multi layout UI

* update unit tests

* add missing file

* update test schema

* fix duplicate test id

* fix tabs

* PR lint

* fix pytest
2019-05-22 13:21:33 -07:00
Charlotte Weaver 63af79d3f8 Add developer guidelines (#769)
* Add developer guidelines

* minor formatting

* PR clarifications/lint

* more pr fixes

* link fix

* below->above

* pr suggestions
2019-05-21 13:57:34 -07:00
Bruce Martin fcc05f6a00 coordinate system fixes for embedded graph (#768)
* change pan speed to 1 per issue #722

* correct handle scaling of graph when aspect ratio less than one

* add package lock

* add invert to our scale functions

* correctly transform to/from gl coordinates

* remove unused import

* fix naming of import

* update smoke tests
2019-05-20 14:22:41 -07:00
Bruce Martin de3407d875 change scripts to support windows (#775) 2019-05-20 11:42:57 -07:00
Charlotte Weaver 2d4e827bea wait for element before getting text/html (#777) 2019-05-20 11:35:53 -07:00
Bruce Martin 1fa4838863 npm (js) package dependency updates (#765)
* JS package dependency updates

* additional package updates

* more package version updates

* more js package updates

* more JS dependency updates
2019-05-20 10:11:49 -07:00
Charlotte Weaver ab4c74a321 remove psutil (#773) 2019-05-18 10:53:45 -07:00
Charlotte Weaver 82d65addec always run smoke tests (#772) 2019-05-18 10:50:17 -07:00
101 changed files with 9111 additions and 5074 deletions
+5 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.9.1 current_version = 0.11.0
[bumpversion:file:setup.py] [bumpversion:file:setup.py]
search = version="{current_version}" search = version="{current_version}"
@@ -13,3 +13,7 @@ replace = version="{new_version}"
search = "version": "{current_version}" search = "version": "{current_version}"
replace = "version": "{new_version}" replace = "version": "{new_version}"
[bumpversion:file:server/__init__.py]
search = "__version__ = "{current_version}"
replace = "__version__ = "{new_version}"
-1
View File
@@ -27,6 +27,5 @@ jobs:
script: docker build . script: docker build .
- name: "Smoke Tests" - name: "Smoke Tests"
python: "3.6" python: "3.6"
if: branch = master AND type = cron
script: script:
- npm run --prefix client/ smoke-test - npm run --prefix client/ smoke-test
+49
View File
@@ -0,0 +1,49 @@
# contributing to cellxgene
We warmly welcome contributions from the community! Please submit any bug reports and feature requests through [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). Please submit any direct contributions by forking the repository, creating a branch, and submitting a Pull Request. It'd be great for PRs to include test cases and documentation updates where relevant, though we know the core test suite is itself still a work in progress.
All code contributions and dependencies must be compatible with the project's [open-source license (MIT)](LICENSE.txt).
This project adheres to the Contributor Covenant
[code of conduct](https://github.com/chanzuckerberg/.github/tree/master/CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to opensource@chanzuckerberg.com.
And finally, if you have any questions about any of this stuff, just ask! :)
## developer guide
This project has made a few key design choices
- The front-end is built with [`regl`](https://github.com/regl-project/regl) (a webgl library), [`react`](https://reactjs.org/), [`redux`](https://redux.js.org/), [`d3`](https://github.com/d3/d3), and [`blueprint`](https://blueprintjs.com/docs/#core) to handle rendering large numbers of cells with lots of complex interactivity
- The app is designed with a client-server model that can support a range of existing analysis packages for backend computational tasks (currently built for [scanpy](https://github.com/theislab/scanpy))
- The client uses fast cross-filtering to handle selections and comparisons across subsets of data
Depending on your background and interests, you might want to contribute to the frontend, or backend, or both!
If you are interested in working on `cellxgene` development, we recommend cloning the project from Gitub. First you'll need the following installed on your machine
- python 3.6+
- node and npm (we recommend using [nvm](https://github.com/creationix/nvm) if this is your first time with node)
Then clone the project
```
git clone https://github.com/chanzuckerberg/cellxgene.git
```
Build the client web assets by calling `make` from inside the `cellxgene` folder
```
make
```
Install all requirements (we recommend doing this inside a virtual environment)
```
pip install -e .
```
You can start the app while developing either by calling `cellxgene` or by calling `python -m server`. We recommend using the `--debug` flag to see more output, which you can include when reporting bugs.
If you have any questions about developing or contributing, come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-dev` channel.
+20 -39
View File
@@ -2,13 +2,15 @@
> an interactive explorer for single-cell transcriptomics data > an interactive explorer for single-cell transcriptomics data
[![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409)
_cellxgene_ (pronounced "sell-by-jean") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization. _cellxgene_ (pronounced "sell-by-jean") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
<img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30"> <img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30">
- Want to install and use cellxgene? Visit the [cellxgene docs](https://chanzuckerberg.github.io/cellxgene/). - Want to install and use cellxgene? Visit the [cellxgene docs](https://chanzuckerberg.github.io/cellxgene/).
- Want to see where we are going? Check out [our roadmap](ROADMAP.md). - Want to see where we are going? Check out [our roadmap](ROADMAP.md).
- Want to contribute? See our [contributors guide](#Contributing) - Want to contribute? See our [contributors guide](CONTRIBUTING.md).
## quick start ## quick start
@@ -30,11 +32,17 @@ Launch _cellxgene_
cellxgene launch pbmc3k.h5ad --open cellxgene launch pbmc3k.h5ad --open
``` ```
To learn more about what you can do with _cellxgene_, see the [Getting Started](https://chanzuckerberg.github.io/cellxgene/getting-stared/) guide. To learn more about what you can do with _cellxgene_, see the [Getting Started](https://chanzuckerberg.github.io/cellxgene/getting-started.html) guide.
## get in touch ## get in touch
Have questions, suggestions, or comments? You can come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-users` channel. As mentioned above, please submit any feature requests or bugs as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you! Have questions, suggestions, or comments? You can come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-users` channel. Have feature requests or bugs? Please submit these as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you!
## contributing
We warmly welcome contributions from the community! Please see our [contributing guide](CONTRIBUTING.md) 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.
## where we are going ## where we are going
@@ -50,46 +58,19 @@ There are 4 key features we plan to implement in the near term.
For more detail on these features and where we are going, see [our roadmap](ROADMAP.md). For more detail on these features and where we are going, see [our roadmap](ROADMAP.md).
## contributing ## risks of hosting cellxgene
We warmly welcome contributions from the community! Please submit any bug reports and feature requests through [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). Please submit any direct contributions by forking the repository, creating a branch, and submitting a Pull Request. It'd be great for PRs to include test cases and documentation updates where relevant, though we know the core test suite is itself still a work in progress. And all code contributions and dependencies must be compatible with the project's open-source license (MIT). If you have any questions about this stuff, just ask! _cellxgene_ is built on standard web technologies, but is currently designed as a single-user desktop application.
We've done this so we can prioritize the features on [our roadmap](ROADMAP.md).
### developer guide Some of our users have experimented with hosting _cellxgene_ either for their lab or for public use, but please note that the _cellxgene_ team does not officially support, troubleshoot, or maintain any web deployments at this time.
This project has made a few key design choices If do you choose setup _cellxgene_ as a hosted service, you should be aware of the following risks:
- The front-end is built with [`regl`](https://github.com/regl-project/regl) (a webgl library), [`react`](https://reactjs.org/), [`redux`](https://redux.js.org/), [`d3`](https://github.com/d3/d3), and [`blueprint`](https://blueprintjs.com/docs/#core) to handle rendering large numbers of cells with lots of complex interactivity - `$ cellxgene launch` uses Flask's development server, which is not recommended for hosted deployment (see the [Flask documentation](http://flask.pocoo.org/docs/1.0/tutorial/deploy/#run-with-a-production-server))
- The app is designed with a client-server model that can support a range of existing analysis packages for backend computational tasks (currently built for [scanpy](https://github.com/theislab/scanpy)) - We have no testing or official support for deployments where multiple users are accessing the same _cellxgene_ instance.
- The client uses fast cross-filtering to handle selections and comparisons across subsets of data - Your _cellxgene_ instance is likely to hang or crash if too many people access it at the same time, especially if they using functions that call the Python backend (such as differential expression, updating the layout, or coloring by gene).
- _cellxgene_ only supports one instance per dataset
Depending on your background and interests, you might want to contribute to the frontend, or backend, or both!
If you are interested in working on `cellxgene` development, we recommend cloning the project from Gitub. First you'll need the following installed on your machine
- python 3.6+
- node and npm (we recommend using [nvm](https://github.com/creationix/nvm) if this is your first time with node)
Then clone the project
```
git clone https://github.com/chanzuckerberg/cellxgene.git
```
Build the client web assets by calling `make` from inside the `cellxgene` folder
```
make
```
Install all requirements (we recommend doing this inside a virtual environment)
```
pip install -e .
```
You can start the app while developing either by calling `cellxgene` or by calling `python -m server`. We recommend using the `--debug` flag to see more output, which you can include when reporting bugs.
If you have any questions about developing or contributing, come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-dev` channel.
## inspiration ## inspiration
+32 -40
View File
@@ -1,54 +1,46 @@
# cellxgene roadmap # cellxgene roadmap
We are very exited for _cellxgene_ to become a valuable tool in collaborations cellxgene makes it easier for biologists to collaboratively explore and understand their single-cell RNA-seq data.
between computational biologists and experimental biologists working on In the near term, we are focused on continuing to enable fast, interactive exploration of single-cell data, supporting collaborative workflows in single-cell analysis, and improving user support.
single-cell transcriptomics data. _cellxgene_ is in active development, and we If you have questions or feedback about this roadmap, please submit an issue on GitHub.
would love to include the community as we plan new features to work on. If you
have questions of feedback about this roadmap, please submit an issue on
GitHub.
Please note: this roadmap is subject to change. Please note: this roadmap is subject to change.
*Last updated: April 11, 2019* Last updated: June 25, 2019
## what we are building now ## Fast, interactive exploration of single-cell data
In the near term, our goal is to enable teams of computational and experimental ### Exposing Relationships Between Metadata and Data
biologists to collaboratively explore and annotate their single-cell RNA-seq data. Biologists need to understand how variables (stored in metadata) are associated with one another and how they relate to changes in gene expression.
Building upon visualization features that reveal categorical metadata relationships (cluster occupancy) and gene expression relationships (scatterplot), we plan to add exploratory visualization components that enable investigation of relationships between metadata and gene expression.
See [issue #616](https://github.com/chanzuckerberg/cellxgene/issues/616) for more details.
There are 4 key features we plan to implement in the near term. ### Contextualizing Genes
While exploring a transcriptomics dataset, scientists need to understand the biological context of genes.
This context may be provided by user-defined gene metadata or publicly available gene databases.
We plan to support augmenting gene names with additional information that is useful to biologists.
See [issue #96](https://github.com/chanzuckerberg/cellxgene/issues/96) for more detail.
- Click install and launch ## Support collaborative workflows in single-cell analysis
- Manual annotation workflows
- Toggle embeddings
- Gene information
### simple install and launch ### Manual Annotations
cellxgene offers exploratory visualizations that are critical for manual annotation workflows, especially in collaborative environments.
We plan to support manually annotating cells with labels (i.e., cell type or QC flags), and their easy export for downstream analysis.
See [issue #524](https://github.com/chanzuckerberg/cellxgene/issues/524) for more details.
The command line interface for installing and launching cellxgene is a barrier ### Simple Click to Launch
for users who are not used to Python or using the command line. We plan to
support installation and launch of cellxgene on Mac and Windows. See
[Issue #687](https://github.com/chanzuckerberg/cellxgene/issues/687) for more details.
### manual annotation workflows Many biologists prefer not to interact with the command line and need an OS-native experience when using cellxgene.
We plan to implement a point-and-click installation and launch experience so that users can easily load data into cellxgene.
See [issue #687](https://github.com/chanzuckerberg/cellxgene/issues/687) for details.
The exploratory visualization that cellxgene offers is critical for manual ### Python API
annotation workflows, especially in collaborative environments. We plan to For computational biologists, saving h5ad files then loading them into cellxgene is a point of friction.
support manually annotate cells with labels (i.e., cell type or QC flags) for We plan to support importing cellxgene as a Python package so that users can launch cellxgene directly from an interactive environment (such as Jupyter, IPython, or Spyder), and pass data to and from the cellxgene UI.
downstream analysis. See [Issue #524](https://github.com/chanzuckerberg/cellxgene/issues/524)
for more details.
### toggle embeddings ## Improving user support
While a single dataset may have multiple embeddings calculated (tSNE, umap, in ### Improved documentation
situ coordinates, trajectories, etc), cellxgene currently requires the user to select the cellxgene has some specific expectations about how data is stored.
embedding to use in the main layout at launch. We plan to support letting users We want to ensure that new users can get started easily and learn how to use cellxgene with their own data.
toggle between any embedding present in a file from the cellxgene interface. We plan to improve documentation on getting started, installation, data, and contributing.
See [Issue #594](https://github.com/chanzuckerberg/cellxgene/issues/594) for details. See [issue #533](https://github.com/chanzuckerberg/cellxgene/issues/533) for more details.
### gene information
Differential expression returns only the names of genes, but no additional information
about gene metadata, function, or known associations. We plan to help users learn
more about genes they discover by exposing additional gene metadata. See
[Issue #96](https://github.com/chanzuckerberg/cellxgene/issues/96) for details.
+41
View File
@@ -0,0 +1,41 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(['server/gui/main.py'],
pathex=['/Users/charlotteweaver/Documents/Git/cellxgene'],
binaries=[('/System/Library/Frameworks/Tk.framework/Tk', 'tk'), ('/System/Library/Frameworks/Tcl.framework/Tcl', 'tcl')],
datas=[('server/app/web/templates/', 'server/app/web/templates/'), ('server/app/web/static/', 'server/app/web/static/')],
hiddenimports=['sklearn', 'sklearn.utils._cython_blas', 'sklearn.neighbors.typedefs', 'sklearn.neighbors.quad_tree', 'sklearn.tree', 'sklearn.tree._utils'],
hookspath=['server/gui/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False , icon='server/gui/images/cxg_icons.icns')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='cellxgene')
app = BUNDLE(coll,
name='cellxgene.app',
icon='server/gui/images/cxg_icons.icns',
bundle_identifier=None)
+36
View File
@@ -0,0 +1,36 @@
# -*- mode: python -*-
block_cipher = None
a = Analysis(['server\\gui\\main.py'],
pathex=['C:\\Users\\Charlotte\\Documents\\git\\cellxgene'],
binaries=[],
datas=[('server/app/web/templates/', 'server/app/web/templates'), ('server/app/web/static/', 'server/app/web/static')],
hiddenimports=[],
hookspath=['server/gui/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False , icon='server\\gui\\images\\icon.ico')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='cellxgene')
+5 -5
View File
@@ -1,6 +1,6 @@
export const datasets = { export const datasets = {
pbmc3k: { pbmc3k: {
title: "cellxgene: pbmc3k", title: "pbmc3k",
dataframe: { dataframe: {
nObs: "2638", nObs: "2638",
nVar: "1838", nVar: "1838",
@@ -26,8 +26,8 @@ export const datasets = {
cellsets: { cellsets: {
lasso: [ lasso: [
{ {
"coordinates-as-percent": { x1: 0.25, y1: 0.25, x2: 0.35, y2: 0.35 }, "coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 },
count: "26" count: "71"
} }
], ],
categorical: [ categorical: [
@@ -91,8 +91,8 @@ export const datasets = {
} }
}, },
lasso: { lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.45, x2: 0.5, y2: 0.5 }, "coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 },
count: "67" count: "36"
} }
}, },
scatter: { scatter: {
+24 -20
View File
@@ -54,14 +54,14 @@ afterAll(() => {
} }
}); });
describe("did launch", async () => { describe("did launch", () => {
test("page launched", async () => { test("page launched", async () => {
let el = await utils.getOneElementInnerHTML("[data-testid='header']"); let el = await utils.getOneElementInnerHTML("[data-testid='header']");
expect(el).toBe(data.title); expect(el).toBe(data.title);
}); });
}); });
describe("metadata loads", async () => { describe("metadata loads", () => {
test("categories and values from dataset appear", async () => { test("categories and values from dataset appear", async () => {
for (const label in data.categorical) { for (const label in data.categorical) {
await utils.waitByID(`category-${label}`); await utils.waitByID(`category-${label}`);
@@ -87,7 +87,7 @@ describe("metadata loads", async () => {
}); });
}); });
describe("cell selection", async () => { describe("cell selection", () => {
test("selects all cells cellset 1", async () => { test("selects all cells cellset 1", async () => {
const cellCount = await cxgActions.cellSet(1); const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.dataframe.nObs); expect(cellCount).toBe(data.dataframe.nObs);
@@ -138,7 +138,7 @@ describe("cell selection", async () => {
}); });
}); });
describe("gene entry", async () => { describe("gene entry", () => {
test("search for single gene", async () => { test("search for single gene", async () => {
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this // blueprint's typeahead is treating typing weird, clicking & waiting first solves this
await utils.typeInto("gene-search", data.genes.search); await utils.typeInto("gene-search", data.genes.search);
@@ -154,14 +154,17 @@ describe("gene entry", async () => {
await utils.clickOn("section-bulk-add"); await utils.clickOn("section-bulk-add");
await utils.typeInto("input-bulk-add", testGenes.join(",")); await utils.typeInto("input-bulk-add", testGenes.join(","));
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
const userGeneHist = await cxgActions.getAllHistograms(
"histogram-user-gene" const allHistograms = await cxgActions.getAllHistograms(
"histogram-user-gene",
testGenes
); );
expect(userGeneHist).toEqual(expect.arrayContaining(testGenes)); expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
expect(allHistograms.length).toEqual(testGenes.length);
}); });
}); });
describe("diffexp", async () => { describe("diffexp", () => {
test("selects cells, saves them and performs diffexp", async () => { test("selects cells, saves them and performs diffexp", async () => {
for (const select of data.diffexp.cellset1) { for (const select of data.diffexp.cellset1) {
if (select.kind === "categorical") { if (select.kind === "categorical") {
@@ -176,14 +179,18 @@ describe("diffexp", async () => {
} }
await cxgActions.cellSet(2); await cxgActions.cellSet(2);
await utils.clickOn("diffexp-button"); await utils.clickOn("diffexp-button");
const diffExpHists = await cxgActions.getAllHistograms("histogram-diffexp"); const allHistograms = await cxgActions.getAllHistograms(
expect(diffExpHists).toEqual( "histogram-diffexp",
data.diffexp["gene-results"]
);
expect(allHistograms).toEqual(
expect.arrayContaining(data.diffexp["gene-results"]) expect.arrayContaining(data.diffexp["gene-results"])
); );
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length);
}); });
}); });
describe("subset/reset", async () => { describe("subset/reset", () => {
test("subset - cell count matches", async () => { test("subset - cell count matches", async () => {
for (const select of data.subset.cellset1) { for (const select of data.subset.cellset1) {
if (select.kind === "categorical") { if (select.kind === "categorical") {
@@ -257,7 +264,7 @@ describe("subset/reset", async () => {
}); });
}); });
describe("scatter plot", async () => { describe("scatter plot", () => {
test("scatter plot appears", async () => { test("scatter plot appears", async () => {
await cxgActions.reset(); await cxgActions.reset();
const testGenes = data.scatter.genes; const testGenes = data.scatter.genes;
@@ -270,9 +277,9 @@ describe("scatter plot", async () => {
}); });
}); });
describe("clipping", async () => { describe("clipping", () => {
test("clip continuous", async () => { test("clip continuous", async () => {
await cxgActions.clip(data.clip.min, data.clip.max) await cxgActions.clip(data.clip.min, data.clip.max);
const histId = `histogram-${data.clip.metadata}-plot-brush`; const histId = `histogram-${data.clip.metadata}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates( const coords = await cxgActions.calcDragCoordinates(
histId, histId,
@@ -281,16 +288,13 @@ describe("clipping", async () => {
await cxgActions.drag(histId, coords.start, coords.end); await cxgActions.drag(histId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1); const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip.count); expect(cellCount).toBe(data.clip.count);
}); });
test("clip gene", async () => { test("clip gene", async () => {
await utils.typeInto("gene-search", data.clip.gene); await utils.typeInto("gene-search", data.clip.gene);
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await page.waitForSelector( await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
`[data-testid='histogram-${data.clip.gene}']` await cxgActions.clip(data.clip.min, data.clip.max);
);
await cxgActions.clip(data.clip.min, data.clip.max)
const histId = `histogram-${data.clip.gene}-plot-brush`; const histId = `histogram-${data.clip.gene}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates( const coords = await cxgActions.calcDragCoordinates(
histId, histId,
@@ -303,7 +307,7 @@ describe("clipping", async () => {
}); });
// interact with UI elements just that they do not break // interact with UI elements just that they do not break
describe("ui elements don't error", async () => { describe("ui elements don't error", () => {
test("color by", async () => { test("color by", async () => {
for (const label in data.categorical) { for (const label in data.categorical) {
await utils.clickOn(`colorby-${label}`); await utils.clickOn(`colorby-${label}`);
+42 -19
View File
@@ -13,6 +13,26 @@ export const puppeteerUtils = puppeteerPage => ({
); );
}, },
async waitForAllByIds(testids, props = {}) {
await Promise.all(
testids.map(testid =>
puppeteerPage.waitForSelector(`[data-testid='${testid}']`)
)
);
},
async getAllByClass(testclass, props = {}) {
const elements = await puppeteerPage.$$eval(
`[data-testclass=${testclass}]`,
els => {
return els.map(el => {
return el.dataset.testid;
});
}
);
return elements;
},
async typeInto(testid, text) { async typeInto(testid, text) {
// only works for text without special characters // only works for text without special characters
await this.waitByID(testid); await this.waitByID(testid);
@@ -32,8 +52,8 @@ export const puppeteerUtils = puppeteerPage => ({
await puppeteerPage.waitFor(200); await puppeteerPage.waitFor(200);
// select all // select all
await puppeteerPage.click(selector, {clickCount: 3}) await puppeteerPage.click(selector, { clickCount: 3 });
await puppeteerPage.keyboard.type("Backspace") await puppeteerPage.keyboard.type("Backspace");
await puppeteerPage.type(selector, text); await puppeteerPage.type(selector, text);
}, },
@@ -44,11 +64,13 @@ export const puppeteerUtils = puppeteerPage => ({
}, },
async getOneElementInnerHTML(selector) { async getOneElementInnerHTML(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerHTML); let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
return text; return text;
}, },
async getOneElementInnerText(selector) { async getOneElementInnerText(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerText); let text = await puppeteerPage.$eval(selector, el => el.innerText);
return text; return text;
} }
@@ -75,20 +97,16 @@ export const cellxgeneActions = puppeteerPage => ({
await puppeteerPage.mouse.up(); await puppeteerPage.mouse.up();
}, },
async getAllHistograms(testclass) { async getAllHistograms(testclass, testids) {
await puppeteerUtils(puppeteerPage).waitByClass(testclass); const histTestIds = testids.map(tid => `histogram-${tid}`);
const histograms = await puppeteerPage.$$eval( // these load asynchronously, so we need to wait for each histogram individually
`[data-testclass=${testclass}]`, await puppeteerUtils(puppeteerPage).waitForAllByIds(histTestIds);
els => { const allHistograms = await puppeteerUtils(puppeteerPage).getAllByClass(
return els.map(el => { testclass
return el.dataset.testid.substring( );
"histogram_".length, return allHistograms.map(hist =>
el.dataset.testid.length hist.substr("histogram_".length, hist.length)
);
});
}
); );
return histograms;
}, },
async getAllCategoriesAndCounts(category) { async getAllCategoriesAndCounts(category) {
@@ -178,11 +196,16 @@ export const cellxgeneActions = puppeteerPage => ({
await page.waitFor(200); await page.waitFor(200);
}, },
async clip(min = 0, max = 100) { async clip(min = 0, max = 100) {
await puppeteerUtils(puppeteerPage).clickOn("visualization-settings"); await puppeteerUtils(puppeteerPage).clickOn("visualization-settings");
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-min-input", min); await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-max-input", max); "clip-min-input",
min
);
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
"clip-max-input",
max
);
await puppeteerUtils(puppeteerPage).clickOn("clip-commit"); await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
} }
}); });
@@ -0,0 +1,69 @@
import * as Dataframe from "../../../src/util/dataframe";
describe("Dataframe column histogram", () => {
test("categorical by categorical", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("cat").histogram(df.col("name"));
expect(h1).toMatchObject(
new Map([
["n1", new Map([["c1", 1]])],
["n2", new Map([["c2", 1]])],
["n3", new Map([["c3", 1]])]
])
);
// memoized?
expect(df.col("cat").histogram(df.col("name"))).toMatchObject(h1);
});
test("continuous by categorical", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("value").histogram(3, [0, 2], df.col("name"));
expect(h1).toMatchObject(
new Map([["n1", [1, 0, 0]], ["n2", [0, 1, 0]], ["n3", [0, 0, 1]]])
);
// memoized?
expect(df.col("value").histogram(3, [0, 2], df.col("name"))).toMatchObject(
h1
);
});
test("categorical", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("cat").histogram();
expect(h1).toMatchObject(new Map([["c1", 1], ["c2", 1], ["c3", 1]]));
// memoized?
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
});
test("continuous", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("value").histogram(3, [0, 2]);
expect(h1).toMatchObject([1, 1, 1]);
// memoized?
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
});
});
@@ -34,28 +34,38 @@ const aSchemaResponse = {
type: "float32" type: "float32"
}, },
annotations: { annotations: {
obs: [ obs: {
{ name: "name", type: "string" }, index: "name",
{ name: "field1", type: "int32" }, columns: [
{ name: "field2", type: "float32" }, { name: "name", type: "string" },
{ name: "field3", type: "boolean" }, { name: "field1", type: "int32" },
{ { name: "field2", type: "float32" },
name: "field4", { name: "field3", type: "boolean" },
type: "categorical", {
categories: field4Categories name: "field4",
} type: "categorical",
], categories: field4Categories
var: [ }
{ name: "name", type: "string" }, ]
{ name: "fieldA", type: "int32" }, },
{ name: "fieldB", type: "float32" }, var: {
{ name: "fieldC", type: "boolean" }, index: "name",
{ columns: [
name: "fieldD", { name: "name", type: "string" },
type: "categorical", { name: "fieldA", type: "int32" },
categories: fieldDCategories { name: "fieldB", type: "float32" },
} { name: "fieldC", type: "boolean" },
] {
name: "fieldD",
type: "categorical",
categories: fieldDCategories
}
]
}
},
layout: {
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
var: []
} }
} }
}; };
@@ -53,13 +53,15 @@ describe("createUniverseFromResponse", () => {
expect(universe.obsAnnotations.dims).toEqual([ expect(universe.obsAnnotations.dims).toEqual([
nObs, nObs,
REST.schema.schema.annotations.obs.length REST.schema.schema.annotations.obs.columns.length
]); ]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]); expect(universe.obsLayout.dims).toEqual([nObs, 2]);
expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); expect(universe.obsLayout.colIndex.keys()).toEqual(
universe.schema.layout.obs[0].dims
);
expect(universe.varAnnotations.dims).toEqual([ expect(universe.varAnnotations.dims).toEqual([
nVar, nVar,
REST.schema.schema.annotations.var.length REST.schema.schema.annotations.var.columns.length
]); ]);
expect(universe.varData.isEmpty()).toBeTruthy(); expect(universe.varData.isEmpty()).toBeTruthy();
}); });
@@ -29,7 +29,8 @@ const defaultBigBang = () => {
/* create crossfilter */ /* create crossfilter */
const crossfilter = World.createObsDimensions( const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations), new Crossfilter(world.obsAnnotations),
world world,
REST.schema.schema.layout.obs[0].dims
); );
return { return {
@@ -138,7 +139,9 @@ describe("createWorldFromCurrentSelection", () => {
expect(world.obsLayout.rowIndex.keys()).toEqual( expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices) new Int32Array(matchingIndices)
); );
expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); expect(world.obsLayout.colIndex.keys()).toEqual(
world.schema.layout.obs[0].dims
);
}); });
}); });
@@ -152,14 +155,18 @@ describe("createObsDimensionMap", () => {
const { crossfilter } = defaultBigBang(); const { crossfilter } = defaultBigBang();
const annotationNames = _.map( const annotationNames = _.map(
REST.schema.schema.annotations.obs, REST.schema.schema.annotations.obs.columns,
c => c.name c => c.name
); );
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name"); const obsIndexColName = REST.schema.schema.annotations.obs.index;
const schemaByObsName = _.keyBy(
REST.schema.schema.annotations.obs.columns,
"name"
);
expect(crossfilter).toBeDefined(); expect(crossfilter).toBeDefined();
annotationNames.forEach(name => { annotationNames.forEach(name => {
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)]; const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
if (name === "name") { if (name === obsIndexColName) {
expect(dim).toBeUndefined(); expect(dim).toBeUndefined();
} else { } else {
const { type } = schemaByObsName[name]; const { type } = schemaByObsName[name];
+1 -1
View File
@@ -7,8 +7,8 @@ module.exports = {
], ],
plugins: [ plugins: [
"@babel/plugin-proposal-function-bind", "@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }], ["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
"@babel/plugin-proposal-export-namespace-from", "@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-optional-chaining", "@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator" "@babel/plugin-proposal-nullish-coalescing-operator"
+1 -1
View File
@@ -6,8 +6,8 @@ module.exports = {
], ],
plugins: [ plugins: [
"@babel/plugin-proposal-function-bind", "@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }], ["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
"@babel/plugin-proposal-export-namespace-from", "@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements", "@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime", "@babel/plugin-transform-runtime",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 312 B

+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>cellxgene</title> <title>cell&times;gene</title>
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
<style> <style>
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div { html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>cellxgene</title> <title>cell&times;gene</title>
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
<style> <style>
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div { html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
+5064 -3430
View File
File diff suppressed because it is too large Load Diff
+53 -50
View File
@@ -1,6 +1,6 @@
{ {
"name": "cellxgene", "name": "cellxgene",
"version": "0.9.1", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.", "description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene", "repository": "https://github.com/chanzuckerberg/cellxgene",
@@ -9,13 +9,13 @@
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"clean": "rimraf build", "clean": "rimraf build",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js", "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"e2e": "jest --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js", "e2e": "node node_modules/jest/bin/jest.js --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
"lint": "eslint src", "lint": "eslint src",
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e", "smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
"start": "node server/development.js", "start": "node server/development.js",
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad", "start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
"test": "jest", "test": "node node_modules/jest/bin/jest.js",
"unit-test": "jest --testPathIgnorePatterns e2e" "unit-test": "node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e"
}, },
"engineStrict": true, "engineStrict": true,
"engines": { "engines": {
@@ -31,8 +31,8 @@
"eslint-scope": "3.7.1" "eslint-scope": "3.7.1"
}, },
"dependencies": { "dependencies": {
"@blueprintjs/core": "^3.15.0", "@blueprintjs/core": "^3.15.1",
"@blueprintjs/icons": "^3.3.0", "@blueprintjs/icons": "^3.8.0",
"@blueprintjs/select": "^3.8.0", "@blueprintjs/select": "^3.8.0",
"canvas-fit": "^1.5.0", "canvas-fit": "^1.5.0",
"d3": "^4.10.0", "d3": "^4.10.0",
@@ -41,79 +41,77 @@
"font-color-contrast": "^1.0.3", "font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4", "fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4", "gl-mat4": "^1.1.4",
"gl-matrix": "^2.7.1", "gl-matrix": "^3.0.0",
"gl-vec3": "^1.1.3",
"is-number": "^7.0.0", "is-number": "^7.0.0",
"key-pressed": "0.0.1", "key-pressed": "0.0.1",
"lodash": "^4.17.4", "lodash": "^4.17.14",
"memoize-one": "^4.0.0", "memoize-one": "^5.0.4",
"mouse-position": "^2.0.1", "mouse-position": "^2.0.1",
"mouse-pressed": "^1.0.0", "mouse-pressed": "^1.0.0",
"normalize.css": "^8.0.0",
"orbit-camera": "^1.0.0", "orbit-camera": "^1.0.0",
"query-string": "^6.1.0", "query-string": "^6.5.0",
"react": "^16.6.0", "react": "^16.8.6",
"react-autocomplete": "^1.7.2", "react-autocomplete": "^1.7.2",
"react-dom": "^16.6.0", "react-dom": "^16.8.6",
"react-helmet": "^5.2.0", "react-helmet": "^5.2.1",
"react-icons": "^3.2.2", "react-icons": "^3.7.0",
"react-redux": "^5.1.0", "react-redux": "^7.0.3",
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-devtools-extension": "^2.13.5",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"regl": "^1.3.9", "regl": "^1.3.11",
"scroll-speed": "^1.0.0", "scroll-speed": "^1.0.0",
"urijs": "^1.19.0" "urijs": "^1.19.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.1.5", "@babel/core": "^7.4.4",
"@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-proposal-decorators": "^7.0.0", "@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0", "@babel/plugin-proposal-export-namespace-from": "^7.2.0",
"@babel/plugin-proposal-function-bind": "^7.0.0", "@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.2.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
"@babel/plugin-proposal-optional-chaining": "^7.2.0", "@babel/plugin-proposal-optional-chaining": "^7.2.0",
"@babel/plugin-transform-react-constant-elements": "^7.0.0", "@babel/plugin-transform-react-constant-elements": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.1.0", "@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.1.5", "@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/register": "^7.0.0", "@babel/register": "^7.4.4",
"@babel/runtime": "^7.1.5", "@babel/runtime": "^7.4.4",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0", "babel-jest": "^24.8.0",
"babel-loader": "^8.0.0", "babel-loader": "^8.0.6",
"babel-preset-modern-browsers": "^12.0.0", "babel-preset-modern-browsers": "^14.0.0",
"chalk": "^2.4.2", "chalk": "^2.4.2",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^4.6.0", "copy-webpack-plugin": "^5.0.3",
"css-loader": "^1.0.1", "css-loader": "^2.1.1",
"eslint": "^5.13.0", "eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^4.0.0", "eslint-config-prettier": "^4.2.0",
"eslint-loader": "^2.1.2", "eslint-loader": "^2.1.2",
"eslint-plugin-filenames": "^1.3.2", "eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.16.0", "eslint-plugin-import": "^2.17.2",
"eslint-plugin-jest": "^22.2.2", "eslint-plugin-jest": "^22.5.1",
"eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.12.4", "eslint-plugin-react": "^7.13.0",
"express": "^4.14.0", "express": "^4.14.0",
"file-loader": "^2.0.0", "file-loader": "^3.0.1",
"html-webpack-inline-source-plugin": "0.0.10", "html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^24.1.0", "jest": "^24.8.0",
"jest-puppeteer": "^4.1.0", "jest-puppeteer": "^4.1.1",
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.4.1", "mini-css-extract-plugin": "^0.6.0",
"puppeteer": "^1.15.0", "puppeteer": "^1.16.0",
"rimraf": "^2.6.3", "rimraf": "^2.6.3",
"serve-favicon": "^2.3.0", "serve-favicon": "^2.3.0",
"start-server-and-test": "^1.7.11", "start-server-and-test": "^1.9.0",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5", "sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^1.1.0", "url-loader": "^1.1.0",
"webpack": "^4.25.1", "webpack": "^4.31.0",
"webpack-cli": "^3.1.0", "webpack-cli": "^3.3.2",
"webpack-dev-middleware": "^3.1.3" "webpack-dev-middleware": "^3.6.2"
}, },
"jest": { "jest": {
"testMatch": [ "testMatch": [
@@ -133,13 +131,18 @@
], ],
"plugins": [ "plugins": [
"@babel/plugin-proposal-function-bind", "@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
[ [
"@babel/plugin-proposal-decorators", "@babel/plugin-proposal-decorators",
{ {
"legacy": true "legacy": true
} }
], ],
[
"@babel/plugin-proposal-class-properties",
{
"loose": true
}
],
"@babel/plugin-proposal-export-namespace-from", "@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements", "@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime", "@babel/plugin-transform-runtime",
+31 -20
View File
@@ -21,24 +21,32 @@ const doInitialDataLoad = () =>
dispatch({ type: "initial data load start" }); dispatch({ type: "initial data load start" });
try { try {
const requestJson = _(["config", "schema"]) /*
Step 1 - config & schema, all JSON
*/
const requestJson = ["config", "schema"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`) .map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doJsonRequest(url)) .map(url => doJsonRequest(url));
.value(); const stepOneResults = await Promise.all(requestJson);
const requestBinary = _([
"annotations/obs",
"annotations/var?annotation-name=name",
"layout/obs"
])
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url))
.value();
const results = await Promise.all(_.concat(requestJson, requestBinary));
/* set config defaults */ /* set config defaults */
const config = { ...globals.configDefaults, ...results[0].config }; const config = { ...globals.configDefaults, ...stepOneResults[0].config };
const [, schema, obsAnno, varAnno, obsLayout] = [...results]; const schema = stepOneResults[1];
/*
Step 2 - dataframes, all binary. NOTE: uses results of step 1.
*/
/* only load names for var annotations, if possible*/
const varIndexName = schema?.schema?.annotations?.var?.index;
const varAnnotationsQuery = varIndexName
? `?annotation-name=${varIndexName}`
: "";
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url));
const stepTwoResults = await Promise.all(requestBinary);
const [obsAnno, varAnno, obsLayout] = [...stepTwoResults];
const universe = Universe.createUniverseFromResponse( const universe = Universe.createUniverseFromResponse(
config, config,
schema, schema,
@@ -91,6 +99,10 @@ needs expression data.
Transparently utilizes cached data if it is already present. Transparently utilizes cached data if it is already present.
*/ */
async function _doRequestExpressionData(dispatch, getState, genes) { async function _doRequestExpressionData(dispatch, getState, genes) {
const state = getState();
const { universe } = state;
const varIndexName = universe.schema.annotations.var.index;
/* helper for this function only */ /* helper for this function only */
const fetchData = async geneNames => { const fetchData = async geneNames => {
const res = await fetch( const res = await fetch(
@@ -100,7 +112,7 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
body: JSON.stringify({ body: JSON.stringify({
filter: { filter: {
var: { var: {
annotation_value: [{ name: "name", values: geneNames }] annotation_value: [{ name: varIndexName, values: geneNames }]
} }
} }
}), }),
@@ -123,8 +135,6 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
return Universe.convertDataFBStoObject(universe, data); return Universe.convertDataFBStoObject(universe, data);
}; };
const state = getState();
const { universe } = state;
/* preload data already in cache */ /* preload data already in cache */
let expressionData = _.transform( let expressionData = _.transform(
genes, genes,
@@ -241,6 +251,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
*/ */
const state = getState(); const state = getState();
const { universe } = state; const { universe } = state;
const varIndexName = universe.schema.annotations.var.index;
// Legal values are null, Array or TypedArray. Null is initial state. // Legal values are null, Array or TypedArray. Null is initial state.
if (!set1) set1 = []; if (!set1) set1 = [];
@@ -277,7 +288,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const data = await res.json(); const data = await res.json();
// result is [ [varIdx, ...], ... ] // result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r => const topNGenes = _.map(data, r =>
universe.varAnnotations.at(r[0], "name") universe.varAnnotations.at(r[0], varIndexName)
); );
/* /*
+15 -8
View File
@@ -4,9 +4,11 @@ import Helmet from "react-helmet";
import { connect } from "react-redux"; import { connect } from "react-redux";
import Container from "./framework/container"; import Container from "./framework/container";
import LeftSideBar from "./leftsidebar"; import LeftSideBar from "./leftSidebar";
import Legend from "./continuousLegend"; import Legend from "./continuousLegend";
import Graph from "./graph/graph"; import Graph from "./graph/graph";
import MenuBar from "./menubar";
import actions from "../actions"; import actions from "../actions";
@connect(state => ({ @connect(state => ({
@@ -71,18 +73,23 @@ class App extends React.Component {
loading cellxgene loading cellxgene
</div> </div>
) : null} ) : null}
<div> {error ? (
{loading ? null : <LeftSideBar />}
<div <div
style={{ style={{
padding: 15, position: "fixed",
width: 1440 - 410 /* but responsive */, fontWeight: 500,
marginLeft: 350 /* but responsive */ top: window.innerHeight / 2,
left: window.innerWidth / 2 - 50
}} }}
> >
{loading ? null : <Graph key={graphRenderCounter} />} error loading
<Legend />
</div> </div>
) : null}
<div>
{loading ? null : <LeftSideBar />}
{loading ? null : <MenuBar />}
{loading ? null : <Graph key={graphRenderCounter} />}
<Legend />
</div> </div>
</Container> </Container>
); );
@@ -0,0 +1,3 @@
:local(.value):hover {
background: rgba(167, 182, 194, 0.3);
}
@@ -19,13 +19,6 @@ class Categories extends React.Component {
padding: globals.leftSidebarSectionPadding padding: globals.leftSidebarSectionPadding
}} }}
> >
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 4
})}
>
Categorical Metadata
</p>
{_.map(categoricalSelection, (catState, catName) => ( {_.map(categoricalSelection, (catState, catName) => (
<Category key={catName} metadataField={catName} /> <Category key={catName} metadataField={catName} />
))} ))}
@@ -27,10 +27,10 @@ class Category extends React.Component {
const cat = categoricalSelection[metadataField]; const cat = categoricalSelection[metadataField];
const categoryCount = { const categoryCount = {
// total number of categories in this dimension // total number of categories in this dimension
totalCatCount: cat.numCategories, totalCatCount: cat.numCategoryValues,
// number of selected options in this category // number of selected options in this category
selectedCatCount: _.reduce( selectedCatCount: _.reduce(
cat.categorySelected, cat.categoryValueSelected,
(res, cond) => (cond ? res + 1 : res), (res, cond) => (cond ? res + 1 : res),
0 0
) )
@@ -91,7 +91,7 @@ class Category extends React.Component {
const { categoricalSelection, metadataField } = this.props; const { categoricalSelection, metadataField } = this.props;
const cat = categoricalSelection[metadataField]; const cat = categoricalSelection[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryIndices]); const optTuples = sortedCategoryValues([...cat.categoryValueIndices]);
return _.map(optTuples, (tuple, i) => ( return _.map(optTuples, (tuple, i) => (
<Value <Value
optTuples={optTuples} optTuples={optTuples}
+166 -35
View File
@@ -2,60 +2,191 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import {
Popover,
PopoverInteractionKind,
Position,
Classes
} from "@blueprintjs/core";
@connect() @connect()
class Occupancy extends React.Component { class Occupancy extends React.Component {
render() { _WIDTH = 100;
const { occupancy, colorScale, colorAccessor, schema, world } = this.props;
const width = 100;
const height = 11;
const categories = schema.annotations.obsByName[colorAccessor]?.categories; _HEIGHT = 11;
createHistogram = () => {
/*
Knowing that colorScale is based off continous data,
createHistogram fetches the continous data in relation to the cells releveant to the catagory value.
It then seperates that data into 50 bins for drawing the mini-histogram
*/
const {
world,
metadataField,
colorAccessor,
category,
categoryIndex
} = this.props;
if (!this.canvas) return;
const groupBy = world.obsAnnotations.col(metadataField);
const col =
world.obsAnnotations.col(colorAccessor) ||
world.varData.col(colorAccessor);
const range = col.summarize();
const histogramMap = col.histogram(
50,
[range.min, range.max],
groupBy
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
const bins = histogramMap.get(category.categoryValues[categoryIndex]);
const xScale = d3
.scaleLinear()
.domain([0, bins.length])
.range([0, this._WIDTH]);
const largestBin = Math.max(...bins);
const yScale = d3
.scaleLinear()
.domain([0, largestBin])
.range([0, this._HEIGHT]);
const ctx = this.canvas.getContext("2d");
ctx.fillStyle = "#000";
let x;
let y;
const rectWidth = this._WIDTH / bins.length;
for (let i = 0, { length } = bins; i < length; i += 1) {
x = xScale(i);
y = yScale(bins[i]);
ctx.fillRect(x, this._HEIGHT - y, rectWidth, y);
}
};
createOccupancyStack = () => {
/*
Knowing that the color scale is based off of catagorical data,
createOccupancyStack obtains a map showing the number if cells per colored value
Using the colorScale a stack of colored bars is drawn representing the map
*/
const {
world,
metadataField,
colorAccessor,
category,
categoryIndex,
schema,
colorScale
} = this.props;
const ctx = this.canvas?.getContext("2d");
if (!ctx) return;
const groupBy = world.obsAnnotations.col(metadataField);
const occupancyMap = world.obsAnnotations
.col(colorAccessor)
.histogram(groupBy);
const occupancy = occupancyMap.get(category.categoryValues[categoryIndex]);
const x = d3 const x = d3
.scaleLinear() .scaleLinear()
/* get all the keys d[1] as an array, then find the sum */ /* get all the keys d[1] as an array, then find the sum */
.domain([0, d3.sum(Array.from(occupancy, d => d[1]))]) .domain([0, d3.sum(Array.from(occupancy.values()))])
.range([0, width]); .range([0, this._WIDTH]);
const categories = schema.annotations.obsByName[colorAccessor]?.categories;
let currentOffset = 0; let currentOffset = 0;
const dfColumn = world.obsAnnotations.col(colorAccessor); const dfColumn = world.obsAnnotations.col(colorAccessor);
const categoryValues = dfColumn.summarize().categories; const categoryValues = dfColumn.summarize().categories;
const stacks = categoryValues.map(d => {
const o = occupancy.get(d);
const scaledValue = x(o); let o;
let scaledValue;
let value;
const stackItem = { for (let i = 0, { length } = categoryValues; i < length; i += 1) {
key: d, value = categoryValues[i];
value: o || 0, o = occupancy.get(value);
rectWidth: o ? scaledValue : 0, scaledValue = x(o);
offset: currentOffset, ctx.fillStyle = o
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)" ? colorScale(categories.indexOf(value))
}; : "rgb(255,255,255)";
ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
currentOffset += o ? scaledValue : 0; currentOffset += o ? scaledValue : 0;
return stackItem; }
}); };
render() {
const {
colorAccessor,
categoricalSelection,
category,
categoryIndex
} = this.props;
this.canvas?.getContext("2d").clearRect(0, 0, this._WIDTH, this._HEIGHT);
const colorByIsCatagoricalData = !!categoricalSelection[colorAccessor];
return ( return (
<svg <Popover
style={{ interactionKind={PopoverInteractionKind.HOVER_TARGET_ONLY}
marginRight: 5, hoverOpenDelay={1500}
width, hoverCloseDelay={200}
height position={Position.LEFT}
modifiers={{
preventOverflow: { enabled: false },
hide: { enabled: false }
}} }}
lazy
usePortal
disabled={colorByIsCatagoricalData}
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
> >
{stacks.map(d => ( <canvas
<rect className="bp3-popover-targer"
key={d.key} style={{
width={d.rectWidth} marginRight: 5,
height={height} width: this._WIDTH,
x={d.offset} height: this._HEIGHT,
title={d.metadataField} borderBottom: colorByIsCatagoricalData
fill={d.fill} ? ""
/> : "solid rgb(230, 230, 230) 0.25px"
))} }}
</svg> width={this._WIDTH}
height={this._HEIGHT}
ref={ref => {
this.canvas = ref;
if (colorByIsCatagoricalData) this.createOccupancyStack();
else this.createHistogram();
}}
/>
<div key="text" style={{ fontFamily: "Roboto", fontSize: "14px" }}>
<p style={{ margin: "0" }}>
This histograms shows the distribution of{" "}
<strong>{colorAccessor}</strong> within{" "}
<strong>{category.categoryValues[categoryIndex]}</strong>.
<br />
<br />
The x axis is the same for each histogram, while the y axis is
scaled to the largest bin within this histogram instead of the
largest bin within the whole category.
</p>
</div>
</Popover>
); );
} }
} }
+80 -41
View File
@@ -2,8 +2,8 @@
import { connect } from "react-redux"; import { connect } from "react-redux";
import React from "react"; import React from "react";
import Occupancy from "./occupancy"; import Occupancy from "./occupancy";
import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
import * as globals from "../../globals"; import * as globals from "../../globals";
import styles from "./categorical.css";
@connect(state => ({ @connect(state => ({
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
@@ -13,23 +13,68 @@ import * as globals from "../../globals";
world: state.world world: state.world
})) }))
class CategoryValue extends React.Component { class CategoryValue extends React.Component {
toggleOff() { toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({ dispatch({
type: "categorical metadata filter deselect", type: "categorical metadata filter deselect",
metadataField, metadataField,
categoryIndex categoryIndex
}); });
} };
toggleOn() { shouldComponentUpdate = nextProps => {
/*
Checks to see if at least one of the following changed:
* world state
* the color accessor (what is currently being colored by)
* if this catagorical value's selection status has changed
If and only if true, update the component
*/
const { props } = this;
const { metadataField, categoryIndex, categoricalSelection } = props;
const { categoricalSelection: newCategoricalSelection } = nextProps;
const valueSelectionChange =
categoricalSelection[metadataField].categoryValueSelected[
categoryIndex
] !==
newCategoricalSelection[metadataField].categoryValueSelected[
categoryIndex
];
const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
return valueSelectionChange || worldChange || colorAccessorChange;
};
toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({ dispatch({
type: "categorical metadata filter select", type: "categorical metadata filter select",
metadataField, metadataField,
categoryIndex categoryIndex
}); });
} };
handleMouseEnter = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "category value mouse hover start",
metadataField,
categoryIndex
});
};
handleMouseExit = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "category value mouse hover end",
metadataField,
categoryIndex
});
};
render() { render() {
const { const {
@@ -39,15 +84,14 @@ class CategoryValue extends React.Component {
colorAccessor, colorAccessor,
colorScale, colorScale,
i, i,
schema, schema
world
} = this.props; } = this.props;
if (!categoricalSelection) return null; if (!categoricalSelection) return null;
const category = categoricalSelection[metadataField]; const category = categoricalSelection[metadataField];
const selected = category.categorySelected[categoryIndex]; const selected = category.categoryValueSelected[categoryIndex];
const count = category.categoryCounts[categoryIndex]; const count = category.categoryValueCounts[categoryIndex];
const value = category.categoryValues[categoryIndex]; const value = category.categoryValues[categoryIndex];
const displayString = String( const displayString = String(
category.categoryValues[categoryIndex] category.categoryValues[categoryIndex]
@@ -56,29 +100,26 @@ class CategoryValue extends React.Component {
/* this is the color scale, so add swatches below */ /* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor; const isColorBy = metadataField === colorAccessor;
let categories = null; let categories = null;
let occupancy = null;
if (isColorBy && schema) { if (isColorBy && schema) {
categories = schema.annotations.obsByName[colorAccessor]?.categories; categories = schema.annotations.obsByName[colorAccessor]?.categories;
} }
if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) {
occupancy = countCategoryValues2D(
metadataField,
colorAccessor,
world.obsAnnotations
);
}
return ( return (
<div <div
key={i} key={i}
className={styles.value}
data-testclass="categorical-row"
style={{ style={{
padding: "4px 7px",
display: "flex", display: "flex",
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between" justifyContent: "space-between",
marginBottom: "2px",
borderRadius: "2px"
}} }}
data-testclass="categorical-row" onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseExit}
> >
<div <div
style={{ style={{
@@ -90,34 +131,32 @@ class CategoryValue extends React.Component {
justifyContent: "space-between" justifyContent: "space-between"
}} }}
> >
<label className="bp3-control bp3-checkbox"> <div style={{ display: "flex" }}>
<input <label className="bp3-control bp3-checkbox" style={{ margin: 0 }}>
onChange={ <input
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this) onChange={selected ? this.toggleOff : this.toggleOn}
} data-testclass="categorical-value-select"
data-testclass="categorical-value-select" data-testid={`categorical-value-select-${metadataField}-${displayString}`}
data-testid={`categorical-value-select-${metadataField}-${displayString}`} checked={selected}
checked={selected} type="checkbox"
type="checkbox" />
/> <span
<span className="bp3-control-indicator" /> className="bp3-control-indicator"
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
/>
</label>
<span <span
data-testid={`categorical-value-${metadataField}-${displayString}`} data-testid={`categorical-value-${metadataField}-${displayString}`}
data-testclass="categorical-value" data-testclass="categorical-value"
style={{ wordBreak: "break-all" }}
> >
{displayString} {displayString}
</span> </span>
</label> </div>
<span style={{ flexShrink: 0 }}> <span style={{ flexShrink: 0 }}>
{colorAccessor && {colorAccessor && !isColorBy ? (
!isColorBy && <Occupancy category={category} {...this.props} />
categoricalSelection[colorAccessor] ? (
<Occupancy
occupancy={occupancy.get(
category.categoryValues[categoryIndex]
)}
{...this.props}
/>
) : null} ) : null}
</span> </span>
</div> </div>
@@ -48,7 +48,7 @@ class Continuous extends React.Component {
} }
/* initial value for iterator to simulate index, ranges is an object */ /* initial value for iterator to simulate index, ranges is an object */
let zebra = -1; let zebra = 0;
return ( return (
<div> <div>
@@ -66,11 +66,15 @@ class Continuous extends React.Component {
? _.map(obsAnnotations.colIndex.keys(), key => { ? _.map(obsAnnotations.colIndex.keys(), key => {
const isColorField = const isColorField =
key.includes("color") || key.includes("Color"); key.includes("color") || key.includes("Color");
if (key === "name" || isColorField) return null; if (key === schema.annotations.obs.index || isColorField)
return null;
const summary = obsAnnotations.col(key).summarize(); const summary = obsAnnotations.col(key).summarize();
const nonFiniteExtent = const nonFiniteExtent =
summary.min === undefined || summary.max === undefined; summary.min === undefined ||
summary.max === undefined ||
Number.isNaN(summary.min) ||
Number.isNaN(summary.max);
if (!summary.categorical && !nonFiniteExtent) { if (!summary.categorical && !nonFiniteExtent) {
zebra += 1; zebra += 1;
return ( return (
@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic"; import { interpolateCool } from "d3-scale-chromatic";
// create continuous color legend // create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f // http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
+18
View File
@@ -0,0 +1,18 @@
import React from "react";
import * as globals from "../../globals";
const Logo = props => {
const { size } = props;
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" fill="white" />
<rect width="48" height="48" fill={globals.logoColor} />
<rect x="19" y="19" width="22" height="22" fill="white" />
<rect x="24" y="24" width="12" height="12" fill={globals.logoColor} />
<rect x="7" y="19" width="7" height="22" fill="white" />
<rect x="19" y="7" width="22" height="7" fill="white" />
</svg>
);
};
export default Logo;
@@ -1,101 +0,0 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as globals from "../../globals";
import actions from "../../actions";
import CellSetButton from "./cellSetButtons";
@connect(state => ({
differential: state.differential,
world: state.world,
crossfilter: state.crossfilter
}))
class Expression extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
computeDiffExp() {
const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) {
dispatch(
actions.requestDifferentialExpression(
differential.celllist1,
differential.celllist2
)
);
}
}
clearDifferentialExpression() {
const { dispatch, differential } = this.props;
dispatch({
type: "clear differential expression",
diffExp: differential.diffExp
});
dispatch({
type: "clear scatterplot"
});
}
render() {
const { differential } = this.props;
if (!differential) {
return null;
}
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
return (
<div
style={{
marginRight: 10,
marginBottom: 10,
paddingLeft: globals.leftSidebarSectionPadding
}}
>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content="Add two cells selections, see the top 15 differentially expressed genes between them"
position="bottom"
>
<AnchorButton
style={{ marginTop: 10 }}
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
fill
type="button"
onClick={this.computeDiffExp.bind(this)}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
>
<Button
type="button"
fill
style={{ marginTop: 10 }}
intent="warning"
onClick={this.clearDifferentialExpression.bind(this)}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
);
}
}
export default Expression;
+17 -32
View File
@@ -21,7 +21,6 @@ import {
postUserErrorToast, postUserErrorToast,
keepAroundErrorToast keepAroundErrorToast
} from "../framework/toasters"; } from "../framework/toasters";
import ExpressionButtons from "./expressionButtons";
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => { const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
if (!modifiers.matchesPredicate) { if (!modifiers.matchesPredicate) {
@@ -85,7 +84,8 @@ class GeneExpression extends React.Component {
*/ */
const { world } = this.props; const { world } = this.props;
const { varAnnotations } = world; const { varAnnotations } = world;
const geneNames = varAnnotations.col("name").asArray(); const varIndexName = world.schema.annotations.var.index;
const geneNames = varAnnotations.col(varIndexName).asArray();
if (geneNames.length > 0) { if (geneNames.length > 0) {
const placeholder = []; const placeholder = [];
let len = geneNames.length; let len = geneNames.length;
@@ -107,6 +107,7 @@ class GeneExpression extends React.Component {
handleClick(g) { handleClick(g) {
const { world, dispatch, userDefinedGenes } = this.props; const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const gene = g.target; const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) { if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists"); postUserErrorToast("That gene already exists");
@@ -114,7 +115,9 @@ class GeneExpression extends React.Component {
postUserErrorToast( postUserErrorToast(
"That's too many genes, you can have at most 15 user defined genes" "That's too many genes, you can have at most 15 user defined genes"
); );
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) { } else if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
postUserErrorToast("That doesn't appear to be a valid gene name."); postUserErrorToast("That doesn't appear to be a valid gene name.");
} else { } else {
dispatch({ type: "single user defined gene start" }); dispatch({ type: "single user defined gene start" });
@@ -127,6 +130,7 @@ class GeneExpression extends React.Component {
handleBulkAddClick() { handleBulkAddClick() {
const { world, dispatch, userDefinedGenes } = this.props; const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const { bulkAdd } = this.state; const { bulkAdd } = this.state;
/* /*
@@ -145,7 +149,9 @@ class GeneExpression extends React.Component {
if (userDefinedGenes.indexOf(gene) !== -1) { if (userDefinedGenes.indexOf(gene) !== -1) {
return keepAroundErrorToast("That gene already exists"); return keepAroundErrorToast("That gene already exists");
} }
if (world.varAnnotations.col("name").indexOf(gene) === undefined) { if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
return keepAroundErrorToast( return keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.` `${gene} doesn't appear to be a valid gene name.`
); );
@@ -168,24 +174,12 @@ class GeneExpression extends React.Component {
userDefinedGenesLoading, userDefinedGenesLoading,
differential differential
} = this.props; } = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const { tab, bulkAdd } = this.state; const { tab, bulkAdd } = this.state;
return ( return (
<div> <div>
<div <div>
style={{
marginTop: 30
}}
>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
paddingLeft: globals.leftSidebarSectionPadding,
margin: 0
})}
>
Selected Genes
</p>
<div <div
style={{ style={{
padding: globals.leftSidebarSectionPadding padding: globals.leftSidebarSectionPadding
@@ -201,7 +195,7 @@ class GeneExpression extends React.Component {
this.setState({ tab: "autosuggest" }); this.setState({ tab: "autosuggest" });
}} }}
> >
Autosuggest Autosuggest genes
</Button> </Button>
<Button <Button
active={tab === "bulkadd"} active={tab === "bulkadd"}
@@ -243,7 +237,7 @@ class GeneExpression extends React.Component {
itemRenderer={renderGene.bind(this)} itemRenderer={renderGene.bind(this)}
items={ items={
world && world.varAnnotations world && world.varAnnotations
? world.varAnnotations.col("name").asArray() ? world.varAnnotations.col(varIndexName).asArray()
: ["No genes"] : ["No genes"]
} }
popoverProps={{ minimal: true }} popoverProps={{ minimal: true }}
@@ -253,7 +247,7 @@ class GeneExpression extends React.Component {
data-testid={"add-gene"} data-testid={"add-gene"}
loading={userDefinedGenesLoading} loading={userDefinedGenesLoading}
> >
Add Add gene
</Button> </Button>
</ControlGroup> </ControlGroup>
) : null} ) : null}
@@ -284,7 +278,7 @@ class GeneExpression extends React.Component {
onClick={this.handleBulkAddClick.bind(this)} onClick={this.handleBulkAddClick.bind(this)}
loading={userDefinedGenesLoading} loading={userDefinedGenesLoading}
> >
Add Add genes
</Button> </Button>
</ControlGroup> </ControlGroup>
</FormGroup> </FormGroup>
@@ -311,18 +305,9 @@ class GeneExpression extends React.Component {
: null} : null}
</div> </div>
<div> <div>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 40,
paddingLeft: globals.leftSidebarSectionPadding
})}
>
Differentially Expressed Genes
</p>
<ExpressionButtons />
{differential.diffExp {differential.diffExp
? _.map(differential.diffExp, (value, index) => { ? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], "name"); const name = world.varAnnotations.at(value[0], varIndexName);
const values = world.varData.col(name); const values = world.varData.col(name);
if (!values) { if (!values) {
return null; return null;
+15 -1
View File
@@ -1,5 +1,6 @@
// jshint esversion: 6 // jshint esversion: 6
const mat4 = require("gl-mat4"); const mat4 = require("gl-mat4");
const vec3 = require("gl-vec3");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js // opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
@@ -38,7 +39,20 @@ export default function(regl) {
uniforms: { uniforms: {
distance: regl.prop("distance"), distance: regl.prop("distance"),
view: regl.prop("view"), view: regl.prop("view"),
projection: ({viewportWidth, viewportHeight}) => mat4.perspective([], Math.PI / 2, viewportWidth / viewportHeight, 0.01, 1000) projection: ({ viewportWidth, viewportHeight }) => {
const aspectRatio = viewportWidth / viewportHeight;
let m = mat4.perspective(
[],
Math.PI / 2,
viewportWidth / viewportHeight,
0.01,
1000
);
if (aspectRatio < 1) {
m = mat4.scale(m, m, vec3.fromValues(1, 1, 1 / aspectRatio));
}
return m;
}
}, },
count: regl.prop("count"), count: regl.prop("count"),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import * as d3 from "d3";
import styles from "./graph.css";
export default (responsive, graphPaddingRight, xy, text, colorBy) => {
const containerWidth = responsive.width - graphPaddingRight;
const svg = d3
.select("#graphAttachPoint")
.append("svg")
.attr("id", "centroid-container")
.attr("data-testid", "centroid-overlay")
.attr("width", containerWidth)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`)
.style("z-index", 998)
.style("pointer-events", "none");
// TODO: Create own styles, ask Colin for an explanation on the css
// For now I'm going to put centroid z-index at 998 and lasso on 999
const label = svg
.append("g")
.attr("transform", `translate(${xy[0]}, ${xy[1]})`);
label
.append("text")
.attr("text-anchor", "middle")
.text(text)
.style("font-family", "Roboto Condensed")
.style("font-size", "18px")
.style("font-weight", "700")
.style("fill", colorBy ? "black" : "rgb(32, 178, 212)");
return svg;
};
@@ -16,15 +16,19 @@ export default (
handleEndAction, handleEndAction,
handleCancelAction, handleCancelAction,
responsive, responsive,
graphPaddingRight graphPaddingRight,
graphInteractionMode
) => { ) => {
const svg = d3 const svg = d3
.select("#graphAttachPoint") .select("#graphAttachPoint")
.append("svg") .append("svg")
.attr("id", "tool")
.attr("data-testid", "layout-overlay") .attr("data-testid", "layout-overlay")
.attr("width", responsive.width - graphPaddingRight) .attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height) .attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`); .attr("class", `${styles.graphSVG}`)
.style("z-index", 999)
.style("display", graphInteractionMode === "select" ? "inherit" : "none");
if (selectionToolType === "brush") { if (selectionToolType === "brush") {
const brush = d3 const brush = d3
@@ -1,15 +1,15 @@
// jshint esversion: 6 // jshint esversion: 6
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import Categorical from "./categorical/categorical"; import Categorical from "../categorical/categorical";
import Continuous from "./continuous/continuous"; import Continuous from "../continuous/continuous";
import GeneExpression from "./geneExpression"; import GeneExpression from "../geneExpression";
import * as globals from "../globals"; import * as globals from "../../globals";
import DynamicScatterplot from "./scatterplot/scatterplot"; import DynamicScatterplot from "../scatterplot/scatterplot";
import TopLeftLogoAndTitle from "./topLeftLogoAndTitle";
@connect(state => ({ @connect(state => ({
responsive: state.responsive, responsive: state.responsive,
datasetTitle: state.config?.displayNames?.dataset,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor scatterplotYYaccessor: state.controls.scatterplotYYaccessor
})) }))
@@ -17,7 +17,6 @@ class LeftSideBar extends React.Component {
render() { render() {
const { const {
responsive, responsive,
datasetTitle,
scatterplotXXaccessor, scatterplotXXaccessor,
scatterplotYYaccessor scatterplotYYaccessor
} = this.props; } = this.props;
@@ -26,8 +25,7 @@ class LeftSideBar extends React.Component {
this magic number should be made less fragile, this magic number should be made less fragile,
if cellxgene logo or tabs change, this must as well if cellxgene logo or tabs change, this must as well
*/ */
const metadataSectionPadding = 0; const logoRelatedPadding = 50;
// scatterplotXXaccessor && scatterplotYYaccessor ? 450 : 0;
return ( return (
<div <div
@@ -35,27 +33,14 @@ class LeftSideBar extends React.Component {
position: "fixed", position: "fixed",
backgroundColor: "white", backgroundColor: "white",
/* x y blur spread color */ /* x y blur spread color */
boxShadow: "1px 0px 6px 2px rgba(153,153,153,0.4)" boxShadow: "-3px 0px 6px 2px rgba(153,153,153,0.4)"
}} }}
> >
<p <TopLeftLogoAndTitle />
data-testid="header"
style={{
position: "fixed",
top: globals.cellxgeneTitleTopPadding,
left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding,
margin: 0,
fontSize: globals.largestFontSize,
color: globals.darkerGrey,
width: "100%"
}}
>
cellxgene: {datasetTitle}
</p>
<div <div
style={{ style={{
height: responsive.height - metadataSectionPadding, height: responsive.height - logoRelatedPadding,
marginTop: logoRelatedPadding,
width: globals.leftSidebarWidth, width: globals.leftSidebarWidth,
overflowY: "auto", overflowY: "auto",
overflowX: "hidden" overflowX: "hidden"
@@ -0,0 +1,71 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Logo from "../framework/logo";
@connect(state => ({
responsive: state.responsive,
datasetTitle: state.config?.displayNames?.dataset ?? "",
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const { datasetTitle } = this.props;
const paddingToAvoidScrollBar = 15;
return (
<div
style={{
paddingLeft: 8,
paddingTop: 8,
width: globals.leftSidebarWidth - paddingToAvoidScrollBar,
position: "absolute",
backgroundColor: "white",
zIndex: 8888
/* x y blur spread color */
// boxShadow: "-5px -1px 4px 2px rgba(225,225,225,0.4)"
}}
>
<Logo size={30} />
<span
style={{
fontSize: 28,
position: "relative",
top: -6,
fontWeight: "bold",
marginLeft: 5,
color: globals.logoColor,
userSelect: "none"
}}
>
cell<span
style={{
position: "relative",
top: 1,
fontWeight: 300,
fontSize: 24
}}
>
×
</span>gene
</span>
<span
data-testid="header"
style={{
fontSize: 14,
position: "relative",
marginLeft: 7,
top: -8
}}
>
{datasetTitle}
</span>
</div>
);
}
}
export default LeftSideBar;
@@ -32,7 +32,7 @@ class CellSetButton extends React.Component {
render() { render() {
const { differential, eitherCellSetOneOrTwo } = this.props; const { differential, eitherCellSetOneOrTwo } = this.props;
const cellListName = `celllist${eitherCellSetOneOrTwo}`; const cellListName = `celllist${eitherCellSetOneOrTwo}`;
let cells_selected = differential[cellListName] const cellsSelected = differential[cellListName]
? differential[cellListName].length ? differential[cellListName].length
: 0; : 0;
return ( return (
@@ -41,7 +41,6 @@ class CellSetButton extends React.Component {
position="top" position="top"
> >
<AnchorButton <AnchorButton
style={{ marginRight: 10 }}
type="button" type="button"
disabled={differential.diffExp} disabled={differential.diffExp}
onClick={this.set.bind(this)} onClick={this.set.bind(this)}
@@ -50,7 +49,7 @@ class CellSetButton extends React.Component {
{eitherCellSetOneOrTwo} {eitherCellSetOneOrTwo}
{": "} {": "}
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}> <span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
{cells_selected} {cellsSelected}
</span> </span>
{" cells"} {" cells"}
</AnchorButton> </AnchorButton>
+130
View File
@@ -0,0 +1,130 @@
// jshint esversion: 6
import React from "react";
import {
Position,
Button,
Popover,
NumericInput,
Icon
} from "@blueprintjs/core";
function Clip(props) {
const {
pendingClipPercentiles,
clipPercentileMin,
clipPercentileMax,
handleClipOpening,
handleClipClosing,
handleClipCommit,
isClipDisabled,
handleClipOnKeyPress,
handleClipPercentileMaxValueChange,
handleClipPercentileMinValueChange
} = props;
const clipMin =
pendingClipPercentiles?.clipPercentileMin ?? clipPercentileMin;
const clipMax =
pendingClipPercentiles?.clipPercentileMax ?? clipPercentileMax;
const activeClipClass =
clipPercentileMin > 0 || clipPercentileMax < 100
? " bp3-intent-warning"
: "";
return (
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
onOpening={handleClipOpening}
onClosing={handleClipClosing}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<div>Clip all continuous values to percentile range</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 5,
paddingBottom: 5
}}
>
<NumericInput
style={{ width: 50 }}
data-testid="clip-min-input"
onValueChange={handleClipPercentileMinValueChange}
onKeyPress={handleClipOnKeyPress}
value={clipMin}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon icon="percentage" intent="primary" iconSize={14} />
</div>
}
/>
<span style={{ marginRight: 5, marginLeft: 5 }}> - </span>
<NumericInput
style={{ width: 50 }}
data-testid="clip-max-input"
onValueChange={handleClipPercentileMaxValueChange}
onKeyPress={handleClipOnKeyPress}
value={clipMax}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon icon="percentage" intent="primary" iconSize={14} />
</div>
}
/>
<Button
type="button"
data-testid="clip-commit"
className="bp3-button"
disabled={isClipDisabled()}
style={{
cursor: "pointer",
marginRight: 5,
marginLeft: 5
}}
onClick={handleClipCommit}
>
Clip
</Button>
</div>
</div>
}
/>
</div>
);
}
export default Clip;
+464
View File
@@ -0,0 +1,464 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import {
Button,
AnchorButton,
Tooltip,
Popover,
Position,
RadioGroup,
Radio
} from "@blueprintjs/core";
import { World } from "../../util/stateManager";
import actions from "../../actions";
import CellSetButton from "./cellSetButtons";
import InformationMenu from "./infoMenu";
import UndoRedoReset from "./undoRedoReset";
import Clip from "./clip";
@connect(state => ({
universe: state.universe,
world: state.world,
loading: state.controls.loading,
crossfilter: state.crossfilter,
differential: state.differential,
resettingInterface: state.controls.resettingInterface,
layoutChoice: state.layoutChoice,
graphInteractionMode: state.controls.graphInteractionMode,
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
userDefinedGenes: state.controls.userDefinedGenes,
diffexpGenes: state.controls.diffexpGenes,
colorAccessor: state.colors.colorAccessor,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2,
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0
}))
class MenuBar extends React.Component {
static isValidDigitKeyEvent(e) {
/*
Return true if this event is necessary to enter a percent number input.
Return false if not.
Returns true for events with keys: backspace, control, alt, meta, [0-9],
or events that don't have a key.
*/
if (e.key === null) return true;
if (e.ctrlKey || e.altKey || e.metaKey) return true;
// concept borrowed from blueprint's numericInputUtils:
// keys that print a single character when pressed have a `key` name of
// length 1. every other key has a longer `key` name (e.g. "Backspace",
// "ArrowUp", "Shift"). since none of those keys can print a character
// to the field--and since they may have important native behaviors
// beyond printing a character--we don't want to disable their effects.
const isSingleCharKey = e.key.length === 1;
if (!isSingleCharKey) return true;
const key = e.key.charCodeAt(0) - 48; /* "0" */
return key >= 0 && key <= 9;
}
constructor(props) {
super(props);
this.state = {
pendingClipPercentiles: null
};
}
isClipDisabled = () => {
/*
return true if clip button should be disabled.
*/
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
const { world } = this.props;
const currentClipMin = 100 * world?.clipQuantiles?.min;
const currentClipMax = 100 * world?.clipQuantiles?.max;
// if you change this test, be careful with logic around
// comparisons between undefined / NaN handling.
const isDisabled =
!(clipPercentileMin < clipPercentileMax) ||
(clipPercentileMin === currentClipMin &&
clipPercentileMax === currentClipMax);
return isDisabled;
};
isResetDisabled = () => {
/*
Reset should be disabled when all of the following are true:
* nothing is selected in the crossfilter
* world EQ universe
* nothing is colored by
* there are no userDefinedGenes or diffexpGenes displayed
* scatterplot is not displayed
* nothing in cellset1 or cellset2
* clip percentiles are [0,100]
*/
const {
crossfilter,
world,
universe,
userDefinedGenes,
diffexpGenes,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor,
celllist1,
celllist2,
clipPercentileMin,
clipPercentileMax
} = this.props;
if (!crossfilter || !world || !universe) {
return false;
}
const nothingSelected = crossfilter.countSelected() === crossfilter.size();
const nothingColoredBy = !colorAccessor;
const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0;
const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor;
const nothingInCellsets = !celllist1 && !celllist2;
return (
nothingSelected &&
World.worldEqUniverse(world, universe) &&
nothingColoredBy &&
noGenes &&
scatterNotDpl &&
nothingInCellsets &&
clipPercentileMax === 100 &&
clipPercentileMin === 0
);
};
resetInterface = () => {
const { dispatch } = this.props;
dispatch({
type: "interface reset started"
});
dispatch(actions.resetInterface());
};
handleClipOnKeyPress = e => {
/*
allow only numbers, plus other critical keys which
may be required to make a number
*/
if (!MenuBar.isValidDigitKeyEvent(e)) {
e.preventDefault();
}
};
handleClipPercentileMinValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
/*
clamp to [0, currentClipPercentileMax]
*/
if (v <= 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMin = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipPercentileMaxValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
/*
clamp to [0, 100]
*/
if (v < 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMax = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipCommit = () => {
const { dispatch } = this.props;
const { pendingClipPercentiles } = this.state;
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
const min = clipPercentileMin / 100;
const max = clipPercentileMax / 100;
dispatch({
type: "set clip quantiles",
clipQuantiles: { min, max }
});
};
handleClipOpening = () => {
const { clipPercentileMin, clipPercentileMax } = this.props;
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipClosing = () => {
this.setState({ pendingClipPercentiles: null });
};
handleLayoutChoiceChange = e => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value
});
};
computeDiffExp = () => {
const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) {
dispatch(
actions.requestDifferentialExpression(
differential.celllist1,
differential.celllist2
)
);
}
};
clearDifferentialExpression = () => {
const { dispatch, differential } = this.props;
dispatch({
type: "clear differential expression",
diffExp: differential.diffExp
});
dispatch({
type: "clear scatterplot"
});
};
render() {
const {
dispatch,
differential,
crossfilter,
resettingInterface,
libraryVersions,
undoDisabled,
redoDisabled,
selectionTool,
clipPercentileMin,
clipPercentileMax,
layoutChoice,
graphInteractionMode
} = this.props;
const { pendingClipPercentiles } = this.state;
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter";
}
return (
<div
style={{
position: "fixed",
right: 8,
top: 8
}}
>
<div className="bp3-button-group" style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content="Add two cells selections, see the top 15 differentially expressed genes between them"
position="bottom"
>
<AnchorButton
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
icon="left-join"
fill
type="button"
onClick={this.computeDiffExp}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
>
<Button
type="button"
fill
intent="warning"
onClick={this.clearDifferentialExpression}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
<Tooltip
content="Show only metadata and cells which are currently selected"
position="left"
>
<AnchorButton
type="button"
data-testid="subset-button"
disabled={
crossfilter &&
(crossfilter.countSelected() === 0 ||
crossfilter.countSelected() === crossfilter.size())
}
style={{
marginRight: 10
}}
onClick={() => {
dispatch(actions.regraph());
dispatch({ type: "increment graph render counter" });
}}
>
subset to current selection
</AnchorButton>
</Tooltip>
<div className="bp3-button-group">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
active={graphInteractionMode === "select"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "select"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<RadioGroup
label="Layout Choice"
onChange={this.handleLayoutChoiceChange}
selectedValue={layoutChoice.current}
>
{layoutChoice.available.map(name => (
<Radio label={name} value={name} key={name} />
))}
</RadioGroup>
</div>
}
/>
</div>
<Clip
pendingClipPercentiles={pendingClipPercentiles}
clipPercentileMin={clipPercentileMin}
clipPercentileMax={clipPercentileMax}
handleClipOpening={this.handleClipOpening}
handleClipClosing={this.handleClipClosing}
handleClipCommit={this.handleClipCommit}
isClipDisabled={this.isClipDisabled}
handleClipOnKeyPress={this.handleClipOnKeyPress}
handleClipPercentileMaxValueChange={
this.handleClipPercentileMaxValueChange
}
handleClipPercentileMinValueChange={
this.handleClipPercentileMinValueChange
}
/>
<UndoRedoReset
dispatch={dispatch}
isResetDisabled={this.isResetDisabled}
resetInterface={this.resetInterface}
resettingInterface={resettingInterface}
undoDisabled={undoDisabled}
redoDisabled={redoDisabled}
/>
<InformationMenu libraryVersions={libraryVersions} />
</div>
);
}
}
export default MenuBar;
+61
View File
@@ -0,0 +1,61 @@
// jshint esversion: 6
import React from "react";
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) {
const { libraryVersions } = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Popover
content={
<Menu>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/faq.html"
target="_blank"
icon="help"
text="FAQ"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
/>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="book"
text="Docs"
/>
<MenuItem
href="https://github.com/chanzuckerberg/cellxgene"
target="_blank"
icon="git-branch"
text="Github"
/>
<MenuItem
target="_blank"
text={`cellxgene v${
libraryVersions && libraryVersions.cellxgene
? libraryVersions.cellxgene
: null
}`}
/>
<MenuItem text="MIT License" />
</Menu>
}
position={Position.BOTTOM_RIGHT}
>
<Button
type="button"
className="bp3-button bp3-icon-info-sign"
style={{
cursor: "pointer"
}}
/>
</Popover>
</div>
);
}
export default InformationMenu;
@@ -0,0 +1,64 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
function InformationMenu(props) {
const {
resettingInterface,
undoDisabled,
redoDisabled,
resetInterface,
isResetDisabled,
dispatch
} = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Redo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
disabled={redoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/redo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip
content="Reset cellxgene, clearing all selections"
position="left"
>
<AnchorButton
disabled={isResetDisabled()}
style={{ marginLeft: 10 }}
type="button"
loading={resettingInterface}
intent="none"
icon="refresh"
onClick={resetInterface}
data-testid="reset"
data-testclass={`resetting-${resettingInterface}`}
>
reset
</AnchorButton>
</Tooltip>
</div>
);
}
export default InformationMenu;
@@ -3,7 +3,6 @@
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html // https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
import React from "react"; import React from "react";
import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core"; import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl"; import _regl from "regl";
+1 -111
View File
@@ -31,6 +31,7 @@ export const darkGreen = "#448C4D";
export const nonFiniteCellColor = lightGrey; export const nonFiniteCellColor = lightGrey;
export const defaultCellColor = "rgb(0,0,0,1)"; export const defaultCellColor = "rgb(0,0,0,1)";
export const logoColor = "black"; /* logo pink: "#E9429A" */
/* typography constants */ /* typography constants */
@@ -72,114 +73,3 @@ let _API = {
if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API; if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API;
export const API = _API; export const API = _API;
export const ordinalColors = [
"#0ac115",
"#c10ab6",
"#c1710a",
"#0a5ac1",
"#c1150a",
"#0ab6c1",
"#5ac10a",
"#710ac1",
"#0ac171",
"#c10a5a",
"#b6c10a",
"#150ac1",
"#b2ffb7",
"#ffb2fa",
"#ffddb2",
"#b2d4ff",
"#ffb7b2",
"#b2faff",
"#d4ffb2",
"#ddb2ff",
"#b2ffdd",
"#ffb2d4",
"#faffb2",
"#b7b2ff",
"#27a908",
"#8b08a9",
"#a93a08",
"#0877a9",
"#a90827",
"#08a98b",
"#77a908",
"#3a08a9",
"#08a93a",
"#a90877",
"#a98b08",
"#0827a9",
"#00ff0f",
"#ff00ef",
"#ff8e00",
"#0070ff",
"#ff0f00",
"#00efff",
"#70ff00",
"#8e00ff",
"#00ff8e",
"#ff0070",
"#efff00",
"#0f00ff",
"#006606",
"#66005f",
"#663900",
"#002c66",
"#660600",
"#005f66",
"#2c6600",
"#390066",
"#006639",
"#66002c",
"#5f6600",
"#060066",
"#83ff65",
"#e165ff",
"#ff9565",
"#65cfff",
"#ff6583",
"#65ffe1",
"#cfff65",
"#9565ff",
"#65ff95",
"#ff65cf",
"#ffe165",
"#6583ff",
"#009909",
"#99008f",
"#995500",
"#004399",
"#990900",
"#008f99",
"#439900",
"#550099",
"#009955",
"#990043",
"#8f9900",
"#090099",
"#d9fecc",
"#f1ccfe",
"#fed7cc",
"#ccf3fe",
"#feccd9",
"#ccfef1",
"#f3fecc",
"#d7ccfe",
"#ccfed7",
"#feccf3",
"#fef1cc",
"#ccd9fe",
"#47ea51",
"#ea47e0",
"#eaa247",
"#478fea",
"#ea5147",
"#47e0ea",
"#8fea47",
"#a247ea",
"#47eaa2",
"#ea478f",
"#e0ea47",
"#5147ea"
];
+14 -12
View File
@@ -30,15 +30,15 @@ const CategoricalSelection = (
/* /*
Set the specific category in this field to false Set the specific category in this field to false
*/ */
const newCategorySelected = Array.from( const newCategoryValueSelected = Array.from(
state[action.metadataField].categorySelected state[action.metadataField].categoryValueSelected
); );
newCategorySelected[action.categoryIndex] = true; newCategoryValueSelected[action.categoryIndex] = true;
const newCategoricalSelection = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: newCategorySelected categoryValueSelected: newCategoryValueSelected
} }
}; };
return newCategoricalSelection; return newCategoricalSelection;
@@ -48,15 +48,15 @@ const CategoricalSelection = (
/* /*
Set the specific category in this field to false Set the specific category in this field to false
*/ */
const newCategorySelected = Array.from( const newCategoryValueSelected = Array.from(
state[action.metadataField].categorySelected state[action.metadataField].categoryValueSelected
); );
newCategorySelected[action.categoryIndex] = false; newCategoryValueSelected[action.categoryIndex] = false;
const newCategoricalSelection = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: newCategorySelected categoryValueSelected: newCategoryValueSelected
} }
}; };
return newCategoricalSelection; return newCategoricalSelection;
@@ -70,8 +70,9 @@ const CategoricalSelection = (
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: Array.from( categorySelected: false,
state[action.metadataField].categorySelected categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(false) ).fill(false)
} }
}; };
@@ -86,8 +87,9 @@ const CategoricalSelection = (
...state, ...state,
[action.metadataField]: { [action.metadataField]: {
...state[action.metadataField], ...state[action.metadataField],
categorySelected: Array.from( categorySelected: true,
state[action.metadataField].categorySelected categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(true) ).fill(true)
} }
}; };
+44
View File
@@ -0,0 +1,44 @@
import calcCentroid from "../util/centroid";
const initialState = {
metadataField: "",
categoryIndex: -1,
categoryField: "",
centroidXY: [-1, -1]
};
const CentroidLabel = (state = initialState, action, sharedNextState) => {
const { categoricalSelection, world, layoutChoice } = sharedNextState;
const { metadataField, categoryIndex } = action;
const categoryField =
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
switch (action.type) {
case "category value mouse hover start":
return {
...state,
metadataField,
categoryIndex,
categoryField,
centroidXY: null /* calcCentroid( This function call is computationally heavy and also leading to large GC. Before reimplementation, look into optimization and memoization
world,
metadataField,
categoryField,
layoutChoice.currentDimNames
) */
};
case "category value mouse hover end":
if (
metadataField === state.metadataField &&
categoryIndex === state.categoryIndex
) {
return initialState;
}
return state;
default:
return state;
}
};
export default CentroidLabel;
+41 -11
View File
@@ -1,4 +1,4 @@
import { createColors } from "../util/stateManager"; import { ColorHelpers } from "../util/stateManager";
const ColorsReducer = ( const ColorsReducer = (
state = { state = {
@@ -17,7 +17,7 @@ const ColorsReducer = (
const { world } = nextSharedState; const { world } = nextSharedState;
const colorMode = null; const colorMode = null;
const colorAccessor = null; const colorAccessor = null;
const { rgb, scale } = createColors(world, colorMode); const { rgb, scale } = ColorHelpers.createColors(world, colorMode);
return { return {
...state, ...state,
colorAccessor, colorAccessor,
@@ -29,9 +29,23 @@ const ColorsReducer = (
case "set clip quantiles": case "set clip quantiles":
case "set World to current selection": { case "set World to current selection": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
prevControls,
state,
prevWorld
);
if (resetColorState) {
return resetColorState;
}
const { colorMode, colorAccessor } = state; const { colorMode, colorAccessor } = state;
const { world } = nextSharedState; const { world } = nextSharedState;
const { rgb, scale } = createColors(world, colorMode, colorAccessor); const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return { return {
...state, ...state,
rgb, rgb,
@@ -40,14 +54,9 @@ const ColorsReducer = (
} }
case "reset colorscale": { case "reset colorscale": {
const { world } = prevSharedState;
const { rgb, scale } = createColors(world);
return { return {
...state, ...state,
colorMode: null, ...ColorHelpers.resetColors(prevSharedState.world)
colorAccessor: null,
rgb,
scale
}; };
} }
@@ -62,7 +71,11 @@ const ColorsReducer = (
const colorMode = !resetCurrent ? action.type : null; const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.colorAccessor : null; const colorAccessor = !resetCurrent ? action.colorAccessor : null;
const { rgb, scale } = createColors(world, colorMode, colorAccessor); const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return { return {
...state, ...state,
colorMode, colorMode,
@@ -81,7 +94,11 @@ const ColorsReducer = (
const colorMode = !resetCurrent ? action.type : null; const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.gene : null; const colorAccessor = !resetCurrent ? action.gene : null;
const { rgb, scale } = createColors(world, colorMode, colorAccessor); const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return { return {
...state, ...state,
colorMode, colorMode,
@@ -91,6 +108,19 @@ const ColorsReducer = (
}; };
} }
case "clear differential expression": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
prevControls,
state,
prevWorld
);
if (resetColorState) {
return resetColorState;
}
return state;
}
default: { default: {
return state; return state;
} }
+8 -2
View File
@@ -16,7 +16,7 @@ const Controls = (
diffexpGenes: [], diffexpGenes: [],
resettingInterface: false, resettingInterface: false,
graphInteractionMode: "select",
opacityForDeselectedCells: 0.2, opacityForDeselectedCells: 0.2,
scatterplotXXaccessor: null, // just easier to read scatterplotXXaccessor: null, // just easier to read
scatterplotYYaccessor: null, scatterplotYYaccessor: null,
@@ -93,9 +93,10 @@ const Controls = (
} }
case "request differential expression success": { case "request differential expression success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const _diffexpGenes = []; const _diffexpGenes = [];
action.data.forEach(d => { action.data.forEach(d => {
_diffexpGenes.push(world.varAnnotations.at(d[0], "name")); _diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName));
}); });
return { return {
...state, ...state,
@@ -137,6 +138,11 @@ const Controls = (
/******************************* /*******************************
User Events User Events
*******************************/ *******************************/
case "change graph interaction mode":
return {
...state,
graphInteractionMode: action.data
};
case "change opacity deselected cells in 2d graph background": case "change opacity deselected cells in 2d graph background":
return { return {
...state, ...state,
+33 -7
View File
@@ -20,10 +20,11 @@ const CrossfilterReducer = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "initial data load complete (universe exists)": { case "initial data load complete (universe exists)": {
const { world } = nextSharedState; const { world, layoutChoice } = nextSharedState;
const crossfilter = World.createObsDimensions( const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations), new Crossfilter(world.obsAnnotations),
world world,
layoutChoice.currentDimNames
); );
return crossfilter; return crossfilter;
} }
@@ -43,9 +44,13 @@ const CrossfilterReducer = (
case "set clip quantiles": case "set clip quantiles":
case "set World to current selection": { case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls; const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState; const { world, layoutChoice } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations); let crossfilter = new Crossfilter(world.obsAnnotations);
crossfilter = World.createObsDimensions(crossfilter, world); crossfilter = World.createObsDimensions(
crossfilter,
world,
layoutChoice.currentDimNames
);
crossfilter = ControlsHelpers.createGeneDimensions( crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes, userDefinedGenes,
diffexpGenes, diffexpGenes,
@@ -55,6 +60,23 @@ const CrossfilterReducer = (
return crossfilter; return crossfilter;
} }
case "set layout choice": {
/*
when switching layouts:
- delete the existing XY index
- add the new XY index (which implicitly selects all on it)
*/
const { world, layoutChoice } = nextSharedState;
return state
.delDimension(layoutDimensionName("XY"))
.addDimension(
layoutDimensionName("XY"),
"spatial",
world.obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
world.obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
);
}
case "request user defined gene success": { case "request user defined gene success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const gene = action.data.genes[0]; const gene = action.data.genes[0];
@@ -68,8 +90,9 @@ const CrossfilterReducer = (
case "request differential expression success": { case "request differential expression success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const genes = _.map(action.data, d => const genes = _.map(action.data, d =>
world.varAnnotations.at(d[0], "name") world.varAnnotations.at(d[0], varIndexName)
); );
const crossfilter = _.reduce( const crossfilter = _.reduce(
genes, genes,
@@ -87,10 +110,11 @@ const CrossfilterReducer = (
case "clear differential expression": { case "clear differential expression": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const crossfilter = _.reduce( const crossfilter = _.reduce(
action.diffExp, action.diffExp,
(xfltr, values) => { (xfltr, values) => {
const name = world.varAnnotations.at(values[0], "name"); const name = world.varAnnotations.at(values[0], varIndexName);
return xfltr.delDimension(diffexpDimensionName(name)); return xfltr.delDimension(diffexpDimensionName(name));
}, },
state state
@@ -161,10 +185,12 @@ const CrossfilterReducer = (
case "categorical metadata filter select": case "categorical metadata filter select":
case "categorical metadata filter deselect": { case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState; const { categoricalSelection } = nextSharedState;
const { world } = prevSharedState;
const cat = categoricalSelection[action.metadataField]; const cat = categoricalSelection[action.metadataField];
const col = world.obsAnnotations.col(action.metadataField);
return state.select(obsAnnoDimensionName(action.metadataField), { return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact", mode: "exact",
values: ControlsHelpers.selectedValuesForCategory(cat) values: ControlsHelpers.selectedValuesForCategory(cat, col)
}); });
} }
+2 -1
View File
@@ -7,7 +7,8 @@ const GraphSelection = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "set clip quantiles": case "set clip quantiles":
case "reset World to eq Universe": { case "reset World to eq Universe":
case "set layout choice": {
return { return {
...state, ...state,
selection: { selection: {
+6 -1
View File
@@ -12,9 +12,11 @@ import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter"; import crossfilter from "./crossfilter";
import colors from "./colors"; import colors from "./colors";
import differential from "./differential"; import differential from "./differential";
import layoutChoice from "./layoutChoice";
import responsive from "./responsive"; import responsive from "./responsive";
import controls from "./controls"; import controls from "./controls";
import resetCache from "./resetCache"; import resetCache from "./resetCache";
import centroidLabel from "./centroidLabel";
import undoableConfig from "./undoableConfig"; import undoableConfig from "./undoableConfig";
@@ -23,6 +25,7 @@ const Reducer = undoable(
["config", config], ["config", config],
["universe", universe], ["universe", universe],
["world", world], ["world", world],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection], ["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection], ["continuousSelection", continuousSelection],
["graphSelection", graphSelection], ["graphSelection", graphSelection],
@@ -31,6 +34,7 @@ const Reducer = undoable(
["controls", controls], ["controls", controls],
["differential", differential], ["differential", differential],
["responsive", responsive], ["responsive", responsive],
["centroidLabel", centroidLabel],
["resetCache", resetCache] ["resetCache", resetCache]
]), ]),
[ [
@@ -41,7 +45,8 @@ const Reducer = undoable(
"crossfilter", "crossfilter",
"colors", "colors",
"controls", "controls",
"differential" "differential",
"layoutChoice"
], ],
undoableConfig undoableConfig
); );
+50
View File
@@ -0,0 +1,50 @@
/*
we have a UI heuristic to pick the default layout, based on assumptions
about commonly used names. Preferentially, pick in the following order:
1. "umap"
2. "tsne"
3. "pca"
4. give up, use the first available
*/
function bestDefaultLayout(layouts) {
const preferredNames = ["umap", "tsne", "pca"];
const idx = preferredNames.findIndex(name => layouts.indexOf(name) !== -1);
if (idx !== -1) return preferredNames[idx];
return layouts[0];
}
const LayoutChoice = (
state = {
available: [], // all available choices
current: undefined, // name of the current layout, eg, 'umap'
currentDimNames: [] // dimension name
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
// set default to default
const { schema } = nextSharedState.world;
const available = schema.layout.obs.map(v => v.name);
const current = bestDefaultLayout(available);
const currentDimNames = schema.layout.obsByName[current].dims;
return { available, current, currentDimNames };
}
case "set layout choice": {
const { schema } = nextSharedState.world;
const current = action.layoutChoice;
const currentDimNames = schema.layout.obsByName[current].dims;
return { ...state, current, currentDimNames };
}
default: {
return state;
}
}
};
export default LayoutChoice;
+17 -5
View File
@@ -29,7 +29,10 @@ const skipOnActions = new Set([
"clear all user defined genes", "clear all user defined genes",
"get single gene expression for coloring started", "get single gene expression for coloring started",
"get single gene expression for coloring error" "get single gene expression for coloring error",
"category value mouse hover start",
"category value mouse hover end"
]); ]);
/* /*
@@ -59,7 +62,7 @@ const saveOnActions = new Set([
"categorical metadata filter select", "categorical metadata filter select",
"categorical metadata filter deselect", "categorical metadata filter deselect",
"categorical metadata filter all of these", "categorical metadata filter all of these",
"categorical metadata none of these", "categorical metadata filter none of these",
"color by categorical metadata", "color by categorical metadata",
"color by continuous metadata", "color by continuous metadata",
@@ -72,7 +75,10 @@ const saveOnActions = new Set([
"store current cell selection as differential set 2", "store current cell selection as differential set 2",
"set World to current selection", "set World to current selection",
"set clip quantiles" "set clip quantiles",
"set layout choice",
"change graph interaction mode"
]); ]);
/** /**
@@ -98,9 +104,15 @@ const applyPending = () => ({
[actionKey]: "applyPending", [actionKey]: "applyPending",
[stateKey]: { fsm: null } [stateKey]: { fsm: null }
}); });
const skip = fsm => ({ [actionKey]: "skip", [stateKey]: { fsm } }); const skip = (fsm, transition) => ({
[actionKey]: "skip",
[stateKey]: { fsm: transition.to !== "done" ? fsm : null }
});
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } }); const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
const save = fsm => ({ [actionKey]: "save", [stateKey]: { fsm } }); const save = (fsm, transition) => ({
[actionKey]: "save",
[stateKey]: { fsm: transition.to !== "done" ? fsm : null }
});
/* /*
Error handler for state transitions that are unexpected. Called by Error handler for state transitions that are unexpected. Called by
+1 -2
View File
@@ -5,10 +5,9 @@ const mp = require("mouse-position");
const mb = require("mouse-pressed"); const mb = require("mouse-pressed");
const key = require("key-pressed"); const key = require("key-pressed");
const panSpeed = 0.4; const panSpeed = 1.0; // changed from 0.4 to 1.0 per issue #722
const scaleSpeed = 0.5; const scaleSpeed = 0.5;
const scaleMax = 3; const scaleMax = 3;
// const scaleMin = 1.15
const scaleMin = 1.03; const scaleMin = 1.03;
function attachCamera(canvas, opts) { function attachCamera(canvas, opts) {
+58
View File
@@ -0,0 +1,58 @@
import quantile from "./quantile";
/*
Centroid coordinate calculation
*/
const calcMeanCentroid = (world, annoName, annoValue, layoutDimNames) => {
const centroid = { x: 0, y: 0, size: 0 };
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
for (let i = 0, len = annoArray.length; i < len; i += 1) {
if (annoArray[i] === annoValue) {
centroid.x += layoutXArray[i];
centroid.y += layoutYArray[i];
centroid.size += 1;
}
}
if (centroid[2] !== 0) {
centroid.x /= centroid.size;
centroid.y /= centroid.size;
}
return [centroid.x, centroid.y];
};
const calcMedianCentroid = (world, annoName, annoValue, layoutDimNames) => {
const centroidX = [];
const centroidY = [];
let hasFinite = false;
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
for (let i = 0, len = annoArray.length; i < len; i += 1) {
if (annoArray[i] === annoValue) {
hasFinite =
Number.isFinite(layoutXArray[i]) || Number.isFinite(layoutYArray[i])
? true
: hasFinite;
centroidX.push(layoutXArray[i]);
centroidY.push(layoutYArray[i]);
}
}
if (hasFinite) {
const medianX = quantile([0.5], Float64Array.from(centroidX));
const medianY = quantile([0.5], Float64Array.from(centroidY));
return [medianX, medianY];
}
return null;
};
export default calcMedianCentroid;
+49 -7
View File
@@ -1,8 +1,19 @@
import { IdentityInt32Index, isLabelIndex } from "./labelIndex"; import { IdentityInt32Index, isLabelIndex } from "./labelIndex";
// weird cross-dependency that we should clean up someday... // weird cross-dependency that we should clean up someday...
import { sortArray } from "../typedCrossfilter/sort"; import { sortArray } from "../typedCrossfilter/sort";
import { isTypedArray, isArrayOrTypedArray, callOnceLazy } from "./util"; import {
isTypedArray,
isArrayOrTypedArray,
callOnceLazy,
memoize
} from "./util";
import { summarizeContinuous, summarizeCategorical } from "./summarize"; import { summarizeContinuous, summarizeCategorical } from "./summarize";
import {
histogramCategorical,
hashCategorical,
histogramContinuous,
hashContinuous
} from "./histogram";
/* /*
Dataframe is an immutable 2D matrix similiar to Python Pandas Dataframe, Dataframe is an immutable 2D matrix similiar to Python Pandas Dataframe,
@@ -59,6 +70,17 @@ Dataframe
**/ **/
class Dataframe { class Dataframe {
/**
memoization helpers.
**/
static __DataframeId__ = 0;
static __getId() {
const id = Dataframe.__DataframeId__;
Dataframe.__DataframeId__ += 1;
return id;
}
/** /**
Constructors & factories Constructors & factories
**/ **/
@@ -102,6 +124,7 @@ class Dataframe {
this.length = nRows; // convenience accessor for row dimension this.length = nRows; // convenience accessor for row dimension
this.rowIndex = rowIndex; this.rowIndex = rowIndex;
this.colIndex = colIndex; this.colIndex = colIndex;
this.__id = Dataframe.__getId();
this.__compile(__columnsAccessor); this.__compile(__columnsAccessor);
} }
@@ -144,7 +167,7 @@ class Dataframe {
} }
} }
static __compileColumn(column, getOffset, getLabel) { static __compileColumn(column, getRowByOffset, getRowByLabel) {
/* /*
Each column accessor is a function which will lookup data by Each column accessor is a function which will lookup data by
index (ie, is equivalent to dataframe.get(row, col), where 'col' index (ie, is equivalent to dataframe.get(row, col), where 'col'
@@ -172,12 +195,15 @@ class Dataframe {
iget(offset) -- return the value at 'offset' iget(offset) -- return the value at 'offset'
... and more ...
*/ */
const { length } = column; const { length } = column;
const __id = Dataframe.__getId();
/* get value by row label */ /* get value by row label */
const get = function get(rlabel) { const get = function get(rlabel) {
return column[getOffset(rlabel)]; return column[getRowByOffset(rlabel)];
}; };
/* get value by row offset */ /* get value by row offset */
@@ -192,7 +218,7 @@ class Dataframe {
/* test for row label inclusion in column */ /* test for row label inclusion in column */
const has = function has(rlabel) { const has = function has(rlabel) {
const offset = getOffset(rlabel); const offset = getRowByOffset(rlabel);
return offset >= 0 && offset < length; return offset >= 0 && offset < length;
}; };
@@ -212,7 +238,7 @@ class Dataframe {
if (offset === -1) { if (offset === -1) {
return undefined; return undefined;
} }
return getLabel(offset); return getRowByLabel(offset);
}; };
/* /*
@@ -224,12 +250,25 @@ class Dataframe {
: summarizeCategorical(column) : summarizeCategorical(column)
); );
/*
Create histogram bins for this column. Memoized.
*/
if (isTypedArray(column)) {
const mFn = memoize(histogramContinuous, hashContinuous);
get.histogram = (bins, domain, by) => mFn(get, bins, domain, by);
} else {
const mFn = memoize(histogramCategorical, hashCategorical);
get.histogram = by => mFn(get, by);
}
get.summarize = summarize; get.summarize = summarize;
get.asArray = asArray; get.asArray = asArray;
get.has = has; get.has = has;
get.ihas = ihas; get.ihas = ihas;
get.indexOf = indexOf; get.indexOf = indexOf;
get.iget = iget; get.iget = iget;
get.__id = __id;
return get; return get;
} }
@@ -239,12 +278,15 @@ class Dataframe {
Use an existing accessor if provided, else compile a new one. Use an existing accessor if provided, else compile a new one.
*/ */
const { getOffset, getLabel } = this.rowIndex; const {
getOffset: getRowByOffset,
getLabel: getRowByLabel
} = this.rowIndex;
this.__columnsAccessor = this.__columns.map((column, idx) => { this.__columnsAccessor = this.__columns.map((column, idx) => {
if (accessors[idx]) { if (accessors[idx]) {
return accessors[idx]; return accessors[idx];
} }
return Dataframe.__compileColumn(column, getOffset, getLabel); return Dataframe.__compileColumn(column, getRowByOffset, getRowByLabel);
}); });
} }
+135
View File
@@ -0,0 +1,135 @@
/*
Dataframe histogram
*/
import { isTypedArray } from "./util";
function _histogramContinuous(column, bins, min, max) {
const valBins = new Array(bins).fill(0);
if (!column) {
return valBins;
}
const binWidth = (max - min) / (bins - 1);
const colArray = column.asArray();
for (let r = 0, len = colArray.length; r < len; r += 1) {
const val = colArray[r];
if (val <= max && val >= min) {
// ensure test excludes NaN values
const valBin = (val - min) / binWidth;
valBins[valBin] += 1;
}
}
return valBins;
}
function _histogramContinuousBy(column, bins, min, max, by) {
const byMap = new Map();
if (!column || !by) {
return byMap;
}
const binWidth = (max - min) / (bins - 1);
const byArray = by.asArray();
const colArray = column.asArray();
for (let r = 0, len = colArray.length; r < len; r += 1) {
const byBin = byArray[r];
let valBins = byMap.get(byBin);
if (valBins === undefined) {
valBins = new Array(bins).fill(0);
byMap.set(byBin, valBins);
}
const val = colArray[r];
if (val <= max && val >= min) {
// ensure test excludes NaN values
const valBin = (val - min) / binWidth;
valBins[Math.floor(valBin)] += 1;
}
}
return byMap;
}
function _histogramCategorical(column) {
const valMap = new Map();
if (!column) {
return valMap;
}
const colArray = column.asArray();
for (let r = 0, len = colArray.length; r < len; r += 1) {
const valBin = colArray[r];
let curCount = valMap.get(valBin);
if (curCount === undefined) {
curCount = 0;
}
valMap.set(valBin, curCount + 1);
}
return valMap;
}
function _histogramCategoricalBy(column, by) {
const byMap = new Map();
if (!column || !by) {
return byMap;
}
const byArray = by.asArray();
const colArray = column.asArray();
for (let r = 0, len = colArray.length; r < len; r += 1) {
const byBin = byArray[r];
let valMap = byMap.get(byBin);
if (valMap === undefined) {
valMap = new Map();
byMap.set(byBin, valMap);
}
const valBin = colArray[r];
let curCount = valMap.get(valBin);
if (curCount === undefined) {
curCount = 0;
}
valMap.set(valBin, curCount + 1);
}
return byMap;
}
/*
Count category occupancy. Optional group-by category.
*/
export function histogramCategorical(column, by) {
if (by && isTypedArray(by)) {
throw new Error("Group by column must be categorical");
}
return by
? _histogramCategoricalBy(column, by)
: _histogramCategorical(column);
}
/*
Memoization hash for histogramCategorical()
*/
export function hashCategorical(column, by) {
if (by) {
return `${column.__id}:${by.__id}`;
}
return `${column.__id}:`;
}
/*
Bin counts for continuous/scalar values, with optional group-by category.
Values outside domain are ignored.
*/
export function histogramContinuous(column, bins = 40, domain = [0, 1], by) {
if (by && isTypedArray(by)) {
throw new Error("Group by column must be categorical");
}
const [min, max] = domain;
return by
? _histogramContinuousBy(column, bins, min, max, by)
: _histogramContinuous(column, bins, min, max);
}
/*
Memoization hash for histogramContinuous
*/
export function hashContinuous(column, bins = "", domain = [0, 0], by) {
const [min, max] = domain;
if (by) {
return `${column.__id}:${bins}:${min}:${max}:${by.__id}`;
}
return `${column.__id}::${bins}:${min}:${max}`;
}
+24 -1
View File
@@ -5,6 +5,10 @@ Private utility code for dataframe
export { isTypedArray, isArrayOrTypedArray } from "../typeHelpers"; export { isTypedArray, isArrayOrTypedArray } from "../typeHelpers";
export function callOnceLazy(f) { export function callOnceLazy(f) {
/*
call function once, and save the result, regardless of arguments (this is not
the same as typical memoization).
*/
let value; let value;
let calledOnce = false; let calledOnce = false;
const result = function result(...args) { const result = function result(...args) {
@@ -14,6 +18,25 @@ export function callOnceLazy(f) {
} }
return value; return value;
}; };
return result; return result;
} }
export function memoize(fn, hashFn) {
/*
function memoization, with user-provided hash. hashFn must return a
key which will be unique as a Map key (ie, obeys "sameValueZero" algorithm
as defined in the JS spec). For more info on hash key, see:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#Key_equality
*/
const cache = new Map();
const wrap = function wrap(...args) {
const key = hashFn(...args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
};
return wrap;
}
+10 -4
View File
@@ -9,8 +9,14 @@
// this is is equivalent to d3.scaleLinear().domain([0,1]).range([-1,1]) // this is is equivalent to d3.scaleLinear().domain([0,1]).range([-1,1])
export default (domain, range) => { export default (domain, range) => {
const domainStart = domain[0]; const domainStart = domain[0];
const scale = (range[1] - range[0]) / (domain[1] - domain[0]); const scale = (range[1] - range[0]) / (domain[1] - domain[0]);
const rangeStart = range[0]; const invScale = 1 / scale;
return value => (value - domainStart) * scale + rangeStart; const rangeStart = range[0];
const f = value => (value - domainStart) * scale + rangeStart;
// inverter
f.invert = value => (value - rangeStart) * invScale + domainStart;
return f;
}; };
+24 -2
View File
@@ -15,7 +15,7 @@ create new colors state object. Paramters:
"color by continuous metadata", "color by categorical metadata" "color by continuous metadata", "color by categorical metadata"
- -
*/ */
function createColors(world, colorMode = null, colorAccessor = null) { export function createColors(world, colorMode = null, colorAccessor = null) {
switch (colorMode) { switch (colorMode) {
case "color by categorical metadata": { case "color by categorical metadata": {
return createColorsByCategoricalMetadata(world, colorAccessor); return createColorsByCategoricalMetadata(world, colorAccessor);
@@ -117,4 +117,26 @@ function createColorsByExpression(world, accessor) {
return { rgb, scale }; return { rgb, scale };
} }
export default createColors; export const resetColors = world => {
const { rgb, scale } = createColors(world);
return {
colorMode: null,
colorAccessor: null,
rgb,
scale
};
};
export const checkIfColorByDiffexpAndResetColors = (
prevControls,
state,
prevWorld
) => {
if (prevControls.diffexpGenes.includes(state.colorAccessor)) {
return {
...state,
...resetColors(prevWorld)
};
}
return null;
};
+36 -18
View File
@@ -21,16 +21,16 @@ Remember that option values can be ANY js type, except undefined/null.
{ {
_category_name_1: { _category_name_1: {
// map of option value to index // map of option value to index
categoryIndices: Map([ categoryValueIndices: Map([
catval1: index, catval1: index,
... ...
]) ])
// index->selection true/false state // index->selection true/false state
categorySelected: [ true/false, true/false, ... ] categoryValueSelected: [ true/false, true/false, ... ]
// number of options // number of options
numCategories: number, numCategoryValues: number,
// isTruncated - true if the options for selection has // isTruncated - true if the options for selection has
// been truncated (ie, was too large to implement) // been truncated (ie, was too large to implement)
@@ -56,27 +56,31 @@ function topNCategories(summary) {
export function createCategoricalSelection(maxCategoryItems, world) { export function createCategoricalSelection(maxCategoryItems, world) {
const res = {}; const res = {};
const obsIndexName = world.schema.annotations.obs.index;
_.forEach(world.obsAnnotations.colIndex.keys(), key => { _.forEach(world.obsAnnotations.colIndex.keys(), key => {
const summary = world.obsAnnotations.col(key).summarize(); const summary = world.obsAnnotations.col(key).summarize();
if (summary.categories) { if (summary.categories) {
const isColorField = key.includes("color") || key.includes("Color"); const isColorField = key.includes("color") || key.includes("Color");
const isSelectableCategory = const isSelectableCategory =
!isColorField && !isColorField &&
key !== "name" && key !== obsIndexName &&
summary.categories.length < maxCategoryItems; summary.categories.length < maxCategoryItems;
if (isSelectableCategory) { if (isSelectableCategory) {
const [categoryValues, categoryCounts] = topNCategories(summary); const [categoryValues, categoryValueCounts] = topNCategories(summary);
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i])); const categoryValueIndices = new Map(
const numCategories = categoryIndices.size; categoryValues.map((v, i) => [v, i])
const categorySelected = new Array(numCategories).fill(true); );
const numCategoryValues = categoryValueIndices.size;
const categoryValueSelected = new Array(numCategoryValues).fill(true);
const isTruncated = categoryValues.length < summary.numCategories; const isTruncated = categoryValues.length < summary.numCategories;
res[key] = { res[key] = {
categoryValues, // array: of natively typed category values categoryValues, // array: of natively typed category values
categoryIndices, // map: category value (native type) -> category index categoryValueIndices, // map: category value (native type) -> category index
categorySelected, // array: t/f selection state categoryValueSelected, // array: t/f selection state
numCategories, // number: of categories numCategoryValues, // number: of values in the category
isTruncated, // bool: true if list was truncated isTruncated, // bool: true if list was truncated
categoryCounts // array: cardinality of each category categoryValueCounts, // array: cardinality of each category,
categorySelected: true // bool - default state for entire category
}; };
} }
} }
@@ -88,12 +92,26 @@ export function createCategoricalSelection(maxCategoryItems, world) {
given a categoricalSelection, return the list of all category values given a categoricalSelection, return the list of all category values
where selection state is true (ie, they are selected). where selection state is true (ie, they are selected).
*/ */
export function selectedValuesForCategory(categorySelectionState) { export function selectedValuesForCategory(categorySelectionState, dfColumn) {
const selectedValues = _([...categorySelectionState.categoryIndices]) const {
.filter(tuple => categorySelectionState.categorySelected[tuple[1]]) categorySelected,
.map(tuple => tuple[0]) categoryValueSelected,
.value(); categoryValueIndices
return selectedValues; } = categorySelectionState;
let selectedValues;
if (categorySelected) {
selectedValues = new Set(dfColumn.summarize().categories);
} else {
selectedValues = new Set();
}
categoryValueIndices.forEach((catIndex, catValue) => {
if (!categoryValueSelected[catIndex]) {
selectedValues.delete(catValue);
} else {
selectedValues.add(catValue);
}
});
return [...selectedValues.values()];
} }
/* /*
+1 -1
View File
@@ -14,7 +14,7 @@ This is all VERY tightly integrated with reducers and actions, and
exists to support those concepts. exists to support those concepts.
*/ */
export { default as createColors } from "./colorHelpers"; export * as ColorHelpers from "./colorHelpers";
export * as Universe from "./universe"; export * as Universe from "./universe";
export * as World from "./world"; export * as World from "./world";
export * as WorldUtil from "./worldUtil"; export * as WorldUtil from "./worldUtil";
+17 -23
View File
@@ -104,27 +104,11 @@ function LayoutFBSToDataframe(arrayBuffer) {
throw new Error("Unexpected layout data type returned from server"); throw new Error("Unexpected layout data type returned from server");
} }
/*
TODO: XXX
TEMPORARY CODE AND COMMENT to support the progressive implementation
of multi-layout support. For now, we search for one of the following
in the layouts and use it if we find it: umap, then tsne, then pca,
then whatever is first in the list.
*/
let layoutIndex = 0;
["umap", "tsne", "pca"].some(name => {
const idx = fbs.colIdx.indexOf(`${name}_0`);
if (idx !== -1) {
layoutIndex = idx;
}
return idx !== -1;
});
const df = new Dataframe.Dataframe( const df = new Dataframe.Dataframe(
[fbs.nRows, 2], [fbs.nRows, fbs.nCols],
[fbs.columns[layoutIndex], fbs.columns[layoutIndex + 1]], fbs.columns,
null, null,
new Dataframe.KeyIndex(["X", "Y"]) new Dataframe.KeyIndex(fbs.colIdx)
); );
return df; return df;
} }
@@ -140,7 +124,7 @@ function reconcileSchemaCategoriesWithSummary(universe) {
cases, add a 'categories' field to the schema so it is accessible. cases, add a 'categories' field to the schema so it is accessible.
*/ */
universe.schema.annotations.obs.forEach(s => { universe.schema.annotations.obs.columns.forEach(s => {
if ( if (
s.type === "string" || s.type === "string" ||
s.type === "boolean" || s.type === "boolean" ||
@@ -172,6 +156,9 @@ export function createUniverseFromResponse(
universe.schema = schema; universe.schema = schema;
universe.nObs = schema.dataframe.nObs; universe.nObs = schema.dataframe.nObs;
universe.nVar = schema.dataframe.nVar; universe.nVar = schema.dataframe.nVar;
/* add defaults, as we can't assume back-end will fully populate schema */
if (!schema.layout.var) schema.layout.var = [];
if (!schema.layout.obs) schema.layout.obs = [];
/* annotations */ /* annotations */
universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse); universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse);
@@ -192,10 +179,16 @@ export function createUniverseFromResponse(
/* Index schema for ease of use */ /* Index schema for ease of use */
universe.schema.annotations.obsByName = fromEntries( universe.schema.annotations.obsByName = fromEntries(
universe.schema.annotations.obs.map(v => [v.name, v]) universe.schema.annotations.obs.columns.map(v => [v.name, v])
); );
universe.schema.annotations.varByName = fromEntries( universe.schema.annotations.varByName = fromEntries(
universe.schema.annotations.var.map(v => [v.name, v]) universe.schema.annotations.var.columns.map(v => [v.name, v])
);
universe.schema.layout.obsByName = fromEntries(
universe.schema.layout.obs.map(v => [v.name, v])
);
universe.schema.layout.varByName = fromEntries(
universe.schema.layout.var.map(v => [v.name, v])
); );
return universe; return universe;
} }
@@ -220,8 +213,9 @@ export function convertDataFBStoObject(universe, arrayBuffer) {
throw new Error("Unexpected non-floating point response from server."); throw new Error("Unexpected non-floating point response from server.");
} }
const varIndexName = universe.schema.annotations.var.index;
for (let c = 0; c < colIdx.length; c += 1) { for (let c = 0; c < colIdx.length; c += 1) {
const varName = universe.varAnnotations.at(colIdx[c], "name"); const varName = universe.varAnnotations.at(colIdx[c], varIndexName);
result[varName] = columns[c]; result[varName] = columns[c];
} }
return result; return result;
+9 -5
View File
@@ -260,13 +260,17 @@ function deduceDimensionType(attributes, fieldName) {
return dimensionType; return dimensionType;
} }
export function createObsDimensions(crossfilter, world) { export function createObsDimensions(crossfilter, world, XYdimNames) {
/* /*
create and return a crossfilter with a dimension for every obs annotation create and return a crossfilter with a dimension for every obs annotation
for which we have a supported type, *except* 'name' for which we have a supported type, *except* for the index column, indicated
by schema.annotations.obs.index.
*/ */
const { schema, obsLayout, obsAnnotations } = world; const { schema, obsLayout, obsAnnotations } = world;
const annoList = schema.annotations.obs.filter(anno => anno.name !== "name"); const indexName = schema.annotations.obs.index;
const annoList = schema.annotations.obs.columns.filter(
anno => anno.name !== indexName
);
crossfilter = annoList.reduce((xfltr, anno) => { crossfilter = annoList.reduce((xfltr, anno) => {
const dimType = deduceDimensionType(anno, anno.name); const dimType = deduceDimensionType(anno, anno.name);
const colData = obsAnnotations.col(anno.name).asArray(); const colData = obsAnnotations.col(anno.name).asArray();
@@ -283,8 +287,8 @@ export function createObsDimensions(crossfilter, world) {
return crossfilter.addDimension( return crossfilter.addDimension(
layoutDimensionName("XY"), layoutDimensionName("XY"),
"spatial", "spatial",
obsLayout.col("X").asArray(), obsLayout.col(XYdimNames[0]).asArray(),
obsLayout.col("Y").asArray() obsLayout.col(XYdimNames[1]).asArray()
); );
} }
+91
View File
@@ -0,0 +1,91 @@
# Developer guidelines
### Requirements
- npm
- Python 3.6+
- Chrome
[See dev section of README](../README.md)
**All instructions are expected to be run from the top level cellxgene directory unless otherwise specified.**
## Server dev
### Install
* Build the client and put static files in place: `make build-for-server-dev`
* Install from local files: `make install-dev`
### Launch
* `cellxgene launch [options] <datafile>`
### Reloading
If you install cellxgene using `make install-dev` the server will be restarted every time you make changes on the server code. If changes affects the client, the browser must be reloaded.
### Linter
We use `flake8` to lint code. Travis CI runs `flake8 server`.
### Test
1. Install development requirements `pip install -r server/requirements-dev.txt`
2. Run tests `pytest server/test`
### Tips
* Install in a virtualenv
* May need to rebuild/reinstall when you make client changes
## Client dev
### Install
1. Install prereqs for client: `npm install --prefix client/ client`
2. Install cellxgene server: `pip install -e .` Caveat: this will not build the production client package - you must use the [server install](#install) instructions above to serve web assets.
### Launch
To launch with hot reloading you need to launch the server and the client separately. Node's hot reloading starts the client on its own node server and auto-refreshes when changes are made.
1. Launch server (the client relies on the REST API being available): `cellxgene launch [options] <datafile>`
2. Launch client: in `client/` directory run `npm run start`
3. Client will be served on localhost:3000
### Build
To build only the client: `make build-client`
### Linter
We use `eslint` to lint the code and `prettier` as our code formatter.
### Test
In `client/` directory run `npm run unit-test`
### Tips
* You can also install/launch the server side code from npm scrips (requires python3.6 with virtualenv) in `client/` directory run `npm run backend-dev`
## Running tests
Client and server tests run on Travis CI for every push, PR, and commit to master on github. End to end tests run nightly on master only.
### Server unit tests
Install development requirements `pip install -r server/requirements-dev.txt`
Run tests `pytest server/test`
### Client unit tests
In `client/` directory run `npm run unit-test`
### End to end tests
End to end tests use two env variables:
* `JEST_ENV` - environment to run end to end tests. Default `dev`
* `prod` - run headless with no slowdown, chromium will not open.
* `dev` - opens chromimum, runs tests with minimal slowdown, close on exit.
* `debug` - opens chromium, runs tests with 100ms slowdown, dev tools open, chrome stays open on exit.
* `JEST_CXG_PORT` - port that end to end tests are being run on. Default `3000` (client hosted port).
On CI the end to end tests are run with `JEST_ENV` set to `prod` using the `smoke-test` npm script
To run end to end tests as they will be run on CI
1. cellxgene should be built and installed as [specified in server dev](#install)
2. `export JEST_ENV='prod'`
3. `export JEST_CXG_PORT='5000'`
4. Run `npm run --prefix client/ smoke-test`
Run end to end tests interactively during development
1. cellxgene should be installed as [specified in client dev](#install-1)
2. Follow [launch](#launch-1) instructions for client dev with dataset `example-dataset/pbmc3k`
3. Run `npm run --prefix client/ e2e`
4. To debug a failing test `export JEST_ENV='debug'` and re-run.
+93 -28
View File
@@ -1,29 +1,94 @@
--- ---
layout: default layout: default
title: data title: data
description: Data description: Data
--- ---
# Using `cellxgene prepare`
#### What is `cellxgene prepare`?
`prepare` offers an easy command line interface (CLI) to preliminarily wrangle your data into the required format for previewing it with `cellxgene`.
#### What is `cellxgene prepare` _not_?
`cellxgene prepare` is not meant as a way to formally process or analyze your data. It's simply a utility for quickly wrangling your data into cellxgene-compatible format and computing a "vanilla" embedding so you can try out `cellxgene` and get a general sense of a dataset.
#### What input formats does it accept?
Currently, we accept `h5ad` and `loom` files, as well as `10x` directories, and are hoping to accept more formats in the future.
While we'd like to support quick conversion from seurat and bioconductor, these packages don't currently output a python-parseable intermediate file type. In the meantime, you might check out the [converters](https://satijalab.org/seurat/v3.0/conversion_vignette.html) that are under early development.
#### What can `cellxgene prepare` do?
`prepare` uses scanpy to:
- Handle simple data normalization (from a [recipe](https://www.pydoc.io/pypi/scanpy-0.2.3/autoapi/preprocessing/recipes/index.html))
- Do basic preprocessing to run PCA and compute the neighbor graph
- Infer clusters
- Reduce dimensionality to generate embeddings.
You can control which steps to run and their methods (when applicable), via the CLI. The CLI also includes options for computing QC metrics, enforcing matrix sparcity, specifying index names, and plotting output.
**To see a full list of available arguments and options, run `cellxgene prepare --help`.**
#### How do I use `cellxgene prepare`?
As a quick example, let's construct a command to use `prepare` to take a raw expression matrix and generate a processed `h5ad` ready to visualize with cellxgene.
We'll start off using the raw data from the pbmc3k dataset. This dataset is described [here](https://icb-scanpy.readthedocs-hosted.com/en/stable/api/scanpy.datasets.pbmc3k.html), and is available as part of the scanpy API. For this example, we'll assume this raw data is stored in a file called `pbmc3k-raw.h5ad`.
Our `prepare` compose our command looks like this:
<img src="prepare-cmd-example.jpg" width="700" />
Let's look at what `prepare` is doing to our data, and how each step relates to the command above. You can see a walkthrough of what's going on under the hood for this example in [this notebook](https://github.com/chanzuckerberg/cellxgene-vignettes/blob/master/dataset-processing/pbmc3k-prepare-example.ipynb).
**1 - Compute quality control metrics and store this in our `AnnData` object for later inspection (A)**
**2 - Normalize the expression matrix using a basic preprocessing recipe (B)**
**3 - Do some preprocessing to run PCA and compute the neighbor graph (auto)**
**4 - Infer clusters with the Louvain algorithm and store these labels to visualize later (auto)**
**5 - Compute and store umap and tsne embeddings (C)**
**6 - Write results to file (D)**
# Example datasets to use with cellxgene
**To download and use these datasets, run:**
# data vignette: how to use cellxgene prepare `curl -O [URL]`
`unzip [filename.zip]`
#### coming soon! `cellxgene launch [filename.h5ad] --open`
# example datasets to use with cellxgene ### Peripheral blood mononuclear cells
### Examination of single cells from primary human pancreas tissue Healthy human PBMCs (10X).
cells: 2,544
tissue(s): pancreas - Source: [10X genomics](https://support.10xgenomics.com/single-cell-gene-expression/datasets/1.1.0/pbmc3k)
data: [Human Cell Atlas Data Portal](https://prod.data.humancellatlas.org/explore/projects?filter=%5B%7B%22facetName%22%3A%22organ%22%2C%22terms%22%3A%5B%22pancreas%22%5D%7D%2C%7B%22facetName%22%3A%22project%22%2C%22terms%22%3A%5B%22Single+cell+transcriptome+analysis+of+human+pancreas%22%5D%7D%5D) - Cells: 2,638
paper: [Enge, Martin, et al.](https://www.cell.com/cell/fulltext/S0092-8674(17)31053-X?_returnURL=https%3A%2F%2Flinkinghub.elsevier.com%2Fretrieve%2Fpii%2FS009286741731053X%3Fshowall%3Dtrue) - File size: 19MB
- [Raw data](http://cf.10xgenomics.com/samples/cell-exp/1.1.0/pbmc3k/pbmc3k_filtered_gene_bc_matrices.tar.gz)
### Tabula Muris - [Processing](https://github.com/chanzuckerberg/cellxgene-vignettes/blob/master/dataset-processing/pbmc3k-processing.ipynb)
cells: 53,800 - Download: `curl -O https://cellxgene-example-data.czi.technology/pbmc3k.h5ad.zip`
tissue(s): muscle, pancreas, bone, large intestine, heart, brain, fat, mammary gland, tongue , diaphragm, bladder, spleen, thymus, lung , skin, liver, trachea, kidney
data: [Tabula Muris Data](https://github.com/czbiohub/tabula-muris-vignettes/tree/master/data) ### Tabula muris
paper: [Tabula Muris Consortium](https://www.nature.com/articles/s41586-018-0590-4)
20 organs and tissues from healthy mice (Smart-Seq2).
### Transcriptional profiling of 1.3 million brain cells Rich metadata and annotations.
cells: 1,330,000
tissue(s): brain - Source: [bioRxiv, CZBiohub](https://www.biorxiv.org/content/10.1101/237446v2)
data: [10x Genomics](https://community.10xgenomics.com/t5/10x-Blog/Our-1-3-million-single-cell-dataset-is-ready-to-download/ba-p/276) - Cells: 45,423
- File size: 174MB
- [Raw data](https://figshare.com/projects/Tabula_Muris_Transcriptomic_characterization_of_20_organs_and_tissues_from_Mus_musculus_at_single_cell_resolution/27733)
- [Processing](https://github.com/chanzuckerberg/cellxgene-vignettes/blob/master/dataset-processing/tabula-muris-processing.ipynb)
- Download: `curl -O https://cellxgene-example-data.czi.technology/tabula-muris.h5ad.zip`
### Tabula muris senis
22 organs and tissues from healthy mice at ages 3mo, 18mo, 21mo, and 24mo (Smart-Seq2).
Rich metadata and annotations.
- Source: [bioRxiv, CZBiohub](https://www.biorxiv.org/content/10.1101/661728v1)
- Cells: 81,478
- File size: 3.9GB
- Raw data [geo link coming soon!]
- [Processing](https://www.biorxiv.org/content/10.1101/661728v1)
- Download: `curl -O https://cellxgene-example-data.czi.technology/tabula-muris-senis.h5ad.zip`
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+28 -1
View File
@@ -1,7 +1,7 @@
BUILDDIR := build BUILDDIR := build
CLIENTBUILD := $(BUILDDIR)/client CLIENTBUILD := $(BUILDDIR)/client
SERVERBUILD := $(BUILDDIR)/server SERVERBUILD := $(BUILDDIR)/server
CLEANFILES := $(BUILDDIR)/ client/build dist cellxgene.egg-info CLEANFILES := $(BUILDDIR)/ client/build build dist cellxgene.egg-info
PART ?= patch PART ?= patch
@@ -84,6 +84,9 @@ release-directly-to-prod : dev-env pydist twine-prod
dev-env : dev-env :
pip install -r server/requirements-dev.txt pip install -r server/requirements-dev.txt
gui-env : dev-env
pip install -r server/requirements-gui.txt
# give PART=[major, minor, part] as param to make bump # give PART=[major, minor, part] as param to make bump
bump : bump :
bumpversion --config-file .bumpversion.cfg $(PART) bumpversion --config-file .bumpversion.cfg $(PART)
@@ -130,3 +133,27 @@ uninstall :
pip uninstall -y cellxgene || : pip uninstall -y cellxgene || :
.PHONY : install install-dev install-release-test install-release uninstall .PHONY : install install-dev install-release-test install-release uninstall
# GUI
build-assets :
pyside2-rcc server/gui/cellxgene.qrc -o server/gui/cellxgene_rc.py
gui-spec-osx : clean-lite gui-env
pip install -e .[gui]
pyi-makespec -D -w --additional-hooks-dir server/gui/ -n cellxgene --add-binary='/System/Library/Frameworks/Tk.framework/Tk':'tk' --add-binary='/System/Library/Frameworks/Tcl.framework/Tcl':'tcl' --add-data server/app/web/templates/:server/app/web/templates/ --add-data server/app/web/static/:server/app/web/static/ --icon server/gui/images/cxg_icons.icns server/gui/main.py
mv cellxgene.spec cellxgene-osx.spec
gui-spec-windows : clean-lite dev-env
pip install -e .[gui]
pyi-makespec -D -w --additional-hooks-dir server/gui/ -n cellxgene --add-data server/app/web/templates;server/app/web/templates --add-data server/app/web/static;server/app/web/static --icon server/gui/images/icon.ico server/gui/main.py
mv cellxgene.spec cellxgene-windows.spec
gui-build-osx : clean-lite
pyinstaller --clean cellxgene-osx.spec
gui-build-windows : clean-lite
pyinstaller --clean cellxgene-windows.spec
.PHONY : build-assets gui-build-osx gui-build-windows gui-build-osx gui-build-windows
+1
View File
@@ -0,0 +1 @@
__version__ = "0.11.0"
+1 -4
View File
@@ -28,7 +28,6 @@ class CXGDriver(metaclass=ABCMeta):
def _get_default_config(): def _get_default_config():
return { return {
"layout": None, "layout": None,
"diffexp": None,
"max_category_items": None, "max_category_items": None,
"diffexp_lfc_cutoff": None "diffexp_lfc_cutoff": None
} }
@@ -38,14 +37,12 @@ class CXGDriver(metaclass=ABCMeta):
features = { features = {
"cluster": {"available": False}, "cluster": {"available": False},
"layout": {"obs": {"available": False}, "var": {"available": False}}, "layout": {"obs": {"available": False}, "var": {"available": False}},
"diffexp": {"available": False}, "diffexp": {"available": True, "interactiveLimit": 50000}
} }
# TODO - Interactive limit should be generated from the actual available methods see GH issue #94 # TODO - Interactive limit should be generated from the actual available methods see GH issue #94
if self.config["layout"]: if self.config["layout"]:
# TODO handle "var" when gene layout becomes available # TODO handle "var" when gene layout becomes available
features["layout"]["obs"] = {"available": True, "interactiveLimit": 50000} features["layout"]["obs"] = {"available": True, "interactiveLimit": 50000}
if self.config["diffexp"]:
features["diffexp"] = {"available": True, "interactiveLimit": 50000}
return features return features
@abstractmethod @abstractmethod
+6 -6
View File
@@ -1,9 +1,10 @@
from http import HTTPStatus from http import HTTPStatus
import pkg_resources
import warnings import warnings
from flask import Blueprint, current_app, jsonify, make_response, request from flask import Blueprint, current_app, jsonify, make_response, request
from flask_restful import Api, Resource from flask_restful import Api, Resource
from server import __version__ as cellxgene_version
from anndata import __version__ as anndata_version
from server.app.util.constants import ( from server.app.util.constants import (
Axis, Axis,
@@ -59,16 +60,15 @@ class ConfigAPI(Resource):
}, },
], ],
"displayNames": { "displayNames": {
"engine": f"cellxgene Scanpy engine version {pkg_resources.get_distribution('cellxgene').version}", "engine": f"cellxgene Scanpy engine version ",
"dataset": current_app.config["DATASET_TITLE"], "dataset": current_app.config["DATASET_TITLE"],
}, },
"parameters": { "parameters": {
"max_category_items": current_app.data.config["max_category_items"] "max-category-items": current_app.data.config["max_category_items"]
}, },
"library_versions": { "library_versions": {
"scanpy": pkg_resources.get_distribution("scanpy").version, "cellxgene": cellxgene_version,
"cellxgene": pkg_resources.get_distribution("cellxgene").version, "anndata": anndata_version
"anndata": pkg_resources.get_distribution("cellxgene").version
} }
} }
} }
+88 -35
View File
@@ -43,48 +43,67 @@ class ScanpyEngine(CXGDriver):
def _get_default_config(): def _get_default_config():
return { return {
"layout": [], "layout": [],
"diffexp": "ttest",
"max_category_items": 100, "max_category_items": 100,
"obs_names": None, "obs_names": None,
"var_names": None, "var_names": None,
"diffexp_lfc_cutoff": 0.01, "diffexp_lfc_cutoff": 0.01,
} }
def _alias_annotation_names(self, axis, name): @staticmethod
""" def _create_unique_column_name(df, col_name_prefix):
Do all user-specified annotation aliasing. """ given the columns of a dataframe, and a name prefix, return a column name which
does not exist in the dataframe, AND which is prefixed by `prefix`
As a *critical* side-effect, ensure the indices are simple number ranges The approach is to append a numeric suffix, starting at zero and increasing by
(accomplished by calling pandas.DataFrame.reset_index()) one, until an unused name is found (eg, prefix_0, prefix_1, ...).
""" """
if name == "name": suffix = 0
# a noop, so skip it while f"{col_name_prefix}{suffix}" in df:
return suffix += 1
return f"{col_name_prefix}{suffix}"
ax_name = str(axis) def _alias_annotation_names(self):
df_axis = getattr(self.data, ax_name) """
if name is None: The front-end relies on the existance of a unique, human-readable
# reset index to simple range; alias "name" to point at the index for obs & var (eg, var is typically gene name, obs the cell name).
# previously specified index. The user can specify these via the --obs-names and --var-names config.
df_axis.reset_index(inplace=True) If they are not specified, use the existing index to create them, giving
df_axis.rename(inplace=True, columns={"index": "name"}) the resulting column a unique name (eg, "name").
elif name in df_axis.columns:
if name not in df_axis.columns: In both cases, enforce that the result is unique, and communicate the
index column name to the front-end via the obs_names and var_names config
(which is incorporated into the schema).
"""
for (ax_name, config_name) in ((Axis.OBS, "obs_names"), (Axis.VAR, "var_names")):
name = self.config[config_name]
df_axis = getattr(self.data, str(ax_name))
if name is None:
# Default: create unique names from index
if not df_axis.index.is_unique:
raise KeyError(
f"Values in {ax_name}.index must be unique. "
"Please prepare data to contain unique index values, or specify an "
"alternative with --{ax_name}-name."
)
name = self._create_unique_column_name(df_axis.columns, "name_")
self.config[config_name] = name
# reset index to simple range; alias name to point at the
# previously specified index.
df_axis.rename_axis(name, inplace=True)
df_axis.reset_index(inplace=True)
elif name in df_axis.columns:
# User has specified alternative column for unique names, and it exists
if not df_axis[name].is_unique:
raise KeyError(
f"Values in {ax_name}.{name} must be unique. "
"Please prepare data to contain unique values."
)
df_axis.reset_index(drop=True, inplace=True)
else:
# user specified a non-existent column name
raise KeyError( raise KeyError(
f"Annotation name {name}, specified in --{ax_name}-name does not exist." f"Annotation name {name}, specified in --{ax_name}-name does not exist."
) )
if not df_axis[name].is_unique:
raise KeyError(
f"Values in -{ax_name}-name must be unique. "
"Please prepare data to contain unique values."
)
# reset index to simple range; alias user-specified annotation to "name"
df_axis.reset_index(drop=True, inplace=True)
df_axis.rename(inplace=True, columns={name: "name"})
else:
raise KeyError(
f"Annotation name {name}, specified in --{ax_name}_name does not exist."
)
@staticmethod @staticmethod
def _can_cast_to_float32(ann): def _can_cast_to_float32(ann):
@@ -114,7 +133,17 @@ class ScanpyEngine(CXGDriver):
"nVar": self.gene_count, "nVar": self.gene_count,
"type": str(self.data.X.dtype), "type": str(self.data.X.dtype),
}, },
"annotations": {"obs": [], "var": []}, "annotations": {
"obs": {
"index": self.config["obs_names"],
"columns": []
},
"var": {
"index": self.config["var_names"],
"columns": []
}
},
"layout": {"obs": []}
} }
for ax in Axis: for ax in Axis:
curr_axis = getattr(self.data, str(ax)) curr_axis = getattr(self.data, str(ax))
@@ -138,7 +167,15 @@ class ScanpyEngine(CXGDriver):
raise TypeError( raise TypeError(
f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene." f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene."
) )
self.schema["annotations"][ax].append(ann_schema) self.schema["annotations"][ax]["columns"].append(ann_schema)
for layout in self.config['layout']:
layout_schema = {
"name": layout,
"type": "float32",
"dims": [f"{layout}_0", f"{layout}_1"]
}
self.schema["layout"]["obs"].append(layout_schema)
def _load_data(self, data): def _load_data(self, data):
# as of AnnData 0.6.19, backed mode performs initial load fast, but at the # as of AnnData 0.6.19, backed mode performs initial load fast, but at the
@@ -164,8 +201,11 @@ class ScanpyEngine(CXGDriver):
@requires_data @requires_data
def _validate_and_initialize(self): def _validate_and_initialize(self):
self._alias_annotation_names(Axis.OBS, self.config["obs_names"]) # var and obs column names must be unique
self._alias_annotation_names(Axis.VAR, self.config["var_names"]) if not self.data.obs.columns.is_unique or not self.data.var.columns.is_unique:
raise KeyError(f"All annotation column names must be unique.")
self._alias_annotation_names()
self._validate_data_types() self._validate_data_types()
self.cell_count = self.data.shape[0] self.cell_count = self.data.shape[0]
self.gene_count = self.data.shape[1] self.gene_count = self.data.shape[1]
@@ -401,13 +441,26 @@ class ScanpyEngine(CXGDriver):
Caveats: Caveats:
* does not support filtering * does not support filtering
* only returns Matrix in columnar layout * only returns Matrix in columnar layout
All embeddings must be individually centered & scaled (isotropically)
to a [0, 1] range.
""" """
try: try:
layout_data = [] layout_data = []
for layout in self.config["layout"]: for layout in self.config["layout"]:
full_embedding = self.data.obsm[f"X_{layout}"] full_embedding = self.data.obsm[f"X_{layout}"]
embedding = full_embedding[:, :2] embedding = full_embedding[:, :2]
normalized_layout = (embedding - embedding.min()) / (embedding.max() - embedding.min())
# scale isotropically
min = embedding.min(axis=0)
max = embedding.max(axis=0)
scale = np.amax(max - min)
normalized_layout = (embedding - min) / scale
# translate to center on both axis
translate = 0.5 - ((max - min) / scale / 2)
normalized_layout = normalized_layout + translate
normalized_layout = normalized_layout.astype(dtype=np.float32) normalized_layout = normalized_layout.astype(dtype=np.float32)
layout_data.append(pandas.DataFrame(normalized_layout, columns=[f"{layout}_0", f"{layout}_1"])) layout_data.append(pandas.DataFrame(normalized_layout, columns=[f"{layout}_0", f"{layout}_1"]))
+1 -1
View File
@@ -5,7 +5,7 @@ from .prepare import prepare
@click.group(name="cellxgene", context_settings=dict(max_content_width=85)) @click.group(name="cellxgene", context_settings=dict(max_content_width=85))
@click.version_option(version="0.9.1", prog_name="cellxgene", message="[%(prog)s] Version %(version)s") @click.version_option(version="0.11.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
def cli(): def cli():
pass pass
+77 -70
View File
@@ -1,4 +1,5 @@
import errno import errno
import functools
import logging import logging
from os import devnull from os import devnull
from os.path import splitext, basename, getsize from os.path import splitext, basename, getsize
@@ -7,37 +8,62 @@ import warnings
import webbrowser import webbrowser
import click import click
import psutil
from server.app.app import Server from server.app.app import Server
from server.app.util.errors import ScanpyFileError from server.app.util.errors import ScanpyFileError
from server.app.util.utils import custom_format_warning from server.app.util.utils import custom_format_warning
from server.utils.utils import find_available_port from server.utils.utils import find_available_port, is_port_available
# anything bigger than this will generate a special message # anything bigger than this will generate a special message
BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB
def common_args(func):
"""
Decorator to contain CLI args that will be common to both CLI and GUI: title and engine args.
"""
@click.option("--title", "-t", help="Title to display (if omitted will use file name).")
@click.option(
"--layout",
"-l",
default=[],
multiple=True,
show_default=True,
help="Layout name, eg, 'umap'."
)
@click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.")
@click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.")
@click.option(
"--max-category-items",
default=1000,
metavar="",
show_default=True,
help="Categories with more distinct values than this will not be displayed.",
)
@click.option(
"--diffexp-lfc-cutoff",
default=0.01,
show_default=True,
help="Relative expression cutoff used when selecting top N differentially expressed genes",
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def parse_engine_args(layout, obs_names, var_names, max_category_items, diffexp_lfc_cutoff):
return {
"layout": layout,
"max_category_items": max_category_items,
"diffexp_lfc_cutoff": diffexp_lfc_cutoff,
"obs_names": obs_names,
"var_names": var_names,
}
@click.command() @click.command()
@click.argument("data", metavar="<data file>", type=click.Path(exists=True, file_okay=True, dir_okay=False)) @click.argument("data", metavar="<data file>", type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.option(
"--layout",
"-l",
default=[],
multiple=True,
show_default=True,
help="Layout name, eg, 'umap'."
)
@click.option(
"--diffexp",
"-d",
type=click.Choice(["ttest"]),
default="ttest",
show_default=True,
help="Method for differential expression.",
)
@click.option("--title", "-t", help="Title to display (if omitted will use file name).", metavar="")
@click.option( @click.option(
"--verbose", "--verbose",
"-v", "-v",
@@ -58,22 +84,7 @@ BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB
) )
@click.option("--port", "-p", help="Port to run server on, if not specified cellxgene will find an available port.", @click.option("--port", "-p", help="Port to run server on, if not specified cellxgene will find an available port.",
metavar="", show_default=True) metavar="", show_default=True)
@click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.")
@click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.")
@click.option("--host", default="127.0.0.1", help="Host IP address") @click.option("--host", default="127.0.0.1", help="Host IP address")
@click.option(
"--max-category-items",
default=100,
metavar="",
show_default=True,
help="Limits the number of categorical annotation items displayed.",
)
@click.option(
"--diffexp-lfc-cutoff",
default=0.01,
show_default=True,
help="Relative expression cutoff used when selecting top N differentially expressed genes",
)
@click.option( @click.option(
"--scripts", "--scripts",
default=[], default=[],
@@ -81,21 +92,21 @@ BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB
help="Additional script files to include in html page", help="Additional script files to include in html page",
show_default=True, show_default=True,
) )
@common_args
def launch( def launch(
data, data,
layout,
diffexp,
title,
verbose, verbose,
debug, debug,
obs_names,
var_names,
open_browser, open_browser,
port, port,
host, host,
layout,
obs_names,
var_names,
max_category_items, max_category_items,
diffexp_lfc_cutoff, diffexp_lfc_cutoff,
scripts, title,
scripts
): ):
"""Launch the cellxgene data viewer. """Launch the cellxgene data viewer.
This web app lets you explore single-cell expression data. This web app lets you explore single-cell expression data.
@@ -108,6 +119,7 @@ def launch(
> cellxgene launch <your data file> --title <your title>""" > cellxgene launch <your data file> --title <your title>"""
e_args = parse_engine_args(layout, obs_names, var_names, max_category_items, diffexp_lfc_cutoff)
# Startup message # Startup message
click.echo("[cellxgene] Starting the CLI...") click.echo("[cellxgene] Starting the CLI...")
@@ -122,27 +134,34 @@ def launch(
else: else:
warnings.formatwarning = custom_format_warning warnings.formatwarning = custom_format_warning
if scripts:
click.echo(r"""
/ / /\ \ \__ _ _ __ _ __ (_)_ __ __ _
\ \/ \/ / _` | '__| '_ \| | '_ \ / _` |
\ /\ / (_| | | | | | | | | | | (_| |
\/ \/ \__,_|_| |_| |_|_|_| |_|\__, |
|___/
The --scripts flag is intended for developers to include google analytics etc. You could be opening yourself to a
security risk by including the --scripts flag. Make sure you trust the scripts that you are including.
""")
scripts_pretty = ", ".join(scripts)
click.confirm(f"Are you sure you want to inject these scripts: {scripts_pretty}?", abort=True)
if not verbose: if not verbose:
sys.tracebacklimit = 0 sys.tracebacklimit = 0
if scripts:
click.echo(r"""
/ / /\ \ \__ _ _ __ _ __ (_)_ __ __ _
\ \/ \/ / _` | '__| '_ \| | '_ \ / _` |
\ /\ / (_| | | | | | | | | | | (_| |
\/ \/ \__,_|_| |_| |_|_|_| |_|\__, |
|___/
The --scripts flag is intended for developers to include google analytics etc. You could be opening yourself to a
security risk by including the --scripts flag. Make sure you trust the scripts that you are including.
""")
scripts_pretty = ", ".join(scripts)
click.confirm(f"Are you sure you want to inject these scripts: {scripts_pretty}?", abort=True)
if not title: if not title:
file_parts = splitext(basename(data)) file_parts = splitext(basename(data))
title = file_parts[0] title = file_parts[0]
if not port: if port:
if debug:
raise click.ClickException("--port and --debug may not be used together (try --verbose for error logging).")
if not is_port_available(host, int(port)):
raise click.ClickException(
f"The port selected {port} is in use, please specify an open port using the --port flag."
)
else:
port = find_available_port(host) port = find_available_port(host)
# Setup app # Setup app
@@ -165,9 +184,6 @@ security risk by including the --scripts flag. Make sure you trust the scripts t
click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take awhile...") click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take awhile...")
else: else:
click.echo(f"[cellxgene] Loading data from {basename(data)}.") click.echo(f"[cellxgene] Loading data from {basename(data)}.")
# if file is larger than main memory, let the user know performance may suffer
if file_size > .95 * psutil.virtual_memory().total:
click.echo(f"[cellxgene] Warning: data file is larger than RAM - application may be very slow.")
# Fix for anaconda python. matplotlib typically expects python to be installed as a framework TKAgg is usually # Fix for anaconda python. matplotlib typically expects python to be installed as a framework TKAgg is usually
# available and fixes this issue. See https://matplotlib.org/faq/virtualenv_faq.html # available and fixes this issue. See https://matplotlib.org/faq/virtualenv_faq.html
@@ -176,17 +192,8 @@ security risk by including the --scripts flag. Make sure you trust the scripts t
mpl.use("TkAgg") mpl.use("TkAgg")
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
args = {
"layout": layout,
"diffexp": diffexp,
"max_category_items": max_category_items,
"diffexp_lfc_cutoff": diffexp_lfc_cutoff,
"obs_names": obs_names,
"var_names": var_names,
}
try: try:
server.attach_data(ScanpyEngine(data, args), title=title) server.attach_data(ScanpyEngine(data, e_args), title=title)
except ScanpyFileError as e: except ScanpyFileError as e:
raise click.ClickException(f"{e}") raise click.ClickException(f"{e}")
@@ -203,7 +210,7 @@ security risk by including the --scripts flag. Make sure you trust the scripts t
sys.stdout = f sys.stdout = f
try: try:
server.app.run(host=host, debug=debug, port=port, threaded=True) server.app.run(host=host, debug=debug, port=port, threaded=True, use_debugger=False)
except OSError as e: except OSError as e:
if e.errno == errno.EADDRINUSE: if e.errno == errno.EADDRINUSE:
raise click.ClickException("Port is in use, please specify an open port using the --port flag.") from e raise click.ClickException("Port is in use, please specify an open port using the --port flag.") from e
-4
View File
@@ -11,10 +11,6 @@ WindowUtils = cef.WindowUtils()
# OS differences # OS differences
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
CefWidgetParent = QWidget CefWidgetParent = QWidget
if LINUX:
# noinspection PyUnresolvedReferences
CefWidgetParent = QX11EmbedContainer
class CefWidget(CefWidgetParent): class CefWidget(CefWidgetParent):
def __init__(self, parent=None): def __init__(self, parent=None):
+8
View File
@@ -0,0 +1,8 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
<file alias="logo.png">images/cellxgene_logo.png</file>
<file alias="collapsed.svg">images/properties_contract.svg</file>
<file alias="expanded.svg">images/properties_expand.svg</file>
<file alias="icon.png">images/properties_expand.svg</file>
</qresource>
</RCC>
+29
View File
@@ -0,0 +1,29 @@
# -*- mode: python -*-
block_cipher = None
a = Analysis(['main.py'],
pathex=['/Users/charlotteweaver/Documents/Git/cellxgene/server/gui'],
hookspath=["/Users/charlotteweaver/Documents/Git/cellxgene/server/gui/"],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
runtime_tmpdir=None,
console=True )
+440
View File
@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
# Resource object code
#
# Created: Wed Jun 19 15:02:04 2019
# by: The Resource Compiler for PySide2 (Qt v5.12.3)
#
# WARNING! All changes made in this file will be lost!
from PySide2 import QtCore
qt_resource_data = b"\
\x00\x00\x0d\xde\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x01;\x00\x00\x00j\x08\x06\x00\x00\x00\xc3y\xf6!\
\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\
\x01\x00\x9a\x9c\x18\x00\x00\x00\x01sRGB\x00\xae\xce\
\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfc\
a\x05\x00\x00\x0dsIDATx\x01\xed\xdd\x7fn\
\x1b\xc7\x15\x07\xf07C\xdbQ\x93\x00f\xfe\x88e7\
\x05L\x9d J/PJ9@\xe4^@\x12\x90H\
(\x0aT\xce\x09,\x9d\xc0\xf2\x1fE \xb9\x80\xd9\x0b\
\xd4\xf2\x01,2'\xb0|\x023@\x0bG\xea\x1fQ\
\xff\x09\x04\xd1;\xaf\xef-\xc9X\xa48\xb3?\xb8\xfc\
e~?\x80b\x85\xbb\xab%g\xdf\xbe\x9d\x9d\x99\x1d\
\x12\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\xc0\x9c24\x22\xa7\xab\x87L3l\xf1xk\
de\x03\x00\xe3g\x09\x00`\x0e \xd9\x01\xc0\x5c@\
\xb2\x03\x80\xb9\x80d\x07\x00s\x01\xc9\x0e\x00\xe6\xc2\x0d\
\x02\x18\xe0m\xf5i\xd5Z\xae\xfb\x96KW{\xed\xee\
\xf1\xd6f\xef6?T\xac-\xbd!\xbf\xa6\xf4r/\
\x11\xc0\x04\xccD\xb2\xbb\xf3\xf2;\x1a\x85\xb3\xaf\x9f\x12\
\x00\xcc\x07\xdc\xc6\x02\xc0\x5c@\xb2\x03\x80\xb9\x80d\x07\
\x00s\x01\xc9\x0e\x00\xe6\x02\x92\x1d\x00\xcc\x05$;\x00\
\x98\x0b31\xf4\x04CD\x00`X\xa8\xd9\x01\xc0\x5c\
\xc0\x13\x14c\xf0\xa6\xfa\xac\xfcI\xa9\xb5\xee\x98\x96\xad\
\xa1\x0a3W\x88\xcc\xb91t.\xbf\xcb\xbf\xa6\x11E\
\xd1\x8b{\x8d\xbf4i\x08\xfa\xd4\x831\xbc&?\xf7\
\xe5\xefW\x88\xb8,\x7f\xbb\xa9\xcb\x9c\xa3\xd7\xcc\xe6\xe8\
^\xe3\xbb\x06\xc1o\xbaef-}\xa9\xc7E\xcbk\
\xd019\xad\x1e\xac9k\xca\x83\xfeF\xc9\xb5N\xee\
4\xfez\x92f\x7f\x1a\x0b\xb7JQ\xb5\xe4\x5c\xb5\xff\
8\xe9~\x1d\xd1kr\xb6\x91\xf68\xbd]=\xdc\xf0\
.tQ\xe3jL\xf5\xc6\x07-wc0b\xfeQ\
\x02\xa46L\xfc%\xc5^dm\xe3\x8b\x97\xdf\x1e\xd1\
\x04a\xf2N\x8f\x22&\xef\x8c\x1f\x9f2v\x97\x8cY\
O\xb3\xbe\x04\x9e\x9e`\x9bY\x82NO\x9eO\xed\xbb\
\x1d&~(\x05^N\xb1I\xd3\x92\xa9}~\xfc\xdd\
^h\xa5\x0f\xfdq\xb1\xff|}\xb0v\x83\xe9q\xfb\
\xc4\xf4\x93\xb2\xda\xd5\xb2\x92x~Eq\x82\xb8\xce\x11\
\xef\xdd;\xde\xde\x0d\xfd\x9d<\xc7\x89$v\x16_~\
\xfb\xcf\xd0J\xa1\xf3L\xf6\xf5\xfd\xdd\xe3\xed\xfd\xf6\xb1\
t\xcf\x92?+\xef\x7f~\xbc\xfd=e\xf0\xdf\xd5\xa7\
\x8f\x8a\x8e\xbdQ\xc1m\xec\x88\x9c}}\xb8S\xb2\xa5\
Wi\x13\x9db\xa6\xaa&\x0b\x0d\xa04\xebkr\xf9\
\xd8^\xbe\x92\x93m7e\xb0\xa9\x8a\xae/'\xc9\x1b\
\xdd\x9e\xa6\x88&\x04\xca)\xcb\xb6g\xabO\x1f\xdf`\
\xf3<\xe9\xe4WZVg\xab\x87\xcf\x0cS\xe2\xba>\
\x9al>\xb1\xad7Y\x8f\x13\xb1\xab\xe9\xbe\xf3\x96\x8b\
\xeeK\xe3\xb0}\xd12\x95\xa4\xf5\x1d\x99\x87\xb2~\x9d\
R\xd0\xd8\xd1\x0b\xc0,\xc5\xdeDncC\xcf\xba\x0e\
\xea\x8c\x98\xb5gcO\xf5j\xc7\xbcK9i@H\
\xc2\xa3\xd0\x15\xf0\xac\xfa\xf7e\xb2\xa5z\x86@\xeb\xa7\
\xb5\xb0\xba\x04\xdd\xca\xb0\xb7\xcfE\xd0\x93\x92\xb9\xb5{\
V=\x5c\xb9\xd3\xd8JuK\xd8\xd5\xaeQ^\xd6%\
14\xee\xf4\xd56\xfb\x9dvj\x22\x94\x81\x94\xf1F\
\xde{\xa0\xd3\xea?\xd6\xc9\xbaZ\xde\xdb\x1c\xdd\xf7\xc7\
\xb6\xb5,\x09oe\xa9\xb1y\x9ee[\xa9E\xads\
\xc6$\xad\x17\xdc\x9fW\x0f\x1ej\x8d\xd0\xb7N\xbb\xbc\
\xad&\xc5\x0a\xe53\x91\xd8C\xcd\xae`oW\x7f\xd8\
\x90\x90\xd9\xa5!i\xc2\x93`\xa8\x0e\xdc\x87\x04\x1b\xdb\
\x1b\xcf\x87Ht]\x1at\xcfi\xc24!\xc8I\xa6\
'W\x99-\xd5%\xe1-\xa7\xdd\xf6\xfd\x89g*\x9a\
\x18B\xb5\xe2\xa2\x8eM\xfa\xf7\xf6\xb4\xaa\x89\x8e\x86\xb7\
\xfc\xa9\xbdLU\xdb\xefS\xa1\x1c\x0c\x19\x7f\x19^)\
o\x1a\x8e\xc6\xde\xf10\xb5\xf9\xac\x90\xec\x0a\x14\x07\x02\
\xd9<A9P\xa9T\x1a\xf8\xb7\xe2v@2\x15*\
\xc6\xf2\xe9\xea\xc1.MH\xbb\x86\xda\x93\x10R'\xbc\
A'\x9e^$\xb4=n\xd0\xfaE\x1e\x9b4\xda\xed\
d\xc5\xd0[L\xdf\xc5o\x04\xca\xbe}\x15\x1c{K\
\xd2\x0c\x93\xa9\x96=\x0c$\xbb\x22Y\xaa\x86\x02A\xda\
}\xce\xb51\x9b\x1c?\xd0\x1f\xc7\x1cl|\xd6[\x8a\
\xfe\xa0\x8bk\x0b\x09\xed\x80\xb2]\xc3Q\xb4\xe9\x5c\xb4\
\x92f?\xf2\xcev\xc6y\x85\xbdJ{1\xe32\xe9\
\x95\x98\xf0\xbc5\x0c\xf9\xac_\xbc\xdc\xbe\xd6\xeb\xd7\xae\
\xd5\xf5\xad\xdb\xe7\xea\xf1\xd1\xb2\x8b\x7fgnR\x0ei\
\xf6w\xf58\xc9\xcf\xa6\xdc\xb3\x06o\xdf}\x17\xbf$\
\xda\x99\x94-\x1e\xe4\x9d[{\xad\xec\xe3v\xb6\xa46\
h\xf9\x0c\xd2\xd1\xf60\xed\xbe\x98\xcc\xdf\xc6\x15{\x18\
zR\xa0\xc4\x9a\x03\xb7V\xee\xd5{\x86(\x1c\xbd]\
=\xd0\x1e*\xefvrBW\xe5\x9f\xc6o\xffo\xdc\
F\xb8\x13\x9d\x8f\xee\xd6\xb7\x1f\xf4\xbd\x98\xb4\x9f\xf2\x82\
mim\xa8F\x13\xa0=\x99\xf2\xfe\xa8\xef\xfdu\x13\
\xde\xb56\xbcP\xa2[\xacoo\x0c\xdaG\x89K\xeb\
\x1c,6jF\x1c\xc9\xf1\xe9iCj\xc8\xbejr\
\x5c\xebr\x92W(\x83\xe4Z$\xef\xc9q\xda\xed{\
\xb1\xf6\xf3\xca\xe1\xbe$\x8b\x9d\x81[\xb4/~\x95,\
\xed\x5c\xce\x91\xf4\xeeo\xd5\xfa^>\x92\xda\xfcm)\
\xbf5\xff\x96\xe6\xcb\xfeW\xda\xb5\xba\x10>Z\xcc\x18\
{rH\xca\xbf\xb3\x97\x1b\xf2\xab\xb7\x8d\xb0(3[\
\xb3\xd3\xce\x85,?\xa3\xf6\xef\xb8\x16b*\xbe\xe5\xcc\
\xee\xc5\xa0\xb1Xz\xa2k\x8d\xc2\xbf\x1d\xf7^a\x13\
\xae\xac\xce\xb9\x81C\x07\xdaC#\xfc\xb5\x14\x09\xba?\
\xd1\x04\xe9\xfbKS\xc3\xd3Z\xc0\xe0DG'\xbeD\
\xf7\x8bl#\x89\xaeJ!lw\x07%\x11}\xcd\x19\
J\xac\x09]\x95\x14\x0b\x9aX\x17=CU>\xe2\x9b\
\xbb\x14 5\xae5J\xaf9 \xd1\xc5$N\x9e\x84\
6\xd4\xf1\xa0\xfd\xafI[\xde7\xa1m\xc2\xb1\xe7\xad\
\xb5\x1a\x09\xf2\xb1\xc4\x1enc\x0br\xc3r5\xb8\x82\
1\x0d\xdf\xa2\x88\xf8\x85o\x99\x9c\xd8\xb7\xbb\xbf\xc7\xb7\
\xb0\x01\x9aPCW}\xc7\xf4\xa3o\x99$\xbb*M\
X(\xe1i\x8dF\x13\x9d\xf4L\x0eLt\xbf\xf2\xcd\
\x15\xdf\xdf\xbd\xa0w\xe1\xf6?M>\x8do3%\xb4\
\x90\xa4X`\xc3\xdeD\xf3Y\xdc\xe3\x1a\xbau\xbe^\
\xe3\xca\xa9\x99ee\x8d=\xb9`xo7u\x8ch\
8\xf6\xf8\xb5\x7f[[\xd4g\x0a\xc2mlA\xe4\x16\
c\xd9\x04n\x93\xd8\xb9\x93\xc0\xc2\x9a\xd4\x1e\x1a\x83\x16\
9\xb2\xbf\xd5\xfa\x8cu\xcb\xa1[Xc\xccIh\xfc\
\x925\xe66\xf9Uh\x0a\xf8niu\xa8\xc2\xc7\xd4\
\xd2\xb2\xe8M\x5c\x9dD\x17\x1a\x96\x91TnL\xce{\
\x22\xe6!5\xa0`ME\xda\xa9\xce\xf3\x8e3\xb3L\
\xa9{\xaa\x0be\xdfU\xa41\xc0\xbbX\x9f\x92\x18&\
\xf6\xf4B\x96uhMVHv\x05)\x91\xb9\xcf\xc1\
\xe5%\xef\x81\x94+b\x83\xd2`S\x09\x8f\xf72\x8f\
$)\xe4\xeeq\x1cG\xc0\xa5\xe1Ix\x95k+\xa6\
Ht\xed\xd5\xa8\x1c.6\x7f\xad;\x0fi\x96(\x87\
\xda\x07\xe5v\xea\x19\xd9\x12\xe5\x11\xaa]\x8d\x96\xad\x84\
\x96j;\xa31\xa5\x1d\xcai\x81.\xf4s\x8d4\xf6\
p\x1b;&\x11EC\x1fH&\x1ei\xa0w\x02n\
*xni\xdfK\x99\xe8:\xebV\xc2\x8bM\xa1'\
\xd9\xe4\x12\xd2\xe8\x1863\xff\x99\x90\xec`j]\xb8\
[\xfb\xbe\xce\x1by}s\x1aj\xa1\x83\xf1\x07\x97\xec\
\xf8\x03\xf8LHv0\xb5>\xb5\x97\xcf|\xb5$\xe9\
\xb4x\x9e\xb6\xdd\xcb\x14\x5cs\x83\xd9\x846\xbb\x82H\
\x8f\xeaO6\xd0\xa0&mvC_\x19\x13OZ\xe6\
\xa6\xac\xd3\xa4\x9c.haj\x92\x82>\x00\xef\x88B\
\xc3,R?_)\xbd\x9f\xe7&\xd4\xb1Sp\xadE\
n\xf9\x9al\x02\xb7\xce:\xf06g\xfb\x94td5\
i\x12\x92\xf6;T\xec1\x8f#\xf6\x90\xec\x0a\x92\x94\
\x88$\x11\xde'\x0a\x8f\x90O\xde\x89\x0eI\x08\xf5*\
\x9a\x17\x8b\xf5\xad\x874\xe34\xd1\xc5\x0f\xdf_\xc5:\
\xe5\x11\x9f\xf4\x0d\x84M\x99\xf0\x5c3\xd4\x93X\xe0p\
\x8eX\xd2\x85O\x12\xd6\x93{\xc7\x83\xc7\xbfM\xafp\
\x19\xb2t\xf2,&L\xc20i\xb8\x8d-\x08\x9b(\
\x98\xc8\x22\xeb\xbe\xf2-{\xbbrP\xd3y\xc9\x06\xfd\
\x5c\x9dr'8|\x85\xe2\x1e\xb1\xf5\xb4\x8f\xdeL\xea\
\xf1\xb0$\xbeD\xe78ZY<\xde~@\xd7\x1f?\
\xea&\xbc\x8a\xefoF\xae\x14.7\xa9A\x16Y\x1e\
r\x1c\xc2\x8f}\xc9q\xa2Y\xe3\x06\x0f\x8d\xea\xcaR\
\x86\x93\x8a=$\xbb\x82,\xb8\x85\xa3\xd0rc\xfcc\
\xaf\xacI\xf7\xf4\x82\x0eQ\x09=m!\xcaif\xc7\
h\xcf\x83\xd7\xaa\xa7\x9d7o\x5cB\x89\xae[s\x8b\
\x9f\x92\xc8\x98\xf0\xfe\xd0\xd8:I*\xb7\xd0\x03\xe9Y\
{\x22?r\xb7j\xa1\xe5\xfa\xd8W\x9a\xb2o\xcf\x19\
w\xf0f\x8c\x13\x00x\xb5\xcb?\xf8\x9cp\xf9\x13\xdb\
zL\x09\xba\x03\xc3'\x11{Hv\x05\xd1\x91\xefr\
B5|\xcb}\x01~\x1a\xbff*\xbe\xed\xe2)\xb3\
\xaf\xfe\x7f`\xf4\xbd\x8a'`\x94\xa41\xe8\xc4\xd7@\
\xd3\x07\xd4;s\x91-\xc7\x13)\xea|kS M\
\xa2\xeb\xca\x93\xf0\xa4\xfd/i2\x84G:\x8f[\xff\
\xab:\x83\x8a\xcdX\x13K\x8a\x85\xf6\xfb\x89\xe7,|\
4\xa8\x96\xa3\xaf\xe9\xb2x\xf2W2\xf14\x5c\xd30\
\xd1jR\x19\xea\xf1\x93\xe4\xfc<\x14{:\xd9,M\
(\xf6f\xb6\xcdnT\x13z\x0e#\xe2h\xcf\x9aR\
\xd5\xb7\xbc3K\xeb\x86\x84\xc5\x89\x8e\xed\x92+\xcdZ\
b\x97\xbe\xeb\x9d\x0fm\xc1\xdd\xdao\x99\xd6Nh,\
\x97\x06\x9d\x9c \x1br\x0b\xdcp\xdcnX\xb6:\xd6\
\xcc\xb4\x96\xb9\xbf\xa3\xc4\xba\xfd\xb3\xea\xe1\xeb\xac\x13f\
\x16)K\xa2\xeb\xd2\x84w\xbar\xd0\xff\xac\xb0\xb7\x0d\
\x8f9:J\x1a\xf4*\xed\xae\x8f\xe5\xf8\xecpg\xe2\
\x05\xe9\xb8\xa8\xea@\xee\xd0`q\x9f\xa4XP\x1a\x0f\
R\x1b\xd2\xd9\x81O\xba\xc7\xc9\xc4O\x88\xb4$\x19\xf4\
(O\xc3D\xab\x1a{\x97\xe6r=<)\x82Y\x93\
\xf7\xba\x962\xf6\x1e\x8f3\xf6P\xb3+P\xe76\xb3\
\x91\xb0ZE\x03B\x82z#i\xf2M\x9d\x9a\xa7?\
\xb8\xb5\xd6\xc0\xcc\xa9\x1a\x82\xb56\xa9\xfb\x89\xf7e\xa8\
\xea\xd9_9\xcb0\x8e\xa2\xc5\xfb\xe5\xbe^\xd7\x84D\
\xd7\xe5\xab\xe1\x19\xba\xfe\xb0|\xcac\xd3\xd9\xbe]f\
\xc1\x87\xf9\x13\xe8\xfe\xa4\xfc\x9f$\xad\xa7\xc7\xe4\xeaq\
\x22\xf2>\x0e6\xf1\x89V5\xf6\x1c\xbb\x22c\xef3\
\x89\xbd\x7f\x8d+\xf6\x90\xec\x0a&W\xf4\xcd\xbcs\xa0\
\xf5\x90\x13\x9e]4\xf0\x09\x82\xc5\xc6\xf6Q\xf0\xe9\x82\
\xec\xfb\x9a\x988\xa1qk\xe5\xca{I\x95\xe8\xba4\
\xe1\xf5&1\xde\xbb\xdb\x18<\xa5\xb8\x1e\x9b\x84\xb6\xbb\
B\xdd\xd5\x9e\xf1\x14s\xc7\xa5\xc6T\x9et\xc7\x92&\
\xf1Y\x8d=$\xbb\x82\xc5S\x02\xb1[\x19*\xe1\xc9\
\x09o\xb8\xf5 t\xc2'>N\x95a_Y\x92\xcb\
(\xc4\x13x\xb6'\xaf\xcc\xf5^n\xf2\xcd\x07\xed\x89\
/yo1\xf0-_\xdd\xc4:\xce\x84\xa7\xc98M\
\x0d/\x91|>-\x9biyv\xb9\xa0\xd8{#q\
\xfe\xe7q\xc5\x1e\x92\xdd\x08t\x13^\xca\xdb\xa6\x1e\xf1\
\xec\xb5\x12\xd4i\xbe\x874\x0e:\x9d\x116gb\xd5\
\x93\xf0W\xbe\xf9\xd54|\xe1\x8e\xbc\x87\xdab}k\
)\xcf{\xd1\xdb+\xd9\xf6\xab\xc5\x84\xaf3Tq\xb9\
jM2m\x99i\x0d{\xc8IM\xb5\x86\xd7N\xe6\
\xd9\x8fSw\xf6d\xfd|\xd3p\x9c\xba:\xb1\xb7\x94\
;\xf6\xa4L%\xf6\xfe\x98\xf6\xfbv\x8b0\x91\x0e\x8a\
\xac\x93i\x8ec\xf2\xcd\xa2u\x02sE\xda#6J\
&\x9e%\xb7\x1aZ_\x93\x5cd\xf9\xc9\x17\xc7\xdbG\
\x94Ag\xc6\x94\xa5\xb4\xfb\xd1\x93\xe7\x9d\xf4\xaa\x19\x8e\
\xf6\xa7\xe9\xe4\x19'=\xc1\xb4\xb1_\xae\xf5\xbbV'\
-\x1d\xd0\xe0\xae\xe5\xa4=\xdf\x17|k\x7f\xc1\x5c>\
4y\xbf^\xacC\x93\xb9\xfcSK{\x9ct\x98\x87\
\xf6~\xea\xfe\x97\xea\xd3\xf9\x0cp'~\xf2\xc4\xdeQ\
\xea\x99~\x0a4\xdc\x11\x0c\xc0\x97d\xf7\xd2\xb6\x96\x05\
\xbaX\x96\xbat\xe5\xfdt9\xaei\x9d=\xff\x95n\
5\x8a\xbc=\x89\xc7e\xc9~$\x00oG\xcc\x9f\x95\
\x8c\xf9E\xda\xab\xfe\xa7\x03C\xe75\xc1\x85t\xcbK\
\x8f\x8b\x96\x15G\xfc\xd3\xd5c\x22\xb1\xac\x1d\x03\xdeG\
\xd7$)mf}\x22\x22\x14\x0f\x8e\xdc\xc9\xac\x1e'\
-KK\xb6lJ\xe6\xfe\xb4\xc5\x1e\x92\x9dG\xd1\xc9\
\x0e\xa6\x8b~\xabY\xda[(\x1d\xd8\x1b\xea\x99\xd5\xa6\
\x84I\xd4T \x1b\xb4\xd9\xc1\xdc\xd1D\xc7\xf6\xe6\xab\
4\xa3\xf8\x93\x06}\x8bs$\xba\xd9\x80\x89\x00`\xae\
\xb4\xbf`\xdc\xc6\xe3\xd5tP\xef\xd9\xea\xe1Z\xcb\xd8\
\xbd\xcb\xa8\xd4\xd3\x94\xa0\xdf\xb9\xd0\xfe&7\x0e\x7fm\
%Q\xa66V\x98\x1c$;\x98+\xfd_\xf2,\xc9\
j\xf9\x06\xbb\xe77\xac\xd3\xdb\xd5\xa6\xbe&\x9d\x11\xe5\
\xf6\x93-\xc9-\x19\xbe\xb1\x900}\x90\xec`nt\
\xbe\xb8:PS3\x15\xfdo\xfa\xc6f\xdeC\x87\xcf\
\xec\x18Y\xb2C\x03?L\x1bv\xb6\x5c\x5c+ux\
\x003L\x1ftP\xc0\xdc\xf8}c{\x7f\xd8\x91\xff\
:VLnq\xbfG\xa2\x9b=\xa8}\xc1\xdc\x89\x1f\
<\x97\xb6;kL\xea)\x860\x18{\xf6!\xd9\xc1\
\xdc\xea\xcc\xb6Q\xb5\xd6~\xa3S9\xc9\xc9Py?\
u\x167\xe5\xb5\xf3\x88\xe8Gc\xa2\x93\x0b\xb7p4\
\xbd\xdff\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x01\xff\x07\xb4<2M\x93'\x06\
\xcb\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x03X\
<\
?xml version=\x221.\
0\x22 encoding=\x22utf\
-8\x22?>\x0a<!-- Gener\
ator: Adobe Illu\
strator 23.0.1, \
SVG Export Plug-\
In . SVG Version\
: 6.00 Build 0) \
-->\x0a<svg versio\
n=\x221.1\x22 id=\x22Laye\
r_1\x22 xmlns=\x22http\
://www.w3.org/20\
00/svg\x22 xmlns:xl\
ink=\x22http://www.\
w3.org/1999/xlin\
k\x22 x=\x220px\x22 y=\x220p\
x\x22\x0a\x09 viewBox=\x220 \
0 100 100\x22 style\
=\x22enable-backgro\
und:new 0 0 100 \
100\x22 xml:space=\x22\
preserve\x22>\x0a<styl\
e type=\x22text/css\
\x22>\x0a path{fill\
:rgb(150, 146, 1\
44)}\x0a polygon\
{fill:rgb(150, 1\
46, 144)}\x0a ci\
rcle{fill:rgb(15\
0, 146, 144)}\x0a \
rect{fill:rgb(\
150, 146, 144)}\x0a\
</style><path d=\
\x22M51.3,75.9c-1.9\
,0-3.8-0.8-5-2.4\
L18.1,38.4c-2.2-\
2.8-1.8-6.9,1-9.\
1c2.8-2.2,6.9-1.\
8,9.1,1l28.2,35.\
1c2.2,2.8,1.8,6.\
9-1,9.1\x0a\x09C54.2,7\
5.5,52.7,75.9,51\
.3,75.9z\x22/>\x0a<pat\
h d=\x22M51.3,75.9c\
-1.4,0-2.9-0.5-4\
-1.4c-2.8-2.2-3.\
2-6.3-1-9.1l28.2\
-35.1c2.2-2.8,6.\
3-3.2,9.1-1c2.8,\
2.2,3.2,6.3,1,9.\
1L56.4,73.5\x0a\x09C55\
.1,75.1,53.2,75.\
9,51.3,75.9z\x22/>\x0a\
</svg>\x0a\
\x00\x00\x03Y\
<\
?xml version=\x221.\
0\x22 encoding=\x22utf\
-8\x22?>\x0a<!-- Gener\
ator: Adobe Illu\
strator 23.0.1, \
SVG Export Plug-\
In . SVG Version\
: 6.00 Build 0) \
-->\x0a<svg versio\
n=\x221.1\x22 id=\x22Laye\
r_1\x22 xmlns=\x22http\
://www.w3.org/20\
00/svg\x22 xmlns:xl\
ink=\x22http://www.\
w3.org/1999/xlin\
k\x22 x=\x220px\x22 y=\x220p\
x\x22\x0a\x09 viewBox=\x220 \
0 100 100\x22 style\
=\x22enable-backgro\
und:new 0 0 100 \
100;\x22 xml:space=\
\x22preserve\x22>\x0a<sty\
le type=\x22text/cs\
s\x22>\x0a path{fil\
l:rgb(150, 146, \
144)}\x0a polygo\
n{fill:rgb(150, \
146, 144)}\x0a c\
ircle{fill:rgb(1\
50, 146, 144)}\x0a \
rect{fill:rgb\
(150, 146, 144)}\
\x0a</style><path d\
=\x22M31.8,56.4c-1.\
9,0-3.8-0.8-5-2.\
4c-2.2-2.8-1.8-6\
.9,1-9.1l35.1-28\
.2c2.8-2.2,6.9-1\
.8,9.1,1c2.2,2.8\
,1.8,6.9-1,9.1L3\
5.8,54.9\x0a\x09C34.6,\
55.9,33.2,56.4,3\
1.8,56.4z\x22/>\x0a<pa\
th d=\x22M66.9,84.6\
c-1.4,0-2.9-0.5-\
4-1.4L27.7,54.9c\
-2.8-2.2-3.2-6.3\
-1-9.1c2.2-2.8,6\
.3-3.2,9.1-1l35.\
1,28.2c2.8,2.2,3\
.2,6.3,1,9.1\x0a\x09C7\
0.6,83.8,68.8,84\
.6,66.9,84.6z\x22/>\
\x0a</svg>\x0a\
\x00\x00\x03X\
<\
?xml version=\x221.\
0\x22 encoding=\x22utf\
-8\x22?>\x0a<!-- Gener\
ator: Adobe Illu\
strator 23.0.1, \
SVG Export Plug-\
In . SVG Version\
: 6.00 Build 0) \
-->\x0a<svg versio\
n=\x221.1\x22 id=\x22Laye\
r_1\x22 xmlns=\x22http\
://www.w3.org/20\
00/svg\x22 xmlns:xl\
ink=\x22http://www.\
w3.org/1999/xlin\
k\x22 x=\x220px\x22 y=\x220p\
x\x22\x0a\x09 viewBox=\x220 \
0 100 100\x22 style\
=\x22enable-backgro\
und:new 0 0 100 \
100\x22 xml:space=\x22\
preserve\x22>\x0a<styl\
e type=\x22text/css\
\x22>\x0a path{fill\
:rgb(150, 146, 1\
44)}\x0a polygon\
{fill:rgb(150, 1\
46, 144)}\x0a ci\
rcle{fill:rgb(15\
0, 146, 144)}\x0a \
rect{fill:rgb(\
150, 146, 144)}\x0a\
</style><path d=\
\x22M51.3,75.9c-1.9\
,0-3.8-0.8-5-2.4\
L18.1,38.4c-2.2-\
2.8-1.8-6.9,1-9.\
1c2.8-2.2,6.9-1.\
8,9.1,1l28.2,35.\
1c2.2,2.8,1.8,6.\
9-1,9.1\x0a\x09C54.2,7\
5.5,52.7,75.9,51\
.3,75.9z\x22/>\x0a<pat\
h d=\x22M51.3,75.9c\
-1.4,0-2.9-0.5-4\
-1.4c-2.8-2.2-3.\
2-6.3-1-9.1l28.2\
-35.1c2.2-2.8,6.\
3-3.2,9.1-1c2.8,\
2.2,3.2,6.3,1,9.\
1L56.4,73.5\x0a\x09C55\
.1,75.1,53.2,75.\
9,51.3,75.9z\x22/>\x0a\
</svg>\x0a\
"
qt_resource_name = b"\
\x00\x08\
\x05\xe2Y'\
\x00l\
\x00o\x00g\x00o\x00.\x00p\x00n\x00g\
\x00\x08\
\x0aaZ\xa7\
\x00i\
\x00c\x00o\x00n\x00.\x00p\x00n\x00g\
\x00\x0d\
\x0dq\x0b\x87\
\x00c\
\x00o\x00l\x00l\x00a\x00p\x00s\x00e\x00d\x00.\x00s\x00v\x00g\
\x00\x0c\
\x07)\x8aG\
\x00e\
\x00x\x00p\x00a\x00n\x00d\x00e\x00d\x00.\x00s\x00v\x00g\
"
qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x00L\x00\x00\x00\x00\x00\x01\x00\x00\x14\x9b\
\x00\x00\x00\x16\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xe2\
\x00\x00\x00,\x00\x00\x00\x00\x00\x01\x00\x00\x11>\
"
def qInitResources():
QtCore.qRegisterResourceData(0x01, qt_resource_struct, qt_resource_name, qt_resource_data)
def qCleanupResources():
QtCore.qUnregisterResourceData(0x01, qt_resource_struct, qt_resource_name, qt_resource_data)
qInitResources()
+237
View File
@@ -0,0 +1,237 @@
"""
This is PyInstaller hook file for CEF Python. This file
helps PyInstaller find CEF Python dependencies that are
required to run final executable.
See PyInstaller docs for hooks:
https://pyinstaller.readthedocs.io/en/stable/hooks.html
"""
import glob
import os
import platform
import re
import sys
import PyInstaller
from PyInstaller.utils.hooks import is_module_satisfies, get_package_paths
from PyInstaller.compat import is_win, is_darwin, is_linux, is_py2
from PyInstaller import log as logging
# Constants
CEFPYTHON_MIN_VERSION = "57.0"
PYINSTALLER_MIN_VERSION = "3.2.1"
# Makes assumption that using "python.exe" and not "pyinstaller.exe"
# TODO: use this code to work cross-platform:
# from PyInstaller.utils.hooks import get_package_paths
# get_package_paths("cefpython3")
CEFPYTHON3_DIR = get_package_paths("cefpython3")[1]
CYTHON_MODULE_EXT = ".pyd" if is_win else ".so"
# Globals
logger = logging.getLogger(__name__)
# Functions
def check_platforms():
if not is_win and not is_darwin and not is_linux:
raise SystemExit("Error: Currently only Windows, Linux and Darwin "
"platforms are supported, see Issue #135.")
def check_pyinstaller_version():
"""Using is_module_satisfies() for pyinstaller fails when
installed using 'pip install develop.zip' command
(PyInstaller Issue #2802)."""
# Example version string for dev version of pyinstaller:
# > 3.3.dev0+g5dc9557c
version = PyInstaller.__version__
match = re.search(r"^\d+\.\d+(\.\d+)?", version)
if not (match.group(0) >= PYINSTALLER_MIN_VERSION):
raise SystemExit("Error: pyinstaller %s or higher is required"
% PYINSTALLER_MIN_VERSION)
def check_cefpython3_version():
if not is_module_satisfies("cefpython3 >= %s" % CEFPYTHON_MIN_VERSION):
raise SystemExit("Error: cefpython3 %s or higher is required"
% CEFPYTHON_MIN_VERSION)
def get_cefpython_modules():
"""Get all cefpython Cython modules in the cefpython3 package.
It returns a list of names without file extension. Eg.
'cefpython_py27'. """
pyds = glob.glob(os.path.join(CEFPYTHON3_DIR,
"cefpython_py*" + CYTHON_MODULE_EXT))
assert len(pyds) > 1, "Missing cefpython3 Cython modules"
modules = []
for path in pyds:
filename = os.path.basename(path)
mod = filename.replace(CYTHON_MODULE_EXT, "")
modules.append(mod)
return modules
def get_excluded_cefpython_modules():
"""CEF Python package includes Cython modules for various Python
versions. When using Python 2.7 pyinstaller should not
bundle modules for eg. Python 3.6, otherwise it will
cause to include Python 3 dll dependencies. Returns a list
of fully qualified names eg. 'cefpython3.cefpython_py27'."""
pyver = "".join(map(str, sys.version_info[:2]))
pyver_string = "py%s" % pyver
modules = get_cefpython_modules()
excluded = []
for mod in modules:
if pyver_string in mod:
continue
excluded.append("cefpython3.%s" % mod)
logger.info("Exclude cefpython3 module: %s" % excluded[-1])
return excluded
def get_cefpython3_datas():
"""Returning almost all of cefpython binaries as DATAS (see exception
below), because pyinstaller does strange things and fails if these are
returned as BINARIES. It first updates manifest in .dll files:
>> Updating manifest in chrome_elf.dll
And then because of that it fails to load the library:
>> hsrc = win32api.LoadLibraryEx(filename, 0, LOAD_LIBRARY_AS_DATAFILE)
>> pywintypes.error: (5, 'LoadLibraryEx', 'Access is denied.')
It is not required for pyinstaller to modify in any way
CEF binaries or to look for its dependencies. CEF binaries
does not have any external dependencies like MSVCR or similar.
The .pak .dat and .bin files cannot be marked as BINARIES
as pyinstaller would fail to find binary depdendencies on
these files.
One exception is subprocess (subprocess.exe on Windows) executable
file, which is passed to pyinstaller as BINARIES in order to collect
its dependecies.
DATAS are in format: tuple(full_path, dest_subdir).
"""
ret = list()
if is_win:
cefdatadir = "."
elif is_darwin or is_linux:
cefdatadir = "."
else:
assert False, "Unsupported system {}".format(platform.system())
# Binaries, licenses and readmes in the cefpython3/ directory
for filename in os.listdir(CEFPYTHON3_DIR):
# Ignore Cython modules which are already handled by
# pyinstaller automatically.
if filename[:-len(CYTHON_MODULE_EXT)] in get_cefpython_modules():
continue
# CEF binaries and datas
extension = os.path.splitext(filename)[1]
if extension in \
[".exe", ".dll", ".pak", ".dat", ".bin", ".txt", ".so", ".plist"] \
or filename.lower().startswith("license"):
logger.info("Include cefpython3 data: {}".format(filename))
ret.append((os.path.join(CEFPYTHON3_DIR, filename), cefdatadir))
if is_darwin:
# "Chromium Embedded Framework.framework/Resources" with subdirectories
# is required. Contain .pak files and locales (each locale in separate
# subdirectory).
resources_subdir = \
os.path.join("Chromium Embedded Framework.framework", "Resources")
base_path = os.path.join(CEFPYTHON3_DIR, resources_subdir)
assert os.path.exists(base_path), \
"{} dir not found in cefpython3".format(resources_subdir)
for path, dirs, files in os.walk(base_path):
for file in files:
absolute_file_path = os.path.join(path, file)
dest_path = os.path.relpath(path, CEFPYTHON3_DIR)
ret.append((absolute_file_path, dest_path))
logger.info("Include cefpython3 data: {}/{}".format(dest_path, file))
elif is_win or is_linux:
# The .pak files in cefpython3/locales/ directory
locales_dir = os.path.join(CEFPYTHON3_DIR, "locales")
assert os.path.exists(locales_dir), \
"locales/ dir not found in cefpython3"
for filename in os.listdir(locales_dir):
logger.info("Include cefpython3 data: {}/{}".format(
os.path.basename(locales_dir), filename))
ret.append((os.path.join(locales_dir, filename),
os.path.join(cefdatadir, "locales")))
# Optional .so/.dll files in cefpython3/swiftshader/ directory
swiftshader_dir = os.path.join(CEFPYTHON3_DIR, "swiftshader")
if os.path.isdir(swiftshader_dir):
for filename in os.listdir(swiftshader_dir):
logger.info("Include cefpython3 data: {}/{}".format(
os.path.basename(swiftshader_dir), filename))
ret.append((os.path.join(swiftshader_dir, filename),
os.path.join(cefdatadir, "swiftshader")))
return ret
# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
# Checks
check_platforms()
check_pyinstaller_version()
check_cefpython3_version()
# Info
logger.info("CEF Python package directory: %s" % CEFPYTHON3_DIR)
# Hidden imports.
# PyInstaller has no way on detecting imports made by Cython
# modules, so all pure Python imports made in cefpython .pyx
# files need to be manually entered here.
# TODO: Write a tool script that would find such imports in
# .pyx files automatically.
hiddenimports = [
"codecs",
"copy",
"datetime",
"inspect",
"json",
"os",
"platform",
"random",
"re",
"sys",
"time",
"traceback",
"types",
"urllib",
"weakref",
]
if is_py2:
hiddenimports += [
"urlparse",
]
# Excluded modules
excludedimports = get_excluded_cefpython_modules()
# Include binaries requiring to collect its dependencies
if is_darwin or is_linux:
binaries = [(os.path.join(CEFPYTHON3_DIR, "subprocess"), ".")]
elif is_win:
binaries = [(os.path.join(CEFPYTHON3_DIR, "subprocess.exe"), ".")]
else:
binaries = []
# Include datas
datas = get_cefpython3_datas()
# Notify pyinstaller.spec code that this hook was executed
# and that it succeeded.
os.environ["PYINSTALLER_CEFPYTHON3_HOOK_SUCCEEDED"] = "1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
path{fill:rgb(150, 146, 144)}
polygon{fill:rgb(150, 146, 144)}
circle{fill:rgb(150, 146, 144)}
rect{fill:rgb(150, 146, 144)}
</style><path d="M31.8,56.4c-1.9,0-3.8-0.8-5-2.4c-2.2-2.8-1.8-6.9,1-9.1l35.1-28.2c2.8-2.2,6.9-1.8,9.1,1c2.2,2.8,1.8,6.9-1,9.1L35.8,54.9
C34.6,55.9,33.2,56.4,31.8,56.4z"/>
<path d="M66.9,84.6c-1.4,0-2.9-0.5-4-1.4L27.7,54.9c-2.8-2.2-3.2-6.3-1-9.1c2.2-2.8,6.3-3.2,9.1-1l35.1,28.2c2.8,2.2,3.2,6.3,1,9.1
C70.6,83.8,68.8,84.6,66.9,84.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100" xml:space="preserve">
<style type="text/css">
path{fill:rgb(150, 146, 144)}
polygon{fill:rgb(150, 146, 144)}
circle{fill:rgb(150, 146, 144)}
rect{fill:rgb(150, 146, 144)}
</style><path d="M51.3,75.9c-1.9,0-3.8-0.8-5-2.4L18.1,38.4c-2.2-2.8-1.8-6.9,1-9.1c2.8-2.2,6.9-1.8,9.1,1l28.2,35.1c2.2,2.8,1.8,6.9-1,9.1
C54.2,75.5,52.7,75.9,51.3,75.9z"/>
<path d="M51.3,75.9c-1.4,0-2.9-0.5-4-1.4c-2.8-2.2-3.2-6.3-1-9.1l28.2-35.1c2.2-2.8,6.3-3.2,9.1-1c2.8,2.2,3.2,6.3,1,9.1L56.4,73.5
C55.1,75.1,53.2,75.9,51.3,75.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 856 B

+249 -73
View File
@@ -1,33 +1,49 @@
# flake8: noqa F403, F405 # flake8: noqa F403, F405
from os.path import splitext, basename from functools import partialmethod
from multiprocessing import Pipe, Process, freeze_support
from os import environ
from os.path import splitext, basename, dirname, join
import sys import sys
import threading import threading
from cefpython3 import cefpython as cef from cefpython3 import cefpython as cef
import PySide2
from PySide2.QtGui import *
from PySide2.QtCore import * from PySide2.QtCore import *
from PySide2.QtWidgets import * from PySide2.QtWidgets import *
from server.app.app import Server import server.gui.cellxgene_rc
from server.gui.browser import CefWidget, CefApplication from server.gui.browser import CefWidget, CefApplication
from server.gui.workers import DataLoadWorker, ServerRunWorker from server.gui.workers import Worker, SiteReadyWorker
from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals, Emitter, WorkerSignals, FileChanged
from server.utils.constants import MODES from server.utils.utils import find_available_port
if WINDOWS or LINUX:
dirname = dirname(PySide2.__file__)
plugin_path = join(dirname, 'plugins', 'platforms')
environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = plugin_path
# Configuration # Configuration
# TODO remember this or calculate it? # TODO remember this or calculate it?
WIDTH = 1024 WIDTH = 1300
HEIGHT = 768 HEIGHT = 800
MAX_CONTENT_WIDTH = 700
GUI_PORT = find_available_port("localhost")
BROWSER_INDEX = 0
LOAD_INDEX = 1
# noinspection PyUnresolvedReferences
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super(MainWindow, self).__init__(None) super(MainWindow, self).__init__(None)
self.cef_widget = None self.cef_widget = None
self.data_widget = None self.data_widget = None
self.server = Server() self.stacked_layout = None
self.server.create_app() self.parent_conn, self.child_conn = None, None
self.runServer() self.load_emitter = None
self.emitter_thread = None
self.worker = None
self.url = f"http://localhost:{GUI_PORT}/"
self.setWindowTitle("cellxgene") self.setWindowTitle("cellxgene")
# Strong focus - accepts focus by tab & click # Strong focus - accepts focus by tab & click
@@ -35,13 +51,26 @@ class MainWindow(QMainWindow):
self.setupLayout() self.setupLayout()
self.setupMenu() self.setupMenu()
def showBrowser(self):
self.stacked_layout.setCurrentIndex(BROWSER_INDEX)
def restartOnError(self):
self.window().shutdownServer()
# close emitter on error/finished
self.parent_conn, self.child_conn = Pipe()
self.load_emitter = Emitter(self.parent_conn, WorkerSignals)
self.emitter_thread = threading.Thread(target=self.load_emitter.run, daemon=True)
self.emitter_thread.start()
# send to load with error message?
def setupLayout(self): def setupLayout(self):
self.resize(WIDTH, HEIGHT) self.resize(WIDTH, HEIGHT)
self.cef_widget = CefWidget(self) self.cef_widget = CefWidget(self)
self.cef_widget.setSizePolicy(QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding))
self.data_widget = LoadWidget(self) self.data_widget = LoadWidget(self)
self.stacked_layout = QStackedLayout() self.stacked_layout = QStackedLayout()
self.stacked_layout.addWidget(self.data_widget)
self.stacked_layout.addWidget(self.cef_widget) self.stacked_layout.addWidget(self.cef_widget)
self.stacked_layout.addWidget(self.data_widget)
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0) main_layout.setSpacing(0)
@@ -67,9 +96,26 @@ class MainWindow(QMainWindow):
# cef widget in the layout with the container. # cef widget in the layout with the container.
self.container = QWidget.createWindowContainer( self.container = QWidget.createWindowContainer(
self.cef_widget.hidden_window, parent=self) self.cef_widget.hidden_window, parent=self)
stacked_layout.addWidget(self.container, 1, 0) self.stacked_layout.replaceWidget(self.cef_widget, self.container)
self.stacked_layout.setCurrentIndex(LOAD_INDEX)
def setupServer(self):
self.shutdownServer()
# close emitter on error/finished
self.parent_conn, self.child_conn = Pipe()
self.load_emitter = Emitter(self.parent_conn, WorkerSignals)
self.emitter_thread = threading.Thread(target=self.load_emitter.run, daemon=True)
self.emitter_thread.start()
# send to load with error message?
def shutdownServer(self):
if self.worker:
self.worker.terminate()
if self.parent_conn:
self.parent_conn.close()
def setupMenu(self): def setupMenu(self):
# TODO add communication to subprocess on reload
main_menu = self.menuBar() main_menu = self.menuBar()
file_menu = main_menu.addMenu('File') file_menu = main_menu.addMenu('File')
load_action = QAction("Load file...", self) load_action = QAction("Load file...", self)
@@ -79,7 +125,11 @@ class MainWindow(QMainWindow):
file_menu.addAction(load_action) file_menu.addAction(load_action)
def showLoad(self): def showLoad(self):
self.stacked_layout.setCurrentIndex(0) self.clearMessages()
self.stacked_layout.setCurrentIndex(LOAD_INDEX)
def clearMessages(self):
self.data_widget.reset()
def closeEvent(self, event): def closeEvent(self, event):
# Close browser (force=True) and free CEF reference # Close browser (force=True) and free CEF reference
@@ -87,11 +137,6 @@ class MainWindow(QMainWindow):
self.cef_widget.browser.CloseBrowser(True) self.cef_widget.browser.CloseBrowser(True)
self.clearBrowserReferences() self.clearBrowserReferences()
def runServer(self):
worker = ServerRunWorker(self.server.app, host="127.0.0.1", port=8000)
self.httpd = threading.Thread(target=worker.run, daemon=True)
self.httpd.start()
def clearBrowserReferences(self): def clearBrowserReferences(self):
# Clear browser references that you keep anywhere in your # Clear browser references that you keep anywhere in your
# code. All references must be cleared for CEF to shutdown cleanly. # code. All references must be cleared for CEF to shutdown cleanly.
@@ -102,93 +147,217 @@ class LoadWidget(QFrame):
def __init__(self, parent): def __init__(self, parent):
super(LoadWidget, self).__init__(parent=parent) super(LoadWidget, self).__init__(parent=parent)
# Init layout # Init layout
self.MAX_CONTENT_WIDTH = 500
load_ui_layout = QVBoxLayout() load_ui_layout = QVBoxLayout()
h_margin = (WIDTH - self.MAX_CONTENT_WIDTH) // 2 h_margin = (WIDTH - MAX_CONTENT_WIDTH) // 2
if h_margin < 10: if h_margin < 10:
h_margin = 10 h_margin = 10
load_ui_layout.setContentsMargins(h_margin, 20, h_margin, 20) load_ui_layout.setContentsMargins(h_margin, 20, h_margin, 20)
logo_layout = QHBoxLayout() logo_layout = QHBoxLayout()
logo_layout.setContentsMargins(0, 0, 0, 20) logo_layout.setContentsMargins(0, 0, 0, 20)
load_layout = QGridLayout() file_layout = QVBoxLayout()
load_layout.setContentsMargins(0, 0, 0, 0)
load_layout.setSpacing(0)
message_layout = QHBoxLayout() message_layout = QHBoxLayout()
message_layout.setContentsMargins(0, 0, 0, 0) message_layout.setContentsMargins(0, 0, 0, 0)
self.title = "" self.serverError = False
self.label = QLabel("cellxgene") self.file_name = FilePath()
self.label = QLabel()
logo = QPixmap(":/logo.png")
self.label.setPixmap(logo)
self.label.setContentsMargins(100, 0, 100, 0)
logo_layout.addWidget(self.label) logo_layout.addWidget(self.label)
# UI section # UI section
# TODO add load spinner
# TODO add cancel button to send back to browser (if available) # TODO add cancel button to send back to browser (if available)
self.embedding_label = QLabel("embedding: ")
load_layout.addWidget(self.embedding_label, 0, 0)
self.file_label = QLabel("file: ")
load_layout.addWidget(self.file_label, 0, 1)
self.embeddings = QComboBox(self)
self.embeddings.currentIndexChanged.connect(self.updateEmbedding)
self.embeddings.addItems(MODES)
self.embedding_selection = MODES[0]
load_layout.addWidget(self.embeddings, 1, 0)
self.load = QPushButton("Open...") self.file_area = FileArea(self)
self.load.clicked.connect(self.onLoad) self.file_name.signals.changed.connect(self.updatePath)
load_layout.addWidget(self.load, 1, 1)
self.launch_widget = QLabel("Select a file to launch cellxgene")
# self.launch_widget.setEnabled(False)
# self.launch_widget.clicked.connect(self.onLoad)
self.progress = QProgressBar()
self.progress.setTextVisible(False)
file_layout.addWidget(self.file_area)
self.loading_layout = QStackedLayout()
self.loading_layout.addWidget(self.launch_widget)
self.loading_layout.addWidget(self.progress)
file_layout.addLayout(self.loading_layout)
file_layout.setStretch(0, 10)
# Error section # Error section
self.error_label = QLabel("") self.error_label = QLabel("")
self.error_label.setWordWrap(True) self.error_label.setWordWrap(True)
self.error_label.setFixedWidth(self.MAX_CONTENT_WIDTH) self.error_label.setFixedWidth(MAX_CONTENT_WIDTH)
message_layout.addWidget(self.error_label, alignment=Qt.AlignTop) message_layout.addWidget(self.error_label)
# Options Form
# Layout # Layout
for l in [logo_layout, load_layout, message_layout ]: for l in [logo_layout, file_layout, message_layout]:
load_ui_layout.addLayout(l) load_ui_layout.addLayout(l)
load_ui_layout.setStretch(2, 10) #TODO remove magic number
load_ui_layout.setStretch(1, 10)
self.setLayout(load_ui_layout) self.setLayout(load_ui_layout)
self.timer = QTimer()
self.timer.setInterval(100)
self.timer.timeout.connect(self.updateProgress)
self.signals = FileLoadSignals() self.signals = FileLoadSignals()
self.signals.selectedFile.connect(self.createScanpyEngine) self.signals.selectedFile.connect(self.createScanpyEngine)
self.signals.error.connect(self.onError)
def updateEmbedding(self, idx): def updatePath(self):
self.embedding_selection = MODES[idx] file_name = self.file_name.value
if file_name:
self.file_area.label.setText("File: " + file_name)
else:
self.file_area.label.setText("")
# self.launch_widget.setEnabled(bool(file_name))
def reset(self):
self.loading_layout.setCurrentIndex(0)
self.timer.stop()
self.error_label.setText("")
self.file_name.updateValue(None)
def updateProgress(self):
curr_val = self.progress.value()
next_val = (curr_val + 1) % 100
self.progress.setValue(next_val)
def resetProgress(self):
self.progress.setValue(0)
self.loading_layout.setCurrentIndex(0)
self.timer.stop()
def createScanpyEngine(self, file_name): def createScanpyEngine(self, file_name):
worker = DataLoadWorker(file_name, self.embedding_selection) title = splitext(basename(file_name))[0]
worker.signals.result.connect(self.onDataSuccess) self.window().setupServer()
worker.signals.error.connect(self.onDataError) worker = Worker(self.window().parent_conn, self.window().child_conn, file_name, host="127.0.0.1",
self.load_worker = threading.Thread(target=worker.run, daemon=True) port=GUI_PORT, title=title, engine_options={})
self.load_worker.start() self.window().load_emitter.signals.ready.connect(self.onDataReady)
self.window().load_emitter.signals.engine_error.connect(self.onServerError)
self.window().load_emitter.signals.server_error.connect(self.onServerError)
# Error is generic error from emitter
self.window().load_emitter.signals.error.connect(self.onServerError)
self.window().worker = Process(target=worker.run, daemon=True)
self.window().worker.start()
self.window().child_conn.close()
def onLoad(self): def onLoad(self):
if self.file_name.value:
# Reset error on reload
self.serverError = False
self.loading_layout.setCurrentIndex(1)
self.timer.start()
self.signals.selectedFile.emit(self.file_name.value)
else:
self.signals.error.emit("Please select a file before launching.")
def onDataReady(self):
self.site_ready_worker = SiteReadyWorker(self.window().url)
self.site_ready_worker.signals.ready.connect(self.onServerReady)
self.site_ready_worker.signals.error.connect(self.onServerError)
srw_thread = threading.Thread(target=self.site_ready_worker.run, daemon=True)
srw_thread.start()
def onServerReady(self):
if not self.serverError:
self.resetProgress()
self.window().cef_widget.browser.Navigate(self.window().url)
self.window().showBrowser()
def onError(self, err, server_error=False):
# Restart worker
if server_error:
self.serverError = True
# Report error and switch to load screen
self.window().shutdownServer()
self.resetProgress()
self.window().stacked_layout.setCurrentIndex(LOAD_INDEX)
self.error_label.setText(f"Error: {err}")
self.error_label.resize(MAX_CONTENT_WIDTH, self.error_label.height())
self.window().repaint()
onServerError = partialmethod(onError, server_error=True)
class FilePath(QObject):
def __init__(self):
super(FilePath, self).__init__()
self.value = ""
self.signals = FileChanged()
def updateValue(self, path=None):
self.value = path
self.signals.changed.emit(self.value != path)
class FileArea(QFrame):
def __init__(self, parent):
super(FileArea, self).__init__()
self.setFrameShape(QFrame.Box)
self.setMinimumHeight(100)
self.setFixedWidth(MAX_CONTENT_WIDTH)
self.setAcceptDrops(True)
self.instructions = QLabel(self)
self.instructions.setText("Drag & Drop a h5ad file to load or open")
self.instructions.setGeometry(10, 10, MAX_CONTENT_WIDTH, self.instructions.height())
self.loadButton = QPushButton("Open...", parent=self)
x_pos = (MAX_CONTENT_WIDTH - self.loadButton.width()) / 2
self.loadButton.setGeometry(x_pos, 50, self.loadButton.width(), self.loadButton.height())
self.loadButton.clicked.connect(self.fileBrowse)
self.label = QLabel(self)
self.label.setGeometry(10, 75, MAX_CONTENT_WIDTH, self.label.height())
def fileBrowse(self):
options = QFileDialog.Options() options = QFileDialog.Options()
# options |= QFileDialog.DontUseNativeDialog # options |= QFileDialog.DontUseNativeDialog
file_name, _ = QFileDialog.getOpenFileName(self, file_name, _ = QFileDialog.getOpenFileName(self,
"Open H5AD File", "", "H5AD Files (*.h5ad)", options=options) "Open H5AD File", "", "H5AD Files (*.h5ad)", options=options)
self.title = splitext(basename(file_name))[0]
if file_name: if file_name:
self.signals.selectedFile.emit(file_name) self.parent().file_name.updateValue(file_name)
self.parent().onLoad()
def dragEnterEvent(self, e):
if e.mimeData().hasUrls:
e.accept()
else:
e.ignore()
def onDataSuccess(self, data): def dragMoveEvent(self, e):
self.window().server.attach_data(data, self.title) if e.mimeData().hasUrls:
self.navigateToLocation() e.accept()
# Reveal browser else:
self.window().stacked_layout.setCurrentIndex(1) e.ignore()
def onDataError(self, err): def dropEvent(self, e):
self.error_label.setText(f"Error: {err}") """
self.error_label.resize(self.MAX_CONTENT_WIDTH, self.error_label.height()) Drop files directly onto the widget
File locations are stored in fname
def navigateToLocation(self, location="http://localhost:8000/"): :param e:
self.window().cef_widget.browser.Navigate(location) :return:
"""
if e.mimeData().hasUrls:
e.setDropAction(Qt.CopyAction)
e.accept()
for url in e.mimeData().urls():
file_name = str(url.toLocalFile())
self.parent().file_name.updateValue(file_name)
self.parent().onLoad()
else:
e.ignore()
def main(): def main():
freeze_support()
# This generates an error.log file on error # This generates an error.log file on error
sys.excepthook = cef.ExceptHook # To shutdown all CEF processes on error sys.excepthook = cef.ExceptHook # To shutdown all CEF processes on error
settings = {} settings = {}
@@ -200,19 +369,26 @@ def main():
cef.Initialize(settings) cef.Initialize(settings)
app = CefApplication(sys.argv) app = CefApplication(sys.argv)
main_window = MainWindow() main_window = MainWindow()
main_window.setWindowTitle("cellxgene")
main_window.setUnifiedTitleAndToolBarOnMac(True)
main_window.setWindowIcon(QIcon(":icon.png"))
main_window.show() main_window.show()
main_window.activateWindow() main_window.activateWindow()
main_window.raise_() main_window.raise_()
app.exec_() try:
app.exec_()
except Exception as e:
raise
finally:
# Clean up on close
if not cef.GetAppSetting("external_message_pump"):
app.stopTimer()
# Clean up on close main_window.shutdownServer()
if not cef.GetAppSetting("external_message_pump"): del main_window # Just to be safe, similarly to "del app"
app.stopTimer() del app # Must destroy app object before calling Shutdown
# TODO clean up threads when we switch threading model cef.Shutdown()
del main_window # Just to be safe, similarly to "del app" sys.exit(0)
del app # Must destroy app object before calling Shutdown
cef.Shutdown()
sys.exit(0)
if __name__ == '__main__': if __name__ == '__main__':
+54
View File
@@ -1,3 +1,4 @@
import errno
import platform import platform
from PySide2.QtCore import QObject, Signal from PySide2.QtCore import QObject, Signal
@@ -13,13 +14,66 @@ class WorkerSignals(QObject):
Defines the signals available from a running worker thread. Defines the signals available from a running worker thread.
Supported signals are: Supported signals are:
finished finished
ready
error - `str` error message error - `str` error message
result - `object` data returned from processing, anything result - `object` data returned from processing, anything
""" """
finished = Signal() finished = Signal()
engine_error = Signal(str)
server_error = Signal(str)
error = Signal(str) error = Signal(str)
result = Signal(object) result = Signal(object)
ready = Signal()
class SiteReadySignals(QObject):
"""
Defines the signals available from a running worker thread.
Supported signals are:
timeout
ready
error - `str` error message
"""
ready = Signal()
timeout = Signal()
error = Signal(str)
class FileLoadSignals(QObject): class FileLoadSignals(QObject):
selectedFile = Signal(str) selectedFile = Signal(str)
error = Signal(str)
class FileChanged(QObject):
changed = Signal(bool)
class Emitter:
def __init__(self, transport, signals):
self.transport = transport
self.signals = signals()
def _emit(self, signature, args=None):
if args is None:
getattr(self.signals, signature).emit()
else:
getattr(self.signals, signature).emit(args)
def run(self):
while True:
try:
signature = self.transport.recv()
except EOFError:
# Server done
break
except OSError as e:
if e.errno == errno.EBADF:
break
else:
self.signals.error.emit(str(e))
break
except Exception as e:
self.signals.error.emit(str(e))
break
else:
self._emit(*signature)
+77 -35
View File
@@ -1,47 +1,89 @@
import traceback from multiprocessing import Process
import time
from server.gui.utils import WorkerSignals import requests
from server.gui.utils import SiteReadySignals
class DataLoadWorker(): class EmittingProcess(Process):
def __init__(self, data_file, layout="umap", *args, **kwargs): def __init__(self, parent_conn, child_conn, *arg, **kwargs):
super(DataLoadWorker, self).__init__() super(EmittingProcess, self).__init__()
self.data_file = data_file self.parent_conn = parent_conn
self.layout = layout self.child_conn = child_conn
self.signals = WorkerSignals()
def run(self): def run(self):
if not self.data_file: self.parent_conn.close()
self.signals.finished.emit()
return
# delayed import to speed load def emit(self, signal_name, *args):
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine message = (signal_name, *args)
args = { self.child_conn.send(message)
"layout": self.layout,
"diffexp": "ttest",
"max_category_items": 100,
"diffexp_lfc_cutoff": 0.01,
"obs_names": None,
"var_names": None,
}
try:
data_results = ScanpyEngine(self.data_file, args)
except Exception as e:
traceback.print_exc()
self.signals.error.emit(str(e))
else:
self.signals.result.emit(data_results)
finally:
self.signals.finished.emit()
class ServerRunWorker(): class Worker(EmittingProcess):
def __init__(self, app, host, port, *args, **kwargs): def __init__(self, parent_conn, child_conn, data_file, host, port, title, engine_options, *args, **kwargs):
super(ServerRunWorker, self).__init__() super(Worker, self).__init__(parent_conn, child_conn)
self.app = app self.data_file = data_file
self.host = host self.host = host
self.port = port self.port = port
self.title = title
self.engine_options = engine_options
def run(self): def run(self):
self.app.run(host=self.host, debug=False, port=self.port, threaded=True) super(Worker, self).run()
if not self.data_file:
self.emit("finished")
return
from server.app.app import Server
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
# create server
try:
server = Server()
server.create_app()
except Exception as e:
self.emit("server_error", str(e))
self.emit("finished")
return
# load data
try:
args = {
"max_category_items": 100,
"diffexp_lfc_cutoff": 0.01,
"obs_names": None,
"var_names": None,
}
args.update(self.engine_options)
data = ScanpyEngine(self.data_file, args)
server.attach_data(data, self.title)
self.emit("ready")
except Exception as e:
self.emit("engine_error", str(e))
self.emit("finished")
return
# launch server
try:
server.app.run(host=self.host, debug=False, port=self.port, threaded=True)
except Exception as e:
self.emit("server_error", str(e))
finally:
self.emit("finished")
class SiteReadyWorker:
def __init__(self, location):
super(SiteReadyWorker, self).__init__()
self.signals = SiteReadySignals()
self.location = location
def run(self):
session = requests.Session()
for i in range(90):
try:
session.head(self.location)
self.signals.ready.emit()
break
except requests.exceptions.ConnectionError:
time.sleep(1)
except Exception as e:
self.signals.error.emit(str(e))
self.signals.timeout.emit()
+4
View File
@@ -0,0 +1,4 @@
cefpython3>=66
requests
PyInstaller>=3.4
PySide2>=5.12.3
+3 -4
View File
@@ -1,4 +1,4 @@
anndata>=0.6.15 anndata>=0.6.20
click>=6.7 click>=6.7
Flask>=1.0.2 Flask>=1.0.2
Flask-Caching>=1.4.0 Flask-Caching>=1.4.0
@@ -9,8 +9,7 @@ flatbuffers>=1.10.0
matplotlib>=2.2 matplotlib>=2.2
numpy>=1.15.2 numpy>=1.15.2
pandas>=0.23.1 pandas>=0.23.1
psutil>=5.6.2
scanpy>=1.3.7 scanpy>=1.3.7
scipy>=1.1.0,<1.3 scipy>=1.1.0
scikit-learn>=0.19.1,!=0.20.0 scikit-learn>=0.19.1,!=0.20.0
tables>=3.5.1 tables==3.5.1
+53 -38
View File
@@ -5,46 +5,61 @@
"type": "float32" "type": "float32"
}, },
"annotations": { "annotations": {
"obs": {
"index": "name_0",
"columns": [
{
"name": "name_0",
"type": "string"
},
{
"name": "n_genes",
"type": "int32"
},
{
"name": "percent_mito",
"type": "float32"
},
{
"name": "n_counts",
"type": "float32"
},
{
"name": "louvain",
"type": "categorical",
"categories": [
"CD4 T cells",
"CD14+ Monocytes",
"B cells",
"CD8 T cells",
"NK cells",
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes"
]
}
]
},
"var": {
"index": "name_0",
"columns": [
{
"name": "name_0",
"type": "string"
},
{
"name": "n_cells",
"type": "int32"
}
]
}
},
"layout": {
"obs": [ "obs": [
{ {
"name": "name", "name": "umap",
"type": "string" "type": "float32",
}, "dims": ["umap_0", "umap_1"]
{
"name": "n_genes",
"type": "int32"
},
{
"name": "percent_mito",
"type": "float32"
},
{
"name": "n_counts",
"type": "float32"
},
{
"name": "louvain",
"type": "categorical",
"categories": [
"CD4 T cells",
"CD14+ Monocytes",
"B cells",
"CD8 T cells",
"NK cells",
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes"
]
}
],
"var": [
{
"name": "name",
"type": "string"
},
{
"name": "n_cells",
"type": "int32"
} }
] ]
} }
+11 -6
View File
@@ -19,11 +19,12 @@ class EndPoints(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.ps = Popen(["cellxgene", "launch", "example-dataset/pbmc3k.h5ad", "--debug", "--port", "5005"]) cls.ps = Popen(["cellxgene", "launch", "example-dataset/pbmc3k.h5ad", "--verbose", "--port", "5005"])
session = requests.Session() session = requests.Session()
for i in range(90): for i in range(90):
try: try:
session.get(f"{URL_BASE}schema") result = session.get(f"{URL_BASE}schema")
cls.schema = result.json()
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
time.sleep(1) time.sleep(1)
@@ -45,7 +46,8 @@ class EndPoints(unittest.TestCase):
self.assertEqual(result.headers["Content-Type"], "application/json") self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json() result_data = result.json()
self.assertEqual(result_data["schema"]["dataframe"]["nObs"], 2638) self.assertEqual(result_data["schema"]["dataframe"]["nObs"], 2638)
self.assertEqual(len(result_data["schema"]["annotations"]["obs"]), 5) self.assertEqual(len(result_data["schema"]["annotations"]["obs"]), 2)
self.assertEqual(len(result_data["schema"]["annotations"]["obs"]["columns"]), 5)
def test_config(self): def test_config(self):
endpoint = "config" endpoint = "config"
@@ -95,7 +97,8 @@ class EndPoints(unittest.TestCase):
self.assertIsNotNone(df['col_idx']) self.assertIsNotNone(df['col_idx'])
self.assertIsNone(df['row_idx']) self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['name', 'n_genes', 'percent_mito', 'n_counts', 'louvain']) obs_index_col_name = self.schema["schema"]["annotations"]["obs"]["index"]
self.assertListEqual(df['col_idx'], [obs_index_col_name, 'n_genes', 'percent_mito', 'n_counts', 'louvain'])
def test_get_annotations_obs_keys_fbs(self): def test_get_annotations_obs_keys_fbs(self):
endpoint = "annotations/obs" endpoint = "annotations/obs"
@@ -165,7 +168,8 @@ class EndPoints(unittest.TestCase):
self.assertIsNotNone(df['col_idx']) self.assertIsNotNone(df['col_idx'])
self.assertIsNone(df['row_idx']) self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['name', 'n_cells']) var_index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
self.assertListEqual(df['col_idx'], [var_index_col_name, 'n_cells'])
def test_get_annotations_var_keys_fbs(self): def test_get_annotations_var_keys_fbs(self):
endpoint = "annotations/var" endpoint = "annotations/var"
@@ -247,7 +251,8 @@ class EndPoints(unittest.TestCase):
endpoint = f"data/var" endpoint = f"data/var"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
var_filter = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}}} index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
var_filter = {"filter": {"var": {"annotation_value": [{"name": index_col_name, "values": ["RER1"]}]}}}
result = self.session.put(url, headers=header, json=var_filter) result = self.session.put(url, headers=header, json=var_filter)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
+2 -2
View File
@@ -8,7 +8,7 @@ import decode_fbs
import requests import requests
LOCAL_URL = "http://127.0.0.1:5005/" LOCAL_URL = "http://127.0.0.1:5006/"
VERSION = "v0.2" VERSION = "v0.2"
URL_BASE = f"{LOCAL_URL}api/{VERSION}/" URL_BASE = f"{LOCAL_URL}api/{VERSION}/"
@@ -21,7 +21,7 @@ class WithNaNs(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.ps = Popen( cls.ps = Popen(
["cellxgene", "launch", "server/test/test_datasets/nan.h5ad", "--debug", "--port", "5005"] ["cellxgene", "launch", "server/test/test_datasets/nan.h5ad", "--verbose", "--port", "5006"]
) )
session = requests.Session() session = requests.Session()
for i in range(90): for i in range(90):
+4 -3
View File
@@ -13,7 +13,6 @@ class NaNTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.args = { self.args = {
"layout": ["umap"], "layout": ["umap"],
"diffexp": "ttest",
"max_category_items": 100, "max_category_items": 100,
"obs_names": None, "obs_names": None,
"var_names": None, "var_names": None,
@@ -58,14 +57,16 @@ class NaNTest(unittest.TestCase):
def test_annotation(self): def test_annotation(self):
annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("obs")) annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("obs"))
obs_index_col_name = self.data.schema["annotations"]["obs"]["index"]
self.assertEqual( self.assertEqual(
annotations["col_idx"], annotations["col_idx"],
["name", "n_genes", "percent_mito", "n_counts", "louvain"] [obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain"]
) )
self.assertEqual(annotations["n_rows"], 100) self.assertEqual(annotations["n_rows"], 100)
self.assertTrue(math.isnan(annotations["columns"][2][0])) self.assertTrue(math.isnan(annotations["columns"][2][0]))
annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("var")) annotations = decode_fbs.decode_matrix_FBS(self.data.annotation_to_fbs_matrix("var"))
self.assertEqual(annotations["col_idx"], ["name", "n_cells", "var_with_nans"]) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
self.assertEqual(annotations["col_idx"], [var_index_col_name, "n_cells", "var_with_nans"])
self.assertEqual(annotations["n_rows"], 100) self.assertEqual(annotations["n_rows"], 100)
self.assertTrue(math.isnan(annotations["columns"][2][0])) self.assertTrue(math.isnan(annotations["columns"][2][0]))
+15 -10
View File
@@ -16,7 +16,6 @@ class EngineTest(unittest.TestCase):
def setUp(self): def setUp(self):
args = { args = {
"layout": ["umap"], "layout": ["umap"],
"diffexp": "ttest",
"max_category_items": 100, "max_category_items": 100,
"obs_names": None, "obs_names": None,
"var_names": None, "var_names": None,
@@ -31,9 +30,11 @@ class EngineTest(unittest.TestCase):
self.assertTrue(self.data.data.X[0, 0] - -0.171_469_51 < epsilon) self.assertTrue(self.data.data.X[0, 0] - -0.171_469_51 < epsilon)
def test_mandatory_annotations(self): def test_mandatory_annotations(self):
self.assertIn("name", self.data.data.obs) obs_index_col_name = self.data.schema["annotations"]["obs"]["index"]
self.assertIn(obs_index_col_name, self.data.data.obs)
self.assertEqual(list(self.data.data.obs.index), list(range(2638))) self.assertEqual(list(self.data.data.obs.index), list(range(2638)))
self.assertIn("name", self.data.data.var) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
self.assertIn(var_index_col_name, self.data.data.var)
self.assertEqual(list(self.data.data.var.index), list(range(1838))) self.assertEqual(list(self.data.data.var.index), list(range(1838)))
@pytest.mark.filterwarnings("ignore:Scanpy data matrix") @pytest.mark.filterwarnings("ignore:Scanpy data matrix")
@@ -70,8 +71,8 @@ class EngineTest(unittest.TestCase):
self.assertEqual(data["n_cols"], 91) self.assertEqual(data["n_cols"], 91)
def test_obs_and_var_names(self): def test_obs_and_var_names(self):
self.assertEqual(np.sum(self.data.data.var["name"].isna()), 0) self.assertEqual(np.sum(self.data.data.var[self.data.schema["annotations"]["var"]["index"]].isna()), 0)
self.assertEqual(np.sum(self.data.data.obs["name"].isna()), 0) self.assertEqual(np.sum(self.data.data.obs[self.data.schema["annotations"]["obs"]["index"]].isna()), 0)
def test_schema(self): def test_schema(self):
with open(path.join(path.dirname(__file__), "schema.json")) as fh: with open(path.join(path.dirname(__file__), "schema.json")) as fh:
@@ -108,16 +109,18 @@ class EngineTest(unittest.TestCase):
annotations = decode_fbs.decode_matrix_FBS(fbs) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations["n_rows"], 2638) self.assertEqual(annotations["n_rows"], 2638)
self.assertEqual(annotations["n_cols"], 5) self.assertEqual(annotations["n_cols"], 5)
obs_index_col_name = self.data.schema["annotations"]["obs"]["index"]
self.assertEqual( self.assertEqual(
annotations["col_idx"], annotations["col_idx"],
["name", "n_genes", "percent_mito", "n_counts", "louvain"], [obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain"],
) )
fbs = self.data.annotation_to_fbs_matrix("var") fbs = self.data.annotation_to_fbs_matrix("var")
annotations = decode_fbs.decode_matrix_FBS(fbs) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations['n_rows'], 1838) self.assertEqual(annotations['n_rows'], 1838)
self.assertEqual(annotations['n_cols'], 2) self.assertEqual(annotations['n_cols'], 2)
self.assertEqual(annotations["col_idx"], ["name", "n_cells"]) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
self.assertEqual(annotations["col_idx"], [var_index_col_name, "n_cells"])
def test_annotation_fields(self): def test_annotation_fields(self):
fbs = self.data.annotation_to_fbs_matrix("obs", ["n_genes", "n_counts"]) fbs = self.data.annotation_to_fbs_matrix("obs", ["n_genes", "n_counts"])
@@ -125,7 +128,8 @@ class EngineTest(unittest.TestCase):
self.assertEqual(annotations["n_rows"], 2638) self.assertEqual(annotations["n_rows"], 2638)
self.assertEqual(annotations['n_cols'], 2) self.assertEqual(annotations['n_cols'], 2)
fbs = self.data.annotation_to_fbs_matrix("var", ["name"]) var_index_col_name = self.data.schema["annotations"]["var"]["index"]
fbs = self.data.annotation_to_fbs_matrix("var", [var_index_col_name])
annotations = decode_fbs.decode_matrix_FBS(fbs) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations['n_rows'], 1838) self.assertEqual(annotations['n_rows'], 1838)
self.assertEqual(annotations['n_cols'], 1) self.assertEqual(annotations['n_cols'], 1)
@@ -163,9 +167,10 @@ class EngineTest(unittest.TestCase):
self.data.data_frame_to_fbs_matrix(filter_["filter"], "var") self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
def test_data_named_gene(self): def test_data_named_gene(self):
var_index_col_name = self.data.schema["annotations"]["var"]["index"]
filter_ = { filter_ = {
"filter": { "filter": {
"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]} "var": {"annotation_value": [{"name": var_index_col_name, "values": ["RER1"]}]}
} }
} }
fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var") fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
@@ -176,7 +181,7 @@ class EngineTest(unittest.TestCase):
filter_ = { filter_ = {
"filter": { "filter": {
"var": {"annotation_value": [{"name": "name", "values": ["SPEN", "TYMP", "PRMT2"]}]} "var": {"annotation_value": [{"name": var_index_col_name, "values": ["SPEN", "TYMP", "PRMT2"]}]}
} }
} }
fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var") fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
@@ -16,7 +16,6 @@ class DataLoadEngineTest(unittest.TestCase):
def test_delayed_load_args(self): def test_delayed_load_args(self):
args = { args = {
"layout": ["tsne"], "layout": ["tsne"],
"diffexp": "ttest",
"max_category_items": 1000, "max_category_items": 1000,
"obs_names": "foo", "obs_names": "foo",
"var_names": "bar", "var_names": "bar",
+13 -6
View File
@@ -10,10 +10,17 @@ def find_available_port(host, port=5005):
# Takes approx 2 seconds to do a scan of 5000 ports on my laptop # Takes approx 2 seconds to do a scan of 5000 ports on my laptop
num_ports_to_try = 5000 num_ports_to_try = 5000
for port_to_try in range(port, port + num_ports_to_try): for port_to_try in range(port, port + num_ports_to_try):
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: if is_port_available(host, port_to_try):
try: return port_to_try
s.bind((host, port_to_try))
return port_to_try
except socket.error:
pass
raise socket.error(errno.EADDRINUSE, f"No port in range {port} - {port + num_ports_to_try - 1} available.") raise socket.error(errno.EADDRINUSE, f"No port in range {port} - {port + num_ports_to_try - 1} available.")
def is_port_available(host, port):
is_available = False
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
try:
s.bind((host, port))
is_available = True
except socket.error:
pass
return is_available
+3 -1
View File
@@ -1,4 +1,6 @@
[flake8] [flake8]
max-line-length = 120 max-line-length = 120
ignore = E203, W503 ignore = E203, W503
exclude = server/app/util/fbs/NetEncoding/ exclude =
server/app/util/fbs/NetEncoding/,
server/gui/cellxgene_rc.py

Some files were not shown because too many files have changed in this diff Show More